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/CLAUDE.md +373 -11
- package/README.md +48 -5
- package/analyze.js +161 -9
- package/app.js +1894 -109
- package/delivery.js +779 -0
- package/export-jobs.js +9 -5
- package/frame-cache.js +49 -0
- package/frame-stats.js +267 -0
- package/index.html +136 -4
- package/mcp-server.js +318 -1070
- package/media-lib.js +287 -0
- package/media-probe.js +88 -0
- package/package.json +1 -1
- package/paths.js +9 -2
- package/project-ops.js +1290 -0
- package/proxy.js +180 -0
- package/server.js +498 -15
- package/style.css +361 -0
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
|
735
|
-
|
|
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,
|
|
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
|
}
|