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.
@@ -46,6 +46,7 @@ from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_unit
46
46
  from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape, ass_escape_markup,
47
47
  strip_srt_markup, reinsert_srt_markup, carry_srt_markup, srt_alignment)
48
48
  from _common.emit import refuse # noqa: E402
49
+ from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
49
50
  from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, layer_base_chain, load_brand, overlay_format, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
50
51
  # The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
51
52
  # The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
@@ -103,6 +104,32 @@ def cue_times_match(line: str) -> "Optional[re.Match[str]]":
103
104
  # an SRT timing line: the times alone, the cue text on the lines after it
104
105
  DROP_FRAME_RE = re.compile(r"^\s*\d+:\d{2}:\d{2};\d+")
105
106
  SRT_TIMING_RE = re.compile(r"^\s*\d+:\d{2}:\d{2}[,.]\d+\s*-->\s*\d+:\d{2}:\d{2}[,.]\d+\s*$")
107
+ # the same line as WebVTT may write it, without the hours: `00:01.000 --> 00:02.000`
108
+ TIMING_ONLY_RE = re.compile(r"^\s*(?:\d+:)?\d{1,2}:\d{2}[,.]\d+\s*-->\s*(?:\d+:)?\d{1,2}:\d{2}[,.]\d+\s*$")
109
+ ASS_SECTIONS = ("[script info]", "[v4+ styles]", "[v4 styles]", "[events]")
110
+
111
+
112
+ def refuse_not_a_cue_file(path: str, lines: List[str]) -> None:
113
+ """--text given a file in another format (#404): read as a cue file, a WebVTT header, an ASS
114
+ section or a JSON document was burned in line by line, auto-timed 3 s apart. Named by what the
115
+ file starts with; the SRT timing-line check below catches a file with no such header."""
116
+ head = next((ln.strip() for ln in lines if ln.strip()), "")
117
+ cue_shape = "A cue file has one cue per line: `START-END text` (or text alone, timed --auto-seconds apart)"
118
+ if head.upper().startswith("WEBVTT"):
119
+ die(f"--text: {path} is a WebVTT subtitle file (it starts with {head[:20]!r}); pass it as --srt, which reads "
120
+ f"WebVTT (render.py: --srt, not --cues). {cue_shape}", kind="input")
121
+ if head.lower() in ASS_SECTIONS or head.startswith("Dialogue:"):
122
+ die(f"--text: {path} is an ASS/SSA subtitle file (it starts with {head[:20]!r}); pass it as --ass, which burns "
123
+ f"it with the styles inside it (in a render.py project, the captions stage's \"ass\" key). {cue_shape}",
124
+ kind="input")
125
+ if head[:1] in "{[":
126
+ try:
127
+ doc = json.loads("\n".join(lines))
128
+ except ValueError:
129
+ doc = None
130
+ if isinstance(doc, dict) or (isinstance(doc, list) and doc and isinstance(doc[0], (dict, list))):
131
+ die(f"--text: {path} is a JSON document, not a cue file. {cue_shape}. A whisper JSON is a transcript: "
132
+ "caption.py --transcribe writes the cues from the audio", kind="input")
106
133
 
107
134
 
108
135
  def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[float] = None) -> List[Tuple[float, float, str]]:
@@ -111,7 +138,12 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[fl
111
138
  # split at "\n" only: splitlines() also breaks a cue at U+2028, U+0085, \v and \f, which
112
139
  # made the rest of the line an auto-timed cue of its own
113
140
  lines = read_text_or_die(path, "--text").split("\n")
141
+ refuse_not_a_cue_file(path, lines)
114
142
  for n, raw in enumerate(lines, 1):
143
+ if TIMING_ONLY_RE.match(raw) and not SRT_TIMING_RE.match(raw):
144
+ # WebVTT's short form (no hours), in a file without the WEBVTT header
145
+ die(f"--text: {path} is a subtitle file, not a cue file (line {n} is a timing line with no text: "
146
+ f"'{raw.strip()}'); pass it as --srt (render.py: --srt, not --cues)", kind="input")
115
147
  if SRT_TIMING_RE.match(raw):
116
148
  # a cue file puts the text on the line with its times; this line has the times alone, so
117
149
  # the file is an SRT. Read as a cue file, its numbers and timing lines would be burned in
@@ -257,6 +289,9 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
257
289
  die(f"--emoji mono: this ffmpeg draws no emoji glyph through libass ({support['detail']}), so "
258
290
  f"{len(clusters_all)} emoji would come out as empty boxes -- pass --emoji-assets DIR for colour, "
259
291
  "or --emoji none to leave them out of the drawn text", kind="input")
292
+ if want == "mono" and support.get("libass_color"):
293
+ mode = "color"
294
+ warn(emoji_mono_on_colour_note(support), "emoji_mono_drawn_in_colour")
260
295
  plan = {"mode": mode, "count": len(clusters_all),
261
296
  "clusters": sorted({emoji_codepoint_name(cl) for cl in clusters_all}),
262
297
  "assets": assets, "missing": [], "overlays": []}
@@ -354,8 +389,7 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
354
389
  "--emoji-max raises it); ffmpeg's filter graph and the per-frame cost both grow "
355
390
  "linearly -- split the job, or use --emoji none", kind="input")
356
391
  if plan["missing"]:
357
- warn("no PNG in the assets directory for " + ", ".join(plan["missing"]) +
358
- " -- those clusters are drawn by the text font instead", "emoji_assets_missing", missing=list(plan["missing"]))
392
+ warn(emoji_missing_asset_note(plan["missing"], support), "emoji_assets_missing", missing=list(plan["missing"]))
359
393
  plan["box_px"] = int(round(box_px))
360
394
  return out_cues, plan
361
395
 
@@ -1934,7 +1968,9 @@ def main() -> int:
1934
1968
  notes.append("emoji rendered monochrome (no colour path on this ffmpeg; "
1935
1969
  "--emoji-assets DIR for colour)")
1936
1970
  if emoji_plan["mode"] == "none":
1937
- notes.append("emoji stripped from the drawn text (--emoji none)")
1971
+ # --emoji none was asked for only when it was: an auto run that can draw none says that
1972
+ notes.append("emoji stripped from the drawn text (--emoji none)" if getattr(args, "emoji", "auto") == "none" else
1973
+ "emoji left out of the drawn text: nothing here draws them (--emoji auto; the emoji_not_drawn warning says why)")
1938
1974
  if emoji_plan["missing"]:
1939
1975
  notes.append("no PNG asset for " + ", ".join(emoji_plan["missing"]))
1940
1976
  if notes:
package/scripts/color.py CHANGED
@@ -420,6 +420,41 @@ def legalize_report(standard: str, output: str) -> "tuple":
420
420
  "worst_frame": worst, "tolerance_pct": LEGALIZE_TOLERANCE_PCT}])
421
421
 
422
422
 
423
+ def refuse_bad_cube(path: str) -> None:
424
+ """--lut given a .cube that holds no complete 3D LUT (#404): lut3d failed on it with "Invalid
425
+ data found when processing input", which reads as an unreadable video and got that hint. Only
426
+ .cube is checked (lut3d's other formats are left to it), by what lut3d itself needs: a
427
+ LUT_3D_SIZE line and size^3 rows that start with three numbers."""
428
+ if os.path.splitext(path)[1].lower() != ".cube":
429
+ return
430
+ size = rows = 0
431
+ one_d = False
432
+ try:
433
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
434
+ for line in fh:
435
+ words = line.split()
436
+ if not words or words[0].startswith("#"):
437
+ continue
438
+ if words[0] == "LUT_3D_SIZE" and len(words) > 1 and words[1].isdigit():
439
+ size = int(words[1])
440
+ elif words[0] == "LUT_1D_SIZE":
441
+ one_d = True
442
+ elif len(words) >= 3:
443
+ try:
444
+ [float(w) for w in words[:3]]
445
+ rows += 1
446
+ except ValueError:
447
+ pass
448
+ except OSError as e:
449
+ die(f"--lut: cannot read {path}: {e.strerror}", kind="input")
450
+ if size < 2 or rows < size ** 3:
451
+ what = ("it is a 1D LUT (LUT_1D_SIZE), and --lut applies a 3D one" if one_d and size < 2
452
+ else "it has no LUT_3D_SIZE line" if size < 2
453
+ else f"it has {rows} rows of three numbers, and a LUT_3D_SIZE {size} table has {size ** 3}")
454
+ die(f"--lut {path} is not a complete 3D .cube LUT: {what}", kind="input",
455
+ hint="a .cube file is plain text with a LUT_3D_SIZE line and size^3 rows of three numbers; export it again from the grading tool")
456
+
457
+
423
458
  def main() -> int:
424
459
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
425
460
  ap.add_argument("input")
@@ -565,6 +600,7 @@ def main() -> int:
565
600
  f"tagged BT.709 (sweep F10). Run --to-sdr first (or chain it), or --force if the LUT expects HDR input")
566
601
  if not os.path.exists(args.lut):
567
602
  die(f"LUT not found: {args.lut}")
603
+ refuse_bad_cube(args.lut)
568
604
  if not (0.0 <= args.lut_strength <= 1.0):
569
605
  die(f"--lut-strength {args.lut_strength:g} is outside 0..1")
570
606
  lut = f"lut3d=file={escape_filter_path(args.lut)}:interp=tetrahedral"
@@ -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,22 @@ 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}"
65
+ if "\x00" in title:
66
+ # FFmpeg ends a metadata value at NUL: "A\x00B" was written as "A", verified (the
67
+ # sibling of A260 in render.py's inline chapters)
68
+ die(f"{path}:{n}: the chapter title holds a NUL character, which a chapter title cannot carry; remove it")
55
69
  if entries and start <= entries[-1]["start"]:
56
70
  die(f"{path}:{n}: chapter at {start:g}s does not come after the previous one at {entries[-1]['start']:g}s")
57
71
  if duration and start >= duration:
@@ -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: