refactor(web): replace unbounded preview frame buffering with bounded, disposable frame access

Playback crash symptoms (short freeze, then browser/tab crash) strongly match an OOM pattern from preloading or retaining too many decoded video frames (`ImageBitmap`/raw RGBA buffers). The media manager should stop eager full-clip frame extraction and move to bounded caching with explicit disposal. This change should make video decoding demand-driven (current/nearby frames only), and ensure old frames are evicted and closed.

Affected files: media-manager.ts

Signed-off-by: ChinhLee <76194645+chinhkrb113@users.noreply.github.com>
This commit is contained in:
ChinhLee 2026-04-02 22:01:52 +07:00
parent 3516bd69f4
commit fe9b7122d1
1 changed files with 75 additions and 0 deletions

View File

@ -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<ImageBitmap>;
}): Promise<ImageBitmap> {
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);