ffmpeg-skill 2.2.5 → 2.3.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/SKILL.md CHANGED
@@ -23,7 +23,7 @@ Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `chec
23
23
  7. **Keep the user's originals.** Never overwrite the source; write new files next to input or where asked. An existing output path is refused (`kind: input`); `--overwrite` is the one way to say "yes, replace it".
24
24
  8. **Look at the picture.** Whenever the picture changed (captions, overlays, graphics, crop/pad, resize, colour, transitions, a `join.py` scaling a clip to the first clip's frame, `color.py --to-sdr`) run `look.py OUTPUT --tiles 3x2` (or `--at T` for one frame) and view the PNG (4x3 for whole-clip layout). Not finished until `Look:` names that PNG — a probe cannot see a caption on a face. Audio-only: `Look: not needed`. What to look for splits like `check.py`'s rows in step 5:
25
25
  - **Mechanical (this skill's own job to verify and report):** the specified text/logo is at the specified position, subtitles appear at the specified timestamps, dimensions are even. Letterboxing from `fit.py --fit pad` is the *correct* result of that mode, never a defect to flag.
26
- - **Judgement (report it, don't silently pass or fail):** whether a subject or face is cut off, text sits over a face, colours look washed out, a transition lands. These need deciding what the subject *is*, which belongs to the calling agent — say what you see in one line and let them judge it.
26
+ - **Judgement (report it, don't silently pass or fail):** whether a subject or face is cut off, text sits over a face, colours look washed out, a transition lands. These need deciding what the subject *is*, the calling agent's call — say what you see in one line; they judge.
27
27
  With no vision capability, write `Look: PATH (pixels not inspected; agent has no image view)` — never claim a picture was inspected when it wasn't, without stalling for a capability that isn't there.
28
28
 
29
29
  ## Before you run anything: what to ask, what to assume
@@ -136,7 +136,7 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
136
136
  | "add chapters", "chapter markers for YouTube" | `metadata.py episode.mp4 --chapters chapters.txt` (`TIME TITLE` per line; streams copied; in a project: `"chapters"`). `--auto-chapters` proposes them from measured pauses/scene cuts (`Chapter N`) |
137
137
  | "set the title / artist / comment" | `metadata.py episode.mp4 --title "Episode 12" --artist "Studio"` |
138
138
  | "put these videos in a 4x2 grid" | `grid.py t1.mp4 ... t8.mp4 --cols 4 --rows 2` |
139
- | "stitch these clips", "add a crossfade" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` (TTS parts: `--list parts.txt`, `--on-missing skip`) |
139
+ | "stitch these clips", "add a crossfade" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` (TTS: `--list parts.txt`, `--on-missing skip`, `--on-silent fail`) |
140
140
  | "several changes to one edit", 3+ steps | `render.py --init project.json`, edit, `render.py project.json` |
141
141
  | "I changed one stage, don't redo the rest" | `render.py project.json --cache DIR` — identical stages reused (`--from STAGE` starts there) |
142
142
  | "open it in Premiere / Resolve / Final Cut" | `render.py project.json --export-timeline edit.fcpxml\|.edl\|.otio` — renders nothing; read `not_exported`; no project? `--write-project` |
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.2.5`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.3.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`
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
119
119
  "contract_version": "1.0",
120
120
  "deprecated": [],
121
121
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
122
- "skill": {"id": "ffmpeg-skill", "version": "2.2.5", "execution_mode": "local", "kind": "execution",
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.3.1", "execution_mode": "local", "kind": "execution",
123
123
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
124
124
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
125
125
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -186,6 +186,11 @@ The workflow in `SKILL.md` is "probe first, verify last". The contract states it
186
186
  (fit, caption, overlay, graphics, color, join, multicam, render). Audio-only tools and
187
187
  audio-only inputs never need `look`; the report line is `Look: not needed`. `check`
188
188
  rows carry `kind: format` (fix it) or `kind: judgement` (decide with the user).
189
+ The `audio` row FAILs (WARN when no loudness target applies) when the track is present but silent (peak at or below -50 dBFS, from
190
+ the loudness pass or, when loudness is skipped, one volumedetect pass). `check --content`
191
+ (opt-in; render.py `"check": {"content": true}`) adds `black`, `frozen` and `silence` rows
192
+ from one decode pass; without it the row set is unchanged. Thresholds:
193
+ `docs/design-decisions.md` ("check.py --content").
189
194
 
190
195
  ### Dry run
191
196
 
@@ -220,7 +225,8 @@ subtitle/LUT/font files a filter reads. Tools that print their document without
220
225
  (`probe`, the analysis tools) still write the plan at exit; `verify.py` and `render.py`
221
226
  refuse `--plan` (their steps run for real; a project file is already a plan). `render.py FILE` executes a plan:
222
227
  it refuses (`kind: input`) when an input's size or head/tail hash differs from the plan,
223
- runs the tool with the planned `argv`, then the verify steps, and reports `plan`, `tool`,
228
+ runs the tool with the planned `argv`, then the verify steps (a `check` step may also carry
229
+ `"content": true`, added by hand, for `check.py --content`), and reports `plan`, `tool`,
224
230
  `tool_result` and `check`. `plan_version` is bumped when the document's shape changes.
225
231
 
226
232
  ### Repeatability
@@ -441,6 +447,20 @@ runs a shell, evaluates strings, or executes anything other than the named scrip
441
447
 
442
448
  ## JSON output
443
449
 
450
+ Non-finite numbers never reach a `--json` document (2.2.6): every tool prints through one
451
+ writer that turns `-inf` / `inf` into the strings `"-inf"` / `"inf"` (the spelling
452
+ `loudness.py` already used for a silent input) and NaN into `null`, then serialises with
453
+ `allow_nan=False`, so a strict JSON parser reads every document. A silent file's loudness is
454
+ therefore `"lufs": "-inf"`, never `-Infinity`.
455
+
456
+ `audio.py` (2.2.6) measures the whole-file peak of every `--music` / `--replace` / `--effects`
457
+ file and, under `--duck`, of the voice: at or below `--silence-threshold` (default -50 dBFS)
458
+ the track is silent. `--on-silent warn` (default) mixes it and adds `silent: [{flag, path,
459
+ peak_db}]` plus a `notes` line; `--on-silent fail` names it in the single `kind: input`
460
+ refusal's `problems` with reason `"silent (peak X dBFS)"`. A real run measures every file
461
+ that exists. `export.py`'s `loudness` gains `silent: true` for a silent output, whose note
462
+ says "output audio is silent" instead of recommending `loudness.py`.
463
+
444
464
  Per-tool keys added in 1.13: `audio` (`audio.py`) reports the mix it built — the
445
465
  `--voice` level, `stereo_widen`, whether an `--effects` bed was mixed, and with
446
466
  `--music` the `music_volume` plus a `duck` object naming the threshold (dB and
@@ -477,6 +497,7 @@ Per-tool keys added in 1.17.1, all additive:
477
497
  | key | tool | what it holds |
478
498
  |---|---|---|
479
499
  | `caption` | `render.py` | the caption stage's own block, forwarded verbatim from `caption.py` (the cue-layout counts plus the fit-size keys above), so a template run can be read for `split` and `size_used` without re-running the stage. `null` when the project has no captions stage — and also when the captions stage came from the `--cache` (a cache hit carries no stage document, so a second `render.py … --cache DIR` run reports `caption: null` while `stages_done` still lists `captions`). `caption.py --mode mux` writes no `caption` block at all |
500
+ | `cues_burned`, `cues_outside` | `caption.py` | siblings inside the `caption` block, **burn mode only** (2.2.6): `cues_burned` counts the non-blank cues that overlap `[0, duration]` of the video and are drawn, `cues_outside` the non-blank cues wholly outside it (each also named in `notes`). A burn where none is visible — every cue after the end, every cue blank, an `--ass` with no `Dialogue` lines — is refused with `kind: input` instead of reporting `verified: true`; `waveform.py --srt` passes that refusal on with the same kind |
480
501
  | `text_unchanged` | `caption.py` | a sibling inside the `caption` block, **burn mode only** (`--mode mux` never touches the text and omits the key): `true` when the drawn text equals the cues that were handed in — nothing transcribed, no cue dropped, no cue **split** across two consecutive cues and no glyph stripped (`--emoji none`). Wrapping, line breaks and timing do not count: the words are the same. This tool never rewrites, shortens or translates a cue, so the key is a statement of what happened, not a judgement of the text |
481
502
 
482
503
 
@@ -497,6 +518,14 @@ Per-tool keys added after 2.2.2, all additive:
497
518
  | key | tool | what it holds |
498
519
  |---|---|---|
499
520
  | `sdr_path`, `notes` | `color.py --to-sdr` | `sdr_path` is `"tonemap"` for PQ / HLG / Dolby Vision input (and `--force` on an untagged file) or `"gamut"` for BT.2020 primaries on an SDR transfer, which is converted to BT.709 without a tone map (`--tonemap`/`--peak`/`--desat` do not apply); `notes` says which path was taken and why |
521
+ | `silent` | `join.py` (every run) | `[{index, path, peak_db}]`: inputs whose whole-file audio peak (volumedetect) is at or below `--silence-threshold` (default -50 dBFS) and that `--on-silent warn` (default) joined anyway, with a `notes` line; `[]` otherwise. `--on-silent fail` names them in the `kind: input` refusal's `problems` (reason `silent (peak -91.0 dBFS)`), `skip` lists them under `skipped`. An input with no audio stream is never silent; `verified` is unaffected |
522
+ | `short_segments`, `duplicates` | `join.py` (every run) | warnings, never refusals, and the join command is unchanged: `short_segments: [{index, path, duration}]` names each measured input shorter than 2 frames at the join's fps (an audio-only join: shorter than 0.05 s); `duplicates: [{path, indices}]` names a path (compared resolved) listed more than once — repeating a clip can be intended. `[]` when none; each non-empty one adds a `notes` line |
523
+ | `silent` | `waveform.py` (every run) | `true` when the input audio's whole-file peak is at or below `--silence-threshold` (default -50 dBFS), which draws a flat line; `--on-silent warn` (default) renders it anyway with a `notes` line, `fail` refuses (`kind: input`) before ffmpeg runs. `false` when audible, `null` under `--dry-run` (nothing measured). `verified` is unaffected |
524
+
525
+ `caption.py --transcribe` and `silence.py --filler --transcribe`: when a local speech engine ran
526
+ and produced no cue, the refusal is `"<engine> found no speech in <input>"` with `kind: input`,
527
+ `reason: "no_speech"` and `engine` in the failure document — distinct from the "no local
528
+ speech-to-text engine found" refusal, which now only means no engine was found.
500
529
 
501
530
  `check.py` also gains an informational `subtitles` row on **every** platform:
502
531
  `PASS` when every soft subtitle stream carries a language tag, `WARN` when one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.2.5",
3
+ "version": "2.3.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",
@@ -1,6 +1,6 @@
1
1
  # Script reference
2
2
 
3
- Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, default 18 -- 30 for `proxy.py`; up to 63 for av1; ignored by prores). 2.0 removed `--crf` from these tools (it was `--quality`'s alias); `export.py` keeps its own `--crf`, which is not an alias. Every tool refuses (`kind: input`, before anything runs) to replace an existing output unless `--overwrite` is given. `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither flag; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
3
+ Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, default 18 -- 30 for `proxy.py`; up to 63 for av1; ignored by prores). 2.0 removed `--crf` from these tools (it was `--quality`'s alias); `export.py` keeps its own `--crf`, which is not an alias. Every tool refuses (`kind: input`, before anything runs) to replace an existing output unless `--overwrite` is given. `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither flag; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize`/`join` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
4
4
 
5
5
  ## Time grammar (every time-taking flag, 1.9)
6
6
 
@@ -312,7 +312,7 @@ waveform.py INPUT [--style waveform|spectrum] [--width W] [--height H] [--fps N]
312
312
  [--image PATH] [--image-fit cover|contain|blur]
313
313
  [--position bottom|centre|top|strip] [--vis-height FRAC] [--opacity 0..1]
314
314
  [--platform NAME] [--srt FILE | --text FILE] [--title TEXT] [--brand brand.json]
315
- [-o OUT]
315
+ [--on-silent warn|fail] [--silence-threshold -50] [-o OUT]
316
316
  ```
317
317
  Renders the input's audio as a video: `--style waveform` (default, FFmpeg's
318
318
  `showwaves`) draws amplitude over time; `--style spectrum` (`showspectrum`)
@@ -343,7 +343,11 @@ give an image or a colour. The title and caption stages receive `--overwrite`, `
343
343
  `--srt`/`--text` file is refused (`kind: input`) before anything is encoded. Without any of
344
344
  these flags the command line is byte-identical to 1.15's. Every run's result carries an `audiogram` object (style,
345
345
  background, image, position, vis_height, platform, captions, title, stages,
346
- verified). `render.py --template audiogram` is the one-call form; it is
346
+ verified). A real run peak-measures the input audio: at or below `--silence-threshold`
347
+ (default -50 dBFS) the render is a flat line, so `--on-silent warn` (default) renders it with
348
+ `silent: true` and a note and `fail` refuses (`kind: input`) before encoding; `silent` is
349
+ `null` under `--dry-run`. An `--image` ffprobe reports as 0x0 is refused as undecodable.
350
+ `render.py --template audiogram` is the one-call form; it is
347
351
  deliberately not part of `--template all`.
348
352
 
349
353
  ### freeze.py — hold a frame for N seconds
@@ -556,7 +560,7 @@ writes the resulting cut list exactly as it does today.
556
560
  ```
557
561
  join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadeblack|fadewhite|circleopen|none]
558
562
  [--duration 0.5] [--width W --height H] [--fps N] [--fit pad|crop] [-o OUT]
559
- join.py --list parts.txt [--on-missing fail|skip] [...]
563
+ join.py --list parts.txt [--on-missing fail|skip] [--on-silent warn|fail|skip] [--silence-threshold -50] [...]
560
564
  ```
561
565
  `--list FILE` (2.2) reads the clips from a file, one per line in order, paths relative to the
562
566
  file (blank lines and `#` comments ignored; ffmpeg's `file 'x.wav'` lines accepted), for the
@@ -572,6 +576,16 @@ in for its streams (any audio extension the skill reads, `.aiff` and `.caf` incl
572
576
  picture), so a pending `.mp4` next to measured audio is refused as the mix a real run would
573
577
  refuse; `expected_duration` is null while any input is pending, and `notes` names the planned
574
578
  frame, rate or xfade offsets that rest on a pending input's unmeasured placeholder.
579
+ Every existing input with an audio stream is also measured (one volumedetect pass; not under `--dry-run`
580
+ too): a peak at or below `--silence-threshold` (default -50 dBFS) is silent, the trace of a TTS
581
+ call that wrote a valid but empty file. `--on-silent warn` (default) joins it and names it under
582
+ `silent: [{index, path, peak_db}]` and in `notes`; `fail` refuses it in the same `kind: input`
583
+ document (`silent (peak -91.0 dBFS)`); `skip` leaves it out under `skipped`. A clip without an
584
+ audio stream is never silent.
585
+ Two warnings never refuse or change the command: `short_segments: [{index, path, duration}]`
586
+ names a clip shorter than 2 frames at the join's fps (audio-only: 0.05 s), and
587
+ `duplicates: [{path, indices}]` a path listed more than once (a repeat can be intended); both
588
+ are always present (`[]` when none) and noted.
575
589
  Normalises every clip to one frame size, fps, `yuv420p`, 48 kHz and one
576
590
  channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
577
591
  silent track generated for clips without audio), then chains `xfade` +
@@ -617,7 +631,8 @@ A plan is a single tool's dry run as an artifact: `cut.py in.mp4 --start 2 --end
617
631
  head+tail), commands, output, verify}` and runs nothing. `render.py cut.json`
618
632
  re-fingerprints the inputs (refusing, `kind: input`, if any changed since the
619
633
  plan), runs the tool with the planned argv, then the verify steps (probe; `check`
620
- for a `--platform` or a platform export preset), and reports `plan`, `tool`,
634
+ for a `--platform` or a platform export preset; add `"content": true` to a check
635
+ step for the black/frozen/silence rows), and reports `plan`, `tool`,
621
636
  `tool_result` and `check`. Show the plan to the user, get the yes, execute:
622
637
  one round trip instead of re-deriving the command.
623
638
  `"export": {"preset": "reels", "normalize": true}` forwards `export.py --normalize`
@@ -838,7 +853,7 @@ measurements that combine with each other and with `--beats`/`--highlights`:
838
853
 
839
854
  ### check.py — pre-delivery compliance
840
855
  ```
841
- check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|facebook|broadcast|podcast|custom [--no-loudness] [--json]
856
+ check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|facebook|broadcast|podcast|custom [--no-loudness] [--content] [--json]
842
857
  [--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
843
858
  ```
844
859
  PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
@@ -855,6 +870,14 @@ minimum height, fps, codecs, size, LUFS, true peak, SDR-only) come from the one
855
870
  in `scripts/_platforms.py`, which `export.py` and the `render.py` templates read too -- so the
856
871
  loudness a preset normalises to and the loudness this tool checks are the same value by
857
872
  construction, not by two lists agreeing.
873
+ The `audio` row FAILs (under a loudness target; WARN without one) on a present-but-silent track (peak <= -50 dBFS, the join/audio
874
+ `--silence-threshold` default): measured from the loudness pass, or with one volumedetect
875
+ pass when `--no-loudness` (or a spec with no loudness target) skips it.
876
+ `--content` (opt-in, one decode pass) adds three rows: `black` (share of the duration that is
877
+ black: WARN > 10%, FAIL >= 95%), `frozen` (longest frozen span: WARN > max(3 s, 30% of the
878
+ duration), FAIL when it covers >= 95%, i.e. the whole video) and `silence` (share below
879
+ -50 dBFS: WARN > 50%, FAIL >= 95%). A render project enables it with `"check": {"platform":
880
+ "reels", "content": true}`.
858
881
 
859
882
  ### batch.py — same recipe over a folder, cached
860
883
  ```
@@ -1125,6 +1148,16 @@ the honest sentence for a report, made automatic: only the line breaks, the
1125
1148
  timing and the type size ever move, and those do not count as a change. The key
1126
1149
  is burn mode only; `--mode mux` never touches the text and omits it.
1127
1150
 
1151
+ **Cues that can be seen (2.2.6).** A burn counts the cues it actually draws:
1152
+ `cues_burned` (non-blank, overlapping `[0, duration]`) and `cues_outside`
1153
+ (non-blank, wholly after the end or before 0) in the `caption` block, with a
1154
+ warning note naming the outside count. When no cue is visible — the only cue
1155
+ starts after the video ends, every cue is blank, or an `--ass` file has no
1156
+ `Dialogue` lines — the run is refused with `kind: input` (`no cue falls inside
1157
+ the video (0–3.0 s); first cue starts at 10.0 s`, `every cue is blank`), dry
1158
+ runs included. `waveform.py --srt` passes the refusal on. `graphics.py` and
1159
+ `overlay.py` treat whitespace-only `--title`/`--name`/`--text` as missing.
1160
+
1128
1161
  Under `--dry-run`/`--plan` on an input that does not exist yet there is no
1129
1162
  geometry to measure. With `--platform` the destination's own frame is used —
1130
1163
  that frame *is* what the real run will have, so the planned `FontSize` is the
@@ -1400,6 +1433,10 @@ threshold ducks on quieter speech, a shorter release brings the bed back faster.
1400
1433
  Every `--music` / `--effects` / `--replace` file is checked before ffmpeg runs (under
1401
1434
  `--dry-run` too): missing, empty, unreadable or with no audio stream, all of them are named in
1402
1435
  one refusal (`kind: input`, `problems: [{flag, path, reason}]`).
1436
+ Each of those files, and under `--duck` the voice, is also measured (whole-file peak, under
1437
+ not under `--dry-run`): at or below `--silence-threshold` (default -50 dBFS) it is silent.
1438
+ `--on-silent warn` (default) mixes it anyway and reports `silent: [{flag, path, peak_db}]`
1439
+ plus a note; `--on-silent fail` adds it to the same refusal with reason `silent (peak X dBFS)`.
1403
1440
  `--stereo-widen 0..1` widens the stereo image (`extrastereo=m=1+2*amount`) and
1404
1441
  needs a real stereo source: it scales the side signal (L−R), so a mono track
1405
1442
  duplicated to two channels has nothing to scale. A 1-channel input is refused
@@ -50,6 +50,28 @@ def parse_srt(path: str) -> List[Tuple[float, float, str]]:
50
50
  return cues
51
51
 
52
52
 
53
+ NO_SPEECH_REASON = "no_speech"
54
+
55
+
56
+ def die_no_speech(engine: str, video: str) -> None:
57
+ """An engine ran and heard nothing: kind input, `reason: "no_speech"`, naming the engine and
58
+ the input -- not the "no engine found" refusal (one was found) and not "no cues found in"
59
+ the engine's own temporary SRT (a path the caller never gave and that is already deleted)."""
60
+ die(f"{engine} found no speech in {video}", kind="input", reason=NO_SPEECH_REASON, engine=engine,
61
+ hint="check that the input (and --audio-stream) carries the speech, or write the cues by hand with --text")
62
+
63
+
64
+ def _engine_cues(srt: str, engine: str, video: str) -> List[Tuple[float, float, str]]:
65
+ """The cues of an SRT an engine wrote; an SRT with no cue in it is die_no_speech()."""
66
+ try:
67
+ text = Path(srt).read_text(encoding="utf-8", errors="replace")
68
+ except OSError:
69
+ text = ""
70
+ if "-->" not in text:
71
+ die_no_speech(engine, video)
72
+ return parse_srt(srt)
73
+
74
+
53
75
  def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
54
76
  with open(path, "w", encoding="utf-8") as fh:
55
77
  for i, (s, e, t) in enumerate(cues, 1):
@@ -122,7 +144,7 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
122
144
  proc = _asr_run(cmd, subprocess, "whisper.cpp")
123
145
  if proc.returncode == 0 and os.path.exists(base + ".srt"):
124
146
  info(f"transcribed with whisper.cpp ({os.path.basename(cli)}, model {os.path.basename(model_path)})")
125
- cues = parse_srt(base + ".srt")
147
+ cues = _engine_cues(base + ".srt", "whisper.cpp", video)
126
148
  write_srt(cues, out_srt)
127
149
  return cues
128
150
  info("whisper.cpp found but failed: " + (proc.stderr.strip().splitlines() or ["?"])[-1][:200])
@@ -144,10 +166,11 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
144
166
  if t.is_alive():
145
167
  die(f"faster-whisper exceeded the {STATE.timeout:.0f} s time limit; raise --timeout for a long recording", code=124, kind="timeout")
146
168
  cues = list(result)
147
- if cues:
148
- info("transcribed with faster-whisper")
149
- write_srt(cues, out_srt)
150
- return cues
169
+ if not cues:
170
+ die_no_speech("faster-whisper", video)
171
+ info("transcribed with faster-whisper")
172
+ write_srt(cues, out_srt)
173
+ return cues
151
174
  except ImportError:
152
175
  pass
153
176
  # 3. openai-whisper CLI
@@ -159,7 +182,7 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
159
182
  srt = os.path.join(tmpdir, "audio.srt")
160
183
  if proc.returncode == 0 and os.path.exists(srt):
161
184
  info("transcribed with openai-whisper")
162
- cues = parse_srt(srt)
185
+ cues = _engine_cues(srt, "openai-whisper", video)
163
186
  write_srt(cues, out_srt)
164
187
  return cues
165
188
  die_no_engine("Or write the cues by hand with --text cues.txt (see format above).")
@@ -243,8 +243,21 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Op
243
243
  return path
244
244
 
245
245
 
246
+ def json_safe(obj: Any) -> Any:
247
+ """Replace non-finite floats with the strings loudness.py already reports ("-inf" / "inf")
248
+ and NaN with null: json.dumps would otherwise write -Infinity / NaN, which no strict JSON
249
+ parser accepts (a silent file measures -inf LUFS)."""
250
+ if isinstance(obj, float) and not math.isfinite(obj):
251
+ return None if obj != obj else ("inf" if obj > 0 else "-inf")
252
+ if isinstance(obj, dict):
253
+ return {k: json_safe(v) for k, v in obj.items()}
254
+ if isinstance(obj, (list, tuple)):
255
+ return [json_safe(v) for v in obj]
256
+ return obj
257
+
258
+
246
259
  def print_json(obj: Any) -> None:
247
- sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
260
+ sys.stdout.write(json.dumps(json_safe(obj), indent=2, ensure_ascii=False, allow_nan=False) + "\n")
248
261
 
249
262
 
250
263
  # Deferred for the same reason as runner's import of this module: probe needs die() from here, and
@@ -343,11 +343,15 @@ def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
343
343
  return sorted(set(out))
344
344
 
345
345
 
346
- def measured_level_dbfs(path: str, seconds: float = 120.0) -> Optional[Dict[str, float]]:
346
+ def measured_level_dbfs(path: str, seconds: Optional[float] = 120.0,
347
+ audio_stream: Optional[int] = None) -> Optional[Dict[str, float]]:
347
348
  """Mean and peak level of the first `seconds` of audio (volumedetect), in dBFS; None if unmeasurable.
348
- Cheap enough to run once as a hint when a threshold-based tool found nothing."""
349
+ Cheap enough to run once as a hint when a threshold-based tool found nothing. seconds=None
350
+ measures the whole file; audio_stream picks the Nth audio stream (default: ffmpeg's choice)."""
349
351
  ffmpeg = require_tool("ffmpeg")
350
- proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.0f}", "-i", path, "-vn",
352
+ head = ["-t", f"{seconds:.0f}"] if seconds is not None else []
353
+ pick = ["-map", f"0:a:{audio_stream}"] if audio_stream is not None else []
354
+ proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", *head, "-i", path, *pick, "-vn",
351
355
  "-af", "volumedetect", "-f", "null", "-"], check=False)
352
356
  m_mean = re.search(r"mean_volume:\s*(-?[0-9.]+) dB", proc.stderr)
353
357
  m_max = re.search(r"max_volume:\s*(-?[0-9.]+) dB", proc.stderr)
@@ -463,6 +467,68 @@ def detect_silences(path: str, threshold: float, min_silence: float) -> "List[Tu
463
467
  return silences
464
468
 
465
469
 
470
+ # 2.3.0's silence threshold: a track whose whole-file peak is at or below this is digital
471
+ # silence (join.py / audio.py --silence-threshold default, check.py's audio row).
472
+ SILENT_PEAK_DBFS = -50.0
473
+
474
+ # check.py --content thresholds (docs/design-decisions.md "check.py --content").
475
+ CONTENT_BLACK_PIX_TH = 0.10 # blackdetect pixel threshold (share of the luma range)
476
+ CONTENT_MIN_SPAN = 0.1 # blackdetect minimum run, seconds
477
+ CONTENT_FREEZE_NOISE = "-60dB" # freezedetect noise tolerance
478
+ CONTENT_FREEZE_MIN = 0.5 # freezedetect minimum run, seconds
479
+ CONTENT_SILENCE_MIN = 0.5 # silencedetect minimum run, seconds
480
+
481
+ _BLACK_RE = re.compile(r"black_start:\s*([0-9.]+)\s+black_end:\s*([0-9.]+)")
482
+ _FREEZE_RE = re.compile(r"lavfi\.freezedetect\.freeze_(start|end):\s*([0-9.]+)")
483
+
484
+
485
+ def detect_content(path: str, duration: float, *, video: bool = True, audio: bool = True) -> Dict[str, Any]:
486
+ """One decode pass of blackdetect + freezedetect (video) and silencedetect (audio, at
487
+ SILENT_PEAK_DBFS). Returns {"black": [(s, e)], "frozen": [(s, e)], "silence": [(s, e)]}
488
+ with open-ended spans closed at `duration`; a key is absent when its stream is. {} when the
489
+ input is a pending dry-run placeholder."""
490
+ if dry_run_input_pending(path) or not (video or audio):
491
+ return {}
492
+ ffmpeg = require_tool("ffmpeg")
493
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path]
494
+ if video:
495
+ cmd += ["-map", "0:v:0", "-vf", f"blackdetect=d={CONTENT_MIN_SPAN}:pix_th={CONTENT_BLACK_PIX_TH},"
496
+ f"freezedetect=n={CONTENT_FREEZE_NOISE}:d={CONTENT_FREEZE_MIN}"]
497
+ if audio:
498
+ cmd += ["-map", "0:a:0", "-af", f"silencedetect=noise={SILENT_PEAK_DBFS:g}dB:d={CONTENT_SILENCE_MIN}"]
499
+ proc = run_analysis(cmd + ["-f", "null", "-"], check=False, record=True)
500
+ if proc.returncode != 0:
501
+ die(f"content detection failed:\n{proc.stderr.strip()[-800:]}", kind="ffmpeg")
502
+ end = max(duration, 0.0)
503
+ out: Dict[str, Any] = {}
504
+ if video:
505
+ out["black"] = [(float(a), min(float(b), end) if end else float(b)) for a, b in _BLACK_RE.findall(proc.stderr)]
506
+ frozen: "List[Tuple[float, float]]" = []
507
+ start = None
508
+ for kind, val in _FREEZE_RE.findall(proc.stderr):
509
+ if kind == "start":
510
+ start = float(val)
511
+ elif start is not None:
512
+ frozen.append((start, float(val)))
513
+ start = None
514
+ if start is not None:
515
+ frozen.append((start, end))
516
+ out["frozen"] = frozen
517
+ if audio:
518
+ sil: "List[Tuple[float, float]]" = []
519
+ start = None
520
+ for kind, val in SIL_RE.findall(proc.stderr):
521
+ if kind == "start":
522
+ start = float(val)
523
+ elif start is not None:
524
+ sil.append((start, min(float(val), end) if end else float(val)))
525
+ start = None
526
+ if start is not None:
527
+ sil.append((start, end))
528
+ out["silence"] = sil
529
+ return out
530
+
531
+
466
532
  SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
467
533
 
468
534
 
@@ -196,7 +196,9 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
196
196
  {"capability": "filter:loudnorm", "when": "preset youtube / youtube4k / reels / x with audio: the written file is measured against the platform's loudness target (result `loudness`)"}],
197
197
  video_required=True, audio_only=False, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
198
198
  "check": dict(role="verification", inputs=["media artifact"], outputs=["compliance rows JSON on stdout (no file)"],
199
- required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "loudness rows (default)"}, {"capability": "filter:loudnorm", "when": "loudness rows (default)"}],
199
+ required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "loudness rows (default); the audio row's volumedetect silence test when loudness is skipped"}, {"capability": "filter:loudnorm", "when": "loudness rows (default)"},
200
+ {"capability": "filter:blackdetect", "when": "--content (black row)"}, {"capability": "filter:freezedetect", "when": "--content (frozen row)"},
201
+ {"capability": "filter:silencedetect", "when": "--content (silence row)"}],
200
202
  video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
201
203
  "scenes": dict(role="analysis", inputs=["video asset"], outputs=["scene / audio-peak / highlight JSON on stdout", "EDL text (--edl)", "per-scene contact sheet PNG (--sheet)"],
202
204
  required=FF + ["filter:scdet"], optional=[{"capability": "filter:drawtext", "when": "--sheet"}, {"capability": "filter:tile", "when": "--sheet"},
@@ -231,7 +233,7 @@ DRY_RUN_ANALYSIS = {
231
233
  "cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
232
234
  "silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written",
233
235
  "loudness": "the loudnorm measurement pass runs so input_i and the planned pass-2 command are real; the normalised output is not written",
234
- "check": "read-only tool; the loudness measurement runs under --dry-run too, so every row is present",
236
+ "check": "read-only tool; the loudness measurement (or, when it is skipped, the audio row's volumedetect) and the --content decode run under --dry-run too, so every row is present",
235
237
  "stabilize": "vidstabdetect (pass 1, into a temp file) runs; the stabilised output (pass 2) is not written",
236
238
  }
237
239
  DRY_RUN_NOTES = {
@@ -394,9 +396,9 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
394
396
  if name == "check":
395
397
  extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
396
398
  "notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named: youtube was assumed and judgement rows are WARN"},
397
- "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
399
+ "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
398
400
  elif name == "caption":
399
- extra = {"caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column"},
401
+ extra = {"caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column. Burn mode: cues_burned -- non-blank cues that overlap [0, duration] and are drawn -- and cues_outside -- non-blank cues wholly outside it (2.2.6); a burn with no visible cue is refused, kind input"},
400
402
  "tracks": {"type": "array", "description": "--mode mux: one entry per subtitle stream in the output ({index, file, language, title, codec, default, cues, kept_from_input}); a stream the input already carried has file null and kept_from_input true (1.16)"},
401
403
  "subtitle_tracks": {"type": "integer", "description": "--mode mux: how many subtitle streams the output carries"},
402
404
  "emoji": {"type": "object", "description": "how the emoji in the text were drawn (mode, overlays, missing)"},
@@ -408,6 +410,7 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
408
410
  "notes": {"type": "array", "items": {"type": "string"}}}
409
411
  elif name == "waveform":
410
412
  extra = {"audiogram": {"type": "object", "description": "{style, background ('image' or 'color'), image, position, vis_height, platform, captions, title, stages, verified} -- present on every run, so a plain waveform answers background 'color' (1.16)"},
413
+ "silent": {"type": ["boolean", "null"], "description": "true when the input audio's whole-file peak is at or below --silence-threshold (default -50 dBFS) and --on-silent warn (default) rendered the flat line anyway, with a notes line; false when audible; null under --dry-run (nothing measured). --on-silent fail refuses as kind input instead; verified is unaffected"},
411
414
  "notes": {"type": "array", "items": {"type": "string"}}}
412
415
  elif name == "scenes":
413
416
  extra = {"file": {"type": "string"}, "duration": {"type": "number"}, "scene_count": {"type": "integer"}, "scenes": {"type": "array"}, "audio_peaks": {"type": "array"},
@@ -437,7 +440,7 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
437
440
  elif name == "report":
438
441
  extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"]}}
439
442
  elif name == "export":
440
- extra = {"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file"},
443
+ extra = {"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent"},
441
444
  "notes": {"type": "array", "items": {"type": "string"}}}
442
445
  elif name == "loudness":
443
446
  extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result"},
@@ -461,12 +464,17 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
461
464
  "sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
462
465
  "video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"},
463
466
  "skipped": {"type": "array", "description": "inputs --on-missing skip left out: [{index, path, reason}] ([] when none were)"},
467
+ "silent": {"type": "array", "description": "inputs whose audio peak is at or below --silence-threshold (default -50 dBFS) and that --on-silent warn (default) joined anyway: [{index, path, peak_db}] ([] otherwise; fail refuses them under problems, skip lists them under skipped); an input with no audio stream is never silent"},
468
+ "short_segments": {"type": "array", "description": "warning, never a refusal: measured inputs shorter than 2 frames at the join's fps (audio-only join: shorter than 0.05 s), [{index, path, duration}] ([] when none), each also in notes; a pending --dry-run input is not measured"},
469
+ "duplicates": {"type": "array", "description": "warning, never a refusal (a repeat can be intended): a path (compared resolved) listed more than once and joined each time, [{path, indices}] with 0-based list positions ([] when none), also in notes"},
464
470
  "pending": {"type": "array", "description": "--dry-run: inputs that do not exist yet, planned on as an earlier step's output and never also under skipped: [{index, path}] ([] otherwise)"},
465
- "notes": {"type": "array", "items": {"type": "string"}, "description": "--dry-run with pending inputs: which ones, what a real run does if one is still missing (including a skip that leaves fewer than two inputs), whether the mode came from the extensions, and which planned numbers are the unmeasured stub's placeholders (a pending first clip's frame and rate, xfade offsets after a pending clip)"}}
471
+ "notes": {"type": "array", "items": {"type": "string"}, "description": "--on-silent warn: which silent inputs were joined anyway; short_segments / duplicates: which inputs and why; --dry-run with pending inputs: which ones, what a real run does if one is still missing (including a skip that leaves fewer than two inputs), whether the mode came from the extensions, and which planned numbers are the unmeasured stub's placeholders (a pending first clip's frame and rate, xfade offsets after a pending clip)"}}
466
472
  elif name == "audio":
467
473
  extra = {"video": {"type": "boolean", "description": "true when the input's video stream was copied; false for an audio output extension (extraction)"},
468
474
  "audio_stream": {"type": "integer", "description": "which input audio stream was processed (--audio-stream)"},
469
475
  "dynamics": {"type": "array", "items": {"enum": ["agate", "acompressor", "alimiter"]}, "description": "typed dynamics filters applied, in graph order"},
476
+ "silent": {"type": "array", "description": "--on-silent warn (default), only when a track was silent: [{flag, path, peak_db}] for each --music/--replace/--effects file, or under --duck the voice (flag \"input\"), whose whole-file peak is at or below --silence-threshold (default -50 dBFS); it was mixed anyway. --on-silent fail refuses instead, reason \"silent (peak X dBFS)\" in problems"},
477
+ "notes": {"type": "array", "items": {"type": "string"}, "description": "present with `silent`: which tracks were silent and how to refuse them"},
470
478
  "audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db actually used)"}}
471
479
  props = dict(base)
472
480
  props.update(extra)
package/scripts/audio.py CHANGED
@@ -25,7 +25,7 @@ import sys
25
25
  from pathlib import Path
26
26
  from typing import Any, Dict, List
27
27
 
28
- 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, probe, run, run_keeping_subtitles, fmt_secs
28
+ 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
29
 
30
30
  VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
31
31
 
@@ -129,6 +129,30 @@ def preflight_beds(args: argparse.Namespace) -> List[Dict[str, Any]]:
129
129
  return problems
130
130
 
131
131
 
132
+ def find_silent_tracks(args: argparse.Namespace, problems: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
133
+ """Every extra file (and, under --duck, the voice track that keys the ducking) whose whole-file
134
+ peak is at or below --silence-threshold dBFS: a silent --music / --replace / --effects bed
135
+ used to be mixed and reported as verified, and a silent voice leaves --duck with nothing to
136
+ duck on. Files already named in `problems`, and files an earlier stage has not written yet
137
+ (--dry-run), are not measured; a real file is measured under --dry-run too, like the preflight."""
138
+ bad = {p["path"] for p in problems}
139
+ tracks = [(f, v, None) for f, v in (("--replace", args.replace), ("--music", args.music),
140
+ ("--effects", args.effects)) if v and v not in bad]
141
+ if args.duck and not args.replace:
142
+ tracks.append(("input", args.input, args.audio_stream))
143
+ silent: List[Dict[str, Any]] = []
144
+ if STATE.dry_run:
145
+ # a writing tool's dry run runs no ffmpeg (docs/contract.md); the real run measures
146
+ return silent
147
+ for flag, path, stream in tracks:
148
+ if dry_run_input_pending(path) or not os.path.isfile(path):
149
+ continue
150
+ level = measured_level_dbfs(path, seconds=None, audio_stream=stream)
151
+ if level is not None and level["peak_dbfs"] <= args.silence_threshold:
152
+ silent.append({"flag": flag, "path": path, "peak_db": level["peak_dbfs"]})
153
+ return silent
154
+
155
+
132
156
  def main() -> int:
133
157
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
134
158
  ap.add_argument("input")
@@ -188,6 +212,11 @@ def main() -> int:
188
212
  dyn.add_argument("--gate-knee", type=float, help="knee dB 1..8 (default 2.83)")
189
213
  ap.add_argument("--audio-stream", type=int, default=0, help="which audio stream of the input to process, 0-based in file order (probe lists them under audio_streams)")
190
214
  ap.add_argument("--bitrate", default="192k")
215
+ ap.add_argument("--on-silent", choices=["warn", "fail"], default="warn",
216
+ help="a --music/--replace/--effects file (or, under --duck, the voice) whose peak is at or below "
217
+ "--silence-threshold: warn (default: mix it, report it under `silent`) or fail (refuse before ffmpeg runs)")
218
+ ap.add_argument("--silence-threshold", type=float, default=-50.0, metavar="DBFS",
219
+ help="peak level in dBFS at or below which a track counts as silent (default -50)")
191
220
  add_common(ap)
192
221
  args = ap.parse_args()
193
222
  apply_common(args)
@@ -216,11 +245,23 @@ def main() -> int:
216
245
  die(f"--stereo-widen must be 0..1 (0 = untouched, 1 = maximum), got {args.stereo_widen:g}")
217
246
 
218
247
  problems = preflight_beds(args)
248
+ silent = find_silent_tracks(args, problems)
249
+ if args.on_silent == "fail":
250
+ problems += [{"flag": t["flag"], "path": t["path"], "reason": f"silent (peak {t['peak_db']:g} dBFS)"} for t in silent]
251
+ silent = []
219
252
  if problems:
220
253
  die(f"{len(problems)} of the extra audio files cannot be mixed: "
221
254
  + "; ".join(f"{p['flag']} {p['path']}: {p['reason']}" for p in problems),
222
255
  kind="input", problems=problems,
223
- hint="each --music / --effects / --replace file must be a readable file with an audio stream")
256
+ hint="each --music / --effects / --replace file must be a readable file with an audio stream"
257
+ + (f" and a peak above {args.silence_threshold:g} dBFS (--on-silent warn mixes a silent one anyway)"
258
+ if args.on_silent == "fail" else ""))
259
+ notes: List[str] = []
260
+ if silent:
261
+ notes.append("silent track(s) mixed anyway: " + "; ".join(
262
+ f"{t['flag']} {t['path']} peaks at {t['peak_db']:g} dBFS" for t in silent)
263
+ + f" (at or below --silence-threshold {args.silence_threshold:g}); pass --on-silent fail to refuse instead")
264
+ info("warning: " + notes[-1])
224
265
 
225
266
  meta = probe(args.input)
226
267
  dur = meta.get("duration") or 0.0
@@ -376,7 +417,8 @@ def main() -> int:
376
417
  if args.duck else None)
377
418
  emit(output, audio=audio_block, video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
378
419
  dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f],
379
- dropped_non_av_streams=dropped_streams)
420
+ dropped_non_av_streams=dropped_streams,
421
+ **({"silent": silent, "notes": notes} if silent else {}))
380
422
  return 0
381
423
 
382
424
 
@@ -491,6 +491,53 @@ def parse_ass_dialogue(path: str) -> str:
491
491
  return "\n".join(text)
492
492
 
493
493
 
494
+ def parse_ass_cues(path: str) -> List[Tuple[float, float, str]]:
495
+ """(start, end, drawn text) of every Dialogue line of an ASS file, override blocks stripped."""
496
+ def secs(stamp: str) -> float:
497
+ h, m, rest = stamp.strip().split(":")
498
+ return int(h) * 3600 + int(m) * 60 + float(rest)
499
+
500
+ cues: List[Tuple[float, float, str]] = []
501
+ # the [Events] Format line names the columns; Text is always last (libass reads it the same
502
+ # way), and a file without one gets the standard ten
503
+ names = ["layer", "start", "end", "style", "name", "marginl", "marginr", "marginv", "effect", "text"]
504
+ for line in read_text_or_die(path, "--ass").lstrip("\ufeff").splitlines():
505
+ if line.startswith("Format:") and "text" in line.lower():
506
+ names = [n.strip().lower() for n in line.partition(":")[2].split(",")]
507
+ continue
508
+ if not line.startswith("Dialogue:"):
509
+ continue
510
+ fields = line.partition(":")[2].split(",", len(names) - 1)
511
+ if len(fields) < len(names) or "start" not in names or "end" not in names:
512
+ continue
513
+ try:
514
+ start, end = secs(fields[names.index("start")]), secs(fields[names.index("end")])
515
+ except (ValueError, IndexError):
516
+ continue
517
+ text = re.sub(r"\{[^}]*\}", "", fields[-1]).replace("\\N", "\n").replace("\\n", "\n").replace("\\h", " ")
518
+ cues.append((start, end, text))
519
+ return cues
520
+
521
+
522
+ def cue_text_visible(text: str) -> bool:
523
+ return bool((text or "").strip())
524
+
525
+
526
+ def count_visible_cues(cues, duration) -> Tuple[int, int]:
527
+ """(cues drawn inside [0, duration], non-blank cues entirely outside it). Blank cues count in
528
+ neither. With no known duration every non-blank cue is taken as drawn."""
529
+ burned = outside = 0
530
+ dur = float(duration) if duration else None
531
+ for start, end, text in cues:
532
+ if not cue_text_visible(text):
533
+ continue
534
+ if end > 0 and (dur is None or start < dur):
535
+ burned += 1
536
+ else:
537
+ outside += 1
538
+ return burned, outside
539
+
540
+
494
541
  def shift_ass_file(src: str, dst: str, offset: float) -> int:
495
542
  """Copy an ASS file with every Dialogue start/end moved by `offset` seconds."""
496
543
  def shift(stamp: str) -> str:
@@ -1247,8 +1294,8 @@ def main() -> int:
1247
1294
  srt_path = new_srt
1248
1295
  planned_cues = adjusted
1249
1296
  side_notes.append(f"the burned subtitles are {new_srt}, the adjusted copy of "
1250
- f"{os.path.basename(args.srt)} this run writes (--offset/--max-lines/--min-duration); "
1251
- "re-run this command without --dry-run to produce it")
1297
+ f"{os.path.basename(args.srt)} this run writes (--offset/--max-lines/--min-duration)"
1298
+ + ("; re-run this command without --dry-run to produce it" if STATE.dry_run else ""))
1252
1299
  if args.ass and args.offset and os.path.exists(args.ass):
1253
1300
  shifted = os.path.splitext(output)[0] + "_offset.ass"
1254
1301
  if STATE.dry_run:
@@ -1258,8 +1305,8 @@ def main() -> int:
1258
1305
  info(f"wrote {shifted} ({n} cues shifted by {args.offset:+g} s)")
1259
1306
  args.ass = shifted
1260
1307
  side_notes.append(f"the burned subtitles are {shifted}, the offset copy of "
1261
- f"{os.path.basename(ass_sample_path)} this run writes; re-run this command "
1262
- "without --dry-run to produce it")
1308
+ f"{os.path.basename(ass_sample_path)} this run writes"
1309
+ + ("; re-run this command without --dry-run to produce it" if STATE.dry_run else ""))
1263
1310
  # a side file this run has planned but (under --dry-run) not written is still the file the
1264
1311
  # command names, so its absence must not be reported as a missing input
1265
1312
  planned_only = STATE.dry_run and planned_cues is not None
@@ -1436,6 +1483,44 @@ def main() -> int:
1436
1483
  if args.ass:
1437
1484
  if not generated_ass and not os.path.exists(args.ass) and not planned_ass:
1438
1485
  die(f"ASS file not found: {args.ass}")
1486
+ # 2.2.6: a burn whose cues are all blank, or all outside the video, draws nothing -- and used
1487
+ # to report verified. Count the cues that can actually be seen; none is a refusal.
1488
+ if generated_ass:
1489
+ burn_cues: Optional[List[Tuple[float, float, str]]] = cues_for_ass
1490
+ elif args.ass and not os.path.exists(ass_sample_path):
1491
+ # a dry run's pending --ass (an earlier step writes it): nothing to count yet
1492
+ burn_cues = None
1493
+ elif args.ass:
1494
+ if planned_ass: # dry run: the offset copy is not written yet, shift the caller's file
1495
+ burn_cues = [(s + args.offset, e + args.offset, t) for s, e, t in parse_ass_cues(ass_sample_path)]
1496
+ else:
1497
+ burn_cues = parse_ass_cues(args.ass)
1498
+ if not burn_cues:
1499
+ die(f"no Dialogue lines found in {ass_sample_path} -- nothing would be drawn", kind="input")
1500
+ elif planned_cues is not None:
1501
+ burn_cues = planned_cues
1502
+ elif args.text or args.transcribe:
1503
+ burn_cues = cues or None # empty under a --transcribe dry run: nothing to count yet
1504
+ elif srt_path and os.path.exists(srt_path):
1505
+ burn_cues = parse_srt(srt_path)
1506
+ else:
1507
+ burn_cues = None
1508
+ if burn_cues:
1509
+ burned, outside = count_visible_cues(burn_cues, meta.get("duration"))
1510
+ if not burned and not outside:
1511
+ die("every cue is blank -- nothing would be drawn", kind="input")
1512
+ if not burned:
1513
+ first = min(s for s, _e, t in burn_cues if cue_text_visible(t))
1514
+ die(f"no cue falls inside the video (0\u2013{float(meta['duration']):.1f} s); first cue "
1515
+ f"starts at {first:.1f} s -- check --offset or the cue file", kind="input")
1516
+ if outside:
1517
+ msg = (f"{outside} cue(s) fall outside the video (0\u2013{float(meta['duration']):.1f} s) "
1518
+ "and are not drawn")
1519
+ info(f"warning: {msg}")
1520
+ side_notes.append(msg)
1521
+ caption_stats["cues_burned"] = burned
1522
+ caption_stats["cues_outside"] = outside
1523
+ if args.ass:
1439
1524
  vf = f"ass={escape_filter_path(args.ass)}"
1440
1525
  if args.fonts_dir:
1441
1526
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
package/scripts/check.py CHANGED
@@ -18,6 +18,12 @@ Examples:
18
18
  python3 check.py reel.mp4 --platform reels --json
19
19
  python3 check.py spot.mov --platform broadcast
20
20
  python3 check.py clip.mp4 --platform custom --max-duration 30 --aspect 1:1 --lufs -16
21
+ python3 check.py final.mp4 --platform reels --content
22
+
23
+ --content (opt-in) adds three content rows from one decode pass: black (share of the
24
+ duration that is black: WARN > 10%, FAIL >= 95%), frozen (longest frozen span: WARN >
25
+ max(3 s, 30% of the duration), FAIL when the whole video is frozen) and silence (share
26
+ below -50 dBFS: WARN > 50%, FAIL >= 95%). Without it the row set is unchanged.
21
27
  """
22
28
  import argparse
23
29
  import json
@@ -27,6 +33,7 @@ from fractions import Fraction
27
33
  from typing import Any, Dict, List
28
34
 
29
35
  from _platforms import PLATFORMS, PLATFORM_CHOICES, spec_of, resolve as resolve_platform
36
+ from _common.probe import SILENT_PEAK_DBFS, detect_content, measured_level_dbfs
30
37
  from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
31
38
 
32
39
  # The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
@@ -52,6 +59,17 @@ def measure_loudness(path: str) -> Dict[str, float]:
52
59
  return {}
53
60
 
54
61
 
62
+ # --content thresholds (docs/design-decisions.md, "check.py --content").
63
+ BLACK_WARN, BLACK_FAIL = 0.10, 0.95 # share of the duration that is black
64
+ FROZEN_WARN_S, FROZEN_WARN_SHARE = 3.0, 0.30 # longest frozen span: WARN above max(3 s, 30%)
65
+ FROZEN_FAIL = 0.95 # a frozen span covering this share = whole video frozen
66
+ SILENCE_WARN, SILENCE_FAIL = 0.50, 0.95 # share of the duration below SILENT_PEAK_DBFS
67
+
68
+
69
+ def _span_total(spans: Any) -> float:
70
+ return sum(max(0.0, e - s) for s, e in spans)
71
+
72
+
55
73
  def aspect_name(w: int, h: int) -> str:
56
74
  f = Fraction(w, h)
57
75
  for name, target in (("16:9", Fraction(16, 9)), ("9:16", Fraction(9, 16)), ("1:1", Fraction(1)), ("4:5", Fraction(4, 5)), ("4:3", Fraction(4, 3)), ("21:9", Fraction(21, 9))):
@@ -70,6 +88,8 @@ def main() -> int:
70
88
  ap.add_argument("--tp", type=float, help="override true-peak ceiling")
71
89
  ap.add_argument("--max-mb", type=float, help="override max file size in MB")
72
90
  ap.add_argument("--no-loudness", action="store_true", help="skip the loudness measurement (faster)")
91
+ ap.add_argument("--content", action="store_true",
92
+ help="also check the content (one decode pass): black, frozen and silence rows")
73
93
  add_common(ap)
74
94
  args = ap.parse_args()
75
95
  apply_common(args)
@@ -193,10 +213,24 @@ def main() -> int:
193
213
  "by number and the viewer has to guess") if untagged else "")
194
214
 
195
215
  if a:
216
+ audio_row = next(r for r in rows if r["check"] == "audio")
217
+
218
+ def silent_audio(peak: float) -> None:
219
+ # a present-but-silent track is the failed-TTS / muted-export case: "present" is not a pass
220
+ # FAIL under a platform with a loudness target (it would fail loudness anyway); WARN
221
+ # otherwise: a muted screen recording or a b-roll clip is a legitimate silent track
222
+ audio_row.update(status="FAIL" if spec["lufs"] is not None else "WARN", value=audio_row["value"] + f", silent (peak {peak:.1f} dB)",
223
+ expected=f"present, peak above {SILENT_PEAK_DBFS:g} dBFS",
224
+ fix="audio.py --replace with the real track, or re-export from the master",
225
+ reason="the audio track exists but carries no sound: viewers hear nothing")
226
+ loudness_ran = False
196
227
  if a.get("sample_rate") and a["sample_rate"] not in (44100, 48000):
197
228
  row("sample rate", "WARN", a["sample_rate"], "44100 or 48000", "loudness.py --sample-rate 48000")
198
229
  if not args.no_loudness and spec["lufs"] is not None:
199
230
  lm = measure_loudness(args.input)
231
+ loudness_ran = bool(lm)
232
+ if lm and lm["tp"] <= SILENT_PEAK_DBFS:
233
+ silent_audio(lm["tp"])
200
234
  if lm:
201
235
  diff = abs(lm["lufs"] - spec["lufs"])
202
236
  row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g} for speech or music; leave ambience/near-silence (<= -40 LUFS) alone and say so",
@@ -210,11 +244,39 @@ def main() -> int:
210
244
  row("loudness", "WARN", "could not measure", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", "re-run check.py, or verify loudness manually",
211
245
  reason="ffmpeg's loudness measurement didn't produce a readable result -- this was not actually checked")
212
246
  row("true peak", "WARN", "could not measure", f"<= {spec['tp']:g} dBTP", "re-run check.py, or verify true peak manually")
247
+ if not loudness_ran and not dry_run_input_pending(args.input):
248
+ # loudness skipped (--no-loudness, or a spec with no target): one cheap volumedetect pass
249
+ lv = measured_level_dbfs(args.input, seconds=None)
250
+ if lv and lv["peak_dbfs"] <= SILENT_PEAK_DBFS:
251
+ silent_audio(lv["peak_dbfs"])
213
252
  elif args.platform in ("podcast",):
214
253
  row("audio", "FAIL", "none", "audio stream", "audio.py --replace")
215
254
  else:
216
255
  row("audio", "WARN", "none", "audio stream", "audio.py --replace (silent uploads are often rejected)")
217
256
 
257
+ if args.content:
258
+ cd = detect_content(args.input, dur, video=bool(v), audio=bool(a))
259
+ span = dur if dur > 0 else 0.0
260
+ if "black" in cd and span:
261
+ share = _span_total(cd["black"]) / span
262
+ row("black", "FAIL" if share >= BLACK_FAIL else ("WARN" if share > BLACK_WARN else "PASS"),
263
+ f"{share * 100:.0f}% black", f"<= {BLACK_WARN * 100:.0f}% black",
264
+ "look.py the black spans; trim them with cut.py or re-export from the master",
265
+ reason="an all-black or mostly black picture is usually a failed render or a missing clip")
266
+ if "frozen" in cd and span:
267
+ longest = max((e - s for s, e in cd["frozen"]), default=0.0)
268
+ limit = max(FROZEN_WARN_S, FROZEN_WARN_SHARE * span)
269
+ row("frozen", "FAIL" if longest >= FROZEN_FAIL * span else ("WARN" if longest > limit else "PASS"),
270
+ f"longest {longest:.1f}s", f"<= {limit:.1f}s frozen",
271
+ "look.py the frozen span: a still title card is fine, a stuck decode is not",
272
+ reason="a picture that never changes is usually a stuck frame, not the edit")
273
+ if "silence" in cd and span:
274
+ share = _span_total(cd["silence"]) / span
275
+ row("silence", "FAIL" if share >= SILENCE_FAIL else ("WARN" if share > SILENCE_WARN else "PASS"),
276
+ f"{share * 100:.0f}% silent", f"<= {SILENCE_WARN * 100:.0f}% below {SILENT_PEAK_DBFS:g} dBFS",
277
+ "silence.py to cut the gaps, or audio.py --replace when the track is empty",
278
+ reason="most of the running time has no audible sound")
279
+
218
280
  failed = [r for r in rows if r["status"] == "FAIL"]
219
281
  warned = [r for r in rows if r["status"] == "WARN"]
220
282
  notes: List[str] = []
package/scripts/export.py CHANGED
@@ -30,6 +30,7 @@ Examples:
30
30
  """
31
31
  import argparse
32
32
  import json
33
+ import math
33
34
  import os
34
35
  import sys
35
36
  from pathlib import Path
@@ -227,7 +228,14 @@ def main() -> int:
227
228
  ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
228
229
  extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok}
229
230
  extra["verification"] = [{"step": "loudness", "ok": ok, "platform": platform}]
230
- if not ok and args.normalize:
231
+ if not math.isfinite(m["lufs"]):
232
+ # a silent output measures -inf LUFS: no gain reaches the target, so recommending
233
+ # (or running, under --normalize) loudness.py only returns "nothing to normalise"
234
+ extra["loudness"]["silent"] = True
235
+ notes.append(f"output audio is silent (integrated loudness -inf, {platform} expects {spec['lufs']:g} LUFS); "
236
+ "check the source's audio or give the export a real track (audio.py --replace)")
237
+ info("warning: " + notes[-1])
238
+ elif not ok and args.normalize:
231
239
  # Eval 7: every platform job ran export -> loudness.py -> export again (two video
232
240
  # encodes). The levels pass only re-encodes audio, so do it here on the written
233
241
  # file and the caller gets one export that meets the spec.
@@ -120,6 +120,12 @@ def main() -> int:
120
120
  add_common(ap)
121
121
  args = ap.parse_args()
122
122
  apply_common(args)
123
+ # 2.2.6: whitespace-only text is no text -- stripped before the "needs --title" checks, which
124
+ # used to pass on " " and burn an empty bar
125
+ for _attr in ("name", "title", "subtitle", "text", "top", "bottom"):
126
+ _v = getattr(args, _attr, None)
127
+ if isinstance(_v, str):
128
+ setattr(args, _attr, _v if _v.strip() else None)
123
129
 
124
130
  args.platform = resolve_platform(args.platform)
125
131
  if args.platform and not PLATFORMS[args.platform].get("frame"):
package/scripts/join.py CHANGED
@@ -27,13 +27,19 @@ refused with every problem named at once (kind input), never discovered one run
27
27
  Under --dry-run a segment that does not exist yet is an earlier step's output: it is planned
28
28
  on and named under `pending` (never `skipped`), whichever --on-missing was given, and its
29
29
  extension stands in for what it will hold (an audio extension: no picture).
30
+
31
+ Every input with an audio stream is also measured (one volumedetect pass): a segment whose peak
32
+ is at or below --silence-threshold (default -50 dBFS) is silent -- the trace of a TTS call that
33
+ wrote a valid but empty wav. --on-silent warn (default) joins it and names it under `silent`;
34
+ fail refuses it with the other input problems; skip drops it into `skipped`. A clip with no
35
+ audio stream is never silent.
30
36
  """
31
37
  import argparse
32
38
  import os
33
39
  import sys
34
40
  from typing import Any, Dict, List, Optional, Tuple
35
41
 
36
- from _common import STATE, AUDIO_MEDIA_EXT, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
42
+ from _common import STATE, AUDIO_MEDIA_EXT, 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
37
43
 
38
44
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
39
45
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -43,12 +49,23 @@ STATE_SKIPPED: List[Dict[str, Any]] = []
43
49
  # --dry-run: inputs that do not exist yet, planned on as an earlier step's output
44
50
  STATE_PENDING: List[Dict[str, Any]] = []
45
51
  STATE_NOTES: List[str] = []
52
+ # --on-silent warn: inputs whose audio peak is at or below --silence-threshold, joined anyway
53
+ STATE_SILENT: List[Dict[str, Any]] = []
54
+ # warnings, never refusals: a clip shorter than MIN_SEGMENT_FRAMES frames (audio-only: shorter than
55
+ # MIN_AUDIO_SEGMENT seconds) and a path listed more than once (repeating a clip can be intentional)
56
+ STATE_SHORT: List[Dict[str, Any]] = []
57
+ STATE_DUPES: List[Dict[str, Any]] = []
58
+ # original 0-based list position of each input still in args.inputs (after any skip)
59
+ STATE_KEPT: List[int] = []
60
+ MIN_SEGMENT_FRAMES = 2
61
+ MIN_AUDIO_SEGMENT = 0.05
46
62
 
47
63
 
48
64
  def reported() -> Dict[str, Any]:
49
65
  """The input bookkeeping both success documents carry: `skipped` is what the join left out,
50
66
  `pending` what a dry run planned on without it existing; `notes` says the same in words."""
51
- extra: Dict[str, Any] = {"skipped": list(STATE_SKIPPED), "pending": list(STATE_PENDING)}
67
+ extra: Dict[str, Any] = {"skipped": list(STATE_SKIPPED), "pending": list(STATE_PENDING), "silent": list(STATE_SILENT),
68
+ "short_segments": list(STATE_SHORT), "duplicates": list(STATE_DUPES)}
52
69
  if STATE_NOTES:
53
70
  extra["notes"] = list(STATE_NOTES)
54
71
  return extra
@@ -77,6 +94,29 @@ def clip_length(meta: Dict[str, Any], fps: float) -> float:
77
94
  return v
78
95
 
79
96
 
97
+ def find_duplicates(paths: List[str]) -> List[Dict[str, Any]]:
98
+ """Every path (compared by its resolved absolute form) listed more than once, with the 0-based
99
+ list positions it appears at. A warning, not a refusal: repeating a clip can be intentional."""
100
+ seen: Dict[str, List[int]] = {}
101
+ for i, p in enumerate(paths):
102
+ seen.setdefault(os.path.realpath(p), []).append(i)
103
+ return [{"path": paths[ix[0]], "indices": ix} for ix in seen.values() if len(ix) > 1]
104
+
105
+
106
+ def note_short_segments(paths: List[str], metas: List[dict], minimum: float, unit: str) -> None:
107
+ """Record every measured input shorter than `minimum` seconds (a pending dry-run stub is not
108
+ measured): such a clip joins as a flash nobody sees -- usually a failed earlier step."""
109
+ for p, m, k in zip(paths, metas, STATE_KEPT or range(len(paths))):
110
+ dur = m.get("duration") or 0.0
111
+ if m.get("dry_run") or dur >= minimum:
112
+ continue
113
+ STATE_SHORT.append({"index": k, "path": p, "duration": round(dur, 4)})
114
+ if STATE_SHORT:
115
+ listing = ", ".join(f"#{s['index'] + 1} {s['path']} ({s['duration']:g}s)" for s in STATE_SHORT)
116
+ info(f"warning: {len(STATE_SHORT)} input(s) shorter than {unit}: {listing}")
117
+ STATE_NOTES.append(f"{len(STATE_SHORT)} input(s) are shorter than {unit} and were joined anyway: {listing}")
118
+
119
+
80
120
  def expected_text(expected: Optional[float]) -> str:
81
121
  """unpending_length() as the stderr summary line puts it."""
82
122
  return f"expected ~{expected:.3f}s" if expected is not None else "expected length unknown until the pending inputs exist"
@@ -87,6 +127,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
87
127
  n = len(args.inputs)
88
128
  durs = [m.get("duration") or 0.0 for m in metas]
89
129
  d = args.duration if args.transition != "none" else 0.0
130
+ note_short_segments(args.inputs, metas, MIN_AUDIO_SEGMENT, f"{MIN_AUDIO_SEGMENT:g}s")
90
131
  for p, dur in zip(args.inputs, durs):
91
132
  if d and dur <= d * 2 and not STATE.dry_run:
92
133
  die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s crossfade; shorten --duration")
@@ -164,12 +205,13 @@ def read_list(path: str) -> List[str]:
164
205
  return entries
165
206
 
166
207
 
167
- def preflight(paths: List[str]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
208
+ def preflight(paths: List[str], audible: Optional[List[int]] = None) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
168
209
  """Every problem with every input, found before anything runs: missing, empty (0 bytes,
169
210
  the usual trace of a TTS or download step that failed after creating its file), or not
170
211
  readable as media. One ffprobe per file; nothing is decoded. Under --dry-run a file that
171
212
  does not exist yet is not a problem but pending (the second list, with a note): in a
172
- planned pipeline it is an earlier step's output, and the plan keeps it."""
213
+ planned pipeline it is an earlier step's output, and the plan keeps it. The index of every
214
+ readable input with an audio stream is appended to `audible` (for find_silent())."""
173
215
  ffprobe = require_tool("ffprobe")
174
216
  problems: List[Dict[str, Any]] = []
175
217
  pending: List[Dict[str, Any]] = []
@@ -192,9 +234,30 @@ def preflight(paths: List[str]) -> Tuple[List[Dict[str, Any]], List[Dict[str, An
192
234
  if proc.returncode != 0 or not kinds & {"audio", "video"}:
193
235
  first = ((proc.stderr or "").strip().splitlines() or ["no audio or video stream"])[-1]
194
236
  problems.append({"index": i, "path": p, "reason": f"unreadable: {first}"})
237
+ elif "audio" in kinds and audible is not None:
238
+ audible.append(i)
195
239
  return problems, pending
196
240
 
197
241
 
242
+ def find_silent(paths: List[str], audible: List[int], threshold: float) -> List[Dict[str, Any]]:
243
+ """The audible inputs whose whole-file peak (volumedetect max_volume) is at or below
244
+ `threshold` dBFS: one decode of the audio per input. Not under --dry-run: join is a writing
245
+ tool, and a dry run of it (or of render.py, which joins through it) runs no ffmpeg -- the plan
246
+ notes that silence is measured on the real run. An input whose level cannot be measured is
247
+ left to the join itself."""
248
+ silent: List[Dict[str, Any]] = []
249
+ if STATE.dry_run:
250
+ if audible:
251
+ STATE_NOTES.append("silence is not measured under --dry-run; a real run measures every input with audio "
252
+ "for --on-silent")
253
+ return silent
254
+ for i in audible:
255
+ lv = measured_level_dbfs(paths[i], seconds=86400)
256
+ if lv is not None and lv["peak_dbfs"] <= threshold:
257
+ silent.append({"index": i, "path": paths[i], "peak_db": lv["peak_dbfs"]})
258
+ return silent
259
+
260
+
198
261
  def main() -> int:
199
262
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
200
263
  ap.add_argument("inputs", nargs="*", help="two or more clips in order (or --list FILE)")
@@ -203,6 +266,12 @@ def main() -> int:
203
266
  ap.add_argument("--on-missing", choices=["fail", "skip"], default="fail",
204
267
  help="a missing, empty or unreadable input: fail (default; every problem named at once) or skip "
205
268
  "(join the rest; each skipped input is reported under `skipped`)")
269
+ ap.add_argument("--on-silent", choices=["warn", "fail", "skip"], default="warn",
270
+ help="an input whose audio peak is at or below --silence-threshold: warn (default; join it, "
271
+ "name it under `silent`), fail (refuse with the other input problems) or skip (leave it out, "
272
+ "under `skipped`); a clip with no audio stream is never silent")
273
+ ap.add_argument("--silence-threshold", type=float, default=-50.0, metavar="DB",
274
+ help="peak level in dBFS at or below which an input counts as silent (default -50)")
206
275
  ap.add_argument("-o", "--output", help="output file (default: <first>_joined.mp4)")
207
276
  ap.add_argument("--transition", choices=TRANSITIONS, default="fade", help="transition between clips (default fade)")
208
277
  ap.add_argument("--duration", type=float, default=0.5, help="transition length in seconds (default 0.5)")
@@ -228,19 +297,52 @@ def main() -> int:
228
297
  args.inputs = read_list(args.list)
229
298
  if not args.inputs:
230
299
  die("give at least two clips (or --list FILE)")
300
+ if args.silence_threshold >= 0:
301
+ die(f"--silence-threshold is a peak level in dBFS and must be negative, got {args.silence_threshold:g}")
231
302
  skipped: List[Dict[str, Any]] = []
232
- problems, pending = preflight(args.inputs)
233
- if problems and args.on_missing == "fail":
234
- listing = "; ".join(f"#{p['index'] + 1} {p['path']}: {p['reason']}" for p in problems)
235
- die(f"{len(problems)} of {len(args.inputs)} inputs cannot be joined: {listing}",
236
- hint="fix or regenerate them, or pass --on-missing skip to join the rest", problems=problems)
237
- if problems:
238
- bad = {p["index"] for p in problems}
239
- skipped = problems
303
+ orig_inputs = list(args.inputs)
304
+ audible: List[int] = []
305
+ problems, pending = preflight(args.inputs, audible)
306
+ silent = find_silent(args.inputs, audible, args.silence_threshold)
307
+ silent_problems = [{"index": s["index"], "path": s["path"], "reason": f"silent (peak {s['peak_db']:.1f} dBFS)"}
308
+ for s in silent]
309
+ refused = sorted((problems if args.on_missing == "fail" else [])
310
+ + (silent_problems if args.on_silent == "fail" else []), key=lambda p: p["index"])
311
+ if refused:
312
+ listing = "; ".join(f"#{p['index'] + 1} {p['path']}: {p['reason']}" for p in refused)
313
+ ways = []
314
+ if args.on_missing == "fail" and problems:
315
+ ways.append("--on-missing skip")
316
+ if args.on_silent == "fail" and silent:
317
+ ways.append("--on-silent skip (or warn)")
318
+ die(f"{len(refused)} of {len(args.inputs)} inputs cannot be joined: {listing}",
319
+ hint="fix or regenerate them, or pass " + " and ".join(ways) + " to join the rest", problems=refused)
320
+ dropped = sorted((problems if args.on_missing == "skip" else [])
321
+ + (silent_problems if args.on_silent == "skip" else []), key=lambda p: p["index"])
322
+ if dropped:
323
+ bad = {p["index"] for p in dropped}
324
+ skipped = dropped
240
325
  args.inputs = [p for i, p in enumerate(args.inputs) if i not in bad]
241
- for p in problems:
326
+ for p in dropped:
242
327
  info(f"skipping #{p['index'] + 1} {p['path']}: {p['reason']}")
328
+ if silent and args.on_silent == "warn":
329
+ STATE_SILENT[:] = silent
330
+ listing = ", ".join(f"#{s['index'] + 1} {s['path']} (peak {s['peak_db']:.1f} dBFS)" for s in silent)
331
+ info(f"warning: {len(silent)} input(s) silent at or below {args.silence_threshold:g} dBFS, joined anyway: {listing}")
332
+ STATE_NOTES.append(f"{len(silent)} input(s) have no audible sound (peak at or below {args.silence_threshold:g} dBFS) "
333
+ f"and were joined anyway: {listing}; pass --on-silent fail or skip to refuse or drop them")
243
334
  STATE_SKIPPED[:] = skipped
335
+ STATE_KEPT[:] = [i for i in range(len(args.inputs) + len(skipped)) if i not in {p["index"] for p in skipped}]
336
+ dupes = [dd for dd in find_duplicates(orig_inputs)
337
+ if len([i for i in dd["indices"] if i in STATE_KEPT]) > 1]
338
+ for dd in dupes:
339
+ dd["indices"] = [i for i in dd["indices"] if i in STATE_KEPT]
340
+ STATE_DUPES[:] = dupes
341
+ if dupes:
342
+ listing = "; ".join(f"{dd['path']} at #" + ", #".join(str(i + 1) for i in dd["indices"]) for dd in dupes)
343
+ info(f"warning: input(s) listed more than once: {listing}")
344
+ STATE_NOTES.append(f"{len(dupes)} input(s) are listed more than once and joined each time: {listing}; "
345
+ "fine if the repeat is intended")
244
346
  STATE_PENDING[:] = pending
245
347
  if pending:
246
348
  # a dry run cannot know whether the file will be there: it plans on it, and says what a
@@ -305,6 +407,7 @@ def main() -> int:
305
407
  fps = args.fps or first.get("fps") or 30.0
306
408
  fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
307
409
  w, h = w - (w % 2), h - (h % 2)
410
+ note_short_segments(args.inputs, metas, MIN_SEGMENT_FRAMES / fps, f"{MIN_SEGMENT_FRAMES} frames at {fps:g} fps")
308
411
  durs = [m.get("duration") or 0.0 for m in metas]
309
412
  d = args.duration if args.transition != "none" else 0.0
310
413
  # a crossfaded join gives each clip one length for both streams, so the offsets that place
@@ -154,6 +154,8 @@ def main() -> int:
154
154
  args.scale = int(brand.get("logo_scale", 160))
155
155
  if args.opacity == 1.0:
156
156
  args.opacity = float(brand.get("logo_opacity", 1.0))
157
+ if args.text is not None and not args.text.strip():
158
+ die("--text is blank -- give the words to draw", kind="input")
157
159
  if not (args.image or args.text or args.video):
158
160
  die("give --image, --text, --logo or --video")
159
161
  if args.brand:
package/scripts/render.py CHANGED
@@ -29,7 +29,7 @@ Project format (all keys optional except clips):
29
29
  "loudness": {"lufs": -14, "tp": -1},
30
30
  "fit": {"duration": 60},
31
31
  "export": {"preset": "reels", "normalize": true}, (default for platform presets; false opts out)
32
- "check": {"platform": "reels"},
32
+ "check": {"platform": "reels"}, ("content": true adds check.py --content's black/frozen/silence rows)
33
33
  "chapters": "chapters.txt" (or [{"at": "0:00", "title": "Intro"}, ...])
34
34
  }
35
35
 
@@ -149,7 +149,7 @@ OBJECT_KEYS: Dict[str, frozenset] = {
149
149
  "loudness": frozenset({"lufs", "tp"}),
150
150
  "fit": frozenset({"duration", "method", "aspect", "fit", "width", "height", "fps", "smooth"}),
151
151
  "export": frozenset({"preset", "fit", "crf", "normalize"}),
152
- "check": frozenset({"platform"}),
152
+ "check": frozenset({"platform", "content"}),
153
153
  }
154
154
  # Typos difflib cannot see: a clip is trimmed with in/out, not the start/end that time a title.
155
155
  NEAR_KEYS: Dict[str, Dict[str, str]] = {"clips[]": {"start": "in", "end": "out", "from": "in", "to": "out"},
@@ -729,8 +729,11 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
729
729
  check_result = None
730
730
  exit_code = 0
731
731
  for step in plan.get("verify") or []:
732
- if step.get("tool") == "check" and step.get("platform") and output:
733
- cp = run_tool([str(HERE / "check.py"), output, "--platform", step["platform"], "--json"] + child_args())
732
+ # a check step names a platform, asks for check.py --content's black/frozen/silence rows
733
+ # ("content": true), or both -- the same keys a project's "check" stage takes
734
+ if step.get("tool") == "check" and (step.get("platform") or step.get("content") is True) and output:
735
+ cp = run_tool([str(HERE / "check.py"), output] + (["--platform", str(step["platform"])] if step.get("platform") else [])
736
+ + (["--content"] if step.get("content") is True else []) + ["--json"] + child_args())
734
737
  try:
735
738
  check_result = json.loads(cp.stdout)
736
739
  except ValueError:
@@ -742,7 +745,7 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
742
745
  # same command run by hand)
743
746
  if exit_code:
744
747
  failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
745
- info(f"plan done: {output}, but the {check_result.get('platform')} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""))
748
+ info(f"plan done: {output}, but the {check_result.get('platform') or 'content'} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""))
746
749
  else:
747
750
  info(f"plan done: {output}")
748
751
  emit(output, plan=path, tool=tool, stages=[tool] + (["check"] if check_result else []), check=check_result, tool_result=doc,
@@ -1341,7 +1344,8 @@ def main() -> int:
1341
1344
  check_result = None
1342
1345
  exit_code = 0
1343
1346
  if ck and ck.get("platform") and not STATE.dry_run:
1344
- proc = run_tool([str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"] + child_args())
1347
+ proc = run_tool([str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"]
1348
+ + (["--content"] if ck.get("content") is True else []) + child_args())
1345
1349
  try:
1346
1350
  check_result = json.loads(proc.stdout)
1347
1351
  except ValueError:
@@ -37,7 +37,7 @@ import sys
37
37
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
38
38
  from _common import (add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base,
39
39
  info, load_brand, pad_filters, probe, run, STATE, validate_color, video_args, X264_PRESETS,
40
- fmt_secs, child_args, run_tool)
40
+ fmt_secs, child_args, run_tool, measured_level_dbfs)
41
41
 
42
42
  WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
43
43
 
@@ -56,6 +56,11 @@ def main() -> int:
56
56
  ap.add_argument("--split-channels", action="store_true", help="draw each channel in its own lane instead of summing to one")
57
57
  ap.add_argument("--audio-stream", type=int, default=0,
58
58
  help="which audio stream of the input to render, 0-based in file order (default 0)")
59
+ ap.add_argument("--on-silent", choices=["warn", "fail"], default="warn",
60
+ help="input audio whose peak is at or below --silence-threshold (the render would be a flat "
61
+ "line): warn (default: render it, report `silent: true`) or fail (refuse before ffmpeg runs)")
62
+ ap.add_argument("--silence-threshold", type=float, default=-50.0, metavar="DBFS",
63
+ help="peak level in dBFS at or below which the input counts as silent (default -50)")
59
64
  ag = ap.add_argument_group("audiogram (1.16)",
60
65
  "The visualisation over a picture, for an episode that has no video. The image is a "
61
66
  "local file you give: this skill has no network access and never invents cover art.")
@@ -136,6 +141,19 @@ def main() -> int:
136
141
  audio_streams = meta.get("audio_streams") or []
137
142
  if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
138
143
  die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
144
+ if args.silence_threshold >= 0:
145
+ die(f"--silence-threshold is a peak level in dBFS and must be negative, got {args.silence_threshold:g}")
146
+ # silent input draws a flat line that used to be reported as verified; a writing tool's dry
147
+ # run runs no ffmpeg (docs/contract.md), so only the real run measures
148
+ silent_peak = None
149
+ if not STATE.dry_run:
150
+ level = measured_level_dbfs(args.input, seconds=None, audio_stream=args.audio_stream)
151
+ if level is not None and level["peak_dbfs"] <= args.silence_threshold:
152
+ silent_peak = level["peak_dbfs"]
153
+ if args.on_silent == "fail":
154
+ die(f"{args.input} is silent (audio stream {args.audio_stream} peaks at {silent_peak:g} dBFS, at or below "
155
+ f"--silence-threshold {args.silence_threshold:g}): the visualisation would be a flat line",
156
+ kind="input", hint="check the audio, or pass --on-silent warn to render it anyway")
139
157
  output = args.output or default_output(args.input, "waveform")
140
158
 
141
159
  # The visualisation's own band. Without --image/--position it fills the frame, which is what
@@ -243,6 +261,10 @@ def main() -> int:
243
261
  notes.append(f"the visualisation band and {args.platform}'s bottom safe zone overlap by "
244
262
  f"{overlap}px: the captions or the app's own UI will sit over the waveform "
245
263
  f"(lower --vis-height to {max(0.05, (args.height - safe_px) / args.height):.2f})")
264
+ if silent_peak is not None:
265
+ info(f"warning: {args.input} is silent (peak {silent_peak:g} dBFS); the visualisation is a flat line")
266
+ notes.append(f"the input audio is silent (peak {silent_peak:g} dBFS, at or below --silence-threshold "
267
+ f"{args.silence_threshold:g}): the visualisation is a flat line; pass --on-silent fail to refuse instead")
246
268
  duration_ok = True
247
269
  if not STATE.dry_run and meta.get("duration") and result.get("duration"):
248
270
  duration_ok = abs(float(result["duration"]) - float(meta["duration"])) <= 0.05
@@ -259,7 +281,7 @@ def main() -> int:
259
281
  notes.append(f"the render is {float(v['fps']):g} fps against the {args.fps:g} fps asked for")
260
282
  # reported on every run, not only an audiogram one: a caller that keys on `audiogram.background`
261
283
  # should not have to guess whether the key exists (`"color"` is the plain-waveform answer).
262
- extra = {"audiogram": {
284
+ extra = {"silent": None if STATE.dry_run else silent_peak is not None, "audiogram": {
263
285
  "style": args.style,
264
286
  "background": "image" if args.image else "color",
265
287
  "image": args.image,