diff --git a/CLAUDE.md b/CLAUDE.md index 4065cc2..10995da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,7 @@ Examples in `library/svg/`: `sparkles.svg` (loop), `lower-third.svg`, ```jsonc { "name": "My Edit", - "width": 1280, "height": 720, "fps": 30, // canvas/export settings + "width": 1280, "height": 720, "fps": 50, // project timeline rate — sole FPS source for preview/export "background": "#000000", // canvas color behind all clips (optional) "revision": 7, // bump on every write! "markers": [ { "t": 2.5 }, { "t": 5.0, "label": "drop" } ], @@ -411,10 +411,15 @@ 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?}` → `{id,mode}` where `fps` is required (pass + `project.fps` — no server-side default) and `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 +530,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..8106893 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: 30, // overwritten by project.json / applyProject background: "#000000", revision: 0, folders: [], // {id, name, parentId:null|string, open:true} — Project-bin tree (virtual) @@ -236,6 +248,11 @@ const project = { outPoint: null, // timeline work-area OUT (seconds), or null disabledTracks: [], // track ids (V4…A3) hidden from preview/export when listed }; +/** Sole runtime FPS source — always the loaded project’s `fps`. */ +function projectFps() { + const n = Number(project.fps); + return (Number.isFinite(n) && n > 0) ? n : 1; +} const state = { time: 0, playing: false, pps: 60, snap: true, previewRate: 1, // playback speed for PREVIEW only — never affects export @@ -248,6 +265,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 @@ -342,7 +360,7 @@ function escapeHtml(s) { function fmt(t) { t = Math.max(0, t); const m = Math.floor(t / 60), s = Math.floor(t % 60), - f = Math.floor((t % 1) * project.fps); + f = Math.floor((t % 1) * projectFps()); const p = (n) => String(n).padStart(2, "0"); return `${p(m)}:${p(s)}:${p(f)}`; } @@ -436,12 +454,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: projectFps(), + 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; @@ -573,7 +616,8 @@ function applyProject(data) { const disabledTracks = normalizeDisabledTracks(data.disabledTracks); Object.assign(project, { name: data.name || "Untitled Project", - width: data.width || 1280, height: data.height || 720, fps: data.fps || 30, + width: data.width || 1280, height: data.height || 720, + fps: (Number(data.fps) > 0 ? Number(data.fps) : project.fps), background: data.background || "#000000", revision: data.revision || 0, folders: normalizeFolders(data.folders), @@ -2212,7 +2256,7 @@ function drawRuler() { type: "draw", w, h, dpr, sl: els.timelineScroll.scrollLeft, pps: state.pps, markers: project.markers, inPoint: project.inPoint, outPoint: project.outPoint, - time: state.time, fps: project.fps, + time: state.time, fps: projectFps(), }); return; } @@ -2623,7 +2667,7 @@ function goToKeyframe(dir) { } const times = keyframeTimelineTimes(clips); if (!times.length) { toast("No keyframes"); return; } - const eps = 0.5 / Math.max(1, project.fps || 30); + const eps = 0.5 / projectFps(); if (dir > 0) { const next = times.find((t) => t > state.time + eps); if (next == null) { toast("No next keyframe"); return; } @@ -3794,7 +3838,7 @@ function refreshAudioHold() { const audio = ensureAudio(); try { audio.ctx.resume(); } catch { } const t = state.time; - const frameDur = 1 / Math.max(1, project.fps || 30); + const frameDur = 1 / projectFps(); const gen = ++audioHoldGen; // Stop previous voices before starting the new slice for (const n of audioHoldNodes) disposeAudioHoldNode(n); @@ -5195,23 +5239,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,30 +5331,139 @@ 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(); } /* ── Fast export ── */ let renderCancelled = false; -function seekVideosTo(t) { +/* ── Fast / WebCodecs frame sync ── + HTMLVideoElement has no “step one frame” API — assigning currentTime always + seeks. On the export hot path a seek-per-frame is the dominant cost, so: + • already within ½ media-frame → no-op + • small forward step + buffered → brief play + requestVideoFrameCallback + (sequential decode from the current position, then pause) + • reverse / large jump / unbuffered → hard seek (same as before) */ +function hardSeekVideo(el, mt) { + return new Promise((res) => { + if (Math.abs(el.currentTime - mt) < 1e-4 && el.readyState >= 2) { res(); return; } + const done = () => { + clearTimeout(tm); + el.removeEventListener("seeked", done); + res(); + }; + const tm = setTimeout(done, 1500); + el.addEventListener("seeked", done); + try { + if (!el.paused) el.pause(); + el.currentTime = mt; + } catch { done(); } + }); +} +/** Play forward until mediaTime reaches mt, then pause. (Must pause: the export + loop does async work between frames, so a free-running element would overrun.) */ +function playAdvanceVideo(el, mt, eps, rate) { + return new Promise((res) => { + let settled = false; + let rvfcId = null; + let poll = null; + const prevMuted = el.muted; + let prevRate = 1; + try { prevRate = el.playbackRate; } catch { } + const cleanup = () => { + clearTimeout(tm); + if (poll) { clearInterval(poll); poll = null; } + if (rvfcId != null && typeof el.cancelVideoFrameCallback === "function") { + try { el.cancelVideoFrameCallback(rvfcId); } catch { } + rvfcId = null; + } + try { el.pause(); } catch { } + try { el.muted = prevMuted; } catch { } + try { el.playbackRate = prevRate; } catch { } + }; + const finish = () => { + if (settled) return; + settled = true; + cleanup(); + // Play can land a hair early/late — snap only when meaningfully off + if (Math.abs(el.currentTime - mt) > eps * 2) + hardSeekVideo(el, mt).then(res); + else res(); + }; + const remain = Math.max(0, mt - el.currentTime); + const tm = setTimeout(finish, Math.min(3000, 400 + (remain * 1000) / Math.max(0.1, rate) * 2.5)); + const check = (mediaTime) => { + if ((mediaTime != null ? mediaTime : el.currentTime) >= mt - eps) finish(); + }; + try { el.playbackRate = clamp(rate, 0.1, 8); } catch { } + // muted → autoplay-friendly after async export setup (user-gesture may be gone) + el.muted = true; + if (typeof el.requestVideoFrameCallback === "function") { + const onFrame = (_now, meta) => { + if (settled) return; + check(meta?.mediaTime); + if (!settled) rvfcId = el.requestVideoFrameCallback(onFrame); + }; + rvfcId = el.requestVideoFrameCallback(onFrame); + } else { + poll = setInterval(() => check(el.currentTime), 4); + } + const p = el.play(); + if (p && typeof p.catch === "function") { + p.catch(() => { + if (settled) return; + settled = true; + cleanup(); + hardSeekVideo(el, mt).then(res); + }); + } + }); +} +async function seekVideosTo(t) { + const fps = projectFps(); const waits = []; + const restoreGain = []; for (const c of project.clips) { if (c.kind !== "video") continue; if (!isTrackEnabled(c.track)) continue; const el = getClipEl(c); if (!el) continue; - if (!activeAt(c, t)) { if (!el.paused) el.pause(); continue; } + if (!activeAt(c, t)) { + if (!el.paused) el.pause(); + const g = runtime.clipGain.get(c.id); + if (g) g.gain.value = clamp(evalProps(c, t).volume, 0, 4); + continue; + } const mt = mediaTimeAt(c, t); - if (Math.abs(el.currentTime - mt) < 1e-4 && el.readyState >= 2) continue; - waits.push(new Promise((res) => { - const done = () => { clearTimeout(tm); el.removeEventListener("seeked", done); res(); }; - const tm = setTimeout(done, 1500); - el.addEventListener("seeked", done); - try { el.currentTime = mt; } catch { done(); } - })); + const local = clamp(t - c.start, 0, c.duration); + const sp = clamp(kfChannel(c, "speed", local, clipSpeed(c)), 0.1, 8); + // One timeline frame in media-time; half-frame = “already on the right frame” + const mediaFrame = sp / fps; + const eps = 0.5 * mediaFrame; + if (el.readyState >= 2 && Math.abs(el.currentTime - mt) <= eps) continue; + + const g = runtime.clipGain.get(c.id); + if (g) { + g.gain.value = 0; + restoreGain.push(c); + } + + const delta = mt - el.currentTime; + // ~4 timeline frames forward + data in buffer → sequential play instead of seek storm + const maxPlay = mediaFrame * 4; + if (delta > eps && delta <= maxPlay && el.readyState >= 2) { + waits.push(playAdvanceVideo(el, mt, eps, sp)); + } else { + waits.push(hardSeekVideo(el, mt)); + } + } + await Promise.all(waits); + for (const c of restoreGain) { + const g = runtime.clipGain.get(c.id); + if (g) g.gain.value = clamp(evalProps(c, t).volume, 0, 4); } - return Promise.all(waits); } function encodeWAV(buf) { const ch = buf.numberOfChannels, len = buf.length, sr = buf.sampleRate; @@ -5330,7 +5541,7 @@ async function fastExport() { els.exportOverlay.classList.remove("hidden"); els.exportProgress.style.width = "0%"; els.exportNote.textContent = "Rendering frames → ffmpeg. You can switch tabs; export continues."; - const fps = project.fps, dur = Math.max(1 / fps, projDur()); + const fps = projectFps(), dur = Math.max(1 / fps, projDur()); const frames = Math.max(1, Math.round(dur * fps)); let sessId = null; try { @@ -5379,7 +5590,183 @@ 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, { signal, getError } = {}) { + const cancelled = () => renderCancelled || !!(signal && signal.aborted); + const failed = () => (getError ? getError() : null); + if (cancelled()) return Promise.reject(new Error("cancelled")); + { + const err = failed(); + if (err) return Promise.reject(err); + } + 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 (cancelled()) done(new Error("cancelled")); + else { + const err = failed(); + if (err) done(err); + else if (encoder.encodeQueueSize <= max) done(null); + } + }; + encoder.ondequeue = tick; + // ondequeue alone won't notice Cancel / encoder·upload failure — poll + 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 = projectFps(), 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", + }), + 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, { signal, getError: () => uploadError }); + 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 +5841,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..e8bc2c1 100644 --- a/index.html +++ b/index.html @@ -75,7 +75,7 @@ - 1280 × 720 · 30fps +
@@ -159,8 +159,31 @@

Export

+
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 fdf7476..414d79f 100644 --- a/server.js +++ b/server.js @@ -144,36 +144,100 @@ 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) { +const EXPORT_IDLE_MS = 10 * 60 * 1000; // abandon sessions with no successful activity +const EXPORT_SWEEP_MS = 60 * 1000; +let exportSweepTimer = null; +function touchExport(sess) { + if (sess) sess.lastTouch = Date.now(); +} +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 rate = Number(fps); + if (!Number.isFinite(rate) || rate <= 0) { + throw new Error("export fps required (pass project.fps)"); + } const sess = { - proc, dir, videoPath, name: safeName(name || "export"), - wav: null, err: () => stderr, - done: new Promise((res) => proc.on("close", res)), + 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(), + 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 {} +} +function sweepIdleExports() { + const now = Date.now(); + for (const [id, s] of [...exportSessions]) { + if (now - (s.lastTouch || 0) > EXPORT_IDLE_MS) cleanupExport(id); + } +} +function startExportSweep() { + if (exportSweepTimer) return; + exportSweepTimer = setInterval(sweepIdleExports, EXPORT_SWEEP_MS); +} +function stopExportSweep() { + if (!exportSweepTimer) return; + clearInterval(exportSweepTimer); + exportSweepTimer = null; } /* Static file with HTTP Range support (required for