ffmpeg-skill 1.4.3 → 1.4.4

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
@@ -265,7 +265,7 @@ Steps: probe -> color (failed); nothing written
265
265
  Notes: send a valid .cube, or say if you want the clip left as is
266
266
  ```
267
267
 
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.
268
+ 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
269
 
270
270
  ## Things that look right but are wrong
271
271
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
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,18 @@ 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
+ (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."""
155
162
  sys.stderr.write(f"error: {msg}\n")
156
163
  if STATE.json:
157
- print_json({
164
+ doc: Dict[str, Any] = {
158
165
  "status": "failed", "exit_code": code,
159
166
  "error": {
160
167
  "kind": kind, "message": msg,
@@ -162,7 +169,9 @@ def die(msg: str, code: int = 1, kind: str = "input") -> "None":
162
169
  "retryable": ERROR_RETRYABLE,
163
170
  },
164
171
  "commands": list(STATE.commands),
165
- })
172
+ }
173
+ doc.update(extra)
174
+ print_json(doc)
166
175
  sys.exit(code)
167
176
 
168
177
 
@@ -461,6 +470,39 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
461
470
  return proc
462
471
 
463
472
 
473
+ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True) -> subprocess.CompletedProcess:
474
+ """Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats):
475
+ output to `-f null` or a pipe, nothing written. These are not run() calls -- they run under
476
+ --dry-run too, since the analysis is the tool's whole job -- but they get the same wall-clock
477
+ limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg` failure
478
+ instead of an exit-0 "0 scenes found" over a file ffmpeg could not read."""
479
+ limit = _limit_for(cmd)
480
+ try:
481
+ proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, timeout=limit)
482
+ except subprocess.TimeoutExpired:
483
+ _timed_out(cmd, limit or 0)
484
+ if check and proc.returncode != 0:
485
+ err = proc.stderr if text else proc.stderr.decode(errors="replace")
486
+ _fail(cmd, proc.returncode, err)
487
+ return proc
488
+
489
+
490
+ def child_args() -> List[str]:
491
+ """The shared flags a tool that runs sibling scripts (render.py, batch.py) forwards to them,
492
+ so one `--timeout`/`--overwrite`/`--fast`/`--dry-run` on the outer command governs every
493
+ stage. Before 1.4.3 only --fast and --dry-run were forwarded; a --timeout given to render.py
494
+ stopped at render.py."""
495
+ args: List[str] = []
496
+ if STATE.fast:
497
+ args.append("--fast")
498
+ if STATE.dry_run:
499
+ args.append("--dry-run")
500
+ if STATE.overwrite:
501
+ args.append("--overwrite")
502
+ args += ["--timeout", f"{STATE.timeout:g}"]
503
+ return args
504
+
505
+
464
506
  def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
465
507
  """Run an ffmpeg command that already maps its video/audio, trying first to also
466
508
  stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
@@ -1045,7 +1087,7 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
1045
1087
  ffmpeg = require_tool("ffmpeg")
1046
1088
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-an",
1047
1089
  "-vf", "fps=2,signalstats,metadata=print:file=-", "-f", "null", "-"]
1048
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
1090
+ proc = run_analysis(cmd, check=False)
1049
1091
  vals: Dict[str, List[float]] = {}
1050
1092
  for line in proc.stdout.splitlines():
1051
1093
  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,
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__":
@@ -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")
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__":
@@ -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/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,11 +80,7 @@ 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")
83
+ cmd = [sys.executable, str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args()
88
84
  info("→ " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
89
85
  proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
90
86
  for line in proc.stderr.splitlines():
@@ -360,12 +356,12 @@ def main() -> int:
360
356
  check_result = json.loads(proc.stdout)
361
357
  except ValueError:
362
358
  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"):
359
+ if check_result.get("failed"):
367
360
  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
361
  exit_code = 1
362
+ elif check_result.get("error") or check_result.get("status") == "failed":
363
+ info(f"check: could not run check.py — {check_result.get('error')}")
364
+ exit_code = 1
369
365
  else:
370
366
  info(f"check: OK for {ck['platform']}")
371
367
  stages_done.append("check")
@@ -378,9 +374,15 @@ def main() -> int:
378
374
  # to before the PID suffix was added.
379
375
  import shutil
380
376
  shutil.rmtree(work, ignore_errors=True)
377
+ if exit_code:
378
+ # The deliverable is written and verified, but it does not meet the requested platform
379
+ # spec (or the check itself could not run): a failed delivery, reported as one.
380
+ failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
381
+ die(f"rendered {output} but the {ck['platform']} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""),
382
+ kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result)
381
383
  info(f"rendered {output} via {' → '.join(stages_done)}")
382
384
  emit(output, stages=stages_done, check=check_result)
383
- return exit_code
385
+ return 0
384
386
 
385
387
 
386
388
  if __name__ == "__main__":
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 []
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__":