ffmpeg-skill 1.4.6 → 1.4.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.4.6",
3
+ "version": "1.4.7",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -1,6 +1,6 @@
1
1
  # Script reference
2
2
 
3
- Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) and `check` (skips only the loudness-measurement pass) still run ffprobe/ffmpeg, `sync`/`multicam`/`scenes`/`cropdetect`/`report` still run ffmpeg/ffprobe to measure or analyse (they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
3
+ Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
4
4
 
5
5
  ## Contents
6
6
  - probe.py — inspect
@@ -462,12 +462,17 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
462
462
  return proc
463
463
 
464
464
 
465
- def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True) -> subprocess.CompletedProcess:
466
- """Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats):
467
- output to `-f null` or a pipe, nothing written. These are not run() calls -- they run under
468
- --dry-run too, since the analysis is the tool's whole job -- but they get the same wall-clock
469
- limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg` failure
470
- instead of an exit-0 "0 scenes found" over a file ffmpeg could not read."""
465
+ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, record: bool = False) -> subprocess.CompletedProcess:
466
+ """Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats,
467
+ silence detection, loudness, stabilisation pass 1): output to `-f null`, a pipe or a temp
468
+ file, no deliverable written. These are not run() calls -- they run under --dry-run too,
469
+ since a plan built on a fake measurement is not a plan (silence.py used to report "0
470
+ silences" and loudness.py a made-up -20 LUFS under --dry-run) -- but they get the same
471
+ wall-clock limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg`
472
+ failure instead of an exit-0 "0 scenes found" over a file ffmpeg could not read. record=True
473
+ lists the command in the --json `commands` like run() does."""
474
+ if record:
475
+ STATE.commands.append(_cmdline(cmd))
471
476
  limit = _limit_for(cmd)
472
477
  try:
473
478
  proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, timeout=limit)
@@ -479,6 +484,17 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True) -
479
484
  return proc
480
485
 
481
486
 
487
+ def dry_run_input_pending(path: str) -> bool:
488
+ """True when a measurement cannot run because its input does not exist yet under --dry-run:
489
+ in a render.py/batch.py plan each stage's input is the previous stage's output, which a dry
490
+ run never wrote. The measurement is then skipped (with a note) rather than failing the plan;
491
+ on a real file the measurement runs even under --dry-run."""
492
+ if STATE.dry_run and not os.path.exists(path):
493
+ info(f"[dry-run] {path} does not exist yet (an earlier dry-run stage would write it); measurement skipped")
494
+ return True
495
+ return False
496
+
497
+
482
498
  def child_limit(per_call: Optional[float] = None) -> Optional[float]:
483
499
  """Wall-clock ceiling for running one sibling script as a subprocess (render/batch/report
484
500
  stages, the MCP server's dispatch). A tool runs a handful of ffmpeg/ffprobe calls, each
@@ -1142,6 +1158,9 @@ def db_to_linear(db: float) -> float:
1142
1158
  def read_text_or_die(path: str, flag: str) -> str:
1143
1159
  """Read a caller-supplied UTF-8 text file (a cue list, chapters, notes) or fail as kind input
1144
1160
  with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback."""
1161
+ if os.path.isdir(path):
1162
+ # checked first: Windows raises PermissionError, not IsADirectoryError, for a directory
1163
+ die(f"{flag}: {path} is a directory, not a text file")
1145
1164
  try:
1146
1165
  with open(path, "r", encoding="utf-8") as fh:
1147
1166
  return fh.read()
@@ -215,10 +215,13 @@ DRY_RUN_ANALYSIS = {
215
215
  "scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
216
216
  "report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
217
217
  "cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
218
+ "silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written",
219
+ "loudness": "the loudnorm measurement pass runs so input_i and the planned pass-2 command are real; the normalised output is not written",
220
+ "check": "read-only tool; the loudness measurement runs under --dry-run too, so every row is present",
221
+ "stabilize": "vidstabdetect (pass 1, into a temp file) runs; the stabilised output (pass 2) is not written",
218
222
  }
219
223
  DRY_RUN_NOTES = {
220
224
  "probe": "read-only tool; --dry-run changes nothing (ffprobe still runs)",
221
- "check": "read-only tool; --dry-run skips the ffmpeg loudness measurement, so loudness rows are absent",
222
225
  "verify": "not supported: the flag is accepted but the steps run and outputs are written",
223
226
  }
224
227
 
package/scripts/batch.py CHANGED
@@ -31,7 +31,7 @@ import time
31
31
  from pathlib import Path
32
32
  from typing import Any, Dict, List
33
33
 
34
- from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool
34
+ from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, read_text_or_die
35
35
 
36
36
  HERE = Path(__file__).resolve().parent
37
37
  MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac"}
@@ -102,7 +102,10 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path) -> Dict
102
102
  final = final_path(src, recipe, outdir)
103
103
  t0 = time.time()
104
104
  if recipe.get("project"):
105
- proj = json.loads(Path(recipe["project"]).read_text(encoding="utf-8"))
105
+ try:
106
+ proj = json.loads(read_text_or_die(str(recipe["project"]), "recipe.project"))
107
+ except ValueError as e:
108
+ die(f"recipe.project: {recipe['project']} is not valid JSON: {e}")
106
109
  idx = int(recipe.get("clip_key", 0))
107
110
  proj.setdefault("clips", [{}])
108
111
  while len(proj["clips"]) <= idx:
@@ -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, X264_PRESETS
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, read_text_or_die
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
 
@@ -48,33 +48,32 @@ TIME_RE = re.compile(
48
48
  def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[float] = None) -> List[Tuple[float, float, str]]:
49
49
  cues: List[Tuple[float, float, str]] = []
50
50
  cursor = 0.0
51
- with open(path, encoding="utf-8") as fh:
52
- for raw in fh:
53
- line = raw.rstrip("\n")
54
- if not line.strip():
55
- continue
56
- m = TIME_RE.match(line)
57
- if m:
58
- try:
59
- start, end = parse_time(m.group("a"), fps), parse_time(m.group("b"), fps)
60
- except MissingFpsError as e:
61
- die(f"cue '{line}': {e} -- pass --fps, or --input's own fps is used automatically when given")
62
- except ValueError:
63
- # TIME_RE matched (so m.group("text") is the real cue text, not the broken
64
- # timestamp), but one of the two timestamps itself failed to parse (e.g. a
65
- # malformed "00:00:03.15.999") -- falling back to `line.strip()` here used to
66
- # burn the whole raw line, broken timestamp included, into the caption instead
67
- # of just the text after it.
68
- start, end, text = cursor, cursor + auto_seconds, m.group("text").strip()
69
- else:
70
- text = m.group("text").strip()
51
+ for raw in read_text_or_die(path, "--text").lstrip("\ufeff").splitlines(True):
52
+ line = raw.rstrip("\n")
53
+ if not line.strip():
54
+ continue
55
+ m = TIME_RE.match(line)
56
+ if m:
57
+ try:
58
+ start, end = parse_time(m.group("a"), fps), parse_time(m.group("b"), fps)
59
+ except MissingFpsError as e:
60
+ die(f"cue '{line}': {e} -- pass --fps, or --input's own fps is used automatically when given")
61
+ except ValueError:
62
+ # TIME_RE matched (so m.group("text") is the real cue text, not the broken
63
+ # timestamp), but one of the two timestamps itself failed to parse (e.g. a
64
+ # malformed "00:00:03.15.999") -- falling back to `line.strip()` here used to
65
+ # burn the whole raw line, broken timestamp included, into the caption instead
66
+ # of just the text after it.
67
+ start, end, text = cursor, cursor + auto_seconds, m.group("text").strip()
71
68
  else:
72
- start, end, text = cursor, cursor + auto_seconds, line.strip()
73
- if end <= start:
74
- die(f"cue '{line}': end must be after start")
75
- text = text.replace(" | ", "\n").replace("|", "\n")
76
- cues.append((start, end, text))
77
- cursor = end + gap
69
+ text = m.group("text").strip()
70
+ else:
71
+ start, end, text = cursor, cursor + auto_seconds, line.strip()
72
+ if end <= start:
73
+ die(f"cue '{line}': end must be after start")
74
+ text = text.replace(" | ", "\n").replace("|", "\n")
75
+ cues.append((start, end, text))
76
+ cursor = end + gap
78
77
  if not cues:
79
78
  die(f"no cues found in {path}")
80
79
  return cues
@@ -173,8 +172,7 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
173
172
  def parse_srt(path: str) -> List[Tuple[float, float, str]]:
174
173
  cues: List[Tuple[float, float, str]] = []
175
174
  block: List[str] = []
176
- with open(path, encoding="utf-8-sig") as fh:
177
- content = fh.read().replace("\r\n", "\n") + "\n\n"
175
+ content = read_text_or_die(path, "--srt").lstrip("\ufeff").replace("\r\n", "\n") + "\n\n"
178
176
  for line in content.split("\n"):
179
177
  if line.strip():
180
178
  block.append(line)
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 STATE, 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, run_analysis, dry_run_input_pending
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},
@@ -41,8 +41,10 @@ SPECS: Dict[str, Dict[str, Any]] = {
41
41
 
42
42
 
43
43
  def measure_loudness(path: str) -> Dict[str, float]:
44
+ if dry_run_input_pending(path):
45
+ return {}
44
46
  ffmpeg = require_tool("ffmpeg")
45
- proc = run([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], quiet=True, check=False)
47
+ proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], check=False, record=True)
46
48
  m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
47
49
  if not m:
48
50
  return {}
@@ -42,14 +42,22 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
42
42
  ffmpeg = require_tool("ffmpeg")
43
43
  per_window = max(0.5, seconds / max(1, samples))
44
44
  rects: List[Tuple[int, int, int, int]] = []
45
+ failures: List[List[str]] = []
45
46
  for i in range(samples):
46
47
  start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
47
48
  start = max(0.0, start)
48
49
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
49
50
  "-vf", f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
50
- proc = run_analysis(cmd)
51
+ proc = run_analysis(cmd, check=False)
52
+ if proc.returncode != 0:
53
+ # One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
54
+ # still measure. Only when every window fails is there nothing to report.
55
+ failures.append(proc.stderr.strip().splitlines()[-1:] or ["?"])
56
+ continue
51
57
  for m in CROP_RE.finditer(proc.stderr):
52
58
  rects.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))))
59
+ if failures and len(failures) == samples:
60
+ die(f"cropdetect could not decode any of the {samples} sampled windows: {failures[-1][0][:300]}", kind="ffmpeg")
53
61
  return rects
54
62
 
55
63
 
@@ -18,16 +18,18 @@ import os
18
18
  import re
19
19
  import sys
20
20
 
21
- from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
21
+ from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, dry_run_input_pending
22
22
 
23
23
 
24
24
 
25
25
  def measure(path: str, I: float, tp: float, lra: float) -> dict:
26
- if STATE.dry_run:
27
- return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0", "silent": False}
26
+ if dry_run_input_pending(path):
27
+ return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0", "silent": False, "placeholder": True}
28
28
  ffmpeg = require_tool("ffmpeg")
29
29
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
30
- proc = run(cmd, check=False)
30
+ # Pass 1 is a measurement: it runs under --dry-run too, so the planned pass-2 command and
31
+ # the reported input_i are real (before 1.4.6 a dry run returned a made-up -20 LUFS).
32
+ proc = run_analysis(cmd, check=False, record=True)
31
33
  m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
32
34
  if proc.returncode != 0 or not m:
33
35
  die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
@@ -86,6 +88,10 @@ def main() -> int:
86
88
  cmd.append(output)
87
89
  run(cmd)
88
90
 
91
+ if STATE.dry_run:
92
+ # pass 1 measured the input for real; there is no output to measure
93
+ emit(output, measured={k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")})
94
+ return 0
89
95
  after = measure(output, args.lufs, args.tp, args.lra)
90
96
  if not after.get("silent"):
91
97
  info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS)")
package/scripts/render.py CHANGED
@@ -358,7 +358,7 @@ def main() -> int:
358
358
  check_result = None
359
359
  exit_code = 0
360
360
  if ck and ck.get("platform") and not STATE.dry_run:
361
- proc = run_tool([str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"])
361
+ proc = run_tool([str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"] + child_args())
362
362
  try:
363
363
  check_result = json.loads(proc.stdout)
364
364
  except ValueError:
@@ -386,7 +386,8 @@ def main() -> int:
386
386
  # spec (or the check itself could not run): a failed delivery, reported as one.
387
387
  failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
388
388
  die(f"rendered {output} but the {ck['platform']} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""),
389
- kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result)
389
+ kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
390
+ probe=probe(output, role="output"))
390
391
  info(f"rendered {output} via {' → '.join(stages_done)}")
391
392
  emit(output, stages=stages_done, check=check_result)
392
393
  return 0
package/scripts/report.py CHANGED
@@ -18,7 +18,7 @@ import tempfile
18
18
  from pathlib import Path
19
19
  from typing import Any, Dict, List, Optional
20
20
 
21
- from _common import STATE, add_common, apply_common, die, emit, info, probe, read_text_or_die, run_tool
21
+ from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, read_text_or_die, run_tool
22
22
 
23
23
  HERE = Path(__file__).resolve().parent
24
24
 
@@ -26,14 +26,14 @@ HERE = Path(__file__).resolve().parent
26
26
  def sheet_b64(path: str, tiles: str = "4x2", width: int = 1200) -> Optional[str]:
27
27
  with tempfile.TemporaryDirectory(prefix="ffskill_report_") as tmp:
28
28
  png = os.path.join(tmp, "sheet.png")
29
- proc = run_tool([str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png])
29
+ proc = run_tool([str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png] + child_args())
30
30
  if proc.returncode != 0 or not os.path.exists(png):
31
31
  return None
32
32
  return base64.b64encode(Path(png).read_bytes()).decode("ascii")
33
33
 
34
34
 
35
35
  def loudness(path: str) -> Dict[str, Any]:
36
- proc = run_tool([str(HERE / "loudness.py"), path, "--measure-only"])
36
+ proc = run_tool([str(HERE / "loudness.py"), path, "--measure-only"] + child_args())
37
37
  try:
38
38
  d = json.loads(proc.stdout)
39
39
  return {"lufs": round(float(d["input_i"]), 1), "tp": round(float(d["input_tp"]), 1), "lra": round(float(d["input_lra"]), 1)}
@@ -42,7 +42,7 @@ def loudness(path: str) -> Dict[str, Any]:
42
42
 
43
43
 
44
44
  def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
45
- proc = run_tool([str(HERE / "check.py"), path, "--platform", platform, "--json"])
45
+ proc = run_tool([str(HERE / "check.py"), path, "--platform", platform, "--json"] + child_args())
46
46
  try:
47
47
  doc = json.loads(proc.stdout)
48
48
  except ValueError:
@@ -12,20 +12,23 @@ Examples:
12
12
  python3 silence.py talk.mp4 --edl keep.txt # also save the kept ranges (START-END per line, cut.py --segments format)
13
13
  """
14
14
  import argparse
15
+ import os
15
16
  import re
16
17
  import sys
17
18
  from typing import List, Tuple
18
19
 
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
+ 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, run_analysis, dry_run_input_pending
20
21
 
21
22
  SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
22
23
 
23
24
 
24
25
  def detect(path: str, threshold: float, min_silence: float) -> List[Tuple[float, float]]:
26
+ if dry_run_input_pending(path):
27
+ return []
25
28
  ffmpeg = require_tool("ffmpeg")
26
29
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af",
27
30
  f"silencedetect=noise={threshold}dB:d={min_silence}", "-f", "null", "-"]
28
- proc = run(cmd, quiet=True, check=False)
31
+ proc = run_analysis(cmd, check=False, record=True)
29
32
  if proc.returncode != 0:
30
33
  die(f"silencedetect failed:\n{proc.stderr.strip()[-800:]}", kind="ffmpeg")
31
34
  silences: List[Tuple[float, float]] = []
@@ -86,7 +89,7 @@ def main() -> int:
86
89
  "removed_seconds": round(removed, 3),
87
90
  }
88
91
  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:
92
+ if not silences and not (STATE.dry_run and not os.path.exists(args.input)):
90
93
  # Nothing under the threshold is a valid result, not a failure -- but an agent that only
91
94
  # sees "0 silences" tends to reach for raw ffmpeg next. Say what the floor actually is and
92
95
  # what threshold would bite, so the retry is a flag change, not a workaround.
@@ -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, X264_PRESETS
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, run_analysis, dry_run_input_pending
31
31
 
32
32
 
33
33
  def main() -> int:
@@ -62,18 +62,20 @@ def main() -> int:
62
62
  trf = str(Path(tmp) / "transforms.trf")
63
63
  trf_arg = escape_filter_path(trf)
64
64
 
65
- if not STATE.dry_run:
66
- ffmpeg = require_tool("ffmpeg")
67
- detect_vf = f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
68
- if args.tripod:
69
- # A frame number, not a boolean: frame 1 is the standard reference for "lock to
70
- # this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
71
- detect_vf += ":tripod=1"
72
- detect_cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", args.input,
73
- "-vf", detect_vf, "-f", "null", "-"]
74
- proc = run(detect_cmd, check=False)
65
+ ffmpeg = require_tool("ffmpeg")
66
+ detect_vf = f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
67
+ if args.tripod:
68
+ # A frame number, not a boolean: frame 1 is the standard reference for "lock to
69
+ # this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
70
+ detect_vf += ":tripod=1"
71
+ detect_cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", args.input,
72
+ "-vf", detect_vf, "-f", "null", "-"]
73
+ # Pass 1 is a measurement into a temp file (the transforms), so it runs under --dry-run
74
+ # as well; only pass 2, the write, is skipped there.
75
+ if not dry_run_input_pending(args.input):
76
+ proc = run_analysis(detect_cmd, check=False, record=True)
75
77
  if proc.returncode != 0:
76
- die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}")
78
+ die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
77
79
 
78
80
  crop_mode = {"keep": 0, "black": 1}[args.crop]
79
81
  transform_vf = f"vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"