ffmpeg-skill 1.4.3 → 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/README.md CHANGED
@@ -293,7 +293,7 @@ The contract is generated from the code that runs, not maintained beside it. For
293
293
  | `mutates_input` | always `false` |
294
294
  | `idempotency_hint` | `bit_exact`, `content_equivalent`, `cached` or `environment_dependent` |
295
295
 
296
- `contract_version` (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments → argv), the JSON shapes for success and failure (`{"status": "failed", "error": {"kind": "input | ffmpeg | missing_tool", "message": …}}`), and that no tool runs a shell or executes anything other than the named script, `ffmpeg` and `ffprobe`. Field-by-field reference: [docs/contract.md](docs/contract.md).
296
+ `contract_version` (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments → argv), the JSON shapes for success and failure (`{"status": "failed", "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification", "message": …}}`), and that no tool runs a shell or executes anything other than the named script, `ffmpeg` and `ffprobe`. Field-by-field reference: [docs/contract.md](docs/contract.md).
297
297
 
298
298
  ### MCP
299
299
 
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,10 +262,14 @@ 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
 
268
- Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | output | missing_tool, "message": ...}}` with `--json` and exits non-zero; quote the message, do not paraphrase it into a success.
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
+
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
271
275
 
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.3",
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",
@@ -63,6 +63,7 @@ ERROR_CODE = {
63
63
  "ffmpeg": "FFMPEG_EXECUTION_FAILED",
64
64
  "output": "OUTPUT_INVALID",
65
65
  "timeout": "TIMEOUT",
66
+ "verification": "VERIFICATION_FAILED",
66
67
  }
67
68
 
68
69
  # Wall-clock ceiling for one ffmpeg/ffprobe invocation, in seconds. A hung ffmpeg (a build
@@ -149,12 +150,19 @@ def add_pad_fill_args(parser: "argparse.ArgumentParser") -> None:
149
150
  parser.add_argument("--pad-blur", type=int, default=20, help="blur radius in pixels for --pad-fill blur (default 20)")
150
151
 
151
152
 
152
- def die(msg: str, code: int = 1, kind: str = "input") -> "None":
153
+ def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
153
154
  """Exit with a message. Under --json also print a machine-readable failure document
154
- (status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged."""
155
- sys.stderr.write(f"error: {msg}\n")
155
+ (status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged.
156
+
157
+ `extra` fields are added to the failure document: a tool whose *result* failed (check.py's
158
+ platform rows, render.py's check stage, batch.py's per-item results, verify.py's steps) keeps
159
+ reporting that detail while the top-level status says failed. Before 1.4.3 those four printed
160
+ `status: "completed"` next to a non-zero exit code, so a caller keying on the status alone
161
+ read a failed delivery as a success."""
162
+ hint = extra.pop("hint", None)
163
+ sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
156
164
  if STATE.json:
157
- print_json({
165
+ doc: Dict[str, Any] = {
158
166
  "status": "failed", "exit_code": code,
159
167
  "error": {
160
168
  "kind": kind, "message": msg,
@@ -162,7 +170,11 @@ def die(msg: str, code: int = 1, kind: str = "input") -> "None":
162
170
  "retryable": ERROR_RETRYABLE,
163
171
  },
164
172
  "commands": list(STATE.commands),
165
- })
173
+ }
174
+ if hint:
175
+ doc["error"]["hint"] = hint
176
+ doc.update(extra)
177
+ print_json(doc)
166
178
  sys.exit(code)
167
179
 
168
180
 
@@ -259,6 +271,9 @@ def apply_common(args: "argparse.Namespace") -> None:
259
271
  STATE.timeout = max(0.0, float(args.timeout))
260
272
  if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
261
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}")
262
277
 
263
278
 
264
279
  def emit(output: Optional[str], **extra: Any) -> None:
@@ -461,6 +476,39 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
461
476
  return proc
462
477
 
463
478
 
479
+ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True) -> subprocess.CompletedProcess:
480
+ """Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats):
481
+ output to `-f null` or a pipe, nothing written. These are not run() calls -- they run under
482
+ --dry-run too, since the analysis is the tool's whole job -- but they get the same wall-clock
483
+ limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg` failure
484
+ instead of an exit-0 "0 scenes found" over a file ffmpeg could not read."""
485
+ limit = _limit_for(cmd)
486
+ try:
487
+ proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, timeout=limit)
488
+ except subprocess.TimeoutExpired:
489
+ _timed_out(cmd, limit or 0)
490
+ if check and proc.returncode != 0:
491
+ err = proc.stderr if text else proc.stderr.decode(errors="replace")
492
+ _fail(cmd, proc.returncode, err)
493
+ return proc
494
+
495
+
496
+ def child_args() -> List[str]:
497
+ """The shared flags a tool that runs sibling scripts (render.py, batch.py) forwards to them,
498
+ so one `--timeout`/`--overwrite`/`--fast`/`--dry-run` on the outer command governs every
499
+ stage. Before 1.4.3 only --fast and --dry-run were forwarded; a --timeout given to render.py
500
+ stopped at render.py."""
501
+ args: List[str] = []
502
+ if STATE.fast:
503
+ args.append("--fast")
504
+ if STATE.dry_run:
505
+ args.append("--dry-run")
506
+ if STATE.overwrite:
507
+ args.append("--overwrite")
508
+ args += ["--timeout", f"{STATE.timeout:g}"]
509
+ return args
510
+
511
+
464
512
  def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
465
513
  """Run an ffmpeg command that already maps its video/audio, trying first to also
466
514
  stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
@@ -1036,6 +1084,55 @@ def db_to_linear(db: float) -> float:
1036
1084
  return 10 ** (db / 20.0)
1037
1085
 
1038
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
+
1039
1136
  def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
1040
1137
  """Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
1041
1138
 
@@ -1045,7 +1142,7 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
1045
1142
  ffmpeg = require_tool("ffmpeg")
1046
1143
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-an",
1047
1144
  "-vf", "fps=2,signalstats,metadata=print:file=-", "-f", "null", "-"]
1048
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
1145
+ proc = run_analysis(cmd, check=False)
1049
1146
  vals: Dict[str, List[float]] = {}
1050
1147
  for line in proc.stdout.splitlines():
1051
1148
  if "lavfi.signalstats." in line and "=" in line:
@@ -1037,7 +1037,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
1037
1037
  "json_output": {
1038
1038
  "success": {"status": "completed", "exit_code": 0, "stdout": "one JSON document (output_schema)"},
1039
1039
  "failure": {"status": "failed", "exit_code": "non-zero (127 when ffmpeg/ffprobe is missing)", "stdout": "{\"status\": \"failed\", \"exit_code\": N, \"error\": {\"kind\": ..., \"message\": ...}, \"commands\": [...]} when --json was given", "stderr": "human-readable message"},
1040
- "error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124"},
1040
+ "error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached); exit 1"},
1041
1041
  "success_criterion": "exit 0 AND the output exists AND is non-empty AND ffprobe reads a stream from it; only then is status completed printed and the output probe attached",
1042
1042
  },
1043
1043
  "capabilities": caps,
@@ -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/batch.py CHANGED
@@ -32,7 +32,7 @@ import time
32
32
  from pathlib import Path
33
33
  from typing import Any, Dict, List
34
34
 
35
- from _common import STATE, add_common, apply_common, die, emit, info
35
+ from _common import STATE, add_common, apply_common, child_args, die, emit, info
36
36
 
37
37
  HERE = Path(__file__).resolve().parent
38
38
  MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac"}
@@ -78,11 +78,7 @@ def run_step(argv: List[str]) -> bool:
78
78
  if script not in ALLOWED_STEP_SCRIPTS:
79
79
  die(f"recipe step names a script that isn't one of this skill's own tools: {script!r} "
80
80
  f"(must be a bare filename like 'silence.py', found in scripts/)")
81
- cmd = [sys.executable, str(HERE / script)] + argv[1:]
82
- if STATE["fast"]:
83
- cmd.append("--fast")
84
- if STATE["dry_run"]:
85
- cmd.append("--dry-run")
81
+ cmd = [sys.executable, str(HERE / script)] + argv[1:] + child_args()
86
82
  info(" → " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
87
83
  proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
88
84
  if proc.returncode != 0:
@@ -219,11 +215,15 @@ def main() -> int:
219
215
  pass
220
216
  done = sum(1 for r in results if r["ok"])
221
217
  info(f"{done}/{len(results)} processed, {sum(1 for r in results if r.get('cached'))} from cache")
222
- emit(None, results=results, processed=done, total=len(results))
223
218
  if not args.json:
224
219
  for r in results:
225
220
  print(f"{'OK ' if r['ok'] else 'FAIL'} {r['file']} -> {r['output']}" + (" (cached)" if r.get("cached") else ""))
226
- return 0 if done == len(results) else 1
221
+ if done != len(results):
222
+ failed_files = [r["file"] for r in results if not r["ok"]]
223
+ die(f"{len(results) - done} of {len(results)} items failed: {', '.join(failed_files[:5])}" + (" ..." if len(failed_files) > 5 else ""),
224
+ kind="verification", output=None, dry_run=STATE.dry_run, results=results, processed=done, total=len(results))
225
+ emit(None, results=results, processed=done, total=len(results))
226
+ return 0
227
227
 
228
228
 
229
229
  if __name__ == "__main__":
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
 
@@ -87,12 +87,36 @@ def transcribe(video: str, out_srt: str, language: Optional[str], model: str, au
87
87
  import shutil
88
88
  import subprocess
89
89
  import tempfile
90
- from _common import require_tool
90
+ from _common import require_tool, run_analysis, STATE
91
91
  ffmpeg = require_tool("ffmpeg")
92
92
  tmpdir = tempfile.mkdtemp(prefix="ffskill_asr_")
93
+ try:
94
+ return _transcribe_in(tmpdir, video, out_srt, language, model, audio_stream, ffmpeg, shutil, subprocess)
95
+ finally:
96
+ shutil.rmtree(tmpdir, ignore_errors=True)
97
+
98
+
99
+ def _asr_run(cmd: List[str], subprocess, name: str) -> "subprocess.CompletedProcess":
100
+ """Run a speech-to-text engine under the same wall-clock limit as an ffmpeg call."""
101
+ from _common import STATE, die
102
+ limit = STATE.timeout or None
103
+ try:
104
+ return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=limit)
105
+ except subprocess.TimeoutExpired:
106
+ die(f"{name} exceeded the {limit:.0f} s time limit and was killed; raise --timeout for a long recording",
107
+ code=124, kind="timeout")
108
+ return None # unreachable
109
+
110
+
111
+ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int,
112
+ ffmpeg: str, shutil, subprocess) -> List[Tuple[float, float, str]]:
113
+ from _common import run_analysis
93
114
  wav = os.path.join(tmpdir, "audio.wav")
94
- subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
95
- "-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav], check=True)
115
+ # A wav in our own temp dir: a measurement input for the engine, not a deliverable, so it
116
+ # is not a run() call (no --dry-run gate, not recorded), but it keeps the time limit and
117
+ # reports an unreadable input as kind ffmpeg instead of a CalledProcessError traceback.
118
+ run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
119
+ "-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav])
96
120
  # 1. whisper.cpp
97
121
  cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp") or shutil.which("main")
98
122
  if cli and (shutil.which("whisper-cli") or shutil.which("whisper-cpp")):
@@ -106,7 +130,7 @@ def transcribe(video: str, out_srt: str, language: Optional[str], model: str, au
106
130
  cmd = [cli, "-m", model_path, "-f", wav, "-osrt", "-of", base]
107
131
  if language:
108
132
  cmd += ["-l", language]
109
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
133
+ proc = _asr_run(cmd, subprocess, "whisper.cpp")
110
134
  if proc.returncode == 0 and os.path.exists(base + ".srt"):
111
135
  info(f"transcribed with whisper.cpp ({os.path.basename(cli)}, model {os.path.basename(model_path)})")
112
136
  cues = parse_srt(base + ".srt")
@@ -130,7 +154,7 @@ def transcribe(video: str, out_srt: str, language: Optional[str], model: str, au
130
154
  cmd = ["whisper", wav, "--model", model, "--output_format", "srt", "--output_dir", tmpdir]
131
155
  if language:
132
156
  cmd += ["--language", language]
133
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
157
+ proc = _asr_run(cmd, subprocess, "openai-whisper")
134
158
  srt = os.path.join(tmpdir, "audio.srt")
135
159
  if proc.returncode == 0 and os.path.exists(srt):
136
160
  info("transcribed with openai-whisper")
@@ -382,7 +406,7 @@ def main() -> int:
382
406
  anim.add_argument("--write-ass", help="where to save the generated ASS (default: next to the output)")
383
407
  enc = ap.add_argument_group("encoding")
384
408
  enc.add_argument("--crf", type=int, default=18)
385
- enc.add_argument("--preset", default="medium")
409
+ enc.add_argument("--preset", default="medium", choices=X264_PRESETS)
386
410
  add_common(ap)
387
411
  args = ap.parse_args()
388
412
  apply_common(args)
package/scripts/check.py CHANGED
@@ -25,7 +25,7 @@ import sys
25
25
  from fractions import Fraction
26
26
  from typing import Any, Dict, List
27
27
 
28
- from _common import add_common, apply_common, die, emit, info, probe, require_tool, run
28
+ from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run
29
29
 
30
30
  SPECS: Dict[str, Dict[str, Any]] = {
31
31
  "youtube": {"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60, "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
@@ -190,8 +190,12 @@ def main() -> int:
190
190
  line += f" -> {r['fix']}"
191
191
  print(line)
192
192
  print(f" {len(rows)} checks, {len(failed)} failed, {len(warned)} warnings")
193
- emit(None, platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=not failed)
194
- return 1 if failed else 0
193
+ if failed:
194
+ die(f"{len(failed)} of {len(rows)} {args.platform} checks failed: {', '.join(r['check'] for r in failed)}",
195
+ kind="verification", output=None, dry_run=STATE.dry_run,
196
+ platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=False)
197
+ emit(None, platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=True)
198
+ return 0
195
199
 
196
200
 
197
201
  if __name__ == "__main__":
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()
@@ -29,12 +29,11 @@ Examples:
29
29
  """
30
30
  import argparse
31
31
  import re
32
- import subprocess
33
32
  import sys
34
33
  from collections import Counter
35
34
  from typing import Dict, List, Tuple
36
35
 
37
- from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool
36
+ from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis
38
37
 
39
38
  CROP_RE = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
40
39
 
@@ -48,7 +47,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
48
47
  start = max(0.0, start)
49
48
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
50
49
  "-vf", f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
51
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
50
+ proc = run_analysis(cmd)
52
51
  for m in CROP_RE.finditer(proc.stderr):
53
52
  rects.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))))
54
53
  return rects
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
@@ -56,7 +56,7 @@ import sys
56
56
  from pathlib import Path
57
57
  from typing import Any, Dict, List
58
58
 
59
- from _common import STATE, add_common, apply_common, die, emit, info, probe
59
+ from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe
60
60
 
61
61
  HERE = Path(__file__).resolve().parent
62
62
 
@@ -80,22 +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 [])
84
- if STATE["fast"]:
85
- cmd.append("--fast")
86
- if STATE["dry_run"]:
87
- cmd.append("--dry-run")
88
- 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])))
89
85
  proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
90
86
  for line in proc.stderr.splitlines():
91
87
  if line.startswith("$ ") or line.startswith("[dry-run]"):
92
88
  STATE["commands"].append(line[2:] if line.startswith("$ ") else line)
93
89
  elif line.strip():
94
90
  info(" " + line)
91
+ try:
92
+ doc = json.loads(proc.stdout.strip() or "{}")
93
+ except ValueError:
94
+ doc = {}
95
95
  if proc.returncode != 0:
96
- die(f"{script} failed")
97
- out = proc.stdout.strip().splitlines()
98
- 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 "")
99
103
 
100
104
 
101
105
  def main() -> int:
@@ -360,12 +364,12 @@ def main() -> int:
360
364
  check_result = json.loads(proc.stdout)
361
365
  except ValueError:
362
366
  check_result = {"error": proc.stderr.strip()[-300:]}
363
- if check_result.get("error"):
364
- info(f"check: could not run check.py — {check_result['error']}")
365
- exit_code = 1
366
- elif check_result.get("failed"):
367
+ if check_result.get("failed"):
367
368
  info(f"check: {check_result['failed']} FAIL — " + "; ".join(f"{r['check']}={r['value']} ({r['fix']})" for r in check_result["checks"] if r["status"] == "FAIL"))
368
369
  exit_code = 1
370
+ elif check_result.get("error") or check_result.get("status") == "failed":
371
+ info(f"check: could not run check.py — {check_result.get('error')}")
372
+ exit_code = 1
369
373
  else:
370
374
  info(f"check: OK for {ck['platform']}")
371
375
  stages_done.append("check")
@@ -378,9 +382,15 @@ def main() -> int:
378
382
  # to before the PID suffix was added.
379
383
  import shutil
380
384
  shutil.rmtree(work, ignore_errors=True)
385
+ if exit_code:
386
+ # The deliverable is written and verified, but it does not meet the requested platform
387
+ # spec (or the check itself could not run): a failed delivery, reported as one.
388
+ failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
389
+ die(f"rendered {output} but the {ck['platform']} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""),
390
+ kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result)
381
391
  info(f"rendered {output} via {' → '.join(stages_done)}")
382
392
  emit(output, stages=stages_done, check=check_result)
383
- return exit_code
393
+ return 0
384
394
 
385
395
 
386
396
  if __name__ == "__main__":
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)
package/scripts/scenes.py CHANGED
@@ -22,11 +22,10 @@ import math
22
22
  import os
23
23
  import re
24
24
  import struct
25
- import subprocess
26
25
  import sys
27
26
  from typing import Dict, List, Tuple
28
27
 
29
- from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run
28
+ from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run, run_analysis
30
29
 
31
30
  SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
32
31
 
@@ -38,9 +37,8 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
38
37
  a real cut is a one-frame spike. On real footage this roughly doubles precision at
39
38
  equal recall compared with the raw scdet threshold."""
40
39
  ffmpeg = require_tool("ffmpeg")
41
- proc = subprocess.run([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
42
- "scale=320:-2,scdet=threshold=0,metadata=print:file=-", "-f", "null", "-"],
43
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
40
+ proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
41
+ "scale=320:-2,scdet=threshold=0,metadata=print:file=-", "-f", "null", "-"])
44
42
  # No `sc_pass=1` on scdet: on FFmpeg 5.x that option means "pass only the frames whose
45
43
  # score exceeds the threshold", so every truly static frame (score exactly 0 -- a title
46
44
  # card, colour bars) is dropped before metadata=print and the frame numbers are re-counted
@@ -90,8 +88,8 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
90
88
 
91
89
  def audio_envelope(path: str, step_s: float) -> List[float]:
92
90
  ffmpeg = require_tool("ffmpeg")
93
- proc = subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"],
94
- stdout=subprocess.PIPE, stderr=subprocess.PIPE)
91
+ proc = run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"],
92
+ check=False, text=False)
95
93
  n = len(proc.stdout) // 2
96
94
  if n == 0:
97
95
  return []
@@ -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()
package/scripts/sync.py CHANGED
@@ -30,11 +30,10 @@ import json
30
30
  import math
31
31
  import os
32
32
  import struct
33
- import subprocess
34
33
  import sys
35
34
  from typing import List
36
35
 
37
- from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
36
+ from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args
38
37
 
39
38
  SR = 8000 # decode sample rate
40
39
 
@@ -59,7 +58,7 @@ def decode_mono(path: str, seconds: float, start: float = 0.0) -> List[float]:
59
58
  ffmpeg = require_tool("ffmpeg")
60
59
  cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{seconds:.3f}",
61
60
  "-vn", "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"]
62
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
61
+ proc = run_analysis(cmd, check=False, text=False)
63
62
  if proc.returncode != 0 or not proc.stdout:
64
63
  die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
65
64
  n = len(proc.stdout) // 2
package/scripts/verify.py CHANGED
@@ -24,7 +24,7 @@ import time
24
24
  from pathlib import Path
25
25
  from typing import Dict, List
26
26
 
27
- from _common import add_common, apply_common, die, emit, info, probe
27
+ from _common import STATE, add_common, apply_common, die, emit, info, probe
28
28
 
29
29
  HERE = Path(__file__).resolve().parent
30
30
  MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac", ".aac"}
@@ -176,13 +176,16 @@ def main() -> int:
176
176
  if args.report:
177
177
  Path(args.report).write_text(report, encoding="utf-8")
178
178
  info(f"wrote {args.report}")
179
- if args.json:
180
- emit(None, report=args.report, files=results, failed=failed, total=total)
181
- else:
179
+ if not args.json:
182
180
  print(report)
183
181
  if tmp and not args.keep:
184
182
  tmp.cleanup()
185
- return 1 if failed else 0
183
+ if failed:
184
+ die(f"{failed} of {total} verification steps failed", kind="verification", output=None, dry_run=STATE.dry_run,
185
+ report=args.report, files=results, failed=failed, total=total)
186
+ if args.json:
187
+ emit(None, report=args.report, files=results, failed=failed, total=total)
188
+ return 0
186
189
 
187
190
 
188
191
  if __name__ == "__main__":
@@ -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)