perf(canvas): 大画布渲染与媒体 - 布局不变时复用空间索引,合并重复的资源与封面请求
连线索引只存端点 ID,文本更新不再重建几何。小地图一次遍历隐藏折叠帧子节点。资源 Blob 与签名刷新合并在途请求,视频封面优先复用已缓存文件。
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user