diff --git a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx
index 47cea0404..2a55a75d9 100644
--- a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx
+++ b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx
@@ -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
;
+ connectionIdsByNodeId: Map>;
+ dragPreview: CanvasNodeDragPreview | null;
+ dragPreviewConnectionIds: Set;
+};
+
+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();
+ 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 | 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 | 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();
+ 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);
diff --git a/web/src/components/canvas/canvas-node-action-context.ts b/web/src/components/canvas/canvas-node-action-context.ts
index 8029ac512..0e6174b11 100644
--- a/web/src/components/canvas/canvas-node-action-context.ts
+++ b/web/src/components/canvas/canvas-node-action-context.ts
@@ -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;
/** 打开节点级肖像排查工作台;任务生命周期由画布页面持有。 */
diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx
index ce9e09955..f3f268977 100644
--- a/web/src/components/canvas/canvas-node-content.tsx
+++ b/web/src/components/canvas/canvas-node-content.tsx
@@ -419,7 +419,7 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded
function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false, hydrateMediaPreview = false }: CanvasNodeContentProps) {
const playerBoxRef = useRef(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 } label="空视频节点" color={theme.node.placeholder} />;
if (!mediaActive) return ;
@@ -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 (
- {url ?

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"}`} /> :
{loading ? : }
}
+ {url ?

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"}`} /> :
{loading ? : }
}
);
@@ -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 };
}
diff --git a/web/src/components/canvas/canvas-project-card.tsx b/web/src/components/canvas/canvas-project-card.tsx
index 87a70d28a..a37cf992c 100644
--- a/web/src/components/canvas/canvas-project-card.tsx
+++ b/web/src/components/canvas/canvas-project-card.tsx
@@ -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();
+
export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; onClick: () => void }) {
return