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/analyze.js CHANGED
@@ -5,13 +5,16 @@
5
5
  music beats + BPM, an audio-energy curve, the drop, and the extracted music
6
6
  track — everything an agent needs to rebuild the same edit with new footage.
7
7
 
8
- Use as a module: const { analyze } = require("./analyze");
8
+ Use as a module: const { analyze, frame, frameGrid } = require("./analyze");
9
9
  Use from the CLI: node analyze.js media/ref.mp4 [--threshold=0.3] [--no-music]
10
+ node analyze.js --frame=3.2 media/ref.mp4 > frame.jpg
11
+ node analyze.js --sheet=12 media/ref.mp4 > sheet.jpg
10
12
  ═══════════════════════════════════════════════════════════════════════════ */
11
13
  "use strict";
12
14
  const fs = require("fs");
13
15
  const path = require("path");
14
16
  const { spawn } = require("child_process");
17
+ const Stats = require("./frame-stats");
15
18
 
16
19
  const SR = 22050; // analysis sample rate
17
20
  const HOP = 512; // onset-envelope hop size (~23 ms)
@@ -193,6 +196,143 @@ async function extractMusic(file, outDir) {
193
196
  return out;
194
197
  }
195
198
 
199
+ /* ── Frames: one JPEG still, or an N-up contact sheet ──
200
+ The analyzer above is the "ears" — cuts, beats, energy. These are the "eyes":
201
+ what is actually on screen at a given moment, so an agent picks a shot by
202
+ looking instead of guessing from an energy number.
203
+
204
+ A single frame is an input seek (-ss before -i): ffmpeg jumps to the keyframe
205
+ at or before t and decodes forward, which is accurate and far cheaper than
206
+ decoding everything up to t. Output goes to a pipe, not a temp file.
207
+
208
+ Every grab also returns measurements (frame-stats.js) alongside the pixels,
209
+ because the model on the other end of the MCP socket may not be able to see
210
+ the image. One decode, two answers: the JPEG and the numbers. */
211
+
212
+ const MAX_SHEET_FRAMES = 60; // one tile filter pass; more is a wall of thumbnails
213
+ const MAX_SHEET_COLS = 12;
214
+ const MIN_WIDTH = 32;
215
+ const MAX_WIDTH = 3840;
216
+ /* Measurements are taken from a decode this size, independent of the JPEG's:
217
+ a colour histogram over a few thousand pixels says exactly as much as one
218
+ over a few hundred thousand, and the mean luma of a downscaled frame is
219
+ cleaner (no JPEG ringing around edges). */
220
+ const STATS_WIDTH = 64;
221
+ /* …and one contact-sheet cell's width, for the same reason. */
222
+ const STATS_CELL_W = 48;
223
+
224
+ function checkWidth(w) {
225
+ const n = Math.round(Number(w) || 0);
226
+ if (!Number.isFinite(n) || n < MIN_WIDTH || n > MAX_WIDTH)
227
+ throw new Error(`width must be ${MIN_WIDTH}–${MAX_WIDTH} px`);
228
+ return n;
229
+ }
230
+ function checkQuality(q) {
231
+ const n = q == null ? 4 : Math.round(Number(q));
232
+ if (!Number.isFinite(n) || n < 1 || n > 31) throw new Error("quality must be 1–31 (ffmpeg -q:v, lower is better)");
233
+ return n;
234
+ }
235
+ /** Run an ffmpeg graph and hand back raw bytes.
236
+ * `pre` sits BEFORE -i (input seek), `post` after it (-t bounds the decode);
237
+ * `filter` is -vf. */
238
+ async function pipeOut(file, pre, filter, post, fmtArgs) {
239
+ const args = ["-hide_banner", "-nostats", "-loglevel", "error", ...(pre || []), "-i", file];
240
+ args.push(...(post || []), "-an", "-sn", "-dn", "-vf", filter, ...fmtArgs);
241
+ const r = await run("ffmpeg", args, { binary: true });
242
+ if (!r.stdout || !r.stdout.length) throw new Error("ffmpeg produced nothing — is the timestamp inside the clip? (has video?)");
243
+ return r.stdout;
244
+ }
245
+ /** One JPEG of a frame, for the model to look at. */
246
+ const jpegOut = (file, pre, filter, quality, post) =>
247
+ pipeOut(file, pre, filter, post, ["-frames:v", "1", "-c:v", "mjpeg", "-q:v", String(quality), "-f", "image2pipe", "-"])
248
+ .then((buf) => {
249
+ if (buf[0] !== 0xff || buf[1] !== 0xd8)
250
+ throw new Error("ffmpeg produced no frame — is the timestamp inside the clip? (has video?)");
251
+ return buf;
252
+ });
253
+ /** One raw RGB24 decode of the same frame, to measure. */
254
+ const rgbOut = (file, pre, filter, post) =>
255
+ pipeOut(file, pre, filter, post, ["-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]);
256
+
257
+ /** The graph every single-frame call shares, at two sizes. */
258
+ /** One still at `t` seconds. Returns {jpeg, time, stats, …}. */
259
+ async function frame(file, { t = 0, width = 768, quality, stats = true } = {}) {
260
+ if (!fs.existsSync(file)) throw new Error("File not found: " + file);
261
+ const at = Number(t);
262
+ if (!Number.isFinite(at) || at < 0) throw new Error("t must be seconds ≥ 0");
263
+ const w = checkWidth(width), q = checkQuality(quality);
264
+ /* ffmpeg clamps an out-of-range seek and hands back the LAST frame, so an
265
+ agent asking for a moment past the end would get a plausible-looking but
266
+ wrong picture. Refuse instead of lying about where this is. */
267
+ const info = await probe(file);
268
+ if (!info.hasVideo) throw new Error("the file has no video track — there is no frame to look at");
269
+ if (at >= info.duration) throw new Error(`t=${at} is past the end of the clip (${info.duration}s)`);
270
+ const pre = ["-ss", String(at)]; // seek to the keyframe before t, decode forward
271
+ const jpeg = await jpegOut(file, pre, `scale=${w}:-2:flags=bilinear`, q);
272
+ let measured = null;
273
+ if (stats) {
274
+ /* -2 rounds the height to an even number, so the real pixel count can
275
+ differ from width*height by one row; derive the height from the bytes
276
+ rather than assuming it. */
277
+ const raw = await rgbOut(file, pre, `scale=${STATS_WIDTH}:-2:flags=bilinear`);
278
+ const h = Math.max(1, Math.round(raw.length / 3 / STATS_WIDTH));
279
+ if (raw.length >= 3 * STATS_WIDTH) measured = Stats.describe(raw, STATS_WIDTH, h);
280
+ }
281
+ return { jpeg, time: at, width: w, frames: 1, duration: info.duration, stats: measured };
282
+ }
283
+
284
+ /** N frames spread across the whole clip in one grid image (left→right, top→bottom).
285
+ Returns {jpeg, times[], cols, rows, …}. Cell i is the frame at times[i]. */
286
+ async function frameGrid(file, { frames = 12, cols = null, from = 0, to, width = 320, quality, stats = true } = {}) {
287
+ if (!fs.existsSync(file)) throw new Error("File not found: " + file);
288
+ const info = await probe(file);
289
+ if (!info.hasVideo) throw new Error("the file has no video track — there is no frame to look at");
290
+ const n = Math.round(Number(frames));
291
+ if (!Number.isFinite(n) || n < 2 || n > MAX_SHEET_FRAMES)
292
+ throw new Error(`frames must be 2–${MAX_SHEET_FRAMES}`);
293
+ /* No cols given → the squarest layout: 6 frames give 3×2, not 4×2 with two
294
+ black cells. ceil(√n) always fits MAX_SHEET_COLS for n ≤ 60, and an
295
+ explicit cols still wins. */
296
+ const c = cols == null ? Math.ceil(Math.sqrt(n)) : Math.round(Number(cols));
297
+ if (!Number.isFinite(c) || c < 1 || c > MAX_SHEET_COLS) throw new Error(`cols must be 1–${MAX_SHEET_COLS}`);
298
+ const rows = Math.ceil(n / c);
299
+ const start = Number.isFinite(Number(from)) ? Math.max(0, Number(from)) : 0;
300
+ const end = Number.isFinite(Number(to)) ? Number(to) : info.duration;
301
+ if (!(end > start)) throw new Error("to must be greater than from");
302
+ const w = checkWidth(width), q = checkQuality(quality);
303
+
304
+ // One sample per bin, mid-bin: fps= gives us exactly n frames across the span.
305
+ const times = Array.from({ length: n }, (_, i) => Math.round((start + (i + 0.5) * (end - start) / n) * 1000) / 1000);
306
+ const fps = n / (end - start);
307
+ // -ss to the start of the range and -t to bound it, so a survey of 30–90 s in
308
+ // a 10-minute file doesn't decode the ten minutes. fps= then samples n
309
+ // frames evenly across what is left.
310
+ const pre = start > 0 ? ["-ss", String(start)] : [];
311
+ const span = end - start;
312
+ /* tile packs cols×rows input frames into one output frame and pads the final
313
+ row with black if the input runs out — the mid-bin sampling above only
314
+ misses by a frame at the very end, never a whole cell. */
315
+ const graph = `fps=${fps.toFixed(6)},scale=${w}:-2:flags=bilinear,tile=${c}x${rows}`;
316
+ const jpeg = await jpegOut(file, pre, graph, q, ["-t", String(span)]);
317
+
318
+ let measured = null;
319
+ if (stats !== false) {
320
+ /* Measure the same grid with tiny cells — 48px wide is plenty for a
321
+ histogram, a mean luma and a per-cell difference, and keeps the second
322
+ decode cheap. scale=-2 keeps the cell's aspect, so the cell height
323
+ follows from the pixel count: the grid holds cols×rows cells of
324
+ STATS_CELL_W, and the byte count says how tall they came out. */
325
+ const graph2 = `fps=${fps.toFixed(6)},scale=${STATS_CELL_W}:-2:flags=bilinear,tile=${c}x${rows}`;
326
+ const raw = await rgbOut(file, pre, graph2, ["-t", String(span)]);
327
+ const gw = c * STATS_CELL_W;
328
+ const gh = Math.round(raw.length / 3 / gw);
329
+ measured = gh >= rows
330
+ ? Stats.describeGrid(raw, gw, gh, c, rows, times)
331
+ : null;
332
+ }
333
+ return { jpeg, times, cols: c, rows, width: w, frames: n, duration: info.duration, stats: measured };
334
+ }
335
+
196
336
  /* ── Main entry ──
197
337
  opts: threshold — scene-cut sensitivity (default: adaptive 0.30→0.20→0.12)
198
338
  music — extract the audio track (default true)
@@ -273,17 +413,29 @@ async function analyze(file, opts = {}) {
273
413
  };
274
414
  }
275
415
 
276
- module.exports = { analyze };
416
+ module.exports = { analyze, frame, frameGrid };
277
417
 
278
418
  /* ── CLI ── */
279
419
  if (require.main === module) {
280
420
  const args = process.argv.slice(2);
281
421
  const file = args.find((a) => !a.startsWith("--"));
282
- if (!file) { console.error("Usage: node analyze.js <video> [--threshold=0.3] [--no-music]"); process.exit(1); }
283
- const tArg = args.find((a) => a.startsWith("--threshold="));
284
- analyze(path.resolve(file), {
285
- threshold: tArg ? parseFloat(tArg.split("=")[1]) : undefined,
286
- music: !args.includes("--no-music"),
287
- }).then((bp) => console.log(JSON.stringify(bp, null, 2)))
288
- .catch((e) => { console.error("analyze failed: " + e.message); process.exit(1); });
422
+ if (!file) { console.error("Usage: node analyze.js <video> [--threshold=0.3] [--no-music]\n" +
423
+ " node analyze.js --frame=3.2 <video> > frame.jpg\n" +
424
+ " node analyze.js --sheet=12 --cols=4 <video> > sheet.jpg"); process.exit(1); }
425
+ const arg = (k) => { const a = args.find((x) => x.startsWith("--" + k + "=")); return a ? a.slice(k.length + 3) : undefined; };
426
+ const tArg = arg("threshold");
427
+ const p = path.resolve(file);
428
+ const write = (buf) => process.stdout.write(buf);
429
+ if (args.some((a) => a.startsWith("--frame=") || a.startsWith("--sheet=")))
430
+ (arg("sheet")
431
+ ? frameGrid(p, { frames: +arg("sheet"), cols: arg("cols") == null ? null : +arg("cols"), from: arg("from"), to: arg("to"), width: +(arg("width") || 320), quality: arg("quality") })
432
+ : frame(p, { t: +arg("frame"), width: +(arg("width") || 768), quality: arg("quality") })
433
+ ).then((r) => write(r.jpeg))
434
+ .catch((e) => { console.error("frame extraction failed: " + e.message); process.exit(1); });
435
+ else
436
+ analyze(p, {
437
+ threshold: tArg ? parseFloat(tArg) : undefined,
438
+ music: !args.includes("--no-music"),
439
+ }).then((bp) => console.log(JSON.stringify(bp, null, 2)))
440
+ .catch((e) => { console.error("analyze failed: " + e.message); process.exit(1); });
289
441
  }