ffmpeg-skill 2.20.0 → 2.21.1

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
@@ -412,6 +412,33 @@ The short list for humans. The agent-facing version, with the reasoning, is the
412
412
 
413
413
  Each operation is a script whose flags are its argparse parser, and that parser is what `contract --json` and the MCP `inputSchema` are built from. CI fails the build when those drift from the scripts. A caller-supplied filter graph, `-vf` string, or other ffmpeg option is not an argument any script accepts. `doctor` reads `ffmpeg -filters` (and `-encoders`, `-bsfs`) and reports a filter a tool needs before a job runs into it. MCP does accept an `argv` array, and that array is the named script's own CLI flags, still bound to that script: the script's flags, never an ffmpeg command line and never a shell.
414
414
 
415
+ ### Why 42 tools? Isn't that overkill?
416
+
417
+ One tool per job, because each job promises something different, and the tool measures that promise after the run. `loudness.py` re-measures integrated loudness and true peak. `cut.py` reports how far a cut snapped to a keyframe. `silence.py` returns the ranges it kept. `export.py` checks the preset's maximum length. A value a tool cannot read fails that step as `not_measured`; it does not pass. One tool with a mode flag would need as large a schema, and its checks would be vaguer.
418
+
419
+ An agent does not read 42 schemas. Over MCP, `tools/list` advertises the core 12 (bold below). The other 30 are called by name through `tools/call` when a job needs one, and `FFMPEG_SKILL_MCP_FULL=1` lists all 42. The groups are the ones in [Tools](#tools).
420
+
421
+ <!-- why42:start -->
422
+ | Group | MCP default / all | Tools |
423
+ |---|---|---|
424
+ | Analysis and inspection | 2 / 3 | **probe**, **look**, scenes |
425
+ | Editing | 3 / 24 | **cut**, **fit**, **silence**, join, crop, cropdetect, deinterlace, denoise, redact, sphere, straighten, insert, background, reverse, stabilize, sequence, waveform, freeze, pad, speedramp, loop, broll, metadata, grid |
426
+ | Audio | 2 / 3 | **audio**, **loudness**, sync |
427
+ | Picture | 2 / 4 | **caption**, **graphics**, overlay, color |
428
+ | Delivery | 2 / 4 | **export**, **check**, proxy, report |
429
+ | Orchestration | 1 / 4 | **render**, batch, multicam, verify |
430
+ | **Total** | **12 / 42** | |
431
+ <!-- why42:end -->
432
+
433
+ Without the other 30, an agent writes raw ffmpeg for those jobs, and a mistake there often still exits 0:
434
+
435
+ - `stabilize.py` runs vidstab in two passes, and the second pass needs the first pass's transforms file.
436
+ - `color.py` builds the HDR-to-SDR chain. With a wrong colour tag the run still finishes, and the picture comes out washed out.
437
+ - `join.py` puts transitions between clips that differ in size, frame rate or audio layout. Done by hand, those clips have to be normalised first or xfade fails.
438
+ - `sync.py` and `multicam.py` find the offset between two recordings by audio cross-correlation. No ffmpeg filter measures it.
439
+
440
+ Every command a tool ran is in its `--json` `commands`, shell-quoted so it can be pasted and run again, and `--dry-run` prints the commands without running anything. In the 100-prompt agent eval (1.17.0, [Tested on real footage](#tested-on-real-footage)) the agent made no raw ffmpeg calls and reported no false successes.
441
+
415
442
  ### How does ffmpeg-skill compare with Remotion?
416
443
 
417
444
  ffmpeg-skill edits footage you already have, on this machine, with FFmpeg tools whose flags are a machine-readable contract (`contract --json`). Remotion renders a video from React components. They complement each other: this repository does not call Remotion, and a file Remotion writes is an ordinary input (`probe.py`, `cut.py`, `fit.py`, `export.py`, `check.py`). [`remotion-ffmpeg-skill`](https://github.com/kajisho5/remotion-ffmpeg-skill) joins the two. It measures the footage with this repo, draws the graphics in Remotion from those measurements, and checks and delivers the result with this repo again. Install both (`npx ffmpeg-skill`, then `npx remotion-ffmpeg-skill`). As with the other skills, the dependency runs one way: it calls these tools, and nothing here calls it. HyperFrames renders a video from HTML and is the same kind of neighbour: a file it writes is an ordinary input too.
@@ -614,6 +641,7 @@ Contributing a change: see [CONTRIBUTING.md](CONTRIBUTING.md).
614
641
  | [SECURITY.md](SECURITY.md) | how to report a vulnerability privately |
615
642
  | [SKILL.md](SKILL.md) | what the agent reads: workflow, request → tool map, audio-only rules, report format, pitfalls |
616
643
  | [references/scripts.md](references/scripts.md) | per-flag reference for every tool |
644
+ | [references/scripts.md § Verdict thresholds](references/scripts.md#verdict-thresholds-what-passes) | every number behind `verified` and `check.py`'s rows (tolerances, silence levels, per-platform loudness and length), each with the constant or flag it comes from |
617
645
  | [references/devices.md](references/devices.md) | real-device notes (iPhone HDR, GoPro, DJI, screen recordings) |
618
646
  | [references/ci-platform-pitfalls.md](references/ci-platform-pitfalls.md) | per-OS ffmpeg/CI behaviour differences already diagnosed once — read before re-diagnosing a Windows/macOS-only test failure |
619
647
  | [references/process-pitfalls.md](references/process-pitfalls.md) | process mistakes already made once (breaking a pinned test by narrowing a capability list, retrying a git/GitHub operation this environment can't do, re-designing a fixture instead of recognising a real platform difference) — a living record, add to it whenever one recurs |
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`2.20.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.21.1`) | 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`
@@ -131,7 +131,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
131
131
  "deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
132
132
  {"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"}],
133
133
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
134
- "skill": {"id": "ffmpeg-skill", "version": "2.20.0", "execution_mode": "local", "kind": "execution",
134
+ "skill": {"id": "ffmpeg-skill", "version": "2.21.1", "execution_mode": "local", "kind": "execution",
135
135
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
136
136
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
137
137
  "requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
@@ -679,7 +679,7 @@ Per-tool keys added after 2.2.2, all additive:
679
679
  | `encoder`, `hw`, `notes` | every re-encoding tool, `export.py`, `render.py`, `batch.py` (and its rows), `waveform.py` | `encoder`: the video encoder the last command ran (after any GPU→CPU fallback; under `--dry-run`, the planned one), `copy` for a stream copy; absent when no command encoded video. A tool that runs other tools reports its stages': `render.py` its stages and an executed plan's tool, `waveform.py` the title/caption stage that writes its file (its `commands` include that stage's). `hw` is present when VideoToolbox was asked for (`--hw`, `FFMPEG_SKILL_HW=1`, or a stage or `batch.py` item that reported it), on `export.py` when `FFMPEG_SKILL_HW=1` is set but its delivery preset does not take it, and when an encode ran on VideoToolbox because the build has no `libx264`/`libx265`: `{requested, source: flag\|env\|auto, used, fallback, notes}`. `requested` says whether this run's encodes were asked onto VideoToolbox (`false` for that export, and for `source: "auto"`, where nobody asked: `notes` names the missing CPU encoder and the one that ran, and `--no-hw` is not offered as a remedy since there is no CPU encoder to go back to; a job VideoToolbox refuses there is a plain `kind: ffmpeg` failure, not retried), `used` whether the encoder that ran last is VideoToolbox (`null` under `--dry-run`, which ran nothing, and when the tool encoded nothing itself: `batch.py`, whose top-level `hw` gathers its items' `requested`/`source`/`fallback`/`notes` while each `results` row carries the item's own `encoder`, and `hw`/`notes` when VideoToolbox was asked for), `fallback` whether a job VideoToolbox refused was re-encoded on the CPU, and `notes` why. Only a VideoToolbox failure (the encoder could not open, or its compression session failed) is retried on the CPU; any other failure is reported as it happened, once. Every VideoToolbox encode adds a top-level `notes` line: its quality is not CRF-equivalent (`--quality` / a preset's CRF maps to `-q:v` approximately), and `verified` stays what it is for every tool, the measured properties of the output. A `kind: ffmpeg` failure under `--hw` / `FFMPEG_SKILL_HW` carries `encoder` and `hw` for the command that ran last (a stage's, when `render.py`/`waveform.py` re-raise it); when the command itself failed inside the run (not a tool's own check afterwards, such as `cut.py`'s), its message also names that encoder |
680
680
  | `transcription` | `caption.py --transcribe` (every `--mode`); inside `filler` for `silence.py --filler --transcribe` | `{routing, detected_language?, engine, model, language}`: the engine and model that made the transcript; `language` is the language the engine was told -- the one named (`--language`, `--filler-lang`), else the one auto's detector named -- and, when nothing was named, the language the engine decoded where it reports one (whisper.cpp's word-timing JSON, faster-whisper, openai-whisper's word-timing JSON), else `null`; `detected_language` is present when auto's whisper.cpp detector named one; `routing` says why `--engine auto` chose the engine (`"requested"` for a named engine), ending `, no Parakeet engine transcribed it` when auto chose Parakeet and a Whisper engine made the transcript after every Parakeet engine failed |
681
681
  | `notes` | `silence.py --filler --transcribe` (new for `silence.py`; `caption.py` already had it) | present when `--engine auto` ran the English-only Parakeet model on English *assumed* (no language given or detectable, and no Whisper engine installed or none that succeeded): the transcript is wrong if the speech is not English. `caption.py --transcribe` puts the same line in its `notes` |
682
- | `reencode_reason`, `segment_precision`, `least_exact_precision`, `start_snapped` | `cut.py` (every run) | `reencode_reason`: why anything was re-encoded, distinct values in first-seen order, `[]` for a clean copy: `requested` (`--accurate`), `codec` (`--codec`), `vfr`, `vfr_inconclusive`, `pcm_container`, `copy_failed`, `tolerance`, `concat_fallback` (a `--segments` join that could not stay a stream copy, which now also sets `reencoded: true` and `mode: hybrid`; it reported `copy` before). `segment_precision`: each `--segments` segment's precision (`null` for one segment); `least_exact_precision`: the least exact of them, on every run. `precision` and `keyframe_snapped` keep their meaning (`keyframe_snapped` is `precision == "packet"`, a stream copy end to end); `least_exact_precision` differs from `precision` only for an audio-only join of copied and re-encoded parts. `start_snapped`: measured, `true` when a copied segment's picture starts more than one frame from its requested start (or its start could not be measured: an unknown start is never claimed exact), `false` when every start is where asked or everything re-encoded, `null` under `--dry-run`. `requested_segments`/`requested_duration` keep their meaning, the request clamped to the media's duration: a segment ended with the video (less than a frame of sound trimmed) shows in `duration_delta_seconds` and a `notes` line |
682
+ | `reencode_reason`, `segment_precision`, `least_exact_precision`, `start_snapped` | `cut.py` (every run) | `reencode_reason`: why anything was re-encoded, distinct values in first-seen order, `[]` for a clean copy: `requested` (`--accurate`), `codec` (`--codec`), `vfr`, `vfr_inconclusive`, `pcm_container`, `copy_failed`, `stream_lost` (the copy wrote no video or no audio: an MPEG-TS copy whose window holds no video keyframe, #367), `tolerance`, `concat_fallback` (a `--segments` join that could not stay a stream copy, which now also sets `reencoded: true` and `mode: hybrid`; it reported `copy` before). `segment_precision`: each `--segments` segment's precision (`null` for one segment); `least_exact_precision`: the least exact of them, on every run. `precision` and `keyframe_snapped` keep their meaning (`keyframe_snapped` is `precision == "packet"`, a stream copy end to end); `least_exact_precision` differs from `precision` only for an audio-only join of copied and re-encoded parts. `start_snapped`: measured, `true` when a copied segment's picture starts more than one frame from its requested start (or its start could not be measured: an unknown start is never claimed exact), `false` when every start is where asked or everything re-encoded, `null` under `--dry-run`. `requested_segments`/`requested_duration` keep their meaning, the request clamped to the media's duration: a segment ended with the video (less than a frame of sound trimmed) shows in `duration_delta_seconds` and a `notes` line |
683
683
  | `edit_list`, `stored_preroll_seconds`, `av_start_skew_seconds`, `notes` | `cut.py` (every run) | `edit_list`: `true` only for a single-segment `.mp4`/`.m4v`/`.mov` stream copy run with `--edit-list`, which keeps the MP4 edit list the copy writes instead of `-avoid_negative_ts make_zero` (the default, unchanged): the keyframe's pre-roll is stored but hidden, so the picture starts at `--start`. `stored_preroll_seconds`: with `edit_list`, the seconds decoded from the keyframe the copy's seek lands on (measured with the seek ffmpeg makes) up to the first presented frame; `null` otherwise. Under `--edit-list` a copy is judged by its video's length (a `notes` line says so when the container is longer), and one that starts where asked but ends past `--tolerance` re-encodes (`tolerance`) without a `lossless_alternative`. `av_start_skew_seconds`: the output's audio start minus its video start (`null` when either is missing or under `--dry-run`); on a stream copy, when it is more than max(2 frames, 0.1 s) away from the source's own audio/video offset where the copy's picture starts (the keyframe it began from; `--start` under `--edit-list`), a `notes` line names `--accurate`, and `--edit-list` for a single `.mp4`/`.mov` copy whose start snapped. A re-encode carries no such note. The skew is reported, never repaired. `notes`: what the run did that a caller should know, in plain sentences (`[]` when nothing) |
684
684
  | `video.start_time`, `audio.start_time` | `probe.py` (and every `probe` block a tool returns) | where each stream starts, in seconds on the file's own clock (ffprobe's stream `start_time`; `null` when unknown). A video stream that starts after its audio, or the reverse, is how a lossless cut's A/V skew shows; `cut.py` reads them for `av_start_skew_seconds` |
685
685
  | `cut_reencode_reasons` | `batch.py` (and its `results` rows) | a row whose recipe ran `cut.py` lists each `reencode_reason` its `cut.py` calls named, once per call, beside `cut_reencoded`; the top level counts them across the folder (`{}` when no call re-encoded, `null` when no `cut.py` step ran) on a completed batch, as `cut_stream_copy` does: a failure document carries `results`, whose rows hold each row's reasons; a re-encoded call in a row served from a cache an earlier 2.x batch wrote, which recorded no reasons, counts as `unknown`. `cut_stream_copy` is unchanged |
@@ -709,6 +709,18 @@ silence a tool reported as fine while another measurement disagreed):
709
709
  | `inputs_duration`, `transition_shortening`; step `duration`'s `inputs_total` | `join.py` | the clips' own total and how much the transitions took out of it. `expected_duration` is derived from the transition the join applied, so it cannot show that on its own |
710
710
  | `silence_kinds`, `silence_breakdown` | `silence.py` | per detected silence, `digital` (at or below -90 dBFS) or `below_threshold` (quiet sound under `--threshold`), and the count and seconds of each with the level that defines it |
711
711
 
712
+ Keys added after 2.21, all additive (#367 follow-up, and a report of a filename with a space that
713
+ lost 40 s of sound with no error):
714
+
715
+ | key | tool | what it holds |
716
+ |---|---|---|
717
+ | step `streams`'s `expected_count`, `measured_count` | `cut.py`, `join.py`, `export.py` | how many video and audio streams the output should have and has. These writers map one of each, so a count below the expected one is `missing`; `expected`/`measured` keep their booleans |
718
+ | `verification[]` step `stream_lengths` (`property: stream_end`) | `cut.py`, `join.py`, `export.py`, when the output keeps both picture and sound | where each stream ends (`ends`: start plus its own length, the DURATION tag in Matroska/WebM) and how far before the later one (`shortfall`). The inputs' own gap is allowed (`input_shortfall`, summed over join's clips; for cut.py only the gap inside the requested ranges) plus `tolerance` (`_common.emit.STREAM_LENGTH_TOLERANCE`, 0.25 s); a stream that ends earlier is named in `short` and fails the step. Ends, not lengths: a stream that only starts late is cut's start-skew note. An output length that cannot be read fails with `reason: not_measured`, and so does an output gap when an input's own stream length could not be read (`input_shortfall: null`: the gap may be the input's) |
719
+ | `verification[]` step `decode` (`property: decodable`) | `cut.py`, `join.py`, `export.py` | `frames`: per kept stream, how many frames ffmpeg decoded from the first `window` seconds (`_common.emit.DECODE_WINDOW_SECONDS`, 1 s) and from its last `window` before the stream's own end (the read starts `DECODE_SEEK_MARGIN_SECONDS`, 10 s, earlier so a seek past the last keyframe still decodes, but only frames still on screen in the window count; a tail with no frame is read once more from up to `DECODE_LONG_GOP_SECONDS`, 60 s, earlier, for a GOP longer than the margin). A stream with no frame at either end is named in `undecodable` and fails the step; one with no end time of its own (the container's is the longest stream's) has its tail `not_measured`, which fails the step without calling it undecodable. `join.py` runs it on an audio-only join too |
720
+ | `probe.start_time` | `probe.py` and every output probe | where the file's timeline starts (the container's start time; MPEG-TS starts at about 1.4 s). A `-ss`, a cut's `--start` and the decode check's reads count from here |
721
+ | `probe.stream_counts` | `probe.py` and every output probe | `{video, audio}`: how many picture streams (a cover picture is not one) and sound streams the file carries |
722
+ | `probe.video.tagged_duration` | `probe.py` and every output probe | the video stream's DURATION tag in seconds (Matroska/WebM give no stream duration), as `audio.tagged_duration` since 2.20 |
723
+
712
724
  Per-tool keys added after 2.10.11 ("nothing happens silently"), all additive:
713
725
 
714
726
  | key | tool | what it holds |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.20.0",
3
+ "version": "2.21.1",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -14,7 +14,69 @@ yourself, which is the only way for a tool with no input file (`caption.py
14
14
  anywhere is `kind: input` naming the flag. Nothing else about times differs
15
15
  between tools.
16
16
 
17
+ ## Verdict thresholds (what passes)
18
+
19
+ The numbers behind `verified`, `check.py`'s PASS/WARN/FAIL rows and the cuts the tools make. A
20
+ result also carries the tolerance it used in each `verification[]` step (`tolerance`, since 2.18).
21
+ Every row names the constant or flag it comes from, and a test holds the table to it
22
+ (`test_the_thresholds_table_matches_the_code`).
23
+
24
+ <!-- thresholds:start -->
25
+ | what | value | source | used by |
26
+ |---|---|---|---|
27
+ | true peak may exceed the dBTP ceiling by | 0.1 dB | `_platforms.TP_TOLERANCE_DB` | `loudness.py`, `check.py`, `export.py` |
28
+ | a normalised file is on its `-I` target within | 1.0 LU | `loudness.LUFS_TOLERANCE` | `loudness.py` |
29
+ | a levels pass keeps the sound's length to (or one AAC frame, 1024 samples, if longer) | 0.03 s | `loudness.AUDIO_LENGTH_TOLERANCE` | `loudness.py` |
30
+ | a joined picture is as long as the clips less the transitions, to (frames) | 1.5 | `join.DURATION_TOLERANCE_FRAMES` | `join.py` |
31
+ | ... and at least | 0.05 s | `join.DURATION_TOLERANCE_MIN` | `join.py` |
32
+ | the picture and the sound end together, beyond the gap the inputs had, to | 0.25 s | `_common.emit.STREAM_LENGTH_TOLERANCE` | `cut.py`, `join.py`, `export.py` (`stream_lengths`) |
33
+ | every kept stream decodes at least one frame in its first and its last | 1.0 s | `_common.emit.DECODE_WINDOW_SECONDS` | `cut.py`, `join.py`, `export.py` (`decode`) |
34
+ | a lossless cut may land this far from the asked time before it re-encodes | 0.5 s | `cut.py --tolerance` | `cut.py` |
35
+ | silence is sound at or below | -35 dBFS | `silence.py --threshold` | `silence.py` |
36
+ | ... lasting at least | 0.6 s | `silence.py --min-silence` | `silence.py` |
37
+ | a removed silence is digital silence when 99% of it sits at or below | -90 dBFS | `silence.DIGITAL_SILENCE_DB` | `silence.py` (`silence_kinds`) |
38
+ | a track is silent when its peak is at or below | -50 dBFS | `_common.probe.SILENT_PEAK_DBFS` | `check.py`; the `--silence-threshold` default of `join.py`, `audio.py`, `waveform.py` |
39
+ | a filler word longer than this is a held vowel, kept | 1.2 s | `_common.decision.FILLER_MAX_WORD` | `silence.py --filler` |
40
+ | a pause this long between two tokens of an unspaced script is a word boundary | 0.1 s | `_common.decision.FILLER_BOUNDARY_GAP` | `silence.py --filler` |
41
+ | `--content` black: WARN above this share of the duration | 0.10 | `check.BLACK_WARN` | `check.py --content` |
42
+ | `--content` black: FAIL at this share | 0.95 | `check.BLACK_FAIL` | `check.py --content` |
43
+ | `--content` black: a pixel is black below this share of the luma range | 0.10 | `_common.probe.CONTENT_BLACK_PIX_TH` | `check.py --content` |
44
+ | `--content` black: a black run shorter than this is not counted | 0.1 s | `_common.probe.CONTENT_MIN_SPAN` | `check.py --content` |
45
+ | `--content` frozen: WARN when the longest span is above this, or above 30% | 3.0 s | `check.FROZEN_WARN_S` | `check.py --content` |
46
+ | `--content` frozen: FAIL when one span covers this share | 0.95 | `check.FROZEN_FAIL` | `check.py --content` |
47
+ | `--content` frozen: a picture unchanged for at least this long is frozen | 0.5 s | `_common.probe.CONTENT_FREEZE_MIN` | `check.py --content` |
48
+ | `--content` frozen: frames this close count as unchanged (freezedetect noise) | -60 dB | `_common.probe.CONTENT_FREEZE_NOISE` | `check.py --content` |
49
+ | `--content` silence: WARN above this share below the silent level | 0.50 | `check.SILENCE_WARN` | `check.py --content` |
50
+ | `--content` silence: FAIL at this share | 0.95 | `check.SILENCE_FAIL` | `check.py --content` |
51
+ | `--content` silence: a silent run shorter than this is not counted | 0.5 s | `_common.probe.CONTENT_SILENCE_MIN` | `check.py --content` |
52
+ | `--content` dropout: a gap of sound at or below | -80 dBFS | `_common.probe.CONTENT_DROPOUT_DB` | `check.py --content` |
53
+ | ... between (s) and 1 s long, with sound on both sides | 0.02 s | `_common.probe.CONTENT_DROPOUT_MIN` | `check.py --content` |
54
+ | a beat grid below this confidence is not snapped to | 0.5 | `_common.decision.BEAT_MIN_CONFIDENCE` | `cut.py --snap beats`, `scenes.py --beats` |
55
+ <!-- thresholds:end -->
56
+
57
+ Delivery targets per platform (`scripts/_platforms.py`). A file passes loudness within the
58
+ platform's tolerance of its target, and true peak at or below its ceiling plus 0.1 dB. `export.py`
59
+ holds the written length to the maximum with no tolerance, as `check.py` does.
60
+
61
+ <!-- platforms:start -->
62
+ | platform | loudness | tolerance | true peak | max duration |
63
+ |---|---|---|---|---|
64
+ | `tiktok` | -14 LUFS | ±2 LU | -1 dBTP | 600 s |
65
+ | `reels` | -14 LUFS | ±2 LU | -1 dBTP | 90 s |
66
+ | `shorts` | -14 LUFS | ±2 LU | -1 dBTP | 180 s |
67
+ | `youtube` | -14 LUFS | ±2 LU | -1 dBTP | 43200 s |
68
+ | `youtube-hdr` | -14 LUFS | ±2 LU | -1 dBTP | 43200 s |
69
+ | `youtube-av1` | -14 LUFS | ±2 LU | -1 dBTP | 43200 s |
70
+ | `x` | -14 LUFS | ±3 LU | -1 dBTP | 140 s |
71
+ | `linkedin` | -14 LUFS | ±3 LU | -1 dBTP | 600 s |
72
+ | `facebook` | -14 LUFS | ±3 LU | -1 dBTP | 14400 s |
73
+ | `podcast` | -16 LUFS | ±1 LU | -1 dBTP | — |
74
+ | `broadcast` | -23 LUFS | ±1 LU | -1 dBTP | — |
75
+ | `custom` | — | ±2 LU | -1 dBTP | — |
76
+ <!-- platforms:end -->
77
+
17
78
  ## Contents
79
+ - verdict thresholds — what passes, and where each number lives
18
80
  - probe.py — inspect
19
81
  - cut.py — cut / join segments
20
82
  - fit.py — target duration and/or aspect, rotate/flip
@@ -105,7 +167,7 @@ in the length. `keyframe_snapped` is true for every stream copy (`precision`
105
167
  than a frame from `--start`, or when its start could not be measured (an unknown start is
106
168
  never claimed exact); `null` under `--dry-run`.
107
169
  `reencode_reason` lists every cause of a re-encode (`requested`, `codec`, `vfr`,
108
- `vfr_inconclusive`, `pcm_container`, `copy_failed`, `tolerance`, `concat_fallback`);
170
+ `vfr_inconclusive`, `pcm_container`, `copy_failed`, `stream_lost`, `tolerance`, `concat_fallback`);
109
171
  `--segments` adds `segment_precision`, and `least_exact_precision` is the least exact one.
110
172
  A `--segments` video copy join adds `join_check` (`packets`, `expected_packets`,
111
173
  `max_step_seconds`, `audio_offset_ms` per part, `audio_parts_checked`, `ok`: the
@@ -396,21 +396,190 @@ UNVERIFIED: Dict[str, List[str]] = {
396
396
  }
397
397
 
398
398
 
399
- def stream_inventory_step(meta: Dict[str, Any], expect: Dict[str, bool]) -> Dict[str, Any]:
399
+ # a picture and its sound may differ in length by this much more than they did in the input
400
+ STREAM_LENGTH_TOLERANCE = 0.25
401
+ # how many seconds at each end of every kept stream the decode check decodes
402
+ DECODE_WINDOW_SECONDS = 1.0
403
+ # how much earlier than its window the tail decode starts reading (a seek past the last keyframe)
404
+ DECODE_SEEK_MARGIN_SECONDS = 10.0
405
+ # how far back the one retry of an empty tail reads (a GOP longer than the margin)
406
+ DECODE_LONG_GOP_SECONDS = 60.0
407
+
408
+
409
+ def _expected_count(want: Any) -> int:
410
+ """`expect_streams` values: a count, or True/False for "at least one" / "none"."""
411
+ return int(want) if not isinstance(want, bool) else (1 if want else 0)
412
+
413
+
414
+ def stream_inventory_step(meta: Dict[str, Any], expect: Dict[str, Any]) -> Dict[str, Any]:
400
415
  """The output's stream composition checked against what the operation should have produced
401
416
  (#367: a cut of an MPEG-TS source lost its whole video stream and still said verified: true,
402
417
  because the only properties measured were duration and start skew). `expect` comes from the
403
418
  input's own probe and the tool's arguments (provenance "input_premeasurement"): a stream the
404
- input had and the operation was not told to drop must still be there."""
405
- measured = {kind: bool(meta.get(kind)) for kind in expect}
406
- missing = [k for k, want in expect.items() if want and not measured[k]]
407
- step: Dict[str, Any] = {"step": "streams", "property": "stream_inventory", "expected": dict(expect),
408
- "measured": measured, "provenance": "input_premeasurement", "ok": not missing}
419
+ input had and the operation was not told to drop must still be there. A value is a count
420
+ (the cut, join and export writers map one stream of each kind) or a bool for "at least one";
421
+ fewer streams than that is `missing`."""
422
+ counts = meta.get("stream_counts") or {k: int(bool(meta.get(k))) for k in expect}
423
+ want = {k: _expected_count(v) for k, v in expect.items()}
424
+ got = {k: int(counts.get(k) or 0) if meta.get(k) else 0 for k in expect}
425
+ missing = [k for k in expect if got[k] < want[k]]
426
+ step: Dict[str, Any] = {"step": "streams", "property": "stream_inventory",
427
+ "expected": {k: want[k] > 0 for k in expect}, "measured": {k: got[k] > 0 for k in expect},
428
+ "expected_count": want, "measured_count": got,
429
+ "provenance": "input_premeasurement", "ok": not missing}
409
430
  if missing:
410
431
  step["missing"] = missing
411
432
  return step
412
433
 
413
434
 
435
+ def stream_length(meta: Dict[str, Any], kind: str) -> Optional[float]:
436
+ """One stream's own length: its duration, else the DURATION tag Matroska/WebM keep. A value
437
+ that is not a finite number is no length (a NaN compares false and would pass every check)."""
438
+ s = meta.get(kind) or {}
439
+ for v in (s.get("duration"), s.get("tagged_duration")):
440
+ if isinstance(v, (int, float)) and math.isfinite(v) and v > 0:
441
+ return float(v)
442
+ return None
443
+
444
+
445
+ def stream_end(meta: Dict[str, Any], kind: str) -> Optional[float]:
446
+ """Where one stream ends on the file's timeline: its start plus its own length."""
447
+ length = stream_length(meta, kind)
448
+ return None if length is None else ((meta.get(kind) or {}).get("start_time") or 0.0) + length
449
+
450
+
451
+ def file_origin(meta: Dict[str, Any]) -> float:
452
+ """Where the file's timeline starts: the container's start time, else its earliest stream's.
453
+ A -ss, a cut's --start and a window are counted from here, while stream starts and ends are
454
+ on the file's own timestamps."""
455
+ if isinstance(meta.get("start_time"), (int, float)):
456
+ return float(meta["start_time"])
457
+ starts = [(meta.get(k) or {}).get("start_time") for k in ("video", "audio")]
458
+ return min([s for s in starts if isinstance(s, (int, float))] or [0.0])
459
+
460
+
461
+ def stream_lengths_step(meta: Dict[str, Any], references: Sequence[Dict[str, Any]],
462
+ tolerance: float = STREAM_LENGTH_TOLERANCE,
463
+ windows: Optional[Sequence[Tuple[float, float]]] = None) -> Dict[str, Any]:
464
+ """Where the picture and the sound of the output end, against each other, allowing the gap
465
+ the inputs already had. A filename with a space was reported to have lost 40 s of sound with
466
+ no error: the container's length is the longest stream's, so a duration check passes while
467
+ one stream stops early. `short` names a stream that ends more than `tolerance` (plus the
468
+ inputs' own shortfall, summed over the inputs) before the other one. Ends, not lengths: a
469
+ stream that only starts late is the start-skew check's (cut.py reports it in a note). A length
470
+ that cannot be read is not a pass (`not_measured`). `windows` (cut.py's requested ranges, on
471
+ the input's own timeline) count only the gap the input has inside them: a source whose video
472
+ runs 5 s past its sound allows nothing to a cut from its middle."""
473
+ kinds = ("video", "audio")
474
+ lengths = {k: stream_length(meta, k) for k in kinds}
475
+ step: Dict[str, Any] = {"step": "stream_lengths", "property": "stream_end", "measured": lengths,
476
+ "tolerance": tolerance, "provenance": "input_premeasurement"}
477
+ unknown = [k for k in kinds if lengths[k] is None]
478
+ if unknown:
479
+ return dict(step, ok=False, reason="not_measured: no length for the %s stream" % " and ".join(unknown))
480
+ allowed: Dict[str, float] = {k: 0.0 for k in kinds}
481
+ unread_input = False
482
+ for ref in references:
483
+ if not all(ref.get(k) for k in kinds):
484
+ continue # an input with one stream (join's silent clip) brings no gap of its own
485
+ ref_ends = {k: stream_end(ref, k) for k in kinds}
486
+ if None in ref_ends.values():
487
+ unread_input = True # its gap is unknown, not zero
488
+ continue
489
+ rel = {k: ref_ends[k] - file_origin(ref) for k in kinds} # the clock cut's ranges are on
490
+ for s, e in (windows or [(0.0, max(rel.values()))]):
491
+ inside = {k: max(s, min(e, rel[k])) for k in kinds}
492
+ top = max(inside.values())
493
+ for k in kinds:
494
+ allowed[k] += top - inside[k]
495
+ ends = {k: stream_end(meta, k) for k in kinds}
496
+ last = max(ends.values())
497
+ raw = {k: last - ends[k] for k in kinds}
498
+ short = [k for k in kinds if raw[k] > allowed[k] + tolerance]
499
+ step.update(ends={k: round(v, 3) for k, v in ends.items()}, shortfall={k: round(v, 3) for k, v in raw.items()},
500
+ input_shortfall={k: round(v, 3) for k, v in allowed.items()}, ok=not short)
501
+ if short and unread_input:
502
+ # a gap the output has may be one an input already had: neither a pass nor a "short"
503
+ step["input_shortfall"] = None
504
+ step["reason"] = ("not_measured: an input's stream length cannot be read, so whether the %s gap "
505
+ "was already in the input is unknown" % " and ".join(short))
506
+ elif short:
507
+ step["short"] = short
508
+ return step
509
+
510
+
511
+ def decode_step(path: str, meta: Dict[str, Any], kinds: Sequence[str], window: float = DECODE_WINDOW_SECONDS) -> Dict[str, Any]:
512
+ """Decode the first `window` seconds of every kept stream and the last `window` before that
513
+ stream's own end, and count the frames. A stream ffprobe lists from the container header can
514
+ still hold nothing a decoder accepts; the inventory and length checks read the header, this
515
+ one reads the payload. The tail read starts DECODE_SEEK_MARGIN_SECONDS earlier, so a seek that
516
+ lands past the last keyframe (MPEG-TS seeks by timestamp, not to a keyframe) still decodes,
517
+ but only frames in the last `window` count, and the read stops a second past the end. A
518
+ stream whose own end cannot be read is not decoded at its end: its tail is `not_measured`,
519
+ not undecodable (the container's end is the longest stream's, not this one's)."""
520
+ from _common.runner import require_tool, run_analysis
521
+ ffmpeg = require_tool("ffmpeg")
522
+ frames: Dict[str, Dict[str, int]] = {}
523
+ unmeasured: List[str] = []
524
+ for kind in kinds:
525
+ # 0:V is the motion picture only: an attached cover picture would be 0:v:0
526
+ sel = ["-map", "0:%s:0" % ("V" if kind == "video" else "a"), "-f", "framecrc", "-"]
527
+ base = [ffmpeg, "-hide_banner", "-nostdin", "-v", "error"]
528
+ origin = file_origin(meta)
529
+ # the head is the stream's own first window: sound that starts 2 s in has none in the file's
530
+ head_from = max(0.0, ((meta.get(kind) or {}).get("start_time") or origin) - origin)
531
+ head = run_analysis(base + ["-ss", "%.3f" % head_from, "-i", path, "-t", "%g" % window] + sel, check=False)
532
+ frames[kind] = {"head": _framecrc_frames(head) if head.returncode == 0 else 0}
533
+ # the stream's own end only: the container's is the longest stream's, and a shorter stream
534
+ # read there would decode nothing and be called undecodable when it is only unmeasured
535
+ end = stream_end(meta, kind)
536
+ if end is None:
537
+ frames[kind]["tail"] = 0
538
+ unmeasured.append(kind)
539
+ continue
540
+ tail_from = max(0.0, end - origin - window - DECODE_SEEK_MARGIN_SECONDS) # -ss counts from the origin
541
+ # -copyts keeps the file's own timestamps, the timeline `end` is measured on
542
+ def tail_frames(start: float) -> int:
543
+ proc = run_analysis(base + ["-copyts", "-ss", "%.3f" % start, "-t", "%g" % (end - origin - start + 1.0), "-i", path]
544
+ + sel, check=False)
545
+ return _framecrc_frames(proc, since=end - window) if proc.returncode == 0 else 0
546
+ frames[kind]["tail"] = tail_frames(tail_from)
547
+ if not frames[kind]["tail"] and tail_from > 0:
548
+ # a GOP longer than the margin (long-GOP MPEG-TS) has no keyframe after tail_from: read
549
+ # once more from up to DECODE_LONG_GOP_SECONDS earlier before calling it undecodable
550
+ # (bounded, so a failing hour-long stream is not decoded whole)
551
+ frames[kind]["tail"] = tail_frames(max(0.0, end - origin - window - DECODE_LONG_GOP_SECONDS))
552
+ bad = [k for k in kinds if not all(frames[k].values())]
553
+ step: Dict[str, Any] = {"step": "decode", "property": "decodable", "frames": frames, "window": window,
554
+ "provenance": "measurement", "ok": not bad}
555
+ if bad:
556
+ step["undecodable"] = [k for k in bad if k not in unmeasured]
557
+ if unmeasured:
558
+ step["reason"] = "not_measured: no end time for the %s stream" % " and ".join(unmeasured)
559
+ return step
560
+
561
+
562
+ def _framecrc_frames(proc: Any, since: Optional[float] = None) -> int:
563
+ """Frames in ffmpeg's framecrc output; with `since`, only those still on screen after that time
564
+ (pts plus duration past it: a last frame held for 2 s starts before the window and still
565
+ counts, one that ends exactly at it does not). Rows
566
+ are `stream, dts, pts, duration, size, crc`, timestamps in the `#tb N: num/den` time base."""
567
+ tb: Dict[str, float] = {}
568
+ n = 0
569
+ for line in (proc.stdout or "").splitlines():
570
+ if line.startswith("#tb "):
571
+ idx, frac = line[4:].split(":", 1)
572
+ num, den = frac.strip().split("/")
573
+ tb[idx.strip()] = float(num) / float(den)
574
+ elif line and not line.startswith("#"):
575
+ cols = [c.strip() for c in line.split(",")]
576
+ if since is None:
577
+ n += 1
578
+ elif len(cols) > 3 and cols[0] in tb and (float(cols[2]) + float(cols[3])) * tb[cols[0]] > since + 1e-6:
579
+ n += 1
580
+ return n
581
+
582
+
414
583
  def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any) -> None:
415
584
  """Final stdout line: the output path, or a JSON document with --json.
416
585
 
@@ -432,9 +601,17 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
432
601
  # verified nothing. Spec failures the tool cannot fix on its own (export's loudness gap)
433
602
  # keep status completed and say verified: false, so a caller keys on one field.
434
603
  expect_streams = extra.pop("expect_streams", None)
604
+ stream_reference = extra.pop("stream_reference", None)
605
+ stream_windows = extra.pop("stream_windows", None)
435
606
  steps: List[Dict[str, Any]] = ([{"step": "probe", "ok": True}] if meta else []) + list(extra.pop("verification", None) or [])
436
607
  if meta and expect_streams:
437
608
  steps.append(stream_inventory_step(meta, expect_streams))
609
+ kept = [k for k in ("video", "audio") if _expected_count(expect_streams.get(k, 0))]
610
+ if stream_reference is not None and len(kept) == 2:
611
+ steps.append(stream_lengths_step(meta, stream_reference, windows=stream_windows))
612
+ present = [k for k in kept if meta.get(k)]
613
+ if present:
614
+ steps.append(decode_step(output, meta, present))
438
615
  # a tool that ran others (render.py) verified what its stages verified too (absorb_stage())
439
616
  steps += [s for s in ctx.stage_verification if s not in steps]
440
617
  if output and not ctx.dry_run and os.path.splitext(output)[1].lower() not in MEDIA_EXT:
@@ -206,12 +206,21 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
206
206
  "file": path,
207
207
  "format": fmt.get("format_name"),
208
208
  "duration": duration,
209
+ # where the file's timeline starts (MPEG-TS starts at 1.4 s, not 0): a -ss and a cut's
210
+ # --start count from here
211
+ "start_time": _to_float(fmt.get("start_time")),
209
212
  "size_bytes": _to_int(fmt.get("size")),
210
213
  "bitrate": _to_int(fmt.get("bit_rate")),
211
214
  "video": None,
212
215
  "audio": None,
213
216
  "subtitle_streams": len(subs),
214
217
  "data_streams": data_stream_count,
218
+ # how many picture and sound streams the file carries (a cover picture is not a video
219
+ # stream); the streams check compares them with what the operation should have written
220
+ "stream_counts": {
221
+ "video": sum(1 for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0),
222
+ "audio": sum(1 for s in streams if s.get("codec_type") == "audio"),
223
+ },
215
224
  # container-level chapter markers and the common tags, so metadata.py's result is
216
225
  # verifiable the same way every other tool's is (additive keys, 1.x-safe)
217
226
  "chapters": [{
@@ -286,6 +295,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
286
295
  "nb_frames": _to_int(video.get("nb_frames")),
287
296
  # the stream's own length (a container's also counts audio priming and a longer track)
288
297
  "duration": _to_float(video.get("duration")),
298
+ # Matroska/WebM keep the stream's length in a DURATION tag only, as for audio below
299
+ "tagged_duration": _tag_seconds((video.get("tags") or {}).get("DURATION")),
289
300
  # where the stream starts; a video stream that starts after its audio (or the reverse)
290
301
  # is how a lossless cut's A/V skew shows up
291
302
  "start_time": _to_float(video.get("start_time")),
@@ -1377,9 +1377,16 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
1377
1377
 
1378
1378
 
1379
1379
  def shell_quote(s: str) -> str:
1380
- if not s or any(ch in s for ch in " \t\n\r\\\"';|&<>()[]{}$*?"):
1381
- return "'" + s.replace("'", "'\\''") + "'"
1382
- return s
1380
+ """One argument as a POSIX shell word, so a printed command can be pasted and run again.
1381
+ Anything outside a known-safe set is single-quoted: the old deny-list left a backtick, `!`, a
1382
+ leading `#` or `~` and non-ASCII bare, and a shell ran `x`id`.mp4`'s backtick as a command
1383
+ and dropped `#tag.mp4` (and everything after it) as a comment."""
1384
+ if s and _SHELL_SAFE.fullmatch(s):
1385
+ return s
1386
+ return "'" + s.replace("'", "'\\''") + "'"
1387
+
1388
+
1389
+ _SHELL_SAFE = re.compile(r"[A-Za-z0-9_@%+=:,./-]+")
1383
1390
 
1384
1391
 
1385
1392
  def ffmpeg_base(overwrite: bool = True) -> List[str]:
@@ -630,8 +630,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
630
630
  "keyframe_snapped": {"type": "boolean", "description": "precision is packet: the run is a stream copy end to end, which lands on keyframes and packets. Whether a copied start really moved is start_snapped"},
631
631
  "start_snapped": {"type": ["boolean", "null"], "description": "measured: true when a copied segment's picture starts more than one frame from its requested start (the keyframe the copy's seek lands on, measured with the seek ffmpeg makes); false when every start is where asked (an MP4 edit list hides the pre-roll, the start is a keyframe, an audio-only copy, or everything re-encoded); null when a copied start was not measured (--dry-run)"},
632
632
  "reencoded": {"type": "boolean"},
633
- "reencode_reason": {"type": "array", "items": {"enum": ["requested", "codec", "vfr", "vfr_inconclusive", "pcm_container", "copy_failed", "tolerance", "concat_fallback"]},
634
- "description": "why anything was re-encoded, distinct values in first-seen order ([] when nothing was): requested (--accurate), codec (--codec), vfr (a variable-frame-rate source forced --accurate), vfr_inconclusive (its frame timing could not be measured), pcm_container (compressed audio into a .wav), copy_failed (the stream copy errored), tolerance (a keyframe snap past --tolerance), concat_fallback (--segments parts could not be joined by stream copy)"},
633
+ "reencode_reason": {"type": "array", "items": {"enum": ["requested", "codec", "vfr", "vfr_inconclusive", "pcm_container", "copy_failed", "stream_lost", "tolerance", "concat_fallback"]},
634
+ "description": "why anything was re-encoded, distinct values in first-seen order ([] when nothing was): requested (--accurate), codec (--codec), vfr (a variable-frame-rate source forced --accurate), vfr_inconclusive (its frame timing could not be measured), pcm_container (compressed audio into a .wav), copy_failed (the stream copy errored), stream_lost (the copy wrote no video or no audio, as an MPEG-TS copy with no keyframe in its window does), tolerance (a keyframe snap past --tolerance), concat_fallback (--segments parts could not be joined by stream copy)"},
635
635
  "segment_precision": {"type": ["array", "null"], "items": {"enum": ["packet", "sample", "codec_frame", "frame"]},
636
636
  "description": "--segments only: each segment's precision in order; least_exact_precision is the least exact of them (null for a single segment)"},
637
637
  "edit_list": {"type": "boolean", "description": "--edit-list on a single MP4/MOV stream copy: it kept the edit list it wrote instead of -avoid_negative_ts make_zero, so the keyframe's pre-roll is stored but hidden and the picture and the sound start at the requested time (false for every other path, the default included)"},
package/scripts/audio.py CHANGED
@@ -25,6 +25,7 @@ import sys
25
25
  from pathlib import Path
26
26
  from typing import Any, Dict, List
27
27
 
28
+ from _common.probe import SILENT_PEAK_DBFS
28
29
  from _common import STATE, add_common, dry_run_input_pending, require_tool, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, measured_level_dbfs, probe, run, run_keeping_subtitles, fmt_secs
29
30
  from _common.emit import warn
30
31
 
@@ -216,7 +217,7 @@ def main() -> int:
216
217
  ap.add_argument("--on-silent", choices=["warn", "fail"], default="warn",
217
218
  help="a --music/--replace/--effects file (or, under --duck, the voice) whose peak is at or below "
218
219
  "--silence-threshold: warn (default: mix it, report it under `silent`) or fail (refuse before ffmpeg runs)")
219
- ap.add_argument("--silence-threshold", type=float, default=-50.0, metavar="DBFS",
220
+ ap.add_argument("--silence-threshold", type=float, default=SILENT_PEAK_DBFS, metavar="DBFS",
220
221
  help="peak level in dBFS at or below which a track counts as silent (default -50)")
221
222
  add_common(ap)
222
223
  args = ap.parse_args()
package/scripts/cut.py CHANGED
@@ -865,8 +865,17 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
865
865
  info("stream copy failed, falling back to re-encode")
866
866
  return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta, reasons + ["copy_failed"], edit_list_ok)
867
867
  die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
868
- if not reencode and tolerance >= 0 and not STATE.dry_run and copy_t is None:
869
- out_meta = probe(dst)
868
+ out_meta = probe(dst) if not reencode and not STATE.dry_run and copy_t is None else None
869
+ if out_meta is not None:
870
+ # #367: a copy from MPEG-TS whose window holds no video keyframe writes no video at all
871
+ # (the TS demuxer does not seek back to one, as the MP4 demuxer does), and ffmpeg exits 0
872
+ lost = [k for k in ("video", "audio") if meta.get(k) and not out_meta.get(k) and not (k == "video" and audio_only)]
873
+ if lost:
874
+ info(f"the stream copy wrote no {' or '.join(lost)} stream (no keyframe to start it from); "
875
+ "re-encoding this segment")
876
+ return _with_notes(cut_one(src, start, end, dst, True, crf, preset, tolerance, meta, reasons + ["stream_lost"],
877
+ edit_list_ok), notes)
878
+ if out_meta is not None and tolerance >= 0:
870
879
  got = out_meta.get("duration") or 0.0
871
880
  vdur, fps = (out_meta.get("video") or {}).get("duration"), (meta.get("video") or {}).get("fps")
872
881
  if edit_list and not audio_only and vdur and fps and abs(got - vdur) > 1.0 / fps:
@@ -1678,10 +1687,12 @@ def main() -> int:
1678
1687
  info(f"wrote {output} ({fmt_secs(got)}, expected ~{expected:.3f}s, "
1679
1688
  + ("re-encoded" if reencoded else "lossless stream copy") + f", {precision} precision)")
1680
1689
  # the streams the source had and this cut was not told to drop (an audio extension drops the
1681
- # picture): #367, a TS cut that lost its video must not say verified
1682
- expect_streams = {"video": bool(meta.get("video")) and not is_audio_output(output), "audio": bool(meta.get("audio"))}
1690
+ # picture): #367, a TS cut that lost its video must not say verified. ffmpeg's default stream
1691
+ # selection writes one of each, so that is the count; the source's own picture/sound gap is
1692
+ # the reference for the lengths check
1693
+ expect_streams = {"video": int(bool(meta.get("video")) and not is_audio_output(output)), "audio": int(bool(meta.get("audio")))}
1683
1694
  emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
1684
- expect_streams=expect_streams,
1695
+ expect_streams=expect_streams, stream_reference=[meta], stream_windows=[(s, e) for s, e in requested],
1685
1696
  dropped_non_av_streams=bool(DROPPED_STREAMS),
1686
1697
  requested_start=round(requested[0][0], 6) if len(requested) == 1 else None,
1687
1698
  requested_end=round(requested[0][1], 6) if len(requested) == 1 else None,
package/scripts/export.py CHANGED
@@ -350,7 +350,10 @@ def main() -> int:
350
350
  if notes:
351
351
  extra["notes"] = notes
352
352
  # the delivery keeps the source's picture and, except as a GIF, its sound
353
- extra["expect_streams"] = {"video": bool(meta.get("video")), "audio": has_audio and args.preset != "gif"}
353
+ extra["expect_streams"] = {"video": int(bool(meta.get("video"))), "audio": int(bool(has_audio and args.preset != "gif"))}
354
+ extra["stream_reference"] = [meta]
355
+ if trimmed: # only the kept 0..max of the source can have handed its gap to the output
356
+ extra["stream_windows"] = [(0.0, float(p["max"]))]
354
357
  emit(output, **extra)
355
358
  return 0
356
359
 
package/scripts/join.py CHANGED
@@ -39,6 +39,7 @@ import os
39
39
  import sys
40
40
  from typing import Any, Dict, List, Optional, Tuple
41
41
 
42
+ from _common.probe import SILENT_PEAK_DBFS
42
43
  from _common import STATE, AUDIO_MEDIA_EXT, draws_chroma, drawn_chain, label_chain, matrix_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
43
44
  from _common.emit import warn
44
45
 
@@ -50,6 +51,9 @@ STATE_SKIPPED: List[Dict[str, Any]] = []
50
51
  # --dry-run: inputs that do not exist yet, planned on as an earlier step's output
51
52
  STATE_PENDING: List[Dict[str, Any]] = []
52
53
  STATE_NOTES: List[str] = []
54
+ # the joined picture's length against the clips' sum less the transitions: 1.5 frames, at least 0.05 s
55
+ DURATION_TOLERANCE_FRAMES = 1.5
56
+ DURATION_TOLERANCE_MIN = 0.05
53
57
  # --on-silent warn: inputs whose audio peak is at or below --silence-threshold, joined anyway
54
58
  STATE_SILENT: List[Dict[str, Any]] = []
55
59
  # warnings, never refusals: a clip shorter than MIN_SEGMENT_FRAMES frames (audio-only: shorter than
@@ -191,7 +195,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
191
195
  + ("crossfade" if d else "butt join") + ")")
192
196
  overlap = overlap_report(durs, expected)
193
197
  emit(output, mode="audio", **overlap, **reported(), clips=n, transition=args.transition if d else "none", expected_duration=expected,
194
- sample_rate=rate, channels=channels, video=False)
198
+ sample_rate=rate, channels=channels, video=False, expect_streams={"audio": 1})
195
199
  return 0
196
200
 
197
201
 
@@ -316,7 +320,7 @@ def main() -> int:
316
320
  ap.add_argument("--allow-silent", metavar="N[,N...]",
317
321
  help="input numbers (1-based) whose silence is intended, e.g. a planned pause: always joined, "
318
322
  "listed under `silent` with intended: true, no warning, whatever --on-silent says")
319
- ap.add_argument("--silence-threshold", type=float, default=-50.0, metavar="DB",
323
+ ap.add_argument("--silence-threshold", type=float, default=SILENT_PEAK_DBFS, metavar="DB",
320
324
  help="peak level in dBFS at or below which an input counts as silent (default -50)")
321
325
  ap.add_argument("-o", "--output", help="output file (default: <first>_joined.mp4)")
322
326
  ap.add_argument("--transition", choices=TRANSITIONS, default="fade", help="transition between clips (default fade)")
@@ -579,7 +583,7 @@ def main() -> int:
579
583
  measured = (r.get("video") or {}).get("duration") or r.get("duration")
580
584
  verification = []
581
585
  if expected is not None and measured is not None and not STATE.dry_run:
582
- tol = max(1.5 / fps, 0.05)
586
+ tol = max(DURATION_TOLERANCE_FRAMES / fps, DURATION_TOLERANCE_MIN)
583
587
  verification.append({"step": "duration", "property": "video_duration", "ok": abs(measured - expected) <= tol,
584
588
  "measured": round(measured, 3), "expected": expected,
585
589
  "delta": round(measured - expected, 3), "tolerance": round(tol, 3),
@@ -594,7 +598,7 @@ def main() -> int:
594
598
  info(f"wrote {output} ({fmt_secs(measured)}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
595
599
  overlap = overlap_report(lens, expected)
596
600
  emit(output, mode="video", **overlap, **reported(), clips=n, transition=args.transition, expected_duration=expected,
597
- verification=verification, expect_streams={"video": True, "audio": True},
601
+ verification=verification, expect_streams={"video": 1, "audio": 1}, stream_reference=metas,
598
602
  audio_substituted=substituted,
599
603
  dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
600
604
  return 0
@@ -22,6 +22,7 @@ import sys
22
22
  from _platforms import TP_TOLERANCE_DB
23
23
 
24
24
  AUDIO_LENGTH_TOLERANCE = 0.03 # seconds: one AAC frame at 48 kHz is 0.021 s
25
+ LUFS_TOLERANCE = 1.0 # LU: a normalised file within this of the requested -I is on target
25
26
  from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
26
27
 
27
28
 
@@ -159,7 +160,7 @@ def main() -> int:
159
160
  if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext]:
160
161
  result["audio_bitrate_used"] = bitrate
161
162
  result["encodes"] = rounds + 1
162
- if not after.get("silent") and abs(float(after["input_i"]) - args.lufs) > 1.0:
163
+ if not after.get("silent") and abs(float(after["input_i"]) - args.lufs) > LUFS_TOLERANCE:
163
164
  result["note"] = (f"integrated loudness is {float(after['input_i']) - args.lufs:+.1f} LU from the target because the "
164
165
  f"true-peak ceiling had to absorb the encoder's overshoot; a lossless delivery (wav/flac) or a pinned "
165
166
  f"higher --audio-bitrate keeps both")
@@ -169,12 +170,12 @@ def main() -> int:
169
170
  kind="verification", output=output, result=result,
170
171
  hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
171
172
  verification = [{"step": "loudness",
172
- "ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - args.lufs) <= 1.0 and float(after["input_tp"]) <= args.tp + TP_TOLERANCE_DB),
173
+ "ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - args.lufs) <= LUFS_TOLERANCE and float(after["input_tp"]) <= args.tp + TP_TOLERANCE_DB),
173
174
  "lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": args.lufs, "target_tp": args.tp,
174
175
  "property": "loudness", "expected": {"lufs": args.lufs, "tp_max": args.tp},
175
176
  "measured": {"lufs": float(after["input_i"]), "tp": float(after["input_tp"])},
176
177
  "delta": {"lufs": round(float(after["input_i"]) - args.lufs, 2), "tp": round(float(after["input_tp"]) - args.tp, 2)},
177
- "tolerance": {"lufs": 1.0, "tp": TP_TOLERANCE_DB}, "provenance": "user_specified"}]
178
+ "tolerance": {"lufs": LUFS_TOLERANCE, "tp": TP_TOLERANCE_DB}, "provenance": "user_specified"}]
178
179
  written = probe(output, role="output")
179
180
  out_audio = audio_length(written)
180
181
  if in_audio and out_audio:
@@ -35,6 +35,7 @@ import os
35
35
  import sys
36
36
 
37
37
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
38
+ from _common.probe import SILENT_PEAK_DBFS
38
39
  from _common import (add_common, apply_common, aac_args, default_output, die, draws_chroma, emit, ffmpeg_base,
39
40
  info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
40
41
  fmt_secs, child_args, run_tool, measured_level_dbfs)
@@ -60,7 +61,7 @@ def main() -> int:
60
61
  ap.add_argument("--on-silent", choices=["warn", "fail"], default="warn",
61
62
  help="input audio whose peak is at or below --silence-threshold (the render would be a flat "
62
63
  "line): warn (default: render it, report `silent: true`) or fail (refuse before ffmpeg runs)")
63
- ap.add_argument("--silence-threshold", type=float, default=-50.0, metavar="DBFS",
64
+ ap.add_argument("--silence-threshold", type=float, default=SILENT_PEAK_DBFS, metavar="DBFS",
64
65
  help="peak level in dBFS at or below which the input counts as silent (default -50)")
65
66
  ag = ap.add_argument_group("audiogram (1.16)",
66
67
  "The visualisation over a picture, for an episode that has no video. The image is a "