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

@@ -38,6 +38,7 @@ type CanvasNodeContextMenuProps = {
onRedo: () => void;
onPaste: () => void;
onCopyNode: () => void;
onCreateGenerationCopy: () => void;
onDuplicate: () => void;
onDelete: () => void;
onSaveAsset: () => void;
@@ -72,6 +73,7 @@ export function CanvasNodeContextMenu({
onRedo,
onPaste,
onCopyNode,
onCreateGenerationCopy,
onDuplicate,
onDelete,
onSaveAsset,
@@ -201,6 +203,7 @@ export function CanvasNodeContextMenu({
<MenuButton icon={<Copy />} label="复制节点" shortcut="⌘C" onClick={() => runAction(onCopyNode)} />
{isImage ? <MenuButton icon={<Clipboard />} label="复制图片" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null}
<MenuButton icon={<Link2 />} label={isImage ? "复制图片地址" : "复制视频地址"} disabled={!canCopyMediaUrl} onClick={() => runAction(onCopyMediaUrl)} />
<MenuButton icon={<Copy />} label="创建生成副本" onClick={() => runAction(onCreateGenerationCopy)} />
<MenuButton icon={<Layers3 />} label="创建参数变体" shortcut="⌘D" onClick={() => runAction(onDuplicate)} />
<MenuButton icon={<Trash2 />} label="删除节点" danger onClick={() => runAction(onDelete)} />
</>

View File

@@ -1,5 +1,5 @@
import { Dropdown, Input } from "antd";
import { Download, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { Download, LoaderCircle, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import type { KeyboardEvent } from "react";
import { ProjectPreview } from "@/components/canvas/canvas-project-card";
@@ -12,10 +12,12 @@ type CanvasFolderCardProps = {
project: CanvasProject;
projectName?: string;
onClick: () => void;
onPrefetch?: () => void;
opening?: boolean;
};
/** 画布库中的文件夹封面:单一卡片表面承载预览和信息,避免相邻卡片互相侵入。 */
export function CanvasFolderCard({ project, projectName, onClick }: CanvasFolderCardProps) {
export function CanvasFolderCard({ project, projectName, onClick, onPrefetch, opening = false }: CanvasFolderCardProps) {
const renameProject = useCanvasStore((state) => state.renameProject);
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const editingId = useCanvasUiStore((state) => state.editingProjectId);
@@ -42,10 +44,11 @@ export function CanvasFolderCard({ project, projectName, onClick }: CanvasFolder
};
return (
<article className={cn("canvas-folder-card", selected && "is-selected", editing && "is-editing")}>
<div className="canvas-folder-open" role="button" tabIndex={0} aria-label={`打开画布 ${project.title}`} onClick={() => !editing && onClick()} onKeyDown={handleOpenKeyDown}>
<article className={cn("canvas-folder-card", selected && "is-selected", editing && "is-editing", opening && "is-opening")} onPointerEnter={onPrefetch} onPointerDown={onPrefetch} onFocusCapture={onPrefetch}>
<div className="canvas-folder-open" role="button" tabIndex={0} aria-label={`打开画布 ${project.title}`} aria-busy={opening} onClick={() => !editing && !opening && onClick()} onKeyDown={handleOpenKeyDown}>
<div className="canvas-folder-preview" aria-hidden="true">
<ProjectPreview project={project} preferLatestImage />
{opening ? <div className="canvas-folder-opening"><LoaderCircle className="size-5 animate-spin" /><span>正在打开</span></div> : null}
</div>
<div className="canvas-folder-body">
<div className="canvas-folder-heading-row">

View File

@@ -3,7 +3,7 @@ import { Group, Leafer, Path, Rect } from "leafer-ui";
import { activeConnectionPath, canvasConnectionPath } from "@/components/canvas/canvas-connections";
import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport";
import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasNodeDragPreview, subscribeCanvasSelectionPreview, type CanvasNodeDragPreview } from "@/lib/canvas/canvas-live-viewport";
import { calculateCanvasPreviewTransform, sameCanvasViewport, shouldRebaseCanvasRaster } from "@/lib/canvas/canvas-leafer-viewport";
import type { CanvasTheme } from "@/lib/canvas-theme";
import type { CanvasDisplayConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox, ViewportTransform } from "@/types/canvas";
@@ -37,6 +37,18 @@ type LeaferScene = {
type UnderlayScene = LeaferScene & {
connections: Group;
connectionEntries: Map<string, ConnectionSceneEntry>;
connectionIdsByNodeId: Map<string, Set<string>>;
dragPreview: CanvasNodeDragPreview | null;
dragPreviewConnectionIds: Set<string>;
};
type ConnectionSceneEntry = {
path: Path;
connection: CanvasDisplayConnection["connection"];
from: CanvasNodeData;
to: CanvasNodeData;
signature: string;
};
type OverlayScene = LeaferScene & {
@@ -104,11 +116,15 @@ export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps)
const unsubscribeSelection = subscribeCanvasSelectionPreview(container, (selection) => {
syncSelection(overlay.selection, selection, propsRef.current.theme);
});
const unsubscribeNodeDrag = subscribeCanvasNodeDragPreview(container, (preview) => {
applyConnectionDragPreview(underlay, propsRef.current, preview);
});
resize();
return () => {
unsubscribe();
unsubscribeSelection();
unsubscribeNodeDrag();
resizeObserver.disconnect();
window.removeEventListener("resize", resize);
underlay.leafer.destroy(true);
@@ -173,7 +189,7 @@ function createUnderlayScene(host: HTMLDivElement): UnderlayScene {
const connections = new Group({ hittable: false });
world.add(connections);
leafer.add(world);
return { leafer, world, host, connections };
return { leafer, world, host, connections, connectionEntries: new Map(), connectionIdsByNodeId: new Map(), dragPreview: null, dragPreviewConnectionIds: new Set() };
}
function createOverlayScene(host: HTMLDivElement): OverlayScene {
@@ -194,22 +210,109 @@ function createOverlayScene(host: HTMLDivElement): OverlayScene {
}
function rebuildConnections(scene: UnderlayScene, props: CanvasLeaferGraphicsLayerProps) {
scene.connections.removeAll(true);
props.displayConnections.forEach(({ connection, from, to }) => {
const nextIds = new Set(props.displayConnections.map(({ connection }) => connection.id));
for (const [connectionId, entry] of scene.connectionEntries) {
if (nextIds.has(connectionId)) continue;
entry.path.remove();
scene.connectionEntries.delete(connectionId);
}
scene.connectionIdsByNodeId.clear();
const previewIds = scene.dragPreview ? scene.dragPreview.nodeIds : null;
for (const { connection, from, to } of props.displayConnections) {
const emphasized = props.selectedConnectionId === connection.id || props.relatedConnectionIds.has(connection.id);
const path = new Path({
path: canvasConnectionPath(connection, from, to, props.scriptScrollTopById[from.id] || 0, props.scriptScrollTopById[to.id] || 0).pathD,
stroke: emphasized ? props.theme.accent.primary : props.theme.node.muted,
strokeWidth: emphasized ? 1.6 : 1,
strokeScaleFixed: true,
strokeCap: "round",
opacity: emphasized ? 0.52 : 0.24,
hittable: false,
});
scene.connections.add(path);
const signature = connectionSceneSignature(connection, from, to, props, emphasized);
let entry = scene.connectionEntries.get(connection.id);
if (!entry) {
const path = new Path({ hittable: false });
scene.connections.add(path);
entry = { path, connection, from, to, signature: "" };
scene.connectionEntries.set(connection.id, entry);
}
entry.connection = connection;
entry.from = from;
entry.to = to;
if (entry.signature !== signature || scene.dragPreview) {
syncConnectionPath(entry, props, scene.dragPreview, previewIds);
entry.signature = signature;
}
for (const nodeId of [from.id, to.id]) {
const connectionIds = scene.connectionIdsByNodeId.get(nodeId) || new Set<string>();
connectionIds.add(connection.id);
scene.connectionIdsByNodeId.set(nodeId, connectionIds);
}
}
scene.dragPreviewConnectionIds = collectPreviewConnectionIds(scene, scene.dragPreview);
}
function connectionSceneSignature(connection: CanvasDisplayConnection["connection"], from: CanvasNodeData, to: CanvasNodeData, props: CanvasLeaferGraphicsLayerProps, emphasized: boolean) {
return [
connection.id,
connection.fromNodeId,
connection.toNodeId,
connection.fromHandleId || "",
connection.toHandleId || "",
from.id,
from.position.x,
from.position.y,
from.width,
from.height,
to.id,
to.position.x,
to.position.y,
to.width,
to.height,
props.scriptScrollTopById[from.id] || 0,
props.scriptScrollTopById[to.id] || 0,
emphasized ? "active" : "idle",
props.theme.accent.primary,
props.theme.node.muted,
].join("|");
}
function syncConnectionPath(entry: ConnectionSceneEntry, props: CanvasLeaferGraphicsLayerProps, preview: CanvasNodeDragPreview | null, previewIds: ReadonlySet<string> | null = preview?.nodeIds || null) {
const from = translatePreviewNode(entry.from, previewIds, preview);
const to = translatePreviewNode(entry.to, previewIds, preview);
const emphasized = props.selectedConnectionId === entry.connection.id || props.relatedConnectionIds.has(entry.connection.id);
entry.path.set({
path: canvasConnectionPath(entry.connection, from, to, props.scriptScrollTopById[entry.from.id] || 0, props.scriptScrollTopById[entry.to.id] || 0).pathD,
stroke: emphasized ? props.theme.accent.primary : props.theme.node.muted,
strokeWidth: emphasized ? 1.6 : 1,
strokeScaleFixed: true,
strokeCap: "round",
opacity: emphasized ? 0.52 : 0.24,
hittable: false,
});
}
function translatePreviewNode(node: CanvasNodeData, previewIds: ReadonlySet<string> | null, preview: CanvasNodeDragPreview | null) {
if (!preview || !previewIds?.has(node.id) || (preview.x === 0 && preview.y === 0)) return node;
return { ...node, position: { x: node.position.x + preview.x, y: node.position.y + preview.y } };
}
function collectPreviewConnectionIds(scene: UnderlayScene, preview: CanvasNodeDragPreview | null) {
const connectionIds = new Set<string>();
if (!preview) return connectionIds;
for (const nodeId of preview.nodeIds) {
scene.connectionIdsByNodeId.get(nodeId)?.forEach((connectionId) => connectionIds.add(connectionId));
}
return connectionIds;
}
function applyConnectionDragPreview(scene: UnderlayScene, props: CanvasLeaferGraphicsLayerProps, preview: CanvasNodeDragPreview | null) {
const affectedConnectionIds = new Set(scene.dragPreviewConnectionIds);
const previewIds = preview?.nodeIds || null;
collectPreviewConnectionIds(scene, preview).forEach((connectionId) => affectedConnectionIds.add(connectionId));
scene.dragPreview = preview;
scene.dragPreviewConnectionIds = collectPreviewConnectionIds(scene, preview);
for (const connectionId of affectedConnectionIds) {
const entry = scene.connectionEntries.get(connectionId);
if (!entry) continue;
syncConnectionPath(entry, props, preview, previewIds);
}
}
function syncOverlayContent(scene: OverlayScene, props: CanvasLeaferGraphicsLayerProps, viewportScale: number) {
const selection = props.selectionBox;
scene.selection.visible = Boolean(selection);

View File

@@ -11,6 +11,10 @@ export type CanvasNodeActionContextValue = {
deleteNode?: (node: CanvasNodeData) => void;
/** 合并式更新节点 metadata;扩展节点(如调色)在自己的面板里改参数时用。 */
updateMetadata?: (nodeId: string, patch: CanvasNodeMetadata) => void;
/** 更新单个节点;媒体尺寸回写使用它,避免触发全量节点时间戳归并。 */
updateNode?: (nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => void;
/** 合并延迟提交媒体测量结果,避免首屏图片同时解码时反复刷新画布。 */
updateMediaNode?: (nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => void;
/** 改节点宽高;图片首次量到真实尺寸后按比例校正节点用。 */
resizeNode?: (nodeId: string, size: { width: number; height: number }) => void;
/** 打开节点级肖像排查工作台;任务生命周期由画布页面持有。 */

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

View File

@@ -12,6 +12,9 @@ import { resolveBackendApiUrl } from "@/stores/use-config-store";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { cn } from "@/lib/utils";
type ProjectPreviewMedia = { node: CanvasNodeData; url: string; storageKey?: string };
const projectPreviewMediaCache = new WeakMap<CanvasNodeData[], { first?: ProjectPreviewMedia; latest?: ProjectPreviewMedia }>();
export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; onClick: () => void }) {
return <button type="button" className="app-canvas-create-card" disabled={disabled} onClick={onClick}>
<span className="app-canvas-create-preview"><Plus className="app-canvas-create-icon" /></span>
@@ -106,16 +109,7 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
}
export function ProjectPreview({ project, preferLatestImage = false }: { project: CanvasProject; preferLatestImage?: boolean }) {
const mediaNodes = project.nodes
.flatMap((node) => {
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return [];
const url = getNodeMediaUrl(node);
return isPreviewUrl(url) ? [{ node, url, storageKey: node.metadata?.storageKey }] : [];
});
const imageNodes = mediaNodes.filter(({ node }) => node.type === CanvasNodeType.Image);
const media = preferLatestImage
? imageNodes[imageNodes.length - 1] || mediaNodes[mediaNodes.length - 1]
: imageNodes[0] || mediaNodes[0];
const media = projectPreviewMedia(project.nodes, preferLatestImage);
if (media) {
const { node, url, storageKey } = media;
return (
@@ -145,6 +139,31 @@ export function ProjectPreview({ project, preferLatestImage = false }: { project
);
}
export function projectPreviewMedia(nodes: CanvasNodeData[], preferLatestImage = false) {
let cached = projectPreviewMediaCache.get(nodes);
if (!cached) {
let firstMedia: ProjectPreviewMedia | undefined;
let firstImage: ProjectPreviewMedia | undefined;
let latestMedia: ProjectPreviewMedia | undefined;
let latestImage: ProjectPreviewMedia | undefined;
for (const node of nodes) {
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) continue;
const url = getNodeMediaUrl(node);
if (!isPreviewUrl(url)) continue;
const media = { node, url, storageKey: node.metadata?.storageKey };
firstMedia ||= media;
latestMedia = media;
if (node.type === CanvasNodeType.Image) {
firstImage ||= media;
latestImage = media;
}
}
cached = { first: firstImage || firstMedia, latest: latestImage || latestMedia };
projectPreviewMediaCache.set(nodes, cached);
}
return preferLatestImage ? cached.latest : cached.first;
}
function getNodeMediaUrl(node: CanvasNodeData) {
const resourceId = resourceIdFromStorageKey(node.metadata?.storageKey);
if (resourceId) return resourceFileUrl(resourceId);