ffmpeg-skill 3.8.12 → 3.8.14

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.
Files changed (44) hide show
  1. package/README.md +2 -1
  2. package/SKILL.md +1 -1
  3. package/docs/contract.md +11 -5
  4. package/mcp/server.py +0 -1
  5. package/package.json +5 -2
  6. package/references/flags.md +2 -2
  7. package/references/scripts.md +72 -27
  8. package/scripts/_common/asr.py +1 -1
  9. package/scripts/_common/color.py +7 -2
  10. package/scripts/_common/decision.py +40 -8
  11. package/scripts/_common/emit.py +38 -10
  12. package/scripts/_common/probe.py +34 -6
  13. package/scripts/_common/runner.py +76 -7
  14. package/scripts/_contract.py +24 -10
  15. package/scripts/_platforms.py +0 -5
  16. package/scripts/_smartcut.py +23 -5
  17. package/scripts/audio.py +86 -28
  18. package/scripts/background.py +1 -1
  19. package/scripts/caption.py +2 -3
  20. package/scripts/check.py +3 -3
  21. package/scripts/color.py +7 -1
  22. package/scripts/crop.py +3 -2
  23. package/scripts/cropdetect.py +1 -1
  24. package/scripts/cut.py +50 -17
  25. package/scripts/export.py +39 -6
  26. package/scripts/fit.py +4 -3
  27. package/scripts/freeze.py +1 -1
  28. package/scripts/graphics.py +2 -3
  29. package/scripts/insert.py +1 -1
  30. package/scripts/look.py +9 -4
  31. package/scripts/loop.py +3 -3
  32. package/scripts/loudness.py +20 -12
  33. package/scripts/multicam.py +56 -15
  34. package/scripts/overlay.py +1 -1
  35. package/scripts/probe.py +13 -2
  36. package/scripts/proxy.py +11 -2
  37. package/scripts/redact.py +5 -1
  38. package/scripts/scenes.py +82 -21
  39. package/scripts/silence.py +62 -16
  40. package/scripts/sphere.py +23 -3
  41. package/scripts/stabilize.py +5 -2
  42. package/scripts/sync.py +11 -6
  43. package/scripts/verify.py +82 -20
  44. package/scripts/waveform.py +12 -2
package/README.md CHANGED
@@ -331,6 +331,7 @@ rather than drifting silently. There is no separate `input_schema` file to forge
331
331
  ```bash
332
332
  npx ffmpeg-skill contract --json # or: python3 scripts/_contract.py --json
333
333
  npx ffmpeg-skill contract --json --static # without environment detection
334
+ npx ffmpeg-skill contract --json --tool cut # one tool's entry under `tools` (repeatable): ~60 KB, not ~950 KB
334
335
  ```
335
336
 
336
337
  The contract is generated from the code that runs, not maintained beside it. For each of the 42 tools (`ffmpeg-skill/<name>`) it states:
@@ -514,7 +515,7 @@ type on every OS.
514
515
  | Result | Measurement |
515
516
  |---|---|
516
517
  | **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 |
518
+ | **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
519
  | **0 missed gaps** | `silence.py`, 20 cases with known gaps, ≤ 1 ms leftover silence |
519
520
  | **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
520
521
  | **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/SKILL.md CHANGED
@@ -16,7 +16,7 @@ Shared flags (the rest, codec defaults and `--hw`: `references/flags.md`):
16
16
 
17
17
  Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`,
18
18
  `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`, `overlay`, `metadata`, `cut`, `render`,
19
- `batch`) may still run ffmpeg/ffprobe without writing artifacts; `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
19
+ `batch`) may still run ffmpeg/ffprobe without writing artifacts; `verify` ignores the flag. Per tool: `contract --json --tool NAME` `dry_run`.
20
20
 
21
21
  Audio-only files work in the audio tools; picture tools refuse them (`references/workflow.md#audio-only-files`).
22
22
 
package/docs/contract.md CHANGED
@@ -7,6 +7,11 @@ dry-run, input preservation and repeatability. It is the interface a planning ag
7
7
  consumes instead of reading `SKILL.md`, which stays written for a coding agent that
8
8
  follows the workflow by hand.
9
9
 
10
+ The whole document is about 950 KB, almost all of it the 42 `tools` entries. `--tool NAME`
11
+ (repeatable; `cut` or `cut.py`) keeps only the named tools under `tools`, in the order given,
12
+ and every other key as it is: about 55 to 85 KB for one tool. An unknown name is refused (exit
13
+ 2) with the list of tools, and `doctor` does not take the flag.
14
+
10
15
  The contract is derived from the code that runs, not maintained beside it:
11
16
 
12
17
  - the tool list is every script in `scripts/` that does not start with `_`;
@@ -21,7 +26,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
26
  | Field | Meaning | Changes when |
22
27
  |---|---|---|
23
28
  | `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 |
29
+ | `skill.version` | the npm / package.json version (`3.8.14`) | any release |
25
30
 
26
31
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
32
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -149,7 +154,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
154
  "deprecated": [],
150
155
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
156
  {"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",
157
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.14", "execution_mode": "local", "kind": "execution",
153
158
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
159
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
160
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
@@ -689,7 +694,8 @@ says "output audio is silent" instead of recommending `loudness.py`.
689
694
  Per-tool keys added in 1.13: `audio` (`audio.py`) reports the mix it built — the
690
695
  `--voice` level, `stereo_widen`, whether an `--effects` bed was mixed, and with
691
696
  `--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
697
+ linear), ratio (the sidechain gate's 9000 since A134), attack, release and `amount_db`
698
+ (the depth in dB the bed is lowered under speech) actually used, or `null` when `--duck` was not
693
699
  given. Since #486 `audio.py` also reports `timing`, `{tempo, atempo, length_samples, sample_rate}`:
694
700
  the `--tempo` factor and the atempo stages (each 0.5..2.0) it was built from, `--length-samples`,
695
701
  and the rate that count is in (`null` before a pending input is measured); with
@@ -814,7 +820,7 @@ pointer to cuts that may sit in a breath):
814
820
 
815
821
  | key | tool | what it holds |
816
822
  |---|---|---|
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` |
823
+ | `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
824
  | `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
825
  | `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
826
 
@@ -950,7 +956,7 @@ A refusal also carries `error.reason`, a stable snake_case code for *why*
950
956
  `output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
951
957
  `output_container_codecs` (a `.webm` output for an H.264/HEVC or AAC encode, which WebM cannot hold),
952
958
  `media_path_is_url` (`subfile,...:` included), `media_path_is_playlist` (an HLS, DASH, IMF or ffconcat playlist given as media, DASH and IMF told by content whatever the file is called), `media_path_has_line_break` (a path with a line break, which a concat list cannot hold),
953
- `output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
959
+ `output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_extension_missing` (a media output with no file extension, which leaves ffmpeg no container to choose), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
954
960
  `render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
955
961
  `encoder_list_unreadable`, and `--transcribe`'s `asr_engine_missing`, `no_speech`, `engine_failed`
956
962
  and `english_only`), and `error.details`, the facts the message is written from, so a
package/mcp/server.py CHANGED
@@ -18,7 +18,6 @@ Claude Desktop / Claude Code config example:
18
18
  """
19
19
  import json
20
20
  import os
21
- import subprocess
22
21
  import sys
23
22
  from pathlib import Path
24
23
  from typing import Any, Dict, List, Optional
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.8.12",
3
+ "version": "3.8.14",
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": [
@@ -53,7 +53,10 @@
53
53
  "docs/contract.md",
54
54
  "SKILL.md",
55
55
  "README.md",
56
- "LICENSE"
56
+ "LICENSE",
57
+ "!**/__pycache__/**",
58
+ "!**/*.pyc",
59
+ "!**/*.pyo"
57
60
  ],
58
61
  "scripts": {
59
62
  "test": "python3 tests/run_parallel.py && python3 tests/test_contract.py",
@@ -5,7 +5,7 @@ The long form of SKILL.md's Quick start. `--help` on a script is still its full
5
5
 
6
6
  ## Running the scripts
7
7
 
8
- Scripts live in `scripts/` next to SKILL.md; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. SKILL.md is enough to do a job: SKILL.md's request table routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as SKILL.md; open one only for a question you have: `references/scripts.md` (most flags; `--help` lists all), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end of SKILL.md). MCP: `tools/list` shows only the core 12 by default; the other 30 (per SKILL.md's request table) stay callable by name via `tools/call`, described by `contract --json`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
8
+ Scripts live in `scripts/` next to SKILL.md; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. SKILL.md is enough to do a job: SKILL.md's request table routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as SKILL.md; open one only for a question you have: `references/scripts.md` (most flags; `--help` lists all), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end of SKILL.md). MCP: `tools/list` shows only the core 12 by default; the other 30 (per SKILL.md's request table) stay callable by name via `tools/call`, described by `contract --json --tool NAME`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
9
9
 
10
10
  ## Shared flags
11
11
 
@@ -13,4 +13,4 @@ Shared flags (every script accepts them; the exceptions are named): `--dry-run`
13
13
 
14
14
  ## What `--dry-run` runs
15
15
 
16
- Writing tools run no ffmpeg under `--dry-run` (ffprobe still reads an input that exists); the measuring tools (`probe` with `--analyze`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`; `overlay` for a multi-line `--text` on FFmpeg 8.0-8.1.0; `metadata` with `--auto-chapters`; `cut` with `--snap beats`) still run their ffmpeg measurements, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`), and `render` and `batch` run each stage or step as its own dry run; `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
16
+ Writing tools run no ffmpeg under `--dry-run` (ffprobe still reads an input that exists); the measuring tools (`probe` with `--analyze`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`; `overlay` for a multi-line `--text` on FFmpeg 8.0-8.1.0; `metadata` with `--auto-chapters`; `cut` with `--snap beats`) still run their ffmpeg measurements, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`), and `render` and `batch` run each stage or step as its own dry run; `verify` ignores the flag. Per tool: `contract --json --tool NAME` `dry_run`.
@@ -131,7 +131,10 @@ JSON with `duration`, `video{codec,width,height,fps,pix_fmt,color_space,rotation
131
131
  (`hdr`, and `hdr_signal` equal to it, is true only for a PQ / HLG transfer or Dolby Vision -- since 2.0;
132
132
  `bt2020_or_hdr` also counts BT.2020 primaries on an SDR transfer, which `hdr_format` names "BT.2020 SDR"),
133
133
  `audio{codec,channels,sample_rate,start_time}` (`start_time`: where the stream starts on the file's
134
- own clock, `null` when unknown). `--compact` gives one line per file.
134
+ own clock, `null` when unknown). `--compact` gives one line per file. `--field` prints one line
135
+ per file: a string as itself and any other value as JSON (`true`, `{"codec": ...}`), an empty
136
+ line for a field that is not there; with `--json` every line is JSON (a string quoted, a
137
+ missing field `null`).
135
138
 
136
139
  ### cut.py — cut / join segments
137
140
  ```
@@ -147,7 +150,7 @@ by default since 3.0.0 (`--keep-hevc`; `--no-keep-hevc` uses x264 as 2.x did);
147
150
  `--codec` overrides all of them). `--accurate` always re-encodes; `--tolerance -1` never does for a keyframe snap
148
151
  (a `--segments` join of open-GOP video, one whose later B-frame `.mp4`/`.mov` segment starts
149
152
  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
153
+ 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
154
  the result was "lossless stream copy" or "re-encoded"; when the snap forced a
152
155
  re-encode, the result's `lossless_alternative` names the nearest keyframe
153
156
  `--start` that would stream-copy instead, so the trade can be offered.
@@ -338,7 +341,7 @@ the rectangle for you. Use this when the rectangle is already known (a
338
341
  face-detection box, a saved crop, a hand-picked region). The rectangle must
339
342
  lie entirely inside the source frame (after accounting for display rotation);
340
343
  `--width`/`--height` must be even (4:2:0 chroma) and are refused, never
341
- rounded, if they aren't.
344
+ rounded, if they aren't; an odd `--x`/`--y` is taken as given.
342
345
 
343
346
  ### cropdetect.py — measure black bars, report the crop rectangle
344
347
  ```
@@ -429,7 +432,9 @@ Wraps FFmpeg's `v360` filter to bake out an ordinary flat clip pointed at a
429
432
  fixed direction — the same "look this way" operation a VR headset or a 360
430
433
  player's viewport does. `--yaw`/`--pitch`/`--roll` aim the camera (each
431
434
  -180..180, default 0); `--h-fov`/`--v-fov` set how wide the view is (each
432
- 1..170, default 90/60). `--input-projection` must match the source's own
435
+ 1..170; `--h-fov` defaults to 90, and without `--v-fov` the vertical view is the
436
+ one that keeps the output's pixels square, 58.7 at 90 and 1920x1080; a pair that
437
+ is not square-pixel is drawn as given and said in `notes`). `--input-projection` must match the source's own
433
438
  projection (default `equirect`, the most common capture/export format) —
434
439
  this tool cannot detect it from the pixels, so a wrong value produces a
435
440
  distorted or garbled output, not an error. There is no subject detection or
@@ -502,7 +507,9 @@ a temporary transforms file (deleted after the run), pass 2 smooths and
502
507
  re-renders. `--shakiness` (default 5) trades analysis time for how much
503
508
  motion it looks for; `--smoothing` (default 15) is how many neighbouring
504
509
  frames the camera path is averaged over; `--zoom` crops in slightly to hide
505
- the black edges stabilizing can introduce. Needs an ffmpeg built with
510
+ the black edges stabilizing can introduce, and is the only zoom applied (0, the
511
+ default, keeps the original framing and lets the edges show: vidstab's own
512
+ "optimal zoom" is off). Needs an ffmpeg built with
506
513
  `--enable-libvidstab`; `doctor` reports this tool `usable: no` when that's
507
514
  missing (a plain Homebrew ffmpeg build, for example) rather than failing at
508
515
  run time.
@@ -530,7 +537,9 @@ Renders the input's audio as a video: `--style waveform` (default, FFmpeg's
530
537
  `showwaves`) draws amplitude over time; `--style spectrum` (`showspectrum`)
531
538
  draws a frequency-over-time heatmap instead, reading more out of dense
532
539
  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
540
+ carries the audio it visualizes. The default output of an audio input is
541
+ `<name>_waveform.mp4` (an audio extension cannot hold the picture), and `-o` with an audio
542
+ extension is refused. For an audio-only input (no video stream
534
543
  needed) or any file with an audio track worth visualizing.
535
544
 
536
545
  **Audiogram (1.16).** `--image PATH` puts a local still behind the
@@ -719,12 +728,14 @@ silence.py INPUT --filler --words transcript.json [--filler-lang auto|en|ja|ko|z
719
728
  [--transcribe] [--engine auto|parakeet-mlx|parakeet.cpp|whisper.cpp|faster-whisper|openai-whisper]
720
729
  [--model MODEL] [--filler-list] [--max-cuts 400]
721
730
  ```
722
- Runs `silencedetect`, keeps `--margin` seconds of air around speech, drops
731
+ Runs `silencedetect`, keeps `--margin` seconds of air around speech (none at the start or the
732
+ end of the sound, where there is no speech to keep it for), drops
723
733
  gaps shorter than `--min-silence`, and re-encodes the kept ranges the way `cut.py --segments
724
734
  --accurate` does: both streams cut on the same frame times, so the sound never drifts from the
725
735
  picture at the cuts, and a sound that starts late keeps its offset. `--list` prints silences, kept ranges and seconds removed
726
736
  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`
737
+ so the user can edit the list by hand (written once the cut is, as `--timemap` is). `--max-cuts N`
738
+ (400) refuses to render more than N removal ranges; `--list` reports them whatever their number. Quiet rooms need `--threshold -40`
728
739
  to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
729
740
 
730
741
  **Breaths and weak word endings.** `--margin` is the padding kept on each side of every cut
@@ -766,9 +777,10 @@ unchanged, and filler-word cuts (which sit in sound by design) are measured but
766
777
  source and output times, and every removed range with the output time it was cut at. `.csv`
767
778
  gives `type,source_start,source_end,output_start,output_end` rows (a removed row's two output
768
779
  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`,
780
+ made on the source, or to find a removed sentence again. Its times advance by the exact
781
+ lengths of the ranges the render cuts on (with a picture, each bound on its nearest frame time;
782
+ with sound alone, as asked), rounded only when written, so nothing adds up over many cuts; they
783
+ are computed, not measured on the written file. An existing map is refused without `--overwrite`,
772
784
  and a missing directory or a directory at the path as `kind: input`, before anything runs (a dry
773
785
  run predicts both); the map is written only after the cut is, so a failed or refused encode
774
786
  leaves none. A dry run writes nothing.
@@ -1201,7 +1213,8 @@ scenes.py INPUT [--shots] [--audio-peaks] [--speech] [--json]
1201
1213
  ```
1202
1214
  Lists scenes with audio energy, the loudest moments, and (with
1203
1215
  `--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
1216
+ the loudest window of each scene; each range lies inside its scene (it starts at the cut or on
1217
+ the 0.5 s grid after it, to the hundredth rounded inward). Review the sheet + JSON, adjust the EDL, then
1205
1218
  `cut.py --segments`. Cut detection is a one-frame spike test (benchmark on
1206
1219
  hard cuts between real single takes: precision 0.95, recall 1.00 at the default
1207
1220
  threshold; raise `--threshold` to 12 for 0.98 precision at 0.94 recall).
@@ -1619,8 +1632,14 @@ multicam.py REF CAM2 [CAM3 ...] [--switch "START-END:CAM,..."|energy [--min-shot
1619
1632
  ```
1620
1633
  All inputs are aligned to the first one by audio (same engine as `sync.py`,
1621
1634
  `--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
1635
+ each range of the reference timeline (gaps fall back to camera 0; ranges that overlap are
1636
+ refused, `kind: input`, since each moment has one camera), `--auto N`
1637
+ simply alternates every N seconds. Under `--fix-drift` a drifting camera's picture is retimed
1638
+ with its sound (`setpts=(PTS-STARTPTS)/ratio`), so a range lasts its reference length on both;
1639
+ `--write-project`'s clips name that camera's own in/out, with the ratio as the clip's `speed`
1640
+ (render.py plays it at that rate, so it lasts its reference length there too), and the project states
1641
+ `"transition": {"type": "none"}`: a switch is a cut, and render.py's default 0.5 s crossfade at every join
1642
+ would run the timeline 0.5 s a switch short and move each later switch. Audio comes from the reference unless
1624
1643
  `--audio` picks another input, e.g. an external recorder that has no video.
1625
1644
  `--offsets-only` reports offsets and confidence without rendering. `cuts` names
1626
1645
  the camera actually rendered: a range whose camera has not started yet plays
@@ -1657,14 +1676,17 @@ Each step runs with `--json`, and a failed one keeps its tool's own `kind`,
1657
1676
  completed one carries the tool's `verified`. Steps that do not apply are listed
1658
1677
  under the file's `skipped` (`quick`, `no_video`, `no_audio`, `not_hdr`,
1659
1678
  `not_multichannel`, `input_unreadable`) and as SKIP rows in the report.
1679
+ `--overwrite` and `--fast` given to verify.py reach every step, so a rerun into the same
1680
+ `--out` replaces the earlier outputs. The outputs are kept in `--out`, or with `--keep` alone
1681
+ in a new folder; either is the result's `out_dir` (`null` when they were deleted).
1660
1682
  Run this first when a user hands over footage from a device you have not
1661
1683
  seen before, and fix or report what fails.
1662
1684
 
1663
1685
  ### look.py — see the result
1664
1686
  ```
1665
- look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes
1687
+ look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes (-o basename -> basename_sheet.png)
1666
1688
  look.py INPUT --at 2.5 [--at 7] [-o basename] # single frames -> basename_2.500s.png
1667
- look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame
1689
+ look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame (-o basename -> basename_vs_AFTER_0_4.000s.png)
1668
1690
  look.py INPUT --safe tiktok [--at 3] # shade what the app's UI covers
1669
1691
  look.py INPUT --guides title,action,4:3 [--at 3] # broadcast safe-area outlines
1670
1692
  ```
@@ -2015,7 +2037,8 @@ sync.py REFERENCE SOURCE [SOURCE ...] [--json] [--max-offset 30] [--analyze-seco
2015
2037
  ```
2016
2038
  Cross-correlates loudness envelopes: coarse FFT search (20 ms), then a direct
2017
2039
  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
2040
+ = the source recording started later, so `--trim-second`/`--replace-audio` delay it by that
2041
+ much (a negative one drops its first -offset seconds). `--replace-audio` writes the reference
2019
2042
  video with the source file's audio aligned (video stream copied); the output
2020
2043
  keeps the reference's full length -- a shorter or head-trimmed source file is
2021
2044
  padded with silence, never allowed to cut the picture.
@@ -2031,8 +2054,11 @@ could not be measured is skipped and says why: `drift_skipped_reason` is
2031
2054
  `too_short` (no tail window far enough from the head) or `low_confidence`
2032
2055
  (top level for one source, and in each `sources` entry; null when applied).
2033
2056
  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):
2057
+ (0–1, normalised correlation with a runner-up penalty: the best other peak more than five
2058
+ steps from the winner); below 0.3 the match is doubtful. Since A126 the runner-up is never the
2059
+ winner's own neighbour, which held an exact match at 0.59, so a clean match can read near 1.0 and a
2060
+ tie between two offsets reads at most 0.5. The benchmark below was measured with the earlier runner-up and has
2061
+ not been re-run since; on real dialogue/music (±30 s offsets, gain, noise, EQ):
2036
2062
  with the default 120 s window 40/40 within 10 ms (max 1.1 ms); with a 60 s
2037
2063
  window 95 %, misses flagged below 0.3. Keep `--analyze-seconds` at least 4×
2038
2064
  `--max-offset` (default 120 s vs 30 s): lags with under 35 % overlap are
@@ -2169,10 +2195,13 @@ highpass, and `medium`/`strong` run their own denoise stage at that strength
2169
2195
  that is off is refused, as the `--duck-*` flags are: `--denoise-strength` without
2170
2196
  `--denoise`, `--music-volume`, `--music-fade-out` or `--music-loop` without `--music`,
2171
2197
  `--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),
2198
+ `--duck` lowers the music by `--duck-amount` dB (0..60, default 12) while the speech is above
2199
+ `--duck-threshold`, and leaves it at its level in the pauses: a sidechain gate keyed by the
2200
+ speech passes the bed under speech, and that part, scaled by `-(1 - 10^(-amount/20))`, is added
2201
+ back to the bed (`sidechaingate=threshold=0.05:ratio=9000:range=0:knee=1:attack=20:release=400`, then
2202
+ `volume=-0.748811` for 12 dB, by default). Through 3.8.8 it was a sidechain compressor whose
2203
+ ratio was `amount/3` (at least 2), so the depth followed the speech's level, not the flag
2204
+ (audit A134). `--duck-threshold DB` (default −26.02 dBFS, i.e. the 0.05 linear),
2176
2205
  `--duck-attack MS` (20) and `--duck-release MS` (400) move each one; a lower
2177
2206
  threshold ducks on quieter speech, a shorter release brings the bed back faster.
2178
2207
  `--json`'s `audio` block reports the settings the run actually used.
@@ -2200,7 +2229,19 @@ swresample. Video is always stream-copied, and so is a subtitle/data track
2200
2229
  when the container can hold it (`dropped_non_av_streams` says when it could not).
2201
2230
  `--limit` (and `--voice strong`'s limiter) is `alimiter` with `level=disabled:latency=1`: the
2202
2231
  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).
2232
+ `--limit-attack` (5 ms, 240 samples at 48 kHz) late against the picture (#476). With `--music`
2233
+ or `--effects` both limiters run on the mix, after the beds are added: the combined mix is
2234
+ limited before it is encoded (A129). A lossy output (`.m4a`, `.mp3`, `.opus`, AAC beside a
2235
+ picture) can then peak over the ceiling, since the encoder works after the limiter; use
2236
+ `loudness.py` for final levels, which measures the written file. Without a bed they run on the
2237
+ main track, where they always ran.
2238
+ The typed dynamics flags speak dB where the filter takes a linear value: a knee
2239
+ (`--comp-knee`, `--gate-knee`) is 0..18.06 dB, the filters' linear 1..8, default 9.03 dB
2240
+ (2.82843); the defaults `--help` gives are ffmpeg's own, in the flag's unit (A135).
2241
+ `--bitrate` is the AAC rate beside a picture (192k) and the rate of a lossy audio output's
2242
+ codec (`.m4a`/`.aac` 256k, `.mp3` `-q:a 0`, `.ogg` `-q:a 6`, `.opus` 128k when it is not given);
2243
+ `.wav` and `.flac` refuse it (A143). A `--music-fade-out` on a bed shorter than the output (and
2244
+ not `--music-loop`ed) ends where the bed ends (A142).
2204
2245
  With `--replace`, `--music` or a kept picture the sound is padded with silence or trimmed to the
2205
2246
  source's length counted in samples at the track's rate (`apad=whole_len=N,atrim=end_sample=N`),
2206
2247
  so it is exact to the sample, not to the millisecond (#478). Sound that starts after the picture
@@ -2249,8 +2290,11 @@ video containers or the codec matching the extension (.wav → PCM, .flac, .mp3)
2249
2290
  The written file is measured again: a lossy encoder can push peaks past the
2250
2291
  ceiling loudnorm held (ffmpeg's AAC at 192k turned one transient from -2.4 to
2251
2292
  +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
2293
+ did not pass `--audio-bitrate` (an `.m4a`/`.aac` output starts at its 256k, so only 320k is
2294
+ left), then with the loudnorm ceiling lowered by the overshoot -- until the file itself meets
2295
+ `--tp`. A pinned `--audio-bitrate` reaches an audio output's codec as well as a video
2296
+ container's AAC (through 3.8.8 an `.m4a` was 256k whatever was asked, and its retries too);
2297
+ `.wav`/`.flac` refuse it. `result` reports
2254
2298
  `tp_ceiling_used`, `audio_bitrate_used`, `encodes`, and a `note` when the
2255
2299
  integrated loudness ended more than 1 LU from the target because of it.
2256
2300
 
@@ -2336,7 +2380,8 @@ neither.
2336
2380
  proxy.py INPUT [--width W | --scale F] [--quality N] [--fps N] [--no-audio] [-o OUT]
2337
2381
  ```
2338
2382
  Not a delivery preset: resizes to `--width` (default 640) or by `--scale`
2339
- factor, re-encodes at a proxy-grade `--quality` (default 30) with the fastest
2383
+ factor, never wider than the source and never under 2 px (a `notes` line says
2384
+ when either caps the width asked), re-encodes at a proxy-grade `--quality` (default 30) with the fastest
2340
2385
  x264/x265 preset, keeps the source's own dynamic range (HDR stays HDR;
2341
2386
  run `color.py --to-sdr` first if SDR is wanted) and keeps a subtitle/data
2342
2387
  track when the container can hold it (`dropped_non_av_streams`). Only executes the spec
@@ -1098,7 +1098,7 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
1098
1098
  _shutil = SafeShutil(_shutil) # look engines up and launch them by full path, never from the working directory
1099
1099
  import subprocess as _subprocess
1100
1100
  import tempfile as _tempfile
1101
- from _common import require_tool, run_analysis, STATE
1101
+ from _common import require_tool, STATE
1102
1102
  ffmpeg = require_tool("ffmpeg")
1103
1103
  wanted = requested_engine(engine)
1104
1104
  language = engine_language(language)
@@ -395,8 +395,13 @@ def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float = 1000.0, desat: floa
395
395
  prim = v.get("color_primaries") or "bt2020"
396
396
  space = v.get("color_space") or "bt2020nc"
397
397
  source_peak = HLG_PEAK_NITS if trc == "arib-std-b67" else peak
398
- # zscale needs explicit input tags when the file lacks them
399
- chain: List[str] = [
398
+ # zscale needs explicit input tags when the file lacks them, and on 6.1 its `pin` does not
399
+ # reach a frame whose own primaries are unspecified ("no path between colorspaces", kind
400
+ # ffmpeg: --to-sdr --force on an untagged file failed through 3.8.8), so a frame missing a tag
401
+ # is labelled with what the chain reads it as first
402
+ missing = not (v.get("color_transfer") and v.get("color_primaries") and v.get("color_space"))
403
+ label = [f"setparams=color_primaries={prim}:color_trc={trc}:colorspace={space}"] if missing else []
404
+ chain: List[str] = label + [
400
405
  f"zscale=tin={trc}:pin={prim}:min={space}:rin={v.get('color_range') or 'tv'}:t=linear"
401
406
  f":npl={TONEMAP_REFERENCE_NITS:g}",
402
407
  "format=gbrpf32le",
@@ -695,6 +695,16 @@ def _vt_bt709(codec: str) -> List[str]:
695
695
  return ["-bsf:v", f"{codec}_metadata=colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1"]
696
696
 
697
697
 
698
+ def source_transfer(v: Dict[str, Any]) -> str:
699
+ """The transfer a 10-bit re-encode of a BT.2020 / HDR source is tagged with: the source's own
700
+ tag, or, when it has none, what probe() read it as. BT.2020 primaries with no transfer tag are
701
+ `hdr_format` "BT.2020 SDR" (not an HDR signal), so they are tagged bt2020-10, the SDR curve
702
+ BT.2020 defines; through 3.8.8 every such re-encode came out tagged HLG (audit A153). An
703
+ HDR signal with no transfer tag (Dolby Vision whose base layer is untagged) keeps the HLG
704
+ default it always had."""
705
+ return v.get("color_transfer") or ("arib-std-b67" if v.get("hdr_signal") else "bt2020-10")
706
+
707
+
698
708
  def _vt_args(codec: str, crf: int, meta: Optional[Dict[str, Any]], keep_bt709: bool) -> Optional[List[str]]:
699
709
  """VideoToolbox encoder options for `codec`, or None (with a note in STATE.hw_notes) when the
700
710
  request stays on the CPU: no VideoToolbox encoder for the codec, this machine cannot, or the
@@ -716,7 +726,7 @@ def _vt_args(codec: str, crf: int, meta: Optional[Dict[str, Any]], keep_bt709: b
716
726
  v = (meta or {}).get("video") or {}
717
727
  hdr = bool(v.get("bt2020_or_hdr"))
718
728
  hdr_tags = ["-colorspace", v.get("color_space") or "bt2020nc", "-color_primaries", v.get("color_primaries") or "bt2020",
719
- "-color_trc", v.get("color_transfer") or "arib-std-b67"]
729
+ "-color_trc", source_transfer(v)]
720
730
  if codec == "prores":
721
731
  return ["-c:v", name, "-profile:v", "hq", "-pix_fmt", "p210le"] + (hdr_tags if hdr else [])
722
732
  # The HDR curve was fitted on the Main10 HEVC line; an 8-bit H.264 line (an export preset on
@@ -847,7 +857,7 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
847
857
  hdr = bool(v.get("bt2020_or_hdr"))
848
858
  cs = v.get("color_space") or "bt2020nc"
849
859
  prim = v.get("color_primaries") or "bt2020"
850
- trc = v.get("color_transfer") or "arib-std-b67"
860
+ trc = source_transfer(v)
851
861
  hdr_tags = ["-colorspace", cs, "-color_primaries", prim, "-color_trc", trc]
852
862
  if codec == "h264":
853
863
  if hdr:
@@ -1152,7 +1162,7 @@ def source_codec_video_args(meta: Optional[Dict[str, Any]], crf: int = 18, prese
1152
1162
  def _x265_hdr_default(v: Dict[str, Any], crf: int, preset: str) -> List[str]:
1153
1163
  cs = v.get("color_space") or "bt2020nc"
1154
1164
  prim = v.get("color_primaries") or "bt2020"
1155
- trc = v.get("color_transfer") or "arib-std-b67"
1165
+ trc = source_transfer(v)
1156
1166
  x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1{hdr10_x265_params(v)}" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
1157
1167
  return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
1158
1168
  "-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
@@ -1173,10 +1183,27 @@ AUDIO_CODECS = {
1173
1183
  }
1174
1184
 
1175
1185
 
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."""
1186
+ LOSSLESS_AUDIO_EXT = {".wav", ".flac"} # the AUDIO_CODECS whose codec takes no bitrate
1187
+
1188
+
1189
+ def audio_codec_for(output_path: str, bitrate: Optional[str] = None) -> List[str]:
1190
+ """Pick an audio codec that the output container can actually hold.
1191
+
1192
+ `bitrate` is one the caller asked for: it replaces the rate or quality an audio extension
1193
+ names (`-q:a 0` for .mp3, 256k for .m4a), which through 3.8.8 it never reached (A143), and is
1194
+ the AAC rate of any other container (192k when it is None). A lossless extension takes none;
1195
+ the caller refuses a bitrate for it."""
1178
1196
  ext = os.path.splitext(output_path)[1].lower()
1179
- return list(AUDIO_CODECS.get(ext, ["-c:a", "aac", "-b:a", default_bitrate]))
1197
+ if ext not in AUDIO_CODECS:
1198
+ return ["-c:a", "aac", "-b:a", bitrate or "192k"]
1199
+ args = list(AUDIO_CODECS[ext])
1200
+ if bitrate is None or ext in LOSSLESS_AUDIO_EXT:
1201
+ return args
1202
+ for flag in ("-b:a", "-q:a"):
1203
+ if flag in args:
1204
+ i = args.index(flag)
1205
+ del args[i:i + 2]
1206
+ return args + ["-b:a", bitrate]
1180
1207
 
1181
1208
 
1182
1209
  def is_audio_output(output_path: str) -> bool:
@@ -1590,7 +1617,7 @@ def _refine_grid(onset_times: "Sequence[float]", strengths: "Sequence[float]", i
1590
1617
  it), and onset_time = phase + k * interval is solved for both unknowns with each pair weighted
1591
1618
  by its onset strength squared; the pairing is then redone against the refitted grid, until it
1592
1619
  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
1620
+ 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
1621
  more than interval/BEAT_ALIGN_DIVISOR is discarded), and fewer than four paired grid points
1595
1622
  keep the original: the fit needs evidence, and an onset-free stretch gives it none.
1596
1623
  Returns phase in [0, interval).
@@ -1613,8 +1640,13 @@ def _refine_grid(onset_times: "Sequence[float]", strengths: "Sequence[float]", i
1613
1640
  if sxx <= 0:
1614
1641
  break
1615
1642
  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:
1643
+ if abs(new_iv - interval) > interval / BEAT_ALIGN_DIVISOR:
1617
1644
  break
1645
+ # A146: a fit just outside `bpm_range` is held on its bound, not discarded. A tempo that
1646
+ # sits on the bound (90 BPM under --beat-range 60-90) fits a hair beyond it as often as
1647
+ # inside, and discarding the fit kept the step-rounded interval (89.76 BPM), whose grid
1648
+ # was 80 ms off a 30 s click track by its end
1649
+ new_iv = min(max(new_iv, 60.0 / hi_bpm), 60.0 / lo_bpm)
1618
1650
  new_ph = mt - new_iv * mk
1619
1651
  moved = abs(new_iv - iv) < 1e-9 and abs(new_ph - ph) < 1e-7
1620
1652
  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: