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