ffmpeg-skill 1.8.0 → 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 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 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`.
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 `doctor --json`
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.8.0`) | any release |
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.8.0", "execution_mode": "local", "kind": "execution",
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"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.8.0",
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",
@@ -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`; 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`).
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
@@ -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 out and os.path.splitext(str(out))[1].lower() not in (".mov", ".mkv"):
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
- if crf is not None and not 0 <= int(crf) <= (63 if STATE.codec == "av1" else 51):
314
- die(f"--crf must be between 0 and 51 (x264/x265 scale; 18 is visually lossless, 23 the encoder default), got {crf}")
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
 
@@ -1671,7 +1678,7 @@ def _sdr_bt709(encoder: str) -> "Tuple[str, List[str]]":
1671
1678
  return "colorprim=bt709:transfer=bt709:colormatrix=bt709", []
1672
1679
 
1673
1680
 
1674
- def encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any]] = None) -> List[str]:
1681
+ def encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any]] = None, keep_bt709: bool = True) -> List[str]:
1675
1682
  """The one place that turns (--codec, --quality, --preset, source) into encoder options.
1676
1683
 
1677
1684
  h264 -> x264 8-bit BT.709 (refuses HDR: 8-bit H.264 cannot carry it); hevc -> x265, Main10
@@ -1689,13 +1696,13 @@ def encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any
1689
1696
  if hdr:
1690
1697
  die(f"--codec h264 cannot carry HDR ({v.get('hdr_format') or 'BT.2020'}): 8-bit H.264 is SDR only",
1691
1698
  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)
1699
+ return _x264_raw(crf, preset, keep_bt709)
1693
1700
  if codec == "hevc":
1694
1701
  if hdr:
1695
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}"
1696
- return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf + 2), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
1703
+ return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
1697
1704
  "-x265-params", x265] + hdr_tags + ["-movflags", "+faststart"]
1698
- params, extra = _sdr_bt709("libx265")
1705
+ params, extra = _sdr_bt709("libx265") if keep_bt709 else ("", [])
1699
1706
  return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-tag:v", "hvc1",
1700
1707
  "-x265-params", "log-level=error" + (":" + params if params else "")] + extra + ["-movflags", "+faststart"]
1701
1708
  if codec == "av1":
@@ -1704,12 +1711,12 @@ def encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any
1704
1711
  args = ["-c:v", "libsvtav1", "-preset", str(SVT_PRESET.get(preset, 6)), "-crf", str(min(63, crf)), "-pix_fmt", pix]
1705
1712
  if hdr:
1706
1713
  args += hdr_tags
1707
- else:
1714
+ elif keep_bt709:
1708
1715
  params, extra = _sdr_bt709("libsvtav1")
1709
1716
  args += (["-svtav1-params", params] if params else []) + extra
1710
1717
  elif "libaom-av1" in ffmpeg_encoders():
1711
1718
  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]
1719
+ args += hdr_tags if hdr else (_sdr_bt709("libaom-av1")[1] if keep_bt709 else [])
1713
1720
  else:
1714
1721
  die("--codec av1 needs an AV1 encoder (libsvtav1 or libaom-av1) and this ffmpeg build has neither", kind="missing_tool",
1715
1722
  hint="install an ffmpeg built with SVT-AV1 (most distribution builds are), or use --codec hevc")
@@ -1733,7 +1740,7 @@ def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) ->
1733
1740
  """SDR H.264 encoder args -- or, when --codec named another encoder, that encoder's SDR args
1734
1741
  (color.py's --to-sdr path builds its own H.264 line; the flag still has to reach it)."""
1735
1742
  if STATE.codec and STATE.codec != "h264":
1736
- return encoder_args(STATE.codec, crf, preset, None)
1743
+ return encoder_args(STATE.codec, crf, preset, None, keep_bt709)
1737
1744
  return _x264_raw(crf, preset, keep_bt709)
1738
1745
 
1739
1746
 
@@ -1754,7 +1761,7 @@ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "med
1754
1761
  prim = v.get("color_primaries") or "bt2020"
1755
1762
  trc = v.get("color_transfer") or "arib-std-b67"
1756
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}"
1757
- 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",
1758
1765
  "-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
1759
1766
 
1760
1767
 
@@ -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": list(meta["optional"]) + (CODEC_CAPS if "codec" in schema["properties"] else [])},
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/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 (name in JUDGEMENT or name == "true peak"):
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,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.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
+ 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
- tmp = str(Path(output).with_name(Path(output).stem + "_loudnorm" + Path(output).suffix))
157
- proc = run_tool([str(HERE / "loudness.py"), output, "-I", f"{spec['lufs']:g}", "--tp", f"{spec['tp']:g}", "-o", tmp, "--json", "--overwrite"] + child_args())
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/render.py CHANGED
@@ -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
 
@@ -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 += ["-c:v", "libx264", "-preset", args.preset, "-crf", str(args.crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
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