ffmpeg-skill 3.8.7 → 3.8.9

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")
@@ -2692,10 +2820,23 @@ def parse_json(text: str) -> Any:
2692
2820
  if isinstance(value, (dict, list)):
2693
2821
  if depth > JSON_MAX_DEPTH:
2694
2822
  raise JsonTooDeep(f"nested more than {JSON_MAX_DEPTH} levels deep")
2823
+ if isinstance(value, dict):
2824
+ stack.extend((k, depth) for k in value)
2695
2825
  stack.extend((v, depth + 1) for v in (value.values() if isinstance(value, dict) else value))
2826
+ elif isinstance(value, str) and _SURROGATE.search(value):
2827
+ # A264: "\ud83d" alone is valid JSON and no text: json reads it as a lone surrogate,
2828
+ # which no UTF-8 file, argument or stdout can encode -- the run died on the first write
2829
+ # with a UnicodeEncodeError traceback and nothing on stdout
2830
+ bad = _SURROGATE.search(value).group(0)
2831
+ raise ValueError(f"the string \"{value[:60].encode('ascii', 'backslashreplace').decode()}\" holds \\u{ord(bad):04x}, "
2832
+ "half of a UTF-16 surrogate pair with no other half: it is no character, so no file can "
2833
+ "hold it -- write the character itself, or both halves of its \\u escape")
2696
2834
  return data
2697
2835
 
2698
2836
 
2837
+ _SURROGATE = re.compile("[\ud800-\udfff]")
2838
+
2839
+
2699
2840
  def read_json(path: "os.PathLike[str] | str") -> Any:
2700
2841
  """parse_json() of a UTF-8 file. Raises OSError for a file that cannot be read and ValueError
2701
2842
  (UnicodeDecodeError is one) for anything else, so `except (OSError, ValueError)` covers it."""
@@ -142,11 +142,15 @@ def start_tc_frames(tc: Dict[str, Any], rate: Fraction) -> int:
142
142
 
143
143
  def _tc_label(frames: int, rate: Fraction, drop: bool) -> str:
144
144
  """`frames` as hh:mm:ss:ff at the nominal integer rate; drop-frame (`;`) for an NTSC source
145
- whose own timecode is drop-frame, so the label is the one the media itself shows."""
145
+ whose own timecode is drop-frame, so the label is the one the media itself shows. Timecode
146
+ wraps at 24 hours as the media's own does (output_timecode_args() counts the same day): a
147
+ source starting at 23:59:50:00 is labelled 00:00:00:00 ten seconds in, not 24:00:00:00, which
148
+ no editor reads (A266)."""
146
149
  nominal = int(round(float(rate)))
147
150
  if not _drop_frame(drop, rate):
148
- return _tc(frames, nominal)
151
+ return _tc(frames % (nominal * 86400), nominal) if nominal >= 1 else _tc(frames, nominal)
149
152
  d = nominal // 15
153
+ frames %= (nominal * 600 - d * 9) * 144
150
154
  per_10, per_min = nominal * 600 - d * 9, nominal * 60 - d
151
155
  tens, rem = divmod(frames, per_10)
152
156
  frames += d * 9 * tens + (d * ((rem - d) // per_min) if rem > d else 0)
@@ -376,6 +380,19 @@ def _tc(frames: int, fps_int: int) -> str:
376
380
  return f"{h:02d}:{m:02d}:{s:02d}:{f:02d}"
377
381
 
378
382
 
383
+ # CMX 3600 numbers events in a three-digit field, and record timecode runs from 01:00:00:00 within
384
+ # one 24-hour day.
385
+ EDL_MAX_EVENTS = 999
386
+
387
+
388
+ def _edl_text(value: Any) -> str:
389
+ """Caller text (a file name, a clip or chapter name, the title) on one EDL line. The format is
390
+ one statement per line with no escape, so a line break -- any str.splitlines() boundary -- or
391
+ another control character in a name added lines, or whole events, that parse_error() then
392
+ accepted (A261). Each becomes a space."""
393
+ return "".join(" " if (ch in "\x85\u2028\u2029" or ord(ch) < 32 or ord(ch) == 127) else ch for ch in str(value))
394
+
395
+
379
396
  def to_edl(tl: Dict[str, Any]) -> str:
380
397
  """CMX 3600, non-drop timecode at the nominal integer rate, record starting at 01:00:00:00.
381
398
  Every event is reel AX (the convention for file-based media) with the file named in
@@ -386,16 +403,24 @@ def to_edl(tl: Dict[str, Any]) -> str:
386
403
  from where the previous one stopped to where the next dissolve begins."""
387
404
  fps_int = int(round(float(tl["rate"])))
388
405
  hour = fps_int * 3600
406
+ rate = tl["rate"]
389
407
  clips = tl["clips"]
390
408
  d = tl["transition_frames"]
391
409
  # record range of each event: starts `trim_head` before its visible start (the dissolve
392
410
  # window), ends where the next event starts
393
411
  starts = [c["record_in"] - c["trim_head"] for c in clips]
394
412
  ends = starts[1:] + [tl["total"]]
395
- lines = [f"TITLE: {tl['name']}", "FCM: NON-DROP FRAME", ""]
413
+ events = len(clips) + (1 if tl["music"] else 0)
414
+ if events > EDL_MAX_EVENTS:
415
+ # A265: f"{n:03d}" wrote event 1000 as four digits, outside the field
416
+ raise TimelineError(f"an EDL numbers its events in three digits (at most {EDL_MAX_EVENTS}); this timeline has "
417
+ f"{events} -- export .fcpxml or .otio, or split the project")
418
+ if hour + tl["total"] > fps_int * 86400:
419
+ raise TimelineError(f"the timeline ({float(Fraction(tl['total']) / rate):.0f}s) runs past 24:00:00:00 when the "
420
+ "EDL's record starts at 01:00:00:00 -- export .fcpxml or .otio, or split the project")
421
+ lines = [f"TITLE: {_edl_text(tl['name'])}", "FCM: NON-DROP FRAME", ""]
396
422
  n = 0
397
423
  prev_src_end = ""
398
- rate = tl["rate"]
399
424
 
400
425
  def src_tc(c: Dict[str, Any], seq_frames: int) -> str:
401
426
  """The source timecode `seq_frames` (sequence frames of source media) after the clip's in
@@ -415,13 +440,13 @@ def to_edl(tl: Dict[str, Any]) -> str:
415
440
  f"{_tc(rec_in, fps_int)} {_tc(rec_in, fps_int)}")
416
441
  lines.append(f"{n:03d} AX {chan:<5} D {d:03d} {s_in} {s_out} "
417
442
  f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
418
- lines.append(f"* FROM CLIP NAME: {clips[i - 1]['name']}")
419
- lines.append(f"* TO CLIP NAME: {c['name']}")
443
+ lines.append(f"* FROM CLIP NAME: {_edl_text(clips[i - 1]['name'])}")
444
+ lines.append(f"* TO CLIP NAME: {_edl_text(c['name'])}")
420
445
  else:
421
446
  lines.append(f"{n:03d} AX {chan:<5} C {s_in} {s_out} "
422
447
  f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
423
- lines.append(f"* FROM CLIP NAME: {c['name']}")
424
- lines.append(f"* SOURCE FILE: {c['src']}")
448
+ lines.append(f"* FROM CLIP NAME: {_edl_text(c['name'])}")
449
+ lines.append(f"* SOURCE FILE: {_edl_text(c['src'])}")
425
450
  if abs(c["speed"] - 1.0) > 1e-6:
426
451
  # the speed in source frames per second, at the rate the source labels are counted in
427
452
  own_fps = int(round(float(c.get("rate") or rate)))
@@ -433,11 +458,11 @@ def to_edl(tl: Dict[str, Any]) -> str:
433
458
  m = tl["music"]
434
459
  lines.append(f"{n:03d} AX A2 C {_tc(0, fps_int)} {_tc(m['length'], fps_int)} "
435
460
  f"{_tc(hour, fps_int)} {_tc(hour + m['length'], fps_int)}")
436
- lines.append(f"* FROM CLIP NAME: {m['name']}")
437
- lines.append(f"* SOURCE FILE: {m['src']}")
461
+ lines.append(f"* FROM CLIP NAME: {_edl_text(m['name'])}")
462
+ lines.append(f"* SOURCE FILE: {_edl_text(m['src'])}")
438
463
  lines.append("")
439
464
  for mk in tl["markers"]:
440
- lines.append(f"* LOC: {_tc(hour + mk['at'], fps_int)} WHITE {mk['title']}")
465
+ lines.append(f"* LOC: {_tc(hour + mk['at'], fps_int)} WHITE {_edl_text(mk['title'])}")
441
466
  return "\n".join(lines).rstrip("\n") + "\n"
442
467
 
443
468
 
@@ -459,6 +484,14 @@ def _url(path: str) -> str:
459
484
  return Path(os.path.abspath(path)).as_uri()
460
485
 
461
486
 
487
+ def _xml_text(value: Any) -> str:
488
+ """Caller text as XML 1.0 can hold it: a control character other than tab, line feed and
489
+ carriage return (and U+FFFE/U+FFFF) has no representation at all, not even escaped, so a clip
490
+ or chapter name carrying one made the FCPXML not well-formed (the sibling of A261). Each
491
+ becomes a space."""
492
+ return "".join(" " if ((ord(ch) < 32 and ch not in "\t\n\r") or ch in "\ufffe\uffff") else ch for ch in str(value))
493
+
494
+
462
495
  def to_fcpxml(tl: Dict[str, Any]) -> str:
463
496
  """FCPXML 1.10 (Final Cut Pro 10.6+, DaVinci Resolve): one library/event/project, a spine of
464
497
  asset-clips with cross dissolves between them, the music bed as a connected clip on lane -1
@@ -466,7 +499,7 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
466
499
  sequence has its own format (r1); each video asset has its source's, and a clip of another
467
500
  aspect states its spatial conform (adjust-conform) for frame.fit."""
468
501
  rate = tl["rate"]
469
- q = lambda s: _xml_escape(str(s), {'"': "&quot;"}) # noqa: E731
502
+ q = lambda s: _xml_escape(_xml_text(s), {'"': "&quot;"}) # noqa: E731
470
503
  fd = _t(1, rate)
471
504
  res: List[str] = []
472
505
  ids: Dict[Any, str] = {} # a format's (width, height, frameDuration) or an asset's path -> its id
@@ -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
@@ -751,7 +756,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
751
756
  "guides": {"type": "array", "description": "--guides only: each outline drawn on the PNG, {guide: action|title, fraction (0.93 / 0.90 of the frame, SMPTE ST 2046-1), of: frame} or {guide: 4:3, window, action: 0.90, title: 0.80}; drawn on the preview only"}}
752
757
  elif name == "render":
753
758
  extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"], "description": "check.py's document for a \"check\" stage with a platform, \"content\": true, or both; when check.py printed none, a failure document with error.reason check_output_unparsed"},
754
- "cache": {"type": ["object", "null"], "description": "--cache: {dir, ffmpeg, hits, misses, miss_reasons: [{stage, reason: not_cached|sidecar_missing|artifact_missing|sidecar_corrupt|artifact_truncated|no_output_path, ...}], saved_seconds, entries, would_hit (--dry-run)}"},
759
+ "cache": {"type": ["object", "null"], "description": "--cache: {dir, ffmpeg, hits, misses, miss_reasons: [{stage, reason: not_cached|sidecar_missing|artifact_missing|sidecar_corrupt|artifact_truncated|no_output_path|destination_not_owned, ...}], saved_seconds, entries, would_hit (--dry-run)}"},
755
760
  "caption": {"type": ["object", "null"], "description": "the caption stage's own block, forwarded verbatim from caption.py (cue-layout counts and fit-size keys); null without a caption stage"},
756
761
  "snap": {"type": ["object", "null"], "description": "a project's \"snap\": cut.py's snap block for the first snapped clip, with every clip's in clips; null when nothing was snapped"},
757
762
  "intermediate_preset": {"type": ["string", "null"], "description": "2.15: the x264/x265 preset the stages before an encoding export ran at (--intermediate-preset, default superfast since 2.16); null when no stage received it (no encoding export reached, --fast, --stop-after before the export, a copy export, or nothing but the export encoded)"},
@@ -763,7 +768,10 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
763
768
  "tool_result": {"type": "object", "description": "plan execution: the tool's own --json document"},
764
769
  "timeline": {"type": "object", "description": "--export-timeline: format, rate, duration, frames, clips, transition, "
765
770
  "transition_frames, music, markers, notes, and not_exported (what the project asks for that an "
766
- "editor timeline cannot carry)"}}
771
+ "editor timeline cannot carry)"},
772
+ "templates": {"type": "array", "description": "--list-templates --json: one row per shipped template, {template, "
773
+ "destination, frame: {width, height} or null (audio), fit, max_duration, lufs, safe: {top, bottom, "
774
+ "left, right}}; without --json the same rows are a table"}}
767
775
  elif name == "verify":
768
776
  extra = {"report": {"type": ["string", "null"]},
769
777
  "files": {"type": "array", "description": "one entry per file: {file, probe, steps, skipped}. A step is {step, ok, seconds, error (one line, for the table)}, plus the tool's verified when it completed, and on a failure its own kind, reason, message, details and exit_code (reason step_timeout or step_output_unparsed when the tool printed no failure document); skipped lists the steps that do not apply as {step, reason: quick|no_video|no_audio|not_hdr|not_multichannel|input_unreadable, message}"},
package/scripts/batch.py CHANGED
@@ -37,7 +37,8 @@ from _common import STATE, add_common, apply_common, child_args, die, emit, info
37
37
  from _common.runner import (HW_FORCED_ENV, OWNED_ENV, _OutputLock, add_hw_orchestrator_args, copy_to_sibling, env_hw, read_json,
38
38
  owned_identity, stop_children)
39
39
  from _common.emit import absorb_stage_hw, audit, note, shown, steps_encoder_report, warn
40
- from _common.decision import rebase_project_paths
40
+ from _common.decision import NO_DEFAULT_ENCODE_EXT, rebase_project_paths
41
+ from _common.runner import parse_json
41
42
 
42
43
  HERE = Path(__file__).resolve().parent
43
44
  MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")} # one list (_common); a batch walks media, not stills
@@ -51,9 +52,16 @@ ALLOWED_STEP_SCRIPTS = {p.name for p in HERE.glob("*.py") if not p.name.startswi
51
52
 
52
53
 
53
54
  def file_key(path: Path) -> str:
55
+ """A cheap change detector: the name, the size, the modification time in nanoseconds and the
56
+ first and last MiB. A rewrite of the middle of a file keeps its size and its ends, so the
57
+ modification time is what tells it apart: in whole seconds (up to 3.8.4) two writes within
58
+ one second shared a key, and the old output was served as cached. A file whose mtime is reset
59
+ to the earlier value (``touch -r``, a copy that preserves it) or a filesystem that keeps
60
+ whole seconds (FAT keeps two) still shares one; hashing every byte would cost a read of the
61
+ whole file per item per run (docs/design-decisions.md, "After 3.8.4")."""
54
62
  st = path.stat()
55
63
  h = hashlib.sha1(usedforsecurity=False) # a change detector, not a secret
56
- h.update(f"{path.name}|{st.st_size}|{int(st.st_mtime)}".encode())
64
+ h.update(f"{path.name}|{st.st_size}|{st.st_mtime_ns}".encode())
57
65
  with open(path, "rb") as fh: # first and last MB: cheap and good enough to detect changes
58
66
  h.update(fh.read(1 << 20))
59
67
  if st.st_size > 2 << 20:
@@ -86,6 +94,30 @@ def side_inputs_key(recipe: Dict[str, Any]) -> str:
86
94
  return h.hexdigest()[:8]
87
95
 
88
96
 
97
+ def caseless_glob(pattern: str) -> str:
98
+ """`pattern` with every letter outside a [...] class matched in either case: "*.mp4" finds
99
+ GOPR0001.MP4, as it already did on macOS's and Windows' default filesystems, so a recipe means
100
+ the same files on every platform (A82: on Linux a camera folder was an empty batch). pathlib's
101
+ case_sensitive= needs Python 3.12; 3.10 is the floor."""
102
+ out: List[str] = []
103
+ in_class = False
104
+ first = -1 # index of the class's first member: a "]" there is a member, not the end ("[]a]", "[!]a]")
105
+ for i, ch in enumerate(pattern):
106
+ if in_class:
107
+ out.append(ch)
108
+ if ch == "]" and i > first:
109
+ in_class = False
110
+ elif ch == "[":
111
+ first = i + 2 if pattern[i + 1:i + 2] == "!" else i + 1
112
+ in_class = "]" in pattern[first + 1:] # an unclosed [ ("[!]x") is a literal character to fnmatch
113
+ out.append(ch if in_class else "[[]")
114
+ elif ch.isalpha() and ch.lower() != ch.upper():
115
+ out.append(f"[{ch.lower()}{ch.upper()}]")
116
+ else:
117
+ out.append(ch)
118
+ return "".join(out)
119
+
120
+
89
121
  def recipe_key(recipe: Dict[str, Any]) -> str:
90
122
  # A "project" recipe is just {"project": "<path>", "clip_key": N} -- the actual settings
91
123
  # (export preset, captions, everything) live in the file at that path, not in this dict.
@@ -93,18 +125,57 @@ def recipe_key(recipe: Dict[str, Any]) -> str:
93
125
  # itself) left the key, and so every cache hit, unchanged: a preset swapped from "copy" to
94
126
  # "x" (a real re-encode) still served the old cached output. Fold the referenced file's own
95
127
  # content into the key so a content change invalidates the cache like any other edit would.
128
+ # main() calls this on every pass, so a --watch loop sees an edit made while it runs (A81).
96
129
  project_content = ""
97
130
  if recipe.get("project"):
98
131
  try:
99
132
  project_content = Path(recipe["project"]).read_text(encoding="utf-8")
100
133
  except (OSError, UnicodeDecodeError) as exc:
101
134
  # keyed on the recipe alone: an unreadable project cannot invalidate the cache by its
102
- # content, so say so (each item that needs it then fails on reading it)
103
- warn(f"recipe.project {recipe['project']} could not be read ({exc}); the cache key covers the recipe only",
104
- "recipe_project_unreadable", path=str(recipe["project"]), error=str(exc))
135
+ # content, so say so (each item that needs it then fails on reading it); once per run
136
+ entry = {"code": "recipe_project_unreadable", "path": str(recipe["project"])}
137
+ if not any(all(w.get(k) == v for k, v in entry.items()) for w in STATE.warnings if isinstance(w, dict)):
138
+ warn(f"recipe.project {recipe['project']} could not be read ({exc}); the cache key covers the recipe only",
139
+ "recipe_project_unreadable", path=str(recipe["project"]), error=str(exc))
140
+ else:
141
+ project_content += "\0" + project_files_key(recipe, project_content)
105
142
  return hashlib.sha1((json.dumps(recipe, sort_keys=True) + "\0" + project_content).encode(), usedforsecurity=False).hexdigest()[:12]
106
143
 
107
144
 
145
+ def project_files_key(recipe: Dict[str, Any], text: str) -> str:
146
+ """The files a project recipe's project names (music, cues, the brand and its logo and font,
147
+ overlay images and videos, the chapters file, every clip but the one each item replaces),
148
+ each keyed by its own content as file_key() keys a source (A80): an edited cue file or music
149
+ bed changes the output, so it has to change the key. The list is render.py's own
150
+ project_input_files(), read against the project file's directory as render reads it. A
151
+ project that does not parse adds nothing: each item then fails reading it."""
152
+ try:
153
+ proj = parse_json(text)
154
+ except ValueError:
155
+ return ""
156
+ if not isinstance(proj, dict):
157
+ return ""
158
+ from render import project_input_files # here, not at the top: render.py imports this module
159
+ base = Path(str(recipe["project"])).resolve().parent
160
+ clips = proj.get("clips")
161
+ idx = int(recipe.get("clip_key") or 0) # check_recipe_types() has refused anything else
162
+ if isinstance(clips, list) and idx < len(clips) and isinstance(clips[idx], dict):
163
+ # the clip every item replaces with itself: its placeholder is no input of any item
164
+ proj = dict(proj, clips=[dict(c, src=None) if i == idx else c for i, c in enumerate(clips)])
165
+
166
+ def rel(p: Any) -> str:
167
+ return str(p) if os.path.isabs(str(p)) else str(base / str(p))
168
+ h = hashlib.sha1(usedforsecurity=False)
169
+ for name in sorted(set(project_input_files(proj, rel))):
170
+ path = Path(name)
171
+ try:
172
+ ident = file_key(path) if path.is_file() else "-"
173
+ except OSError:
174
+ ident = "-"
175
+ h.update(f"{name}\0{ident}\0".encode())
176
+ return h.hexdigest()
177
+
178
+
108
179
  def gpu_key() -> str:
109
180
  """The GPU setting the steps resolve, as a suffix for the item cache key: a VideoToolbox file is
110
181
  not the CPU's bytes, so a --hw output is never served to a --no-hw run or the other way round
@@ -222,6 +293,11 @@ def final_path(src: Path, recipe: Dict[str, Any], outdir: Path) -> Path:
222
293
  # detection of its own; see one_pass()'s pre-flight check, which uses this same computation
223
294
  # to catch that before any file is actually processed (and the earlier one silently clobbered).
224
295
  final_ext = recipe.get("ext") or src.suffix.lstrip(".") or "mp4"
296
+ if not recipe.get("ext") and src.suffix.lower() in NO_DEFAULT_ENCODE_EXT:
297
+ # the #415 default-name rule (default_output()): the steps re-encode to x264/AAC, which a
298
+ # WebM cannot hold, so every .webm item was refused output_container_codecs (A83). A
299
+ # recipe that keeps WebM (a stream copy, export.py --preset vp9-alpha) sets "ext".
300
+ final_ext = "mp4"
225
301
  steps = recipe.get("steps") or []
226
302
  if not recipe.get("ext") and not recipe.get("project") and steps and not step_writes(steps[-1]) and not writes_nothing(recipe):
227
303
  # the last step writes nothing, so what is delivered is an intermediate: its name says its
@@ -649,7 +725,6 @@ def main() -> int:
649
725
  audit("batch_cache_reset", {"path": str(cache_path), "size": size, "error": str(exc)},
650
726
  default_path=str(outdir / ".ffskill-audit.jsonl"))
651
727
  cache = {}
652
- rkey = recipe_key(recipe) + gpu_key()
653
728
  glob = recipe.get("glob") or "*"
654
729
 
655
730
  # --jobs: every item is itself an ffmpeg that already threads across cores, so beyond a few
@@ -706,6 +781,7 @@ def main() -> int:
706
781
  # first seen, last modified at least one interval ago; until then its row is
707
782
  # skipped: "settling", not cached, and the next pass looks again.
708
783
  last_seen: Dict[str, "tuple[int, int]"] = {}
784
+ shown_empty = {"done": False}
709
785
 
710
786
  def settle_poll(files: List[Path]) -> "set":
711
787
  if not args.watch:
@@ -728,7 +804,12 @@ def main() -> int:
728
804
  return unsettled
729
805
 
730
806
  def one_pass() -> List[Dict[str, Any]]:
731
- files = sorted(p for p in folder.glob(glob) if p.is_file() and p.suffix.lower() in MEDIA_EXT and outdir not in p.parents)
807
+ files = sorted(p for p in folder.glob(caseless_glob(glob)) if p.is_file() and p.suffix.lower() in MEDIA_EXT and outdir not in p.parents)
808
+ if not files and not shown_empty["done"]:
809
+ # a batch of nothing completed with total 0 and not a word (A82): say what matched nothing
810
+ shown_empty["done"] = True
811
+ note(f"no media file in {folder} matches the recipe's glob {glob!r} (matched without regard to case); "
812
+ "nothing was processed")
732
813
  # Two different sources can compute the same final path (most often a fixed recipe "ext"
733
814
  # collapsing e.g. clip.mp4 and clip.mov to the same clip_out.mp4) -- catch that before
734
815
  # processing anything, rather than letting the later one silently overwrite the earlier
@@ -805,7 +886,9 @@ def main() -> int:
805
886
  slots: "List[Optional[Dict[str, Any]]]" = [None] * len(files)
806
887
  pending: List[tuple] = []
807
888
  cache_miss: Dict[int, Dict[str, Any]] = {}
808
- pass_key = rkey + side_inputs_key(recipe)
889
+ # read on every pass: a --watch loop serves nothing from an edited project.json, cue file or
890
+ # music bed (computed once before the loop, it did: A81)
891
+ pass_key = recipe_key(recipe) + gpu_key() + side_inputs_key(recipe)
809
892
  polled = settle_poll(files)
810
893
  for i, src in enumerate(files):
811
894
  if str(src) in polled: