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

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