ffmpeg-skill 2.11.0 → 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.
@@ -74,7 +74,10 @@ def _record(bucket: str, msg: str, code: str, ctx: "Optional[Context]", data: Di
74
74
  ctx = ctx or STATE
75
75
  entry: Dict[str, Any] = {"code": code, "message": msg}
76
76
  entry.update(data)
77
- getattr(ctx, bucket).append(entry)
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)
78
81
  sys.stderr.write(f"{prefix}{msg}\n")
79
82
  return entry
80
83
 
@@ -82,7 +85,8 @@ def _record(bucket: str, msg: str, code: str, ctx: "Optional[Context]", data: Di
82
85
  def warn(msg: str, code: str, *, ctx: "Optional[Context]" = None, **data: Any) -> Dict[str, Any]:
83
86
  """Say something went other than asked, on stderr AND in the document
84
87
  (`diagnostics.warnings`: {code, message, ...data}). Use it where a bare info("warning: ...")
85
- would leave the caller of --json nothing to read."""
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`)."""
86
90
  return _record("warnings", msg, code, ctx, data, "warning: ")
87
91
 
88
92
 
@@ -291,10 +295,32 @@ def absorb_stage_hw(stage: str, doc: Any, ctx: "Optional[Context]" = None) -> No
291
295
  "used": hw.get("used"), "fallback": bool(hw.get("fallback")), "quality_note": quality})
292
296
 
293
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
+
294
316
  def absorb_stage(stage: str, doc: Any, ctx: "Optional[Context]" = None) -> Dict[str, Any]:
295
- """A child tool's --json document into this result: its `hw` facts (absorb_stage_hw) and its
296
- `diagnostics` records, each tagged with the stage it came from. Returns the fields a re-raise
297
- of the stage's failure carries in its own `error`: the child's `reason` and `details`."""
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`."""
298
324
  ctx = ctx or STATE
299
325
  absorb_stage_hw(stage, doc, ctx)
300
326
  if not isinstance(doc, dict):
@@ -305,6 +331,22 @@ def absorb_stage(stage: str, doc: Any, ctx: "Optional[Context]" = None) -> Dict[
305
331
  tagged = dict(entry, stage=entry.get("stage") or stage)
306
332
  if tagged not in getattr(ctx, bucket):
307
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)
308
350
  err = doc.get("error") if isinstance(doc.get("error"), dict) else {}
309
351
  return {k: err[k] for k in ("reason", "details") if err.get(k) is not None}
310
352
 
@@ -351,6 +393,8 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
351
393
  # verified nothing. Spec failures the tool cannot fix on its own (export's loudness gap)
352
394
  # keep status completed and say verified: false, so a caller keys on one field.
353
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]
354
398
  if output and not ctx.dry_run and os.path.splitext(output)[1].lower() not in MEDIA_EXT:
355
399
  steps.insert(0, {"step": "exists", "ok": True})
356
400
  doc["verified"] = not ctx.dry_run and bool(steps) and all(s.get("ok") for s in steps)
@@ -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")
@@ -71,10 +71,26 @@ PROBE_TIMEOUT = 120.0
71
71
 
72
72
 
73
73
  def _env_timeout() -> float:
74
+ return _read_env_timeout()[0]
75
+
76
+
77
+ def _read_env_timeout() -> "Tuple[float, Optional[str]]":
78
+ """(the per-call limit FFMPEG_SKILL_TIMEOUT sets, why its value was not taken as written or
79
+ None). Unset or blank is the default with nothing to say. A value that is not a number (or is
80
+ NaN) is the default, and a negative one is 0 (no limit) as it always was; both used to pass
81
+ without a word, and apply_common() now records them (`timeout_env_invalid`)."""
82
+ raw = os.environ.get("FFMPEG_SKILL_TIMEOUT")
83
+ if raw is None or not raw.strip():
84
+ return DEFAULT_TIMEOUT, None
74
85
  try:
75
- return max(0.0, float(os.environ.get("FFMPEG_SKILL_TIMEOUT", DEFAULT_TIMEOUT)))
86
+ value = float(raw)
87
+ if value != value:
88
+ raise ValueError("NaN")
76
89
  except ValueError:
77
- return DEFAULT_TIMEOUT
90
+ return DEFAULT_TIMEOUT, f"FFMPEG_SKILL_TIMEOUT={raw!r} is not a number of seconds; using the default {DEFAULT_TIMEOUT:g} s"
91
+ if value < 0:
92
+ return 0.0, f"FFMPEG_SKILL_TIMEOUT={raw!r} is negative; read as 0, which means no time limit"
93
+ return value, None
78
94
 
79
95
 
80
96
  # None of the four kinds above are retryable in practice: an "input"/"missing_tool" failure is
@@ -218,7 +234,7 @@ class Context:
218
234
 
219
235
  __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
220
236
  "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
221
- "notes", "warnings", "fallbacks", "skipped", "audit_log")
237
+ "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification")
222
238
 
223
239
  def __init__(self) -> None:
224
240
  self.reset()
@@ -256,6 +272,10 @@ class Context:
256
272
  self.fallbacks: List[Dict[str, Any]] = []
257
273
  self.skipped: List[Dict[str, Any]] = []
258
274
  self.audit_log: Optional[str] = None # render.py --audit-log FILE: where audit() appends; None = the event's own default
275
+ # render.py (stages, an executed plan, a pack's renders): the verification steps each child
276
+ # tool reported, tagged with `stage`, which emit() adds to this result's own so that
277
+ # `verified` is the conjunction of every stage's (absorb_stage())
278
+ self.stage_verification: List[Dict[str, Any]] = []
259
279
 
260
280
 
261
281
  STATE = Context()
@@ -330,6 +350,10 @@ def apply_common(args: "argparse.Namespace") -> None:
330
350
  STATE.overwrite = bool(getattr(args, "overwrite", False))
331
351
  if getattr(args, "timeout", None) is not None:
332
352
  STATE.timeout = max(0.0, float(args.timeout))
353
+ else:
354
+ env_limit, env_problem = _read_env_timeout()
355
+ if env_problem:
356
+ warn(env_problem, "timeout_env_invalid", value=os.environ.get("FFMPEG_SKILL_TIMEOUT"), timeout=env_limit)
333
357
  if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
334
358
  args.preset = "veryfast"
335
359
  STATE.codec = getattr(args, "codec", None) or None
@@ -387,9 +411,10 @@ _CHILDREN: List[Tuple[subprocess.Popen, Sequence[str]]] = []
387
411
  _SIGNALS_INSTALLED = False
388
412
 
389
413
 
390
- def _on_signal(signum: int, frame: Any) -> None:
391
- import signal as _signal
392
- name = {getattr(_signal, "SIGINT", None): "SIGINT", getattr(_signal, "SIGTERM", None): "SIGTERM"}.get(signum, str(signum))
414
+ def stop_children() -> None:
415
+ """Stop every child this tool is waiting on and remove each one's partial output: what the
416
+ signal handler does before it exits, for a tool that reports more than "interrupted" after a
417
+ signal (batch.py's partial table)."""
393
418
  for proc, cmd in list(_CHILDREN):
394
419
  try:
395
420
  proc.terminate() # ffmpeg exits promptly on SIGTERM; a sibling script runs this same handler
@@ -403,6 +428,12 @@ def _on_signal(signum: int, frame: Any) -> None:
403
428
  if cmd:
404
429
  _cleanup_partial_output(cmd)
405
430
  _CHILDREN.clear()
431
+
432
+
433
+ def _on_signal(signum: int, frame: Any) -> None:
434
+ import signal as _signal
435
+ name = {getattr(_signal, "SIGINT", None): "SIGINT", getattr(_signal, "SIGTERM", None): "SIGTERM"}.get(signum, str(signum))
436
+ stop_children()
406
437
  die(f"interrupted by {name}: the running command was stopped and its partial output removed; nothing was written",
407
438
  code=128 + signum, kind="interrupted")
408
439
 
@@ -1031,7 +1062,10 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
1031
1062
  return proc, ran
1032
1063
  retry = _odd_dimension_retry(ran, proc.stderr or "")
1033
1064
  if retry is not None:
1034
- info("source has odd dimensions; scaling to even before encoding (yuv420p needs it)")
1065
+ # recorded, not only said: the output's frame is a pixel narrower or shorter than
1066
+ # the source's, which a caller comparing sizes has to be able to read
1067
+ record_fallback("source has odd dimensions; scaling to even before encoding (yuv420p needs it)",
1068
+ "odd_dimensions_scaled_even", ctx=ctx, output=cmd[-1], scale=EVEN_SCALE)
1035
1069
  ctx.commands[-1] = _cmdline(retry[:-1] + [cmd[-1]])
1036
1070
  return _execute(retry), retry
1037
1071
  if "not divisible by 2" in (proc.stderr or ""):
@@ -1130,10 +1164,12 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, r
1130
1164
  def dry_run_input_pending(path: str) -> bool:
1131
1165
  """True when a measurement cannot run because its input does not exist yet under --dry-run:
1132
1166
  in a render.py/batch.py plan each stage's input is the previous stage's output, which a dry
1133
- run never wrote. The measurement is then skipped (with a note) rather than failing the plan;
1134
- on a real file the measurement runs even under --dry-run."""
1167
+ run never wrote. The measurement is then skipped rather than failing the plan, and the skip is
1168
+ recorded (`diagnostics.skipped`, `measurement_input_pending`, once per path) beside its stderr
1169
+ line; on a real file the measurement runs even under --dry-run."""
1135
1170
  if STATE.dry_run and not os.path.exists(path):
1136
- info(f"[dry-run] {path} does not exist yet (an earlier dry-run stage would write it); measurement skipped")
1171
+ record_skip(f"[dry-run] {path} does not exist yet (an earlier dry-run stage would write it); measurement skipped",
1172
+ "measurement_input_pending", path=path)
1137
1173
  return True
1138
1174
  return False
1139
1175
 
@@ -1196,9 +1232,19 @@ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
1196
1232
  (e.g. a container change) makes that first attempt fail; retry the same command without the
1197
1233
  extra maps rather than let a tool that never touched subtitles start hard-failing because of
1198
1234
  them. `cmd` is the full argv *without* the output path. Returns True only when the
1199
- retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped)."""
1200
- if run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False).returncode == 0:
1235
+ retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped).
1236
+
1237
+ The retry is recorded with the first attempt's error (`diagnostics.fallbacks`,
1238
+ `non_av_streams_dropped`): the caller's `dropped_non_av_streams` says that streams went, the
1239
+ record says why, which is the only way to tell a container that cannot hold them from a first
1240
+ attempt that failed for another reason and happened to succeed the second time."""
1241
+ first = run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False)
1242
+ if first.returncode == 0:
1201
1243
  return False
1244
+ error = " | ".join([ln.strip() for ln in (first.stderr or "").splitlines() if ln.strip()][-3:])[-500:]
1245
+ record_fallback(f"the subtitle/data streams could not be copied into {os.path.basename(output)} "
1246
+ f"({error or 'ffmpeg exit ' + str(first.returncode)}); encoding again without them",
1247
+ "non_av_streams_dropped", output=output, returncode=first.returncode, error=error)
1202
1248
  run(cmd + [output])
1203
1249
  return True
1204
1250
 
@@ -1459,41 +1505,70 @@ def ffmpeg_encoders() -> set:
1459
1505
  ffmpeg_version(). That is the one case for ffprobe. On a mixed install (a Homebrew ffprobe next
1460
1506
  to a static ffmpeg) its list is another build's, so a real run never uses it: the AV1/ProRes
1461
1507
  refusals and the VideoToolbox check must describe the binary that encodes, and a plan that
1462
- read ffprobe's list is re-decided against ffmpeg's when it runs."""
1508
+ read ffprobe's list is re-decided against ffmpeg's when it runs.
1509
+
1510
+ An empty list is not "this build has none": when the list could not be read (the binary did
1511
+ not run, timed out, exited non-zero, or printed nothing a list is made of), the reason is kept
1512
+ (ffmpeg_list_error("encoders")) and recorded once as a `ffmpeg_list_unreadable` warning, so a
1513
+ refusal or a CPU note built on the empty set names the read failure, not a missing encoder."""
1463
1514
  global _ENCODERS
1464
1515
  if _ENCODERS is None:
1465
- _ENCODERS = set()
1466
- tool = "ffprobe" if STATE.dry_run else "ffmpeg"
1467
- # --timeout bounds this read too: on Apple Silicon every CPU re-encode asks for the list
1468
- # (#314), so a deadlocked ffmpeg must not outlive the limit the caller set before the encode
1469
- limit = min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else PROBE_TIMEOUT
1470
- try:
1471
- out = subprocess.run([ffmpeg_exe(tool)[0] or tool, "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
1472
- stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=limit).stdout
1473
- _ENCODERS = set(re.findall(r"^\s*[VAS][.\w]{5}\s+(\S+)", out, re.M))
1474
- except (OSError, subprocess.SubprocessError):
1475
- pass
1516
+ _ENCODERS = _read_ff_list("-encoders", r"^\s*[VAS][.\w]{5}\s+(\S+)")
1476
1517
  return _ENCODERS
1477
1518
 
1478
1519
 
1479
1520
  def ffmpeg_bsfs() -> set:
1480
- """Names from `ffmpeg -bsfs`, read once, empty when it cannot be read; from ffprobe under
1481
- --dry-run, for the reason ffmpeg_encoders() gives. color.py --retag rewrites an H.264/HEVC
1482
- stream's colour description with h264_metadata/hevc_metadata only when the build has it."""
1521
+ """Names from `ffmpeg -bsfs`, read once, empty when it cannot be read (the reason is kept and
1522
+ recorded as ffmpeg_encoders() keeps it); from ffprobe under --dry-run, for the reason
1523
+ ffmpeg_encoders() gives. color.py --retag rewrites an H.264/HEVC stream's colour description
1524
+ with h264_metadata/hevc_metadata only when the build has it."""
1483
1525
  global _BSFS
1484
1526
  if _BSFS is None:
1485
- _BSFS = set()
1486
- tool = "ffprobe" if STATE.dry_run else "ffmpeg"
1487
- limit = min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else PROBE_TIMEOUT
1488
- try:
1489
- out = subprocess.run([ffmpeg_exe(tool)[0] or tool, "-hide_banner", "-bsfs"], stdout=subprocess.PIPE,
1490
- stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=limit).stdout
1491
- _BSFS = set(re.findall(r"^\s*([a-z0-9_]+)\s*$", out, re.M))
1492
- except (OSError, subprocess.SubprocessError):
1493
- pass
1527
+ _BSFS = _read_ff_list("-bsfs", r"^\s*([a-z0-9_]+)\s*$")
1494
1528
  return _BSFS
1495
1529
 
1496
1530
 
1531
+ # why the last read of a capability list came back empty: "-encoders"/"-bsfs" -> reason
1532
+ _LIST_ERRORS: Dict[str, str] = {}
1533
+
1534
+
1535
+ def ffmpeg_list_error(flag: str) -> Optional[str]:
1536
+ """Why `ffmpeg -encoders` / `-bsfs` (flag "encoders" or "bsfs") could not be read, or None when
1537
+ it was read (or not yet asked for). Only while the cached list is empty: a list a test or a
1538
+ later read filled in has nothing to explain."""
1539
+ flag = "-" + flag.lstrip("-")
1540
+ cache = _ENCODERS if flag == "-encoders" else _BSFS if flag == "-bsfs" else None
1541
+ return None if cache else _LIST_ERRORS.get(flag)
1542
+
1543
+
1544
+ def _read_ff_list(flag: str, pattern: str) -> set:
1545
+ tool = "ffprobe" if STATE.dry_run else "ffmpeg"
1546
+ # --timeout bounds this read too: on Apple Silicon every CPU re-encode asks for the list
1547
+ # (#314), so a deadlocked ffmpeg must not outlive the limit the caller set before the encode
1548
+ limit = min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else PROBE_TIMEOUT
1549
+ _LIST_ERRORS.pop(flag, None)
1550
+ names: set = set()
1551
+ why = None
1552
+ try:
1553
+ proc = subprocess.run([ffmpeg_exe(tool)[0] or tool, "-hide_banner", flag], stdout=subprocess.PIPE,
1554
+ stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=limit)
1555
+ names = set(re.findall(pattern, proc.stdout or "", re.M))
1556
+ if not names:
1557
+ tail = [ln.strip() for ln in (proc.stderr or "").splitlines() if ln.strip()][-1:]
1558
+ why = (f"{tool} {flag} exited {proc.returncode}" if proc.returncode else f"{tool} {flag} listed nothing") + \
1559
+ (f": {tail[0][:200]}" if tail else "")
1560
+ except subprocess.TimeoutExpired:
1561
+ why = f"{tool} {flag} did not answer within {limit:g} s"
1562
+ except (OSError, subprocess.SubprocessError) as exc:
1563
+ why = f"{tool} {flag} could not run: {exc}"
1564
+ if why:
1565
+ _LIST_ERRORS[flag] = why
1566
+ what = "encoder" if flag == "-encoders" else "bitstream filter"
1567
+ warn(f"could not read this ffmpeg build's {what} list ({why}); nothing that needs it can be confirmed, which is not the same as the build lacking it",
1568
+ "ffmpeg_list_unreadable", list=flag.lstrip("-"), tool=tool, error=why)
1569
+ return names
1570
+
1571
+
1497
1572
  def read_text_or_die(path: str, flag: str) -> str:
1498
1573
  """Read a caller-supplied UTF-8 text file (a cue list, chapters, notes) or fail as kind input
1499
1574
  with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback."""
@@ -1518,4 +1593,4 @@ def read_text_or_die(path: str, flag: str) -> str:
1518
1593
  # emit needs runner's Context/STATE/ERROR_CODE, so the two form a cycle that has to be cut
1519
1594
  # somewhere: by the time this line runs every name emit reads from runner above is defined, and
1520
1595
  # every name runner reads from emit is only ever read inside a function body, never at import.
1521
- from _common.emit import _plan_at_exit, audit, die, hw_report, info, record_fallback, refuse, warn # noqa: E402
1596
+ from _common.emit import _plan_at_exit, audit, die, hw_report, info, record_fallback, record_skip, refuse, warn # noqa: E402
@@ -29,6 +29,7 @@ from typing import Any, Dict, List, Optional, Tuple
29
29
  from xml.sax.saxutils import escape as _xml_escape
30
30
 
31
31
  from _common.decision import MissingFpsError, aspect_ratio, frame_size, parse_time
32
+ from _common.emit import warn
32
33
 
33
34
  FORMATS = {".edl": "edl", ".fcpxml": "fcpxml", ".otio": "otio"}
34
35
  # Common NTSC rates are stored as the exact ratio an editor expects, not as 29.97.
@@ -482,17 +483,32 @@ WRITERS = {"edl": to_edl, "fcpxml": to_fcpxml, "otio": to_otio}
482
483
 
483
484
 
484
485
  def parses(path: str, fmt: str) -> bool:
485
- """The verification step: the written file reads back as what it claims to be."""
486
+ """The verification step: the written file reads back as what it claims to be. A file that
487
+ does not is recorded with the reason (`diagnostics.warnings`, `timeline_parse_failed`: the
488
+ exception, or what was read instead), beside the step's `ok: false`."""
489
+ why = parse_error(path, fmt)
490
+ if why:
491
+ warn(f"{path} does not read back as {fmt}: {why}", "timeline_parse_failed", path=path, format=fmt, error=why)
492
+ return why is None
493
+
494
+
495
+ def parse_error(path: str, fmt: str) -> Optional[str]:
496
+ """Why `path` does not read back as `fmt`, or None when it does (parses() without the record)."""
486
497
  try:
487
498
  text = Path(path).read_text(encoding="utf-8")
488
499
  if fmt == "fcpxml":
489
500
  import xml.etree.ElementTree as ET
490
- return ET.fromstring(text.encode("utf-8")).tag == "fcpxml"
501
+ tag = ET.fromstring(text.encode("utf-8")).tag
502
+ return None if tag == "fcpxml" else f"the root element is <{tag}>, not <fcpxml>"
491
503
  if fmt == "otio":
492
- return json.loads(text).get("OTIO_SCHEMA") == "Timeline.1"
493
- return text.startswith("TITLE:") and any(line[:3].isdigit() for line in text.splitlines())
494
- except (OSError, ValueError, SyntaxError):
495
- return False
504
+ doc = json.loads(text)
505
+ schema = doc.get("OTIO_SCHEMA") if isinstance(doc, dict) else None
506
+ return None if schema == "Timeline.1" else f"OTIO_SCHEMA is {schema!r}, not 'Timeline.1'"
507
+ if not text.startswith("TITLE:"):
508
+ return "the first line is not TITLE:"
509
+ return None if any(line[:3].isdigit() for line in text.splitlines()) else "no numbered event line"
510
+ except (OSError, ValueError, SyntaxError) as exc:
511
+ return f"{type(exc).__name__}: {exc}"
496
512
 
497
513
 
498
514
  def summary(tl: Dict[str, Any], fmt: str) -> Dict[str, Any]: