ffmpeg-skill 1.16.1 → 1.17.1

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.
package/scripts/render.py CHANGED
@@ -52,16 +52,35 @@ Examples:
52
52
  """
53
53
  import argparse
54
54
  import difflib
55
+ import hashlib
55
56
  import re
56
57
  import json
57
58
  import os
59
+ import shutil
58
60
  import sys
61
+ import time
59
62
  from pathlib import Path
60
- from typing import Any, Dict, List
63
+ from typing import Any, Dict, List, Optional, Sequence
61
64
 
62
65
  from export import PRESETS, PLATFORM_OF
63
66
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
64
- from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, fingerprint, PLAN_VERSION
67
+ from _common import STATE, add_common, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, fingerprint, PLAN_VERSION, ffmpeg_version
68
+ import subprocess
69
+ from _contract import CONTRACT_VERSION
70
+ from batch import file_key
71
+
72
+
73
+ def _skill_version() -> str:
74
+ """The shipped version, from package.json -- in the cache key so a stage whose implementation
75
+ changed cannot serve back an artifact the old one wrote."""
76
+ try:
77
+ return str(json.loads((Path(__file__).resolve().parent.parent / "package.json")
78
+ .read_text(encoding="utf-8")).get("version") or "?")
79
+ except (OSError, ValueError):
80
+ return "?"
81
+
82
+
83
+ SKILL_VERSION = _skill_version()
65
84
 
66
85
  HERE = Path(__file__).resolve().parent
67
86
  TEMPLATE_DIR = HERE.parent / "templates"
@@ -97,17 +116,22 @@ TEMPLATE = {
97
116
  OBJECT_KEYS: Dict[str, frozenset] = {
98
117
  "project": frozenset({"output", "frame", "clips", "transition", "silence", "brand", "captions",
99
118
  "graphics", "overlays", "audio", "loudness", "fit", "export", "check", "chapters",
100
- "audiogram", "template"}),
101
- "clips[]": frozenset({"src", "in", "out", "speed"}),
119
+ "audiogram", "template", "snap"}),
120
+ "clips[]": frozenset({"src", "in", "out", "speed", "snap"}),
121
+ # 1.17: beat snapping, forwarded to cut.py for any clip that has in/out
122
+ "snap": frozenset({"to", "tolerance", "min_confidence", "source"}),
102
123
  "frame": frozenset({"aspect", "width", "height", "fps", "fit"}),
103
124
  "transition": frozenset({"type", "duration"}),
104
125
  "silence": frozenset({"threshold", "min_silence", "margin"}),
105
126
  # 1.16: the picture an audio-only source gets before the rest of the chain can work on it
106
127
  "audiogram": frozenset({"image", "image_fit", "style", "position", "vis_height", "opacity",
107
128
  "platform", "title", "color", "background", "width", "height", "fps"}),
129
+ # 1.17.1: fit_size / min_size / fit_size_scope, so a project can state the fit policy the
130
+ # templates now default to (eval 18 cs1 hit "unknown key 'fit_size'" and hand-ran the stages).
108
131
  "captions": frozenset({"text", "srt", "ass", "font", "size", "color", "position", "margin",
109
132
  "animate", "highlight_color", "outline", "karaoke", "bold", "box",
110
- "lang", "offset", "max_lines", "min_duration"}),
133
+ "lang", "offset", "max_lines", "min_duration",
134
+ "fit_size", "min_size", "fit_size_scope"}),
111
135
  # the 1.14 social templates (sticker/hook/meme) take their own text and timing, so a
112
136
  # graphics[] entry can carry them too -- a template that only works from the CLI is not
113
137
  # "usable inside a render.py graphics[] entry" (review 12)
@@ -180,6 +204,23 @@ def fill_template(node: Any, values: Dict[str, Any]) -> "tuple":
180
204
  return node, True
181
205
 
182
206
 
207
+ def brand_states_caption_size(path: Optional[str]) -> bool:
208
+ """True only when a brand file actually names a caption size. `--brand` alone says nothing
209
+ about type size -- brand_caption_style() applies no defaults -- so the presence of the flag
210
+ must not switch caption.py's `--fit-size auto` off (review 17 finding 1)."""
211
+ if not path or not os.path.isfile(path):
212
+ return False
213
+ try:
214
+ stated = load_brand(path).get("_stated") or {}
215
+ except SystemExit:
216
+ raise
217
+ except Exception:
218
+ return False
219
+ # BRAND_DEFAULTS always supplies caption.size, so only what the FILE said can answer this
220
+ # (the same reason brand_states_font() exists).
221
+ return brand_caption_style(stated).get("size") is not None
222
+
223
+
183
224
  def template_project(name: str, args) -> Dict[str, Any]:
184
225
  """One template plus the run's arguments as a ready-to-render project."""
185
226
  tpl = load_template(name)
@@ -195,6 +236,14 @@ def template_project(name: str, args) -> Dict[str, Any]:
195
236
  tpl["captions"]["margin"] = defaults["margin"]
196
237
  for key in ("position", "animate", "outline"):
197
238
  tpl["captions"].setdefault(key, defaults[key])
239
+ # 1.17.1: that size is the table's default, not a size anyone asked for, so it must not
240
+ # switch off caption.py's `--fit-size auto` the way a stated --size does -- eval 18 saw a
241
+ # long cue split across two consecutive cues instead of the type shrinking to fit. A
242
+ # template that states "fit_size" wins, and so does a brand file that STATES a caption
243
+ # size (that size is a statement about the look). A brand of colours or a font alone
244
+ # states no size, so it must not stand the fitter down: review 17 finding 1.
245
+ if not brand_states_caption_size(args.brand):
246
+ tpl["captions"].setdefault("fit_size", "on")
198
247
  output = args.output or str(template_output(args.input, name, dest))
199
248
  values = {
200
249
  "$INPUT": os.path.abspath(args.input),
@@ -359,7 +408,7 @@ def check_keys(obj: Any, schema: str, label: str) -> None:
359
408
 
360
409
  def validate_project(proj: Dict[str, Any]) -> None:
361
410
  check_keys(proj, "project", "project")
362
- for name in ("frame", "transition", "silence", "audiogram", "captions", "audio", "loudness", "fit", "export", "check"):
411
+ for name in ("frame", "transition", "silence", "audiogram", "captions", "audio", "loudness", "fit", "export", "check", "snap"):
363
412
  check_keys(proj.get(name), name, name)
364
413
  check_keys((proj.get("audio") or {}).get("stems"), "audio.stems", "audio.stems")
365
414
  if isinstance(proj.get("chapters"), list):
@@ -374,10 +423,55 @@ def validate_project(proj: Dict[str, Any]) -> None:
374
423
  if isinstance(items, list):
375
424
  for i, item in enumerate(items):
376
425
  check_keys(item, f"{name}[]", f"{name}[{i}]")
426
+ if name == "clips" and isinstance(item, dict) and item.get("snap") is not None:
427
+ check_keys(item["snap"], "snap", f"clips[{i}].snap")
428
+
429
+
430
+ _LAST_DOC: Dict[str, Any] = {} # the JSON document the most recent sh() child printed
431
+
432
+
433
+ def _refuse_uncached_earlier_stage(stage: str) -> None:
434
+ """--from STAGE promises the earlier stages come from the cache. When one does not, say so
435
+ rather than quietly re-encoding the thing the caller asked to skip."""
436
+ target = CACHE.get("from")
437
+ if not target or stage not in STAGE_ORDER or target not in STAGE_ORDER:
438
+ return
439
+ if STAGE_ORDER.index(stage) < STAGE_ORDER.index(target):
440
+ die(f"--from {target}: the {stage} stage is not in {CACHE['dir']} for this project and "
441
+ "these inputs, so there is nothing to start from. Render once without --from to fill "
442
+ "the cache (note that a different ffmpeg build or skill version never reuses one).",
443
+ kind="input")
444
+
377
445
 
446
+ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> str:
447
+ """Run a sibling script, forwarding --fast / --dry-run, returning its printed output path.
378
448
 
379
- def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
380
- """Run a sibling script, forwarding --fast / --dry-run, returning its printed output path."""
449
+ With --cache and a named `stage`, an identical stage that ran before is served from the
450
+ cache instead of re-encoded. `stages_done` is unchanged either way: a cached stage is still
451
+ a stage that happened.
452
+ """
453
+ full = [str(a) for a in argv] + (extra or [])
454
+ dest = full[full.index("-o") + 1] if "-o" in full[:-1] else None
455
+ key = None
456
+ if stage and CACHE.get("dir") and dest:
457
+ inputs = [a for a in full if os.path.exists(a) and a != dest]
458
+ # The destination is where this stage's answer goes, not part of the question: hashing it
459
+ # would make a second run with the first run's output already on disk miss every time.
460
+ key_args = ["<out>" if a == dest else a for a in full]
461
+ key = cache_key(stage, script, key_args, inputs, dest)
462
+ if cache_lookup(stage, key, dest):
463
+ CACHE["hits"].append(stage)
464
+ info(f"→ {script} {stage}: served from --cache")
465
+ # No child ran, so there is no document: say so rather than leaving the PREVIOUS
466
+ # child's document standing, which a caller reading _LAST_DOC would misattribute.
467
+ _LAST_DOC.clear()
468
+ _LAST_DOC["cached"] = True
469
+ return dest
470
+ CACHE["misses"].append(stage)
471
+ _refuse_uncached_earlier_stage(stage)
472
+ elif stage and CACHE.get("dir"):
473
+ CACHE["misses"].append(stage)
474
+ started = time.time()
381
475
  cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args() + ["--json"]
382
476
  info("→ " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd[:-1])))
383
477
  proc = run_tool(cmd)
@@ -397,7 +491,136 @@ def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
397
491
  extra_fields = {"hint": err["hint"]} if err.get("hint") else {}
398
492
  die(f"{script} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
399
493
  code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script, **extra_fields)
400
- return str(doc.get("output") or "")
494
+ _LAST_DOC.clear()
495
+ _LAST_DOC.update(doc if isinstance(doc, dict) else {})
496
+ out_path = str(doc.get("output") or "")
497
+ if key:
498
+ cache_store(stage, key, out_path or (dest or ""), time.time() - started)
499
+ return out_path
500
+
501
+
502
+ # ------------------------------------------------------------------ the stage cache (1.17)
503
+ #
504
+ # Opt-in only: --cache DIR. There is no default directory -- a cache that appears on someone's
505
+ # disk without being asked for is a surprise, and this tool's posture is that a plan leaves
506
+ # nothing behind.
507
+
508
+ STAGE_ORDER = ("clips", "audiogram", "join", "silence", "fit", "captions", "graphics",
509
+ "overlays", "audio", "loudness", "export", "chapters")
510
+
511
+ def _fresh_cache() -> "Dict[str, Any]":
512
+ return {"dir": None, "ffmpeg": None, "hits": [], "misses": [], "saved_seconds": 0.0,
513
+ "entries": 0, "would_hit": [], "from": None}
514
+
515
+
516
+ # Module-level so sh() can reach it without threading a parameter through every stage. main()
517
+ # resets it on entry, so two renders in one process (a test session, an embedding caller) do not
518
+ # inherit each other's hit/miss lists.
519
+ CACHE: Dict[str, Any] = _fresh_cache()
520
+
521
+
522
+ def _content_hash(path: str) -> str:
523
+ """The same cheap content fingerprint batch.py caches on: name, size, mtime, first and last
524
+ MB. Reused rather than reinvented so one file has one identity across the skill."""
525
+ try:
526
+ return file_key(Path(path))
527
+ except (OSError, ValueError):
528
+ return "missing"
529
+
530
+
531
+ def ffmpeg_banner() -> str:
532
+ """The whole `ffprobe -version` first line, not just major.minor.
533
+
534
+ Two 7.1.x builds with different libx264 produce different bytes from the same command, and
535
+ the cache exists to hand back bytes. major.minor cannot tell them apart, so the banner --
536
+ which carries the build string and the configuration's version suffix -- is what goes in the
537
+ key. Unreadable falls back to the parsed pair, which still separates the major releases.
538
+ """
539
+ try:
540
+ out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE,
541
+ stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=20).stdout
542
+ first = (out or "").strip().splitlines()
543
+ if first:
544
+ return first[0].strip()
545
+ except (OSError, subprocess.SubprocessError):
546
+ pass
547
+ return ".".join(str(n) for n in ffmpeg_version())
548
+
549
+
550
+ def cache_key(stage: str, script: str, argv: "Sequence[Any]", inputs: "Sequence[str]",
551
+ dest: "Optional[str]" = None) -> str:
552
+ """sha1 of a canonical description of exactly what this stage is about to do.
553
+
554
+ The ffmpeg build banner and the skill version are IN the key, deliberately: a different build
555
+ simply misses rather than being asked to trust an artifact it did not write, and a stage whose
556
+ implementation changed must not serve an old one back.
557
+
558
+ `child_args()` is in the key too, and that is not a detail. render.py appends it to every
559
+ stage command AFTER the arguments the stage itself built, and it carries `--fast` -- which
560
+ rewrites the child's preset to veryfast. Without it in the key, `render --cache C --fast`
561
+ stored a draft and the next `render --cache C` served that draft back as the delivery, with
562
+ `cache.hits` presenting it as a legitimate reuse.
563
+
564
+ The output's extension is in the key as well (#15): the artifact is stored as `<key><ext>`
565
+ while the sidecar is `<key>.json`, so two runs differing only in container would otherwise
566
+ share one sidecar and invalidate each other on every run.
567
+ """
568
+ payload = {
569
+ "stage": stage, "tool": script,
570
+ "args": [_content_hash(str(a)) if os.path.exists(str(a)) else str(a) for a in argv],
571
+ "inputs": [{"hash": _content_hash(p)} for p in inputs],
572
+ "child": [a for a in child_args() if a != "--dry-run"],
573
+ "codec": STATE.codec, "ext": Path(dest).suffix if dest else None,
574
+ "ffmpeg": CACHE.get("ffmpeg"), "skill": SKILL_VERSION, "contract": CONTRACT_VERSION,
575
+ }
576
+ return hashlib.sha1(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
577
+
578
+
579
+ def cache_lookup(stage: str, key: str, dest: str) -> bool:
580
+ """Put the cached artifact for `key` at `dest` and return True, or return False on any
581
+ mismatch -- silently, because a miss is not an error, it is just work to do."""
582
+ cdir = CACHE.get("dir")
583
+ if not cdir:
584
+ return False
585
+ side = Path(cdir) / f"{key}.json"
586
+ art = Path(cdir) / f"{key}{Path(dest).suffix or '.bin'}"
587
+ if not (side.exists() and art.exists()):
588
+ return False
589
+ try:
590
+ meta = json.loads(side.read_text(encoding="utf-8"))
591
+ except (OSError, ValueError):
592
+ return False
593
+ if int(meta.get("size") or -1) != art.stat().st_size:
594
+ return False
595
+ if STATE.dry_run:
596
+ CACHE["would_hit"].append(stage)
597
+ return True
598
+ Path(dest).parent.mkdir(parents=True, exist_ok=True)
599
+ try:
600
+ if Path(dest).exists():
601
+ Path(dest).unlink()
602
+ os.link(art, dest) # a hardlink where the filesystem allows it ...
603
+ except OSError:
604
+ shutil.copy2(art, dest) # ... and a copy where it does not. Never a move: the cache
605
+ CACHE["saved_seconds"] += float(meta.get("seconds") or 0.0)
606
+ return True
607
+
608
+
609
+ def cache_store(stage: str, key: str, produced: str, seconds: float) -> None:
610
+ """Keep `produced` for the next run. Never under --dry-run: a plan writes nothing."""
611
+ cdir = CACHE.get("dir")
612
+ if not cdir or STATE.dry_run or not produced or not os.path.exists(produced):
613
+ return
614
+ art = Path(cdir) / f"{key}{Path(produced).suffix or '.bin'}"
615
+ try:
616
+ shutil.copy2(produced, art)
617
+ Path(cdir, f"{key}.json").write_text(json.dumps({
618
+ "stage": stage, "ffmpeg": CACHE.get("ffmpeg"), "skill": SKILL_VERSION,
619
+ "created": time.time(), "size": art.stat().st_size, "seconds": round(seconds, 2),
620
+ "artifact": art.name}, indent=2), encoding="utf-8")
621
+ CACHE["entries"] += 1
622
+ except OSError as exc:
623
+ info(f"cache: could not store the {stage} artifact ({exc}); the run is unaffected")
401
624
 
402
625
 
403
626
  def execute_plan(plan: Dict[str, Any], path: str) -> int:
@@ -501,6 +724,14 @@ def main() -> int:
501
724
  ap.add_argument("--init", metavar="FILE", help="write a starter project file and exit")
502
725
  ap.add_argument("--work", help="work directory for intermediates (default: <output>_work)")
503
726
  ap.add_argument("--keep", action="store_true", help="keep intermediates (default: kept only when --work is given)")
727
+ ap.add_argument("--cache", metavar="DIR",
728
+ help="reuse the artifacts of identical stages from a previous run. Opt-in: "
729
+ "there is no default directory. The ffmpeg version and the skill version "
730
+ "are part of every key, so a cache never crosses either.")
731
+ ap.add_argument("--from", dest="from_stage", metavar="STAGE",
732
+ choices=list(STAGE_ORDER),
733
+ help="start at this stage, taking every earlier one from --cache; refuses if "
734
+ "one of them is not there")
504
735
  ap.add_argument("--stop-after", choices=["clips", "join", "silence", "fit", "captions", "graphics", "overlays", "audio", "loudness", "export"], help="stop after this stage (for iterating)")
505
736
  tpl = ap.add_argument_group("delivery templates (one command per destination)")
506
737
  tpl.add_argument("--template", metavar="NAME", help="render INPUT with a shipped template: " + ", ".join(template_names())
@@ -601,7 +832,6 @@ def main() -> int:
601
832
  # a failed or dry run used to leave <output>_work_<pid>/ behind (sweep F15): the
602
833
  # auto-named directory is ours alone, so remove it on every exit path
603
834
  import atexit
604
- import shutil
605
835
  atexit.register(lambda: shutil.rmtree(work, ignore_errors=True))
606
836
  # Intermediates keep the delivery's media kind: a .mp4 project is unchanged (every stage file
607
837
  # is still clipNN.mp4 / fit.mp4 / loudnorm.mp4), while an audio-only delivery (the podcast
@@ -618,6 +848,38 @@ def main() -> int:
618
848
  dest = str(((proj.get("check") or {}).get("platform") or "")) if proj.get("template") else ""
619
849
  platform_args: List[str] = ["--platform", dest] if dest in PLATFORMS and PLATFORMS[dest].get("frame") else []
620
850
  stages_done: List[str] = []
851
+ # what the caption stage reported (wrapped/split counts and the fit-size keys), so a caller
852
+ # reading render.py's JSON can see whether the type was shrunk to fit or a cue was split.
853
+ caption_report: Optional[Dict[str, Any]] = None
854
+
855
+ CACHE.clear()
856
+ CACHE.update(_fresh_cache())
857
+ if args.cache:
858
+ cdir = Path(args.cache)
859
+ try:
860
+ cdir.mkdir(parents=True, exist_ok=True)
861
+ probe_file = cdir / ".writable"
862
+ probe_file.write_text("", encoding="utf-8")
863
+ probe_file.unlink()
864
+ except OSError as exc:
865
+ die(f"--cache {args.cache}: not a writable directory ({exc})", kind="output")
866
+ CACHE["dir"] = str(cdir)
867
+ CACHE["ffmpeg"] = ffmpeg_banner()
868
+ CACHE["entries"] = len(list(cdir.glob("*.json")))
869
+ CACHE["from"] = args.from_stage
870
+ if args.from_stage and not args.cache:
871
+ die(f"--from {args.from_stage} needs --cache DIR with a previous run's stages: without a "
872
+ "cache there is no earlier artifact to start from, so every stage would run anyway.",
873
+ kind="input")
874
+
875
+ # A project may ask for its clip boundaries to land on the music's beat. The measurement and
876
+ # the refusal both live in cut.py -- render forwards the request and reports what came back,
877
+ # so a project that does not name "snap" builds the command line 1.16 built.
878
+ snap_spec = proj.get("snap") or {}
879
+ snap_reports: List[Dict[str, Any]] = []
880
+ if snap_spec and str(snap_spec.get("to") or "") not in ("", "none", "beats"):
881
+ die(f'snap.to: only "beats" (or "none") is a beat grid this skill can measure, got '
882
+ f'{snap_spec.get("to")!r}', kind="input")
621
883
 
622
884
  # ---- clips
623
885
  parts: List[str] = []
@@ -635,7 +897,26 @@ def main() -> int:
635
897
  argv += ["--start", c["in"]]
636
898
  if c.get("out") is not None:
637
899
  argv += ["--end", c["out"]]
638
- sh("cut.py", *argv)
900
+ clip_snap = dict(snap_spec)
901
+ clip_snap.update(c.get("snap") or {})
902
+ if str(clip_snap.get("to") or "none") == "beats":
903
+ argv += ["--snap", "beats"]
904
+ if clip_snap.get("tolerance") is not None:
905
+ argv += ["--snap-tolerance", str(clip_snap["tolerance"])]
906
+ if clip_snap.get("min_confidence") is not None:
907
+ argv += ["--min-confidence", str(clip_snap["min_confidence"])]
908
+ if clip_snap.get("source"):
909
+ argv += ["--snap-source", rel(clip_snap["source"])]
910
+ sh("cut.py", *argv, stage="clips")
911
+ if _LAST_DOC.get("snap"):
912
+ snap_reports.append({"clip": i, **_LAST_DOC["snap"]})
913
+ elif _LAST_DOC.get("cached") and str(clip_snap.get("to") or "none") == "beats":
914
+ # The cut is the one the cache holds, so it WAS snapped -- the moves are simply
915
+ # not re-measured. Saying `snap: null` here would report the opposite.
916
+ snap_reports.append({"clip": i, "mode": "beats", "source": "cache",
917
+ "note": "this clip came from --cache; it was snapped when "
918
+ "it was first rendered and the moves are in that "
919
+ "run's result"})
639
920
  else:
640
921
  part = src
641
922
  if c.get("speed"):
@@ -645,7 +926,7 @@ def main() -> int:
645
926
  if abs(spd - 1.0) > 1e-6: # speed 1.0 used to cost a full re-encode for nothing
646
927
  dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else 10.0
647
928
  fitted = str(work / f"clip{i:02d}_speed{mid}")
648
- sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
929
+ sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted, stage="clips")
649
930
  part = fitted
650
931
  parts.append(part)
651
932
  stages_done.append("clips")
@@ -667,7 +948,7 @@ def main() -> int:
667
948
  if ag.get(key) is not None:
668
949
  argv += [flag, str(ag[key])]
669
950
  argv += brand_args
670
- sh("waveform.py", *argv)
951
+ sh("waveform.py", *argv, stage="audiogram")
671
952
  current = nxt
672
953
  parts = [current]
673
954
  stages_done.append("audiogram")
@@ -686,7 +967,7 @@ def main() -> int:
686
967
  argv += ["--height", str(frame["height"])]
687
968
  if frame.get("fps"):
688
969
  argv += ["--fps", str(frame["fps"])]
689
- sh("join.py", *argv)
970
+ sh("join.py", *argv, stage="join")
690
971
  stages_done.append("join")
691
972
  if args.stop_after == "join":
692
973
  emit(current, stages=stages_done)
@@ -700,7 +981,7 @@ def main() -> int:
700
981
  for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin")):
701
982
  if sil.get(k) is not None:
702
983
  argv += [flag, str(sil[k])]
703
- sh("silence.py", *argv)
984
+ sh("silence.py", *argv, stage="silence")
704
985
  current = nxt
705
986
  stages_done.append("silence")
706
987
  if args.stop_after == "silence":
@@ -725,7 +1006,7 @@ def main() -> int:
725
1006
  for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("height", "--height"), ("fps", "--fps"), ("smooth", "--smooth")):
726
1007
  if fit.get(k) is not None:
727
1008
  argv += [flag, str(fit[k])]
728
- sh("fit.py", *argv)
1009
+ sh("fit.py", *argv, stage="fit")
729
1010
  current = nxt
730
1011
  stages_done.append("fit")
731
1012
  if args.stop_after == "fit":
@@ -745,13 +1026,16 @@ def main() -> int:
745
1026
  argv += ["--ass", rel(cap["ass"])]
746
1027
  else:
747
1028
  die("captions needs text, srt or ass")
748
- for k, flag in (("font", "--font"), ("size", "--size"), ("color", "--color"), ("position", "--position"), ("margin", "--margin"), ("animate", "--animate"), ("highlight_color", "--highlight-color"), ("outline", "--outline"), ("lang", "--lang"), ("offset", "--offset"), ("max_lines", "--max-lines"), ("min_duration", "--min-duration")):
1029
+ for k, flag in (("font", "--font"), ("size", "--size"), ("color", "--color"), ("position", "--position"), ("margin", "--margin"), ("animate", "--animate"), ("highlight_color", "--highlight-color"), ("outline", "--outline"), ("lang", "--lang"), ("offset", "--offset"), ("max_lines", "--max-lines"), ("min_duration", "--min-duration"),
1030
+ ("fit_size", "--fit-size"), ("min_size", "--min-size"), ("fit_size_scope", "--fit-size-scope")):
749
1031
  if cap.get(k) is not None:
750
1032
  argv += [flag, str(cap[k])]
751
1033
  for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
752
1034
  if cap.get(k):
753
1035
  argv.append(flag)
754
- sh("caption.py", *(argv + brand_args + platform_args))
1036
+ sh("caption.py", *(argv + brand_args + platform_args), stage="captions")
1037
+ if isinstance(_LAST_DOC.get("caption"), dict):
1038
+ caption_report = dict(_LAST_DOC["caption"])
755
1039
  current = nxt
756
1040
  stages_done.append("captions")
757
1041
  if args.stop_after == "captions":
@@ -769,7 +1053,7 @@ def main() -> int:
769
1053
  if g.get(k) is not None:
770
1054
  argv += [flag, str(g[k])]
771
1055
  # the entry's own "platform" is the more specific statement than the template's destination
772
- sh("graphics.py", *(argv + brand_args + ([] if g.get("platform") else platform_args)))
1056
+ sh("graphics.py", *(argv + brand_args + ([] if g.get("platform") else platform_args)), stage="graphics")
773
1057
  current = nxt
774
1058
  if "graphics" not in stages_done:
775
1059
  stages_done.append("graphics")
@@ -796,7 +1080,7 @@ def main() -> int:
796
1080
  argv.append("--box")
797
1081
  # review 12: the overlay stage was the one stage that never heard which destination this
798
1082
  # is, so a template's top-left logo landed 24 px in -- under TikTok's own status bar.
799
- sh("overlay.py", *(argv + brand_args + ([] if ov.get("platform") else platform_args)))
1083
+ sh("overlay.py", *(argv + brand_args + ([] if ov.get("platform") else platform_args)), stage="overlays")
800
1084
  current = nxt
801
1085
  if "overlays" not in stages_done:
802
1086
  stages_done.append("overlays")
@@ -834,7 +1118,7 @@ def main() -> int:
834
1118
  for k, flag in (("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
835
1119
  if au.get(k):
836
1120
  argv.append(flag)
837
- sh("audio.py", *argv)
1121
+ sh("audio.py", *argv, stage="audio")
838
1122
  current = nxt
839
1123
  stages_done.append("audio")
840
1124
  if args.stop_after == "audio":
@@ -850,7 +1134,7 @@ def main() -> int:
850
1134
  argv += ["-I", str(ld["lufs"])]
851
1135
  if ld.get("tp") is not None:
852
1136
  argv += ["--tp", str(ld["tp"])]
853
- sh("loudness.py", *argv)
1137
+ sh("loudness.py", *argv, stage="loudness")
854
1138
  current = nxt
855
1139
  stages_done.append("loudness")
856
1140
  if args.stop_after == "loudness":
@@ -874,7 +1158,7 @@ def main() -> int:
874
1158
  info(f"export: --normalize on by default for the {ex['preset']} preset (set \"normalize\": false to skip)")
875
1159
  if normalize:
876
1160
  argv += ["--normalize"] # one export that meets the platform's loudness (export.py --normalize)
877
- sh("export.py", *argv)
1161
+ sh("export.py", *argv, stage="export")
878
1162
  stages_done.append("export")
879
1163
  else:
880
1164
  if not STATE.dry_run:
@@ -895,7 +1179,7 @@ def main() -> int:
895
1179
  else:
896
1180
  chapter_file = rel(ch)
897
1181
  tagged = str(work / ("chapters" + Path(output).suffix))
898
- sh("metadata.py", output, "--chapters", chapter_file, "-o", tagged)
1182
+ sh("metadata.py", output, "--chapters", chapter_file, "-o", tagged, stage="chapters")
899
1183
  if not STATE.dry_run:
900
1184
  place_output(tagged, output)
901
1185
  stages_done.append("chapters")
@@ -926,7 +1210,6 @@ def main() -> int:
926
1210
  # default name carries this process's PID, nothing else will ever reuse -- and so
927
1211
  # implicitly clean up -- a leftover dry-run directory the way a same-named real run used
928
1212
  # to before the PID suffix was added.
929
- import shutil
930
1213
  shutil.rmtree(work, ignore_errors=True)
931
1214
  if exit_code:
932
1215
  # The deliverable is written and verified, but it does not meet the requested platform
@@ -936,7 +1219,24 @@ def main() -> int:
936
1219
  kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
937
1220
  probe=probe(output, role="output"))
938
1221
  info(f"rendered {output} via {' → '.join(stages_done)}")
939
- emit(output, stages=stages_done, check=check_result,
1222
+ cache_report = None
1223
+ if args.cache:
1224
+ cache_report = {"dir": CACHE["dir"], "ffmpeg": CACHE["ffmpeg"],
1225
+ "hits": CACHE["hits"], "misses": CACHE["misses"],
1226
+ "saved_seconds": round(CACHE["saved_seconds"], 1),
1227
+ "entries": CACHE["entries"]}
1228
+ if STATE.dry_run:
1229
+ cache_report["would_hit"] = CACHE["would_hit"]
1230
+ info(f"cache: {len(CACHE['hits'])} hit(s) ({', '.join(CACHE['hits']) or '-'}), "
1231
+ f"{len(CACHE['misses'])} miss(es) ({', '.join(CACHE['misses']) or '-'})")
1232
+ # One entry per snapped clip, `clip` naming which. A single-clip project keeps the shape a
1233
+ # caller reads today by also carrying the first entry's keys at the top level.
1234
+ snap_report: Optional[Dict[str, Any]] = None
1235
+ if snap_reports:
1236
+ snap_report = dict(snap_reports[0])
1237
+ snap_report["clips"] = snap_reports
1238
+ emit(output, stages=stages_done, check=check_result, snap=snap_report, cache=cache_report,
1239
+ caption=caption_report,
940
1240
  verification=[{"step": "check", "ok": True, "platform": ck["platform"]}] if check_result else [])
941
1241
  return 0
942
1242