ffmpeg-skill 1.16.1 → 1.17.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.
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, 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,8 +116,10 @@ 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"}),
@@ -359,7 +380,7 @@ def check_keys(obj: Any, schema: str, label: str) -> None:
359
380
 
360
381
  def validate_project(proj: Dict[str, Any]) -> None:
361
382
  check_keys(proj, "project", "project")
362
- for name in ("frame", "transition", "silence", "audiogram", "captions", "audio", "loudness", "fit", "export", "check"):
383
+ for name in ("frame", "transition", "silence", "audiogram", "captions", "audio", "loudness", "fit", "export", "check", "snap"):
363
384
  check_keys(proj.get(name), name, name)
364
385
  check_keys((proj.get("audio") or {}).get("stems"), "audio.stems", "audio.stems")
365
386
  if isinstance(proj.get("chapters"), list):
@@ -374,10 +395,55 @@ def validate_project(proj: Dict[str, Any]) -> None:
374
395
  if isinstance(items, list):
375
396
  for i, item in enumerate(items):
376
397
  check_keys(item, f"{name}[]", f"{name}[{i}]")
398
+ if name == "clips" and isinstance(item, dict) and item.get("snap") is not None:
399
+ check_keys(item["snap"], "snap", f"clips[{i}].snap")
400
+
401
+
402
+ _LAST_DOC: Dict[str, Any] = {} # the JSON document the most recent sh() child printed
403
+
404
+
405
+ def _refuse_uncached_earlier_stage(stage: str) -> None:
406
+ """--from STAGE promises the earlier stages come from the cache. When one does not, say so
407
+ rather than quietly re-encoding the thing the caller asked to skip."""
408
+ target = CACHE.get("from")
409
+ if not target or stage not in STAGE_ORDER or target not in STAGE_ORDER:
410
+ return
411
+ if STAGE_ORDER.index(stage) < STAGE_ORDER.index(target):
412
+ die(f"--from {target}: the {stage} stage is not in {CACHE['dir']} for this project and "
413
+ "these inputs, so there is nothing to start from. Render once without --from to fill "
414
+ "the cache (note that a different ffmpeg build or skill version never reuses one).",
415
+ kind="input")
416
+
377
417
 
418
+ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> str:
419
+ """Run a sibling script, forwarding --fast / --dry-run, returning its printed output path.
378
420
 
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."""
421
+ With --cache and a named `stage`, an identical stage that ran before is served from the
422
+ cache instead of re-encoded. `stages_done` is unchanged either way: a cached stage is still
423
+ a stage that happened.
424
+ """
425
+ full = [str(a) for a in argv] + (extra or [])
426
+ dest = full[full.index("-o") + 1] if "-o" in full[:-1] else None
427
+ key = None
428
+ if stage and CACHE.get("dir") and dest:
429
+ inputs = [a for a in full if os.path.exists(a) and a != dest]
430
+ # The destination is where this stage's answer goes, not part of the question: hashing it
431
+ # would make a second run with the first run's output already on disk miss every time.
432
+ key_args = ["<out>" if a == dest else a for a in full]
433
+ key = cache_key(stage, script, key_args, inputs, dest)
434
+ if cache_lookup(stage, key, dest):
435
+ CACHE["hits"].append(stage)
436
+ info(f"→ {script} {stage}: served from --cache")
437
+ # No child ran, so there is no document: say so rather than leaving the PREVIOUS
438
+ # child's document standing, which a caller reading _LAST_DOC would misattribute.
439
+ _LAST_DOC.clear()
440
+ _LAST_DOC["cached"] = True
441
+ return dest
442
+ CACHE["misses"].append(stage)
443
+ _refuse_uncached_earlier_stage(stage)
444
+ elif stage and CACHE.get("dir"):
445
+ CACHE["misses"].append(stage)
446
+ started = time.time()
381
447
  cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args() + ["--json"]
382
448
  info("→ " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd[:-1])))
383
449
  proc = run_tool(cmd)
@@ -397,7 +463,136 @@ def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
397
463
  extra_fields = {"hint": err["hint"]} if err.get("hint") else {}
398
464
  die(f"{script} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
399
465
  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 "")
466
+ _LAST_DOC.clear()
467
+ _LAST_DOC.update(doc if isinstance(doc, dict) else {})
468
+ out_path = str(doc.get("output") or "")
469
+ if key:
470
+ cache_store(stage, key, out_path or (dest or ""), time.time() - started)
471
+ return out_path
472
+
473
+
474
+ # ------------------------------------------------------------------ the stage cache (1.17)
475
+ #
476
+ # Opt-in only: --cache DIR. There is no default directory -- a cache that appears on someone's
477
+ # disk without being asked for is a surprise, and this tool's posture is that a plan leaves
478
+ # nothing behind.
479
+
480
+ STAGE_ORDER = ("clips", "audiogram", "join", "silence", "fit", "captions", "graphics",
481
+ "overlays", "audio", "loudness", "export", "chapters")
482
+
483
+ def _fresh_cache() -> "Dict[str, Any]":
484
+ return {"dir": None, "ffmpeg": None, "hits": [], "misses": [], "saved_seconds": 0.0,
485
+ "entries": 0, "would_hit": [], "from": None}
486
+
487
+
488
+ # Module-level so sh() can reach it without threading a parameter through every stage. main()
489
+ # resets it on entry, so two renders in one process (a test session, an embedding caller) do not
490
+ # inherit each other's hit/miss lists.
491
+ CACHE: Dict[str, Any] = _fresh_cache()
492
+
493
+
494
+ def _content_hash(path: str) -> str:
495
+ """The same cheap content fingerprint batch.py caches on: name, size, mtime, first and last
496
+ MB. Reused rather than reinvented so one file has one identity across the skill."""
497
+ try:
498
+ return file_key(Path(path))
499
+ except (OSError, ValueError):
500
+ return "missing"
501
+
502
+
503
+ def ffmpeg_banner() -> str:
504
+ """The whole `ffprobe -version` first line, not just major.minor.
505
+
506
+ Two 7.1.x builds with different libx264 produce different bytes from the same command, and
507
+ the cache exists to hand back bytes. major.minor cannot tell them apart, so the banner --
508
+ which carries the build string and the configuration's version suffix -- is what goes in the
509
+ key. Unreadable falls back to the parsed pair, which still separates the major releases.
510
+ """
511
+ try:
512
+ out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE,
513
+ stderr=subprocess.DEVNULL, text=True, timeout=20).stdout
514
+ first = (out or "").strip().splitlines()
515
+ if first:
516
+ return first[0].strip()
517
+ except (OSError, subprocess.SubprocessError):
518
+ pass
519
+ return ".".join(str(n) for n in ffmpeg_version())
520
+
521
+
522
+ def cache_key(stage: str, script: str, argv: "Sequence[Any]", inputs: "Sequence[str]",
523
+ dest: "Optional[str]" = None) -> str:
524
+ """sha1 of a canonical description of exactly what this stage is about to do.
525
+
526
+ The ffmpeg build banner and the skill version are IN the key, deliberately: a different build
527
+ simply misses rather than being asked to trust an artifact it did not write, and a stage whose
528
+ implementation changed must not serve an old one back.
529
+
530
+ `child_args()` is in the key too, and that is not a detail. render.py appends it to every
531
+ stage command AFTER the arguments the stage itself built, and it carries `--fast` -- which
532
+ rewrites the child's preset to veryfast. Without it in the key, `render --cache C --fast`
533
+ stored a draft and the next `render --cache C` served that draft back as the delivery, with
534
+ `cache.hits` presenting it as a legitimate reuse.
535
+
536
+ The output's extension is in the key as well (#15): the artifact is stored as `<key><ext>`
537
+ while the sidecar is `<key>.json`, so two runs differing only in container would otherwise
538
+ share one sidecar and invalidate each other on every run.
539
+ """
540
+ payload = {
541
+ "stage": stage, "tool": script,
542
+ "args": [_content_hash(str(a)) if os.path.exists(str(a)) else str(a) for a in argv],
543
+ "inputs": [{"hash": _content_hash(p)} for p in inputs],
544
+ "child": [a for a in child_args() if a != "--dry-run"],
545
+ "codec": STATE.codec, "ext": Path(dest).suffix if dest else None,
546
+ "ffmpeg": CACHE.get("ffmpeg"), "skill": SKILL_VERSION, "contract": CONTRACT_VERSION,
547
+ }
548
+ return hashlib.sha1(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
549
+
550
+
551
+ def cache_lookup(stage: str, key: str, dest: str) -> bool:
552
+ """Put the cached artifact for `key` at `dest` and return True, or return False on any
553
+ mismatch -- silently, because a miss is not an error, it is just work to do."""
554
+ cdir = CACHE.get("dir")
555
+ if not cdir:
556
+ return False
557
+ side = Path(cdir) / f"{key}.json"
558
+ art = Path(cdir) / f"{key}{Path(dest).suffix or '.bin'}"
559
+ if not (side.exists() and art.exists()):
560
+ return False
561
+ try:
562
+ meta = json.loads(side.read_text(encoding="utf-8"))
563
+ except (OSError, ValueError):
564
+ return False
565
+ if int(meta.get("size") or -1) != art.stat().st_size:
566
+ return False
567
+ if STATE.dry_run:
568
+ CACHE["would_hit"].append(stage)
569
+ return True
570
+ Path(dest).parent.mkdir(parents=True, exist_ok=True)
571
+ try:
572
+ if Path(dest).exists():
573
+ Path(dest).unlink()
574
+ os.link(art, dest) # a hardlink where the filesystem allows it ...
575
+ except OSError:
576
+ shutil.copy2(art, dest) # ... and a copy where it does not. Never a move: the cache
577
+ CACHE["saved_seconds"] += float(meta.get("seconds") or 0.0)
578
+ return True
579
+
580
+
581
+ def cache_store(stage: str, key: str, produced: str, seconds: float) -> None:
582
+ """Keep `produced` for the next run. Never under --dry-run: a plan writes nothing."""
583
+ cdir = CACHE.get("dir")
584
+ if not cdir or STATE.dry_run or not produced or not os.path.exists(produced):
585
+ return
586
+ art = Path(cdir) / f"{key}{Path(produced).suffix or '.bin'}"
587
+ try:
588
+ shutil.copy2(produced, art)
589
+ Path(cdir, f"{key}.json").write_text(json.dumps({
590
+ "stage": stage, "ffmpeg": CACHE.get("ffmpeg"), "skill": SKILL_VERSION,
591
+ "created": time.time(), "size": art.stat().st_size, "seconds": round(seconds, 2),
592
+ "artifact": art.name}, indent=2), encoding="utf-8")
593
+ CACHE["entries"] += 1
594
+ except OSError as exc:
595
+ info(f"cache: could not store the {stage} artifact ({exc}); the run is unaffected")
401
596
 
402
597
 
403
598
  def execute_plan(plan: Dict[str, Any], path: str) -> int:
@@ -501,6 +696,14 @@ def main() -> int:
501
696
  ap.add_argument("--init", metavar="FILE", help="write a starter project file and exit")
502
697
  ap.add_argument("--work", help="work directory for intermediates (default: <output>_work)")
503
698
  ap.add_argument("--keep", action="store_true", help="keep intermediates (default: kept only when --work is given)")
699
+ ap.add_argument("--cache", metavar="DIR",
700
+ help="reuse the artifacts of identical stages from a previous run. Opt-in: "
701
+ "there is no default directory. The ffmpeg version and the skill version "
702
+ "are part of every key, so a cache never crosses either.")
703
+ ap.add_argument("--from", dest="from_stage", metavar="STAGE",
704
+ choices=list(STAGE_ORDER),
705
+ help="start at this stage, taking every earlier one from --cache; refuses if "
706
+ "one of them is not there")
504
707
  ap.add_argument("--stop-after", choices=["clips", "join", "silence", "fit", "captions", "graphics", "overlays", "audio", "loudness", "export"], help="stop after this stage (for iterating)")
505
708
  tpl = ap.add_argument_group("delivery templates (one command per destination)")
506
709
  tpl.add_argument("--template", metavar="NAME", help="render INPUT with a shipped template: " + ", ".join(template_names())
@@ -601,7 +804,6 @@ def main() -> int:
601
804
  # a failed or dry run used to leave <output>_work_<pid>/ behind (sweep F15): the
602
805
  # auto-named directory is ours alone, so remove it on every exit path
603
806
  import atexit
604
- import shutil
605
807
  atexit.register(lambda: shutil.rmtree(work, ignore_errors=True))
606
808
  # Intermediates keep the delivery's media kind: a .mp4 project is unchanged (every stage file
607
809
  # is still clipNN.mp4 / fit.mp4 / loudnorm.mp4), while an audio-only delivery (the podcast
@@ -619,6 +821,35 @@ def main() -> int:
619
821
  platform_args: List[str] = ["--platform", dest] if dest in PLATFORMS and PLATFORMS[dest].get("frame") else []
620
822
  stages_done: List[str] = []
621
823
 
824
+ CACHE.clear()
825
+ CACHE.update(_fresh_cache())
826
+ if args.cache:
827
+ cdir = Path(args.cache)
828
+ try:
829
+ cdir.mkdir(parents=True, exist_ok=True)
830
+ probe_file = cdir / ".writable"
831
+ probe_file.write_text("", encoding="utf-8")
832
+ probe_file.unlink()
833
+ except OSError as exc:
834
+ die(f"--cache {args.cache}: not a writable directory ({exc})", kind="output")
835
+ CACHE["dir"] = str(cdir)
836
+ CACHE["ffmpeg"] = ffmpeg_banner()
837
+ CACHE["entries"] = len(list(cdir.glob("*.json")))
838
+ CACHE["from"] = args.from_stage
839
+ if args.from_stage and not args.cache:
840
+ die(f"--from {args.from_stage} needs --cache DIR with a previous run's stages: without a "
841
+ "cache there is no earlier artifact to start from, so every stage would run anyway.",
842
+ kind="input")
843
+
844
+ # A project may ask for its clip boundaries to land on the music's beat. The measurement and
845
+ # the refusal both live in cut.py -- render forwards the request and reports what came back,
846
+ # so a project that does not name "snap" builds the command line 1.16 built.
847
+ snap_spec = proj.get("snap") or {}
848
+ snap_reports: List[Dict[str, Any]] = []
849
+ if snap_spec and str(snap_spec.get("to") or "") not in ("", "none", "beats"):
850
+ die(f'snap.to: only "beats" (or "none") is a beat grid this skill can measure, got '
851
+ f'{snap_spec.get("to")!r}', kind="input")
852
+
622
853
  # ---- clips
623
854
  parts: List[str] = []
624
855
  for i, c in enumerate(clips):
@@ -635,7 +866,26 @@ def main() -> int:
635
866
  argv += ["--start", c["in"]]
636
867
  if c.get("out") is not None:
637
868
  argv += ["--end", c["out"]]
638
- sh("cut.py", *argv)
869
+ clip_snap = dict(snap_spec)
870
+ clip_snap.update(c.get("snap") or {})
871
+ if str(clip_snap.get("to") or "none") == "beats":
872
+ argv += ["--snap", "beats"]
873
+ if clip_snap.get("tolerance") is not None:
874
+ argv += ["--snap-tolerance", str(clip_snap["tolerance"])]
875
+ if clip_snap.get("min_confidence") is not None:
876
+ argv += ["--min-confidence", str(clip_snap["min_confidence"])]
877
+ if clip_snap.get("source"):
878
+ argv += ["--snap-source", rel(clip_snap["source"])]
879
+ sh("cut.py", *argv, stage="clips")
880
+ if _LAST_DOC.get("snap"):
881
+ snap_reports.append({"clip": i, **_LAST_DOC["snap"]})
882
+ elif _LAST_DOC.get("cached") and str(clip_snap.get("to") or "none") == "beats":
883
+ # The cut is the one the cache holds, so it WAS snapped -- the moves are simply
884
+ # not re-measured. Saying `snap: null` here would report the opposite.
885
+ snap_reports.append({"clip": i, "mode": "beats", "source": "cache",
886
+ "note": "this clip came from --cache; it was snapped when "
887
+ "it was first rendered and the moves are in that "
888
+ "run's result"})
639
889
  else:
640
890
  part = src
641
891
  if c.get("speed"):
@@ -645,7 +895,7 @@ def main() -> int:
645
895
  if abs(spd - 1.0) > 1e-6: # speed 1.0 used to cost a full re-encode for nothing
646
896
  dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else 10.0
647
897
  fitted = str(work / f"clip{i:02d}_speed{mid}")
648
- sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
898
+ sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted, stage="clips")
649
899
  part = fitted
650
900
  parts.append(part)
651
901
  stages_done.append("clips")
@@ -667,7 +917,7 @@ def main() -> int:
667
917
  if ag.get(key) is not None:
668
918
  argv += [flag, str(ag[key])]
669
919
  argv += brand_args
670
- sh("waveform.py", *argv)
920
+ sh("waveform.py", *argv, stage="audiogram")
671
921
  current = nxt
672
922
  parts = [current]
673
923
  stages_done.append("audiogram")
@@ -686,7 +936,7 @@ def main() -> int:
686
936
  argv += ["--height", str(frame["height"])]
687
937
  if frame.get("fps"):
688
938
  argv += ["--fps", str(frame["fps"])]
689
- sh("join.py", *argv)
939
+ sh("join.py", *argv, stage="join")
690
940
  stages_done.append("join")
691
941
  if args.stop_after == "join":
692
942
  emit(current, stages=stages_done)
@@ -700,7 +950,7 @@ def main() -> int:
700
950
  for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin")):
701
951
  if sil.get(k) is not None:
702
952
  argv += [flag, str(sil[k])]
703
- sh("silence.py", *argv)
953
+ sh("silence.py", *argv, stage="silence")
704
954
  current = nxt
705
955
  stages_done.append("silence")
706
956
  if args.stop_after == "silence":
@@ -725,7 +975,7 @@ def main() -> int:
725
975
  for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("height", "--height"), ("fps", "--fps"), ("smooth", "--smooth")):
726
976
  if fit.get(k) is not None:
727
977
  argv += [flag, str(fit[k])]
728
- sh("fit.py", *argv)
978
+ sh("fit.py", *argv, stage="fit")
729
979
  current = nxt
730
980
  stages_done.append("fit")
731
981
  if args.stop_after == "fit":
@@ -751,7 +1001,7 @@ def main() -> int:
751
1001
  for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
752
1002
  if cap.get(k):
753
1003
  argv.append(flag)
754
- sh("caption.py", *(argv + brand_args + platform_args))
1004
+ sh("caption.py", *(argv + brand_args + platform_args), stage="captions")
755
1005
  current = nxt
756
1006
  stages_done.append("captions")
757
1007
  if args.stop_after == "captions":
@@ -769,7 +1019,7 @@ def main() -> int:
769
1019
  if g.get(k) is not None:
770
1020
  argv += [flag, str(g[k])]
771
1021
  # 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)))
1022
+ sh("graphics.py", *(argv + brand_args + ([] if g.get("platform") else platform_args)), stage="graphics")
773
1023
  current = nxt
774
1024
  if "graphics" not in stages_done:
775
1025
  stages_done.append("graphics")
@@ -796,7 +1046,7 @@ def main() -> int:
796
1046
  argv.append("--box")
797
1047
  # review 12: the overlay stage was the one stage that never heard which destination this
798
1048
  # 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)))
1049
+ sh("overlay.py", *(argv + brand_args + ([] if ov.get("platform") else platform_args)), stage="overlays")
800
1050
  current = nxt
801
1051
  if "overlays" not in stages_done:
802
1052
  stages_done.append("overlays")
@@ -834,7 +1084,7 @@ def main() -> int:
834
1084
  for k, flag in (("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
835
1085
  if au.get(k):
836
1086
  argv.append(flag)
837
- sh("audio.py", *argv)
1087
+ sh("audio.py", *argv, stage="audio")
838
1088
  current = nxt
839
1089
  stages_done.append("audio")
840
1090
  if args.stop_after == "audio":
@@ -850,7 +1100,7 @@ def main() -> int:
850
1100
  argv += ["-I", str(ld["lufs"])]
851
1101
  if ld.get("tp") is not None:
852
1102
  argv += ["--tp", str(ld["tp"])]
853
- sh("loudness.py", *argv)
1103
+ sh("loudness.py", *argv, stage="loudness")
854
1104
  current = nxt
855
1105
  stages_done.append("loudness")
856
1106
  if args.stop_after == "loudness":
@@ -874,7 +1124,7 @@ def main() -> int:
874
1124
  info(f"export: --normalize on by default for the {ex['preset']} preset (set \"normalize\": false to skip)")
875
1125
  if normalize:
876
1126
  argv += ["--normalize"] # one export that meets the platform's loudness (export.py --normalize)
877
- sh("export.py", *argv)
1127
+ sh("export.py", *argv, stage="export")
878
1128
  stages_done.append("export")
879
1129
  else:
880
1130
  if not STATE.dry_run:
@@ -895,7 +1145,7 @@ def main() -> int:
895
1145
  else:
896
1146
  chapter_file = rel(ch)
897
1147
  tagged = str(work / ("chapters" + Path(output).suffix))
898
- sh("metadata.py", output, "--chapters", chapter_file, "-o", tagged)
1148
+ sh("metadata.py", output, "--chapters", chapter_file, "-o", tagged, stage="chapters")
899
1149
  if not STATE.dry_run:
900
1150
  place_output(tagged, output)
901
1151
  stages_done.append("chapters")
@@ -926,7 +1176,6 @@ def main() -> int:
926
1176
  # default name carries this process's PID, nothing else will ever reuse -- and so
927
1177
  # implicitly clean up -- a leftover dry-run directory the way a same-named real run used
928
1178
  # to before the PID suffix was added.
929
- import shutil
930
1179
  shutil.rmtree(work, ignore_errors=True)
931
1180
  if exit_code:
932
1181
  # The deliverable is written and verified, but it does not meet the requested platform
@@ -936,7 +1185,23 @@ def main() -> int:
936
1185
  kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
937
1186
  probe=probe(output, role="output"))
938
1187
  info(f"rendered {output} via {' → '.join(stages_done)}")
939
- emit(output, stages=stages_done, check=check_result,
1188
+ cache_report = None
1189
+ if args.cache:
1190
+ cache_report = {"dir": CACHE["dir"], "ffmpeg": CACHE["ffmpeg"],
1191
+ "hits": CACHE["hits"], "misses": CACHE["misses"],
1192
+ "saved_seconds": round(CACHE["saved_seconds"], 1),
1193
+ "entries": CACHE["entries"]}
1194
+ if STATE.dry_run:
1195
+ cache_report["would_hit"] = CACHE["would_hit"]
1196
+ info(f"cache: {len(CACHE['hits'])} hit(s) ({', '.join(CACHE['hits']) or '-'}), "
1197
+ f"{len(CACHE['misses'])} miss(es) ({', '.join(CACHE['misses']) or '-'})")
1198
+ # One entry per snapped clip, `clip` naming which. A single-clip project keeps the shape a
1199
+ # caller reads today by also carrying the first entry's keys at the top level.
1200
+ snap_report: Optional[Dict[str, Any]] = None
1201
+ if snap_reports:
1202
+ snap_report = dict(snap_reports[0])
1203
+ snap_report["clips"] = snap_reports
1204
+ emit(output, stages=stages_done, check=check_result, snap=snap_report, cache=cache_report,
940
1205
  verification=[{"step": "check", "ok": True, "platform": ck["platform"]}] if check_result else [])
941
1206
  return 0
942
1207
 
package/scripts/scenes.py CHANGED
@@ -20,17 +20,38 @@ Examples:
20
20
  import argparse
21
21
  import math
22
22
  import sys
23
- from typing import Dict, List, Tuple
23
+ from typing import Dict, List, Optional, Tuple
24
24
 
25
25
  # `detect_scenes` moved into _common/probe.py in 1.16.0 (see silence.py); the body is unchanged.
26
- from _common import detect_scenes, STATE, add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, run, decode_pcm_mono, rms_envelope
26
+ from _common import (detect_scenes, STATE, add_common, apply_common, beat_grid, default_font_file, die, emit,
27
+ escape_filter_path, ffmpeg_base, info, print_json, probe, run, decode_pcm_mono,
28
+ rms_envelope, BEAT_MIN_CONFIDENCE)
27
29
 
28
30
 
29
31
 
30
- def audio_envelope(path: str, step_s: float) -> List[float]:
31
- """RMS level per step_s window at 8 kHz, absolute (a loud scene scores higher); [] when the
32
- audio cannot be decoded (the cut scoring then runs on the picture alone)."""
33
- return rms_envelope(decode_pcm_mono(path, 8000, check=False), int(8000 * step_s))
32
+ def audio_envelope(path: str, step_s: float, *, rate: int = 8000,
33
+ samples: "Optional[List[float]]" = None) -> List[float]:
34
+ """RMS level per step_s window, absolute (a loud scene scores higher); [] when the audio
35
+ cannot be decoded (the cut scoring then runs on the picture alone).
36
+
37
+ `samples` reuses PCM a caller already decoded rather than decoding the same file twice --
38
+ --beats needs a 10 ms envelope and the scene scoring a 0.5 s one, and 0.5 s is an integer
39
+ multiple of 10 ms, so both come from one pass.
40
+ """
41
+ if samples is None:
42
+ samples = decode_pcm_mono(path, rate, check=False)
43
+ return rms_envelope(samples, int(rate * step_s))
44
+
45
+
46
+ def parse_beat_range(text: str) -> "tuple":
47
+ """`--beat-range 60-200` as (lo, hi) BPM."""
48
+ try:
49
+ lo, hi = (float(p) for p in str(text).replace(" ", "").split("-", 1))
50
+ except ValueError:
51
+ die(f"--beat-range: expected LO-HI in BPM (e.g. 60-200), got {text!r}", kind="input")
52
+ if not (0 < lo < hi):
53
+ die(f"--beat-range {text}: LO must be above 0 and below HI", kind="input")
54
+ return (lo, hi)
34
55
 
35
56
 
36
57
  def main() -> int:
@@ -45,6 +66,16 @@ def main() -> int:
45
66
  ap.add_argument("--target", type=float, help="with --highlights: total seconds the picks should add up to (trims long scenes)")
46
67
  ap.add_argument("--max-scene", type=float, default=15.0, help="cap a highlight range at this many seconds (default 15)")
47
68
  ap.add_argument("--edl", help="write highlight ranges as START-END lines (cut.py --segments format)")
69
+ ap.add_argument("--beats", action="store_true",
70
+ help="measure the music's beat grid (tempo, beat times, confidence) and report it; "
71
+ "a measurement, not a proposal -- no cut is made and no beat is invented")
72
+ ap.add_argument("--beat-step", type=float, default=0.01,
73
+ help="envelope resolution in seconds for the onset pass with --beats (default 0.01)")
74
+ ap.add_argument("--beat-range", default="60-200",
75
+ help="tempo search range in BPM for --beats (default 60-200)")
76
+ ap.add_argument("--min-confidence", type=float, default=BEAT_MIN_CONFIDENCE,
77
+ help="with --beats: below this confidence the grid is still reported, marked "
78
+ f"usable: false (default {BEAT_MIN_CONFIDENCE})")
48
79
  ap.add_argument("--sheet", help="write a contact sheet PNG with the first frame of every scene")
49
80
  ap.add_argument("--no-timecode", action="store_true", help="--sheet without the burnt-in timecode stamp (a way out if drawtext itself is unusable, see doctor)")
50
81
  add_common(ap)
@@ -54,11 +85,26 @@ def main() -> int:
54
85
  meta = probe(args.input)
55
86
  if not meta.get("video"):
56
87
  die("input has no video stream")
88
+ # Only parsed when it is going to be used: --beat-range is a --beats flag, and a run that
89
+ # never asked for beats should not be able to die on one.
90
+ beat_range = parse_beat_range(args.beat_range) if args.beats else (60.0, 200.0)
91
+ if args.beats:
92
+ if not meta.get("audio"):
93
+ die("--beats needs an audio stream; this file has none", kind="input")
94
+ if args.beat_step <= 0:
95
+ die("--beat-step must be greater than 0", kind="input")
57
96
  dur = meta.get("duration") or 0.0
58
97
  cuts = detect_scenes(args.input, args.threshold, args.min_scene, dur, args.ratio)
59
98
  bounds = cuts + [dur]
60
99
  step_s = 0.5
61
- env = audio_envelope(args.input, step_s) if meta.get("audio") else []
100
+ # With --beats the file is decoded once, at the finer rate, and both envelopes come from that
101
+ # one pass: the 0.5 s scene blocks are an exact multiple of the 10 ms onset blocks.
102
+ beat_rate = 22050
103
+ fine_samples = decode_pcm_mono(args.input, beat_rate, check=False) if (args.beats and meta.get("audio")) else None
104
+ if fine_samples is not None:
105
+ env = audio_envelope(args.input, step_s, rate=beat_rate, samples=fine_samples)
106
+ else:
107
+ env = audio_envelope(args.input, step_s) if meta.get("audio") else []
62
108
 
63
109
  scenes = []
64
110
  for i in range(len(bounds) - 1):
@@ -82,6 +128,34 @@ def main() -> int:
82
128
  result: Dict = {"file": args.input, "duration": round(dur, 3), "scene_count": len(scenes), "scenes": scenes, "audio_peaks": peaks}
83
129
  info(f"{len(scenes)} scenes, {len(peaks)} audio peaks over {dur:.1f}s")
84
130
 
131
+ if args.beats:
132
+ # A beat grid is a measurement of the music's periodicity, not a statement about where a
133
+ # cut belongs. scenes.py reports what it measured, including a low confidence: reporting a
134
+ # weak measurement is honest, and only a tool that CHANGES a file refuses to act on one.
135
+ fine = rms_envelope(fine_samples or [], max(1, int(round(beat_rate * args.beat_step))))
136
+ grid = beat_grid(fine, args.beat_step, bpm_range=beat_range,
137
+ min_confidence=args.min_confidence, duration=dur)
138
+ result["beats"] = grid["beats"]
139
+ result["beat_grid"] = {
140
+ # The regular grid AND the subset a measured onset supports. A tool that MOVES
141
+ # something (cut.py --snap beats) may only use the subset; scenes.py reports both,
142
+ # because here the regular grid is the measurement being made.
143
+ "supported_beats": grid["supported_beats"],
144
+ "tempo_bpm": grid["tempo_bpm"], "interval": grid["interval"],
145
+ "confidence": grid["confidence"], "phase": grid["phase"],
146
+ "onsets": len(grid["onsets"]), "supported": grid["supported"],
147
+ "unsupported": grid["unsupported"], "method": grid["method"],
148
+ "step_s": grid["step_s"], "range_bpm": grid["range_bpm"], "usable": grid["usable"],
149
+ }
150
+ if grid["tempo_bpm"] is None:
151
+ info(f"--beats: no steady pulse in this audio (confidence {grid['confidence']:.2f}) -- "
152
+ "speech, ambience or rubato has no tempo to measure")
153
+ else:
154
+ info(f"--beats: {grid['tempo_bpm']:.1f} BPM, {len(grid['beats'])} beats, confidence "
155
+ f"{grid['confidence']:.2f} ({grid['supported']} of {len(grid['beats'])} grid points "
156
+ f"have a measured onset)"
157
+ + ("" if grid["usable"] else f" -- below --min-confidence {args.min_confidence}, usable: false"))
158
+
85
159
  if args.highlights:
86
160
  if args.rank_by == "duration":
87
161
  rank_key = lambda sc: (-sc["duration"], sc["start"])