ffmpeg-skill 3.8.12 → 3.8.13

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
@@ -514,7 +514,7 @@ type on every OS.
514
514
  | Result | Measurement |
515
515
  |---|---|
516
516
  | **92 / 92** | verification steps on a 10-file real-device corpus (GoPro, DJI, iPhone incl. Dolby Vision, Android screen recordings, HDR10, 24p, Tears of Steel), 0.8.0, local ffmpeg 6.1 |
517
- | **40 / 40 within 10 ms** | `sync.py` offset detection, ±30 s offsets with gain, noise and EQ changes on real dialogue and music, 120 s windows (max error 1.1 ms); 60 s stress windows 95 % within 10 ms, 4 of 5 misses flagged by confidence |
517
+ | **40 / 40 within 10 ms** | `sync.py` offset detection, ±30 s offsets with gain, noise and EQ changes on real dialogue and music, 120 s windows (max error 1.1 ms); 60 s stress windows 95 % within 10 ms, 4 of 5 misses flagged by confidence (the confidence of 3.8.8 and earlier; not re-run since its runner-up changed) |
518
518
  | **0 missed gaps** | `silence.py`, 20 cases with known gaps, ≤ 1 ms leftover silence |
519
519
  | **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
520
520
  | **exact to the sample** | `cut.py --accurate` on WAV, FLAC (44.1 kHz) and AAC → WAV; WAV stream copy within 2 ms; AAC output +21 ms of encoder priming, reported as `codec_frame` (0.9.1) |
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.8.12`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.8.13`) | 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.8.12", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.13", "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"},
@@ -689,7 +689,8 @@ says "output audio is silent" instead of recommending `loudness.py`.
689
689
  Per-tool keys added in 1.13: `audio` (`audio.py`) reports the mix it built — the
690
690
  `--voice` level, `stereo_widen`, whether an `--effects` bed was mixed, and with
691
691
  `--music` the `music_volume` plus a `duck` object naming the threshold (dB and
692
- linear), ratio, attack and release actually used, or `null` when `--duck` was not
692
+ linear), ratio (the sidechain gate's 9000 since A134), attack, release and `amount_db`
693
+ (the depth in dB the bed is lowered under speech) actually used, or `null` when `--duck` was not
693
694
  given. Since #486 `audio.py` also reports `timing`, `{tempo, atempo, length_samples, sample_rate}`:
694
695
  the `--tempo` factor and the atempo stages (each 0.5..2.0) it was built from, `--length-samples`,
695
696
  and the rate that count is in (`null` before a pending input is measured); with
@@ -814,7 +815,7 @@ pointer to cuts that may sit in a breath):
814
815
 
815
816
  | key | tool | what it holds |
816
817
  |---|---|---|
817
- | `timemap` | `silence.py --timemap FILE` | the path the time map was written to (`.json`: `{kept: [{source_start, source_end, output_start, output_end}], removed: [{source_start, source_end, output_time}]}`; `.csv`: `type,source_start,source_end,output_start,output_end` rows, a removed row's output columns both its cut point). Output times are computed from the millisecond-rounded cut bounds, not measured on the written file: the render keeps whole video frames and audio blocks, so each kept range can differ by up to one frame (about 20-40 ms) and the difference can add up over many cuts. Nothing is written under `--dry-run` |
818
+ | `timemap` | `silence.py --timemap FILE` | the path the time map was written to (`.json`: `{kept: [{source_start, source_end, output_start, output_end}], removed: [{source_start, source_end, output_time}]}`; `.csv`: `type,source_start,source_end,output_start,output_end` rows, a removed row's output columns both its cut point). Output times are the sums of the ranges the render cuts on (with a picture each bound moved to its nearest frame time, never before 0; with sound alone the range as asked), unrounded until they are written to the millisecond, so no error adds up over many cuts. They are computed, not measured on the written file. Nothing is written under `--dry-run` |
818
819
  | `cut_points` | `silence.py` (every run that cuts) | per cut, `{source_time, output_time, edge, rms_dbfs}`: the RMS level 50 ms either side of the cut on the source, the loudest channel (`null` for digital silence), `edge` `out` where the source is left and `in` where it is picked up; `filler: true` on an edge that sits on a removed filler word. The whole key is `null` when the source could not be decoded for the measurement (`cut_levels_unmeasured` warning): unmeasured, not silent |
819
820
  | `cut_warnings` | `silence.py` | the `cut_points` (not filler cuts) whose level is within `silence.CUT_SOUND_MARGIN_DB` (10 dB) of `--threshold`: a breath or a word's weak tail may be cut there. A `cut_near_sound` warning names them. `verified` is unchanged: this is where to listen, not a measured failure |
820
821
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.8.12",
3
+ "version": "3.8.13",
4
4
  "mcpName": "io.github.kajisho5/ffmpeg-skill",
5
5
  "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.",
6
6
  "keywords": [
@@ -147,7 +147,7 @@ by default since 3.0.0 (`--keep-hevc`; `--no-keep-hevc` uses x264 as 2.x did);
147
147
  `--codec` overrides all of them). `--accurate` always re-encodes; `--tolerance -1` never does for a keyframe snap
148
148
  (a `--segments` join of open-GOP video, one whose later B-frame `.mp4`/`.mov` segment starts
149
149
  between keyframes, or one that fails its check, still re-cuts).
150
- Multiple segments are concatenated in the order given. `--remove A-B,...` (2.24) is the inverse: the ranges are dropped (overlaps merged, clamped to the file) and what is left is cut as `--segments` would cut it; a leftover shorter than one frame is not kept, and a removal that leaves nothing is refused. It does not combine with `--segments`, `--start` (even `--start 0`), `--end`, `--duration` or `--snap beats` (a snapped edge could move into a dropped range). Since 2.17.2 every join the re-encode makes fades the sound for `--declick-ms` (20 ms) on each side, so butted cuts do not click; `--no-declick` butts them as before, and a stream-copy join (sound copied) is not faded. The result reports `declick_ms` (null when there was no re-encoded join). stderr reports whether
150
+ Multiple segments are concatenated in the order given. `--remove A-B,...` (2.24) is the inverse: the ranges are dropped (overlaps merged, clamped to the file) and what is left is cut as `--segments` would cut it; a leftover shorter than one frame is not kept, nor (to a video output) one that holds less than one frame of picture, such as sound that runs past the last frame (a note names it), and a removal that leaves nothing is refused. It does not combine with `--segments`, `--start` (even `--start 0`), `--end`, `--duration` or `--snap beats` (a snapped edge could move into a dropped range). Since 2.17.2 every join the re-encode makes fades the sound for `--declick-ms` (20 ms) on each side, so butted cuts do not click; `--no-declick` butts them as before, and a stream-copy join (sound copied) is not faded. The result reports `declick_ms` (null when there was no re-encoded join). stderr reports whether
151
151
  the result was "lossless stream copy" or "re-encoded"; when the snap forced a
152
152
  re-encode, the result's `lossless_alternative` names the nearest keyframe
153
153
  `--start` that would stream-copy instead, so the trade can be offered.
@@ -530,7 +530,9 @@ Renders the input's audio as a video: `--style waveform` (default, FFmpeg's
530
530
  `showwaves`) draws amplitude over time; `--style spectrum` (`showspectrum`)
531
531
  draws a frequency-over-time heatmap instead, reading more out of dense
532
532
  mixes at the cost of being less immediately readable. The output always
533
- carries the audio it visualizes. For an audio-only input (no video stream
533
+ carries the audio it visualizes. The default output of an audio input is
534
+ `<name>_waveform.mp4` (an audio extension cannot hold the picture), and `-o` with an audio
535
+ extension is refused. For an audio-only input (no video stream
534
536
  needed) or any file with an audio track worth visualizing.
535
537
 
536
538
  **Audiogram (1.16).** `--image PATH` puts a local still behind the
@@ -719,12 +721,14 @@ silence.py INPUT --filler --words transcript.json [--filler-lang auto|en|ja|ko|z
719
721
  [--transcribe] [--engine auto|parakeet-mlx|parakeet.cpp|whisper.cpp|faster-whisper|openai-whisper]
720
722
  [--model MODEL] [--filler-list] [--max-cuts 400]
721
723
  ```
722
- Runs `silencedetect`, keeps `--margin` seconds of air around speech, drops
724
+ Runs `silencedetect`, keeps `--margin` seconds of air around speech (none at the start or the
725
+ end of the sound, where there is no speech to keep it for), drops
723
726
  gaps shorter than `--min-silence`, and re-encodes the kept ranges the way `cut.py --segments
724
727
  --accurate` does: both streams cut on the same frame times, so the sound never drifts from the
725
728
  picture at the cuts, and a sound that starts late keeps its offset. `--list` prints silences, kept ranges and seconds removed
726
729
  without rendering; `--edl` saves the kept ranges in `cut.py --segments` format
727
- so the user can edit the list by hand. Quiet rooms need `--threshold -40`
730
+ so the user can edit the list by hand (written once the cut is, as `--timemap` is). `--max-cuts N`
731
+ (400) refuses to render more than N removal ranges; `--list` reports them whatever their number. Quiet rooms need `--threshold -40`
728
732
  to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
729
733
 
730
734
  **Breaths and weak word endings.** `--margin` is the padding kept on each side of every cut
@@ -766,9 +770,10 @@ unchanged, and filler-word cuts (which sit in sound by design) are measured but
766
770
  source and output times, and every removed range with the output time it was cut at. `.csv`
767
771
  gives `type,source_start,source_end,output_start,output_end` rows (a removed row's two output
768
772
  columns are its cut point); any other path must end in `.json`. Use it to re-time subtitles
769
- made on the source, or to find a removed sentence again. Its times advance by the
770
- millisecond-rounded ranges the render cuts on; they are computed, not measured on the written
771
- file, so each kept range can be off by up to one video frame or audio block. An existing map is refused without `--overwrite`,
773
+ made on the source, or to find a removed sentence again. Its times advance by the exact
774
+ lengths of the ranges the render cuts on (with a picture, each bound on its nearest frame time;
775
+ with sound alone, as asked), rounded only when written, so nothing adds up over many cuts; they
776
+ are computed, not measured on the written file. An existing map is refused without `--overwrite`,
772
777
  and a missing directory or a directory at the path as `kind: input`, before anything runs (a dry
773
778
  run predicts both); the map is written only after the cut is, so a failed or refused encode
774
779
  leaves none. A dry run writes nothing.
@@ -1201,7 +1206,8 @@ scenes.py INPUT [--shots] [--audio-peaks] [--speech] [--json]
1201
1206
  ```
1202
1207
  Lists scenes with audio energy, the loudest moments, and (with
1203
1208
  `--highlights`) proposes N ranges that add up to `--target` seconds, biased to
1204
- the loudest window of each scene. Review the sheet + JSON, adjust the EDL, then
1209
+ the loudest window of each scene; each range lies inside its scene (it starts at the cut or on
1210
+ the 0.5 s grid after it, to the hundredth rounded inward). Review the sheet + JSON, adjust the EDL, then
1205
1211
  `cut.py --segments`. Cut detection is a one-frame spike test (benchmark on
1206
1212
  hard cuts between real single takes: precision 0.95, recall 1.00 at the default
1207
1213
  threshold; raise `--threshold` to 12 for 0.98 precision at 0.94 recall).
@@ -1619,8 +1625,14 @@ multicam.py REF CAM2 [CAM3 ...] [--switch "START-END:CAM,..."|energy [--min-shot
1619
1625
  ```
1620
1626
  All inputs are aligned to the first one by audio (same engine as `sync.py`,
1621
1627
  `--fix-drift` for long takes). `--switch` names which camera is on screen for
1622
- each range of the reference timeline (gaps fall back to camera 0), `--auto N`
1623
- simply alternates every N seconds. Audio comes from the reference unless
1628
+ each range of the reference timeline (gaps fall back to camera 0; ranges that overlap are
1629
+ refused, `kind: input`, since each moment has one camera), `--auto N`
1630
+ simply alternates every N seconds. Under `--fix-drift` a drifting camera's picture is retimed
1631
+ with its sound (`setpts=(PTS-STARTPTS)/ratio`), so a range lasts its reference length on both;
1632
+ `--write-project`'s clips name that camera's own in/out, with the ratio as the clip's `speed`
1633
+ (render.py plays it at that rate, so it lasts its reference length there too), and the project states
1634
+ `"transition": {"type": "none"}`: a switch is a cut, and render.py's default 0.5 s crossfade at every join
1635
+ would run the timeline 0.5 s a switch short and move each later switch. Audio comes from the reference unless
1624
1636
  `--audio` picks another input, e.g. an external recorder that has no video.
1625
1637
  `--offsets-only` reports offsets and confidence without rendering. `cuts` names
1626
1638
  the camera actually rendered: a range whose camera has not started yet plays
@@ -2015,7 +2027,8 @@ sync.py REFERENCE SOURCE [SOURCE ...] [--json] [--max-offset 30] [--analyze-seco
2015
2027
  ```
2016
2028
  Cross-correlates loudness envelopes: coarse FFT search (20 ms), then a direct
2017
2029
  1 ms refinement (pure Python, a 2-minute window takes ~1-3 s). Positive offset
2018
- = the source recording started later. `--replace-audio` writes the reference
2030
+ = the source recording started later, so `--trim-second`/`--replace-audio` delay it by that
2031
+ much (a negative one drops its first -offset seconds). `--replace-audio` writes the reference
2019
2032
  video with the source file's audio aligned (video stream copied); the output
2020
2033
  keeps the reference's full length -- a shorter or head-trimmed source file is
2021
2034
  padded with silence, never allowed to cut the picture.
@@ -2031,8 +2044,11 @@ could not be measured is skipped and says why: `drift_skipped_reason` is
2031
2044
  `too_short` (no tail window far enough from the head) or `low_confidence`
2032
2045
  (top level for one source, and in each `sources` entry; null when applied).
2033
2046
  Check `confidence`
2034
- (0–1, normalised correlation with a runner-up penalty); below 0.3 the match is
2035
- doubtful. Benchmark on real dialogue/music (±30 s offsets, gain, noise, EQ):
2047
+ (0–1, normalised correlation with a runner-up penalty: the best other peak more than five
2048
+ steps from the winner); below 0.3 the match is doubtful. Since A126 the runner-up is never the
2049
+ winner's own neighbour, which held an exact match at 0.59, so a clean match can read near 1.0 and a
2050
+ tie between two offsets reads at most 0.5. The benchmark below was measured with the earlier runner-up and has
2051
+ not been re-run since; on real dialogue/music (±30 s offsets, gain, noise, EQ):
2036
2052
  with the default 120 s window 40/40 within 10 ms (max 1.1 ms); with a 60 s
2037
2053
  window 95 %, misses flagged below 0.3. Keep `--analyze-seconds` at least 4×
2038
2054
  `--max-offset` (default 120 s vs 30 s): lags with under 35 % overlap are
@@ -2169,10 +2185,13 @@ highpass, and `medium`/`strong` run their own denoise stage at that strength
2169
2185
  that is off is refused, as the `--duck-*` flags are: `--denoise-strength` without
2170
2186
  `--denoise`, `--music-volume`, `--music-fade-out` or `--music-loop` without `--music`,
2171
2187
  `--effects-volume` without `--effects` (a value equal to the default is not seen as given).
2172
- `--duck` uses a sidechain compressor keyed by the speech so music dips under
2173
- dialogue and swells in pauses:
2174
- `sidechaincompress=threshold=0.05:ratio=<amount/3, min 2>:attack=20:release=400:makeup=1`
2175
- by default. `--duck-threshold DB` (default −26.02 dBFS, i.e. the 0.05 linear),
2188
+ `--duck` lowers the music by `--duck-amount` dB (0..60, default 12) while the speech is above
2189
+ `--duck-threshold`, and leaves it at its level in the pauses: a sidechain gate keyed by the
2190
+ speech passes the bed under speech, and that part, scaled by `-(1 - 10^(-amount/20))`, is added
2191
+ back to the bed (`sidechaingate=threshold=0.05:ratio=9000:range=0:knee=1:attack=20:release=400`, then
2192
+ `volume=-0.748811` for 12 dB, by default). Through 3.8.8 it was a sidechain compressor whose
2193
+ ratio was `amount/3` (at least 2), so the depth followed the speech's level, not the flag
2194
+ (audit A134). `--duck-threshold DB` (default −26.02 dBFS, i.e. the 0.05 linear),
2176
2195
  `--duck-attack MS` (20) and `--duck-release MS` (400) move each one; a lower
2177
2196
  threshold ducks on quieter speech, a shorter release brings the bed back faster.
2178
2197
  `--json`'s `audio` block reports the settings the run actually used.
@@ -2200,7 +2219,19 @@ swresample. Video is always stream-copied, and so is a subtitle/data track
2200
2219
  when the container can hold it (`dropped_non_av_streams` says when it could not).
2201
2220
  `--limit` (and `--voice strong`'s limiter) is `alimiter` with `level=disabled:latency=1`: the
2202
2221
  look-ahead is compensated, so the limited sound starts on the input's own sample instead of
2203
- `--limit-attack` (5 ms, 240 samples at 48 kHz) late against the picture (#476).
2222
+ `--limit-attack` (5 ms, 240 samples at 48 kHz) late against the picture (#476). With `--music`
2223
+ or `--effects` both limiters run on the mix, after the beds are added: the combined mix is
2224
+ limited before it is encoded (A129). A lossy output (`.m4a`, `.mp3`, `.opus`, AAC beside a
2225
+ picture) can then peak over the ceiling, since the encoder works after the limiter; use
2226
+ `loudness.py` for final levels, which measures the written file. Without a bed they run on the
2227
+ main track, where they always ran.
2228
+ The typed dynamics flags speak dB where the filter takes a linear value: a knee
2229
+ (`--comp-knee`, `--gate-knee`) is 0..18.06 dB, the filters' linear 1..8, default 9.03 dB
2230
+ (2.82843); the defaults `--help` gives are ffmpeg's own, in the flag's unit (A135).
2231
+ `--bitrate` is the AAC rate beside a picture (192k) and the rate of a lossy audio output's
2232
+ codec (`.m4a`/`.aac` 256k, `.mp3` `-q:a 0`, `.ogg` `-q:a 6`, `.opus` 128k when it is not given);
2233
+ `.wav` and `.flac` refuse it (A143). A `--music-fade-out` on a bed shorter than the output (and
2234
+ not `--music-loop`ed) ends where the bed ends (A142).
2204
2235
  With `--replace`, `--music` or a kept picture the sound is padded with silence or trimmed to the
2205
2236
  source's length counted in samples at the track's rate (`apad=whole_len=N,atrim=end_sample=N`),
2206
2237
  so it is exact to the sample, not to the millisecond (#478). Sound that starts after the picture
@@ -2249,8 +2280,11 @@ video containers or the codec matching the extension (.wav → PCM, .flac, .mp3)
2249
2280
  The written file is measured again: a lossy encoder can push peaks past the
2250
2281
  ceiling loudnorm held (ffmpeg's AAC at 192k turned one transient from -2.4 to
2251
2282
  +3.7 dBFS). When it does, the tool re-encodes -- first at 256k then 320k if you
2252
- did not pass `--audio-bitrate`, then with the loudnorm ceiling lowered by the
2253
- overshoot -- until the file itself meets `--tp`. `result` reports
2283
+ did not pass `--audio-bitrate` (an `.m4a`/`.aac` output starts at its 256k, so only 320k is
2284
+ left), then with the loudnorm ceiling lowered by the overshoot -- until the file itself meets
2285
+ `--tp`. A pinned `--audio-bitrate` reaches an audio output's codec as well as a video
2286
+ container's AAC (through 3.8.8 an `.m4a` was 256k whatever was asked, and its retries too);
2287
+ `.wav`/`.flac` refuse it. `result` reports
2254
2288
  `tp_ceiling_used`, `audio_bitrate_used`, `encodes`, and a `note` when the
2255
2289
  integrated loudness ended more than 1 LU from the target because of it.
2256
2290
 
@@ -1173,10 +1173,27 @@ AUDIO_CODECS = {
1173
1173
  }
1174
1174
 
1175
1175
 
1176
- def audio_codec_for(output_path: str, default_bitrate: str = "192k") -> List[str]:
1177
- """Pick an audio codec that the output container can actually hold."""
1176
+ LOSSLESS_AUDIO_EXT = {".wav", ".flac"} # the AUDIO_CODECS whose codec takes no bitrate
1177
+
1178
+
1179
+ def audio_codec_for(output_path: str, bitrate: Optional[str] = None) -> List[str]:
1180
+ """Pick an audio codec that the output container can actually hold.
1181
+
1182
+ `bitrate` is one the caller asked for: it replaces the rate or quality an audio extension
1183
+ names (`-q:a 0` for .mp3, 256k for .m4a), which through 3.8.8 it never reached (A143), and is
1184
+ the AAC rate of any other container (192k when it is None). A lossless extension takes none;
1185
+ the caller refuses a bitrate for it."""
1178
1186
  ext = os.path.splitext(output_path)[1].lower()
1179
- return list(AUDIO_CODECS.get(ext, ["-c:a", "aac", "-b:a", default_bitrate]))
1187
+ if ext not in AUDIO_CODECS:
1188
+ return ["-c:a", "aac", "-b:a", bitrate or "192k"]
1189
+ args = list(AUDIO_CODECS[ext])
1190
+ if bitrate is None or ext in LOSSLESS_AUDIO_EXT:
1191
+ return args
1192
+ for flag in ("-b:a", "-q:a"):
1193
+ if flag in args:
1194
+ i = args.index(flag)
1195
+ del args[i:i + 2]
1196
+ return args + ["-b:a", bitrate]
1180
1197
 
1181
1198
 
1182
1199
  def is_audio_output(output_path: str) -> bool:
@@ -1590,7 +1607,7 @@ def _refine_grid(onset_times: "Sequence[float]", strengths: "Sequence[float]", i
1590
1607
  it), and onset_time = phase + k * interval is solved for both unknowns with each pair weighted
1591
1608
  by its onset strength squared; the pairing is then redone against the refitted grid, until it
1592
1609
  stops moving (three passes at most). This only sharpens the grid the autocorrelation chose --
1593
- the tempo cannot leave `bpm_range`, an octave cannot flip (a refit that moves the interval by
1610
+ the tempo cannot leave `bpm_range` (a fit beyond a bound is held on it), an octave cannot flip (a refit that moves the interval by
1594
1611
  more than interval/BEAT_ALIGN_DIVISOR is discarded), and fewer than four paired grid points
1595
1612
  keep the original: the fit needs evidence, and an onset-free stretch gives it none.
1596
1613
  Returns phase in [0, interval).
@@ -1613,8 +1630,13 @@ def _refine_grid(onset_times: "Sequence[float]", strengths: "Sequence[float]", i
1613
1630
  if sxx <= 0:
1614
1631
  break
1615
1632
  new_iv = sum(w * (k - mk) * (t - mt) for k, t, w in pairs) / sxx
1616
- if not (60.0 / hi_bpm <= new_iv <= 60.0 / lo_bpm) or abs(new_iv - interval) > interval / BEAT_ALIGN_DIVISOR:
1633
+ if abs(new_iv - interval) > interval / BEAT_ALIGN_DIVISOR:
1617
1634
  break
1635
+ # A146: a fit just outside `bpm_range` is held on its bound, not discarded. A tempo that
1636
+ # sits on the bound (90 BPM under --beat-range 60-90) fits a hair beyond it as often as
1637
+ # inside, and discarding the fit kept the step-rounded interval (89.76 BPM), whose grid
1638
+ # was 80 ms off a 30 s click track by its end
1639
+ new_iv = min(max(new_iv, 60.0 / hi_bpm), 60.0 / lo_bpm)
1618
1640
  new_ph = mt - new_iv * mk
1619
1641
  moved = abs(new_iv - iv) < 1e-9 and abs(new_ph - ph) < 1e-7
1620
1642
  iv, ph = new_iv, new_ph
@@ -640,14 +640,43 @@ def stream_inventory_step(meta: Dict[str, Any], expect: Dict[str, Any]) -> Dict[
640
640
  return step
641
641
 
642
642
 
643
+ def _finite(v: Any) -> Optional[float]:
644
+ return float(v) if isinstance(v, (int, float)) and math.isfinite(v) else None
645
+
646
+
647
+ def own_length(s: Optional[Dict[str, Any]]) -> Optional[float]:
648
+ """The length of one probed stream (`meta["video"]`, `meta["audio"]`): its duration, else the
649
+ DURATION tag Matroska/WebM keep, which is where the stream ENDS on the file's clock (the
650
+ muxer's last timestamp plus its packet's duration), not how long it lasts: a sound from 1 s
651
+ to 6 s is tagged 6. The tag less the stream's start (from 0 when the start is a priming
652
+ frame before 0). A value that is not a finite number is no length (a NaN compares false and
653
+ would pass every check)."""
654
+ s = s or {}
655
+ duration = _finite(s.get("duration"))
656
+ if duration is not None and duration > 0:
657
+ return duration
658
+ tagged = _finite(s.get("tagged_duration"))
659
+ if tagged is None:
660
+ return None
661
+ length = tagged - max(_finite(s.get("start_time")) or 0.0, 0.0)
662
+ return length if length > 0 else None
663
+
664
+
665
+ def own_end(s: Optional[Dict[str, Any]]) -> Optional[float]:
666
+ """Where one probed stream ends on the file's own timestamps: its start plus its duration,
667
+ else the Matroska/WebM DURATION tag, which is already that end."""
668
+ s = s or {}
669
+ duration = _finite(s.get("duration"))
670
+ if duration is not None and duration > 0:
671
+ return (_finite(s.get("start_time")) or 0.0) + duration
672
+ if own_length(s) is None:
673
+ return None
674
+ return _finite(s.get("tagged_duration"))
675
+
676
+
643
677
  def stream_length(meta: Dict[str, Any], kind: str) -> Optional[float]:
644
- """One stream's own length: its duration, else the DURATION tag Matroska/WebM keep. A value
645
- that is not a finite number is no length (a NaN compares false and would pass every check)."""
646
- s = meta.get(kind) or {}
647
- for v in (s.get("duration"), s.get("tagged_duration")):
648
- if isinstance(v, (int, float)) and math.isfinite(v) and v > 0:
649
- return float(v)
650
- return None
678
+ """One stream's own length (own_length())."""
679
+ return own_length(meta.get(kind))
651
680
 
652
681
 
653
682
  LENGTH_UNREAD_HINT = ("remux the file so its container states a length (export.py INPUT --preset copy -o remuxed.mkv), "
@@ -674,9 +703,8 @@ def input_length(meta: Optional[Dict[str, Any]], path: Optional[str] = None) ->
674
703
 
675
704
 
676
705
  def stream_end(meta: Dict[str, Any], kind: str) -> Optional[float]:
677
- """Where one stream ends on the file's timeline: its start plus its own length."""
678
- length = stream_length(meta, kind)
679
- return None if length is None else ((meta.get(kind) or {}).get("start_time") or 0.0) + length
706
+ """Where one stream ends on the file's timeline (own_end())."""
707
+ return own_end(meta.get(kind))
680
708
 
681
709
 
682
710
  def file_origin(meta: Dict[str, Any]) -> float:
@@ -43,11 +43,16 @@ def fingerprint(path: str) -> Dict[str, Any]:
43
43
 
44
44
 
45
45
  def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None, start: float = 0.0,
46
- *, check: bool = True) -> List[float]:
46
+ *, check: bool = True) -> "Sequence[float]":
47
47
  """Decode (part of) a file's audio to mono float samples in [-1, 1) at `sample_rate` via a
48
48
  single ffmpeg pass under --timeout. Shared by scenes.py (audio envelope for cut scoring) and
49
49
  sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else [].
50
- The samples are on the file's clock: a sound that starts late is padded at its head."""
50
+ The samples are on the file's clock: a sound that starts late is padded at its head.
51
+
52
+ The samples are an array('f'), 4 bytes each: a list of Python floats took about 32 (24 for the
53
+ float, 8 for the slot), so scenes.py held ~2.3 GB per hour of audio at 8 kHz + 22.05 kHz and
54
+ multicam --switch energy one such list per camera (A140). An s16 sample divided by 32768 is
55
+ exact in a float32, so every value read back is the double the list held."""
51
56
  ffmpeg = require_tool("ffmpeg")
52
57
  cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
53
58
  if start:
@@ -65,9 +70,15 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
65
70
  if check:
66
71
  die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr.decode(errors="replace")))
67
72
  return []
68
- n = len(proc.stdout) // 2
69
- import struct
70
- return [v / 32768.0 for v in struct.unpack(f"<{n}h", proc.stdout[: n * 2])]
73
+ import array
74
+ import sys
75
+ data, proc = proc.stdout, None
76
+ pcm = array.array("h")
77
+ pcm.frombytes(memoryview(data)[: len(data) // 2 * 2])
78
+ del data
79
+ if sys.byteorder == "big":
80
+ pcm.byteswap() # s16le on the pipe
81
+ return array.array("f", (v / 32768.0 for v in pcm))
71
82
 
72
83
 
73
84
  def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool = False, remove_mean: bool = False) -> List[float]:
@@ -203,6 +214,23 @@ def shorter_than_a_loudness_block(length: "Optional[float]") -> bool:
203
214
  return length is not None and 0 < length < LOUDNESS_BLOCK_SECONDS
204
215
 
205
216
 
217
+ def riff_wave_args(path: str) -> List[str]:
218
+ """`-f wav` for a file that opens with a RIFF/WAVE header. FFmpeg 9.0.2 scores its MPEG-TS
219
+ probe above the WAV one when the PCM happens to hold 0x47 at a transport packet's spacing (a
220
+ steady tone can): ffprobe then reads a file this skill has just written as a broken transport
221
+ stream and fails with "End of file". The header says what the file is, so the probe is told.
222
+ Measured on 9.0.2 (CI): the same file fails unforced and reads with `-f wav`; 6.1 to 8.0 read
223
+ it either way."""
224
+ if not os.path.isfile(path): # a pipe or a device is never opened here: the open would block
225
+ return []
226
+ try:
227
+ with open(path, "rb") as fh:
228
+ head = fh.read(12)
229
+ except OSError:
230
+ return []
231
+ return ["-f", "wav"] if head[:4] == b"RIFF" and head[8:12] == b"WAVE" else []
232
+
233
+
206
234
  def probe(path: str, role: str = "input") -> Dict[str, Any]:
207
235
  """Return a compact, script-friendly description of a media file.
208
236
 
@@ -229,7 +257,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
229
257
  die(f"input not found: {path}")
230
258
  ffprobe = require_tool("ffprobe")
231
259
  proc = run(
232
- [ffprobe, "-v", "error", *still_input_args(path),
260
+ [ffprobe, "-v", "error", *still_input_args(path), *riff_wave_args(path),
233
261
  "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path],
234
262
  quiet=True,
235
263
  check=False,
@@ -2109,11 +2109,56 @@ def _set_refusal_note(ctx: "Context", reason: str, outcome: str) -> None:
2109
2109
  ctx.hw_notes.append(reason + outcome)
2110
2110
 
2111
2111
 
2112
+ # A filter graph longer than this is handed to ffmpeg in a file (A138). Linux refuses any single
2113
+ # argument over 128 KiB (MAX_ARG_STRLEN) and Windows a whole command line over 32767 characters, and
2114
+ # a graph grows with the items it is built from: scenes.py --sheet's select expression is ~56
2115
+ # characters a scene, so ~2,340 scenes raised OSError E2BIG before ffmpeg ran, with a traceback and
2116
+ # no JSON. A graph under the limit is passed as it always was.
2117
+ LONG_FILTER_CHARS = 8000 if os.name == "nt" else 100_000
2118
+ _FILTER_OPTION_RE = re.compile(r"^-(?:filter_complex|lavfi|vf|af|filter(?::[^\s]+)?)$")
2119
+
2120
+
2121
+ def _filter_file_option(option: str) -> str:
2122
+ """The option that reads `option`'s value from a file: `-/OPTION` from FFmpeg 7.0, which
2123
+ deprecated the `_script` spellings, and `-filter_complex_script` / `-filter_script[:spec]`
2124
+ before it (6.x, the oldest CI runs)."""
2125
+ complex_graph = option in ("-filter_complex", "-lavfi")
2126
+ spec = {"-vf": ":v", "-af": ":a"}.get(option, option[len("-filter"):] if option.startswith("-filter:") else "")
2127
+ if ffmpeg_version() >= (7, 0):
2128
+ return "-/filter_complex" if complex_graph else "-/filter" + spec
2129
+ return "-filter_complex_script" if complex_graph else "-filter_script" + spec
2130
+
2131
+
2132
+ @contextlib.contextmanager
2133
+ def long_filters_in_files(cmd: Sequence[str]) -> "Any":
2134
+ """`cmd` with every filter graph longer than LONG_FILTER_CHARS moved into a temp file read by
2135
+ ffmpeg's file form of the same option; the files are removed when the block ends. The command a
2136
+ run records and prints is the caller's own, with the graph inline."""
2137
+ import tempfile
2138
+ out = list(cmd)
2139
+ made: List[str] = []
2140
+ try:
2141
+ if _is_ffmpeg(out):
2142
+ for i in range(1, len(out) - 1):
2143
+ if _FILTER_OPTION_RE.match(out[i]) and len(out[i + 1]) > LONG_FILTER_CHARS:
2144
+ fd, path = tempfile.mkstemp(prefix="ffmpeg-skill-graph-", suffix=".txt")
2145
+ made.append(path)
2146
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
2147
+ fh.write(out[i + 1])
2148
+ out[i], out[i + 1] = _filter_file_option(out[i]), path
2149
+ yield out
2150
+ finally:
2151
+ for path in made:
2152
+ with contextlib.suppress(OSError):
2153
+ os.remove(path)
2154
+
2155
+
2112
2156
  def _execute(exec_cmd: List[str]) -> subprocess.CompletedProcess:
2113
2157
  """One attempt, never exiting on failure (run() decides after its retries)."""
2114
- if STATE.progress and _is_ffmpeg(exec_cmd) and exec_cmd[-1] != "-":
2115
- return _run_with_progress(exec_cmd, False)
2116
- return _run_captured(exec_cmd, False)
2158
+ with long_filters_in_files(exec_cmd) as cmd:
2159
+ if STATE.progress and _is_ffmpeg(cmd) and cmd[-1] != "-":
2160
+ return _run_with_progress(cmd, False)
2161
+ return _run_captured(cmd, False)
2117
2162
 
2118
2163
 
2119
2164
  def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, record: bool = False) -> subprocess.CompletedProcess:
@@ -2134,7 +2179,8 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, r
2134
2179
  # input filename on stderr, and loudness/check parse the loudnorm JSON out of it. The
2135
2180
  # encoding kwargs are rejected with text=False, so they are only passed for text mode.
2136
2181
  text_kw = {"encoding": "utf-8", "errors": "replace"} if text else {}
2137
- proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, **text_kw, timeout=limit)
2182
+ with long_filters_in_files(cmd) as exec_cmd:
2183
+ proc = subprocess.run(exec_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, **text_kw, timeout=limit)
2138
2184
  except subprocess.TimeoutExpired:
2139
2185
  _timed_out(cmd, limit or 0)
2140
2186
  if check and proc.returncode != 0:
@@ -170,7 +170,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
170
170
  required=FF + [X264, AAC], optional=[HDR_X265],
171
171
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
172
172
  "audio": dict(role="execution", inputs=["video or audio asset", "music bed (--music) or replacement track (--replace)", "effects/atmos track (--effects)"], outputs=["artifact with the processed audio (video stream-copied, or dropped when -o has an audio extension)"],
173
- required=FF, optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaincompress", "when": "--duck"},
173
+ required=FF, optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaingate", "when": "--duck"}, {"capability": "filter:dcshift", "when": "--duck"},
174
174
  {"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit / --voice strong"}, {"capability": "filter:agate", "when": "--gate"},
175
175
  {"capability": "filter:deesser", "when": "--voice medium (the default) / --voice strong"},
176
176
  {"capability": "filter:extrastereo", "when": "--stereo-widen"},
@@ -872,7 +872,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
872
872
  "silent": {"type": "array", "description": "--on-silent warn (default), only when a track was silent: [{flag, path, peak_db}] for each --music/--replace/--effects file, or under --duck the voice (flag \"input\"), whose whole-file peak is at or below --silence-threshold (default -50 dBFS); it was mixed anyway. --on-silent fail refuses instead, reason \"silent (peak X dBFS)\" in problems"},
873
873
  "notes": {"type": "array", "items": {"type": "string"}, "description": "present with `silent`: which tracks were silent and how to refuse them"},
874
874
  "timing": {"type": "object", "description": "#486: {tempo, atempo, length_samples, sample_rate}: the --tempo factor (null without it), the atempo stages (each 0.5..2.0, [] at tempo 1) whose product it is, --length-samples (null without it) and the sample rate that count is in (null when not measured yet); with --length-samples a length_samples verification step counts the written track's decoded samples (exact for PCM/FLAC/ALAC, within tolerance_samples, one codec frame, for a lossy codec)"},
875
- "audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, channel (--channel N, the 0-based channel kept, else null), sample_rate (--sample-rate, else null; both are read back from the output as the channels / sample_rate verification steps), effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db actually used)"}}
875
+ "audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, channel (--channel N, the 0-based channel kept, else null), sample_rate (--sample-rate, else null; both are read back from the output as the channels / sample_rate verification steps), effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, the sidechain gate's ratio, attack_ms, release_ms, and amount_db, the depth in dB the bed is lowered under speech)"}}
876
876
  for key, prop in EMITTED_KEYS.get(name, {}).items():
877
877
  extra.setdefault(key, prop)
878
878
  if name in ("overlay", "graphics", "caption", "grid"):
@@ -110,6 +110,21 @@ def ineligible(params: Optional[Dict[str, Any]], meta: Dict[str, Any], ext: str)
110
110
  return None
111
111
 
112
112
 
113
+ # Matroska stores every timestamp in milliseconds (its default TimestampScale), whatever the source's
114
+ # time base: an .mkv output's stream reads 1/1000 however it is written
115
+ MATROSKA_TIME_BASE = "1/1000"
116
+
117
+
118
+ def expected_time_base(params: Dict[str, Any], ext: str) -> Optional[str]:
119
+ """The time base the joined output's video should read: the source's for MP4/MOV, whose time
120
+ scale execute() sets from it (-video_track_timescale), and Matroska's own for .mkv. Comparing a
121
+ .mkv against an MP4 source's 1/15360 failed every such cut, and only after the whole smart cut had
122
+ been built and checked (#462 A45)."""
123
+ if ext.lower() == ".mkv":
124
+ return MATROSKA_TIME_BASE
125
+ return params.get("time_base")
126
+
127
+
113
128
  def _rate(params: Dict[str, Any]) -> Optional[Fraction]:
114
129
  try:
115
130
  q = Fraction(str(params.get("r_frame_rate") or ""))
@@ -165,8 +180,10 @@ def plan_segment(packets: List[Tuple[Any, Any, bool]], s: float, e: float, vend:
165
180
  raise SmartFallback(f"the keyframe at {packets[k1][0]:.3f}s opens its GOP (frames decoded after it are shown "
166
181
  "before it), so a copy cannot start there", "open_gop")
167
182
  k1_pts = packets[k1][0]
168
- # where the copy stops: the last keyframe presented at or before the end bound, decoded after K1
169
- stops = [i for i in keyidx if packets[i][1] > packets[k1][1] + 1e-9
183
+ # where the copy stops: the last keyframe presented at or before the end bound, decoded after K1.
184
+ # A segment that runs to the end of the video has no end bound to stop before: its copy runs
185
+ # to the end, and the last GOP is whole, so it is copied, not re-encoded as a tail (#462 A44)
186
+ stops = [i for i in keyidx if not to_end and packets[i][1] > packets[k1][1] + 1e-9
170
187
  and (end_bound is None or packets[i][0] <= end_bound + 1e-6)]
171
188
  k2 = stops[-1] if stops else None
172
189
  if k2 is not None and gop_is_open(packets, k2):
@@ -422,9 +439,10 @@ def verify(src: str, dst: str, parts: List[Dict[str, Any]], params: Dict[str, An
422
439
  decoded += g
423
440
  offset += n
424
441
  out_params = stream_params(dst) or {}
425
- if out_params.get("time_base") != params.get("time_base"):
426
- raise SmartFallback(f"the output's time base is {out_params.get('time_base')}, the source's "
427
- f"{params.get('time_base')}", "time_base")
442
+ want_tb = expected_time_base(params, os.path.splitext(dst)[1])
443
+ if out_params.get("time_base") != want_tb:
444
+ raise SmartFallback(f"the output's time base is {out_params.get('time_base')}, where "
445
+ f"{want_tb} was expected (the source's is {params.get('time_base')})", "time_base")
428
446
  diff = mismatches(params, out_params)
429
447
  if diff:
430
448
  raise SmartFallback("the output's stream does not match the source: " + ", ".join(diff), "params_mismatch")