ffmpeg-skill 2.8.2 → 2.8.3
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 +1 -1
- package/docs/contract.md +17 -5
- package/package.json +1 -1
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/drawtext.py +2 -2
- package/scripts/_common/emoji.py +2 -1
- package/scripts/_common/runner.py +37 -3
- package/scripts/_contract.py +37 -9
- package/scripts/look.py +3 -3
- package/scripts/render.py +2 -2
package/README.md
CHANGED
|
@@ -484,7 +484,7 @@ FFmpeg itself:
|
|
|
484
484
|
| Ubuntu / Debian | `sudo apt install ffmpeg` |
|
|
485
485
|
| Windows | `winget install Gyan.FFmpeg` |
|
|
486
486
|
|
|
487
|
-
**On macOS, `ffmpeg-full` is keg-only:** Homebrew installs it without linking it, so after `brew install ffmpeg-full` the `ffmpeg` on `PATH` is still the lean `ffmpeg` formula (often present as a dependency of another formula), or no `ffmpeg` at all
|
|
487
|
+
**On macOS, `ffmpeg-full` is keg-only:** Homebrew installs it without linking it, so after `brew install ffmpeg-full` the `ffmpeg` on `PATH` is still the lean `ffmpeg` formula (often present as a dependency of another formula), or no `ffmpeg` at all. **If the `ffmpeg` on `PATH` is Homebrew's lean formula, the skill uses the `ffmpeg-full` of the same Homebrew prefix by itself** (`ffmpeg` and `ffprobe` both, from `opt/ffmpeg-full/bin`; `doctor` says so, and `doctor --json` gives `ffmpeg_path` / `ffmpeg_source`); any other `ffmpeg` on `PATH` (a tap build, MacPorts, a static download) is used as it is. With no `ffmpeg` on `PATH` at all, or to make the whole shell use it, do one of two things:
|
|
488
488
|
|
|
489
489
|
- **Put it first on `PATH`.** Add `export PATH="$(brew --prefix ffmpeg-full)/bin:$PATH"` to your shell profile. `brew --prefix ffmpeg-full` is the stable `opt/` path, so this survives upgrades, but it only reaches processes started from a shell that read the profile.
|
|
490
490
|
- **Link it over the lean formula:** `brew unlink ffmpeg 2>/dev/null; brew link --overwrite --force ffmpeg-full`. Every process finds this one, but `brew doctor` warns about a linked keg-only formula, and an upgrade of the lean `ffmpeg` has been seen to link the lean build back on top without a message. After upgrading, check that `command -v ffmpeg` still resolves into `ffmpeg-full`, and re-run the link if it doesn't.
|
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.8.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.8.3`) | 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`
|
|
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
119
119
|
"contract_version": "1.0",
|
|
120
120
|
"deprecated": [],
|
|
121
121
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
122
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.8.
|
|
122
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.8.3", "execution_mode": "local", "kind": "execution",
|
|
123
123
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
124
124
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
125
125
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
@@ -359,9 +359,12 @@ Names: `ffmpeg`, `ffprobe`, `encoder:<name>`, `filter:<name>`, `bsf:<name>`,
|
|
|
359
359
|
`optional` the union of the conditional ones. With detection (the default)
|
|
360
360
|
`available`, `missing` and `missing_optional` are added from `doctor`, which reads
|
|
361
361
|
`ffmpeg -encoders / -filters / -bsfs` and looks for a local whisper or Parakeet. Pass `--static`
|
|
362
|
-
to omit detection. Nothing from the environment other than those lists
|
|
363
|
-
ffmpeg/ffprobe/python versions
|
|
364
|
-
`
|
|
362
|
+
to omit detection. Nothing from the environment other than those lists, the
|
|
363
|
+
ffmpeg/ffprobe/python versions and the paths of the ffmpeg/ffprobe binaries the tools run
|
|
364
|
+
(`ffmpeg_path`, `ffprobe_path`; a font file's path under `fonts` is the other one) is printed; no
|
|
365
|
+
environment variables (`doctor`'s `hw.default_on` is a boolean derived from `FFMPEG_SKILL_HW`,
|
|
366
|
+
never its value). The two binary paths can sit under a home directory, so read a pasted `doctor`
|
|
367
|
+
before posting it.
|
|
365
368
|
|
|
366
369
|
`external:whisper` is **optional** for two tools since 1.17: `caption.py`
|
|
367
370
|
(`--transcribe`) and `silence.py` (`--filler --transcribe`). Neither requires
|
|
@@ -418,6 +421,15 @@ capabilities by hand to answer "can I run `caption.py` on this machine right now
|
|
|
418
421
|
passing overall does not mean every tool is usable (a plain Homebrew `ffmpeg` on macOS is `ok`
|
|
419
422
|
for tools that don't need `subtitles`/`drawtext`/`zscale`, but `caption.usable` is `"no"`).
|
|
420
423
|
|
|
424
|
+
`doctor`'s `ffmpeg_path`, `ffmpeg_source`, `ffprobe_path` and `ffprobe_source` name the binary the
|
|
425
|
+
tools actually run (`null` when none is on PATH). `*_source` is `"path"` (what PATH resolves) or
|
|
426
|
+
`"homebrew-ffmpeg-full"`: on macOS, when the binary PATH resolves is Homebrew's lean `ffmpeg` /
|
|
427
|
+
`ffmpeg@N` formula (its real path is under `<prefix>/Cellar/ffmpeg…`) and the same prefix has the
|
|
428
|
+
keg-only `opt/ffmpeg-full/bin/<name>`, the tools run that one (#317), because `brew install
|
|
429
|
+
ffmpeg-full` does not put it on PATH and the lean build has no drawtext/subtitles/zscale. Nothing
|
|
430
|
+
else is substituted. `ffmpeg` and `ffprobe` (version strings) keep their meaning; the four keys are
|
|
431
|
+
additive.
|
|
432
|
+
|
|
421
433
|
`doctor`'s `gpu_encoders` field reports GPU-backed encoders (`nvenc`, `videotoolbox`, `qsv`,
|
|
422
434
|
`vaapi`, `amf`) present in this ffmpeg *build*, read from `-encoders` alone — `{"status":
|
|
423
435
|
"parsed"|"unparsed"|"failed"|"missing", "present": [...]}`. It proves the build shipped the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.3",
|
|
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",
|
|
@@ -55,7 +55,7 @@ from _common.runner import (
|
|
|
55
55
|
add_common, apply_common, _check_existing_output, refuse_existing_outputs, _check_no_overwrite_input, _check_output_path, child_args,
|
|
56
56
|
child_limit, _CHILDREN, _cleanup_partial_output, _cmdline, CODECS, Context, DEFAULT_TIMEOUT,
|
|
57
57
|
_DRAWTEXT_PENDING, _DRAWTEXT_TMPDIR, _drawtext_tmpdir, dry_run_input_pending, _ENCODERS, _env_timeout,
|
|
58
|
-
ERROR_CODE, ERROR_RETRYABLE, EVEN_SCALE, _execute, _fail, ffmpeg_base, ffmpeg_encoders, _FFMPEG_VERSION,
|
|
58
|
+
ERROR_CODE, ERROR_RETRYABLE, EVEN_SCALE, _execute, _fail, ffmpeg_base, ffmpeg_encoders, ffmpeg_exe, _FFMPEG_VERSION,
|
|
59
59
|
ffmpeg_version, flush_drawtext_textfiles, INSTALL_HINTS, install_signal_handlers, _is_ffmpeg, _limit_for,
|
|
60
60
|
_odd_dimension_retry, _on_signal, _OutputLock, _pid_dead, place_output, PROBE_TIMEOUT, _progress_line,
|
|
61
61
|
read_text_or_die, refuse_output_is_input, _remember_output, require_tool, run, run_analysis, _run_captured,
|
|
@@ -195,7 +195,7 @@ __all__ = [
|
|
|
195
195
|
"_emoji_name_candidates", "EMOJI_RANGES", "_EMOJI_REGIONAL", "emoji_support", "_EMOJI_SUPPORT_CACHE",
|
|
196
196
|
"_EMOJI_TAIL", "encoder_args", "_ENCODERS", "_env_timeout", "ERROR_CODE", "ERROR_RETRYABLE", "escape_drawtext",
|
|
197
197
|
"escape_filter_path", "EVEN_SCALE", "_execute", "_fail", "_family_rank", "FC_LANG", "_fc_list_fonts",
|
|
198
|
-
"FC_UNKNOWN", "ffmpeg_base", "ffmpeg_encoders", "_FFMPEG_VERSION", "ffmpeg_version", "fingerprint",
|
|
198
|
+
"FC_UNKNOWN", "ffmpeg_base", "ffmpeg_encoders", "ffmpeg_exe", "_FFMPEG_VERSION", "ffmpeg_version", "fingerprint",
|
|
199
199
|
"flush_drawtext_textfiles", "fmt_secs", "fmt_smpte_time", "fmt_srt_time", "font_covers_script",
|
|
200
200
|
"font_family_for_script", "font_family_of_file", "FONT_FLAG_HINT", "font_for_script", "FONT_INSTALL_HINT",
|
|
201
201
|
"fonts_dir_covers_script", "_fraction", "has_emoji", "info", "INSTALL_HINTS", "install_signal_handlers",
|
|
@@ -10,7 +10,7 @@ import shutil
|
|
|
10
10
|
import subprocess
|
|
11
11
|
from typing import Dict, Optional
|
|
12
12
|
from _common.decision import escape_filter_path
|
|
13
|
-
from _common.runner import STATE, _DRAWTEXT_PENDING, _drawtext_tmpdir, ffmpeg_version
|
|
13
|
+
from _common.runner import STATE, _DRAWTEXT_PENDING, _drawtext_tmpdir, ffmpeg_exe, ffmpeg_version
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def drawtext_boxborderw(vertical: int, horizontal: int) -> str:
|
|
@@ -43,7 +43,7 @@ def drawtext_shaping() -> "Dict[str, bool]":
|
|
|
43
43
|
if _SHAPING_BUILD_CACHE:
|
|
44
44
|
return dict(_SHAPING_BUILD_CACHE)
|
|
45
45
|
text = ""
|
|
46
|
-
exe =
|
|
46
|
+
exe = ffmpeg_exe("ffmpeg")[0]
|
|
47
47
|
if exe:
|
|
48
48
|
for flag in ("-buildconf", "-version"):
|
|
49
49
|
try:
|
package/scripts/_common/emoji.py
CHANGED
|
@@ -9,6 +9,7 @@ import shutil
|
|
|
9
9
|
import subprocess
|
|
10
10
|
from typing import Any, Dict, List, Tuple, Optional
|
|
11
11
|
from _common.emit import die
|
|
12
|
+
from _common.runner import ffmpeg_exe
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
# --------------------------------------------------------------------------- emoji (1.15)
|
|
@@ -192,7 +193,7 @@ def _libass_color_probe() -> "Optional[bool]":
|
|
|
192
193
|
the font listing: Noto Color Emoji installs happily on builds whose freetype/libass has no
|
|
193
194
|
colour-bitmap path at all, and those render a monochrome outline instead (measured). ~80 ms.
|
|
194
195
|
None means the probe could not be run (no ffmpeg, a failure) -- unknown, not false."""
|
|
195
|
-
exe =
|
|
196
|
+
exe = ffmpeg_exe("ffmpeg")[0]
|
|
196
197
|
if not exe:
|
|
197
198
|
return None
|
|
198
199
|
import tempfile
|
|
@@ -102,7 +102,7 @@ def ffmpeg_version() -> "Tuple[int, int]":
|
|
|
102
102
|
if _FFMPEG_VERSION is None:
|
|
103
103
|
_FFMPEG_VERSION = (0, 0)
|
|
104
104
|
try:
|
|
105
|
-
out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
|
|
105
|
+
out = subprocess.run([ffmpeg_exe("ffprobe")[0] or "ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
|
|
106
106
|
timeout=PROBE_TIMEOUT).stdout
|
|
107
107
|
m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)", out)
|
|
108
108
|
if m:
|
|
@@ -125,9 +125,43 @@ def ffmpeg_version() -> "Tuple[int, int]":
|
|
|
125
125
|
return _FFMPEG_VERSION
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
def _homebrew_full_build(path: str, name: str) -> str:
|
|
129
|
+
"""On macOS, the keg-only `ffmpeg-full` binary that should replace `path`, or "".
|
|
130
|
+
|
|
131
|
+
Homebrew splits FFmpeg in two: the `ffmpeg` formula is lean (no freetype, libass, fontconfig,
|
|
132
|
+
harfbuzz, so no drawtext/subtitles/ass) and the keg-only `ffmpeg-full` is not linked onto
|
|
133
|
+
PATH. `brew install ffmpeg-full` therefore changes nothing for a user whose PATH still reaches
|
|
134
|
+
the lean one (#317). The swap happens only when `path` resolves into THIS Homebrew prefix's
|
|
135
|
+
Cellar as `ffmpeg` / `ffmpeg@N` and the same prefix has `opt/ffmpeg-full/bin/<name>`; a tap
|
|
136
|
+
build, MacPorts, a static download or a custom prefix is left exactly as the user put it."""
|
|
137
|
+
if platform.system() != "Darwin":
|
|
138
|
+
return ""
|
|
139
|
+
real = os.path.realpath(path)
|
|
140
|
+
prefix, sep, rest = real.partition("/Cellar/")
|
|
141
|
+
if not sep:
|
|
142
|
+
return ""
|
|
143
|
+
formula = rest.split("/", 1)[0]
|
|
144
|
+
if formula != "ffmpeg" and not re.fullmatch(r"ffmpeg@\d+", formula):
|
|
145
|
+
return ""
|
|
146
|
+
full = os.path.join(prefix, "opt", "ffmpeg-full", "bin", name)
|
|
147
|
+
return full if os.path.isfile(full) and os.access(full, os.X_OK) else ""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ffmpeg_exe(name: str) -> Tuple[str, str]:
|
|
151
|
+
"""`(path, source)` of the ffmpeg/ffprobe the tools run; `("", "")` when none is on PATH.
|
|
152
|
+
|
|
153
|
+
`source` is "path" (what PATH resolves) or "homebrew-ffmpeg-full" (see _homebrew_full_build).
|
|
154
|
+
Not cached: PATH is read every time, as `shutil.which` was before this existed."""
|
|
155
|
+
found = shutil.which(name)
|
|
156
|
+
if not found:
|
|
157
|
+
return "", ""
|
|
158
|
+
full = _homebrew_full_build(found, name)
|
|
159
|
+
return (full, "homebrew-ffmpeg-full") if full else (found, "path")
|
|
160
|
+
|
|
161
|
+
|
|
128
162
|
def require_tool(name: str) -> str:
|
|
129
163
|
"""Return the absolute path of ffmpeg/ffprobe or exit with install steps."""
|
|
130
|
-
path = shutil.which(name)
|
|
164
|
+
path = ffmpeg_exe(name)[0] if name in ("ffmpeg", "ffprobe") else shutil.which(name)
|
|
131
165
|
if path:
|
|
132
166
|
return path
|
|
133
167
|
system = platform.system()
|
|
@@ -1297,7 +1331,7 @@ def ffmpeg_encoders() -> set:
|
|
|
1297
1331
|
_ENCODERS = set()
|
|
1298
1332
|
tool = "ffprobe" if STATE.dry_run else "ffmpeg"
|
|
1299
1333
|
try:
|
|
1300
|
-
out = subprocess.run([
|
|
1334
|
+
out = subprocess.run([ffmpeg_exe(tool)[0] or tool, "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
|
|
1301
1335
|
stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=PROBE_TIMEOUT).stdout
|
|
1302
1336
|
_ENCODERS = set(re.findall(r"^\s*[VAS][.\w]{5}\s+(\S+)", out, re.M))
|
|
1303
1337
|
except (OSError, subprocess.SubprocessError):
|
package/scripts/_contract.py
CHANGED
|
@@ -632,13 +632,20 @@ def _parse_ff_list(flag: str, text: str) -> List[str]:
|
|
|
632
632
|
return names
|
|
633
633
|
|
|
634
634
|
|
|
635
|
+
def _exe(binary: str) -> str:
|
|
636
|
+
"""The ffmpeg/ffprobe the tools run (PATH, or Homebrew's keg-only ffmpeg-full over a lean one:
|
|
637
|
+
_common.runner.ffmpeg_exe), so doctor reports the build the tools will use, not the one PATH names."""
|
|
638
|
+
from _common.runner import ffmpeg_exe
|
|
639
|
+
return ffmpeg_exe(binary)[0]
|
|
640
|
+
|
|
641
|
+
|
|
635
642
|
def _ff_listing(binary: str, flag: str) -> Dict[str, Any]:
|
|
636
643
|
"""`{"names": [...], "status": parsed | unparsed | failed | missing, "detail": str}` for `ffmpeg <flag>`.
|
|
637
644
|
|
|
638
645
|
`parsed`: rows recognised. `unparsed`: ffmpeg ran but no row matched, so the capabilities it
|
|
639
646
|
covers are unknown, not absent. `failed`: ffmpeg exited non-zero. `missing`: no binary on PATH.
|
|
640
647
|
"""
|
|
641
|
-
exe =
|
|
648
|
+
exe = _exe(binary)
|
|
642
649
|
if not exe:
|
|
643
650
|
return {"names": [], "status": "missing", "detail": f"{binary} not on PATH"}
|
|
644
651
|
try:
|
|
@@ -684,7 +691,7 @@ def _gpu_encoders(encoders_listing: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
684
691
|
|
|
685
692
|
|
|
686
693
|
def _version_line(binary: str) -> Optional[str]:
|
|
687
|
-
exe =
|
|
694
|
+
exe = _exe(binary)
|
|
688
695
|
if not exe:
|
|
689
696
|
return None
|
|
690
697
|
try:
|
|
@@ -696,6 +703,19 @@ def _version_line(binary: str) -> Optional[str]:
|
|
|
696
703
|
return m.group(1) if m else (first[0] if first else "unknown")
|
|
697
704
|
|
|
698
705
|
|
|
706
|
+
def _exe_report() -> Dict[str, Any]:
|
|
707
|
+
"""`ffmpeg_path` / `ffmpeg_source` / `ffprobe_path` / `ffprobe_source`: which binary the tools run
|
|
708
|
+
and why ("path": what PATH resolves; "homebrew-ffmpeg-full": macOS, a lean Homebrew ffmpeg on
|
|
709
|
+
PATH replaced by the keg-only ffmpeg-full of the same prefix, #317). None when not on PATH."""
|
|
710
|
+
from _common.runner import ffmpeg_exe
|
|
711
|
+
out: Dict[str, Any] = {}
|
|
712
|
+
for name in ("ffmpeg", "ffprobe"):
|
|
713
|
+
path, source = ffmpeg_exe(name)
|
|
714
|
+
out[f"{name}_path"] = path or None
|
|
715
|
+
out[f"{name}_source"] = source or None
|
|
716
|
+
return out
|
|
717
|
+
|
|
718
|
+
|
|
699
719
|
def _whisper_available() -> bool:
|
|
700
720
|
if shutil.which("whisper-cli") or shutil.which("whisper-cpp") or shutil.which("whisper"):
|
|
701
721
|
return True
|
|
@@ -742,7 +762,7 @@ def _drawtext_probe() -> Dict[str, Any]:
|
|
|
742
762
|
fontfile= render works but a bare one crashes, drawtext stays available and the result
|
|
743
763
|
carries a `note`: only a fontconfig lookup (`--font <family>` with no file) would crash.
|
|
744
764
|
"""
|
|
745
|
-
exe =
|
|
765
|
+
exe = _exe("ffmpeg")
|
|
746
766
|
if not exe:
|
|
747
767
|
return {"status": "unknown", "detail": "ffmpeg not on PATH"}
|
|
748
768
|
# Probe the way the tools call drawtext: with the fontfile= they pick by default (#298). On a
|
|
@@ -867,6 +887,7 @@ def doctor() -> Dict[str, Any]:
|
|
|
867
887
|
state: Dict[str, str] = {} # capability -> available | missing | unknown
|
|
868
888
|
wanted = required_capabilities()
|
|
869
889
|
drawtext_probe: Optional[Dict[str, Any]] = None
|
|
890
|
+
drawtext_crashed = False # listed but crashed (#100), as opposed to absent from the build
|
|
870
891
|
|
|
871
892
|
def _from(kind: str, name: str) -> str:
|
|
872
893
|
lst = listings[kind]
|
|
@@ -878,9 +899,9 @@ def doctor() -> Dict[str, Any]:
|
|
|
878
899
|
|
|
879
900
|
for cap in wanted["required"] + wanted["optional"]:
|
|
880
901
|
if cap == "ffmpeg":
|
|
881
|
-
state[cap] = "available" if
|
|
902
|
+
state[cap] = "available" if _exe("ffmpeg") else "missing"
|
|
882
903
|
elif cap == "ffprobe":
|
|
883
|
-
state[cap] = "available" if
|
|
904
|
+
state[cap] = "available" if _exe("ffprobe") else "missing"
|
|
884
905
|
elif cap.startswith("encoder:"):
|
|
885
906
|
state[cap] = _from("encoders", cap[8:])
|
|
886
907
|
elif cap == "filter:drawtext":
|
|
@@ -897,6 +918,7 @@ def doctor() -> Dict[str, Any]:
|
|
|
897
918
|
drawtext_probe = probe
|
|
898
919
|
if probe["status"] == "missing":
|
|
899
920
|
drawtext_probe = probe
|
|
921
|
+
drawtext_crashed = True
|
|
900
922
|
state[cap] = "missing"
|
|
901
923
|
else:
|
|
902
924
|
state[cap] = "available"
|
|
@@ -926,6 +948,7 @@ def doctor() -> Dict[str, Any]:
|
|
|
926
948
|
"python": ".".join(str(x) for x in sys.version_info[:3]),
|
|
927
949
|
"ffmpeg": _version_line("ffmpeg"),
|
|
928
950
|
"ffprobe": _version_line("ffprobe"),
|
|
951
|
+
**_exe_report(),
|
|
929
952
|
"available": available,
|
|
930
953
|
"missing": missing_required,
|
|
931
954
|
"missing_optional": missing_optional,
|
|
@@ -934,7 +957,7 @@ def doctor() -> Dict[str, Any]:
|
|
|
934
957
|
"errors": errors,
|
|
935
958
|
**({"notes": notes} if notes else {}),
|
|
936
959
|
"ok": not missing_required and not unknown_required,
|
|
937
|
-
"tools": _tool_usability(state),
|
|
960
|
+
"tools": _tool_usability(state, drawtext_crashed),
|
|
938
961
|
"gpu_encoders": _gpu_encoders(listings["encoders"]),
|
|
939
962
|
"hw": _hw_default(),
|
|
940
963
|
"fonts": _fonts_capability(probe=True),
|
|
@@ -1006,7 +1029,7 @@ def _fonts_summary_line(fonts: Dict[str, Any]) -> str:
|
|
|
1006
1029
|
return "; ".join(parts)
|
|
1007
1030
|
|
|
1008
1031
|
|
|
1009
|
-
def _capability_fix_hint(cap: str) -> str:
|
|
1032
|
+
def _capability_fix_hint(cap: str, drawtext_crashed: bool = False) -> str:
|
|
1010
1033
|
"""One-line, plain-language remedy for a single missing/unknown capability."""
|
|
1011
1034
|
if cap in ("ffmpeg", "ffprobe"):
|
|
1012
1035
|
from _common import INSTALL_HINTS
|
|
@@ -1026,6 +1049,8 @@ def _capability_fix_hint(cap: str) -> str:
|
|
|
1026
1049
|
full_hint = "install/build ffmpeg with it enabled"
|
|
1027
1050
|
if cap.startswith("encoder:"):
|
|
1028
1051
|
return f"this ffmpeg build has no {cap[8:]} encoder; {full_hint}"
|
|
1052
|
+
if cap == "filter:drawtext" and not drawtext_crashed:
|
|
1053
|
+
return f"this ffmpeg build has no drawtext filter (no freetype); {full_hint}"
|
|
1029
1054
|
if cap == "filter:drawtext":
|
|
1030
1055
|
return ("drawtext crashed instead of rendering a frame (see errors[] for the exit detail) -- "
|
|
1031
1056
|
"every drawtext tool already resolves a concrete font file automatically when one can "
|
|
@@ -1054,7 +1079,7 @@ def _capability_fix_hint(cap: str) -> str:
|
|
|
1054
1079
|
return f"'{cap}' is not available; see docs/contract.md"
|
|
1055
1080
|
|
|
1056
1081
|
|
|
1057
|
-
def _tool_usability(state: Dict[str, str]) -> Dict[str, Dict[str, Any]]:
|
|
1082
|
+
def _tool_usability(state: Dict[str, str], drawtext_crashed: bool = False) -> Dict[str, Dict[str, Any]]:
|
|
1058
1083
|
"""Per-tool usable/missing/unknown, folded from the same capability `state` doctor already
|
|
1059
1084
|
computed. Answers "can I run this tool on this machine today", not just "what capabilities
|
|
1060
1085
|
exist" -- a caller reading only `available`/`missing` still has to cross-reference each tool's
|
|
@@ -1067,7 +1092,7 @@ def _tool_usability(state: Dict[str, str]) -> Dict[str, Dict[str, Any]]:
|
|
|
1067
1092
|
entry: Dict[str, Any] = {"usable": "no" if missing else ("unknown" if unknown else "yes")}
|
|
1068
1093
|
if missing:
|
|
1069
1094
|
entry["missing"] = missing
|
|
1070
|
-
entry["fix"] = "; ".join(dict.fromkeys(_capability_fix_hint(c) for c in missing))
|
|
1095
|
+
entry["fix"] = "; ".join(dict.fromkeys(_capability_fix_hint(c, drawtext_crashed) for c in missing))
|
|
1071
1096
|
if unknown:
|
|
1072
1097
|
entry["unknown"] = unknown
|
|
1073
1098
|
tools[name] = entry
|
|
@@ -1507,6 +1532,9 @@ def main() -> int:
|
|
|
1507
1532
|
else:
|
|
1508
1533
|
print(f"ffmpeg-skill {d['version']} (this installed copy; re-run `npx ffmpeg-skill` to refresh it -- copies are not updated automatically)")
|
|
1509
1534
|
print(f"python {d['python']}; ffmpeg {d['ffmpeg'] or 'MISSING'}; ffprobe {d['ffprobe'] or 'MISSING'}")
|
|
1535
|
+
for name in ("ffmpeg", "ffprobe"):
|
|
1536
|
+
if d[f"{name}_source"] == "homebrew-ffmpeg-full":
|
|
1537
|
+
print(f"{name} runs from {d[f'{name}_path']}: Homebrew's keg-only ffmpeg-full, used because the {name} on PATH is the lean formula (no drawtext/subtitles)")
|
|
1510
1538
|
# counts, not the full capability list: the names of the ~60 available capabilities
|
|
1511
1539
|
# answer no question a caller has (they are in `doctor --json .available` when one
|
|
1512
1540
|
# does), while what is MISSING is the whole reason to run doctor (1.11.0 token diet).
|
package/scripts/look.py
CHANGED
|
@@ -17,7 +17,7 @@ from pathlib import Path
|
|
|
17
17
|
from typing import List, Optional
|
|
18
18
|
|
|
19
19
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
20
|
-
from _common import STATE, add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_analysis, time_arg
|
|
20
|
+
from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_analysis, time_arg
|
|
21
21
|
|
|
22
22
|
FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
|
|
23
23
|
|
|
@@ -37,7 +37,7 @@ def _png_size(png_path: str) -> "Optional[tuple]":
|
|
|
37
37
|
# ffprobe/ffmpeg measurement in this codebase, check=False since a probe failure here is
|
|
38
38
|
# an unknown-metric result (has_ink: None), not a reason to die -- a stall still does,
|
|
39
39
|
# exactly like every other measurement's timeout.
|
|
40
|
-
proc = run_analysis(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
|
40
|
+
proc = run_analysis([require_tool("ffprobe"), "-v", "error", "-select_streams", "v:0", "-show_entries",
|
|
41
41
|
"stream=width,height", "-of", "csv=p=0", png_path], check=False)
|
|
42
42
|
parts = proc.stdout.strip().split(",")
|
|
43
43
|
if len(parts) != 2:
|
|
@@ -49,7 +49,7 @@ def _png_size(png_path: str) -> "Optional[tuple]":
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def _png_pixels(png_path: str, w: int, h: int) -> "Optional[bytes]":
|
|
52
|
-
proc = run_analysis(["ffmpeg", "-v", "error", "-i", png_path, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
|
|
52
|
+
proc = run_analysis([require_tool("ffmpeg"), "-v", "error", "-i", png_path, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
|
|
53
53
|
check=False, text=False)
|
|
54
54
|
data = proc.stdout
|
|
55
55
|
return data if len(data) >= w * h * 3 else None
|
package/scripts/render.py
CHANGED
|
@@ -64,7 +64,7 @@ from typing import Any, Dict, List, Optional, Sequence
|
|
|
64
64
|
|
|
65
65
|
from export import PRESETS, PLATFORM_OF
|
|
66
66
|
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
67
|
-
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
|
|
67
|
+
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
|
|
68
68
|
from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args
|
|
69
69
|
from _common.emit import absorb_stage_hw
|
|
70
70
|
import subprocess
|
|
@@ -605,7 +605,7 @@ def ffmpeg_banner() -> str:
|
|
|
605
605
|
key. Unreadable falls back to the parsed pair, which still separates the major releases.
|
|
606
606
|
"""
|
|
607
607
|
try:
|
|
608
|
-
out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE,
|
|
608
|
+
out = subprocess.run([ffmpeg_exe("ffprobe")[0] or "ffprobe", "-version"], stdout=subprocess.PIPE,
|
|
609
609
|
stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=20).stdout
|
|
610
610
|
first = (out or "").strip().splitlines()
|
|
611
611
|
if first:
|