fix(canvas): 资源引用 - 校验递归素材并修复断链
This commit is contained in:
@@ -18,12 +18,12 @@ const metaStore = localforage.createInstance({ name: "infinite-canvas", storeNam
|
||||
const objectUrls = new Map<string, string>();
|
||||
const sessionBlobs = new Map<string, Blob>();
|
||||
const inFlight = new Map<string, Promise<string>>();
|
||||
const scheduled = new Set<string>();
|
||||
const cacheMetaTouchWarnings = new Set<string>();
|
||||
const metaTouchedAt = new Map<string, number>();
|
||||
const downloadQueue: Array<() => void> = [];
|
||||
let activeDownloads = 0;
|
||||
let persistQueue: Promise<void> = Promise.resolve();
|
||||
let cacheGeneration = 0;
|
||||
const MAX_CACHE_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
const FALLBACK_CACHE_BYTES = 512 * 1024 * 1024;
|
||||
const MIN_CACHE_BYTES = 64 * 1024 * 1024;
|
||||
@@ -33,6 +33,18 @@ const BUDGET_REFRESH_MS = 5 * 60 * 1000;
|
||||
// 现代浏览器对同源 HTTP/2 连接多路复用;上限给到 16 让大画布冷启动在 1~2 轮内完成并发拉取。
|
||||
const MAX_CONCURRENT_DOWNLOADS = 16;
|
||||
|
||||
/** Release session-only byte resources during account/logout transitions. */
|
||||
export function clearResourceBlobCache() {
|
||||
cacheGeneration += 1;
|
||||
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
objectUrls.clear();
|
||||
sessionBlobs.clear();
|
||||
inFlight.clear();
|
||||
metaTouchedAt.clear();
|
||||
cacheMetaTouchWarnings.clear();
|
||||
cacheStats = null;
|
||||
}
|
||||
|
||||
export async function getCachedResourceObjectUrl(storageKey: string) {
|
||||
const target = await cacheTarget(storageKey);
|
||||
if (!target) return "";
|
||||
@@ -56,42 +68,25 @@ export async function cacheResourceObjectUrl(storageKey: string) {
|
||||
const pending = inFlight.get(target.key);
|
||||
if (pending) return pending;
|
||||
|
||||
const task = withDownloadSlot(() => downloadAndCacheResource(storageKey, target)).finally(() => inFlight.delete(target.key));
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放器先使用支持 Range 的资源 URL 起播;确认用户实际播放后,再延迟下载完整 Blob。
|
||||
* 这样不会让 IndexedDB 缓存阻塞首帧,同时后续打开可直接复用本地 Object URL。
|
||||
*/
|
||||
export function scheduleResourceBlobCache(storageKey: string, delayMs = 4_000) {
|
||||
if (!resourceIdFromStorageKey(storageKey) || scheduled.has(storageKey)) return;
|
||||
scheduled.add(storageKey);
|
||||
const run = () => {
|
||||
void cacheResourceObjectUrl(storageKey)
|
||||
.catch((error) => {
|
||||
// 这是播放后的后台缓存优化,不应让播放器失败;但下载/持久化异常必须可观测。
|
||||
console.warn("后台缓存资源 Blob 失败", { storageKey, error });
|
||||
return "";
|
||||
})
|
||||
.finally(() => scheduled.delete(storageKey));
|
||||
};
|
||||
if (typeof window === "undefined") {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
window.setTimeout(run, Math.max(0, delayMs));
|
||||
}
|
||||
|
||||
function withDownloadSlot<T>(task: () => Promise<T>) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
downloadQueue.push(() => {
|
||||
activeDownloads += 1;
|
||||
task().then(resolve, reject).finally(() => {
|
||||
activeDownloads -= 1;
|
||||
runDownloadQueue();
|
||||
});
|
||||
task()
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
activeDownloads -= 1;
|
||||
runDownloadQueue();
|
||||
});
|
||||
});
|
||||
runDownloadQueue();
|
||||
});
|
||||
@@ -123,12 +118,12 @@ export async function getCachedResourceBlob(storageKey: string) {
|
||||
const pending = inFlight.get(target.key);
|
||||
if (pending) {
|
||||
await pending.catch(() => "");
|
||||
const downloaded = sessionBlobs.get(target.key) || await blobStore.getItem<Blob>(target.key);
|
||||
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);
|
||||
const downloaded = sessionBlobs.get(target.key) || (await blobStore.getItem<Blob>(target.key));
|
||||
if (downloaded) return downloaded;
|
||||
return loadAndPersistResource(storageKey);
|
||||
}
|
||||
@@ -139,9 +134,9 @@ async function loadAndPersistResource(storageKey: string) {
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function downloadAndCacheResource(storageKey: string, target: ResourceCacheMeta) {
|
||||
async function downloadAndCacheResource(storageKey: string, target: ResourceCacheMeta, generation: number) {
|
||||
const blob = await downloadResourceBlob(storageKey, target);
|
||||
if (!blob) return "";
|
||||
if (!blob || generation !== cacheGeneration) return "";
|
||||
return objectUrl(target.key, blob);
|
||||
}
|
||||
|
||||
@@ -251,7 +246,6 @@ async function touchCacheMeta(target: ResourceCacheMeta) {
|
||||
await metaStore.setItem(target.key, { ...current, lastAccessedAt: now });
|
||||
}
|
||||
|
||||
|
||||
function touchCacheMetaSafely(target: ResourceCacheMeta) {
|
||||
void touchCacheMeta(target).catch((error) => {
|
||||
// 访问时间只服务于缓存淘汰,不影响资源读取;失败可降级,但不能无痕吞掉。
|
||||
|
||||
Reference in New Issue
Block a user