ffmpeg-skill 3.2.0 → 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 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`, `scenes`, `look`) refuse an audio file with "input has no video stream" instead of inventing a picture.
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.0`) | any release |
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.0", "execution_mode": "local", "kind": "execution",
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.0",
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",
@@ -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
@@ -1851,6 +1868,11 @@ audio.py INPUT [--voice [light|medium|strong] | --denoise [--denoise-strength 25
1851
1868
  [--effects FILE [--effects-volume -14]] [--stereo-widen 0..1]
1852
1869
  [--fade-in S] [--fade-out S] [--stereo | --mono | --downmix] [--replace FILE] [-o OUT]
1853
1870
  ```
1871
+ `--denoise` is `afftdn=nf=-N:tn=1`, where N is `--denoise-strength`, the noise
1872
+ floor in dB below full scale (20..60, default 25). It is the starting floor that
1873
+ `tn=1` then tracks, not an amount removed; ffmpeg's `nf` accepts -80..-20 only, so
1874
+ values under 20 are refused before ffmpeg runs.
1875
+
1854
1876
  `--voice` takes a strength; a bare `--voice` is `medium`, the chain it has always
1855
1877
  produced. The exact filter chains:
1856
1878
 
@@ -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) -> "Dict[str, Any]":
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
- 5. beats = phase + n * interval across the duration. A grid must be regular, so a grid
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
- t = phase
1317
- while t <= total_s + 1e-9:
1318
- beats.append(round(t, 4))
1319
- t += interval
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)]
@@ -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=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
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/audio.py CHANGED
@@ -161,7 +161,7 @@ def main() -> int:
161
161
  ap.add_argument("-o", "--output", help="output file (default: <name>_audio.<ext>)")
162
162
  clean = ap.add_argument_group("clean-up")
163
163
  clean.add_argument("--denoise", action="store_true", help="FFT noise reduction (afftdn, adaptive)")
164
- clean.add_argument("--denoise-strength", type=float, default=25.0, help="noise floor in dB to remove, 10..60 (default 25)")
164
+ clean.add_argument("--denoise-strength", type=float, default=25.0, help="afftdn noise floor in dB below full scale, 20..60 (default 25; ffmpeg accepts nf -80..-20, and tn=1 then tracks the floor from this starting point)")
165
165
  clean.add_argument("--voice", nargs="?", const="medium", choices=["light", "medium", "strong"], default=None,
166
166
  help="speech preset (default medium when the flag is given bare): light = highpass 80 Hz + gentle compression; "
167
167
  "medium = highpass, de-esser, denoise, gentle compression; strong = medium plus a harder de-esser, a second "
@@ -305,8 +305,9 @@ def main() -> int:
305
305
  if args.voice:
306
306
  fx.append(VOICE_LEVELS[args.voice])
307
307
  elif args.denoise:
308
- if not 10 <= args.denoise_strength <= 60:
309
- die(f"--denoise-strength must be 10..60 (dB of noise floor to remove), got {args.denoise_strength:g}")
308
+ # afftdn's nf accepts -80..-20 only; 10..19.99 used to pass here and then fail inside ffmpeg.
309
+ if not 20 <= args.denoise_strength <= 60:
310
+ die(f"--denoise-strength must be 20..60 (afftdn noise floor in dB below full scale), got {args.denoise_strength:g}")
310
311
  fx.append(f"afftdn=nf=-{args.denoise_strength:g}:tn=1")
311
312
  if args.gain:
312
313
  fx.append(f"volume={args.gain:g}dB")
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
- env = rms_envelope(samples, max(1, int(round(BEAT_RATE * 0.01))))
1208
- return beat_grid(env, 0.01, min_confidence=min_confidence)
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
- if not meta.get("video"):
152
- die("input has no video stream")
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
- fine_samples = decode_pcm_mono(args.input, beat_rate, check=False) if (need_fine and meta.get("audio")) else None
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 meta.get("audio") else []
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 meta.get("audio"):
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 meta.get("audio"):
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
- fine = rms_envelope(fine_samples or [], max(1, int(round(beat_rate * args.beat_step))))
228
- grid = beat_grid(fine, args.beat_step, bpm_range=beat_range,
229
- min_confidence=args.min_confidence, duration=dur)
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 grid["tempo_bpm"] is None:
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: