ffmpeg-skill 1.7.2 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/SKILL.md +4 -2
- package/docs/contract.md +12 -2
- package/package.json +1 -1
- package/references/scripts.md +1 -1
- package/scripts/_common.py +127 -7
- package/scripts/_contract.py +20 -2
- package/scripts/check.py +2 -2
- package/scripts/cut.py +4 -0
- package/scripts/export.py +12 -3
- package/scripts/render.py +1 -1
- 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` |
|
|
@@ -441,6 +442,7 @@ Contributing a change: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
441
442
|
| | |
|
|
442
443
|
|---|---|
|
|
443
444
|
| [CONTRIBUTING.md](CONTRIBUTING.md) | scope, dev setup, tests, PR expectations |
|
|
445
|
+
| [docs/roadmap.md](docs/roadmap.md) | 1.8.0 to 1.20.0 one theme per minor (1.8 to 1.10 pre-ship 2.0), and what 2.0.0 then removes |
|
|
444
446
|
| [docs/design-decisions.md](docs/design-decisions.md) | behaviours that look like bugs but are decisions, with rationale and the pinning test; read before filing a bug |
|
|
445
447
|
| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Contributor Covenant 2.1; reports go through the SECURITY.md channel |
|
|
446
448
|
| [SECURITY.md](SECURITY.md) | how to report a vulnerability privately |
|
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`). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
|
|
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`,
|
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.8.1`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -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.8.1", "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"},
|
|
@@ -162,6 +162,16 @@ tool's job; only the artifact is skipped, including side files such as `--edl`,
|
|
|
162
162
|
generated `.ass`), and `verify` does not support dry-run (its steps run). `SKILL.md` and
|
|
163
163
|
`references/scripts.md` repeat the same list; the contract is the authority.
|
|
164
164
|
|
|
165
|
+
`--codec h264|hevc|av1|prores` and `--quality N` (1.8) are on every tool whose schema has
|
|
166
|
+
`crf` (the ones that re-encode), marked `common`. They are resolved in one place
|
|
167
|
+
(`_common.encoder_args()`): hevc keeps an HDR source Main10 with its tags and writes 8-bit
|
|
168
|
+
BT.709 for SDR; av1 is SVT-AV1 with libaom as the fallback; prores is 422 HQ and needs a
|
|
169
|
+
`.mov`/`.mkv` output; h264 refuses an HDR source (`kind: input`). `--quality` is the CRF scale
|
|
170
|
+
and overrides `--crf`. Without `--codec` the encoder is what it always was (x264 for SDR, x265
|
|
171
|
+
Main10 for HDR), so the flags add no behaviour to a caller that does not pass them. The
|
|
172
|
+
encoder each value needs is listed under the tool's optional capabilities (`--codec hevc` and
|
|
173
|
+
so on). `export.py` refuses `--codec`: its presets decide the codec.
|
|
174
|
+
|
|
165
175
|
`--plan FILE` (1.6) is a dry run that also writes a plan document: `{"plan_version": 1,
|
|
166
176
|
"tool", "argv", "cwd", "inputs": [{"path", "size", "sha256_head_tail"}], "commands",
|
|
167
177
|
"output", "verify": [{"tool": "probe"}, {"tool": "check", "platform"}], "notes"}`. It
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/references/scripts.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Script reference
|
|
2
2
|
|
|
3
|
-
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--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` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
|
|
3
|
+
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--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
4
|
|
|
5
5
|
## Contents
|
|
6
6
|
- probe.py — inspect
|
package/scripts/_common.py
CHANGED
|
@@ -218,6 +218,10 @@ def require_tool(name: str) -> str:
|
|
|
218
218
|
|
|
219
219
|
|
|
220
220
|
X264_PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow", "placebo")
|
|
221
|
+
CODECS = ("h264", "hevc", "av1", "prores")
|
|
222
|
+
# x264 preset names mapped onto SVT-AV1's 0-13 speed scale (lower = slower / better)
|
|
223
|
+
SVT_PRESET = {"ultrafast": 12, "superfast": 11, "veryfast": 10, "faster": 9, "fast": 8, "medium": 6, "slow": 4, "slower": 3, "veryslow": 2, "placebo": 1}
|
|
224
|
+
_ENCODERS: Optional[set] = None
|
|
221
225
|
|
|
222
226
|
|
|
223
227
|
class Context:
|
|
@@ -228,7 +232,7 @@ class Context:
|
|
|
228
232
|
makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
|
|
229
233
|
"""
|
|
230
234
|
|
|
231
|
-
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs")
|
|
235
|
+
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec")
|
|
232
236
|
|
|
233
237
|
def __init__(self) -> None:
|
|
234
238
|
self.reset()
|
|
@@ -247,14 +251,16 @@ class Context:
|
|
|
247
251
|
self.plan: Optional[str] = None # --plan FILE: write the dry-run as a plan document (implies --dry-run)
|
|
248
252
|
self.plan_written = False # write_plan() ran (emit or the exit hook), so the hook does not write twice
|
|
249
253
|
self.plan_inputs: List[str] = [] # side inputs (srt/ass/lut/font files) a tool named through escape_filter_path
|
|
254
|
+
self.codec: Optional[str] = None # --codec: encoder for the re-encode (None = x264 for SDR, x265 for HDR)
|
|
250
255
|
|
|
251
256
|
|
|
252
257
|
|
|
253
258
|
STATE = Context()
|
|
254
259
|
|
|
255
260
|
|
|
256
|
-
def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
257
|
-
"""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."""
|
|
258
264
|
g = ap.add_argument_group("agent options")
|
|
259
265
|
g.add_argument("--dry-run", action="store_true", help="print the ffmpeg commands that would run, run nothing")
|
|
260
266
|
g.add_argument("--json", action="store_true", help="print a JSON result (output, probe, commands) on stdout instead of the path")
|
|
@@ -267,6 +273,13 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
|
267
273
|
help="allow replacing an existing output (warned today, refused from 2.0)")
|
|
268
274
|
g.add_argument("--plan", metavar="FILE",
|
|
269
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")
|
|
276
|
+
if codec and "--crf" in ap._option_string_actions:
|
|
277
|
+
# only the tools that re-encode (they declare --crf before add_common): one encoder choice
|
|
278
|
+
# resolved in video_args(), the 2.0 encoder abstraction pre-shipped in 1.8 (docs/roadmap.md)
|
|
279
|
+
g.add_argument("--codec", choices=CODECS, default=None,
|
|
280
|
+
help="video encoder for the re-encode: h264 (x264, the default for SDR), hevc (x265, the default for HDR), av1 (SVT-AV1 or libaom), prores (422 HQ, needs a .mov/.mkv output); HDR sources keep their colour on hevc/av1/prores")
|
|
281
|
+
g.add_argument("--quality", type=int, default=None, metavar="N",
|
|
282
|
+
help="encoder quality on the CRF scale (lower = better; 18 visually lossless for x264/x265, up to 63 for av1); overrides --crf, ignored by prores")
|
|
270
283
|
|
|
271
284
|
|
|
272
285
|
def apply_common(args: "argparse.Namespace") -> None:
|
|
@@ -285,9 +298,27 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
285
298
|
STATE.timeout = max(0.0, float(args.timeout))
|
|
286
299
|
if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
|
|
287
300
|
args.preset = "veryfast"
|
|
301
|
+
STATE.codec = getattr(args, "codec", None) or None
|
|
302
|
+
quality = getattr(args, "quality", None)
|
|
303
|
+
if quality is not None:
|
|
304
|
+
top = 63 if STATE.codec == "av1" else 51
|
|
305
|
+
if not 0 <= int(quality) <= top:
|
|
306
|
+
die(f"--quality must be between 0 and {top} for {STATE.codec or 'h264'} (CRF scale; 18 is visually lossless), got {quality}")
|
|
307
|
+
args.crf = int(quality) # every tool reads args.crf; --quality is the codec-neutral spelling of it
|
|
308
|
+
if STATE.codec == "prores" and hasattr(args, "output"):
|
|
309
|
+
out = getattr(args, "output", None)
|
|
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"):
|
|
316
|
+
die(f"--codec prores needs a .mov (or .mkv) output; {os.path.basename(str(out))} cannot hold ProRes",
|
|
317
|
+
hint="give -o NAME.mov")
|
|
288
318
|
crf = getattr(args, "crf", None)
|
|
289
|
-
|
|
290
|
-
|
|
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}")
|
|
291
322
|
install_signal_handlers()
|
|
292
323
|
|
|
293
324
|
|
|
@@ -1619,13 +1650,100 @@ def bt709_tag_args(encoder: str = "libx264") -> List[str]:
|
|
|
1619
1650
|
return ["-x264-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709"]
|
|
1620
1651
|
|
|
1621
1652
|
|
|
1622
|
-
def
|
|
1653
|
+
def ffmpeg_encoders() -> set:
|
|
1654
|
+
"""Names from `ffmpeg -encoders`, read once; empty when ffmpeg is missing. Used only to pick
|
|
1655
|
+
an AV1 encoder and to refuse --codec av1 / prores before ffmpeg would."""
|
|
1656
|
+
global _ENCODERS
|
|
1657
|
+
if _ENCODERS is None:
|
|
1658
|
+
_ENCODERS = set()
|
|
1659
|
+
try:
|
|
1660
|
+
out = subprocess.run([shutil.which("ffmpeg") or "ffmpeg", "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
|
|
1661
|
+
stderr=subprocess.DEVNULL, text=True, timeout=PROBE_TIMEOUT).stdout
|
|
1662
|
+
_ENCODERS = set(re.findall(r"^\s*[VAS][.\w]{5}\s+(\S+)", out, re.M))
|
|
1663
|
+
except (OSError, subprocess.SubprocessError):
|
|
1664
|
+
pass
|
|
1665
|
+
return _ENCODERS
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
def _sdr_bt709(encoder: str) -> "Tuple[str, List[str]]":
|
|
1669
|
+
"""BT.709 SDR tags for `encoder` as (encoder-params string, extra output options): the two
|
|
1670
|
+
spellings bt709_tag_args() picks between, split so a caller that already builds an encoder
|
|
1671
|
+
params string can merge them (the option given twice keeps only the last)."""
|
|
1672
|
+
if ffmpeg_version() < (7, 1):
|
|
1673
|
+
return "", ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
|
|
1674
|
+
if encoder == "libsvtav1":
|
|
1675
|
+
return "color-primaries=1:transfer-characteristics=1:matrix-coefficients=1", []
|
|
1676
|
+
if encoder == "libaom-av1":
|
|
1677
|
+
return "", [] # no VUI params option; an untagged 8-bit stream reads as BT.709 everywhere
|
|
1678
|
+
return "colorprim=bt709:transfer=bt709:colormatrix=bt709", []
|
|
1679
|
+
|
|
1680
|
+
|
|
1681
|
+
def encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any]] = None, keep_bt709: bool = True) -> List[str]:
|
|
1682
|
+
"""The one place that turns (--codec, --quality, --preset, source) into encoder options.
|
|
1683
|
+
|
|
1684
|
+
h264 -> x264 8-bit BT.709 (refuses HDR: 8-bit H.264 cannot carry it); hevc -> x265, Main10
|
|
1685
|
+
with the source's tags for HDR, 8-bit BT.709 otherwise; av1 -> SVT-AV1 (libaom fallback),
|
|
1686
|
+
10-bit for HDR; prores -> ProRes 422 HQ, source tags kept. 1.8: chosen by --codec; without
|
|
1687
|
+
it video_args() does what it always did (x264 for SDR, x265 Main10 for HDR).
|
|
1688
|
+
"""
|
|
1689
|
+
v = (meta or {}).get("video") or {}
|
|
1690
|
+
hdr = bool(v.get("hdr"))
|
|
1691
|
+
cs = v.get("color_space") or "bt2020nc"
|
|
1692
|
+
prim = v.get("color_primaries") or "bt2020"
|
|
1693
|
+
trc = v.get("color_transfer") or "arib-std-b67"
|
|
1694
|
+
hdr_tags = ["-colorspace", cs, "-color_primaries", prim, "-color_trc", trc]
|
|
1695
|
+
if codec == "h264":
|
|
1696
|
+
if hdr:
|
|
1697
|
+
die(f"--codec h264 cannot carry HDR ({v.get('hdr_format') or 'BT.2020'}): 8-bit H.264 is SDR only",
|
|
1698
|
+
hint="run color.py --to-sdr first, or use --codec hevc / av1 / prores, which keep the source's HDR")
|
|
1699
|
+
return _x264_raw(crf, preset, keep_bt709)
|
|
1700
|
+
if codec == "hevc":
|
|
1701
|
+
if hdr:
|
|
1702
|
+
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}"
|
|
1703
|
+
return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
|
|
1704
|
+
"-x265-params", x265] + hdr_tags + ["-movflags", "+faststart"]
|
|
1705
|
+
params, extra = _sdr_bt709("libx265") if keep_bt709 else ("", [])
|
|
1706
|
+
return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-tag:v", "hvc1",
|
|
1707
|
+
"-x265-params", "log-level=error" + (":" + params if params else "")] + extra + ["-movflags", "+faststart"]
|
|
1708
|
+
if codec == "av1":
|
|
1709
|
+
pix = "yuv420p10le" if hdr else "yuv420p"
|
|
1710
|
+
if "libsvtav1" in ffmpeg_encoders():
|
|
1711
|
+
args = ["-c:v", "libsvtav1", "-preset", str(SVT_PRESET.get(preset, 6)), "-crf", str(min(63, crf)), "-pix_fmt", pix]
|
|
1712
|
+
if hdr:
|
|
1713
|
+
args += hdr_tags
|
|
1714
|
+
elif keep_bt709:
|
|
1715
|
+
params, extra = _sdr_bt709("libsvtav1")
|
|
1716
|
+
args += (["-svtav1-params", params] if params else []) + extra
|
|
1717
|
+
elif "libaom-av1" in ffmpeg_encoders():
|
|
1718
|
+
args = ["-c:v", "libaom-av1", "-crf", str(min(63, crf)), "-b:v", "0", "-cpu-used", "6", "-row-mt", "1", "-pix_fmt", pix]
|
|
1719
|
+
args += hdr_tags if hdr else (_sdr_bt709("libaom-av1")[1] if keep_bt709 else [])
|
|
1720
|
+
else:
|
|
1721
|
+
die("--codec av1 needs an AV1 encoder (libsvtav1 or libaom-av1) and this ffmpeg build has neither", kind="missing_tool",
|
|
1722
|
+
hint="install an ffmpeg built with SVT-AV1 (most distribution builds are), or use --codec hevc")
|
|
1723
|
+
return args + ["-movflags", "+faststart"]
|
|
1724
|
+
if codec == "prores":
|
|
1725
|
+
if "prores_ks" not in ffmpeg_encoders():
|
|
1726
|
+
die("--codec prores needs the prores_ks encoder and this ffmpeg build lacks it", kind="missing_tool")
|
|
1727
|
+
return ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le"] + (hdr_tags if hdr else [])
|
|
1728
|
+
die(f"unknown --codec {codec!r} (one of {', '.join(CODECS)})")
|
|
1729
|
+
return []
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
def _x264_raw(crf: int, preset: str, keep_bt709: bool = True) -> List[str]:
|
|
1623
1733
|
args = ["-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
|
|
1624
1734
|
if keep_bt709:
|
|
1625
1735
|
args += bt709_tag_args("libx264")
|
|
1626
1736
|
return args
|
|
1627
1737
|
|
|
1628
1738
|
|
|
1739
|
+
def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) -> List[str]:
|
|
1740
|
+
"""SDR H.264 encoder args -- or, when --codec named another encoder, that encoder's SDR args
|
|
1741
|
+
(color.py's --to-sdr path builds its own H.264 line; the flag still has to reach it)."""
|
|
1742
|
+
if STATE.codec and STATE.codec != "h264":
|
|
1743
|
+
return encoder_args(STATE.codec, crf, preset, None, keep_bt709)
|
|
1744
|
+
return _x264_raw(crf, preset, keep_bt709)
|
|
1745
|
+
|
|
1746
|
+
|
|
1629
1747
|
def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium") -> List[str]:
|
|
1630
1748
|
"""Encoder args that preserve what the source is.
|
|
1631
1749
|
|
|
@@ -1634,6 +1752,8 @@ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "med
|
|
|
1634
1752
|
so cutting/captioning/fitting an iPhone HDR clip stays HDR instead of becoming a
|
|
1635
1753
|
washed-out file mislabelled as BT.709. Use color.py --to-sdr when SDR is wanted.
|
|
1636
1754
|
"""
|
|
1755
|
+
if STATE.codec:
|
|
1756
|
+
return encoder_args(STATE.codec, crf, preset, meta)
|
|
1637
1757
|
v = (meta or {}).get("video") or {}
|
|
1638
1758
|
if not v.get("hdr"):
|
|
1639
1759
|
return x264_args(crf, preset)
|
|
@@ -1641,7 +1761,7 @@ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "med
|
|
|
1641
1761
|
prim = v.get("color_primaries") or "bt2020"
|
|
1642
1762
|
trc = v.get("color_transfer") or "arib-std-b67"
|
|
1643
1763
|
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}"
|
|
1644
|
-
return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf + 2), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
|
|
1764
|
+
return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
|
|
1645
1765
|
"-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
|
|
1646
1766
|
|
|
1647
1767
|
|
package/scripts/_contract.py
CHANGED
|
@@ -57,6 +57,10 @@ ROLES = {
|
|
|
57
57
|
FF = ["ffmpeg", "ffprobe"]
|
|
58
58
|
X264 = "encoder:libx264"
|
|
59
59
|
X265 = "encoder:libx265"
|
|
60
|
+
# --codec (1.8, every tool that re-encodes): the encoder the flag names, on top of the tool's own list
|
|
61
|
+
CODEC_CAPS = [{"capability": X265, "when": "--codec hevc"},
|
|
62
|
+
{"capability": "encoder:libsvtav1", "when": "--codec av1 (libaom-av1 is the fallback)"},
|
|
63
|
+
{"capability": "encoder:prores_ks", "when": "--codec prores"}]
|
|
60
64
|
AAC = "encoder:aac"
|
|
61
65
|
HDR_X265 = {"capability": X265, "when": "the source is HDR (kept as HEVC Main10)"}
|
|
62
66
|
AUDIO_OUT = [
|
|
@@ -333,7 +337,7 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
|
|
|
333
337
|
props: Dict[str, Any] = {}
|
|
334
338
|
required: List[str] = []
|
|
335
339
|
positional: List[str] = []
|
|
336
|
-
common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite", "plan"}
|
|
340
|
+
common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite", "plan", "codec", "quality"}
|
|
337
341
|
for action in parser._actions:
|
|
338
342
|
if isinstance(action, argparse._HelpAction):
|
|
339
343
|
continue
|
|
@@ -648,6 +652,7 @@ def required_capabilities() -> Dict[str, List[str]]:
|
|
|
648
652
|
for meta in TOOL_META.values():
|
|
649
653
|
req.update(meta["required"])
|
|
650
654
|
opt.update(o["capability"] for o in meta["optional"] if o["capability"] != "delegated")
|
|
655
|
+
opt.update(o["capability"] for o in CODEC_CAPS)
|
|
651
656
|
opt -= req
|
|
652
657
|
return {"required": sorted(req), "optional": sorted(opt)}
|
|
653
658
|
|
|
@@ -871,6 +876,19 @@ def capability_map(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
|
871
876
|
|
|
872
877
|
|
|
873
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
|
+
|
|
874
892
|
def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
875
893
|
if name not in TOOL_META:
|
|
876
894
|
# a public script without metadata is drift: fail loudly instead of guessing its role or capabilities
|
|
@@ -898,7 +916,7 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
|
898
916
|
"description": (parser.description or "").strip().splitlines()[0] if parser.description else "",
|
|
899
917
|
"executable": f"scripts/{name}.py",
|
|
900
918
|
"role": meta["role"],
|
|
901
|
-
"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 [])},
|
|
902
920
|
"inputs": list(meta["inputs"]),
|
|
903
921
|
"outputs": list(meta["outputs"]),
|
|
904
922
|
"input_schema": schema,
|
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
|
@@ -174,6 +174,10 @@ def main() -> int:
|
|
|
174
174
|
if meta.get("video", {}) and meta["video"].get("variable_frame_rate_suspected") and not args.accurate:
|
|
175
175
|
info("source looks variable-frame-rate; lossless cuts on VFR are unreliable, switching to --accurate")
|
|
176
176
|
args.accurate = True
|
|
177
|
+
if STATE.codec and not args.accurate:
|
|
178
|
+
# "cut this and make it HEVC": a stream copy keeps the source codec, so the request is a re-encode
|
|
179
|
+
info(f"--codec {STATE.codec} asks for a re-encode; the lossless copy path keeps the source codec, switching to --accurate")
|
|
180
|
+
args.accurate = True
|
|
177
181
|
|
|
178
182
|
fps = (meta.get("video") or {}).get("fps")
|
|
179
183
|
if args.segments:
|
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,6 +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.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")
|
|
76
79
|
|
|
77
80
|
p = PRESETS[args.preset]
|
|
78
81
|
meta = probe(args.input)
|
|
@@ -150,12 +153,15 @@ def main() -> int:
|
|
|
150
153
|
# encodes). The levels pass only re-encodes audio, so do it here on the written
|
|
151
154
|
# file and the caller gets one export that meets the spec.
|
|
152
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")
|
|
153
|
-
|
|
154
|
-
|
|
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())
|
|
155
160
|
try:
|
|
156
161
|
child = json.loads(proc.stdout)
|
|
157
162
|
except ValueError:
|
|
158
163
|
child = {}
|
|
164
|
+
STATE.commands.extend(child.get("commands") or []) # the encode that changed the audio belongs in this run's log
|
|
159
165
|
if proc.returncode != 0 or child.get("status") != "completed":
|
|
160
166
|
err = child.get("error") or {}
|
|
161
167
|
if os.path.exists(tmp):
|
|
@@ -174,6 +180,9 @@ def main() -> int:
|
|
|
174
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; "
|
|
175
181
|
f"run loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} on this file, or export with --normalize")
|
|
176
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")
|
|
177
186
|
if notes:
|
|
178
187
|
extra["notes"] = notes
|
|
179
188
|
emit(output, **extra)
|
package/scripts/render.py
CHANGED
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
|