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.
- package/README.md +4 -3
- package/docs/contract.md +107 -11
- package/mcp/server.py +7 -1
- package/package.json +1 -1
- package/references/scripts.md +92 -20
- package/scripts/_common/__init__.py +6 -4
- package/scripts/_common/asr.py +2 -2
- package/scripts/_common/decision.py +57 -11
- package/scripts/_common/emit.py +198 -2
- package/scripts/_common/fonts.py +93 -28
- package/scripts/_common/runner.py +246 -68
- package/scripts/_common/timeline.py +22 -6
- package/scripts/_contract.py +168 -10
- 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 +223 -74
- 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
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
package/scripts/_common/emit.py
CHANGED
|
@@ -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,
|
|
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,
|
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")
|