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.
@@ -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", r"^\s*[VAS][.\w]{5}\s+(\S+)")
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", r"^\s*(?:[A-Z.|]{1,6}\s+)?([A-Za-z0-9_]+)\s+\S*->\S*")
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
- _LIST_ERRORS.pop(flag, None)
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")
@@ -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 = "。、!?」』)" # a break AFTER one of these is preferred
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
- JA_NO_LINE_START = "ぁぃぅぇぉっゃゅょァィゥェォッャュョーヽヾゝゞ、。!?)」』】〕》’”%"
152
- JA_NO_LINE_END = "(「『【〔《‘“" # ... and the ones that may never end a line
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
- measured.extend(chunk if mode == "measured"
757
- else _balance(list(chunk), max_em, "measured", None, gaps))
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])
@@ -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"], optional=[HDR_X265],
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 used (\"No such filter\"); details: {name}; the hint points at doctor",
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 ffmpeg itself reported \"Unknown encoder\" for the encoder the command named (kind ffmpeg; details: {name}; the hint points at doctor)",
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 by the text font (monochrome at best): no colour path here"),
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 font draws them; {missing}"),
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