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

View File

@@ -0,0 +1,11 @@
import type { Asset } from "@/stores/use-asset-store";
const PRIMARY_VERSION_ID_MAX_LENGTH = 36;
export function assetForRemoteSync(asset: Asset): Asset {
const primaryVersionId = asset.primaryVersionId?.trim();
if (!primaryVersionId || [...primaryVersionId].length <= PRIMARY_VERSION_ID_MAX_LENGTH) return asset;
const { primaryVersionId: _invalidPrimaryVersionId, ...remoteAsset } = asset;
return remoteAsset as Asset;
}

View File

@@ -1,4 +1,5 @@
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type Position } from "@/types/canvas";
import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex } from "@/lib/canvas/canvas-spatial-index";
export const FRAME_HEADER_HEIGHT = 36;
export const FRAME_PADDING = 24;
@@ -52,23 +53,44 @@ export function findFrameDropTarget(nodes: CanvasNodeData[], draggedNodeIds: Set
return (
[...nodes]
.reverse()
.find((frame) => {
if (!isFrameNode(frame) || (frame.metadata?.frame?.collapsed && !isCanvasFolderNode(frame)) || draggedNodeIds.has(frame.id)) return false;
const canContain = frame.metadata?.folder?.assetFolderId ? canLinkedFolderArchive : isCanvasFolderNode(frame) ? canFolderContain : canFrameContain;
if (!dragged.every(canContain)) return false;
const left = frame.position.x;
const top = frame.position.y + (isCanvasFolderNode(frame) && frame.metadata?.frame?.collapsed ? 0 : FRAME_HEADER_HEIGHT);
const right = frame.position.x + frame.width;
const bottom = frame.position.y + frame.height;
return dragged.every((node) => {
const centerX = node.position.x + node.width / 2;
const centerY = node.position.y + node.height / 2;
return centerX >= left && centerX <= right && centerY >= top && centerY <= bottom;
});
})?.id || null
.find((frame) => isValidFrameDropTarget(frame, dragged, draggedNodeIds))?.id || null
);
}
export type CanvasFrameDropIndex = CanvasSpatialIndex<CanvasNodeData>;
export function buildCanvasFrameDropIndex(nodes: CanvasNodeData[]): CanvasFrameDropIndex {
return buildCanvasSpatialIndex(nodes.filter(isFrameNode).map((node) => ({ id: node.id, bounds: canvasNodeBounds(node), value: node })));
}
/** Finds a drop target using the drag preview offset without rebuilding all node positions. */
export function findFrameDropTargetFromIndex(index: CanvasFrameDropIndex, draggedNodes: CanvasNodeData[], draggedNodeIds: Set<string>, offset: Position) {
if (!draggedNodes.length) return null;
const centers = draggedNodes.map((node) => ({ x: node.position.x + offset.x + node.width / 2, y: node.position.y + offset.y + node.height / 2 }));
const left = Math.min(...centers.map((center) => center.x)) - 0.01;
const top = Math.min(...centers.map((center) => center.y)) - 0.01;
const right = Math.max(...centers.map((center) => center.x)) + 0.01;
const bottom = Math.max(...centers.map((center) => center.y)) + 0.01;
return [...index.query({ left, top, right, bottom })]
.reverse()
.find((frame) => isValidFrameDropTarget(frame, draggedNodes, draggedNodeIds, offset))?.id || null;
}
function isValidFrameDropTarget(frame: CanvasNodeData, dragged: CanvasNodeData[], draggedNodeIds: Set<string>, offset: Position = { x: 0, y: 0 }) {
if (!isFrameNode(frame) || (frame.metadata?.frame?.collapsed && !isCanvasFolderNode(frame)) || draggedNodeIds.has(frame.id)) return false;
const canContain = frame.metadata?.folder?.assetFolderId ? canLinkedFolderArchive : isCanvasFolderNode(frame) ? canFolderContain : canFrameContain;
if (!dragged.every(canContain)) return false;
const left = frame.position.x;
const top = frame.position.y + (isCanvasFolderNode(frame) && frame.metadata?.frame?.collapsed ? 0 : FRAME_HEADER_HEIGHT);
const right = frame.position.x + frame.width;
const bottom = frame.position.y + frame.height;
return dragged.every((node) => {
const centerX = node.position.x + offset.x + node.width / 2;
const centerY = node.position.y + offset.y + node.height / 2;
return centerX >= left && centerX <= right && centerY >= top && centerY <= bottom;
});
}
export function applyFrameDrop(nodes: CanvasNodeData[], draggedNodeIds: Set<string>, frameId: string | null) {
const target = frameId ? nodes.find((node) => node.id === frameId && isFrameNode(node)) : null;
const canContain = target?.metadata?.folder?.assetFolderId ? canLinkedFolderArchive : target && isCanvasFolderNode(target) ? canFolderContain : canFrameContain;

View File

@@ -10,7 +10,15 @@ const BATCH_CHILD_GAP = 36;
const NODE_CLEARANCE = 36;
export function canGenerateImageInPlace(sourceNode: CanvasNodeData | undefined) {
return sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content;
return canGenerateMediaInPlace(sourceNode, CanvasNodeType.Image);
}
export function canGenerateMediaInPlace(sourceNode: CanvasNodeData | undefined, mediaType: typeof CanvasNodeType.Image | typeof CanvasNodeType.Video | typeof CanvasNodeType.Audio) {
if (sourceNode?.type !== mediaType) return false;
if (sourceNode.metadata?.generationResultPlacement) return sourceNode.metadata.generationResultPlacement === "replace-node";
if (!sourceNode.metadata?.content) return true;
// 兼容显式落点字段引入前创建的复制节点和版本节点。
return Boolean(sourceNode.metadata?.copiedFromNodeId || sourceNode.metadata?.versionOfNodeId);
}
export function imageGenerationGroupSize(rootSize: Size, imageSize: Size, childCount: number): Size {

View File

@@ -3,6 +3,21 @@ import type { SelectionBox, ViewportTransform } from "@/types/canvas";
export const CANVAS_VIEWPORT_PREVIEW_EVENT = "canvas:viewport-preview";
export const CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT = "canvas:graphics-viewport-preview";
export const CANVAS_SELECTION_PREVIEW_EVENT = "canvas:selection-preview";
export const CANVAS_NODE_DRAG_PREVIEW_EVENT = "canvas:node-drag-preview";
export type CanvasNodeDragPreview = {
x: number;
y: number;
nodeIds: ReadonlySet<string>;
};
type NodeDragPreviewDomState = {
elementsById: Map<string, HTMLElement>;
previousIds: Set<string>;
};
const nodeDragPreviewDomStates = new WeakMap<HTMLDivElement, NodeDragPreviewDomState>();
const liveViewportElements = new WeakMap<HTMLDivElement, { worldLayer: HTMLElement | null; gridLayer: HTMLElement | null }>();
// 空间网格点模式的点半径(像素单位)。远距时使用更小半径,避免点阵糊成一团。
export function canvasDotPx(scale: number): string {
@@ -19,19 +34,35 @@ export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewpo
const gridSize = 48 * viewport.k;
const dotGridSize = canvasDotGridPx(viewport.k);
const committedScale = Number(container.style.getPropertyValue("--canvas-committed-scale")) || viewport.k;
container.style.setProperty("--canvas-live-x", `${viewport.x}px`);
container.style.setProperty("--canvas-live-y", `${viewport.y}px`);
let elements = liveViewportElements.get(container);
if (!elements) {
elements = {
worldLayer: container.querySelector<HTMLElement>("[data-canvas-world-layer]"),
gridLayer: container.querySelector<HTMLElement>("[data-canvas-grid-layer]"),
};
liveViewportElements.set(container, elements);
}
const worldLayer = elements.worldLayer;
if (worldLayer) {
// 平移期间直接更新合成层,避免修改容器继承变量导致所有节点重新计算样式。
worldLayer.style.transformOrigin = "0 0";
worldLayer.style.transform = `translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.k / committedScale})`;
worldLayer.style.willChange = container.dataset.canvasViewportInteracting === "true" ? "transform" : "";
}
container.style.setProperty("--canvas-live-scale", String(viewport.k));
// 外置节点标题用同一帧逆倍率抵消世界层缩放,避免等待 React 提交后再校正尺寸。
container.style.setProperty("--canvas-live-inverse-scale", String(1 / Math.max(viewport.k, 0.05)));
container.style.setProperty("--canvas-live-scale-ratio", String(viewport.k / committedScale));
container.style.setProperty("--canvas-grid-size", `${gridSize}px`);
container.style.setProperty("--canvas-grid-x", `${viewport.x % gridSize}px`);
container.style.setProperty("--canvas-grid-y", `${viewport.y % gridSize}px`);
container.style.setProperty("--canvas-dot-grid-size", `${dotGridSize}px`);
container.style.setProperty("--canvas-dot-grid-x", `${viewport.x % dotGridSize}px`);
container.style.setProperty("--canvas-dot-grid-y", `${viewport.y % dotGridSize}px`);
container.style.setProperty("--canvas-dot-size", canvasDotPx(viewport.k));
const gridLayer = elements.gridLayer;
if (gridLayer) {
// 网格是唯一需要每帧更新背景偏移的元素,不把这些变量放在画布根节点上继承。
gridLayer.style.setProperty("--canvas-grid-size", `${gridSize}px`);
gridLayer.style.setProperty("--canvas-grid-x", `${viewport.x % gridSize}px`);
gridLayer.style.setProperty("--canvas-grid-y", `${viewport.y % gridSize}px`);
gridLayer.style.setProperty("--canvas-dot-grid-size", `${dotGridSize}px`);
gridLayer.style.setProperty("--canvas-dot-grid-x", `${viewport.x % dotGridSize}px`);
gridLayer.style.setProperty("--canvas-dot-grid-y", `${viewport.y % dotGridSize}px`);
gridLayer.style.setProperty("--canvas-dot-size", canvasDotPx(viewport.k));
}
// 图形层必须逐帧跟随 DOM 世界层;浮层和滚动通知仍可按原频率节流。
container.dispatchEvent(new CustomEvent<ViewportTransform>(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, { detail: viewport }));
if (notify) {
@@ -53,6 +84,53 @@ export function subscribeCanvasViewportPreview(container: HTMLDivElement, listen
return () => container.removeEventListener(CANVAS_VIEWPORT_PREVIEW_EVENT, handlePreview);
}
/**
* Applies transient node movement without changing React state. Only nodes
* currently mounted in the virtualized world are touched; the committed
* positions are still written by the drag-end path.
*/
export function applyCanvasNodeDragPreview(container: HTMLDivElement | null, preview: CanvasNodeDragPreview | null) {
if (!container) return;
let state = nodeDragPreviewDomStates.get(container);
if (!state) {
state = { elementsById: new Map(), previousIds: new Set() };
nodeDragPreviewDomStates.set(container, state);
}
for (const nodeId of state.previousIds) {
state.elementsById.get(nodeId)?.style.removeProperty("translate");
}
state.previousIds.clear();
if (preview) {
// Build the lookup once per drag session. A single DOM scan is much
// cheaper than one selector query per selected node on every frame.
if (state.elementsById.size === 0) {
container.querySelectorAll<HTMLElement>("[data-node-id]").forEach((element) => {
const nodeId = element.dataset.nodeId;
if (nodeId) state?.elementsById.set(nodeId, element);
});
}
for (const nodeId of preview.nodeIds) {
const element = state.elementsById.get(nodeId);
if (!element || !element.isConnected) continue;
element.style.setProperty("translate", `${preview.x}px ${preview.y}px`);
state.previousIds.add(nodeId);
}
} else {
state.elementsById.clear();
}
container.dispatchEvent(new CustomEvent<CanvasNodeDragPreview | null>(CANVAS_NODE_DRAG_PREVIEW_EVENT, { detail: preview }));
}
export function subscribeCanvasNodeDragPreview(container: HTMLDivElement, listener: (preview: CanvasNodeDragPreview | null) => void) {
const handlePreview = (event: Event) => listener((event as CustomEvent<CanvasNodeDragPreview | null>).detail);
container.addEventListener(CANVAS_NODE_DRAG_PREVIEW_EVENT, handlePreview);
return () => container.removeEventListener(CANVAS_NODE_DRAG_PREVIEW_EVENT, handlePreview);
}
export function applyCanvasSelectionPreview(container: HTMLDivElement | null, selection: SelectionBox) {
container?.dispatchEvent(new CustomEvent<SelectionBox>(CANVAS_SELECTION_PREVIEW_EVENT, { detail: selection }));
}

View File

@@ -1,5 +1,5 @@
import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import type { CanvasNodeData, CanvasNodeMetadata, StoryboardRow } from "@/types/canvas";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type StoryboardRow } from "@/types/canvas";
const COPY_TITLE_SUFFIX = /^(.*)_copy(\d+)$/i;
@@ -62,6 +62,9 @@ export function isolateCopiedNodeMetadata(node: CanvasNodeData, idMap: ReadonlyM
delete metadata.versionPrimary;
metadata.copiedFromNodeId = node.id;
if (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) {
metadata.generationResultPlacement = "replace-node";
}
metadata.frame = node.metadata?.frame ? { ...node.metadata.frame } : undefined;
metadata.referenceSetId = remapOwnedNodeId(node.metadata?.referenceSetId, idMap);
metadata.referenceAssetNodeIds = node.metadata?.referenceAssetNodeIds

View File

@@ -50,10 +50,15 @@ export function normalizeCanvasNodeTimestamps(nodes: CanvasNodeData[], fallback:
export function stampCanvasNodeChanges(previousNodes: CanvasNodeData[], nextNodes: CanvasNodeData[], now = new Date().toISOString()) {
if (previousNodes === nextNodes) return previousNodes;
const previousById = new Map(previousNodes.map((node) => [node.id, node]));
// Most canvas edits preserve order and replace one node. Avoid allocating a
// 50k-entry id map and comparing metadata for every untouched node in that
// hot path. The map fallback still handles inserts, deletes and reorders.
const sameOrder = previousNodes.length === nextNodes.length && previousNodes.every((node, index) => node.id === nextNodes[index]?.id);
const previousById = sameOrder ? null : new Map(previousNodes.map((node) => [node.id, node]));
let changed = false;
const stamped = nextNodes.map((node) => {
const previous = previousById.get(node.id);
const stamped = nextNodes.map((node, index) => {
const previous = sameOrder ? previousNodes[index] : previousById?.get(node.id);
if (previous === node) return node;
const createdAt = canvasNodeCreatedAt(node, canvasNodeCreatedAt(previous || node, now)) || now;
const meaningfulChange = previous ? canvasNodeMeaningfullyChanged(previous, node) : false;
const updatedAt = previous
@@ -68,6 +73,33 @@ export function stampCanvasNodeChanges(previousNodes: CanvasNodeData[], nextNode
return changed ? stamped : nextNodes;
}
/**
* Update one node without routing through the generic full-array timestamp
* reconciliation. This is used by media metadata callbacks, which can fire
* in bursts while a large canvas is first becoming visible.
*/
export function updateCanvasNode(nodes: CanvasNodeData[], nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData, now = new Date().toISOString()) {
return updateCanvasNodes(nodes, new Map([[nodeId, update]]), now);
}
export function updateCanvasNodes(nodes: CanvasNodeData[], updates: ReadonlyMap<string, (node: CanvasNodeData) => CanvasNodeData>, now = new Date().toISOString()) {
if (!updates.size) return nodes;
let changed = false;
const next = nodes.map((current) => {
const update = updates.get(current.id);
if (!update) return current;
const updated = update(current);
if (updated === current) return current;
changed = true;
const createdAt = canvasNodeCreatedAt(updated, canvasNodeCreatedAt(current, now)) || now;
const updatedAt = canvasNodeMeaningfullyChanged(current, updated)
? now
: canvasNodeUpdatedAt(updated, canvasNodeUpdatedAt(current, createdAt)) || createdAt;
return updated.createdAt === createdAt && updated.updatedAt === updatedAt ? updated : { ...updated, createdAt, updatedAt };
});
return changed ? next : nodes;
}
function canvasNodeMeaningfullyChanged(previous: CanvasNodeData, next: CanvasNodeData) {
if (
previous.type !== next.type

View File

@@ -2,6 +2,12 @@ import { CanvasNodeType, type CanvasMediaPerformanceMode, type CanvasNodeData }
const STORAGE_KEY = "canvas-media-performance-mode";
// The DOM budget is deliberately below the connection budget. Image-heavy
// canvases pay a texture/compositing cost per mounted card, even when the
// cards themselves are already virtualized spatially.
export const CANVAS_MAX_RENDERED_NODES = 720;
export const CANVAS_MAX_RENDERED_CONNECTIONS = 5000;
export function readCanvasMediaPerformanceMode(): CanvasMediaPerformanceMode {
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
@@ -26,6 +32,17 @@ export function shouldReduceCanvasMediaEffects(mode: CanvasMediaPerformanceMode,
return nodes.length >= 80 || mediaCount >= 32;
}
export function canvasNodeRenderPadding(reduceMediaEffects: boolean, previouslyRendered: boolean) {
if (previouslyRendered) return reduceMediaEffects ? 640 : 384;
return reduceMediaEffects ? 128 : 192;
}
export function canvasNodeRenderBudget(scale: number) {
if (scale < 0.14) return 280;
if (scale < 0.28) return 420;
return CANVAS_MAX_RENDERED_NODES;
}
export function resolveActiveCanvasMediaNodeId(selectedNodeIds: ReadonlySet<string>, nodeById: ReadonlyMap<string, CanvasNodeData>) {
if (selectedNodeIds.size !== 1) return null;
const nodeId = selectedNodeIds.values().next().value;

View File

@@ -550,7 +550,8 @@ export function supportsVideoReferenceAudio(config: AiConfig) {
export function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
const configWidth = NODE_DEFAULT_SIZE[CanvasNodeType.Config].width;
const configHeight = NODE_DEFAULT_SIZE[CanvasNodeType.Config].height;
return nodes.map((node) => {
let changed = false;
const reset = nodes.map((node) => {
const mediaNode = ensureMediaNodeMinimumSize(node);
const resizedNode =
mediaNode.type === CanvasNodeType.Config && (mediaNode.width < configWidth || mediaNode.height < configHeight)
@@ -558,8 +559,11 @@ export function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
: mediaNode.type === CanvasNodeType.Script && mediaNode.height < NODE_DEFAULT_SIZE[CanvasNodeType.Script].height
? { ...mediaNode, height: NODE_DEFAULT_SIZE[CanvasNodeType.Script].height }
: mediaNode;
return resizedNode.metadata?.status === "loading" ? { ...resizedNode, metadata: { ...resizedNode.metadata, errorDetails: "正在从任务中心恢复生成状态..." } } : resizedNode;
const restoredNode = resizedNode.metadata?.status === "loading" ? { ...resizedNode, metadata: { ...resizedNode.metadata, errorDetails: "正在从任务中心恢复生成状态..." } } : resizedNode;
if (restoredNode !== node) changed = true;
return restoredNode;
});
return changed ? reset : nodes;
}
export function isGenerationCanceled(error: unknown) {

View File

@@ -74,17 +74,56 @@ export function buildAssetMentionReferences(assets: Asset[]): CanvasResourceRefe
});
}
export function buildCanvasResourceReferences(nodes: CanvasNodeData[], connections: CanvasConnection[], contextNodeId?: string | null) {
export function buildCanvasResourceReferences(nodes: CanvasNodeData[], connections: CanvasConnection[], contextNodeId?: string | null, targetNodes?: CanvasNodeData[]) {
const contextNodes = contextNodeId ? getMentionResourceNodes(contextNodeId, nodes, connections) : [];
const globalReferences = labelResourceNodes(nodes.filter(isResourceNode), false);
const sourceNodes = targetNodes ? uniqueCanvasNodes([...targetNodes, ...contextNodes]) : nodes;
const globalReferences = labelResourceNodes(sourceNodes.filter(isResourceNode), false);
const activeByNodeId = new Map(labelResourceNodes(contextNodes, true).map((reference) => [reference.nodeId, reference]));
return globalReferences.map((reference) => activeByNodeId.get(reference.nodeId) || reference);
}
function uniqueCanvasNodes(nodes: CanvasNodeData[]) {
const seen = new Set<string>();
return nodes.filter((node) => {
if (seen.has(node.id)) return false;
seen.add(node.id);
return true;
});
}
export function buildNodeMentionReferences(node: CanvasNodeData, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
return labelResourceNodes(getMentionResourceNodes(node.id, nodes, connections), true);
}
export function buildCanvasNodeMentionReferenceMap(nodes: CanvasNodeData[], connections: CanvasConnection[], targetNodes: CanvasNodeData[] = nodes) {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const resourceInputsByTargetId = new Map<string, CanvasNodeData[]>();
const configTargetBySourceId = new Map<string, string>();
for (const connection of connections) {
const source = nodeById.get(connection.fromNodeId);
const target = nodeById.get(connection.toNodeId);
if (!source || !target) continue;
if (isResourceNode(source)) {
const inputs = resourceInputsByTargetId.get(target.id) || [];
inputs.push(source);
resourceInputsByTargetId.set(target.id, inputs);
}
if (target.type === CanvasNodeType.Config && !configTargetBySourceId.has(source.id)) {
configTargetBySourceId.set(source.id, target.id);
}
}
const referencesByNodeId = new Map<string, CanvasResourceReference[]>();
for (const node of targetNodes) {
const configTargetId = configTargetBySourceId.get(node.id);
const configInputs = configTargetId ? (resourceInputsByTargetId.get(configTargetId) || []).filter((input) => input.id !== node.id) : [];
const ownInputs = resourceInputsByTargetId.get(node.id) || [];
const inputs = configInputs.length ? configInputs : ownInputs.length ? ownInputs : isResourceNode(node) ? [node] : [];
referencesByNodeId.set(node.id, labelResourceNodes(inputs, true));
}
return referencesByNodeId;
}
export function buildOrderedCanvasResourceReferences(nodes: CanvasNodeData[], active = true) {
return labelResourceNodes(nodes.filter(isResourceNode), active);
}

View File

@@ -0,0 +1,123 @@
export type CanvasSpatialBounds = {
left: number;
top: number;
right: number;
bottom: number;
};
export type CanvasSpatialIndexEntry<T> = {
id: string;
bounds: CanvasSpatialBounds;
value: T;
};
type Bucket = number[];
export type CanvasSpatialIndex<T> = {
query: (bounds: CanvasSpatialBounds, limit?: number) => T[];
};
const DEFAULT_CELL_SIZE = 1024;
const MAX_BUCKET_COVERAGE = 256;
/**
* A uniform grid keeps viewport queries proportional to nearby content. The
* original entry order is restored after bucket de-duplication so stacking
* behavior remains deterministic.
*/
export function buildCanvasSpatialIndex<T>(entries: CanvasSpatialIndexEntry<T>[], cellSize = DEFAULT_CELL_SIZE): CanvasSpatialIndex<T> {
const normalizedCellSize = Number.isFinite(cellSize) && cellSize > 0 ? cellSize : DEFAULT_CELL_SIZE;
const buckets = new Map<string, Bucket>();
const largeEntryIndexes: number[] = [];
const validEntries: CanvasSpatialIndexEntry<T>[] = [];
entries.forEach((entry) => {
const bounds = normalizeBounds(entry.bounds);
if (!bounds || !entry.id) return;
const index = validEntries.push({ ...entry, bounds }) - 1;
const minColumn = Math.floor(bounds.left / normalizedCellSize);
const maxColumn = Math.floor((bounds.right - Number.EPSILON) / normalizedCellSize);
const minRow = Math.floor(bounds.top / normalizedCellSize);
const maxRow = Math.floor((bounds.bottom - Number.EPSILON) / normalizedCellSize);
if ((maxColumn - minColumn + 1) * (maxRow - minRow + 1) > MAX_BUCKET_COVERAGE) {
largeEntryIndexes.push(index);
return;
}
for (let row = minRow; row <= maxRow; row += 1) {
for (let column = minColumn; column <= maxColumn; column += 1) {
const key = `${column}:${row}`;
const bucket = buckets.get(key);
if (bucket) bucket.push(index);
else buckets.set(key, [index]);
}
}
});
return {
query(bounds, limit) {
const normalizedBounds = normalizeBounds(bounds);
if (!normalizedBounds || validEntries.length === 0) return [];
const resultLimit = Number.isFinite(limit) && (limit || 0) > 0 ? Math.floor(limit as number) : null;
const minColumn = Math.floor(normalizedBounds.left / normalizedCellSize);
const maxColumn = Math.floor((normalizedBounds.right - Number.EPSILON) / normalizedCellSize);
const minRow = Math.floor(normalizedBounds.top / normalizedCellSize);
const maxRow = Math.floor((normalizedBounds.bottom - Number.EPSILON) / normalizedCellSize);
if (resultLimit) {
const candidateIndexes = new Set<number>();
const resultIndexes: number[] = [];
const addBucket = (bucket: Bucket | undefined) => {
if (!bucket) return false;
for (const index of bucket) {
if (candidateIndexes.has(index)) continue;
candidateIndexes.add(index);
const entry = validEntries[index];
if (!intersects(entry.bounds, normalizedBounds)) continue;
resultIndexes.push(index);
if (resultIndexes.length >= resultLimit) return true;
}
return false;
};
for (let row = minRow; row <= maxRow; row += 1) {
for (let column = minColumn; column <= maxColumn; column += 1) {
if (addBucket(buckets.get(`${column}:${row}`))) return resultIndexes.sort((left, right) => left - right).map((index) => validEntries[index].value);
}
}
for (const index of largeEntryIndexes) {
if (candidateIndexes.has(index)) continue;
const entry = validEntries[index];
if (!intersects(entry.bounds, normalizedBounds)) continue;
resultIndexes.push(index);
if (resultIndexes.length >= resultLimit) break;
}
return resultIndexes.sort((left, right) => left - right).map((index) => validEntries[index].value);
}
const candidateIndexes = new Set<number>();
for (let row = minRow; row <= maxRow; row += 1) {
for (let column = minColumn; column <= maxColumn; column += 1) {
buckets.get(`${column}:${row}`)?.forEach((index) => candidateIndexes.add(index));
}
}
largeEntryIndexes.forEach((index) => candidateIndexes.add(index));
return [...candidateIndexes]
.sort((left, right) => left - right)
.map((index) => validEntries[index])
.filter((entry) => intersects(entry.bounds, normalizedBounds))
.map((entry) => entry.value);
},
};
}
export function canvasNodeBounds(node: { position: { x: number; y: number }; width: number; height: number }): CanvasSpatialBounds {
return { left: node.position.x, top: node.position.y, right: node.position.x + node.width, bottom: node.position.y + node.height };
}
function normalizeBounds(bounds: CanvasSpatialBounds | undefined): CanvasSpatialBounds | null {
if (!bounds) return null;
const { left, top, right, bottom } = bounds;
if (![left, top, right, bottom].every(Number.isFinite) || right <= left || bottom <= top) return null;
return { left, top, right, bottom };
}
function intersects(left: CanvasSpatialBounds, right: CanvasSpatialBounds) {
return left.right > right.left && left.left < right.right && left.bottom > right.top && left.top < right.bottom;
}

View File

@@ -8,12 +8,14 @@ const workspaceRouteLoaders = {
export const loadAssetsPage = workspaceRouteLoaders.assets;
export const loadCanvasPage = workspaceRouteLoaders.canvas;
export const loadCanvasProjectPage = () => import("@/pages/canvas/project");
export const loadCreatePage = workspaceRouteLoaders.create;
export const loadProjectsPage = workspaceRouteLoaders.projects;
export const loadWalletPage = workspaceRouteLoaders.wallet;
export function preloadWorkspaceRoute(pathnameOrSlug: string) {
const slug = pathnameOrSlug.replace(/^\//, "").split("/", 1)[0] as keyof typeof workspaceRouteLoaders;
const load = workspaceRouteLoaders[slug];
// 根路径就是创作页,预加载时仍映射到其内部模块名。
const slug = pathnameOrSlug.replace(/^\//, "").split("/", 1)[0] || "create";
const load = workspaceRouteLoaders[slug as keyof typeof workspaceRouteLoaders];
if (load) void load();
}

View File

@@ -50,9 +50,8 @@ export async function executeImageGeneration({
const count = getGenerationCount(generationConfig.count);
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
const isImageNode = sourceNode?.type === CanvasNodeType.Image;
const isCopiedVariant = count === 1 && isImageNode && Boolean(sourceNode?.metadata?.copiedFromNodeId || sourceNode?.metadata?.versionOfNodeId || sourceNode?.title.endsWith(" Copy") || sourceNode?.title.includes(" · "));
const reuseSourceNode = canGenerateImageInPlace(sourceNode) || isCopiedVariant;
const directCopiedBatch = count > 1 && isImageNode && Boolean(sourceNode?.metadata?.content) && (Boolean(sourceNode?.metadata?.copiedFromNodeId) || sourceNode?.title.endsWith(" Copy"));
const reuseSourceNode = canGenerateImageInPlace(sourceNode);
const directCopiedBatch = count > 1 && isImageNode && Boolean(sourceNode?.metadata?.content) && reuseSourceNode;
// 已有图片生成新结果并保留旧版本;参考图只来自入边,避免把旧结果误当成自身输入。
const referenceImages = generationContext.referenceImages;
const generationType = referenceImages.length ? ("edit" as const) : ("generation" as const);
@@ -289,4 +288,3 @@ export async function executeImageGeneration({
return next;
});
}

View File

@@ -1,6 +1,7 @@
import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { canGenerateMediaInPlace } from "@/lib/canvas/canvas-generation-layout";
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { nextCanvasVersionLabel } from "@/lib/canvas/canvas-layout";
import { buildAudioGenerationMetadata, buildVideoGenerationMetadata, generationReferenceUrls, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
@@ -33,9 +34,9 @@ export async function executeVideoGeneration({
retryContext,
}: CanvasGenerationExecution) {
const spec = nodeSizeFromRatio(generationConfig.size, NODE_DEFAULT_SIZE[CanvasNodeType.Video].width, NODE_DEFAULT_SIZE[CanvasNodeType.Video].height) || NODE_DEFAULT_SIZE[CanvasNodeType.Video];
const isEmptyVideoNode = sourceNode?.type === CanvasNodeType.Video && !sourceNode.metadata?.content;
const isExistingVideoNode = sourceNode?.type === CanvasNodeType.Video && Boolean(sourceNode.metadata?.content);
const videoId = isEmptyVideoNode ? nodeId : nanoid();
const reuseSourceNode = canGenerateMediaInPlace(sourceNode, CanvasNodeType.Video);
const isExistingVideoNode = sourceNode?.type === CanvasNodeType.Video && Boolean(sourceNode.metadata?.content) && !reuseSourceNode;
const videoId = reuseSourceNode ? nodeId : nanoid();
const versionRootId = isExistingVideoNode && sourceNode ? sourceNode.metadata?.versionOfNodeId || sourceNode.id : undefined;
const parent = sourceNode?.position || { x: 0, y: 0 };
const videoGenerationMetadata = buildVideoGenerationMetadata(sourceNode, generationContext, generationConfig);
@@ -43,11 +44,11 @@ export async function executeVideoGeneration({
id: videoId,
type: CanvasNodeType.Video,
title: effectivePrompt.slice(0, 32) || "Generated Video",
position: isEmptyVideoNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y },
width: isEmptyVideoNode ? sourceNode.width : spec.width,
height: isEmptyVideoNode ? sourceNode.height : spec.height,
position: reuseSourceNode ? sourceNode!.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y },
width: reuseSourceNode ? sourceNode!.width : spec.width,
height: reuseSourceNode ? sourceNode!.height : spec.height,
metadata: {
...(isEmptyVideoNode ? sourceNode.metadata || {} : {}),
...(reuseSourceNode ? sourceNode?.metadata || {} : {}),
prompt: effectivePrompt,
status: NODE_STATUS_LOADING,
errorDetails: undefined,
@@ -69,7 +70,7 @@ export async function executeVideoGeneration({
registerPendingNodeIds([videoId]);
// 待生成版本先加入版本族,但只有成功结果才能替换当前主版本。
setNodes((current) => {
if (isEmptyVideoNode) return current.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node));
if (reuseSourceNode) return current.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node));
if (!isExistingVideoNode || !sourceNode) return [...current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), videoNode];
const rootId = versionRootId!;
const nextLabel = nextCanvasVersionLabel(rootId, current);
@@ -83,7 +84,7 @@ export async function executeVideoGeneration({
];
});
// 重新生成已有视频时,新节点继承源视频的上游连接,与源视频保持并行关系,而不是作为其下游子节点。
if (!isEmptyVideoNode) {
if (!reuseSourceNode) {
setConnections((current) => {
if (!isExistingVideoNode) return [...current, { id: nanoid(), fromNodeId: nodeId, toNodeId: videoId }];
return [...current, ...canvasConnections.filter((connection) => connection.toNodeId === nodeId).map((connection) => ({ ...connection, id: nanoid(), toNodeId: videoId }))];

View File

@@ -24,6 +24,7 @@ type CanvasProjectContextMenuProps = {
onRedo: () => void;
onPaste: (position: Position) => void;
onCopyNode: (nodeId: string) => void;
onCreateGenerationCopy: (nodeId: string) => void;
onDuplicate: (nodeId: string) => void;
onDeleteNode: (nodeId: string) => void;
onDeleteConnection: (connectionId: string) => void;
@@ -69,6 +70,9 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
onCopyNode={() => {
if (menu.type === "node") props.onCopyNode(menu.nodeId);
}}
onCreateGenerationCopy={() => {
if (menu.type === "node") props.onCreateGenerationCopy(menu.nodeId);
}}
onDuplicate={() => {
if (menu.type === "node") props.onDuplicate(menu.nodeId);
}}

View File

@@ -1,4 +1,4 @@
import { memo, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
import { memo, useMemo, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
import { Link2 } from "lucide-react";
import { ConnectionPath } from "@/components/canvas/canvas-connections";
@@ -84,10 +84,10 @@ const EMPTY_CANVAS_NODES: CanvasNodeData[] = [];
export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
const { viewportScale } = props;
const activeMediaNodeId = resolveActiveCanvasMediaNodeId(props.selectedNodeIds, props.nodeById);
const orderedVisibleNodes = [
const orderedVisibleNodes = useMemo(() => [
...props.visibleNodes.filter(isFrameNode),
...sortCanvasNodesByStackOrder(props.visibleNodes.filter((node) => !isFrameNode(node)), props.nodeStackOrder),
];
], [props.nodeStackOrder, props.visibleNodes]);
const framePreviewNodes = (node: CanvasNodeData) => {
const assetFolderId = node.metadata?.folder?.assetFolderId;
if (assetFolderId) return props.linkedFolderPreviewNodesById.get(assetFolderId) || EMPTY_CANVAS_NODES;
@@ -144,7 +144,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
scale={viewportScale}
isSelected={props.selectedNodeIds.has(node.id)}
mediaActive={activeMediaNodeId === node.id}
hydrateMediaPreview={activeMediaNodeId === null}
hydrateMediaPreview={activeMediaNodeId === null && !props.reduceMediaEffects}
isRelated={props.relatedNodeIds.has(node.id)}
isFocusRelated={props.activeNodeId === node.id}
isConnectionTarget={props.connectionTargetNodeId === node.id || props.batchConnectionPreview?.targetNodeId === node.id}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { App, Button, Dropdown, Modal, Select } from "antd";
@@ -19,6 +19,7 @@ import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
import { saveCanvasDrawing, type CanvasDrawingRenderDraft } from "@/lib/canvas/canvas-drawing-storage";
import { createCanvasProjectWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { listProjects } from "@/services/api/projects";
import { loadCanvasProjectPage } from "@/lib/workspace-route-modules";
export default function CanvasPage() {
const { message } = App.useApp();
@@ -31,6 +32,8 @@ export default function CanvasPage() {
const [projectFilter, setProjectFilter] = useState("all");
const loadMoreRef = useRef<HTMLDivElement>(null);
const [loadedProjectCount, setLoadedProjectCount] = useState(50);
const [openingProjectId, setOpeningProjectId] = useState("");
const openingProjectIdRef = useRef("");
const hydrated = useCanvasStore((state) => state.hydrated);
const projects = useCanvasStore((state) => state.projects);
const importProject = useCanvasStore((state) => state.importProject);
@@ -45,9 +48,16 @@ export default function CanvasPage() {
const agentMode = mode === "new" || mode === "recent" || mode === "choose";
const handoffMode = mode === "handoff";
const forwardedQuery = agentMode || handoffMode ? `?${searchParams.toString()}` : "";
const enterProject = (id: string) => {
navigate(`/canvas/${id}${forwardedQuery}`);
};
const preloadProject = useCallback(() => {
void loadCanvasProjectPage();
}, []);
const enterProject = useCallback((id: string) => {
if (openingProjectIdRef.current) return;
openingProjectIdRef.current = id;
setOpeningProjectId(id);
preloadProject();
window.requestAnimationFrame(() => navigate(`/canvas/${id}${forwardedQuery}`));
}, [forwardedQuery, navigate, preloadProject]);
const createAndEnter = () => {
void createCanvasProjectWithRemoteSync(`自由画布 ${projects.length + 1}`).then(({ id, syncError }) => {
if (syncError) message.warning(syncError instanceof Error ? `画布已在本地创建,云端同步失败:${syncError.message}` : "画布已在本地创建,云端同步失败");
@@ -342,6 +352,8 @@ export default function CanvasPage() {
project={project}
projectName={project.projectId ? projectNames.get(project.projectId) || "未同步项目" : undefined}
onClick={() => enterProject(project.id)}
onPrefetch={preloadProject}
opening={openingProjectId === project.id}
/>
))}
</CollectionGrid>

View File

@@ -76,7 +76,7 @@ import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDra
import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter";
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain";
import { stampCanvasNodeChanges } from "@/lib/canvas/canvas-node-timestamps";
import { stampCanvasNodeChanges, updateCanvasNode, updateCanvasNodes } from "@/lib/canvas/canvas-node-timestamps";
import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff";
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress";
@@ -92,7 +92,7 @@ import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs";
import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolbar";
import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs";
import { CanvasProjectWorldLayers } from "./canvas-project-world-layers";
import { CanvasNodeActionContext } from "@/components/canvas/canvas-node-action-context";
import { CanvasNodeActionContext, type CanvasNodeActionContextValue } from "@/components/canvas/canvas-node-action-context";
import { bringCanvasNodeToFront, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
import { PortraitClearanceModal } from "@/components/canvas/portrait-clearance/portrait-clearance-modal";
import { CanvasNodeGraphContext, type CanvasNodeGraphContextValue } from "@/components/canvas/canvas-node-graph-context";
@@ -131,6 +131,7 @@ import {
type CanvasFolderStyle,
type CanvasFolderTheme,
type CanvasNodeData,
type CanvasNodeMetadata,
type CanvasMediaPerformanceMode,
type StoryboardColumn,
type StoryboardShotCount,
@@ -1386,6 +1387,45 @@ function InfiniteCanvasPage() {
setToolbarNodeId(null);
setPortraitClearanceNodeId(node.id);
}, []);
const duplicateNodeFromContent = useCallback((node: CanvasNodeData) => duplicateNode(node.id), [duplicateNode]);
const deleteNodeFromContent = useCallback((node: CanvasNodeData) => deleteNodes(new Set([node.id])), [deleteNodes]);
const updateNodeFromContent = useCallback((nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => {
setNodesState((current) => {
const next = updateCanvasNode(current, nodeId, update);
nodesRef.current = next;
return next;
});
}, []);
const pendingMediaUpdatesRef = useRef(new Map<string, (node: CanvasNodeData) => CanvasNodeData>());
const mediaUpdateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const updateMediaNodeFromContent = useCallback((nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => {
const previous = pendingMediaUpdatesRef.current.get(nodeId);
pendingMediaUpdatesRef.current.set(nodeId, previous ? (node) => update(previous(node)) : update);
if (mediaUpdateTimerRef.current) return;
mediaUpdateTimerRef.current = setTimeout(() => {
const updates = pendingMediaUpdatesRef.current;
pendingMediaUpdatesRef.current = new Map();
mediaUpdateTimerRef.current = null;
if (!updates.size) return;
setNodesState((current) => {
const next = updateCanvasNodes(current, updates);
nodesRef.current = next;
return next;
});
}, 120);
}, []);
const updateNodeMetadataFromContent = useCallback((nodeId: string, patch: CanvasNodeMetadata) => {
updateNodeFromContent(nodeId, (node) => ({ ...node, metadata: { ...node.metadata, ...patch } }));
}, [updateNodeFromContent]);
const canvasNodeActions = useMemo<CanvasNodeActionContextValue>(() => ({
download: downloadNodeImage,
duplicate: duplicateNodeFromContent,
deleteNode: deleteNodeFromContent,
updateMetadata: updateNodeMetadataFromContent,
updateNode: updateNodeFromContent,
updateMediaNode: updateMediaNodeFromContent,
openPortraitClearance,
}), [deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openPortraitClearance, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent]);
const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasAgentOperations({
projectId,
domainProjectId: currentProject?.projectId,
@@ -2202,7 +2242,7 @@ function InfiniteCanvasPage() {
onFileDragLeave={handleFileDragLeave}
onFileDragOver={handleFileDragOver}
>
<CanvasNodeActionContext.Provider value={{ download: downloadNodeImage, duplicate: (node) => duplicateNode(node.id), deleteNode: (node) => deleteNodes(new Set([node.id])), updateMetadata: (nodeId, patch) => setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))), resizeNode: (nodeId, size) => setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, width: size.width, height: size.height } : node))), openPortraitClearance }}>
<CanvasNodeActionContext.Provider value={canvasNodeActions}>
<CanvasNodeGraphContext.Provider value={nodeGraphContext}>
<CanvasProjectWorldLayers
projectId={projectId}
@@ -2562,6 +2602,7 @@ function InfiniteCanvasPage() {
onRedo={redoCanvas}
onPaste={pasteAtPosition}
onCopyNode={(nodeId) => copyNodesToClipboard(new Set([nodeId]))}
onCreateGenerationCopy={(nodeId) => duplicateNode(nodeId, "copy")}
onDuplicate={duplicateNode}
onDeleteNode={(nodeId) => deleteNodes(new Set([nodeId]))}
onDeleteConnection={deleteConnection}

View File

@@ -340,13 +340,14 @@ export function useCanvasNodeOperations({
setContextMenu((current) => current?.type === "connection" && current.connectionId === connectionId ? null : current);
}, [commitConnections, connectionsRef, setContextMenu, setSelectedConnectionId]);
const duplicateNode = useCallback((nodeId: string) => {
const duplicateNode = useCallback((nodeId: string, duplicateMode: "variant" | "copy" = "variant") => {
const source = nodesRef.current.find((node) => node.id === nodeId);
if (!source) return;
const sources = isFrameNode(source) ? [source, ...getFrameChildren(source.id, nodesRef.current)] : [source];
const idMap = new Map(sources.map((node, index) => [node.id, `${node.type}-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`]));
const versionRootId = isFrameNode(source) ? undefined : source.metadata?.versionOfNodeId || source.id;
const versionRootId = duplicateMode === "variant" && !isFrameNode(source) ? source.metadata?.versionOfNodeId || source.id : undefined;
const versionLabel = versionRootId ? nextCanvasVersionLabel(versionRootId, nodesRef.current) : undefined;
const copyTitle = duplicateMode === "copy" ? nextCopiedNodeTitle(source.title, nodesRef.current.map((node) => node.title)) : undefined;
const copiedNodes = sources.map((node) => {
const metadata = isolateCopiedNodeMetadata(node, idMap);
if (node.type === CanvasNodeType.Drawing) {
@@ -364,7 +365,7 @@ export function useCanvasNodeOperations({
return {
...node,
id: idMap.get(node.id)!,
title: node.id === source.id ? `${node.title.replace(/ · [A-Z]$/, "")} · ${versionLabel || "副本"}` : node.title,
title: node.id === source.id ? copyTitle || `${node.title.replace(/ · [A-Z]$/, "")} · ${versionLabel || "副本"}` : node.title,
position: { x: node.position.x + 36, y: node.position.y + 36 },
parentId: node.parentId ? idMap.get(node.parentId) || node.parentId : undefined,
metadata,
@@ -379,7 +380,7 @@ export function useCanvasNodeOperations({
}
const id = idMap.get(source.id)!;
const nextNodes = [
...nodesRef.current.map((node) => node.id === source.id && versionRootId && !node.metadata?.versionLabel ? { ...node, title: `${node.title} · A`, metadata: { ...node.metadata, versionOfNodeId: versionRootId, versionLabel: "A", versionPrimary: true } } : node),
...nodesRef.current.map((node) => node.id === source.id && versionRootId && !node.metadata?.versionLabel ? { ...node, title: `${node.title} · A`, metadata: { ...node.metadata, versionOfNodeId: versionRootId, versionLabel: "A", versionPrimary: true, generationResultPlacement: "replace-node" as const } } : node),
...copiedNodes,
];
commitNodes(nextNodes);

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { startTransition, useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { App } from "antd";
import { useNavigate } from "react-router";
@@ -6,12 +6,12 @@ import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, normalizeCanvasApp
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
import { hydrateAssistantImages, hydrateCanvasImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, CanvasNodeMetadata, ViewportTransform } from "@/types/canvas";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
import type { CanvasHistorySnapshot } from "./use-canvas-history";
type UseCanvasProjectLifecycleOptions = {
@@ -130,12 +130,15 @@ export function useCanvasProjectLifecycle({
const initialSessions = project.chatSessions || [];
// 先恢复可交互的节点和布局,媒体缓存/资源校验放到后台,避免首屏被远程资源拖住。
applyRestoredProject(initialNodes, initialSessions);
const [nodesResult, sessionsResult] = await Promise.allSettled([hydrateCanvasImages(initialNodes), hydrateAssistantImages(initialSessions)]);
if (cancelled) return;
if (nodesResult.status === "fulfilled") setNodes((current) => mergeHydratedNodeMedia(current, initialNodes, nodesResult.value));
if (sessionsResult.status === "fulfilled") setChatSessions((current) => mergeHydratedSessions(current, sessionsResult.value));
if (nodesResult.status === "rejected" || sessionsResult.status === "rejected") message.warning("部分本地媒体恢复失败,已使用项目记录继续打开");
startTransition(() => applyRestoredProject(initialNodes, initialSessions));
// 画布媒体由节点自己的视口观察器按需加载;打开时遍历并解析全部节点会让大画布形成 N+1 资源读取。
void hydrateAssistantImages(initialSessions)
.then((hydratedSessions) => {
if (!cancelled) setChatSessions((current) => mergeHydratedSessions(current, hydratedSessions));
})
.catch(() => {
if (!cancelled) message.warning("部分助手会话素材恢复失败,已使用项目记录继续打开");
});
};
void restore();
return () => {
@@ -252,24 +255,6 @@ export function useCanvasProjectLifecycle({
};
}
const hydratedMediaMetadataKeys = ["content", "storageKey", "naturalWidth", "naturalHeight", "bytes", "mimeType", "durationMs", "hasAudio", "videoPreview"] as const satisfies readonly (keyof CanvasNodeMetadata)[];
function mergeHydratedNodeMedia(currentNodes: CanvasNodeData[], initialNodes: CanvasNodeData[], hydratedNodes: CanvasNodeData[]) {
const initialById = new Map(initialNodes.map((node) => [node.id, node]));
const hydratedById = new Map(hydratedNodes.map((node) => [node.id, node]));
return currentNodes.map((node) => {
const initial = initialById.get(node.id);
const hydrated = hydratedById.get(node.id);
if (!initial || !hydrated || node.metadata?.content !== initial.metadata?.content) return node;
const metadata = { ...node.metadata } as CanvasNodeMetadata;
hydratedMediaMetadataKeys.forEach((key) => {
const value = hydrated.metadata?.[key];
if (value !== undefined) (metadata as Record<string, unknown>)[key] = value;
});
return { ...node, metadata };
});
}
function mergeHydratedSessions(currentSessions: CanvasAssistantSession[], hydratedSessions: CanvasAssistantSession[]) {
const hydratedById = new Map(hydratedSessions.map((session) => [session.id, session]));
return currentSessions.map((session) => {

View File

@@ -1,15 +1,16 @@
import { useMemo, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { buildNodeGenerationInputs, type NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
import { sameNodeSemanticData } from "@/lib/canvas/canvas-project-domain";
import { shouldReduceCanvasMediaEffects } from "@/lib/canvas/canvas-performance-mode";
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CONNECTIONS, shouldReduceCanvasMediaEffects } from "@/lib/canvas/canvas-performance-mode";
import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references";
import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions";
import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex, type CanvasSpatialIndexEntry } from "@/lib/canvas/canvas-spatial-index";
import type { Skill } from "@/services/api/skills";
import type { Asset, ImageAsset } from "@/stores/use-asset-store";
import type { DirectorScene } from "@/types/director";
import { CanvasNodeType, type CanvasConnection, type CanvasMediaPerformanceMode, type CanvasNodeData, type ContextMenuState, type ViewportTransform } from "@/types/canvas";
import { CanvasNodeType, type CanvasConnection, type CanvasDisplayConnection, type CanvasMediaPerformanceMode, type CanvasNodeData, type ContextMenuState, type ViewportTransform } from "@/types/canvas";
type DragPreview = { x: number; y: number; nodeIds: Set<string> } | null;
@@ -74,26 +75,65 @@ export function useCanvasRenderModel({
}: UseCanvasRenderModelOptions) {
const reduceMediaEffects = useMemo(() => shouldReduceCanvasMediaEffects(mediaPerformanceMode, nodes), [mediaPerformanceMode, nodes]);
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
const collapsedBatchChildIds = useMemo(() => {
const hidden = new Set<string>();
nodes.forEach((node) => {
// These maps are consumed by both virtualization and node chrome. Building
// them together keeps a metadata update from walking a 50k-node array six
// separate times before React can paint the next frame.
const nodeDerivedData = useMemo(() => {
const collapsedBatchChildIds = new Set<string>();
const renderHiddenNodeIds = new Set<string>();
const frameChildrenById = new Map<string, CanvasNodeData[]>();
const canvasImageNodes: CanvasNodeData[] = [];
const batchRoots: CanvasNodeData[] = [];
const batchMotionById = new Map<string, { x: number; y: number; index: number }>();
const batchChildIndexByRootId = new Map<string, Map<string, number>>();
for (const node of nodes) {
const rootId = node.metadata?.batchRootId;
if (!rootId) return;
const root = rootId ? nodeById.get(rootId) : undefined;
if (root && !root.metadata?.imageBatchExpanded) {
collapsedBatchChildIds.add(node.id);
if (!collapsingBatchIds.has(root.id)) renderHiddenNodeIds.add(node.id);
}
if (rootId && collapsingBatchIds.has(rootId)) renderHiddenNodeIds.delete(node.id);
const parent = node.parentId ? nodeById.get(node.parentId) : undefined;
if (parent && isFrameNode(parent)) {
const children = frameChildrenById.get(parent.id);
if (children) children.push(node);
else frameChildrenById.set(parent.id, [node]);
if (parent.metadata?.frame?.collapsed) renderHiddenNodeIds.add(node.id);
}
if (node.metadata?.isBatchRoot) batchRoots.push(node);
if (node.type === CanvasNodeType.Image && node.metadata?.content && !collapsedBatchChildIds.has(node.id) && !(parent && isFrameNode(parent) && parent.metadata?.frame?.collapsed)) {
canvasImageNodes.push(node);
}
}
for (const root of batchRoots) {
const childIndex = new Map((root.metadata?.batchChildIds || []).map((childId, index) => [childId, index]));
batchChildIndexByRootId.set(root.id, childIndex);
}
for (const node of nodes) {
const rootId = node.metadata?.batchRootId;
if (!rootId) continue;
const root = nodeById.get(rootId);
if (root && !root.metadata?.imageBatchExpanded) hidden.add(node.id);
});
return hidden;
}, [nodeById, nodes]);
const renderHiddenNodeIds = useMemo(() => {
const hidden = new Set(collapsedBatchChildIds);
nodes.forEach((node) => {
const rootId = node.metadata?.batchRootId;
if (rootId && collapsingBatchIds.has(rootId)) hidden.delete(node.id);
const parent = node.parentId ? nodeById.get(node.parentId) : null;
if (parent && isFrameNode(parent) && parent.metadata?.frame?.collapsed) hidden.add(node.id);
});
return hidden;
}, [collapsedBatchChildIds, collapsingBatchIds, nodeById, nodes]);
const index = batchChildIndexByRootId.get(rootId)?.get(node.id) ?? 0;
const stackX = root ? root.position.x + 34 + index * 14 : node.position.x;
const stackY = root ? root.position.y + 14 + index * 8 : node.position.y;
batchMotionById.set(node.id, { x: stackX - node.position.x, y: stackY - node.position.y, index: Math.max(index, 0) });
}
const batchChildCountById = new Map<string, number>();
for (const root of batchRoots) {
const childIndex = batchChildIndexByRootId.get(root.id);
const liveChildCount = [...(childIndex?.keys() || [])].filter((childId) => nodeById.get(childId)?.metadata?.batchRootId === root.id).length;
batchChildCountById.set(root.id, liveChildCount);
}
return { batchChildCountById, batchMotionById, canvasImageNodes, collapsedBatchChildIds, frameChildrenById, renderHiddenNodeIds };
}, [collapsingBatchIds, nodeById, nodes]);
const { batchChildCountById, batchMotionById, canvasImageNodes, collapsedBatchChildIds, frameChildrenById, renderHiddenNodeIds } = nodeDerivedData;
const connectionLayerBounds = useMemo(() => {
const padding = (reduceMediaEffects ? 96 : 144) / Math.max(viewport.k, 0.05);
const left = -viewport.x / viewport.k - padding;
@@ -103,27 +143,70 @@ export function useCanvasRenderModel({
return { left, top, width: Math.max(2, width), height: Math.max(2, height) };
}, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const renderBounds = useMemo(() => {
const padding = (reduceMediaEffects ? 128 : 192) / viewport.k;
const viewLeft = -viewport.x / viewport.k - padding;
const viewTop = -viewport.y / viewport.k - padding;
const viewRight = viewLeft + viewportSize.width / viewport.k + padding * 2;
const viewBottom = viewTop + viewportSize.height / viewport.k + padding * 2;
return { left: viewLeft, top: viewTop, right: viewRight, bottom: viewBottom };
const enterPadding = canvasNodeRenderPadding(reduceMediaEffects, false) / viewport.k;
const retainPadding = canvasNodeRenderPadding(reduceMediaEffects, true) / viewport.k;
const viewLeft = -viewport.x / viewport.k;
const viewTop = -viewport.y / viewport.k;
const viewWidth = viewportSize.width / viewport.k;
const viewHeight = viewportSize.height / viewport.k;
return {
enter: { left: viewLeft - enterPadding, top: viewTop - enterPadding, right: viewLeft + viewWidth + enterPadding, bottom: viewTop + viewHeight + enterPadding },
retain: { left: viewLeft - retainPadding, top: viewTop - retainPadding, right: viewLeft + viewWidth + retainPadding, bottom: viewTop + viewHeight + retainPadding },
};
}, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const nodeSpatialIndexRef = useRef<{ source: CanvasNodeData[]; index: CanvasSpatialIndex<string> } | null>(null);
const nodeSpatialIndex = useMemo(() => {
const previous = nodeSpatialIndexRef.current;
const geometryUnchanged = previous
&& previous.source.length === nodes.length
&& nodes.every((node, index) => {
const old = previous.source[index];
return old.id === node.id
&& old.position.x === node.position.x
&& old.position.y === node.position.y
&& old.width === node.width
&& old.height === node.height;
});
if (geometryUnchanged) return previous.index;
const index = buildCanvasSpatialIndex(nodes.map((node) => ({ id: node.id, bounds: canvasNodeBounds(node), value: node.id })));
nodeSpatialIndexRef.current = { source: nodes, index };
return index;
}, [nodes]);
const renderedNodeIdsRef = useRef<Set<string>>(new Set());
const visibleNodes = useMemo(() => {
const frames: CanvasNodeData[] = [];
const regular: CanvasNodeData[] = [];
nodes.forEach((node) => {
const renderedNodeIds = renderedNodeIdsRef.current;
const renderBudget = canvasNodeRenderBudget(viewport.k);
const forcedNodeIds = new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])].slice(0, renderBudget));
const candidates = nodeSpatialIndex.query(renderBounds.retain, renderBudget + forcedNodeIds.size)
.map((nodeId) => nodeById.get(nodeId))
.filter((node): node is CanvasNodeData => Boolean(node));
const candidateIds = new Set(candidates.map((node) => node.id));
for (const nodeId of forcedNodeIds) {
if (candidateIds.has(nodeId)) continue;
const node = nodeById.get(nodeId);
if (node) candidates.push(node);
}
const prioritized = candidates.filter((node) => forcedNodeIds.has(node.id));
const remaining = candidates.filter((node) => !forcedNodeIds.has(node.id)).slice(0, Math.max(0, renderBudget - prioritized.length));
[...prioritized, ...remaining].forEach((node) => {
if (renderHiddenNodeIds.has(node.id)) return;
const retained = selectedNodeIds.has(node.id) || Boolean(dragPreview?.nodeIds.has(node.id));
if (!retained && (node.position.x + node.width <= renderBounds.left || node.position.x >= renderBounds.right || node.position.y + node.height <= renderBounds.top || node.position.y >= renderBounds.bottom)) return;
const retained = forcedNodeIds.has(node.id) || renderedNodeIds.has(node.id);
const insideEnterBounds = node.position.x + node.width > renderBounds.enter.left
&& node.position.x < renderBounds.enter.right
&& node.position.y + node.height > renderBounds.enter.top
&& node.position.y < renderBounds.enter.bottom;
if (!retained && !insideEnterBounds) return;
(isFrameNode(node) ? frames : regular).push(node);
});
return [...frames, ...regular];
}, [dragPreview, nodes, renderBounds, renderHiddenNodeIds, selectedNodeIds]);
}, [dragPreview, nodeById, nodeSpatialIndex, renderBounds, renderHiddenNodeIds, selectedNodeIds]);
useEffect(() => {
renderedNodeIdsRef.current = new Set(visibleNodes.map((node) => node.id));
}, [visibleNodes]);
const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image"), [assets]);
const canvasImageNodes = useMemo(() => nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content) && !collapsedBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed)), [collapsedBatchChildIds, nodeById, nodes]);
const semanticNodesRef = useRef(nodes);
const semanticNodes = useMemo(() => {
const previous = semanticNodesRef.current;
@@ -156,53 +239,24 @@ export function useCanvasRenderModel({
const selectedNodeBounds = useMemo(() => {
if (selectedNodeIds.size < 2) return null;
const selectedNodes = nodes.filter((node) => selectedNodeIds.has(node.id) && !renderHiddenNodeIds.has(node.id));
const selectedNodes = [...selectedNodeIds]
.map((nodeId) => nodeById.get(nodeId))
.filter((node): node is CanvasNodeData => Boolean(node && !renderHiddenNodeIds.has(node.id)));
if (selectedNodes.length < 2) return null;
const left = Math.min(...selectedNodes.map((node) => node.position.x));
const top = Math.min(...selectedNodes.map((node) => node.position.y));
const right = Math.max(...selectedNodes.map((node) => node.position.x + node.width));
const bottom = Math.max(...selectedNodes.map((node) => node.position.y + node.height));
return { left, top, width: right - left, height: bottom - top, count: selectedNodes.length };
}, [nodes, renderHiddenNodeIds, selectedNodeIds]);
const selectedVideoNodes = useMemo(() => nodes
.filter((node) => selectedNodeIds.has(node.id) && node.type === CanvasNodeType.Video && Boolean(node.metadata?.content) && !renderHiddenNodeIds.has(node.id))
}, [nodeById, renderHiddenNodeIds, selectedNodeIds]);
const selectedVideoNodes = useMemo(() => [...selectedNodeIds]
.map((nodeId) => nodeById.get(nodeId))
.filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Video && node.metadata?.content && !renderHiddenNodeIds.has(node.id)))
.sort((a, b) => {
const shotA = a.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
const shotB = b.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
return shotA - shotB || a.position.y - b.position.y || a.position.x - b.position.x;
}), [nodes, renderHiddenNodeIds, selectedNodeIds]);
const batchChildCountById = useMemo(() => {
const map = new Map<string, number>();
nodes.forEach((node) => {
if (!node.metadata?.isBatchRoot) return;
const liveChildCount = (node.metadata.batchChildIds || []).filter((childId) => nodeById.get(childId)?.metadata?.batchRootId === node.id).length;
map.set(node.id, liveChildCount);
});
return map;
}, [nodeById, nodes]);
const frameChildrenById = useMemo(() => {
const map = new Map<string, CanvasNodeData[]>();
nodes.forEach((node) => {
if (!node.parentId) return;
const children = map.get(node.parentId) || [];
children.push(node);
map.set(node.parentId, children);
});
return map;
}, [nodes]);
const batchMotionById = useMemo(() => {
const map = new Map<string, { x: number; y: number; index: number }>();
nodes.forEach((node) => {
const rootId = node.metadata?.batchRootId;
if (!rootId) return;
const root = nodeById.get(rootId);
const index = root?.metadata?.batchChildIds?.indexOf(node.id) ?? 0;
const stackX = root ? root.position.x + 34 + index * 14 : node.position.x;
const stackY = root ? root.position.y + 14 + index * 8 : node.position.y;
map.set(node.id, { x: stackX - node.position.x, y: stackY - node.position.y, index: Math.max(index, 0) });
});
return map;
}, [nodeById, nodes]);
}), [nodeById, renderHiddenNodeIds, selectedNodeIds]);
const relatedHighlight = useMemo(() => {
const nodeIds = new Set<string>();
const connectionIds = new Set<string>();
@@ -216,45 +270,89 @@ export function useCanvasRenderModel({
});
return { nodeIds, connectionIds };
}, [activeNodeId, connections]);
const displayConnections = useMemo(() => connections.flatMap((connection) => {
if (collapsedBatchChildIds.has(connection.fromNodeId) || collapsedBatchChildIds.has(connection.toNodeId)) return [];
const fromNode = nodeById.get(connection.fromNodeId);
const toNode = nodeById.get(connection.toNodeId);
if (!fromNode || !toNode) return [];
const fromParent = fromNode.parentId ? nodeById.get(fromNode.parentId) : null;
const toParent = toNode.parentId ? nodeById.get(toNode.parentId) : null;
const displayFrom = fromParent && isFrameNode(fromParent) && fromParent.metadata?.frame?.collapsed ? fromParent : fromNode;
const displayTo = toParent && isFrameNode(toParent) && toParent.metadata?.frame?.collapsed ? toParent : toNode;
if (displayFrom.id === displayTo.id) return [];
const from = dragPreview?.nodeIds.has(displayFrom.id) ? { ...displayFrom, position: { x: displayFrom.position.x + dragPreview.x, y: displayFrom.position.y + dragPreview.y } } : displayFrom;
const to = dragPreview?.nodeIds.has(displayTo.id) ? { ...displayTo, position: { x: displayTo.position.x + dragPreview.x, y: displayTo.position.y + dragPreview.y } } : displayTo;
const connectionLeft = Math.min(from.position.x, to.position.x);
const connectionTop = Math.min(from.position.y, to.position.y);
const connectionRight = Math.max(from.position.x + from.width, to.position.x + to.width);
const connectionBottom = Math.max(from.position.y + from.height, to.position.y + to.height);
if (connectionRight <= renderBounds.left || connectionLeft >= renderBounds.right || connectionBottom <= renderBounds.top || connectionTop >= renderBounds.bottom) return [];
return [{ connection, from, to }];
}), [collapsedBatchChildIds, connections, dragPreview, nodeById, renderBounds]);
const connectionSpatialIndex = useMemo(() => {
const entries: CanvasSpatialIndexEntry<CanvasDisplayConnection>[] = [];
const connectionIdsByNodeId = new Map<string, Set<string>>();
connections.forEach((connection) => {
if (collapsedBatchChildIds.has(connection.fromNodeId) || collapsedBatchChildIds.has(connection.toNodeId)) return;
const fromNode = nodeById.get(connection.fromNodeId);
const toNode = nodeById.get(connection.toNodeId);
if (!fromNode || !toNode) return;
const fromParent = fromNode.parentId ? nodeById.get(fromNode.parentId) : null;
const toParent = toNode.parentId ? nodeById.get(toNode.parentId) : null;
const displayFrom = fromParent && isFrameNode(fromParent) && fromParent.metadata?.frame?.collapsed ? fromParent : fromNode;
const displayTo = toParent && isFrameNode(toParent) && toParent.metadata?.frame?.collapsed ? toParent : toNode;
if (displayFrom.id === displayTo.id) return;
const left = Math.min(displayFrom.position.x, displayTo.position.x);
const top = Math.min(displayFrom.position.y, displayTo.position.y);
const right = Math.max(displayFrom.position.x + displayFrom.width, displayTo.position.x + displayTo.width);
const bottom = Math.max(displayFrom.position.y + displayFrom.height, displayTo.position.y + displayTo.height);
const value = { connection, from: displayFrom, to: displayTo };
entries.push({ id: connection.id, bounds: { left, top, right, bottom }, value });
for (const nodeId of new Set([connection.fromNodeId, connection.toNodeId, displayFrom.id, displayTo.id])) {
const ids = connectionIdsByNodeId.get(nodeId) || new Set<string>();
ids.add(connection.id);
connectionIdsByNodeId.set(nodeId, ids);
}
});
return { index: buildCanvasSpatialIndex(entries), connectionIdsByNodeId, entriesById: new Map(entries.map((entry) => [entry.id, entry.value])) };
}, [collapsedBatchChildIds, connections, nodeById]);
const displayConnections = useMemo(() => {
const candidateById = new Map<string, CanvasDisplayConnection>();
connectionSpatialIndex.index.query(renderBounds.retain, CANVAS_MAX_RENDERED_CONNECTIONS).forEach((display) => candidateById.set(display.connection.id, display));
dragPreview?.nodeIds.forEach((nodeId) => {
connectionSpatialIndex.connectionIdsByNodeId.get(nodeId)?.forEach((connectionId) => {
const display = connectionSpatialIndex.entriesById.get(connectionId);
if (display) candidateById.set(connectionId, display);
});
});
return [...candidateById.values()].flatMap(({ connection, from: sourceFrom, to: sourceTo }) => {
const from = dragPreview?.nodeIds.has(sourceFrom.id) ? { ...sourceFrom, position: { x: sourceFrom.position.x + dragPreview.x, y: sourceFrom.position.y + dragPreview.y } } : sourceFrom;
const to = dragPreview?.nodeIds.has(sourceTo.id) ? { ...sourceTo, position: { x: sourceTo.position.x + dragPreview.x, y: sourceTo.position.y + dragPreview.y } } : sourceTo;
const connectionLeft = Math.min(from.position.x, to.position.x);
const connectionTop = Math.min(from.position.y, to.position.y);
const connectionRight = Math.max(from.position.x + from.width, to.position.x + to.width);
const connectionBottom = Math.max(from.position.y + from.height, to.position.y + to.height);
if (connectionRight <= renderBounds.retain.left || connectionLeft >= renderBounds.retain.right || connectionBottom <= renderBounds.retain.top || connectionTop >= renderBounds.retain.bottom) return [];
return [{ connection, from, to }];
});
}, [connectionSpatialIndex, dragPreview, renderBounds]);
const configInputsById = useMemo(() => {
const map = new Map<string, NodeGenerationInput[]>();
semanticNodes.forEach((node) => {
if (node.type === CanvasNodeType.Config) map.set(node.id, buildNodeGenerationInputs(node.id, semanticNodes, connections));
const configNodeIds = new Set<string>();
visibleNodes.forEach((node) => {
if (node.type === CanvasNodeType.Config) configNodeIds.add(node.id);
});
selectedNodeIds.forEach((nodeId) => {
if (nodeById.get(nodeId)?.type === CanvasNodeType.Config) configNodeIds.add(nodeId);
});
if (dialogNodeId && nodeById.get(dialogNodeId)?.type === CanvasNodeType.Config) configNodeIds.add(dialogNodeId);
configNodeIds.forEach((nodeId) => map.set(nodeId, buildNodeGenerationInputs(nodeId, semanticNodes, connections)));
return map;
}, [connections, semanticNodes]);
}, [connections, dialogNodeId, nodeById, selectedNodeIds, semanticNodes, visibleNodes]);
const activeDirectorNode = useMemo(() => semanticNodes.find((node) => node.id === directorNodeId) || null, [directorNodeId, semanticNodes]);
const activeStylePresetId = useMemo(() => semanticNodes.find((node) => node.metadata?.workflowKind === "styleboard")?.metadata?.stylePresetId, [semanticNodes]);
const activeScriptNode = useMemo(() => semanticNodes.find((node) => node.id === scriptEditorNodeId && node.type === CanvasNodeType.Script) || null, [scriptEditorNodeId, semanticNodes]);
const activeDirectorScene = useMemo(() => directorScenes?.find((scene) => scene.id === activeDirectorNode?.metadata?.directorSceneId) || null, [activeDirectorNode?.metadata?.directorSceneId, directorScenes]);
const canvasResourceReferences = useMemo(() => buildCanvasResourceReferences(semanticNodes, connections, dialogNodeId || activeNodeId), [activeNodeId, connections, dialogNodeId, semanticNodes]);
const resourceReferenceTargetNodes = useMemo(() => {
const targetNodes = [...visibleNodes];
const activeId = dialogNodeId || activeNodeId;
if (activeId) {
const activeNode = nodeById.get(activeId);
if (activeNode) targetNodes.push(activeNode);
}
return targetNodes;
}, [activeNodeId, dialogNodeId, nodeById, visibleNodes]);
const canvasResourceReferences = useMemo(() => buildCanvasResourceReferences(semanticNodes, connections, dialogNodeId || activeNodeId, resourceReferenceTargetNodes), [activeNodeId, connections, dialogNodeId, resourceReferenceTargetNodes, semanticNodes]);
const resourceReferenceByNodeId = useMemo(() => new Map(canvasResourceReferences.map((reference) => [reference.nodeId, reference])), [canvasResourceReferences]);
const skillMentionReferences = useMemo(() => buildSkillMentionReferences(addedSkills), [addedSkills]);
const mentionReferencesByNodeId = useMemo(() => {
const map = new Map<string, ReturnType<typeof buildNodeMentionReferences>>();
semanticNodes.forEach((node) => map.set(node.id, [...buildNodeMentionReferences(node, semanticNodes, connections), ...skillMentionReferences]));
const map = buildCanvasNodeMentionReferenceMap(semanticNodes, connections, visibleNodes);
if (!skillMentionReferences.length) return map;
map.forEach((references, nodeId) => map.set(nodeId, [...references, ...skillMentionReferences]));
return map;
}, [connections, semanticNodes, skillMentionReferences]);
}, [connections, semanticNodes, skillMentionReferences, visibleNodes]);
return {
activeDirectorNode,

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type RefObject, type SetStateAction } from "react";
import { applyCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport";
import { calculateNodeAlignment, createNodeAlignmentContext, isHiddenBatchChild, sameStringSet, type NodeAlignmentContext } from "@/lib/canvas/canvas-project-domain";
import { applyFrameDrop, findFrameDropTarget, getFrameChildIds, isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame";
import { applyCanvasNodeDragPreview, applyCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport";
import { calculateNodeAlignment, createNodeAlignmentContext, sameStringSet, type NodeAlignmentContext } from "@/lib/canvas/canvas-project-domain";
import { applyFrameDrop, buildCanvasFrameDropIndex, findFrameDropTargetFromIndex, getFrameChildIds, isFrameNode } from "@/lib/canvas/canvas-frame";
import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex } from "@/lib/canvas/canvas-spatial-index";
import type { CanvasNodeData, Position, SelectionBox, ViewportTransform } from "@/types/canvas";
type UseCanvasSelectionControllerOptions = {
@@ -34,6 +35,8 @@ type DragState = {
startX: number;
startY: number;
draggedNodeIds: string[];
draggedRenderNodeIds: string[];
draggedRenderNodeIdSet: Set<string>;
initialSelectedNodes: Array<{ id: string; x: number; y: number }>;
};
@@ -44,6 +47,8 @@ const EMPTY_DRAG_STATE: DragState = {
startX: 0,
startY: 0,
draggedNodeIds: [],
draggedRenderNodeIds: [],
draggedRenderNodeIdSet: new Set(),
initialSelectedNodes: [],
};
@@ -75,12 +80,14 @@ export function useCanvasSelectionController({
const lastFrameDropCheckRef = useRef(0);
const selectionFrameRef = useRef<number | null>(null);
const selectionBoundsElementRef = useRef<HTMLDivElement>(null);
const selectionCandidatesRef = useRef<Array<{ id: string; left: number; top: number; right: number; bottom: number }>>([]);
const selectionSpatialIndexRef = useRef<CanvasSpatialIndex<CanvasNodeData>>(buildCanvasSpatialIndex([]));
const pendingSelectionPointRef = useRef<Position | null>(null);
const selectionActivatedRef = useRef(false);
const selectionBoxRef = useRef<SelectionBox | null>(null);
const nodeDraggingRef = useRef(false);
const dragRef = useRef<DragState>({ ...EMPTY_DRAG_STATE });
const frameDropIndexRef = useRef(buildCanvasFrameDropIndex([]));
const draggedNodesRef = useRef<CanvasNodeData[]>([]);
const [selectionBox, setSelectionBox] = useState<SelectionBox | null>(null);
const [frameDropTargetId, setFrameDropTargetId] = useState<string | null>(null);
const [isNodeDragging, setIsNodeDragging] = useState(false);
@@ -89,7 +96,6 @@ export function useCanvasSelectionController({
const cancelSelectionBox = useCallback(() => {
selectionBoxRef.current = null;
selectionCandidatesRef.current = [];
pendingSelectionPointRef.current = null;
selectionActivatedRef.current = false;
if (selectionFrameRef.current) cancelAnimationFrame(selectionFrameRef.current);
@@ -125,9 +131,16 @@ export function useCanvasSelectionController({
};
selectionBoxRef.current = nextSelectionBox;
selectionActivatedRef.current = false;
selectionCandidatesRef.current = nodesRef.current
.filter((node) => !isHiddenBatchChild(node, nodesRef.current) && !isNodeHiddenByCollapsedFrame(node, nodesRef.current))
.map((node) => ({ id: node.id, left: node.position.x, top: node.position.y, right: node.position.x + node.width, bottom: node.position.y + node.height }));
const currentNodes = nodesRef.current;
const nodeById = new Map(currentNodes.map((node) => [node.id, node]));
const hiddenBatchChildIds = new Set(currentNodes.flatMap((node) => {
const rootId = node.metadata?.batchRootId;
const root = rootId ? nodeById.get(rootId) : undefined;
return root && !root.metadata?.imageBatchExpanded ? [node.id] : [];
}));
selectionSpatialIndexRef.current = buildCanvasSpatialIndex(currentNodes
.filter((node) => !hiddenBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed))
.map((node) => ({ id: node.id, bounds: canvasNodeBounds(node), value: node })));
setSelectedConnectionId(null);
}, [cancelPendingConnectionCreate, nodesRef, onCanvasSelectionStart, screenToCanvas, selectedNodeIdsRef, setSelectedConnectionId]);
@@ -181,7 +194,11 @@ export function useCanvasSelectionController({
const initialSelectedNodes = currentNodes.filter((node) => dragIds.has(node.id) && !node.metadata?.locked).map((node) => ({ id: node.id, x: node.position.x, y: node.position.y }));
if (!initialSelectedNodes.length) return;
dragRef.current = { isDraggingNode: true, hasMoved: false, openPanelOnClick: !isMultiSelectClick, startX: event.clientX, startY: event.clientY, draggedNodeIds, initialSelectedNodes };
frameDropIndexRef.current = buildCanvasFrameDropIndex(currentNodes);
draggedNodesRef.current = currentNodes.filter((node) => dragIds.has(node.id) && !node.metadata?.locked);
const draggedRenderNodeIds = initialSelectedNodes.map((item) => item.id);
const draggedRenderNodeIdSet = new Set(draggedRenderNodeIds);
dragRef.current = { isDraggingNode: true, hasMoved: false, openPanelOnClick: !isMultiSelectClick, startX: event.clientX, startY: event.clientY, draggedNodeIds, draggedRenderNodeIds, draggedRenderNodeIdSet, initialSelectedNodes };
historyPausedRef.current = true;
nodeDraggingRef.current = true;
pendingNodeDragRef.current = { x: 0, y: 0 };
@@ -189,8 +206,9 @@ export function useCanvasSelectionController({
lastFrameDropCheckRef.current = 0;
setIsNodeDragging(true);
setAlignmentGuides({});
setDragPreview({ x: 0, y: 0, nodeIds: new Set(initialSelectedNodes.map((item) => item.id)) });
}, [historyPausedRef, nodesRef, onBatchConnectionTarget, onNodeBringToFront, onNodeClick, onNodeInteractionStart, selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
setDragPreview({ x: 0, y: 0, nodeIds: draggedRenderNodeIdSet });
applyCanvasNodeDragPreview(containerRef.current, { x: 0, y: 0, nodeIds: draggedRenderNodeIdSet });
}, [containerRef, historyPausedRef, nodesRef, onBatchConnectionTarget, onNodeBringToFront, onNodeClick, onNodeInteractionStart, selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
const finishNodeDrag = useCallback((clientX?: number, clientY?: number) => {
if (dragFrameRef.current) {
@@ -209,6 +227,7 @@ export function useCanvasSelectionController({
historyPausedRef.current = false;
nodeDraggingRef.current = false;
applyCanvasNodeDragPreview(containerRef.current, null);
setIsNodeDragging(false);
setDragPreview(null);
setAlignmentGuides({});
@@ -218,7 +237,7 @@ export function useCanvasSelectionController({
const initial = initialById.get(node.id);
return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node;
});
const targetId = findFrameDropTarget(positioned, draggedNodeIds);
const targetId = findFrameDropTargetFromIndex(frameDropIndexRef.current, draggedNodesRef.current, draggedNodeIds, { x: dx, y: dy });
const target = targetId ? positioned.find((node) => node.id === targetId) : undefined;
const linkedFolder = target?.metadata?.folder?.assetFolderId ? target : undefined;
// 素材库文件夹只建立归档关系,不把画布节点变成其本地子节点。
@@ -228,6 +247,7 @@ export function useCanvasSelectionController({
}
setFrameDropTargetId(null);
alignmentContextRef.current = null;
draggedNodesRef.current = [];
dragRef.current = { ...EMPTY_DRAG_STATE };
if (wasClick && clickedNodeId) {
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
@@ -245,23 +265,22 @@ export function useCanvasSelectionController({
const aligned = calculateNodeAlignment(alignmentContextRef.current, pendingNodeDragRef.current, 7 / viewportRef.current.k);
const latest = aligned.offset;
pendingAlignmentGuidesRef.current = aligned.guides;
const initialById = new Map(dragRef.current.initialSelectedNodes.map((item) => [item.id, item]));
const now = performance.now();
if (now - lastFrameDropCheckRef.current >= 100) {
lastFrameDropCheckRef.current = now;
const draggedNodeIds = new Set(dragRef.current.draggedNodeIds);
const positioned = nodesRef.current.map((node) => {
const initial = initialById.get(node.id);
return initial ? { ...node, position: { x: initial.x + latest.x, y: initial.y + latest.y } } : node;
});
setFrameDropTargetId(findFrameDropTarget(positioned, draggedNodeIds));
setFrameDropTargetId(findFrameDropTargetFromIndex(frameDropIndexRef.current, draggedNodesRef.current, draggedNodeIds, latest));
}
setDragPreview((current) => current ? { ...current, x: latest.x, y: latest.y } : current);
applyCanvasNodeDragPreview(containerRef.current, {
x: latest.x,
y: latest.y,
nodeIds: dragRef.current.draggedRenderNodeIdSet,
});
const nextGuides = dragRef.current.hasMoved ? pendingAlignmentGuidesRef.current : {};
setAlignmentGuides((current) => current.vertical === nextGuides.vertical && current.horizontal === nextGuides.horizontal ? current : nextGuides);
dragFrameRef.current = null;
});
}, [nodesRef, viewportRef]);
}, [containerRef, viewportRef]);
const handlePointerMove = useCallback((event: PointerEvent) => {
if (dragRef.current.isDraggingNode) {
@@ -302,8 +321,7 @@ export function useCanvasSelectionController({
selectionBoxRef.current = selection;
applyCanvasSelectionPreview(containerRef.current, selection);
const nextSelected = new Set<string>(selection.additive || selection.subtractive ? selection.initialSelectedNodeIds : []);
selectionCandidatesRef.current.forEach((node) => {
if (rectX >= node.right || rectX + rectW <= node.left || rectY >= node.bottom || rectY + rectH <= node.top) return;
selectionSpatialIndexRef.current.query({ left: rectX, top: rectY, right: rectX + Math.max(rectW, 0.01), bottom: rectY + Math.max(rectH, 0.01) }).forEach((node) => {
if (selection.subtractive) nextSelected.delete(node.id);
else nextSelected.add(node.id);
});
@@ -337,6 +355,7 @@ export function useCanvasSelectionController({
return () => {
if (dragFrameRef.current) cancelAnimationFrame(dragFrameRef.current);
if (selectionFrameRef.current) cancelAnimationFrame(selectionFrameRef.current);
applyCanvasNodeDragPreview(containerRef.current, null);
window.removeEventListener("mousemove", handleNodeDragMove);
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("pointermove", handlePointerMove);

View File

@@ -4,7 +4,7 @@ import { createBrowserRouter, Navigate, Outlet } from "react-router";
import { RequireAuth } from "@/components/auth/require-auth";
import { RequireFeature } from "@/components/auth/require-feature";
import { FullScreenLoader, WorkspaceRouteLoader } from "@/components/ui/aceternity/full-screen-loader";
import { loadAssetsPage, loadCanvasPage, loadCreatePage, loadProjectsPage, loadWalletPage } from "@/lib/workspace-route-modules";
import { loadAssetsPage, loadCanvasPage, loadCanvasProjectPage, loadCreatePage, loadProjectsPage, loadWalletPage } from "@/lib/workspace-route-modules";
import UserLayout from "@/layouts/user-layout";
import { AuthScene } from "@/pages/auth/auth-scene";
import RouteErrorPage from "@/pages/route-error";
@@ -36,10 +36,9 @@ const LoginPage = lazy(() => import("@/pages/auth/login"));
const RegisterPage = lazy(() => import("@/pages/auth/register"));
const ForgotPasswordPage = lazy(() => import("@/pages/auth/forgot-password"));
const CanvasPage = lazy(loadCanvasPage);
const CanvasProjectPage = lazy(() => import("@/pages/canvas/project"));
const CanvasProjectPage = lazy(loadCanvasProjectPage);
const SharedCanvasPage = lazy(() => import("@/pages/canvas/shared"));
const CreatePage = lazy(loadCreatePage);
const HomePage = lazy(() => import("@/pages/home"));
const NotFound = lazy(() => import("@/pages/not-found"));
const SkillsPage = lazy(() => import("@/pages/skills"));
const PluginsPage = lazy(() => import("@/pages/plugins"));
@@ -96,9 +95,7 @@ export const router = createBrowserRouter([
),
errorElement: <RouteErrorPage />,
children: [
{ path: "/", element: <Navigate to="/create" replace /> },
{ path: "/create", element: <RequireAuth>{deferred(<CreatePage />)}</RequireAuth> },
{ path: "/home", element: deferred(<HomePage />) },
{ path: "/", element: <RequireAuth>{deferred(<CreatePage />)}</RequireAuth> },
{
path: "/tasks",
element: (

View File

@@ -2,6 +2,7 @@ import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteUserDataSnapshot, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { assetForRemoteSync } from "@/lib/asset-remote-sync";
import type { Asset } from "@/stores/use-asset-store";
import { flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
@@ -184,7 +185,7 @@ async function saveRemoteUserDataBatch() {
acknowledgedProjects.set(source.id, source);
}
for (const source of dirtyAssets) {
const remotePayload = await ensureRemoteResourceReferences(source, uploaded);
const remotePayload = await ensureRemoteResourceReferences(assetForRemoteSync(source), uploaded);
await upsertRemoteAsset(remotePayload);
acknowledgedAssets.set(source.id, source);
}

View File

@@ -260,6 +260,7 @@ function pendingGenerationAttempt(scope: string, projectId: string, effectKeys?:
}
function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, durableProject: CanvasProject | undefined) {
if (!hasGenerationEffectKeys(project) && !hasGenerationEffectKeys(durableProject)) return project;
const durableNodes = new Map((durableProject?.nodes || []).map((node) => [node.id, node]));
const durableSessions = new Map((durableProject?.chatSessions || []).map((session) => [session.id, session]));
let changed = false;
@@ -288,7 +289,7 @@ function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, du
}
continue;
}
if (JSON.stringify(localKeys) === JSON.stringify(durableKeys)) {
if (sameGenerationEffectKeys(localKeys, durableKeys)) {
nodes.push(node);
continue;
}
@@ -318,7 +319,7 @@ function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, du
}
continue;
}
if (JSON.stringify(session.generationEffectKeys) === JSON.stringify(durableKeys)) {
if (sameGenerationEffectKeys(session.generationEffectKeys, durableKeys)) {
chatSessions.push(session);
continue;
}
@@ -341,6 +342,19 @@ function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, du
return changed ? { ...project, nodes, chatSessions } : project;
}
function hasGenerationEffectKeys(value: CanvasProject | undefined) {
if (!value) return false;
return value.nodes.some((node) => Boolean(node.metadata?.generationEffectKeys?.length))
|| value.chatSessions.some((session) => Boolean(session.generationEffectKeys?.length));
}
function sameGenerationEffectKeys(left?: readonly string[], right?: readonly string[]) {
if (left === right) return true;
if (!left?.length && !right?.length) return true;
if (!left || !right || left.length !== right.length) return false;
return left.every((value, index) => value === right[index]);
}
function ordinaryCanvasPersistenceState(scope: string, state: PersistedCanvasState, durableProjects: CanvasProject[]) {
const durableById = new Map(durableProjects.map((project) => [project.id, project]));
return {

View File

@@ -3860,6 +3860,8 @@
.canvas-folder-preview .canvas-project-media img { filter: saturate(.94) contrast(1.02); transition: transform 420ms var(--motion-ease-out), filter 240ms ease-out; }
.canvas-folder-card:hover .canvas-folder-preview .canvas-project-media img { filter: saturate(1) contrast(1.04); transform: scale(1.025); }
.canvas-folder-preview .canvas-project-preview-canvas { background: var(--canvas-cover-surface); }
.canvas-folder-opening { position: absolute; inset: 0; z-index: 2; display: flex; align-items: center; justify-content: center; gap: var(--space-2); background: color-mix(in srgb, var(--background) 78%, transparent); color: var(--foreground); font-size: var(--fs-caption); font-weight: 600; backdrop-filter: blur(3px); }
.canvas-folder-card.is-opening { pointer-events: none; }
.canvas-folder-body {
display: flex;
@@ -9324,6 +9326,13 @@
transition: none !important;
}
/* 平移时优先保证世界层合成,暂时关闭每个媒体卡片的滤镜和阴影绘制。 */
[data-canvas-viewport-interacting="true"] :where(.canvas-node-shell, .canvas-frame-shell, .canvas-folder-preview, .canvas-folder-front, .aceternity-comet-card) {
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
}
/* 静止期用最终倍率重新排版 DOM,避免 Windows Chromium 复用交互期的低分辨率合成纹理。 */
.canvas-world-layer {
transform: translate(var(--canvas-live-x), var(--canvas-live-y)) scale(var(--canvas-live-scale-ratio));

View File

@@ -340,6 +340,7 @@ export type CanvasNodeMetadata = {
versionLabel?: string;
versionPrimary?: boolean;
copiedFromNodeId?: string;
generationResultPlacement?: "replace-node" | "new-version";
directorSceneId?: string;
directorShotId?: string;
directorPreviewNodeId?: string;