ffmpeg-skill 3.6.7 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/SKILL.md +3 -0
- package/docs/contract.md +24 -5
- package/package.json +1 -1
- package/references/scripts.md +44 -6
- package/references/workflow.md +1 -1
- package/scripts/_common/alpha.py +7 -4
- package/scripts/_common/color.py +30 -0
- package/scripts/_common/runner.py +71 -0
- package/scripts/_contract.py +9 -4
- package/scripts/_platforms.py +17 -1
- package/scripts/_smartcut.py +7 -5
- package/scripts/audio.py +186 -11
- package/scripts/check.py +24 -6
- package/scripts/export.py +3 -2
- package/scripts/render.py +20 -3
package/README.md
CHANGED
|
@@ -250,7 +250,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
250
250
|
|
|
251
251
|
| Tool | What it does |
|
|
252
252
|
|---|---|
|
|
253
|
-
| `audio.py` | Voice clean-up chain at three strengths (`--voice light\|medium\|strong`), FFT denoise, typed compressor / limiter / gate, music bed with sidechain ducking (`--duck-amount/-threshold/-attack/-release`), a never-ducked effects bed (`--effects`), `--stereo-widen`, fades, 5.1 → stereo, track replacement, extraction (`-o out.wav`), `--audio-stream N` |
|
|
253
|
+
| `audio.py` | Voice clean-up chain at three strengths (`--voice light\|medium\|strong`), FFT denoise, typed compressor / limiter / gate, music bed with sidechain ducking (`--duck-amount/-threshold/-attack/-release`), a never-ducked effects bed (`--effects`), `--stereo-widen`, fades, 5.1 → stereo, track replacement, extraction (`-o out.wav`), `--audio-stream N`, a tempo change that keeps the pitch (`--tempo`, atempo stages in 0.5–2.0) and an exact length in samples (`--length-samples`), resampling (`--sample-rate`) and one channel by index (`--channel N`), each read back from the output |
|
|
254
254
|
| `sync.py` | Offset between two recordings by audio cross-correlation (1 ms, pure Python), clock-drift correction; aligned video or audio out (audio-to-audio only — no lip-sync/face detection); a third or later camera is an additional `more_sources` positional (`sync.py REF A B ...`), the `second` positional itself keeps its name |
|
|
255
255
|
| `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP or any target (`--lra` for the range), video stream-copied; the written file is measured again and re-encoded until it meets `--tp` (lossy encoders overshoot); `--measure-only`; `--versions streaming,ebu-r128,broadcast-jp` (or `LUFS/TP` pairs) writes one file per target from one master, each measured after and listed in `versions` (`output` is null), `verified` only when every version met its own target |
|
|
256
256
|
|
|
@@ -269,7 +269,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
269
269
|
|---|---|
|
|
270
270
|
| `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `dnxhr-lb`/`sq`/`hq`/`hqx`/`444`, the alpha masters `prores4444`/`prores4444xq`/`qtrle`/`vp9-alpha`/`hap-alpha` (alpha read back), `key-fill` (a switcher's fill + key pair), `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR or are an editing master (`prores`, `dnxhr-*`: the source's tags kept, codec, profile and pixel format read back) (a BT.601-matrix or RGB source is converted to BT.709, not relabelled); `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) ; `--max-mb MB` fits the file under a size limit by a two-pass encode and measures it ; `--preset copy --repair` remuxes a damaged file (timestamps regenerated, corrupt packets dropped) and counts the errors before and after |
|
|
271
271
|
| `proxy.py` | Small, low-bitrate proxy for downstream AI analysis/preview/editing decisions — resize by `--width`/`--scale`, proxy-grade `--quality` (default 30), `--fps`, `--no-audio`; not a delivery preset |
|
|
272
|
-
| `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; `--content` adds black-frame, frozen-frame, silence, short audio-dropout, single-frame-flash and flat-first-frame rows ; `--compare REF` measures SSIM/PSNR against a reference, and with `--expect-changed SPAN\|none` fails when the picture changed outside the spans the caller declared; `--levels bt709\|ebu-r103` counts the luma and chroma samples outside the broadcast legal range, per clip and per window, with min/max Y/U/V; `--expect-identity` checks that the file is the one a run reported writing (`output_identity`) |
|
|
272
|
+
| `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; a length or file size over the table's upload limit is a WARN marked `basis: guidance` (platforms change these, the uploader checks the current one), while `--max-duration` / `--max-mb` state the caller's own limit and FAIL when a `--platform` is named (with none, an over-length is WARN like every judgement row); `--content` adds black-frame, frozen-frame, silence, short audio-dropout, single-frame-flash and flat-first-frame rows ; `--compare REF` measures SSIM/PSNR against a reference, and with `--expect-changed SPAN\|none` fails when the picture changed outside the spans the caller declared; `--levels bt709\|ebu-r103` counts the luma and chroma samples outside the broadcast legal range, per clip and per window, with min/max Y/U/V; `--expect-identity` checks that the file is the one a run reported writing (`output_identity`) |
|
|
273
273
|
| `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
|
|
274
274
|
|
|
275
275
|
**Orchestration**
|
package/SKILL.md
CHANGED
|
@@ -34,6 +34,7 @@ Every step in full: `references/workflow.md`.
|
|
|
34
34
|
A delivery request is one `render.py --template`; three or more steps: a `render.py` project.
|
|
35
35
|
5. **Check** with `check.py OUTPUT --platform X --expect-identity S:M` (the writer's `output_identity`):
|
|
36
36
|
fix format rows; fix duration, aspect, fps or loudness only when the request implies it. Mention WARNs.
|
|
37
|
+
A `basis: guidance` WARN (length, size) is the table's upload limit: the uploader checks the current one.
|
|
37
38
|
6. **Verify** from the writer's `--json` probe or `probe.py`: exit 0 and the requested numbers, or it failed.
|
|
38
39
|
7. **Keep originals:** never overwrite a source; `--overwrite` only for this job's files or ones the user
|
|
39
40
|
asked to replace.
|
|
@@ -146,6 +147,8 @@ Variants, caveats and extra flags per row: `references/requests.md`.
|
|
|
146
147
|
| "wider stereo" | `audio.py band.wav --stereo-widen 0.5` |
|
|
147
148
|
| "swap in the narration track" | `audio.py in.mp4 --replace narration.wav` |
|
|
148
149
|
| "give me the sound as WAV" | `audio.py in.mp4 -o in.wav` |
|
|
150
|
+
| "speed the line up, same pitch", "exactly 96000 samples long" | `audio.py line.wav --tempo 1.2 --length-samples 96000 -o fit.wav` |
|
|
151
|
+
| "just the third mic", "resample to 48 kHz" | `audio.py room.wav --channel 2 --sample-rate 48000 -o mic3.wav` |
|
|
149
152
|
| "compress the voice", "limit the peaks", "gate the noise" | `audio.py in.mp4 --compress` (or `--limit`, `--gate`) |
|
|
150
153
|
| "audiogram", "turn this podcast into a video" | `render.py --template audiogram ep.m4a --image cover.png` |
|
|
151
154
|
| "make this a Reel / TikTok / Short / YouTube" | `render.py --template reels in.mp4 --cues cues.txt` |
|
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.8.0`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.0", "execution_mode": "local", "kind": "execution",
|
|
153
153
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
154
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
155
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
|
@@ -225,7 +225,14 @@ The workflow in `SKILL.md` is "probe first, verify last". The contract states it
|
|
|
225
225
|
`requires_visual_verification` is `true` exactly for the tools that change the picture
|
|
226
226
|
(fit, caption, overlay, graphics, color, join, multicam, render). Audio-only tools and
|
|
227
227
|
audio-only inputs never need `look`; the report line is `Look: not needed`. `check`
|
|
228
|
-
rows carry `kind: format` (fix it) or `kind: judgement` (decide with the user)
|
|
228
|
+
rows carry `kind: format` (fix it) or `kind: judgement` (decide with the user), and `basis`:
|
|
229
|
+
`requirement` (the row can FAIL) or `guidance`. The `duration` and `file size` rows are `basis: guidance`
|
|
230
|
+
when their limit is the platform table's upload limit (`scripts/_platforms.py`, dated
|
|
231
|
+
`UPLOAD_LIMITS_AS_OF`): over it, or not measurable against it, is WARN with a `reason` naming the
|
|
232
|
+
table's date, never FAIL, and does not make `check` exit non-zero. Platforms change upload limits
|
|
233
|
+
and whoever uploads checks the current one. A limit the caller states (`--max-duration`, `--max-mb`)
|
|
234
|
+
is the caller's requirement: `basis: requirement`, FAIL when over or not measured (with no `--platform`
|
|
235
|
+
named, `duration` is WARN as every judgement row is).
|
|
229
236
|
The `audio` row FAILs (WARN when no loudness target applies) when the track is present but silent (peak at or below -50 dBFS, from
|
|
230
237
|
the loudness pass or, when loudness is skipped, one volumedetect pass). `check --content`
|
|
231
238
|
(opt-in; render.py `"check": {"content": true}`) adds `black`, `frozen` and `silence` rows
|
|
@@ -675,7 +682,18 @@ Per-tool keys added in 1.13: `audio` (`audio.py`) reports the mix it built — t
|
|
|
675
682
|
`--voice` level, `stereo_widen`, whether an `--effects` bed was mixed, and with
|
|
676
683
|
`--music` the `music_volume` plus a `duck` object naming the threshold (dB and
|
|
677
684
|
linear), ratio, attack and release actually used, or `null` when `--duck` was not
|
|
678
|
-
given. `
|
|
685
|
+
given. Since #486 `audio.py` also reports `timing`, `{tempo, atempo, length_samples, sample_rate}`:
|
|
686
|
+
the `--tempo` factor and the atempo stages (each 0.5..2.0) it was built from, `--length-samples`,
|
|
687
|
+
and the rate that count is in (`null` before a pending input is measured); with
|
|
688
|
+
`--length-samples` a `length_samples` verification step decodes the written track and counts its
|
|
689
|
+
samples (`expected`, `measured`, `tolerance_samples`: 0 for PCM/FLAC/ALAC, one codec frame for a
|
|
690
|
+
lossy codec; `reason: not_measured` and `ok: false` when the count cannot be read).
|
|
691
|
+
Since #487 `audio.py --sample-rate HZ` resamples the output and `--channel N` keeps one channel by
|
|
692
|
+
index (0-based); the `audio` block reports `channel` and `sample_rate` (`null` when not asked), and
|
|
693
|
+
each is read back from the output by probe as a verification step, `sample_rate` and `channels`
|
|
694
|
+
(`{expected, measured}`; 1 channel, or 2 with `--stereo`; `reason: not_measured` when probe cannot
|
|
695
|
+
read it).
|
|
696
|
+
`loudness.py` reports `measured` (the input's loudnorm measurement,
|
|
679
697
|
including `input_lra`) and `targets` (the requested lufs / tp / lra).
|
|
680
698
|
`check.py --platform podcast` adds two informational rows to `checks`,
|
|
681
699
|
`channels` and `chapters`.
|
|
@@ -761,7 +779,7 @@ silence a tool reported as fine while another measurement disagreed):
|
|
|
761
779
|
|
|
762
780
|
| key | tool | what it holds |
|
|
763
781
|
|---|---|---|
|
|
764
|
-
| `verification[]` step `duration` | `export.py` (a preset with a platform maximum, without `--allow-long`) | the written file's length against the maximum, measured as `check.py` measures it (the container's duration, `tolerance` 0). A length that cannot be read fails the step with `reason: not_measured` |
|
|
782
|
+
| `verification[]` step `duration` | `export.py` (a preset with a platform maximum, without `--allow-long`) | the written file's length against the maximum, measured as `check.py` measures it (the container's duration, `tolerance` 0). A length that cannot be read fails the step with `reason: not_measured`. The step verifies the trim `export.py` itself made; `check.py` reports the same table limit as guidance (WARN) |
|
|
765
783
|
| `verification[]` step `duration` (`property: audio_duration`) | `loudness.py` | the output's sound against the input's, to one AAC frame (at least 0.03 s): a levels pass changes no timing. A length that cannot be read fails the step with `reason: not_measured` |
|
|
766
784
|
| `inputs_duration`, `transition_shortening`; step `duration`'s `inputs_total` | `join.py` | the clips' own total and how much the transitions took out of it. `expected_duration` is derived from the transition the join applied, so it cannot show that on its own |
|
|
767
785
|
| `frame_rate`, `clip_frames`, `transition_frames` | `join.py` (video join; added after 3.6.2) | the join's rate as an exact fraction (`"30000/1001"`), the whole frames of it each clip holds (its sound is cut to the samples those frames last), and the frames each transition overlaps (`--duration` rounded to whole frames, at least 1; `0` for `--transition none`). Lengths and xfade offsets are counted in these frames, never in rounded seconds (#455, #468); `inputs_duration` and `expected_duration` are derived from them. A pending `--dry-run` input counts 0 frames |
|
|
@@ -910,6 +928,7 @@ here claims otherwise until real exit-code/stderr sniffing exists to back that u
|
|
|
910
928
|
A refusal also carries `error.reason`, a stable snake_case code for *why*
|
|
911
929
|
(`contract --json` → `json_output.error_reasons` lists them: `output_exists`, `output_is_input`,
|
|
912
930
|
`output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
|
|
931
|
+
`output_container_codecs` (a `.webm` output for an H.264/HEVC or AAC encode, which WebM cannot hold),
|
|
913
932
|
`media_path_is_url` (`subfile,...:` included), `media_path_is_playlist` (an HLS, DASH, IMF or ffconcat playlist given as media, DASH and IMF told by content whatever the file is called), `media_path_has_line_break` (a path with a line break, which a concat list cannot hold),
|
|
914
933
|
`output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
|
|
915
934
|
`render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
5
5
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
6
6
|
"keywords": [
|
package/references/scripts.md
CHANGED
|
@@ -58,8 +58,11 @@ Every row names the constant or flag it comes from, and a test holds the table t
|
|
|
58
58
|
<!-- thresholds:end -->
|
|
59
59
|
|
|
60
60
|
Delivery targets per platform (`scripts/_platforms.py`). A file passes loudness within the
|
|
61
|
-
platform's tolerance of its target, and true peak at or below its ceiling plus 0.1 dB.
|
|
62
|
-
|
|
61
|
+
platform's tolerance of its target, and true peak at or below its ceiling plus 0.1 dB. The
|
|
62
|
+
maximum length (and the file size `check.py` reads from the same table) is an upload limit: the
|
|
63
|
+
table's as of `_platforms.UPLOAD_LIMITS_AS_OF`, guidance rather than a requirement, because
|
|
64
|
+
platforms change it and whoever uploads checks the current one. `check.py` WARNs over it;
|
|
65
|
+
`export.py` trims to it unless `--allow-long` and holds the written length to it with no tolerance.
|
|
63
66
|
|
|
64
67
|
<!-- platforms:start -->
|
|
65
68
|
| platform | loudness | tolerance | true peak | max duration |
|
|
@@ -1240,6 +1243,15 @@ step before reporting a deliverable; fix FAILs, mention WARNs. Without
|
|
|
1240
1243
|
`--platform` the youtube spec is assumed and the judgement rows (duration,
|
|
1241
1244
|
aspect, fps, resolution, loudness, true peak) come back as WARN with a `notes`
|
|
1242
1245
|
line, not FAIL: name the platform when the file is a delivery for it.
|
|
1246
|
+
Every row carries `basis`: `requirement` or `guidance`. The `duration` and `file size` rows are
|
|
1247
|
+
`guidance` when the limit is the platform table's: a file over it (or whose length or size could
|
|
1248
|
+
not be measured) is WARN, not FAIL, with a `reason` that names the table's date
|
|
1249
|
+
(`_platforms.UPLOAD_LIMITS_AS_OF`), and it does not make the exit code non-zero by itself. Upload
|
|
1250
|
+
limits change often and belong to whoever uploads, who checks the platform's current one; the
|
|
1251
|
+
encoding rows (codec, aspect, resolution, fps, pixel format, colour, loudness, true peak) are
|
|
1252
|
+
unchanged. `--max-duration S` and `--max-mb N` state the caller's own limit, on any platform
|
|
1253
|
+
including `custom`: those rows are `requirement` and FAIL when over or not measured (with no
|
|
1254
|
+
`--platform` at all, `duration` is WARN like the other judgement rows).
|
|
1243
1255
|
`--platform podcast` adds two informational rows: `channels` (PASS for mono or
|
|
1244
1256
|
stereo, WARN above — podcast players downmix 5.1 unpredictably) and `chapters`
|
|
1245
1257
|
(PASS when the container carries at least one marker, WARN `none` otherwise —
|
|
@@ -2029,7 +2041,8 @@ it) or the copy fails, the video is re-encoded with the tags on its frames
|
|
|
2029
2041
|
`verification`'s `color_tags` step. Tags that are still wrong after the
|
|
2030
2042
|
re-encode fail the run with `kind: verification`. The re-encode is H.264
|
|
2031
2043
|
unless `--codec` says otherwise, so an output container that cannot hold it
|
|
2032
|
-
(`.webm`)
|
|
2044
|
+
(`.webm`) is refused before it runs (`kind: input`, reason `output_container_codecs`) instead of
|
|
2045
|
+
completing with the old tags.
|
|
2033
2046
|
|
|
2034
2047
|
### audio.py — clean-up, music, ducking, layout
|
|
2035
2048
|
```
|
|
@@ -2037,7 +2050,8 @@ audio.py INPUT [--voice [light|medium|strong] | --denoise [--denoise-strength 25
|
|
|
2037
2050
|
[--music FILE [--music-volume -14] [--duck [--duck-amount 12] [--duck-threshold -26.02]
|
|
2038
2051
|
[--duck-attack 20] [--duck-release 400]] [--music-loop]]
|
|
2039
2052
|
[--effects FILE [--effects-volume -14]] [--stereo-widen 0..1]
|
|
2040
|
-
[--fade-in S] [--fade-out S] [--stereo | --mono | --downmix] [--replace FILE]
|
|
2053
|
+
[--fade-in S] [--fade-out S] [--stereo | --mono | --downmix] [--replace FILE]
|
|
2054
|
+
[--tempo FACTOR] [--length-samples N] [--channel N] [--sample-rate HZ] [-o OUT]
|
|
2041
2055
|
```
|
|
2042
2056
|
`--denoise` is `afftdn=nf=-N:tn=1`, where N is `--denoise-strength`, the noise
|
|
2043
2057
|
floor in dB below full scale (20..60, default 25). It is the starting floor that
|
|
@@ -2051,7 +2065,7 @@ produced. The exact filter chains:
|
|
|
2051
2065
|
| --- | --- |
|
|
2052
2066
|
| `light` | `highpass=f=80,acompressor=threshold=-18dB:ratio=2:attack=5:release=80:makeup=1` |
|
|
2053
2067
|
| `medium` | `highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2` |
|
|
2054
|
-
| `strong` | the `medium` chain, then `deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled` |
|
|
2068
|
+
| `strong` | the `medium` chain, then `deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled:latency=1` |
|
|
2055
2069
|
|
|
2056
2070
|
`light` for a good room (rumble and level only, noise floor and sibilance left
|
|
2057
2071
|
alone), `medium` for a normal talking head, `strong` for phone/laptop audio.
|
|
@@ -2088,6 +2102,29 @@ alone comes out 7.7 dB lower than it went in (9.9 dB on 7.1; follow with `loudne
|
|
|
2088
2102
|
leaves a 1-channel input untouched and downmixes >2 channels through
|
|
2089
2103
|
swresample. Video is always stream-copied, and so is a subtitle/data track
|
|
2090
2104
|
when the container can hold it (`dropped_non_av_streams` says when it could not).
|
|
2105
|
+
`--limit` (and `--voice strong`'s limiter) is `alimiter` with `level=disabled:latency=1`: the
|
|
2106
|
+
look-ahead is compensated, so the limited sound starts on the input's own sample instead of
|
|
2107
|
+
`--limit-attack` (5 ms, 240 samples at 48 kHz) late against the picture (#476).
|
|
2108
|
+
With `--replace`, `--music` or a kept picture the sound is padded with silence or trimmed to the
|
|
2109
|
+
source's length counted in samples at the track's rate (`apad=whole_len=N,atrim=end_sample=N`),
|
|
2110
|
+
so it is exact to the sample, not to the millisecond (#478). Sound that starts after the picture
|
|
2111
|
+
keeps its start and still ends with the picture (a trim on the timestamp follows the count).
|
|
2112
|
+
A fade-out ends where the output ends, which is `--length-samples` when that is given.
|
|
2113
|
+
`--tempo FACTOR` (0.1..10) changes the main track's tempo and keeps its pitch: `atempo` stages of
|
|
2114
|
+
0.5..2.0 whose product is the factor (3 is `atempo=2,atempo=1.5`, 0.3 is `atempo=0.5,atempo=0.6`).
|
|
2115
|
+
atempo's own output length is close to, not exactly, the input's divided by the factor, so
|
|
2116
|
+
`--length-samples N` pads with silence or trims the output to exactly N samples per channel at the
|
|
2117
|
+
track's rate; the result's `length_samples` verification step decodes the written track and counts
|
|
2118
|
+
them (exact for PCM/FLAC/ALAC, within one codec frame for a lossy codec). Both need an audio output,
|
|
2119
|
+
or for `--tempo` a `--replace` track beside the picture: the input's own sound beside its
|
|
2120
|
+
stream-copied picture would drift from it (`fit.py --method speed` retimes both). `timing` reports
|
|
2121
|
+
`{tempo, atempo, length_samples, sample_rate}`.
|
|
2122
|
+
`--channel N` keeps channel N of the track (0-based, `pan=mono|c0=cN`, first in the chain) as a
|
|
2123
|
+
1-channel output, or both sides of a stereo one with `--stereo`; an index past the track's channels
|
|
2124
|
+
and `--downmix` are refused. `--sample-rate HZ` (1000..768000) resamples the output (`aresample`,
|
|
2125
|
+
after the mix and the fades; `--length-samples` counts in this rate). Each is read back from the
|
|
2126
|
+
output by probe as a verification step, `channels` and `sample_rate` (`expected`, `measured`), and
|
|
2127
|
+
the `audio` block reports `channel` and `sample_rate`.
|
|
2091
2128
|
Run `loudness.py` after this for final levels.
|
|
2092
2129
|
|
|
2093
2130
|
### loudness.py — EBU R128 normalisation
|
|
@@ -2164,7 +2201,8 @@ overhead) in place of its CRF, and the encode runs in two passes. The written si
|
|
|
2164
2201
|
overshot. Refused for a preset that does not encode with x264 (and under `--hw`), with `--crf`,
|
|
2165
2202
|
for a source with no duration, and when the budget leaves under 100 kb/s for the picture.
|
|
2166
2203
|
Scales into the preset frame (pad by default), tags BT.709 (a BT.601-matrix or RGB source is converted to it, as by every re-encode), sets `+faststart`,
|
|
2167
|
-
trims to platform maximums (Reels 90 s, X 140 s
|
|
2204
|
+
trims to the table's platform maximums (Reels 90 s, X 140 s; the table's as of its date, and
|
|
2205
|
+
platforms change them) unless `--allow-long`, and says
|
|
2168
2206
|
so: `trimmed: {from, to}` and a `notes` line. It
|
|
2169
2207
|
does not touch levels: for youtube / youtube4k / reels / x the written file is
|
|
2170
2208
|
measured and the result's `loudness` (and a `notes` line) says when it is
|
package/references/workflow.md
CHANGED
|
@@ -10,7 +10,7 @@ lines per step; every rule below still applies.
|
|
|
10
10
|
2. **Prefer lossless.** If the request can be met without re-encoding (plain cuts on keyframes, remuxing, audio-only changes), do not re-encode. `cut.py` and `loudness.py` stream-copy video by default; `--accurate` on `cut.py` only for frame-exact cuts.
|
|
11
11
|
3. **Plan with `--dry-run --json`, then execute.** Trust `--json`, not a dry run's summary line, for any number in the plan (a dimension there can be a placeholder). Use before long encodes, to report facts.
|
|
12
12
|
4. **Chain in a sensible order.** A delivery request with no other editing is one template run (`render.py --template NAME INPUT`), not a hand-built chain. Otherwise: colour (HDR→SDR / LUT) → cut → join → silence → fit → caption/overlay → sync → audio → loudness → export. Frame changes before captions, so text is sized for the final frame. Re-encode as few times as possible: intermediates at CRF 18, `export.py` last. **Three or more steps: `render.py` with a project.json.**
|
|
13
|
-
5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X --expect-identity S:M` (the writer's `output_identity`; templates already check); never check after a failed write. Format rows (codec, pixel format, size, true peak, colour tags, VFR) are safe to fix. Judgement rows change content: duration (cut loses material), aspect (crop loses edges), fps (drops motion), loudness (ambience must not be boosted) — fix only when the request implies the answer, else state the choice and its cost. Mention WARNs.
|
|
13
|
+
5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X --expect-identity S:M` (the writer's `output_identity`; templates already check); never check after a failed write. Format rows (codec, pixel format, size, true peak, colour tags, VFR) are safe to fix. Judgement rows change content: duration (cut loses material), aspect (crop loses edges), fps (drops motion), loudness (ambience must not be boosted) — fix only when the request implies the answer, else state the choice and its cost. Mention WARNs. A `duration` or `file size` WARN with `basis: guidance` is the table's upload limit, not a requirement: platforms change these, the uploader checks the current one.
|
|
14
14
|
6. **Verify the output.** Confirm duration, resolution, fps and audio match the request, from the writing tool's `--json`/`--json-brief` probe or `probe.py`, and report those numbers ("final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo"). A step is done only when the script exited 0 and the output probes as expected: a non-zero exit, a missing or empty file, or a probe that contradicts the request is a failure reported with the script's error message.
|
|
15
15
|
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`); add `--overwrite` only for a file this job made or the user asked to replace.
|
|
16
16
|
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:
|
package/scripts/_common/alpha.py
CHANGED
|
@@ -25,6 +25,7 @@ import threading
|
|
|
25
25
|
from typing import Any, Dict, List, Optional
|
|
26
26
|
|
|
27
27
|
from _common import runner as _runner
|
|
28
|
+
from _common.color import carried_tag
|
|
28
29
|
from _common.runner import STATE, ffmpeg_encoders, require_tool, run
|
|
29
30
|
|
|
30
31
|
# A pixel format that carries an alpha plane (yuva*, rgba, argb, gbrap, ya8/ya16, ...).
|
|
@@ -284,10 +285,12 @@ _TAG_KEYS = (("colorspace", "color_space", "-colorspace"), ("color_primaries", "
|
|
|
284
285
|
("color_trc", "color_transfer", "-color_trc"))
|
|
285
286
|
|
|
286
287
|
|
|
287
|
-
def _source_tags(meta: Optional[Dict[str, Any]]) -> List[tuple]:
|
|
288
|
+
def _source_tags(meta: Optional[Dict[str, Any]], dest: str = "option") -> List[tuple]:
|
|
289
|
+
"""(setparams name, value, output option) per source tag that is passed on, the value spelled
|
|
290
|
+
for `dest` (carried_tag: an RGB source's `gbr` is left out, #454)."""
|
|
288
291
|
v = (meta or {}).get("video") or {}
|
|
289
|
-
|
|
290
|
-
|
|
292
|
+
tags = [(param, carried_tag(param, v.get(key), dest), flag) for param, key, flag in _TAG_KEYS]
|
|
293
|
+
return [t for t in tags if t[1]]
|
|
291
294
|
|
|
292
295
|
|
|
293
296
|
def finish_chain(codec: str, mode: Optional[str], meta: Optional[Dict[str, Any]] = None) -> str:
|
|
@@ -295,7 +298,7 @@ def finish_chain(codec: str, mode: Optional[str], meta: Optional[Dict[str, Any]]
|
|
|
295
298
|
when asked, the source's colour tags put back on the frames (`setparams`: a grade's RGB legs
|
|
296
299
|
and an RGB layer's conversion relabel them, BT.601 or unknown), then the line's own pixel
|
|
297
300
|
format (so no later conversion can drop the plane)."""
|
|
298
|
-
tags = _source_tags(meta)
|
|
301
|
+
tags = _source_tags(meta, "setparams")
|
|
299
302
|
label = ("setparams=" + ":".join(f"{p}={val}" for p, val, _ in tags) + ",") if tags else ""
|
|
300
303
|
# premultiplied colour is colour x alpha in RGB: multiplied there (a YUV premultiply scales the
|
|
301
304
|
# limited-range offsets too), after the tags are back so both conversions use the same matrix
|
package/scripts/_common/color.py
CHANGED
|
@@ -57,6 +57,36 @@ NON_BT709_MATRICES = ("smpte170m", "bt470bg", "fcc", "smpte240m")
|
|
|
57
57
|
_RGB_PIX_FMT_RE = re.compile(r"^(?:rgb|bgr|argb|abgr|0rgb|0bgr|gbr|x2rgb|x2bgr|pal8)")
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
# A probed colour tag passed back to FFmpeg (#454). ffprobe prints av_color_*_name(), which is
|
|
61
|
+
# also the setparams filter's table; the output options -colorspace/-color_primaries/-color_trc
|
|
62
|
+
# parse an older one that differs on three names (6.1, measured): -colorspace refuses `gbr` (its
|
|
63
|
+
# name is `rgb`) and -color_trc refuses `bt470m` and `bt470bg` (its names: `gamma22`, `gamma28`).
|
|
64
|
+
# `gbr` is not carried over at all: it says the stored samples are RGB, which is the pixel
|
|
65
|
+
# format's to say (the PNG encoder tags it itself), and a tool that converts the picture to YUV on
|
|
66
|
+
# the way (a grade's legs, a 4:4:4 alpha line) would label YUV samples as RGB with it.
|
|
67
|
+
_TAG_KIND = {"-colorspace": "colorspace", "-color_primaries": "color_primaries", "-color_trc": "color_trc"}
|
|
68
|
+
_OPTION_NAMES = {"color_trc": {"bt470m": "gamma22", "bt470bg": "gamma28"}}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def carried_tag(kind: str, name: Optional[str], dest: str = "option") -> Optional[str]:
|
|
72
|
+
"""The probed tag `name` of `kind` (colorspace, color_primaries, color_trc) spelled the way
|
|
73
|
+
`dest` ("option": -colorspace & co; "setparams") accepts it, or None when it is not to be
|
|
74
|
+
passed on: unknown, unspecified, reserved, or the RGB `gbr`/`rgb`."""
|
|
75
|
+
if not name or name in ("unknown", "unspecified", "reserved") or (kind == "colorspace" and name in ("gbr", "rgb")):
|
|
76
|
+
return None
|
|
77
|
+
return _OPTION_NAMES.get(kind, {}).get(name, name) if dest == "option" else name
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def carried_tag_args(pairs: Sequence[Tuple[str, Optional[str]]]) -> List[str]:
|
|
81
|
+
"""[flag, value, ...] for each (flag, probed name) whose tag carried_tag() passes on."""
|
|
82
|
+
out: List[str] = []
|
|
83
|
+
for flag, name in pairs:
|
|
84
|
+
value = carried_tag(_TAG_KIND.get(flag, flag.lstrip("-")), name)
|
|
85
|
+
if value:
|
|
86
|
+
out += [flag, value]
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
60
90
|
def rgb_to_bt709_filter(pix_fmt: str = "yuv444p") -> str:
|
|
61
91
|
"""An RGB picture converted to BT.709 limited-range YUV right here, in `pix_fmt`, so no later
|
|
62
92
|
conversion (an auto-inserted one, with FFmpeg's BT.601 default) decides the matrix. 4:4:4 by
|
|
@@ -1004,6 +1004,76 @@ def _check_output_path(cmd: Sequence[str]) -> None:
|
|
|
1004
1004
|
refuse("input", "output_dir_not_writable", f"output directory {parent!r} is not writable", output=output, directory=parent)
|
|
1005
1005
|
|
|
1006
1006
|
|
|
1007
|
+
# What the WebM muxer takes (6.1: "Only VP8 or VP9 or AV1 video and Vorbis or Opus audio and
|
|
1008
|
+
# WebVTT subtitles are supported for WebM"), by the encoder a command names (#488).
|
|
1009
|
+
_WEBM_VIDEO_ENCODER_RE = re.compile(r"^(?:libvpx(?:-vp9)?|vp8|vp9|libaom-av1|libsvtav1|librav1e|av1|vp8_\w+|vp9_\w+|av1_\w+)$")
|
|
1010
|
+
_WEBM_AUDIO_ENCODER_RE = re.compile(r"^(?:libopus|opus|libvorbis|vorbis)$")
|
|
1011
|
+
_CODEC_OPTION_RE = re.compile(r"^-(?:c|codec)(?::([va])(?::\d+)?)?$|^-(v|a)codec$")
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _an_input_has(cmd: Sequence[str], kind: str) -> bool:
|
|
1015
|
+
"""False only when every input of `cmd` is a file that was read and holds no `kind` ("v"/"a")
|
|
1016
|
+
stream, and no filter graph could make one. An encoder option for a stream the output will
|
|
1017
|
+
not have selects nothing (FFmpeg applies -c:a after stream selection), so it is not a reason
|
|
1018
|
+
to refuse: cut.py names -c:a aac for a silent source too. Anything not known counts as present."""
|
|
1019
|
+
if any(tok in ("-filter_complex", "-lavfi", "-filter_complex_script") for tok in cmd):
|
|
1020
|
+
return True
|
|
1021
|
+
paths = [cmd[i + 1] for i, tok in enumerate(cmd[:-1]) if tok == "-i"]
|
|
1022
|
+
if not paths:
|
|
1023
|
+
return True
|
|
1024
|
+
for i, tok in enumerate(cmd[:-1]):
|
|
1025
|
+
if tok == "-f" and cmd[i + 1] in ("lavfi", "concat"):
|
|
1026
|
+
return True
|
|
1027
|
+
for path in paths:
|
|
1028
|
+
if not os.path.isfile(path):
|
|
1029
|
+
return True
|
|
1030
|
+
probe_cmd = [shutil.which("ffprobe") or "ffprobe", "-v", "error", "-select_streams", kind,
|
|
1031
|
+
"-show_entries", "stream=index", "-of", "csv=p=0", path]
|
|
1032
|
+
limit = _probe_limit() # PROBE_TIMEOUT, or --timeout when that is lower; never unlimited (--timeout 0)
|
|
1033
|
+
try:
|
|
1034
|
+
proc = subprocess.run(probe_cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=limit)
|
|
1035
|
+
except subprocess.TimeoutExpired:
|
|
1036
|
+
# a stalled read is reported as what it is, not turned into "the stream is there"
|
|
1037
|
+
die(f"ffprobe did not finish reading {path} within {limit:g} s and was killed; nothing was written",
|
|
1038
|
+
code=124, kind="timeout", output=cmd[-1])
|
|
1039
|
+
except OSError:
|
|
1040
|
+
return True
|
|
1041
|
+
if proc.returncode != 0 or proc.stdout.strip():
|
|
1042
|
+
return True
|
|
1043
|
+
return False
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _check_container_codecs(cmd: Sequence[str]) -> None:
|
|
1047
|
+
"""A `.webm` output whose command encodes a picture or a sound WebM cannot hold (the tools'
|
|
1048
|
+
H.264/HEVC and AAC lines) is refused before anything runs, dry runs included: the muxer
|
|
1049
|
+
refused it only after the encode had started, as `kind: ffmpeg` with "Nothing was written
|
|
1050
|
+
into output file", and a dry run said completed (#488). A stream copy is left to the muxer:
|
|
1051
|
+
what it copies is not named in the command."""
|
|
1052
|
+
output = cmd[-1]
|
|
1053
|
+
if os.path.splitext(output)[1].lower() != ".webm":
|
|
1054
|
+
return
|
|
1055
|
+
refused: List[Any] = []
|
|
1056
|
+
for i, tok in enumerate(cmd[:-2]):
|
|
1057
|
+
m = _CODEC_OPTION_RE.match(tok)
|
|
1058
|
+
if not m:
|
|
1059
|
+
continue
|
|
1060
|
+
stream, value = m.group(1) or m.group(2), cmd[i + 1]
|
|
1061
|
+
if value == "copy" or stream is None:
|
|
1062
|
+
continue
|
|
1063
|
+
allowed = _WEBM_VIDEO_ENCODER_RE if stream == "v" else _WEBM_AUDIO_ENCODER_RE
|
|
1064
|
+
if not allowed.match(value):
|
|
1065
|
+
refused.append((stream, value))
|
|
1066
|
+
# only for a stream the output will have: asked last, and only when something would be refused
|
|
1067
|
+
refused = [value for stream, value in dict.fromkeys(refused) if _an_input_has(cmd, stream)]
|
|
1068
|
+
if refused:
|
|
1069
|
+
refuse("input", "output_container_codecs",
|
|
1070
|
+
f"output {output!r} is WebM, which holds only VP8/VP9/AV1 video and Opus/Vorbis sound; this tool "
|
|
1071
|
+
f"encodes {' and '.join(refused)} for it",
|
|
1072
|
+
hint="name a .mp4, .mov or .mkv output (Matroska holds what WebM refuses); "
|
|
1073
|
+
"a transparent VP9 WebM is --keep-alpha --alpha-codec vp9 or export.py --preset vp9-alpha",
|
|
1074
|
+
output=output, container="webm", encoders=refused)
|
|
1075
|
+
|
|
1076
|
+
|
|
1007
1077
|
EVEN_SCALE = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
|
|
1008
1078
|
|
|
1009
1079
|
|
|
@@ -1797,6 +1867,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
|
|
|
1797
1867
|
_refuse_url_media(cmd)
|
|
1798
1868
|
_check_no_overwrite_input(cmd)
|
|
1799
1869
|
_check_output_path(cmd)
|
|
1870
|
+
_check_container_codecs(cmd)
|
|
1800
1871
|
_check_existing_output(cmd)
|
|
1801
1872
|
ctx.commands.append(_cmdline(cmd))
|
|
1802
1873
|
if not quiet:
|
package/scripts/_contract.py
CHANGED
|
@@ -171,6 +171,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
171
171
|
{"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit / --voice strong"}, {"capability": "filter:agate", "when": "--gate"},
|
|
172
172
|
{"capability": "filter:deesser", "when": "--voice medium (the default) / --voice strong"},
|
|
173
173
|
{"capability": "filter:extrastereo", "when": "--stereo-widen"},
|
|
174
|
+
{"capability": "filter:atempo", "when": "--tempo"},
|
|
174
175
|
{"capability": AAC, "when": "output extension isn't .mp3/.opus/.ogg/.flac (audio_codec_for()'s default)"}] + AUDIO_OUT,
|
|
175
176
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
176
177
|
"loudness": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["loudness measurement JSON (--measure-only)", "normalised artifact (video stream-copied)"],
|
|
@@ -438,6 +439,7 @@ ERROR_REASONS = {
|
|
|
438
439
|
"media_path_is_url": "an input or output has a protocol prefix (tcp:, http://, file:, ...) that ffmpeg would open as a URL; the skill reads and writes local files only; details: {path, scheme}",
|
|
439
440
|
"output_dir_missing": "the output's directory does not exist; details: {output, directory}",
|
|
440
441
|
"output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
|
|
442
|
+
"output_container_codecs": "a .webm output whose command encodes video or sound WebM cannot hold (H.264/HEVC, AAC; WebM takes VP8/VP9/AV1 and Opus/Vorbis), refused before ffmpeg runs; details: {output, container, encoders}",
|
|
441
443
|
"timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
|
|
442
444
|
"output_locked": "another run holds the output's lock; details: {lock, output, pid, pid_alive, deadline, why}",
|
|
443
445
|
"plan_inputs_changed": "render.py PLAN: a fingerprinted input is missing or changed; details: {plan, plan_sha256, checked_at, "
|
|
@@ -636,7 +638,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
636
638
|
"levels": {"type": "object", "description": "--levels only: {standard, reference, bit_depth, legal {y, c}: inclusive legal code ranges at the file's depth, window_seconds, frames, out_of_range_pct {y, u, v, chroma}: share of samples outside them (chroma: Cb and Cr samples together), brng_pct (signalstats BRNG: pixels with any component outside the fixed BT.601/709 nominal range), worst_frame {time, y_pct, chroma_pct}, min {y, u, v}, max {y, u, v}, windows: the same per --levels-window seconds ({start, end, ...})}"},
|
|
637
639
|
"change_scope": {"type": "object", "description": "--expect-changed only, when the two files could be compared: {declared [{start, end, rect}], observed [{start, end, frames, rect [x, y, w, h], peak}]: every span with a tile above the floor, undeclared: the same for the tiles no declaration covers, declared_unchanged: declared spans in which nothing changed, floor, tile [columns, rows], frames, quiet_peak: the largest tile at or under the floor}. Times are this file's presentation times in seconds, rectangles are displayed pixels"},
|
|
638
640
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named (youtube was assumed and judgement rows are WARN), when --compare paired a different number of frames than the reference has, or when --expect-changed compared two files that are not encoded alike"},
|
|
639
|
-
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-changed adds change_scope; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS). A value that could not be read is \"could not measure\", never a PASS: duration FAILs (WARN
|
|
641
|
+
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-changed adds change_scope; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS). A value that could not be read is \"could not measure\", never a PASS: duration FAILs under a --max-duration the caller stated (WARN under the platform table's limit or without one) when the file states no length, and black, frozen and silence are then WARN rows; loudness is WARN for audio shorter than one 400 ms block; --tp without --lufs (custom) measures the true peak row"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}, "basis": {"enum": ["requirement", "guidance"], "description": "where the expected value comes from. guidance: the duration and file size rows when their limit is the platform table's upload limit (scripts/_platforms.py, dated UPLOAD_LIMITS_AS_OF); such a row is PASS or WARN, never FAIL, is not counted in failed, and its reason says the uploader checks the platform's current limit. requirement: every other row, and the same two rows under --max-duration / --max-mb (the caller's own limit), which FAIL"}}}}}
|
|
640
642
|
elif name == "caption":
|
|
641
643
|
extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
|
|
642
644
|
"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. --ass: ass_malformed -- Dialogue lines that could not be read and are not counted -- and with --offset ass_shifted / ass_unshifted -- lines moved, and lines whose timestamps could not be read, copied as they were (each count above 0 is also a diagnostics.warnings entry)"},
|
|
@@ -797,7 +799,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
797
799
|
"dynamics": {"type": "array", "items": {"enum": ["agate", "acompressor", "alimiter"]}, "description": "typed dynamics filters applied, in graph order"},
|
|
798
800
|
"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"},
|
|
799
801
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "present with `silent`: which tracks were silent and how to refuse them"},
|
|
800
|
-
"
|
|
802
|
+
"timing": {"type": "object", "description": "#486: {tempo, atempo, length_samples, sample_rate}: the --tempo factor (null without it), the atempo stages (each 0.5..2.0, [] at tempo 1) whose product it is, --length-samples (null without it) and the sample rate that count is in (null when not measured yet); with --length-samples a length_samples verification step counts the written track's decoded samples (exact for PCM/FLAC/ALAC, within tolerance_samples, one codec frame, for a lossy codec)"},
|
|
803
|
+
"audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, channel (--channel N, the 0-based channel kept, else null), sample_rate (--sample-rate, else null; both are read back from the output as the channels / sample_rate verification steps), effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db actually used)"}}
|
|
801
804
|
if name in ("overlay", "graphics", "caption", "grid"):
|
|
802
805
|
extra["font_used"] = FONT_USED_SCHEMA
|
|
803
806
|
if name in ALPHA_LINE_TOOLS:
|
|
@@ -1817,7 +1820,9 @@ MCP_PROMPTS: List[Dict[str, Any]] = [
|
|
|
1817
1820
|
"`check.py <output> --platform {platform}` before calling it done -- format rows (codec, pixel "
|
|
1818
1821
|
"format, size, true peak, colour tags, VFR) are safe to fix; judgement rows (duration, aspect, "
|
|
1819
1822
|
"fps, loudness) change content, so fix them only when the request implies the answer, otherwise "
|
|
1820
|
-
"state the choice and its cost. Mention WARNs; do not chase them."
|
|
1823
|
+
"state the choice and its cost. Mention WARNs; do not chase them. A duration or file size WARN with "
|
|
1824
|
+
"basis guidance is the table's upload limit, which platforms change: report it, the uploader "
|
|
1825
|
+
"checks the current one."),
|
|
1821
1826
|
},
|
|
1822
1827
|
{
|
|
1823
1828
|
"name": "podcast", "description": "Turn an audio recording into a loudness-normalised podcast episode with chapters.",
|
|
@@ -2050,7 +2055,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
2050
2055
|
"verify_last": "run the tools named in each ToolSpec.verification after it wrote an artifact",
|
|
2051
2056
|
"visual": "when requires_visual_verification is true, run ffmpeg-skill/look on the output and inspect the PNG",
|
|
2052
2057
|
"audio_only": "audio-only inputs and audio-only tools never need ffmpeg-skill/look",
|
|
2053
|
-
"check_rows": "ffmpeg-skill/check rows carry kind=format (fix) or kind=judgement (decide with the user)",
|
|
2058
|
+
"check_rows": "ffmpeg-skill/check rows carry kind=format (fix) or kind=judgement (decide with the user), and basis=requirement (can FAIL) or basis=guidance (the platform table's upload length/size limit: WARN at most, the uploader checks the current limit)",
|
|
2054
2059
|
},
|
|
2055
2060
|
"json_output": {
|
|
2056
2061
|
"success": {"status": "completed", "exit_code": 0, "stdout": "one JSON document (output_schema)"},
|
package/scripts/_platforms.py
CHANGED
|
@@ -12,7 +12,9 @@ the description bar. This module is the single table the delivery tools read:
|
|
|
12
12
|
"fps": the frame rate a delivery is conformed to (None = leave alone),
|
|
13
13
|
"spec": check.py's compliance row values (max_duration, aspects,
|
|
14
14
|
min_height, fps_max, codecs, max_bytes, lufs, lufs_tol, tp,
|
|
15
|
-
sdr_only) -- the keys check.py's SPECS has always had
|
|
15
|
+
sdr_only) -- the keys check.py's SPECS has always had;
|
|
16
|
+
max_duration and max_bytes are upload limits, guidance as of
|
|
17
|
+
UPLOAD_LIMITS_AS_OF rather than a requirement (#485),
|
|
16
18
|
"safe": the fraction of the frame each edge's UI covers
|
|
17
19
|
(top/bottom/left/right, 0..1) -- nothing readable goes there,
|
|
18
20
|
"caption": the caption defaults a template uses (size as a fraction of the
|
|
@@ -62,6 +64,20 @@ _CAP_WIDE = {"size": 0.0694, "position": "bottom", "box": False, "outline": 2, "
|
|
|
62
64
|
# by up to that much, so a tighter rule fails a file on measurement noise.
|
|
63
65
|
TP_TOLERANCE_DB = 0.1
|
|
64
66
|
|
|
67
|
+
# Upload limits are guidance (#485). A platform's maximum length and file size change often and
|
|
68
|
+
# differ by account; whoever uploads reads the current ones. The `max_duration` and `max_bytes`
|
|
69
|
+
# below are this table's as of the date here, the day the table as a whole was last decided --
|
|
70
|
+
# not a date each number was read from the platform. check.py reports a file over one of them as
|
|
71
|
+
# WARN with `basis: guidance`, never FAIL; the encoding values (codecs, aspects, fps, loudness,
|
|
72
|
+
# true peak) are the format a delivery is held to and stay requirements.
|
|
73
|
+
UPLOAD_LIMITS_AS_OF = "2026-10-30"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def upload_limit_note(platform: str) -> str:
|
|
77
|
+
"""Why a row measured against this table's length or size limit is guidance, in one clause."""
|
|
78
|
+
return (f"the limit is this tool's table for {platform} as of {UPLOAD_LIMITS_AS_OF}; platforms change "
|
|
79
|
+
"upload limits, so whoever uploads checks the current one")
|
|
80
|
+
|
|
65
81
|
PLATFORMS: Dict[str, Dict[str, Any]] = {
|
|
66
82
|
"tiktok": {
|
|
67
83
|
"title": "TikTok",
|
package/scripts/_smartcut.py
CHANGED
|
@@ -34,6 +34,7 @@ from fractions import Fraction
|
|
|
34
34
|
from typing import Any, Dict, List, Optional, Tuple
|
|
35
35
|
|
|
36
36
|
from _common import STATE, aac_args, concat_list_line, ffmpeg_base, require_tool, run
|
|
37
|
+
from _common.color import carried_tag_args
|
|
37
38
|
from _common.runner import fail_if_classified
|
|
38
39
|
|
|
39
40
|
# the stream parameters a re-encoded part must share with the source (ffprobe -show_streams)
|
|
@@ -129,11 +130,12 @@ def x264_line(params: Dict[str, Any], crf: int, preset: str) -> List[str]:
|
|
|
129
130
|
"-x264-params", f"{b}:open-gop=0"]
|
|
130
131
|
if level:
|
|
131
132
|
args += ["-level:v", f"{level // 10}.{level % 10}"]
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
# spelled the way the output options take them (#454: ffprobe's bt470m/bt470bg transfer and
|
|
134
|
+
# gbr space are not their names)
|
|
135
|
+
args += carried_tag_args([("-colorspace", params.get("color_space")), ("-color_primaries", params.get("color_primaries")),
|
|
136
|
+
("-color_trc", params.get("color_transfer"))])
|
|
137
|
+
if params.get("color_range") and params.get("color_range") != "unknown":
|
|
138
|
+
args += ["-color_range", str(params["color_range"])]
|
|
137
139
|
return args
|
|
138
140
|
|
|
139
141
|
|
package/scripts/audio.py
CHANGED
|
@@ -18,6 +18,8 @@ Examples:
|
|
|
18
18
|
python3 audio.py interview.mp4 -o interview.wav # extract the audio (no video in the output)
|
|
19
19
|
python3 audio.py multi.mkv --audio-stream 1 --voice -o lav.m4a # pick the second audio track, clean it, write M4A
|
|
20
20
|
python3 audio.py talk.wav --compress --comp-threshold -20 --comp-ratio 4 --limit --limit-ceiling -1 -o talk_dyn.wav
|
|
21
|
+
python3 audio.py line.wav --tempo 1.25 --length-samples 96000 -o line_fit.wav # faster, same pitch, exactly 2 s at 48 kHz
|
|
22
|
+
python3 audio.py room.wav --channel 2 --sample-rate 48000 -o mic3.wav # the third microphone alone, resampled to 48 kHz
|
|
21
23
|
"""
|
|
22
24
|
import argparse
|
|
23
25
|
import os
|
|
@@ -42,7 +44,7 @@ VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=thresh
|
|
|
42
44
|
VOICE_LEVELS = {
|
|
43
45
|
"light": "highpass=f=80,acompressor=threshold=-18dB:ratio=2:attack=5:release=80:makeup=1",
|
|
44
46
|
"medium": VOICE_CHAIN,
|
|
45
|
-
"strong": VOICE_CHAIN + ",deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled",
|
|
47
|
+
"strong": VOICE_CHAIN + ",deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled:latency=1",
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
# The sidechain threshold the music bed has used since 1.4 is the linear 0.05 that ffmpeg's
|
|
@@ -95,6 +97,9 @@ def dynamics_filter(name: str, args: argparse.Namespace) -> str:
|
|
|
95
97
|
opts.append(f"{opt}={value:g}")
|
|
96
98
|
if name == "alimiter":
|
|
97
99
|
opts.append("level=disabled") # keep the level: a limiter must not normalise the whole track upwards
|
|
100
|
+
# latency compensation: without it the output is the input delayed by the look-ahead
|
|
101
|
+
# (--limit-attack, 5 ms by default: 240 samples at 48 kHz) and drifts off the picture (#476)
|
|
102
|
+
opts.append("latency=1")
|
|
98
103
|
return name + ("=" + ":".join(opts) if opts else "")
|
|
99
104
|
|
|
100
105
|
|
|
@@ -173,6 +178,90 @@ def main_track_channels(args: argparse.Namespace, meta: Dict[str, Any]) -> int:
|
|
|
173
178
|
return (meta.get("audio") or {}).get("channels") or 0
|
|
174
179
|
|
|
175
180
|
|
|
181
|
+
def main_track_rate(args: argparse.Namespace, meta: Dict[str, Any]) -> int:
|
|
182
|
+
"""Sample rate of the track audio.py processes (as main_track_channels() picks it); 0 when it
|
|
183
|
+
is not known (a dry run's pending input)."""
|
|
184
|
+
if args.replace:
|
|
185
|
+
if dry_run_input_pending(args.replace) or not os.path.isfile(args.replace):
|
|
186
|
+
return 0
|
|
187
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "a:0", "-show_entries",
|
|
188
|
+
"stream=sample_rate", "-of", "csv=p=0", args.replace], quiet=True, check=False)
|
|
189
|
+
first = (proc.stdout or "").strip().splitlines()[:1]
|
|
190
|
+
return int(first[0]) if first and first[0].strip().isdigit() else 0
|
|
191
|
+
streams = meta.get("audio_streams") or []
|
|
192
|
+
track = streams[args.audio_stream] if 0 <= args.audio_stream < len(streams) else (meta.get("audio") or {})
|
|
193
|
+
try:
|
|
194
|
+
return int(track.get("sample_rate") or 0)
|
|
195
|
+
except (TypeError, ValueError):
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# --tempo: atempo keeps the pitch; one stage is kept within 0.5..2.0 (above 2 it drops whole
|
|
200
|
+
# blocks of samples), so a factor outside that range is a chain of stages whose product is it (#486)
|
|
201
|
+
TEMPO_RANGE = (0.1, 10.0)
|
|
202
|
+
LOSSLESS_AUDIO = ("pcm_", "flac", "alac", "wavpack", "tta")
|
|
203
|
+
# samples per frame of the lossy encoders audio_codec_for() picks: a decoded length can differ from
|
|
204
|
+
# the samples written by the encoder's own padding, by at most one frame
|
|
205
|
+
CODEC_FRAME_SAMPLES = {"aac": 1024, "mp3": 1152, "opus": 960, "vorbis": 2048, "ac3": 1536, "eac3": 1536}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def atempo_steps(factor: float) -> List[float]:
|
|
209
|
+
"""atempo stages, each in 0.5..2.0, whose product is `factor`."""
|
|
210
|
+
steps: List[float] = []
|
|
211
|
+
while factor > 2.0:
|
|
212
|
+
steps.append(2.0)
|
|
213
|
+
factor /= 2.0
|
|
214
|
+
while factor < 0.5:
|
|
215
|
+
steps.append(0.5)
|
|
216
|
+
factor /= 0.5
|
|
217
|
+
return steps + [factor]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def decoded_samples(path: str) -> "int | None":
|
|
221
|
+
"""Samples per channel of the first audio track, counted by decoding it (astats); None when
|
|
222
|
+
the count could not be read."""
|
|
223
|
+
proc = run([require_tool("ffmpeg"), "-hide_banner", "-nostats", "-i", path, "-map", "0:a:0", "-af",
|
|
224
|
+
"astats=measure_perchannel=none:measure_overall=Number_of_samples", "-f", "null", "-"],
|
|
225
|
+
quiet=True, check=False)
|
|
226
|
+
found = [line.rsplit(":", 1)[-1].strip() for line in (proc.stderr or "").splitlines() if "Number of samples:" in line]
|
|
227
|
+
return int(found[-1]) if proc.returncode == 0 and found and found[-1].isdigit() else None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def length_step(path: str, codec: str, expected: int) -> Dict[str, Any]:
|
|
231
|
+
"""The `length_samples` verification step: the written track's decoded length against
|
|
232
|
+
--length-samples, exact for a lossless codec, within one codec frame for a lossy one."""
|
|
233
|
+
measured = decoded_samples(path)
|
|
234
|
+
lossless = codec.startswith(LOSSLESS_AUDIO)
|
|
235
|
+
tolerance = 0 if lossless else CODEC_FRAME_SAMPLES.get(codec, 2048)
|
|
236
|
+
step: Dict[str, Any] = {"step": "length_samples", "expected": expected, "measured": measured,
|
|
237
|
+
"tolerance_samples": tolerance, "codec": codec}
|
|
238
|
+
if measured is None:
|
|
239
|
+
return dict(step, ok=False, reason="not_measured")
|
|
240
|
+
return dict(step, ok=abs(measured - expected) <= tolerance)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
SAMPLE_RATE_RANGE = (1000, 768000)
|
|
244
|
+
OPUS_DECODED_RATE = 48000 # what an Opus stream decodes at, whatever rate was encoded (measured: 16 kHz in, 48 kHz out)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def property_step(name: str, expected: int, measured: Any) -> Dict[str, Any]:
|
|
248
|
+
"""A verification step comparing one probed property of the written track with the request
|
|
249
|
+
(#487: `sample_rate`, `channels`); `reason: not_measured` when probe could not read it."""
|
|
250
|
+
try:
|
|
251
|
+
value = int(measured)
|
|
252
|
+
except (TypeError, ValueError):
|
|
253
|
+
return {"step": name, "expected": expected, "measured": None, "ok": False, "reason": "not_measured"}
|
|
254
|
+
return {"step": name, "expected": expected, "measured": value, "ok": value == expected}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def conform_filter(samples: int, rate: int) -> str:
|
|
258
|
+
"""Pad with silence or trim to exactly `samples` at `rate` (#478): counted in samples, not in
|
|
259
|
+
seconds written to the millisecond, which left a result up to 24 samples (48 kHz) off. The
|
|
260
|
+
aformat pins the rate the count is in, so a resampler FFmpeg inserts sits ahead of the count."""
|
|
261
|
+
head = f"aformat=sample_rates={rate}," if rate else ""
|
|
262
|
+
return f"{head}apad=whole_len={samples},atrim=end_sample={samples}"
|
|
263
|
+
|
|
264
|
+
|
|
176
265
|
def main() -> int:
|
|
177
266
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
178
267
|
ap.add_argument("input")
|
|
@@ -209,8 +298,21 @@ def main() -> int:
|
|
|
209
298
|
help="widen the stereo image, 0..1 (0 = untouched, 1 = maximum); needs a real stereo source: a mono input is "
|
|
210
299
|
"refused (duplicating it leaves both channels identical, so there is nothing to widen) and more than two "
|
|
211
300
|
"channels are refused unless --downmix folds them to stereo first")
|
|
301
|
+
fades.add_argument("--channel", type=int, default=None, metavar="N",
|
|
302
|
+
help="keep only channel N of the track (0-based, in the track's own order), as a 1-channel output "
|
|
303
|
+
"(--stereo duplicates it to both sides); checked on the output by probe")
|
|
304
|
+
fades.add_argument("--sample-rate", type=int, default=None, metavar="HZ",
|
|
305
|
+
help="resample the output to HZ (aresample); checked on the output by probe. Default: the track's own rate")
|
|
212
306
|
fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo with the ITU-R BS.775 weights (centre and surrounds -3 dB, LFE dropped), scaled so the fold-down cannot clip")
|
|
213
307
|
fades.add_argument("--replace", help="replace the audio with this file (trimmed/padded to the video)")
|
|
308
|
+
timing = ap.add_argument_group("timing (an audio output, or --replace beside a kept picture)")
|
|
309
|
+
timing.add_argument("--tempo", type=float, default=None, metavar="FACTOR",
|
|
310
|
+
help="change the tempo of the main track keeping its pitch (atempo; 2 = twice as fast, half as long), "
|
|
311
|
+
f"{TEMPO_RANGE[0]:g}..{TEMPO_RANGE[1]:g}; outside 0.5..2.0 it is a chain of atempo stages. "
|
|
312
|
+
"Refused on the input's own sound while its picture is stream-copied (use fit.py --method speed)")
|
|
313
|
+
timing.add_argument("--length-samples", type=int, default=None, metavar="N",
|
|
314
|
+
help="pad with silence or trim the output to exactly N samples per channel at the output rate "
|
|
315
|
+
"(an audio output only: beside a picture the sound is conformed to the picture)")
|
|
214
316
|
dyn = ap.add_argument_group("dynamics (typed; each flag is one option of ffmpeg's acompressor / alimiter / agate)")
|
|
215
317
|
dyn.add_argument("--compress", action="store_true", help="compressor (acompressor); order: gate -> compressor -> limiter")
|
|
216
318
|
dyn.add_argument("--comp-threshold", type=float, help="dBFS above which gain is reduced, -60..0 (ffmpeg default -12.4)")
|
|
@@ -299,6 +401,31 @@ def main() -> int:
|
|
|
299
401
|
# or --replace's first track. meta["audio"] is the input's first track, so a mono a:0 left a
|
|
300
402
|
# stereo --audio-stream 1 unfolded by --mono, and a stereo a:0 halved a mono one (A128)
|
|
301
403
|
in_channels = main_track_channels(args, meta)
|
|
404
|
+
keep_video = has_video and not audio_out
|
|
405
|
+
if args.tempo is not None:
|
|
406
|
+
if not (TEMPO_RANGE[0] <= args.tempo <= TEMPO_RANGE[1]):
|
|
407
|
+
die(f"--tempo must be {TEMPO_RANGE[0]:g}..{TEMPO_RANGE[1]:g}, got {args.tempo:g}")
|
|
408
|
+
if keep_video and not args.replace:
|
|
409
|
+
die("--tempo changes the length of the input's own sound, and its picture is stream-copied, so the two "
|
|
410
|
+
"would drift apart; write an audio output (-o NAME.wav), or retime both with fit.py --method speed",
|
|
411
|
+
kind="input")
|
|
412
|
+
if args.length_samples is not None:
|
|
413
|
+
if args.length_samples < 1:
|
|
414
|
+
die(f"--length-samples must be a positive number of samples, got {args.length_samples}")
|
|
415
|
+
if keep_video:
|
|
416
|
+
die("--length-samples needs an audio output: beside a stream-copied picture the sound is conformed to "
|
|
417
|
+
"the picture's length; write -o NAME.wav (or .flac, .m4a, ...)", kind="input")
|
|
418
|
+
if args.sample_rate is not None and not (SAMPLE_RATE_RANGE[0] <= args.sample_rate <= SAMPLE_RATE_RANGE[1]):
|
|
419
|
+
die(f"--sample-rate must be {SAMPLE_RATE_RANGE[0]}..{SAMPLE_RATE_RANGE[1]} Hz, got {args.sample_rate}")
|
|
420
|
+
if args.channel is not None:
|
|
421
|
+
if args.channel < 0:
|
|
422
|
+
die(f"--channel is a 0-based channel index, got {args.channel}")
|
|
423
|
+
if args.downmix:
|
|
424
|
+
die("--channel and --downmix contradict each other: --channel keeps one channel, --downmix folds them all to stereo")
|
|
425
|
+
if in_channels and args.channel >= in_channels:
|
|
426
|
+
die(f"--channel {args.channel}: the track has {in_channels} channel(s), 0..{in_channels - 1}",
|
|
427
|
+
kind="input", channels=in_channels)
|
|
428
|
+
in_channels = 1 # what every later stage sees: the one channel kept
|
|
302
429
|
if args.stereo_widen is not None:
|
|
303
430
|
if args.mono:
|
|
304
431
|
die("--stereo-widen and --mono contradict each other: there is no stereo image in a 1-channel output")
|
|
@@ -321,6 +448,9 @@ def main() -> int:
|
|
|
321
448
|
idx += 1
|
|
322
449
|
|
|
323
450
|
fx: List[str] = []
|
|
451
|
+
if args.channel is not None:
|
|
452
|
+
# #487: one channel by index, first, so every later stage works on it alone
|
|
453
|
+
fx.append(f"pan=mono|c0=c{args.channel}")
|
|
324
454
|
if args.downmix:
|
|
325
455
|
# ITU-R BS.775: centre and surrounds at -3 dB (0.707), LFE dropped. pan's '<' scales each
|
|
326
456
|
# output so its gains sum to 1 over the channels the input has (1/2.414 on 5.1, 1/3.121 on
|
|
@@ -357,6 +487,22 @@ def main() -> int:
|
|
|
357
487
|
# as it goes before the centre collapses. It runs after the channel layout is settled, so
|
|
358
488
|
# a --downmix 5.1 source is widened on the stereo fold-down rather than on six channels.
|
|
359
489
|
fx.append(f"extrastereo=m={1 + 2 * args.stereo_widen:g}")
|
|
490
|
+
tempo_steps = atempo_steps(args.tempo) if args.tempo is not None and args.tempo != 1.0 else []
|
|
491
|
+
if tempo_steps:
|
|
492
|
+
fx.append(",".join(f"atempo={f:.10g}" for f in tempo_steps))
|
|
493
|
+
# the main track's length in the output: the source's, or the source's / --tempo on an audio
|
|
494
|
+
# output of the input's own sound (beside a picture, or for --replace, it is the picture's)
|
|
495
|
+
main_dur = dur / args.tempo if dur and tempo_steps and not (keep_video or args.replace) else dur
|
|
496
|
+
rate = args.sample_rate or main_track_rate(args, meta)
|
|
497
|
+
# where the output ends, which is where a fade-out has to end: --length-samples when it is
|
|
498
|
+
# given (a fade timed on the source's length sat past the trim and was never heard)
|
|
499
|
+
end_dur = args.length_samples / rate if args.length_samples is not None and rate else main_dur
|
|
500
|
+
if args.length_samples is not None and rate and rate != OPUS_DECODED_RATE and "libopus" in audio_codec_for(output, args.bitrate):
|
|
501
|
+
# An Opus stream decodes at 48 kHz whatever rate went in (a 16 kHz track too), so the
|
|
502
|
+
# decoded track has N * 48000 / rate samples and the count asked for is not the file's
|
|
503
|
+
die(f"--length-samples counts at {rate} Hz, and an Opus output decodes at {OPUS_DECODED_RATE} Hz: "
|
|
504
|
+
f"the file would not hold {args.length_samples} samples",
|
|
505
|
+
kind="input", hint="add --sample-rate 48000 and give the length in 48 kHz samples, or write .wav/.flac")
|
|
360
506
|
|
|
361
507
|
graph: List[str] = []
|
|
362
508
|
graph.append(f"[{main_src}]{','.join(fx) if fx else 'anull'}[main]")
|
|
@@ -369,9 +515,9 @@ def main() -> int:
|
|
|
369
515
|
inputs += ["-i", args.music]
|
|
370
516
|
m = f"{idx}:a:0"
|
|
371
517
|
idx += 1
|
|
372
|
-
mfx = [f"volume={args.music_volume:g}dB", f"atrim=0:{
|
|
373
|
-
if args.music_fade_out and
|
|
374
|
-
mfx.append(f"afade=t=out:st={max(0.0,
|
|
518
|
+
mfx = [f"volume={args.music_volume:g}dB", f"atrim=0:{main_dur:.6f}" if main_dur else "anull"]
|
|
519
|
+
if args.music_fade_out and end_dur:
|
|
520
|
+
mfx.append(f"afade=t=out:st={max(0.0, end_dur - args.music_fade_out):.3f}:d={args.music_fade_out:g}")
|
|
375
521
|
graph.append(f"[{m}]{','.join(mfx)}[music]")
|
|
376
522
|
if args.duck:
|
|
377
523
|
graph.append("[main]asplit=2[mainA][sc]")
|
|
@@ -390,7 +536,7 @@ def main() -> int:
|
|
|
390
536
|
inputs += ["-i", args.effects]
|
|
391
537
|
e = f"{idx}:a:0"
|
|
392
538
|
idx += 1
|
|
393
|
-
efx = [f"volume={args.effects_volume:g}dB", f"atrim=0:{
|
|
539
|
+
efx = [f"volume={args.effects_volume:g}dB", f"atrim=0:{main_dur:.6f}" if main_dur else "anull"]
|
|
394
540
|
graph.append(f"[{e}]{','.join(efx)}[effects]")
|
|
395
541
|
graph.append(f"[{last}][effects]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mixfx]")
|
|
396
542
|
last = "mixfx"
|
|
@@ -398,17 +544,35 @@ def main() -> int:
|
|
|
398
544
|
post: List[str] = []
|
|
399
545
|
if args.fade_in:
|
|
400
546
|
post.append(f"afade=t=in:st=0:d={args.fade_in:g}")
|
|
401
|
-
if args.fade_out and
|
|
402
|
-
post.append(f"afade=t=out:st={max(0.0,
|
|
547
|
+
if args.fade_out and end_dur:
|
|
548
|
+
post.append(f"afade=t=out:st={max(0.0, end_dur - args.fade_out):.3f}:d={args.fade_out:g}")
|
|
403
549
|
# The audio track is conformed to the source duration whenever it is known: padded with
|
|
404
550
|
# silence if the graph came out short, trimmed if long. A mixed track can come out a few
|
|
405
551
|
# hundredths short (amix's dropout_transition, a looped bed's atrim boundary), and with
|
|
406
552
|
# -shortest below that used to shorten the *video* to match: 12.00 s in, 11.925 s out, four
|
|
407
553
|
# frames of a stream-copied picture gone (#164). Padding the audio, not cutting the picture,
|
|
408
554
|
# is the only correct answer for a tool whose contract says the video is never touched.
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
post.append(f"
|
|
555
|
+
if args.sample_rate:
|
|
556
|
+
# #487: resampled after the mix and the fades, ahead of the sample count, which is in this rate
|
|
557
|
+
post.append(f"aresample={args.sample_rate}")
|
|
558
|
+
conform_samples = None
|
|
559
|
+
if args.length_samples is not None:
|
|
560
|
+
# #486: an exact length asked for, counted at the track's rate (unknown only for a dry
|
|
561
|
+
# run's pending input, whose real run pins it)
|
|
562
|
+
conform_samples = args.length_samples
|
|
563
|
+
post.append(conform_filter(conform_samples, rate))
|
|
564
|
+
elif main_dur and (args.replace or args.music or keep_video):
|
|
565
|
+
if rate:
|
|
566
|
+
conform_samples = round(main_dur * rate)
|
|
567
|
+
# The count is of samples, and sound that starts after the picture keeps its start
|
|
568
|
+
# (apad and atrim=end_sample do not move timestamps): N samples from 0.3 s end 0.3 s
|
|
569
|
+
# past the picture. The trim on the timestamp, which is what this was before #478,
|
|
570
|
+
# ends it with the picture; half a sample of slack keeps the Nth sample of a track
|
|
571
|
+
# that starts at 0.
|
|
572
|
+
post.append(conform_filter(conform_samples, rate) + f",atrim=end={main_dur + 0.5 / rate:.6f}")
|
|
573
|
+
else:
|
|
574
|
+
# a dry run's pending input: no rate to count in yet; the real run counts samples
|
|
575
|
+
post.append(f"apad,atrim=0:{main_dur:.6f}")
|
|
412
576
|
if post:
|
|
413
577
|
graph.append(f"[{last}]{','.join(post)}[out]")
|
|
414
578
|
last = "out"
|
|
@@ -444,7 +608,18 @@ def main() -> int:
|
|
|
444
608
|
"ratio": round(max(2.0, args.duck_amount / 3), 1),
|
|
445
609
|
"attack_ms": args.duck_attack, "release_ms": args.duck_release}
|
|
446
610
|
if args.duck else None)
|
|
447
|
-
|
|
611
|
+
verification: List[Dict[str, Any]] = []
|
|
612
|
+
if args.length_samples is not None and not STATE.dry_run:
|
|
613
|
+
verification.append(length_step(output, str(a.get("codec") or ""), args.length_samples))
|
|
614
|
+
if args.sample_rate is not None and not STATE.dry_run:
|
|
615
|
+
verification.append(property_step("sample_rate", args.sample_rate, a.get("sample_rate")))
|
|
616
|
+
if args.channel is not None and not STATE.dry_run:
|
|
617
|
+
verification.append(property_step("channels", 2 if args.stereo else 1, a.get("channels")))
|
|
618
|
+
audio_block["channel"] = args.channel
|
|
619
|
+
audio_block["sample_rate"] = args.sample_rate
|
|
620
|
+
timing_block = {"tempo": args.tempo, "atempo": tempo_steps, "length_samples": args.length_samples,
|
|
621
|
+
"sample_rate": rate or None}
|
|
622
|
+
emit(output, audio=audio_block, timing=timing_block, **({"verification": verification} if verification else {}), video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
|
|
448
623
|
dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f],
|
|
449
624
|
dropped_non_av_streams=dropped_streams,
|
|
450
625
|
**({"silent": silent, "notes": notes} if silent else {}))
|
package/scripts/check.py
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
Checks duration, frame size / aspect, fps, codec, pixel format, colour tags,
|
|
5
5
|
file size, integrated loudness and true peak against the chosen platform
|
|
6
|
-
and prints a PASS/WARN/FAIL table. Exit code 1 when anything FAILs.
|
|
6
|
+
and prints a PASS/WARN/FAIL table. Exit code 1 when anything FAILs. A length
|
|
7
|
+
or file size over the platform table's limit is WARN (`basis: guidance`): upload
|
|
8
|
+
limits change and the uploader checks the current one; --max-duration / --max-mb
|
|
9
|
+
state the caller's own limit, which FAILs when a --platform is named (with none,
|
|
10
|
+
an over-length is WARN, as every judgement row is). Each
|
|
7
11
|
row's `fix` is the command that resolves it; a few of the less obvious FAILs
|
|
8
12
|
(video codec, pixel format, HDR colour, loudness) also carry a plain-language
|
|
9
13
|
`reason` -- "QuickTime and iOS commonly reject video that isn't 8-bit 4:2:0",
|
|
@@ -43,7 +47,7 @@ import sys
|
|
|
43
47
|
from fractions import Fraction
|
|
44
48
|
from typing import Any, Dict, List, Optional, Tuple
|
|
45
49
|
|
|
46
|
-
from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, resolve as resolve_platform
|
|
50
|
+
from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, upload_limit_note, resolve as resolve_platform
|
|
47
51
|
from _common.probe import (CHANGE_FLOOR, CHANGE_GRID, change_mask_tiles, change_tiles, LEGAL_RANGE_CHOICES, measure_signal_range, COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
|
|
48
52
|
detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
|
|
49
53
|
from _common.emit import file_identity
|
|
@@ -361,11 +365,11 @@ def main() -> int:
|
|
|
361
365
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
362
366
|
ap.add_argument("input")
|
|
363
367
|
ap.add_argument("--platform", choices=PLATFORM_CHOICES, default=None, help="delivery spec to check against (default: youtube, with judgement rows reported as WARN because no platform was named)")
|
|
364
|
-
ap.add_argument("--max-duration", type=float, help="
|
|
368
|
+
ap.add_argument("--max-duration", type=float, help="the caller's own maximum length in seconds: over it FAILs when a --platform is named, WARNs with none (the platform table's limit is guidance, WARN)")
|
|
365
369
|
ap.add_argument("--aspect", help="override allowed aspect (e.g. 9:16 or 16:9,1:1)")
|
|
366
370
|
ap.add_argument("--lufs", type=float, help="override loudness target")
|
|
367
371
|
ap.add_argument("--tp", type=float, help="override true-peak ceiling")
|
|
368
|
-
ap.add_argument("--max-mb", type=float, help="
|
|
372
|
+
ap.add_argument("--max-mb", type=float, help="the caller's own maximum file size in MB: over it FAILs (the platform table's limit is guidance, WARN)")
|
|
369
373
|
ap.add_argument("--no-loudness", action="store_true", help="skip the loudness measurement (faster)")
|
|
370
374
|
ap.add_argument("--content", action="store_true",
|
|
371
375
|
help="also check the content (one decode pass, plus an audio-only and a video-only one): black, frozen, silence, dropouts, flashes and first frame rows")
|
|
@@ -448,6 +452,11 @@ def main() -> int:
|
|
|
448
452
|
|
|
449
453
|
# first frame / flashes: a fade from black or a strobe can be the edit, so neither is a mechanical fix
|
|
450
454
|
JUDGEMENT = {"duration", "aspect", "loudness", "true peak", "fps", "resolution", "first frame", "flashes"}
|
|
455
|
+
# #485: the rows whose limit is the platform table's upload limit rather than one the caller
|
|
456
|
+
# stated (--max-duration, --max-mb). Those are guidance: WARN at worst, never counted as failed.
|
|
457
|
+
guidance = {name for name, stated, limit in (("duration", args.max_duration, spec["max_duration"]),
|
|
458
|
+
("file size", args.max_mb, spec["max_bytes"]))
|
|
459
|
+
if stated is None and limit}
|
|
451
460
|
|
|
452
461
|
def row(name: str, status: str, value: Any, expect: Any, fix: str = "", reason: str = "") -> None:
|
|
453
462
|
# "format" rows are safe to fix mechanically; "judgement" rows change the content
|
|
@@ -456,12 +465,19 @@ def main() -> int:
|
|
|
456
465
|
# person would ask "so what?" about) is why it matters in plain terms, not the spec clause.
|
|
457
466
|
if status == "FAIL" and not named and name in JUDGEMENT:
|
|
458
467
|
status = "WARN"
|
|
468
|
+
# "basis" is where the expected value comes from: "requirement" (the delivery format, or a
|
|
469
|
+
# limit the caller stated) can FAIL; "guidance" (the table's upload limit) cannot.
|
|
470
|
+
basis = "guidance" if name in guidance else "requirement"
|
|
471
|
+
if basis == "guidance" and status != "PASS":
|
|
472
|
+
status = "WARN"
|
|
473
|
+
reason = (reason + "; " if reason else "") + upload_limit_note(args.platform)
|
|
459
474
|
rows.append({"check": name, "status": status, "value": value, "expected": expect, "fix": fix,
|
|
460
475
|
"reason": reason if status != "PASS" else "",
|
|
461
|
-
"kind": "judgement" if name in JUDGEMENT else "format"})
|
|
476
|
+
"kind": "judgement" if name in JUDGEMENT else "format", "basis": basis})
|
|
462
477
|
|
|
463
478
|
# A113: a length ffprobe did not report is not a length of 0 s that fits under every limit.
|
|
464
|
-
# The row says it was not measured (FAIL under a length limit
|
|
479
|
+
# The row says it was not measured (FAIL under a length limit the caller stated, WARN under the
|
|
480
|
+
# platform table's or without one), and the
|
|
465
481
|
# --content rows that are shares of the running time say so too instead of disappearing.
|
|
466
482
|
dur_read = meta.get("duration")
|
|
467
483
|
dur = dur_read or 0.0
|
|
@@ -770,6 +786,8 @@ def main() -> int:
|
|
|
770
786
|
line = f" {r['status']:4s} {r['check']:{width}s} {r['value']} (expected {r['expected']})"
|
|
771
787
|
if r["status"] != "PASS" and r["kind"] == "judgement":
|
|
772
788
|
line += " [judgement]"
|
|
789
|
+
if r["status"] != "PASS" and r["basis"] == "guidance":
|
|
790
|
+
line += " [guidance]"
|
|
773
791
|
if r["status"] != "PASS" and r["reason"]:
|
|
774
792
|
line += f" ({r['reason']})"
|
|
775
793
|
if r["status"] != "PASS" and r["fix"]:
|
package/scripts/export.py
CHANGED
|
@@ -55,7 +55,7 @@ from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, rep
|
|
|
55
55
|
from _common.emit import file_identity
|
|
56
56
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
57
57
|
from _common.probe import SILENT_PEAK_DBFS, shorter_than_a_loudness_block
|
|
58
|
-
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
|
|
58
|
+
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, UPLOAD_LIMITS_AS_OF, resolve as resolve_platform
|
|
59
59
|
# Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
|
|
60
60
|
# from a literal restated here: before 1.14 they were typed twice and the facebook preset had
|
|
61
61
|
# already drifted (no duration cap against the table's 14400 s).
|
|
@@ -679,7 +679,8 @@ def main() -> int:
|
|
|
679
679
|
# kept subtitle track is cut at the same point (a cue after it is not written)
|
|
680
680
|
trimmed = {"from": round(float(meta["duration"]), 3), "to": round(float(p["max"]), 3)}
|
|
681
681
|
note(f"trimmed from {trimmed['from']:.3f}s to the {args.preset} maximum of {p['max']:.0f}s "
|
|
682
|
-
"(
|
|
682
|
+
f"(this tool's table as of {UPLOAD_LIMITS_AS_OF}, and platforms change it: "
|
|
683
|
+
"use --allow-long to keep full length)")
|
|
683
684
|
cmd += ["-t", f"{p['max']:.3f}"]
|
|
684
685
|
damage_before = media_damage(args.input) if args.repair and not STATE.dry_run else None
|
|
685
686
|
with tempfile.TemporaryDirectory(prefix="ffskill_2pass_") as passdir:
|
package/scripts/render.py
CHANGED
|
@@ -393,6 +393,9 @@ def render_pack(names: List[str], args) -> int:
|
|
|
393
393
|
"size_bytes": size, "duration": probe_doc.get("duration"),
|
|
394
394
|
# a destination whose check did not run is "not checked", not "pass" (the class of A113)
|
|
395
395
|
"check": ("pass" if chk.get("ok") else ("%d FAIL" % chk["failed"]) if chk.get("failed") else ("not checked" if ok else "failed")),
|
|
396
|
+
# #485: a destination over the table's upload limit passes its check with a
|
|
397
|
+
# WARN; the pack keeps which, so the row is not a bare "pass"
|
|
398
|
+
"guidance": guidance_warns(chk),
|
|
396
399
|
"ok": bool(ok)})
|
|
397
400
|
outputs.append(out)
|
|
398
401
|
if not ok:
|
|
@@ -406,7 +409,8 @@ def render_pack(names: List[str], args) -> int:
|
|
|
406
409
|
for r in rows:
|
|
407
410
|
dur = f"{r['duration']:.2f} s" if r.get("duration") else "-"
|
|
408
411
|
mb = f"{r['size_bytes'] / 1024 / 1024:.1f} MB" if r.get("size_bytes") else "-"
|
|
409
|
-
|
|
412
|
+
verdict = r["check"] + (f" (upload-limit guidance: {'; '.join(r['guidance'])})" if r.get("guidance") else "")
|
|
413
|
+
lines.append(f"| {r['platform']} | {r['file']} | {mb} | {dur} | {verdict} |")
|
|
410
414
|
lines += ["", f"{len(rows)} destinations from one edit ({os.path.basename(args.input)}).",
|
|
411
415
|
"Rendered by ffmpeg-skill; `report.py --pack` turns this table into an HTML report."]
|
|
412
416
|
if not STATE.dry_run:
|
|
@@ -1090,7 +1094,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
|
1090
1094
|
failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
|
|
1091
1095
|
info(f"plan done: {output}, but the {check_result.get('platform') or 'content'} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""))
|
|
1092
1096
|
else:
|
|
1093
|
-
|
|
1097
|
+
advised = guidance_warns(check_result)
|
|
1098
|
+
info(f"plan done: {output}" + (" — upload-limit guidance, the uploader checks the current limit: "
|
|
1099
|
+
+ "; ".join(advised) if advised else ""))
|
|
1094
1100
|
# the tool's own steps (export's loudness, ...) come in through absorb_stage() above, tagged
|
|
1095
1101
|
# with `stage`, so `verified` is theirs and the check's together
|
|
1096
1102
|
emit(output, plan=path, plan_sha256=digest, unbound_inputs=unbound, tool=tool, stages=[tool] + (["check"] if check_result else []), check=check_result, tool_result=doc,
|
|
@@ -1188,6 +1194,14 @@ def _has_sound(current: str, proj: Dict[str, Any], rel: Any) -> bool:
|
|
|
1188
1194
|
return False
|
|
1189
1195
|
|
|
1190
1196
|
|
|
1197
|
+
def guidance_warns(check_doc: Optional[Dict[str, Any]]) -> List[str]:
|
|
1198
|
+
"""The rows of a check result that are over the platform table's upload limit (#485): WARN,
|
|
1199
|
+
`basis: guidance`. The check passes with them, so each place that says a check passed names
|
|
1200
|
+
them, with the limit, for whoever uploads to compare with the platform's current one."""
|
|
1201
|
+
return [f"{r['check']}={r['value']} (expected {r['expected']})" for r in (check_doc or {}).get("checks", [])
|
|
1202
|
+
if r.get("basis") == "guidance" and r.get("status") == "WARN"]
|
|
1203
|
+
|
|
1204
|
+
|
|
1191
1205
|
def planned_clip_length(clip: Dict[str, Any], src: str, index: int) -> float:
|
|
1192
1206
|
"""A dry run's length of clip `index` before its speed stage: the source's measured length,
|
|
1193
1207
|
or its in..out range of it (the cut a real run measures does not exist yet). Only when neither
|
|
@@ -1820,7 +1834,10 @@ def main() -> int:
|
|
|
1820
1834
|
info(f"check: could not run check.py — {err.get('message') if isinstance(err, dict) else err}")
|
|
1821
1835
|
exit_code = 1
|
|
1822
1836
|
else:
|
|
1823
|
-
|
|
1837
|
+
# #485: over the table's upload limit is a WARN the check passes with; say it here too
|
|
1838
|
+
advised = guidance_warns(check_result)
|
|
1839
|
+
info(f"check: OK for {check_label}" + (" — upload-limit guidance, the uploader checks the current limit: "
|
|
1840
|
+
+ "; ".join(advised) if advised else ""))
|
|
1824
1841
|
stages_done.append("check")
|
|
1825
1842
|
elif "check" in proj:
|
|
1826
1843
|
record_skip('the check stage names neither "platform" nor "content": true, so nothing was checked',
|