Skip to content

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

Open
chinhkrb113 wants to merge 1 commit intoOpenCut-app:mainfrom
chinhkrb113:improve/quality/replace-unbounded-preview-frame-bufferin
Open

refactor(web): replace unbounded preview frame buffering with bounded, disposable frame access#758
chinhkrb113 wants to merge 1 commit intoOpenCut-app:mainfrom
chinhkrb113:improve/quality/replace-unbounded-preview-frame-bufferin

Conversation

@chinhkrb113
Copy link
Copy Markdown

@chinhkrb113 chinhkrb113 commented Apr 2, 2026

Code Quality

Problem

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.

Severity: critical
File: apps/web/src/core/managers/media-manager.ts

Solution

Introduce a frame-provider API in MediaManager that:

Changes

  • apps/web/src/core/managers/media-manager.ts (modified)

⚠️ READ BEFORE SUBMITTING ⚠️

We are not currently accepting PRs except for critical bugs.

If this is a bug fix:

  • I've opened an issue first
  • This was approved by a maintainer

If this is a feature:

This PR will be closed. Please open an issue to discuss first.Contributed by Lê Thành Chỉnh
Code is a tool. Mindset is the real value.

Closes #663

Summary by CodeRabbit

  • New Features

    • Implemented frame caching system for media assets to enhance video playback performance and responsiveness.
    • Added automatic memory optimization that intelligently manages and clears cached media frames.
  • Performance Improvements

    • Improved overall media handling efficiency through optimized frame caching and resource cleanup.

…, 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>
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 2, 2026

@chinhkrb113 is attempting to deploy a commit to the OpenCut OSS Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 2, 2026

📝 Walkthrough

Walkthrough

Added an in-memory LRU frame cache to MediaManager with a maximum capacity of 8 cached ImageBitmap frames. Introduced getFrameAt() method to retrieve cached frames or decode new ones, and evictFramesForAsset() method to clear frames for specific assets. Extended clearAllAssets() to close and clear all cached frames alongside existing cache cleanup.

Changes

Cohort / File(s) Summary
Frame Cache Implementation
apps/web/src/core/managers/media-manager.ts
Added in-memory LRU cache for decoded video frames with getFrameAt() for frame retrieval/caching, evictFramesForAsset() for asset-specific cleanup, and internal helpers for LRU eviction and frame closure. Extended clearAllAssets() to evict cached frames.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 Frames now cache with LRU grace,
Eight bitmaps find their resting place,
No more decodes when replayed,
Video playback—smooth, not frayed!
Memory managed, crashes fade. 🎬✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: replacing unbounded preview frame buffering with bounded, disposable frame access via LRU caching and explicit disposal.
Description check ✅ Passed The description identifies the critical problem (OOM from unbounded frame retention), explains the solution (bounded frame cache with disposal), and references the linked issue #663, but the standard repository template checklist is incomplete.
Linked Issues check ✅ Passed The PR implements a bounded frame caching system with LRU eviction and explicit disposal via getFrameAt() and evictFramesForAsset() methods, directly addressing issue #663's browser crash caused by unbounded frame buffering and OOM conditions.
Out of Scope Changes check ✅ Passed All changes are scoped to media-manager.ts and directly implement the bounded frame cache solution for addressing the OOM crash issue; no unrelated modifications detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
apps/web/src/core/managers/media-manager.ts (1)

161-166: Consider defensive check for closed bitmaps on cache hit.

If a caller mistakenly closes the returned ImageBitmap, subsequent cache hits will return an invalid bitmap. Adding a check for frame.width === 0 (closed bitmaps have zero dimensions) could defensively handle this edge case.

🛡️ Optional defensive check
 		const existing = this.frameCache.find(
 			(c) => c.assetId === assetId && c.timeMs === timeMs,
 		);
-		if (existing) {
+		if (existing && existing.frame.width > 0) {
 			existing.lastAccessed = Date.now();
 			return existing.frame;
 		}
+		// If found but closed, remove the stale entry
+		if (existing) {
+			this.frameCache = this.frameCache.filter((c) => c !== existing);
+		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/core/managers/media-manager.ts` around lines 161 - 166, The
cache-return path in the media manager that returns a cached ImageBitmap (the
method described by the comment and using FRAME_CACHE_MAX_SIZE) should
defensively detect a closed bitmap by checking its dimensions (e.g., frame.width
=== 0) on cache hit; if the cached ImageBitmap is closed, remove/evict that
entry from the cache, close it if necessary, and proceed to decode/create a
fresh ImageBitmap before returning it so callers never receive an invalid
(zero-dimension) bitmap. Ensure this check is added where the cached frame is
accessed (the function that retrieves/returns the cached ImageBitmap) and that
eviction/cleanup uses the same cache removal logic currently tied to
FRAME_CACHE_MAX_SIZE.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/core/managers/media-manager.ts`:
- Around line 167-192: Concurrent callers to getFrameAt can both miss frameCache
and decode/push duplicates; add a pending-decode map (e.g., pendingDecodes:
Map<string, Promise<ImageBitmap>>) keyed by `${assetId}:${timeMs}` to coalesce
inflight decodes: first check frameCache, then check pendingDecodes and if
present await it and update lastAccessed on the cached entry before returning;
if not present, create and store a decode promise in pendingDecodes, await it,
then remove the key, perform the bounded-cache eviction via evictLruFrame only
once before pushing the single resolved frame into frameCache (respecting
FRAME_CACHE_MAX_SIZE), and ensure pendingDecodes entries are removed in finally
to avoid leaks.
- Around line 194-204: Remove the leaked ImageBitmap frames by calling
MediaManager.evictFramesForAsset from RemoveMediaAssetCommand.execute: after the
spot where videoCache.clearVideo(...) is invoked, add a call to
editor.media.evictFramesForAsset({ assetId: this.assetId }) so cached frames for
the removed asset are closed and removed; ensure you reference the existing
methods evictFramesForAsset and videoCache.clearVideo and pass this.assetId
exactly as the assetId parameter.

---

Nitpick comments:
In `@apps/web/src/core/managers/media-manager.ts`:
- Around line 161-166: The cache-return path in the media manager that returns a
cached ImageBitmap (the method described by the comment and using
FRAME_CACHE_MAX_SIZE) should defensively detect a closed bitmap by checking its
dimensions (e.g., frame.width === 0) on cache hit; if the cached ImageBitmap is
closed, remove/evict that entry from the cache, close it if necessary, and
proceed to decode/create a fresh ImageBitmap before returning it so callers
never receive an invalid (zero-dimension) bitmap. Ensure this check is added
where the cached frame is accessed (the function that retrieves/returns the
cached ImageBitmap) and that eviction/cleanup uses the same cache removal logic
currently tied to FRAME_CACHE_MAX_SIZE.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: af45e0ec-47fe-4dfe-9c91-a19c091eddbd

📥 Commits

Reviewing files that changed from the base of the PR and between 3516bd6 and fe9b712.

📒 Files selected for processing (1)
  • apps/web/src/core/managers/media-manager.ts

Comment on lines +167 to +192
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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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 invoke decode(), and both push to frameCache. 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
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/core/managers/media-manager.ts` around lines 167 - 192,
Concurrent callers to getFrameAt can both miss frameCache and decode/push
duplicates; add a pending-decode map (e.g., pendingDecodes: Map<string,
Promise<ImageBitmap>>) keyed by `${assetId}:${timeMs}` to coalesce inflight
decodes: first check frameCache, then check pendingDecodes and if present await
it and update lastAccessed on the cached entry before returning; if not present,
create and store a decode promise in pendingDecodes, await it, then remove the
key, perform the bounded-cache eviction via evictLruFrame only once before
pushing the single resolved frame into frameCache (respecting
FRAME_CACHE_MAX_SIZE), and ensure pendingDecodes entries are removed in finally
to avoid leaks.

Comment on lines +194 to +204
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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 ts

Repository: OpenCut-app/OpenCut

Length of output: 173


🏁 Script executed:

# Check the RemoveMediaAssetCommand implementation
cat -n apps/web/src/lib/commands/media/remove-media-asset.ts

Repository: 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.ts

Repository: OpenCut-app/OpenCut

Length of output: 129


🏁 Script executed:

# Check how mediaManager is used in the codebase
rg -n 'mediaManager\.(clearVideo|evictFrames)' --type ts

Repository: 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.ts

Repository: 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 -30

Repository: 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.ts

Repository: 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.ts

Repository: 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.ts

Repository: OpenCut-app/OpenCut

Length of output: 789


Call evictFramesForAsset() in RemoveMediaAssetCommand to clean up cached frames.

The evictFramesForAsset() method correctly clears ImageBitmap frames from the MediaManager's cache, but RemoveMediaAssetCommand.execute() never calls it when removing an asset. While it calls videoCache.clearVideo() to clear the video processing cache, the frame cache in MediaManager is left untouched, causing ImageBitmap objects for deleted assets to leak memory. Add editor.media.evictFramesForAsset({ assetId: this.assetId }) in RemoveMediaAssetCommand.execute() after line 43.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/core/managers/media-manager.ts` around lines 194 - 204, Remove
the leaked ImageBitmap frames by calling MediaManager.evictFramesForAsset from
RemoveMediaAssetCommand.execute: after the spot where videoCache.clearVideo(...)
is invoked, add a call to editor.media.evictFramesForAsset({ assetId:
this.assetId }) so cached frames for the removed asset are closed and removed;
ensure you reference the existing methods evictFramesForAsset and
videoCache.clearVideo and pass this.assetId exactly as the assetId parameter.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Video Playback does not work!

1 participant