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 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, so `doctor` keeps reporting `ffmpeg`, or the `subtitles` and `drawtext` filters, as missing. Make it the one the tools find, in one of two ways:
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.2`) | any release |
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.2", "execution_mode": "local", "kind": "execution",
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 and the
363
- ffmpeg/ffprobe/python versions is printed; no environment variables, no paths (`doctor`'s
364
- `hw.default_on` is a boolean derived from `FFMPEG_SKILL_HW`, never its value).
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.2",
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 = shutil.which("ffmpeg")
46
+ exe = ffmpeg_exe("ffmpeg")[0]
47
47
  if exe:
48
48
  for flag in ("-buildconf", "-version"):
49
49
  try:
@@ -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 = shutil.which("ffmpeg")
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([shutil.which(tool) or tool, "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
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):
@@ -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 = shutil.which(binary)
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 = shutil.which(binary)
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 = shutil.which("ffmpeg")
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 shutil.which("ffmpeg") else "missing"
902
+ state[cap] = "available" if _exe("ffmpeg") else "missing"
882
903
  elif cap == "ffprobe":
883
- state[cap] = "available" if shutil.which("ffprobe") else "missing"
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: