ffmpeg-skill 0.10.0 → 0.12.5

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.
Files changed (56) hide show
  1. package/README.md +70 -10
  2. package/SKILL.md +89 -12
  3. package/bin/install.js +15 -1
  4. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  5. package/mcp/server.py +2 -0
  6. package/package.json +2 -2
  7. package/references/ci-platform-pitfalls.md +111 -0
  8. package/references/process-pitfalls.md +85 -0
  9. package/references/scripts.md +139 -12
  10. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  11. package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
  12. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  13. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  14. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  15. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  16. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  17. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  18. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  19. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  20. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  21. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  22. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  23. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  24. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  25. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  26. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  27. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  28. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  29. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  30. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  31. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  32. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
  33. package/scripts/_common.py +255 -14
  34. package/scripts/_contract.py +276 -13
  35. package/scripts/audio.py +1 -1
  36. package/scripts/background.py +73 -0
  37. package/scripts/caption.py +106 -24
  38. package/scripts/color.py +129 -30
  39. package/scripts/crop.py +79 -0
  40. package/scripts/cut.py +2 -2
  41. package/scripts/export.py +1 -1
  42. package/scripts/fit.py +73 -14
  43. package/scripts/graphics.py +18 -7
  44. package/scripts/insert.py +128 -0
  45. package/scripts/join.py +14 -5
  46. package/scripts/look.py +13 -8
  47. package/scripts/loudness.py +3 -3
  48. package/scripts/multicam.py +1 -1
  49. package/scripts/overlay.py +78 -12
  50. package/scripts/proxy.py +82 -0
  51. package/scripts/reverse.py +56 -0
  52. package/scripts/scenes.py +8 -2
  53. package/scripts/sequence.py +124 -0
  54. package/scripts/silence.py +2 -2
  55. package/scripts/stabilize.py +101 -0
  56. package/scripts/sync.py +1 -1
@@ -41,12 +41,52 @@ INSTALL_HINTS = {
41
41
  }
42
42
 
43
43
 
44
+ # `kind` (below) has been the only machine-readable failure axis since 0.1: a flat, 4-value
45
+ # vocabulary (input / missing_tool / ffmpeg / output) set at the ~7 call sites that ever pass one
46
+ # explicitly, defaulting to "input" everywhere else. `ERROR_CODE` is an additive, purely
47
+ # informational refinement layered on top for agents that want a stable enum to switch on instead
48
+ # of pattern-matching `kind` strings -- it is a static 1:1 relabelling of the exact same 4 buckets,
49
+ # not a new taxonomy. It intentionally does NOT introduce categories this codebase cannot actually
50
+ # distinguish today (e.g. a separate ffprobe-vs-ffmpeg code, or an environment-vs-content-cause
51
+ # split of ffmpeg failures): every ffmpeg subprocess failure is currently one undifferentiated
52
+ # bucket regardless of whether ffmpeg rejected a bad filter argument or died from a full disk,
53
+ # and every "kind": "input" failure covers both a missing file and a bad flag value alike. Adding
54
+ # codes for distinctions the code can't actually make would be guessing, not reporting -- if a
55
+ # future call site can genuinely tell capability-missing apart from bad-argument (see doctor()'s
56
+ # available/missing/unknown states, which already model this for detection but aren't wired into
57
+ # any die() call), split ERROR_CODE then, with evidence, not speculatively now.
58
+ ERROR_CODE = {
59
+ "input": "INPUT_INVALID",
60
+ "missing_tool": "DEPENDENCY_MISSING",
61
+ "ffmpeg": "FFMPEG_EXECUTION_FAILED",
62
+ "output": "OUTPUT_INVALID",
63
+ }
64
+
65
+ # None of the four kinds above are retryable in practice: an "input"/"missing_tool" failure is
66
+ # always deterministic (the same bad path or absent binary fails identically every time), and a
67
+ # "ffmpeg"/"output" failure -- while it COULD in principle be caused by a transient environment
68
+ # condition (full disk, OOM) rather than a bad command -- is never distinguishable from a
69
+ # deterministic content-cause failure without exit-code/stderr sniffing this codebase does not do.
70
+ # Reporting retryable=True for a code we can't actually back up would invite an agent into a blind
71
+ # retry loop against a command that will fail the same way every time; false-for-everything is the
72
+ # honest answer until real sniffing exists to justify anything else.
73
+ ERROR_RETRYABLE = False
74
+
75
+
44
76
  def die(msg: str, code: int = 1, kind: str = "input") -> "None":
45
77
  """Exit with a message. Under --json also print a machine-readable failure document
46
78
  (status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged."""
47
79
  sys.stderr.write(f"error: {msg}\n")
48
80
  if STATE.json:
49
- print_json({"status": "failed", "error": {"kind": kind, "message": msg}})
81
+ print_json({
82
+ "status": "failed", "exit_code": code,
83
+ "error": {
84
+ "kind": kind, "message": msg,
85
+ "code": ERROR_CODE.get(kind, "INTERNAL_ERROR"),
86
+ "retryable": ERROR_RETRYABLE,
87
+ },
88
+ "commands": list(STATE.commands),
89
+ })
50
90
  sys.exit(code)
51
91
 
52
92
 
@@ -135,10 +175,13 @@ def apply_common(args: "argparse.Namespace") -> None:
135
175
 
136
176
  def emit(output: Optional[str], **extra: Any) -> None:
137
177
  """Final stdout line: the output path, or a JSON document with --json."""
178
+ meta: Dict[str, Any] = {}
179
+ if output and not STATE.dry_run:
180
+ meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
138
181
  if STATE.json:
139
182
  doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": STATE.dry_run, "commands": list(STATE.commands)}
140
- if output and not STATE.dry_run and os.path.exists(output):
141
- doc["probe"] = probe(output)
183
+ if meta:
184
+ doc["probe"] = meta
142
185
  doc.update(extra)
143
186
  print_json(doc)
144
187
  elif output:
@@ -153,11 +196,59 @@ def _is_ffmpeg(cmd: Sequence[str]) -> bool:
153
196
  return os.path.basename(cmd[0]).startswith("ffmpeg")
154
197
 
155
198
 
199
+ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
200
+ """A failed ffmpeg command can still have opened its output container (muxer header
201
+ written) before erroring out mid-stream -- unlike a failure that happens before ffmpeg ever
202
+ touches the output path (a bad filter argument, a missing input), which never creates the
203
+ file at all. Both are reported the same way (status: failed), but only the first case used
204
+ to leave a stray, usually-0-byte file behind: verify_output()'s cleanup only runs on the
205
+ success path, so a failed run() call never routed through it. Remove whatever ffmpeg managed
206
+ to write so a caller scanning the output directory after a failure never mistakes a partial
207
+ artifact for a real (if unverified) one."""
208
+ output = cmd[-1]
209
+ if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
210
+ return
211
+ try:
212
+ if os.path.exists(output):
213
+ os.remove(output)
214
+ except OSError:
215
+ pass
216
+
217
+
156
218
  def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
219
+ # Partial-output cleanup already ran in the caller (_run_captured/_run_with_progress) for
220
+ # every failed ffmpeg invocation, not just this check=True path -- see _cleanup_partial_output.
157
221
  tail = "\n".join(stderr.strip().splitlines()[-15:])
158
222
  die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=returncode or 1, kind="ffmpeg")
159
223
 
160
224
 
225
+ def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
226
+ """Refuse an ffmpeg command whose output path resolves to the same file as one of its
227
+ inputs. ffmpeg's own "Output same as Input" guard only catches byte-identical path
228
+ strings; a relative/absolute pair, a leading "./", a redundant ".." segment, or a symlink
229
+ all resolve to the same file but pass that check, so "-o ./same.mp4" on an input opened as
230
+ "same.mp4" would otherwise silently let ffmpeg's -y clobber the source mid-encode. Every
231
+ write-side script routes through this one run() choke point rather than each computing its
232
+ own output path defensively, so the guard lives here once instead of at 25+ call sites."""
233
+ output = cmd[-1]
234
+ if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
235
+ return
236
+ try:
237
+ out_real = os.path.realpath(output)
238
+ except OSError:
239
+ return
240
+ for i, a in enumerate(cmd):
241
+ if a == "-i" and i + 1 < len(cmd):
242
+ inp = cmd[i + 1]
243
+ try:
244
+ if os.path.realpath(inp) == out_real:
245
+ die(f"refusing to run: output {output!r} is the same file as input {inp!r} "
246
+ f"(would overwrite it while ffmpeg is still reading it) -- choose a different --output/-o path",
247
+ kind="input")
248
+ except OSError:
249
+ continue
250
+
251
+
161
252
  def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
162
253
  """Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
163
254
 
@@ -167,6 +258,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
167
258
  """
168
259
  is_ffmpeg = _is_ffmpeg(cmd)
169
260
  if is_ffmpeg:
261
+ _check_no_overwrite_input(cmd)
170
262
  STATE.commands.append(_cmdline(cmd))
171
263
  if not quiet:
172
264
  info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
@@ -177,11 +269,34 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
177
269
  return _run_captured(list(cmd), check)
178
270
 
179
271
 
272
+ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
273
+ """Run an ffmpeg command that already maps its video/audio, trying first to also
274
+ stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
275
+ there are none). A source whose subtitle codec cannot be copied into the target container
276
+ (e.g. a container change) makes that first attempt fail; retry the same command without the
277
+ extra maps rather than let a tool that never touched subtitles start hard-failing because of
278
+ them. `cmd` is the full argv *without* the output path. Returns True only when the
279
+ retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped)."""
280
+ if run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False).returncode == 0:
281
+ return False
282
+ run(cmd + [output])
283
+ return True
284
+
285
+
180
286
  def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
181
287
  """Plain run with stdout/stderr captured."""
182
288
  proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
183
- if check and proc.returncode != 0:
184
- _fail(cmd, proc.returncode, proc.stderr)
289
+ if proc.returncode != 0:
290
+ # Cleanup happens for every failed ffmpeg invocation, not just the check=True/_fail()
291
+ # path: a handful of scripts (cut.py, loudness.py, silence.py, sync.py) call run() with
292
+ # check=False so they can compose their own die() message from proc.stderr, but the
293
+ # partial-output risk is identical either way -- and for a script that retries into the
294
+ # same output path after a check=False failure (e.g. color.py's --retag copy-then-
295
+ # reencode fallback), removing the stale partial first is strictly safer than leaving it
296
+ # for -y to overwrite.
297
+ _cleanup_partial_output(cmd)
298
+ if check:
299
+ _fail(cmd, proc.returncode, proc.stderr)
185
300
  return proc
186
301
 
187
302
 
@@ -216,13 +331,15 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
216
331
  _, err = proc.communicate()
217
332
  if last:
218
333
  sys.stderr.write("\r" + " " * len(last) + "\r")
219
- if check and proc.returncode != 0:
220
- _fail(cmd, proc.returncode, err)
334
+ if proc.returncode != 0:
335
+ _cleanup_partial_output(cmd)
336
+ if check:
337
+ _fail(cmd, proc.returncode, err)
221
338
  return subprocess.CompletedProcess(full, proc.returncode, "", err)
222
339
 
223
340
 
224
341
  def shell_quote(s: str) -> str:
225
- if not s or any(ch in s for ch in " \t\"'\;|&<>()[]{}$*?"):
342
+ if not s or any(ch in s for ch in " \t\\\"';|&<>()[]{}$*?"):
226
343
  return "'" + s.replace("'", "'\\''") + "'"
227
344
  return s
228
345
 
@@ -233,14 +350,58 @@ def ffmpeg_base(overwrite: bool = True) -> List[str]:
233
350
  return cmd
234
351
 
235
352
 
236
- def probe(path: str) -> Dict[str, Any]:
237
- """Return a compact, script-friendly description of a media file."""
353
+ MEDIA_EXT = {".mp4", ".mov", ".mkv", ".webm", ".m4v", ".avi", ".ts", ".mts", ".gif", ".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".png", ".jpg", ".jpeg"}
354
+
355
+
356
+ def _output_failed(path: str, why: str) -> "None":
357
+ """An ffmpeg run reported success but the artifact is not usable: say so, and do not leave a
358
+ 0-byte file behind that a later step could mistake for a result."""
359
+ try:
360
+ if os.path.exists(path) and os.path.getsize(path) == 0:
361
+ os.remove(path)
362
+ why += " (empty file removed)"
363
+ except OSError:
364
+ pass
365
+ die(f"output verification failed: {path}: {why}", kind="output")
366
+
367
+
368
+ def verify_output(path: str) -> Dict[str, Any]:
369
+ """The success criterion for every writing tool: the file exists, is not empty and ffprobe
370
+ can read at least one stream from it. Non-media artifacts (srt, edl, html, md) only need to
371
+ exist and be non-empty. Returns the probe (empty dict for non-media)."""
372
+ if not os.path.exists(path):
373
+ _output_failed(path, "not written")
374
+ if os.path.getsize(path) == 0:
375
+ _output_failed(path, "0 bytes")
376
+ if os.path.splitext(path)[1].lower() not in MEDIA_EXT:
377
+ return {}
378
+ meta = probe(path, role="output")
379
+ if not meta.get("video") and not meta.get("audio"):
380
+ _output_failed(path, "no video or audio stream")
381
+ return meta
382
+
383
+
384
+ def probe(path: str, role: str = "input") -> Dict[str, Any]:
385
+ """Return a compact, script-friendly description of a media file.
386
+
387
+ role="output" marks a file this tool just wrote: a read failure is then reported as an
388
+ output-verification failure (kind "output") instead of an input problem."""
238
389
  if not os.path.exists(path):
390
+ if role == "output" and not STATE["dry_run"]:
391
+ _output_failed(path, "not written")
239
392
  if STATE["dry_run"]:
393
+ # width/height/fps are honestly 0/0/0.0 -- "not measured", matching duration/size_bytes
394
+ # below -- because this is a dry run: the file doesn't exist yet, so there is nothing to
395
+ # probe. Earlier this stub used plausible-looking placeholders (1920x1080x30.0) instead,
396
+ # which some tools' dry-run summary line echoed verbatim as if it were a real computed
397
+ # preview (#77). That was reverted once, because a couple of call sites divided by these
398
+ # values for aspect-ratio math and crashed on a real 0 (join.py, fit.py); those call
399
+ # sites are now guarded to treat 0 as "unknown" and fall back sanely instead of dividing
400
+ # by it, so the stub can finally report the honest, unknown value.
240
401
  return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
241
- "video": {"codec": None, "width": 1920, "height": 1080, "fps": 30.0, "pix_fmt": None, "hdr": False,
402
+ "video": {"codec": None, "width": 0, "height": 0, "fps": 0.0, "pix_fmt": None, "hdr": False,
242
403
  "color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
243
- "audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0}
404
+ "audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0, "data_streams": 0}
244
405
  die(f"input not found: {path}")
245
406
  ffprobe = require_tool("ffprobe")
246
407
  proc = run(
@@ -249,6 +410,8 @@ def probe(path: str) -> Dict[str, Any]:
249
410
  check=False,
250
411
  )
251
412
  if proc.returncode != 0:
413
+ if role == "output":
414
+ _output_failed(path, f"ffprobe cannot read it:\n{proc.stderr.strip()}")
252
415
  die(f"ffprobe failed on {path}:\n{proc.stderr.strip()}")
253
416
  raw = json.loads(proc.stdout or "{}")
254
417
  fmt = raw.get("format", {})
@@ -256,6 +419,7 @@ def probe(path: str) -> Dict[str, Any]:
256
419
  video = next((s for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0), None)
257
420
  audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
258
421
  subs = [s for s in streams if s.get("codec_type") == "subtitle"]
422
+ data_stream_count = sum(1 for s in streams if s.get("codec_type") in ("data", "attachment"))
259
423
 
260
424
  duration = _to_float(fmt.get("duration"))
261
425
  if duration is None and video:
@@ -274,6 +438,14 @@ def probe(path: str) -> Dict[str, Any]:
274
438
  "video": None,
275
439
  "audio": None,
276
440
  "subtitle_streams": len(subs),
441
+ "data_streams": data_stream_count,
442
+ # every subtitle stream in file order: index n here is `-map 0:s:n`
443
+ "subtitle_stream_details": [{
444
+ "index": n,
445
+ "codec": s.get("codec_name"),
446
+ "language": (s.get("tags") or {}).get("language"),
447
+ "title": (s.get("tags") or {}).get("title"),
448
+ } for n, s in enumerate(subs)],
277
449
  }
278
450
  if video:
279
451
  r_rate = _fraction(video.get("r_frame_rate"))
@@ -351,12 +523,30 @@ def default_output(input_path: str, suffix: str, ext: Optional[str] = None) -> s
351
523
  return str(p.with_name(f"{p.stem}_{suffix}.{new_ext}"))
352
524
 
353
525
 
354
- def parse_time(value: str) -> float:
355
- """Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250') or SRT '00:01:30,250'."""
526
+ class MissingFpsError(ValueError):
527
+ """parse_time() saw an hh:mm:ss:ff SMPTE timecode but no fps was given to convert it -- distinct
528
+ from a plain ValueError so a caller that falls back to treating unparseable text as a literal
529
+ line (e.g. caption.py's free-text cue format) can still fail loudly on this one, instead of
530
+ silently swallowing a mistyped/missing --fps as an auto-timed line of digits."""
531
+
532
+
533
+ def parse_time(value: str, fps: Optional[float] = None) -> float:
534
+ """Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250'), SRT '00:01:30,250',
535
+ or -- when `fps` is given -- SMPTE non-drop-frame timecode 'hh:mm:ss:ff' ('00:01:30:15')."""
356
536
  v = value.strip().replace(",", ".")
357
537
  if not v:
358
538
  raise ValueError("empty time")
359
539
  parts = v.split(":")
540
+ if len(parts) == 4:
541
+ if fps is None or fps <= 0:
542
+ raise MissingFpsError(f"'{value}' looks like an hh:mm:ss:ff SMPTE timecode, but no fps was given to convert its frame count to seconds")
543
+ h, m, s, f = parts
544
+ if "." in f:
545
+ raise ValueError(f"bad SMPTE timecode: {value}")
546
+ frame, whole_fps = int(f), int(round(fps))
547
+ if not (0 <= frame < whole_fps):
548
+ raise ValueError(f"bad SMPTE timecode '{value}': frame {frame} is out of range for {fps:g} fps (0-{whole_fps - 1})")
549
+ return int(h) * 3600 + int(m) * 60 + int(s) + frame / fps
360
550
  if len(parts) > 3:
361
551
  raise ValueError(f"bad time: {value}")
362
552
  total = 0.0
@@ -375,6 +565,20 @@ def fmt_srt_time(seconds: float) -> str:
375
565
  return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
376
566
 
377
567
 
568
+ def fmt_smpte_time(seconds: float, fps: float) -> str:
569
+ """SMPTE non-drop-frame timecode 'hh:mm:ss:ff' for a real fps (not the fractional NTSC rates
570
+ -- 29.97/59.94 need drop-frame counting to stay wall-clock accurate, which this does not do)."""
571
+ if seconds < 0:
572
+ seconds = 0.0
573
+ whole_fps = int(round(fps))
574
+ total_frames = int(round(seconds * fps))
575
+ frame = total_frames % whole_fps
576
+ secs_total = total_frames // whole_fps
577
+ h, rem = divmod(secs_total, 3600)
578
+ m, s = divmod(rem, 60)
579
+ return f"{h:02d}:{m:02d}:{s:02d}:{frame:02d}"
580
+
581
+
378
582
  def escape_filter_path(path: str) -> str:
379
583
  """Escape a file path for use as a filter option value (subtitles=, ass=, lut3d=file=, fontfile=, fontsdir=).
380
584
 
@@ -394,6 +598,43 @@ def escape_filter_path(path: str) -> str:
394
598
  return p
395
599
 
396
600
 
601
+ def default_font_file(font_name: str) -> Optional[str]:
602
+ """Resolve `font_name` to a concrete on-disk font file, so a caller can tell drawtext
603
+ `fontfile=<path>` instead of `font=<name>`, when possible.
604
+
605
+ On some real Windows ffmpeg builds (winget's gyan.dev 9.x), drawtext's own fontconfig
606
+ resolution crashes with an access violation whenever it has to resolve a font by family name
607
+ -- with or without a valid fonts.conf on FONTCONFIG_FILE. `fontfile=` is the only form
608
+ confirmed not to crash (#100), since it never touches fontconfig at all. `font_name` itself is
609
+ ignored on Windows for that reason: a fixed, near-universally-present system font is used
610
+ instead of trying to resolve the requested family (which would crash the same way).
611
+
612
+ On Linux/macOS this is best-effort and uses the real requested family: `fc-match` reports the
613
+ same file fontconfig would resolve `font_name` to anyway, so a caller gets the identical font,
614
+ just already resolved to a path -- fontfile= skips a redundant fontconfig lookup and equally
615
+ sidesteps the same class of crash if it exists on some build there too, but the fallback below
616
+ (returning None) is exercised routinely there, not just on failure.
617
+
618
+ Returns None when nothing could be resolved (fc-match missing/unavailable, or no well-known
619
+ Windows font file present); the caller falls back to font=<font_name>, the prior behaviour.
620
+ """
621
+ if platform.system() == "Windows":
622
+ windir = os.environ.get("WINDIR", "C:\\Windows")
623
+ candidate = Path(windir) / "Fonts" / "arial.ttf"
624
+ return str(candidate) if candidate.exists() else None
625
+ exe = shutil.which("fc-match")
626
+ if not exe:
627
+ return None
628
+ try:
629
+ proc = subprocess.run([exe, "--format=%{file}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5)
630
+ except (subprocess.TimeoutExpired, OSError):
631
+ return None
632
+ if proc.returncode != 0:
633
+ return None
634
+ path = proc.stdout.splitlines()[0].strip() if proc.stdout.strip() else ""
635
+ return path if path and os.path.exists(path) else None
636
+
637
+
397
638
  def escape_drawtext(text: str) -> str:
398
639
  return (
399
640
  text.replace("\\", "\\\\")