ffmpeg-skill 3.2.1 → 3.2.2
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 +2 -2
- package/SKILL.md +1 -1
- package/docs/contract.md +3 -3
- package/package.json +1 -1
- package/references/scripts.md +18 -1
- package/scripts/_common/decision.py +134 -8
- package/scripts/_contract.py +6 -3
- package/scripts/cut.py +5 -2
- package/scripts/scenes.py +46 -11
package/README.md
CHANGED
|
@@ -292,7 +292,7 @@ WAV, FLAC, MP3, M4A/AAC, OGG and Opus go through `probe`, `cut`, `join`, `silenc
|
|
|
292
292
|
- **Typed dynamics.** `audio.py --compress --comp-threshold -20 --comp-ratio 4`, `--limit --limit-ceiling -1`, `--gate --gate-threshold -45`. Each flag is one documented option of FFmpeg's `acompressor`, `alimiter` or `agate`, range-checked before ffmpeg runs; no filter string is accepted from the caller.
|
|
293
293
|
- **Loudness.** `loudness.py talk.wav -I -16 --tp -1.5 -o talk.m4a` for podcast levels; `check.py talk.m4a --platform podcast` measures LUFS and true peak and reports the chapter markers and channel count.
|
|
294
294
|
|
|
295
|
-
Picture tools (`fit`, `caption`, `overlay`, `graphics`, `color`, `export`, `
|
|
295
|
+
Picture tools (`fit`, `caption`, `overlay`, `graphics`, `color`, `export`, `look`) refuse an audio file with "input has no video stream" instead of inventing a picture. `scenes.py` measures an audio file (`audio_only: true`, one scene): `--beats`, `--audio-peaks`, `--speech` and `--highlights` run on the sound, and `--sheet` / `--shots` are refused.
|
|
296
296
|
|
|
297
297
|
## Built for agents
|
|
298
298
|
|
|
@@ -390,7 +390,7 @@ The short list for humans. The agent-facing version, with the reasoning, is the
|
|
|
390
390
|
- **Cropping 16:9 to 9:16 discards 70 % of the width.** `fit.py --fit crop` centres by default; pass `--crop-x`/`--crop-y` toward the subject, or pad with `--fit pad --pad-fill blur`. Look at the contact sheet before deciding.
|
|
391
391
|
- **Phrase-aware caption breaking (1.16).** `caption.py`/`graphics.py --wrap phrase` (the default) never breaks inside a word or on the wrong side of a hyphen, never leaves a lone digit, kana or punctuation pair on a line, prefers Japanese sentence ends and particles over a mid-word break, and never ends a line on an article or preposition. All four are penalties over break positions that already fit, so no line is widened and the line count never changes; `--wrap measured` restores 1.15's width-only wrap. The text itself is never rewritten or shortened. Since 1.16.1 a Thai run and a katakana word are never broken inside (Thai writes no space inside a phrase and there is no dictionary: the break goes where you put a space or `|`), and a line wider than the safe width is reported as `overlong` with the fix named.
|
|
392
392
|
- **Caption size fitted to the cue (1.17, reachable from the templates since 1.17.1).** At a platform caption size a line holds about six em, so an ordinary sentence needs four lines and `--max-lines 2` used to cut it into consecutive cues — half the sentence arriving late. `caption.py --fit-size` (default `auto`) walks the size down until every cue fits, *before* laying the cues out, with a legibility floor of 4.5 % of the frame height (`--min-size`, default 13 ASS units). An explicit `--size` or a `brand.json` size is a statement about the look and is never overridden — which in 1.17.0 also silenced the fitter on every `render.py --template` run, since a template fills the size from the platform table; 1.17.1 marks that size as the default it is (`"fit_size": "on"` in the filled project), so the type shrinks and no cue is split. `--fit-size off` restores that earlier behaviour byte for byte, and the caption text is still never rewritten to make it fit.
|
|
393
|
-
- **Beat-synced cuts (1.17).** `scenes.py --beats` reports the measured grid — tempo, beat times, and a confidence built from how far the winning autocorrelation lag stands above the others and how many onsets land on it. `cut.py --snap beats` (and a `"snap"` block in a `render.py` project) moves in/out points to the nearest beat within `--snap-tolerance` — and only onto the grid points a measured onset actually marks, never onto the regular grid's continuation through a passage with no music in it. Below `--min-confidence` it **refuses**: a cut point may move to a measured beat and may not appear from one, so speech and ambience get an honest "no steady pulse here" instead of an invented grid.
|
|
393
|
+
- **Beat-synced cuts (1.17).** `scenes.py --beats` reports the measured grid — tempo, beat times, and a confidence built from how far the winning autocorrelation lag stands above the others and how many onsets land on it. `cut.py --snap beats` (and a `"snap"` block in a `render.py` project) moves in/out points to the nearest beat within `--snap-tolerance` — and only onto the grid points a measured onset actually marks, never onto the regular grid's continuation through a passage with no music in it. Below `--min-confidence` it **refuses**: a cut point may move to a measured beat and may not appear from one, so speech and ambience get an honest "no steady pulse here" instead of an invented grid. A music bed is measured as it is delivered, WAV/FLAC/MP3 with no picture; on a synthetic click track at 90 and 120 BPM every beat is within 1 ms (WAV/FLAC) or 9 ms (MP3) of its click, `tests/test_analysis.py` holds it to 20 ms.
|
|
394
394
|
- **Filler words (1.17).** `silence.py --filler --words transcript.json` removes "um" and "uh" through the same cut graph the silences use. Never without measured word timings — there is no heuristic that finds an "um" without them that would not also cut real speech — and `like`, `tipo` and `cioè` are deliberately not in the built-in lists, because a discourse marker is a content word. Built-in lists since 2.19: English, Japanese, Korean, Chinese, Spanish, German, French, Portuguese, Italian, Dutch, Polish, Russian and Turkish; any other language takes `--filler-words FILE`. Whisper stays optional: `--transcribe` with no engine installed refuses and names the five installs.
|
|
395
395
|
- **Throughput (1.17).** `batch.py --jobs N` runs several files at once, capped at `min(N, cpu_count, 8)` and sharing one `--timeout` budget rather than one per item; the per-item table keeps its order. `render.py --cache DIR` reuses stages whose inputs and arguments did not change, so swapping an export preset re-runs export only. The cache is opt-in with no default directory, and the ffmpeg, skill and contract versions are part of every key, so a cache is never reused across them.
|
|
396
396
|
- **Audiogram (1.16).** `waveform.py --image cover.png` (or `render.py --template audiogram`) puts the waveform over a still plate for an episode that has no picture, with `--platform` for the frame, `--title` and burnt-in captions. The image is a local file you give: nothing is fetched and no cover art is ever invented.
|
package/SKILL.md
CHANGED
|
@@ -102,7 +102,7 @@ Variants, caveats and extra flags per row: `references/requests.md`.
|
|
|
102
102
|
| "cut the ums and uhs" | `silence.py in.mp4 --filler --words words.json` |
|
|
103
103
|
| "just the real pauses, not inside a sentence" | `silence.py in.mp4 --speech-aware` |
|
|
104
104
|
| "a 60 s highlight from this hour" | `scenes.py long.mp4 --highlights 6 --target 60 --edl picks.txt` (then `cut.py --segments` with picks.txt's lines joined by commas) |
|
|
105
|
-
| "cut on the beat" | `cut.py in.mp4 --segments ... --snap beats --snap-source beats.json` (`scenes.py --beats
|
|
105
|
+
| "cut on the beat" | `cut.py in.mp4 --segments ... --snap beats --snap-source beats.json` (`scenes.py --beats`; a WAV/MP3 music bed too) |
|
|
106
106
|
| "speed up here, slow-mo there" | `speedramp.py in.mp4 --segment 0-3:1.0 --segment 3-4:0.25` |
|
|
107
107
|
| "freeze the last frame" | `freeze.py clip.mp4 --hold 2` |
|
|
108
108
|
| "smooth slow motion" | `fit.py in.mp4 --duration 2x --smooth interpolate` |
|
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 (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.2.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.2.2`) | 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`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.2.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.2.2", "execution_mode": "local", "kind": "execution",
|
|
153
153
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
154
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
155
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
|
@@ -191,7 +191,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
|
|
|
191
191
|
| `reencodes_video`, `reencodes_audio` | `"always"` / `"never"` / `"conditional"`, meaning *when that stream is present in the input* — not whether the tool touches the file at all. `"conditional"` tools (`cut`, `export`, `render`, `batch`, `verify`, `caption`, `color`, `join`) carry a `reencode_note` explaining what it depends on — for `caption`, `--mode burn` (default) always re-encodes both streams, `--mode mux` copies both untouched; for `color`, `--strip-dovi` and `--retag` are a stream copy of both (retag only re-encodes if the copy attempt fails or ffprobe still reads the old tags on the copy), while `--to-sdr` / `--lut` / `--correct` always re-encode both; for `join`, `--copy --transition none` copies both, every other join re-encodes both. Several visual tools (`fit`, `overlay`, `graphics`, `multicam`, `silence`) are `"always"` on audio too: this codebase never mixes `-c:v` re-encode with `-c:a copy` in one call, so a caller cannot assume the original audio codec survives just because only the picture changed |
|
|
192
192
|
| `audio_only` | accepts an audio-only input (WAV, MP3, M4A, FLAC, OGG, Opus) |
|
|
193
193
|
| `video_required` | refuses an input without a video stream ("input has no video stream") |
|
|
194
|
-
| | `render` has `audio_only: true` and `video_required: false` since 2.17.4: `--template podcast` and `--template audiogram` take an audio file. `join` has them since 0.9.1: audio-only inputs are joined as audio (no `look` needed then); mixing audio and video inputs is refused |
|
|
194
|
+
| | `scenes` has `audio_only: true` and `video_required: false` after 3.2.0: an audio-only file is one scene, measured by `--beats`, `--audio-peaks`, `--speech` and `--highlights` (result key `audio_only: true`); `--sheet` and `--shots` refuse it. `render` has `audio_only: true` and `video_required: false` since 2.17.4: `--template podcast` and `--template audiogram` take an audio file. `join` has them since 0.9.1: audio-only inputs are joined as audio (no `look` needed then); mixing audio and video inputs is refused |
|
|
195
195
|
| `deterministic_inputs`, `idempotency_hint` | see Repeatability |
|
|
196
196
|
| `mcp` | the MCP tool name and its positional arguments |
|
|
197
197
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.2",
|
|
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",
|
package/references/scripts.md
CHANGED
|
@@ -1072,7 +1072,7 @@ already has a picture.
|
|
|
1072
1072
|
### scenes.py — scene changes and highlight candidates
|
|
1073
1073
|
```
|
|
1074
1074
|
scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15]] [--edl picks.txt] [--sheet scenes.png] [--json]
|
|
1075
|
-
scenes.py INPUT --beats [--beat-step 0.01] [--beat-range 60-200] [--min-confidence 0.5] [--json]
|
|
1075
|
+
scenes.py INPUT --beats [--beat-step 0.01] [--beat-range 60-200] [--min-confidence 0.5] [--json] # INPUT may be audio-only
|
|
1076
1076
|
scenes.py INPUT [--shots] [--audio-peaks] [--speech] [--json]
|
|
1077
1077
|
```
|
|
1078
1078
|
Lists scenes with audio energy, the loudest moments, and (with
|
|
@@ -1106,6 +1106,23 @@ on the grid. With `--beats` the file is decoded once, at 22050 Hz, and the
|
|
|
1106
1106
|
`supported + unsupported == len(beats)` always, and `supported_beats` is that
|
|
1107
1107
|
supported subset — the list `cut.py --snap beats` moves onto.
|
|
1108
1108
|
|
|
1109
|
+
**Audio-only input and timing (after 3.2.0).** INPUT may be a WAV, FLAC, MP3 or any
|
|
1110
|
+
file with no video stream: no scene detection runs, the file is one scene from 0 to
|
|
1111
|
+
its duration, and the document carries `"audio_only": true`. `--beats`,
|
|
1112
|
+
`--audio-peaks`, `--speech` and `--highlights` measure the sound; `--sheet` and
|
|
1113
|
+
`--shots` need a picture and are refused (`kind: input`). Three things set how close
|
|
1114
|
+
the grid is to the music, each of which was off by tens of ms before:
|
|
1115
|
+
`beat_grid.step_s` is the envelope's real step (220 samples at 22050 Hz = 9.977 ms
|
|
1116
|
+
for `--beat-step 0.01`; timing it as 10 ms stretched every time by 0.23 %, 68 ms over
|
|
1117
|
+
30 s); the tempo and phase are refitted by weighted least squares to the strongest
|
|
1118
|
+
onset at each grid point, so a tempo between two envelope steps (90 BPM = 0.6667 s)
|
|
1119
|
+
is not rounded to one (0.67 s, 3.3 ms a beat); and each onset is placed on the
|
|
1120
|
+
decoded samples (`onset_refinement: "pcm-window-ratio"`) instead of at the start of
|
|
1121
|
+
its 10 ms block. A grid point within 10 ms before 0 s is the beat at `0.0`; one further before is not reported (it would make `0.0` a snap target the grid does not mark). Measured on
|
|
1122
|
+
synthetic click tracks at 90 and 120 BPM (first click at 0, 0.137 and 0.25 s): every
|
|
1123
|
+
beat within 1 ms on WAV/FLAC, 9 ms on MP3 (the encoder's pre-echo), 6 ms through AAC;
|
|
1124
|
+
the test holds every click to 20 ms.
|
|
1125
|
+
|
|
1109
1126
|
With `--beats` the file is decoded once, at 22050 Hz, and the 0.5 s scene
|
|
1110
1127
|
envelope is derived from that same pass rather than from a second 8 kHz decode.
|
|
1111
1128
|
One consequence worth knowing: `scenes.py X --json` and `scenes.py X --beats
|
|
@@ -1220,25 +1220,128 @@ def _grid_score(onset_times: "Sequence[float]", strength_at: "Dict[int, float]",
|
|
|
1220
1220
|
return total
|
|
1221
1221
|
|
|
1222
1222
|
|
|
1223
|
+
BEAT_ZERO_TOL_S = 0.01 # a fitted grid point this little before 0 s is the beat at 0.0
|
|
1224
|
+
BEAT_REFINE_TOL_NATS = 1.0 # onset refinement: the latest time within this of the best log ratio
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def refine_onset_times(onset_times: "Sequence[float]", step_s: float, pcm: "Sequence[float]",
|
|
1228
|
+
rate: int) -> "List[float]":
|
|
1229
|
+
"""Each envelope-step onset time placed at the sample where the sound starts. Pure.
|
|
1230
|
+
|
|
1231
|
+
An onset picked on the step_s envelope is only known to the step: it is the start of the block
|
|
1232
|
+
the level rose in, so a click is reported up to one step early (5 ms on average at 10 ms).
|
|
1233
|
+
Around each one, over [t - step, t + 2 step], every candidate time c is scored by
|
|
1234
|
+
log(energy of [c, c + step]) - log(energy of [c - step, c]) -- the rise from the step before c
|
|
1235
|
+
to the step after it, which is largest where the sound starts. Ahead of a hit out of digital
|
|
1236
|
+
silence that score is flat (the window before is silent wherever it ends), so the LATEST
|
|
1237
|
+
candidate within BEAT_REFINE_TOL_NATS of the best is taken: past the start, the window before
|
|
1238
|
+
fills with the hit's own attack and the score falls off at once. Candidates are 0.25 ms apart.
|
|
1239
|
+
A time the PCM does not cover is returned unchanged.
|
|
1240
|
+
"""
|
|
1241
|
+
rate = int(rate)
|
|
1242
|
+
w = max(1, int(round(step_s * rate)))
|
|
1243
|
+
hop = max(1, int(round(rate * 0.00025)))
|
|
1244
|
+
n = len(pcm)
|
|
1245
|
+
out: "List[float]" = []
|
|
1246
|
+
for t in onset_times:
|
|
1247
|
+
centre = int(round(t * rate))
|
|
1248
|
+
lo, hi = centre - w, centre + 2 * w
|
|
1249
|
+
if lo - w < 0 or hi + w > n:
|
|
1250
|
+
out.append(t)
|
|
1251
|
+
continue
|
|
1252
|
+
base = lo - w
|
|
1253
|
+
prefix = [0.0]
|
|
1254
|
+
acc = 0.0
|
|
1255
|
+
for x in pcm[base:hi + w + 1]:
|
|
1256
|
+
acc += x * x
|
|
1257
|
+
prefix.append(acc)
|
|
1258
|
+
scored = []
|
|
1259
|
+
for c in range(lo, hi + 1, hop):
|
|
1260
|
+
k = c - base
|
|
1261
|
+
before = (prefix[k] - prefix[k - w]) / w
|
|
1262
|
+
after = (prefix[k + w] - prefix[k]) / w
|
|
1263
|
+
scored.append((math.log(after + 1e-12) - math.log(before + 1e-12), c))
|
|
1264
|
+
best = max(sc for sc, _ in scored)
|
|
1265
|
+
c_best = max(c for sc, c in scored if sc >= best - BEAT_REFINE_TOL_NATS)
|
|
1266
|
+
out.append(c_best / rate)
|
|
1267
|
+
return out
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def _refine_grid(onset_times: "Sequence[float]", strengths: "Sequence[float]", interval: float,
|
|
1271
|
+
phase: float, lo_bpm: float, hi_bpm: float) -> "tuple":
|
|
1272
|
+
"""(interval, phase) refitted by weighted least squares to the onsets that land on the grid.
|
|
1273
|
+
|
|
1274
|
+
Each grid point k is paired with the STRONGEST onset within interval/BEAT_ALIGN_DIVISOR of it
|
|
1275
|
+
(one per point: the weak onsets noise and a hit's own decay leave around a beat must not pull
|
|
1276
|
+
it), and onset_time = phase + k * interval is solved for both unknowns with each pair weighted
|
|
1277
|
+
by its onset strength squared; the pairing is then redone against the refitted grid, until it
|
|
1278
|
+
stops moving (three passes at most). This only sharpens the grid the autocorrelation chose --
|
|
1279
|
+
the tempo cannot leave `bpm_range`, an octave cannot flip (a refit that moves the interval by
|
|
1280
|
+
more than interval/BEAT_ALIGN_DIVISOR is discarded), and fewer than four paired grid points
|
|
1281
|
+
keep the original: the fit needs evidence, and an onset-free stretch gives it none.
|
|
1282
|
+
Returns phase in [0, interval).
|
|
1283
|
+
"""
|
|
1284
|
+
iv, ph = float(interval), float(phase)
|
|
1285
|
+
for _ in range(3):
|
|
1286
|
+
tol = iv / BEAT_ALIGN_DIVISOR
|
|
1287
|
+
best: "Dict[int, tuple]" = {}
|
|
1288
|
+
for t, st in zip(onset_times, strengths):
|
|
1289
|
+
k = round((t - ph) / iv)
|
|
1290
|
+
if abs(t - (ph + k * iv)) <= tol and (k not in best or st > best[k][1]):
|
|
1291
|
+
best[k] = (t, st)
|
|
1292
|
+
pairs = [(k, t, max(st, 1e-9) ** 2) for k, (t, st) in best.items()]
|
|
1293
|
+
if len(pairs) < 4:
|
|
1294
|
+
break
|
|
1295
|
+
sw = sum(w for _, _, w in pairs)
|
|
1296
|
+
mk = sum(w * k for k, _, w in pairs) / sw
|
|
1297
|
+
mt = sum(w * t for _, t, w in pairs) / sw
|
|
1298
|
+
sxx = sum(w * (k - mk) ** 2 for k, _, w in pairs)
|
|
1299
|
+
if sxx <= 0:
|
|
1300
|
+
break
|
|
1301
|
+
new_iv = sum(w * (k - mk) * (t - mt) for k, t, w in pairs) / sxx
|
|
1302
|
+
if not (60.0 / hi_bpm <= new_iv <= 60.0 / lo_bpm) or abs(new_iv - interval) > interval / BEAT_ALIGN_DIVISOR:
|
|
1303
|
+
break
|
|
1304
|
+
new_ph = mt - new_iv * mk
|
|
1305
|
+
moved = abs(new_iv - iv) < 1e-9 and abs(new_ph - ph) < 1e-7
|
|
1306
|
+
iv, ph = new_iv, new_ph
|
|
1307
|
+
if moved:
|
|
1308
|
+
break
|
|
1309
|
+
return iv, ph % iv
|
|
1310
|
+
|
|
1311
|
+
|
|
1223
1312
|
def beat_grid(envelope: "Sequence[float]", step_s: float, *,
|
|
1224
1313
|
bpm_range: "Sequence[float]" = (60, 200),
|
|
1225
1314
|
min_confidence: float = BEAT_MIN_CONFIDENCE,
|
|
1226
|
-
duration: "Optional[float]" = None
|
|
1315
|
+
duration: "Optional[float]" = None,
|
|
1316
|
+
pcm: "Optional[Sequence[float]]" = None,
|
|
1317
|
+
pcm_rate: "Optional[int]" = None) -> "Dict[str, Any]":
|
|
1227
1318
|
"""A beat grid from an RMS envelope. Pure: numbers in, a dict out -- no ffmpeg, no I/O.
|
|
1228
1319
|
|
|
1229
1320
|
Returns {"beats": [t, ...], "tempo_bpm": float|None, "interval": float|None,
|
|
1230
1321
|
"confidence": 0..1, "onsets": [t, ...], "phase": float,
|
|
1231
1322
|
"supported": int, "unsupported": int, "usable": bool,
|
|
1232
|
-
"method": "rms-flux-autocorrelation", "step_s": step_s, "range_bpm": [lo, hi]
|
|
1323
|
+
"method": "rms-flux-autocorrelation", "step_s": step_s, "range_bpm": [lo, hi],
|
|
1324
|
+
"onset_refinement": "pcm-window-ratio" | None}
|
|
1325
|
+
|
|
1326
|
+
`step_s` must be the envelope's real step (block samples / rate), not the one asked for:
|
|
1327
|
+
every time below is index * step_s. `pcm` and `pcm_rate`, when given, are the samples the
|
|
1328
|
+
envelope was built from; each onset is then placed on them (refine_onset_times) instead of at
|
|
1329
|
+
the start of its envelope block.
|
|
1233
1330
|
|
|
1234
1331
|
The method, in full, so a report can quote it:
|
|
1235
1332
|
1. onset strength = half-wave-rectified first difference of log(env + 1e-9);
|
|
1236
|
-
2. onsets = local maxima above median + 1.5 * MAD over a +/-1 s window, 60 ms refractory
|
|
1333
|
+
2. onsets = local maxima above median + 1.5 * MAD over a +/-1 s window, 60 ms refractory,
|
|
1334
|
+
each placed to 0.25 ms on `pcm` when it is given;
|
|
1237
1335
|
3. tempo = the best autocorrelation lag of the onset signal inside `bpm_range`, with its
|
|
1238
1336
|
half and double checked and the one whose onsets align better preferred (octave
|
|
1239
1337
|
disambiguation: 60, 120 and 240 BPM all autocorrelate on a 120 BPM track);
|
|
1240
1338
|
4. phase = the offset in [0, interval) at which the grid catches the most onset strength;
|
|
1241
|
-
|
|
1339
|
+
then interval and phase are refitted by least squares to the onsets within interval/8 of
|
|
1340
|
+
the grid (_refine_grid), so the tempo is not rounded to a whole number of envelope steps
|
|
1341
|
+
(until 3.2.0, at 10 ms, 90 BPM was measured as 0.67 s and the grid drifted 40-90 ms off the
|
|
1342
|
+
music over 30 s);
|
|
1343
|
+
5. beats = phase + n * interval across the duration (a point within BEAT_ZERO_TOL_S, 10 ms,
|
|
1344
|
+
before 0 s is reported at 0.0; one further before is not reported). A grid must be regular, so a grid
|
|
1242
1345
|
point with no measured onset within interval/4 is still reported -- and counted in
|
|
1243
1346
|
`unsupported`, so a caller can see how much of the grid the audio actually supports.
|
|
1244
1347
|
`supported_beats` is the subset that a measured onset does support: it is what a tool
|
|
@@ -1262,6 +1365,7 @@ def beat_grid(envelope: "Sequence[float]", step_s: float, *,
|
|
|
1262
1365
|
"confidence": 0.0, "onsets": [],
|
|
1263
1366
|
"phase": 0.0, "supported": 0, "unsupported": 0, "usable": False,
|
|
1264
1367
|
"method": "rms-flux-autocorrelation", "step_s": step_s, "range_bpm": [lo_bpm, hi_bpm],
|
|
1368
|
+
"onset_refinement": None,
|
|
1265
1369
|
}
|
|
1266
1370
|
if len(env) < 4 or step_s <= 0:
|
|
1267
1371
|
return out
|
|
@@ -1272,6 +1376,10 @@ def beat_grid(envelope: "Sequence[float]", step_s: float, *,
|
|
|
1272
1376
|
return out
|
|
1273
1377
|
idx = _pick_onsets(strength, step_s)
|
|
1274
1378
|
onset_times = [i * step_s for i in idx]
|
|
1379
|
+
if pcm and pcm_rate:
|
|
1380
|
+
# the PCM the envelope came from places each onset to the sample (refine_onset_times)
|
|
1381
|
+
onset_times = refine_onset_times(onset_times, step_s, pcm, int(pcm_rate))
|
|
1382
|
+
out["onset_refinement"] = "pcm-window-ratio"
|
|
1275
1383
|
out["onsets"] = [round(t, 4) for t in onset_times]
|
|
1276
1384
|
if len(idx) < 2:
|
|
1277
1385
|
return out
|
|
@@ -1312,11 +1420,29 @@ def beat_grid(envelope: "Sequence[float]", step_s: float, *,
|
|
|
1312
1420
|
if alt_score > base_score * BEAT_OCTAVE_MARGIN:
|
|
1313
1421
|
interval, phase, base_score = alt, alt_phase, alt_score
|
|
1314
1422
|
|
|
1423
|
+
# The autocorrelation lag is a whole number of envelope steps, so the interval it gives is the
|
|
1424
|
+
# true one rounded to step_s: 90 BPM (0.6667 s) comes out as 0.67 s at 10 ms, 3.3 ms per beat
|
|
1425
|
+
# that a phase+n*interval grid accumulates to tens of ms within a bar or two of either end
|
|
1426
|
+
# (40-90 ms over 30 s). Refit interval and phase to the onsets themselves (_refine_grid).
|
|
1427
|
+
interval, phase = _refine_grid(onset_times, [strength[j] for j in idx], interval, phase,
|
|
1428
|
+
lo_bpm, hi_bpm)
|
|
1429
|
+
|
|
1315
1430
|
beats = []
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1431
|
+
# The first grid point at or after 0, where "at" allows BEAT_ZERO_TOL_S (one 10 ms envelope
|
|
1432
|
+
# step, the accuracy the grid had before the refit): a track that starts on the beat has no
|
|
1433
|
+
# measurable onset at 0 (there is no step before it), so its phase is fitted from the later
|
|
1434
|
+
# beats and lands a few ms either side of 0 -- that is the beat at 0.0, not a dropped one. A
|
|
1435
|
+
# point further before 0 is not moved there: 0.0 would then be a time the grid does not mark
|
|
1436
|
+
# (a 120 BPM grid with phase 0.46 has its point at -0.04 s), and an onset at 0.08 s would make
|
|
1437
|
+
# it a "supported" snap target. Indexing by n instead of adding the interval up keeps the
|
|
1438
|
+
# float error of a long file out of the last beats.
|
|
1439
|
+
n = -1 if phase - interval >= -BEAT_ZERO_TOL_S else 0
|
|
1440
|
+
while True:
|
|
1441
|
+
t = phase + n * interval
|
|
1442
|
+
if t > total_s + 1e-9:
|
|
1443
|
+
break
|
|
1444
|
+
beats.append(round(max(0.0, t), 4))
|
|
1445
|
+
n += 1
|
|
1320
1446
|
support_tol = interval / BEAT_SUPPORT_DIVISOR
|
|
1321
1447
|
supported_beats = [b for b in beats
|
|
1322
1448
|
if any(abs(b - o) <= support_tol for o in onset_times)]
|
package/scripts/_contract.py
CHANGED
|
@@ -208,10 +208,10 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
208
208
|
{"capability": "filter:blackdetect", "when": "--content (black row)"}, {"capability": "filter:freezedetect", "when": "--content (frozen row)"},
|
|
209
209
|
{"capability": "filter:silencedetect", "when": "--content (silence row)"}],
|
|
210
210
|
video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
|
|
211
|
-
"scenes": dict(role="analysis", inputs=["video asset"], outputs=["scene / audio-peak / highlight JSON on stdout", "EDL text (--edl)", "per-scene contact sheet PNG (--sheet)"],
|
|
211
|
+
"scenes": dict(role="analysis", inputs=["video asset", "audio asset (after 3.2.0: one scene; --beats, --audio-peaks, --speech, --highlights; --sheet and --shots need a picture)"], outputs=["scene / audio-peak / highlight JSON on stdout", "EDL text (--edl)", "per-scene contact sheet PNG (--sheet)"],
|
|
212
212
|
required=FF + ["filter:scdet"], optional=[{"capability": "filter:drawtext", "when": "--sheet"}, {"capability": "filter:tile", "when": "--sheet"},
|
|
213
213
|
{"capability": "ffmpeg", "when": "--beats (one audio decode for the onset pass)"}],
|
|
214
|
-
video_required=
|
|
214
|
+
video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
|
|
215
215
|
"look": dict(role="verification", inputs=["video artifact"], outputs=["PNG contact sheet / frames / side-by-side"],
|
|
216
216
|
required=FF + ["filter:tile"], optional=[{"capability": "filter:drawtext", "when": "timecode stamps (default; without drawtext the sheet is drawn without them, fallback look_no_drawtext)"},
|
|
217
217
|
{"capability": "filter:drawbox", "when": "--safe NAME (the platform's occluded zones)"}, {"capability": "filter:zscale", "when": "HDR source"}, {"capability": "filter:tonemap", "when": "HDR source"}],
|
|
@@ -604,7 +604,10 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
604
604
|
extra = {"file": {"type": "string"}, "duration": {"type": "number"}, "scene_count": {"type": "integer"}, "scenes": {"type": "array"}, "audio_peaks": {"type": "array"},
|
|
605
605
|
"shots": {"type": "array", "description": "--shots (1.18): [{start, end, label, flow_magnitude}]"},
|
|
606
606
|
"audio_peaks_db": {"type": "array", "description": "--audio-peaks (1.18): [{time, level}] measured dBFS, distinct from audio_peaks above"},
|
|
607
|
-
"speech": {"type": "array", "description": "--speech (1.18): [{time, speech_music_ratio}]"}
|
|
607
|
+
"speech": {"type": "array", "description": "--speech (1.18): [{time, speech_music_ratio}]"},
|
|
608
|
+
"audio_only": {"type": "boolean", "description": "After 3.2.0: present (true) when the input has no video stream: no scene detection ran and the file is one scene, 0 to its duration"},
|
|
609
|
+
"beats": {"type": "array", "description": "--beats (1.17): the regular beat grid, seconds"},
|
|
610
|
+
"beat_grid": {"type": "object", "description": "--beats (1.17): {supported_beats, tempo_bpm, interval, confidence, phase, onsets, supported, unsupported, method, step_s (the envelope's real step: 220/22050 s for --beat-step 0.01, after 3.2.0), range_bpm, usable, onset_refinement (after 3.2.0: pcm-window-ratio when each onset was placed on the decoded samples, else null)}"}}
|
|
608
611
|
elif name == "silence":
|
|
609
612
|
extra = {"silences": {"type": "array"}, "keep": {"type": "array"}, "input_duration": {"type": "number"}, "kept_duration": {"type": "number"}, "removed_seconds": {"type": "number"},
|
|
610
613
|
"removed_seconds_total": {"type": "number", "description": "--filler: seconds removed in all, silence plus filler words (removed_seconds stays the silence alone)"},
|
package/scripts/cut.py
CHANGED
|
@@ -1204,8 +1204,11 @@ def _grid_from_source(path: str, min_confidence: float) -> "dict":
|
|
|
1204
1204
|
die(f"--snap-source {path}: this JSON has no beat_grid -- produce one with "
|
|
1205
1205
|
"`scenes.py MUSIC --beats --json`", kind="input")
|
|
1206
1206
|
samples = decode_pcm_mono(path, BEAT_RATE, check=False)
|
|
1207
|
-
|
|
1208
|
-
|
|
1207
|
+
block = max(1, int(round(BEAT_RATE * 0.01)))
|
|
1208
|
+
env = rms_envelope(samples, block)
|
|
1209
|
+
# the envelope's real step is block / rate (220 / 22050 = 9.977 ms), not the 10 ms asked for:
|
|
1210
|
+
# timing the onsets at 10 ms stretched the grid 0.23 % (68 ms over 30 s) -- see scenes.py
|
|
1211
|
+
return beat_grid(env, block / BEAT_RATE, min_confidence=min_confidence)
|
|
1209
1212
|
|
|
1210
1213
|
|
|
1211
1214
|
def snap_segments(args, segments, meta, total):
|
package/scripts/scenes.py
CHANGED
|
@@ -31,6 +31,7 @@ from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
|
31
31
|
from _common.emit import note, record_fallback # noqa: E402
|
|
32
32
|
from _common.color import bt709_label_filter # noqa: E402
|
|
33
33
|
from _common.probe import square_pixels # noqa: E402
|
|
34
|
+
from _common import dry_run_input_pending # noqa: E402
|
|
34
35
|
|
|
35
36
|
SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
|
|
36
37
|
SHOT_W, SHOT_H = 48, 27 # decode size for --shots (16:9-ish; enough blocks, still a few KB/shot)
|
|
@@ -148,8 +149,25 @@ def main() -> int:
|
|
|
148
149
|
guard_sidecar(args.edl, args.input, siblings=(args.sheet,))
|
|
149
150
|
|
|
150
151
|
meta = probe(args.input)
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
# Under --dry-run in a render.py/batch.py plan the input may be an earlier stage's output that
|
|
153
|
+
# was never written: every measurement is skipped (diagnostics.skipped measurement_input_pending)
|
|
154
|
+
# and the document keeps its shape with nothing measured, as loudness.py/cut.py do -- not an
|
|
155
|
+
# ffmpeg failure that stops the plan from being written.
|
|
156
|
+
pending = dry_run_input_pending(args.input)
|
|
157
|
+
# An audio-only file (WAV/FLAC/MP3 ...) is measured too (after 3.2.0): the beat grid, the loudness
|
|
158
|
+
# peaks and the speech ratio are measurements of the sound, and a music bed is usually
|
|
159
|
+
# delivered without a picture. It has no picture to cut, so it is one scene from 0 to its
|
|
160
|
+
# duration -- the same answer a video with no cut in it gets -- and the result says
|
|
161
|
+
# audio_only: true. What needs a picture (--sheet, --shots) is refused, not skipped.
|
|
162
|
+
audio_only = not meta.get("video")
|
|
163
|
+
if audio_only:
|
|
164
|
+
if not meta.get("audio"):
|
|
165
|
+
die("input has no video or audio stream")
|
|
166
|
+
picture_flags = [f for f, on in (("--sheet", args.sheet), ("--shots", args.shots)) if on]
|
|
167
|
+
if picture_flags:
|
|
168
|
+
die(f"{' and '.join(picture_flags)} need{'s' if len(picture_flags) == 1 else ''} a video stream; "
|
|
169
|
+
"this file is audio-only (input has no video stream). --beats, --audio-peaks, --speech "
|
|
170
|
+
"and --highlights measure the sound alone.", kind="input")
|
|
153
171
|
# Only parsed when it is going to be used: --beat-range is a --beats flag, and a run that
|
|
154
172
|
# never asked for beats should not be able to die on one.
|
|
155
173
|
beat_range = parse_beat_range(args.beat_range) if args.beats else (60.0, 200.0)
|
|
@@ -159,18 +177,22 @@ def main() -> int:
|
|
|
159
177
|
if args.beat_step <= 0:
|
|
160
178
|
die("--beat-step must be greater than 0", kind="input")
|
|
161
179
|
dur = meta.get("duration") or 0.0
|
|
162
|
-
cuts = detect_scenes(args.input, args.threshold, args.min_scene, dur, args.ratio)
|
|
180
|
+
cuts = [0.0] if (audio_only or pending) else detect_scenes(args.input, args.threshold, args.min_scene, dur, args.ratio)
|
|
181
|
+
has_audio = bool(meta.get("audio")) and not pending
|
|
163
182
|
bounds = cuts + [dur]
|
|
164
183
|
step_s = 0.5
|
|
165
184
|
# With --beats the file is decoded once, at the finer rate, and both envelopes come from that
|
|
166
185
|
# one pass: the 0.5 s scene blocks are an exact multiple of the 10 ms onset blocks.
|
|
167
186
|
beat_rate = 22050
|
|
168
187
|
need_fine = args.beats or args.audio_peaks or args.speech
|
|
169
|
-
|
|
188
|
+
# --beats decodes with check=True: a decode that fails is an ffmpeg failure, not an empty
|
|
189
|
+
# envelope reported as "no steady pulse in this audio" (the scene scoring alone tolerates it
|
|
190
|
+
# and runs on the picture)
|
|
191
|
+
fine_samples = decode_pcm_mono(args.input, beat_rate, check=bool(args.beats)) if (need_fine and has_audio) else None
|
|
170
192
|
if fine_samples is not None:
|
|
171
193
|
env = audio_envelope(args.input, step_s, rate=beat_rate, samples=fine_samples)
|
|
172
194
|
else:
|
|
173
|
-
env = audio_envelope(args.input, step_s) if
|
|
195
|
+
env = audio_envelope(args.input, step_s) if has_audio else []
|
|
174
196
|
|
|
175
197
|
scenes = []
|
|
176
198
|
for i in range(len(bounds) - 1):
|
|
@@ -192,6 +214,8 @@ def main() -> int:
|
|
|
192
214
|
peaks.sort(key=lambda p: p["time"])
|
|
193
215
|
|
|
194
216
|
result: Dict = {"file": args.input, "duration": round(dur, 3), "scene_count": len(scenes), "scenes": scenes, "audio_peaks": peaks}
|
|
217
|
+
if audio_only:
|
|
218
|
+
result["audio_only"] = True
|
|
195
219
|
info(f"{len(scenes)} scenes, {len(peaks)} audio peaks over {dur:.1f}s")
|
|
196
220
|
|
|
197
221
|
if args.shots:
|
|
@@ -203,7 +227,7 @@ def main() -> int:
|
|
|
203
227
|
info(f"--shots: {len(shots)} shots (" + ", ".join(f"{v} {k}" for k, v in sorted(counts.items())) + ")")
|
|
204
228
|
|
|
205
229
|
if args.audio_peaks:
|
|
206
|
-
if not
|
|
230
|
+
if not has_audio:
|
|
207
231
|
result["audio_peaks_db"] = []
|
|
208
232
|
info("--audio-peaks: no audio stream, nothing to measure")
|
|
209
233
|
else:
|
|
@@ -212,7 +236,7 @@ def main() -> int:
|
|
|
212
236
|
info(f"--audio-peaks: {len(result['audio_peaks_db'])} peaks")
|
|
213
237
|
|
|
214
238
|
if args.speech:
|
|
215
|
-
if not
|
|
239
|
+
if not has_audio:
|
|
216
240
|
result["speech"] = []
|
|
217
241
|
info("--speech: no audio stream, nothing to measure")
|
|
218
242
|
else:
|
|
@@ -224,9 +248,15 @@ def main() -> int:
|
|
|
224
248
|
# A beat grid is a measurement of the music's periodicity, not a statement about where a
|
|
225
249
|
# cut belongs. scenes.py reports what it measured, including a low confidence: reporting a
|
|
226
250
|
# weak measurement is honest, and only a tool that CHANGES a file refuses to act on one.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
251
|
+
# The envelope's real step is a whole number of samples: 220 / 22050 = 9.977 ms for the
|
|
252
|
+
# default 10 ms. Timing the onsets as if it were 10 ms stretched every measured time by
|
|
253
|
+
# 0.23 % -- 68 ms by the end of a 30 s track, the "40-60 ms off" a click track showed --
|
|
254
|
+
# so the grid is timed (and step_s reported) at the step the envelope actually has.
|
|
255
|
+
beat_block = max(1, round(beat_rate * args.beat_step))
|
|
256
|
+
fine = rms_envelope(fine_samples or [], beat_block)
|
|
257
|
+
grid = beat_grid(fine, beat_block / beat_rate, bpm_range=beat_range,
|
|
258
|
+
min_confidence=args.min_confidence, duration=dur,
|
|
259
|
+
pcm=fine_samples, pcm_rate=beat_rate)
|
|
230
260
|
result["beats"] = grid["beats"]
|
|
231
261
|
result["beat_grid"] = {
|
|
232
262
|
# The regular grid AND the subset a measured onset supports. A tool that MOVES
|
|
@@ -238,8 +268,13 @@ def main() -> int:
|
|
|
238
268
|
"onsets": len(grid["onsets"]), "supported": grid["supported"],
|
|
239
269
|
"unsupported": grid["unsupported"], "method": grid["method"],
|
|
240
270
|
"step_s": grid["step_s"], "range_bpm": grid["range_bpm"], "usable": grid["usable"],
|
|
271
|
+
# after 3.2.0, additive: how each onset was timed below the envelope step
|
|
272
|
+
# ("pcm-window-ratio": placed on the decoded samples; null: at the step)
|
|
273
|
+
"onset_refinement": grid["onset_refinement"],
|
|
241
274
|
}
|
|
242
|
-
if
|
|
275
|
+
if pending:
|
|
276
|
+
info("--beats: [dry-run] the input does not exist yet, nothing measured")
|
|
277
|
+
elif grid["tempo_bpm"] is None:
|
|
243
278
|
info(f"--beats: no steady pulse in this audio (confidence {grid['confidence']:.2f}) -- "
|
|
244
279
|
"speech, ambience or rubato has no tempo to measure")
|
|
245
280
|
else:
|