From 076e9b80704a1612561e7a6700ff6b416cc0ac4c Mon Sep 17 00:00:00 2001
From: = <=>
Date: Wed, 29 Jul 2026 12:18:11 +0300
Subject: [PATCH 1/8] feat: replace MediaRecorder with WebCodes (muxed via
ffmpeg) on the server
---
CLAUDE.md | 38 +++++--
app.js | 298 ++++++++++++++++++++++++++++++++++++++++++++++++++---
index.html | 25 ++++-
server.js | 114 +++++++++++++++-----
style.css | 34 ++++++
5 files changed, 455 insertions(+), 54 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 4065cc2..eb59306 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -411,10 +411,14 @@ obvious cuts were missed, raise it if motion is being misread as cuts.
reference video into an edit blueprint (see "Remake a reference video"); extracts
its music into ./media. `GET /api/analyze?src=…` returns the cached blueprint.
- `GET /api/events` — SSE, emits `change` when project.json, ./media or ./library changes
-- Fast export (used by the UI; browser renders frames, ffmpeg encodes):
- `GET /api/export/ffmpeg` → `{available}` · `POST /api/export/begin` `{fps,name}` → `{id}`
- · `POST /api/export/frame?id=` (JPEG body, in order) · `POST /api/export/audio?id=` (WAV body)
- · `POST /api/export/end?id=[&discard=1]` → `{src}` under `/exports/`
+- Fast / WebCodecs export (browser compositor → server ffmpeg):
+ `GET /api/export/ffmpeg` → `{available}` · `POST /api/export/begin`
+ `{fps,name,mode?,"hasAudio"?}` → `{id,mode}` where `mode` is `"jpeg"` (default,
+ Fast) or `"annexb"` (WebCodecs H.264 elementary stream)
+ · `POST /api/export/frame?id=` (JPEG body for jpeg mode, Annex-B NAL bytes for
+ annexb — must be after audio; annexb ffmpeg is spawned on the first frame)
+ · `POST /api/export/audio?id=` (WAV body) · `POST /api/export/end?id=[&discard=1]`
+ → `{src}` under `/exports/`
## Recipes
@@ -525,10 +529,22 @@ guides (▦) to keep captions out of platform UI zones.
## Export
-Export is user-driven (Export button → dialog). Two engines: **Fast** (browser
-renders each frame with the normal compositor — including SVG frames, keys and
-AI masks — streams JPEG frames + an offline WAV mix to the server, ffmpeg
-encodes a CRF-18 faststart MP4 into `./exports/`) and **Realtime**
-(MediaRecorder fallback). Claude cannot trigger export headlessly — the
-compositor lives in the browser; ask the user to click Export, or render with
-ffmpeg directly from `media/` sources if a file is needed.
+Export is user-driven (Export button → dialog). Three engines:
+
+1. **Fast** — browser renders each frame with the normal compositor (SVG, keys,
+ AI masks), streams JPEGs + an offline WAV mix to the server; ffmpeg encodes a
+ CRF-18 faststart MP4 into `./exports/`. Quality / software path.
+2. **WebCodecs** — same frame-accurate compositor loop, but the browser’s
+ `VideoEncoder` produces Annex-B H.264 (Main 4:2:0) and the server stream-copies
+ (`-c:v copy`) while muxing the WAV. Faster uploads, less server CPU. Requires
+ Chromium-class `VideoEncoder` with `avc: { format: "annexb" }` plus ffmpeg.
+ No ffmpeg-style CRF — quality is bitrate + VBR/CBR (export dialog; remembered
+ in localStorage). Optional `bitrateMode: "quantizer"` (fixed QP) exists in the
+ spec but is rarely supported by hardware encoders with Annex-B.
+3. **Realtime (MediaRecorder)** — automatic offline fallback when the server,
+ ffmpeg, or WebCodecs is unavailable. Plays the timeline once and records it;
+ keep the tab focused.
+
+Claude cannot trigger export headlessly — the compositor lives in the browser;
+ask the user to click Export, or render with ffmpeg directly from `media/`
+sources if a file is needed.
diff --git a/app.js b/app.js
index b412674..05c3285 100644
--- a/app.js
+++ b/app.js
@@ -195,6 +195,9 @@ function addLiveAudioTrackBuses(ids) {
const SETTINGS_KEY = "fablecut-settings";
const DEFAULT_SETTINGS = {
linkSelect: false, // timeline ↔ project bin selection sync
+ // WebCodecs has no CRF — bitrate (Mbps) + constant|variable mode
+ webCodecsBitrateMbps: null, // null = auto from canvas size
+ webCodecsBitrateMode: "variable", // "variable" | "constant"
};
let settings = { ...DEFAULT_SETTINGS };
function loadSettings() {
@@ -205,6 +208,15 @@ function loadSettings() {
for (const k of Object.keys(DEFAULT_SETTINGS)) {
if (Object.hasOwn(raw, k)) next[k] = raw[k];
}
+ // coerce WebCodecs bitrate settings
+ const mbps = next.webCodecsBitrateMbps;
+ if (mbps != null) {
+ const n = Number(mbps);
+ next.webCodecsBitrateMbps = (Number.isFinite(n) && n > 0) ? n : null;
+ }
+ if (next.webCodecsBitrateMode !== "constant" && next.webCodecsBitrateMode !== "variable") {
+ next.webCodecsBitrateMode = "variable";
+ }
settings = next;
} catch {
settings = { ...DEFAULT_SETTINGS };
@@ -225,7 +237,7 @@ function setSetting(key, value) {
/* ── State ─────────────────────────────────────────────────────────────── */
const project = {
name: "Untitled Project",
- width: 1280, height: 720, fps: 30,
+ width: 1280, height: 720, fps: 25,
background: "#000000",
revision: 0,
folders: [], // {id, name, parentId:null|string, open:true} — Project-bin tree (virtual)
@@ -248,6 +260,7 @@ const state = {
viewZoom: 1, // program-monitor display zoom (1 = fit stage)
audioHold: false, // while paused, loop one frame of audio at the playhead
ffmpeg: false, // server reports ffmpeg available
+ webCodecs: false, // VideoEncoder + Annex-B H.264 supported
dirtyTimeline: true, gesture: false,
workAreaPlay: false, // when true, play + Home/End stay inside IN/OUT
binTab: "project", // project | elements | sfx | svg
@@ -436,12 +449,37 @@ async function connectServer() {
listenSSE();
fetch("/api/export/ffmpeg").then((r) => r.json())
.then((j) => { state.ffmpeg = !!j.available; }).catch(() => { });
+ detectWebCodecs();
} catch {
state.connected = false;
els.projectName.textContent = project.name + " · ⚪ local session";
}
await probeMissingMeta();
}
+/* Main-profile AVC level by canvas height; Annex-B is required so ffmpeg
+ can ingest the elementary stream with `-f h264` and no avcC converter. */
+function webCodecsAvcCodec() {
+ const h = project.height || 720;
+ if (h > 1080) return "avc1.4D0032"; // Main@L5.0
+ if (h > 720) return "avc1.4D0028"; // Main@L4.0
+ return "avc1.4D001F"; // Main@L3.1
+}
+async function detectWebCodecs() {
+ state.webCodecs = false;
+ try {
+ if (typeof VideoEncoder !== "function" || typeof VideoEncoder.isConfigSupported !== "function") return;
+ const cfg = {
+ codec: webCodecsAvcCodec(),
+ width: Math.max(2, project.width | 0 || 1280),
+ height: Math.max(2, project.height | 0 || 720),
+ bitrate: 8_000_000,
+ framerate: project.fps || 30,
+ avc: { format: "annexb" },
+ };
+ const { supported } = await VideoEncoder.isConfigSupported(cfg);
+ state.webCodecs = !!supported;
+ } catch { state.webCodecs = false; }
+}
const TIMELINE_START_TIME = 0.000; // composition timeline start (seconds)
function normalizeWorkArea(i, o, t0 = TIMELINE_START_TIME) {
let inPoint = (i != null && isFinite(i)) ? Math.max(t0, +i) : null;
@@ -5195,23 +5233,81 @@ function loop(ts) {
}
/* ═══════════════════════════ EXPORT ═══════════════════════════ */
-/* Two engines:
- – fast: the browser renders every frame with the normal compositor
- (frame-accurate, works unfocused) and streams JPEGs + an offline audio
- mix to the server, where ffmpeg encodes a real CRF-18 MP4.
- – realtime: the original MediaRecorder capture, kept as the fallback for
- local sessions / servers without ffmpeg. */
+/* Two primary engines + offline fallback:
+ – fast: JPEG frames → server libx264 (quality / CRF path)
+ – webcodecs: VideoEncoder Annex-B H.264 → server stream-copy mux
+ – realtime MediaRecorder: only when WebCodecs or the server is unavailable */
-function openExportSetup() {
+function syncExportWcOpts() {
+ const opts = $("exportWcOpts");
+ if (!opts) return;
+ const show = !!(els.engineRealtime?.checked && state.webCodecs
+ && state.connected && state.ffmpeg && !els.engineRealtime.disabled);
+ opts.classList.toggle("hidden", !show);
+}
+function fillExportWcOpts() {
+ const br = $("exportWcBitrate");
+ const mode = $("exportWcMode");
+ if (br) {
+ const mbps = getSetting("webCodecsBitrateMbps");
+ const want = mbps == null ? "auto" : String(Math.round(Number(mbps)));
+ br.value = [...br.options].some((o) => o.value === want) ? want : "auto";
+ }
+ if (mode) {
+ const m = getSetting("webCodecsBitrateMode");
+ mode.value = m === "constant" ? "constant" : "variable";
+ }
+}
+function persistExportWcOpts() {
+ const br = $("exportWcBitrate");
+ const mode = $("exportWcMode");
+ if (br) {
+ setSetting("webCodecsBitrateMbps", br.value === "auto" ? null : Number(br.value));
+ }
+ if (mode) {
+ setSetting("webCodecsBitrateMode", mode.value === "constant" ? "constant" : "variable");
+ }
+}
+/** Bitrate for VideoEncoder.configure — no CRF in WebCodecs; only bitrate (+ CBR/VBR). */
+function webCodecsBitrate(w, h, fps) {
+ const mbps = getSetting("webCodecsBitrateMbps");
+ if (mbps != null && Number.isFinite(+mbps) && +mbps > 0) {
+ return Math.round(Math.min(100, Math.max(0.5, +mbps)) * 1_000_000);
+ }
+ // ~0.1 bit/pixel/frame, clamped — same heuristic as before
+ return Math.min(20_000_000, Math.max(2_000_000, Math.round(w * h * fps * 0.1)));
+}
+async function openExportSetup() {
if (state.exporting) return;
if (!project.clips.length) { alert("Timeline is empty — add some clips first."); return; }
+ await detectWebCodecs();
const fastOk = state.connected && state.ffmpeg;
+ const wcOk = fastOk && state.webCodecs;
+ const recOk = !!(window.MediaRecorder && pickMime());
els.engineFast.disabled = !fastOk;
- els.engineFast.checked = fastOk;
- els.engineRealtime.checked = !fastOk;
+ els.engineRealtime.disabled = !wcOk && !recOk;
+ // Prefer Fast, then WebCodecs, then MediaRecorder
+ if (fastOk) {
+ els.engineFast.checked = true;
+ els.engineRealtime.checked = false;
+ } else if (wcOk || recOk) {
+ els.engineFast.checked = false;
+ els.engineRealtime.checked = true;
+ }
$("engineFastNote").textContent = fastOk
- ? "Frame-accurate ffmpeg encode. Keeps rendering if you switch tabs."
+ ? "Frame-accurate. Server encodes H.264 from JPEG frames. Keeps going if you switch tabs."
: "Needs the server + ffmpeg on PATH.";
+ const wcNote = $("engineWebCodecsNote");
+ if (wcNote) {
+ if (wcOk) wcNote.textContent = "Frame-accurate. Browser HW-encodes H.264; server muxes with audio. Faster upload than Fast.";
+ else if (!state.connected || !state.ffmpeg) wcNote.textContent = "Needs the server + ffmpeg. Falling back to in-browser MediaRecorder when selected.";
+ else wcNote.textContent = "This browser does not support VideoEncoder Annex-B H.264. Falling back to MediaRecorder when selected.";
+ }
+ // Relabel the radio when WebCodecs is unavailable but MediaRecorder still works
+ const label = els.engineRealtime?.closest("label")?.querySelector("b");
+ if (label) label.textContent = wcOk ? "WebCodecs (HW encode)" : "Realtime (in-browser)";
+ fillExportWcOpts();
+ syncExportWcOpts();
const warn = $("exportTrackWarn");
const disabled = TRACKS.filter((t) =>
!isTrackEnabled(t.id) && project.clips.some((c) => c.track === t.id)
@@ -5229,8 +5325,10 @@ function openExportSetup() {
els.exportSetup.classList.remove("hidden");
}
function startChosenExport() {
+ persistExportWcOpts();
els.exportSetup.classList.add("hidden");
if (els.engineFast.checked && !els.engineFast.disabled) fastExport();
+ else if (els.engineRealtime.checked && state.connected && state.ffmpeg && state.webCodecs) webCodecsExport();
else startExport();
}
@@ -5379,7 +5477,173 @@ async function fastExport() {
}
}
-/* ── Realtime export (MediaRecorder fallback) ── */
+/* ── WebCodecs export (browser H.264 → server mux) ── */
+/* Uploads MUST be strictly sequential — concurrent /frame POSTs race on the
+ same ffmpeg stdin and deadlock the pipe (progress freezes around a few %). */
+let webCodecsAbort = null;
+function waitEncodeQueue(encoder, max = 2) {
+ if (encoder.encodeQueueSize <= max) return Promise.resolve();
+ return new Promise((res, rej) => {
+ const done = (err) => {
+ clearInterval(poll);
+ encoder.ondequeue = null;
+ err ? rej(err) : res();
+ };
+ const tick = () => {
+ if (renderCancelled) done(new Error("cancelled"));
+ else if (encoder.encodeQueueSize <= max) done(null);
+ };
+ encoder.ondequeue = tick;
+ // ondequeue alone won't notice Cancel — poll the flag
+ const poll = setInterval(tick, 50);
+ tick();
+ });
+}
+async function webCodecsExport() {
+ if (state.exporting) return;
+ if (!state.webCodecs) { startExport(); return; }
+ pause();
+ state.exporting = true; state.rendering = true; renderCancelled = false;
+ webCodecsAbort = new AbortController();
+ const signal = webCodecsAbort.signal;
+ els.exportOverlay.classList.remove("hidden");
+ els.exportProgress.style.width = "0%";
+ els.exportNote.textContent = "Encoding with WebCodecs → ffmpeg mux. You can switch tabs; export continues.";
+ const fps = Number(project.fps) || 30, dur = Math.max(1 / fps, projDur());
+ const frames = Math.max(1, Math.round(dur * fps));
+ const keyEvery = Math.max(1, Math.round(fps * 2));
+ let sessId = null;
+ let encoder = null;
+ let uploadError = null;
+ // single-flight upload chain: each NAL waits for the previous POST to finish
+ let uploadTail = Promise.resolve();
+ let uploadsInFlight = 0;
+ const enqueueUpload = (buf) => {
+ uploadsInFlight++;
+ // recover from a prior rejection so one failed POST doesn't stall the chain
+ const p = uploadTail.catch(() => {}).then(async () => {
+ if (renderCancelled || signal.aborted) throw new Error("cancelled");
+ if (uploadError) throw uploadError;
+ const r = await fetch("/api/export/frame?id=" + sessId, {
+ method: "POST", body: buf, signal,
+ });
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || "frame upload failed");
+ });
+ uploadTail = p.catch((err) => {
+ if (!uploadError) uploadError = err;
+ }).finally(() => { uploadsInFlight--; });
+ return p;
+ };
+ const waitUploadBackpressure = (max = 2) => new Promise((res, rej) => {
+ const tick = () => {
+ if (renderCancelled || signal.aborted) { clearInterval(poll); rej(new Error("cancelled")); }
+ else if (uploadError) { clearInterval(poll); rej(uploadError); }
+ else if (uploadsInFlight <= max) { clearInterval(poll); res(); }
+ };
+ const poll = setInterval(tick, 20);
+ tick();
+ });
+ try {
+ els.exportTitle.textContent = "Mixing audio…";
+ const wav = await renderAudioMix(dur);
+ if (renderCancelled) throw new Error("cancelled");
+
+ const begin = await fetch("/api/export/begin", {
+ method: "POST",
+ body: JSON.stringify({
+ fps,
+ name: project.name.replace(/[^\w\- ]+/g, "") || "export",
+ mode: "annexb",
+ hasAudio: !!wav,
+ }),
+ signal,
+ }).then((r) => r.json());
+ if (!begin.id) throw new Error(begin.error || "export begin failed");
+ sessId = begin.id;
+ if (wav) {
+ const r = await fetch("/api/export/audio?id=" + sessId, { method: "POST", body: wav, signal });
+ if (!r.ok) throw new Error("audio upload failed");
+ }
+
+ // Always encode at project/frame resolution (not display CSS size).
+ const w = Math.max(2, project.width | 0 || 1280);
+ const h = Math.max(2, project.height | 0 || 720);
+ if (els.preview.width !== w || els.preview.height !== h) {
+ els.preview.width = w;
+ els.preview.height = h;
+ }
+ const codec = webCodecsAvcCodec();
+ const bitrate = webCodecsBitrate(w, h, fps);
+ const bitrateMode = getSetting("webCodecsBitrateMode") === "constant" ? "constant" : "variable";
+ encoder = new VideoEncoder({
+ output: (chunk) => {
+ if (uploadError || renderCancelled || signal.aborted) return;
+ const buf = new Uint8Array(chunk.byteLength);
+ chunk.copyTo(buf);
+ enqueueUpload(buf);
+ },
+ error: (e) => { uploadError = e; },
+ });
+ encoder.configure({
+ codec, width: w, height: h, bitrate, bitrateMode, framerate: fps,
+ avc: { format: "annexb" },
+ latencyMode: "quality",
+ });
+ try { await document.fonts.ready; } catch { }
+
+ for (let f = 0; f < frames; f++) {
+ if (renderCancelled || signal.aborted) throw new Error("cancelled");
+ if (uploadError) throw uploadError;
+ await waitUploadBackpressure(2);
+ await waitEncodeQueue(encoder, 2);
+ const t = f / fps;
+ state.time = t;
+ await seekVideosTo(t);
+ await prepareFrameAssets(t);
+ drawFrame(t);
+ // Absolute µs timestamps; duration = delta so average rate stays exact
+ // (constant Math.round(1e6/fps) drifts, e.g. 33333µs → avg 1000000/33333).
+ const ts = Math.round(f * 1e6 / fps);
+ const frame = new VideoFrame(els.preview, {
+ timestamp: ts,
+ duration: Math.round((f + 1) * 1e6 / fps) - ts,
+ });
+ try {
+ encoder.encode(frame, { keyFrame: f === 0 || f % keyEvery === 0 });
+ } finally {
+ frame.close();
+ }
+ const pct = ((f + 1) / frames) * 100;
+ els.exportProgress.style.width = pct.toFixed(1) + "%";
+ els.exportTitle.textContent = `Encoding… ${pct.toFixed(0)}%`;
+ }
+ els.exportTitle.textContent = "Finishing…";
+ await encoder.flush();
+ await uploadTail;
+ if (uploadError) throw uploadError;
+ encoder.close();
+ encoder = null;
+ const end = await fetch("/api/export/end?id=" + sessId, { method: "POST", signal }).then((r) => r.json());
+ if (!end.src) throw new Error(end.error || "mux failed");
+ const a = document.createElement("a");
+ a.href = end.src;
+ a.download = decodeURIComponent(end.src.split("/").pop());
+ a.click();
+ } catch (e) {
+ try { encoder?.close(); } catch { }
+ if (sessId) fetch("/api/export/end?id=" + sessId + "&discard=1", { method: "POST" }).catch(() => { });
+ const msg = e?.name === "AbortError" ? "cancelled" : String(e.message || e);
+ if (msg !== "cancelled") alert("Export failed: " + msg);
+ } finally {
+ webCodecsAbort = null;
+ state.exporting = false; state.rendering = false;
+ els.exportOverlay.classList.add("hidden");
+ els.exportNote.textContent = "Rendering your sequence in real time. Keep this tab focused.";
+ if (runtime.pendingSync) syncFromServer();
+ }
+}
+
+/* ── Realtime export (MediaRecorder offline / unsupported fallback) ── */
let recorder = null, recChunks = [], recDiscard = false;
function pickMime() {
const cands = [
@@ -5454,9 +5718,15 @@ $("btnDelete").addEventListener("click", () => {
$("btnExport").addEventListener("click", openExportSetup);
$("btnStartExport").addEventListener("click", startChosenExport);
$("btnCancelSetup").addEventListener("click", () => els.exportSetup.classList.add("hidden"));
+els.engineFast?.addEventListener("change", syncExportWcOpts);
+els.engineRealtime?.addEventListener("change", syncExportWcOpts);
+$("exportWcBitrate")?.addEventListener("change", persistExportWcOpts);
+$("exportWcMode")?.addEventListener("change", persistExportWcOpts);
$("btnCancelExport").addEventListener("click", () => {
- if (state.rendering) renderCancelled = true;
- else finishExport(false);
+ if (state.rendering) {
+ renderCancelled = true;
+ try { webCodecsAbort?.abort(); } catch { }
+ } else finishExport(false);
});
$("btnPlay").addEventListener("click", () => state.playing ? pause() : play());
els.btnSpeed.addEventListener("click", () => cyclePreviewRate(1));
diff --git a/index.html b/index.html
index bfbd1b1..ed4b2c0 100644
--- a/index.html
+++ b/index.html
@@ -159,8 +159,31 @@
Export
+
+
+
+
WebCodecs has no CRF — quality is driven by bitrate. VBR spends bits on hard frames; CBR holds a steady rate.
+
diff --git a/server.js b/server.js
index fdf7476..7c42ed3 100644
--- a/server.js
+++ b/server.js
@@ -144,36 +144,75 @@ async function faststart(file) {
} catch { try { fs.rmSync(tmp); } catch {} }
}
-/* ── Fast export sessions ──
- The browser renders frames with its own compositor and streams them here as
- JPEGs; ffmpeg encodes them (plus an optional WAV mix) into a real MP4. */
+/* ── Export sessions ──
+ Two modes share the same HTTP session API:
+ jpeg — browser streams JPEGs; ffmpeg encodes H.264 (Fast path)
+ annexb — browser streams Annex-B H.264 NALs; ffmpeg stream-copies (WebCodecs)
+ Annex-B is spawned lazily on the first frame so the WAV (uploaded between
+ /begin and /frame) can be included in the same one-pass mux. */
const exportSessions = new Map();
-function beginExport(fps, name) {
+function attachProc(sess, proc) {
+ sess.proc = proc;
+ sess.stderr = "";
+ proc.stderr.on("data", (d) => { sess.stderr = (sess.stderr + d).slice(-2000); });
+ proc.stdin.on("error", () => {}); // EPIPE if ffmpeg dies mid-stream
+ sess.done = new Promise((res) => proc.on("close", res));
+}
+function beginExport(fps, name, mode) {
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fablecut-"));
- const videoPath = path.join(dir, "video.mp4");
- const proc = spawn("ffmpeg", [
- "-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "-",
- "-c:v", "libx264", "-preset", "fast", "-crf", "18", "-pix_fmt", "yuv420p",
- videoPath,
- ], { stdio: ["pipe", "ignore", "pipe"] });
- let stderr = "";
- proc.stderr.on("data", (d) => { stderr = (stderr + d).slice(-2000); });
- proc.stdin.on("error", () => {}); // EPIPE if ffmpeg dies mid-stream; surfaced via exit code
+ const m = mode === "annexb" ? "annexb" : "jpeg";
const sess = {
- proc, dir, videoPath, name: safeName(name || "export"),
- wav: null, err: () => stderr,
- done: new Promise((res) => proc.on("close", res)),
+ mode: m, fps: Number(fps) || 30, proc: null, dir,
+ name: safeName(name || "export"),
+ videoPath: null, partPath: null, outPath: null,
+ wav: null, stderr: "", done: null,
+ err: () => sess.stderr.trim().split("\n").filter(Boolean).slice(-3)
+ .map((l) => l.trim()).join(" · "),
};
+ if (m === "jpeg") {
+ sess.videoPath = path.join(dir, "video.mp4");
+ attachProc(sess, spawn("ffmpeg", [
+ "-y", "-hide_banner", "-f", "image2pipe", "-framerate", String(sess.fps), "-i", "-",
+ "-c:v", "libx264", "-preset", "fast", "-crf", "18", "-pix_fmt", "yuv420p",
+ sess.videoPath,
+ ], { stdio: ["pipe", "ignore", "pipe"] }));
+ }
+ // serialize stdin writes — concurrent /frame handlers would race the pipe
+ sess.writeLock = Promise.resolve();
exportSessions.set(id, sess);
return id;
}
+/* One-pass mux for WebCodecs: H.264 elementary stream on stdin + optional WAV.
+ Use input `-r` (not only `-framerate`): HW encoders stamp AUs with µs-rounded
+ durations (e.g. 33333µs ≈ 1/30), which otherwise become avg_frame_rate
+ 1000000/33333. `-r` forces CFR PTS so the MP4 matches project.fps exactly. */
+function startAnnexbEncoder(sess) {
+ const base = sess.name.replace(/\.mp4$/i, "");
+ sess.outPath = uniquePath(EXPORTS_DIR, base + ".mp4");
+ // ".part" before the extension so ffmpeg can still pick the mp4 muxer
+ sess.partPath = sess.outPath.slice(0, -4) + ".part.mp4";
+ const fps = sess.fps;
+ const args = [
+ "-y", "-hide_banner",
+ "-fflags", "+genpts",
+ "-f", "h264", "-r", String(fps), "-i", "pipe:0",
+ ];
+ if (sess.wav) args.push("-i", sess.wav);
+ args.push("-map", "0:v:0", "-c:v", "copy");
+ // do not use -shortest: with unset/generated PTS it drops the audio track
+ if (sess.wav) args.push("-map", "1:a:0", "-c:a", "aac", "-b:a", "192k");
+ args.push("-movflags", "+faststart", sess.partPath);
+ attachProc(sess, spawn("ffmpeg", args, { stdio: ["pipe", "ignore", "pipe"] }));
+ return sess.proc;
+}
function cleanupExport(id) {
const s = exportSessions.get(id);
if (!s) return;
exportSessions.delete(id);
- try { s.proc.kill(); } catch {}
+ try { s.proc?.kill(); } catch {}
try { fs.rmSync(s.dir, { recursive: true, force: true }); } catch {}
+ if (s.partPath) try { fs.rmSync(s.partPath, { force: true }); } catch {}
}
/* Static file with HTTP Range support (required for
diff --git a/ruler-worker.js b/ruler-worker.js
index 33b5af7..2c16475 100644
--- a/ruler-worker.js
+++ b/ruler-worker.js
@@ -12,7 +12,7 @@ let cv = null, g = null;
function fmt(t, fps) {
t = Math.max(0, t);
const m = Math.floor(t / 60), s = Math.floor(t % 60),
- f = Math.floor((t % 1) * (fps || 30));
+ f = Math.floor((t % 1) * (fps > 0 ? fps : 1));
const p = (n) => String(n).padStart(2, "0");
return `${p(m)}:${p(s)}:${p(f)}`;
}
diff --git a/server.js b/server.js
index c15585a..414d79f 100644
--- a/server.js
+++ b/server.js
@@ -168,8 +168,12 @@ function beginExport(fps, name, mode) {
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 = {
- mode: m, fps: Number(fps) || 30, proc: null, dir,
+ mode: m, fps: rate, proc: null, dir,
name: safeName(name || "export"),
videoPath: null, partPath: null, outPath: null,
wav: null, stderr: "", done: null, lastTouch: Date.now(),
@@ -364,7 +368,7 @@ const server = http.createServer(async (req, res) => {
try {
const opts = JSON.parse((await readBody(req)).toString("utf8") || "{}");
const mode = opts.mode === "annexb" ? "annexb" : "jpeg";
- sendJSON(res, 200, { id: beginExport(opts.fps || 30, opts.name, mode), mode });
+ sendJSON(res, 200, { id: beginExport(opts.fps, opts.name, mode), mode });
} catch (e) { sendJSON(res, 500, { error: String(e) }); }
return;
}