perf(canvas): 大画布渲染与媒体 - 布局不变时复用空间索引,合并重复的资源与封面请求

连线索引只存端点 ID,文本更新不再重建几何。小地图一次遍历隐藏折叠帧子节点。资源 Blob 与签名刷新合并在途请求,视频封面优先复用已缓存文件。
This commit is contained in:
ddcat666
2026-10-09 17:17:38 +08:00
parent b7416b8346
commit 631a85b4fe
10 changed files with 286 additions and 107 deletions

View File

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

View File

@@ -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<CanvasIndexedConnection>[] = [];
const connectionIdsByNodeId = new Map<string, Set<string>>();
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<string>();
ids.add(connection.id);
connectionIdsByNodeId.set(nodeId, ids);
}
}
return { index: buildCanvasSpatialIndex(entries), connectionIdsByNodeId, entriesById: new Map(entries.map((entry) => [entry.id, entry.value])) };
}

View File

@@ -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<string>) {
const dragged = nodes.filter((node) => draggedNodeIds.has(node.id));
if (!dragged.length) return null;

View File

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

View File

@@ -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<string> } | null;
@@ -90,9 +92,10 @@ export function useCanvasRenderModel({
const renderHiddenNodeIds = new Set<string>();
const frameChildrenById = new Map<string, CanvasNodeData[]>();
const canvasImageNodes: CanvasNodeData[] = [];
const batchRoots: CanvasNodeData[] = [];
const batchChildNodes: CanvasNodeData[] = [];
const batchMotionById = new Map<string, { x: number; y: number; index: number }>();
const batchChildIndexByRootId = new Map<string, Map<string, number>>();
const batchChildCountById = new Map<string, number>();
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<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;
@@ -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<CanvasNodeData[]>(nodes);
const layoutNodes = useMemo(() => {
const retained = retainCanvasLayoutNodes(layoutNodesRef.current, nodes);
layoutNodesRef.current = retained;
return retained;
}, [nodes]);
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 };
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<Set<string>>(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<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 connectionSpatialIndex = useMemo(() => buildCanvasConnectionIndex(layoutNodes, connections), [connections, layoutNodes]);
const displayConnections = useMemo(() => {
const candidateById = new Map<string, CanvasDisplayConnection>();
const candidateById = new Map<string, CanvasIndexedConnection>();
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<string, NodeGenerationInput[]>();

View File

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

View File

@@ -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<CanvasNodeMetadata["videoPreview"]>;
type HydratedPoster = { poster: Blob; persisted: Promise<VideoPreview | null> };
type CanvasVideoHydrationEntry = {
promise: Promise<HydratedPoster | null>;
controller: AbortController;
subscribers: number;
settled: boolean;
};
const canvasVideoHydrationBySource = new Map<string, CanvasVideoHydrationEntry>();
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<HydratedCanvasVideoPreview | null> {
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<HydratedPoster | null> {
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<T>(promise: Promise<T>, signal?: AbortSignal) {
if (!signal) return promise;
if (signal.aborted) return Promise.reject(abortError());
return new Promise<T>((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();
}

View File

@@ -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<string, string>();
const sessionBlobs = new Map<string, Blob>();
const inFlight = new Map<string, Promise<string>>();
const blobInFlight = new Map<string, Promise<Blob | null>>();
const cacheMetaTouchWarnings = new Set<string>();
const metaTouchedAt = new Map<string, number>();
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<string>;
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<T>(task: () => Promise<T>) {
@@ -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<Blob>(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<Blob>(target.key));
if (downloaded) return downloaded;
return loadAndPersistResource(storageKey);
}
await cacheResourceObjectUrl(storageKey).catch(() => "");
const downloaded = sessionBlobs.get(target.key) || (await blobStore.getItem<Blob>(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<Blob>(target.key);
if (!blob) return "";
sessionBlobs.set(target.key, blob);
touchCacheMetaSafely(target);
return objectUrl(target.key, blob);
}

View File

@@ -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<string, Promise<Blob | null>>();");
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");
});
});

View File

@@ -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<Record<string, number>>((result, node) => {