-
Notifications
You must be signed in to change notification settings - Fork 5.1k
refactor(web): replace unbounded preview frame buffering with bounded, disposable frame access #758
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| } | ||
|
Comment on lines
+194
to
+204
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify that evictFramesForAsset is not called in RemoveMediaAssetCommand
rg -n 'evictFramesForAsset' apps/web/src/lib/commands/media/remove-media-asset.ts
# Check all call sites of evictFramesForAsset to confirm it's only called internally
rg -n 'evictFramesForAsset' --type tsRepository: OpenCut-app/OpenCut Length of output: 173 🏁 Script executed: # Check the RemoveMediaAssetCommand implementation
cat -n apps/web/src/lib/commands/media/remove-media-asset.tsRepository: OpenCut-app/OpenCut Length of output: 3518 🏁 Script executed: # Also check if there are other places where assets are removed
rg -n 'clearVideo|evictFrames' apps/web/src/core/managers/media-manager.tsRepository: OpenCut-app/OpenCut Length of output: 129 🏁 Script executed: # Check how mediaManager is used in the codebase
rg -n 'mediaManager\.(clearVideo|evictFrames)' --type tsRepository: OpenCut-app/OpenCut Length of output: 45 🏁 Script executed: # Check where mediaManager is instantiated and used
rg -n 'mediaManager' apps/web/src/core/managers/media-manager.ts | head -20
# Find all references to mediaManager in the codebase
rg -n 'mediaManager' --type ts | head -30
# Check how frameCache is populated
rg -n 'frameCache' apps/web/src/core/managers/media-manager.ts
# Search for where frame caching happens
rg -n 'new CachedFrame\|frameCache\.push\|this\.frameCache' apps/web/src/core/managers/media-manager.tsRepository: OpenCut-app/OpenCut Length of output: 728 🏁 Script executed: # Search for mediaManager instantiation and exports
rg -n 'export.*mediaManager|new MediaManager' --type ts
# Check the EditorCore to see if mediaManager is a property
rg -n 'mediaManager' apps/web/src/core/*.ts
# Find where frames are actually being cached (cacheFrame method call)
rg -n 'cacheFrame|getFrame' --type ts | grep -v node_modules | head -30Repository: OpenCut-app/OpenCut Length of output: 1899 🏁 Script executed: # Check the getFrameAt method implementation in media-manager to see if it calls cacheFrame
sed -n '167,190p' apps/web/src/core/managers/media-manager.ts
# Search for all calls to cacheFrame
rg -n 'cacheFrame' --type ts
# Check video-node.ts to see how getFrameAt is used
sed -n '15,35p' apps/web/src/services/renderer/nodes/video-node.ts
# Check blur-background-node.ts similarly
sed -n '60,75p' apps/web/src/services/renderer/nodes/blur-background-node.tsRepository: OpenCut-app/OpenCut Length of output: 1402 🏁 Script executed: # Check the relationship between videoCache and mediaManager
cat -n apps/web/src/services/video-cache/service.ts | head -50
# See if videoCache is related to mediaManager
rg -n 'MediaManager\|frameCache' apps/web/src/services/video-cache/service.ts
# Check the imports and exports from media-manager.ts
head -30 apps/web/src/core/managers/media-manager.tsRepository: OpenCut-app/OpenCut Length of output: 2391 🏁 Script executed: # Verify how videoCache is cleared and whether it relates to MediaManager
sed -n '1,100p' apps/web/src/services/video-cache/service.ts | grep -A 10 "clearVideo"
# Check if mediaManager is accessible from RemoveMediaAssetCommand
grep -n "editor.media" apps/web/src/lib/commands/media/remove-media-asset.ts
# Verify the complete clearVideo implementation
rg -A 15 "clearVideo" apps/web/src/services/video-cache/service.tsRepository: OpenCut-app/OpenCut Length of output: 789 Call The 🤖 Prompt for AI Agents |
||
|
|
||
| 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); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Race condition allows duplicate entries and unbounded cache growth.
Concurrent calls to
getFrameAt()for the same(assetId, timeMs)can both miss the cache check, both invokedecode(), and both push toframeCache. This breaks the bounded-size guarantee and wastes decode work.Consider tracking in-flight decode operations to coalesce concurrent requests:
🛠️ Proposed fix using a pending-decode map
export class MediaManager { private assets: MediaAsset[] = []; private isLoading = false; private listeners = new Set<() => void>(); private frameCache: CachedFrame[] = []; + private pendingDecodes = new Map<string, Promise<ImageBitmap>>(); + + private frameCacheKey(assetId: string, timeMs: number): string { + return `${assetId}:${timeMs}`; + } 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 key = this.frameCacheKey(assetId, timeMs); + const pending = this.pendingDecodes.get(key); + if (pending) { + return pending; + } + - const frame = await decode(); + const decodePromise = decode().then((frame) => { + this.pendingDecodes.delete(key); + if (this.frameCache.length >= FRAME_CACHE_MAX_SIZE) { + this.evictLruFrame(); + } + this.frameCache.push({ frame, assetId, timeMs, lastAccessed: Date.now() }); + return frame; + }); + this.pendingDecodes.set(key, decodePromise); + return decodePromise; - - if (this.frameCache.length >= FRAME_CACHE_MAX_SIZE) { - this.evictLruFrame(); - } - - this.frameCache.push({ frame, assetId, timeMs, lastAccessed: Date.now() }); - return frame; }🤖 Prompt for AI Agents