fablecut 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js CHANGED
@@ -16,11 +16,13 @@
16
16
  ═══════════════════════════════════════════════════════════════════════════ */
17
17
  "use strict";
18
18
  const http = require("http");
19
+ const crypto = require("crypto");
19
20
  const fs = require("fs");
21
+ const os = require("os");
20
22
  const path = require("path");
21
23
  const { spawn, spawnSync, execFile } = require("child_process");
22
24
 
23
- const { analyze } = require("./analyze");
25
+ const { analyze, frame, frameGrid } = require("./analyze");
24
26
  const {
25
27
  PROFILES_FILE,
26
28
  loadEncodeProfiles,
@@ -32,14 +34,19 @@ const {
32
34
  dryRunProfile,
33
35
  } = require("./encode-profiles");
34
36
  const { downloadImportUrl, maybeFaststart } = require("./import-url");
37
+ const { pruneCache } = require("./frame-cache");
35
38
  const { createExportJobs } = require("./export-jobs");
36
39
  const { denoiseFile, AMOUNT_IDS: DENOISE_AMOUNTS } = require("./denoise");
37
40
 
38
41
  const {
39
42
  APP_DIR, DATA_DIR, MEDIA_DIR, EXPORTS_DIR, ANALYSIS_DIR, LIBRARY_DIR,
40
- PROJECT_FILE, LIBRARY_SUBDIRS, ensureDirs, readSaved, writeSaved,
43
+ PROJECT_FILE, LIBRARY_SUBDIRS, FRAMES_DIR, PROXIES_DIR, ensureDirs, readSaved, writeSaved,
41
44
  } = require("./paths");
42
45
  const Templates = require("./templates");
46
+ const { probeFile } = require("./media-probe");
47
+ const MediaLib = require("./media-lib");
48
+ const { createProxies } = require("./proxy");
49
+ const Delivery = require("./delivery");
43
50
 
44
51
  /* Static app files are served from the install dir; everything the user creates
45
52
  lives under DATA_DIR. The two are the same unless FABLECUT_DATA_DIR is set. */
@@ -102,6 +109,7 @@ function broadcast(event = "change", data = event) {
102
109
  const payload = `event: ${event}\ndata: ${data}\n\n`;
103
110
  for (const res of sseClients) res.write(payload);
104
111
  }
112
+ const proxies = createProxies({ dir: PROXIES_DIR, resolveSrc, probe: probeFile, broadcast: (ev, data) => broadcast(ev, data) });
105
113
  let debounce = null;
106
114
  let profilesDebounce = null;
107
115
  function onFsChange() {
@@ -176,6 +184,32 @@ function run(cmd, args) {
176
184
  });
177
185
  }
178
186
 
187
+ /** A "/media/…" or "/library/…" src → an existing file inside that root, or null.
188
+ * Every src-accepting endpoint resolves through here: basename alone is not
189
+ * enough (library has subfolders) and a bare join is a traversal hole. */
190
+ function resolveSrc(src) {
191
+ const p = decodeURIComponent(String(src || "").split("?")[0]);
192
+ const [root, rel] = p.startsWith("/media/") ? [MEDIA_DIR, p.slice(7)]
193
+ : p.startsWith("/library/") ? [LIBRARY_DIR, p.slice(9)] : [null, null];
194
+ if (!root || !rel) return null;
195
+ const file = path.normalize(path.join(root, rel));
196
+ if (!file.startsWith(path.normalize(root + path.sep))) return null;
197
+ try { return fs.statSync(file).isFile() ? file : null; } catch { return null; }
198
+ }
199
+
200
+ /** An explicit absolute/relative path argument → an existing file, or null.
201
+ * Used by /api/frame only. This deliberately reaches outside media/ and
202
+ * library/: looking at a clip must not require putting it in the project
203
+ * folder. The caller is the local MCP server or the user's own fetch, both
204
+ * already able to read any file the user can — and unlike resolveSrc() it
205
+ * never turns a request string into a path *inside* a served root, so there
206
+ * is no traversal to defend. It is deliberately NOT how a src is resolved. */
207
+ function resolvePathArg(p) {
208
+ if (!p || typeof p !== "string") return null;
209
+ let file = path.resolve(p);
210
+ try { return fs.statSync(file).isFile() ? file : null; } catch { return null; }
211
+ }
212
+
179
213
  /* Remux MP4-family uploads with `+faststart` so the moov atom leads the file —
180
214
  without it <video> stalls for seconds probing over Range requests. */
181
215
  function faststart(file) { return maybeFaststart(file); }
@@ -210,6 +244,140 @@ function attachProc(sess, proc) {
210
244
  proc.stdin.on("error", () => {}); // EPIPE if ffmpeg dies mid-stream
211
245
  sess.done = new Promise((res) => proc.on("close", res));
212
246
  }
247
+ /* Delivery presets (delivery.js) — structured settings instead of raw args.
248
+ Which encoders exist: `ffmpeg -encoders`, and for the hardware ones a 2-frame
249
+ test encode, because a build lists h264_nvenc / _qsv / _amf whether or not
250
+ this machine has the GPU. Asked once, in the background, on first use. */
251
+ let deliveryEncodersP = null;
252
+ function runFfmpeg(args, timeout = 15000) {
253
+ return new Promise((resolve) => {
254
+ execFile("ffmpeg", args, { timeout, maxBuffer: 8 * 1024 * 1024, windowsHide: true },
255
+ (err, stdout, stderr) => resolve({ code: err ? (err.code ?? 1) : 0, stdout: String(stdout || ""), stderr: String(stderr || "") }));
256
+ });
257
+ }
258
+ function deliveryEncoders() {
259
+ if (!HAS_FFMPEG) return Promise.resolve(new Set());
260
+ if (deliveryEncodersP) return deliveryEncodersP;
261
+ deliveryEncodersP = (async () => {
262
+ const listed = Delivery.parseEncoders((await runFfmpeg(["-hide_banner", "-encoders"])).stdout);
263
+ const usable = new Set([...listed].filter((e) => !Delivery.ENCODERS[e]?.hw));
264
+ await Promise.all([...listed].filter((e) => Delivery.ENCODERS[e]?.hw).map(async (e) => {
265
+ const r = await runFfmpeg(["-hide_banner", "-v", "error", "-f", "lavfi", "-i", "color=c=black:s=256x144:r=30:d=0.1",
266
+ "-frames:v", "2", "-c:v", e, "-pix_fmt", Delivery.ENCODERS[e].hw === "nvenc" ? "yuv420p" : "nv12", "-f", "null", "-"]);
267
+ if (r.code === 0) usable.add(e);
268
+ }));
269
+ return usable;
270
+ })();
271
+ return deliveryEncodersP;
272
+ }
273
+ /** Saved delivery presets: library/delivery/<id>.json, each a spec plus a label. */
274
+ function savedDeliveryPresets() { return Delivery.savedPresets(readSaved("delivery")); }
275
+ /* One render, several files: `deliveries:[{delivery, name}, …]` (up to 8) or a
276
+ single `delivery` + name. Every output gets its own ffmpeg, fed the same
277
+ frames; the browser renders each frame once. Frames are raw RGBA when any
278
+ output needs more than 4:2:0 8-bit, else JPEG at the highest quality asked. */
279
+ const MAX_OUTPUTS = 8;
280
+ async function beginDeliveryExport(rate, name, hasAudio, extra) {
281
+ const available = await deliveryEncoders();
282
+ const presets = savedDeliveryPresets();
283
+ const list = Array.isArray(extra.deliveries) ? extra.deliveries : [{ delivery: extra.delivery, name }];
284
+ if (!list.length) throw new Error("deliveries is empty");
285
+ if (list.length > MAX_OUTPUTS) throw new Error(`at most ${MAX_OUTPUTS} outputs per render`);
286
+ const canvasW = extra.canvasW | 0, canvasH = extra.canvasH | 0;
287
+ if (canvasW < 2 || canvasH < 2 || canvasW > 16384 || canvasH > 16384) throw new Error("export width/height required (canvasW, canvasH)");
288
+ const r = extra.region;
289
+ const region = r && typeof r === "object" && [r.x, r.y, r.w, r.h].every(Number.isFinite) && r.w > 0 && r.h > 0 &&
290
+ r.x >= 0 && r.y >= 0 && r.x + r.w <= canvasW + 0.5 && r.y + r.h <= canvasH + 0.5 ? r : null;
291
+ const outs = list.map((d) => {
292
+ const { spec, notes } = Delivery.normalize(d && d.delivery !== undefined ? d.delivery : d, { available, presets });
293
+ return { spec, notes, name: d && typeof d.name === "string" ? d.name : name, frame: Delivery.reframe(spec, canvasW, canvasH, region) };
294
+ });
295
+ // one render has one frame rate: an output asking for another needs a render of its own
296
+ const off = outs.map((o, i) => [o, i]).filter(([o]) => o.spec.fps != null && Math.abs(o.spec.fps - rate) > 0.01);
297
+ if (off.length) throw new Error(off.map(([o, i]) => `output ${i + 1} asks for ${o.spec.fps} fps but this render is ${rate} fps`).join("; ") + " — render each frame rate separately");
298
+ const input = outs.some((o) => Delivery.frameInput(o.spec) === "rgba") ? "rgba" : "jpeg";
299
+ // check every output on a 0.1 s synthetic canvas before the browser renders anything
300
+ const dryDir = fs.mkdtempSync(path.join(os.tmpdir(), "fablecut-dry-"));
301
+ try {
302
+ const dry = await Promise.all(outs.map((o, i) => runFfmpeg(Delivery.ffmpegArgs(o.spec, { fps: rate, input, canvasW, canvasH, frame: o.frame,
303
+ hasAudio, dry: true, outPath: path.join(dryDir, `probe${i}` + Delivery.CONTAINERS[o.spec.container].ext) }), 30000)));
304
+ const bad = dry.map((d, i) => [d, outs[i]]).filter(([d]) => d.code !== 0);
305
+ if (bad.length) {
306
+ throw new Error(bad.map(([d, o]) => {
307
+ const why = d.stderr.trim().split("\n").filter(Boolean).slice(-3).map((l) => l.trim()).join(" · ");
308
+ return `ffmpeg rejected these settings (${Delivery.describe(o.spec)}): ${why || "exit " + d.code}`;
309
+ }).join("\n"));
310
+ }
311
+ } finally { try { fs.rmSync(dryDir, { recursive: true, force: true }); } catch {} }
312
+ const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
313
+ const dir = fs.mkdtempSync(path.join(EXPORTS_DIR, "fablecut-"));
314
+ const duration = Number.isFinite(+extra.duration) && +extra.duration > 0 ? +extra.duration : null;
315
+ for (const o of outs) {
316
+ const ext = Delivery.CONTAINERS[o.spec.container].ext;
317
+ Object.assign(o, reserveExportPaths(EXPORTS_DIR, safeName(o.name || "export"), ext), { proc: null, stderr: "", done: null, failed: null });
318
+ o.err = () => o.stderr.trim().split("\n").filter(Boolean).slice(-3).map((l) => l.trim()).join(" · ");
319
+ }
320
+ const sess = {
321
+ mode: "jpeg", proc: null, fps: rate, outputs: outs, input, canvasW, canvasH, duration,
322
+ hasAudio: !!hasAudio, dir, wav: null, partPath: null, outPath: null,
323
+ stderr: "", done: null, lastTouch: Date.now(), err: () => "",
324
+ };
325
+ sess.writeLock = Promise.resolve();
326
+ exportSessions.set(id, sess);
327
+ const jpegQuality = Math.max(...outs.map((o) => Delivery.jpegQuality(o.spec)));
328
+ const view = outs.map((o) => ({ file: path.basename(o.outPath), spec: o.spec, notes: o.notes, summary: Delivery.describe(o.spec), size: { w: o.frame.outW, h: o.frame.outH } }));
329
+ // the single-output fields stay as they were for one delivery
330
+ return { id, mode: "jpeg", input, jpegQuality, outputs: view, ...view[0] };
331
+ }
332
+ /** Integrated loudness and true peak of a file's first audio stream (ffmpeg ebur128). */
333
+ async function measureLoudness(file, timeout = 10 * 60 * 1000) {
334
+ const r = await runFfmpeg(Delivery.LOUDNESS_ARGS(file), timeout);
335
+ return r.code === 0 ? Delivery.parseEbur128(r.stderr) : null;
336
+ }
337
+ function ffprobeJSON(file) {
338
+ return new Promise((resolve) => {
339
+ execFile("ffprobe", ["-v", "error", "-show_streams", "-show_format", "-of", "json", file], { timeout: 60000, maxBuffer: 8 * 1024 * 1024, windowsHide: true },
340
+ (err, stdout) => { try { resolve(err ? null : JSON.parse(stdout)); } catch { resolve(null); } });
341
+ });
342
+ }
343
+ /** Check a finished output against its spec: size, frames, fps, codecs, length, loudness, file size. */
344
+ const VERIFY_LOUDNESS_MS = 120 * 1000; // the file is already saved; a long re-measure must not hold the reply
345
+ async function verifyDeliveryOutput(sess, o, file) {
346
+ const failed = (text) => ({ ok: false, checks: [{ ok: false, text }], line: "⚠ " + text });
347
+ try {
348
+ const loudWanted = o.spec.audio.codec !== "none" && !!sess.wav;
349
+ const [probe, loud] = await Promise.all([ffprobeJSON(file), loudWanted ? measureLoudness(file, VERIFY_LOUDNESS_MS) : null]);
350
+ if (!probe) return failed("could not read the file back to check it (ffprobe)");
351
+ let size = 0;
352
+ try { size = fs.statSync(file).size; } catch {}
353
+ return Delivery.verify(o.spec, probe, {
354
+ fps: sess.fps, frames: sess.frames || (sess.duration ? Math.round(sess.duration * sess.fps) : null), duration: sess.duration,
355
+ size, loud, loudWanted, frame: o.frame, hasAudio: !!sess.wav,
356
+ });
357
+ } catch (e) { return failed("could not check the file: " + (e.message || e)); }
358
+ }
359
+ /** Spawn one ffmpeg per output of a delivery session. */
360
+ function startDeliveryEncoders(sess) {
361
+ for (const o of sess.outputs) {
362
+ const proc = spawn("ffmpeg", Delivery.ffmpegArgs(o.spec, {
363
+ fps: sess.fps, input: sess.input, canvasW: sess.canvasW, canvasH: sess.canvasH, frame: o.frame,
364
+ wavPath: sess.wav, outPath: o.partPath, duration: sess.duration, loudnorm: o.loudnorm,
365
+ }), { stdio: ["pipe", "ignore", "pipe"] });
366
+ proc.stderr.on("data", (d) => { o.stderr = (o.stderr + d).slice(-2000); });
367
+ proc.stdin.on("error", () => { });
368
+ o.proc = proc;
369
+ o.done = new Promise((res) => proc.on("close", res));
370
+ }
371
+ sess.proc = sess.outputs[0].proc; // "started"
372
+ }
373
+ /** The same frames to every output still running. One that fails is marked and
374
+ * left behind; the render goes on for the rest, and fails only when none is left. */
375
+ async function writeDeliveryFrame(sess, body) {
376
+ const live = sess.outputs.filter((o) => !o.failed);
377
+ await Promise.all(live.map((o) => writeExportFrame(o, body).catch((e) => { o.failed = String(e.message || e); })));
378
+ if (!sess.outputs.some((o) => !o.failed))
379
+ throw new Error(sess.outputs.map((o) => `${path.basename(o.outPath)}: ${o.failed}`).join(" · "));
380
+ }
213
381
  async function beginExport(fps, name, profileId, hasAudio, mode, extra = {}) {
214
382
  const m = mode === "annexb" ? "annexb" : "jpeg";
215
383
  const rate = Number(fps);
@@ -222,6 +390,7 @@ async function beginExport(fps, name, profileId, hasAudio, mode, extra = {}) {
222
390
  if (pixelFormat === "rgba" && (width < 2 || height < 2)) {
223
391
  throw new Error("export width/height required for rgba");
224
392
  }
393
+ if (m === "jpeg" && (extra.delivery || extra.deliveries)) return beginDeliveryExport(rate, name, hasAudio, extra);
225
394
  const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
226
395
  const safe = safeName(name || "export");
227
396
  // Same filesystem as the finished file so renameSync(partPath, out) cannot EXDEV.
@@ -263,10 +432,12 @@ async function beginExport(fps, name, profileId, hasAudio, mode, extra = {}) {
263
432
  /* Encode into the paths reserved at /begin; rename .part → final on clean exit
264
433
  so an aborted render never leaves something that looks like a finished file. */
265
434
  function startEncoder(sess) {
266
- const proc = spawn("ffmpeg", buildExportArgs(sess.profile, {
435
+ if (sess.outputs) return startDeliveryEncoders(sess);
436
+ const args = buildExportArgs(sess.profile, {
267
437
  fps: sess.fps, wavPath: sess.wav, outPath: sess.partPath,
268
438
  pixelFormat: sess.pixelFormat, width: sess.width, height: sess.height,
269
- }), { stdio: ["pipe", "ignore", "pipe"] });
439
+ });
440
+ const proc = spawn("ffmpeg", args, { stdio: ["pipe", "ignore", "pipe"] });
270
441
  proc.stderr.on("data", (d) => { sess.stderr = (sess.stderr + d).slice(-2000); });
271
442
  // EPIPE on end()/late writes is normal once ffmpeg has exited; writeExportFrame
272
443
  // attaches its own error listener while a backpressured write is in flight.
@@ -332,6 +503,10 @@ function cleanupExport(id) {
332
503
  if (!s) return;
333
504
  exportSessions.delete(id);
334
505
  try { s.proc?.kill(); } catch {}
506
+ for (const o of s.outputs || []) {
507
+ try { o.proc?.kill(); } catch {}
508
+ if (o.partPath) try { fs.rmSync(o.partPath, { force: true }); } catch {}
509
+ }
335
510
  try { fs.rmSync(s.dir, { recursive: true, force: true }); } catch {}
336
511
  if (s.partPath) try { fs.rmSync(s.partPath, { force: true }); } catch {}
337
512
  }
@@ -443,6 +618,91 @@ const server = http.createServer(async (req, res) => {
443
618
  return;
444
619
  }
445
620
 
621
+ /* API: media metadata (ffprobe; size only without it).
622
+ GET /api/media-info?src=/media/x.mp4 → {src, info} (info null = file missing)
623
+ POST /api/media-info {srcs:[…], stat?} → {info:{src: info | null}} — up to 2000 per call;
624
+ stat:true answers {size, mtimeMs} without ffprobe (the editor's offline check) */
625
+ if (p === "/api/media-info" && (req.method === "GET" || req.method === "POST")) {
626
+ try {
627
+ if (req.method === "GET") {
628
+ const src = url.searchParams.get("src") || "";
629
+ const file = resolveSrc(src);
630
+ sendJSON(res, 200, { src, info: file ? await probeFile(file) : null });
631
+ return;
632
+ }
633
+ const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
634
+ const srcs = Array.isArray(body.srcs) ? body.srcs.slice(0, 2000).map(String) : [];
635
+ const info = Object.create(null); // any src is a plain key, "__proto__" too
636
+ // probed side by side (media-probe.js caps how many ffprobes run at once)
637
+ await Promise.all(srcs.map(async (src) => {
638
+ const file = resolveSrc(src);
639
+ if (!file) { info[src] = null; return; }
640
+ // stat:true only asks "is it there?" (offline checks) — no ffprobe
641
+ if (body.stat) { const st = await fs.promises.stat(file); info[src] = { size: st.size, mtimeMs: Math.round(st.mtimeMs) }; }
642
+ else info[src] = await probeFile(file);
643
+ }));
644
+ sendJSON(res, 200, { info });
645
+ } catch (e) { sendJSON(res, 400, { error: String(e.message || e) }); }
646
+ return;
647
+ }
648
+
649
+ /* API: where might a missing file be now? Body {media:[{id, name, size?}]} →
650
+ {candidates:{id:[{src, name, size, score, why}]}} — files under media/ and
651
+ library/ ranked by name and size (MediaLib.relinkScore), best first, at most 5 each. */
652
+ if (p === "/api/relink/candidates" && req.method === "POST") {
653
+ try {
654
+ const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
655
+ const want = Array.isArray(body.media) ? body.media.slice(0, 2000) : [];
656
+ const files = [];
657
+ const walk = (root, prefix, d, depth) => {
658
+ let list = [];
659
+ try { list = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
660
+ for (const e of list) {
661
+ if (e.name.startsWith(".")) continue;
662
+ const full = path.join(d, e.name);
663
+ if (e.isDirectory()) { if (depth < 6 && !(root === LIBRARY_DIR && /^(presets|templates|fonts)$/.test(e.name) && depth === 0)) walk(root, prefix, full, depth + 1); continue; }
664
+ let size = 0;
665
+ try { size = fs.statSync(full).size; } catch { continue; }
666
+ const rel = path.relative(root, full).split(path.sep).map(encodeURIComponent).join("/");
667
+ files.push({ src: prefix + rel, name: e.name, size });
668
+ }
669
+ };
670
+ walk(MEDIA_DIR, "/media/", MEDIA_DIR, 0);
671
+ walk(LIBRARY_DIR, "/library/", LIBRARY_DIR, 0);
672
+ const candidates = Object.create(null);
673
+ for (const m of want) {
674
+ if (!m || !m.id) continue;
675
+ candidates[m.id] = files.map((f) => ({ ...f, ...MediaLib.relinkScore(m, f) }))
676
+ .filter((f) => f.score > 0).sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, 5);
677
+ }
678
+ sendJSON(res, 200, { candidates });
679
+ } catch (e) { sendJSON(res, 400, { error: String(e.message || e) }); }
680
+ return;
681
+ }
682
+
683
+ /* API: proxies (see proxy.js).
684
+ POST /api/proxy {srcs:[…], height?:540|720|1080} → {status:{src: status}} — queue them
685
+ POST /api/proxy/status {srcs:[…]} → {status:{src: status | null}}
686
+ POST /api/proxy/delete {srcs:[…]} → {deleted:{src: n}}
687
+ Progress and results also arrive as SSE event "proxy". */
688
+ if ((p === "/api/proxy" || p === "/api/proxy/status" || p === "/api/proxy/delete") && req.method === "POST") {
689
+ try {
690
+ const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
691
+ const srcs = Array.isArray(body.srcs) ? body.srcs.slice(0, 2000).map(String) : [];
692
+ if (p === "/api/proxy/delete") {
693
+ const deleted = {};
694
+ for (const src of srcs) deleted[src] = proxies.remove(src);
695
+ sendJSON(res, 200, { deleted });
696
+ return;
697
+ }
698
+ if (p === "/api/proxy" && !HAS_FFMPEG) { sendJSON(res, 503, { error: "proxies need ffmpeg on PATH" }); return; }
699
+ const status = {};
700
+ for (const src of srcs) status[src] = p === "/api/proxy" ? await proxies.start(src, body.height) : await proxies.status(src);
701
+ sendJSON(res, 200, { status });
702
+ } catch (e) { sendJSON(res, 400, { error: String(e.message || e) }); }
703
+ return;
704
+ }
705
+
446
706
  /* API: default-asset library listing (./library/{sfx,elements,svg,fonts}) */
447
707
  if (p === "/api/library" && req.method === "GET") {
448
708
  const dir = url.searchParams.get("dir");
@@ -537,9 +797,39 @@ const server = http.createServer(async (req, res) => {
537
797
  const opts = JSON.parse((await readBody(req)).toString("utf8") || "{}");
538
798
  if (!HAS_FFMPEG) { sendJSON(res, 400, { error: "ffmpeg not found on PATH — export needs it" }); return; }
539
799
  if (opts.profile) resolveProfile(String(opts.profile));
800
+ if (opts.profile && (opts.preset || opts.delivery || opts.outputs)) { sendJSON(res, 400, { error: "pass a profile (raw ffmpeg args) or a preset / delivery spec / outputs, not both" }); return; }
801
+ // several files from one render: outputs:true = the project's checked batch outputs, or a list of specs
802
+ let deliveries = null, outNotes = [];
803
+ if (opts.outputs) {
804
+ let list = opts.outputs;
805
+ if (list === true) {
806
+ let proj = {};
807
+ try { proj = JSON.parse(fs.readFileSync(PROJECT_FILE, "utf8").replace(/^\uFEFF/, "")); } catch {}
808
+ list = (Array.isArray(proj.outputs) ? proj.outputs : []).filter((o) => o && o.on !== false);
809
+ if (!list.length) { sendJSON(res, 400, { error: "the project has no checked batch outputs — pass outputs:[{preset…}, …], or add some with setProject {outputs}" }); return; }
810
+ }
811
+ if (!Array.isArray(list) || !list.length) { sendJSON(res, 400, { error: "outputs is true or a list of delivery specs" }); return; }
812
+ if (list.length > 16) { sendJSON(res, 400, { error: "at most 16 outputs" }); return; }
813
+ const available = await deliveryEncoders(), presets = savedDeliveryPresets();
814
+ deliveries = list.map((o, i) => {
815
+ const n = Delivery.normalize(typeof o === "string" ? o : o, { available, presets });
816
+ for (const note of n.notes) outNotes.push(`output ${i + 1}: ${note}`);
817
+ return n.spec;
818
+ });
819
+ }
820
+ // a delivery preset with overrides → one complete spec, checked against this ffmpeg
821
+ let delivery = null, notes = [];
822
+ if (opts.preset || opts.delivery) {
823
+ const d = opts.delivery && typeof opts.delivery === "object" ? opts.delivery : {};
824
+ const n = Delivery.normalize({ ...d, preset: opts.preset || d.preset || undefined },
825
+ { available: await deliveryEncoders(), presets: savedDeliveryPresets() });
826
+ delivery = n.spec; notes = n.notes;
827
+ }
540
828
  let revision = null;
541
829
  try { revision = JSON.parse(fs.readFileSync(PROJECT_FILE, "utf8").replace(/^\uFEFF/, "")).revision ?? null; } catch {}
542
- sendJSON(res, 200, exportJobs.request({ range: opts.range, profile: opts.profile, where: opts.where, revision }));
830
+ const job = exportJobs.request({ range: opts.range, profile: opts.profile, delivery: deliveries ? null : delivery, deliveries, where: opts.where, revision });
831
+ sendJSON(res, 200, deliveries ? { ...job, delivery: `${deliveries.length} outputs from one render`, notes: outNotes }
832
+ : delivery ? { ...job, delivery: Delivery.describe(delivery), notes } : job);
543
833
  } catch (e) {
544
834
  const code = e.code === 409 || e.code === 400 ? e.code : /^Unknown encoding profile/.test(e.message || "") ? 400 : 500;
545
835
  sendJSON(res, code, { error: String(e.message || e) });
@@ -564,7 +854,7 @@ const server = http.createServer(async (req, res) => {
564
854
  if (mk != null && (typeof mk !== "object" || typeof mk.clip !== "string")) {
565
855
  sendJSON(res, 400, { error: "mask must be {clip: \"<clip id>\"}" }); return;
566
856
  }
567
- sendJSON(res, 200, exportJobs.request({ kind: "scopes", time, where: opts.where, revision, matte: m || null, mask: mk || null }));
857
+ sendJSON(res, 200, exportJobs.request({ kind: "scopes", time, where: opts.where, revision, matte: m || null, mask: mk || null, image: opts.image === true }));
568
858
  } catch (e) {
569
859
  sendJSON(res, e.code === 409 || e.code === 400 ? e.code : 500, { error: String(e.message || e) });
570
860
  }
@@ -618,6 +908,40 @@ const server = http.createServer(async (req, res) => {
618
908
  } catch (e) { sendJSON(res, 500, { error: String(e) }); }
619
909
  return;
620
910
  }
911
+ /* API: delivery presets — GET lists the saved ones and the encoders this
912
+ ffmpeg can use (the built-in presets live in delivery.js, which the page
913
+ loads too); POST {name, spec} saves one to library/delivery/; POST
914
+ /api/export/presets/delete {id} removes it. */
915
+ if (p === "/api/export/presets" && req.method === "GET") {
916
+ const encoders = [...(await deliveryEncoders())].filter((e) => Delivery.ENCODERS[e]).sort();
917
+ sendJSON(res, 200, { ffmpeg: HAS_FFMPEG, encoders, saved: savedDeliveryPresets(), builtin: Delivery.PRESET_IDS });
918
+ return;
919
+ }
920
+ if ((p === "/api/export/presets" || p === "/api/export/presets/delete") && req.method === "POST") {
921
+ try {
922
+ const opts = JSON.parse((await readBody(req)).toString("utf8") || "{}");
923
+ if (p.endsWith("/delete")) {
924
+ const id = Delivery.presetId(opts.id);
925
+ const file = path.join(LIBRARY_DIR, "delivery", id + ".json");
926
+ if (!id || !fs.existsSync(file)) { sendJSON(res, 404, { error: "no saved delivery preset " + JSON.stringify(opts.id) }); return; }
927
+ fs.unlinkSync(file);
928
+ broadcast("delivery-presets");
929
+ sendJSON(res, 200, { ok: true, id });
930
+ return;
931
+ }
932
+ const label = String(opts.name || "").trim().slice(0, 60);
933
+ const id = Delivery.presetId(label);
934
+ if (!id) { sendJSON(res, 400, { error: "name the preset (letters or digits)" }); return; }
935
+ if (Delivery.PRESETS[id]) { sendJSON(res, 400, { error: `"${label}" is a built-in preset — pick another name` }); return; }
936
+ // what the spec leaves out comes from the preset it names (built-in or saved), not from Custom
937
+ const { spec, notes } = Delivery.normalize(opts.spec || {}, { presets: savedDeliveryPresets() });
938
+ delete spec.preset;
939
+ writeSaved("delivery", id, { id, label, ...spec });
940
+ broadcast("delivery-presets");
941
+ sendJSON(res, 200, { ok: true, id, label, notes });
942
+ } catch (e) { sendJSON(res, 400, { error: String(e.message || e) }); }
943
+ return;
944
+ }
621
945
  if (p === "/api/export/begin" && req.method === "POST") {
622
946
  try {
623
947
  const opts = JSON.parse((await readBody(req)).toString("utf8") || "{}");
@@ -627,7 +951,7 @@ const server = http.createServer(async (req, res) => {
627
951
  sendJSON(res, 200, await beginExport(opts.fps, opts.name, opts.profile, opts.hasAudio !== false, mode, opts));
628
952
  } catch (e) {
629
953
  // an unusable profile is the caller's problem, not a server fault
630
- const bad = /^Unknown encoding profile|was rejected by ffmpeg|export fps required|export width\/height required/.test(e.message || "");
954
+ const bad = /^Unknown encoding profile|was rejected by ffmpeg|ffmpeg rejected these settings|at most \d+ outputs|deliveries is empty|render each frame rate separately|export fps required|export width\/height required/.test(e.message || "");
631
955
  sendJSON(res, bad ? 400 : 500, { error: String(e.message || e) });
632
956
  }
633
957
  return;
@@ -646,7 +970,7 @@ const server = http.createServer(async (req, res) => {
646
970
  // queue behind any in-flight write so concurrent POSTs cannot interleave stdin
647
971
  const run = async () => {
648
972
  if (!sess.proc) sess.mode === "annexb" ? startAnnexbEncoder(sess) : startEncoder(sess);
649
- await writeExportFrame(sess, body);
973
+ await (sess.outputs ? writeDeliveryFrame(sess, body) : writeExportFrame(sess, body));
650
974
  };
651
975
  const writeJob = sess.writeLock.then(run, run);
652
976
  sess.writeLock = writeJob.catch(() => {}); // keep the chain alive after a failed write
@@ -665,6 +989,14 @@ const server = http.createServer(async (req, res) => {
665
989
  try {
666
990
  const wavPath = path.join(sess.dir, "audio.wav");
667
991
  fs.writeFileSync(wavPath, await readBody(req));
992
+ // outputs that normalize loudness need the mix measured (loudnorm pass one) before ffmpeg starts
993
+ const byTarget = new Map();
994
+ for (const o of sess.outputs || []) {
995
+ if (o.spec.audio.loudness == null || o.spec.audio.codec === "none") continue;
996
+ const k = `${o.spec.audio.loudness}/${o.spec.audio.truePeak}`;
997
+ if (!byTarget.has(k)) byTarget.set(k, runFfmpeg(Delivery.LOUDNORM_ARGS(wavPath, o.spec), 10 * 60 * 1000).then((r) => Delivery.parseLoudnorm(r.stderr)));
998
+ o.loudnorm = await byTarget.get(k);
999
+ }
668
1000
  sess.wav = wavPath;
669
1001
  touchExport(sess);
670
1002
  sendJSON(res, 200, { ok: true });
@@ -679,6 +1011,30 @@ const server = http.createServer(async (req, res) => {
679
1011
  if (url.searchParams.get("discard")) { cleanupExport(id); sendJSON(res, 200, { ok: true }); return; }
680
1012
  touchExport(sess); // keep alive through final mux
681
1013
  if (!sess.proc) throw new Error("no frames were uploaded");
1014
+ if (sess.outputs) {
1015
+ // every output finishes on its own; the reply lists each file or why it failed
1016
+ const results = await Promise.all(sess.outputs.map(async (o) => {
1017
+ const file = path.basename(o.outPath);
1018
+ if (!o.failed) {
1019
+ o.proc.stdin.end();
1020
+ const code = await o.done;
1021
+ if (code !== 0) o.failed = "ffmpeg encode failed: " + o.err();
1022
+ }
1023
+ if (o.failed) return { file, ok: false, error: o.failed };
1024
+ // a file that cannot be put in place fails alone; the others keep finishing
1025
+ try { fs.renameSync(o.partPath, o.outPath); }
1026
+ catch (e) { return { file, ok: false, error: "could not save the file: " + (e.message || e) }; }
1027
+ o.partPath = null; // renamed — cleanup must not delete the finished file
1028
+ const report = await verifyDeliveryOutput(sess, o, o.outPath);
1029
+ return { file, ok: true, src: "/exports/" + encodeURIComponent(file), ...(report ? { report } : {}),
1030
+ ...(o.loudnorm ? { mixLoudness: o.loudnorm.input_i } : {}) };
1031
+ }));
1032
+ cleanupExport(id);
1033
+ const ok = results.filter((x) => x.ok);
1034
+ if (!ok.length) throw new Error(results.map((x) => `${x.file}: ${x.error}`).join(" · "));
1035
+ sendJSON(res, 200, { ok: true, src: ok[0].src, outputs: results });
1036
+ return;
1037
+ }
682
1038
  sess.proc.stdin.end();
683
1039
  const code = await sess.done;
684
1040
  if (code !== 0) throw new Error("ffmpeg encode failed: " + sess.err());
@@ -723,6 +1079,127 @@ const server = http.createServer(async (req, res) => {
723
1079
  return;
724
1080
  }
725
1081
 
1082
+ /* API: one frame of a source clip, or an N-up contact sheet across it —
1083
+ the "eyes" to go with /api/analyze's ears, so an agent can look at a
1084
+ moment instead of guessing from an energy number. JPEG in, JPEG out.
1085
+
1086
+ GET /api/frame?src=/media/x.mp4&t=3.2[&w=768][&q=4]
1087
+ GET /api/frame?src=/media/x.mp4&frames=12[&cols=][&from=0][&to=30][&w=320]
1088
+ (cols omitted → the squarest layout: 6 frames give 3×2, 12 give 4×3)
1089
+ GET /api/frame?path=C:\clips\x.mp4&t=3.2
1090
+
1091
+ `src` is a served /media/ or /library/ path. `path` is any readable path on
1092
+ this machine — it does NOT copy the file into media/, because looking at a
1093
+ clip must not change the user's project folder. `path` is answered only
1094
+ with the `X-FableCut-Client: mcp` request header, which only the local MCP
1095
+ client sends: without it a web page could otherwise probe which files
1096
+ exist (`onload` vs `onerror`), make ffmpeg decode them, and fill the cache
1097
+ below with junk. resolveSrc() remains the guard for anything that turns a
1098
+ request string into a path inside a served root.
1099
+
1100
+ &text=1 returns JSON statistics instead of the image, for agents (or
1101
+ models) that cannot see one. Needs ffmpeg on PATH.
1102
+
1103
+ Results are cached under analysis/frames/ by a hash of every argument, so
1104
+ polling the same timestamp costs nothing and the mtime/size of the cached
1105
+ file answers "did the source change underneath this?". The cache is capped
1106
+ (500 files / 128 MB, oldest mtime first — see frame-cache.js), so no
1107
+ combination of arguments can fill the disk. */
1108
+ if (p === "/api/frame" && req.method === "GET") {
1109
+ const pathArg = url.searchParams.get("path");
1110
+ /* `path=` reads any file on this machine, so it is only answered for the
1111
+ local MCP client, which proves itself with a custom header. An <img> or
1112
+ <form> on a web page cannot set one, and a cross-site fetch() that sets
1113
+ one needs a CORS preflight this server never grants — so a page in the
1114
+ user's browser can neither render a frame nor probe which files exist
1115
+ (a missing header is refused before the path is even resolved, so the
1116
+ answer leaks nothing). `src=` needs no header: those files are already
1117
+ served over HTTP at /media/ and /library/. */
1118
+ if (pathArg && req.headers["x-fablecut-client"] !== "mcp") {
1119
+ sendJSON(res, 403, { error: "path= needs the X-FableCut-Client: mcp request header — only the local MCP client may read files outside media/ and library/" });
1120
+ return;
1121
+ }
1122
+ /* The path is resolved next: whether a file is readable never depends on
1123
+ whether ffmpeg happens to be installed. */
1124
+ const src = url.searchParams.get("src") || "";
1125
+ const file = pathArg ? resolvePathArg(pathArg) : resolveSrc(src);
1126
+ if (!file) {
1127
+ sendJSON(res, 404, { error: pathArg
1128
+ ? "path must be an existing file on this machine"
1129
+ : "src must name an existing file under /media/ or /library/" });
1130
+ return;
1131
+ }
1132
+ if (!HAS_FFMPEG) { sendJSON(res, 400, { error: "ffmpeg not found on PATH — frame extraction needs it" }); return; }
1133
+ const num = (k, dflt) => {
1134
+ const raw = url.searchParams.get(k);
1135
+ if (raw === null || raw === "") return dflt;
1136
+ const n = Number(raw);
1137
+ if (!Number.isFinite(n)) throw new Error(`${k} must be a number`);
1138
+ return n;
1139
+ };
1140
+ try {
1141
+ fs.mkdirSync(FRAMES_DIR, { recursive: true });
1142
+ const isSheet = url.searchParams.get("frames") != null;
1143
+ const opts = isSheet
1144
+ ? { frames: num("frames"), cols: num("cols", null), from: num("from", 0), to: num("to", undefined), width: num("w", 320), quality: url.searchParams.get("q") }
1145
+ : { t: num("t", 0), width: num("w", 768), quality: url.searchParams.get("q") };
1146
+ // Key on the source's identity + size + mtime so an overwritten file
1147
+ // never serves a stale grab.
1148
+ const st = fs.statSync(file);
1149
+ const key = crypto.createHash("sha1")
1150
+ .update(JSON.stringify([file, st.size, Math.round(st.mtimeMs), opts])).digest("hex");
1151
+ const cache = path.join(FRAMES_DIR, key + ".jpg");
1152
+ let meta = null;
1153
+ try {
1154
+ meta = JSON.parse(fs.readFileSync(cache.replace(/\.jpg$/, ".json"), "utf8"));
1155
+ } catch { /* render below */ }
1156
+ // Self-healing: if the JPEG exists but meta doesn't, re-render so both are
1157
+ // written together. A partial write (JPEG with no meta) otherwise serves
1158
+ // 500 forever because meta stays null.
1159
+ if (!fs.existsSync(cache) || !meta) {
1160
+ const r = isSheet ? await frameGrid(file, opts) : await frame(file, opts);
1161
+ meta = isSheet
1162
+ ? { frames: r.frames, cols: r.cols, rows: r.rows, times: r.times, duration: r.duration, cellWidth: r.width, stats: r.stats }
1163
+ : { frames: 1, times: [r.time], stats: r.stats };
1164
+ meta.source = pathArg ? pathArg : src;
1165
+ const tmp = cache + ".tmp";
1166
+ fs.writeFileSync(tmp, r.jpeg);
1167
+ fs.renameSync(tmp, cache); // atomic: a half-written jpeg never gets cached
1168
+ fs.writeFileSync(cache.replace(/\.jpg$/, ".json"), JSON.stringify(meta));
1169
+ /* Bound the cache: every distinct argument set mints a file, so cap
1170
+ the directory oldest-first rather than letting it grow forever. */
1171
+ try { pruneCache(FRAMES_DIR); } catch { /* a prune failure is not the request's problem */ }
1172
+ } else {
1173
+ // Refresh LRU order so hot entries survive the prune above.
1174
+ try { fs.utimesSync(cache, new Date(), new Date()); } catch {}
1175
+ }
1176
+ /* text=1 answers JSON measurements instead of the picture, for agents and
1177
+ models that cannot see one. Same decode, no pixels over the wire. */
1178
+ if (url.searchParams.get("text") === "1") {
1179
+ sendJSON(res, 200, {
1180
+ source: meta.source, frames: meta.frames, times: meta.times,
1181
+ cols: meta.cols || null, rows: meta.rows || null, duration: meta.duration ?? null,
1182
+ stats: meta.stats || null,
1183
+ });
1184
+ return;
1185
+ }
1186
+ res.writeHead(200, {
1187
+ "Content-Type": "image/jpeg",
1188
+ "Content-Length": fs.statSync(cache).size,
1189
+ "Cache-Control": "no-cache",
1190
+ "X-FableCut-Frame-Times": (meta.times || []).join(","),
1191
+ "X-FableCut-Frame-Source": encodeURI(meta.source || src),
1192
+ });
1193
+ fs.createReadStream(cache).pipe(res);
1194
+ } catch (e) {
1195
+ const msg = e && e.message ? e.message : String(e);
1196
+ /* A bad argument, a missing file, or a timestamp past the end is the
1197
+ caller's problem; anything else is ours. */
1198
+ sendJSON(res, /must be|File not found|no video|no frame|past the end/.test(msg) ? 400 : 500, { error: msg });
1199
+ }
1200
+ return;
1201
+ }
1202
+
726
1203
  /* API: noise reduction — POST {src:"/media/…"|"/library/…", amount:"light"|"medium"|"strong"}
727
1204
  writes a denoised FLAC of the whole file into media/ (reused if it exists)
728
1205
  and returns {src, name, duration, channels}. Registering it is the caller's job. */
@@ -731,11 +1208,8 @@ const server = http.createServer(async (req, res) => {
731
1208
  try {
732
1209
  const opts = JSON.parse((await readBody(req)).toString("utf8") || "{}");
733
1210
  if (!DENOISE_AMOUNTS.includes(opts.amount)) { sendJSON(res, 400, { error: `amount must be one of ${DENOISE_AMOUNTS.join(", ")}` }); return; }
734
- const src = decodeURIComponent(String(opts.src || "").split("?")[0]);
735
- const [root, rel] = src.startsWith("/media/") ? [MEDIA_DIR, src.slice(7)]
736
- : src.startsWith("/library/") ? [LIBRARY_DIR, src.slice(9)] : [null, null];
737
- const file = root && path.normalize(path.join(root, rel));
738
- if (!file || !file.startsWith(path.normalize(root + path.sep)) || !fs.existsSync(file)) {
1211
+ const file = resolveSrc(opts.src);
1212
+ if (!file) {
739
1213
  sendJSON(res, 404, { error: "src must name an existing file under /media/ or /library/" });
740
1214
  return;
741
1215
  }
@@ -757,9 +1231,18 @@ const server = http.createServer(async (req, res) => {
757
1231
  return;
758
1232
  }
759
1233
 
760
- /* Media files */
1234
+ /* Media files (subfolders too — relinked footage can sit in one) */
761
1235
  if (p.startsWith("/media/")) {
762
- const file = path.join(MEDIA_DIR, path.basename(p));
1236
+ const file = path.normalize(path.join(MEDIA_DIR, p.slice("/media/".length)));
1237
+ if (!file.startsWith(MEDIA_DIR + path.sep)) { res.writeHead(403); res.end(); return; }
1238
+ serveFile(req, res, file);
1239
+ return;
1240
+ }
1241
+
1242
+ /* Proxies (analysis/proxies) */
1243
+ if (p.startsWith("/proxies/")) {
1244
+ const file = proxies.file(p.slice("/proxies/".length));
1245
+ if (!file) { res.writeHead(404); res.end(); return; }
763
1246
  serveFile(req, res, file);
764
1247
  return;
765
1248
  }