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 +2 -2
- package/docs/contract.md +32 -3
- package/package.json +1 -1
- package/references/scripts.md +43 -6
- package/scripts/_common/asr.py +29 -6
- package/scripts/_common/emit.py +14 -1
- package/scripts/_common/probe.py +69 -3
- package/scripts/_contract.py +14 -6
- package/scripts/audio.py +45 -3
- package/scripts/caption.py +89 -4
- package/scripts/check.py +62 -0
- package/scripts/export.py +9 -1
- package/scripts/graphics.py +6 -0
- package/scripts/join.py +116 -13
- package/scripts/overlay.py +2 -0
- package/scripts/render.py +10 -6
- package/scripts/waveform.py +24 -2
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*,
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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",
|
package/references/scripts.md
CHANGED
|
@@ -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).
|
|
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
|
|
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
|
package/scripts/_common/asr.py
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 =
|
|
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).")
|
package/scripts/_common/emit.py
CHANGED
|
@@ -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
|
package/scripts/_common/probe.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
package/scripts/_contract.py
CHANGED
|
@@ -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
|
|
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
|
|
package/scripts/caption.py
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
package/scripts/graphics.py
CHANGED
|
@@ -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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
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
|
package/scripts/overlay.py
CHANGED
|
@@ -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
|
-
|
|
733
|
-
|
|
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"]
|
|
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:
|
package/scripts/waveform.py
CHANGED
|
@@ -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,
|