feat(canvas): 画布工作台 - 新增生成副本并修复大型画布性能、素材同步与删除校验
统一图片与视频副本的生成落点并保留参考连接。\n优化大型画布节点虚拟化、媒体懒加载、拖拽与详情进入路径。\n修复素材主版本 ID 同步及删除时的画布独立引用校验。
This commit is contained in:
@@ -419,7 +419,7 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded
|
||||
|
||||
function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false, hydrateMediaPreview = false }: CanvasNodeContentProps) {
|
||||
const playerBoxRef = useRef<HTMLDivElement>(null);
|
||||
const { updateMetadata } = useCanvasNodeActions();
|
||||
const { updateMediaNode } = useCanvasNodeActions();
|
||||
const { url, loading } = useNodeResourceUrl(node, mediaActive);
|
||||
const subtitleEntries = node.metadata?.subtitleEntries || [];
|
||||
const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle();
|
||||
@@ -434,7 +434,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false
|
||||
if (video.videoWidth <= 0 || video.videoHeight <= 0) return;
|
||||
setVideoSize({ width: video.videoWidth, height: video.videoHeight });
|
||||
if (node.metadata?.naturalWidth !== video.videoWidth || node.metadata?.naturalHeight !== video.videoHeight) {
|
||||
updateMetadata?.(node.id, { naturalWidth: video.videoWidth, naturalHeight: video.videoHeight });
|
||||
updateMediaNode?.(node.id, (current) => ({ ...current, metadata: { ...current.metadata, naturalWidth: video.videoWidth, naturalHeight: video.videoHeight } }));
|
||||
}
|
||||
};
|
||||
video.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
@@ -446,7 +446,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false
|
||||
video.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
};
|
||||
}, [node.id, node.metadata?.naturalHeight, node.metadata?.naturalWidth, subtitleEntries.length, updateMetadata, url]);
|
||||
}, [node.id, node.metadata?.naturalHeight, node.metadata?.naturalWidth, subtitleEntries.length, updateMediaNode, url]);
|
||||
|
||||
if (!node.metadata?.content) return <EmptyMediaContent icon={<Video className="size-7 opacity-35" />} label="空视频节点" color={theme.node.placeholder} />;
|
||||
if (!mediaActive) return <InactiveVideoPreview node={node} theme={theme} hydrateMediaPreview={hydrateMediaPreview} />;
|
||||
@@ -535,7 +535,8 @@ function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, bat
|
||||
const nearViewport = useNearViewport(imageContainerRef);
|
||||
const { url, loading } = useNodeResourceUrl(node, nearViewport);
|
||||
const importedFromLibTV = node.metadata?.importSource?.provider === "libtv";
|
||||
const { resizeNode, updateMetadata } = useCanvasNodeActions();
|
||||
const { updateMediaNode } = useCanvasNodeActions();
|
||||
const measuredSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
|
||||
/**
|
||||
* 让节点跟随图片真实比例。
|
||||
@@ -548,23 +549,34 @@ function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, bat
|
||||
* 手动拉过(manualSize)或自由比例(freeResize)的节点只补记尺寸、不动宽高。
|
||||
*/
|
||||
const fitToImage = (element: HTMLImageElement) => {
|
||||
// LibTV 已提供原图尺寸和节点尺寸;960px 缩略图不能反向覆盖这些数据。
|
||||
if (importedFromLibTV) return;
|
||||
const naturalWidth = element.naturalWidth;
|
||||
const naturalHeight = element.naturalHeight;
|
||||
if (!naturalWidth || !naturalHeight) return;
|
||||
if (node.metadata?.naturalWidth !== naturalWidth || node.metadata?.naturalHeight !== naturalHeight) {
|
||||
updateMetadata?.(node.id, { naturalWidth, naturalHeight });
|
||||
}
|
||||
if (node.metadata?.freeResize || node.metadata?.manualSize) return;
|
||||
const size = fitNodeSize(naturalWidth, naturalHeight);
|
||||
// 差不到 1px 就别动,避免无意义的状态写入。
|
||||
if (Math.abs(size.width - node.width) < 1 && Math.abs(size.height - node.height) < 1) return;
|
||||
resizeNode?.(node.id, size);
|
||||
if (measuredSizeRef.current?.width === naturalWidth && measuredSizeRef.current.height === naturalHeight) return;
|
||||
measuredSizeRef.current = { width: naturalWidth, height: naturalHeight };
|
||||
updateMediaNode?.(node.id, (current) => {
|
||||
const metadata = current.metadata;
|
||||
const needsMetadata = metadata?.naturalWidth !== naturalWidth || metadata?.naturalHeight !== naturalHeight;
|
||||
if (current.metadata?.freeResize || current.metadata?.manualSize) {
|
||||
return needsMetadata ? { ...current, metadata: { ...metadata, naturalWidth, naturalHeight } } : current;
|
||||
}
|
||||
const size = fitNodeSize(naturalWidth, naturalHeight);
|
||||
const needsResize = Math.abs(size.width - current.width) >= 1 || Math.abs(size.height - current.height) >= 1;
|
||||
if (!needsMetadata && !needsResize) return current;
|
||||
return {
|
||||
...current,
|
||||
...(needsResize ? size : {}),
|
||||
metadata: needsMetadata ? { ...metadata, naturalWidth, naturalHeight } : metadata,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} theme={theme} onToggleBatch={onToggleBatch}>
|
||||
<div ref={imageContainerRef} className="h-full w-full overflow-hidden rounded-[var(--node-radius)]">
|
||||
{url ? <img src={url} alt={node.title} loading={importedFromLibTV ? "eager" : "lazy"} decoding="async" draggable={false} onDragStart={(event) => event.preventDefault()} onLoad={(event) => fitToImage(event.currentTarget)} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
|
||||
{url ? <img src={url} alt={node.title} loading="lazy" decoding="async" draggable={false} onDragStart={(event) => event.preventDefault()} onLoad={(event) => fitToImage(event.currentTarget)} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
|
||||
</div>
|
||||
</BatchFrame>
|
||||
);
|
||||
@@ -584,18 +596,23 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
|
||||
: node.metadata?.previewContent
|
||||
|| (node.type === CanvasNodeType.Image && node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content);
|
||||
const isRemoteResource = Boolean(resourceIdFromStorageKey(storageKey));
|
||||
const [url, setUrl] = useState(isRemoteResource ? "" : fallback);
|
||||
// Inline data URLs are already local, but decoding thousands of them is
|
||||
// still expensive. Images must wait for the same viewport gate as remote
|
||||
// resources; otherwise DOM virtualization does not reduce image work.
|
||||
const isLazyVisual = node.type === CanvasNodeType.Image;
|
||||
const [url, setUrl] = useState(isRemoteResource || isLazyVisual ? "" : fallback);
|
||||
const [loading, setLoading] = useState(isRemoteResource && eager);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!isRemoteResource) {
|
||||
setUrl(fallback);
|
||||
setUrl(isLazyVisual && !eager ? "" : fallback);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setUrl("");
|
||||
setLoading(eager);
|
||||
// 只有进入视口或被激活的节点才下载远程媒体;缓存层会复用已有 Blob URL 和 in-flight 请求。
|
||||
const resolve = eager ? cacheResourceObjectUrl(storageKey) : getCachedResourceObjectUrl(storageKey);
|
||||
void resolve.then((cached) => {
|
||||
if (!cancelled) setUrl(cached || (eager ? fallback : ""));
|
||||
@@ -605,7 +622,7 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [eager, fallback, isRemoteResource, storageKey]);
|
||||
}, [eager, fallback, isLazyVisual, isRemoteResource, storageKey]);
|
||||
|
||||
return { url, loading };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user