ffmpeg-skill 3.8.8 → 3.8.10
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 +13 -3
- package/package.json +1 -1
- package/references/gotchas.md +7 -4
- package/references/scripts.md +35 -14
- package/scripts/_ass_overlay.py +7 -4
- package/scripts/_common/asr.py +12 -1
- package/scripts/_common/drawtext.py +11 -0
- package/scripts/_common/emit.py +3 -1
- package/scripts/_common/emoji.py +33 -6
- package/scripts/_common/fonts.py +42 -5
- package/scripts/_common/probe.py +27 -7
- package/scripts/_common/runner.py +133 -5
- package/scripts/_common/wrap.py +60 -18
- package/scripts/_contract.py +14 -9
- package/scripts/caption.py +260 -72
- package/scripts/color.py +36 -0
- package/scripts/cropdetect.py +24 -6
- package/scripts/cut.py +13 -5
- package/scripts/export.py +3 -2
- package/scripts/graphics.py +31 -12
- package/scripts/join.py +20 -2
- package/scripts/loudness.py +2 -1
- package/scripts/metadata.py +11 -1
- package/scripts/overlay.py +11 -6
- package/scripts/probe.py +8 -0
- package/scripts/render.py +21 -3
- package/scripts/silence.py +27 -3
- package/scripts/stabilize.py +6 -2
- package/scripts/sync.py +8 -3
- package/scripts/waveform.py +13 -6
|
@@ -1048,7 +1048,7 @@ _CODEC_OPTION_RE = re.compile(r"^-(?:c|codec)(?::([va])(?::\d+)?)?$|^-(v|a)codec
|
|
|
1048
1048
|
|
|
1049
1049
|
|
|
1050
1050
|
def _an_input_has(cmd: Sequence[str], kind: str) -> bool:
|
|
1051
|
-
"""False only when every input of `cmd` is a file that was read and holds no `kind` ("v"/"a")
|
|
1051
|
+
"""False only when every input of `cmd` is a file that was read and holds no `kind` ("v"/"a"/"s")
|
|
1052
1052
|
stream, and no filter graph could make one. An encoder option for a stream the output will
|
|
1053
1053
|
not have selects nothing (FFmpeg applies -c:a after stream selection), so it is not a reason
|
|
1054
1054
|
to refuse: cut.py names -c:a aac for a silent source too. Anything not known counts as present."""
|
|
@@ -1110,6 +1110,120 @@ def _check_container_codecs(cmd: Sequence[str]) -> None:
|
|
|
1110
1110
|
output=output, container="webm", encoders=refused)
|
|
1111
1111
|
|
|
1112
1112
|
|
|
1113
|
+
# the options whose value is a filter graph this module can read the filter names out of
|
|
1114
|
+
_GRAPH_OPTIONS = frozenset({"-vf", "-af", "-filter:v", "-filter:a", "-filter_complex", "-lavfi"})
|
|
1115
|
+
_ENCODER_OPTION_RE = re.compile(r"^-(?:c|codec):([vas])(?::\d+)?$|^-([vas])codec$")
|
|
1116
|
+
# the output option that drops every stream of a kind: an encoder named for that kind selects nothing
|
|
1117
|
+
_NO_STREAM_OPTION = {"v": "-vn", "a": "-an", "s": "-sn"}
|
|
1118
|
+
# one row of `ffmpeg -encoders` / `ffmpeg -filters` (the filter row by its io-spec token, so the
|
|
1119
|
+
# flag-column width of FFmpeg 6/7 and 8+ does not matter): ffmpeg_encoders(), ffmpeg_filters()
|
|
1120
|
+
_ENCODER_ROW = r"^\s*[VAS][.\w]{5}\s+(\S+)"
|
|
1121
|
+
_FILTER_ROW = r"^\s*(?:[A-Z.|]{1,6}\s+)?([A-Za-z0-9_]+)\s+\S*->\S*"
|
|
1122
|
+
# quiet reads of a list the tool itself never asked for: {"-encoders": names, "-filters": names}
|
|
1123
|
+
_PREFLIGHT_LISTS: Dict[str, set] = {}
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def graph_filter_names(graph: str) -> List[str]:
|
|
1127
|
+
"""The filter names of one filter graph string, in order, without duplicates. A `,` or `;`
|
|
1128
|
+
separates filters only outside '...' quotes and when no backslash escapes it; `[labels]` before
|
|
1129
|
+
a filter and its `=options` / `@instance` are dropped. A graph-level `sws_flags=` is not a
|
|
1130
|
+
filter and is left out, as is anything that is not a plain identifier."""
|
|
1131
|
+
parts: List[str] = []
|
|
1132
|
+
cur: List[str] = []
|
|
1133
|
+
quoted = False
|
|
1134
|
+
i = 0
|
|
1135
|
+
while i < len(graph):
|
|
1136
|
+
ch = graph[i]
|
|
1137
|
+
if ch == "\\" and i + 1 < len(graph):
|
|
1138
|
+
cur.append(graph[i:i + 2])
|
|
1139
|
+
i += 2
|
|
1140
|
+
continue
|
|
1141
|
+
if ch == "'":
|
|
1142
|
+
quoted = not quoted
|
|
1143
|
+
elif ch in ",;" and not quoted:
|
|
1144
|
+
parts.append("".join(cur))
|
|
1145
|
+
cur = []
|
|
1146
|
+
i += 1
|
|
1147
|
+
continue
|
|
1148
|
+
cur.append(ch)
|
|
1149
|
+
i += 1
|
|
1150
|
+
parts.append("".join(cur))
|
|
1151
|
+
names: List[str] = []
|
|
1152
|
+
for part in parts:
|
|
1153
|
+
part = part.strip()
|
|
1154
|
+
while part.startswith("["):
|
|
1155
|
+
end = part.find("]")
|
|
1156
|
+
if end < 0:
|
|
1157
|
+
break
|
|
1158
|
+
part = part[end + 1:].lstrip()
|
|
1159
|
+
m = re.match(r"[A-Za-z0-9_]+", part)
|
|
1160
|
+
if not m or m.end() < len(part) and part[m.end()] not in "=@[ \t":
|
|
1161
|
+
continue
|
|
1162
|
+
if m.group(0) != "sws_flags" and m.group(0) not in names:
|
|
1163
|
+
names.append(m.group(0))
|
|
1164
|
+
return names
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _build_list(flag: str) -> set:
|
|
1168
|
+
"""This build's encoder or filter names for the check below: the list a tool already read, else
|
|
1169
|
+
one quiet read. A list that cannot be read is empty here and refuses nothing; the
|
|
1170
|
+
`ffmpeg_list_unreadable` warning stays with the tools that decide something on the list."""
|
|
1171
|
+
cached = _ENCODERS if flag == "-encoders" else _FILTERS
|
|
1172
|
+
if cached is not None:
|
|
1173
|
+
return cached
|
|
1174
|
+
if flag not in _PREFLIGHT_LISTS:
|
|
1175
|
+
_PREFLIGHT_LISTS[flag] = _read_ff_list(flag, _ENCODER_ROW if flag == "-encoders" else _FILTER_ROW, quiet=True)
|
|
1176
|
+
return _PREFLIGHT_LISTS[flag]
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _check_build_components(cmd: Sequence[str]) -> None:
|
|
1180
|
+
"""An encoder or a filter the command names and this ffmpeg build does not have is refused
|
|
1181
|
+
before anything runs, dry runs included, with the kind, reason and hint the run got from
|
|
1182
|
+
ffmpeg's "Unknown encoder" / "No such filter" (#402): the dry run said completed, and a real
|
|
1183
|
+
run found out after its earlier commands had run. Read from the command as built, so every tool
|
|
1184
|
+
and every route to an encoder (an HDR source kept as HEVC, --codec, a preset) is covered.
|
|
1185
|
+
Only output options are read (a -c:v before an -i picks a decoder), and an encoder only for a
|
|
1186
|
+
stream kind the output keeps: not one -an/-vn/-sn drops or no input file carries. A VideoToolbox encoder is
|
|
1187
|
+
left to --hw's own checks and its CPU retry, a -filter_complex_script and a lavfi input are not
|
|
1188
|
+
parsed, and a list that could not be read refuses nothing."""
|
|
1189
|
+
last_input = max((i for i, tok in enumerate(cmd[:-1]) if tok == "-i"), default=-1)
|
|
1190
|
+
encoders: List[str] = []
|
|
1191
|
+
streams: Dict[str, set] = {} # encoder -> the stream kinds ("v", "a", "s") it is named for
|
|
1192
|
+
filters: List[str] = []
|
|
1193
|
+
output_opts = {str(tok) for tok in cmd[last_input + 2:-1]}
|
|
1194
|
+
for i in range(1, len(cmd) - 1):
|
|
1195
|
+
tok, value = str(cmd[i]), str(cmd[i + 1])
|
|
1196
|
+
m = _ENCODER_OPTION_RE.match(tok)
|
|
1197
|
+
if tok in _GRAPH_OPTIONS:
|
|
1198
|
+
filters += [n for n in graph_filter_names(value) if n not in filters]
|
|
1199
|
+
elif i > last_input and m and value != "copy" and not value.endswith("_videotoolbox"):
|
|
1200
|
+
if value not in encoders:
|
|
1201
|
+
encoders.append(value)
|
|
1202
|
+
streams.setdefault(value, set()).add(m.group(1) or m.group(2))
|
|
1203
|
+
|
|
1204
|
+
def selected(name: str) -> bool:
|
|
1205
|
+
# ffmpeg picks an encoder per output stream, after stream selection: one named for a kind
|
|
1206
|
+
# the output drops (-an/-vn/-sn) or that no input carries (cut.py names -c:a aac for a
|
|
1207
|
+
# silent source too) is never opened. What the command cannot tell (a -filter_complex graph, a
|
|
1208
|
+
# lavfi or unreadable input) counts as selected; explicit -map lists are not read.
|
|
1209
|
+
return any(_NO_STREAM_OPTION[k] not in output_opts and _an_input_has(cmd, k) for k in streams.get(name, ()))
|
|
1210
|
+
|
|
1211
|
+
for flag, wanted, what, reason in (("-encoders", encoders, "encoder", "encoder_missing"),
|
|
1212
|
+
("-filters", filters, "filter", "filter_missing")):
|
|
1213
|
+
have = _build_list(flag) if wanted else set()
|
|
1214
|
+
missing = [n for n in wanted if n not in have] if have else []
|
|
1215
|
+
if what == "encoder":
|
|
1216
|
+
missing = [n for n in missing if selected(n)] # asked last: only an encoder that would be refused is probed for
|
|
1217
|
+
if missing:
|
|
1218
|
+
name = missing[0]
|
|
1219
|
+
hint = filter_missing_hint(name) if what == "filter" else filter_missing_hint(name).replace(
|
|
1220
|
+
f"no {name} filter", f"no {name} encoder", 1)
|
|
1221
|
+
die(f"this ffmpeg build has no {name} {what}, which the command this run builds needs"
|
|
1222
|
+
+ (f" (also missing: {', '.join(missing[1:])})" if missing[1:] else ""),
|
|
1223
|
+
code=1, kind="ffmpeg", hint=hint,
|
|
1224
|
+
error_extra={"reason": reason, "details": {"name": name}})
|
|
1225
|
+
|
|
1226
|
+
|
|
1113
1227
|
EVEN_SCALE = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
|
|
1114
1228
|
|
|
1115
1229
|
|
|
@@ -1904,6 +2018,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
|
|
|
1904
2018
|
_check_no_overwrite_input(cmd)
|
|
1905
2019
|
_check_output_path(cmd)
|
|
1906
2020
|
_check_container_codecs(cmd)
|
|
2021
|
+
_check_build_components(cmd)
|
|
1907
2022
|
_check_existing_output(cmd)
|
|
1908
2023
|
ctx.commands.append(_cmdline(cmd))
|
|
1909
2024
|
if not quiet:
|
|
@@ -2128,6 +2243,16 @@ def _has_non_av_streams(cmd: Sequence[str]) -> bool:
|
|
|
2128
2243
|
return not kinds or bool(kinds & {"subtitle", "data", "attachment"})
|
|
2129
2244
|
|
|
2130
2245
|
|
|
2246
|
+
def ffmpeg_failure_extra(stderr: str) -> Dict[str, Any]:
|
|
2247
|
+
"""The die() keywords that carry the cause ffmpeg named in `stderr` -- a missing encoder or
|
|
2248
|
+
filter, a full disk, an unreadable input -- as `hint` and the error's `reason`/`details`, or {}
|
|
2249
|
+
when it named none (#402): a tool that composes its own failure message (cut.py's re-encode, a
|
|
2250
|
+
measurement pass) reports the cause as run()'s own failures do. Used as
|
|
2251
|
+
`die(msg, kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))`, through the caller's own die."""
|
|
2252
|
+
cls = classify_ffmpeg_failure(stderr or "")
|
|
2253
|
+
return {"hint": cls[1], "error_extra": {"reason": cls[0], "details": cls[2]}} if cls else {}
|
|
2254
|
+
|
|
2255
|
+
|
|
2131
2256
|
def fail_if_classified(proc: subprocess.CompletedProcess) -> None:
|
|
2132
2257
|
"""Stop with a failed attempt's own error when ffmpeg named a cause a second encode would hit
|
|
2133
2258
|
the same way (a full disk, a missing encoder or filter, an unreadable input): the rule
|
|
@@ -2543,7 +2668,7 @@ def ffmpeg_encoders() -> set:
|
|
|
2543
2668
|
refusal or a CPU note built on the empty set names the read failure, not a missing encoder."""
|
|
2544
2669
|
global _ENCODERS
|
|
2545
2670
|
if _ENCODERS is None:
|
|
2546
|
-
_ENCODERS = _read_ff_list("-encoders",
|
|
2671
|
+
_ENCODERS = _read_ff_list("-encoders", _ENCODER_ROW)
|
|
2547
2672
|
return _ENCODERS
|
|
2548
2673
|
|
|
2549
2674
|
|
|
@@ -2565,7 +2690,7 @@ def ffmpeg_filters() -> set:
|
|
|
2565
2690
|
flag-column width of FFmpeg 6/7 (three) and 8+ (two) does not matter."""
|
|
2566
2691
|
global _FILTERS
|
|
2567
2692
|
if _FILTERS is None:
|
|
2568
|
-
_FILTERS = _read_ff_list("-filters",
|
|
2693
|
+
_FILTERS = _read_ff_list("-filters", _FILTER_ROW)
|
|
2569
2694
|
return _FILTERS
|
|
2570
2695
|
|
|
2571
2696
|
|
|
@@ -2605,12 +2730,13 @@ def ffmpeg_list_error(flag: str) -> Optional[str]:
|
|
|
2605
2730
|
return None if cache else _LIST_ERRORS.get(flag)
|
|
2606
2731
|
|
|
2607
2732
|
|
|
2608
|
-
def _read_ff_list(flag: str, pattern: str) -> set:
|
|
2733
|
+
def _read_ff_list(flag: str, pattern: str, quiet: bool = False) -> set:
|
|
2609
2734
|
tool = "ffprobe" if STATE.dry_run else "ffmpeg"
|
|
2610
2735
|
# --timeout bounds this read too: on Apple Silicon every CPU re-encode asks for the list
|
|
2611
2736
|
# (#314), so a deadlocked ffmpeg must not outlive the limit the caller set before the encode
|
|
2612
2737
|
limit = _probe_limit()
|
|
2613
|
-
|
|
2738
|
+
if not quiet:
|
|
2739
|
+
_LIST_ERRORS.pop(flag, None)
|
|
2614
2740
|
names: set = set()
|
|
2615
2741
|
why = None
|
|
2616
2742
|
try:
|
|
@@ -2625,6 +2751,8 @@ def _read_ff_list(flag: str, pattern: str) -> set:
|
|
|
2625
2751
|
why = f"{tool} {flag} did not answer within {limit:g} s"
|
|
2626
2752
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
2627
2753
|
why = f"{tool} {flag} could not run: {exc}"
|
|
2754
|
+
if why and quiet:
|
|
2755
|
+
return set()
|
|
2628
2756
|
if why:
|
|
2629
2757
|
_LIST_ERRORS[flag] = why
|
|
2630
2758
|
what = {"-encoders": "encoder", "-decoders": "decoder", "-bsfs": "bitstream filter"}.get(flag, "filter")
|
package/scripts/_common/wrap.py
CHANGED
|
@@ -145,11 +145,14 @@ JA_PARTICLES = "はがをにでとのへもや" # a break AFTER one
|
|
|
145
145
|
# text on each side of a candidate break -- putting them in the character string above turned
|
|
146
146
|
# か, ら, ま, で, よ and り into one-character particles of their own, which none of them is.
|
|
147
147
|
JA_PARTICLE_WORDS = ("から", "まで", "より")
|
|
148
|
-
JA_SENTENCE_END = "
|
|
148
|
+
JA_SENTENCE_END = "。、!?」』),;:" # a break AFTER one of these is preferred (A54: ,;: too)
|
|
149
149
|
# Characters that may never start a line: small kana, the prolonged sound mark, closing brackets
|
|
150
150
|
# and the Japanese punctuation that hangs on the end of the line before it.
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
# A54 (#457): the fullwidth comma, semicolon, colon and full stop Chinese uses, and the closing
|
|
152
|
+
# brackets of both, are on the same table: a line that opens with ',' is the same fault as one
|
|
153
|
+
# that opens with '、'.
|
|
154
|
+
JA_NO_LINE_START = "ぁぃぅぇぉっゃゅょァィゥェォッャュョーヽヾゝゞ、。!?)」』】〕》’”%,;:.〉]}〗"
|
|
155
|
+
JA_NO_LINE_END = "(「『【〔《‘“〈[{〖" # ... and the ones that may never end a line
|
|
153
156
|
|
|
154
157
|
# R4. Function words belong to the phrase that FOLLOWS them: an article or preposition begins the
|
|
155
158
|
# noun phrase it governs, so a break before one is the good break (the word opens the next line
|
|
@@ -365,7 +368,7 @@ def _function_words(lang: "Optional[str]") -> "frozenset":
|
|
|
365
368
|
"""R4's table for this language. An unknown or absent language gets the union of the six sets:
|
|
366
369
|
a token that appears in several of them is the same kind of word in each, which is why the
|
|
367
370
|
rule is a penalty and not a refusal."""
|
|
368
|
-
key = (lang or "").strip().lower().split("-")[0]
|
|
371
|
+
key = (lang or "").strip().lower().replace("_", "-").split("-")[0]
|
|
369
372
|
if key in FUNCTION_WORDS:
|
|
370
373
|
return frozenset(FUNCTION_WORDS[key])
|
|
371
374
|
return _FUNCTION_WORDS_ANY
|
|
@@ -404,7 +407,7 @@ def break_penalty(prev_char: str, next_char: str, lang: "Optional[str]" = None,
|
|
|
404
407
|
single-character table applies."""
|
|
405
408
|
if not prev_char or not next_char:
|
|
406
409
|
return PENALTY_NEUTRAL
|
|
407
|
-
script = (lang or "").strip().lower().split("-")[0]
|
|
410
|
+
script = (lang or "").strip().lower().replace("_", "-").split("-")[0]
|
|
408
411
|
if script not in ("ja", "zh"):
|
|
409
412
|
# A kana on either side settles it: only Japanese has them, and char_script() reads a bare
|
|
410
413
|
# Han character as Chinese, which used to switch the particle rules off for exactly the
|
|
@@ -494,14 +497,15 @@ def _gaps(lines: "List[str]", gaps: "Optional[List[bool]]") -> "List[bool]":
|
|
|
494
497
|
|
|
495
498
|
|
|
496
499
|
def _fix_orphans(lines: "List[str]", max_em: float,
|
|
497
|
-
gaps: "Optional[List[bool]]" = None) -> "List[str]":
|
|
500
|
+
gaps: "Optional[List[bool]]" = None, kinsoku: bool = False) -> "List[str]":
|
|
498
501
|
"""No last line that is a single stranded atom.
|
|
499
502
|
|
|
500
503
|
Greedy wrapping leaves one character alone whenever the line before it filled exactly: eval 14
|
|
501
504
|
produced a Thai cue ending in a lone `ล` and a Japanese one ending in a lone `行`. While the
|
|
502
505
|
last line is one atom narrower than ORPHAN_MIN_EM, the last atom of the line above moves down
|
|
503
506
|
onto it -- but only while the result still fits and the line above does not become an orphan
|
|
504
|
-
itself, so a two-word cue is never made worse.
|
|
507
|
+
itself, so a two-word cue is never made worse. `kinsoku` (phrase mode) moves a punctuation
|
|
508
|
+
mark down only with the character it closes (_kinsoku_atoms)."""
|
|
505
509
|
lines = list(lines)
|
|
506
510
|
gaps = _gaps(lines, gaps)
|
|
507
511
|
for _ in range(len(lines)):
|
|
@@ -510,7 +514,7 @@ def _fix_orphans(lines: "List[str]", max_em: float,
|
|
|
510
514
|
tail = _atoms(lines[-1])
|
|
511
515
|
if len(tail) != 1 or text_width_em(lines[-1]) >= ORPHAN_MIN_EM:
|
|
512
516
|
break
|
|
513
|
-
prev = _atoms(lines[-2])
|
|
517
|
+
prev = _kinsoku_atoms(_atoms(lines[-2])) if kinsoku else _atoms(lines[-2])
|
|
514
518
|
if len(prev) < 2:
|
|
515
519
|
break
|
|
516
520
|
moved, spaced = prev[-1]
|
|
@@ -532,14 +536,15 @@ def _fix_weak_lines(lines: "List[str]", max_em: float,
|
|
|
532
536
|
1.15 only ever looked at the last line, so a stranded digit or kana in the middle of a
|
|
533
537
|
three-line cue survived. Walking upward from the last line, while a line is weak the last atom
|
|
534
538
|
of the line above moves down onto it -- with 1.15's two guards intact (the result must still
|
|
535
|
-
fit, and the line above must not itself become weak), so the line count never changes.
|
|
539
|
+
fit, and the line above must not itself become weak), so the line count never changes. A
|
|
540
|
+
punctuation mark moves down only with the character it closes (_kinsoku_atoms, A54)."""
|
|
536
541
|
lines = list(lines)
|
|
537
542
|
gaps = _gaps(lines, gaps)
|
|
538
543
|
for i in range(len(lines) - 1, 0, -1):
|
|
539
544
|
for _ in range(len(lines)):
|
|
540
545
|
if not _is_weak_line(lines[i]):
|
|
541
546
|
break
|
|
542
|
-
prev = _atoms(lines[i - 1])
|
|
547
|
+
prev = _kinsoku_atoms(_atoms(lines[i - 1]))
|
|
543
548
|
if len(prev) < 2:
|
|
544
549
|
break
|
|
545
550
|
moved, spaced = prev[-1]
|
|
@@ -633,9 +638,36 @@ def _rebalance_phrase(lines: "List[str]", max_em: float, lang: "Optional[str]",
|
|
|
633
638
|
return out, moved
|
|
634
639
|
|
|
635
640
|
|
|
641
|
+
def _kinsoku_atoms(atoms: "List[Tuple[str, bool]]") -> "List[Tuple[str, bool]]":
|
|
642
|
+
"""The atom list with each no-line-start character joined to the atom before it, and each
|
|
643
|
+
no-line-end character to the atom after it, where no space stands between them.
|
|
644
|
+
|
|
645
|
+
The phrase passes only ever choose among breaks that fit, so a forbidden break the greedy fill
|
|
646
|
+
had already taken stayed whenever no pass reconsidered it: a cue of three lines or more, or
|
|
647
|
+
the pieces of a cue split past --max-lines, opened a line with '、', ',' or '。' (A54, #457).
|
|
648
|
+
Joined, the fill carries the character before the punctuation onto the next line with it."""
|
|
649
|
+
split: "List[Tuple[str, bool]]" = []
|
|
650
|
+
for atom, spaced in atoms:
|
|
651
|
+
# the atomiser reads a run of CJK punctuation as one Latin word ('。('): part it where a
|
|
652
|
+
# closing mark meets an opening one, so each half can join its own neighbour
|
|
653
|
+
start = 0
|
|
654
|
+
for k in range(1, len(atom)):
|
|
655
|
+
if atom[k - 1] in JA_NO_LINE_START and atom[k] in JA_NO_LINE_END:
|
|
656
|
+
split.append((atom[start:k], spaced if start == 0 else False))
|
|
657
|
+
start = k
|
|
658
|
+
split.append((atom[start:], spaced if start == 0 else False))
|
|
659
|
+
out: "List[Tuple[str, bool]]" = []
|
|
660
|
+
for atom, spaced in split:
|
|
661
|
+
if out and not spaced and (atom[0] in JA_NO_LINE_START or out[-1][0][-1] in JA_NO_LINE_END):
|
|
662
|
+
out[-1] = (out[-1][0] + atom, out[-1][1])
|
|
663
|
+
else:
|
|
664
|
+
out.append((atom, spaced))
|
|
665
|
+
return out
|
|
666
|
+
|
|
667
|
+
|
|
636
668
|
def _greedy_chunks(raw: str, max_em: float, *, slice_overlong: bool = False,
|
|
637
669
|
sliced: "Optional[List[int]]" = None,
|
|
638
|
-
gaps: "Optional[List[bool]]" = None) -> "List[str]":
|
|
670
|
+
gaps: "Optional[List[bool]]" = None, kinsoku: bool = False) -> "List[str]":
|
|
639
671
|
"""The greedy fill on its own: the line count every mode must keep.
|
|
640
672
|
|
|
641
673
|
`slice_overlong` (off by default -- only caption.py's final burn-in pass turns it on, never
|
|
@@ -647,7 +679,10 @@ def _greedy_chunks(raw: str, max_em: float, *, slice_overlong: bool = False,
|
|
|
647
679
|
`broken_inside_word` without re-deriving it from the output lines.
|
|
648
680
|
|
|
649
681
|
`gaps`, when given, is filled with one flag per break: did a space stand there in `raw`? The
|
|
650
|
-
passes in _balance() carry it, so a break they move keeps (or drops) exactly that space.
|
|
682
|
+
passes in _balance() carry it, so a break they move keeps (or drops) exactly that space.
|
|
683
|
+
|
|
684
|
+
`kinsoku` (phrase mode) never breaks before a no-line-start or after a no-line-end character
|
|
685
|
+
(_kinsoku_atoms); `measured` keeps 1.15's fill."""
|
|
651
686
|
current = ""
|
|
652
687
|
current_gap = False # did a space stand before `current`'s first atom
|
|
653
688
|
chunk: "List[str]" = []
|
|
@@ -671,7 +706,7 @@ def _greedy_chunks(raw: str, max_em: float, *, slice_overlong: bool = False,
|
|
|
671
706
|
return pieces[-1], False
|
|
672
707
|
return atom, spaced
|
|
673
708
|
|
|
674
|
-
for atom, spaced in _atoms(raw):
|
|
709
|
+
for atom, spaced in (_kinsoku_atoms(_atoms(raw)) if kinsoku else _atoms(raw)):
|
|
675
710
|
if not current:
|
|
676
711
|
current, current_gap = start_line(atom, spaced)
|
|
677
712
|
continue
|
|
@@ -699,7 +734,7 @@ def _balance(chunk: "List[str]", max_em: float, mode: str, lang: "Optional[str]"
|
|
|
699
734
|
fixed = _fix_orphans(chunk, max_em, gaps)
|
|
700
735
|
rebalanced = _rebalance(fixed, max_em, list(gaps) if gaps is not None else None)
|
|
701
736
|
else:
|
|
702
|
-
fixed = _fix_weak_lines(_fix_orphans(chunk, max_em, gaps), max_em, gaps)
|
|
737
|
+
fixed = _fix_weak_lines(_fix_orphans(chunk, max_em, gaps, kinsoku=True), max_em, gaps)
|
|
703
738
|
later = list(gaps) if gaps is not None else None
|
|
704
739
|
rebalanced, _moved = _rebalance_phrase(fixed, max_em, lang, later)
|
|
705
740
|
rebalanced = _fix_weak_lines(rebalanced, max_em, later)
|
|
@@ -729,7 +764,8 @@ def wrap_text(text: str, max_em: float, *, balance: bool = True, mode: str = "ph
|
|
|
729
764
|
if not raw.strip():
|
|
730
765
|
continue
|
|
731
766
|
gaps: "List[bool]" = []
|
|
732
|
-
chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps
|
|
767
|
+
chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps,
|
|
768
|
+
kinsoku=mode == "phrase")
|
|
733
769
|
lines.extend(_balance(chunk, max_em, mode, lang, gaps) if balance else chunk)
|
|
734
770
|
return lines or [text]
|
|
735
771
|
def wrap_variants(text: str, max_em: float, *, mode: str = "phrase",
|
|
@@ -750,11 +786,17 @@ def wrap_variants(text: str, max_em: float, *, mode: str = "phrase",
|
|
|
750
786
|
if not raw.strip():
|
|
751
787
|
continue
|
|
752
788
|
gaps: "List[bool]" = []
|
|
753
|
-
chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps
|
|
789
|
+
chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps,
|
|
790
|
+
kinsoku=mode == "phrase")
|
|
754
791
|
greedy.extend(chunk)
|
|
755
792
|
wrapped.extend(_balance(list(chunk), max_em, mode, lang, gaps))
|
|
756
|
-
|
|
757
|
-
|
|
793
|
+
if mode == "measured":
|
|
794
|
+
measured.extend(chunk)
|
|
795
|
+
else:
|
|
796
|
+
# 1.15's wrap fills without the kinsoku joins (A54), so it is filled on its own
|
|
797
|
+
plain_gaps: "List[bool]" = []
|
|
798
|
+
plain = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, gaps=plain_gaps)
|
|
799
|
+
measured.extend(_balance(plain, max_em, "measured", None, plain_gaps))
|
|
758
800
|
if not greedy:
|
|
759
801
|
greedy = [text]
|
|
760
802
|
return (wrapped or [text], greedy, measured or [text])
|
package/scripts/_contract.py
CHANGED
|
@@ -105,7 +105,9 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
105
105
|
optional=[HDR_X265, {"capability": "filter:delogo", "when": "--mode delogo"}],
|
|
106
106
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
107
107
|
"waveform": dict(role="execution", inputs=["audio or video asset (audio track only)"], outputs=["generated waveform or spectrum visualization video artifact"],
|
|
108
|
-
required=FF + [X264, AAC, "filter:showwaves"], optional=[{"capability": "filter:showspectrum", "when": "--style spectrum"}
|
|
108
|
+
required=FF + [X264, AAC, "filter:showwaves"], optional=[{"capability": "filter:showspectrum", "when": "--style spectrum"},
|
|
109
|
+
{"capability": "filter:drawtext", "when": "--title (graphics.py draws it)"},
|
|
110
|
+
{"capability": "filter:subtitles", "when": "--srt (caption.py burns it)"}],
|
|
109
111
|
video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
110
112
|
"sphere": dict(role="execution", inputs=["360/spherical video asset"], outputs=["flat rectilinear video artifact of the chosen viewport"],
|
|
111
113
|
required=FF + [X264, AAC, "filter:v360"], optional=[HDR_X265],
|
|
@@ -133,7 +135,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
133
135
|
{"capability": "filter:scdet", "when": "--auto-chapters --from scenes|both"}],
|
|
134
136
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="bit_exact", deterministic=True),
|
|
135
137
|
"loop": dict(role="execution", inputs=["video asset"], outputs=["video artifact repeated to the requested count or duration (--boomerang: played forward then backward, silent)"],
|
|
136
|
-
required=FF + [X264, AAC], optional=[{"capability": "filter:reverse", "when": "--boomerang"}, {"capability": "filter:loop", "when": "--boomerang"}, {"capability": "filter:xfade", "when": "--crossfade"}, {"capability": "encoder:ffv1", "when": "--crossfade"}, {"capability": "encoder:flac", "when": "--crossfade"}],
|
|
138
|
+
required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:reverse", "when": "--boomerang"}, {"capability": "filter:loop", "when": "--boomerang"}, {"capability": "filter:xfade", "when": "--crossfade"}, {"capability": "encoder:ffv1", "when": "--crossfade"}, {"capability": "encoder:flac", "when": "--crossfade"}],
|
|
137
139
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
138
140
|
"insert": dict(role="execution", inputs=["still image"], outputs=["silent video artifact of the requested duration / frame size / fps"],
|
|
139
141
|
required=FF + [X264], optional=[{"capability": "filter:zoompan", "when": "--zoom / --pan"}],
|
|
@@ -158,7 +160,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
158
160
|
required=FF + [X264, AAC], optional=[{"capability": "filter:drawtext", "when": "--text"}, {"capability": "filter:chromakey", "when": "--chromakey"}, HDR_X265],
|
|
159
161
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
160
162
|
"graphics": dict(role="execution", inputs=["video asset"], outputs=["video artifact with the drawn template"],
|
|
161
|
-
required=FF + [X264, AAC, "filter:drawtext"],
|
|
163
|
+
required=FF + [X264, AAC, "filter:drawtext"],
|
|
164
|
+
optional=[HDR_X265, {"capability": "filter:ass", "when": "--text-render ass, a script drawtext cannot shape, or emoji drawn through libass or as PNGs"}],
|
|
162
165
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
163
166
|
"sync": dict(role="analysis_and_execution", inputs=["reference recording (video or audio)", "second recording (video or audio)"], outputs=["offset / drift JSON on stdout", "aligned artifact with --replace-audio / --trim-second / --fix-drift -o"],
|
|
164
167
|
required=FF, optional=[{"capability": AAC, "when": "writing a video container"}] + AUDIO_OUT,
|
|
@@ -201,7 +204,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
201
204
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
202
205
|
"export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
|
|
203
206
|
required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores and dnxhr-* use pcm_s16le, copy stream-copies, gif has no audio)"},
|
|
204
|
-
{"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"}, {"capability": "encoder:dnxhd", "when": "preset dnxhr-lb / dnxhr-sq / dnxhr-hq / dnxhr-hqx / dnxhr-444"}, {"capability": "encoder:prores_ks", "when": "preset prores4444 / prores4444xq / key-fill"}, {"capability": "encoder:qtrle", "when": "preset qtrle"}, {"capability": "encoder:libvpx-vp9", "when": "preset vp9-alpha"}, {"capability": "encoder:hap", "when": "preset hap-alpha (an ffmpeg built with snappy)"}, {"capability": "filter:alphaextract", "when": "an alpha preset (the alpha is read back)"},
|
|
207
|
+
{"capability": X265, "when": "preset h265 / youtube-hdr"}, {"capability": "encoder:libsvtav1", "when": "preset youtube-av1 (libaom-av1 is the fallback)"}, {"capability": "encoder:prores_ks", "when": "preset prores"}, {"capability": "encoder:dnxhd", "when": "preset dnxhr-lb / dnxhr-sq / dnxhr-hq / dnxhr-hqx / dnxhr-444"}, {"capability": "encoder:prores_ks", "when": "preset prores4444 / prores4444xq / key-fill"}, {"capability": "encoder:qtrle", "when": "preset qtrle"}, {"capability": "encoder:libvpx-vp9", "when": "preset vp9-alpha"}, {"capability": "encoder:hap", "when": "preset hap-alpha (an ffmpeg built with snappy)"}, {"capability": "filter:alphaextract", "when": "an alpha preset (the alpha is read back)"},
|
|
205
208
|
{"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur or --fit blur"},
|
|
206
209
|
{"capability": "filter:eq", "when": "--fit blur (the background copy is dimmed)"},
|
|
207
210
|
{"capability": "filter:loudnorm", "when": "preset youtube / youtube4k / reels / x with audio: the written file is measured against the platform's loudness target (result `loudness`)"}],
|
|
@@ -423,7 +426,7 @@ DIAGNOSTICS_SCHEMA = {
|
|
|
423
426
|
# failure it is). Every refuse() call in scripts/ names one of these (a test reads the sources).
|
|
424
427
|
ERROR_REASONS = {
|
|
425
428
|
"disk_full": "kind ffmpeg: ffmpeg reported \"No space left on device\" while writing; the hint says to free space or write elsewhere; details: {}",
|
|
426
|
-
"filter_missing": "kind ffmpeg: this ffmpeg build lacks a filter the command
|
|
429
|
+
"filter_missing": "kind ffmpeg: this ffmpeg build lacks a filter the command names: for a command run() is given (not a run_analysis() measurement pass), refused before ffmpeg runs, dry runs included, when the build's filter list was read and lacks it, else ffmpeg's own \"No such filter\"; details: {name}; the hint points at doctor",
|
|
427
430
|
"input_incomplete": "kind ffmpeg: the input has no index (\"moov atom not found\"): a recording cut off or a copy not finished; --repair cannot rebuild it; details: {}",
|
|
428
431
|
"input_unreadable": "kind ffmpeg: ffmpeg could not read the input as media (\"Invalid data found when processing input\"); details: {}",
|
|
429
432
|
"ffmpeg_unsupported": "kind missing_tool: the ffmpeg/ffprobe on PATH reports a version older than 6.1, which 3.0.0 no longer supports (5.x was deprecated in 2.14.0); details: {version, required}",
|
|
@@ -453,7 +456,7 @@ ERROR_REASONS = {
|
|
|
453
456
|
"plan_field_invalid": "render.py PLAN: inputs, unbound_inputs, verify (objects), commands (strings) or cwd has the wrong JSON type; "
|
|
454
457
|
"details: {plan, plan_sha256, checked_at, field, field_type, audit_log}",
|
|
455
458
|
"decoder_missing": "color.py / graphics.py / overlay.py: a VP9 source or --video layer stored with an alpha layer, on a build with no libvpx-vp9 decoder (FFmpeg's own VP9 decoder drops the alpha), kind missing_tool; details: {name}",
|
|
456
|
-
"encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or
|
|
459
|
+
"encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or a command run() is given names an encoder the build lacks for a stream the output keeps (an HDR source kept as HEVC, --codec hevc, a preset's encoder; not -c:a for a source with no sound, or with -an): refused before ffmpeg runs, dry runs included, when the build's encoder list was read and lacks it, else ffmpeg's own \"Unknown encoder\" (kind ffmpeg; details: {name}; the hint points at doctor)",
|
|
457
460
|
"asr_engine_missing": "kind missing_tool: --transcribe (caption.py; silence.py with --filler or a sentence flag) found no local speech-to-text engine; the message lists the install lines; details: {}",
|
|
458
461
|
"encoder_list_unreadable": "--codec av1/prores: the build's encoder list could not be read, so whether it has the encoder is unknown (kind ffmpeg); details: {codec, encoders, error}",
|
|
459
462
|
"audio_too_short": "kind input: loudness.py: the audio has a true peak but is shorter than one 400 ms gating block, so it has no integrated loudness to normalise from (not silence); details: {input_tp}",
|
|
@@ -483,7 +486,8 @@ DIAGNOSTIC_CODES = {
|
|
|
483
486
|
"av1_encoder_libaom": ("fallbacks", "--codec av1 on a build without libsvtav1: libaom-av1 encoded instead; {wanted, used}"),
|
|
484
487
|
"crf_clamped": ("warnings", "the CRF handed to the encoder was above its maximum and was capped (the HEVC HDR line adds 2); {encoder, requested, asked, used}"),
|
|
485
488
|
# fonts and text
|
|
486
|
-
"emoji_not_drawn": ("warnings", "caption.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; {count, reason}"),
|
|
489
|
+
"emoji_not_drawn": ("warnings", "caption.py / graphics.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; also graphics.py --text-render drawtext, which has no font fallback; {count, reason}"),
|
|
490
|
+
"emoji_mono_drawn_in_colour": ("warnings", "caption.py / graphics.py --emoji mono on a build whose libass draws the emoji face in colour: nothing makes the glyph monochrome, so the result's emoji.mode is color (graphics.py --text-render drawtext draws none: mode none, emoji_not_drawn)"),
|
|
487
491
|
"hdr10_metadata_dropped": ("warnings", "a PQ output measured without the source's HDR10 mastering-display and/or content-light metadata (an encoder with no option for it, e.g. prores_ks or libaom on FFmpeg before 7); {dropped, codec}"),
|
|
488
492
|
"source_items_dropped": ("warnings", "the written file lacks a subtitle track, chapters, a cover picture or surround channels the first input carried (source_carry lists everything kept and dropped, tags included); not repeated for a subtitle track the tool already reports through dropped_non_av_streams; {dropped}"),
|
|
489
493
|
"audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams). Not compared when two or more inputs have sound (grid --audio-from, a mix): which source the output carries is the tool's choice; {source, kept, dropped}"),
|
|
@@ -497,9 +501,10 @@ DIAGNOSTIC_CODES = {
|
|
|
497
501
|
"font_path_not_ascii": ("warnings", "a --font-file under a non-ASCII path could not be given an ASCII form on Windows; drawtext may not open it; {font}"),
|
|
498
502
|
"fonts_dir_lacks_script": ("fallbacks", "no face in --fonts-dir covers the text's script, so one was picked by script; {fonts_dir, script}"),
|
|
499
503
|
"font_variant_missing": ("fallbacks", "--lang named Traditional Chinese but no Traditional face is installed, so a Simplified one draws it (some glyph forms differ); {lang, file}"),
|
|
500
|
-
"emoji_monochrome": ("warnings", "emoji drawn
|
|
504
|
+
"emoji_monochrome": ("warnings", "caption.py / graphics.py: emoji drawn monochrome through libass (no colour path here). overlay.py --text: emoji left to the one font drawtext loads, which has no fallback, so a font without the glyph draws an empty box"),
|
|
505
|
+
"words_outside_input": ("warnings", "silence.py --filler --words: word timings that start at or after the end of the input (a transcript of another file, or times in milliseconds); {count, words, duration}"),
|
|
501
506
|
"emoji_stripped": ("warnings", "overlay.py --emoji none: emoji removed from the drawn text; {mode}"),
|
|
502
|
-
"emoji_assets_missing": ("warnings", "--emoji-assets has no PNG for these clusters; the text
|
|
507
|
+
"emoji_assets_missing": ("warnings", "--emoji-assets has no PNG for these clusters; they stay in the text for libass, and the message says whether libass draws an emoji glyph on this build; {missing}"),
|
|
503
508
|
"drawtext_newline_glyph": ("warnings", "graphics.py --text-render drawtext on FFmpeg 8.0-8.0.1/8.1.0: a box is drawn at each line break of a multi-line label; {text_render}"),
|
|
504
509
|
"timeline_parse_failed": ("warnings", "render.py --export-timeline: the written file does not read back as its format (the parse step is ok: false); {path, format, error}"),
|
|
505
510
|
# captions
|