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.
- package/README.md +1 -1
- package/docs/contract.md +69 -13
- package/mcp/server.py +7 -1
- package/package.json +1 -1
- package/references/scripts.md +79 -14
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/asr.py +2 -2
- package/scripts/_common/decision.py +57 -11
- package/scripts/_common/emit.py +49 -5
- package/scripts/_common/fonts.py +93 -28
- package/scripts/_common/runner.py +112 -37
- package/scripts/_common/timeline.py +22 -6
- package/scripts/_contract.py +122 -12
- package/scripts/audio.py +2 -1
- package/scripts/batch.py +240 -105
- package/scripts/caption.py +68 -18
- package/scripts/color.py +2 -1
- package/scripts/cropdetect.py +19 -4
- package/scripts/export.py +42 -10
- package/scripts/graphics.py +24 -18
- package/scripts/grid.py +7 -3
- package/scripts/join.py +5 -3
- package/scripts/loop.py +4 -2
- package/scripts/multicam.py +11 -3
- package/scripts/overlay.py +14 -10
- package/scripts/render.py +133 -44
- package/scripts/report.py +77 -23
- package/scripts/reverse.py +4 -2
- package/scripts/sequence.py +4 -2
- package/scripts/silence.py +19 -7
- package/scripts/sync.py +18 -7
- package/scripts/verify.py +98 -24
- package/scripts/waveform.py +3 -2
package/scripts/_common/emit.py
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
296
|
-
`diagnostics` records
|
|
297
|
-
|
|
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)
|
package/scripts/_common/fonts.py
CHANGED
|
@@ -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.
|
|
26
|
-
|
|
27
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
88
|
+
return none
|
|
68
89
|
if proc.returncode != 0:
|
|
69
|
-
return
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
|
409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
|
1134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1481
|
-
|
|
1482
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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]:
|