feat(canvas): 画布工作台 - 新增生成副本并修复大型画布性能、素材同步与删除校验

统一图片与视频副本的生成落点并保留参考连接。\n优化大型画布节点虚拟化、媒体懒加载、拖拽与详情进入路径。\n修复素材主版本 ID 同步及删除时的画布独立引用校验。
This commit is contained in:
ddCat
2026-09-01 14:57:05 +08:00
parent 434f63e153
commit 3f4a9ea3d0
45 changed files with 1348 additions and 286 deletions

View File

@@ -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 };
}