ffmpeg-skill 1.8.0 → 1.9.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 +1 -0
- package/SKILL.md +19 -3
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/scripts.md +16 -2
- package/scripts/_common.py +41 -17
- package/scripts/_contract.py +14 -1
- package/scripts/broll.py +5 -5
- package/scripts/check.py +2 -2
- package/scripts/cut.py +11 -11
- package/scripts/export.py +12 -6
- package/scripts/freeze.py +2 -5
- package/scripts/render.py +10 -3
- package/scripts/waveform.py +2 -2
package/README.md
CHANGED
|
@@ -355,6 +355,7 @@ FFmpeg 8 shortened the flag column of `ffmpeg -filters`. A parser anchored on th
|
|
|
355
355
|
| **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
|
|
356
356
|
| **exact to the sample** | `cut.py --accurate` on WAV, FLAC (44.1 kHz) and AAC → WAV; WAV stream copy within 2 ms; AAC output +21 ms of encoder priming, reported as `codec_frame` (0.9.1) |
|
|
357
357
|
| **72 / 72** | agent runs of 24 prompts (12 English edits, 8 Japanese, 4 that must be declined), three repeats, graded by an independent model: routing, honest refusals and user's language 72/72, report format 71/72, visual check whenever the picture changed 24/24 (0.8.4) |
|
|
358
|
+
| **108 / 108** | 1.8.0 re-run (2026-09-12, three passes per prompt, Sonnet agent, regex grader + independent Opus grader that re-probed 22 outputs): routing 108/108, honest refusals and failures 108/108 with 0 false successes and 0 raw ffmpeg calls, visual check 22/24, report format 108/108 by regex (91/108 by the stricter grader: 'What/how:' in place of Steps:), user's language 98/108 by the stricter grader (Japanese labels-only reports counted), trigger set 22/22; 13 of 14 platform exports used `--normalize` and platform jobs went from three encodes to one; r04/f01 now answered in the request's language 5/6 (was 0/6). Details in `evals/results/iteration-8.json` |
|
|
358
359
|
| **108 / 108** | 1.7.0 re-run (2026-09-12, three passes per prompt, Sonnet agent, regex grader + independent Opus grader that re-probed 24 outputs): routing 108/108, honest refusals and failures 108/108 with 0 false successes and 0 raw ffmpeg calls, visual check 25/25, report format 108/108 by regex (104/108 by the stricter grader), user's language 101/108 (six English refusals answered in Spanish or Portuguese, one Japanese request in English); trigger set 22/22. Iteration-6 fixes held (fade-in only, Japanese audio trims, music no longer shortens the video). Details in `evals/results/iteration-7.json` |
|
|
359
360
|
| **36 / 36** | 1.4.15 re-run (2026-09-12, one pass per prompt, Sonnet agent, regex grader + manual review): 24-prompt set routing 20/20, honest refusals 5/5, visual check 8/8, report format 25/25, user's language 9/9; exec set real execution 6/6, honest failure on bad inputs 5/5 with 0 false successes, audio-as-audio 3/3, one Japanese report with English labels; trigger set 22/22. Both iteration-5 defects gone (no raw ffmpeg fallback, music no longer shortens the video). Details in `evals/results/iteration-6.json` |
|
|
360
361
|
| **36 / 36** | 1.4.0 re-run (2026-09-11, one pass per prompt, Sonnet agent, regex grader + manual review): 24-prompt set routing 20/20, honest refusals 5/5, visual check 8/8, user's language 8/9; exec set real execution 6/6, honest failure on bad inputs 5/5 with 0 false successes, audio-as-audio 3/3; trigger set 22/22. Details and the six findings in `evals/results/iteration-5.json` |
|
package/SKILL.md
CHANGED
|
@@ -5,12 +5,14 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
|
|
|
5
5
|
|
|
6
6
|
# ffmpeg-skill
|
|
7
7
|
|
|
8
|
-
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress`, `--timeout SECONDS` (a single ffmpeg run is killed past this and reported as `kind: timeout`; default 1800) and `--overwrite` (consent to replace an output that already exists; without it the tool warns today and refuses from 2.0) and `--plan FILE` (a dry run written as a plan: fingerprinted inputs, commands, expected output, verify steps; `render.py FILE` executes it later and refuses if an input changed, so "plan → user confirms → execute" is one round trip). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact (nor side files such as `--edl`, `--sheet` or a generated `.ass`); `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Every tool that re-encodes also takes `--codec h264|hevc|av1|prores` and `--quality N` (CRF scale; overrides `--crf`): without them SDR is x264 and HDR is x265 Main10, as before; `--codec prores` needs
|
|
8
|
+
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress`, `--timeout SECONDS` (a single ffmpeg run is killed past this and reported as `kind: timeout`; default 1800) and `--overwrite` (consent to replace an output that already exists; without it the tool warns today and refuses from 2.0) and `--plan FILE` (a dry run written as a plan: fingerprinted inputs, commands, expected output, verify steps; `render.py FILE` executes it later and refuses if an input changed, so "plan → user confirms → execute" is one round trip). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact (nor side files such as `--edl`, `--sheet` or a generated `.ass`); `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Every tool that re-encodes also takes `--codec h264|hevc|av1|prores` and `--quality N` (CRF scale; overrides `--crf`): without them SDR is x264 and HDR is x265 Main10, as before; `--codec prores` needs an explicit `-o NAME.mov` (or `.mkv`), `--codec h264` refuses an HDR source (run `color.py --to-sdr` first). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
|
|
9
9
|
|
|
10
10
|
## Workflow (always follow this order)
|
|
11
11
|
|
|
12
12
|
0. **Check the environment once per session, if unfamiliar.** On a machine
|
|
13
|
-
you haven't confirmed capability on this session, run
|
|
13
|
+
you haven't confirmed capability on this session, run
|
|
14
|
+
`python3 <skill-dir>/scripts/_contract.py doctor --json` (the npm install
|
|
15
|
+
also exposes it as `npx ffmpeg-skill doctor --json`; there is no doctor.py)
|
|
14
16
|
once: check `ok` and the target tool's `usable` before relying on it. If
|
|
15
17
|
`usable` isn't `yes`, don't run that tool — report the missing capability
|
|
16
18
|
instead of discovering it via a runtime failure (a missing `libass`,
|
|
@@ -64,7 +66,8 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
|
|
|
64
66
|
7. **Keep the user's originals.** Never overwrite the source file. Write new
|
|
65
67
|
files next to the input or where the user asked.
|
|
66
68
|
8. **Look at the picture.** Whenever the picture changed (captions, overlays,
|
|
67
|
-
graphics, crop/pad, resize, colour, transitions
|
|
69
|
+
graphics, crop/pad, resize, colour, transitions, a `join.py` that scaled or
|
|
70
|
+
padded a clip to the first clip's frame) run `look.py OUTPUT`
|
|
68
71
|
(contact sheet) or `look.py OUTPUT --at T`, view the PNG. The job is not
|
|
69
72
|
finished until the report's `Look:` line names that PNG; a probe alone
|
|
70
73
|
cannot see a caption sitting on someone's face. Audio-only jobs (sync,
|
|
@@ -160,6 +163,7 @@ If a request needs an FFmpeg feature none of the 42 scripts expose, say so and n
|
|
|
160
163
|
| "remove the green screen", "chroma key this" | `overlay.py bg.mp4 --video greenscreen.mp4 --chromakey 0x00ff00` |
|
|
161
164
|
| "sync the lav mic to the camera", "line up the two cameras" | `sync.py camera.mp4 mic.wav --replace-audio` / `sync.py camA.mp4 camB.mp4 --trim-second` |
|
|
162
165
|
| "fix the audio levels", "normalise to -14 LUFS" | `loudness.py input.mp4` (`-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast) |
|
|
166
|
+
| "cut this and make it HEVC / AV1", "a ProRes intermediate of the trimmed clip" (an edit whose *output codec* the user named) | `cut.py input.mp4 --start 0:10 --end 0:40 --codec hevc` (`--codec h264\|hevc\|av1\|prores` and `--quality N` on every editing tool that re-encodes; ProRes needs `-o NAME.mov`; without a named codec leave the default) |
|
|
163
167
|
| "export for YouTube / Reels / X", "give me a ProRes master", "make it HEVC" | `export.py input.mp4 --preset youtube|reels|x|prores|h265` (`--normalize` meets the platform's loudness spec in the same call, no separate `loudness.py` pass) |
|
|
164
168
|
| "make a GIF preview" | `export.py input.mp4 --preset gif` |
|
|
165
169
|
| "make a small/low-res proxy for an analysis pass", "a cheap preview file" | `proxy.py input.mp4 [--width 640 --no-audio]` — not a delivery preset, see `export.py` for those |
|
|
@@ -251,6 +255,18 @@ Look: final_sheet.png (captions inside the safe area, logo top-right)
|
|
|
251
255
|
Notes: source was VFR, conformed to 30 fps; audio was mono, made stereo
|
|
252
256
|
```
|
|
253
257
|
|
|
258
|
+
The same five lines for a Japanese request, prose in Japanese around the English labels
|
|
259
|
+
(this is the shape a short job keeps too; English `Done:`/`Steps:` sentences with one Japanese
|
|
260
|
+
word in `Notes:` is not a Japanese report):
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
Done: final.mp4 — 59.98 秒、1080x1920、30 fps、H.264、AAC ステレオ、-14.1 LUFS
|
|
264
|
+
Steps: 0:12-1:12 をカット(無劣化)-> 9:16 にクロップ -> 字幕(ポップ、カラオケ)-> ラウドネス -14 -> Reels 書き出し
|
|
265
|
+
Check: reels — 12 項目すべて合格(verified: true)
|
|
266
|
+
Look: final_sheet.png(字幕はセーフエリア内、ロゴは右上)
|
|
267
|
+
Notes: 元は VFR だったので 30 fps に揃えた。音声はモノラルだったのでステレオにした
|
|
268
|
+
```
|
|
269
|
+
|
|
254
270
|
Keep it to those five lines plus anything the user must decide. Attach the contact sheet when the edit touched the picture. Never report success without the probe of the output; never describe a fix you did not run.
|
|
255
271
|
|
|
256
272
|
When a step fails, replace `Done:` with `Failed:` and keep the rest honest:
|
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 (`1.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.9.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`
|
|
@@ -83,7 +83,7 @@ on, the line says so.
|
|
|
83
83
|
```json
|
|
84
84
|
{
|
|
85
85
|
"contract_version": "1.0",
|
|
86
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.9.0", "execution_mode": "local", "kind": "execution",
|
|
87
87
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
88
88
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
89
89
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
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,18 @@
|
|
|
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`, `--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 tool that re-encodes (
|
|
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`, `--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, overrides `--crf`; up to 63 for av1; ignored by prores). `--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`).
|
|
4
|
+
|
|
5
|
+
## Time grammar (every time-taking flag, 1.9)
|
|
6
|
+
|
|
7
|
+
One parser, `time_arg()`, behind every `--start`, `--end`, `--at`, `--from`,
|
|
8
|
+
`--duration`, `--segments`, cue file and project field: seconds (`12.5`),
|
|
9
|
+
`mm:ss(.fff)` (`1:30`), `hh:mm:ss(.fff)` (`00:01:30.250`; a comma also works,
|
|
10
|
+
as in SRT). A four-part `hh:mm:ss:ff` is SMPTE non-drop-frame timecode at the
|
|
11
|
+
source's frame rate; append `@fps` (`00:01:02:15@29.97`) to name the rate
|
|
12
|
+
yourself, which is the only way for a tool with no input file (`caption.py
|
|
13
|
+
--text` without a video, and `--fps` there). A four-part value with no fps
|
|
14
|
+
anywhere is `kind: input` naming the flag. Nothing else about times differs
|
|
15
|
+
between tools.
|
|
4
16
|
|
|
5
17
|
## Contents
|
|
6
18
|
- probe.py — inspect
|
|
@@ -43,7 +55,9 @@ Every script prints the same information with `--help`; this file exists so the
|
|
|
43
55
|
```
|
|
44
56
|
probe.py INPUT... [--compact] [--field duration|video.fps|...]
|
|
45
57
|
```
|
|
46
|
-
JSON with `duration`, `video{codec,width,height,fps,pix_fmt,color_space,rotation,variable_frame_rate_suspected}
|
|
58
|
+
JSON with `duration`, `video{codec,width,height,fps,pix_fmt,color_space,rotation,variable_frame_rate_suspected,hdr,hdr_signal,hdr_format}`
|
|
59
|
+
(`hdr_signal` is true only for a PQ / HLG transfer or Dolby Vision; `hdr` also counts
|
|
60
|
+
BT.2020 primaries on an SDR transfer, which `hdr_format` names "BT.2020 SDR" -- 2.0 renames),
|
|
47
61
|
`audio{codec,channels,sample_rate}`. `--compact` gives one line per file.
|
|
48
62
|
|
|
49
63
|
### cut.py — cut / join segments
|
package/scripts/_common.py
CHANGED
|
@@ -258,8 +258,9 @@ class Context:
|
|
|
258
258
|
STATE = Context()
|
|
259
259
|
|
|
260
260
|
|
|
261
|
-
def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
262
|
-
"""Add the flags every script shares.
|
|
261
|
+
def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
|
|
262
|
+
"""Add the flags every script shares. `codec=False` is for a tool that re-encodes but whose
|
|
263
|
+
preset decides the encoder (export.py): it must not advertise --codec/--quality in its schema."""
|
|
263
264
|
g = ap.add_argument_group("agent options")
|
|
264
265
|
g.add_argument("--dry-run", action="store_true", help="print the ffmpeg commands that would run, run nothing")
|
|
265
266
|
g.add_argument("--json", action="store_true", help="print a JSON result (output, probe, commands) on stdout instead of the path")
|
|
@@ -272,7 +273,7 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
|
272
273
|
help="allow replacing an existing output (warned today, refused from 2.0)")
|
|
273
274
|
g.add_argument("--plan", metavar="FILE",
|
|
274
275
|
help="write the dry run as a plan (inputs fingerprinted, commands, expected output, verify steps) that render.py FILE executes later; implies --dry-run")
|
|
275
|
-
if "--crf" in ap._option_string_actions:
|
|
276
|
+
if codec and "--crf" in ap._option_string_actions:
|
|
276
277
|
# only the tools that re-encode (they declare --crf before add_common): one encoder choice
|
|
277
278
|
# resolved in video_args(), the 2.0 encoder abstraction pre-shipped in 1.8 (docs/roadmap.md)
|
|
278
279
|
g.add_argument("--codec", choices=CODECS, default=None,
|
|
@@ -304,14 +305,20 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
304
305
|
if not 0 <= int(quality) <= top:
|
|
305
306
|
die(f"--quality must be between 0 and {top} for {STATE.codec or 'h264'} (CRF scale; 18 is visually lossless), got {quality}")
|
|
306
307
|
args.crf = int(quality) # every tool reads args.crf; --quality is the codec-neutral spelling of it
|
|
307
|
-
if STATE.codec == "prores":
|
|
308
|
+
if STATE.codec == "prores" and hasattr(args, "output"):
|
|
308
309
|
out = getattr(args, "output", None)
|
|
309
|
-
if
|
|
310
|
+
if not out:
|
|
311
|
+
# every tool defaults its output to the source's extension (or .mp4): ProRes in an .mp4
|
|
312
|
+
# fails inside ffmpeg with "codec not currently supported in container" (review 7)
|
|
313
|
+
die("--codec prores needs an explicit -o NAME.mov (or .mkv): the default output name keeps the source's container, which cannot hold ProRes",
|
|
314
|
+
hint="give -o NAME.mov")
|
|
315
|
+
if os.path.splitext(str(out))[1].lower() not in (".mov", ".mkv"):
|
|
310
316
|
die(f"--codec prores needs a .mov (or .mkv) output; {os.path.basename(str(out))} cannot hold ProRes",
|
|
311
317
|
hint="give -o NAME.mov")
|
|
312
318
|
crf = getattr(args, "crf", None)
|
|
313
|
-
|
|
314
|
-
|
|
319
|
+
top = 63 if STATE.codec == "av1" else 51
|
|
320
|
+
if crf is not None and not 0 <= int(crf) <= top:
|
|
321
|
+
die(f"--crf must be between 0 and {top} ({'SVT-AV1' if STATE.codec == 'av1' else 'x264/x265'} scale; 18 is visually lossless), got {crf}")
|
|
315
322
|
install_signal_handlers()
|
|
316
323
|
|
|
317
324
|
|
|
@@ -1315,6 +1322,10 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
1315
1322
|
"pix_fmt": video.get("pix_fmt"),
|
|
1316
1323
|
"bit_depth": _bit_depth(pix),
|
|
1317
1324
|
"hdr": hdr,
|
|
1325
|
+
# 1.9 (2.0 A1 pre-shipped as a parallel key): true only for a PQ / HLG transfer or Dolby
|
|
1326
|
+
# Vision, i.e. a genuinely HDR signal. `hdr` also counts BT.2020 primaries on an SDR
|
|
1327
|
+
# transfer ("BT.2020 SDR" in hdr_format) and keeps that meaning until 2.0 renames it.
|
|
1328
|
+
"hdr_signal": trc in ("smpte2084", "arib-std-b67") or bool(dovi),
|
|
1318
1329
|
"hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
|
|
1319
1330
|
"HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
|
|
1320
1331
|
"dolby_vision": dovi,
|
|
@@ -1422,10 +1433,23 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
|
1422
1433
|
v = value.strip().replace(",", ".")
|
|
1423
1434
|
if not v:
|
|
1424
1435
|
raise ValueError("empty time")
|
|
1436
|
+
if "@" in v:
|
|
1437
|
+
# 1.9: 'hh:mm:ss:ff@29.97' names the timecode's rate explicitly (docs/design-decisions.md,
|
|
1438
|
+
# time grammar); it overrides the source fps a tool passed in, and is meaningless without
|
|
1439
|
+
# the four-part form
|
|
1440
|
+
v, _, rate = v.rpartition("@")
|
|
1441
|
+
try:
|
|
1442
|
+
fps = float(rate)
|
|
1443
|
+
except ValueError:
|
|
1444
|
+
raise ValueError(f"bad @fps suffix in '{value}' (expected a number such as @29.97)")
|
|
1445
|
+
if fps <= 0:
|
|
1446
|
+
raise ValueError(f"bad @fps suffix in '{value}': the rate must be positive")
|
|
1447
|
+
if len(v.split(":")) != 4:
|
|
1448
|
+
raise ValueError(f"'{value}': the @fps suffix belongs to an hh:mm:ss:ff timecode, not to seconds or mm:ss")
|
|
1425
1449
|
parts = v.split(":")
|
|
1426
1450
|
if len(parts) == 4:
|
|
1427
1451
|
if fps is None or fps <= 0:
|
|
1428
|
-
raise MissingFpsError(f"'{value}' looks like an hh:mm:ss:ff SMPTE timecode, but no fps was given to convert its frame count to seconds")
|
|
1452
|
+
raise MissingFpsError(f"'{value}' looks like an hh:mm:ss:ff SMPTE timecode, but no fps was given to convert its frame count to seconds (append @fps, e.g. {value}@29.97, or use seconds / mm:ss / hh:mm:ss.ms)")
|
|
1429
1453
|
h, m, s, f = parts
|
|
1430
1454
|
if "." in f:
|
|
1431
1455
|
raise ValueError(f"bad SMPTE timecode: {value}")
|
|
@@ -1455,7 +1479,7 @@ def time_arg(value: str, flag: str, fps: Optional[float] = None) -> float:
|
|
|
1455
1479
|
except MissingFpsError as e:
|
|
1456
1480
|
die(f"{flag} {value!r}: {e}")
|
|
1457
1481
|
except ValueError as e:
|
|
1458
|
-
die(f"{flag} {value!r}: {e} (use seconds, mm:ss, hh:mm:ss.ms or
|
|
1482
|
+
die(f"{flag} {value!r}: {e} (use seconds, mm:ss, hh:mm:ss.ms, or hh:mm:ss:ff at the source's fps or with an explicit @fps suffix)")
|
|
1459
1483
|
return 0.0 # unreachable
|
|
1460
1484
|
|
|
1461
1485
|
|
|
@@ -1671,7 +1695,7 @@ def _sdr_bt709(encoder: str) -> "Tuple[str, List[str]]":
|
|
|
1671
1695
|
return "colorprim=bt709:transfer=bt709:colormatrix=bt709", []
|
|
1672
1696
|
|
|
1673
1697
|
|
|
1674
|
-
def encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any]] = None) -> List[str]:
|
|
1698
|
+
def encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any]] = None, keep_bt709: bool = True) -> List[str]:
|
|
1675
1699
|
"""The one place that turns (--codec, --quality, --preset, source) into encoder options.
|
|
1676
1700
|
|
|
1677
1701
|
h264 -> x264 8-bit BT.709 (refuses HDR: 8-bit H.264 cannot carry it); hevc -> x265, Main10
|
|
@@ -1689,13 +1713,13 @@ def encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any
|
|
|
1689
1713
|
if hdr:
|
|
1690
1714
|
die(f"--codec h264 cannot carry HDR ({v.get('hdr_format') or 'BT.2020'}): 8-bit H.264 is SDR only",
|
|
1691
1715
|
hint="run color.py --to-sdr first, or use --codec hevc / av1 / prores, which keep the source's HDR")
|
|
1692
|
-
return _x264_raw(crf, preset)
|
|
1716
|
+
return _x264_raw(crf, preset, keep_bt709)
|
|
1693
1717
|
if codec == "hevc":
|
|
1694
1718
|
if hdr:
|
|
1695
1719
|
x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
|
|
1696
|
-
return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf + 2), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
|
|
1720
|
+
return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
|
|
1697
1721
|
"-x265-params", x265] + hdr_tags + ["-movflags", "+faststart"]
|
|
1698
|
-
params, extra = _sdr_bt709("libx265")
|
|
1722
|
+
params, extra = _sdr_bt709("libx265") if keep_bt709 else ("", [])
|
|
1699
1723
|
return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-tag:v", "hvc1",
|
|
1700
1724
|
"-x265-params", "log-level=error" + (":" + params if params else "")] + extra + ["-movflags", "+faststart"]
|
|
1701
1725
|
if codec == "av1":
|
|
@@ -1704,12 +1728,12 @@ def encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any
|
|
|
1704
1728
|
args = ["-c:v", "libsvtav1", "-preset", str(SVT_PRESET.get(preset, 6)), "-crf", str(min(63, crf)), "-pix_fmt", pix]
|
|
1705
1729
|
if hdr:
|
|
1706
1730
|
args += hdr_tags
|
|
1707
|
-
|
|
1731
|
+
elif keep_bt709:
|
|
1708
1732
|
params, extra = _sdr_bt709("libsvtav1")
|
|
1709
1733
|
args += (["-svtav1-params", params] if params else []) + extra
|
|
1710
1734
|
elif "libaom-av1" in ffmpeg_encoders():
|
|
1711
1735
|
args = ["-c:v", "libaom-av1", "-crf", str(min(63, crf)), "-b:v", "0", "-cpu-used", "6", "-row-mt", "1", "-pix_fmt", pix]
|
|
1712
|
-
args += hdr_tags if hdr else _sdr_bt709("libaom-av1")[1]
|
|
1736
|
+
args += hdr_tags if hdr else (_sdr_bt709("libaom-av1")[1] if keep_bt709 else [])
|
|
1713
1737
|
else:
|
|
1714
1738
|
die("--codec av1 needs an AV1 encoder (libsvtav1 or libaom-av1) and this ffmpeg build has neither", kind="missing_tool",
|
|
1715
1739
|
hint="install an ffmpeg built with SVT-AV1 (most distribution builds are), or use --codec hevc")
|
|
@@ -1733,7 +1757,7 @@ def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) ->
|
|
|
1733
1757
|
"""SDR H.264 encoder args -- or, when --codec named another encoder, that encoder's SDR args
|
|
1734
1758
|
(color.py's --to-sdr path builds its own H.264 line; the flag still has to reach it)."""
|
|
1735
1759
|
if STATE.codec and STATE.codec != "h264":
|
|
1736
|
-
return encoder_args(STATE.codec, crf, preset, None)
|
|
1760
|
+
return encoder_args(STATE.codec, crf, preset, None, keep_bt709)
|
|
1737
1761
|
return _x264_raw(crf, preset, keep_bt709)
|
|
1738
1762
|
|
|
1739
1763
|
|
|
@@ -1754,7 +1778,7 @@ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "med
|
|
|
1754
1778
|
prim = v.get("color_primaries") or "bt2020"
|
|
1755
1779
|
trc = v.get("color_transfer") or "arib-std-b67"
|
|
1756
1780
|
x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
|
|
1757
|
-
return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf + 2), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
|
|
1781
|
+
return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
|
|
1758
1782
|
"-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
|
|
1759
1783
|
|
|
1760
1784
|
|
package/scripts/_contract.py
CHANGED
|
@@ -876,6 +876,19 @@ def capability_map(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
|
876
876
|
|
|
877
877
|
|
|
878
878
|
# ----------------------------------------------------------------------------- contract
|
|
879
|
+
def _merge_optional(base: List[Dict[str, Any]], extra: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
880
|
+
"""Optional capabilities de-duplicated by name: a tool that already lists encoder:libx265 for
|
|
881
|
+
HDR sources gets its `when` extended by the --codec condition instead of a second entry."""
|
|
882
|
+
out: List[Dict[str, Any]] = [dict(o) for o in base]
|
|
883
|
+
for o in extra:
|
|
884
|
+
hit = next((b for b in out if b["capability"] == o["capability"]), None)
|
|
885
|
+
if hit is None:
|
|
886
|
+
out.append(dict(o))
|
|
887
|
+
elif o["when"] not in hit["when"]:
|
|
888
|
+
hit["when"] = f"{hit['when']}; {o['when']}"
|
|
889
|
+
return out
|
|
890
|
+
|
|
891
|
+
|
|
879
892
|
def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
880
893
|
if name not in TOOL_META:
|
|
881
894
|
# a public script without metadata is drift: fail loudly instead of guessing its role or capabilities
|
|
@@ -903,7 +916,7 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
|
903
916
|
"description": (parser.description or "").strip().splitlines()[0] if parser.description else "",
|
|
904
917
|
"executable": f"scripts/{name}.py",
|
|
905
918
|
"role": meta["role"],
|
|
906
|
-
"capabilities": {"required": list(meta["required"]), "optional":
|
|
919
|
+
"capabilities": {"required": list(meta["required"]), "optional": _merge_optional(meta["optional"], CODEC_CAPS if "codec" in schema["properties"] else [])},
|
|
907
920
|
"inputs": list(meta["inputs"]),
|
|
908
921
|
"outputs": list(meta["outputs"]),
|
|
909
922
|
"input_schema": schema,
|
package/scripts/broll.py
CHANGED
|
@@ -21,7 +21,7 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import Any, Dict, List
|
|
23
23
|
|
|
24
|
-
from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info,
|
|
24
|
+
from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def main() -> int:
|
|
@@ -69,13 +69,13 @@ def main() -> int:
|
|
|
69
69
|
meta_b = probe(path)
|
|
70
70
|
if not meta_b.get("video"):
|
|
71
71
|
die(f"{path} has no video stream")
|
|
72
|
-
at =
|
|
73
|
-
start_b =
|
|
72
|
+
at = time_arg(args.at[i], "--at", fps)
|
|
73
|
+
start_b = time_arg(per(args.from_, i, "0"), "--from", fps)
|
|
74
74
|
if args.end:
|
|
75
|
-
end =
|
|
75
|
+
end = time_arg(per(args.end, i, "0"), "--end", fps)
|
|
76
76
|
length = end - at
|
|
77
77
|
else:
|
|
78
|
-
length =
|
|
78
|
+
length = time_arg(per(args.duration, i, "4"), "--duration", fps)
|
|
79
79
|
if length <= 0:
|
|
80
80
|
die(f"cutaway {i + 1}: length must be > 0 (at {at:g}s, got {length:g}s)")
|
|
81
81
|
if dur_a and at >= dur_a:
|
package/scripts/check.py
CHANGED
|
@@ -98,14 +98,14 @@ def main() -> int:
|
|
|
98
98
|
v, a = meta.get("video") or {}, meta.get("audio") or {}
|
|
99
99
|
rows: List[Dict[str, Any]] = []
|
|
100
100
|
|
|
101
|
-
JUDGEMENT = {"duration", "aspect", "loudness", "fps", "resolution"}
|
|
101
|
+
JUDGEMENT = {"duration", "aspect", "loudness", "true peak", "fps", "resolution"}
|
|
102
102
|
|
|
103
103
|
def row(name: str, status: str, value: Any, expect: Any, fix: str = "", reason: str = "") -> None:
|
|
104
104
|
# "format" rows are safe to fix mechanically; "judgement" rows change the content
|
|
105
105
|
# (what is cut, what is cropped, how loud ambience gets) and need a decision.
|
|
106
106
|
# "fix" is the command that resolves it; "reason" (only on the FAILs a non-technical
|
|
107
107
|
# person would ask "so what?" about) is why it matters in plain terms, not the spec clause.
|
|
108
|
-
if status == "FAIL" and not named and
|
|
108
|
+
if status == "FAIL" and not named and name in JUDGEMENT:
|
|
109
109
|
status = "WARN"
|
|
110
110
|
rows.append({"check": name, "status": status, "value": value, "expected": expect, "fix": fix,
|
|
111
111
|
"reason": reason if status != "PASS" else "",
|
package/scripts/cut.py
CHANGED
|
@@ -30,7 +30,7 @@ import sys
|
|
|
30
30
|
import tempfile
|
|
31
31
|
from typing import List, Tuple
|
|
32
32
|
|
|
33
|
-
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output,
|
|
33
|
+
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input, fmt_secs
|
|
34
34
|
|
|
35
35
|
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
|
|
36
36
|
DROPPED_STREAMS: List[str] = []
|
|
@@ -39,13 +39,9 @@ DROPPED_STREAMS: List[str] = []
|
|
|
39
39
|
NEAREST_KEYFRAMES: list = []
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
def _t(value: str, fps) -> float:
|
|
43
|
-
"""
|
|
44
|
-
|
|
45
|
-
return parse_time(value, fps)
|
|
46
|
-
except (ValueError, MissingFpsError) as e:
|
|
47
|
-
die(f"bad time {value!r}: {e}")
|
|
48
|
-
return 0.0 # unreachable
|
|
42
|
+
def _t(value: str, fps, flag: str = "--segments") -> float:
|
|
43
|
+
"""time_arg() with the input's fps (SMPTE hh:mm:ss:ff, or @fps): the one parser every tool uses (1.9)."""
|
|
44
|
+
return time_arg(value, flag, fps)
|
|
49
45
|
|
|
50
46
|
|
|
51
47
|
def parse_segments(spec: str, fps=None) -> List[Tuple[float, float]]:
|
|
@@ -174,22 +170,26 @@ def main() -> int:
|
|
|
174
170
|
if meta.get("video", {}) and meta["video"].get("variable_frame_rate_suspected") and not args.accurate:
|
|
175
171
|
info("source looks variable-frame-rate; lossless cuts on VFR are unreliable, switching to --accurate")
|
|
176
172
|
args.accurate = True
|
|
173
|
+
if STATE.codec and not args.accurate:
|
|
174
|
+
# "cut this and make it HEVC": a stream copy keeps the source codec, so the request is a re-encode
|
|
175
|
+
info(f"--codec {STATE.codec} asks for a re-encode; the lossless copy path keeps the source codec, switching to --accurate")
|
|
176
|
+
args.accurate = True
|
|
177
177
|
|
|
178
178
|
fps = (meta.get("video") or {}).get("fps")
|
|
179
179
|
if args.segments:
|
|
180
180
|
segments = parse_segments(args.segments, fps)
|
|
181
181
|
else:
|
|
182
|
-
start = _t(args.start, fps)
|
|
182
|
+
start = _t(args.start, fps, "--start")
|
|
183
183
|
if start < 0:
|
|
184
184
|
die(f"--start must not be negative, got {args.start!r}")
|
|
185
185
|
if args.end and args.duration:
|
|
186
186
|
die("use --end or --duration, not both")
|
|
187
187
|
if args.end:
|
|
188
|
-
end = _t(args.end, fps)
|
|
188
|
+
end = _t(args.end, fps, "--end")
|
|
189
189
|
if end < 0:
|
|
190
190
|
die(f"--end must not be negative, got {args.end!r}")
|
|
191
191
|
elif args.duration:
|
|
192
|
-
end = start + _t(args.duration, fps)
|
|
192
|
+
end = start + _t(args.duration, fps, "--duration")
|
|
193
193
|
else:
|
|
194
194
|
end = total
|
|
195
195
|
if end <= start:
|
package/scripts/export.py
CHANGED
|
@@ -62,7 +62,7 @@ def main() -> int:
|
|
|
62
62
|
ap.add_argument("--crf", type=int, help="override CRF")
|
|
63
63
|
ap.add_argument("--normalize", action="store_true", help="youtube/youtube4k/reels/x: when the written file misses the platform's loudness spec, run loudness.py on it (audio re-encoded, video copied) so one export delivers")
|
|
64
64
|
ap.add_argument("--list", action="store_true", help="list presets and exit")
|
|
65
|
-
add_common(ap)
|
|
65
|
+
add_common(ap, codec=False) # the preset decides the codec; --codec would only be refused
|
|
66
66
|
args = ap.parse_args()
|
|
67
67
|
apply_common(args)
|
|
68
68
|
|
|
@@ -73,9 +73,9 @@ def main() -> int:
|
|
|
73
73
|
if not args.input or not args.preset:
|
|
74
74
|
die("input and --preset are required (or use --list)")
|
|
75
75
|
validate_color(args.pad_color, "--pad-color")
|
|
76
|
-
if args.
|
|
77
|
-
die(f"
|
|
78
|
-
hint="drop --
|
|
76
|
+
if args.normalize and args.preset not in PLATFORM_OF:
|
|
77
|
+
die(f"--normalize applies to the platform presets ({', '.join(sorted(PLATFORM_OF))}); --preset {args.preset} has no loudness spec to meet",
|
|
78
|
+
hint="drop --normalize, or run loudness.py with your own target")
|
|
79
79
|
|
|
80
80
|
p = PRESETS[args.preset]
|
|
81
81
|
meta = probe(args.input)
|
|
@@ -153,12 +153,15 @@ def main() -> int:
|
|
|
153
153
|
# encodes). The levels pass only re-encodes audio, so do it here on the written
|
|
154
154
|
# file and the caller gets one export that meets the spec.
|
|
155
155
|
info(f"loudness {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP is outside {platform}'s spec; normalising to {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP")
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
# a private name: <stem>_loudnorm.<ext> is loudness.py's own default output, so a
|
|
157
|
+
# real file of that name next to the export was overwritten and renamed away (review 7)
|
|
158
|
+
tmp = str(Path(output).with_name(f".{Path(output).stem}.normalize-{os.getpid()}{Path(output).suffix}"))
|
|
159
|
+
proc = run_tool([str(HERE / "loudness.py"), output, "-I", f"{spec['lufs']:g}", "--tp", f"{spec['tp']:g}", "-o", tmp, "--json"] + child_args())
|
|
158
160
|
try:
|
|
159
161
|
child = json.loads(proc.stdout)
|
|
160
162
|
except ValueError:
|
|
161
163
|
child = {}
|
|
164
|
+
STATE.commands.extend(child.get("commands") or []) # the encode that changed the audio belongs in this run's log
|
|
162
165
|
if proc.returncode != 0 or child.get("status") != "completed":
|
|
163
166
|
err = child.get("error") or {}
|
|
164
167
|
if os.path.exists(tmp):
|
|
@@ -177,6 +180,9 @@ def main() -> int:
|
|
|
177
180
|
notes.append(f"loudness {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP is outside {platform}'s {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP; "
|
|
178
181
|
f"run loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} on this file, or export with --normalize")
|
|
179
182
|
info("warning: " + notes[-1])
|
|
183
|
+
elif platform and has_audio and args.normalize:
|
|
184
|
+
spec = PLATFORMS[platform]
|
|
185
|
+
notes.append(f"[dry-run] --normalize: loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} would run on the written file if it misses {platform}'s spec")
|
|
180
186
|
if notes:
|
|
181
187
|
extra["notes"] = notes
|
|
182
188
|
emit(output, **extra)
|
package/scripts/freeze.py
CHANGED
|
@@ -19,7 +19,7 @@ Examples:
|
|
|
19
19
|
import argparse
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, MissingFpsError,
|
|
22
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, MissingFpsError, time_arg, fmt_secs, run
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def main() -> int:
|
|
@@ -45,10 +45,7 @@ def main() -> int:
|
|
|
45
45
|
dur = meta.get("duration") or 0.0
|
|
46
46
|
fps = meta["video"].get("fps") or 30.0
|
|
47
47
|
if args.at is not None:
|
|
48
|
-
|
|
49
|
-
at = parse_time(args.at, (meta.get("video") or {}).get("fps"))
|
|
50
|
-
except (ValueError, MissingFpsError) as e:
|
|
51
|
-
die(f"--at {args.at!r}: {e}")
|
|
48
|
+
at = time_arg(args.at, "--at", (meta.get("video") or {}).get("fps"))
|
|
52
49
|
else:
|
|
53
50
|
at = dur
|
|
54
51
|
if at < 0 or at > dur:
|
package/scripts/render.py
CHANGED
|
@@ -56,7 +56,7 @@ import sys
|
|
|
56
56
|
from pathlib import Path
|
|
57
57
|
from typing import Any, Dict, List
|
|
58
58
|
|
|
59
|
-
from export import PRESETS
|
|
59
|
+
from export import PRESETS, PLATFORM_OF
|
|
60
60
|
from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, fingerprint, PLAN_VERSION
|
|
61
61
|
|
|
62
62
|
HERE = Path(__file__).resolve().parent
|
|
@@ -74,7 +74,7 @@ TEMPLATE = {
|
|
|
74
74
|
"audio": None,
|
|
75
75
|
"loudness": {"lufs": -14, "tp": -1},
|
|
76
76
|
"fit": None,
|
|
77
|
-
"export": {"preset": "youtube"},
|
|
77
|
+
"export": {"preset": "youtube", "normalize": True},
|
|
78
78
|
"check": {"platform": "youtube"},
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -464,7 +464,14 @@ def main() -> int:
|
|
|
464
464
|
argv += ["--fit", ex["fit"]]
|
|
465
465
|
if ex.get("crf") is not None:
|
|
466
466
|
argv += ["--crf", str(ex["crf"])]
|
|
467
|
-
|
|
467
|
+
normalize = ex.get("normalize")
|
|
468
|
+
if normalize is None and ex["preset"] in PLATFORM_OF and not proj.get("loudness"):
|
|
469
|
+
# eval 8: a reels project without the key rendered fully, failed the loudness check and
|
|
470
|
+
# was rendered again; a platform preset with no loudness stage of its own gets the
|
|
471
|
+
# one-export behaviour by default ("normalize": false opts out)
|
|
472
|
+
normalize = True
|
|
473
|
+
info(f"export: --normalize on by default for the {ex['preset']} preset (set \"normalize\": false to skip)")
|
|
474
|
+
if normalize:
|
|
468
475
|
argv += ["--normalize"] # one export that meets the platform's loudness (export.py --normalize)
|
|
469
476
|
sh("export.py", *argv)
|
|
470
477
|
stages_done.append("export")
|
package/scripts/waveform.py
CHANGED
|
@@ -22,7 +22,7 @@ Examples:
|
|
|
22
22
|
import argparse
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color, X264_PRESETS, fmt_secs
|
|
25
|
+
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
26
26
|
|
|
27
27
|
WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
|
|
28
28
|
|
|
@@ -76,7 +76,7 @@ def main() -> int:
|
|
|
76
76
|
vf = f"color=c={args.background}:s={args.width}x{args.height}:r={args.fps:g}[bg];[0:a:{args.audio_stream}]{vf}[vis];[bg][vis]overlay=format=auto"
|
|
77
77
|
|
|
78
78
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", vf, "-map", f"0:a:{args.audio_stream}"]
|
|
79
|
-
cmd +=
|
|
79
|
+
cmd += video_args(None, args.crf, args.preset) # the one encoder line, so --codec / --quality reach it (review 7)
|
|
80
80
|
cmd += aac_args()
|
|
81
81
|
# -shortest alone is not enough on FFmpeg 5.x: showwaves keeps emitting frames after the
|
|
82
82
|
# audio ends (a 12 s source came out 14.08 s on 5.1.1, #146), so the output is also capped
|