feat(*): 创作工作台 - 优化全景打光交互、生成模式与图片缓存

This commit is contained in:
ddCat
2026-09-12 00:12:13 +08:00
parent 2b25d15f9c
commit b413870d04
14 changed files with 546 additions and 658 deletions

View File

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