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.
@@ -43,12 +43,30 @@ MOTION_CENTRE_W, MOTION_CENTRE_H = 64, 36
43
43
  CROP_RE = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
44
44
 
45
45
 
46
+ def round_rect(rect: Tuple[int, int, int, int], round_to: int, full: Tuple[int, int]) -> Tuple[int, int, int, int]:
47
+ """One measured rectangle (w, h, x, y) as it is reported: the whole frame when the picture
48
+ reaches every edge (cropdetect's own round=2 reading of an odd side is one pixel short of it),
49
+ else rounded down to a multiple of `round_to` and re-centred the way cropdetect's round option
50
+ does it. Rounding inside the filter made a bar-free 1920x1080 read 1920x1072 at the default 16
51
+ (#403): 1080 is not a multiple of 16, and the rounded size was then compared with the source's."""
52
+ w, h, x, y = rect
53
+ fw, fh = full
54
+ if fw and fh and x == 0 and y == 0 and w >= fw - fw % 2 and h >= fh - fh % 2:
55
+ return fw, fh, 0, 0
56
+ # the filter's own reading of its round option: 1 is 16, an odd value is doubled
57
+ round_to = 16 if round_to <= 1 else round_to * 2 if round_to % 2 else round_to
58
+ shrink_w, shrink_h = w % round_to, h % round_to
59
+ return w - shrink_w, h - shrink_h, x + ((shrink_w // 2 + 1) & ~1), y + ((shrink_h // 2 + 1) & ~1)
60
+
61
+
46
62
  def detect(path: str, seconds: float, samples: int, limit: float, round_to: int, duration: float,
47
- failed: "Optional[List[Dict]]" = None, square: str = "") -> List[Tuple[int, int, int, int]]:
63
+ failed: "Optional[List[Dict]]" = None, square: str = "", full: Tuple[int, int] = (0, 0)) -> List[Tuple[int, int, int, int]]:
48
64
  """Crop rectangles from `samples` windows spread over the file. A window ffmpeg cannot decode
49
65
  is skipped, and appended to `failed` as {start, seconds, error} when the caller passes a list,
50
66
  so the result can say how many of the readings it stands on. `square` is the source's
51
- square_pixels() scale, run first so the rectangle is in the frame crop.py crops (#411)."""
67
+ square_pixels() scale, run first so the rectangle is in the frame crop.py crops (#411).
68
+ `full` is that frame's size: the filter measures at round=2 and round_rect() rounds each
69
+ reading, so a picture that fills the frame is reported as the frame."""
52
70
  ffmpeg = require_tool("ffmpeg")
53
71
  per_window = max(0.5, seconds / max(1, samples))
54
72
  rects: List[Tuple[int, int, int, int]] = []
@@ -57,7 +75,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
57
75
  start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
58
76
  start = max(0.0, start)
59
77
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
60
- "-map", f"0:{PROBED_VIDEO}", "-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
78
+ "-map", f"0:{PROBED_VIDEO}", "-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round=2:reset=1", "-f", "null", "-"]
61
79
  proc = run_analysis(cmd, check=False)
62
80
  if proc.returncode != 0:
63
81
  # One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
@@ -67,7 +85,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
67
85
  failed.append({"start": round(start, 3), "seconds": round(per_window, 3), "error": failures[-1][0][:300]})
68
86
  continue
69
87
  for m in CROP_RE.finditer(proc.stderr):
70
- rects.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))))
88
+ rects.append(round_rect((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))), round_to, full))
71
89
  if failures and len(failures) == samples:
72
90
  die(f"cropdetect could not decode any of the {samples} sampled windows: {failures[-1][0][:300]}", kind="ffmpeg")
73
91
  return rects
@@ -125,7 +143,7 @@ def main() -> int:
125
143
  ap.add_argument("--seconds", type=float, default=10.0, help="total seconds of footage to sample across the file (default 10)")
126
144
  ap.add_argument("--samples", type=int, default=5, help="number of windows spread across the file (default 5)")
127
145
  ap.add_argument("--limit", type=float, default=0.0941176, help="black-pixel threshold, 0..1 (default ~0.094, cropdetect's own default)")
128
- ap.add_argument("--round", type=int, default=16, dest="round_to", help="the reported width/height are rounded to a multiple of this (default 16)")
146
+ ap.add_argument("--round", type=int, default=16, dest="round_to", help="a detected crop's width/height are rounded down to a multiple of this (default 16); a picture that reaches every edge is reported as the whole frame, unrounded")
129
147
  ap.add_argument("--motion-centre", action="store_true",
130
148
  help="report the motion centroid per second, sampled the same way as the crop "
131
149
  "detection above (report only -- this tool never picks a reframe, it hands "
@@ -162,7 +180,7 @@ def main() -> int:
162
180
  # as the top code of the source's bit depth (255 for 8 bits, 1023 for 10).
163
181
  limit = args.limit if args.limit < 1 else float((1 << int(meta["video"].get("bit_depth") or 8)) - 1)
164
182
  rects = detect(args.input, args.seconds, args.samples, limit, args.round_to, duration, failed,
165
- square_pixels(meta["video"]))
183
+ square_pixels(meta["video"]), (sw, sh))
166
184
  # windows_failed: a window ffmpeg could not decode was skipped without a word, so a
167
185
  # crop measured on one window of five read as well-founded as one measured on all five
168
186
  result: Dict = {"file": args.input, "source_width": sw, "source_height": sh,
package/scripts/cut.py CHANGED
@@ -58,7 +58,7 @@ import tempfile
58
58
  import zlib
59
59
  from typing import List, Tuple
60
60
 
61
- from _common.runner import JsonTooDeep, fail_if_classified, read_json
61
+ from _common.runner import ffmpeg_failure_extra, JsonTooDeep, fail_if_classified, read_json
62
62
  from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
63
63
  from _common import require_tool
64
64
  from _common.probe import PROBED_AUDIO, PROBED_VIDEO, TS_EXTS, probed_maps, several_av_streams, signatures_match, stream_signature
@@ -950,7 +950,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
950
950
  fail_if_classified(proc) # a full disk fails the re-encode too: its error, not a retry (#415)
951
951
  info("stream copy failed, falling back to re-encode")
952
952
  return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta, reasons + ["copy_failed"], edit_list_ok)
953
- die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
953
+ die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
954
954
  out_meta = probe(dst) if not reencode and not STATE.dry_run and copy_t is None else None
955
955
  if out_meta is not None:
956
956
  # #367: a copy from MPEG-TS whose window holds no video keyframe writes no video at all
@@ -1222,7 +1222,7 @@ def join_from_source(src: str, segments: List[Tuple[float, float]], dst: str, me
1222
1222
  BEAT_RATE = 22050 # the decode rate the onset pass uses, matching scenes.py --beats
1223
1223
 
1224
1224
 
1225
- def _grid_from_source(path: str, min_confidence: float) -> "dict":
1225
+ def _grid_from_source(path: str, min_confidence: float, named: bool = False) -> "dict":
1226
1226
  """The beat grid of `path`: a scenes.py --json document if that is what it is, otherwise a
1227
1227
  media file to measure. Reading a document is how a caller avoids a second decode."""
1228
1228
  try:
@@ -1260,7 +1260,15 @@ def _grid_from_source(path: str, min_confidence: float) -> "dict":
1260
1260
  if isinstance(doc, dict):
1261
1261
  die(f"--snap-source {path}: this JSON has no beat_grid -- produce one with "
1262
1262
  "`scenes.py MUSIC --beats --json`", kind="input")
1263
+ if doc is not None:
1264
+ die(f"--snap-source {path}: this JSON is not a scenes.py document (it is a {type(doc).__name__}, "
1265
+ "not an object with beat_grid) -- produce one with `scenes.py MUSIC --beats --json`", kind="input")
1263
1266
  samples = decode_pcm_mono(path, BEAT_RATE, check=False)
1267
+ if not samples and named:
1268
+ # not "no reliable beat grid in this audio": nothing was decoded, so nothing was measured (#404)
1269
+ die(f"--snap-source {path}: no audio could be decoded from it, and it is not a scenes.py --beats --json "
1270
+ "document, so no beat grid was measured", kind="input",
1271
+ hint="pass the music bed (a media file with sound) or the JSON `scenes.py MUSIC --beats --json` prints")
1264
1272
  block = max(1, int(round(BEAT_RATE * 0.01)))
1265
1273
  env = rms_envelope(samples, block)
1266
1274
  # the envelope's real step is block / rate (220 / 22050 = 9.977 ms), not the 10 ms asked for:
@@ -1295,7 +1303,7 @@ def snap_segments(args, segments, meta, total):
1295
1303
  die("--min-confidence must be greater than 0: at 0 every grid is 'reliable', including "
1296
1304
  "one measured from noise, which is exactly what --snap beats must not cut to. Use "
1297
1305
  "--snap none if you do not want the points moved at all.", kind="input")
1298
- grid = _grid_from_source(source, args.min_confidence)
1306
+ grid = _grid_from_source(source, args.min_confidence, named=bool(args.snap_source))
1299
1307
  confidence = float(grid.get("confidence") or 0.0)
1300
1308
  tempo = grid.get("tempo_bpm")
1301
1309
  if confidence < args.min_confidence or not grid.get("beats"):
@@ -1880,7 +1888,7 @@ def main() -> int:
1880
1888
  requested_start=round(requested[0][0], 6) if len(requested) == 1 else None,
1881
1889
  requested_end=round(requested[0][1], 6) if len(requested) == 1 else None,
1882
1890
  requested_segments=[[round(s, 6), round(e, 6)] for s, e in requested] if len(requested) > 1 else None,
1883
- requested_duration=round(expected, 6), output_duration=round(got, 6) if got is not None else None,
1891
+ requested_duration=round(expected, 6), output_duration=round(got, 6) if got is not None and not STATE.dry_run else None,
1884
1892
  duration_delta_seconds=round(error_ms / 1000, 6) if error_ms is not None else None,
1885
1893
  mode=mode, keyframe_snapped=report["keyframe_snapped"], start_snapped=report["start_snapped"],
1886
1894
  reencode_reason=reencode_reason, least_exact_precision=report["least_exact_precision"],
package/scripts/export.py CHANGED
@@ -256,8 +256,9 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
256
256
  # than the picture, and the key has none
257
257
  pair["duration"] = [round(v.get("duration") or v.get("tagged_duration") or 0, 3) for v in (fv, kv)]
258
258
  same = all(a == b and a not in (None, 0) for a, b in pair.values())
259
- steps = [{"step": "key_fill_pair", "property": "frames_size_rate_length", "ok": same, "measured": pair,
260
- "provenance": "output_measurement"}]
259
+ # the two written files against each other: no expected value, so no provenance (#403: it said
260
+ # "output_measurement", which is not one of the contract's values)
261
+ steps = [{"step": "key_fill_pair", "property": "frames_size_rate_length", "ok": same, "measured": pair}]
261
262
  key_level = _luma_stats(key)
262
263
  level = _alpha_compare(key_level, source_alpha)
263
264
  steps.append({"step": "key_level", "property": "key_mean_vs_source_alpha", "ok": level["ok"], "comparison": level,
@@ -34,6 +34,8 @@ import sys
34
34
  from typing import List, Optional, Tuple
35
35
 
36
36
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
37
+ from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
38
+ from _common.drawtext import shaping_refusal
37
39
  from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
38
40
  color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
39
41
  ffmpeg_base, info, layer_base_chain, load_brand, overlay_format, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
@@ -288,8 +290,7 @@ def main(force_route: Optional[str] = None) -> int:
288
290
  f"({support['detail']}), so they would come out as empty boxes -- pass --emoji-assets DIR "
289
291
  "for colour, or --emoji none", kind="input")
290
292
  if args.text_render == "drawtext" and shaping:
291
- die(f"{LANGUAGE_NAMES.get(_script, _script)} text cannot be shaped by drawtext on any ffmpeg "
292
- "build (the marks are reordered by harfbuzz, which drawtext does not use): drop "
293
+ die(f"{LANGUAGE_NAMES.get(_script, _script)} text {shaping_refusal(_script)}: drop "
293
294
  "--text-render drawtext to render it through libass, or draw it with caption.py",
294
295
  kind="input")
295
296
  route = "ass" if (args.text_render == "ass" or
@@ -301,14 +302,26 @@ def main(force_route: Optional[str] = None) -> int:
301
302
  # mode "mono" from the drawtext route is a claim the frame does not keep. libass DOES have a
302
303
  # fallback chain, so an auto run routes there instead; a run that pinned --text-render
303
304
  # drawtext degrades to "none" (strip) and says so rather than drawing tofu.
304
- if emoji_mode == "mono" and route == "drawtext":
305
+ # "color" as much as "mono" (#403): an auto run on a build whose libass draws colour reported
306
+ # mode color from the drawtext route, which drew the empty box.
307
+ emoji_count = len(emoji_clusters(all_text))
308
+ emoji_names = sorted({emoji_codepoint_name(cl) for _i, cl in emoji_clusters(all_text)})
309
+ not_drawn = None
310
+ mono_in_colour = emoji_mode == "mono" and args.emoji == "mono" and bool(support.get("libass_color"))
311
+ if mono_in_colour:
312
+ emoji_mode = "color"
313
+ if emoji_mode in ("mono", "color") and route == "drawtext":
305
314
  if args.text_render == "auto":
306
315
  route = "ass"
307
316
  else:
308
317
  emoji_mode = "none"
309
- info("emoji: --text-render drawtext has no font fallback chain, so the cluster would "
310
- "be drawn as an empty box -- stripped from the text instead "
311
- "(--text-render auto renders it monochrome through libass)")
318
+ not_drawn = "--text-render drawtext has no font fallback chain, so each would be an empty box (--text-render auto draws them through libass)"
319
+ elif emoji_mode == "none" and args.emoji == "auto":
320
+ not_drawn = ("libass draws no emoji glyph on this build (an empty box at best)"
321
+ if support.get("libass_glyph") is False else "no face here draws them")
322
+ if mono_in_colour and emoji_mode == "color":
323
+ # said once the route is final: a pinned drawtext route strips them instead (mode none)
324
+ warn(emoji_mono_on_colour_note(support), "emoji_mono_drawn_in_colour")
312
325
  if emoji_mode == "mono":
313
326
  warn("emoji rendered monochrome (no colour path on this ffmpeg; "
314
327
  "--emoji-assets DIR for colour). " + support["detail"], "emoji_monochrome")
@@ -329,7 +342,13 @@ def main(force_route: Optional[str] = None) -> int:
329
342
  setattr(args, _attr, _strip_emoji(getattr(args, _attr, None)))
330
343
  all_text = " ".join(t for t in (args.name, args.title, args.subtitle, args.text,
331
344
  args.top, args.bottom) if t)
332
- info("emoji: stripped from the drawn text (--emoji none)")
345
+ if not_drawn:
346
+ # an auto or rerouted run did not get --emoji none: it says what it left out and why,
347
+ # as caption.py does (emoji_not_drawn), and counts what it left out
348
+ warn(f"emoji: {emoji_count} left out of the drawn text: {not_drawn} -- pass --emoji-assets DIR to draw them as PNGs",
349
+ "emoji_not_drawn", count=emoji_count, reason=support.get("detail"))
350
+ else:
351
+ info("emoji: stripped from the drawn text (--emoji none)")
333
352
  elements: List[dict] = []
334
353
 
335
354
  def ass_font_family() -> "Optional[str]":
@@ -712,10 +731,10 @@ def main(force_route: Optional[str] = None) -> int:
712
731
  f"(limit {_max}, --emoji-max raises it); ffmpeg's filter graph and the "
713
732
  "per-frame cost both grow linearly -- split the job, or use --emoji none", kind="input")
714
733
  if missing:
715
- warn("no PNG in the assets directory for " + ", ".join(missing), "emoji_assets_missing", missing=list(missing))
734
+ warn(emoji_missing_asset_note(list(missing), support), "emoji_assets_missing", missing=list(missing))
716
735
  if emoji_mode:
717
- emoji_result = {"mode": emoji_mode, "count": len(emoji_clusters(all_text)),
718
- "clusters": sorted(seen) or sorted({emoji_codepoint_name(cl) for _i, cl in emoji_clusters(all_text)}),
736
+ emoji_result = {"mode": emoji_mode, "count": emoji_count,
737
+ "clusters": sorted(seen) or emoji_names,
719
738
  "assets": emoji_assets, "missing": missing,
720
739
  "overlays": len(emoji_overlays)}
721
740
  ass_path = args.write_ass or os.path.splitext(output)[0] + "_gfx.ass"
@@ -730,8 +749,8 @@ def main(force_route: Optional[str] = None) -> int:
730
749
  text_overlay_ass(elements, play_w=W, play_h=H, path=ass_path, fonts_dir=ass_fonts_dir())
731
750
  info(f"wrote {ass_path} ({len(elements)} text elements, rendered through libass)")
732
751
  elif emoji_mode:
733
- emoji_result = {"mode": emoji_mode, "count": len(emoji_clusters(all_text)),
734
- "clusters": sorted({emoji_codepoint_name(cl) for _i, cl in emoji_clusters(all_text)}),
752
+ emoji_result = {"mode": emoji_mode, "count": emoji_count,
753
+ "clusters": emoji_names,
735
754
  "assets": emoji_assets, "missing": [], "overlays": 0}
736
755
 
737
756
  # The boxes with chroma (layers) go under the text, and the picture they and a converted colour
package/scripts/join.py CHANGED
@@ -278,10 +278,17 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
278
278
  return 0
279
279
 
280
280
 
281
+ # ffmpeg concat-demuxer directives other than `file`: they change what a `file` line plays
282
+ CONCAT_DIRECTIVES = frozenset({"inpoint", "outpoint", "duration", "stream", "exact_stream_id", "file_packet_metadata",
283
+ "file_packet_meta", "option", "stream_meta", "stream_codec", "stream_extradata", "chapter"})
284
+
285
+
281
286
  def read_list(path: str) -> List[str]:
282
287
  """--list FILE: one segment per line, in order, relative to the list file's own folder.
283
288
  Blank lines and # comments are ignored, and ffmpeg's concat-demuxer spelling
284
- (`file 'part 01.wav'`) is accepted, so a list written for `ffmpeg -f concat` works as is."""
289
+ (`file 'part 01.wav'`, the `ffconcat version` header) is accepted, so a list written for
290
+ `ffmpeg -f concat` works as is unless it trims: `inpoint`, `outpoint`, `duration` and the other
291
+ directives are refused by name, since nothing here applies them."""
285
292
  # the shared reader: a BOM, CRLF and a file that is not UTF-8 are read or refused as every
286
293
  # other text input is, and a line ends at "\n" only
287
294
  text = read_text_or_die(path, "--list")
@@ -295,6 +302,17 @@ def read_list(path: str) -> List[str]:
295
302
  line = line[5:].strip()
296
303
  if len(line) >= 2 and line[0] == line[-1] and line[0] in "'\"":
297
304
  line = line[1:-1].replace("'\\''", "'")
305
+ elif line.split()[0] == "ffconcat" and line.split()[1:2] == ["version"]:
306
+ continue # the concat demuxer's header line names no file
307
+ elif line.split()[0] in CONCAT_DIRECTIVES and not os.path.exists(os.path.join(base, line)):
308
+ # only `file` lines are read: a directive was taken for a path, reported as a missing
309
+ # (or, under --dry-run, pending) input, and with --on-missing skip its trim was lost.
310
+ # `stream` takes no argument (FFmpeg 6.1's concat demuxer); a line naming a file that is
311
+ # there is a path, whatever its first word
312
+ die(f"--list {path}: `{line}` is an ffmpeg concat directive, which join.py does not apply: the list is "
313
+ "read for its `file` lines only", kind="input",
314
+ hint="trim a clip with cut.py first and list the cut file (inpoint/outpoint/duration), "
315
+ "or remove the line")
298
316
  entries.append(line if os.path.isabs(line) else os.path.join(base, line))
299
317
  if not entries:
300
318
  # the failure this exists for: an upstream step produced nothing, and the join must
@@ -585,7 +603,7 @@ def main() -> int:
585
603
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
586
604
  ap.add_argument("inputs", nargs="*", help="two or more clips in order (or --list FILE)")
587
605
  ap.add_argument("--list", metavar="FILE", help="read the clips from FILE, one per line in order (paths relative to FILE; "
588
- "ffmpeg concat lines `file 'x.wav'` also accepted); an empty list is refused")
606
+ "ffmpeg concat lines `file 'x.wav'` also accepted, other concat directives refused); an empty list is refused")
589
607
  ap.add_argument("--on-missing", choices=["fail", "skip"], default="fail",
590
608
  help="a missing, empty or unreadable input: fail (default; every problem named at once) or skip "
591
609
  "(join the rest; each skipped input is reported under `skipped`)")
@@ -25,6 +25,7 @@ AUDIO_LENGTH_TOLERANCE = 0.03 # seconds: one AAC frame at 48 kHz is 0.021 s
25
25
  LUFS_TOLERANCE = 1.0 # LU: a normalised file within this of the requested -I is on target
26
26
  from _common.probe import PROBED_AUDIO, probed_maps, shorter_than_a_loudness_block
27
27
  from _common.emit import refuse
28
+ from _common.runner import ffmpeg_failure_extra
28
29
  from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
29
30
 
30
31
 
@@ -77,7 +78,7 @@ def measure(path: str, I: float, tp: float, lra: float) -> dict:
77
78
  proc = run_analysis(cmd, check=False, record=True)
78
79
  m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
79
80
  if proc.returncode != 0 or not m:
80
- die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
81
+ die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
81
82
  data = json.loads(m.group(0))
82
83
  data["too_short"] = False
83
84
  for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset"):
@@ -24,6 +24,7 @@ chapters.txt:
24
24
  1:03:00 Outro
25
25
  """
26
26
  import argparse
27
+ import re
27
28
  import os
28
29
  import sys
29
30
  import tempfile
@@ -49,9 +50,18 @@ def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
49
50
  if not line or line.startswith("#"):
50
51
  continue
51
52
  parts = line.split(None, 1)
53
+ vtt_header = not entries and line.upper().startswith("WEBVTT")
54
+ if vtt_header or (len(parts) > 1 and parts[1].startswith("-->")):
55
+ # a WebVTT header or an SRT/WebVTT timing line: read as chapters, its cue numbers and
56
+ # times became chapter starts and the run failed on their order, never naming the file (#404)
57
+ what = "a WebVTT header" if vtt_header else "a subtitle timing line"
58
+ die(f"{path}:{n}: `{line[:60]}` is {what}, so this is an SRT/WebVTT subtitle file, "
59
+ "not a chapter list (one chapter per line: `0:00 Intro`)", kind="input")
52
60
  # chapter files carry no fps, so hh:mm:ss:ff needs its @fps suffix; time_arg() says so
53
61
  start = time_arg(parts[0], f"{path}:{n}")
54
- title = parts[1].strip() if len(parts) > 1 else f"Chapter {len(entries) + 1}"
62
+ # `0:00 - Intro` (the form YouTube descriptions use): the separator is not part of the title
63
+ title = re.sub(r"^[-\u2013\u2014|:]\s+", "", parts[1].strip()) if len(parts) > 1 else ""
64
+ title = title or f"Chapter {len(entries) + 1}"
55
65
  if "\x00" in title:
56
66
  # FFmpeg ends a metadata value at NUL: "A\x00B" was written as "A", verified (the
57
67
  # sibling of A260 in render.py's inline chapters)
@@ -41,7 +41,8 @@ from _common.color import is_rgb
41
41
  from _common.decision import length_output_t
42
42
  from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
43
43
  has_alpha_plane, report as alpha_report, source_alpha, source_format, tag_args, video_line)
44
- from _common.probe import display_size, square_pixels, square_pixels_note
44
+ from _common.probe import display_size, refuse_not_a_still, square_pixels, square_pixels_note
45
+ from _common.drawtext import shaping_refusal
45
46
  from _common.wrap import wrap_text
46
47
  from _common import text_width_em
47
48
  from _common.fonts import font_choice, record_font_choice
@@ -323,8 +324,7 @@ def main() -> int:
323
324
  # refuse and name the two tools that render the script correctly through libass.
324
325
  _sc = detect_script(args.text, getattr(args, "lang", None))
325
326
  if needs_shaping(_sc):
326
- die(f"{LANGUAGE_NAMES.get(_sc, _sc)} text cannot be shaped by drawtext on any ffmpeg build "
327
- "(the marks are reordered by harfbuzz, which drawtext does not use): draw it with "
327
+ die(f"{LANGUAGE_NAMES.get(_sc, _sc)} text {shaping_refusal(_sc)}: draw it with "
328
328
  "caption.py (--text cues, burned through libass) or graphics.py (--template with "
329
329
  "--text-render ass) instead", kind="input")
330
330
  if has_emoji(args.text):
@@ -337,8 +337,12 @@ def main() -> int:
337
337
  args.text = args.text[:_i] + args.text[_i + len(_cl):]
338
338
  warn("emoji: stripped from the drawn text (--emoji none)", "emoji_stripped", mode="none")
339
339
  else:
340
- warn("emoji are drawn by the text font here (monochrome at best); "
341
- "caption.py/graphics.py composite colour PNGs with --emoji-assets", "emoji_monochrome")
340
+ # not "drawn by the text font": drawtext loads that one font and has no fallback, so
341
+ # a font without the glyph (DejaVu Sans, every script font) draws an empty box (#403)
342
+ warn("emoji are left to the text font here: drawtext loads that one font and has no fallback, so "
343
+ "a font without the glyph draws an empty box (monochrome at best with one). --emoji none "
344
+ "leaves them out; caption.py/graphics.py composite colour PNGs with --emoji-assets",
345
+ "emoji_monochrome")
342
346
  script_file = script_family = None
343
347
  if args.text and not args.font_file:
344
348
  # 1.12: non-Latin overlay text picks a font by script, so a title in Japanese, Korean,
@@ -440,7 +444,8 @@ def main() -> int:
440
444
  cmd = ffmpeg_base() + ["-i", args.input]
441
445
 
442
446
  if args.image:
443
- probe(args.image)
447
+ refuse_not_a_still(args.image, probe(args.image), "--image",
448
+ "--video composites a moving layer; `look.py FILE --at T` writes one frame of it as a PNG")
444
449
  chain: List[str] = ["format=rgba"]
445
450
  if args.scale_percent:
446
451
  chain.append(f"scale={int(vw * args.scale_percent / 100)}:-1")
package/scripts/probe.py CHANGED
@@ -73,6 +73,14 @@ def main() -> int:
73
73
  for r in results:
74
74
  if r.get("video"):
75
75
  r["levels"] = analyze_levels(r["file"])
76
+ transfer = r["video"].get("color_transfer")
77
+ if r["video"].get("hdr") and "error" not in r["levels"]:
78
+ # the guess is "flat and desaturated, yet tagged SDR": a PQ/HLG stream is not
79
+ # tagged SDR, and its code values were read as if they were (#403)
80
+ r["levels"]["looks_like_log"] = None
81
+ r["levels"]["reason"] = "not_measured: the stream is tagged HDR (%s), and the Log guess is made for an SDR-tagged picture" % (transfer or r["video"].get("hdr_format") or "HDR")
82
+ r["levels"]["note"] = ("the stream is tagged HDR (%s): these are its code values on an 8-bit scale, not display-referred SDR levels, "
83
+ "and whether it looks like Log was not judged" % (transfer or r["video"].get("hdr_format") or "HDR"))
76
84
 
77
85
  if args.field:
78
86
  for r in results:
package/scripts/render.py CHANGED
@@ -1090,6 +1090,18 @@ def _from_plan_cwd(output: Any) -> Any:
1090
1090
  return os.path.abspath(str(output)) if isinstance(output, str) and output else output
1091
1091
 
1092
1092
 
1093
+ def check_not_run(check_result: Optional[Dict[str, Any]]) -> Optional[str]:
1094
+ """Why check.py graded nothing, or None when it did: a document with no FAIL count that is
1095
+ failed or carries an error is a check that could not run (an unparsed document, check.py's own
1096
+ refusal), not a check the delivery failed (#403: both were reported as "check failed")."""
1097
+ if not check_result or check_result.get("failed"):
1098
+ return None
1099
+ err = check_result.get("error")
1100
+ if not err and check_result.get("status") != "failed":
1101
+ return None
1102
+ return str((err.get("message") if isinstance(err, dict) else err) or "check.py reported status failed with no rows")
1103
+
1104
+
1093
1105
  def execute_plan(plan: Dict[str, Any], path: str) -> int:
1094
1106
  """Run a plan written by `<tool> --plan FILE`: refuse if any fingerprinted input changed since
1095
1107
  the plan was made (the plan's commands would then describe a different edit), run the tool
@@ -1189,7 +1201,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
1189
1201
  # same command run by hand)
1190
1202
  if exit_code:
1191
1203
  failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
1192
- info(f"plan done: {output}, but the {check_result.get('platform') or 'content'} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""))
1204
+ unrun = check_not_run(check_result)
1205
+ info(f"plan done: {output}, but the {check_result.get('platform') or 'content'} check "
1206
+ + (f"could not run, so nothing was checked: {unrun}" if unrun else "failed" + (f": {', '.join(failed_rows)}" if failed_rows else "")))
1193
1207
  else:
1194
1208
  advised = guidance_warns(check_result)
1195
1209
  info(f"plan done: {output}" + (" — upload-limit guidance, the uploader checks the current limit: "
@@ -1197,7 +1211,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
1197
1211
  # the tool's own steps (export's loudness, ...) come in through absorb_stage() above, tagged
1198
1212
  # with `stage`, so `verified` is theirs and the check's together
1199
1213
  emit(output, plan=path, plan_sha256=digest, unbound_inputs=unbound, tool=tool, stages=[tool] + (["check"] if check_result else []), check=check_result, tool_result=doc,
1200
- verification=([{"step": "check", "ok": not exit_code, "platform": check_result.get("platform")}] if check_result else []))
1214
+ verification=([dict({"step": "check", "ok": not exit_code, "platform": check_result.get("platform")},
1215
+ **({"reason": "not_measured: " + check_not_run(check_result)} if check_not_run(check_result) else {}))]
1216
+ if check_result else []))
1201
1217
  return 0
1202
1218
 
1203
1219
 
@@ -2084,7 +2100,9 @@ def main() -> int:
2084
2100
  # The deliverable is written and verified, but it does not meet the requested platform
2085
2101
  # spec (or the check itself could not run): a failed delivery, reported as one.
2086
2102
  failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
2087
- die(f"rendered {output} but the {check_label} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""),
2103
+ unrun = check_not_run(check_result)
2104
+ die(f"rendered {output} but the {check_label} check "
2105
+ + (f"could not run, so nothing was checked: {unrun}" if unrun else "failed" + (f": {', '.join(failed_rows)}" if failed_rows else "")),
2088
2106
  kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
2089
2107
  probe=probe(output, role="output"))
2090
2108
  info(f"rendered {output} via {' → '.join(stages_done)}")
@@ -121,7 +121,7 @@ def sentence_words(args) -> "Tuple[list, str]":
121
121
  raw = parse_json(read_text_or_die(args.words, "--words"))
122
122
  except ValueError as exc:
123
123
  die(f"--words {args.words}: not readable JSON ({exc})", kind="input")
124
- words, _had_segments, _untimed = _words_from_transcript(raw)
124
+ words, _had_segments, _untimed = _words_from_words_file(args.words, raw)
125
125
  if not words:
126
126
  die(f"no word timings in {args.words}: the sentence flags need "
127
127
  '{"words": [{"word": ..., "start": ..., "end": ...}]} (or the same inside "segments").',
@@ -356,7 +356,15 @@ def resolve_filler(args, meta):
356
356
  except ValueError as exc:
357
357
  die(f"--words {args.words}: not readable JSON ({exc})", kind="input")
358
358
  source = f"whisper-json:{args.words}"
359
- words, had_segments, untimed = _words_from_transcript(raw)
359
+ words, had_segments, untimed = _words_from_words_file(args.words, raw)
360
+ length = float(meta.get("duration") or 0.0)
361
+ late = [w for w in words if w["start"] >= length] if length > 0 else []
362
+ if late:
363
+ # nothing said it: the cuts were planned on times the input does not have (#404)
364
+ warn(f"--words {args.words}: {len(late)} of {len(words)} word timings start at or after the end of the input "
365
+ f"({length:.3f}s; the last at {max(w['start'] for w in late):g}s) -- a transcript of another file, or "
366
+ "times in milliseconds (a whisper JSON's start/end are seconds); no word is cut there",
367
+ "words_outside_input", count=len(late), words=len(words), duration=round(length, 3))
360
368
  if not words:
361
369
  if had_segments:
362
370
  die(f"the transcript in {args.words} has segment timings but no word timings; "
@@ -423,7 +431,11 @@ def resolve_filler(args, meta):
423
431
  if primary in names:
424
432
  return names[primary]
425
433
  return primary[:2] if len(primary) > 3 else primary
426
- spoken = _code((raw or {}).get("language")) if isinstance(raw, dict) else ""
434
+ # openai-whisper's top-level `language`, or whisper.cpp's `result.language` (its
435
+ # --output-json-full document is read for its words too: _words_from_words_file())
436
+ result = raw.get("result") if isinstance(raw, dict) else None
437
+ spoken = _code(raw.get("language") or (result.get("language") if isinstance(result, dict) else None)) \
438
+ if isinstance(raw, dict) else ""
427
439
  spoken = spoken or _code(transcription.get("language") or transcription.get("detected_language"))
428
440
  if spoken and spoken not in FILLER_WORDS and not args.filler_words:
429
441
  die(f"the transcript is in {spoken}, which has no built-in filler list. The languages with one are "
@@ -482,6 +494,18 @@ def resolve_filler(args, meta):
482
494
  return block, [(s["start"], s["end"]) for s in spans]
483
495
 
484
496
 
497
+ def _words_from_words_file(path, data):
498
+ """_words_from_transcript() for a --words file, plus whisper.cpp's own shape: its
499
+ --output-json-full document (transcription[].tokens[], offsets in milliseconds) is what the
500
+ refusals here tell a caller to make, and it was then refused for having no word timings (#404).
501
+ Read as --transcribe reads the same file."""
502
+ words, had_segments, untimed = _words_from_transcript(data)
503
+ if not words and isinstance(data, dict) and isinstance(data.get("transcription"), list):
504
+ words = _asr._words_from_whisper_cpp_json(path)
505
+ had_segments = had_segments or bool(data["transcription"])
506
+ return words, had_segments, untimed
507
+
508
+
485
509
  def _words_from_transcript(data):
486
510
  """([{word,start,end}], whether the document had segments at all, [{word}] of the words
487
511
  that carried no usable timing) from a whisper JSON. The third list lets --filler count a
@@ -28,7 +28,7 @@ import tempfile
28
28
  from pathlib import Path
29
29
 
30
30
  from _common.probe import PROBED_VIDEO
31
- from _common.runner import default_selection_maps
31
+ from _common.runner import ffmpeg_failure_extra, default_selection_maps, require_filter
32
32
  from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
33
33
 
34
34
 
@@ -57,6 +57,10 @@ def main() -> int:
57
57
  meta = probe(args.input)
58
58
  if not meta.get("video"):
59
59
  die("input has no video stream")
60
+ # pass 1 is a measurement that runs under --dry-run too: refused here, with the reason a run
61
+ # that met "No such filter" gets, instead of as an unclassified pass-1 failure (#402)
62
+ for name in ("vidstabdetect", "vidstabtransform"):
63
+ require_filter(name, "stabilizing (vid.stab)")
60
64
  has_audio = bool(meta.get("audio"))
61
65
  output = args.output or default_output(args.input, "stab")
62
66
 
@@ -89,7 +93,7 @@ def main() -> int:
89
93
  if not dry_run_input_pending(args.input):
90
94
  proc = run_analysis(detect_cmd, check=False, record=True)
91
95
  if proc.returncode != 0:
92
- die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
96
+ die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
93
97
 
94
98
  crop_mode = {"keep": 0, "black": 1}[args.crop]
95
99
  transform_vf = f"{even}vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
package/scripts/sync.py CHANGED
@@ -33,7 +33,7 @@ import sys
33
33
  from typing import Dict, List
34
34
 
35
35
  from _common.runner import fail_if_classified
36
- from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
36
+ from _common import STATE, video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
37
37
  from _common.emit import record_skip, warn
38
38
  from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, length_output_t, silence_filter, stream_starts
39
39
  from _common.timeline import output_timecode_args
@@ -259,8 +259,13 @@ def measure_one(reference: str, path: str, args) -> Dict:
259
259
  offset = offset + (drift_ratio - 1.0) * head_mid
260
260
  drift_info = {"residual_at_end_seconds": round(residual, 4), "measured_over_seconds": round(elapsed, 2),
261
261
  "drift_ppm": round((drift_ratio - 1) * 1e6, 1),
262
- "meaning": "second file runs %.1f ppm %s (%.3fs over %.0fs); it will be resampled to match" % (
263
- abs(drift_ratio - 1) * 1e6, "long/slow" if drift_ratio > 1 else "short/fast", abs(residual), elapsed),
262
+ # a measure-only run writes nothing (#403: it said "will be resampled")
263
+ "meaning": "second file runs %.1f ppm %s (%.3fs over %.0fs); %s" % (
264
+ abs(drift_ratio - 1) * 1e6, "long/slow" if drift_ratio > 1 else "short/fast", abs(residual), elapsed,
265
+ ("it would be resampled to match in the file this run plans (--dry-run writes nothing)" if STATE.dry_run
266
+ else "it is resampled to match in the file this run writes")
267
+ if args.replace_audio or args.trim_second else
268
+ "nothing is resampled in this measure-only run (--replace-audio or --trim-second writes the corrected file)"),
264
269
  "confidence": round(end_score, 3)}
265
270
  else:
266
271
  skipped_reason = "low_confidence"
@@ -36,7 +36,7 @@ import os
36
36
  import sys
37
37
 
38
38
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
39
- from _common.probe import SILENT_PEAK_DBFS, square_pixels, square_pixels_note
39
+ from _common.probe import SILENT_PEAK_DBFS, refuse_not_a_still, square_pixels, square_pixels_note
40
40
  from _common import still_input_args # noqa: E402
41
41
  from _common import (add_common, apply_common, aac_args, default_output, die, draws_chroma, emit, ffmpeg_base,
42
42
  info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
@@ -119,10 +119,13 @@ def main() -> int:
119
119
  "the image locally and pass its path", kind="input")
120
120
  if not os.path.exists(args.image):
121
121
  die(f"--image file not found: {args.image}", kind="input")
122
- if not STATE.dry_run:
123
- plate = probe(args.image, role="input")
124
- if not (plate.get("video") or {}).get("width"):
125
- die(f"--image is not an image ffmpeg can decode: {args.image}", kind="input")
122
+ # read under --dry-run too (the file exists, checked above): a plate the real run refuses
123
+ # is refused by the plan (#404: a dry run completed on a video given as --image)
124
+ plate = probe(args.image, role="input")
125
+ if not (plate.get("video") or {}).get("width"):
126
+ die(f"--image is not an image ffmpeg can decode: {args.image}", kind="input")
127
+ refuse_not_a_still(args.image, plate, "--image",
128
+ "`look.py FILE --at T` writes one frame of a video as a PNG")
126
129
  if args.background != "black":
127
130
  die("--image and a non-default --background exclude each other: the plate is either "
128
131
  "the picture or the colour", kind="input")
@@ -375,8 +378,12 @@ def _child(script_name: str, argv: "list") -> None:
375
378
  extra = {"hint": err["hint"]} if err.get("hint") else {}
376
379
  if doc.get("encoder"):
377
380
  extra["encoder"] = doc["encoder"]
381
+ # the stage's own reason and details ride along (#402: a caller keyed on error.reason saw
382
+ # filter_missing from caption.py and graphics.py run by hand, and nothing from here)
383
+ why = {k: err[k] for k in ("reason", "details") if err.get(k) is not None}
378
384
  die(f"{script_name} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
379
- code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script_name, **extra)
385
+ code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script_name,
386
+ error_extra=why or None, **extra)
380
387
 
381
388
  if __name__ == "__main__":
382
389
  sys.exit(main())