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