diff --git a/apps/web/src/core/managers/media-manager.ts b/apps/web/src/core/managers/media-manager.ts index 4add3c041..ad9f980ee 100644 --- a/apps/web/src/core/managers/media-manager.ts +++ b/apps/web/src/core/managers/media-manager.ts @@ -6,10 +6,20 @@ import { generateUUID } from "@/utils/id"; import { videoCache } from "@/services/video-cache/service"; import { BatchCommand, RemoveMediaAssetCommand } from "@/lib/commands"; +const FRAME_CACHE_MAX_SIZE = 8; + +interface CachedFrame { + frame: ImageBitmap; + assetId: string; + timeMs: number; + lastAccessed: number; +} + export class MediaManager { private assets: MediaAsset[] = []; private isLoading = false; private listeners = new Set<() => void>(); + private frameCache: CachedFrame[] = []; constructor(private editor: EditorCore) {} @@ -120,6 +130,7 @@ export class MediaManager { clearAllAssets(): void { videoCache.clearAll(); + this.evictAllFrames(); this.assets.forEach((asset) => { if (asset.url) { @@ -147,6 +158,70 @@ export class MediaManager { return this.isLoading; } + /** + * Returns a cached ImageBitmap for the given asset and time, or decodes and + * caches a new one. The caller must NOT close the returned bitmap — the cache + * owns its lifetime. Old entries are evicted (and closed) when the cache + * exceeds FRAME_CACHE_MAX_SIZE. + */ + async getFrameAt({ + assetId, + timeMs, + decode, + }: { + assetId: string; + timeMs: number; + decode: () => Promise; + }): Promise { + const existing = this.frameCache.find( + (c) => c.assetId === assetId && c.timeMs === timeMs, + ); + if (existing) { + existing.lastAccessed = Date.now(); + return existing.frame; + } + + const frame = await decode(); + + if (this.frameCache.length >= FRAME_CACHE_MAX_SIZE) { + this.evictLruFrame(); + } + + this.frameCache.push({ frame, assetId, timeMs, lastAccessed: Date.now() }); + return frame; + } + + evictFramesForAsset({ assetId }: { assetId: string }): void { + const remaining: CachedFrame[] = []; + for (const entry of this.frameCache) { + if (entry.assetId === assetId) { + entry.frame.close(); + } else { + remaining.push(entry); + } + } + this.frameCache = remaining; + } + + private evictLruFrame(): void { + if (this.frameCache.length === 0) return; + let lruIndex = 0; + for (let i = 1; i < this.frameCache.length; i++) { + if (this.frameCache[i].lastAccessed < this.frameCache[lruIndex].lastAccessed) { + lruIndex = i; + } + } + this.frameCache[lruIndex].frame.close(); + this.frameCache.splice(lruIndex, 1); + } + + private evictAllFrames(): void { + for (const entry of this.frameCache) { + entry.frame.close(); + } + this.frameCache = []; + } + subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener);