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/CLAUDE.md +802 -16
- package/README.md +71 -7
- package/analyze.js +161 -9
- package/app.js +3905 -258
- package/comp.js +195 -0
- package/delivery.js +779 -0
- package/edit-ops.js +50 -18
- package/export-jobs.js +9 -5
- package/frame-cache.js +49 -0
- package/frame-stats.js +267 -0
- package/index.html +282 -72
- package/library/README.md +2 -0
- package/library/elements/logo-placeholder.svg +12 -0
- package/mcp-server.js +324 -656
- package/media-lib.js +287 -0
- package/media-probe.js +88 -0
- package/motion.js +803 -0
- package/package.json +1 -1
- package/paths.js +33 -3
- package/project-ops.js +1290 -0
- package/proxy.js +180 -0
- package/server.js +517 -15
- package/style.css +534 -20
- package/templates.js +576 -0
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
|
|
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
|
|
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
|
|
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]"
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
}
|