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

+
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; }