ffmpeg-skill 2.11.0 → 2.12.0

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.
@@ -51,6 +51,8 @@ from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji
51
51
  # caption.parse_srt / caption.transcribe / caption.whisper_word_timings before 1.17 still does.
52
52
  from _common import asr as _asr
53
53
  from _common.asr import ENGINE_CHOICES
54
+ from _common.emit import warn
55
+ from _common.fonts import font_choice, record_font_choice
54
56
  from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
55
57
  write_srt)
56
58
  from _common import (SAFE_WIDTH_FRACTION, ORPHAN_MIN_EM, WRAP_MODES, wrap_text, wrap_variants, best_break,
@@ -219,8 +221,8 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
219
221
  return out, plan
220
222
  if mode in ("color", "mono"):
221
223
  if mode == "mono":
222
- info("warning: emoji rendered monochrome (no colour path on this ffmpeg; "
223
- "--emoji-assets DIR for colour). " + support["detail"])
224
+ warn("emoji rendered monochrome (no colour path on this ffmpeg; "
225
+ "--emoji-assets DIR for colour). " + support["detail"], "emoji_monochrome")
224
226
  return cues, plan
225
227
  # --- the PNG overlay route -------------------------------------------------------------
226
228
  if not play_w or not play_h:
@@ -295,8 +297,8 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
295
297
  "--emoji-max raises it); ffmpeg's filter graph and the per-frame cost both grow "
296
298
  "linearly -- split the job, or use --emoji none", kind="input")
297
299
  if plan["missing"]:
298
- info("warning: no PNG in the assets directory for " + ", ".join(plan["missing"]) +
299
- " -- those clusters are drawn by the text font instead")
300
+ warn("no PNG in the assets directory for " + ", ".join(plan["missing"]) +
301
+ " -- those clusters are drawn by the text font instead", "emoji_assets_missing", missing=list(plan["missing"]))
300
302
  plan["box_px"] = int(round(box_px))
301
303
  return out_cues, plan
302
304
 
@@ -493,13 +495,16 @@ def parse_ass_dialogue(path: str) -> str:
493
495
  return "\n".join(text)
494
496
 
495
497
 
496
- def parse_ass_cues(path: str) -> List[Tuple[float, float, str]]:
497
- """(start, end, drawn text) of every Dialogue line of an ASS file, override blocks stripped."""
498
+ def parse_ass_cues(path: str, stats: Optional[dict] = None) -> List[Tuple[float, float, str]]:
499
+ """(start, end, drawn text) of every Dialogue line of an ASS file, override blocks stripped.
500
+ A Dialogue line with too few fields or an unreadable start/end is left out; `stats`, when
501
+ given, gets their number as `malformed` so the caller can say how many it could not count."""
498
502
  def secs(stamp: str) -> float:
499
503
  h, m, rest = stamp.strip().split(":")
500
504
  return int(h) * 3600 + int(m) * 60 + float(rest)
501
505
 
502
506
  cues: List[Tuple[float, float, str]] = []
507
+ malformed = 0
503
508
  # the [Events] Format line names the columns; Text is always last (libass reads it the same
504
509
  # way), and a file without one gets the standard ten
505
510
  names = ["layer", "start", "end", "style", "name", "marginl", "marginr", "marginv", "effect", "text"]
@@ -511,13 +516,17 @@ def parse_ass_cues(path: str) -> List[Tuple[float, float, str]]:
511
516
  continue
512
517
  fields = line.partition(":")[2].split(",", len(names) - 1)
513
518
  if len(fields) < len(names) or "start" not in names or "end" not in names:
519
+ malformed += 1
514
520
  continue
515
521
  try:
516
522
  start, end = secs(fields[names.index("start")]), secs(fields[names.index("end")])
517
523
  except (ValueError, IndexError):
524
+ malformed += 1
518
525
  continue
519
526
  text = re.sub(r"\{[^}]*\}", "", fields[-1]).replace("\\N", "\n").replace("\\n", "\n").replace("\\h", " ")
520
527
  cues.append((start, end, text))
528
+ if stats is not None:
529
+ stats["malformed"] = malformed
521
530
  return cues
522
531
 
523
532
 
@@ -540,8 +549,17 @@ def count_visible_cues(cues, duration) -> Tuple[int, int]:
540
549
  return burned, outside
541
550
 
542
551
 
543
- def shift_ass_file(src: str, dst: str, offset: float) -> int:
544
- """Copy an ASS file with every Dialogue start/end moved by `offset` seconds."""
552
+ def shift_ass_file(src: str, dst: str, offset: float) -> Tuple[int, int]:
553
+ """Copy an ASS file with every Dialogue start/end moved by `offset` seconds; (lines shifted,
554
+ Dialogue lines copied unshifted because their timestamps could not be read)."""
555
+ text, n, unshifted = shift_ass_text(Path(src).read_text(encoding="utf-8-sig"), offset)
556
+ Path(dst).write_text(text, encoding="utf-8-sig")
557
+ return n, unshifted
558
+
559
+
560
+ def shift_ass_text(source: str, offset: float) -> Tuple[str, int, int]:
561
+ """shift_ass_file() on text: (shifted text, lines shifted, Dialogue lines left as they were).
562
+ Pure, so a dry run can report the counts without writing the copy."""
545
563
  def shift(stamp: str) -> str:
546
564
  h, m, rest = stamp.split(":")
547
565
  secs = int(h) * 3600 + int(m) * 60 + float(rest) + offset
@@ -552,22 +570,25 @@ def shift_ass_file(src: str, dst: str, offset: float) -> int:
552
570
  ss, cc = divmod(rem, 100)
553
571
  return f"{hh}:{mm:02d}:{ss:02d}.{cc:02d}"
554
572
 
555
- n = 0
573
+ n = unshifted = 0
556
574
  out = []
557
- for line in Path(src).read_text(encoding="utf-8-sig").splitlines():
575
+ for line in source.splitlines():
558
576
  if line.startswith("Dialogue:"):
559
577
  head, sep, rest = line.partition(":")
560
578
  fields = rest.split(",")
579
+ done = False
561
580
  if len(fields) >= 3:
562
581
  try:
563
582
  fields[1], fields[2] = shift(fields[1].strip()), shift(fields[2].strip())
564
583
  line = head + sep + ",".join(fields)
565
584
  n += 1
585
+ done = True
566
586
  except (ValueError, IndexError):
567
587
  pass
588
+ if not done:
589
+ unshifted += 1 # copied as it was: counted and reported, not dropped unseen
568
590
  out.append(line)
569
- Path(dst).write_text("\n".join(out) + "\n", encoding="utf-8-sig")
570
- return n
591
+ return "\n".join(out) + "\n", n, unshifted
571
592
 
572
593
 
573
594
  def word_durations_from_timings(words: List[Tuple[float, float, str]], start: float, end: float,
@@ -1296,6 +1317,7 @@ def main() -> int:
1296
1317
  planned_cues: Optional[List[Tuple[float, float, str]]] = None
1297
1318
  # an English-only transcript made on an assumption says so first (asr.assumed_english_note)
1298
1319
  side_notes: List[str] = list(getattr(args, "_asr_notes", None) or [])
1320
+ ass_counts: dict = {} # --ass: ass_shifted / ass_unshifted (with --offset) and ass_malformed, into `caption`
1299
1321
  ass_sample_path = args.ass
1300
1322
  if args.srt and not (args.text or args.transcribe) and os.path.exists(srt_path or ""):
1301
1323
  adjusted, changed = lay_out(parse_srt(srt_path))
@@ -1315,10 +1337,18 @@ def main() -> int:
1315
1337
  if args.ass and args.offset and os.path.exists(args.ass):
1316
1338
  shifted = os.path.splitext(output)[0] + "_offset.ass"
1317
1339
  if STATE.dry_run:
1318
- info(f"[dry-run] would write {shifted} (cues shifted by {args.offset:+g} s)")
1340
+ _text, n, unshifted = shift_ass_text(read_text_or_die(args.ass, "--ass").lstrip("\ufeff"), args.offset)
1341
+ info(f"[dry-run] would write {shifted} ({n} cues shifted by {args.offset:+g} s)")
1319
1342
  else:
1320
- n = shift_ass_file(args.ass, shifted, args.offset)
1343
+ n, unshifted = shift_ass_file(args.ass, shifted, args.offset)
1321
1344
  info(f"wrote {shifted} ({n} cues shifted by {args.offset:+g} s)")
1345
+ # the counts reach the document (caption.ass_shifted / ass_unshifted), and a line
1346
+ # whose timestamps could not be read -- copied unshifted -- is a warning, not a silence
1347
+ ass_counts.update(ass_shifted=n, ass_unshifted=unshifted)
1348
+ if unshifted:
1349
+ warn(f"{unshifted} Dialogue line(s) in {args.ass} have timestamps that could not be read, so they were not "
1350
+ f"shifted by {args.offset:+g} s (copied as they were)", "ass_lines_unshifted",
1351
+ path=args.ass, count=unshifted, offset=args.offset)
1322
1352
  args.ass = shifted
1323
1353
  side_notes.append(f"the burned subtitles are {shifted}, the offset copy of "
1324
1354
  f"{os.path.basename(ass_sample_path)} this run writes"
@@ -1449,6 +1479,19 @@ def main() -> int:
1449
1479
  _script, font_file, font_family = script_font_for_text(
1450
1480
  sample, lang=args.language, font=args.font, font_explicit=font_explicit,
1451
1481
  fonts_dir=args.fonts_dir)
1482
+ # the font libass draws with, reported as `font_used`: a --fonts-dir (or a brand's font
1483
+ # file) is searched first for the family, by libass itself; otherwise fontconfig resolves it
1484
+ if args.ass:
1485
+ # the caller's own ASS names its fonts in its styles; this run does not choose them
1486
+ font_used = {"asked": None, "file": None, "family": None, "how": "ass_styles",
1487
+ "fonts_dir": args.fonts_dir or (os.path.dirname(font_file) if font_file else None), "substituted": False}
1488
+ elif font_file or not args.fonts_dir:
1489
+ # the family libass is given is the sanitised one (ass_font_name): that is what it resolves
1490
+ font_used = record_font_choice(font_choice(ass_font_name(args.font), script_file=font_file, script_family=font_family,
1491
+ renderer="libass"))
1492
+ else:
1493
+ font_used = {"asked": ass_font_name(args.font), "file": None, "family": ass_font_name(args.font), "how": "fonts_dir",
1494
+ "fonts_dir": args.fonts_dir, "substituted": False}
1452
1495
  if font_file:
1453
1496
  args.font = font_family or args.font
1454
1497
  if not args.fonts_dir:
@@ -1508,10 +1551,16 @@ def main() -> int:
1508
1551
  # a dry run's pending --ass (an earlier step writes it): nothing to count yet
1509
1552
  burn_cues = None
1510
1553
  elif args.ass:
1554
+ parsed: dict = {}
1511
1555
  if planned_ass: # dry run: the offset copy is not written yet, shift the caller's file
1512
- burn_cues = [(s + args.offset, e + args.offset, t) for s, e, t in parse_ass_cues(ass_sample_path)]
1556
+ burn_cues = [(s + args.offset, e + args.offset, t) for s, e, t in parse_ass_cues(ass_sample_path, parsed)]
1513
1557
  else:
1514
- burn_cues = parse_ass_cues(args.ass)
1558
+ burn_cues = parse_ass_cues(args.ass, parsed)
1559
+ ass_counts["ass_malformed"] = parsed.get("malformed", 0)
1560
+ if ass_counts["ass_malformed"]:
1561
+ warn(f"{ass_counts['ass_malformed']} Dialogue line(s) in {ass_sample_path} could not be read (too few fields "
1562
+ "or an unreadable start/end) and are not counted as cues", "ass_lines_malformed",
1563
+ path=ass_sample_path, count=ass_counts["ass_malformed"])
1515
1564
  if not burn_cues:
1516
1565
  die(f"no Dialogue lines found in {ass_sample_path} -- nothing would be drawn", kind="input")
1517
1566
  elif planned_cues is not None:
@@ -1533,7 +1582,7 @@ def main() -> int:
1533
1582
  if outside:
1534
1583
  msg = (f"{outside} cue(s) fall outside the video (0\u2013{float(meta['duration']):.1f} s) "
1535
1584
  "and are not drawn")
1536
- info(f"warning: {msg}")
1585
+ warn(msg, "cues_outside_video", count=outside, duration=float(meta["duration"]))
1537
1586
  side_notes.append(msg)
1538
1587
  caption_stats["cues_burned"] = burned
1539
1588
  caption_stats["cues_outside"] = outside
@@ -1606,7 +1655,8 @@ def main() -> int:
1606
1655
  if caption_stats["text_unchanged"]:
1607
1656
  info("caption text unchanged: the cues were burned exactly as given (line breaks, timing "
1608
1657
  "and type size only)")
1609
- extra["caption"] = dict(caption_stats)
1658
+ extra["caption"] = dict(caption_stats, **ass_counts)
1659
+ extra["font_used"] = font_used
1610
1660
  if getattr(args, "_asr", None):
1611
1661
  extra["transcription"] = args._asr
1612
1662
  if emoji_plan:
package/scripts/color.py CHANGED
@@ -24,6 +24,7 @@ import sys
24
24
  from typing import List, Optional
25
25
 
26
26
  from _common import STATE, add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, ffmpeg_exe, info, probe, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
27
+ from _common.emit import warn
27
28
 
28
29
  TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
29
30
 
@@ -388,7 +389,7 @@ def main() -> int:
388
389
  if v.get("codec") != "hevc":
389
390
  die("--strip-dovi only applies to HEVC (Dolby Vision) streams")
390
391
  if not v.get("dolby_vision"):
391
- info("note: no Dolby Vision metadata detected; removing unregistered SEI anyway")
392
+ warn("no Dolby Vision metadata detected; removing unregistered SEI anyway", "dovi_not_detected")
392
393
  cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy", "-bsf:v", "filter_units=remove_types=62", "-tag:v", "hvc1"]
393
394
  if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
394
395
  cmd += ["-movflags", "+faststart"]
@@ -31,9 +31,10 @@ import argparse
31
31
  import re
32
32
  import sys
33
33
  from collections import Counter
34
- from typing import Dict, List, Tuple
34
+ from typing import Dict, List, Optional, Tuple
35
35
 
36
36
  from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames, frame_flow
37
+ from _common.emit import warn
37
38
 
38
39
  MOTION_CENTRE_FPS = 2.0
39
40
  MOTION_CENTRE_W, MOTION_CENTRE_H = 64, 36
@@ -41,7 +42,11 @@ MOTION_CENTRE_W, MOTION_CENTRE_H = 64, 36
41
42
  CROP_RE = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
42
43
 
43
44
 
44
- def detect(path: str, seconds: float, samples: int, limit: float, round_to: int, duration: float) -> List[Tuple[int, int, int, int]]:
45
+ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int, duration: float,
46
+ failed: "Optional[List[Dict]]" = None) -> List[Tuple[int, int, int, int]]:
47
+ """Crop rectangles from `samples` windows spread over the file. A window ffmpeg cannot decode
48
+ is skipped, and appended to `failed` as {start, seconds, error} when the caller passes a list,
49
+ so the result can say how many of the readings it stands on."""
45
50
  ffmpeg = require_tool("ffmpeg")
46
51
  per_window = max(0.5, seconds / max(1, samples))
47
52
  rects: List[Tuple[int, int, int, int]] = []
@@ -56,6 +61,8 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
56
61
  # One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
57
62
  # still measure. Only when every window fails is there nothing to report.
58
63
  failures.append(proc.stderr.strip().splitlines()[-1:] or ["?"])
64
+ if failed is not None:
65
+ failed.append({"start": round(start, 3), "seconds": round(per_window, 3), "error": failures[-1][0][:300]})
59
66
  continue
60
67
  for m in CROP_RE.finditer(proc.stderr):
61
68
  rects.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))))
@@ -145,8 +152,16 @@ def main() -> int:
145
152
  sw, sh = sh, sw
146
153
  duration = meta.get("duration") or 0.0
147
154
 
148
- rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration)
149
- result: Dict = {"file": args.input, "source_width": sw, "source_height": sh}
155
+ failed: List[Dict] = []
156
+ rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration, failed)
157
+ # windows_failed: a window ffmpeg could not decode was skipped without a word, so a
158
+ # crop measured on one window of five read as well-founded as one measured on all five
159
+ result: Dict = {"file": args.input, "source_width": sw, "source_height": sh,
160
+ "windows": args.samples, "windows_failed": len(failed)}
161
+ if failed:
162
+ warn(f"cropdetect could not decode {len(failed)} of {args.samples} sampled windows; the crop rests on the other "
163
+ f"{args.samples - len(failed)}: {failed[-1]['error']}", "cropdetect_windows_failed",
164
+ failed=len(failed), windows=args.samples, starts=[f["start"] for f in failed])
150
165
  if not rects:
151
166
  result["crop"] = None
152
167
  info("no crop bars detected (cropdetect produced no readings -- try --limit higher, or the source may already be full-frame)")
package/scripts/export.py CHANGED
@@ -38,6 +38,7 @@ from typing import Dict, List
38
38
 
39
39
  from _common import STATE, add_common, apply_common, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
40
40
  from _common.decision import hw_preset_video, restate_last_swap
41
+ from _common.emit import note, record_skip, warn
41
42
  from check import SPECS as PLATFORMS, measure_loudness
42
43
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, resolve as resolve_platform
43
44
  # Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
@@ -108,6 +109,22 @@ PLATFORM_OF["youtube4k"] = PLATFORM_TABLE["youtube"]["check"]
108
109
  PRESET_CHOICES: List[str] = sorted(set(PRESETS) | {a for a in _ALIASES if resolve_platform(a) in PRESETS})
109
110
 
110
111
 
112
+ def _loudness_unmeasured(extra: Dict[str, object], notes: List[str], platform: str, spec: Dict, normalized: bool = False) -> None:
113
+ """The written file's loudness could not be read (ffmpeg's loudnorm JSON did not parse): the
114
+ platform's loudness was not verified, and the document says so -- a `loudness` block with null
115
+ values and a reason, a failing `loudness` verification step, a warning -- rather than leaving
116
+ the block out, which read as "nothing to check". check.py reports the same case as a WARN row."""
117
+ reason = "not_measured"
118
+ extra["loudness"] = {"lufs": None, "tp": None, "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": None,
119
+ "measured": False, "reason": reason, **({"normalized": True} if normalized else {})}
120
+ extra["verification"] = [{"step": "loudness", "ok": False, "platform": platform, "reason": reason}]
121
+ msg = (f"the written file's loudness could not be measured{' after --normalize' if normalized else ''} "
122
+ f"(ffmpeg's loudnorm output did not parse), so {platform}'s {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP was not "
123
+ "verified; run check.py on the file")
124
+ notes.append(msg)
125
+ warn(msg, "loudness_not_measured", platform=platform, normalized=normalized)
126
+
127
+
111
128
  def main() -> int:
112
129
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
113
130
  ap.add_argument("input", nargs="?")
@@ -154,7 +171,7 @@ def main() -> int:
154
171
  # not HDR but still needs converting before BT.709 tags describe it (eval 24 h1)
155
172
  kind = "HDR" if meta["video"].get("hdr") else "wide-gamut SDR, not HDR"
156
173
  notes.append("source is %s (%s). This preset outputs SDR BT.709 tags without converting it; run color.py --to-sdr first for correct colours." % (kind, meta["video"].get("hdr_format")))
157
- info("warning: " + notes[-1])
174
+ warn(notes[-1], "colour_not_converted", preset=args.preset, hdr_format=meta["video"].get("hdr_format"))
158
175
  has_audio = bool(meta.get("audio"))
159
176
  output = args.output or default_output(args.input, args.preset, p["ext"])
160
177
  out_ext = Path(output).suffix.lstrip(".").lower()
@@ -227,15 +244,20 @@ def main() -> int:
227
244
  if out_ext == "mp4":
228
245
  cmd += ["-movflags", "+faststart"]
229
246
  cmd += (p["audio"] if has_audio else ["-an"])
247
+ trimmed = None
230
248
  if p["max"] and not args.allow_long and (meta.get("duration") or 0) > p["max"]:
231
- info(f"trimming to the platform maximum of {p['max']:.0f}s (use --allow-long to keep full length)")
249
+ # the preset's promise (the platform's maximum), but the caller loses the tail: say how much
250
+ # in the document (`trimmed`, `notes`), not only on stderr
251
+ trimmed = {"from": round(float(meta["duration"]), 3), "to": round(float(p["max"]), 3)}
252
+ note(f"trimmed from {trimmed['from']:.3f}s to the {args.preset} maximum of {p['max']:.0f}s "
253
+ "(use --allow-long to keep full length)")
232
254
  cmd += ["-t", f"{p['max']:.3f}"]
233
255
  cmd.append(output)
234
256
  run(cmd)
235
257
  result = probe(output, role="output")
236
258
  v = result["video"]
237
259
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
238
- extra: Dict[str, object] = {}
260
+ extra: Dict[str, object] = {"trimmed": trimmed} if trimmed else {}
239
261
  platform = PLATFORM_OF.get(args.preset)
240
262
  if platform and has_audio and not STATE.dry_run:
241
263
  # Every eval run that exported for a platform then had to come back with loudness.py: the
@@ -243,6 +265,8 @@ def main() -> int:
243
265
  # written file here so the result names the gap and the caller plans one pass, not two.
244
266
  spec = PLATFORMS[platform]
245
267
  m = measure_loudness(output)
268
+ if not m:
269
+ _loudness_unmeasured(extra, notes, platform, spec)
246
270
  if m:
247
271
  ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
248
272
  extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok}
@@ -253,7 +277,7 @@ def main() -> int:
253
277
  extra["loudness"]["silent"] = True
254
278
  notes.append(f"output audio is silent (integrated loudness -inf, {platform} expects {spec['lufs']:g} LUFS); "
255
279
  "check the source's audio or give the export a real track (audio.py --replace)")
256
- info("warning: " + notes[-1])
280
+ warn(notes[-1], "output_audio_silent", platform=platform)
257
281
  elif not ok and args.normalize:
258
282
  # Eval 7: every platform job ran export -> loudness.py -> export again (two video
259
283
  # encodes). The levels pass only re-encodes audio, so do it here on the written
@@ -275,17 +299,25 @@ def main() -> int:
275
299
  die(f"--normalize: loudness.py failed: {err.get('message') or proc.stderr.strip()[-300:]}",
276
300
  kind=err.get("kind") or "ffmpeg", output=output, hint=err.get("hint"))
277
301
  os.replace(tmp, output)
278
- m = measure_loudness(output) or m
279
- ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
280
- extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok, "normalized": True}
281
- extra["verification"] = [{"step": "loudness", "ok": ok, "platform": platform}]
302
+ m = measure_loudness(output)
303
+ if not m:
304
+ # the file changed: the measurement before --normalize no longer describes it
305
+ _loudness_unmeasured(extra, notes, platform, spec, normalized=True)
306
+ ok = True # nothing more to say about a level that was not measured
307
+ else:
308
+ ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
309
+ extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok, "normalized": True}
310
+ extra["verification"] = [{"step": "loudness", "ok": ok, "platform": platform}]
282
311
  if not ok:
283
312
  notes.append(f"loudness is still {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP after loudness.py (target {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP): {child.get('result', {}).get('note') or 'the encoder overshoots the ceiling'}")
284
- info("warning: " + notes[-1])
313
+ warn(notes[-1], "loudness_outside_spec", platform=platform, lufs=m["lufs"], tp=m["tp"], normalized=True)
285
314
  elif not ok:
286
315
  notes.append(f"loudness {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP is outside {platform}'s {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP; "
287
316
  f"run loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} on this file, or export with --normalize")
288
- info("warning: " + notes[-1])
317
+ warn(notes[-1], "loudness_outside_spec", platform=platform, lufs=m["lufs"], tp=m["tp"], normalized=False)
318
+ elif platform and not has_audio and not STATE.dry_run:
319
+ record_skip(f"no loudness check against {platform}'s spec: the source has no audio, so the export has none",
320
+ "loudness_no_audio", platform=platform)
289
321
  elif platform and has_audio and args.normalize:
290
322
  spec = PLATFORMS[platform]
291
323
  notes.append(f"[dry-run] --normalize: loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} would run on the written file if it misses {platform}'s spec")
@@ -29,17 +29,19 @@ import argparse
29
29
  import os
30
30
  import re
31
31
  import sys
32
- from typing import List, Optional
32
+ from typing import List, Optional, Tuple
33
33
 
34
34
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
35
35
  from _common import (aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
36
- color_hex, default_font_file, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
36
+ color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
37
37
  ffmpeg_base, info, label_chain, load_brand, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
38
38
  drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
39
39
  LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
40
40
  emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
41
41
  EMOJI_ASSET_HINT, text_width_em, drawtext_shaping, wrap_text, WRAP_MODES,
42
42
  SAFE_WIDTH_FRACTION, drawtext_draws_newline_glyph, is_multiline, _DRAWTEXT_PENDING)
43
+ from _common.emit import warn
44
+ from _common.fonts import font_choice, record_font_choice
43
45
  from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
44
46
 
45
47
  # where the template's text chain goes in a filter_complex it builds (graphics.main fills it in)
@@ -52,15 +54,16 @@ def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
52
54
  return f"0x{color_hex(hex_rgb)}@{alpha:g}"
53
55
 
54
56
 
55
- def font_opts(brand: dict, font: Optional[str], font_file: Optional[str], script_file: Optional[str] = None) -> str:
56
- if font_file or brand.get("font_file"):
57
- return f"fontfile={escape_filter_path(font_file or brand['font_file'])}"
58
- if script_file: # a font picked by the script of the text itself (1.12)
59
- return f"fontfile={escape_filter_path(script_file)}"
60
- resolved = default_font_file(font or brand.get("font", "DejaVu Sans"))
61
- if resolved:
62
- return f"fontfile={escape_filter_path(resolved)}"
63
- return f"font='{escape_drawtext(font or brand.get('font', 'DejaVu Sans'))}'"
57
+ def font_opts(brand: dict, font: Optional[str], font_file: Optional[str], script_file: Optional[str] = None,
58
+ script_family: Optional[str] = None, renderer: str = "drawtext") -> "Tuple[str, dict]":
59
+ """(the drawtext font option, the choice behind it -- the result's `font_used`): the caller's
60
+ file, a font picked by the script of the text itself (1.12), the asked family resolved to a
61
+ file, or the family by name when nothing resolves."""
62
+ choice = font_choice(font or brand.get("font", "DejaVu Sans"), font_file=font_file or brand.get("font_file"),
63
+ script_file=script_file, script_family=script_family, renderer=renderer)
64
+ if choice["file"]:
65
+ return f"fontfile={escape_filter_path(choice['file'])}", choice
66
+ return f"font='{escape_drawtext(font or brand.get('font', 'DejaVu Sans'))}'", choice
64
67
 
65
68
 
66
69
  def main(force_route: Optional[str] = None) -> int:
@@ -156,7 +159,7 @@ def main(force_route: Optional[str] = None) -> int:
156
159
  _script, script_file, _family = script_font_for_text(
157
160
  " ".join(t for t in (args.name, args.title, args.subtitle, args.text, args.top, args.bottom) if t),
158
161
  lang=args.lang, font=args.font, font_explicit=bool(args.font), font_file=args.font_file or brand.get("font_file"))
159
- fo = font_opts(brand, args.font, args.font_file, script_file)
162
+ fo, font_used = font_opts(brand, args.font, args.font_file, script_file, _family)
160
163
 
161
164
  meta = probe(args.input)
162
165
  if not meta.get("video"):
@@ -263,8 +266,8 @@ def main(force_route: Optional[str] = None) -> int:
263
266
  "be drawn as an empty box -- stripped from the text instead "
264
267
  "(--text-render auto renders it monochrome through libass)")
265
268
  if emoji_mode == "mono":
266
- info("warning: emoji rendered monochrome (no colour path on this ffmpeg; "
267
- "--emoji-assets DIR for colour). " + support["detail"])
269
+ warn("emoji rendered monochrome (no colour path on this ffmpeg; "
270
+ "--emoji-assets DIR for colour). " + support["detail"], "emoji_monochrome")
268
271
  if emoji_mode == "none":
269
272
  if not "".join(ch for ch in all_text if char_script(ch) != "emoji").strip():
270
273
  die("the template's text is nothing but emoji and this machine can draw none of them "
@@ -565,9 +568,9 @@ def main(force_route: Optional[str] = None) -> int:
565
568
  "(FFmpeg 8.0-8.0.1 and 8.1.0; fixed in 8.0.2/8.1.1), so it is rendered through libass")
566
569
  _DRAWTEXT_PENDING.clear()
567
570
  return main(force_route="ass")
568
- info("warning: this ffmpeg's drawtext draws a box at each line break of a multi-line label "
571
+ warn("this ffmpeg's drawtext draws a box at each line break of a multi-line label "
569
572
  "(FFmpeg 8.0-8.0.1 and 8.1.0; fixed in 8.0.2/8.1.1): drop --text-render drawtext to render it "
570
- "through libass, or upgrade FFmpeg")
573
+ "through libass, or upgrade FFmpeg", "drawtext_newline_glyph", text_render="drawtext")
571
574
 
572
575
  output = args.output or default_output(args.input, "gfx")
573
576
  ass_path = None
@@ -631,7 +634,7 @@ def main(force_route: Optional[str] = None) -> int:
631
634
  f"(limit {_max}, --emoji-max raises it); ffmpeg's filter graph and the "
632
635
  "per-frame cost both grow linearly -- split the job, or use --emoji none", kind="input")
633
636
  if missing:
634
- info("warning: no PNG in the assets directory for " + ", ".join(missing))
637
+ warn("no PNG in the assets directory for " + ", ".join(missing), "emoji_assets_missing", missing=list(missing))
635
638
  if emoji_mode:
636
639
  emoji_result = {"mode": emoji_mode, "count": len(emoji_clusters(all_text)),
637
640
  "clusters": sorted(seen) or sorted({emoji_codepoint_name(cl) for _i, cl in emoji_clusters(all_text)}),
@@ -708,8 +711,11 @@ def main(force_route: Optional[str] = None) -> int:
708
711
  dropped_streams = run_keeping_subtitles(cmd, output)
709
712
  r = probe(output, role="output")
710
713
  info(f"wrote {output} ({fmt_secs(r['duration'])}, {args.template})")
714
+ if route == "ass":
715
+ # libass looks the family up itself (fontconfig): the same choice, asked for that renderer
716
+ font_used = font_opts(brand, args.font, args.font_file, script_file, _family, renderer="libass")[1]
711
717
  extra = {"template": args.template, "dropped_non_av_streams": dropped_streams,
712
- "text_renderer": route, "script": _script}
718
+ "text_renderer": route, "script": _script, "font_used": record_font_choice(font_used)}
713
719
  if ass_path:
714
720
  extra["ass"] = ass_path
715
721
  if emoji_result:
package/scripts/grid.py CHANGED
@@ -25,8 +25,9 @@ import argparse
25
25
  import os
26
26
  import sys
27
27
 
28
- from _common import add_common, apply_common, aac_args, cfr_args, default_font_file, default_output, die, emit, X264_PRESETS, fmt_secs, \
28
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, X264_PRESETS, fmt_secs, \
29
29
  draws_chroma, drawn_chain, escape_drawtext, escape_filter_path, ffmpeg_base, info, label_chain, matrix_chain, probe, run, validate_color, video_args
30
+ from _common.fonts import font_choice, record_font_choice
30
31
 
31
32
  LABEL_MARGIN = 10
32
33
 
@@ -84,7 +85,10 @@ def main() -> int:
84
85
  durations = [m.get("duration") or 0.0 for m in metas]
85
86
  target_duration = max(durations) if args.pad else min(durations)
86
87
 
87
- font_file = default_font_file(args.font)
88
+ font_used = font_choice(args.font) # the label font, reported as `font_used` when labels are drawn
89
+ font_file = font_used["file"]
90
+ if args.label == "auto":
91
+ record_font_choice(font_used)
88
92
  output = args.output or default_output(args.inputs[0], "grid")
89
93
 
90
94
  cmd = ffmpeg_base()
@@ -157,7 +161,7 @@ def main() -> int:
157
161
  v = result["video"]
158
162
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {args.cols}x{args.rows} grid, "
159
163
  f"{n} clips, {'padded to longest' if args.pad else 'stopped at shortest'})")
160
- emit(output, cols=args.cols, rows=args.rows, clips=n)
164
+ emit(output, cols=args.cols, rows=args.rows, clips=n, **({"font_used": font_used} if args.label == "auto" else {}))
161
165
  return 0
162
166
 
163
167
 
package/scripts/join.py CHANGED
@@ -40,6 +40,7 @@ import sys
40
40
  from typing import Any, Dict, List, Optional, Tuple
41
41
 
42
42
  from _common import STATE, AUDIO_MEDIA_EXT, draws_chroma, drawn_chain, label_chain, matrix_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
43
+ from _common.emit import warn
43
44
 
44
45
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
45
46
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -113,7 +114,7 @@ def note_short_segments(paths: List[str], metas: List[dict], minimum: float, uni
113
114
  STATE_SHORT.append({"index": k, "path": p, "duration": round(dur, 4)})
114
115
  if STATE_SHORT:
115
116
  listing = ", ".join(f"#{s['index'] + 1} {s['path']} ({s['duration']:g}s)" for s in STATE_SHORT)
116
- info(f"warning: {len(STATE_SHORT)} input(s) shorter than {unit}: {listing}")
117
+ warn(f"{len(STATE_SHORT)} input(s) shorter than {unit}: {listing}", "short_inputs", count=len(STATE_SHORT))
117
118
  STATE_NOTES.append(f"{len(STATE_SHORT)} input(s) are shorter than {unit} and were joined anyway: {listing}")
118
119
 
119
120
 
@@ -361,7 +362,8 @@ def main() -> int:
361
362
  STATE_SILENT[:] = sorted(intended + (silent if args.on_silent == "warn" else []), key=lambda s: s["index"])
362
363
  if silent and args.on_silent == "warn":
363
364
  listing = ", ".join(f"#{s['index'] + 1} {s['path']} (peak {s['peak_db']:.1f} dBFS)" for s in silent)
364
- info(f"warning: {len(silent)} input(s) silent at or below {args.silence_threshold:g} dBFS, joined anyway: {listing}")
365
+ warn(f"{len(silent)} input(s) silent at or below {args.silence_threshold:g} dBFS, joined anyway: {listing}",
366
+ "silent_inputs", count=len(silent))
365
367
  STATE_NOTES.append(f"{len(silent)} input(s) have no audible sound (peak at or below {args.silence_threshold:g} dBFS) "
366
368
  f"and were joined anyway: {listing}; pass --on-silent fail or skip to refuse or drop them")
367
369
  STATE_SKIPPED[:] = skipped
@@ -373,7 +375,7 @@ def main() -> int:
373
375
  STATE_DUPES[:] = dupes
374
376
  if dupes:
375
377
  listing = "; ".join(f"{dd['path']} at #" + ", #".join(str(i + 1) for i in dd["indices"]) for dd in dupes)
376
- info(f"warning: input(s) listed more than once: {listing}")
378
+ warn(f"input(s) listed more than once: {listing}", "duplicate_inputs", count=len(dupes))
377
379
  STATE_NOTES.append(f"{len(dupes)} input(s) are listed more than once and joined each time: {listing}; "
378
380
  "fine if the repeat is intended")
379
381
  STATE_PENDING[:] = pending
package/scripts/loop.py CHANGED
@@ -25,6 +25,7 @@ import math
25
25
  import sys
26
26
 
27
27
  from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
28
+ from _common.emit import warn
28
29
 
29
30
 
30
31
  # The loop filter's size is capped at 32767 frames, so one boomerang cycle (2N-2 frames) must fit.
@@ -51,8 +52,9 @@ def _boomerang_graph(meta, src_dur: float, times) -> str:
51
52
  bytes_per_frame = v["width"] * v["height"] * 1.5 * (2 if (v.get("bit_depth") or 8) > 8 else 1)
52
53
  held = bytes_per_frame * frames
53
54
  if held > 2 * 1024 ** 3:
54
- info(f"warning: --boomerang holds about {held / 1024 ** 3:.1f} GiB of decoded frames in memory "
55
- f"({frames} frames of {v['width']}x{v['height']}) -- cut the clip shorter first (cut.py) if RAM is tight")
55
+ warn(f"--boomerang holds about {held / 1024 ** 3:.1f} GiB of decoded frames in memory "
56
+ f"({frames} frames of {v['width']}x{v['height']}) -- cut the clip shorter first (cut.py) if RAM is tight",
57
+ "memory_heavy", bytes=int(held))
56
58
  repeats = -1 if times is None else times - 1
57
59
  # Retimed by frame index at the source's nominal rate: a VFR source's reversed half carries
58
60
  # its gaps mirrored, and a CFR conform after the graph duplicated a frame against them (a
@@ -32,6 +32,7 @@ import sys
32
32
  from typing import Dict, List, Tuple
33
33
 
34
34
  from _common import label_chain, matrix_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
35
+ from _common.emit import record_fallback, warn
35
36
  from sync import measure_offset
36
37
 
37
38
  ENERGY_WINDOW_S = 0.25 # --switch energy: loudness measured over this window on the reference timeline
@@ -199,7 +200,8 @@ def main() -> int:
199
200
  conf.append(score)
200
201
  info(f"{p}: offset {off:+.3f}s (confidence {score:.2f})" + (f", drift {(ratio - 1) * 1e6:+.0f} ppm" if args.fix_drift else ""))
201
202
  if score < 0.1:
202
- info(f"warning: {p} has low correlation confidence ({score:.2f}); check that it shares an audio event with the reference before trusting this offset")
203
+ warn(f"{p} has low correlation confidence ({score:.2f}); check that it shares an audio event with the reference before "
204
+ "trusting this offset", "sync_low_confidence", path=p, confidence=round(score, 3))
203
205
 
204
206
  report = {"inputs": args.inputs, "offsets_seconds": [round(o, 4) for o in offsets],
205
207
  "confidence": [round(c, 3) for c in conf]}
@@ -275,13 +277,19 @@ def main() -> int:
275
277
  convert = [matrix_chain(m, metas[0]) for m in metas]
276
278
  if any(convert):
277
279
  convert = [c or label_chain(m, metas[0]) for c, m in zip(convert, metas)]
280
+ # what is actually rendered: a range whose camera has not started yet plays camera 0, and the
281
+ # result says so (`cuts` names the camera on screen, diagnostics.fallbacks the one asked for)
282
+ rendered: List[Tuple[float, float, int]] = []
278
283
  for i, (s, e, c) in enumerate(filled):
279
284
  # reference time t maps to source time (t - offset_c) * ratio_c
280
285
  src_s = (s - offsets[c]) * ratios[c]
281
286
  src_e = (e - offsets[c]) * ratios[c]
282
287
  if src_s < 0:
283
- info(f"warning: camera {c} has not started at reference {s:.2f}s; using camera 0 for that range")
288
+ record_fallback(f"camera {c} has not started at reference {s:.2f}s; using camera 0 for that range "
289
+ f"({s:.3f}-{e:.3f}s)", "camera_not_started", start=round(s, 3), end=round(e, 3),
290
+ requested_camera=c, camera=0, starts_at=round(offsets[c], 3))
284
291
  c, src_s, src_e = 0, s, e
292
+ rendered.append((s, e, c))
285
293
  parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{convert[c]}{geo}[v{i}]")
286
294
  labels.append(f"[v{i}]")
287
295
  project_clips.append({"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)})
@@ -321,7 +329,7 @@ def main() -> int:
321
329
  r = probe(output, role="output")
322
330
  info(f"wrote {output} ({fmt_secs(r['duration'])}, {len(filled)} cuts, audio from input {a})")
323
331
  extra = {"switch_mode": "energy", "min_shot": args.min_shot} if energy_mode else {}
324
- emit(output, cuts=[[round(s, 3), round(e, 3), c] for s, e, c in filled], **report, **extra)
332
+ emit(output, cuts=[[round(s, 3), round(e, 3), c] for s, e, c in rendered], **report, **extra)
325
333
  return 0
326
334
 
327
335