ffmpeg-skill 1.7.1 → 1.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 CHANGED
@@ -441,6 +441,7 @@ Contributing a change: see [CONTRIBUTING.md](CONTRIBUTING.md).
441
441
  | | |
442
442
  |---|---|
443
443
  | [CONTRIBUTING.md](CONTRIBUTING.md) | scope, dev setup, tests, PR expectations |
444
+ | [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
445
  | [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
446
  | [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Contributor Covenant 2.1; reports go through the SECURITY.md channel |
446
447
  | [SECURITY.md](SECURITY.md) | how to report a vulnerability privately |
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ 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 a `.mov` output, `--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
 
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.7.1`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.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`
@@ -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.7.1", "execution_mode": "local", "kind": "execution",
86
+ "skill": {"id": "ffmpeg-skill", "version": "1.8.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"},
@@ -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.7.1",
3
+ "version": "1.8.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",
@@ -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 tool that re-encodes (the ones with `--crf`) 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 a `.mov`/`.mkv` output, `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and refuses `--codec` -- 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
@@ -385,6 +385,8 @@ plan), runs the tool with the planned argv, then the verify steps (probe; `check
385
385
  for a `--platform` or a platform export preset), and reports `plan`, `tool`,
386
386
  `tool_result` and `check`. Show the plan to the user, get the yes, execute:
387
387
  one round trip instead of re-deriving the command.
388
+ `"export": {"preset": "reels", "normalize": true}` forwards `export.py --normalize`
389
+ so the rendered file meets the platform's loudness without a separate pass.
388
390
 
389
391
  Stages: clips (cut, optional speed) → join (transition) → silence → fit →
390
392
  captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
@@ -413,7 +415,10 @@ check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|broadcast|podca
413
415
  [--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
414
416
  ```
415
417
  PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
416
- step before reporting a deliverable; fix FAILs, mention WARNs.
418
+ step before reporting a deliverable; fix FAILs, mention WARNs. Without
419
+ `--platform` the youtube spec is assumed and the judgement rows (duration,
420
+ aspect, fps, resolution, loudness, true peak) come back as WARN with a `notes`
421
+ line, not FAIL: name the platform when the file is a delivery for it.
417
422
 
418
423
  ### batch.py — same recipe over a folder, cached
419
424
  ```
@@ -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,6 +251,7 @@ 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
 
@@ -267,6 +272,13 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
267
272
  help="allow replacing an existing output (warned today, refused from 2.0)")
268
273
  g.add_argument("--plan", metavar="FILE",
269
274
  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
+ # only the tools that re-encode (they declare --crf before add_common): one encoder choice
277
+ # resolved in video_args(), the 2.0 encoder abstraction pre-shipped in 1.8 (docs/roadmap.md)
278
+ g.add_argument("--codec", choices=CODECS, default=None,
279
+ 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")
280
+ g.add_argument("--quality", type=int, default=None, metavar="N",
281
+ 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
282
 
271
283
 
272
284
  def apply_common(args: "argparse.Namespace") -> None:
@@ -285,8 +297,20 @@ def apply_common(args: "argparse.Namespace") -> None:
285
297
  STATE.timeout = max(0.0, float(args.timeout))
286
298
  if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
287
299
  args.preset = "veryfast"
300
+ STATE.codec = getattr(args, "codec", None) or None
301
+ quality = getattr(args, "quality", None)
302
+ if quality is not None:
303
+ top = 63 if STATE.codec == "av1" else 51
304
+ if not 0 <= int(quality) <= top:
305
+ die(f"--quality must be between 0 and {top} for {STATE.codec or 'h264'} (CRF scale; 18 is visually lossless), got {quality}")
306
+ args.crf = int(quality) # every tool reads args.crf; --quality is the codec-neutral spelling of it
307
+ if STATE.codec == "prores":
308
+ out = getattr(args, "output", None)
309
+ if out and os.path.splitext(str(out))[1].lower() not in (".mov", ".mkv"):
310
+ die(f"--codec prores needs a .mov (or .mkv) output; {os.path.basename(str(out))} cannot hold ProRes",
311
+ hint="give -o NAME.mov")
288
312
  crf = getattr(args, "crf", None)
289
- if crf is not None and not 0 <= int(crf) <= 51:
313
+ if crf is not None and not 0 <= int(crf) <= (63 if STATE.codec == "av1" else 51):
290
314
  die(f"--crf must be between 0 and 51 (x264/x265 scale; 18 is visually lossless, 23 the encoder default), got {crf}")
291
315
  install_signal_handlers()
292
316
 
@@ -1619,13 +1643,100 @@ def bt709_tag_args(encoder: str = "libx264") -> List[str]:
1619
1643
  return ["-x264-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709"]
1620
1644
 
1621
1645
 
1622
- def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) -> List[str]:
1646
+ def ffmpeg_encoders() -> set:
1647
+ """Names from `ffmpeg -encoders`, read once; empty when ffmpeg is missing. Used only to pick
1648
+ an AV1 encoder and to refuse --codec av1 / prores before ffmpeg would."""
1649
+ global _ENCODERS
1650
+ if _ENCODERS is None:
1651
+ _ENCODERS = set()
1652
+ try:
1653
+ out = subprocess.run([shutil.which("ffmpeg") or "ffmpeg", "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
1654
+ stderr=subprocess.DEVNULL, text=True, timeout=PROBE_TIMEOUT).stdout
1655
+ _ENCODERS = set(re.findall(r"^\s*[VAS][.\w]{5}\s+(\S+)", out, re.M))
1656
+ except (OSError, subprocess.SubprocessError):
1657
+ pass
1658
+ return _ENCODERS
1659
+
1660
+
1661
+ def _sdr_bt709(encoder: str) -> "Tuple[str, List[str]]":
1662
+ """BT.709 SDR tags for `encoder` as (encoder-params string, extra output options): the two
1663
+ spellings bt709_tag_args() picks between, split so a caller that already builds an encoder
1664
+ params string can merge them (the option given twice keeps only the last)."""
1665
+ if ffmpeg_version() < (7, 1):
1666
+ return "", ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
1667
+ if encoder == "libsvtav1":
1668
+ return "color-primaries=1:transfer-characteristics=1:matrix-coefficients=1", []
1669
+ if encoder == "libaom-av1":
1670
+ return "", [] # no VUI params option; an untagged 8-bit stream reads as BT.709 everywhere
1671
+ return "colorprim=bt709:transfer=bt709:colormatrix=bt709", []
1672
+
1673
+
1674
+ def encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any]] = None) -> List[str]:
1675
+ """The one place that turns (--codec, --quality, --preset, source) into encoder options.
1676
+
1677
+ h264 -> x264 8-bit BT.709 (refuses HDR: 8-bit H.264 cannot carry it); hevc -> x265, Main10
1678
+ with the source's tags for HDR, 8-bit BT.709 otherwise; av1 -> SVT-AV1 (libaom fallback),
1679
+ 10-bit for HDR; prores -> ProRes 422 HQ, source tags kept. 1.8: chosen by --codec; without
1680
+ it video_args() does what it always did (x264 for SDR, x265 Main10 for HDR).
1681
+ """
1682
+ v = (meta or {}).get("video") or {}
1683
+ hdr = bool(v.get("hdr"))
1684
+ cs = v.get("color_space") or "bt2020nc"
1685
+ prim = v.get("color_primaries") or "bt2020"
1686
+ trc = v.get("color_transfer") or "arib-std-b67"
1687
+ hdr_tags = ["-colorspace", cs, "-color_primaries", prim, "-color_trc", trc]
1688
+ if codec == "h264":
1689
+ if hdr:
1690
+ die(f"--codec h264 cannot carry HDR ({v.get('hdr_format') or 'BT.2020'}): 8-bit H.264 is SDR only",
1691
+ 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)
1693
+ if codec == "hevc":
1694
+ if hdr:
1695
+ 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",
1697
+ "-x265-params", x265] + hdr_tags + ["-movflags", "+faststart"]
1698
+ params, extra = _sdr_bt709("libx265")
1699
+ return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-tag:v", "hvc1",
1700
+ "-x265-params", "log-level=error" + (":" + params if params else "")] + extra + ["-movflags", "+faststart"]
1701
+ if codec == "av1":
1702
+ pix = "yuv420p10le" if hdr else "yuv420p"
1703
+ if "libsvtav1" in ffmpeg_encoders():
1704
+ args = ["-c:v", "libsvtav1", "-preset", str(SVT_PRESET.get(preset, 6)), "-crf", str(min(63, crf)), "-pix_fmt", pix]
1705
+ if hdr:
1706
+ args += hdr_tags
1707
+ else:
1708
+ params, extra = _sdr_bt709("libsvtav1")
1709
+ args += (["-svtav1-params", params] if params else []) + extra
1710
+ elif "libaom-av1" in ffmpeg_encoders():
1711
+ 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]
1713
+ else:
1714
+ die("--codec av1 needs an AV1 encoder (libsvtav1 or libaom-av1) and this ffmpeg build has neither", kind="missing_tool",
1715
+ hint="install an ffmpeg built with SVT-AV1 (most distribution builds are), or use --codec hevc")
1716
+ return args + ["-movflags", "+faststart"]
1717
+ if codec == "prores":
1718
+ if "prores_ks" not in ffmpeg_encoders():
1719
+ die("--codec prores needs the prores_ks encoder and this ffmpeg build lacks it", kind="missing_tool")
1720
+ return ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le"] + (hdr_tags if hdr else [])
1721
+ die(f"unknown --codec {codec!r} (one of {', '.join(CODECS)})")
1722
+ return []
1723
+
1724
+
1725
+ def _x264_raw(crf: int, preset: str, keep_bt709: bool = True) -> List[str]:
1623
1726
  args = ["-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
1624
1727
  if keep_bt709:
1625
1728
  args += bt709_tag_args("libx264")
1626
1729
  return args
1627
1730
 
1628
1731
 
1732
+ def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) -> List[str]:
1733
+ """SDR H.264 encoder args -- or, when --codec named another encoder, that encoder's SDR args
1734
+ (color.py's --to-sdr path builds its own H.264 line; the flag still has to reach it)."""
1735
+ if STATE.codec and STATE.codec != "h264":
1736
+ return encoder_args(STATE.codec, crf, preset, None)
1737
+ return _x264_raw(crf, preset, keep_bt709)
1738
+
1739
+
1629
1740
  def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium") -> List[str]:
1630
1741
  """Encoder args that preserve what the source is.
1631
1742
 
@@ -1634,6 +1745,8 @@ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "med
1634
1745
  so cutting/captioning/fitting an iPhone HDR clip stays HDR instead of becoming a
1635
1746
  washed-out file mislabelled as BT.709. Use color.py --to-sdr when SDR is wanted.
1636
1747
  """
1748
+ if STATE.codec:
1749
+ return encoder_args(STATE.codec, crf, preset, meta)
1637
1750
  v = (meta or {}).get("video") or {}
1638
1751
  if not v.get("hdr"):
1639
1752
  return x264_args(crf, preset)
@@ -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
@@ -379,6 +383,7 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
379
383
  extra: Dict[str, Any] = {}
380
384
  if name == "check":
381
385
  extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
386
+ "notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named: youtube was assumed and judgement rows are WARN"},
382
387
  "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
383
388
  elif name == "scenes":
384
389
  extra = {"file": {"type": "string"}, "duration": {"type": "number"}, "scene_count": {"type": "integer"}, "scenes": {"type": "array"}, "audio_peaks": {"type": "array"}}
@@ -647,6 +652,7 @@ def required_capabilities() -> Dict[str, List[str]]:
647
652
  for meta in TOOL_META.values():
648
653
  req.update(meta["required"])
649
654
  opt.update(o["capability"] for o in meta["optional"] if o["capability"] != "delegated")
655
+ opt.update(o["capability"] for o in CODEC_CAPS)
650
656
  opt -= req
651
657
  return {"required": sorted(req), "optional": sorted(opt)}
652
658
 
@@ -897,7 +903,7 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
897
903
  "description": (parser.description or "").strip().splitlines()[0] if parser.description else "",
898
904
  "executable": f"scripts/{name}.py",
899
905
  "role": meta["role"],
900
- "capabilities": {"required": list(meta["required"]), "optional": list(meta["optional"])},
906
+ "capabilities": {"required": list(meta["required"]), "optional": list(meta["optional"]) + (CODEC_CAPS if "codec" in schema["properties"] else [])},
901
907
  "inputs": list(meta["inputs"]),
902
908
  "outputs": list(meta["outputs"]),
903
909
  "input_schema": schema,
package/scripts/check.py CHANGED
@@ -66,7 +66,7 @@ def aspect_name(w: int, h: int) -> str:
66
66
  def main() -> int:
67
67
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
68
68
  ap.add_argument("input")
69
- ap.add_argument("--platform", choices=sorted(SPECS), default="youtube")
69
+ ap.add_argument("--platform", choices=sorted(SPECS), default=None, help="delivery spec to check against (default: youtube, with judgement rows reported as WARN because no platform was named)")
70
70
  ap.add_argument("--max-duration", type=float, help="override max duration in seconds")
71
71
  ap.add_argument("--aspect", help="override allowed aspect (e.g. 9:16 or 16:9,1:1)")
72
72
  ap.add_argument("--lufs", type=float, help="override loudness target")
@@ -77,6 +77,11 @@ def main() -> int:
77
77
  args = ap.parse_args()
78
78
  apply_common(args)
79
79
 
80
+ # Eval 7: runs that only wanted the format rows got youtube's loudness / true-peak FAILs and
81
+ # spent a paragraph explaining why they left them alone. Without a named platform the
82
+ # judgement rows are advisory: WARN, not FAIL, and not counted as failed.
83
+ named = args.platform is not None
84
+ args.platform = args.platform or "youtube"
80
85
  spec = dict(SPECS[args.platform])
81
86
  if args.max_duration is not None:
82
87
  spec["max_duration"] = args.max_duration
@@ -100,6 +105,8 @@ def main() -> int:
100
105
  # (what is cut, what is cropped, how loud ambience gets) and need a decision.
101
106
  # "fix" is the command that resolves it; "reason" (only on the FAILs a non-technical
102
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 (name in JUDGEMENT or name == "true peak"):
109
+ status = "WARN"
103
110
  rows.append({"check": name, "status": status, "value": value, "expected": expect, "fix": fix,
104
111
  "reason": reason if status != "PASS" else "",
105
112
  "kind": "judgement" if name in JUDGEMENT else "format"})
@@ -179,9 +186,12 @@ def main() -> int:
179
186
 
180
187
  failed = [r for r in rows if r["status"] == "FAIL"]
181
188
  warned = [r for r in rows if r["status"] == "WARN"]
189
+ notes: List[str] = []
190
+ if not named:
191
+ notes.append("no --platform given: youtube's spec was assumed, so judgement rows (duration, aspect, fps, resolution, loudness, true peak) are WARN, not FAIL; name a platform to enforce them")
182
192
  if not args.json:
183
193
  width = max(len(r["check"]) for r in rows)
184
- print(f"{args.input} — {args.platform}")
194
+ print(f"{args.input} — {args.platform}" + ("" if named else " (assumed)"))
185
195
  for r in rows:
186
196
  line = f" {r['status']:4s} {r['check']:{width}s} {r['value']} (expected {r['expected']})"
187
197
  if r["status"] != "PASS" and r["kind"] == "judgement":
@@ -192,11 +202,14 @@ def main() -> int:
192
202
  line += f" -> {r['fix']}"
193
203
  print(line)
194
204
  print(f" {len(rows)} checks, {len(failed)} failed, {len(warned)} warnings")
205
+ for n in notes:
206
+ print(f" note: {n}")
207
+ extra: Dict[str, Any] = {"notes": notes} if notes else {}
195
208
  if failed:
196
209
  die(f"{len(failed)} of {len(rows)} {args.platform} checks failed: {', '.join(r['check'] for r in failed)}",
197
210
  kind="verification", output=None, dry_run=STATE.dry_run,
198
- platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=False)
199
- emit(None, platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=True)
211
+ platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=False, **extra)
212
+ emit(None, platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=True, **extra)
200
213
  return 0
201
214
 
202
215
 
package/scripts/export.py CHANGED
@@ -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.codec:
77
+ die(f"export.py's presets decide the codec (--preset h265 for HEVC, prores for ProRes); --codec {args.codec} is for the editing tools",
78
+ hint="drop --codec here, or run the edit with --codec and export with --preset copy")
76
79
 
77
80
  p = PRESETS[args.preset]
78
81
  meta = probe(args.input)
package/scripts/render.py CHANGED
@@ -27,7 +27,7 @@ Project format (all keys optional except clips):
27
27
  "audio": {"voice": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "music_fade_out": 2},
28
28
  "loudness": {"lufs": -14, "tp": -1},
29
29
  "fit": {"duration": 60},
30
- "export": {"preset": "reels"},
30
+ "export": {"preset": "reels", "normalize": true},
31
31
  "check": {"platform": "reels"}
32
32
  }
33
33
 
@@ -464,6 +464,8 @@ 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
+ if ex.get("normalize"):
468
+ argv += ["--normalize"] # one export that meets the platform's loudness (export.py --normalize)
467
469
  sh("export.py", *argv)
468
470
  stages_done.append("export")
469
471
  else: