feat: add frame caching demo documentation
This commit is contained in:
400
web/lib/frame-store.ts
Normal file
400
web/lib/frame-store.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
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<CachedFrameSession | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<CachedFrameSession | null> {
|
||||
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<CachedFrameSession | null> {
|
||||
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<void> {
|
||||
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<string, unknown> {
|
||||
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<IDBDatabase> {
|
||||
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<T>(
|
||||
mode: IDBTransactionMode,
|
||||
operation: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
return openDatabase().then(
|
||||
(db) =>
|
||||
new Promise<T>((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 事务失败。");
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user