ffmpeg-skill 2.10.11 → 2.12.0

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.
@@ -16,8 +16,8 @@ from pathlib import Path
16
16
  from typing import Any, Dict, List, Optional, Sequence, Tuple
17
17
  from _common.color import (bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, drawn_stream_format,
18
18
  is_neutral_color, rgb_island, rgb_to_bt709_filter, _sdr_bt709)
19
- from _common.emit import die
20
- from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_version, hw_platform_reason
19
+ from _common.emit import die, record_fallback, refuse, warn
20
+ from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_list_error, ffmpeg_version, hw_platform_reason
21
21
 
22
22
 
23
23
  def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken: float = 0.0,
@@ -321,7 +321,11 @@ def _vt_args(codec: str, crf: int, meta: Optional[Dict[str, Any]], keep_bt709: b
321
321
  reason = None if name else f"no VideoToolbox encoder for {codec}"
322
322
  reason = reason or hw_platform_reason()
323
323
  if not reason and name not in ffmpeg_encoders():
324
- reason = f"this ffmpeg build has no {name}"
324
+ unread = ffmpeg_list_error("encoders")
325
+ # an encoder list that could not be read says nothing about the build: name the read
326
+ # failure, not a missing encoder (the read itself is a ffmpeg_list_unreadable warning)
327
+ reason = (f"{name} could not be confirmed (this ffmpeg build's encoder list could not be read: {unread})" if unread
328
+ else f"this ffmpeg build has no {name}")
325
329
  if reason:
326
330
  note = f"{reason}; encoded on the CPU"
327
331
  if note not in STATE.hw_notes:
@@ -471,7 +475,7 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
471
475
  if codec == "hevc":
472
476
  if hdr:
473
477
  x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
474
- return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
478
+ return ["-c:v", "libx265", "-preset", preset, "-crf", str(_clamped_crf("libx265", crf + 2, 51, crf)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
475
479
  "-x265-params", x265] + hdr_tags + ["-movflags", "+faststart"]
476
480
  params, extra = _sdr_bt709("libx265") if keep_bt709 else ("", [])
477
481
  return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-tag:v", "hvc1",
@@ -479,27 +483,56 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
479
483
  if codec == "av1":
480
484
  pix = "yuv420p10le" if hdr else "yuv420p"
481
485
  if "libsvtav1" in ffmpeg_encoders():
482
- args = ["-c:v", "libsvtav1", "-preset", str(SVT_PRESET.get(preset, 6)), "-crf", str(min(63, crf)), "-pix_fmt", pix]
486
+ args = ["-c:v", "libsvtav1", "-preset", str(SVT_PRESET.get(preset, 6)), "-crf", str(_clamped_crf("libsvtav1", crf, 63, crf)), "-pix_fmt", pix]
483
487
  if hdr:
484
488
  args += hdr_tags
485
489
  elif keep_bt709:
486
490
  params, extra = _sdr_bt709("libsvtav1")
487
491
  args += (["-svtav1-params", params] if params else []) + extra
488
492
  elif "libaom-av1" in ffmpeg_encoders():
489
- args = ["-c:v", "libaom-av1", "-crf", str(min(63, crf)), "-b:v", "0", "-cpu-used", "6", "-row-mt", "1", "-pix_fmt", pix]
493
+ # recorded: libaom is the slower AV1 encoder with its own CRF scale and speed setting,
494
+ # so the same --quality does not give the file SVT-AV1 would have
495
+ record_fallback("--codec av1: this ffmpeg build has no libsvtav1 (SVT-AV1), so libaom-av1 encodes instead "
496
+ "(slower; -cpu-used 6, its own CRF scale)", "av1_encoder_libaom", wanted="libsvtav1", used="libaom-av1")
497
+ args = ["-c:v", "libaom-av1", "-crf", str(_clamped_crf("libaom-av1", crf, 63, crf)), "-b:v", "0", "-cpu-used", "6", "-row-mt", "1", "-pix_fmt", pix]
490
498
  args += hdr_tags if hdr else (_sdr_bt709("libaom-av1")[1] if keep_bt709 else [])
491
499
  else:
492
- die("--codec av1 needs an AV1 encoder (libsvtav1 or libaom-av1) and this ffmpeg build has neither", kind="missing_tool",
493
- hint="install an ffmpeg built with SVT-AV1 (most distribution builds are), or use --codec hevc")
500
+ _refuse_missing_encoder("av1", ["libsvtav1", "libaom-av1"], "an AV1 encoder (libsvtav1 or libaom-av1)",
501
+ "install an ffmpeg built with SVT-AV1 (most distribution builds are), or use --codec hevc")
494
502
  return args + ["-movflags", "+faststart"]
495
503
  if codec == "prores":
496
504
  if "prores_ks" not in ffmpeg_encoders():
497
- die("--codec prores needs the prores_ks encoder and this ffmpeg build lacks it", kind="missing_tool")
505
+ _refuse_missing_encoder("prores", ["prores_ks"], "the prores_ks encoder", None)
498
506
  return ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le"] + (hdr_tags if hdr else [])
499
507
  die(f"unknown --codec {codec!r} (one of {', '.join(CODECS)})")
500
508
  return []
501
509
 
502
510
 
511
+ def _clamped_crf(encoder: str, value: int, top: int, requested: int) -> int:
512
+ """`value` capped at the encoder's maximum CRF, the cap recorded (`crf_clamped`) when it
513
+ bites: the HEVC HDR line asks x265 for CRF+2, so --quality 50 or 51 there is encoded at 51."""
514
+ if value <= top:
515
+ return value
516
+ warn(f"{encoder}: CRF {value} (from {requested}) is above the encoder's maximum {top}; encoded at CRF {top}",
517
+ "crf_clamped", encoder=encoder, requested=requested, asked=value, used=top)
518
+ return top
519
+
520
+
521
+ def _refuse_missing_encoder(codec: str, encoders: List[str], what: str, hint: Optional[str]) -> None:
522
+ """--codec av1/prores with no encoder for it -- or with an encoder list that could not be read,
523
+ which proves nothing about the build and is refused under its own reason rather than as a
524
+ missing encoder (ffmpeg_list_error)."""
525
+ unread = ffmpeg_list_error("encoders")
526
+ if unread:
527
+ refuse("ffmpeg", "encoder_list_unreadable",
528
+ f"--codec {codec} needs {what}, and whether this ffmpeg build has one is unknown: its encoder list "
529
+ f"could not be read ({unread})",
530
+ hint="check that ffmpeg runs (python3 scripts/_contract.py doctor), then retry",
531
+ codec=codec, encoders=encoders, error=unread)
532
+ refuse("missing_tool", "encoder_missing", f"--codec {codec} needs {what} and this ffmpeg build has "
533
+ + ("neither" if len(encoders) > 1 else "none"), hint=hint, codec=codec, encoders=encoders)
534
+
535
+
503
536
  def _x264_raw(crf: int, preset: str, keep_bt709: bool = True) -> List[str]:
504
537
  args = ["-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
505
538
  if keep_bt709:
@@ -1159,17 +1192,22 @@ def normalise_filler_token(word: str) -> str:
1159
1192
 
1160
1193
 
1161
1194
  def filler_spans(words, wordlist, *, pad: float = FILLER_PAD, min_gap: float = FILLER_MIN_GAP,
1162
- max_word: float = FILLER_MAX_WORD) -> "List[Dict[str, Any]]":
1195
+ max_word: float = FILLER_MAX_WORD, stats: "Optional[Dict[str, int]]" = None) -> "List[Dict[str, Any]]":
1163
1196
  """Time spans to remove, from measured word timings. Pure: no subprocess, no I/O.
1164
1197
 
1165
1198
  `words` is [{"word", "start", "end"}] as whisper emits. A word is removed only when its
1166
1199
  normalised form is in `wordlist` AND it carries a real start < end pair AND its length is
1167
1200
  <= max_word. Adjacent spans closer than min_gap merge. Returns [{"start", "end", "word"}]
1168
1201
  sorted and non-overlapping.
1202
+
1203
+ `stats`, when given, receives the listed words that were kept anyway, so the caller can say
1204
+ so: `matched_untimed` (no usable start < end: nothing to cut on) and `matched_too_long`
1205
+ (longer than max_word: a held vowel someone meant).
1169
1206
  """
1170
1207
  listed = {normalise_filler_token(w) for w in (wordlist or set())}
1171
1208
  listed.discard("")
1172
1209
  hits = []
1210
+ untimed = too_long = 0
1173
1211
  for entry in words or []:
1174
1212
  if not isinstance(entry, dict):
1175
1213
  continue
@@ -1179,8 +1217,13 @@ def filler_spans(words, wordlist, *, pad: float = FILLER_PAD, min_gap: float = F
1179
1217
  try:
1180
1218
  start, end = float(entry["start"]), float(entry["end"])
1181
1219
  except (KeyError, TypeError, ValueError):
1220
+ untimed += 1
1182
1221
  continue # no measured timing: nothing to cut
1183
- if not (end > start) or (end - start) > max_word:
1222
+ if not (end > start):
1223
+ untimed += 1
1224
+ continue
1225
+ if (end - start) > max_word:
1226
+ too_long += 1
1184
1227
  continue
1185
1228
  hits.append({"start": max(0.0, start - pad), "end": end + pad, "word": token})
1186
1229
  hits.sort(key=lambda h: (h["start"], h["end"]))
@@ -1194,6 +1237,9 @@ def filler_spans(words, wordlist, *, pad: float = FILLER_PAD, min_gap: float = F
1194
1237
  for m in merged:
1195
1238
  m["start"] = round(m["start"], 4)
1196
1239
  m["end"] = round(m["end"], 4)
1240
+ if stats is not None:
1241
+ stats["matched_untimed"] = untimed
1242
+ stats["matched_too_long"] = too_long
1197
1243
  return merged
1198
1244
 
1199
1245
 
@@ -13,7 +13,8 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
13
13
  from _common.runner import Context, ERROR_CODE, ERROR_RETRYABLE, STATE
14
14
 
15
15
 
16
- def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]" = None, **extra: Any) -> "None":
16
+ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]" = None,
17
+ error_extra: "Optional[Dict[str, Any]]" = None, **extra: Any) -> "None":
17
18
  """Exit with a message. Under --json also print a machine-readable failure document
18
19
  (status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged.
19
20
 
@@ -21,7 +22,10 @@ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]
21
22
  platform rows, render.py's check stage, batch.py's per-item results, verify.py's steps) keeps
22
23
  reporting that detail while the top-level status says failed. Before 1.4.3 those four printed
23
24
  `status: "completed"` next to a non-zero exit code, so a caller keying on the status alone
24
- read a failed delivery as a success."""
25
+ read a failed delivery as a success.
26
+
27
+ `error_extra` goes inside `error` (refuse() passes `reason` and `details` there); the
28
+ collector's records (warn() and friends) are merged into the document as emit() merges them."""
25
29
  hint = extra.pop("hint", None)
26
30
  ctx = ctx or STATE # 1.10: the optional per-request Context (2.0 makes it required); STATE is the default instance
27
31
  _set_current_ctx(ctx) # the atexit hook has no argument: it reads the ctx emit()/die() last used
@@ -48,11 +52,107 @@ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]
48
52
  }
49
53
  if hint:
50
54
  doc["error"]["hint"] = hint
55
+ for key, value in (error_extra or {}).items():
56
+ if value is not None:
57
+ doc["error"][key] = value
51
58
  doc.update(extra)
59
+ _merge_collector(doc, ctx)
52
60
  print_json(doc)
53
61
  sys.exit(code)
54
62
 
55
63
 
64
+ def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code: int = 1,
65
+ ctx: "Optional[Context]" = None, extra: "Optional[Dict[str, Any]]" = None, **details: Any) -> "None":
66
+ """die() for a refusal a caller has to act on: `error.reason` is a stable snake_case code for
67
+ *why* (output_exists, output_is_input, output_locked, plan_inputs_changed, ...), and
68
+ `error.details` holds the facts the message is written from (paths, expected and actual
69
+ values), so a caller never parses the prose. `extra` adds top-level keys as die()'s do."""
70
+ die(msg, code=code, kind=kind, ctx=ctx, hint=hint, error_extra={"reason": reason, "details": json_safe(details)}, **(extra or {}))
71
+
72
+
73
+ def _record(bucket: str, msg: str, code: str, ctx: "Optional[Context]", data: Dict[str, Any], prefix: str) -> Dict[str, Any]:
74
+ ctx = ctx or STATE
75
+ entry: Dict[str, Any] = {"code": code, "message": msg}
76
+ entry.update(data)
77
+ # once per run: a decision taken for every encode of a job (an AV1 encoder fallback, a CRF
78
+ # clamp) is one record, though its stderr line is printed each time it is taken
79
+ if entry not in getattr(ctx, bucket):
80
+ getattr(ctx, bucket).append(entry)
81
+ sys.stderr.write(f"{prefix}{msg}\n")
82
+ return entry
83
+
84
+
85
+ def warn(msg: str, code: str, *, ctx: "Optional[Context]" = None, **data: Any) -> Dict[str, Any]:
86
+ """Say something went other than asked, on stderr AND in the document
87
+ (`diagnostics.warnings`: {code, message, ...data}). Use it where a bare info("warning: ...")
88
+ would leave the caller of --json nothing to read (a source test refuses that spelling).
89
+ Every code is listed in the contract (`json_output.diagnostic_codes`)."""
90
+ return _record("warnings", msg, code, ctx, data, "warning: ")
91
+
92
+
93
+ def record_fallback(msg: str, code: str, *, ctx: "Optional[Context]" = None, **data: Any) -> Dict[str, Any]:
94
+ """The run took another route than the asked one and went on (`diagnostics.fallbacks`)."""
95
+ return _record("fallbacks", msg, code, ctx, data, "")
96
+
97
+
98
+ def record_skip(msg: str, code: str, *, ctx: "Optional[Context]" = None, **data: Any) -> Dict[str, Any]:
99
+ """A step that was asked for, or would normally run, did not (`diagnostics.skipped`)."""
100
+ return _record("skipped", msg, code, ctx, data, "")
101
+
102
+
103
+ def note(msg: str, ctx: "Optional[Context]" = None) -> None:
104
+ """A plain `notes` line that also goes to stderr."""
105
+ ctx = ctx or STATE
106
+ if msg not in ctx.notes:
107
+ ctx.notes.append(msg)
108
+ sys.stderr.write(f"{msg}\n")
109
+
110
+
111
+ def _merge_collector(doc: Dict[str, Any], ctx: "Context") -> None:
112
+ """The collector into a result or failure document: ctx.notes join `notes` (once each), and
113
+ the three record lists become `diagnostics`, present only when one of them is not empty
114
+ (`warnings` and `skipped` are not top-level keys: check.py's `warnings` is a count and join.py's
115
+ `skipped` its left-out inputs)."""
116
+ extra_notes = [n for n in ctx.notes if n not in (doc.get("notes") or [])]
117
+ if extra_notes:
118
+ doc["notes"] = list(doc.get("notes") or []) + extra_notes
119
+ diag = {k: list(getattr(ctx, k)) for k in ("warnings", "fallbacks", "skipped") if getattr(ctx, k)}
120
+ if diag:
121
+ merged = dict(doc.get("diagnostics") or {})
122
+ for k, v in diag.items():
123
+ merged[k] = list(merged.get(k) or []) + [e for e in v if e not in (merged.get(k) or [])]
124
+ doc["diagnostics"] = merged
125
+
126
+
127
+ def audit(event: str, details: Dict[str, Any], *, default_path: Optional[str] = None, plan_sha256: Optional[str] = None,
128
+ ctx: "Optional[Context]" = None) -> Dict[str, Any]:
129
+ """Append one JSON line to the audit log: {ts, tool, event, pid, plan_sha256?, details}.
130
+ The log is render.py's --audit-log FILE when given, else the event's own `default_path` (a
131
+ plan's `<plan>.audit.jsonl`, a lock's hidden sibling); neither = nothing to write. Never under
132
+ --dry-run (a dry run writes nothing). A write that fails is a recorded warning, never a
133
+ failure of the run. Returns what happened, for the caller's `details.audit_log`."""
134
+ import datetime
135
+ ctx = ctx or STATE
136
+ path = ctx.audit_log or default_path
137
+ if not path:
138
+ return {"path": None, "written": False, "reason": "no_audit_log"}
139
+ if ctx.dry_run:
140
+ return {"path": path, "written": False, "reason": "dry_run"}
141
+ rec: Dict[str, Any] = {"ts": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
142
+ "tool": os.path.splitext(os.path.basename(sys.argv[0] or "?"))[0], "event": event, "pid": os.getpid()}
143
+ if plan_sha256:
144
+ rec["plan_sha256"] = plan_sha256
145
+ rec["details"] = details
146
+ try:
147
+ with open(path, "a", encoding="utf-8") as f:
148
+ f.write(json.dumps(json_safe(rec), ensure_ascii=False, sort_keys=True) + "\n")
149
+ except OSError as exc:
150
+ warn(f"could not append the {event} record to the audit log {path} ({exc}); the run is unaffected",
151
+ "audit_log_unwritable", ctx=ctx, path=path, event=event, error=str(exc))
152
+ return {"path": path, "written": False, "reason": "unwritable", "error": str(exc)}
153
+ return {"path": path, "written": True}
154
+
155
+
56
156
  def info(msg: str, ctx: "Optional[Context]" = None) -> None:
57
157
  # under --dry-run nothing is written; do not let scripts claim otherwise
58
158
  ctx = ctx or STATE
@@ -195,6 +295,62 @@ def absorb_stage_hw(stage: str, doc: Any, ctx: "Optional[Context]" = None) -> No
195
295
  "used": hw.get("used"), "fallback": bool(hw.get("fallback")), "quality_note": quality})
196
296
 
197
297
 
298
+ def _stage_hw_notes(doc: Dict[str, Any]) -> List[str]:
299
+ """The notes lines a stage's document carries only because of its GPU encode (its `hw.notes`
300
+ and the VideoToolbox quality line): absorb_stage_hw() already reports those, once."""
301
+ hw = doc.get("hw") if isinstance(doc.get("hw"), dict) else {}
302
+ out = [str(n) for n in hw.get("notes") or []]
303
+ enc, line = _video_encoders([str(c) for c in (doc.get("commands") or [])])
304
+ for no_cpu in (False, True):
305
+ q = hw_quality_note(enc, line, no_cpu)
306
+ if q:
307
+ out.append(q)
308
+ return out
309
+
310
+
311
+ # A stage's own artifact checks: a stage that completed passed them (verify_output() fails the
312
+ # stage otherwise), and its artifact is an intermediate, so carrying them would only lengthen the list.
313
+ _STAGE_ARTIFACT_STEPS = ("probe", "exists")
314
+
315
+
316
+ def absorb_stage(stage: str, doc: Any, ctx: "Optional[Context]" = None) -> Dict[str, Any]:
317
+ """A child tool's --json document into this result: its `hw` facts (absorb_stage_hw), its
318
+ `diagnostics` records and its `notes` (each tagged with the stage it came from), and -- for a
319
+ stage that completed in a real run -- its verification steps beyond the artifact probe, tagged
320
+ with `stage`, which emit() adds to this result's own: the parent's `verified` is then the
321
+ conjunction of every stage's (a stage that said `verified: false` without a failing step adds
322
+ one `{step: verified, stage, ok: false}`). Returns the fields a re-raise of the stage's failure
323
+ carries in its own `error`: the child's `reason` and `details`."""
324
+ ctx = ctx or STATE
325
+ absorb_stage_hw(stage, doc, ctx)
326
+ if not isinstance(doc, dict):
327
+ return {}
328
+ for bucket in ("warnings", "fallbacks", "skipped"):
329
+ for entry in (doc.get("diagnostics") or {}).get(bucket) or []:
330
+ if isinstance(entry, dict):
331
+ tagged = dict(entry, stage=entry.get("stage") or stage)
332
+ if tagged not in getattr(ctx, bucket):
333
+ getattr(ctx, bucket).append(tagged)
334
+ hw_only = _stage_hw_notes(doc)
335
+ for n in doc.get("notes") or []:
336
+ if not isinstance(n, str) or n in hw_only or n.endswith(ENV_HW_NOTE) or any(n.endswith(": " + h) for h in hw_only):
337
+ continue
338
+ # a nested stage's note (render.py inside a pack) already names its stage
339
+ tagged_note = n if n.startswith(f"{stage}: ") else f"{stage}: {n}"
340
+ if tagged_note not in ctx.notes:
341
+ ctx.notes.append(tagged_note)
342
+ if doc.get("status") == "completed" and not doc.get("dry_run") and not ctx.dry_run:
343
+ carried = [dict(s, stage=s.get("stage") or stage) for s in doc.get("verification") or []
344
+ if isinstance(s, dict) and (s.get("step") not in _STAGE_ARTIFACT_STEPS or not s.get("ok"))]
345
+ if doc.get("verified") is False and all(s.get("ok") for s in carried):
346
+ carried.append({"step": "verified", "stage": stage, "ok": False})
347
+ for s in carried:
348
+ if s not in ctx.stage_verification:
349
+ ctx.stage_verification.append(s)
350
+ err = doc.get("error") if isinstance(doc.get("error"), dict) else {}
351
+ return {k: err[k] for k in ("reason", "details") if err.get(k) is not None}
352
+
353
+
198
354
  def steps_encoder_report(steps: Sequence["Tuple[str, Any]"]) -> Dict[str, Any]:
199
355
  """batch.py's per-item `encoder`, `hw` and `notes`, from the --json documents of the steps
200
356
  (child processes) that made the item: the last encoder a step ran (a later stream copy keeps
@@ -237,6 +393,8 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
237
393
  # verified nothing. Spec failures the tool cannot fix on its own (export's loudness gap)
238
394
  # keep status completed and say verified: false, so a caller keys on one field.
239
395
  steps: List[Dict[str, Any]] = ([{"step": "probe", "ok": True}] if meta else []) + list(extra.pop("verification", None) or [])
396
+ # a tool that ran others (render.py) verified what its stages verified too (absorb_stage())
397
+ steps += [s for s in ctx.stage_verification if s not in steps]
240
398
  if output and not ctx.dry_run and os.path.splitext(output)[1].lower() not in MEDIA_EXT:
241
399
  steps.insert(0, {"step": "exists", "ok": True})
242
400
  doc["verified"] = not ctx.dry_run and bool(steps) and all(s.get("ok") for s in steps)
@@ -246,6 +404,7 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
246
404
  hw_notes = [n for n in _hw_notes_for(ctx) if n not in (doc.get("notes") or [])]
247
405
  if hw_notes:
248
406
  doc["notes"] = list(doc.get("notes") or []) + hw_notes
407
+ _merge_collector(doc, ctx)
249
408
  if ctx.plan:
250
409
  doc["plan"] = write_plan(ctx.plan, output, extra, ctx=ctx)
251
410
  print_json(_brief(doc, meta) if ctx.json_brief else doc)
@@ -345,6 +504,41 @@ def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = (), ctx: "Option
345
504
  return seen
346
505
 
347
506
 
507
+ def _plan_unbound_inputs(commands: Sequence[str], ctx: "Optional[Context]" = None) -> List[str]:
508
+ """The inputs _plan_inputs() could not fingerprint because they did not exist when the plan
509
+ was made: a planned command's `-i` file (or a side input registered through
510
+ escape_filter_path) that is not on disk and is not written by an earlier planned command (the
511
+ tool's own intermediates are bound by the command that makes them). render.py reports them on
512
+ execution: nothing checks that their content is what the plan was made for."""
513
+ import shlex
514
+ produced: List[str] = []
515
+ out: List[str] = []
516
+
517
+ def consider(p: str) -> None:
518
+ if (not p or p == "-" or p.startswith("pipe:") or os.path.exists(p)
519
+ or os.path.abspath(p) in produced or os.path.abspath(p) in out):
520
+ return
521
+ out.append(os.path.abspath(p))
522
+ for p in (ctx or STATE).plan_inputs:
523
+ consider(p)
524
+ for line in commands:
525
+ try:
526
+ toks = shlex.split(line.split("] ", 1)[1] if line.startswith("[dry-run] ") else line)
527
+ except ValueError:
528
+ continue
529
+ fmt = None # an input's -f (lavfi's "inputs" are filter graphs, not files)
530
+ for i, tok in enumerate(toks[:-1]):
531
+ if tok == "-f":
532
+ fmt = toks[i + 1]
533
+ elif tok == "-i":
534
+ if fmt != "lavfi":
535
+ consider(toks[i + 1])
536
+ fmt = None
537
+ if toks:
538
+ produced.append(os.path.abspath(toks[-1]))
539
+ return out
540
+
541
+
348
542
  def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Optional[Context]" = None) -> str:
349
543
  """The dry run as an artifact: what will run, on which exact inputs, producing what, checked
350
544
  how. `render.py PLAN` executes it after re-fingerprinting the inputs (issue #189 C).
@@ -382,6 +576,8 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Op
382
576
  "argv": cleaned,
383
577
  "cwd": os.getcwd(),
384
578
  "inputs": [fingerprint(p) for p in _plan_inputs(ctx.commands, cleaned, ctx)],
579
+ # inputs that did not exist yet, so carry no fingerprint (render.py reports them on execution)
580
+ "unbound_inputs": _plan_unbound_inputs(ctx.commands, ctx),
385
581
  "commands": list(ctx.commands),
386
582
  "output": os.path.abspath(output) if output else None,
387
583
  "verify": verify,
@@ -11,20 +11,21 @@ import shutil
11
11
  import subprocess
12
12
  from pathlib import Path
13
13
  from typing import Dict, List, Optional, Sequence, Tuple
14
- from _common.emit import die, info
14
+ from _common.emit import die, info, record_fallback, warn
15
15
  from _common.emoji import _is_emoji_char
16
16
 
17
17
 
18
18
  def default_font_file(font_name: str) -> Optional[str]:
19
19
  """Resolve `font_name` to a concrete on-disk font file, so a caller can tell drawtext
20
- `fontfile=<path>` instead of `font=<name>`, when possible.
20
+ `fontfile=<path>` instead of `font=<name>`, when possible (resolve_font()'s `file`).
21
21
 
22
22
  On some real Windows ffmpeg builds (winget's gyan.dev 9.x), drawtext's own fontconfig
23
23
  resolution crashes with an access violation whenever it has to resolve a font by family name
24
24
  -- with or without a valid fonts.conf on FONTCONFIG_FILE. `fontfile=` is the only form
25
- confirmed not to crash (#100), since it never touches fontconfig at all. `font_name` itself is
26
- ignored on Windows for that reason: a fixed, near-universally-present system font is used
27
- instead of trying to resolve the requested family (which would crash the same way).
25
+ confirmed not to crash (#100), since it never touches fontconfig at all. On Windows the
26
+ requested family is looked for by file name in the Fonts folder; a family that is not there
27
+ falls back to a CJK system font (for a CJK-looking request) or Arial, a near-universally-present
28
+ system font, instead of trying to resolve the requested family (which would crash the same way).
28
29
 
29
30
  On Linux/macOS this is best-effort and uses the real requested family: `fc-match` reports the
30
31
  same file fontconfig would resolve `font_name` to anyway, so a caller gets the identical font,
@@ -35,6 +36,23 @@ def default_font_file(font_name: str) -> Optional[str]:
35
36
  Returns None when nothing could be resolved (fc-match missing/unavailable, or no well-known
36
37
  Windows font file present); the caller falls back to font=<font_name>, the prior behaviour.
37
38
  """
39
+ return resolve_font(font_name)["file"]
40
+
41
+
42
+ # generic fontconfig aliases: resolving one to a concrete family is what they are for, not a substitution
43
+ _GENERIC_FAMILIES = {"sans", "sansserif", "serif", "mono", "monospace", "cursive", "fantasy", "systemui", "emoji"}
44
+
45
+
46
+ def _family_key(name: "Optional[str]") -> str:
47
+ return re.sub(r"[^a-z0-9]", "", (name or "").lower())
48
+
49
+
50
+ def resolve_font(font_name: str) -> "Dict[str, object]":
51
+ """default_font_file() with what it found: {file, family, substituted, via}. `substituted` is
52
+ True when the file is not the requested family -- a Windows machine without it (a CJK system
53
+ font or Arial instead), or fc-match answering with another family (fontconfig always answers
54
+ something). `via` is windows_fonts or fc-match; file and family are None when nothing resolved."""
55
+ none: "Dict[str, object]" = {"file": None, "family": None, "substituted": False, "via": None}
38
56
  if platform.system() == "Windows":
39
57
  windir = os.environ.get("WINDIR", "C:\\Windows")
40
58
  fonts = Path(windir) / "Fonts"
@@ -42,33 +60,78 @@ def default_font_file(font_name: str) -> Optional[str]:
42
60
  # removed (Noto Sans CJK JP -> NotoSansCJKjp-Regular.otf, Meiryo -> meiryo.ttc), then the
43
61
  # common CJK system fonts when the request looks CJK (so Japanese text does not render as
44
62
  # boxes in Arial), and Arial only as the last resort.
45
- wanted = re.sub(r"[^a-z0-9]", "", (font_name or "").lower())
63
+ wanted = _family_key(font_name)
46
64
  try:
47
65
  files = sorted(fonts.iterdir()) if fonts.is_dir() else []
48
66
  except OSError:
49
67
  files = []
50
68
  if wanted:
51
69
  for f in files:
52
- stem = re.sub(r"[^a-z0-9]", "", f.stem.lower())
70
+ stem = _family_key(f.stem)
53
71
  if f.suffix.lower() in (".ttf", ".otf", ".ttc") and stem.startswith(wanted):
54
- return str(f)
72
+ return {"file": str(f), "family": font_name, "substituted": False, "via": "windows_fonts"}
55
73
  if any(k in wanted for k in ("cjk", "gothic", "mincho", "meiryo", "yugoth", "msgothic", "malgun", "simhei", "simsun", "jp", "kr", "sc", "tc")):
56
74
  for name in ("NotoSansCJKjp-Regular.otf", "NotoSansCJK-Regular.ttc", "meiryo.ttc", "YuGothM.ttc", "msgothic.ttc", "malgun.ttf", "msyh.ttc"):
57
75
  if (fonts / name).exists():
58
- return str(fonts / name)
76
+ return {"file": str(fonts / name), "family": Path(name).stem, "substituted": bool(wanted), "via": "windows_fonts"}
59
77
  candidate = fonts / "arial.ttf"
60
- return str(candidate) if candidate.exists() else None
78
+ if candidate.exists():
79
+ return {"file": str(candidate), "family": "Arial",
80
+ "substituted": bool(wanted) and wanted != "arial" and wanted not in _GENERIC_FAMILIES, "via": "windows_fonts"}
81
+ return none
61
82
  exe = shutil.which("fc-match")
62
83
  if not exe:
63
- return None
84
+ return none
64
85
  try:
65
- proc = subprocess.run([exe, "--format=%{file}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=5)
86
+ proc = subprocess.run([exe, "--format=%{file}\n%{family}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=5)
66
87
  except (subprocess.TimeoutExpired, OSError):
67
- return None
88
+ return none
68
89
  if proc.returncode != 0:
69
- return None
70
- path = proc.stdout.splitlines()[0].strip() if proc.stdout.strip() else ""
71
- return path if path and os.path.exists(path) else None
90
+ return none
91
+ lines = proc.stdout.splitlines()
92
+ path = lines[0].strip() if lines else ""
93
+ if not (path and os.path.exists(path)):
94
+ return none
95
+ # fontconfig lists a face's family names comma-separated (localised names included); the
96
+ # request is met when one of them is the requested family, or the request names a style of it
97
+ families = [f.strip() for f in (lines[1] if len(lines) > 1 else "").split(",") if f.strip()]
98
+ wanted = _family_key(font_name)
99
+ met = (not wanted or wanted in _GENERIC_FAMILIES or not families
100
+ or any(_family_key(f) and (wanted.startswith(_family_key(f)) or _family_key(f).startswith(wanted)) for f in families))
101
+ return {"file": path, "family": families[0] if families else None, "substituted": not met, "via": "fc-match"}
102
+
103
+
104
+ def font_choice(asked: "Optional[str]", *, font_file: "Optional[str]" = None, script_file: "Optional[str]" = None,
105
+ script_family: "Optional[str]" = None, renderer: str = "drawtext") -> "Dict[str, object]":
106
+ """The font a text-drawing tool draws with, as its result's `font_used`: {asked, file, family,
107
+ how, substituted}. `how`: font_file (the caller's own file), script (a font picked to cover the
108
+ text's script, 1.12), resolved (the asked family resolved to a file: fc-match, or the Windows
109
+ Fonts folder), by_name (no file resolved; ffmpeg/libass look the family up themselves, so the
110
+ file is not known here). For libass (`renderer="libass"`) a resolved file is the one fontconfig
111
+ picks for the family, which is what libass loads through fontconfig; on Windows libass is
112
+ given the family by name, so nothing is resolved for it."""
113
+ if font_file:
114
+ return {"asked": font_file, "file": font_file, "family": None, "how": "font_file", "substituted": False}
115
+ if script_file:
116
+ return {"asked": asked, "file": script_file, "family": script_family, "how": "script", "substituted": False}
117
+ if renderer == "libass" and platform.system() == "Windows":
118
+ return {"asked": asked, "file": None, "family": asked, "how": "by_name", "substituted": False}
119
+ found = resolve_font(asked or "")
120
+ if not found["file"]:
121
+ return {"asked": asked, "file": None, "family": asked, "how": "by_name", "substituted": False}
122
+ return {"asked": asked, "file": found["file"], "family": found["family"], "how": "resolved",
123
+ "substituted": bool(found["substituted"]), "via": found["via"]}
124
+
125
+
126
+ def record_font_choice(choice: "Dict[str, object]") -> "Dict[str, object]":
127
+ """Record a substituted font (`diagnostics.fallbacks`, `font_substituted`) and return `choice`
128
+ for the result's `font_used`: fontconfig answers every request with some font, and drawtext and
129
+ libass draw with it and exit 0, so without this the only trace was the picture."""
130
+ if choice.get("substituted"):
131
+ record_fallback(f"font: '{choice['asked']}' is not installed here; drawing with {choice.get('family') or '?'} "
132
+ f"({choice['file']}) instead", "font_substituted",
133
+ asked=choice["asked"], file=choice["file"], family=choice.get("family"), via=choice.get("via"))
134
+ return choice
72
135
 
73
136
 
74
137
  # --------------------------------------------------------------------------- script detection
@@ -399,14 +462,14 @@ def script_font_for_text(text: str, *, lang: "Optional[str]" = None, font: "Opti
399
462
 
400
463
  Returns (script, None, None) when the caller's own choice stands: Latin text, an explicit
401
464
  --font-file, an explicit --font (which is kept even when fontconfig says it does not cover
402
- the script -- with one info line saying so, because overriding a user's stated font silently
403
- is worse than a warning), or a --fonts-dir that does carry the script. Otherwise the resolved
465
+ the script -- with one recorded warning saying so, `font_lacks_script`, because overriding a
466
+ user's stated font silently is worse than a warning), or a --fonts-dir that does carry the script. Otherwise the resolved
404
467
  file is returned with ONE info line naming it.
405
468
 
406
469
  A script fontconfig says nothing covers is a failed job (tofu is not a delivery). A machine
407
470
  with no working fontconfig at all answers "unknown", not "missing": the job continues with
408
- the caller's font -- libass and drawtext still have their own font backends -- and one info
409
- line says the coverage could not be verified.
471
+ the caller's font -- libass and drawtext still have their own font backends -- and one recorded
472
+ warning (`font_coverage_unverified`) says the coverage could not be verified.
410
473
  """
411
474
  script = detect_script(text or "", lang)
412
475
  if script == "latin":
@@ -415,24 +478,26 @@ def script_font_for_text(text: str, *, lang: "Optional[str]" = None, font: "Opti
415
478
  return script, None, None
416
479
  if font_explicit and font:
417
480
  if not font_covers_script(font, script):
418
- info(f"font: '{font}' does not cover {LANGUAGE_NAMES[script]} text on this machine; keeping it as asked "
419
- f"(drop --font, or {FONT_FLAG_HINT}, to pick one by script automatically)")
481
+ warn(f"font: '{font}' does not cover {LANGUAGE_NAMES[script]} text on this machine; keeping it as asked "
482
+ f"(drop --font, or {FONT_FLAG_HINT}, to pick one by script automatically)", "font_lacks_script",
483
+ font=font, script=script)
420
484
  return script, None, None
421
485
  if fonts_dir:
422
486
  covered = fonts_dir_covers_script(fonts_dir, script)
423
487
  if covered:
424
488
  return script, None, None
425
489
  if covered is None:
426
- info(f"font: could not verify that {fonts_dir} covers {LANGUAGE_NAMES[script]} text "
427
- "(no fc-scan on this machine); using it as given")
490
+ warn(f"font: could not verify that {fonts_dir} covers {LANGUAGE_NAMES[script]} text "
491
+ "(no fc-scan on this machine); using it as given", "font_coverage_unverified", fonts_dir=fonts_dir, script=script)
428
492
  return script, None, None
429
- info(f"font: no face in {fonts_dir} covers {LANGUAGE_NAMES[script]} text; "
430
- "picking one by script instead (the directory is still searched first)")
493
+ record_fallback(f"font: no face in {fonts_dir} covers {LANGUAGE_NAMES[script]} text; "
494
+ "picking one by script instead (the directory is still searched first)", "fonts_dir_lacks_script",
495
+ fonts_dir=fonts_dir, script=script)
431
496
  entry = _script_font_entry(script)
432
497
  if entry is FC_UNKNOWN:
433
- info(f"font: could not verify that this machine can render {LANGUAGE_NAMES[script]} text "
498
+ warn(f"font: could not verify that this machine can render {LANGUAGE_NAMES[script]} text "
434
499
  "(no working fontconfig); rendering with the font as given -- "
435
- "doctor --json .fonts.scripts reports what is known")
500
+ "doctor --json .fonts.scripts reports what is known", "font_coverage_unverified", script=script)
436
501
  return script, None, None
437
502
  if not entry:
438
503
  die(f"no installed font covers {LANGUAGE_NAMES[script]} text on this machine — {FONT_INSTALL_HINT}", kind="input")