ffmpeg-skill 1.16.0 → 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/cut.py CHANGED
@@ -25,11 +25,13 @@ Examples:
25
25
  python3 cut.py talk.mp4 --start 1:00 --end 2:00 -o part.wav # audio extraction
26
26
  """
27
27
  import argparse
28
+ import json
28
29
  import os
29
30
  import sys
30
31
  import tempfile
31
32
  from typing import List, Tuple
32
33
 
34
+ from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
33
35
  from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input, fmt_secs
34
36
 
35
37
  # outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
@@ -148,6 +150,123 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
148
150
  return reencode
149
151
 
150
152
 
153
+ BEAT_RATE = 22050 # the decode rate the onset pass uses, matching scenes.py --beats
154
+
155
+
156
+ def _grid_from_source(path: str, min_confidence: float) -> "dict":
157
+ """The beat grid of `path`: a scenes.py --json document if that is what it is, otherwise a
158
+ media file to measure. Reading a document is how a caller avoids a second decode."""
159
+ try:
160
+ with open(path, "r", encoding="utf-8") as fh:
161
+ doc = json.load(fh)
162
+ except (OSError, ValueError):
163
+ doc = None
164
+ if isinstance(doc, dict) and doc.get("beat_grid"):
165
+ grid = dict(doc["beat_grid"])
166
+ grid["beats"] = doc.get("beats") or []
167
+ # A scenes.py document carries the supported subset since 1.17; one written by an older
168
+ # build does not, and a grid whose supported points are unknown is not one this tool may
169
+ # move a cut onto -- an unknown subset is not an empty one, but it is not a measurement
170
+ # either, so it is refused rather than silently treated as "all of them".
171
+ grid["supported_beats"] = doc.get("beat_grid", {}).get("supported_beats")
172
+ if grid["supported_beats"] is None:
173
+ grid["supported_beats"] = doc.get("supported_beats")
174
+ try:
175
+ tempo = grid.get("tempo_bpm")
176
+ grid["tempo_bpm"] = float(tempo) if tempo is not None else None
177
+ grid["confidence"] = float(grid.get("confidence") or 0.0)
178
+ except (TypeError, ValueError):
179
+ die(f"--snap-source {path}: beat_grid.tempo_bpm and .confidence must be numbers "
180
+ "(regenerate it with `scenes.py MUSIC --beats --json`)", kind="input")
181
+ if grid["beats"] and grid["tempo_bpm"] is None:
182
+ die(f"--snap-source {path}: this document lists beats but no tempo_bpm, so no grid "
183
+ "was actually measured in it. Regenerate it with "
184
+ "`scenes.py MUSIC --beats --json`.", kind="input")
185
+ grid["usable"] = grid["confidence"] >= min_confidence
186
+ return grid
187
+ if isinstance(doc, dict):
188
+ die(f"--snap-source {path}: this JSON has no beat_grid -- produce one with "
189
+ "`scenes.py MUSIC --beats --json`", kind="input")
190
+ samples = decode_pcm_mono(path, BEAT_RATE, check=False)
191
+ env = rms_envelope(samples, max(1, int(round(BEAT_RATE * 0.01))))
192
+ return beat_grid(env, 0.01, min_confidence=min_confidence)
193
+
194
+
195
+ def snap_segments(args, segments, meta, total):
196
+ """Move every in/out point to the nearest measured beat. Returns (result dict, segments).
197
+
198
+ A cut point may move to a measured, onset-supported grid point and may not appear from one:
199
+ the number of segments is unchanged, and nothing is ever proposed. The keyframe/tolerance
200
+ decision downstream then runs on the snapped values, which is the right order -- whether a cut
201
+ can be lossless depends on where it actually lands.
202
+ """
203
+ source = args.snap_source or args.input
204
+ if not args.snap_source and not meta.get("audio"):
205
+ die("--snap beats needs audio to measure a beat in; this file has none. Cut without it "
206
+ "(--snap none), or pass --snap-source with the music bed.", kind="input")
207
+ # Compare the PARSED segments against the whole file, not the raw --start string: "0:00",
208
+ # "0.0" and "00:00:00" are all a zero start that a string comparison lets through, and the
209
+ # run would then snap the implicit end point and silently shorten a whole-file copy.
210
+ whole_file = (len(segments) == 1 and abs(segments[0][0]) < 1e-6
211
+ and (not total or abs(segments[0][1] - total) < 1e-6))
212
+ if whole_file:
213
+ die("--snap beats has no in or out point to move: this run copies the whole file. Give "
214
+ "--start/--end (or --segments), or drop --snap.", kind="input")
215
+ # A floor of zero would make the confidence check vacuous -- a grid measured from noise scores
216
+ # above 0.0 and would pass -- and the whole point of the flag is that a cut only moves onto a
217
+ # pulse somebody can hear. The number is a floor on belief, so it must be a positive one.
218
+ if args.min_confidence <= 0:
219
+ die("--min-confidence must be greater than 0: at 0 every grid is 'reliable', including "
220
+ "one measured from noise, which is exactly what --snap beats must not cut to. Use "
221
+ "--snap none if you do not want the points moved at all.", kind="input")
222
+ grid = _grid_from_source(source, args.min_confidence)
223
+ confidence = float(grid.get("confidence") or 0.0)
224
+ tempo = grid.get("tempo_bpm")
225
+ if confidence < args.min_confidence or not grid.get("beats"):
226
+ die(f"no reliable beat grid in this audio (confidence {confidence:.2f}, needs "
227
+ f"{args.min_confidence:.2f}): cutting to invented beats would move your in/out points "
228
+ "to times nothing in the audio supports. Re-run with --snap none, or pass "
229
+ "--snap-source from a music bed.", kind="input")
230
+ # THE grid a cut may move onto is the onset-supported subset, never the full regular grid.
231
+ # beat_grid() reports a regular grid over the whole duration by design -- a grid has to be
232
+ # regular -- so it runs on through a passage with no music in it. Snapping to one of those
233
+ # points moves a cut to a time nothing in the audio marks, which is the fabrication this
234
+ # release forbids and which this tool's own refusal text promises it does not do.
235
+ supported = grid.get("supported_beats")
236
+ if supported is None:
237
+ die(f"--snap-source {source}: this document does not say which grid points a measured "
238
+ "onset supports, so there is no way to tell a beat from a gap in it. Regenerate it "
239
+ "with `scenes.py MUSIC --beats --json`.", kind="input")
240
+ if not supported:
241
+ die(f"no measured onset supports any point of this beat grid (confidence "
242
+ f"{confidence:.2f}): the grid is regular but nothing in the audio marks it, so every "
243
+ "move would be to an invented time. Re-run with --snap none, or pass --snap-source "
244
+ "from a music bed.", kind="input")
245
+ points = [t for seg in segments for t in seg]
246
+ moved = snap_points(points, supported, args.snap_tolerance)
247
+ out_segments = []
248
+ for i in range(0, len(moved), 2):
249
+ s, e = moved[i]["to"], moved[i + 1]["to"]
250
+ if e <= s: # a snap that would collapse the segment is not applied to it
251
+ s, e = moved[i]["from"], moved[i + 1]["from"]
252
+ moved[i].update({"to": s, "delta": 0.0, "snapped": False, "beat_index": None})
253
+ moved[i + 1].update({"to": e, "delta": 0.0, "snapped": False, "beat_index": None})
254
+ out_segments.append((s, e))
255
+ snapped = sum(1 for m in moved if m["snapped"])
256
+ for m in moved:
257
+ if m["snapped"]:
258
+ info(f"--snap beats: {m['from']:.3f}s -> {m['to']:.3f}s ({m['delta'] * 1000:+.0f} ms)")
259
+ info(f"--snap beats: {tempo:.1f} BPM, confidence {confidence:.2f}; {snapped} of {len(moved)} "
260
+ f"point(s) moved, within {args.snap_tolerance:.3f}s, onto {len(supported)} of "
261
+ f"{len(grid['beats'])} grid point(s) a measured onset supports")
262
+ return ({"mode": "beats", "tolerance": args.snap_tolerance, "confidence": confidence,
263
+ "tempo_bpm": tempo, "grid": "supported", "grid_points": len(supported),
264
+ "moved": [dict(m) for m in moved], "snapped": snapped,
265
+ "unchanged": len(moved) - snapped,
266
+ "source": "measured" if not args.snap_source else args.snap_source},
267
+ out_segments)
268
+
269
+
151
270
  def main() -> int:
152
271
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
153
272
  ap.add_argument("input")
@@ -159,6 +278,17 @@ def main() -> int:
159
278
  ap.add_argument("--segments", help="comma separated START-END list, e.g. '0:05-0:12,1:00-1:20' (joined in order)")
160
279
  ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate (video) / sample-accurate (audio) cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
161
280
  ap.add_argument("--tolerance", type=float, default=0.5, help="max seconds a lossless cut may deviate before re-encoding kicks in (default 0.5, -1 = never)")
281
+ snap = ap.add_argument_group("beat snapping")
282
+ snap.add_argument("--snap", choices=["none", "beats"], default="none",
283
+ help="move each in/out point to the nearest measured beat (default none)")
284
+ snap.add_argument("--snap-tolerance", type=float, default=0.12,
285
+ help="most seconds a point may move with --snap beats (default 0.12, about a "
286
+ "quarter of a beat at 120 BPM)")
287
+ snap.add_argument("--snap-source", metavar="FILE",
288
+ help="take the beat grid from this scenes.py --beats --json document (or from "
289
+ "this media file) instead of measuring the input again")
290
+ snap.add_argument("--min-confidence", type=float, default=BEAT_MIN_CONFIDENCE,
291
+ help=f"refuse to snap below this measured beat confidence (default {BEAT_MIN_CONFIDENCE})")
162
292
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF when re-encoding (default 18)")
163
293
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset when re-encoding")
164
294
  add_common(ap)
@@ -196,6 +326,10 @@ def main() -> int:
196
326
  die("end must be after start")
197
327
  segments = [(start, end)]
198
328
 
329
+ snap_result = None
330
+ if args.snap == "beats":
331
+ snap_result, segments = snap_segments(args, segments, meta, total)
332
+
199
333
  for s, e in segments:
200
334
  if total and s >= total:
201
335
  die(f"segment start {s:.3f}s is beyond the media duration {total:.3f}s")
@@ -249,7 +383,8 @@ def main() -> int:
249
383
  # the trade the caller can offer instead of a re-encode (eval e02: "without losing quality")
250
384
  lossless_alternative=(f"--start {min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])):.3f} lands on a keyframe: "
251
385
  f"stream copy with no re-encode, {abs(min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])) - segments[0][0]):.2f}s off the requested start")
252
- if mode == "hybrid" and NEAREST_KEYFRAMES and len(segments) == 1 else None)
386
+ if mode == "hybrid" and NEAREST_KEYFRAMES and len(segments) == 1 else None,
387
+ snap=snap_result)
253
388
  return 0
254
389
 
255
390
 
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