ffmpeg-skill 1.17.3 → 1.18.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/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`1.17.3`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.18.0`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -88,19 +88,19 @@ spelling keeps working until 2.0.
88
88
 
89
89
  | What 2.0 removes | Since | Replacement | To be ready today |
90
90
  |---|---|---|---|
91
- | The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.17.3 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
92
- | `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.17.3 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
93
- | `json` and `progress` in the MCP `inputSchema` | 1.17.3 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
94
- | `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.17.3 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
95
- | Overwriting an existing output with only a warning | 1.17.3 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
91
+ | The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.18.0 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
92
+ | `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.18.0 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
93
+ | `json` and `progress` in the MCP `inputSchema` | 1.18.0 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
94
+ | `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.18.0 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
95
+ | Overwriting an existing output with only a warning | 1.18.0 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
96
96
 
97
97
  ## Skill
98
98
 
99
99
  ```json
100
100
  {
101
101
  "contract_version": "1.0",
102
- "deprecated": [{"what": "...", "since": "1.17.3", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
- "skill": {"id": "ffmpeg-skill", "version": "1.17.3", "execution_mode": "local", "kind": "execution",
102
+ "deprecated": [{"what": "...", "since": "1.18.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
+ "skill": {"id": "ffmpeg-skill", "version": "1.18.0", "execution_mode": "local", "kind": "execution",
104
104
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
105
105
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
106
106
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -128,7 +128,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
128
128
  | `output_schema` | what `--json` prints on stdout |
129
129
  | `supports_dry_run`, `dry_run` | whether `--dry-run` plans without running ffmpeg or writing files |
130
130
  | `supports_json` | whether `--json` exists |
131
- | `supports_json_brief` | whether `--json-brief` exists (1.17.3): the same success document with `probe` replaced by a compact `summary` (`duration_s`, `width`, `height`, `fps`, `vcodec`, `acodec`, `channels`, and `lufs` when the tool measured one), `commands` replaced by the number of commands run, and the per-step `verification` list dropped (its verdict stays in `verified`). Tool-specific keys are unchanged, `--json`'s own output is unchanged, and a failure prints the same failure document either way |
131
+ | `supports_json_brief` | whether `--json-brief` exists (1.18.0): the same success document with `probe` replaced by a compact `summary` (`duration_s`, `width`, `height`, `fps`, `vcodec`, `acodec`, `channels`, and `lufs` when the tool measured one), `commands` replaced by the number of commands run, and the per-step `verification` list dropped (its verdict stays in `verified`). Tool-specific keys are unchanged, `--json`'s own output is unchanged, and a failure prints the same failure document either way |
132
132
  | `mutates_input` | always `false`: no tool overwrites its input |
133
133
  | `produces_artifact` | writes a file (media, PNG, HTML, EDL) |
134
134
  | `verification` | `{required, tools}`: which tools to run on the output afterwards |
@@ -450,7 +450,7 @@ Per-tool keys added in 1.17, all additive:
450
450
  | `jobs`, `jobs_requested`, `wall_seconds`, `item_seconds_total`, `timed_out` | `batch.py` | the parallelism actually applied and the number asked for, the batch's wall clock, the sum of the per-item times (so the speed-up can be quoted), and whether the shared timeout budget ran out. A timed-out item carries `"skipped": "timeout"` in its result row |
451
451
  | `cache` | `render.py --cache` | `{dir, ffmpeg, hits, misses, saved_seconds, entries}`, plus `would_hit` under `--dry-run`. The ffmpeg build banner, the skill version, the contract version, the forwarded flags (`--fast`, `--codec`, …) and the output's extension are all part of every key, so a cache is never reused across any of them — a `--fast` draft is never served to a run that did not ask for one |
452
452
 
453
- Per-tool keys added in 1.17.3, all additive:
453
+ Per-tool keys added in 1.18.0, all additive:
454
454
 
455
455
  | key | tool | what it holds |
456
456
  |---|---|---|
@@ -458,6 +458,18 @@ Per-tool keys added in 1.17.3, all additive:
458
458
  | `text_unchanged` | `caption.py` | a sibling inside the `caption` block, **burn mode only** (`--mode mux` never touches the text and omits the key): `true` when the drawn text equals the cues that were handed in — nothing transcribed, no cue dropped, no cue **split** across two consecutive cues and no glyph stripped (`--emoji none`). Wrapping, line breaks and timing do not count: the words are the same. This tool never rewrites, shortens or translates a cue, so the key is a statement of what happened, not a judgement of the text |
459
459
 
460
460
 
461
+ Per-tool keys added in 1.18.0, all additive:
462
+
463
+ | key | tool | what it holds |
464
+ |---|---|---|
465
+ | `shots` | `scenes.py --shots` | `[{start, end, label, flow_magnitude}]` per detected scene, `label` one of `static`/`pan`/`motion` from a lightweight block-matching optical-flow proxy (frames decoded at 4 fps, 48x27, no external dependency). A shot too short to sample two frames is `static` with `flow_magnitude: 0` |
466
+ | `audio_peaks_db` | `scenes.py --audio-peaks` | `[{time, level}]`, measured dBFS loudness peaks. A **new** key: the pre-existing `audio_peaks` (always reported, unrelated unitless RMS figures used for `--highlights` scoring) keeps its 1.0 meaning unchanged |
467
+ | `speech` | `scenes.py --speech` | `[{time, speech_music_ratio}]`, a per-second zero-crossing-rate ratio against the file's own median — a measured proxy, not a speech/music classification |
468
+ | `motion_centre` | `cropdetect.py --motion-centre` | `[{time, x, y, x_frac, y_frac, motion}]` per second, sampled over the same windows as the crop-bar detection. `x`/`y` are source pixels, `x_frac`/`y_frac` a 0..1 fraction of `source_width`/`source_height`; a window with no measured motion reports `x`/`y`/`x_frac`/`y_frac: null`. Report only — this tool never picks a reframe |
469
+ | `speech_aware`, `speech_aware.breaths` | `silence.py --speech-aware` | `{min_silence, floor, breaths_kept, breaths_kept_seconds, breaths}`. `breaths` are the sub-`--min-silence` gaps kept because they sit inside a sentence; the removal list (`silences`, `keep`, `removed_seconds`) already reflects the speech-aware classification. Composes with `--filler` through the same `keep_ranges()`/`merge_spans()` pipeline, so `--speech-aware --filler` produces one removal list |
470
+ | `sources` | `sync.py` | `[{path, offset_s, confidence, drift_ppm}]`, one entry per SOURCE. Present for every run, including the original single-SOURCE shape (where it mirrors the top-level `second`/`offset_seconds`/`confidence` additively). With 2+ SOURCEs it is the *only* per-source shape: there is no top-level `second`/`offset_seconds` because there is no single pair to put there |
471
+ | `switch_mode`, `min_shot` | `multicam.py --switch energy` | `"energy"` and the `--min-shot` value used (default 1.5s), alongside the existing `cuts` (`[[start, end, camera], ...]`) which already carries the camera index for `--edl`'s companion cut list |
472
+
461
473
  `check.py` also gains an informational `subtitles` row on **every** platform:
462
474
  `PASS` when every soft subtitle stream carries a language tag, `WARN` when one
463
475
  does not (or when there are none). Like `channels` and `chapters` it is never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.17.3",
3
+ "version": "1.18.0",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -128,7 +128,7 @@ these had ever shown up before.
128
128
  are still spelled exactly that way: `_common` is a package since the refactor release after
129
129
  1.15.0, and its `__init__.py` re-exports every name the single module defined, so
130
130
  `_common.drawtext_boxborderw()`, `_common.ffmpeg_version()` and `_common.bt709_tag_args()`
131
- below resolve through the facade unchanged. The definitions are in `_common/text.py`,
131
+ below resolve through the facade unchanged. The definitions are in `_common/drawtext.py`,
132
132
  `_common/runner.py` and `_common/color.py` respectively.
133
133
  - **`showwaves` keeps emitting frames after the audio ends, `-shortest` notwithstanding.** A
134
134
  12 s source came out 14.08 s on 5.1.1. `waveform.py` now also passes `-t <source duration>`.
@@ -161,7 +161,7 @@ rounded, if they aren't.
161
161
 
162
162
  ### cropdetect.py — measure black bars, report the crop rectangle
163
163
  ```
164
- cropdetect.py INPUT [--seconds N] [--samples N] [--limit F] [--round N]
164
+ cropdetect.py INPUT [--seconds N] [--samples N] [--limit F] [--round N] [--motion-centre]
165
165
  ```
166
166
  Measurement only -- writes no file. Samples `--samples` windows spread
167
167
  across the file (default 5, totalling `--seconds` 10s of footage) and
@@ -174,6 +174,14 @@ removing detected bars is wanted -- genuine letterboxed content (a
174
174
  scope-ratio film in a 16:9 frame) "detects" the same way as accidental
175
175
  bars; look at the frame before cropping it away.
176
176
 
177
+ `--motion-centre` (1.18) reports the motion centroid once per second, sampled
178
+ across the same windows: `motion_centre: [{time, x, y, x_frac, y_frac, motion}]`,
179
+ `x`/`y` in source pixels and `x_frac`/`y_frac` as a 0..1 fraction of
180
+ `source_width`/`source_height`. A frame with no measured motion in a window
181
+ reports `x`/`y`/`x_frac`/`y_frac: null` and `motion: 0`. Report only, like the
182
+ crop rectangle above -- this hands the calling agent numbers to reframe a 9:16
183
+ crop with; it never picks the subject or the crop box itself.
184
+
177
185
  ### deinterlace.py — deinterlace interlaced footage
178
186
  ```
179
187
  deinterlace.py INPUT [--mode frame|field] [--parity auto|tff|bff] [--only-interlaced] [-o OUT]
@@ -465,7 +473,7 @@ each shorter clip's last frame (with silence) out to the longest.
465
473
 
466
474
  ### silence.py — remove dead air / jump cuts
467
475
  ```
468
- silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.15] [--min-keep 0.2] [--list] [--edl keep.txt] [-o OUT]
476
+ silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.15] [--min-keep 0.2] [--list] [--edl keep.txt] [--speech-aware] [-o OUT]
469
477
  silence.py INPUT --filler --words transcript.json [--filler-lang auto|en|ja|es|de|fr|pt|it]
470
478
  [--filler-words FILE] [--filler-extra W,W] [--filler-keep W,W] [--filler-pad 0.02]
471
479
  [--transcribe] [--filler-list] [--max-cuts 400]
@@ -531,6 +539,17 @@ additive sibling covering everything that went. A filler word quiet enough to
531
539
  sit inside a detected silence is merged into it rather than counted twice, so
532
540
  the two figures can be equal.
533
541
 
542
+ **`--speech-aware` (1.18)** keeps breaths shorter than `--min-silence` when
543
+ they sit inside a sentence, and only cuts at sentence-boundary pauses
544
+ (`--min-silence` or longer). It re-runs silence detection at a much shorter
545
+ floor to measure the short gaps at all, then classifies each one: shorter than
546
+ `--min-silence` is an in-sentence breath (kept, listed in
547
+ `speech_aware.breaths`); `--min-silence` or longer is a sentence boundary (cut,
548
+ same as the plain flag). It composes with `--filler` through the same
549
+ `keep_ranges()`/`merge_spans()` pipeline the filler-inside-a-silence fix
550
+ already uses, so `--speech-aware --filler` produces one removal list. `--edl`
551
+ writes the resulting cut list exactly as it does today.
552
+
534
553
  ### join.py — concatenate with transitions
535
554
  ```
536
555
  join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadeblack|fadewhite|circleopen|none]
@@ -701,6 +720,7 @@ already has a picture.
701
720
  ```
702
721
  scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15]] [--edl picks.txt] [--sheet scenes.png] [--json]
703
722
  scenes.py INPUT --beats [--beat-step 0.01] [--beat-range 60-200] [--min-confidence 0.5] [--json]
723
+ scenes.py INPUT [--shots] [--audio-peaks] [--speech] [--json]
704
724
  ```
705
725
  Lists scenes with audio energy, the loudest moments, and (with
706
726
  `--highlights`) proposes N ranges that add up to `--target` seconds, biased to
@@ -751,6 +771,29 @@ would change a file on the strength of it (`cut.py --snap beats`). `--edl` with
751
771
  `--beats` is unchanged: beats are never written as an EDL, because a beat is
752
772
  not a cut. No audio stream is a `kind: input` refusal.
753
773
 
774
+ **`--shots`, `--audio-peaks`, `--speech` (1.18)** are three independent
775
+ measurements that combine with each other and with `--beats`/`--highlights`:
776
+
777
+ - `--shots` labels each already-detected scene `static`/`pan`/`motion` by a
778
+ lightweight block-matching optical-flow proxy (frames decoded at 4 fps,
779
+ 48x27, no external dependency), reporting `shots: [{start, end, label,
780
+ flow_magnitude}]`. A shot too short to sample two frames is `static` with
781
+ `flow_magnitude: 0` — there is nothing to measure motion between. A proxy,
782
+ the same spirit as `--rank-by`: it reports what a coarse block match saw,
783
+ not what is interesting about the shot.
784
+ - `--audio-peaks` reports loudness peaks as measured dBFS: `audio_peaks_db:
785
+ [{time, level}]`. This is a new key, kept separate from the pre-existing
786
+ (always-on) `audio_peaks` list, whose entries are an unrelated unitless RMS
787
+ figure used for `--highlights` scoring — a different unit needed a
788
+ different key so `audio_peaks`'s meaning does not change underneath a
789
+ caller reading it since 1.0.
790
+ - `--speech` reports a per-second zero-crossing-rate ratio,
791
+ `speech: [{time, speech_music_ratio}]` — speech's rapid consonant
792
+ transients raise the zero-crossing rate; sustained tones (music, room
793
+ tone) cross zero at a steadier rate. `1.0` means "typical for this file's
794
+ own median," not an absolute threshold. This is a measured number, not a
795
+ speech/music classification — nothing here decides which stretch is which.
796
+
754
797
  ### check.py — pre-delivery compliance
755
798
  ```
756
799
  check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|facebook|broadcast|podcast|custom [--no-loudness] [--json]
@@ -905,7 +948,8 @@ hand the path to the user together with the numbers.
905
948
 
906
949
  ### multicam.py — align several cameras and switch between them
907
950
  ```
908
- multicam.py REF CAM2 [CAM3 ...] [--switch "START-END:CAM,..."] | [--auto N] [--audio IDX] [--fix-drift]
951
+ multicam.py REF CAM2 [CAM3 ...] [--switch "START-END:CAM,..."|energy [--min-shot 1.5]] | [--auto N]
952
+ [--audio IDX] [--fix-drift] [--edl cuts.txt]
909
953
  [--offsets-only] [--width W --height H --fps N] [-o OUT]
910
954
  ```
911
955
  All inputs are aligned to the first one by audio (same engine as `sync.py`,
@@ -915,6 +959,23 @@ simply alternates every N seconds. Audio comes from the reference unless
915
959
  `--audio` picks another input, e.g. an external recorder that has no video.
916
960
  `--offsets-only` reports offsets and confidence without rendering.
917
961
 
962
+ **`--switch energy` (1.18)** auto-switches to whichever camera (of those with
963
+ a video stream) measures the loudest audio at each 0.25 s window on the
964
+ reference timeline, then folds any run shorter than `--min-shot` (default
965
+ 1.5 s) into its neighbour so the cut never lingers on a shot too short to
966
+ read. A measured loudest-camera pick, the same spirit as `scenes.py
967
+ --rank-by audio`: a proxy for who is talking, not a judgement — a loud crowd
968
+ or a hot mic wins over a quiet subject exactly like the scene ranking does.
969
+ `--edl` (1.18, any switch mode) writes the resulting cut list as `cut.py
970
+ --segments`-format `START-END` lines; the camera index for each cut is
971
+ already in the JSON `cuts` field (`[[start, end, camera], ...]`), which is
972
+ what `--edl` leaves out on purpose — one file for an NLE's cut list, one
973
+ field for the camera it came from. The multicam timeline needs no dedicated
974
+ project format: each cut is a `render.py` clip (`{"src": ..., "in": ...,
975
+ "out": ...}` on that camera's own timeline, shifted by its measured offset),
976
+ so a switch list can be re-rendered with different `--min-shot` values by
977
+ editing `project.json`'s `clips`, not by inventing a second timeline schema.
978
+
918
979
  ### verify.py — real-footage verification kit
919
980
  ```
920
981
  verify.py FILES_OR_FOLDERS [--quick] [--report verify.md] [--out DIR --keep] [--seconds 6] [--json]
@@ -1185,18 +1246,18 @@ draws whatever glyph the text font has; `--emoji none` strips them).
1185
1246
 
1186
1247
  ### sync.py — offset detection, alignment, drift correction
1187
1248
  ```
1188
- sync.py REFERENCE SECOND [--json] [--max-offset 30] [--analyze-seconds 120] [--fix-drift [--drift-window 60]]
1189
- [--replace-audio | --trim-second] [-o OUT]
1249
+ sync.py REFERENCE SOURCE [SOURCE ...] [--json] [--max-offset 30] [--analyze-seconds 120]
1250
+ [--fix-drift [--drift-window 60]] [--replace-audio | --trim-second] [-o OUT]
1190
1251
  ```
1191
1252
  Cross-correlates loudness envelopes: coarse FFT search (20 ms), then a direct
1192
1253
  1 ms refinement (pure Python, a 2-minute window takes ~1-3 s). Positive offset
1193
- = the second recording started later. `--replace-audio` writes the reference
1194
- video with the second file's audio aligned (video stream copied); the output
1195
- keeps the reference's full length -- a shorter or head-trimmed second file is
1254
+ = the source recording started later. `--replace-audio` writes the reference
1255
+ video with the source file's audio aligned (video stream copied); the output
1256
+ keeps the reference's full length -- a shorter or head-trimmed source file is
1196
1257
  padded with silence, never allowed to cut the picture.
1197
- `--trim-second` writes the second file shifted to the reference timeline.
1258
+ `--trim-second` writes the source file shifted to the reference timeline.
1198
1259
  `--fix-drift` measures the offset again near the end of the overlap, reports
1199
- the clock difference in ppm, and resamples the second file so a 60-minute
1260
+ the clock difference in ppm, and resamples the source file so a 60-minute
1200
1261
  take stays in sync (typical consumer devices drift 20-500 ppm = up to 1.8 s/h).
1201
1262
  Use it whenever the recording is longer than ~10 minutes. Check `confidence`
1202
1263
  (0–1, normalised correlation with a runner-up penalty); below 0.3 the match is
@@ -1206,6 +1267,20 @@ window 95 %, misses flagged below 0.3. Keep `--analyze-seconds` at least 4×
1206
1267
  `--max-offset` (default 120 s vs 30 s): lags with under 35 % overlap are
1207
1268
  ignored, so an offset larger than ~60 % of the window cannot be found.
1208
1269
 
1270
+ **One reference, 1+ SOURCE (1.18).** With exactly one SOURCE the CLI keeps its
1271
+ original shape byte for byte -- `second`, `offset_seconds`, `confidence`,
1272
+ `meaning`, `drift` at the top level, `--replace-audio`/`--trim-second`
1273
+ available -- with the same measurement additively mirrored under a new
1274
+ `sources: [{path, offset_s, confidence, drift_ppm}]` list. With 2+ SOURCEs the
1275
+ result is one offsets JSON, `{reference, sources: [...]}`, and no flat
1276
+ `second`/`offset_seconds` (there is no single pair to put there);
1277
+ `--replace-audio`/`--trim-second` refuse with `kind: input`, since each writes
1278
+ one synced output and there is more than one source to choose from. This is
1279
+ the same offset/drift measurement `multicam.py` already loops over per camera
1280
+ internally (it imports `sync.measure_offset`); `sync.py`'s own N-source CLI
1281
+ now exposes that loop directly, for aligning cameras without cutting between
1282
+ them.
1283
+
1209
1284
  ### color.py — HDR to SDR, LUTs, colour tags, Dolby Vision
1210
1285
  ```
1211
1286
  color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|bt2390] [--peak 1000] [--desat 0] [-o OUT]
@@ -7,8 +7,9 @@ error reporting, and provides a compact media probe used by every script.
7
7
  Since the refactor release after 1.15.0 the helpers live in one module per responsibility --
8
8
  runner (process execution and timeouts), probe (ffprobe and the measured facts), decision (the
9
9
  pure copy-vs-re-encode and capability choices), emit (result documents, die(), info()), color
10
- (colour tags and the HDR paths) and text (fonts, scripts, emoji, drawtext) -- and this file is a
11
- facade that re-exports every name they define. `import _common` and `from _common import x` mean
10
+ (colour tags and the HDR paths) and, since the refactor after 1.17.3, fonts, emoji, drawtext and wrap
11
+ (text.py is a re-export shim over those four) -- and this file is a facade that re-exports every name
12
+ they define. `import _common` and `from _common import x` mean
12
13
  exactly what they meant when this was one 3072-line module; nothing else about the package is
13
14
  part of the contract.
14
15
  """
@@ -67,12 +68,13 @@ from _common.emit import (
67
68
  )
68
69
  from _common.probe import (
69
70
  analyze_levels, _aspect_string, _bit_depth, decode_pcm_mono, detect_scenes, detect_silences, fingerprint,
70
- _fraction, keyframes_near, SCORE_RE, SIL_RE,
71
+ _fraction, keyframes_near, SCORE_RE, SIL_RE, decode_gray_frames,
71
72
  measured_level_dbfs, MEDIA_EXT, _output_failed, probe, rms_envelope, _to_float, _to_int, verify_output
72
73
  )
73
74
  from _common.decision import (
74
75
  aac_args, add_pad_fill_args, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
75
76
  description_block, _evidence_rank, fmt_chapter_time, propose_chapters,
77
+ frame_flow, label_shot_flow, MOTION_GRID, MOTION_SEARCH, MOTION_STATIC_PX, MOTION_PAN_SPREAD,
76
78
  filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOURSE_MARKERS, FILLER_MAX_WORD,
77
79
  FILLER_MIN_GAP, FILLER_PAD, normalise_filler_token,
78
80
  beat_grid, snap_points, BEAT_MIN_CONFIDENCE, BEAT_ONSET_K, BEAT_OCTAVE_MARGIN,
@@ -112,7 +114,7 @@ from _common.text import (
112
114
  SHAPING_SCRIPTS, text_width_em, _VS15, _VS16, WINDOWS_FONTS, _ZWJ
113
115
  )
114
116
 
115
- from _common import asr, color, decision, runner, text # noqa: F401,E402
117
+ from _common import asr, color, decision, drawtext, emoji, fonts, runner, text, wrap # noqa: F401,E402
116
118
 
117
119
  # `_common.emit` and `_common.probe` are the FUNCTIONS, as they have always been -- the
118
120
  # from-imports above rebound the package attribute the submodule import had set. The two modules
@@ -123,7 +125,7 @@ from _common import asr, color, decision, runner, text # noqa: F401,E402
123
125
  _emit_module = sys.modules["_common.emit"]
124
126
  _probe_module = sys.modules["_common.probe"]
125
127
 
126
- _MODULES = (runner, _emit_module, _probe_module, decision, color, text, asr)
128
+ _MODULES = (runner, _emit_module, _probe_module, decision, color, text, fonts, emoji, drawtext, wrap, asr)
127
129
 
128
130
 
129
131
  class _Facade(_types.ModuleType):
@@ -204,6 +206,7 @@ __all__ = [
204
206
  "place_output", "_plan_at_exit", "_plan_inputs", "_PLAN_STRIP", "PLAN_VERSION", "PREFERRED_FAMILIES",
205
207
  "print_json", "probe", "PROBE_TIMEOUT", "_progress_line", "read_text_or_die", "refuse_output_is_input",
206
208
  "_remember_output", "require_tool", "resolve_emoji_assets", "_result_v2", "rms_envelope", "run", "run_analysis",
209
+ "decode_gray_frames", "frame_flow", "label_shot_flow", "MOTION_GRID", "MOTION_SEARCH", "MOTION_STATIC_PX", "MOTION_PAN_SPREAD",
207
210
  "_run_captured", "run_keeping_subtitles", "run_tool", "_run_with_progress", "_SCRIPT_FONT_CACHE",
208
211
  "_script_font_entry", "script_font_for_text", "script_font_status", "_script_font_uncached", "_SCRIPT_RANGES",
209
212
  "SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote",
@@ -864,3 +864,89 @@ def filler_spans(words, wordlist, *, pad: float = FILLER_PAD, min_gap: float = F
864
864
  m["start"] = round(m["start"], 4)
865
865
  m["end"] = round(m["end"], 4)
866
866
  return merged
867
+
868
+
869
+ # --- 1.18.0: lightweight block-matching motion estimate (scenes.py --shots, cropdetect.py
870
+ # --motion-centre) ---------------------------------------------------------------------------
871
+
872
+ MOTION_GRID = 4 # NxN anchor blocks per frame
873
+ MOTION_SEARCH = 3 # +/- pixels searched per block, at the decoded (low) resolution
874
+ MOTION_STATIC_PX = 0.35 # average per-frame displacement below this, at decode resolution, is "static"
875
+ MOTION_PAN_SPREAD = 0.6 # block-to-block direction agreement above this (0..1) reads as a pan
876
+
877
+
878
+ def _block_match(prev: bytes, cur: bytes, w: int, h: int, cx: int, cy: int, half: int, search: int) -> "Tuple[float, float]":
879
+ """(dx, dy) that best aligns a `half*2` square centred at (cx, cy) in `prev` to `cur`,
880
+ searched over +/- `search` px by sum-of-absolute-differences. Coordinates and the returned
881
+ offset are in decoded-frame pixels (a handful of pixels a side at 1.18.0's sample size)."""
882
+ x0, y0 = max(half, min(w - half - 1, cx)), max(half, min(h - half - 1, cy))
883
+ ref = [prev[(y0 + dy) * w + (x0 + dx)] for dy in range(-half, half + 1) for dx in range(-half, half + 1)]
884
+
885
+ def sad_at(xx: int, yy: int) -> "Optional[int]":
886
+ if xx - half < 0 or xx + half >= w or yy - half < 0 or yy + half >= h:
887
+ return None
888
+ sad = 0
889
+ for dy in range(-half, half + 1):
890
+ row = (yy + dy) * w
891
+ for dx in range(-half, half + 1):
892
+ sad += abs(ref[(dy + half) * (2 * half + 1) + (dx + half)] - cur[row + xx + dx])
893
+ return sad
894
+
895
+ # Zero shift is the tie-break candidate, not the search order's first cell: on a textureless
896
+ # block (a flat colour, sky, an out-of-focus background) every offset scores the same SAD, and
897
+ # without an explicit tie towards "no motion" the scan used to report the search window's
898
+ # first corner as the measured displacement -- a still frame with nothing to match against
899
+ # read as steady motion in one direction, every time.
900
+ best_sad, best = sad_at(x0, y0), (0.0, 0.0)
901
+ if best_sad is None:
902
+ best_sad = float("inf")
903
+ for sy in range(-search, search + 1):
904
+ for sx in range(-search, search + 1):
905
+ if sx == 0 and sy == 0:
906
+ continue
907
+ sad = sad_at(x0 + sx, y0 + sy)
908
+ if sad is not None and sad < best_sad:
909
+ best_sad, best = sad, (float(sx), float(sy))
910
+ return best
911
+
912
+
913
+ def frame_flow(prev: bytes, cur: bytes, w: int, h: int, *, grid: int = MOTION_GRID,
914
+ search: int = MOTION_SEARCH) -> "Dict[str, Any]":
915
+ """One measurement between two consecutive decoded grayscale frames: the mean block
916
+ displacement vector, its magnitude, and how consistently the blocks agree on direction
917
+ (0 = every block moved a different way, 1 = every block agrees -- a pan or dolly moves the
918
+ whole frame one way, on-screen motion inside a mostly-static frame does not)."""
919
+ half = max(1, min(w, h) // (grid * 3))
920
+ vecs: "List[Tuple[float, float]]" = []
921
+ for gy in range(grid):
922
+ for gx in range(grid):
923
+ cx = int((gx + 0.5) * w / grid)
924
+ cy = int((gy + 0.5) * h / grid)
925
+ vecs.append(_block_match(prev, cur, w, h, cx, cy, half, search))
926
+ mdx = sum(v[0] for v in vecs) / len(vecs)
927
+ mdy = sum(v[1] for v in vecs) / len(vecs)
928
+ magnitude = math.hypot(mdx, mdy)
929
+ mean_len = sum(math.hypot(*v) for v in vecs) / len(vecs)
930
+ agreement = (magnitude / mean_len) if mean_len > 1e-6 else 1.0 # 1.0 = every block agrees
931
+ return {"dx": mdx, "dy": mdy, "magnitude": magnitude, "agreement": min(1.0, agreement)}
932
+
933
+
934
+ def label_shot_flow(flows: "Sequence[Dict[str, Any]]") -> "Dict[str, Any]":
935
+ """{label, flow_magnitude} for one shot from its per-frame-pair flow measurements.
936
+
937
+ static: mean displacement below MOTION_STATIC_PX. pan: above it, and blocks agree on
938
+ direction (a camera move shifts the whole frame). motion: above it, blocks disagree (motion
939
+ inside an otherwise still frame -- handheld jitter, or a subject moving across a static
940
+ background). This is a measured proxy, the same spirit as scenes.py --rank-by: it reports
941
+ what a coarse block match saw, not what is interesting about the shot."""
942
+ if not flows:
943
+ return {"label": "static", "flow_magnitude": 0.0}
944
+ magnitude = sum(f["magnitude"] for f in flows) / len(flows)
945
+ agreement = sum(f["agreement"] for f in flows) / len(flows)
946
+ if magnitude < MOTION_STATIC_PX:
947
+ label = "static"
948
+ elif agreement >= MOTION_PAN_SPREAD:
949
+ label = "pan"
950
+ else:
951
+ label = "motion"
952
+ return {"label": label, "flow_magnitude": round(magnitude, 3)}
@@ -0,0 +1,125 @@
1
+ """drawtext: the option builders (textfile= route, boxborderw form per ffmpeg version), the
2
+ font-name escape and the shaping-library probe that decides drawtext vs libass per script.
3
+ Split out of _common.text in the refactor after 1.17.3; every body is byte-identical.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ from typing import Dict, Optional
12
+ from _common.decision import escape_filter_path
13
+ from _common.runner import STATE, _DRAWTEXT_PENDING, _drawtext_tmpdir, ffmpeg_version
14
+
15
+
16
+ def drawtext_boxborderw(vertical: int, horizontal: int) -> str:
17
+ """drawtext's per-side `boxborderw=top|right|bottom|left` (and the two-value `v|h` form)
18
+ arrived in FFmpeg 6.1; 5.x and 6.0 reject the `|` with "Error setting option boxborderw"
19
+ (found by the FFmpeg 5.1.1 CI job, #146). Older builds get the larger single value."""
20
+ if ffmpeg_version() >= (6, 1):
21
+ return f"{vertical}|{horizontal}"
22
+ return str(max(vertical, horizontal))
23
+
24
+
25
+ # --------------------------------------------------------------------------- shaping (1.15)
26
+ # Scripts whose correct rendering needs harfbuzz-class reordering and re-clustering (Indic matras,
27
+ # Thai/Lao mark stacking). drawtext does NOT use harfbuzz even in an --enable-libharfbuzz build, so
28
+ # these come out wrong through drawtext on every build and must go through libass. Arabic and
29
+ # Hebrew are NOT here: drawtext's text_shaping uses fribidi, which does bidi and Arabic joining
30
+ # correctly -- they only join this set on a build compiled without fribidi.
31
+ SHAPING_SCRIPTS = frozenset({"hi", "bn", "ta", "te", "kn", "ml", "gu", "pa", "si", "th", "lo", "km", "my"})
32
+
33
+
34
+ BIDI_SCRIPTS = frozenset({"ar", "he"})
35
+
36
+
37
+ _SHAPING_BUILD_CACHE: "Dict[str, bool]" = {}
38
+
39
+
40
+ def drawtext_shaping() -> "Dict[str, bool]":
41
+ """Which shaping libraries THIS ffmpeg was built with, from -buildconf (falling back to the
42
+ `configuration:` line of -version). Cached per process."""
43
+ if _SHAPING_BUILD_CACHE:
44
+ return dict(_SHAPING_BUILD_CACHE)
45
+ text = ""
46
+ exe = shutil.which("ffmpeg")
47
+ if exe:
48
+ for flag in ("-buildconf", "-version"):
49
+ try:
50
+ proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE,
51
+ stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", timeout=10)
52
+ except (subprocess.TimeoutExpired, OSError):
53
+ break
54
+ if proc.returncode == 0 and proc.stdout.strip():
55
+ text = proc.stdout
56
+ break
57
+ _SHAPING_BUILD_CACHE.update({"fribidi": "--enable-libfribidi" in text,
58
+ "harfbuzz": "--enable-libharfbuzz" in text})
59
+ return dict(_SHAPING_BUILD_CACHE)
60
+
61
+
62
+ def needs_shaping(script: str) -> bool:
63
+ """Whether drawtext would render `script` wrongly on this build."""
64
+ if script in SHAPING_SCRIPTS:
65
+ return True
66
+ return script in BIDI_SCRIPTS and not drawtext_shaping()["fribidi"]
67
+
68
+
69
+ def escape_drawtext(text: str) -> str:
70
+ """Escape a FONT NAME for a single-quoted drawtext option value (`font='<this>'`).
71
+
72
+ Since 1.15 this is no longer the route for drawn TEXT -- use drawtext_text_opts(), which puts
73
+ the text in a file and keeps `\'` and `%` verbatim. It remains the escape for the font-name
74
+ fallback, where the value is a family name that never legitimately contains a quote or a
75
+ percent sign.
76
+
77
+ Every ffmpeg filter-graph special character (`\\ : % , [ ] ;`) needs a backslash escape
78
+ regardless of the surrounding quotes -- the graph parser still splits on an unescaped `,`/`;`
79
+ or ends an option list on an unescaped `:`/`[`/`]` even while "inside" a quoted value. The
80
+ quote character itself has no reliable backslash escape at all: `\\'` and the POSIX shell
81
+ close-insert-reopen trick both parse fine in a simple `-vf` chain but silently corrupt a
82
+ `-filter_complex` chain that uses explicit `[label]` pads (confirmed by rendering the result:
83
+ trailing option names leak into the picture as literal text). `%` has the same problem as far
84
+ as drawtext's own expansion scanner is concerned. Both are therefore dropped here rather than
85
+ escaped -- which is exactly why drawn text no longer comes through this function.
86
+ """
87
+ text = re.sub(r"[\x00-\x1f\x7f]", "", text)
88
+ return (
89
+ text.replace("'", "")
90
+ .replace("%", "")
91
+ .replace("\\", "\\\\")
92
+ .replace(":", "\\:")
93
+ .replace(",", "\\,")
94
+ .replace("[", "\\[")
95
+ .replace("]", "\\]")
96
+ .replace(";", "\\;")
97
+ )
98
+
99
+
100
+ def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
101
+ """`textfile=<path>:expansion=none` for drawtext -- the one route that is provably safe for
102
+ every character on every build shape this repo uses.
103
+
104
+ The filter-graph parser never sees the text at all: only the PATH is parsed, and
105
+ escape_filter_path() already handles that. `expansion=none` switches off drawtext's own
106
+ `%{...}` scanner, which is the reason `%` was unsafe (a bare `\%` logs "Stray %" on one build
107
+ and fails the whole filter chain on another). With the scanner off, `'`, `%`, `:`, `,`, `[`,
108
+ `]`, `;` and `\` all reach the picture verbatim -- 1.15 fixes `overlay.py --text "it's 100%
109
+ done"` losing both characters. Control characters are still stripped: a one-line burnt-in
110
+ label has no use for them.
111
+
112
+ The file is UTF-8, mode 0600, in a private per-run directory (see _drawtext_tmpdir) that is
113
+ removed when the process ends. It is *registered* here and written by run() only if the
114
+ command about to run actually names it, so --dry-run and the ASS route write nothing; a
115
+ printed plan therefore names a path that no longer exists once the run is over, which is the
116
+ same promise every other temp file in this skill makes.
117
+ """
118
+ cleaned = re.sub(r"[\x00-\x1f\x7f]", "", text or "")
119
+ import hashlib
120
+ name = "t_" + hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:16] + ".txt"
121
+ if tmpdir is None:
122
+ tmpdir = _drawtext_tmpdir(create=not STATE.dry_run)
123
+ path = os.path.join(tmpdir, name)
124
+ _DRAWTEXT_PENDING[path] = cleaned
125
+ return f"textfile={escape_filter_path(path)}:expansion=none"