const DB_NAME = "video-process-frame-store-v1"; const DB_VERSION = 1; const STORE_NAME = "sessions"; const LEGACY_CACHE_NAME = "video-process-frame-manifests-v1"; export interface CachedFrameRecord { index: number; width: number; height: number; timestampUs: number; blob: Blob; } export interface CachedFrameSession { version: 1; url: string; sourceSize: number | null; codec: string; frameCount: number; keyFrameCount: number; frames: CachedFrameRecord[]; } interface LegacyFrameThumb { index: number; src: string; width: number; height: number; timestampUs: number; } interface LegacyFrameManifest { version: 1; url: string; sourceSize: number | null; codec: string; frameCount: number; keyFrameCount: number; frames: LegacyFrameThumb[]; } export async function readCachedFrameSession(url: string): Promise { const stored = await readSessionFromIndexedDb(url); if (stored) { return stored; } const legacy = await readLegacyFrameManifest(url); if (!legacy) { return null; } try { await writeCachedFrameSession(legacy); await deleteLegacyFrameManifest(url); } catch { // Migration is best-effort. } return legacy; } export async function writeCachedFrameSession(session: CachedFrameSession): Promise { const normalized = normalizeCachedFrameSession(session); if (!normalized) { throw new Error("无效的帧缓存会话。"); } if (typeof indexedDB === "undefined") { return; } await runStoreOperation("readwrite", (store) => store.put(normalized)); } export async function clearCachedFrameSession(url: string): Promise { if (typeof indexedDB !== "undefined") { try { await runStoreOperation("readwrite", (store) => store.delete(url)); } catch { // IndexedDB is best-effort. } } await deleteLegacyFrameManifest(url); } function normalizeCachedFrameSession(value: unknown, expectedUrl?: string): CachedFrameSession | null { if (!isRecord(value)) { return null; } if (value.version !== 1 || typeof value.url !== "string") { return null; } if (expectedUrl && value.url !== expectedUrl) { return null; } if ( !isNullableNumber(value.sourceSize) || typeof value.codec !== "string" || !isNonNegativeInteger(value.frameCount) || !isNonNegativeInteger(value.keyFrameCount) || !Array.isArray(value.frames) ) { return null; } const frames = value.frames.map(normalizeCachedFrameRecord); if (frames.some((frame) => frame === null)) { return null; } const normalizedFrames = frames as CachedFrameRecord[]; if (normalizedFrames.length !== value.frameCount) { return null; } normalizedFrames.sort((left, right) => left.index - right.index); return { version: 1, url: value.url, sourceSize: value.sourceSize, codec: value.codec, frameCount: value.frameCount, keyFrameCount: value.keyFrameCount, frames: normalizedFrames, }; } function normalizeCachedFrameRecord(value: unknown): CachedFrameRecord | null { if (!isRecord(value)) { return null; } if ( !isNonNegativeInteger(value.index) || !isPositiveInteger(value.width) || !isPositiveInteger(value.height) || !isNonNegativeInteger(value.timestampUs) || !(value.blob instanceof Blob) ) { return null; } return { index: value.index, width: value.width, height: value.height, timestampUs: value.timestampUs, blob: value.blob, }; } async function readSessionFromIndexedDb(url: string): Promise { if (typeof indexedDB === "undefined") { return null; } try { const record = await runStoreOperation("readonly", (store) => store.get(url)); return normalizeCachedFrameSession(record, url); } catch { return null; } } async function readLegacyFrameManifest(url: string): Promise { if (typeof caches === "undefined") { return null; } try { const cache = await caches.open(LEGACY_CACHE_NAME); const cached = await cache.match(url); if (!cached) { return null; } const parsed = (await cached.json()) as unknown; const legacy = normalizeLegacyFrameManifest(parsed, url); if (!legacy) { return null; } const frames = await Promise.all( legacy.frames.map(async (frame) => ({ index: frame.index, width: frame.width, height: frame.height, timestampUs: frame.timestampUs, blob: dataUrlToBlob(frame.src), })), ); frames.sort((left, right) => left.index - right.index); return { version: 1, url: legacy.url, sourceSize: legacy.sourceSize, codec: legacy.codec, frameCount: legacy.frameCount, keyFrameCount: legacy.keyFrameCount, frames, }; } catch { return null; } } async function deleteLegacyFrameManifest(url: string): Promise { if (typeof caches === "undefined") { return; } try { const cache = await caches.open(LEGACY_CACHE_NAME); await cache.delete(url); } catch { // Cache Storage is best-effort. } } function normalizeLegacyFrameManifest(value: unknown, expectedUrl?: string): LegacyFrameManifest | null { if (!isRecord(value)) { return null; } if (value.version !== 1 || typeof value.url !== "string") { return null; } if (expectedUrl && value.url !== expectedUrl) { return null; } if ( !isNullableNumber(value.sourceSize) || typeof value.codec !== "string" || !isNonNegativeInteger(value.frameCount) || !isNonNegativeInteger(value.keyFrameCount) || !Array.isArray(value.frames) ) { return null; } const frames = value.frames.map((frame) => { if (!isRecord(frame)) { return null; } if ( !isNonNegativeInteger(frame.index) || typeof frame.src !== "string" || !isPositiveInteger(frame.width) || !isPositiveInteger(frame.height) || !isNonNegativeInteger(frame.timestampUs) ) { return null; } return { index: frame.index, src: frame.src, width: frame.width, height: frame.height, timestampUs: frame.timestampUs, }; }); if (frames.some((frame) => frame === null)) { return null; } const normalizedFrames = frames as LegacyFrameThumb[]; if (normalizedFrames.length !== value.frameCount) { return null; } normalizedFrames.sort((left, right) => left.index - right.index); return { version: 1, url: value.url, sourceSize: value.sourceSize, codec: value.codec, frameCount: value.frameCount, keyFrameCount: value.keyFrameCount, frames: normalizedFrames, }; } function dataUrlToBlob(dataUrl: string): Blob { const separatorIndex = dataUrl.indexOf(","); if (!dataUrl.startsWith("data:") || separatorIndex === -1) { throw new Error("无效的数据 URL。"); } const header = dataUrl.slice(5, separatorIndex); const payload = dataUrl.slice(separatorIndex + 1); const isBase64 = header.endsWith(";base64"); const mimeType = isBase64 ? header.slice(0, -7) || "application/octet-stream" : header || "application/octet-stream"; if (!isBase64) { return new Blob([decodeURIComponent(payload)], { type: mimeType }); } const binary = atob(payload); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return new Blob([bytes], { type: mimeType }); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function isNullableNumber(value: unknown): value is number | null { return value === null || typeof value === "number"; } function isNonNegativeInteger(value: unknown): value is number { return typeof value === "number" && Number.isInteger(value) && value >= 0; } function isPositiveInteger(value: unknown): value is number { return typeof value === "number" && Number.isInteger(value) && value > 0; } function openDatabase(): Promise { if (typeof indexedDB === "undefined") { return Promise.reject(new Error("当前浏览器不支持 IndexedDB。")); } return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME, { keyPath: "url" }); } }; request.onerror = () => { reject(request.error ?? new Error("打开 IndexedDB 失败。")); }; request.onsuccess = () => { const db = request.result; db.onversionchange = () => db.close(); resolve(db); }; }); } function runStoreOperation( mode: IDBTransactionMode, operation: (store: IDBObjectStore) => IDBRequest, ): Promise { return openDatabase().then( (db) => new Promise((resolve, reject) => { const transaction = db.transaction(STORE_NAME, mode); const store = transaction.objectStore(STORE_NAME); let result: T | undefined; let requestError: Error | null = null; const request = operation(store); request.onsuccess = () => { result = request.result; }; request.onerror = () => { requestError = request.error ?? new Error("IndexedDB 请求失败。"); transaction.abort(); }; transaction.oncomplete = () => { db.close(); resolve(result as T); }; transaction.onabort = () => { db.close(); reject(requestError ?? transaction.error ?? new Error("IndexedDB 事务已中止。")); }; transaction.onerror = () => { requestError = requestError ?? transaction.error ?? request.error ?? new Error("IndexedDB 事务失败。"); }; }), ); }