ffmpeg-skill 1.4.4 → 1.4.5

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/SKILL.md CHANGED
@@ -95,7 +95,7 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
95
95
 
96
96
  ## Before you run anything: what to ask, what to assume
97
97
 
98
- Ask one short question only when the answer changes the output materially and the request does not imply it:
98
+ Ask one short question only when the answer changes the output materially and the request does not imply it. When several things are open at once (a vague "make it for social media" leaves destination, aspect method, length and captions unresolved), do not ask them one per turn: propose one bundle with your defaults and let the user change any part ("Reels: 9:16 with padding, trimmed to 60 s, -14 LUFS, no captions — OK, or change something?"). One question, one answer, then the run.
99
99
 
100
100
  - **Destination** decides aspect, length limit, loudness and codec. "For Reels" answers all four. If no destination is named and the edit is a plain cut/caption, keep the source format and say so; if the user asks to "export", "post" or "deliver", ask where.
101
101
  - **Duration** ("make it 60 s") without a method: speed up for ≤1.5× changes, trim otherwise, and state which you chose. Ask if the content is a talk (trimming loses words) and the change is large.
@@ -262,9 +262,13 @@ When a step fails, replace `Done:` with `Failed:` and keep the rest honest:
262
262
  ```
263
263
  Failed: color.py --lut grade.cube exited 1 — ffmpeg: "Unable to parse LUT file" (the .cube is not a valid LUT)
264
264
  Steps: probe -> color (failed); nothing written
265
+ Check: nothing to verify
266
+ Look: not needed (nothing written)
265
267
  Notes: send a valid .cube, or say if you want the clip left as is
266
268
  ```
267
269
 
270
+ A refusal (the request asks for a judgement this skill does not make, or for something outside its scope) uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run (usually only probe), `Look: not needed`. Both keep the five labels so a reader can scan a failed report the way they scan a successful one. When a tool's failure JSON carries `error.hint`, quote it in `Notes:` — it is the flag change that would make the retry meaningful.
271
+
268
272
  Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | output | missing_tool | timeout | verification, "message": ...}}` with `--json` and exits non-zero; quote the message, do not paraphrase it into a success.
269
273
 
270
274
  ## Things that look right but are wrong
package/mcp/server.py CHANGED
@@ -102,7 +102,13 @@ def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
102
102
  if proc.returncode != 0:
103
103
  err = proc.stderr.strip().splitlines()
104
104
  tail = "\n".join(err[-12:])
105
- return {"isError": True, "content": [{"type": "text", "text": f"{name} failed (exit {proc.returncode})\n{tail}"}]}
105
+ failed: Dict[str, Any] = {"isError": True, "content": [{"type": "text", "text": f"{name} failed (exit {proc.returncode})\n{tail}"}]}
106
+ # The child's own failure document (status, error.kind/code/hint, commands) is the
107
+ # machine-readable half of the contract; dropping it here left an MCP caller regex-
108
+ # parsing prose to tell a timeout from a missing binary.
109
+ if isinstance(structured, dict):
110
+ failed["structuredContent"] = structured
111
+ return failed
106
112
  if structured is None:
107
113
  text = stdout or "\n".join(proc.stderr.strip().splitlines()[-5:])
108
114
  result: Dict[str, Any] = {"content": [{"type": "text", "text": text}]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.4.4",
3
+ "version": "1.4.5",
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",
@@ -159,7 +159,8 @@ def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
159
159
  reporting that detail while the top-level status says failed. Before 1.4.3 those four printed
160
160
  `status: "completed"` next to a non-zero exit code, so a caller keying on the status alone
161
161
  read a failed delivery as a success."""
162
- sys.stderr.write(f"error: {msg}\n")
162
+ hint = extra.pop("hint", None)
163
+ sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
163
164
  if STATE.json:
164
165
  doc: Dict[str, Any] = {
165
166
  "status": "failed", "exit_code": code,
@@ -170,6 +171,8 @@ def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
170
171
  },
171
172
  "commands": list(STATE.commands),
172
173
  }
174
+ if hint:
175
+ doc["error"]["hint"] = hint
173
176
  doc.update(extra)
174
177
  print_json(doc)
175
178
  sys.exit(code)
@@ -268,6 +271,9 @@ def apply_common(args: "argparse.Namespace") -> None:
268
271
  STATE.timeout = max(0.0, float(args.timeout))
269
272
  if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
270
273
  args.preset = "veryfast"
274
+ crf = getattr(args, "crf", None)
275
+ if crf is not None and not 0 <= int(crf) <= 51:
276
+ die(f"--crf must be between 0 and 51 (x264/x265 scale; 18 is visually lossless, 23 the encoder default), got {crf}")
271
277
 
272
278
 
273
279
  def emit(output: Optional[str], **extra: Any) -> None:
@@ -1078,6 +1084,55 @@ def db_to_linear(db: float) -> float:
1078
1084
  return 10 ** (db / 20.0)
1079
1085
 
1080
1086
 
1087
+ def read_text_or_die(path: str, flag: str) -> str:
1088
+ """Read a caller-supplied UTF-8 text file (a cue list, chapters, notes) or fail as kind input
1089
+ with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback."""
1090
+ try:
1091
+ with open(path, "r", encoding="utf-8") as fh:
1092
+ return fh.read()
1093
+ except FileNotFoundError:
1094
+ die(f"{flag}: {path} does not exist")
1095
+ except IsADirectoryError:
1096
+ die(f"{flag}: {path} is a directory, not a text file")
1097
+ except UnicodeDecodeError as e:
1098
+ die(f"{flag}: {path} is not UTF-8 text ({e.reason} at byte {e.start}); save it as UTF-8")
1099
+ except OSError as e:
1100
+ die(f"{flag}: cannot read {path}: {e.strerror}")
1101
+ return "" # unreachable
1102
+
1103
+
1104
+ def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
1105
+ """Video keyframe timestamps within +-window seconds of t, ascending. Read with
1106
+ -read_intervals so a long file is not scanned end to end; empty when ffprobe cannot say."""
1107
+ ffprobe = require_tool("ffprobe")
1108
+ lo = max(0.0, t - window)
1109
+ proc = run([ffprobe, "-v", "error", "-select_streams", "v:0", "-skip_frame", "nokey",
1110
+ "-read_intervals", f"{lo:.3f}%{t + window:.3f}", "-show_entries", "frame=pts_time",
1111
+ "-of", "csv=p=0", path], quiet=True, check=False)
1112
+ if proc.returncode != 0:
1113
+ return []
1114
+ out: List[float] = []
1115
+ for line in proc.stdout.splitlines():
1116
+ try:
1117
+ out.append(round(float(line.strip().rstrip(",")), 3))
1118
+ except ValueError:
1119
+ continue
1120
+ return sorted(set(out))
1121
+
1122
+
1123
+ def measured_level_dbfs(path: str, seconds: float = 120.0) -> Optional[Dict[str, float]]:
1124
+ """Mean and peak level of the first `seconds` of audio (volumedetect), in dBFS; None if unmeasurable.
1125
+ Cheap enough to run once as a hint when a threshold-based tool found nothing."""
1126
+ ffmpeg = require_tool("ffmpeg")
1127
+ proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.0f}", "-i", path, "-vn",
1128
+ "-af", "volumedetect", "-f", "null", "-"], check=False)
1129
+ m_mean = re.search(r"mean_volume:\s*(-?[0-9.]+) dB", proc.stderr)
1130
+ m_max = re.search(r"max_volume:\s*(-?[0-9.]+) dB", proc.stderr)
1131
+ if not (m_mean and m_max):
1132
+ return None
1133
+ return {"mean_dbfs": float(m_mean.group(1)), "peak_dbfs": float(m_max.group(1))}
1134
+
1135
+
1081
1136
  def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
1082
1137
  """Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
1083
1138
 
@@ -14,7 +14,7 @@ import argparse
14
14
  import math
15
15
  import sys
16
16
 
17
- from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args
17
+ from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS
18
18
 
19
19
 
20
20
  def main() -> int:
@@ -29,7 +29,7 @@ def main() -> int:
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
31
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
32
- ap.add_argument("--preset", default="medium", help="x264 preset")
32
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
33
33
  add_common(ap)
34
34
  args = ap.parse_args()
35
35
  apply_common(args)
package/scripts/broll.py CHANGED
@@ -21,7 +21,7 @@ import argparse
21
21
  import sys
22
22
  from typing import Any, Dict, List
23
23
 
24
- from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
24
+ from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS
25
25
 
26
26
 
27
27
  def main() -> int:
@@ -36,10 +36,11 @@ def main() -> int:
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
38
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
39
- ap.add_argument("--preset", default="medium", help="x264 preset")
39
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
40
40
  add_common(ap)
41
41
  args = ap.parse_args()
42
42
  apply_common(args)
43
+ validate_color(args.pad_color, "--pad-color")
43
44
 
44
45
  n = len(args.insert)
45
46
  if len(args.at) != n:
@@ -36,7 +36,7 @@ import sys
36
36
  from pathlib import Path
37
37
  from typing import List, Optional, Tuple
38
38
 
39
- from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args
39
+ from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS
40
40
 
41
41
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
42
42
 
@@ -406,7 +406,7 @@ def main() -> int:
406
406
  anim.add_argument("--write-ass", help="where to save the generated ASS (default: next to the output)")
407
407
  enc = ap.add_argument_group("encoding")
408
408
  enc.add_argument("--crf", type=int, default=18)
409
- enc.add_argument("--preset", default="medium")
409
+ enc.add_argument("--preset", default="medium", choices=X264_PRESETS)
410
410
  add_common(ap)
411
411
  args = ap.parse_args()
412
412
  apply_common(args)
package/scripts/color.py CHANGED
@@ -22,7 +22,7 @@ import os
22
22
  import sys
23
23
  from typing import List
24
24
 
25
- from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args
25
+ from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args, X264_PRESETS
26
26
 
27
27
  TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
28
28
 
@@ -204,7 +204,7 @@ def main() -> int:
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
206
  ap.add_argument("--crf", type=int, default=18)
207
- ap.add_argument("--preset", default="medium")
207
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
208
208
  add_common(ap)
209
209
  args = ap.parse_args()
210
210
  apply_common(args)
package/scripts/crop.py CHANGED
@@ -21,7 +21,7 @@ Examples:
21
21
  import argparse
22
22
  import sys
23
23
 
24
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
24
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
25
25
 
26
26
 
27
27
  def main() -> int:
@@ -33,7 +33,7 @@ def main() -> int:
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
35
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
36
- ap.add_argument("--preset", default="medium", help="x264 preset")
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)
39
39
  args = ap.parse_args()
package/scripts/cut.py CHANGED
@@ -30,7 +30,11 @@ import sys
30
30
  import tempfile
31
31
  from typing import List, Tuple
32
32
 
33
- from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run
33
+ from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run, X264_PRESETS, keyframes_near
34
+
35
+ # keyframe timestamps found next to a requested cut that the tolerance turned into a re-encode
36
+ # (reported so the caller can choose a lossless cut at one of them next time)
37
+ NEAREST_KEYFRAMES: list = []
34
38
 
35
39
 
36
40
  def parse_segments(spec: str) -> List[Tuple[float, float]]:
@@ -116,8 +120,15 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
116
120
  if not reencode and tolerance >= 0 and not STATE["dry_run"]:
117
121
  got = probe(dst).get("duration") or 0.0
118
122
  if abs(got - dur) > tolerance:
123
+ near = keyframes_near(src, start)
124
+ alt = ""
125
+ if near:
126
+ closest = min(near, key=lambda k: abs(k - start))
127
+ alt = (f"; for a lossless cut move --start to a keyframe (nearest: {closest:.3f}s"
128
+ + (f", others within 5 s: {', '.join(f'{k:.3f}' for k in near if k != closest)}" if len(near) > 1 else "") + ")")
129
+ NEAREST_KEYFRAMES.extend(k for k in near if k not in NEAREST_KEYFRAMES)
119
130
  info(f"stream copy landed on a keyframe {abs(got - dur):.2f}s away from the requested cut "
120
- f"(> {tolerance:.2f}s tolerance); re-encoding this segment for accuracy")
131
+ f"(> {tolerance:.2f}s tolerance); re-encoding this segment for accuracy{alt}")
121
132
  return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
122
133
  return reencode
123
134
 
@@ -134,7 +145,7 @@ def main() -> int:
134
145
  ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate (video) / sample-accurate (audio) cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
135
146
  ap.add_argument("--tolerance", type=float, default=0.5, help="max seconds a lossless cut may deviate before re-encoding kicks in (default 0.5, -1 = never)")
136
147
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF when re-encoding (default 18)")
137
- ap.add_argument("--preset", default="medium", help="x264 preset when re-encoding")
148
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset when re-encoding")
138
149
  add_common(ap)
139
150
  args = ap.parse_args()
140
151
  apply_common(args)
@@ -211,7 +222,8 @@ def main() -> int:
211
222
  requested_segments=[[round(s, 6), round(e, 6)] for s, e in segments] if len(segments) > 1 else None,
212
223
  requested_duration=round(expected, 6), output_duration=round(got, 6) if got is not None else None,
213
224
  duration_delta_seconds=round(error_ms / 1000, 6) if error_ms is not None else None,
214
- mode=mode, keyframe_snapped=keyframe_snapped)
225
+ mode=mode, keyframe_snapped=keyframe_snapped,
226
+ nearest_keyframes=sorted(NEAREST_KEYFRAMES) if NEAREST_KEYFRAMES else None)
215
227
  return 0
216
228
 
217
229
 
@@ -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, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
25
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS
26
26
 
27
27
  MODES = {"frame": 0, "field": 1}
28
28
  PARITIES = {"auto": -1, "tff": 0, "bff": 1}
@@ -41,7 +41,7 @@ def main() -> int:
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
43
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
44
- ap.add_argument("--preset", default="medium", help="x264 preset")
44
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
45
45
  add_common(ap)
46
46
  args = ap.parse_args()
47
47
  apply_common(args)
@@ -18,7 +18,7 @@ Examples:
18
18
  import argparse
19
19
  import sys
20
20
 
21
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
21
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS
22
22
 
23
23
  # hqdn3d's own AVOptions default to 0 (off); these tested presets are the light/medium/heavy
24
24
  # starting points its own documentation and common usage recommend (spatial then temporal,
@@ -43,7 +43,7 @@ def main() -> int:
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
45
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
46
- ap.add_argument("--preset", default="medium", help="x264 preset")
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)
49
49
  args = ap.parse_args()
package/scripts/fit.py CHANGED
@@ -38,7 +38,7 @@ import sys
38
38
  from fractions import Fraction
39
39
  from typing import List
40
40
 
41
- from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args
41
+ from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS
42
42
  ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
43
43
 
44
44
 
@@ -101,7 +101,7 @@ def main() -> int:
101
101
  r.add_argument("--flip", choices=["h", "v"], help="mirror the picture horizontally (h) or vertically (v)")
102
102
  e = ap.add_argument_group("encoding")
103
103
  e.add_argument("--crf", type=int, default=18)
104
- e.add_argument("--preset", default="medium")
104
+ e.add_argument("--preset", default="medium", choices=X264_PRESETS)
105
105
  e.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
106
106
  add_common(ap)
107
107
  args = ap.parse_args()
package/scripts/freeze.py CHANGED
@@ -19,7 +19,7 @@ Examples:
19
19
  import argparse
20
20
  import sys
21
21
 
22
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
22
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS
23
23
 
24
24
 
25
25
  def main() -> int:
@@ -31,7 +31,7 @@ def main() -> int:
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
33
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
34
- ap.add_argument("--preset", default="medium", help="x264 preset")
34
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
35
35
  add_common(ap)
36
36
  args = ap.parse_args()
37
37
  apply_common(args)
@@ -21,7 +21,7 @@ import argparse
21
21
  import sys
22
22
  from typing import List, Optional
23
23
 
24
- from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw
24
+ from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS
25
25
 
26
26
  TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
27
27
 
@@ -62,7 +62,7 @@ def main() -> int:
62
62
  ap.add_argument("--font-file")
63
63
  ap.add_argument("--scale", type=float, default=1.0, help="size multiplier (default 1)")
64
64
  ap.add_argument("--crf", type=int, default=18)
65
- ap.add_argument("--preset", default="medium")
65
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
66
66
  add_common(ap)
67
67
  args = ap.parse_args()
68
68
  apply_common(args)
package/scripts/grid.py CHANGED
@@ -25,7 +25,7 @@ import argparse
25
25
  import os
26
26
  import sys
27
27
 
28
- from _common import add_common, apply_common, aac_args, cfr_args, default_font_file, default_output, die, emit, \
28
+ from _common import add_common, apply_common, aac_args, cfr_args, default_font_file, default_output, die, emit, X264_PRESETS, \
29
29
  escape_drawtext, escape_filter_path, ffmpeg_base, info, probe, run, validate_color, video_args
30
30
 
31
31
  LABEL_MARGIN = 10
@@ -50,7 +50,7 @@ def main() -> int:
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
52
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
53
- ap.add_argument("--preset", default="medium", help="x264 preset")
53
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
54
54
  add_common(ap)
55
55
  args = ap.parse_args()
56
56
  apply_common(args)
package/scripts/insert.py CHANGED
@@ -27,7 +27,7 @@ import argparse
27
27
  import math
28
28
  import sys
29
29
 
30
- from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
30
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS
31
31
 
32
32
 
33
33
  def even(n: float) -> int:
@@ -47,7 +47,7 @@ def main() -> int:
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
49
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
50
- ap.add_argument("--preset", default="medium", help="x264 preset")
50
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
51
51
  add_common(ap)
52
52
  args = ap.parse_args()
53
53
  apply_common(args)
package/scripts/join.py CHANGED
@@ -23,7 +23,7 @@ import argparse
23
23
  import sys
24
24
  from typing import List
25
25
 
26
- from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, validate_color
26
+ from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, validate_color, X264_PRESETS
27
27
 
28
28
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
29
29
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -94,7 +94,7 @@ def main() -> int:
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
96
  ap.add_argument("--crf", type=int, default=18)
97
- ap.add_argument("--preset", default="medium")
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)")
100
100
  aud.add_argument("--channels", type=int, choices=[1, 2, 6, 8], help="output channel count (default: the widest clip)")
package/scripts/loop.py CHANGED
@@ -18,7 +18,7 @@ import argparse
18
18
  import math
19
19
  import sys
20
20
 
21
- from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
21
+ from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS
22
22
 
23
23
 
24
24
  def main() -> int:
@@ -29,7 +29,7 @@ def main() -> int:
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
31
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
32
- ap.add_argument("--preset", default="medium", help="x264 preset")
32
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
33
33
  add_common(ap)
34
34
  args = ap.parse_args()
35
35
  apply_common(args)
@@ -30,7 +30,7 @@ import tempfile
30
30
  from pathlib import Path
31
31
  from typing import Any, Dict, List, Optional
32
32
 
33
- from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, STATE
33
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, STATE, read_text_or_die
34
34
 
35
35
  CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
36
36
  TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
@@ -39,7 +39,7 @@ TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
39
39
  def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
40
40
  """`TIME TITLE` per line -> [{"start", "end", "title"}], validated: ascending starts, every
41
41
  start inside the file, the last chapter running to the file's end."""
42
- text = Path(path).read_text(encoding="utf-8")
42
+ text = read_text_or_die(path, "--chapters")
43
43
  entries: List[Dict[str, Any]] = []
44
44
  for n, raw in enumerate(text.splitlines(), start=1):
45
45
  line = raw.strip()
@@ -29,7 +29,7 @@ import argparse
29
29
  import sys
30
30
  from typing import List, Tuple
31
31
 
32
- from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args
32
+ from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args, X264_PRESETS
33
33
  from sync import measure_offset
34
34
 
35
35
 
@@ -69,7 +69,7 @@ def main() -> int:
69
69
  ap.add_argument("--height", type=int, help="output height (default: reference)")
70
70
  ap.add_argument("--fps", type=float, help="output fps (default: reference)")
71
71
  ap.add_argument("--crf", type=int, default=18)
72
- ap.add_argument("--preset", default="medium")
72
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
73
73
  add_common(ap)
74
74
  args = ap.parse_args()
75
75
  apply_common(args)
@@ -24,7 +24,7 @@ import argparse
24
24
  import sys
25
25
  from typing import List, Optional
26
26
 
27
- from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args
27
+ from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS
28
28
 
29
29
  POS = {
30
30
  "top-left": ("{m}", "{m}"),
@@ -119,7 +119,7 @@ def main() -> int:
119
119
  txt.add_argument("--box-color", default="black@0.5")
120
120
  enc = ap.add_argument_group("encoding")
121
121
  enc.add_argument("--crf", type=int, default=18)
122
- enc.add_argument("--preset", default="medium")
122
+ enc.add_argument("--preset", default="medium", choices=X264_PRESETS)
123
123
  add_common(ap)
124
124
  args = ap.parse_args()
125
125
  apply_common(args)
package/scripts/pad.py CHANGED
@@ -16,7 +16,7 @@ Examples:
16
16
  import argparse
17
17
  import sys
18
18
 
19
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args
19
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS
20
20
 
21
21
 
22
22
  def main() -> int:
@@ -27,7 +27,7 @@ def main() -> int:
27
27
  ap.add_argument("--end", type=float, default=0.0, help="seconds of padding to add after the clip (default 0)")
28
28
  ap.add_argument("--color", default="black", help="padding colour (default black)")
29
29
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
30
- ap.add_argument("--preset", default="medium", help="x264 preset")
30
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
31
31
  add_common(ap)
32
32
  args = ap.parse_args()
33
33
  apply_common(args)
package/scripts/redact.py CHANGED
@@ -20,7 +20,7 @@ Examples:
20
20
  import argparse
21
21
  import sys
22
22
 
23
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
23
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS
24
24
 
25
25
 
26
26
  def main() -> int:
@@ -37,7 +37,7 @@ def main() -> int:
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
39
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
40
- ap.add_argument("--preset", default="medium", help="x264 preset")
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)
43
43
  args = ap.parse_args()
package/scripts/render.py CHANGED
@@ -80,18 +80,26 @@ TEMPLATE = {
80
80
 
81
81
  def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
82
82
  """Run a sibling script, forwarding --fast / --dry-run, returning its printed output path."""
83
- cmd = [sys.executable, str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args()
84
- info("→ " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
83
+ cmd = [sys.executable, str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args() + ["--json"]
84
+ info("→ " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd[:-1])))
85
85
  proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
86
86
  for line in proc.stderr.splitlines():
87
87
  if line.startswith("$ ") or line.startswith("[dry-run]"):
88
88
  STATE["commands"].append(line[2:] if line.startswith("$ ") else line)
89
89
  elif line.strip():
90
90
  info(" " + line)
91
+ try:
92
+ doc = json.loads(proc.stdout.strip() or "{}")
93
+ except ValueError:
94
+ doc = {}
91
95
  if proc.returncode != 0:
92
- die(f"{script} failed")
93
- out = proc.stdout.strip().splitlines()
94
- return out[-1] if out else ""
96
+ # Re-raise the stage's own failure: its kind, exit code and hint are what the caller
97
+ # needs (a timeout inside audio.py is a timeout, not an "input" error of render.py).
98
+ err = doc.get("error") or {}
99
+ extra_fields = {"hint": err["hint"]} if err.get("hint") else {}
100
+ die(f"{script} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
101
+ code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script, **extra_fields)
102
+ return str(doc.get("output") or "")
95
103
 
96
104
 
97
105
  def main() -> int:
package/scripts/report.py CHANGED
@@ -19,7 +19,7 @@ import tempfile
19
19
  from pathlib import Path
20
20
  from typing import Any, Dict, List, Optional
21
21
 
22
- from _common import STATE, add_common, apply_common, die, emit, info, probe
22
+ from _common import STATE, add_common, apply_common, die, emit, info, probe, read_text_or_die
23
23
 
24
24
  HERE = Path(__file__).resolve().parent
25
25
 
@@ -46,9 +46,16 @@ def loudness(path: str) -> Dict[str, Any]:
46
46
  def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
47
47
  proc = subprocess.run([sys.executable, str(HERE / "check.py"), path, "--platform", platform, "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
48
48
  try:
49
- return json.loads(proc.stdout)
49
+ doc = json.loads(proc.stdout)
50
50
  except ValueError:
51
+ doc = None
52
+ if not isinstance(doc, dict) or "checks" not in doc:
53
+ # check.py could not run at all (missing ffmpeg, unreadable file): its failure document
54
+ # has no rows to render. A failed *verification* still carries its rows and is shown.
55
+ reason = ((doc or {}).get("error") or {}).get("message") or (proc.stderr.strip().splitlines() or ["?"])[-1]
56
+ info(f"check.py could not run: {reason[:200]}")
51
57
  return None
58
+ return doc
52
59
 
53
60
 
54
61
  def fmt_dur(sec: Optional[float]) -> str:
@@ -99,8 +106,8 @@ def main() -> int:
99
106
  sheets["before"] = sheet_b64(args.before)
100
107
  if after.get("video"):
101
108
  sheets["after"] = sheet_b64(args.after)
102
- commands = Path(args.commands).read_text(encoding="utf-8").splitlines() if args.commands else []
103
- notes = Path(args.notes).read_text(encoding="utf-8") if args.notes else ""
109
+ commands = read_text_or_die(args.commands, "--commands").splitlines() if args.commands else []
110
+ notes = read_text_or_die(args.notes, "--notes") if args.notes else ""
104
111
  title = args.title or f"Delivery report — {Path(args.after).name}"
105
112
  output = args.output or str(Path(args.after).with_name(Path(args.after).stem + "_report.html"))
106
113
 
@@ -14,7 +14,7 @@ Examples:
14
14
  import argparse
15
15
  import sys
16
16
 
17
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
17
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
18
18
 
19
19
 
20
20
  def main() -> int:
@@ -23,7 +23,7 @@ def main() -> int:
23
23
  ap.add_argument("-o", "--output", help="output file (default: <name>_reverse.<ext>)")
24
24
  ap.add_argument("--no-audio", action="store_true", help="drop audio instead of reversing it")
25
25
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
26
- ap.add_argument("--preset", default="medium", help="x264 preset")
26
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
27
27
  add_common(ap)
28
28
  args = ap.parse_args()
29
29
  apply_common(args)
@@ -19,7 +19,7 @@ import sys
19
19
  import tempfile
20
20
  from pathlib import Path
21
21
 
22
- from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
22
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
23
23
 
24
24
 
25
25
  def even(n: float) -> int:
@@ -43,7 +43,7 @@ def main() -> int:
43
43
  ap.add_argument("--width", type=int, help="output width in px; with --height also given, both are used directly")
44
44
  ap.add_argument("--height", type=int, help="output height in px; with --width also given, both are used directly")
45
45
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
46
- ap.add_argument("--preset", default="medium", help="x264 preset")
46
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
47
47
  add_common(ap)
48
48
  args = ap.parse_args()
49
49
  apply_common(args)
@@ -16,7 +16,7 @@ import re
16
16
  import sys
17
17
  from typing import List, Tuple
18
18
 
19
- from _common import video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args
19
+ from _common import STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args, X264_PRESETS, measured_level_dbfs
20
20
 
21
21
  SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
22
22
 
@@ -65,7 +65,7 @@ def main() -> int:
65
65
  ap.add_argument("--list", action="store_true", help="only print silences and the kept ranges")
66
66
  ap.add_argument("--edl", help="write the kept ranges to this file, one START-END per line")
67
67
  ap.add_argument("--crf", type=int, default=18)
68
- ap.add_argument("--preset", default="medium")
68
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
69
69
  add_common(ap)
70
70
  args = ap.parse_args()
71
71
  apply_common(args)
@@ -86,6 +86,19 @@ def main() -> int:
86
86
  "removed_seconds": round(removed, 3),
87
87
  }
88
88
  info(f"{len(silences)} silences, keeping {len(keeps)} ranges: {kept:.2f}s of {duration:.2f}s (removing {removed:.2f}s)")
89
+ if not silences and not STATE.dry_run:
90
+ # Nothing under the threshold is a valid result, not a failure -- but an agent that only
91
+ # sees "0 silences" tends to reach for raw ffmpeg next. Say what the floor actually is and
92
+ # what threshold would bite, so the retry is a flag change, not a workaround.
93
+ level = measured_level_dbfs(args.input)
94
+ if level:
95
+ suggested = min(-5.0, round(level["mean_dbfs"] + 6.0))
96
+ summary["hint"] = (f"no passage sits below {args.threshold:g} dBFS for {args.min_silence:g}s; the track's mean level is "
97
+ f"{level['mean_dbfs']:.1f} dBFS (peak {level['peak_dbfs']:.1f}). For a quiet-room recording try "
98
+ f"--threshold {suggested:g}, or a shorter --min-silence")
99
+ else:
100
+ summary["hint"] = f"no passage sits below {args.threshold:g} dBFS for {args.min_silence:g}s; try a higher --threshold (e.g. -25) or a shorter --min-silence"
101
+ info("hint: " + summary["hint"])
89
102
 
90
103
  if args.edl:
91
104
  with open(args.edl, "w", encoding="utf-8") as fh:
@@ -19,7 +19,7 @@ import argparse
19
19
  import sys
20
20
  from typing import List, Tuple
21
21
 
22
- from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
22
+ from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
23
23
 
24
24
  MAX_SPEED = 20.0
25
25
  MIN_SPEED = 0.05
@@ -60,7 +60,7 @@ def main() -> int:
60
60
  ap.add_argument("--segment", action="append", required=True, dest="segments",
61
61
  help=f"START-END:FACTOR, repeatable; segments must cover 0..duration with no gaps or overlaps, in order. FACTOR is {MIN_SPEED}..{MAX_SPEED} (2.0 = twice as fast, 0.5 = half speed)")
62
62
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
63
- ap.add_argument("--preset", default="medium", help="x264 preset")
63
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
64
64
  add_common(ap)
65
65
  args = ap.parse_args()
66
66
  apply_common(args)
package/scripts/sphere.py CHANGED
@@ -31,7 +31,7 @@ Examples:
31
31
  import argparse
32
32
  import sys
33
33
 
34
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
34
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS
35
35
 
36
36
  # v360's own AVOption names for the input projections real 360 cameras/exports actually
37
37
  # produce (ffmpeg -h filter=v360 documents 24 total; this is the subset a caller is likely
@@ -65,7 +65,7 @@ def main() -> int:
65
65
  out.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
66
66
  out.add_argument("--interp", choices=INTERP_METHODS, default="lanczos", help="resampling method (default lanczos)")
67
67
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
68
- ap.add_argument("--preset", default="medium", help="x264 preset")
68
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
69
69
  ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
70
70
  add_common(ap)
71
71
  args = ap.parse_args()
@@ -27,7 +27,7 @@ import sys
27
27
  import tempfile
28
28
  from pathlib import Path
29
29
 
30
- from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args
30
+ from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS
31
31
 
32
32
 
33
33
  def main() -> int:
@@ -40,7 +40,7 @@ def main() -> int:
40
40
  ap.add_argument("--crop", choices=["keep", "black"], default="keep", help="edges --zoom doesn't crop away: keep (stretch border pixels, default) or black (fill solid black)")
41
41
  ap.add_argument("--tripod", action="store_true", help="lock the frame fully still against a single reference frame instead of smoothing the camera's motion")
42
42
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
43
- ap.add_argument("--preset", default="medium", help="x264 preset")
43
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
44
44
  add_common(ap)
45
45
  args = ap.parse_args()
46
46
  apply_common(args)
@@ -22,7 +22,7 @@ import argparse
22
22
  import math
23
23
  import sys
24
24
 
25
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args
25
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS
26
26
 
27
27
 
28
28
  def main() -> int:
@@ -36,7 +36,7 @@ def main() -> int:
36
36
  ap.add_argument("--audio-stream", type=int, default=0,
37
37
  help="which audio stream of the input to keep, 0-based in file order (default 0)")
38
38
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
39
- ap.add_argument("--preset", default="medium", help="x264 preset")
39
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
40
40
  ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
41
41
  add_common(ap)
42
42
  args = ap.parse_args()
@@ -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
25
+ from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color, X264_PRESETS
26
26
 
27
27
  WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
28
28
 
@@ -42,7 +42,7 @@ def main() -> int:
42
42
  ap.add_argument("--audio-stream", type=int, default=0,
43
43
  help="which audio stream of the input to render, 0-based in file order (default 0)")
44
44
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
45
- ap.add_argument("--preset", default="medium", help="x264 preset")
45
+ ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
46
46
  add_common(ap)
47
47
  args = ap.parse_args()
48
48
  apply_common(args)