ffmpeg-skill 1.25.0 → 2.0.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.
Files changed (48) hide show
  1. package/README.md +6 -6
  2. package/SKILL.md +2 -2
  3. package/docs/contract.md +54 -46
  4. package/package.json +1 -1
  5. package/references/gotchas.md +5 -5
  6. package/references/scripts.md +6 -6
  7. package/scripts/_common/__init__.py +6 -6
  8. package/scripts/_common/decision.py +2 -2
  9. package/scripts/_common/emit.py +2 -37
  10. package/scripts/_common/probe.py +10 -6
  11. package/scripts/_common/runner.py +13 -45
  12. package/scripts/_contract.py +34 -31
  13. package/scripts/background.py +1 -1
  14. package/scripts/broll.py +2 -2
  15. package/scripts/caption.py +1 -1
  16. package/scripts/check.py +3 -3
  17. package/scripts/color.py +4 -4
  18. package/scripts/crop.py +1 -1
  19. package/scripts/cut.py +1 -1
  20. package/scripts/deinterlace.py +1 -1
  21. package/scripts/denoise.py +1 -1
  22. package/scripts/export.py +2 -2
  23. package/scripts/fit.py +2 -2
  24. package/scripts/freeze.py +1 -1
  25. package/scripts/graphics.py +1 -1
  26. package/scripts/grid.py +2 -2
  27. package/scripts/insert.py +1 -1
  28. package/scripts/join.py +2 -2
  29. package/scripts/look.py +1 -1
  30. package/scripts/loop.py +1 -1
  31. package/scripts/multicam.py +2 -2
  32. package/scripts/overlay.py +1 -1
  33. package/scripts/pad.py +1 -1
  34. package/scripts/probe.py +1 -1
  35. package/scripts/proxy.py +2 -2
  36. package/scripts/redact.py +1 -1
  37. package/scripts/render.py +4 -1
  38. package/scripts/report.py +1 -1
  39. package/scripts/reverse.py +1 -1
  40. package/scripts/sequence.py +1 -1
  41. package/scripts/silence.py +1 -1
  42. package/scripts/speedramp.py +1 -1
  43. package/scripts/sphere.py +1 -1
  44. package/scripts/stabilize.py +1 -1
  45. package/scripts/straighten.py +1 -1
  46. package/scripts/sync.py +1 -1
  47. package/scripts/verify.py +2 -2
  48. package/scripts/waveform.py +1 -1
@@ -195,45 +195,20 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
195
195
  g.add_argument("--timeout", type=float, default=None, metavar="SECONDS",
196
196
  help=f"kill an ffmpeg run past this many seconds, kind=timeout (default {DEFAULT_TIMEOUT:.0f}; 0 = no limit)")
197
197
  g.add_argument("--overwrite", action="store_true",
198
- help="allow replacing an existing output (warned today, refused from 2.0)")
198
+ help="allow replacing an existing output (refused without it)")
199
199
  g.add_argument("--plan", metavar="FILE",
200
200
  help="write the dry run as a plan (inputs fingerprinted, commands, expected output, verify steps) that render.py FILE executes later; implies --dry-run")
201
- if codec and "--crf" in ap._option_string_actions:
202
- # --crf became an alias of --quality in 1.8; 1.10 deprecates it (removed in 2.0, see the
203
- # `deprecated` list in `contract --json` and docs/contract.md "What 2.0 changes"). Marked
204
- # here, once, rather than in each re-encoding tool's own parser.
205
- crf = ap._option_string_actions["--crf"]
206
- # The flag's own default moves aside so apply_common() can tell an explicit --crf (in any
207
- # spelling argparse accepts, including the --cr / --c abbreviations) from the default;
208
- # apply_common() puts _CRF_DEFAULT back when the flag was absent.
209
- global _CRF_DEFAULT
210
- _CRF_DEFAULT = crf.default
211
- crf.deprecated_default = crf.default # the schema still advertises it (_contract._json_type)
212
- crf.default = None
213
- if "deprecated" not in (crf.help or ""):
214
- # the nine tools that declare --crf with no help string used to fall through this and
215
- # never show the mark at all (review 9)
216
- crf.help = (crf.help or "x264 CRF when re-encoding (default 18)") + " (deprecated: use --quality)"
217
- # only the tools that re-encode (they declare --crf before add_common): one encoder choice
218
- # resolved in video_args(), the 2.0 encoder abstraction pre-shipped in 1.8 (docs/roadmap.md)
201
+ if codec and "crf" in ap._defaults:
202
+ # A tool that re-encodes declares its CRF default with set_defaults(crf=N) before calling
203
+ # this; --codec/--quality are its only encoder flags, resolved in video_args(). --crf was
204
+ # the 1.x spelling of --quality (an alias from 1.8, deprecated in 1.10, removed in 2.0).
219
205
  g.add_argument("--codec", choices=CODECS, default=None,
220
206
  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")
221
- g.add_argument("--quality", type=int, default=None, metavar="N",
222
- 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")
223
-
224
-
225
- # The declared default of a deprecated --crf, parked by add_common() (one parser per process).
226
- _CRF_DEFAULT: Optional[int] = None
207
+ g.add_argument("--quality", type=int, default=ap._defaults["crf"], metavar="N",
208
+ help="encoder quality on the CRF scale (default %(default)s; lower = better; 18 visually lossless for x264/x265, up to 63 for av1); ignored by prores")
227
209
 
228
210
 
229
211
  def apply_common(args: "argparse.Namespace") -> None:
230
- # Was --crf typed? add_common() parked the flag's default (None in its place) on every tool
231
- # whose --crf is deprecated, i.e. the ones that also have --quality; export.py's --crf is not
232
- # an alias and keeps its own default. Scanning sys.argv for "--crf" instead missed the unique
233
- # prefixes argparse accepts (--cr, --c) and never ran for batch.py's recipe steps (review 9).
234
- crf_explicit = hasattr(args, "quality") and getattr(args, "crf", None) is not None
235
- if hasattr(args, "quality") and hasattr(args, "crf") and args.crf is None:
236
- args.crf = _CRF_DEFAULT
237
212
  STATE.plan = getattr(args, "plan", None) or None
238
213
  STATE.dry_run = bool(getattr(args, "dry_run", False)) or bool(STATE.plan)
239
214
  if STATE.plan:
@@ -269,12 +244,7 @@ def apply_common(args: "argparse.Namespace") -> None:
269
244
  if os.path.splitext(str(out))[1].lower() not in (".mov", ".mkv"):
270
245
  die(f"--codec prores needs a .mov (or .mkv) output; {os.path.basename(str(out))} cannot hold ProRes",
271
246
  hint="give -o NAME.mov")
272
- crf = getattr(args, "crf", None)
273
- # The warning the deprecation policy asks for, only when the caller typed the flag (see
274
- # crf_explicit above). export.py has no --quality (its preset chooses the encoder), so its
275
- # --crf is not an alias and is not deprecated: warn only where --quality exists.
276
- if crf is not None and crf_explicit:
277
- info("warning: --crf is deprecated since 1.10.0; use --quality N (the same CRF scale, codec-neutral). --crf is removed in 2.0.")
247
+ crf = getattr(args, "crf", None) # export.py's own --crf (its preset chooses the encoder)
278
248
  top = 63 if STATE.codec == "av1" else 51
279
249
  if crf is not None and not 0 <= int(crf) <= top:
280
250
  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}")
@@ -565,9 +535,9 @@ def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
565
535
  def _check_existing_output(cmd: Sequence[str]) -> None:
566
536
  """An output path that already exists is someone's file: a previous result, a source the
567
537
  agent mis-named, a deliverable from another run. ffmpeg's -y (which every command carries so
568
- a run never blocks on a y/N prompt) would replace it without a word. Until 2.0 this only
569
- warns, per docs/contract.md's deprecation policy; FFMPEG_SKILL_NO_OVERWRITE=1 opts into the
570
- 2.0 behaviour (refuse) today, and --overwrite is the explicit consent either way. Paths this
538
+ a run never blocks on a y/N prompt) would replace it without a word. Since 2.0 it is refused
539
+ (kind: input, before anything runs, dry runs included) unless --overwrite gives explicit
540
+ consent; 1.x only warned, and FFMPEG_SKILL_NO_OVERWRITE=1 was the opt-in to this. Paths this
571
541
  process wrote itself (a two-pass tool, a copy-then-re-encode fallback) are never in question."""
572
542
  output = cmd[-1]
573
543
  if output in ("-",) or output.startswith("pipe:") or output.startswith("-"):
@@ -586,10 +556,8 @@ def _check_existing_output(cmd: Sequence[str]) -> None:
586
556
  pass
587
557
  if STATE.overwrite:
588
558
  return
589
- if os.environ.get("FFMPEG_SKILL_NO_OVERWRITE", "") not in ("", "0"):
590
- die(f"refusing to overwrite existing output {output!r}: pass --overwrite to replace it, or choose another -o path", kind="input")
591
- info(f"warning: {output} already exists and will be overwritten (pass --overwrite to confirm; "
592
- f"from 2.0 an existing output is refused without it, FFMPEG_SKILL_NO_OVERWRITE=1 enables that now)")
559
+ die(f"refusing to overwrite existing output {output!r}: pass --overwrite to replace it, or choose another -o path",
560
+ kind="input", hint="pass --overwrite, or choose another -o path")
593
561
 
594
562
 
595
563
  def _remember_output(cmd: Sequence[str]) -> None:
@@ -351,9 +351,7 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
351
351
  if isinstance(action, argparse._HelpAction):
352
352
  continue
353
353
  prop: Dict[str, Any] = _json_type(action)
354
- # add_common() parks a deprecated --crf's default aside (so an explicit flag is
355
- # distinguishable from the default); the schema still advertises the real one
356
- default = getattr(action, "deprecated_default", action.default)
354
+ default = action.default
357
355
  if action.help and action.help != argparse.SUPPRESS:
358
356
  prop["description"] = action.help % {"default": default} if "%(default)" in action.help else action.help
359
357
  if action.choices:
@@ -1087,34 +1085,39 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
1087
1085
  # tools that print JSON without --json (probe) or whose primary output is a file path (look): the transport
1088
1086
  # does not append --json for them (stated in invocation.structured.argument_mapping.json)
1089
1087
  # ----------------------------------------------------------------------------- deprecations
1090
- # What 2.0.0 removes, announced here per docs/contract.md's three-step deprecation policy:
1088
+ # What the next major removes, announced here per docs/contract.md's three-step deprecation policy:
1091
1089
  # step 1 (this list, --help text and the CHANGELOG) in a minor, step 2 keeps it working, step 3
1092
- # removes it in the major. `where` says which surface a caller sees it on. docs/contract.md's
1093
- # "What 2.0 changes" section is written from this list.
1094
- DEPRECATED: List[Dict[str, str]] = [
1095
- {"what": "top-level per-tool keys next to result_v2 in a success document (output, probe, commands, verified, verification and each tool's own keys)",
1096
- "since": "1.10.0", "replacement": "result_v2 (FFMPEG_SKILL_RESULT_V2=1 today; the only shape in 2.0)",
1097
- "removed_in": "2.0.0", "where": "json"},
1090
+ # removes it in the major. `where` says which surface a caller sees it on. Empty at 2.0.0: all
1091
+ # five 1.10.0 deprecations were settled by it (REMOVED below).
1092
+ DEPRECATED: List[Dict[str, str]] = []
1093
+
1094
+ # What 2.0.0 removed or changed, with the version that deprecated each: the "Removed" half of the
1095
+ # policy's step 3, kept in the contract so a caller migrating from 1.x can diff it instead of reading
1096
+ # a CHANGELOG. docs/contract.md's "What 2.0 changed" table is written from this list.
1097
+ REMOVED: List[Dict[str, str]] = [
1098
1098
  {"what": "--crf as an alias of --quality on every re-encoding tool that takes --quality (export.py keeps --crf: its preset chooses the encoder)",
1099
- "since": "1.10.0", "replacement": "--quality N (same CRF scale, codec-neutral)",
1100
- "removed_in": "2.0.0", "where": "cli"},
1099
+ "since": "1.10.0", "removed_in": "2.0.0", "where": "cli",
1100
+ "replacement": "--quality N (the same CRF scale, codec-neutral; each tool's old --crf default is its --quality default)"},
1101
1101
  {"what": "json and progress in the MCP inputSchema (they are CLI transport flags, not tool arguments)",
1102
- "since": "1.10.0", "replacement": "nothing: the MCP transport sets them itself (FFMPEG_SKILL_MCP_LEAN=1 drops them today)",
1103
- "removed_in": "2.0.0", "where": "mcp"},
1102
+ "since": "1.10.0", "removed_in": "2.0.0", "where": "mcp",
1103
+ "replacement": "nothing: the MCP transport sets them itself; a client that still sends them is not refused"},
1104
1104
  {"what": "probe's hdr meaning BT.2020 primaries or a PQ/HLG transfer",
1105
- "since": "1.10.0", "replacement": "hdr_signal (true only for PQ / HLG / Dolby Vision); in 2.0 hdr takes that meaning and hdr_format keeps naming the BT.2020 SDR case",
1106
- "removed_in": "2.0.0", "where": "json"},
1107
- {"what": "overwriting an existing output without --overwrite (warned, not refused)",
1108
- "since": "1.10.0", "replacement": "--overwrite, or FFMPEG_SKILL_NO_OVERWRITE=1 to refuse today",
1109
- "removed_in": "2.0.0", "where": "behaviour"},
1105
+ "since": "1.10.0", "removed_in": "2.0.0", "where": "json",
1106
+ "replacement": "hdr is now true only for PQ / HLG / Dolby Vision (as hdr_signal, which stays); bt2020_or_hdr carries the 1.x meaning"},
1107
+ {"what": "overwriting an existing output without --overwrite (1.x warned)",
1108
+ "since": "1.10.0", "removed_in": "2.0.0", "where": "behaviour",
1109
+ "replacement": "--overwrite as explicit consent; without it an existing output is refused (kind: input) before anything runs"},
1110
+ {"what": "the result_v2 preview key and FFMPEG_SKILL_RESULT_V2 (the v1 top-level keys it was to replace were un-deprecated: they are the 2.0 shape)",
1111
+ "since": "1.10.0", "removed_in": "2.0.0", "where": "json",
1112
+ "replacement": "the top-level keys, unchanged from 1.x; --json-brief for a short document"},
1110
1113
  ]
1111
1114
 
1112
1115
 
1113
1116
  MCP_JSON_EXEMPT = ("look", "probe")
1114
- # opt-in lean MCP schema (roadmap 1.10.0): json/progress are transport flags the server appends
1115
- # itself, not tool arguments. Off by default so tools/list stays byte-identical to the CLI surface
1116
- # the contract promises; 2.0 drops them unconditionally.
1117
- MCP_LEAN_DROP = ("json", "progress")
1117
+ # json/progress are CLI transport flags the MCP server sets itself, not tool arguments: 2.0 leaves
1118
+ # them out of every inputSchema (1.10.0-1.x kept them unless FFMPEG_SKILL_MCP_LEAN=1). A client
1119
+ # that still sends them is not refused -- build_argv() maps them like any other key.
1120
+ MCP_TRANSPORT_FLAGS = ("json", "progress")
1118
1121
  # default MCP tools/list surface (roadmap P1-7, shipped 1.18.3): the 42-tool schema dump costs
1119
1122
  # every MCP session context whether it needs it or not. These 12 are the tools eval iterations
1120
1123
  # 17-20's ground-truth `expect` lists actually name most often across the 118 agent prompts in
@@ -1185,7 +1188,7 @@ MCP_PROMPTS: List[Dict[str, Any]] = [
1185
1188
  "template": ("Bring {input} from HDR/Dolby Vision down to SDR: `color.py {input} --to-sdr` (or `--strip-dovi` "
1186
1189
  "to keep HDR and only drop the Dolby Vision layer, when strip_dovi is asked for). A flat, washed-out "
1187
1190
  "look after a re-encode usually means HDR metadata was lost upstream; `probe` shows `hdr: true` "
1188
- "for BT.2020 primaries or a PQ/HLG transfer, and `hdr_signal: true` only for a real PQ/HLG/DV transfer."),
1191
+ "only for a real PQ/HLG/Dolby Vision signal, and `bt2020_or_hdr: true` also for BT.2020 SDR."),
1189
1192
  },
1190
1193
  ]
1191
1194
 
@@ -1259,13 +1262,12 @@ def mcp_input_schema(spec: Dict[str, Any]) -> Dict[str, Any]:
1259
1262
  one_of = [[{"required": [d]} for d in group] for group in src.get("one_of_required", [])]
1260
1263
  if one_of:
1261
1264
  structured["anyOf"] = one_of[0] if len(one_of) == 1 else [{"allOf": [{"anyOf": g} for g in one_of]}]
1262
- if os.environ.get("FFMPEG_SKILL_MCP_LEAN", "") not in ("", "0"):
1263
- for dest in MCP_LEAN_DROP:
1264
- props.pop(dest, None)
1265
- if structured.get("required"):
1266
- structured["required"] = [d for d in structured["required"] if d not in MCP_LEAN_DROP]
1267
- if not structured["required"]:
1268
- del structured["required"]
1265
+ for dest in MCP_TRANSPORT_FLAGS:
1266
+ props.pop(dest, None)
1267
+ if structured.get("required"):
1268
+ structured["required"] = [d for d in structured["required"] if d not in MCP_TRANSPORT_FLAGS]
1269
+ if not structured["required"]:
1270
+ del structured["required"]
1269
1271
  schema: Dict[str, Any] = {"type": "object", "properties": props, "additionalProperties": False}
1270
1272
  if structured:
1271
1273
  schema["anyOf"] = [{"required": ["argv"]}, structured]
@@ -1297,6 +1299,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
1297
1299
  return {
1298
1300
  "contract_version": CONTRACT_VERSION,
1299
1301
  "deprecated": [dict(d) for d in DEPRECATED],
1302
+ "removed": [dict(d) for d in REMOVED],
1300
1303
  "skill": {
1301
1304
  "id": SKILL_ID,
1302
1305
  "version": version,
@@ -28,7 +28,7 @@ def main() -> int:
28
28
  src.add_argument("--color", default="black", help="solid background colour, e.g. black, 0x101010 (default black)")
29
29
  src.add_argument("--gradient", help="two colours as C1:C2 for a linear gradient, e.g. 0xff6a00:0x0057ff")
30
30
  ap.add_argument("--angle", type=float, default=0.0, help="gradient angle in degrees (with --gradient, default 0 = left to right)")
31
- ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
31
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
32
32
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
33
33
  add_common(ap)
34
34
  args = ap.parse_args()
package/scripts/broll.py CHANGED
@@ -35,7 +35,7 @@ def main() -> int:
35
35
  ap.add_argument("--from", dest="from_", action="append", metavar="TIME", help="where in B to start from (default 0); one per --insert, or omit")
36
36
  ap.add_argument("--audio", choices=["a", "b", "mix"], default="a", help="under a cutaway: A's audio (default), B's audio, or both mixed")
37
37
  ap.add_argument("--pad-color", default="black", help="pad colour when B's aspect differs from A's (default black)")
38
- ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
38
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
39
39
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
40
40
  add_common(ap)
41
41
  args = ap.parse_args()
@@ -104,7 +104,7 @@ def main() -> int:
104
104
  for i, c in enumerate(cutaways):
105
105
  geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
106
106
  parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
107
- f"{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('hdr') else 'yuv420p'}[b{i}]")
107
+ f"{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
108
108
  parts.append(f"{cur}[b{i}]overlay=0:0:eof_action=pass:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'[v{i}]")
109
109
  cur = f"[v{i}]"
110
110
  vout = cur
@@ -939,7 +939,7 @@ def main() -> int:
939
939
  help="how words are timed inside a cue: 'energy' follows the speech loudness in the audio (default), 'even' splits time equally")
940
940
  anim.add_argument("--write-ass", help="where to save the generated ASS (default: next to the output)")
941
941
  enc = ap.add_argument_group("encoding")
942
- enc.add_argument("--crf", type=int, default=18)
942
+ enc.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
943
943
  enc.add_argument("--preset", default="medium", choices=X264_PRESETS)
944
944
  add_common(ap)
945
945
  args = ap.parse_args(_glue_negative_offset(sys.argv[1:]))
package/scripts/check.py CHANGED
@@ -137,14 +137,14 @@ def main() -> int:
137
137
  if args.platform in ("reels", "tiktok", "x", "linkedin", "facebook"):
138
138
  row("pixel format", "PASS" if pf == "yuv420p" else "FAIL", pf, "yuv420p (8-bit 4:2:0)", "export.py preset re-encodes to yuv420p",
139
139
  reason="QuickTime and iOS commonly reject video that isn't 8-bit 4:2:0")
140
- if spec["sdr_only"] and v.get("hdr"):
140
+ if spec["sdr_only"] and v.get("bt2020_or_hdr"):
141
141
  row("colour", "FAIL", v.get("hdr_format"), "SDR BT.709", "color.py --to-sdr",
142
142
  reason="a platform or player without HDR support will show this washed-out, too dark, or with wrong colours -- not a rendering glitch, a colour space mismatch")
143
143
  else:
144
144
  tags = (v.get("color_primaries"), v.get("color_transfer"))
145
145
  untagged = not tags[0] and not tags[1]
146
- if v.get("hdr") or tags == ("bt709", "bt709") or args.platform in ("podcast", "custom"):
147
- row("colour", "PASS", f"{tags[0]}/{tags[1]}" + (f" ({v.get('hdr_format')})" if v.get("hdr") else ""), "bt709/bt709 tagged (or HDR)")
146
+ if v.get("bt2020_or_hdr") or tags == ("bt709", "bt709") or args.platform in ("podcast", "custom"):
147
+ row("colour", "PASS", f"{tags[0]}/{tags[1]}" + (f" ({v.get('hdr_format')})" if v.get("bt2020_or_hdr") else ""), "bt709/bt709 tagged (or HDR)")
148
148
  elif untagged and (v.get("bit_depth") or 8) == 8:
149
149
  # untagged 8-bit video is treated as BT.709 by every player and platform; nothing to fix
150
150
  row("colour", "PASS", "untagged (players assume bt709)", "bt709/bt709 tagged (or HDR)")
package/scripts/color.py CHANGED
@@ -203,7 +203,7 @@ def main() -> int:
203
203
  "the first track, same as leaving it unset always did. Only affects modes that re-encode "
204
204
  "audio (--to-sdr, --lut, --correct, and --retag's re-encode fallback) -- --strip-dovi and "
205
205
  "a successful --retag stream-copy all streams untouched, so the flag has nothing to select there.")
206
- ap.add_argument("--crf", type=int, default=18)
206
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
207
207
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
208
208
  add_common(ap)
209
209
  args = ap.parse_args()
@@ -279,13 +279,13 @@ def main() -> int:
279
279
 
280
280
  measurements = None
281
281
  if args.to_sdr:
282
- if not v.get("hdr") and not args.force:
282
+ if not v.get("bt2020_or_hdr") and not args.force:
283
283
  die(f"{args.input} is not tagged as HDR (transfer={v.get('color_transfer')}, primaries={v.get('color_primaries')}). Use --force to tone-map anyway.")
284
284
  vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
285
285
  output = args.output or default_output(args.input, "sdr")
286
286
  tag = "sdr"
287
287
  elif args.correct:
288
- if v.get("hdr") and not args.force:
288
+ if v.get("bt2020_or_hdr") and not args.force:
289
289
  die(f"{args.input} is HDR ({v.get('hdr_format')}); --correct works on SDR pixels and would tag PQ/HLG data as BT.709 "
290
290
  f"without a tone map (sweep F10). Run --to-sdr first, or --force to grade the raw values anyway")
291
291
  vf = correction_chain(args)
@@ -295,7 +295,7 @@ def main() -> int:
295
295
  # "looks better" judgement -- the same primitive probe.py --analyze uses for Log detection.
296
296
  measurements = None if STATE.dry_run else {"input": analyze_levels(args.input)}
297
297
  else:
298
- if v.get("hdr") and not args.force:
298
+ if v.get("bt2020_or_hdr") and not args.force:
299
299
  die(f"{args.input} is HDR ({v.get('hdr_format')}); a LUT made for SDR applied to PQ/HLG pixels gives a wrong picture "
300
300
  f"tagged BT.709 (sweep F10). Run --to-sdr first (or chain it), or --force if the LUT expects HDR input")
301
301
  if not os.path.exists(args.lut):
package/scripts/crop.py CHANGED
@@ -32,7 +32,7 @@ def main() -> int:
32
32
  ap.add_argument("--y", type=int, required=True, help="top edge of the crop rectangle, in source pixels")
33
33
  ap.add_argument("--width", type=int, required=True, help="crop width in px (must be even)")
34
34
  ap.add_argument("--height", type=int, required=True, help="crop height in px (must be even)")
35
- ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
35
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
36
36
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
37
37
  ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
38
38
  add_common(ap)
package/scripts/cut.py CHANGED
@@ -292,7 +292,7 @@ def main() -> int:
292
292
  "this media file) instead of measuring the input again")
293
293
  snap.add_argument("--min-confidence", type=float, default=BEAT_MIN_CONFIDENCE,
294
294
  help=f"refuse to snap below this measured beat confidence (default {BEAT_MIN_CONFIDENCE})")
295
- ap.add_argument("--crf", type=int, default=18, help="x264 CRF when re-encoding (default 18)")
295
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
296
296
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset when re-encoding")
297
297
  add_common(ap)
298
298
  args = ap.parse_args()
@@ -40,7 +40,7 @@ def main() -> int:
40
40
  help="only deinterlace frames the source marks as interlaced; leave the rest untouched")
41
41
  ap.add_argument("--audio-stream", type=int, default=0,
42
42
  help="which audio stream of the input to keep, 0-based in file order (default 0)")
43
- ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
43
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
44
44
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
45
45
  add_common(ap)
46
46
  args = ap.parse_args()
@@ -42,7 +42,7 @@ def main() -> int:
42
42
  ap.add_argument("--chroma-temporal", type=float, help="temporal chroma denoising strength (default from --strength)")
43
43
  ap.add_argument("--audio-stream", type=int, default=0,
44
44
  help="which audio stream of the input to keep, 0-based in file order (default 0)")
45
- ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
45
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
46
46
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
47
47
  ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
48
48
  add_common(ap)
package/scripts/export.py CHANGED
@@ -141,13 +141,13 @@ def main() -> int:
141
141
  if not meta.get("video"):
142
142
  die("input has no video stream")
143
143
  notes: List[str] = []
144
- if p.get("hdr_only") and not meta["video"].get("hdr"):
144
+ if p.get("hdr_only") and not meta["video"].get("bt2020_or_hdr"):
145
145
  # The point of this preset is that the delivery stays HDR. Running it on an SDR source
146
146
  # would write a 10-bit HEVC file labelled with SDR tags and call it an HDR delivery.
147
147
  die(f"--preset {args.preset} delivers HDR and this source is SDR ({meta['video'].get('codec')}, "
148
148
  f"{meta['video'].get('color_transfer') or 'untagged'})",
149
149
  hint="use --preset youtube for an SDR delivery; there is no way to invent HDR range from an SDR master")
150
- if meta["video"].get("hdr") and args.preset not in ("prores", "copy", "youtube-hdr"):
150
+ if meta["video"].get("bt2020_or_hdr") and args.preset not in ("prores", "copy", "youtube-hdr"):
151
151
  notes.append("source is HDR (%s). This preset outputs SDR BT.709 tags without tone mapping; run color.py --to-sdr first for correct colours." % meta["video"].get("hdr_format"))
152
152
  info("warning: " + notes[-1])
153
153
  has_audio = bool(meta.get("audio"))
package/scripts/fit.py CHANGED
@@ -106,7 +106,7 @@ def main() -> int:
106
106
  r.add_argument("--rotate", type=int, choices=[90, 180, 270], help="rotate the picture clockwise by this many degrees")
107
107
  r.add_argument("--flip", choices=["h", "v"], help="mirror the picture horizontally (h) or vertically (v)")
108
108
  e = ap.add_argument_group("encoding")
109
- e.add_argument("--crf", type=int, default=18)
109
+ e.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
110
110
  e.add_argument("--preset", default="medium", choices=X264_PRESETS)
111
111
  e.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
112
112
  add_common(ap)
@@ -227,7 +227,7 @@ def main() -> int:
227
227
  # (gamma-encoded) signal and something else entirely on a PQ/HLG one -- so an HDR
228
228
  # source keeps its blurred background undimmed rather than being silently altered
229
229
  # (review 12). The blur itself is neutral either way, and no tone mapping happens.
230
- darken = 0.0 if meta["video"].get("hdr") else BLUR_DARKEN
230
+ darken = 0.0 if meta["video"].get("bt2020_or_hdr") else BLUR_DARKEN
231
231
  if not darken:
232
232
  info("--fit blur: HDR source, so the blurred background is not dimmed "
233
233
  "(an eq on PQ/HLG code values is not the -15%% dim it is on SDR); "
package/scripts/freeze.py CHANGED
@@ -30,7 +30,7 @@ def main() -> int:
30
30
  ap.add_argument("--hold", type=float, required=True, help="how long the freeze lasts, in seconds")
31
31
  ap.add_argument("--mode", choices=["insert", "extend"], default="insert",
32
32
  help="insert (default): hold pushes the rest of the clip later; extend: only valid at/after the clip's end, makes the last frame last longer with nothing pushed")
33
- ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
33
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
34
34
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
35
35
  add_common(ap)
36
36
  args = ap.parse_args()
@@ -115,7 +115,7 @@ def main() -> int:
115
115
  emo.add_argument("--emoji-scale", type=float, default=1.0,
116
116
  help="emoji box as a multiple of the line's font size (default 1.0)")
117
117
  emo.add_argument("--emoji-max", type=int, default=60, help="most emoji overlays one run may build (default 60)")
118
- ap.add_argument("--crf", type=int, default=18)
118
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
119
119
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
120
120
  add_common(ap)
121
121
  args = ap.parse_args()
package/scripts/grid.py CHANGED
@@ -49,7 +49,7 @@ def main() -> int:
49
49
  ap.add_argument("--audio-from", type=int, help="0-based index into inputs to take audio from (default: no audio)")
50
50
  ap.add_argument("--gap", type=int, default=0, help="gap between cells in px, must be even (default 0, cells touch)")
51
51
  ap.add_argument("--background", default="black", help="colour of the gap/pad borders (default black)")
52
- ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
52
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
53
53
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
54
54
  add_common(ap)
55
55
  args = ap.parse_args()
@@ -124,7 +124,7 @@ def main() -> int:
124
124
  # A grid is an 8-bit SDR composite by design (a comparison/contact artefact, not a
125
125
  # deliverable); an HDR input is flattened like look.py's contact sheet flattens it. Say so
126
126
  # once so the caller is not surprised by the 8-bit output.
127
- if any((m.get("video") or {}).get("hdr") for m in metas):
127
+ if any((m.get("video") or {}).get("bt2020_or_hdr") for m in metas):
128
128
  info("note: an HDR input is composited into an 8-bit SDR grid (grid.py is a comparison artefact); use color.py --to-sdr first for a graded conversion")
129
129
  cmd += video_args(None, args.crf, args.preset)
130
130
  cmd += cfr_args(None, args.fps)
package/scripts/insert.py CHANGED
@@ -46,7 +46,7 @@ def main() -> int:
46
46
  ap.add_argument("--zoom", choices=["in", "out"], help="Ken Burns: slow linear zoom in or out across the clip")
47
47
  ap.add_argument("--zoom-amount", type=float, default=1.3, help="end (zoom in) or start (zoom out) zoom factor, > 1.0 (default 1.3)")
48
48
  ap.add_argument("--pan", choices=["left", "right", "up", "down"], help="drift the visible window this direction while zoomed (needs --zoom)")
49
- ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
49
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
50
50
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
51
51
  add_common(ap)
52
52
  args = ap.parse_args()
package/scripts/join.py CHANGED
@@ -93,7 +93,7 @@ def main() -> int:
93
93
  ap.add_argument("--fps", type=float, help="output frame rate (default: first clip)")
94
94
  ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how clips of another aspect reach the frame (default pad)")
95
95
  ap.add_argument("--pad-color", default="black")
96
- ap.add_argument("--crf", type=int, default=18)
96
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
97
97
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
98
98
  aud = ap.add_argument_group("audio-only inputs")
99
99
  aud.add_argument("--sample-rate", type=int, help="output sample rate in Hz (default: first clip's)")
@@ -170,7 +170,7 @@ def main() -> int:
170
170
  geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
171
171
  # Any HDR input makes the join HDR (10-bit, HEVC via video_args on that clip's tags): an SDR
172
172
  # first clip used to drag an HDR second clip down to 8-bit without a tone map.
173
- hdr_meta = next((m for m in metas if (m.get("video") or {}).get("hdr")), None)
173
+ hdr_meta = next((m for m in metas if (m.get("video") or {}).get("bt2020_or_hdr")), None)
174
174
  pixfmt = "yuv420p10le" if hdr_meta else "yuv420p"
175
175
  # the audio-only join keeps the widest layout; the video join used to force stereo and
176
176
  # silently dropped the centre/LFE of 5.1 material
package/scripts/look.py CHANGED
@@ -191,7 +191,7 @@ def main() -> int:
191
191
  f"left {z['left'] * 100:.0f}% / right {z['right'] * 100:.0f}% of the frame -- keep text out of those")
192
192
  tc = safe_filter + tc
193
193
  # HDR sources: tone-map for the PNG so the agent judges representative colours, not raw HLG/PQ
194
- if meta["video"].get("hdr"):
194
+ if meta["video"].get("bt2020_or_hdr"):
195
195
  v = meta["video"]
196
196
  tm = (f"zscale=tin={v.get('color_transfer') or 'arib-std-b67'}:pin={v.get('color_primaries') or 'bt2020'}:min={v.get('color_space') or 'bt2020nc'}:rin=tv:t=linear:npl=1000,"
197
197
  "format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable,zscale=t=bt709:m=bt709:r=tv,format=yuv420p,")
package/scripts/loop.py CHANGED
@@ -28,7 +28,7 @@ def main() -> int:
28
28
  group = ap.add_mutually_exclusive_group(required=True)
29
29
  group.add_argument("--times", type=int, help="repeat the whole clip this many times (2 = original + 1 repeat)")
30
30
  group.add_argument("--duration", help="loop (and trim the last repeat) to hit exactly this target duration (seconds or mm:ss)")
31
- ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
31
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
32
32
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
33
33
  add_common(ap)
34
34
  args = ap.parse_args()
@@ -144,7 +144,7 @@ def main() -> int:
144
144
  ap.add_argument("--width", type=int, help="output width (default: reference)")
145
145
  ap.add_argument("--height", type=int, help="output height (default: reference)")
146
146
  ap.add_argument("--fps", type=float, help="output fps (default: reference)")
147
- ap.add_argument("--crf", type=int, default=18)
147
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
148
148
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
149
149
  add_common(ap)
150
150
  args = ap.parse_args()
@@ -260,7 +260,7 @@ def main() -> int:
260
260
  w, h = h, w
261
261
  fps = args.fps or v0.get("fps") or 30.0
262
262
  fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
263
- pixfmt = "yuv420p10le" if v0.get("hdr") else "yuv420p"
263
+ pixfmt = "yuv420p10le" if v0.get("bt2020_or_hdr") else "yuv420p"
264
264
  geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps:g},format={pixfmt}"
265
265
 
266
266
  cmd = ffmpeg_base()
@@ -137,7 +137,7 @@ def main() -> int:
137
137
  emo.add_argument("--emoji-max", type=int, default=60, help="most emoji overlays one run may build")
138
138
  txt.add_argument("--box-color", default="black@0.5")
139
139
  enc = ap.add_argument_group("encoding")
140
- enc.add_argument("--crf", type=int, default=18)
140
+ enc.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
141
141
  enc.add_argument("--preset", default="medium", choices=X264_PRESETS)
142
142
  add_common(ap)
143
143
  args = ap.parse_args()
package/scripts/pad.py CHANGED
@@ -26,7 +26,7 @@ def main() -> int:
26
26
  ap.add_argument("--start", default="0", help="padding to add before the clip: seconds or mm:ss (default 0)")
27
27
  ap.add_argument("--end", default="0", help="padding to add after the clip: seconds or mm:ss (default 0)")
28
28
  ap.add_argument("--color", default="black", help="padding colour (default black)")
29
- ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
29
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
30
30
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
31
31
  add_common(ap)
32
32
  args = ap.parse_args()
package/scripts/probe.py CHANGED
@@ -48,7 +48,7 @@ def main() -> int:
48
48
  line += f" | {v.get('width')}x{v.get('height')} @ {v.get('fps')}fps {v.get('codec')} {v.get('pix_fmt')}"
49
49
  if v.get("variable_frame_rate_suspected"):
50
50
  line += " (VFR?)"
51
- if v.get("hdr"):
51
+ if v.get("bt2020_or_hdr"): # hdr_format also names the BT.2020 SDR case
52
52
  line += f" [{v.get('hdr_format')}]"
53
53
  if r.get("levels", {}).get("looks_like_log"):
54
54
  line += " [Log?]"
package/scripts/proxy.py CHANGED
@@ -5,7 +5,7 @@ editing-decision workflows that only need to look at (or feed a model) a
5
5
  much smaller stand-in for the original.
6
6
 
7
7
  Resizes to --width (default 640px, height follows the source aspect) or by
8
- --scale factor, re-encodes at a proxy-grade --crf (default 30 - well above any
8
+ --scale factor, re-encodes at a proxy-grade --quality (default 30 - well above any
9
9
  delivery preset's 18-24 in export.py, since a proxy trades visual quality for
10
10
  size and speed), and always uses the fastest x264/x265 preset. Keeps the
11
11
  source's own dynamic range (an HDR source proxies to HEVC10, same as every
@@ -39,7 +39,7 @@ def main() -> int:
39
39
  ap.add_argument("-o", "--output", help="output file (default: <name>_proxy.<ext>)")
40
40
  ap.add_argument("--width", type=int, default=640, help="output width in px, height follows the source aspect (default 640)")
41
41
  ap.add_argument("--scale", type=float, help="scale factor applied to the source dimensions instead of --width (0 < scale <= 1)")
42
- ap.add_argument("--crf", type=int, default=30, help="proxy-grade CRF, higher = smaller/lower quality (default 30)")
42
+ ap.set_defaults(crf=30) # --quality's default (the --crf alias was removed in 2.0)
43
43
  ap.add_argument("--fps", type=float, help="force a constant output frame rate")
44
44
  ap.add_argument("--no-audio", action="store_true", help="drop audio entirely (default: keep it)")
45
45
  add_common(ap)
package/scripts/redact.py CHANGED
@@ -36,7 +36,7 @@ def main() -> int:
36
36
  ap.add_argument("--block-size", type=int, default=12, help="mosaic block size in px, --mode pixelate only (default 12)")
37
37
  ap.add_argument("--audio-stream", type=int, default=0,
38
38
  help="which audio stream of the input to keep, 0-based in file order (default 0)")
39
- ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
39
+ ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
40
40
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
41
41
  ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
42
42
  add_common(ap)
package/scripts/render.py CHANGED
@@ -64,7 +64,7 @@ from typing import Any, Dict, List, Optional, Sequence
64
64
 
65
65
  from export import PRESETS, PLATFORM_OF
66
66
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
67
- from _common import STATE, add_common, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, fingerprint, PLAN_VERSION, ffmpeg_version
67
+ from _common import STATE, add_common, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, _check_existing_output, fingerprint, PLAN_VERSION, ffmpeg_version
68
68
  import subprocess
69
69
  from _contract import CONTRACT_VERSION
70
70
  from batch import file_key
@@ -816,6 +816,9 @@ def main() -> int:
816
816
  output = rel(proj.get("output") or "final.mp4")
817
817
  # the final stage is a copy from the work dir, so run()'s own guard never sees the sources (review 5)
818
818
  refuse_output_is_input(output, *[rel(c.get("src")) for c in clips if c.get("src")])
819
+ # 2.0 refuses an existing output without --overwrite; the final place_output() would, but
820
+ # only after every stage had run -- say so before the first one instead
821
+ _check_existing_output(["ffmpeg", output])
819
822
  # The default work dir name comes only from the output path, with no PID or timestamp --
820
823
  # two concurrent render.py runs targeting the same output (a batch.py "project" recipe
821
824
  # processing several files in parallel, or simply running render.py twice by mistake) shared
package/scripts/report.py CHANGED
@@ -71,7 +71,7 @@ def media_rows(meta: Dict[str, Any], ld: Dict[str, Any]) -> List[List[str]]:
71
71
  ["Duration", fmt_dur(meta.get("duration"))],
72
72
  ["Size", f"{(meta.get('size_bytes') or 0) / 1024 / 1024:.1f} MB"],
73
73
  ["Video", f"{v.get('codec')} {v.get('width')}×{v.get('height')} @ {v.get('fps')} fps, {v.get('pix_fmt')}" if v else "none"],
74
- ["Colour", (f"{v.get('color_primaries')}/{v.get('color_transfer')}" + (f" — {v.get('hdr_format')}" if v.get("hdr") else " (SDR)")) if v else "—"],
74
+ ["Colour", (f"{v.get('color_primaries')}/{v.get('color_transfer')}" + (f" — {v.get('hdr_format')}" if v.get("bt2020_or_hdr") else " (SDR)")) if v else "—"],
75
75
  ["Frame rate", ("variable (suspected)" if v.get("variable_frame_rate_suspected") else "constant") if v else "—"],
76
76
  ["Audio", f"{a.get('codec')} {a.get('channels')} ch {a.get('sample_rate')} Hz" if a else "none"],
77
77
  ]