feat(*): 创作工作台 - 优化全景打光交互、生成模式与图片缓存
This commit is contained in:
@@ -20,6 +20,7 @@ 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();
|
||||
@@ -28,17 +29,9 @@ const FALLBACK_CACHE_BYTES = 512 * 1024 * 1024;
|
||||
const MIN_CACHE_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_CACHE_ENTRIES = 500;
|
||||
const TOUCH_INTERVAL_MS = 10 * 60 * 1000;
|
||||
const BUDGET_REFRESH_MS = 5 * 60 * 1000;
|
||||
// 现代浏览器对同源 HTTP/2 连接多路复用;上限给到 16 让大画布冷启动在 1~2 轮内完成并发拉取。
|
||||
const MAX_CONCURRENT_DOWNLOADS = 16;
|
||||
const recentlyTouched = new Set<string>();
|
||||
|
||||
export function peekCachedResourceObjectUrl(storageKey: string): string {
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
if (!resourceId) return "";
|
||||
const userScope = getActiveUserScope();
|
||||
if (userScope === "guest") return "";
|
||||
const key = `${userScope}:${resourceId}:file`;
|
||||
return objectUrls.get(key) || "";
|
||||
}
|
||||
|
||||
export async function getCachedResourceObjectUrl(storageKey: string) {
|
||||
const target = await cacheTarget(storageKey);
|
||||
@@ -46,6 +39,15 @@ export async function getCachedResourceObjectUrl(storageKey: string) {
|
||||
return readCachedObjectUrl(target);
|
||||
}
|
||||
|
||||
/** 同步读取本会话内存中的 Blob URL,命中时节点首帧即可上屏,不必等 IndexedDB 异步读取。 */
|
||||
export function peekCachedResourceObjectUrl(storageKey: string) {
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
if (!resourceId) return "";
|
||||
const userScope = getActiveUserScope();
|
||||
if (userScope === "guest") return "";
|
||||
return objectUrls.get(`${userScope}:${resourceId}:file`) || "";
|
||||
}
|
||||
|
||||
export async function cacheResourceObjectUrl(storageKey: string) {
|
||||
const target = await cacheTarget(storageKey);
|
||||
if (!target) return "";
|
||||
@@ -169,6 +171,7 @@ async function persistBlob(target: ResourceCacheMeta, blob: Blob) {
|
||||
|
||||
try {
|
||||
await write();
|
||||
invalidateCacheStats();
|
||||
return;
|
||||
} catch (firstError) {
|
||||
// 第一次失败通常意味着浏览器配额不足;激进淘汰后只允许再尝试一次,
|
||||
@@ -176,6 +179,7 @@ async function persistBlob(target: ResourceCacheMeta, blob: Blob) {
|
||||
await evictFor(blob.size, target.key, true);
|
||||
try {
|
||||
await write();
|
||||
invalidateCacheStats();
|
||||
return;
|
||||
} catch (retryError) {
|
||||
const cleanupResults = await Promise.allSettled([blobStore.removeItem(target.key), metaStore.removeItem(target.key)]);
|
||||
@@ -226,11 +230,15 @@ async function cacheTarget(storageKey: string): Promise<ResourceCacheMeta | null
|
||||
}
|
||||
|
||||
async function touchCacheMeta(target: ResourceCacheMeta) {
|
||||
if (recentlyTouched.has(target.key)) return;
|
||||
recentlyTouched.add(target.key);
|
||||
const now = Date.now();
|
||||
// 同一会话内重复访问不落 IndexedDB:淘汰扫描在写入时才需要元数据,
|
||||
// 内存时间戳足够让高频读取跳过跨进程 I/O。
|
||||
const lastTouchedAt = metaTouchedAt.get(target.key);
|
||||
if (lastTouchedAt && now - lastTouchedAt < TOUCH_INTERVAL_MS) return;
|
||||
metaTouchedAt.set(target.key, now);
|
||||
const current = await metaStore.getItem<ResourceCacheMeta>(target.key);
|
||||
if (!current || Date.now() - current.lastAccessedAt < TOUCH_INTERVAL_MS) return;
|
||||
await metaStore.setItem(target.key, { ...current, lastAccessedAt: Date.now() });
|
||||
if (!current || now - current.lastAccessedAt < TOUCH_INTERVAL_MS) return;
|
||||
await metaStore.setItem(target.key, { ...current, lastAccessedAt: now });
|
||||
}
|
||||
|
||||
|
||||
@@ -243,12 +251,20 @@ function touchCacheMetaSafely(target: ResourceCacheMeta) {
|
||||
});
|
||||
}
|
||||
|
||||
// 条目数/字节数与存储配额在内存中跟踪,常规写入只做 O(1) 检查,
|
||||
// 只有接近预算才执行全量 LRU 扫描,避免每张图持久化都 iterate 全部元数据。
|
||||
let cacheStats: { count: number; bytes: number } | null = null;
|
||||
let cachedBudget: { value: number; at: number } | null = null;
|
||||
|
||||
async function evictFor(incomingBytes: number, protectedKey: string, aggressive = false) {
|
||||
const entryCount = await metaStore.length().catch(() => 0);
|
||||
const budget = aggressive ? Math.max(MIN_CACHE_BYTES, (await cacheBudget()) / 2) : await cacheBudget();
|
||||
// 只有当条目数接近上限或需要激进淘汰时,才进行全量迭代,避免写入每个文件都跑全表扫描
|
||||
if (!aggressive && entryCount < MAX_CACHE_ENTRIES - 10 && incomingBytes < budget / 10) return;
|
||||
|
||||
// 常规写入只在统计可信或明显未接近阈值时跳过全表扫描;写入会使内存统计失效,
|
||||
// 因此不会用可能过时的字节数做永久放行。
|
||||
if (!aggressive) {
|
||||
if (cacheStats && cacheStats.count < MAX_CACHE_ENTRIES && cacheStats.bytes + incomingBytes <= budget) return;
|
||||
if (!cacheStats && entryCount < MAX_CACHE_ENTRIES - 10 && incomingBytes < budget / 10) return;
|
||||
}
|
||||
const metas: ResourceCacheMeta[] = [];
|
||||
await metaStore.iterate<ResourceCacheMeta, void>((value) => {
|
||||
if (value?.key) metas.push(value);
|
||||
@@ -263,6 +279,11 @@ async function evictFor(incomingBytes: number, protectedKey: string, aggressive
|
||||
total -= Math.max(0, candidate.size || 0);
|
||||
count -= 1;
|
||||
}
|
||||
cacheStats = { count, bytes: total };
|
||||
}
|
||||
|
||||
function invalidateCacheStats() {
|
||||
cacheStats = null;
|
||||
}
|
||||
|
||||
async function removeCacheEntry(meta: ResourceCacheMeta) {
|
||||
@@ -274,10 +295,15 @@ async function removeCacheEntry(meta: ResourceCacheMeta) {
|
||||
}
|
||||
|
||||
async function cacheBudget() {
|
||||
if (!navigator.storage?.estimate) return FALLBACK_CACHE_BYTES;
|
||||
const estimate = await navigator.storage.estimate().catch(() => null);
|
||||
if (!estimate?.quota) return FALLBACK_CACHE_BYTES;
|
||||
return Math.min(MAX_CACHE_BYTES, Math.max(MIN_CACHE_BYTES, Math.floor(estimate.quota * 0.2)));
|
||||
// 配额估计是跨进程调用且会话内基本稳定,短暂缓存避免每张图一次 IPC 往返。
|
||||
if (cachedBudget && Date.now() - cachedBudget.at < BUDGET_REFRESH_MS) return cachedBudget.value;
|
||||
let value = FALLBACK_CACHE_BYTES;
|
||||
if (navigator.storage?.estimate) {
|
||||
const estimate = await navigator.storage.estimate().catch(() => null);
|
||||
if (estimate?.quota) value = Math.min(MAX_CACHE_BYTES, Math.max(MIN_CACHE_BYTES, Math.floor(estimate.quota * 0.2)));
|
||||
}
|
||||
cachedBudget = { value, at: Date.now() };
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectUrl(key: string, blob: Blob) {
|
||||
|
||||
Reference in New Issue
Block a user