diff --git a/CLAUDE.md b/CLAUDE.md index 4065cc2..b5bc36b 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 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) @@ -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,8 +415,12 @@ 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}` - · `POST /api/export/frame?id=` (JPEG body, in order) · `POST /api/export/audio?id=` (WAV body) + `GET /api/export/ffmpeg` → `{available}` · `GET /api/export/profiles[?detail=1]` → + `{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 @@ -527,8 +534,69 @@ 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** +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. 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 +{ + "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.1–1) + "extension": ".mp4", + "args": ["-c:v", "libx264", "-preset", "veryfast", "-crf", "23", + "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", + "-movflags", "+faststart", "-shortest"] + } + } +} +``` + +Export is **one ffmpeg pass**. The server owns the input side and the output path; +`args` is everything in between, verbatim: + +``` +ffmpeg -y -f image2pipe -framerate -i - [-i audio.wav] exports/ +``` + +- **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. +- 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 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) +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 each `args` +array; `{profile:"hq"}` returns one profile. `fablecut_status` shows the effective profile. diff --git a/app.js b/app.js index b412674..72c8979 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,90 @@ 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. */ +/* 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: "-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: "-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: "-c:v libx264 -preset slow -crf 16 -c:a aac -b:a 256k", + 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 +5299,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 +5317,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 +5431,18 @@ 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, + // 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"); sessId = begin.id; @@ -5354,7 +5458,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 +5557,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..447aa06 --- /dev/null +++ b/encode-profiles.js @@ -0,0 +1,179 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + 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"); + +/* 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 = { + 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 normalizeProfile(id, raw) { + const p = raw && typeof raw === "object" ? raw : {}; + // 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 || id), + description: String(p.description || p.desc || ""), + jpegQuality: q >= 0.1 && q <= 1 ? q : BUILTIN.jpegQuality, + extension: ext.startsWith(".") ? ext : `.${ext}`, + args, + }; +} + +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; + const issues = []; + try { + if (fs.existsSync(PROFILES_FILE)) + file = JSON.parse(fs.readFileSync(PROFILES_FILE, "utf8").replace(/^\uFEFF/, "")); + 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 = {}; + 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_ID; + if (!profiles[defaultId]) { + 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 }; + 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, max = 120) { + const s = (p.args || []).join(" ") || "(no args — ffmpeg defaults)"; + return s.length > max ? s.slice(0, max - 1) + "…" : s; +} + +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: p.extension, + summary: profileSummary(p), + }; + out.profiles[id] = detail ? { ...base, args: p.args } : base; + } + return out; +} + +/* 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; +} + +/* 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 { } + } +} + +module.exports = { + PROFILES_FILE, + loadEncodeProfiles, + invalidateEncodeProfiles, + resolveProfile, + listProfilesPublic, + profileSummary, + buildExportArgs, + dryRunProfile, +}; diff --git a/encoding-profiles.json b/encoding-profiles.json new file mode 100644 index 0000000..8b620d0 --- /dev/null +++ b/encoding-profiles.json @@ -0,0 +1,63 @@ +{ + "default": "delivery", + "profiles": { + "draft": { + "label": "Draft · H.264 fast", + "description": "Quick preview — smaller file, faster encode.", + "jpegQuality": 0.85, + "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, + "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 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, + "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 the project to 1920x1080 @ 25fps (progressive); the filter chain builds the 50i field order.", + "jpegQuality": 0.98, + "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/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..e935552 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). 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 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" }, + }, + }, + }, ]; /* ── Tool implementations ── */ @@ -160,14 +172,32 @@ 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 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 + ")"}`, `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 +328,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 +466,21 @@ 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, + extension: p.extension, + args: p.args, + }, 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..425d5e0 100644 --- a/server.js +++ b/server.js @@ -19,6 +19,16 @@ const os = require("os"); const { spawn, spawnSync, execFile } = require("child_process"); const { analyze } = require("./analyze"); +const { + PROFILES_FILE, + loadEncodeProfiles, + invalidateEncodeProfiles, + resolveProfile, + listProfilesPublic, + profileSummary, + buildExportArgs, + dryRunProfile, +} = require("./encode-profiles"); const ROOT = __dirname; const MEDIA_DIR = path.join(ROOT, "media"); @@ -94,7 +104,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 {} @@ -146,34 +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) { +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", [ - "-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "-", - "-c:v", "libx264", "-preset", "fast", "-crf", "18", "-pix_fmt", "yuv420p", - videoPath, - ], { stdio: ["pipe", "ignore", "pipe"] }); - let stderr = ""; - proc.stderr.on("data", (d) => { stderr = (stderr + d).slice(-2000); }); - proc.stdin.on("error", () => {}); // EPIPE if ffmpeg dies mid-stream; surfaced via exit code const sess = { - proc, dir, videoPath, 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; + 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