Skip to content

Web codecs for encoding in the browser - #49

Open
PlkMarudny wants to merge 8 commits into
ronak-create:mainfrom
PlkMarudny:WebCodecs
Open

Web codecs for encoding in the browser#49
PlkMarudny wants to merge 8 commits into
ronak-create:mainfrom
PlkMarudny:WebCodecs

Conversation

@PlkMarudny

@PlkMarudny PlkMarudny commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Replace MediaRecorder with WebCodecs for 'realtime' rendering.

image

WebCodecs are used for encoding final video instead of MediaRecorder (still there as a fallback). Only encoding is done in the browser, muxing is still processed by ffmpeg on the server. As seek() per each frame was used before ~40ms was added to each frame processsing time. This process was optimized as well. Technically, the speed above realtime can be expected.

Type of change

  • Bug fix
  • New feature (rendering / API)
  • Docs
  • Refactor / internal

How was it verified?

  • node --check server.js && node --check app.js && node --check mcp-server.js passes
  • Opened the editor and confirmed the change in preview
  • Confirmed the change in an export (fast or realtime), if it affects rendering
  • Updated CLAUDE.md / README.md if the schema, props, or API changed

Checklist

  • No new runtime dependencies added
  • Preview and export render identically (single compositor)
  • Commits are focused and messages are descriptive

Summary by CodeRabbit

  • New Features
    • Added WebCodecs export (hardware H.264) with bitrate and VBR/CBR controls.
    • Export engine selection now prefers Fast, then WebCodecs, with a MediaRecorder fallback when needed.
    • Improved frame-sequenced exporting, including mode-aware cancellation handling.
    • New projects default to 50 FPS.
  • Bug Fixes
    • Corrected timecode formatting when FPS is non-positive.
  • Documentation
    • Expanded export API guidance for Fast vs WebCodecs, including required fps/mode behavior and audio/video sequencing.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds WebCodecs H.264 export with configurable bitrate controls, browser capability detection, sequential Annex-B frame uploads, and server-side FFmpeg muxing. Existing JPEG export remains supported, with realtime fallback behavior retained.

Changes

WebCodecs export

Layer / File(s) Summary
Export settings and engine selection
app.js, index.html, style.css
Adds WebCodecs bitrate and rate-control settings, capability detection, engine selection, export controls, and related styling.
Timeline FPS consistency
app.js, ruler-worker.js, index.html, CLAUDE.md
Uses validated project FPS values across timeline calculations, ruler formatting, audio holds, Fast export, and documentation.
Browser encoding and frame synchronization
app.js
Adds frame-stepping helpers, timestamped VideoEncoder output, sequential uploads with backpressure, audio mixing, and cancellation handling.
Mode-aware export API and muxing
server.js, CLAUDE.md
Adds JPEG and Annex-B export sessions, serialized FFmpeg writes, mode-specific finalization, lifecycle cleanup, and updated API documentation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers: xusnitdinov

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: adding WebCodecs-based browser encoding.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server.js (1)

350-369: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Awaiting drain can hang the request (and the whole write chain) if ffmpeg dies.

At Lines 360-361 the promise resolves only on drain. If ffmpeg exits or the pipe errors while the buffer is full, drain never fires, this POST never responds, and every later /frame POST queues behind sess.writeLock forever — the client sees a frozen progress bar rather than an error. Race the drain against process close/stdin error.

Also, const p at Line 363 shadows the route-level p (pathname, Line 252); renaming avoids a future TDZ trap.

🐛 Proposed fix
       const run = async () => {
         const proc = sess.proc || (sess.mode === "annexb" ? startAnnexbEncoder(sess) : null);
         if (!proc) throw new Error("export encoder not started");
         if (proc.exitCode !== null) throw new Error("ffmpeg exited: " + sess.err());
-        if (!proc.stdin.write(body))
-          await new Promise((r) => proc.stdin.once("drain", r));
+        if (!proc.stdin.write(body)) {
+          await new Promise((resolve, reject) => {
+            const onDrain = () => { cleanup(); resolve(); };
+            const onDead = () => { cleanup(); reject(new Error("ffmpeg exited: " + sess.err())); };
+            const cleanup = () => {
+              proc.stdin.off("drain", onDrain);
+              proc.stdin.off("close", onDead);
+              proc.off("close", onDead);
+            };
+            proc.stdin.once("drain", onDrain);
+            proc.stdin.once("close", onDead);
+            proc.once("close", onDead);
+          });
+        }
       };
-      const p = sess.writeLock.then(run, run);
-      sess.writeLock = p.catch(() => {}); // keep the chain alive after a failed write
-      await p;
+      const write = sess.writeLock.then(run, run);
+      sess.writeLock = write.catch(() => {}); // keep the chain alive after a failed write
+      await write;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server.js` around lines 350 - 369, Update the write handling in the
`/api/export/frame` POST route’s `run` function to race the stdin `drain` wait
against ffmpeg process termination and stdin errors, rejecting promptly when
either failure occurs so `sess.writeLock` does not remain blocked. Also rename
the inner `const p` promise to avoid shadowing the route-level pathname
variable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app.js`:
- Around line 5428-5440: Restore shared playback state after export frame
synchronization: in app.js lines 5428-5440, restore
runtime.clipGain.get(c.id).gain.value to the evaluated clip volume after
Promise.all(waits) or when the clip becomes inactive; in app.js lines 5390-5392,
update playAdvanceVideo cleanup() to restore each element’s captured muted and
playbackRate values. Use playAdvanceVideo and the clip synchronization flow as
the implementation anchors.
- Line 240: Align the default project FPS across the server, UI, and
documentation with app.js’s 25 FPS value: update the Untitled Project
export/import defaults in server.js, the displayed FPS in index.html, and the
documented default in CLAUDE.md. Preserve all other project settings and
behavior.
- Around line 5573-5590: Update waitEncodeQueue to observe the encoder’s error
and abort state in addition to renderCancelled and queue drainage, rejecting
immediately when any failure is reported so polling cannot continue
indefinitely. Mirror the existing waitUploadBackpressure behavior and ensure the
call site propagates the encoder/upload error and abort state into
waitEncodeQueue.

In `@CLAUDE.md`:
- Around line 414-421: Remove the unused hasAudio field from the documented
/api/export/begin payload and from the corresponding client request body in
app.js, leaving audio inclusion controlled solely by whether /api/export/audio
populates sess.wav.

In `@server.js`:
- Around line 209-216: Extend the export session lifecycle around cleanupExport
and the frame/audio write handlers: update each session’s lastTouch timestamp on
successful activity, then add a periodic sweep that removes sessions idle beyond
the chosen timeout via cleanupExport. Ensure the sweep is started with the
server and stopped during shutdown, so abandoned sessions release their ffmpeg
process, temp directory, and .part.mp4.

---

Outside diff comments:
In `@server.js`:
- Around line 350-369: Update the write handling in the `/api/export/frame` POST
route’s `run` function to race the stdin `drain` wait against ffmpeg process
termination and stdin errors, rejecting promptly when either failure occurs so
`sess.writeLock` does not remain blocked. Also rename the inner `const p`
promise to avoid shadowing the route-level pathname variable.
🪄 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 Plus

Run ID: 403c0800-fb04-4b64-9891-044fdb89e415

📥 Commits

Reviewing files that changed from the base of the PR and between 4b7d8f5 and c208412.

📒 Files selected for processing (5)
  • CLAUDE.md
  • app.js
  • index.html
  • server.js
  • style.css

Comment thread app.js Outdated
const project = {
name: "Untitled Project",
width: 1280, height: 720, fps: 30,
width: 1280, height: 720, fps: 25,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for documented/hard-coded 30fps defaults that may now disagree
rg -n --iglob '!node_modules' -e 'fps' -g '*.md' -g 'index.html' -g 'project.json' | rg -n '30|25'
rg -n '\bfps\b[^\n]*\|\|\s*30' -g '*.js'

Repository: ronak-create/FableCut

Length of output: 204


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "FILES_WITH_fps:"
rg -n --iglob '!node_modules' '\bfps\b' | sed -n '1,220p'

echo
echo "app.js relevant lines:"
sed -n '220,260p' app.js | nl -ba -v220

echo
echo "Search 30 defaults/mentions:"
rg -n --iglob '!node_modules' '\b30\b' | rg -n 'fps|frame|rate|export|video|project' | sed -n '1,160p' || true

Repository: ronak-create/FableCut

Length of output: 288


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Tracked files containing fps:"
rg -n '\bfps\b' . -g '!node_modules' | sed -n '1,260p' || true

echo
echo "Tracked files containing 30fps references:"
rg -n '30fps|30 fps|fps 30|FPS 30|30' . -g '!node_modules' -g '*.js' -g '*.md' -g '*.html' -g '*.json' \
  | rg -i 'fps|frame rate|frame rate|export|video|project|animation' \
  | sed -n '1,260p' || true

echo
echo "app.js lines 220-260:"
sed -n '220,260p' app.js | nl -ba -v220 || true

Repository: ronak-create/FableCut

Length of output: 8643


Keep the default project fps aligned across client/server/docs.

app.js creates new projects at fps: 25, but server.js still defaults Untitled Project export/import at 30, index.html shows 30fps, and CLAUDE.md documents fps: 30. If 25 is the intended default, update those references too so new behavior doesn’t split by client/server flow.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.js` at line 240, Align the default project FPS across the server, UI, and
documentation with app.js’s 25 FPS value: update the Untitled Project
export/import defaults in server.js, the displayed FPS in index.html, and the
documented default in CLAUDE.md. Preserve all other project settings and
behavior.

Comment thread app.js
Comment thread app.js Outdated
Comment thread CLAUDE.md
Comment thread server.js

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
CLAUDE.md (1)

419-421: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Clarify the optional-audio ordering rule.

The client skips /api/export/audio when no WAV is produced, so “annexb — must be after audio” is inaccurate for silent exports. State that Annex-B frames must follow audio when audio is uploaded.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` around lines 419 - 421, Update the export endpoint documentation
near the Annex-B frame description to clarify that Annex-B frames must follow
the audio upload only when audio is uploaded. Preserve the existing ordering
requirement for exports that include audio and avoid implying audio is mandatory
for silent exports.
server.js (1)

167-195: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Temp dir is created before fps validation — leaks on every invalid request.

fs.mkdtempSync (line 169) runs before the fps check (171-174). When fps is missing/invalid, beginExport throws but the just-created temp directory is never removed, and the caller (/api/export/begin, 366-374) doesn't clean it up either. Each bad request leaks an empty dir under os.tmpdir() that the idle-sweep can't reach (session was never registered).

🐛 Proposed fix
 function beginExport(fps, name, mode) {
+  const rate = Number(fps);
+  if (!Number.isFinite(rate) || rate <= 0) {
+    throw new Error("export fps required (pass project.fps)");
+  }
   const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
   const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fablecut-"));
   const m = mode === "annexb" ? "annexb" : "jpeg";
-  const rate = Number(fps);
-  if (!Number.isFinite(rate) || rate <= 0) {
-    throw new Error("export fps required (pass project.fps)");
-  }
   const sess = {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server.js` around lines 167 - 195, Move the temporary-directory creation in
beginExport until after the fps validation succeeds, so invalid requests throw
before fs.mkdtempSync is called. Keep session construction and registration
behavior unchanged for valid rates.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app.js`:
- Around line 619-620: Update the FPS assignment in the project-loading logic to
use the initial default FPS rather than project.fps when data.fps is missing,
non-positive, or invalid. Convert data.fps once, require it to be finite and
greater than zero, and otherwise use the existing initial-default FPS symbol;
preserve valid FPS values.

In `@server.js`:
- Around line 440-442: Update the /api/export/end flow around
sess.proc.stdin.end() to await sess.writeLock before closing stdin, ensuring all
queued or in-flight frame writes and drain waits have completed. Preserve the
existing sess.proc validation and touchExport behavior.

---

Outside diff comments:
In `@CLAUDE.md`:
- Around line 419-421: Update the export endpoint documentation near the Annex-B
frame description to clarify that Annex-B frames must follow the audio upload
only when audio is uploaded. Preserve the existing ordering requirement for
exports that include audio and avoid implying audio is mandatory for silent
exports.

In `@server.js`:
- Around line 167-195: Move the temporary-directory creation in beginExport
until after the fps validation succeeds, so invalid requests throw before
fs.mkdtempSync is called. Keep session construction and registration behavior
unchanged for valid rates.
🪄 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 Plus

Run ID: 78466b5b-74ab-4bf5-847c-e70f8f4bdbb8

📥 Commits

Reviewing files that changed from the base of the PR and between c208412 and c816830.

📒 Files selected for processing (5)
  • CLAUDE.md
  • app.js
  • index.html
  • ruler-worker.js
  • server.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • index.html

Comment thread app.js
Comment on lines +619 to +620
width: data.width || 1280, height: data.height || 720,
fps: (Number(data.fps) > 0 ? Number(data.fps) : project.fps),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use a stable fallback FPS when loading invalid project data.

Line 620 preserves the prior project's FPS when data.fps is missing or invalid. Loading a legacy project after a 60fps project can therefore silently render/export it at 60fps. It also accepts Infinity. Fall back to the initial default instead, after a finite-number check.

Proposed fix
 function applyProject(data) {
   const wa = normalizeWorkArea(data.inPoint, data.outPoint);
   const disabledTracks = normalizeDisabledTracks(data.disabledTracks);
+  const fps = Number(data.fps);
   Object.assign(project, {
     name: data.name || "Untitled Project",
     width: data.width || 1280, height: data.height || 720,
-    fps: (Number(data.fps) > 0 ? Number(data.fps) : project.fps),
+    fps: Number.isFinite(fps) && fps > 0 ? fps : 30,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.js` around lines 619 - 620, Update the FPS assignment in the
project-loading logic to use the initial default FPS rather than project.fps
when data.fps is missing, non-positive, or invalid. Convert data.fps once,
require it to be finite and greater than zero, and otherwise use the existing
initial-default FPS symbol; preserve valid FPS values.

Comment thread server.js
Comment on lines +440 to 442
touchExport(sess); // keep alive through final mux
if (!sess.proc) throw new Error("no frames were uploaded");
sess.proc.stdin.end();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Ending stdin without waiting for the write-lock chain to settle.

/api/export/end calls sess.proc.stdin.end() right after the sess.proc check, without awaiting sess.writeLock. If a /api/export/frame request is still mid-flight (queued behind the lock, or awaiting drain), ending stdin concurrently can truncate the final frame or throw a "write after end" error in that in-flight request. Current client behavior (sequential awaited uploads) avoids triggering this, but it's a cheap, defensive guard against future concurrency changes or client bugs.

🛡️ Proposed fix
       touchExport(sess); // keep alive through final mux
       if (!sess.proc) throw new Error("no frames were uploaded");
+      await sess.writeLock; // let any in-flight frame write finish before ending stdin
       sess.proc.stdin.end();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
touchExport(sess); // keep alive through final mux
if (!sess.proc) throw new Error("no frames were uploaded");
sess.proc.stdin.end();
touchExport(sess); // keep alive through final mux
if (!sess.proc) throw new Error("no frames were uploaded");
await sess.writeLock; // let any in-flight frame write finish before ending stdin
sess.proc.stdin.end();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server.js` around lines 440 - 442, Update the /api/export/end flow around
sess.proc.stdin.end() to await sess.writeLock before closing stdin, ensuring all
queued or in-flight frame writes and drain waits have completed. Preserve the
existing sess.proc validation and touchExport behavior.

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.

1 participant