ffmpeg-skill 2.22.1 → 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 +2 -2
- package/package.json +1 -1
- package/scripts/_common/asr.py +21 -7
- package/scripts/_common/drawtext.py +2 -2
- package/scripts/_common/emoji.py +2 -2
- package/scripts/_common/fonts.py +6 -6
- package/scripts/_common/runner.py +75 -6
- package/scripts/_contract.py +6 -3
- package/scripts/color.py +2 -2
- package/scripts/render.py +3 -3
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.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/_common/asr.py
CHANGED
|
@@ -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
|
-
|
|
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")
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
1102
|
-
|
|
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 = [
|
|
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)
|
package/scripts/_common/emoji.py
CHANGED
|
@@ -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 =
|
|
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:
|
package/scripts/_common/fonts.py
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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([
|
|
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([
|
|
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 =
|
|
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
|
|
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()
|
|
@@ -991,7 +1060,7 @@ def apple_silicon() -> bool:
|
|
|
991
1060
|
_APPLE_SILICON = platform.machine() == "arm64"
|
|
992
1061
|
if not _APPLE_SILICON and platform.system() == "Darwin":
|
|
993
1062
|
try:
|
|
994
|
-
out = subprocess.run([
|
|
1063
|
+
out = subprocess.run([safe_which("sysctl") or "/usr/sbin/sysctl", "-n", "hw.optional.arm64"],
|
|
995
1064
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8",
|
|
996
1065
|
errors="replace", timeout=10).stdout
|
|
997
1066
|
_APPLE_SILICON = out.strip() == "1"
|
|
@@ -1603,7 +1672,7 @@ def _read_ff_list(flag: str, pattern: str) -> set:
|
|
|
1603
1672
|
names: set = set()
|
|
1604
1673
|
why = None
|
|
1605
1674
|
try:
|
|
1606
|
-
proc = subprocess.run([
|
|
1675
|
+
proc = subprocess.run([found_exe(tool), "-hide_banner", flag], stdout=subprocess.PIPE,
|
|
1607
1676
|
stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=limit)
|
|
1608
1677
|
names = set(re.findall(pattern, proc.stdout or "", re.M))
|
|
1609
1678
|
if not names:
|
package/scripts/_contract.py
CHANGED
|
@@ -1034,7 +1034,8 @@ def _exe_report() -> Dict[str, Any]:
|
|
|
1034
1034
|
|
|
1035
1035
|
|
|
1036
1036
|
def _whisper_available() -> bool:
|
|
1037
|
-
|
|
1037
|
+
from _common.runner import safe_which
|
|
1038
|
+
if safe_which("whisper-cli") or safe_which("whisper-cpp") or safe_which("whisper"):
|
|
1038
1039
|
return True
|
|
1039
1040
|
return importlib.util.find_spec("faster_whisper") is not None or importlib.util.find_spec("whisper") is not None
|
|
1040
1041
|
|
|
@@ -1043,7 +1044,8 @@ def _parakeet_available() -> bool:
|
|
|
1043
1044
|
"""parakeet-mlx on PATH, or parakeet-cli with a model it can find (PARAKEET_CPP_MODEL or a
|
|
1044
1045
|
.gguf in ~/.cache/parakeet.cpp) -- the same test the speech bridge applies before running it."""
|
|
1045
1046
|
from _common.asr import _parakeet_available as runtime_check # the bridge's own test, not a copy of it
|
|
1046
|
-
|
|
1047
|
+
from _common.runner import SafeShutil
|
|
1048
|
+
return any(runtime_check(e, SafeShutil(shutil)) for e in ("parakeet-mlx", "parakeet.cpp"))
|
|
1047
1049
|
|
|
1048
1050
|
|
|
1049
1051
|
def _hw_default() -> Dict[str, Any]:
|
|
@@ -1134,7 +1136,8 @@ def _font_available(font_name: str) -> Dict[str, Any]:
|
|
|
1134
1136
|
silently renders with a different typeface. `fc-match` is queried instead, since it reports the
|
|
1135
1137
|
family fontconfig actually resolved to; that only equals the request when the font is installed.
|
|
1136
1138
|
"""
|
|
1137
|
-
|
|
1139
|
+
from _common.runner import safe_which
|
|
1140
|
+
exe = safe_which("fc-match")
|
|
1138
1141
|
if not exe:
|
|
1139
1142
|
return {"status": "unknown", "detail": "fc-match not on PATH; drawtext succeeding proves nothing (fontconfig substitutes silently), so availability cannot be verified"}
|
|
1140
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,
|
|
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([
|
|
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,
|
|
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([
|
|
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:
|