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/README.md +11 -7
- package/SKILL.md +5 -5
- package/docs/contract.md +28 -10
- package/package.json +1 -1
- package/references/gotchas.md +4 -0
- package/references/scripts.md +257 -1
- package/scripts/_common/__init__.py +25 -3
- package/scripts/_common/asr.py +369 -0
- package/scripts/_common/decision.py +346 -0
- package/scripts/_common/text.py +124 -0
- package/scripts/_contract.py +4 -2
- package/scripts/batch.py +287 -22
- package/scripts/caption.py +142 -198
- package/scripts/cut.py +136 -1
- package/scripts/render.py +289 -24
- package/scripts/scenes.py +81 -7
- package/scripts/silence.py +207 -5
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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"])
|