From 4745884982148ccc405f7b10dcf21a4f98bc8f92 Mon Sep 17 00:00:00 2001 From: = <=> Date: Tue, 28 Jul 2026 18:48:26 +0300 Subject: [PATCH 1/5] feat: add ffmpeg encoding profiles for 'fast' export --- CLAUDE.md | 85 +++++++++- app.js | 129 ++++++++++++-- encode-profiles.js | 369 +++++++++++++++++++++++++++++++++++++++++ encoding-profiles.json | 49 ++++++ index.html | 6 + mcp-server.js | 52 +++++- server.js | 65 ++++++-- style.css | 37 +++++ 8 files changed, 759 insertions(+), 33 deletions(-) create mode 100644 encode-profiles.js create mode 100644 encoding-profiles.json diff --git a/CLAUDE.md b/CLAUDE.md index 4065cc2..3807f52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,8 @@ Every Claude Code session then has these tools: - `fablecut_import_media` — copy a local file into `./media/` and register it. - `fablecut_analyze_reference` — turn a reference video into an edit blueprint (shots, beats, BPM, energy, drop) + extract its music. See "Remake a reference video". +- `fablecut_encode_profiles` — list ffmpeg encoding presets from `encoding-profiles.json` + (codec, CRF, JPEG quality). Set `project.encodeProfile` via patch to pin a project default. ### Token-efficient editing (important for agents) @@ -167,6 +169,7 @@ Examples in `library/svg/`: `sparkles.svg` (loop), `lower-third.svg`, ], "disabledTracks": [ "A2" ], // ^ optional — track ids (V4 V3 V2 V1 A1 A2 A3) omitted from preview/export when listed + "encodeProfile": "hq", // optional — fast-export profile id (see encoding-profiles.json) "media": [ { "id": "m_abc", "name": "intro.mp4", "kind": "video", // video|audio|image|svg "src": "/media/intro.mp4", // path under ./media or ./library @@ -412,7 +415,9 @@ obvious cuts were missed, raise it if motion is being misread as cuts. 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}` + `GET /api/export/ffmpeg` → `{available}` · `GET /api/export/profiles[?detail=1]` → + `{default, profiles, issues}` · `POST /api/export/begin` `{fps,name,profile?}` → + `{id,profile,label,summary}` (**400** if `profile` is not a defined 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/` @@ -528,7 +533,83 @@ guides (▦) to keep captions out of platform UI zones. 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** +encodes via an **encoding profile** 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. + +### Encoding profiles (`encoding-profiles.json`) + +User-editable at the repo root. Defines ffmpeg settings for Fast export. The +server validates all fields (no raw ffmpeg strings from the client). Edit the +file while the server runs — the UI hot-reloads the profile list via SSE. + +```jsonc +{ + "default": "delivery", // profile id used when nothing else is set + "profiles": { + "draft": { + "label": "Draft · H.264 fast", + "description": "Quick preview — smaller file, faster encode.", + "jpegQuality": 0.85, // browser JPEG frame quality (0.5–1) + "video": { "codec": "libx264", "preset": "veryfast", "crf": 23, "pixFmt": "yuv420p" }, + "audio": { "codec": "aac", "bitrate": "128k" }, + "mux": { "movflags": "+faststart" } + }, + "delivery": { /* … balanced default … */ }, + "hq": { /* … slow preset, lower CRF … */ } + } +} +``` + +**Allowed values:** video codecs `libx264` · `libx265`; presets `ultrafast`…`veryslow`; +CRF 0–51; pixel formats `yuv420p` · `yuv422p` · `yuv444p`; audio `aac` · `libopus` +with bitrates like `192k`. Optional x264 `tune` / `profile` on a profile's `video` object. + +**Advanced fields** (optional — for broadcast / custom ffmpeg): + +| block | fields | maps to ffmpeg | +| --- | --- | --- | +| `encode` | `hideBanner`, `stats`, `loglevel` | global flags on both encode passes | +| `video` | `g`, `maxrate`, `bufsize`, `x264opts`, `vf` | GOP, rate cap, x264 opts, filter chain | +| `audio` | `sampleRate`, `strict` | `-ar`, `-strict` (e.g. AAC `-2`) | +| `mux` | `format`, `extension`, `movflags` | `-f mov`, output `.mov`, faststart | + +Notes on how these are handled: + +- **Profiles inherit** from the same-named built-in (or `delivery` for new ids), so a + profile that only sets `video.crf` keeps the rest. Set `"movflags": null` to clear an + inherited value. +- `mux.format` accepts `mp4` · `mov` · `matroska` (`mkv` is normalized to `matroska`, + since `-f mkv` is not a valid muxer name). The output extension follows the container + unless `mux.extension` overrides it. +- `libopus` is switched to `aac` for MOV (no mapping exists) and gets `-strict -2` + automatically in MP4 — otherwise the mux pass would fail *after* every frame is rendered. +- A `loglevel` of `quiet`/`panic`/`fatal` is raised to `error` for the encode passes: that + stderr is only ever read back to report *why* an export failed. +- Anything invalid is **corrected and reported**, never silently applied. Rejected and + clamped values are listed on server startup, in `GET /api/export/profiles` (`issues`), + and per profile via `fablecut_encode_profiles {detail:true}` (`warnings`). +- `vf` / `x264opts` are restricted to `[\w=.,:/+\-[\]()%| ]`, so filters needing quotes + (e.g. `drawtext=text='hi'`) are refused. Do that work in the timeline instead. +- Frames arrive as **JPEG (4:2:0)**, so `yuv422p`/`yuv444p` cannot recover chroma the + source never had; raise `jpegQuality` before reaching for a wider pixel format. + +Example **`broadcast1080i50`** (included in `encoding-profiles.json`) builds an interlaced +1080i50 `.mov` with your x264 opts, `tinterlace`, 384k AAC @ 48 kHz. Set the project to +**1920×1080 @ 25fps** — the browser renders progressive frames; the profile's `vf` does +`fps=50` + `tinterlace=interleave_top`. + +**Note:** Fast export pipes **composited JPEG frames** from the browser (`-f image2pipe`), +not `-i source.mp4`. Filters and x264 settings apply to that frame stream. To transcode an +existing file verbatim (your one-liner with `-i source.mp4`), run ffmpeg directly — that is +outside the editor compositor path. + +**Which profile is used (priority):** +1. Profile picked in the Export dialog (one-off; saved to browser settings unless overridden) +2. `project.encodeProfile` — set via UI reload or `{op:"setProject", set:{encodeProfile:"hq"}}` +3. Browser setting `encodeProfile` in localStorage (set when you change the Export dropdown) +4. `default` in `encoding-profiles.json` + +**MCP:** `fablecut_encode_profiles` lists profiles; `{detail:true}` includes full settings; +`{profile:"hq"}` returns one profile. `fablecut_status` shows the effective profile for the project. diff --git a/app.js b/app.js index b412674..35751b6 100644 --- a/app.js +++ b/app.js @@ -195,6 +195,7 @@ function addLiveAudioTrackBuses(ids) { const SETTINGS_KEY = "fablecut-settings"; const DEFAULT_SETTINGS = { linkSelect: false, // timeline ↔ project bin selection sync + encodeProfile: null, // null = server default from encoding-profiles.json }; let settings = { ...DEFAULT_SETTINGS }; function loadSettings() { @@ -235,6 +236,7 @@ const project = { inPoint: null, // timeline work-area IN (seconds), or null outPoint: null, // timeline work-area OUT (seconds), or null disabledTracks: [], // track ids (V4…A3) hidden from preview/export when listed + encodeProfile: null, // optional fast-export profile id (overrides browser setting) }; const state = { time: 0, playing: false, pps: 60, snap: true, @@ -325,6 +327,9 @@ const els = { monitorStage: $("monitorStage"), monitorScroll: $("monitorScroll"), monitorZoomInner: $("monitorZoomInner"), kfGraphs: $("kfGraphs"), exportSetup: $("exportSetup"), engineFast: $("engineFast"), engineRealtime: $("engineRealtime"), + exportProfileRow: $("exportProfileRow"), + exportProfileSel: $("exportProfileSel"), exportProfileNote: $("exportProfileNote"), + exportProfileHint: $("exportProfileHint"), }; const ctx2d = els.preview.getContext("2d"); @@ -436,6 +441,7 @@ async function connectServer() { listenSSE(); fetch("/api/export/ffmpeg").then((r) => r.json()) .then((j) => { state.ffmpeg = !!j.available; }).catch(() => { }); + fetchEncodeProfiles(); } catch { state.connected = false; els.projectName.textContent = project.name + " · ⚪ local session"; @@ -583,6 +589,7 @@ function applyProject(data) { inPoint: wa.inPoint, outPoint: wa.outPoint, disabledTracks, + encodeProfile: data.encodeProfile || null, }); const folderIds = new Set(project.folders.map((f) => f.id)); for (const m of project.media) { @@ -640,24 +647,26 @@ function scheduleSave() { }, 400); } function projectJSON() { - const { name, width, height, fps, background, revision, folders, media, clips, markers, inPoint, outPoint, disabledTracks } = project; - return { + const { name, width, height, fps, background, revision, folders, media, clips, markers, inPoint, outPoint, disabledTracks, encodeProfile } = project; + const out = { name, width, height, fps, background, revision, folders: (folders || []).map(({ id, name, parentId, open }) => ({ id, name, parentId: parentId || null, open: open !== false })), media: media.filter((m) => !m.transient).map(({ id, name, kind, src, duration, width, height, folderId }) => ({ id, name, kind, src, duration, width, height, folderId: folderId || null })), clips: clips.map(({ id, mediaId, kind, track, start, in: inn, duration, name, props, keyframes, transitionIn, transitionOut, linkedId, linkGroup }) => { - const out = { id, mediaId, kind, track, start, in: inn, duration, name, props, keyframes, transitionIn, transitionOut }; - if (linkGroup) out.linkGroup = linkGroup; - if (linkedId) out.linkedId = linkedId; - return out; + const clipOut = { id, mediaId, kind, track, start, in: inn, duration, name, props, keyframes, transitionIn, transitionOut }; + if (linkGroup) clipOut.linkGroup = linkGroup; + if (linkedId) clipOut.linkedId = linkedId; + return clipOut; }), markers: (markers || []).map(({ t, label }) => (label ? { t, label } : { t })), inPoint: inPoint == null ? null : inPoint, outPoint: outPoint == null ? null : outPoint, disabledTracks: normalizeDisabledTracks(disabledTracks), }; + if (encodeProfile) out.encodeProfile = encodeProfile; + return out; } function listenSSE() { const es = new EventSource("/api/events"); @@ -672,6 +681,7 @@ async function syncFromServer(force) { runtime.pendingSync = false; if (state.binTab !== "project") fetchLibrary(state.binTab).then(renderLibrary); loadLibraryFonts(); + fetchEncodeProfiles(); try { const res = await fetch("/api/project", { cache: "no-store" }); if (!res.ok) return; @@ -5198,10 +5208,88 @@ function loop(ts) { /* 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. + mix to the server, where ffmpeg encodes via an encoding profile. – realtime: the original MediaRecorder capture, kept as the fallback for local sessions / servers without ffmpeg. */ +let encodeProfiles = { + default: "delivery", + profiles: { + draft: { + label: "Draft · H.264 fast", + description: "Quick preview — smaller file, faster encode.", + summary: "libx264 preset=veryfast crf=23 · aac 128k · JPEG 85%", + jpegQuality: 0.85, + }, + delivery: { + label: "Delivery · H.264 balanced", + description: "Default export — good quality and compatibility.", + summary: "libx264 preset=fast crf=18 · aac 192k · JPEG 95%", + jpegQuality: 0.95, + }, + hq: { + label: "High quality · H.264 slow", + description: "Best H.264 quality — slower encode, larger file.", + summary: "libx264 preset=slow crf=16 · aac 256k · JPEG 98%", + jpegQuality: 0.98, + }, + }, +}; + +async function fetchEncodeProfiles() { + if (!state.connected) return; + try { + const r = await fetch("/api/export/profiles", { cache: "no-store" }); + if (!r.ok) return; + const data = await r.json(); + if (data?.profiles && Object.keys(data.profiles).length) encodeProfiles = data; + } catch { } +} +function effectiveEncodeProfileId() { + return project.encodeProfile || getSetting("encodeProfile") || encodeProfiles.default || "delivery"; +} +function exportProfileMeta(id) { + return encodeProfiles.profiles[id] || { label: id, summary: id, jpegQuality: 0.95 }; +} +function updateExportProfileNote(id) { + const known = Object.hasOwn(encodeProfiles.profiles, id); + const p = exportProfileMeta(id); + if (els.exportProfileNote) { + els.exportProfileNote.textContent = known + ? [p.description, p.summary].filter(Boolean).join(" — ") + : `"${id}" is not defined in encoding-profiles.json — the export will fail until it is added or another profile is picked.`; + } + if (els.exportProfileHint) { + if (project.encodeProfile) { + els.exportProfileHint.textContent = + "Project default (encodeProfile in project.json). Pick another profile here for a one-off export."; + } else if (getSetting("encodeProfile")) { + els.exportProfileHint.textContent = "Browser default — saved when you change this dropdown."; + } else { + els.exportProfileHint.textContent = "Using server default from encoding-profiles.json."; + } + } +} +function populateExportProfileSelect() { + if (!els.exportProfileSel) return; + const ids = Object.keys(encodeProfiles.profiles); + const cur = effectiveEncodeProfileId(); + // a project/browser default naming a deleted profile must stay visible rather + // than silently falling through to whichever option happens to be first + if (cur && !ids.includes(cur)) ids.unshift(cur); + els.exportProfileSel.innerHTML = ids.map((id) => { + const p = encodeProfiles.profiles[id]; + const sel = id === cur ? " selected" : ""; + const label = p ? (p.label || id) : `${id} (not defined on the server)`; + return ``; + }).join(""); + updateExportProfileNote(els.exportProfileSel.value || cur || "delivery"); +} +function syncExportProfileVisibility() { + const show = els.engineFast.checked && !els.engineFast.disabled; + els.exportProfileRow?.classList.toggle("hidden", !show); +} + function openExportSetup() { if (state.exporting) return; if (!project.clips.length) { alert("Timeline is empty — add some clips first."); return; } @@ -5209,6 +5297,7 @@ function openExportSetup() { els.engineFast.disabled = !fastOk; els.engineFast.checked = fastOk; els.engineRealtime.checked = !fastOk; + syncExportProfileVisibility(); $("engineFastNote").textContent = fastOk ? "Frame-accurate ffmpeg encode. Keeps rendering if you switch tabs." : "Needs the server + ffmpeg on PATH."; @@ -5226,7 +5315,10 @@ function openExportSetup() { warn.textContent = ""; warn.classList.add("hidden"); } - els.exportSetup.classList.remove("hidden"); + fetchEncodeProfiles().then(() => { + populateExportProfileSelect(); + els.exportSetup.classList.remove("hidden"); + }); } function startChosenExport() { els.exportSetup.classList.add("hidden"); @@ -5337,8 +5429,15 @@ async function fastExport() { els.exportTitle.textContent = "Mixing audio…"; const wav = await renderAudioMix(dur); if (renderCancelled) throw new Error("cancelled"); + const profileId = els.exportProfileSel?.value || effectiveEncodeProfileId(); + const jpegQ = exportProfileMeta(profileId).jpegQuality ?? 0.95; const begin = await fetch("/api/export/begin", { - method: "POST", body: JSON.stringify({ fps, name: project.name.replace(/[^\w\- ]+/g, "") || "export" }), + method: "POST", + body: JSON.stringify({ + fps, + name: project.name.replace(/[^\w\- ]+/g, "") || "export", + profile: profileId, + }), }).then((r) => r.json()); if (!begin.id) throw new Error(begin.error || "export begin failed"); sessId = begin.id; @@ -5354,7 +5453,7 @@ async function fastExport() { await seekVideosTo(t); await prepareFrameAssets(t); // exact SVG frames + AI masks drawFrame(t); - const blob = await new Promise((res) => els.preview.toBlob(res, "image/jpeg", 0.95)); + const blob = await new Promise((res) => els.preview.toBlob(res, "image/jpeg", jpegQ)); const r = await fetch("/api/export/frame?id=" + sessId, { method: "POST", body: blob }); if (!r.ok) throw new Error((await r.json()).error || "frame upload failed"); const pct = ((f + 1) / frames) * 100; @@ -5453,6 +5552,16 @@ $("btnDelete").addEventListener("click", () => { }); $("btnExport").addEventListener("click", openExportSetup); $("btnStartExport").addEventListener("click", startChosenExport); +els.exportSetup?.addEventListener("change", (e) => { + if (e.target.name === "engine") syncExportProfileVisibility(); +}); +els.exportProfileSel?.addEventListener("change", (e) => { + const id = e.target.value; + if (!project.encodeProfile) { + setSetting("encodeProfile", id === encodeProfiles.default ? null : id); + } + updateExportProfileNote(id); +}); $("btnCancelSetup").addEventListener("click", () => els.exportSetup.classList.add("hidden")); $("btnCancelExport").addEventListener("click", () => { if (state.rendering) renderCancelled = true; diff --git a/encode-profiles.js b/encode-profiles.js new file mode 100644 index 0000000..0b85f2a --- /dev/null +++ b/encode-profiles.js @@ -0,0 +1,369 @@ +"use strict"; +const fs = require("fs"); +const path = require("path"); + +const PROFILES_FILE = path.join(__dirname, "encoding-profiles.json"); + +const VIDEO_CODECS = new Set(["libx264", "libx265"]); +const VIDEO_PRESETS = new Set([ + "ultrafast", "superfast", "veryfast", "faster", "fast", + "medium", "slow", "slower", "veryslow", +]); +const PIX_FMTS = new Set(["yuv420p", "yuv422p", "yuv444p"]); +const AUDIO_CODECS = new Set(["aac", "libopus"]); +const X264_TUNES = new Set(["film", "animation", "grain", "stillimage", "fastdecode", "zerolatency"]); +const X264_PROFILES = new Set(["baseline", "main", "high", "high10", "high422", "high444"]); +const BITRATE_RE = /^\d+([kKmM])?$/; +const ID_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,31}$/; +const LOGLEVELS = new Set(["quiet", "panic", "fatal", "error", "warning", "info", "verbose", "debug", "trace"]); +/* "mkv" is an extension, not an ffmpeg muxer name — normalized to "matroska" */ +const MUX_FORMAT_ALIAS = { mp4: "mp4", mov: "mov", matroska: "matroska", mkv: "matroska" }; +const FORMAT_EXT = { mp4: ".mp4", mov: ".mov", matroska: ".mkv" }; +const EXT_FORMAT = { ".mp4": "mp4", ".m4v": "mp4", ".mov": "mov", ".mkv": "matroska" }; +const OUT_EXT = new Set([".mp4", ".mov", ".mkv", ".m4v"]); +const SAFE_FFMPEG_STR = /^[\w=.,:/+\-[\]()%| ]{0,512}$/; +/* ffmpeg levels that suppress error text; the frame-pipe pass keeps stderr for + diagnostics only (never shown unless the encode fails), so it is clamped up */ +const QUIET_LEVELS = new Set(["quiet", "panic", "fatal"]); + +const BUILTIN = { + default: "delivery", + profiles: { + draft: { + label: "Draft · H.264 fast", + description: "Quick preview — smaller file, faster encode.", + jpegQuality: 0.85, + video: { codec: "libx264", preset: "veryfast", crf: 23, pixFmt: "yuv420p" }, + audio: { codec: "aac", bitrate: "128k" }, + mux: { movflags: "+faststart" }, + }, + delivery: { + label: "Delivery · H.264 balanced", + description: "Default export — good quality and compatibility.", + jpegQuality: 0.95, + video: { codec: "libx264", preset: "fast", crf: 18, pixFmt: "yuv420p" }, + audio: { codec: "aac", bitrate: "192k" }, + mux: { movflags: "+faststart" }, + }, + hq: { + label: "High quality · H.264 slow", + description: "Best H.264 quality — slower encode, larger file.", + jpegQuality: 0.98, + video: { codec: "libx264", preset: "slow", crf: 16, pixFmt: "yuv420p" }, + audio: { codec: "aac", bitrate: "256k" }, + mux: { movflags: "+faststart" }, + }, + }, +}; + +let cache = null; +let cacheMtime = -1; + +function clampNum(v, fallback, min, max) { + const n = Number(v); + if (!isFinite(n)) return fallback; + return Math.min(max, Math.max(min, n)); +} + +/* like clampNum, but reports a silent clamp — an out-of-range value that gets + quietly corrected is how you end up exporting 8 kHz audio and not knowing */ +function clampReported(v, fallback, min, max, label, warn) { + const n = Number(v); + if (!isFinite(n)) return fallback; + const c = Math.min(max, Math.max(min, n)); + if (c !== n) warn(`${label} ${n} out of range — clamped to ${c}`); + return c; +} + +function validateVideo(v, fallback, warn) { + const out = { ...fallback, ...(v || {}) }; + const drop = (key, why) => { delete out[key]; warn(`video.${key} ignored — ${why}`); }; + if (!VIDEO_CODECS.has(out.codec)) { + warn(`video.codec "${out.codec}" not allowed — using ${fallback.codec}`); + out.codec = fallback.codec; + } + if (!VIDEO_PRESETS.has(out.preset)) { + warn(`video.preset "${out.preset}" not allowed — using ${fallback.preset}`); + out.preset = fallback.preset; + } + out.crf = Math.round(clampReported(out.crf, fallback.crf, 0, 51, "video.crf", warn)); + if (!PIX_FMTS.has(out.pixFmt)) { + warn(`video.pixFmt "${out.pixFmt}" not allowed — using ${fallback.pixFmt}`); + out.pixFmt = fallback.pixFmt; + } + if (out.tune && !X264_TUNES.has(out.tune)) drop("tune", "unknown tune"); + if (out.profile && !X264_PROFILES.has(out.profile)) drop("profile", "unknown H.264 profile"); + if (out.g != null) { + const g = Math.round(clampNum(out.g, 0, 1, 600)); + if (g > 0) out.g = g; else drop("g", "must be 1–600"); + } + if (out.maxrate && !BITRATE_RE.test(String(out.maxrate))) drop("maxrate", 'expected e.g. "60M"'); + if (out.bufsize && !BITRATE_RE.test(String(out.bufsize))) drop("bufsize", 'expected e.g. "70M"'); + for (const key of ["x264opts", "vf"]) { + if (!out[key]) { delete out[key]; continue; } + const s = String(out[key]).trim(); + if (s && SAFE_FFMPEG_STR.test(s)) out[key] = s; + else drop(key, "contains characters outside the allowed ffmpeg option set"); + } + return out; +} + +function validateAudio(a, fallback, warn) { + const out = { ...fallback, ...(a || {}) }; + if (!AUDIO_CODECS.has(out.codec)) { + warn(`audio.codec "${out.codec}" not allowed — using ${fallback.codec}`); + out.codec = fallback.codec; + } + if (!BITRATE_RE.test(String(out.bitrate || ""))) { + warn(`audio.bitrate "${out.bitrate}" invalid — using ${fallback.bitrate}`); + out.bitrate = fallback.bitrate; + } + if (out.sampleRate != null) { + const sr = Math.round(clampReported(out.sampleRate, 0, 8000, 192000, "audio.sampleRate", warn)); + if (sr > 0) out.sampleRate = sr; + else { delete out.sampleRate; warn("audio.sampleRate ignored — must be 8000–192000"); } + } + if (out.strict != null) { + const st = String(out.strict); + if (["-2", "-1", "0", "1", "experimental"].includes(st)) out.strict = st; + else { delete out.strict; warn(`audio.strict "${st}" ignored`); } + } + return out; +} + +function validateMux(m, fallback, warn) { + const given = m && typeof m === "object" ? m : {}; + const out = { ...fallback, ...given }; + // an explicit null/"" clears an inherited value (e.g. drop +faststart) + if ("movflags" in given && (given.movflags === null || given.movflags === "")) delete out.movflags; + else if (out.movflags != null && typeof out.movflags !== "string") out.movflags = fallback.movflags; + if (out.format) { + const fmt = MUX_FORMAT_ALIAS[String(out.format).toLowerCase()]; + if (fmt) out.format = fmt; + else { delete out.format; warn(`mux.format "${given.format}" unknown — using mp4`); } + } + if (out.extension) { + const raw = String(out.extension).toLowerCase(); + const ext = raw.startsWith(".") ? raw : `.${raw}`; + if (OUT_EXT.has(ext)) out.extension = ext; + else { delete out.extension; warn(`mux.extension "${given.extension}" unsupported`); } + } + // a bare extension implies the container; keeps -f and the filename in step + if (!out.format && out.extension) out.format = EXT_FORMAT[out.extension]; + if (out.format && out.extension && EXT_FORMAT[out.extension] !== out.format) + warn(`mux.extension ${out.extension} does not match mux.format ${out.format}`); + return out; +} + +function validateEncode(e, fallback, warn) { + const out = { ...fallback, ...(e || {}) }; + if (out.loglevel && !LOGLEVELS.has(String(out.loglevel))) { + warn(`encode.loglevel "${out.loglevel}" unknown — ignored`); + delete out.loglevel; + } + if (out.hideBanner != null) out.hideBanner = !!out.hideBanner; + if (out.stats != null) out.stats = !!out.stats; + return out; +} + +function normalizeProfile(id, raw, fallback) { + const base = fallback || BUILTIN.profiles.delivery; + const p = raw && typeof raw === "object" ? raw : {}; + const warnings = []; + const warn = (msg) => warnings.push(msg); + const mux = validateMux(p.mux, base.mux, warn); + const audio = validateAudio(p.audio, base.audio, warn); + const container = mux.format || "mp4"; + /* Opus has no MOV mapping at all, and MP4 needs the experimental flag — + without this the mux pass dies only after every frame has been rendered. */ + if (audio.codec === "libopus") { + if (container === "mov") { + warn("audio.codec libopus cannot be stored in MOV — using aac"); + audio.codec = "aac"; + } else if (container === "mp4" && audio.strict == null) { + audio.strict = "-2"; + } + } + return { + id, + label: String(p.label || base.label || id), + description: String(p.description || p.desc || base.description || ""), + jpegQuality: clampReported(p.jpegQuality, base.jpegQuality, 0.5, 1, "jpegQuality", warn), + video: validateVideo(p.video, base.video, warn), + audio, + mux, + encode: validateEncode(p.encode, base.encode || {}, warn), + warnings, + }; +} + +function profilesFileMtime() { + try { return fs.statSync(PROFILES_FILE).mtimeMs; } catch { return 0; } +} + +function loadEncodeProfiles(force) { + /* mtime check instead of a plain memo: the MCP server is long-lived and has + no file watcher, so a cache-only read would serve stale profiles forever */ + const mtime = profilesFileMtime(); + if (cache && !force && mtime === cacheMtime) return cache; + + let file = null; + let fileError = null; + try { + if (fs.existsSync(PROFILES_FILE)) { + file = JSON.parse(fs.readFileSync(PROFILES_FILE, "utf8").replace(/^\uFEFF/, "")); + } + } catch (e) { fileError = `encoding-profiles.json could not be parsed (${e.message}) — using built-in profiles`; } + + const profiles = {}; + for (const [id, raw] of Object.entries(BUILTIN.profiles)) { + profiles[id] = normalizeProfile(id, raw, raw); + } + const issues = fileError ? [fileError] : []; + if (file?.profiles && typeof file.profiles === "object") { + for (const [id, raw] of Object.entries(file.profiles)) { + if (!ID_RE.test(id)) { + issues.push(`profile id "${id}" skipped — must start with a letter and use [A-Za-z0-9_-] only`); + continue; + } + profiles[id] = normalizeProfile(id, raw, profiles[id] || BUILTIN.profiles.delivery); + } + } + for (const p of Object.values(profiles)) + for (const w of p.warnings) issues.push(`${p.id}: ${w}`); + + let defaultId = typeof file?.default === "string" ? file.default : BUILTIN.default; + if (!profiles[defaultId]) { + if (file?.default) issues.push(`default "${file.default}" is not a defined profile — using delivery`); + defaultId = "delivery"; + } + + cache = { default: defaultId, profiles, issues }; + cacheMtime = mtime; + return cache; +} + +function invalidateEncodeProfiles() { + cache = null; + cacheMtime = -1; +} + +function resolveProfile(id) { + const cfg = loadEncodeProfiles(); + const pid = id || cfg.default; + const p = cfg.profiles[pid]; + if (!p) throw new Error(`Unknown encoding profile "${pid}"`); + return p; +} + +function profileSummary(p) { + const v = p.video; + const a = p.audio; + const bits = [`${v.codec} preset=${v.preset} crf=${v.crf}`]; + if (v.g) bits.push(`g=${v.g}`); + if (v.vf) bits.push("vf"); + if (v.x264opts) bits.push("x264opts"); + bits.push(`${a.codec} ${a.bitrate}`); + if (a.sampleRate) bits.push(`${a.sampleRate / 1000}kHz`); + if (p.mux?.format) bits.push(p.mux.format); + bits.push(`JPEG ${Math.round(p.jpegQuality * 100)}%`); + return bits.join(" · "); +} + +function ffmpegGlobalArgs(profile) { + const enc = profile.encode || {}; + const args = []; + if (enc.hideBanner) args.push("-hide_banner"); + if (enc.stats) args.push("-stats"); + if (enc.loglevel) { + /* stderr is captured for failure reporting and never shown otherwise, so a + silencing loglevel would only cost us the reason an export died */ + args.push("-loglevel", QUIET_LEVELS.has(enc.loglevel) ? "error" : enc.loglevel); + } + return args; +} + +function exportContainer(profile) { + return profile.mux?.format || EXT_FORMAT[profile.mux?.extension] || "mp4"; +} + +function exportOutputExtension(profile) { + if (profile.mux?.extension) return profile.mux.extension; + return FORMAT_EXT[exportContainer(profile)] || ".mp4"; +} + +function listProfilesPublic(detail) { + const cfg = loadEncodeProfiles(); + const out = { + default: cfg.default, profiles: {}, + file: path.basename(PROFILES_FILE), + issues: cfg.issues || [], + }; + for (const [id, p] of Object.entries(cfg.profiles)) { + // jpegQuality is needed by the browser to encode frames — always included + const base = { + label: p.label, + description: p.description, + jpegQuality: p.jpegQuality, + extension: exportOutputExtension(p), + summary: profileSummary(p), + }; + out.profiles[id] = detail + ? { ...base, video: p.video, audio: p.audio, mux: p.mux, encode: p.encode, warnings: p.warnings } + : base; + } + return out; +} + +function buildVideoEncodeArgs(profile, fps, outputPath) { + const v = profile.video; + const args = [...ffmpegGlobalArgs(profile), + "-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "-", + "-c:v", v.codec, + ]; + if (v.codec === "libx264" || v.codec === "libx265") { + args.push("-preset", v.preset, "-crf", String(v.crf)); + if (v.g) args.push("-g", String(v.g)); + if (v.maxrate) args.push("-maxrate", v.maxrate); + if (v.bufsize) args.push("-bufsize", v.bufsize); + if (v.x264opts) args.push("-x264opts", v.x264opts); + if (v.tune) args.push("-tune", v.tune); + if (v.profile) args.push("-profile:v", v.profile); + } + if (v.vf) args.push("-vf", v.vf); + args.push("-pix_fmt", v.pixFmt, outputPath); + return args; +} + +function buildMuxArgs(profile, videoPath, wavPath, outPath) { + const a = profile.audio; + const container = exportContainer(profile); + const args = [...ffmpegGlobalArgs(profile), "-y", "-i", videoPath]; + if (wavPath) args.push("-i", wavPath); + args.push("-c:v", "copy"); + if (wavPath) { + args.push("-c:a", a.codec); + args.push("-b:a", a.bitrate); + if (a.sampleRate) args.push("-ar", String(a.sampleRate)); + if (a.strict != null) args.push("-strict", String(a.strict)); + args.push("-shortest"); + } + // -movflags is an MP4/MOV muxer option; Matroska warns and ignores it + if (profile.mux?.movflags && container !== "matroska") + args.push("-movflags", profile.mux.movflags); + args.push("-f", container, outPath); + return args; +} + +module.exports = { + PROFILES_FILE, + loadEncodeProfiles, + invalidateEncodeProfiles, + resolveProfile, + listProfilesPublic, + profileSummary, + buildVideoEncodeArgs, + buildMuxArgs, + exportContainer, + exportOutputExtension, +}; diff --git a/encoding-profiles.json b/encoding-profiles.json new file mode 100644 index 0000000..f70c5c9 --- /dev/null +++ b/encoding-profiles.json @@ -0,0 +1,49 @@ +{ + "default": "delivery", + "profiles": { + "draft": { + "label": "Draft · H.264 fast", + "description": "Quick preview — smaller file, faster encode.", + "jpegQuality": 0.85, + "video": { "codec": "libx264", "preset": "veryfast", "crf": 23, "pixFmt": "yuv420p" }, + "audio": { "codec": "aac", "bitrate": "128k" }, + "mux": { "movflags": "+faststart" } + }, + "delivery": { + "label": "Delivery · H.264 balanced", + "description": "Default export — good quality and compatibility.", + "jpegQuality": 0.95, + "encode": { "hideBanner": true, "loglevel": "info", "stats": true }, + "video": { "codec": "libx264", "preset": "fast", "crf": 18, "pixFmt": "yuv420p" }, + "audio": { "codec": "aac", "bitrate": "192k" }, + "mux": { "movflags": "+faststart" } + }, + "hq": { + "label": "High quality · H.264 slow", + "description": "Best H.264 quality — slower encode, larger file.", + "jpegQuality": 0.98, + "video": { "codec": "libx264", "preset": "slow", "crf": 16, "pixFmt": "yuv420p" }, + "audio": { "codec": "aac", "bitrate": "256k" }, + "mux": { "movflags": "+faststart" } + }, + "broadcast1080i50": { + "label": "Broadcast · 1080i50 MOV", + "description": "Interlaced H.264 for broadcast-style delivery. Set project to 1920×1080 @ 25fps (progressive); export applies 50i + x264 opts.", + "jpegQuality": 0.98, + "encode": { "hideBanner": true, "loglevel": "panic", "stats": true }, + "video": { + "codec": "libx264", + "preset": "veryfast", + "crf": 18, + "pixFmt": "yuv420p", + "g": 10, + "maxrate": "60M", + "bufsize": "70M", + "x264opts": "open_gop=0:interlaced=1:me=hex:subme=2:cabac=1:no-deblock:analyse=all:8x8dct=1:bframes=2:weightp=0:sliced-threads:level=41", + "vf": "scale=1920:1080:interl=-1,fps=50,tinterlace=interleave_top:bypass_il" + }, + "audio": { "codec": "aac", "bitrate": "384k", "sampleRate": 48000, "strict": "-2" }, + "mux": { "format": "mov", "extension": ".mov" } + } + } +} diff --git a/index.html b/index.html index bfbd1b1..520dd76 100644 --- a/index.html +++ b/index.html @@ -161,6 +161,12 @@

Export

Realtime (in-browser)
Plays the timeline once and records it. Keep the tab focused.
+
+ + +

+

+
diff --git a/mcp-server.js b/mcp-server.js index 08fac15..cad2477 100644 --- a/mcp-server.js +++ b/mcp-server.js @@ -7,13 +7,14 @@ Tools: fablecut_status, fablecut_docs, fablecut_get_project, fablecut_set_project, fablecut_patch_project, fablecut_import_media, - fablecut_analyze_reference + fablecut_analyze_reference, fablecut_encode_profiles ═══════════════════════════════════════════════════════════════════════════ */ "use strict"; const fs = require("fs"); const path = require("path"); const http = require("http"); const { spawn, spawnSync } = require("child_process"); +const { loadEncodeProfiles, listProfilesPublic, resolveProfile, profileSummary } = require("./encode-profiles"); const ROOT = __dirname; const PROJECT_FILE = path.join(ROOT, "project.json"); @@ -95,7 +96,7 @@ const TOOLS = [ }, { name: "fablecut_patch_project", - description: "Apply targeted edits to the FableCut project WITHOUT round-tripping the whole document — PREFER THIS over get+set for every edit (it is ~10-100x cheaper in tokens and merge-safe by design: it re-reads the latest document from disk, applies your ops in order, bumps revision once, saves atomically). Ops: {op:'addClip', clip:{…}} (id auto-generated if omitted) · {op:'updateClip', id, set:{…}} · {op:'removeClip', id} · {op:'addMedia', media:{…}} · {op:'removeMedia', id} · {op:'setProject', set:{name|width|height|fps|background|markers|disabledTracks}}. updateClip merge rules: top-level keys are replaced (keyframes/transitionIn/transitionOut wholesale), `props` merges key-by-key, and setting any key to null deletes it. All-or-nothing: an invalid op aborts the whole patch unsaved.", + description: "Apply targeted edits to the FableCut project WITHOUT round-tripping the whole document — PREFER THIS over get+set for every edit (it is ~10-100x cheaper in tokens and merge-safe by design: it re-reads the latest document from disk, applies your ops in order, bumps revision once, saves atomically). Ops: {op:'addClip', clip:{…}} (id auto-generated if omitted) · {op:'updateClip', id, set:{…}} · {op:'removeClip', id} · {op:'addMedia', media:{…}} · {op:'removeMedia', id} · {op:'setProject', set:{name|width|height|fps|background|markers|disabledTracks|encodeProfile}}. updateClip merge rules: top-level keys are replaced (keyframes/transitionIn/transitionOut wholesale), `props` merges key-by-key, and setting any key to null deletes it. All-or-nothing: an invalid op aborts the whole patch unsaved.", inputSchema: { type: "object", properties: { @@ -142,6 +143,17 @@ const TOOLS = [ required: ["path"], }, }, + { + name: "fablecut_encode_profiles", + description: "List ffmpeg encoding profiles for Fast export (from encoding-profiles.json). Use to pick a profile id for project.encodeProfile or to inspect codec/CRF/JPEG settings. Edit encoding-profiles.json on disk to add custom profiles — the server hot-reloads it.", + inputSchema: { + type: "object", + properties: { + detail: { type: "boolean", description: "Include full video/audio/jpeg settings per profile (default: summary only)" }, + profile: { type: "string", description: "Return one profile by id instead of the full list" }, + }, + }, + }, ]; /* ── Tool implementations ── */ @@ -160,14 +172,24 @@ async function callTool(name, args) { return `${d}: ${n}`; }).join(", "); const cap = (arr, n) => arr.length > n ? arr.slice(0, n).concat(`… +${arr.length - n} more`) : arr; + let encLine = ""; + try { + const cfg = loadEncodeProfiles(); + const eff = proj.encodeProfile || cfg.default; + const p = cfg.profiles[eff]; + encLine = p + ? `Export profile: ${eff} (${p.label}) — ${profileSummary(p)}` + : `Export profile: server default "${cfg.default}"`; + } catch { encLine = "Export profile: (encoding-profiles.json unavailable)"; } return [ `Editor server: ${up ? "RUNNING — open " + BASE + " in a browser to watch edits live" : "FAILED TO START (check node / port " + PORT + ")"}`, `Project: "${proj.name}" — ${proj.width}x${proj.height} @ ${proj.fps}fps, ${proj.clips.length} clip(s), ${dur.toFixed(2)}s, revision ${proj.revision}`, + encLine, `Registered media: ${cap(proj.media.map((m) => `${m.id} (${m.kind}, ${m.name}${m.duration ? ", " + m.duration + "s" : ""})`), 25).join("; ") || "none"}`, `Files in media/: ${cap(files, 25).join(", ") || "none"}`, `Library assets (./library): ${libSummary}`, `Project file: ${PROJECT_FILE}`, - `Tips: fablecut_docs (use \`section\`) for the schema · fablecut_get_project {compact:true} to see the timeline · fablecut_patch_project for edits (cheapest).`, + `Tips: fablecut_docs (use \`section\`) for the schema · fablecut_get_project {compact:true} to see the timeline · fablecut_patch_project for edits (cheapest) · fablecut_encode_profiles for export presets.`, ].join("\n"); } case "fablecut_docs": { @@ -298,9 +320,12 @@ async function callTool(name, args) { break; } case "setProject": { - const allowed = ["name", "width", "height", "fps", "background", "markers", "disabledTracks"]; + const allowed = ["name", "width", "height", "fps", "background", "markers", "disabledTracks", "encodeProfile"]; for (const [k, v] of Object.entries(op.set || {})) { if (!allowed.includes(k)) throw new Error(`setProject: '${k}' not settable (allowed: ${allowed.join(", ")})`); + if (k === "encodeProfile" && v != null) { + resolveProfile(String(v)); // validate id exists + } if (v === null) delete proj[k]; else proj[k] = v; } notes.push("~project"); @@ -433,6 +458,25 @@ async function callTool(name, args) { ? "Note: duration unknown (no ffprobe). The browser UI will probe and fill it in; re-read the project before trimming this media." : "Ready to use in clips via mediaId."); } + case "fablecut_encode_profiles": { + if (args.profile) { + const p = resolveProfile(String(args.profile)); + return JSON.stringify({ + default: loadEncodeProfiles().default, + profile: args.profile, + label: p.label, + description: p.description, + jpegQuality: p.jpegQuality, + video: p.video, + audio: p.audio, + mux: p.mux, + encode: p.encode, + warnings: p.warnings, + summary: profileSummary(p), + }, null, 2); + } + return JSON.stringify(listProfilesPublic(!!args.detail), null, 2); + } default: throw new Error("Unknown tool: " + name); } diff --git a/server.js b/server.js index fdf7476..a10984d 100644 --- a/server.js +++ b/server.js @@ -19,6 +19,17 @@ const os = require("os"); const { spawn, spawnSync, execFile } = require("child_process"); const { analyze } = require("./analyze"); +const { + PROFILES_FILE, + loadEncodeProfiles, + invalidateEncodeProfiles, + resolveProfile, + listProfilesPublic, + profileSummary, + buildVideoEncodeArgs, + buildMuxArgs, + exportOutputExtension, +} = require("./encode-profiles"); const ROOT = __dirname; const MEDIA_DIR = path.join(ROOT, "media"); @@ -94,7 +105,14 @@ function onFsChange() { } /* watch the directory, not the file — atomic tmp+rename writes would detach a direct file watcher on Windows */ -try { fs.watch(ROOT, (ev, f) => { if (f === "project.json") onFsChange(); }); } catch {} +try { + fs.watch(ROOT, (ev, f) => { + if (f === "project.json" || f === path.basename(PROFILES_FILE)) { + if (f === path.basename(PROFILES_FILE)) invalidateEncodeProfiles(); + onFsChange(); + } + }); +} catch {} try { fs.watch(MEDIA_DIR, onFsChange); } catch {} for (const d of LIBRARY_SUBDIRS) { try { fs.watch(path.join(LIBRARY_DIR, d), onFsChange); } catch {} @@ -148,25 +166,23 @@ async function faststart(file) { 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. */ const exportSessions = new Map(); -function beginExport(fps, name) { +function beginExport(fps, name, profileId) { + const profile = resolveProfile(profileId); 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"] }); + const proc = spawn("ffmpeg", buildVideoEncodeArgs(profile, fps, 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 sess = { - proc, dir, videoPath, name: safeName(name || "export"), + proc, dir, videoPath, profile, name: safeName(name || "export"), wav: null, err: () => stderr, done: new Promise((res) => proc.on("close", res)), }; exportSessions.set(id, sess); - return id; + return { id, profile: profile.id, label: profile.label, summary: profileSummary(profile) }; } function cleanupExport(id) { const s = exportSessions.get(id); @@ -299,12 +315,23 @@ const server = http.createServer(async (req, res) => { sendJSON(res, 200, { available: HAS_FFMPEG }); return; } + if (p === "/api/export/profiles" && req.method === "GET") { + try { + const detail = url.searchParams.get("detail") === "1"; + sendJSON(res, 200, listProfilesPublic(detail)); + } catch (e) { sendJSON(res, 500, { error: String(e) }); } + return; + } if (p === "/api/export/begin" && req.method === "POST") { if (!HAS_FFMPEG) { sendJSON(res, 400, { error: "ffmpeg not found on PATH" }); return; } try { const opts = JSON.parse((await readBody(req)).toString("utf8") || "{}"); - sendJSON(res, 200, { id: beginExport(opts.fps || 30, opts.name) }); - } catch (e) { sendJSON(res, 500, { error: String(e) }); } + if (opts.profile) resolveProfile(opts.profile); // 400, not 500, on a bad id + sendJSON(res, 200, beginExport(opts.fps || 30, opts.name, opts.profile)); + } catch (e) { + const bad = /^Unknown encoding profile/.test(e.message || ""); + sendJSON(res, bad ? 400 : 500, { error: String(e.message || e) }); + } return; } if (p === "/api/export/frame" && req.method === "POST") { @@ -338,13 +365,13 @@ const server = http.createServer(async (req, res) => { sess.proc.stdin.end(); const code = await sess.done; if (code !== 0) throw new Error("ffmpeg encode failed: " + sess.err()); - const out = uniquePath(EXPORTS_DIR, sess.name.replace(/\.mp4$/i, "") + ".mp4"); + const profile = sess.profile || resolveProfile(); + const ext = exportOutputExtension(profile); + const out = uniquePath(EXPORTS_DIR, sess.name.replace(/\.(mp4|mov|m4v|mkv)$/i, "") + ext); if (sess.wav && fs.existsSync(sess.wav)) - await run("ffmpeg", ["-y", "-i", sess.videoPath, "-i", sess.wav, - "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", - "-movflags", "+faststart", out]); + await run("ffmpeg", buildMuxArgs(profile, sess.videoPath, sess.wav, out)); else - await run("ffmpeg", ["-y", "-i", sess.videoPath, "-c", "copy", "-movflags", "+faststart", out]); + await run("ffmpeg", buildMuxArgs(profile, sess.videoPath, null, out)); cleanupExport(id); sendJSON(res, 200, { ok: true, src: "/exports/" + encodeURIComponent(path.basename(out)) }); } catch (e) { cleanupExport(id); sendJSON(res, 500, { error: String(e) }); } @@ -430,5 +457,9 @@ server.listen(PORT, HOST, () => { console.log(` project file : ${PROJECT_FILE}`); console.log(` media folder : ${MEDIA_DIR}`); console.log(` library : ${LIBRARY_DIR} (${LIBRARY_SUBDIRS.join(", ")})`); - console.log(` ffmpeg : ${HAS_FFMPEG ? "found (fast export + faststart remux on)" : "not found (real-time export only)"}\n`); + console.log(` ffmpeg : ${HAS_FFMPEG ? "found (fast export + faststart remux on)" : "not found (real-time export only)"}`); + const enc = loadEncodeProfiles(true); + console.log(` encode prof. : ${PROFILES_FILE} (${Object.keys(enc.profiles).join(", ")} · default ${enc.default})`); + for (const issue of enc.issues || []) console.log(` ⚠ ${issue}`); + console.log(""); }); diff --git a/style.css b/style.css index b145534..7b90449 100644 --- a/style.css +++ b/style.css @@ -1727,6 +1727,43 @@ body.track-size-s .clip .clip-kf-n { line-height: 1.4; } +.export-profile-row { + margin: 0 0 12px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel-2); +} + +.export-profile-label { + display: block; + font-size: 12px; + font-weight: 600; + margin-bottom: 6px; +} + +.export-profile-sel { + width: 100%; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--panel); + color: var(--text); + font: inherit; + font-size: 13px; +} + +.export-profile-note, +.export-profile-hint { + margin: 6px 0 0; + font-size: 12px; + line-height: 1.4; +} + +.export-profile-hint { + opacity: 0.75; +} + .export-track-warn.hidden { display: none; } From 857af4a24b26598d368b4e7d48304564910f9843 Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 29 Jul 2026 10:37:01 +0300 Subject: [PATCH 2/5] fix: format/extension conflict --- CLAUDE.md | 5 ++++- encode-profiles.js | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3807f52..e1c4295 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -582,7 +582,10 @@ Notes on how these are handled: inherited value. - `mux.format` accepts `mp4` · `mov` · `matroska` (`mkv` is normalized to `matroska`, since `-f mkv` is not a valid muxer name). The output extension follows the container - unless `mux.extension` overrides it. + unless `mux.extension` overrides it with one that matches (`.m4v` for `mp4` is fine). + A conflicting pair is **reconciled, not just flagged**: `format` picks the muxer, so the + extension is corrected to match it (`mov` + `.mp4` → `.mov`) and the change is reported. + Giving only `mux.extension` infers the container from it. - `libopus` is switched to `aac` for MOV (no mapping exists) and gets `-strict -2` automatically in MP4 — otherwise the mux pass would fail *after* every frame is rendered. - A `loglevel` of `quiet`/`panic`/`fatal` is raised to `error` for the encode passes: that diff --git a/encode-profiles.js b/encode-profiles.js index 0b85f2a..b212128 100644 --- a/encode-profiles.js +++ b/encode-profiles.js @@ -140,7 +140,8 @@ function validateMux(m, fallback, warn) { if (out.format) { const fmt = MUX_FORMAT_ALIAS[String(out.format).toLowerCase()]; if (fmt) out.format = fmt; - else { delete out.format; warn(`mux.format "${given.format}" unknown — using mp4`); } + // what replaces it is resolved below (from the extension, else mp4) + else { delete out.format; warn(`mux.format "${given.format}" unknown — ignored (allowed: mp4, mov, matroska)`); } } if (out.extension) { const raw = String(out.extension).toLowerCase(); @@ -150,8 +151,15 @@ function validateMux(m, fallback, warn) { } // a bare extension implies the container; keeps -f and the filename in step if (!out.format && out.extension) out.format = EXT_FORMAT[out.extension]; - if (out.format && out.extension && EXT_FORMAT[out.extension] !== out.format) - warn(`mux.extension ${out.extension} does not match mux.format ${out.format}`); + /* A mismatch is reconciled, not just flagged, so exportContainer() and + exportOutputExtension() can never disagree. `format` selects the muxer and + therefore the bytes written, so it wins and the file is renamed to match — + the alternative would quietly write a different container than requested. */ + if (out.format && out.extension && EXT_FORMAT[out.extension] !== out.format) { + const corrected = FORMAT_EXT[out.format]; + warn(`mux.extension ${out.extension} does not match mux.format ${out.format} — using ${corrected}`); + out.extension = corrected; + } return out; } @@ -173,7 +181,7 @@ function normalizeProfile(id, raw, fallback) { const warn = (msg) => warnings.push(msg); const mux = validateMux(p.mux, base.mux, warn); const audio = validateAudio(p.audio, base.audio, warn); - const container = mux.format || "mp4"; + const container = exportContainer({ mux }); /* Opus has no MOV mapping at all, and MP4 needs the experimental flag — without this the mux pass dies only after every frame has been rendered. */ if (audio.codec === "libopus") { From d3d3cfb8eb5a6583342a677cff60f73ea185c54f Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 29 Jul 2026 10:40:52 +0300 Subject: [PATCH 3/5] fix: mcp profile handling --- mcp-server.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/mcp-server.js b/mcp-server.js index cad2477..290bc2f 100644 --- a/mcp-server.js +++ b/mcp-server.js @@ -175,11 +175,19 @@ async function callTool(name, args) { let encLine = ""; try { const cfg = loadEncodeProfiles(); - const eff = proj.encodeProfile || cfg.default; - const p = cfg.profiles[eff]; - encLine = p - ? `Export profile: ${eff} (${p.label}) — ${profileSummary(p)}` - : `Export profile: server default "${cfg.default}"`; + const describe = (id) => { + const p = cfg.profiles[id]; + return p ? `${id} (${p.label}) — ${profileSummary(p)}` : null; + }; + const pinned = proj.encodeProfile; + if (pinned && !cfg.profiles[pinned]) { + /* a project pinning a since-deleted profile must not read as "nothing + configured" — the bad id and the fallback are two separate facts */ + encLine = `Export profile: project encodeProfile "${pinned}" is NOT DEFINED in encoding-profiles.json` + + ` — falling back to default ${describe(cfg.default) || `"${cfg.default}"`}`; + } else { + encLine = `Export profile: ${describe(pinned || cfg.default) || `server default "${cfg.default}"`}`; + } } catch { encLine = "Export profile: (encoding-profiles.json unavailable)"; } return [ `Editor server: ${up ? "RUNNING — open " + BASE + " in a browser to watch edits live" : "FAILED TO START (check node / port " + PORT + ")"}`, From 7bb795a1ca7d4c3ab49f9fa67a772c7d018f52dd Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 29 Jul 2026 10:44:30 +0300 Subject: [PATCH 4/5] fix: prevent x265 to obtain x264 options --- encode-profiles.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/encode-profiles.js b/encode-profiles.js index b212128..45bc632 100644 --- a/encode-profiles.js +++ b/encode-profiles.js @@ -105,6 +105,8 @@ function validateVideo(v, fallback, warn) { if (s && SAFE_FFMPEG_STR.test(s)) out[key] = s; else drop(key, "contains characters outside the allowed ffmpeg option set"); } + if (out.x264opts && out.codec !== "libx264") + drop("x264opts", `only applies to libx264, not ${out.codec} (which takes -x265-params)`); return out; } @@ -334,7 +336,8 @@ function buildVideoEncodeArgs(profile, fps, outputPath) { if (v.g) args.push("-g", String(v.g)); if (v.maxrate) args.push("-maxrate", v.maxrate); if (v.bufsize) args.push("-bufsize", v.bufsize); - if (v.x264opts) args.push("-x264opts", v.x264opts); + // libx264-only private option (libx265 takes -x265-params instead) + if (v.x264opts && v.codec === "libx264") args.push("-x264opts", v.x264opts); if (v.tune) args.push("-tune", v.tune); if (v.profile) args.push("-profile:v", v.profile); } From c980a5a360ee041a5045368a3c46edd1b1dc4c4a Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 29 Jul 2026 11:18:00 +0300 Subject: [PATCH 5/5] refactor: simplify encoding profiles feature Ignore all the validation of parameters and the baroque processing - if someone is adding a ffmpeg profile, he knows what he is doing --- CLAUDE.md | 100 +++++------ app.js | 11 +- encode-profiles.js | 379 ++++++++++------------------------------- encoding-profiles.json | 68 +++++--- mcp-server.js | 12 +- server.js | 74 +++++--- 6 files changed, 230 insertions(+), 414 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e1c4295..b5bc36b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,8 +24,8 @@ Every Claude Code session then has these tools: - `fablecut_import_media` — copy a local file into `./media/` and register it. - `fablecut_analyze_reference` — turn a reference video into an edit blueprint (shots, beats, BPM, energy, drop) + extract its music. See "Remake a reference video". -- `fablecut_encode_profiles` — list ffmpeg encoding presets from `encoding-profiles.json` - (codec, CRF, JPEG quality). Set `project.encodeProfile` via patch to pin a project default. +- `fablecut_encode_profiles` — list export presets from `encoding-profiles.json` (each is a + raw ffmpeg args list). Set `project.encodeProfile` via patch to pin a project default. ### Token-efficient editing (important for agents) @@ -416,9 +416,11 @@ obvious cuts were missed, raise it if motion is being misread as cuts. - `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}` · `GET /api/export/profiles[?detail=1]` → - `{default, profiles, issues}` · `POST /api/export/begin` `{fps,name,profile?}` → - `{id,profile,label,summary}` (**400** if `profile` is not a defined id) - · `POST /api/export/frame?id=` (JPEG body, in order) · `POST /api/export/audio?id=` (WAV body) + `{default, profiles, issues}` · `POST /api/export/begin` `{fps,name,profile?,hasAudio?}` → + `{id,profile,label,summary}` (**400** if `profile` is not a defined id, or if ffmpeg + rejects its args in the dry run) + · `POST /api/export/frame?id=` (JPEG body, in order) · `POST /api/export/audio?id=` (WAV + body — must be sent before the first frame; ffmpeg is spawned on frame 1) · `POST /api/export/end?id=[&discard=1]` → `{src}` under `/exports/` ## Recipes @@ -532,17 +534,18 @@ guides (▦) to keep captions out of platform UI zones. 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 via an **encoding profile** into `./exports/`) and **Realtime** +AI masks — streams JPEG frames + an offline WAV mix to the server, a single ffmpeg +pass encodes them via an **encoding profile** 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. ### Encoding profiles (`encoding-profiles.json`) -User-editable at the repo root. Defines ffmpeg settings for Fast export. The -server validates all fields (no raw ffmpeg strings from the client). Edit the -file while the server runs — the UI hot-reloads the profile list via SSE. +User-editable at the repo root. A profile is a **raw ffmpeg argument list** plus the +two things that are not ffmpeg arguments: `jpegQuality` (the browser's frame quality) +and `extension` (which names the file and therefore picks the muxer). Edit the file +while the server runs — the UI hot-reloads the profile list via SSE. ```jsonc { @@ -551,62 +554,43 @@ file while the server runs — the UI hot-reloads the profile list via SSE. "draft": { "label": "Draft · H.264 fast", "description": "Quick preview — smaller file, faster encode.", - "jpegQuality": 0.85, // browser JPEG frame quality (0.5–1) - "video": { "codec": "libx264", "preset": "veryfast", "crf": 23, "pixFmt": "yuv420p" }, - "audio": { "codec": "aac", "bitrate": "128k" }, - "mux": { "movflags": "+faststart" } - }, - "delivery": { /* … balanced default … */ }, - "hq": { /* … slow preset, lower CRF … */ } + "jpegQuality": 0.85, // browser JPEG frame quality (0.1–1) + "extension": ".mp4", + "args": ["-c:v", "libx264", "-preset", "veryfast", "-crf", "23", + "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", + "-movflags", "+faststart", "-shortest"] + } } } ``` -**Allowed values:** video codecs `libx264` · `libx265`; presets `ultrafast`…`veryslow`; -CRF 0–51; pixel formats `yuv420p` · `yuv422p` · `yuv444p`; audio `aac` · `libopus` -with bitrates like `192k`. Optional x264 `tune` / `profile` on a profile's `video` object. +Export is **one ffmpeg pass**. The server owns the input side and the output path; +`args` is everything in between, verbatim: -**Advanced fields** (optional — for broadcast / custom ffmpeg): +``` +ffmpeg -y -f image2pipe -framerate -i - [-i audio.wav] exports/ +``` -| block | fields | maps to ffmpeg | -| --- | --- | --- | -| `encode` | `hideBanner`, `stats`, `loglevel` | global flags on both encode passes | -| `video` | `g`, `maxrate`, `bufsize`, `x264opts`, `vf` | GOP, rate cap, x264 opts, filter chain | -| `audio` | `sampleRate`, `strict` | `-ar`, `-strict` (e.g. AAC `-2`) | -| `mux` | `format`, `extension`, `movflags` | `-f mov`, output `.mov`, faststart | - -Notes on how these are handled: - -- **Profiles inherit** from the same-named built-in (or `delivery` for new ids), so a - profile that only sets `video.crf` keeps the rest. Set `"movflags": null` to clear an - inherited value. -- `mux.format` accepts `mp4` · `mov` · `matroska` (`mkv` is normalized to `matroska`, - since `-f mkv` is not a valid muxer name). The output extension follows the container - unless `mux.extension` overrides it with one that matches (`.m4v` for `mp4` is fine). - A conflicting pair is **reconciled, not just flagged**: `format` picks the muxer, so the - extension is corrected to match it (`mov` + `.mp4` → `.mov`) and the change is reported. - Giving only `mux.extension` infers the container from it. -- `libopus` is switched to `aac` for MOV (no mapping exists) and gets `-strict -2` - automatically in MP4 — otherwise the mux pass would fail *after* every frame is rendered. -- A `loglevel` of `quiet`/`panic`/`fatal` is raised to `error` for the encode passes: that - stderr is only ever read back to report *why* an export failed. -- Anything invalid is **corrected and reported**, never silently applied. Rejected and - clamped values are listed on server startup, in `GET /api/export/profiles` (`issues`), - and per profile via `fablecut_encode_profiles {detail:true}` (`warnings`). -- `vf` / `x264opts` are restricted to `[\w=.,:/+\-[\]()%| ]`, so filters needing quotes - (e.g. `drawtext=text='hi'`) are refused. Do that work in the timeline instead. +- **There is no allow-list.** Any codec, filter, container or flag your local ffmpeg + supports works — ProRes, DNxHD, NVENC/QSV/VideoToolbox, VP9/AV1, 10-bit, HDR tags. + See the shipped `prores422` and `broadcast1080i50` profiles. +- **Args are validated by ffmpeg itself**, not by a schema: when an export starts the + server dry-runs the profile against a 0.1 s synthetic input (`lavfi`). A typo or an + encoder your build lacks is rejected up front with ffmpeg's own message, instead of + failing after every frame has been rendered. +- Use the **array form** — each element is passed to `spawn` untouched, so no quoting + is needed (`["-vf", "drawtext=text='hi there'"]` just works). A plain string is + accepted and split on whitespace. +- Nothing is injected for you: `+faststart`, `-shortest`, `-strict -2` for Opus in MP4 + and pixel-format choices are all yours to write. - Frames arrive as **JPEG (4:2:0)**, so `yuv422p`/`yuv444p` cannot recover chroma the source never had; raise `jpegQuality` before reaching for a wider pixel format. - -Example **`broadcast1080i50`** (included in `encoding-profiles.json`) builds an interlaced -1080i50 `.mov` with your x264 opts, `tinterlace`, 384k AAC @ 48 kHz. Set the project to -**1920×1080 @ 25fps** — the browser renders progressive frames; the profile's `vf` does -`fps=50` + `tinterlace=interleave_top`. +- The audio mix is only present when the timeline has audio; with no audio there is a + single input, so avoid hardcoded `-map 1:a`. **Note:** Fast export pipes **composited JPEG frames** from the browser (`-f image2pipe`), -not `-i source.mp4`. Filters and x264 settings apply to that frame stream. To transcode an -existing file verbatim (your one-liner with `-i source.mp4`), run ffmpeg directly — that is -outside the editor compositor path. +not `-i source.mp4`. Filters and codec settings apply to that frame stream. To transcode +an existing file verbatim, run ffmpeg directly — that is outside the compositor path. **Which profile is used (priority):** 1. Profile picked in the Export dialog (one-off; saved to browser settings unless overridden) @@ -614,5 +598,5 @@ outside the editor compositor path. 3. Browser setting `encodeProfile` in localStorage (set when you change the Export dropdown) 4. `default` in `encoding-profiles.json` -**MCP:** `fablecut_encode_profiles` lists profiles; `{detail:true}` includes full settings; -`{profile:"hq"}` returns one profile. `fablecut_status` shows the effective profile for the project. +**MCP:** `fablecut_encode_profiles` lists profiles; `{detail:true}` includes each `args` +array; `{profile:"hq"}` returns one profile. `fablecut_status` shows the effective profile. diff --git a/app.js b/app.js index 35751b6..72c8979 100644 --- a/app.js +++ b/app.js @@ -5212,25 +5212,27 @@ function loop(ts) { – realtime: the original MediaRecorder capture, kept as the fallback for local sessions / servers without ffmpeg. */ +/* Placeholder until /api/export/profiles answers — the real list (and the real + ffmpeg args) always comes from encoding-profiles.json on the server. */ let encodeProfiles = { default: "delivery", profiles: { draft: { label: "Draft · H.264 fast", description: "Quick preview — smaller file, faster encode.", - summary: "libx264 preset=veryfast crf=23 · aac 128k · JPEG 85%", + summary: "-c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 128k", jpegQuality: 0.85, }, delivery: { label: "Delivery · H.264 balanced", description: "Default export — good quality and compatibility.", - summary: "libx264 preset=fast crf=18 · aac 192k · JPEG 95%", + summary: "-c:v libx264 -preset fast -crf 18 -c:a aac -b:a 192k", jpegQuality: 0.95, }, hq: { label: "High quality · H.264 slow", description: "Best H.264 quality — slower encode, larger file.", - summary: "libx264 preset=slow crf=16 · aac 256k · JPEG 98%", + summary: "-c:v libx264 -preset slow -crf 16 -c:a aac -b:a 256k", jpegQuality: 0.98, }, }, @@ -5437,6 +5439,9 @@ async function fastExport() { fps, name: project.name.replace(/[^\w\- ]+/g, "") || "export", profile: profileId, + // lets the server dry-run the profile with the same input count we + // will actually feed it, so -map based profiles are checked correctly + hasAudio: !!wav, }), }).then((r) => r.json()); if (!begin.id) throw new Error(begin.error || "export begin failed"); diff --git a/encode-profiles.js b/encode-profiles.js index 45bc632..447aa06 100644 --- a/encode-profiles.js +++ b/encode-profiles.js @@ -1,209 +1,59 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + Encoding profiles — user-editable ffmpeg settings for Fast export. + + A profile is a raw ffmpeg argument list plus the two things that are NOT + ffmpeg arguments: the browser's JPEG frame quality and the output extension. + There is deliberately no allow-list — encoding-profiles.json is a local file + the user owns, the browser only ever sends a profile *id*, and args are + passed to spawn() as an array (no shell), so validating codec names would buy + nothing but a smaller set of usable formats. Typos are caught by dryRunProfile + against the real ffmpeg build instead, which also knows which encoders it has. + + Export is ONE ffmpeg pass; this module owns the input side and the output + path, the profile owns everything in between: + + ffmpeg -y -f image2pipe -framerate -i - [-i audio.wav] + + ═══════════════════════════════════════════════════════════════════════════ */ "use strict"; const fs = require("fs"); +const os = require("os"); const path = require("path"); +const { spawnSync } = require("child_process"); const PROFILES_FILE = path.join(__dirname, "encoding-profiles.json"); -const VIDEO_CODECS = new Set(["libx264", "libx265"]); -const VIDEO_PRESETS = new Set([ - "ultrafast", "superfast", "veryfast", "faster", "fast", - "medium", "slow", "slower", "veryslow", -]); -const PIX_FMTS = new Set(["yuv420p", "yuv422p", "yuv444p"]); -const AUDIO_CODECS = new Set(["aac", "libopus"]); -const X264_TUNES = new Set(["film", "animation", "grain", "stillimage", "fastdecode", "zerolatency"]); -const X264_PROFILES = new Set(["baseline", "main", "high", "high10", "high422", "high444"]); -const BITRATE_RE = /^\d+([kKmM])?$/; -const ID_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,31}$/; -const LOGLEVELS = new Set(["quiet", "panic", "fatal", "error", "warning", "info", "verbose", "debug", "trace"]); -/* "mkv" is an extension, not an ffmpeg muxer name — normalized to "matroska" */ -const MUX_FORMAT_ALIAS = { mp4: "mp4", mov: "mov", matroska: "matroska", mkv: "matroska" }; -const FORMAT_EXT = { mp4: ".mp4", mov: ".mov", matroska: ".mkv" }; -const EXT_FORMAT = { ".mp4": "mp4", ".m4v": "mp4", ".mov": "mov", ".mkv": "matroska" }; -const OUT_EXT = new Set([".mp4", ".mov", ".mkv", ".m4v"]); -const SAFE_FFMPEG_STR = /^[\w=.,:/+\-[\]()%| ]{0,512}$/; -/* ffmpeg levels that suppress error text; the frame-pipe pass keeps stderr for - diagnostics only (never shown unless the encode fails), so it is clamped up */ -const QUIET_LEVELS = new Set(["quiet", "panic", "fatal"]); - +/* Used when encoding-profiles.json is missing or unparseable, so export still + works out of the box. Not a merge base: profiles are taken as written. */ +const BUILTIN_ID = "delivery"; const BUILTIN = { - default: "delivery", - profiles: { - draft: { - label: "Draft · H.264 fast", - description: "Quick preview — smaller file, faster encode.", - jpegQuality: 0.85, - video: { codec: "libx264", preset: "veryfast", crf: 23, pixFmt: "yuv420p" }, - audio: { codec: "aac", bitrate: "128k" }, - mux: { movflags: "+faststart" }, - }, - delivery: { - label: "Delivery · H.264 balanced", - description: "Default export — good quality and compatibility.", - jpegQuality: 0.95, - video: { codec: "libx264", preset: "fast", crf: 18, pixFmt: "yuv420p" }, - audio: { codec: "aac", bitrate: "192k" }, - mux: { movflags: "+faststart" }, - }, - hq: { - label: "High quality · H.264 slow", - description: "Best H.264 quality — slower encode, larger file.", - jpegQuality: 0.98, - video: { codec: "libx264", preset: "slow", crf: 16, pixFmt: "yuv420p" }, - audio: { codec: "aac", bitrate: "256k" }, - mux: { movflags: "+faststart" }, - }, - }, + label: "Delivery · H.264 balanced", + description: "Built-in fallback — good quality and compatibility.", + jpegQuality: 0.95, + extension: ".mp4", + args: ["-c:v", "libx264", "-preset", "fast", "-crf", "18", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", "-shortest"], }; let cache = null; let cacheMtime = -1; -function clampNum(v, fallback, min, max) { - const n = Number(v); - if (!isFinite(n)) return fallback; - return Math.min(max, Math.max(min, n)); -} - -/* like clampNum, but reports a silent clamp — an out-of-range value that gets - quietly corrected is how you end up exporting 8 kHz audio and not knowing */ -function clampReported(v, fallback, min, max, label, warn) { - const n = Number(v); - if (!isFinite(n)) return fallback; - const c = Math.min(max, Math.max(min, n)); - if (c !== n) warn(`${label} ${n} out of range — clamped to ${c}`); - return c; -} - -function validateVideo(v, fallback, warn) { - const out = { ...fallback, ...(v || {}) }; - const drop = (key, why) => { delete out[key]; warn(`video.${key} ignored — ${why}`); }; - if (!VIDEO_CODECS.has(out.codec)) { - warn(`video.codec "${out.codec}" not allowed — using ${fallback.codec}`); - out.codec = fallback.codec; - } - if (!VIDEO_PRESETS.has(out.preset)) { - warn(`video.preset "${out.preset}" not allowed — using ${fallback.preset}`); - out.preset = fallback.preset; - } - out.crf = Math.round(clampReported(out.crf, fallback.crf, 0, 51, "video.crf", warn)); - if (!PIX_FMTS.has(out.pixFmt)) { - warn(`video.pixFmt "${out.pixFmt}" not allowed — using ${fallback.pixFmt}`); - out.pixFmt = fallback.pixFmt; - } - if (out.tune && !X264_TUNES.has(out.tune)) drop("tune", "unknown tune"); - if (out.profile && !X264_PROFILES.has(out.profile)) drop("profile", "unknown H.264 profile"); - if (out.g != null) { - const g = Math.round(clampNum(out.g, 0, 1, 600)); - if (g > 0) out.g = g; else drop("g", "must be 1–600"); - } - if (out.maxrate && !BITRATE_RE.test(String(out.maxrate))) drop("maxrate", 'expected e.g. "60M"'); - if (out.bufsize && !BITRATE_RE.test(String(out.bufsize))) drop("bufsize", 'expected e.g. "70M"'); - for (const key of ["x264opts", "vf"]) { - if (!out[key]) { delete out[key]; continue; } - const s = String(out[key]).trim(); - if (s && SAFE_FFMPEG_STR.test(s)) out[key] = s; - else drop(key, "contains characters outside the allowed ffmpeg option set"); - } - if (out.x264opts && out.codec !== "libx264") - drop("x264opts", `only applies to libx264, not ${out.codec} (which takes -x265-params)`); - return out; -} - -function validateAudio(a, fallback, warn) { - const out = { ...fallback, ...(a || {}) }; - if (!AUDIO_CODECS.has(out.codec)) { - warn(`audio.codec "${out.codec}" not allowed — using ${fallback.codec}`); - out.codec = fallback.codec; - } - if (!BITRATE_RE.test(String(out.bitrate || ""))) { - warn(`audio.bitrate "${out.bitrate}" invalid — using ${fallback.bitrate}`); - out.bitrate = fallback.bitrate; - } - if (out.sampleRate != null) { - const sr = Math.round(clampReported(out.sampleRate, 0, 8000, 192000, "audio.sampleRate", warn)); - if (sr > 0) out.sampleRate = sr; - else { delete out.sampleRate; warn("audio.sampleRate ignored — must be 8000–192000"); } - } - if (out.strict != null) { - const st = String(out.strict); - if (["-2", "-1", "0", "1", "experimental"].includes(st)) out.strict = st; - else { delete out.strict; warn(`audio.strict "${st}" ignored`); } - } - return out; -} - -function validateMux(m, fallback, warn) { - const given = m && typeof m === "object" ? m : {}; - const out = { ...fallback, ...given }; - // an explicit null/"" clears an inherited value (e.g. drop +faststart) - if ("movflags" in given && (given.movflags === null || given.movflags === "")) delete out.movflags; - else if (out.movflags != null && typeof out.movflags !== "string") out.movflags = fallback.movflags; - if (out.format) { - const fmt = MUX_FORMAT_ALIAS[String(out.format).toLowerCase()]; - if (fmt) out.format = fmt; - // what replaces it is resolved below (from the extension, else mp4) - else { delete out.format; warn(`mux.format "${given.format}" unknown — ignored (allowed: mp4, mov, matroska)`); } - } - if (out.extension) { - const raw = String(out.extension).toLowerCase(); - const ext = raw.startsWith(".") ? raw : `.${raw}`; - if (OUT_EXT.has(ext)) out.extension = ext; - else { delete out.extension; warn(`mux.extension "${given.extension}" unsupported`); } - } - // a bare extension implies the container; keeps -f and the filename in step - if (!out.format && out.extension) out.format = EXT_FORMAT[out.extension]; - /* A mismatch is reconciled, not just flagged, so exportContainer() and - exportOutputExtension() can never disagree. `format` selects the muxer and - therefore the bytes written, so it wins and the file is renamed to match — - the alternative would quietly write a different container than requested. */ - if (out.format && out.extension && EXT_FORMAT[out.extension] !== out.format) { - const corrected = FORMAT_EXT[out.format]; - warn(`mux.extension ${out.extension} does not match mux.format ${out.format} — using ${corrected}`); - out.extension = corrected; - } - return out; -} - -function validateEncode(e, fallback, warn) { - const out = { ...fallback, ...(e || {}) }; - if (out.loglevel && !LOGLEVELS.has(String(out.loglevel))) { - warn(`encode.loglevel "${out.loglevel}" unknown — ignored`); - delete out.loglevel; - } - if (out.hideBanner != null) out.hideBanner = !!out.hideBanner; - if (out.stats != null) out.stats = !!out.stats; - return out; -} - -function normalizeProfile(id, raw, fallback) { - const base = fallback || BUILTIN.profiles.delivery; +function normalizeProfile(id, raw) { const p = raw && typeof raw === "object" ? raw : {}; - const warnings = []; - const warn = (msg) => warnings.push(msg); - const mux = validateMux(p.mux, base.mux, warn); - const audio = validateAudio(p.audio, base.audio, warn); - const container = exportContainer({ mux }); - /* Opus has no MOV mapping at all, and MP4 needs the experimental flag — - without this the mux pass dies only after every frame has been rendered. */ - if (audio.codec === "libopus") { - if (container === "mov") { - warn("audio.codec libopus cannot be stored in MOV — using aac"); - audio.codec = "aac"; - } else if (container === "mp4" && audio.strict == null) { - audio.strict = "-2"; - } - } + // a string is split on whitespace as a convenience; the array form is + // canonical because it needs no quoting (e.g. -vf drawtext=text='hi') + const args = Array.isArray(p.args) + ? p.args.map(String) + : String(p.args || "").split(/\s+/).filter(Boolean); + const ext = String(p.extension || BUILTIN.extension).trim().toLowerCase(); + const q = Number(p.jpegQuality); return { id, - label: String(p.label || base.label || id), - description: String(p.description || p.desc || base.description || ""), - jpegQuality: clampReported(p.jpegQuality, base.jpegQuality, 0.5, 1, "jpegQuality", warn), - video: validateVideo(p.video, base.video, warn), - audio, - mux, - encode: validateEncode(p.encode, base.encode || {}, warn), - warnings, + label: String(p.label || id), + description: String(p.description || p.desc || ""), + jpegQuality: q >= 0.1 && q <= 1 ? q : BUILTIN.jpegQuality, + extension: ext.startsWith(".") ? ext : `.${ext}`, + args, }; } @@ -218,34 +68,27 @@ function loadEncodeProfiles(force) { if (cache && !force && mtime === cacheMtime) return cache; let file = null; - let fileError = null; + const issues = []; try { - if (fs.existsSync(PROFILES_FILE)) { + if (fs.existsSync(PROFILES_FILE)) file = JSON.parse(fs.readFileSync(PROFILES_FILE, "utf8").replace(/^\uFEFF/, "")); - } - } catch (e) { fileError = `encoding-profiles.json could not be parsed (${e.message}) — using built-in profiles`; } + else issues.push(`${path.basename(PROFILES_FILE)} not found — using the built-in profile`); + } catch (e) { + issues.push(`${path.basename(PROFILES_FILE)} could not be parsed (${e.message}) — using the built-in profile`); + } const profiles = {}; - for (const [id, raw] of Object.entries(BUILTIN.profiles)) { - profiles[id] = normalizeProfile(id, raw, raw); - } - const issues = fileError ? [fileError] : []; - if (file?.profiles && typeof file.profiles === "object") { - for (const [id, raw] of Object.entries(file.profiles)) { - if (!ID_RE.test(id)) { - issues.push(`profile id "${id}" skipped — must start with a letter and use [A-Za-z0-9_-] only`); - continue; - } - profiles[id] = normalizeProfile(id, raw, profiles[id] || BUILTIN.profiles.delivery); - } - } - for (const p of Object.values(profiles)) - for (const w of p.warnings) issues.push(`${p.id}: ${w}`); + if (file?.profiles && typeof file.profiles === "object") + for (const [id, raw] of Object.entries(file.profiles)) profiles[id] = normalizeProfile(id, raw); + for (const [id, p] of Object.entries(profiles)) + if (!p.args.length) issues.push(`profile "${id}" has no args — ffmpeg will pick its own defaults`); + if (!Object.keys(profiles).length) profiles[BUILTIN_ID] = normalizeProfile(BUILTIN_ID, BUILTIN); - let defaultId = typeof file?.default === "string" ? file.default : BUILTIN.default; + let defaultId = typeof file?.default === "string" ? file.default : BUILTIN_ID; if (!profiles[defaultId]) { - if (file?.default) issues.push(`default "${file.default}" is not a defined profile — using delivery`); - defaultId = "delivery"; + const first = Object.keys(profiles)[0]; + if (file?.default) issues.push(`default "${file.default}" is not a defined profile — using ${first}`); + defaultId = first; } cache = { default: defaultId, profiles, issues }; @@ -266,40 +109,9 @@ function resolveProfile(id) { return p; } -function profileSummary(p) { - const v = p.video; - const a = p.audio; - const bits = [`${v.codec} preset=${v.preset} crf=${v.crf}`]; - if (v.g) bits.push(`g=${v.g}`); - if (v.vf) bits.push("vf"); - if (v.x264opts) bits.push("x264opts"); - bits.push(`${a.codec} ${a.bitrate}`); - if (a.sampleRate) bits.push(`${a.sampleRate / 1000}kHz`); - if (p.mux?.format) bits.push(p.mux.format); - bits.push(`JPEG ${Math.round(p.jpegQuality * 100)}%`); - return bits.join(" · "); -} - -function ffmpegGlobalArgs(profile) { - const enc = profile.encode || {}; - const args = []; - if (enc.hideBanner) args.push("-hide_banner"); - if (enc.stats) args.push("-stats"); - if (enc.loglevel) { - /* stderr is captured for failure reporting and never shown otherwise, so a - silencing loglevel would only cost us the reason an export died */ - args.push("-loglevel", QUIET_LEVELS.has(enc.loglevel) ? "error" : enc.loglevel); - } - return args; -} - -function exportContainer(profile) { - return profile.mux?.format || EXT_FORMAT[profile.mux?.extension] || "mp4"; -} - -function exportOutputExtension(profile) { - if (profile.mux?.extension) return profile.mux.extension; - return FORMAT_EXT[exportContainer(profile)] || ".mp4"; +function profileSummary(p, max = 120) { + const s = (p.args || []).join(" ") || "(no args — ffmpeg defaults)"; + return s.length > max ? s.slice(0, max - 1) + "…" : s; } function listProfilesPublic(detail) { @@ -315,55 +127,44 @@ function listProfilesPublic(detail) { label: p.label, description: p.description, jpegQuality: p.jpegQuality, - extension: exportOutputExtension(p), + extension: p.extension, summary: profileSummary(p), }; - out.profiles[id] = detail - ? { ...base, video: p.video, audio: p.audio, mux: p.mux, encode: p.encode, warnings: p.warnings } - : base; + out.profiles[id] = detail ? { ...base, args: p.args } : base; } return out; } -function buildVideoEncodeArgs(profile, fps, outputPath) { - const v = profile.video; - const args = [...ffmpegGlobalArgs(profile), - "-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "-", - "-c:v", v.codec, - ]; - if (v.codec === "libx264" || v.codec === "libx265") { - args.push("-preset", v.preset, "-crf", String(v.crf)); - if (v.g) args.push("-g", String(v.g)); - if (v.maxrate) args.push("-maxrate", v.maxrate); - if (v.bufsize) args.push("-bufsize", v.bufsize); - // libx264-only private option (libx265 takes -x265-params instead) - if (v.x264opts && v.codec === "libx264") args.push("-x264opts", v.x264opts); - if (v.tune) args.push("-tune", v.tune); - if (v.profile) args.push("-profile:v", v.profile); - } - if (v.vf) args.push("-vf", v.vf); - args.push("-pix_fmt", v.pixFmt, outputPath); +/* The single export pass. Frames arrive on stdin as a JPEG stream; the audio + mix (when the timeline has any) is already on disk by the time we spawn. */ +function buildExportArgs(profile, { fps, wavPath, outPath }) { + // -hide_banner so a failure's stderr tail is the actual error, not the build config + const args = ["-y", "-hide_banner", "-f", "image2pipe", "-framerate", String(fps), "-i", "-"]; + if (wavPath) args.push("-i", wavPath); + args.push(...profile.args, outPath); return args; } -function buildMuxArgs(profile, videoPath, wavPath, outPath) { - const a = profile.audio; - const container = exportContainer(profile); - const args = [...ffmpegGlobalArgs(profile), "-y", "-i", videoPath]; - if (wavPath) args.push("-i", wavPath); - args.push("-c:v", "copy"); - if (wavPath) { - args.push("-c:a", a.codec); - args.push("-b:a", a.bitrate); - if (a.sampleRate) args.push("-ar", String(a.sampleRate)); - if (a.strict != null) args.push("-strict", String(a.strict)); - args.push("-shortest"); +/* Run the profile's args once against a synthetic input before the browser + renders anything. Without this a typo (or an encoder this ffmpeg build lacks) + would only surface when ffmpeg exits — i.e. after every frame was rendered. */ +function dryRunProfile(profile, { fps = 30, hasAudio = true } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fablecut-dry-")); + const out = path.join(dir, `probe${profile.extension}`); + const args = ["-y", "-hide_banner", "-f", "lavfi", + "-i", `color=c=black:s=64x64:r=${fps}:d=0.1`]; + if (hasAudio) args.push("-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"); + args.push("-t", "0.1", ...profile.args, out); + try { + const r = spawnSync("ffmpeg", args, { encoding: "utf8" }); + if (r.error) return { ok: false, error: r.error.message }; + if (r.status === 0) return { ok: true }; + // ffmpeg puts the actual complaint in the last lines of stderr + const lines = String(r.stderr || "").trim().split("\n").filter(Boolean); + return { ok: false, error: lines.slice(-3).join(" · ") || `ffmpeg exited ${r.status}` }; + } finally { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { } } - // -movflags is an MP4/MOV muxer option; Matroska warns and ignores it - if (profile.mux?.movflags && container !== "matroska") - args.push("-movflags", profile.mux.movflags); - args.push("-f", container, outPath); - return args; } module.exports = { @@ -373,8 +174,6 @@ module.exports = { resolveProfile, listProfilesPublic, profileSummary, - buildVideoEncodeArgs, - buildMuxArgs, - exportContainer, - exportOutputExtension, + buildExportArgs, + dryRunProfile, }; diff --git a/encoding-profiles.json b/encoding-profiles.json index f70c5c9..8b620d0 100644 --- a/encoding-profiles.json +++ b/encoding-profiles.json @@ -5,45 +5,59 @@ "label": "Draft · H.264 fast", "description": "Quick preview — smaller file, faster encode.", "jpegQuality": 0.85, - "video": { "codec": "libx264", "preset": "veryfast", "crf": 23, "pixFmt": "yuv420p" }, - "audio": { "codec": "aac", "bitrate": "128k" }, - "mux": { "movflags": "+faststart" } + "extension": ".mp4", + "args": [ + "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "128k", + "-movflags", "+faststart", "-shortest" + ] }, "delivery": { "label": "Delivery · H.264 balanced", "description": "Default export — good quality and compatibility.", "jpegQuality": 0.95, - "encode": { "hideBanner": true, "loglevel": "info", "stats": true }, - "video": { "codec": "libx264", "preset": "fast", "crf": 18, "pixFmt": "yuv420p" }, - "audio": { "codec": "aac", "bitrate": "192k" }, - "mux": { "movflags": "+faststart" } + "extension": ".mp4", + "args": [ + "-c:v", "libx264", "-preset", "fast", "-crf", "18", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "192k", + "-movflags", "+faststart", "-shortest" + ] }, "hq": { - "label": "High quality · H.264 slow", - "description": "Best H.264 quality — slower encode, larger file.", + "label": "High quality · H.264 4:2:2 10-bit slow", + "description": "Best H.264 quality — High 4:2:2 profile, 10-bit. Slower encode, larger file; for mastering and grading round-trips, not for browser or QuickTime playback.", "jpegQuality": 0.98, - "video": { "codec": "libx264", "preset": "slow", "crf": 16, "pixFmt": "yuv420p" }, - "audio": { "codec": "aac", "bitrate": "256k" }, - "mux": { "movflags": "+faststart" } + "extension": ".mp4", + "args": [ + "-c:v", "libx264", "-preset", "slow", "-crf", "16", "-pix_fmt", "yuv422p10le", + "-c:a", "aac", "-b:a", "256k", + "-movflags", "+faststart", "-shortest" + ] }, "broadcast1080i50": { "label": "Broadcast · 1080i50 MOV", - "description": "Interlaced H.264 for broadcast-style delivery. Set project to 1920×1080 @ 25fps (progressive); export applies 50i + x264 opts.", + "description": "Interlaced H.264 for broadcast-style delivery. Set the project to 1920x1080 @ 25fps (progressive); the filter chain builds the 50i field order.", "jpegQuality": 0.98, - "encode": { "hideBanner": true, "loglevel": "panic", "stats": true }, - "video": { - "codec": "libx264", - "preset": "veryfast", - "crf": 18, - "pixFmt": "yuv420p", - "g": 10, - "maxrate": "60M", - "bufsize": "70M", - "x264opts": "open_gop=0:interlaced=1:me=hex:subme=2:cabac=1:no-deblock:analyse=all:8x8dct=1:bframes=2:weightp=0:sliced-threads:level=41", - "vf": "scale=1920:1080:interl=-1,fps=50,tinterlace=interleave_top:bypass_il" - }, - "audio": { "codec": "aac", "bitrate": "384k", "sampleRate": 48000, "strict": "-2" }, - "mux": { "format": "mov", "extension": ".mov" } + "extension": ".mov", + "args": [ + "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-pix_fmt", "yuv420p", + "-g", "10", "-maxrate", "60M", "-bufsize", "70M", + "-x264opts", "open_gop=0:interlaced=1:me=hex:subme=2:cabac=1:no-deblock:analyse=all:8x8dct=1:bframes=2:weightp=0:sliced-threads:level=41", + "-vf", "scale=1920:1080:interl=-1,fps=50,tinterlace=interleave_top:bypass_il", + "-c:a", "aac", "-b:a", "384k", "-ar", "48000", "-strict", "-2", + "-shortest" + ] + }, + "prores422": { + "label": "ProRes 422 HQ · MOV - do not use it", + "description": "Mezzanine / round-trip master. Large files, edits well anywhere.", + "jpegQuality": 1, + "extension": ".mov", + "args": [ + "-c:v", "prores_ks", "-profile:v", "3", "-pix_fmt", "yuv422p10le", + "-c:a", "pcm_s16le", + "-shortest" + ] } } } diff --git a/mcp-server.js b/mcp-server.js index 290bc2f..e935552 100644 --- a/mcp-server.js +++ b/mcp-server.js @@ -145,11 +145,11 @@ const TOOLS = [ }, { name: "fablecut_encode_profiles", - description: "List ffmpeg encoding profiles for Fast export (from encoding-profiles.json). Use to pick a profile id for project.encodeProfile or to inspect codec/CRF/JPEG settings. Edit encoding-profiles.json on disk to add custom profiles — the server hot-reloads it.", + description: "List ffmpeg encoding profiles for Fast export (from encoding-profiles.json). Each profile is a raw ffmpeg argument list plus jpegQuality (browser frame quality) and extension (output container). Use to pick a profile id for project.encodeProfile. Edit encoding-profiles.json on disk to add custom profiles — anything the local ffmpeg supports works, and the server hot-reloads the file. Profiles are dry-run against ffmpeg when an export starts, so a bad argument is rejected before rendering.", inputSchema: { type: "object", properties: { - detail: { type: "boolean", description: "Include full video/audio/jpeg settings per profile (default: summary only)" }, + detail: { type: "boolean", description: "Include the full ffmpeg args array per profile (default: a truncated summary)" }, profile: { type: "string", description: "Return one profile by id instead of the full list" }, }, }, @@ -475,12 +475,8 @@ async function callTool(name, args) { label: p.label, description: p.description, jpegQuality: p.jpegQuality, - video: p.video, - audio: p.audio, - mux: p.mux, - encode: p.encode, - warnings: p.warnings, - summary: profileSummary(p), + extension: p.extension, + args: p.args, }, null, 2); } return JSON.stringify(listProfilesPublic(!!args.detail), null, 2); diff --git a/server.js b/server.js index a10984d..425d5e0 100644 --- a/server.js +++ b/server.js @@ -26,9 +26,8 @@ const { resolveProfile, listProfilesPublic, profileSummary, - buildVideoEncodeArgs, - buildMuxArgs, - exportOutputExtension, + buildExportArgs, + dryRunProfile, } = require("./encode-profiles"); const ROOT = __dirname; @@ -164,32 +163,52 @@ async function faststart(file) { /* ── 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. */ + JPEGs; a single ffmpeg pass encodes them plus the WAV mix into the final file. + ffmpeg is spawned on the FIRST frame, not here: the audio mix is uploaded + between /begin and the first frame, and a one-pass encode needs it on disk. */ const exportSessions = new Map(); -function beginExport(fps, name, profileId) { +function beginExport(fps, name, profileId, hasAudio) { const profile = resolveProfile(profileId); + const dry = dryRunProfile(profile, { fps, hasAudio }); + if (!dry.ok) throw new Error(`profile "${profile.id}" was rejected by ffmpeg: ${dry.error}`); 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", buildVideoEncodeArgs(profile, fps, 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 sess = { - proc, dir, videoPath, profile, name: safeName(name || "export"), - wav: null, err: () => stderr, - done: new Promise((res) => proc.on("close", res)), + proc: null, fps, profile, name: safeName(name || "export"), + dir: fs.mkdtempSync(path.join(os.tmpdir(), "fablecut-")), + wav: null, partPath: null, outPath: null, + stderr: "", done: null, + // ffmpeg's complaint is in the last lines; the rest is progress noise + err: () => sess.stderr.trim().split("\n").filter(Boolean).slice(-3) + .map((l) => l.trim()).join(" · "), }; exportSessions.set(id, sess); return { id, profile: profile.id, label: profile.label, summary: profileSummary(profile) }; } +/* Encode into exports/ under a .part name and rename once ffmpeg exits cleanly, + so an aborted render never leaves something that looks like a finished file. */ +function startEncoder(sess) { + const ext = sess.profile.extension; + const base = sess.name.replace(/\.(mp4|mov|m4v|mkv|webm)$/i, ""); + sess.outPath = uniquePath(EXPORTS_DIR, base + ext); + // ".part" goes BEFORE the extension — ffmpeg picks its muxer from the + // extension, so a trailing ".part" would leave it unable to choose a format + sess.partPath = sess.outPath.slice(0, -ext.length) + ".part" + ext; + const proc = spawn("ffmpeg", buildExportArgs(sess.profile, { + fps: sess.fps, wavPath: sess.wav, outPath: sess.partPath, + }), { stdio: ["pipe", "ignore", "pipe"] }); + proc.stderr.on("data", (d) => { sess.stderr = (sess.stderr + d).slice(-2000); }); + proc.stdin.on("error", () => { }); // EPIPE if ffmpeg dies mid-stream; surfaced via exit code + sess.proc = proc; + sess.done = new Promise((res) => proc.on("close", res)); + return 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