From 631a85b4fecf7e8156a61969a452c227855fc997 Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:17:38 +0800 Subject: [PATCH] =?UTF-8?q?perf(canvas):=20=E5=A4=A7=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E4=B8=8E=E5=AA=92=E4=BD=93=20-=20=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E4=B8=8D=E5=8F=98=E6=97=B6=E5=A4=8D=E7=94=A8=E7=A9=BA?= =?UTF-8?q?=E9=97=B4=E7=B4=A2=E5=BC=95=EF=BC=8C=E5=90=88=E5=B9=B6=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E7=9A=84=E8=B5=84=E6=BA=90=E4=B8=8E=E5=B0=81=E9=9D=A2?= =?UTF-8?q?=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 连线索引只存端点 ID,文本更新不再重建几何。小地图一次遍历隐藏折叠帧子节点。资源 Blob 与签名刷新合并在途请求,视频封面优先复用已缓存文件。 --- web/src/components/canvas/canvas-mini-map.tsx | 4 +- web/src/lib/canvas/canvas-connection-index.ts | 45 ++++++++ web/src/lib/canvas/canvas-frame.ts | 10 ++ web/src/lib/canvas/canvas-layout-snapshot.ts | 22 ++++ .../pages/canvas/use-canvas-render-model.ts | 95 +++++++---------- web/src/services/api/resources.ts | 3 +- web/src/services/canvas-video-preview.ts | 100 ++++++++++++++++-- web/src/services/resource-blob-cache.ts | 64 +++++------ web/test/canvas-media-performance.test.ts | 23 +++- web/test/canvas-spatial-index.test.ts | 27 +++++ 10 files changed, 286 insertions(+), 107 deletions(-) create mode 100644 web/src/lib/canvas/canvas-connection-index.ts create mode 100644 web/src/lib/canvas/canvas-layout-snapshot.ts diff --git a/web/src/components/canvas/canvas-mini-map.tsx b/web/src/components/canvas/canvas-mini-map.tsx index 328181fcf..54f561b19 100644 --- a/web/src/components/canvas/canvas-mini-map.tsx +++ b/web/src/components/canvas/canvas-mini-map.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type RefObject } from "react"; import { canvasThemes } from "@/lib/canvas-theme"; -import { isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame"; +import { isFrameNode, visibleCanvasMinimapNodes } from "@/lib/canvas/canvas-frame"; import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "@/types/canvas"; @@ -26,7 +26,7 @@ export function Minimap({ nodes, viewport, viewportSize, canvasContainerRef, onV const [isDragging, setIsDragging] = useState(false); const width = MINIMAP_WIDTH; const height = MINIMAP_HEIGHT; - const displayNodes = useMemo(() => nodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, nodes)), [nodes]); + const displayNodes = useMemo(() => visibleCanvasMinimapNodes(nodes), [nodes]); const { worldBounds, scale, offset } = useMemo(() => { if (!displayNodes.length) return { worldBounds: { x: -500, y: -500, w: 1000, h: 1000 }, scale: 0.16, offset: { x: 40, y: 0 } }; let minX = Infinity; diff --git a/web/src/lib/canvas/canvas-connection-index.ts b/web/src/lib/canvas/canvas-connection-index.ts new file mode 100644 index 000000000..92e8b5092 --- /dev/null +++ b/web/src/lib/canvas/canvas-connection-index.ts @@ -0,0 +1,45 @@ +import { isFrameNode } from "@/lib/canvas/canvas-frame"; +import { buildCanvasSpatialIndex, type CanvasSpatialIndexEntry } from "@/lib/canvas/canvas-spatial-index"; +import type { CanvasConnection, CanvasNodeData } from "@/types/canvas"; + +export type CanvasIndexedConnection = { connection: CanvasConnection; fromId: string; toId: string }; + +/** Store endpoint IDs so text/task updates can use live nodes without rebuilding geometry. */ +export function buildCanvasConnectionIndex(nodes: CanvasNodeData[], connections: CanvasConnection[]) { + const nodeById = new Map(nodes.map((node) => [node.id, node])); + const entries: CanvasSpatialIndexEntry[] = []; + const connectionIdsByNodeId = new Map>(); + const collapsedBatchChild = (node: CanvasNodeData) => { + const root = node.metadata?.batchRootId ? nodeById.get(node.metadata.batchRootId) : undefined; + return root && !root.metadata?.imageBatchExpanded; + }; + const endpoint = (node: CanvasNodeData) => { + const parent = node.parentId ? nodeById.get(node.parentId) : undefined; + return parent && isFrameNode(parent) && parent.metadata?.frame?.collapsed ? parent : node; + }; + for (const connection of connections) { + const source = nodeById.get(connection.fromNodeId); + const target = nodeById.get(connection.toNodeId); + if (!source || !target || collapsedBatchChild(source) || collapsedBatchChild(target)) continue; + const from = endpoint(source), + to = endpoint(target); + if (from.id === to.id) continue; + const value = { connection, fromId: from.id, toId: to.id }; + entries.push({ + id: connection.id, + bounds: { + left: Math.min(from.position.x, to.position.x), + top: Math.min(from.position.y, to.position.y), + right: Math.max(from.position.x + from.width, to.position.x + to.width), + bottom: Math.max(from.position.y + from.height, to.position.y + to.height), + }, + value, + }); + for (const nodeId of new Set([source.id, target.id, from.id, to.id])) { + const ids = connectionIdsByNodeId.get(nodeId) || new Set(); + ids.add(connection.id); + connectionIdsByNodeId.set(nodeId, ids); + } + } + return { index: buildCanvasSpatialIndex(entries), connectionIdsByNodeId, entriesById: new Map(entries.map((entry) => [entry.id, entry.value])) }; +} diff --git a/web/src/lib/canvas/canvas-frame.ts b/web/src/lib/canvas/canvas-frame.ts index d2018a415..a72d5ccfc 100644 --- a/web/src/lib/canvas/canvas-frame.ts +++ b/web/src/lib/canvas/canvas-frame.ts @@ -46,6 +46,16 @@ export function isNodeHiddenByCollapsedFrame(node: CanvasNodeData, nodes: Canvas return Boolean(getCollapsedParentFrame(node, nodes)); } +/** Build minimap input in one pass; parent lookup must stay O(1) on large canvases. */ +export function visibleCanvasMinimapNodes(nodes: CanvasNodeData[]) { + const byId = new Map(nodes.map((node) => [node.id, node])); + const collapsedFrameIds = new Set(nodes.filter((node) => isFrameNode(node) && node.metadata?.frame?.collapsed).map((node) => node.id)); + return nodes.filter((node) => { + const parent = node.parentId ? byId.get(node.parentId) : undefined; + return !parent || !collapsedFrameIds.has(parent.id); + }); +} + export function findFrameDropTarget(nodes: CanvasNodeData[], draggedNodeIds: Set) { const dragged = nodes.filter((node) => draggedNodeIds.has(node.id)); if (!dragged.length) return null; diff --git a/web/src/lib/canvas/canvas-layout-snapshot.ts b/web/src/lib/canvas/canvas-layout-snapshot.ts new file mode 100644 index 000000000..83654a3f8 --- /dev/null +++ b/web/src/lib/canvas/canvas-layout-snapshot.ts @@ -0,0 +1,22 @@ +import type { CanvasNodeData } from "@/types/canvas"; + +/** Only for geometry/topology consumers; never use this snapshot as node content. */ +export function retainCanvasLayoutNodes(previous: CanvasNodeData[], nodes: CanvasNodeData[]) { + return previous === nodes || (previous.length === nodes.length && nodes.every((node, index) => sameLayout(node, previous[index]))) ? previous : nodes; +} + +function sameLayout(a: CanvasNodeData, b: CanvasNodeData) { + return ( + a === b || + (a.id === b.id && + a.type === b.type && + a.parentId === b.parentId && + a.position.x === b.position.x && + a.position.y === b.position.y && + a.width === b.width && + a.height === b.height && + a.metadata?.batchRootId === b.metadata?.batchRootId && + a.metadata?.imageBatchExpanded === b.metadata?.imageBatchExpanded && + a.metadata?.frame?.collapsed === b.metadata?.frame?.collapsed) + ); +} diff --git a/web/src/pages/canvas/use-canvas-render-model.ts b/web/src/pages/canvas/use-canvas-render-model.ts index 4e8a9adea..14c76dc55 100644 --- a/web/src/pages/canvas/use-canvas-render-model.ts +++ b/web/src/pages/canvas/use-canvas-render-model.ts @@ -6,14 +6,16 @@ import { sameNodeSemanticData } from "@/lib/canvas/canvas-project-domain"; import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CONNECTIONS, shouldReduceCanvasMediaEffects } from "@/lib/canvas/canvas-performance-mode"; import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences, buildToolMentionReference, parseToolMentionTokens } 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 { buildCanvasConnectionIndex, type CanvasIndexedConnection } from "@/lib/canvas/canvas-connection-index"; +import { retainCanvasLayoutNodes } from "@/lib/canvas/canvas-layout-snapshot"; +import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex } from "@/lib/canvas/canvas-spatial-index"; import { canvasOverviewMode, resolveCanvasNodeLOD, type CanvasNodeRenderLOD } from "@/lib/canvas/canvas-node-lod"; import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract"; import { intersectsCanvasBounds, selectCanvasVisibleNodes } from "@/lib/canvas/canvas-node-visibility"; import type { Skill } from "@/services/api/skills"; import type { Asset, ImageAsset } from "@/stores/use-asset-store"; import type { PrevisScene } from "@/types/previs"; -import { CanvasNodeType, type CanvasConnection, type CanvasDisplayConnection, type CanvasMediaPerformanceMode, type CanvasNodeData, type ContextMenuState, type ViewportTransform } from "@/types/canvas"; +import { CanvasNodeType, type CanvasConnection, type CanvasMediaPerformanceMode, type CanvasNodeData, type ContextMenuState, type ViewportTransform } from "@/types/canvas"; type DragPreview = { x: number; y: number; nodeIds: Set } | null; @@ -90,9 +92,10 @@ export function useCanvasRenderModel({ const renderHiddenNodeIds = new Set(); const frameChildrenById = new Map(); const canvasImageNodes: CanvasNodeData[] = []; - const batchRoots: CanvasNodeData[] = []; + const batchChildNodes: CanvasNodeData[] = []; const batchMotionById = new Map(); const batchChildIndexByRootId = new Map>(); + const batchChildCountById = new Map(); for (const node of nodes) { const rootId = node.metadata?.batchRootId; @@ -103,6 +106,12 @@ export function useCanvasRenderModel({ } if (rootId && collapsingBatchIds.has(rootId)) renderHiddenNodeIds.delete(node.id); + if (rootId) batchChildNodes.push(node); + const childIndex = root?.metadata?.isBatchRoot + ? batchChildIndexByRootId.get(root.id) || new Map((root.metadata.batchChildIds || []).map((childId, index) => [childId, index])) + : undefined; + if (root?.metadata?.isBatchRoot && childIndex && !batchChildIndexByRootId.has(root.id)) batchChildIndexByRootId.set(root.id, childIndex); + if (root && childIndex?.has(node.id)) batchChildCountById.set(root.id, (batchChildCountById.get(root.id) || 0) + 1); const parent = node.parentId ? nodeById.get(node.parentId) : undefined; if (parent && isFrameNode(parent)) { const children = frameChildrenById.get(parent.id); @@ -111,17 +120,16 @@ export function useCanvasRenderModel({ if (parent.metadata?.frame?.collapsed) renderHiddenNodeIds.add(node.id); } - if (node.metadata?.isBatchRoot) batchRoots.push(node); + if (node.metadata?.isBatchRoot) { + if (!batchChildIndexByRootId.has(node.id)) batchChildIndexByRootId.set(node.id, new Map((node.metadata.batchChildIds || []).map((childId, index) => [childId, index]))); + if (!batchChildCountById.has(node.id)) batchChildCountById.set(node.id, 0); + } 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) { + for (const node of batchChildNodes) { const rootId = node.metadata?.batchRootId; if (!rootId) continue; const root = nodeById.get(rootId); @@ -131,13 +139,6 @@ export function useCanvasRenderModel({ batchMotionById.set(node.id, { x: stackX - node.position.x, y: stackY - node.position.y, index: Math.max(index, 0) }); } - const batchChildCountById = new Map(); - 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; @@ -162,21 +163,20 @@ export function useCanvasRenderModel({ 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 layoutNodesRef = useRef(nodes); + const layoutNodes = useMemo(() => { + const retained = retainCanvasLayoutNodes(layoutNodesRef.current, nodes); + layoutNodesRef.current = retained; + return retained; + }, [nodes]); const nodeSpatialIndexRef = useRef<{ source: CanvasNodeData[]; index: CanvasSpatialIndex } | 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 }; + if (previous?.source === layoutNodes) return previous.index; + const index = buildCanvasSpatialIndex(layoutNodes.map((node) => ({ id: node.id, bounds: canvasNodeBounds(node), value: node.id }))); + nodeSpatialIndexRef.current = { source: layoutNodes, index }; return index; - }, [nodes]); + }, [layoutNodes]); const renderedNodeIdsRef = useRef>(new Set()); const forcedRenderNodeIds = useMemo(() => new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])]), [dragPreview, selectedNodeIds]); const candidateNodes = useMemo(() => { @@ -328,35 +328,9 @@ export function useCanvasRenderModel({ }); return { nodeIds, connectionIds }; }, [activeNodeId, connections]); - const connectionSpatialIndex = useMemo(() => { - const entries: CanvasSpatialIndexEntry[] = []; - const connectionIdsByNodeId = new Map>(); - 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(); - 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 connectionSpatialIndex = useMemo(() => buildCanvasConnectionIndex(layoutNodes, connections), [connections, layoutNodes]); const displayConnections = useMemo(() => { - const candidateById = new Map(); + const candidateById = new Map(); 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) => { @@ -364,9 +338,12 @@ export function useCanvasRenderModel({ 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; + return [...candidateById.values()].flatMap(({ connection, fromId, toId }) => { + const sourceFrom = nodeById.get(fromId); + const sourceTo = nodeById.get(toId); + if (!sourceFrom || !sourceTo) return []; + const from = dragPreview?.nodeIds.has(fromId) ? { ...sourceFrom, position: { x: sourceFrom.position.x + dragPreview.x, y: sourceFrom.position.y + dragPreview.y } } : sourceFrom; + const to = dragPreview?.nodeIds.has(toId) ? { ...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); @@ -374,7 +351,7 @@ export function useCanvasRenderModel({ 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]); + }, [connectionSpatialIndex, dragPreview, nodeById, renderBounds]); const configInputsById = useMemo(() => { const map = new Map(); diff --git a/web/src/services/api/resources.ts b/web/src/services/api/resources.ts index 013cff4f6..3a713b135 100644 --- a/web/src/services/api/resources.ts +++ b/web/src/services/api/resources.ts @@ -387,7 +387,8 @@ export async function getResourceAccess(storageKey: string | undefined, purpose: const forceRefresh = fetchOptions.forceRefresh === true; if (forceRefresh) { accessCache.delete(key); - accessRequests.delete(key); + const inFlightRefresh = accessRequests.get(key); + if (inFlightRefresh) return inFlightRefresh; if (purpose === "display") { try { await localForageStorageForScope(scope).removeItem(persistedDisplayAccessKey(id, variant)); diff --git a/web/src/services/canvas-video-preview.ts b/web/src/services/canvas-video-preview.ts index dd7f687f2..71518cbb6 100644 --- a/web/src/services/canvas-video-preview.ts +++ b/web/src/services/canvas-video-preview.ts @@ -1,9 +1,20 @@ +import { getActiveUserScope } from "@/lib/user-scope"; import { captureVideoPoster } from "@/lib/video-poster"; +import { getCachedResourceObjectUrl, peekCachedResourceObjectUrl } from "@/services/resource-blob-cache"; import { resolveVideoMediaUrl } from "@/services/file-storage"; import { uploadImage } from "@/services/image-storage"; import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas"; type VideoPreview = NonNullable; +type HydratedPoster = { poster: Blob; persisted: Promise }; +type CanvasVideoHydrationEntry = { + promise: Promise; + controller: AbortController; + subscribers: number; + settled: boolean; +}; + +const canvasVideoHydrationBySource = new Map(); export type HydratedCanvasVideoPreview = { /** 页面先用这个 object URL 渲染首帧,不等待 OSS 预览图上传。 */ @@ -15,21 +26,71 @@ export type HydratedCanvasVideoPreview = { export function hydrateCanvasVideoPreview(node: CanvasNodeData, signal?: AbortSignal) { const sourceKey = node.metadata?.storageKey || node.metadata?.content || ""; if (!sourceKey) return Promise.resolve(null); - return generateCanvasVideoPreview(node, signal).catch((error: unknown) => { - if (error instanceof DOMException && error.name === "AbortError") throw error; - return null; - }); + const key = `${getActiveUserScope()}:${sourceKey}`; + let entry = canvasVideoHydrationBySource.get(key); + if (!entry) { + const controller = new AbortController(); + entry = { promise: Promise.resolve(null), controller, subscribers: 0, settled: false }; + const request = generateCanvasVideoPreview(node, controller.signal).then( + (result) => { + entry!.settled = true; + return result; + }, + (error) => { + entry!.settled = true; + throw error; + }, + ); + entry.promise = request; + canvasVideoHydrationBySource.set(key, entry); + void request.then( + (result) => { + if (result) return result.persisted.finally(() => clearCanvasVideoHydration(key, entry!)); + clearCanvasVideoHydration(key, entry!); + return null; + }, + () => { + clearCanvasVideoHydration(key, entry!); + return null; + }, + ); + } + entry.subscribers += 1; + const shared = entry; + return withAbort(shared.promise, signal) + .then((result) => { + if (!result) return null; + const localUrl = URL.createObjectURL(result.poster); + return { localUrl, persisted: result.persisted }; + }) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") throw error; + return null; + }) + .finally(() => releaseCanvasVideoHydration(key, shared)); } -async function generateCanvasVideoPreview(node: CanvasNodeData, signal?: AbortSignal): Promise { +function releaseCanvasVideoHydration(key: string, entry: CanvasVideoHydrationEntry) { + entry.subscribers = Math.max(0, entry.subscribers - 1); + if (entry.subscribers || entry.settled || canvasVideoHydrationBySource.get(key) !== entry) return; + entry.controller.abort(); + canvasVideoHydrationBySource.delete(key); +} + +function clearCanvasVideoHydration(key: string, entry: CanvasVideoHydrationEntry) { + if (canvasVideoHydrationBySource.get(key) === entry) canvasVideoHydrationBySource.delete(key); +} + +async function generateCanvasVideoPreview(node: CanvasNodeData, signal?: AbortSignal): Promise { await waitForBrowserIdle(signal); throwIfAborted(signal); - const source = await resolveVideoMediaUrl(node.metadata?.storageKey, node.metadata?.content || ""); + const storageKey = node.metadata?.storageKey; + const cachedSource = storageKey ? peekCachedResourceObjectUrl(storageKey) || (await getCachedResourceObjectUrl(storageKey).catch(() => "")) : ""; + const source = cachedSource || (await resolveVideoMediaUrl(node.metadata?.storageKey, node.metadata?.content || "")); if (!source) return null; const captured = await captureVideoPoster(source, { signal, maxWidth: 400 }); throwIfAborted(signal); if (!captured.poster) return null; - const localUrl = URL.createObjectURL(captured.poster); const persisted = uploadImage(captured.poster) .then((preview) => ({ content: preview.url, @@ -44,7 +105,7 @@ async function generateCanvasVideoPreview(node: CanvasNodeData, signal?: AbortSi console.warn("视频首帧持久化失败,保留本地首帧", { nodeId: node.id, error }); return null; }); - return { localUrl, persisted }; + return { poster: captured.poster, persisted }; } function waitForBrowserIdle(signal?: AbortSignal) { @@ -78,6 +139,29 @@ function waitForBrowserIdle(signal?: AbortSignal) { }); } +function withAbort(promise: Promise, signal?: AbortSignal) { + if (!signal) return promise; + if (signal.aborted) return Promise.reject(abortError()); + return new Promise((resolve, reject) => { + const handleAbort = () => { + cleanup(); + reject(abortError()); + }; + const cleanup = () => signal.removeEventListener("abort", handleAbort); + signal.addEventListener("abort", handleAbort, { once: true }); + promise.then( + (value) => { + cleanup(); + resolve(value); + }, + (error) => { + cleanup(); + reject(error); + }, + ); + }); +} + function throwIfAborted(signal?: AbortSignal) { if (signal?.aborted) throw abortError(); } diff --git a/web/src/services/resource-blob-cache.ts b/web/src/services/resource-blob-cache.ts index ceaa32c45..776ce5049 100644 --- a/web/src/services/resource-blob-cache.ts +++ b/web/src/services/resource-blob-cache.ts @@ -17,7 +17,7 @@ const blobStore = localforage.createInstance({ name: "yingce", storeName: "resou const metaStore = localforage.createInstance({ name: "yingce", storeName: "resource_blob_meta" }); const objectUrls = new Map(); const sessionBlobs = new Map(); -const inFlight = new Map>(); +const blobInFlight = new Map>(); const cacheMetaTouchWarnings = new Set(); const metaTouchedAt = new Map(); const downloadQueue: Array<() => void> = []; @@ -39,7 +39,7 @@ export function clearResourceBlobCache() { objectUrls.forEach((url) => URL.revokeObjectURL(url)); objectUrls.clear(); sessionBlobs.clear(); - inFlight.clear(); + blobInFlight.clear(); metaTouchedAt.clear(); cacheMetaTouchWarnings.clear(); cacheStats = null; @@ -65,16 +65,9 @@ export async function cacheResourceObjectUrl(storageKey: string) { if (!target) return ""; const cached = await readCachedObjectUrl(target); if (cached) return cached; - const pending = inFlight.get(target.key); - if (pending) return pending; - const generation = cacheGeneration; - let task: Promise; - task = withDownloadSlot(() => downloadAndCacheResource(storageKey, target, generation)).finally(() => { - if (inFlight.get(target.key) === task) inFlight.delete(target.key); - }); - inFlight.set(target.key, task); - return task; + const blob = await getOrDownloadResourceBlob(storageKey, target); + return blob ? objectUrl(target.key, blob) : ""; } function withDownloadSlot(task: () => Promise) { @@ -108,41 +101,36 @@ export async function primeResourceBlobCache(storageKey: string, blob: Blob) { export async function getCachedResourceBlob(storageKey: string) { const target = await cacheTarget(storageKey); if (!target) return null; + const sessionBlob = sessionBlobs.get(target.key); + if (sessionBlob) return sessionBlob; const cached = await blobStore.getItem(target.key); if (cached) { + sessionBlobs.set(target.key, cached); touchCacheMetaSafely(target); return cached; } - const sessionBlob = sessionBlobs.get(target.key); - if (sessionBlob) return sessionBlob; - const pending = inFlight.get(target.key); - if (pending) { - await pending.catch(() => ""); - const downloaded = sessionBlobs.get(target.key) || (await blobStore.getItem(target.key)); - if (downloaded) return downloaded; - return loadAndPersistResource(storageKey); - } - await cacheResourceObjectUrl(storageKey).catch(() => ""); - const downloaded = sessionBlobs.get(target.key) || (await blobStore.getItem(target.key)); - if (downloaded) return downloaded; - return loadAndPersistResource(storageKey); -} - -async function loadAndPersistResource(storageKey: string) { - const blob = await getResourceBlob(storageKey); - if (blob) await primeResourceBlobCache(storageKey, blob).catch(() => ""); + const blob = await getOrDownloadResourceBlob(storageKey, target); + if (blob) objectUrl(target.key, blob); return blob; } -async function downloadAndCacheResource(storageKey: string, target: ResourceCacheMeta, generation: number) { - const blob = await downloadResourceBlob(storageKey, target); - if (!blob || generation !== cacheGeneration) return ""; - return objectUrl(target.key, blob); +async function getOrDownloadResourceBlob(storageKey: string, target: ResourceCacheMeta) { + const sessionBlob = sessionBlobs.get(target.key); + if (sessionBlob) return sessionBlob; + const pending = blobInFlight.get(target.key); + if (pending) return pending; + + const generation = cacheGeneration; + const task = withDownloadSlot(() => downloadResourceBlob(storageKey, target, generation)).finally(() => { + if (blobInFlight.get(target.key) === task) blobInFlight.delete(target.key); + }); + blobInFlight.set(target.key, task); + return task; } -async function downloadResourceBlob(storageKey: string, target: ResourceCacheMeta) { +async function downloadResourceBlob(storageKey: string, target: ResourceCacheMeta, generation: number) { const blob = await getResourceBlob(storageKey); - if (!blob) return null; + if (!blob || generation !== cacheGeneration) return null; sessionBlobs.set(target.key, blob); if (blob.size <= MAX_CACHE_BYTES) await enqueuePersist(target, blob); return blob; @@ -209,8 +197,14 @@ async function readCachedObjectUrl(target: ResourceCacheMeta) { touchCacheMetaSafely(target); return existing; } + const sessionBlob = sessionBlobs.get(target.key); + if (sessionBlob) { + touchCacheMetaSafely(target); + return objectUrl(target.key, sessionBlob); + } const blob = await blobStore.getItem(target.key); if (!blob) return ""; + sessionBlobs.set(target.key, blob); touchCacheMetaSafely(target); return objectUrl(target.key, blob); } diff --git a/web/test/canvas-media-performance.test.ts b/web/test/canvas-media-performance.test.ts index 6017bddf7..6e35562a6 100644 --- a/web/test/canvas-media-performance.test.ts +++ b/web/test/canvas-media-performance.test.ts @@ -27,6 +27,8 @@ const workflowProductionSource = readFileSync(resolve(import.meta.dir, "../src/p const videoPlayerSource = readFileSync(resolve(import.meta.dir, "../src/components/video-player.tsx"), "utf8"); const canvasProjectSource = moduleGroupSource("pages/canvas/project.tsx"); const globalStylesSource = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8"); +const resourceBlobCacheSource = readFileSync(resolve(import.meta.dir, "../src/services/resource-blob-cache.ts"), "utf8"); +const resourceApiSource = readFileSync(resolve(import.meta.dir, "../src/services/api/resources.ts"), "utf8"); function node(id: string, type: CanvasNodeType): CanvasNodeData { return { id, type, title: id, position: { x: 0, y: 0 }, width: 320, height: 180, metadata: {} }; @@ -122,9 +124,26 @@ describe("large canvas media rendering", () => { expect(workflowProductionSource).not.toContain("await response.blob()"); }); + test("merges concurrent full Blob consumers before starting the download", () => { + expect(resourceBlobCacheSource).toContain("const blobInFlight = new Map>();"); + expect(resourceBlobCacheSource).toContain("const pending = blobInFlight.get(target.key);"); + expect(resourceBlobCacheSource).toContain("return pending;"); + expect(resourceBlobCacheSource).toContain("getOrDownloadResourceBlob(storageKey, target)"); + }); + + test("coalesces forced access refreshes already in flight", () => { + expect(resourceApiSource).toContain("const inFlightRefresh = accessRequests.get(key);"); + expect(resourceApiSource).toContain("if (inFlightRefresh) return inFlightRefresh;"); + }); + + test("reuses a cached full video Blob for poster capture before requesting playback access", () => { + expect(canvasVideoPreviewSource).toContain("peekCachedResourceObjectUrl(storageKey)"); + expect(canvasVideoPreviewSource).toContain("getCachedResourceObjectUrl(storageKey)"); + }); + test("shows a captured first frame before its OSS poster upload finishes and allows later retries", () => { - expect(canvasVideoPreviewSource).toContain("const localUrl = URL.createObjectURL(captured.poster)"); - expect(canvasVideoPreviewSource).toContain("return { localUrl, persisted }"); + expect(canvasVideoPreviewSource).toContain("const localUrl = URL.createObjectURL(result.poster)"); + expect(canvasVideoPreviewSource).toContain("persisted: result.persisted"); expect(canvasVideoPreviewSource).not.toContain("previewRequests"); }); }); diff --git a/web/test/canvas-spatial-index.test.ts b/web/test/canvas-spatial-index.test.ts index 5d9c5423a..1a584e5ff 100644 --- a/web/test/canvas-spatial-index.test.ts +++ b/web/test/canvas-spatial-index.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "bun:test"; import { buildCanvasSpatialIndex, canvasNodeBounds } from "@/lib/canvas/canvas-spatial-index"; +import { buildCanvasConnectionIndex } from "@/lib/canvas/canvas-connection-index"; +import { visibleCanvasMinimapNodes } from "@/lib/canvas/canvas-frame"; +import { retainCanvasLayoutNodes } from "@/lib/canvas/canvas-layout-snapshot"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; describe("canvas spatial index", () => { @@ -49,6 +52,30 @@ describe("canvas spatial index", () => { expect(index.query({ left: 0, top: 0, right: 100, bottom: 100 }, 3)).toEqual([0, 1, 2]); }); + test("filters collapsed frame children with one parent index", () => { + const frame: CanvasNodeData = { id: "frame", type: CanvasNodeType.Frame, title: "框", position: { x: 0, y: 0 }, width: 400, height: 300, metadata: { frame: { collapsed: true } } }; + const child: CanvasNodeData = { id: "child", type: CanvasNodeType.Image, title: "子节点", parentId: frame.id, position: { x: 20, y: 20 }, width: 120, height: 80, metadata: {} }; + const visible = visibleCanvasMinimapNodes([frame, child]); + expect(visible.map((node) => node.id)).toEqual([frame.id]); + }); + + test("reuses the layout snapshot for metadata-only updates", () => { + const original: CanvasNodeData = { id: "node", type: CanvasNodeType.Image, title: "图", position: { x: 0, y: 0 }, width: 120, height: 80, metadata: {} }; + const metadataUpdate = { ...original, metadata: { status: "success" as const } }; + const geometryUpdate = { ...metadataUpdate, position: { x: 40, y: 0 } }; + const previous = [original]; + expect(retainCanvasLayoutNodes(previous, [metadataUpdate])).toBe(previous); + expect(retainCanvasLayoutNodes(previous, [geometryUpdate])).not.toBe(previous); + }); + + test("keeps connection index geometry independent from node metadata", () => { + const from: CanvasNodeData = { id: "from", type: CanvasNodeType.Text, title: "起点", position: { x: 0, y: 0 }, width: 100, height: 80, metadata: {} }; + const to: CanvasNodeData = { id: "to", type: CanvasNodeType.Text, title: "终点", position: { x: 300, y: 0 }, width: 100, height: 80, metadata: {} }; + const index = buildCanvasConnectionIndex([from, to], [{ id: "edge", fromNodeId: from.id, toNodeId: to.id }]); + expect(index.index.query({ left: -1, top: -1, right: 500, bottom: 100 }).map((item) => item.connection.id)).toEqual(["edge"]); + expect(index.entriesById.get("edge")).toMatchObject({ fromId: "from", toId: "to" }); + }); + test("keeps the target 50k canvas mix query-bounded", () => { const nodes = buildLargeCanvasFixture(); const counts = nodes.reduce>((result, node) => {