ffmpeg-skill 2.22.0 → 2.22.2

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/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`2.22.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.22.2`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -131,7 +131,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
131
131
  "deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
132
132
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
133
133
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
134
- "skill": {"id": "ffmpeg-skill", "version": "2.22.0", "execution_mode": "local", "kind": "execution",
134
+ "skill": {"id": "ffmpeg-skill", "version": "2.22.2", "execution_mode": "local", "kind": "execution",
135
135
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
136
136
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
137
137
  "requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.22.0",
3
+ "version": "2.22.2",
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",
@@ -221,7 +221,9 @@ def transcribe_result(video: str, out_srt: str, language: Optional[str], model:
221
221
  model, parakeet_model = requested_model(model)
222
222
  tmpdir = tempfile.mkdtemp(prefix="ffskill_asr_")
223
223
  try:
224
- return _transcribe_in(tmpdir, video, out_srt, language, model, audio_stream, ffmpeg, shutil, subprocess, wanted,
224
+ # the engines' own CLIs are looked up and launched by full path, never from the working directory
225
+ from _common.runner import SafeShutil
226
+ return _transcribe_in(tmpdir, video, out_srt, language, model, audio_stream, ffmpeg, SafeShutil(shutil), subprocess, wanted,
225
227
  parakeet_model=parakeet_model)
226
228
  finally:
227
229
  shutil.rmtree(tmpdir, ignore_errors=True)
@@ -412,8 +414,9 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
412
414
  except ImportError:
413
415
  pass
414
416
  # 3. openai-whisper CLI
415
- if wanted in ("auto", "openai-whisper") and shutil.which("whisper"):
416
- cmd = ["whisper", wav, "--model", model, "--output_format", "srt", "--output_dir", tmpdir]
417
+ whisper_exe = shutil.which("whisper") if wanted in ("auto", "openai-whisper") else None
418
+ if whisper_exe:
419
+ cmd = [whisper_exe, wav, "--model", model, "--output_format", "srt", "--output_dir", tmpdir]
417
420
  if lang:
418
421
  cmd += ["--language", lang]
419
422
  proc = _asr_run(cmd, subprocess, "openai-whisper")
@@ -924,7 +927,11 @@ def run_parakeet(engine: str, wav: str, tmpdir: str, model: Optional[str], langu
924
927
 
925
928
  if engine == "parakeet-mlx":
926
929
  outdir = os.path.join(tmpdir, "pmlx")
927
- cmd = ["parakeet-mlx", wav, "--model", chosen, "--output-format", "json", "--output-dir", outdir,
930
+ exe = shutil.which("parakeet-mlx")
931
+ if not exe: # never the bare name: the OS would look for it in the working directory
932
+ failed("not on PATH")
933
+ return None
934
+ cmd = [exe, wav, "--model", chosen, "--output-format", "json", "--output-dir", outdir,
928
935
  "--max-duration", f"{CUE_MAX_SECONDS:g}", "--silence-gap", f"{CUE_GAP_SECONDS:g}"]
929
936
  # the JSON is looked for as <outdir>/audio.json: a host PARAKEET_OUTPUT_TEMPLATE would name it otherwise
930
937
  env = {k: v for k, v in os.environ.items() if k != "PARAKEET_OUTPUT_TEMPLATE"}
@@ -939,7 +946,11 @@ def run_parakeet(engine: str, wav: str, tmpdir: str, model: Optional[str], langu
939
946
  except OSError:
940
947
  raw, key = "", "sentences"
941
948
  else:
942
- cmd = ["parakeet-cli", "transcribe", "--model", chosen, "--input", wav, "--json"]
949
+ exe = shutil.which("parakeet-cli")
950
+ if not exe:
951
+ failed("not on PATH")
952
+ return None
953
+ cmd = [exe, "transcribe", "--model", chosen, "--input", wav, "--json"]
943
954
  proc = _asr_run(cmd, subprocess, "parakeet.cpp")
944
955
  if proc.returncode != 0:
945
956
  failed("found but failed: " + _failure_line(proc))
@@ -993,6 +1004,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
993
1004
  caller's flag for the language, named in assumed_english_note().
994
1005
  """
995
1006
  import shutil as _shutil
1007
+ from _common.runner import SafeShutil
1008
+ _shutil = SafeShutil(_shutil) # look engines up and launch them by full path, never from the working directory
996
1009
  import subprocess as _subprocess
997
1010
  import tempfile as _tempfile
998
1011
  from _common import require_tool, run_analysis, STATE
@@ -1098,8 +1111,9 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
1098
1111
  pass
1099
1112
 
1100
1113
  # 3. openai-whisper CLI
1101
- if (not failed or every_whisper) and wanted in ("auto", "openai-whisper") and _shutil.which("whisper"):
1102
- cmd = ["whisper", wav, "--model", model, "--word_timestamps", "True",
1114
+ whisper_exe = _shutil.which("whisper") if (not failed or every_whisper) and wanted in ("auto", "openai-whisper") else None
1115
+ if whisper_exe:
1116
+ cmd = [whisper_exe, wav, "--model", model, "--word_timestamps", "True",
1103
1117
  "--output_format", "json", "--output_dir", tmpdir]
1104
1118
  if lang:
1105
1119
  cmd += ["--language", lang]
@@ -10,7 +10,7 @@ import shutil
10
10
  import subprocess
11
11
  from typing import Dict, List, Optional
12
12
  from _common.decision import escape_filter_path
13
- from _common.runner import STATE, _DRAWTEXT_PENDING, _drawtext_tmpdir, ffmpeg_exe, ffmpeg_release, ffmpeg_version
13
+ from _common.runner import STATE, _DRAWTEXT_PENDING, _drawtext_tmpdir, ffmpeg_exe, ffmpeg_release, ffmpeg_version, require_tool
14
14
 
15
15
 
16
16
  def _on_windows() -> bool:
@@ -254,7 +254,7 @@ def drawtext_line_metrics(lines: "List[str]", font_opt: str, fontsize: int) -> "
254
254
  f"y='print({-(1000 + i)},16)+print(text_w,16)+print(top_a,16)+print(bottom_d,16)+print(lh,16)'")
255
255
  if not chain:
256
256
  return out
257
- cmd = [ffmpeg_exe("ffmpeg")[0] or "ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-f", "lavfi",
257
+ cmd = [require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-v", "error", "-f", "lavfi",
258
258
  "-i", "color=c=black:s=16x16:r=1", "-frames:v", "1", "-vf", ",".join(chain), "-f", "null", "-"]
259
259
  try:
260
260
  flush_drawtext_textfiles(cmd)
@@ -5,12 +5,12 @@ Split out of _common.text in the refactor after 1.17.3; every body is byte-ident
5
5
  from __future__ import annotations
6
6
 
7
7
  import os
8
- import shutil
9
8
  import subprocess
10
9
  from typing import Any, Dict, List, Tuple, Optional
11
10
  from _common.decision import escape_filter_path
12
11
  from _common.emit import die
13
12
  from _common.runner import ffmpeg_exe
13
+ from _common.runner import safe_which as _safe_which
14
14
 
15
15
 
16
16
  # --------------------------------------------------------------------------- emoji (1.15)
@@ -183,7 +183,7 @@ _EMOJI_SUPPORT_CACHE: "Dict[Tuple[Optional[str], bool], Dict[str, Any]]" = {}
183
183
 
184
184
  def _emoji_color_font() -> "Tuple[Optional[str], Optional[str], bool]":
185
185
  """(family, file, fontconfig_answered) for the first installed colour emoji family."""
186
- exe = shutil.which("fc-list")
186
+ exe = _safe_which("fc-list")
187
187
  if not exe:
188
188
  return None, None, False
189
189
  for family in _EMOJI_COLOR_FAMILIES:
@@ -7,12 +7,12 @@ from __future__ import annotations
7
7
  import os
8
8
  import platform
9
9
  import re
10
- import shutil
11
10
  import subprocess
12
11
  from pathlib import Path
13
12
  from typing import Dict, List, Optional, Sequence, Tuple
14
13
  from _common.emit import die, info, record_fallback, warn
15
14
  from _common.emoji import _is_emoji_char
15
+ from _common.runner import safe_which as _safe_which
16
16
 
17
17
 
18
18
  def default_font_file(font_name: str) -> Optional[str]:
@@ -79,7 +79,7 @@ def resolve_font(font_name: str) -> "Dict[str, object]":
79
79
  return {"file": str(candidate), "family": "Arial",
80
80
  "substituted": bool(wanted) and wanted != "arial" and wanted not in _GENERIC_FAMILIES, "via": "windows_fonts"}
81
81
  return none
82
- exe = shutil.which("fc-match")
82
+ exe = _safe_which("fc-match")
83
83
  if not exe:
84
84
  return none
85
85
  try:
@@ -220,7 +220,7 @@ def font_family_of_file(path: str) -> "Optional[str]":
220
220
  the file directly; without fontconfig the file stem is the honest best guess."""
221
221
  if not path or not os.path.isfile(path):
222
222
  return None
223
- exe = shutil.which("fc-scan")
223
+ exe = _safe_which("fc-scan")
224
224
  if exe:
225
225
  try:
226
226
  proc = subprocess.run([exe, "--format", "%{family[0]}", path], stdout=subprocess.PIPE,
@@ -307,7 +307,7 @@ def _fc_list_fonts(fc_lang: str) -> "Optional[List[Tuple[str, List[str]]]]":
307
307
  asked at all (no `fc-list` on PATH, or it failed/timed out) -- the difference between
308
308
  "missing" and "unknown", which the caller must not collapse: unknown is not a refusal.
309
309
  """
310
- exe = shutil.which("fc-list")
310
+ exe = _safe_which("fc-list")
311
311
  if not exe:
312
312
  return None
313
313
  try:
@@ -426,7 +426,7 @@ def font_covers_script(font_name: str, script: str) -> bool:
426
426
  """
427
427
  if script not in FC_LANG or not font_name:
428
428
  return True
429
- exe = shutil.which("fc-list")
429
+ exe = _safe_which("fc-list")
430
430
  if not exe:
431
431
  return True
432
432
  try:
@@ -460,7 +460,7 @@ def fonts_dir_covers_script(fonts_dir: str, script: str) -> "Optional[bool]":
460
460
  """
461
461
  if script not in FC_LANG or not fonts_dir or not os.path.isdir(fonts_dir):
462
462
  return None
463
- exe = shutil.which("fc-scan")
463
+ exe = _safe_which("fc-scan")
464
464
  if not exe:
465
465
  return None
466
466
  try:
@@ -118,7 +118,7 @@ def ffmpeg_version() -> "Tuple[int, int]":
118
118
  if _FFMPEG_VERSION is None:
119
119
  _FFMPEG_VERSION = (0, 0)
120
120
  try:
121
- out = subprocess.run([ffmpeg_exe("ffprobe")[0] or "ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
121
+ out = subprocess.run([found_exe("ffprobe"), "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
122
122
  timeout=PROBE_TIMEOUT).stdout
123
123
  m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)", out)
124
124
  if m:
@@ -155,7 +155,7 @@ def ffmpeg_release() -> "Optional[Tuple[int, int, int]]":
155
155
  if not _FFMPEG_RELEASE_READ:
156
156
  _FFMPEG_RELEASE_READ = True
157
157
  try:
158
- out = subprocess.run([ffmpeg_exe("ffprobe")[0] or "ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
158
+ out = subprocess.run([found_exe("ffprobe"), "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
159
159
  timeout=PROBE_TIMEOUT).stdout
160
160
  m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)(?:\.(\d+))?", out)
161
161
  if m:
@@ -187,21 +187,90 @@ def _homebrew_full_build(path: str, name: str) -> str:
187
187
  return full if os.path.isfile(full) and os.access(full, os.X_OK) else ""
188
188
 
189
189
 
190
+ def _real_dir(path: str) -> str:
191
+ """`path` with symlinks resolved, so a PATH entry that links to the working directory is
192
+ recognised as the working directory."""
193
+ return os.path.normcase(os.path.realpath(path))
194
+
195
+
196
+ def _cwd_on_path(entries: List[str]) -> bool:
197
+ cwd = _real_dir(os.getcwd())
198
+ return any(e and os.path.isabs(e) and _real_dir(e) == cwd for e in entries)
199
+
200
+
201
+ def _which_on_path(name: str, entries: List[str]) -> Optional[str]:
202
+ """The first `name` in an absolute PATH entry that is not the working directory."""
203
+ cwd = _real_dir(os.getcwd())
204
+ exts = [""]
205
+ if os.name == "nt" and not os.path.splitext(name)[1]:
206
+ exts = [e for e in os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(os.pathsep) if e] + [""]
207
+ for entry in entries:
208
+ if not entry or not os.path.isabs(entry) or _real_dir(entry) == cwd:
209
+ continue
210
+ for ext in exts:
211
+ candidate = os.path.join(entry, name + ext)
212
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
213
+ return candidate
214
+ return None
215
+
216
+
217
+ def safe_which(name: str, which: Optional[Any] = None) -> Optional[str]:
218
+ """shutil.which() without the working directory. On Windows it looks in the current directory
219
+ before PATH (and so does a bare name handed to subprocess), and a `.` or relative PATH entry
220
+ does the same elsewhere: an `ffmpeg.exe` in a folder the skill is run from would run instead
221
+ of the real one. A hit there (or in a directory that links to it) is dropped and PATH searched
222
+ again, unless the working directory is on PATH as an absolute entry, which is the caller's own
223
+ choice. `which` lets a test pass
224
+ its own lookup."""
225
+ found = (which or shutil.which)(name)
226
+ if not found:
227
+ return found
228
+ entries = os.environ.get("PATH", "").split(os.pathsep)
229
+ if _real_dir(os.path.dirname(os.path.abspath(found))) != _real_dir(os.getcwd()):
230
+ return found
231
+ if _cwd_on_path(entries):
232
+ return os.path.abspath(found) # Windows answers `.\ffmpeg.EXE`; a later chdir must not change what runs
233
+ return _which_on_path(name, entries)
234
+
235
+
236
+ class SafeShutil:
237
+ """A shutil stand-in whose which() is safe_which(); the transcription code takes a `shutil`
238
+ argument (tests pass their own), so the real one is wrapped where it is handed in."""
239
+
240
+ def __init__(self, inner: Any) -> None:
241
+ self._inner = inner
242
+
243
+ def which(self, name: str) -> Optional[str]:
244
+ return safe_which(name, self._inner.which)
245
+
246
+ def __getattr__(self, attr: str) -> Any:
247
+ return getattr(self._inner, attr)
248
+
249
+
190
250
  def ffmpeg_exe(name: str) -> Tuple[str, str]:
191
251
  """`(path, source)` of the ffmpeg/ffprobe the tools run; `("", "")` when none is on PATH.
192
252
 
193
253
  `source` is "path" (what PATH resolves) or "homebrew-ffmpeg-full" (see _homebrew_full_build).
194
254
  Not cached: PATH is read every time, as `shutil.which` was before this existed."""
195
- found = shutil.which(name)
255
+ found = safe_which(name)
196
256
  if not found:
197
257
  return "", ""
198
258
  full = _homebrew_full_build(found, name)
199
259
  return (full, "homebrew-ffmpeg-full") if full else (found, "path")
200
260
 
201
261
 
262
+ def found_exe(name: str) -> str:
263
+ """ffmpeg_exe()'s path, or FileNotFoundError when there is none: never the bare name, which
264
+ the OS would look up in the working directory (Windows) or through a relative PATH entry."""
265
+ path = ffmpeg_exe(name)[0]
266
+ if not path:
267
+ raise FileNotFoundError(f"{name} not on PATH")
268
+ return path
269
+
270
+
202
271
  def require_tool(name: str) -> str:
203
272
  """Return the absolute path of ffmpeg/ffprobe or exit with install steps."""
204
- path = ffmpeg_exe(name)[0] if name in ("ffmpeg", "ffprobe") else shutil.which(name)
273
+ path = ffmpeg_exe(name)[0] if name in ("ffmpeg", "ffprobe") else safe_which(name)
205
274
  if path:
206
275
  return path
207
276
  system = platform.system()
@@ -553,6 +622,46 @@ def video_encoder_of(cmd: Sequence[str]) -> Optional[str]:
553
622
  return enc
554
623
 
555
624
 
625
+ # A protocol prefix (`tcp:`, `http://`, `file:`, `concat:`, ...): ffmpeg opens such a value as a URL,
626
+ # while every guard here reads it as a path. Two or more letters, so a Windows
627
+ # drive letter (`C:\x.mp4`) is a path; `pipe:` is the skill's own stdout/stdin and stays allowed.
628
+ _URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]+:")
629
+
630
+
631
+ def looks_like_url(value: str) -> bool:
632
+ """True for a value ffmpeg would open through a protocol rather than as a local file."""
633
+ return bool(_URL_SCHEME.match(value)) and not value.startswith("pipe:")
634
+
635
+
636
+ def _refuse_url_media(cmd: Sequence[str]) -> None:
637
+ """Refuse an ffmpeg/ffprobe command that names a URL as an input or as its output, before it
638
+ runs or is recorded. This skill reads and writes local files only, and the path guards below
639
+ (output-is-input, existing output, output directory) only mean something for a local path. A
640
+ `-f lavfi` input is a filtergraph, not a location, and is left alone; a local name that
641
+ contains a colon still works written as `./name`."""
642
+ base = os.path.basename(cmd[0])
643
+ if not (base.startswith("ffmpeg") or base.startswith("ffprobe")):
644
+ return
645
+ fmt = None
646
+ values = []
647
+ for i, a in enumerate(cmd[1:-1], start=1):
648
+ if a == "-f":
649
+ fmt = cmd[i + 1]
650
+ elif a == "-i":
651
+ if fmt != "lavfi":
652
+ values.append(cmd[i + 1])
653
+ fmt = None
654
+ last = cmd[-1]
655
+ if not last.startswith("-"):
656
+ values.append(last)
657
+ for value in values:
658
+ if looks_like_url(value):
659
+ refuse("input", "media_path_is_url",
660
+ f"{value!r} would be opened by ffmpeg as a URL ({value.split(':', 1)[0]}:), not as a local file; "
661
+ "this skill reads and writes local files only. Pass a plain path (a local name containing "
662
+ "a colon works as ./name)", path=value, scheme=value.split(":", 1)[0].lower())
663
+
664
+
556
665
  def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
557
666
  """Refuse an ffmpeg command whose output path resolves to the same file as one of its
558
667
  inputs. ffmpeg's own "Output same as Input" guard only catches byte-identical path
@@ -615,6 +724,10 @@ def _check_output_path(cmd: Sequence[str]) -> None:
615
724
  output = cmd[-1]
616
725
  if output == "-" or output.startswith("pipe:") or output.startswith("-"):
617
726
  return
727
+ if looks_like_url(output):
728
+ refuse("input", "media_path_is_url",
729
+ f"output {output!r} would be opened by ffmpeg as a URL, not as a local file; pass a plain path "
730
+ "(a local name containing a colon works as ./name)", path=output, scheme=output.split(":", 1)[0].lower())
618
731
  if os.path.isdir(output):
619
732
  refuse("input", "output_is_directory",
620
733
  f"output {output!r} is a directory; pass a file path (e.g. {os.path.join(output, 'result.mp4')!r})", output=output)
@@ -947,7 +1060,7 @@ def apple_silicon() -> bool:
947
1060
  _APPLE_SILICON = platform.machine() == "arm64"
948
1061
  if not _APPLE_SILICON and platform.system() == "Darwin":
949
1062
  try:
950
- out = subprocess.run([shutil.which("sysctl") or "/usr/sbin/sysctl", "-n", "hw.optional.arm64"],
1063
+ out = subprocess.run([safe_which("sysctl") or "/usr/sbin/sysctl", "-n", "hw.optional.arm64"],
951
1064
  stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8",
952
1065
  errors="replace", timeout=10).stdout
953
1066
  _APPLE_SILICON = out.strip() == "1"
@@ -1042,6 +1155,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
1042
1155
  # or runs the command (color.py imports this module, hence the late import)
1043
1156
  from _common.color import fold_bt709_matrix
1044
1157
  cmd = fold_bt709_matrix(cmd)
1158
+ _refuse_url_media(cmd)
1045
1159
  _check_no_overwrite_input(cmd)
1046
1160
  _check_output_path(cmd)
1047
1161
  _check_existing_output(cmd)
@@ -1144,6 +1258,7 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, r
1144
1258
  wall-clock limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg`
1145
1259
  failure instead of an exit-0 "0 scenes found" over a file ffmpeg could not read. record=True
1146
1260
  lists the command in the --json `commands` like run() does."""
1261
+ _refuse_url_media(cmd)
1147
1262
  if record:
1148
1263
  STATE.commands.append(_cmdline(cmd))
1149
1264
  limit = _limit_for(cmd)
@@ -1557,7 +1672,7 @@ def _read_ff_list(flag: str, pattern: str) -> set:
1557
1672
  names: set = set()
1558
1673
  why = None
1559
1674
  try:
1560
- proc = subprocess.run([ffmpeg_exe(tool)[0] or tool, "-hide_banner", flag], stdout=subprocess.PIPE,
1675
+ proc = subprocess.run([found_exe(tool), "-hide_banner", flag], stdout=subprocess.PIPE,
1561
1676
  stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=limit)
1562
1677
  names = set(re.findall(pattern, proc.stdout or "", re.M))
1563
1678
  if not names:
@@ -415,6 +415,7 @@ ERROR_REASONS = {
415
415
  "output_exists": "an output already exists and --overwrite was not given; details.outputs: [{path, resolved, size, mtime_ns}]",
416
416
  "output_is_input": "the output resolves to the same file as an input; details: {output, input, resolved}",
417
417
  "output_is_directory": "the output path is a directory; details: {output}",
418
+ "media_path_is_url": "an input or output has a protocol prefix (tcp:, http://, file:, ...) that ffmpeg would open as a URL; the skill reads and writes local files only; details: {path, scheme}",
418
419
  "output_dir_missing": "the output's directory does not exist; details: {output, directory}",
419
420
  "output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
420
421
  "timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
@@ -1033,7 +1034,8 @@ def _exe_report() -> Dict[str, Any]:
1033
1034
 
1034
1035
 
1035
1036
  def _whisper_available() -> bool:
1036
- if shutil.which("whisper-cli") or shutil.which("whisper-cpp") or shutil.which("whisper"):
1037
+ from _common.runner import safe_which
1038
+ if safe_which("whisper-cli") or safe_which("whisper-cpp") or safe_which("whisper"):
1037
1039
  return True
1038
1040
  return importlib.util.find_spec("faster_whisper") is not None or importlib.util.find_spec("whisper") is not None
1039
1041
 
@@ -1042,7 +1044,8 @@ def _parakeet_available() -> bool:
1042
1044
  """parakeet-mlx on PATH, or parakeet-cli with a model it can find (PARAKEET_CPP_MODEL or a
1043
1045
  .gguf in ~/.cache/parakeet.cpp) -- the same test the speech bridge applies before running it."""
1044
1046
  from _common.asr import _parakeet_available as runtime_check # the bridge's own test, not a copy of it
1045
- return any(runtime_check(e, shutil) for e in ("parakeet-mlx", "parakeet.cpp"))
1047
+ from _common.runner import SafeShutil
1048
+ return any(runtime_check(e, SafeShutil(shutil)) for e in ("parakeet-mlx", "parakeet.cpp"))
1046
1049
 
1047
1050
 
1048
1051
  def _hw_default() -> Dict[str, Any]:
@@ -1133,7 +1136,8 @@ def _font_available(font_name: str) -> Dict[str, Any]:
1133
1136
  silently renders with a different typeface. `fc-match` is queried instead, since it reports the
1134
1137
  family fontconfig actually resolved to; that only equals the request when the font is installed.
1135
1138
  """
1136
- exe = shutil.which("fc-match")
1139
+ from _common.runner import safe_which
1140
+ exe = safe_which("fc-match")
1137
1141
  if not exe:
1138
1142
  return {"status": "unknown", "detail": "fc-match not on PATH; drawtext succeeding proves nothing (fontconfig substitutes silently), so availability cannot be verified"}
1139
1143
  try:
package/scripts/color.py CHANGED
@@ -23,7 +23,7 @@ import os
23
23
  import sys
24
24
  from typing import List, Optional
25
25
 
26
- from _common import STATE, add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, ffmpeg_exe, info, probe, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
26
+ from _common import STATE, add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
27
27
  from _common.emit import warn
28
28
 
29
29
  TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
@@ -256,7 +256,7 @@ def retag_mismatch(output: str, want: tuple) -> "Optional[str]":
256
256
  got = probe(output, role="output")["video"]
257
257
  stream = (got.get("color_space"), got.get("color_primaries"), got.get("color_transfer"))
258
258
  keys = ("color_space", "color_primaries", "color_transfer")
259
- proc = run_analysis([ffmpeg_exe("ffprobe")[0] or "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_frames",
259
+ proc = run_analysis([require_tool("ffprobe"), "-v", "error", "-select_streams", "v:0", "-show_frames",
260
260
  "-read_intervals", "%+#1", "-show_entries", "frame=" + ",".join(keys), "-of", "json", output],
261
261
  check=False)
262
262
  try:
package/scripts/render.py CHANGED
@@ -74,8 +74,8 @@ from typing import Any, Dict, List, Optional, Sequence
74
74
 
75
75
  from export import PRESETS, PLATFORM_OF
76
76
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
77
- from _common import STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, ffmpeg_exe, X264_PRESETS
78
- from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args
77
+ from _common import STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
78
+ from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, found_exe
79
79
  from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
80
80
  import subprocess
81
81
  from _contract import CONTRACT_VERSION
@@ -679,7 +679,7 @@ def ffmpeg_banner() -> str:
679
679
  key. Unreadable falls back to the parsed pair, which still separates the major releases.
680
680
  """
681
681
  try:
682
- out = subprocess.run([ffmpeg_exe("ffprobe")[0] or "ffprobe", "-version"], stdout=subprocess.PIPE,
682
+ out = subprocess.run([found_exe("ffprobe"), "-version"], stdout=subprocess.PIPE,
683
683
  stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=20).stdout
684
684
  first = (out or "").strip().splitlines()
685
685
  if first: