fablecut 1.13.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/README.md CHANGED
@@ -120,6 +120,8 @@ same time.
120
120
  ffmpeg; the clip's audio switches to it and **Off** switches back. Agents
121
121
  use `fablecut_denoise`.
122
122
  - **Project bin folders** — tree view with expand/collapse; drag media or folders to nest; right-click the **Project** tab → New folder; drop files onto a folder to import into it
123
+ - **Media management** — each file's frame rate, codecs, audio channels and size (ffprobe); search by name, codec, resolution or `#tag`; filter by favorites, used / unused, kind, tag or collection; sort by any detail or by times used; a list view with metadata columns; favorites, tags and collections; **Find timeline instances** and **Reveal in project**; offline files flagged with a "Media offline" slate, **Relink…** (finds moved files by name and size) and **Replace file…**
124
+ - **Proxies** — light, frame-accurate H.264 copies of 4K / HEVC / ProRes footage made in the background (ffmpeg); edit on the proxy with the monitor's **Proxy** button, export always renders the originals
123
125
  - **Import from URL** — **+ URL** downloads an HTTPS video/audio/image into
124
126
  `./media/` (same-origin after import). Remote SVG is refused. Agents use
125
127
  `fablecut_import_media` with an `https://` path. The URL is not kept as
@@ -194,6 +196,27 @@ same time.
194
196
  difference, each with feather, expansion, invert and opacity, all
195
197
  keyframable (outlines morph). Draw and edit them on the monitor, with a
196
198
  matte view. Agents use the `setMask` patch op and measure with `fablecut_scopes`
199
+ - **Shapes and motion graphics** — shape layers of rectangles, ellipses,
200
+ polygons, stars, lines and pen-drawn bezier paths in groups, with fill,
201
+ stroke, dashes and trim paths for draw-ons; null objects, parenting and an
202
+ anchor point on every clip; every value keyframes, edited right on the
203
+ monitor. Agents use the `setShape` and `setParent` patch ops
204
+ - **Motion paths, motion blur and text on a path** — bend a clip's travel into
205
+ a curve with handles on the monitor, turn it along the way, blur fast moves
206
+ with a shutter angle (preview and export), and set titles along lines, arcs,
207
+ circles or drawn paths. Agents use the `setMotionPath` and `setTextPath`
208
+ patch ops
209
+ - **Compositions** — nested sequences: build a lower third, a logo sting or a
210
+ scene once and place it anywhere as one clip that moves, masks, grades and
211
+ blends like a picture, while its video and audio play and export as usual.
212
+ Open one to edit it on the timeline, or precompose a selection. Agents use
213
+ the `addComp`, `inComp` and `precompose` patch ops
214
+ - **Animation presets and templates** — 33 ready-made entrances, exits,
215
+ emphases and loops that fit any clip where it sits, and templates (lower
216
+ thirds, logo animations, 9:16 / 1:1 / 16:9 social layouts) whose text,
217
+ colours and logo you fill in, each placed as its own comp. Save your own
218
+ animations and comps to the library for every project. Agents use the
219
+ `applyPreset`, `addTemplate` and `setTemplate` patch ops
197
220
  - **Motion tracking** — follow a point or a region (with its scale and
198
221
  rotation) through a video clip, then make a mask follow it or pin a title,
199
222
  sticker or any clip to it; the result is ordinary editable keys. Agents use
@@ -266,6 +289,20 @@ same time.
266
289
  `node analyze.js ref.mp4`, `POST /api/analyze`, or the
267
290
  `fablecut_analyze_reference` MCP tool.
268
291
 
292
+ **Look at the footage**
293
+
294
+ - An agent (or you, over REST) can fetch an actual **image** of any moment:
295
+ `GET /api/frame?src=/media/x.mp4&t=3.2` returns a JPEG of that frame, and
296
+ `&frames=12` returns a 4×3 **contact sheet** across the clip — a cheap survey
297
+ of everything a 3-minute take holds. Grabs are cached under `analysis/frames/`.
298
+ - The `fablecut_frame` MCP tool wraps it and returns the image inline, so a
299
+ multimodal model can *look*. Pass a `time` instead of a `path` and it renders
300
+ the **composed timeline** through the editor's own compositor — graded, all
301
+ tracks, identical to what export produces — so "is this grade right?" and "what
302
+ did I actually build?" have a visual answer.
303
+ - Together with the analyzer above, agents pick footage by looking at it instead
304
+ of guessing from an energy number.
305
+
269
306
  **Asset library**
270
307
 
271
308
  - `library/` folders surface as tabs in the UI: **Elements** (overlay art),
@@ -273,18 +310,36 @@ same time.
273
310
 
274
311
  **Export**
275
312
 
313
+ - **Delivery presets**: YouTube, YouTube Shorts, Instagram Reels, TikTok, X,
314
+ LinkedIn, Master, Broadcast (ProRes 422 HQ), Web, Archive (lossless FFV1) and
315
+ Custom. Every setting is a control: resolution and reframing (crop, fit or
316
+ stretch to another shape), frame rate, codec (H.264, HEVC, AV1, VP9, ProRes,
317
+ DNxHR, XDCAM HD422, FFV1) and hardware encoder, CRF / VBR / CBR, max rate,
318
+ keyframe interval, pixel format, color, container, audio codec, bitrate and
319
+ rate, loudness normalization (−14 / −16 / −23 / −24 LUFS with a true-peak
320
+ ceiling), a file-name template and metadata. Save your own presets; the
321
+ dialog warns about upscaling, crops and platform limits before it renders,
322
+ and every finished file is read back and checked — size, frame rate, frame
323
+ count, codecs, length, loudness, true peak and file size
324
+ - **Batch export**: one project → many files (master, YouTube 1080p, a 9:16
325
+ reel, a square, a 720p preview…) from **one render per frame rate and
326
+ range** (up to 8 outputs each) — every frame is drawn once and each output's ffmpeg
327
+ encodes it. Batch outputs are saved with the
328
+ project; an **export queue** runs them back to back with progress, stop,
329
+ retry and open
276
330
  - Fast export: browser renders every frame + an offline audio mix; ffmpeg
277
- encodes **JPEG frames** via an **encoding profile** from
331
+ encodes them with a delivery preset, or a raw **encoding profile** from
278
332
  `encoding-profiles.json` (keeps rendering if you switch tabs). Encode and
279
333
  upload run ahead of the compositor so a fast timeline is not stalled by
280
- `toBlob`. The Export dialog has a profile selector; pin a project default
281
- with `encodeProfile` in `project.json`
334
+ `toBlob`
282
335
  - WebCodecs export: the browser HW-encodes Annex-B H.264; the server
283
336
  stream-copies and muxes audio. Faster uploads; bitrate/VBR-CBR in the
284
337
  Export dialog. Unavailable while an export frame is set (use Fast)
285
338
  - Realtime MediaRecorder fallback when ffmpeg or WebCodecs isn't available
286
339
  - Agents export too: `fablecut_export` runs the Fast export in the open editor,
287
- or in a headless Chrome / Edge the server starts, and returns the file path
340
+ or in a headless Chrome / Edge the server starts, and returns the file path;
341
+ `preset` and `delivery` pick and adjust a delivery preset; `outputs` makes
342
+ several files in one go
288
343
  - Export **Range** dropdown: Entire timeline or IN–OUT (defaults to IN–OUT when
289
344
  markers are set). Effective IN/OUT export bounds are clamped to `projDur()`.
290
345
 
@@ -323,8 +378,8 @@ you choose. Leave it unset and everything stays in the repo, exactly as before.
323
378
  /plugin install fablecut@fablecut
324
379
  ```
325
380
 
326
- That registers the MCP server for you and adds two skills — `edit-video` and
327
- `remake-reel`. Your timeline and footage live in the plugin's own data
381
+ That registers the MCP server for you and adds three skills — `edit-video`,
382
+ `remake-reel` and `motion-graphics`. Your timeline and footage live in the plugin's own data
328
383
  directory, so an update never touches them. Node 18+ and (optionally) ffmpeg
329
384
  still need to be on your machine.
330
385
 
@@ -391,7 +446,7 @@ Three equivalent control surfaces:
391
446
 
392
447
  Tools: `fablecut_status` (auto-starts the editor), `fablecut_docs`,
393
448
  `fablecut_get_project`, `fablecut_set_project`, `fablecut_patch_project`,
394
- `fablecut_import_media`, `fablecut_analyze_reference`,
449
+ `fablecut_import_media`, `fablecut_analyze_reference`, `fablecut_frame`,
395
450
  `fablecut_encode_profiles`, `fablecut_normalize_audio`, `fablecut_auto_duck`,
396
451
  `fablecut_denoise`, `fablecut_export`, `fablecut_scopes`, `fablecut_track`.
397
452
 
@@ -420,6 +475,15 @@ my clips, same music"*. The agent calls `fablecut_analyze_reference`, gets the
420
475
  blueprint (cuts, beats, BPM, energy, drop, extracted music), and rebuilds the
421
476
  structure shot-for-shot with your footage.
422
477
 
478
+ **The agent can see, not just measure.** `fablecut_analyze_reference` gives it
479
+ the *ears* — where the cuts land, which shots hit hard. `fablecut_frame` gives it
480
+ the *eyes*: ask for any moment of any clip (`{path, t:3.2}`) and the call comes
481
+ back with an actual image the model can look at, or ask for a 12-frame contact
482
+ sheet to survey a whole clip in one call (`{path, frames:12}`). Pass a `time`
483
+ with no `path` and it renders the composed timeline — graded, all tracks, exactly
484
+ as export would. So footage gets chosen by looking at it rather than guessing
485
+ from a number, and a grade gets checked against the real picture.
486
+
423
487
  **Conflict-safe concurrent editing**: the UI, the MCP tools, and direct
424
488
  `project.json` writes all agree on a `revision` counter. If you edit a clip in
425
489
  the UI while an agent is mid-task, the agent's next write is rejected (409 from
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
  }