ffmpeg-skill 1.12.0 → 1.14.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 +72 -32
- package/SKILL.md +51 -43
- package/bin/install.js +1 -1
- package/docs/contract.md +66 -9
- package/package.json +4 -2
- package/references/gotchas.md +23 -0
- package/references/scripts.md +160 -14
- package/scripts/_common.py +6 -3
- package/scripts/_contract.py +17 -9
- package/scripts/_platforms.py +251 -0
- package/scripts/audio.py +99 -5
- package/scripts/caption.py +17 -1
- package/scripts/check.py +28 -16
- package/scripts/export.py +87 -11
- package/scripts/fit.py +21 -2
- package/scripts/graphics.py +93 -8
- package/scripts/look.py +35 -0
- package/scripts/loudness.py +3 -0
- package/scripts/overlay.py +37 -13
- package/scripts/render.py +376 -39
- package/scripts/report.py +73 -1
- package/templates/facebook.json +47 -0
- package/templates/linkedin.json +47 -0
- package/templates/podcast.json +22 -0
- package/templates/reels.json +47 -0
- package/templates/shorts.json +47 -0
- package/templates/tiktok.json +47 -0
- package/templates/x.json +47 -0
- package/templates/youtube-shorts.json +47 -0
- package/templates/youtube.json +47 -0
package/scripts/render.py
CHANGED
|
@@ -24,15 +24,17 @@ Project format (all keys optional except clips):
|
|
|
24
24
|
{"logo": true},
|
|
25
25
|
{"text": "Episode 12", "position": "bottom", "start": 1, "end": 5, "fade": 0.3, "box": true}
|
|
26
26
|
],
|
|
27
|
-
"audio": {"voice":
|
|
27
|
+
"audio": {"voice": "medium", "music": "bed.mp3", "music_volume": -16, "duck": true, "music_fade_out": 2,
|
|
28
|
+
"effects": "sfx.wav", "stems": {"dialogue": 0, "music": -18, "effects": -22}},
|
|
28
29
|
"loudness": {"lufs": -14, "tp": -1},
|
|
29
30
|
"fit": {"duration": 60},
|
|
30
31
|
"export": {"preset": "reels", "normalize": true}, (default for platform presets; false opts out)
|
|
31
|
-
"check": {"platform": "reels"}
|
|
32
|
+
"check": {"platform": "reels"},
|
|
33
|
+
"chapters": "chapters.txt" (or [{"at": "0:00", "title": "Intro"}, ...])
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
Stages run in this order: clips (cut) → join → silence → fit → captions →
|
|
35
|
-
graphics → overlays → audio → loudness → export → check. Missing stages are
|
|
37
|
+
graphics → overlays → audio → loudness → export → chapters → check. Missing stages are
|
|
36
38
|
skipped. "brand" points caption/graphics/overlay at a brand.json (fonts,
|
|
37
39
|
colours, logo, safe margin); {"logo": true} in overlays places the brand logo.
|
|
38
40
|
|
|
@@ -58,9 +60,17 @@ from pathlib import Path
|
|
|
58
60
|
from typing import Any, Dict, List
|
|
59
61
|
|
|
60
62
|
from export import PRESETS, PLATFORM_OF
|
|
63
|
+
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
61
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
|
|
62
65
|
|
|
63
66
|
HERE = Path(__file__).resolve().parent
|
|
67
|
+
TEMPLATE_DIR = HERE.parent / "templates"
|
|
68
|
+
# The placeholders a delivery template carries; a block whose placeholder has no value
|
|
69
|
+
# (no --logo, no --title, no cues) is dropped from the filled project rather than rendered empty.
|
|
70
|
+
PLACEHOLDERS = ("$INPUT", "$OUTPUT", "$CUES", "$SRT", "$LOGO", "$TITLE", "$BRAND", "$CHAPTERS")
|
|
71
|
+
# Intermediates follow the delivery's own media kind: an audio-only project (the podcast
|
|
72
|
+
# template) must not carry its stages through .mp4 containers.
|
|
73
|
+
AUDIO_EXT = frozenset({".wav", ".m4a", ".mp3", ".flac", ".aac", ".ogg", ".opus"})
|
|
64
74
|
|
|
65
75
|
TEMPLATE = {
|
|
66
76
|
"output": "final.mp4",
|
|
@@ -76,6 +86,7 @@ TEMPLATE = {
|
|
|
76
86
|
"loudness": {"lufs": -14, "tp": -1},
|
|
77
87
|
"fit": None,
|
|
78
88
|
"export": {"preset": "youtube", "normalize": True},
|
|
89
|
+
"chapters": None,
|
|
79
90
|
"check": {"platform": "youtube"},
|
|
80
91
|
}
|
|
81
92
|
|
|
@@ -85,28 +96,248 @@ TEMPLATE = {
|
|
|
85
96
|
# untrimmed, and a mistyped stage name dropped the stage -- both reported as a success (review 9).
|
|
86
97
|
OBJECT_KEYS: Dict[str, frozenset] = {
|
|
87
98
|
"project": frozenset({"output", "frame", "clips", "transition", "silence", "brand", "captions",
|
|
88
|
-
"graphics", "overlays", "audio", "loudness", "fit", "export", "check"
|
|
99
|
+
"graphics", "overlays", "audio", "loudness", "fit", "export", "check", "chapters",
|
|
100
|
+
"template"}),
|
|
89
101
|
"clips[]": frozenset({"src", "in", "out", "speed"}),
|
|
90
|
-
"frame": frozenset({"aspect", "width", "height", "fps"}),
|
|
102
|
+
"frame": frozenset({"aspect", "width", "height", "fps", "fit"}),
|
|
91
103
|
"transition": frozenset({"type", "duration"}),
|
|
92
104
|
"silence": frozenset({"threshold", "min_silence", "margin"}),
|
|
93
105
|
"captions": frozenset({"text", "srt", "ass", "font", "size", "color", "position", "margin",
|
|
94
106
|
"animate", "highlight_color", "outline", "karaoke", "bold", "box",
|
|
95
107
|
"lang", "offset", "max_lines", "min_duration"}),
|
|
108
|
+
# the 1.14 social templates (sticker/hook/meme) take their own text and timing, so a
|
|
109
|
+
# graphics[] entry can carry them too -- a template that only works from the CLI is not
|
|
110
|
+
# "usable inside a render.py graphics[] entry" (review 12)
|
|
96
111
|
"graphics[]": frozenset({"template", "name", "title", "subtitle", "start", "end", "position",
|
|
97
|
-
"from", "scale", "primary", "text_color", "lang"
|
|
112
|
+
"from", "scale", "primary", "text_color", "lang",
|
|
113
|
+
"text", "top", "bottom", "duration", "margin", "platform"}),
|
|
98
114
|
"overlays[]": frozenset({"logo", "image", "text", "position", "start", "end", "fade", "opacity",
|
|
99
|
-
"scale", "font_size", "font", "font_file", "margin", "box"}),
|
|
115
|
+
"scale", "font_size", "font", "font_file", "margin", "box", "platform"}),
|
|
100
116
|
"audio": frozenset({"music", "replace", "music_volume", "fade_in", "fade_out", "music_fade_out",
|
|
101
|
-
"gain", "duck_amount", "
|
|
102
|
-
"mono", "downmix"
|
|
117
|
+
"gain", "duck_amount", "duck_threshold", "duck_attack", "duck_release",
|
|
118
|
+
"voice", "denoise", "duck", "music_loop", "stereo", "mono", "downmix",
|
|
119
|
+
"stereo_widen", "effects", "effects_volume", "stems"}),
|
|
120
|
+
"audio.stems": frozenset({"dialogue", "music", "effects"}),
|
|
121
|
+
"chapters[]": frozenset({"at", "title"}),
|
|
103
122
|
"loudness": frozenset({"lufs", "tp"}),
|
|
104
123
|
"fit": frozenset({"duration", "method", "aspect", "fit", "width", "height", "fps", "smooth"}),
|
|
105
124
|
"export": frozenset({"preset", "fit", "crf", "normalize"}),
|
|
106
125
|
"check": frozenset({"platform"}),
|
|
107
126
|
}
|
|
108
127
|
# Typos difflib cannot see: a clip is trimmed with in/out, not the start/end that time a title.
|
|
109
|
-
NEAR_KEYS: Dict[str, Dict[str, str]] = {"clips[]": {"start": "in", "end": "out", "from": "in", "to": "out"}
|
|
128
|
+
NEAR_KEYS: Dict[str, Dict[str, str]] = {"clips[]": {"start": "in", "end": "out", "from": "in", "to": "out"},
|
|
129
|
+
"audio.stems": {"voice": "dialogue", "speech": "dialogue", "sfx": "effects", "bed": "music"},
|
|
130
|
+
"chapters[]": {"start": "at", "time": "at", "name": "title"}}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def template_names() -> List[str]:
|
|
134
|
+
"""Every templates/<name>.json that ships with the skill."""
|
|
135
|
+
return sorted(p.stem for p in TEMPLATE_DIR.glob("*.json"))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def load_template(name: str) -> Dict[str, Any]:
|
|
139
|
+
path = TEMPLATE_DIR / f"{name}.json"
|
|
140
|
+
if not path.is_file():
|
|
141
|
+
die(f"unknown template {name!r}", hint="templates: " + ", ".join(template_names()) + " (or 'all')")
|
|
142
|
+
try:
|
|
143
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
144
|
+
except (OSError, ValueError) as exc:
|
|
145
|
+
die(f"cannot read template {path}: {exc}")
|
|
146
|
+
return {}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def fill_template(node: Any, values: Dict[str, Any]) -> "tuple":
|
|
150
|
+
"""Substitute the $PLACEHOLDERS a template carries; return (filled, complete).
|
|
151
|
+
|
|
152
|
+
complete=False means a placeholder in this node had no value, and the caller drops the
|
|
153
|
+
whole block: a template's overlay entry is `{"image": "$LOGO"}`, so a run without --logo
|
|
154
|
+
must lose the overlay entirely rather than render an overlay of nothing."""
|
|
155
|
+
if isinstance(node, str):
|
|
156
|
+
if node in PLACEHOLDERS:
|
|
157
|
+
value = values.get(node)
|
|
158
|
+
return value, value is not None
|
|
159
|
+
return node, True
|
|
160
|
+
if isinstance(node, list):
|
|
161
|
+
kept = []
|
|
162
|
+
for item in node:
|
|
163
|
+
filled, ok = fill_template(item, values)
|
|
164
|
+
if ok:
|
|
165
|
+
kept.append(filled)
|
|
166
|
+
return kept, True
|
|
167
|
+
if isinstance(node, dict):
|
|
168
|
+
out: Dict[str, Any] = {}
|
|
169
|
+
complete = True
|
|
170
|
+
for key, value in node.items():
|
|
171
|
+
filled, ok = fill_template(value, values)
|
|
172
|
+
if not ok:
|
|
173
|
+
complete = False
|
|
174
|
+
continue
|
|
175
|
+
out[key] = filled
|
|
176
|
+
return out, complete
|
|
177
|
+
return node, True
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def template_project(name: str, args) -> Dict[str, Any]:
|
|
181
|
+
"""One template plus the run's arguments as a ready-to-render project."""
|
|
182
|
+
tpl = load_template(name)
|
|
183
|
+
dest = str((tpl.get("check") or {}).get("platform") or name)
|
|
184
|
+
if args.srt and isinstance(tpl.get("captions"), dict) and "text" in tpl["captions"]:
|
|
185
|
+
cap = {("srt" if k == "text" else k): ("$SRT" if k == "text" else v) for k, v in tpl["captions"].items()}
|
|
186
|
+
tpl["captions"] = cap
|
|
187
|
+
# The caption block's size and margin are the table's, not a literal restated in the JSON:
|
|
188
|
+
# change a destination's safe zone in scripts/_platforms.py and every template follows.
|
|
189
|
+
if isinstance(tpl.get("captions"), dict) and dest in PLATFORMS and PLATFORMS[dest].get("frame"):
|
|
190
|
+
defaults = caption_defaults(dest)
|
|
191
|
+
tpl["captions"]["size"] = defaults["size"]
|
|
192
|
+
tpl["captions"]["margin"] = defaults["margin"]
|
|
193
|
+
for key in ("position", "animate", "outline"):
|
|
194
|
+
tpl["captions"].setdefault(key, defaults[key])
|
|
195
|
+
output = args.output or str(template_output(args.input, name, dest))
|
|
196
|
+
values = {
|
|
197
|
+
"$INPUT": os.path.abspath(args.input),
|
|
198
|
+
"$OUTPUT": os.path.abspath(output),
|
|
199
|
+
"$CUES": os.path.abspath(args.cues) if args.cues else None,
|
|
200
|
+
"$SRT": os.path.abspath(args.srt) if args.srt else None,
|
|
201
|
+
"$LOGO": os.path.abspath(args.logo) if args.logo else None,
|
|
202
|
+
"$BRAND": os.path.abspath(args.brand) if args.brand else None,
|
|
203
|
+
"$CHAPTERS": os.path.abspath(args.chapters) if args.chapters else None,
|
|
204
|
+
"$TITLE": args.title,
|
|
205
|
+
}
|
|
206
|
+
for flag, path in (("--cues", args.cues), ("--srt", args.srt), ("--logo", args.logo),
|
|
207
|
+
("--brand", args.brand), ("--chapters", args.chapters)):
|
|
208
|
+
if path and not os.path.isfile(path):
|
|
209
|
+
die(f"{flag}: file not found: {path}")
|
|
210
|
+
proj, _ = fill_template(tpl, values)
|
|
211
|
+
if args.fit:
|
|
212
|
+
proj.setdefault("frame", {})["fit"] = args.fit
|
|
213
|
+
return proj
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def template_ext(dest: str) -> str:
|
|
217
|
+
"""A destination with no frame delivers audio: the podcast template writes .m4a, not .mp4."""
|
|
218
|
+
return ".mp4" if (PLATFORMS.get(dest) or {}).get("frame") else ".m4a"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def template_output(input_path: str, name: str, dest: str) -> Path:
|
|
222
|
+
"""Where a template writes when no -o was given: next to the input, named after it and the
|
|
223
|
+
template. One rule for a single template and for a pack, so `--template tiktok` and
|
|
224
|
+
`--template tiktok,x` put their files in the same place (review 12)."""
|
|
225
|
+
stem = Path(input_path).with_suffix("").name
|
|
226
|
+
return Path(input_path).resolve().parent / f"{stem}_{name}{template_ext(dest)}"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def list_templates() -> None:
|
|
230
|
+
"""The templates, the destination each delivers to, and the zones its UI covers."""
|
|
231
|
+
print("%-15s %-11s %-5s %7s %-10s %s" % ("template", "frame", "fit", "max", "loudness", "safe zones (fraction of the frame)"))
|
|
232
|
+
for name in template_names():
|
|
233
|
+
tpl = load_template(name)
|
|
234
|
+
dest = str((tpl.get("check") or {}).get("platform") or name)
|
|
235
|
+
plat = PLATFORMS.get(dest) or {}
|
|
236
|
+
frame = plat.get("frame")
|
|
237
|
+
spec = plat.get("spec") or {}
|
|
238
|
+
safe = plat.get("safe") or {}
|
|
239
|
+
dur = spec.get("max_duration")
|
|
240
|
+
zones = ", ".join("%s %.2f" % (edge, safe.get(edge, 0)) for edge in ("top", "bottom", "left", "right") if safe.get(edge))
|
|
241
|
+
size = "%dx%d" % (frame["w"], frame["h"]) if frame else "audio"
|
|
242
|
+
fit = str((tpl.get("frame") or {}).get("fit") or "-")
|
|
243
|
+
loud = ("%g LUFS" % spec["lufs"]) if spec.get("lufs") is not None else "-"
|
|
244
|
+
print("%-15s %-11s %-5s %7s %-10s %s" % (name, size, fit, ("%gs" % dur) if dur else "-", loud, zones or "none"))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# `--template all`: every destination a single edit is normally posted to. The podcast template
|
|
248
|
+
# is audio-only and youtube-shorts is an alias of shorts, so neither belongs in a video pack.
|
|
249
|
+
PACK_DEFAULT = ["tiktok", "reels", "shorts", "youtube", "x", "linkedin", "facebook"]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def expand_templates(value: str) -> List[str]:
|
|
253
|
+
if value.strip() == "all":
|
|
254
|
+
return list(PACK_DEFAULT)
|
|
255
|
+
names = [n.strip() for n in value.split(",") if n.strip()]
|
|
256
|
+
if not names:
|
|
257
|
+
die("--template needs a name, a comma-separated list, or 'all'")
|
|
258
|
+
known = template_names()
|
|
259
|
+
seen: List[str] = []
|
|
260
|
+
for name in names:
|
|
261
|
+
# the spellings people write resolve to the destination they mean, the same way
|
|
262
|
+
# check.py --platform and export.py --preset take them (scripts/_platforms.py)
|
|
263
|
+
if name not in known:
|
|
264
|
+
name = resolve_platform(name) or name
|
|
265
|
+
if name not in known:
|
|
266
|
+
die(f"unknown template {name!r}", hint="templates: " + ", ".join(known) + " (or 'all')")
|
|
267
|
+
if name not in seen:
|
|
268
|
+
seen.append(name)
|
|
269
|
+
return seen
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def render_pack(names: List[str], args) -> int:
|
|
273
|
+
"""The social pack: one edit delivered to every named destination, plus a table of what
|
|
274
|
+
was written. Each destination is a full render (its own frame, captions, loudness, export
|
|
275
|
+
and platform check), so the pack reports per-platform results rather than one verdict."""
|
|
276
|
+
stem = Path(args.input).with_suffix("").name
|
|
277
|
+
outdir = Path(args.output).parent if args.output else Path(args.input).resolve().parent
|
|
278
|
+
dests = {name: str((load_template(name).get("check") or {}).get("platform") or name) for name in names}
|
|
279
|
+
rows: List[Dict[str, Any]] = []
|
|
280
|
+
outputs: List[str] = []
|
|
281
|
+
failed: List[str] = []
|
|
282
|
+
for name in names:
|
|
283
|
+
# the destination decides the container: an audio-only destination in a pack must not be
|
|
284
|
+
# handed a .mp4 name the single-template form would never have written (review 12)
|
|
285
|
+
dest_out = str(outdir / (stem + "_" + name + template_ext(dests[name])))
|
|
286
|
+
argv = [str(HERE / "render.py"), args.input, "--template", name, "-o", dest_out]
|
|
287
|
+
for flag, value in (("--cues", args.cues), ("--srt", args.srt), ("--logo", args.logo),
|
|
288
|
+
("--title", args.title), ("--brand", args.brand), ("--fit", args.fit),
|
|
289
|
+
("--chapters", args.chapters)):
|
|
290
|
+
if value:
|
|
291
|
+
argv += [flag, str(value)]
|
|
292
|
+
info(f"→ pack: {name}")
|
|
293
|
+
proc = run_tool(argv + child_args() + ["--json"])
|
|
294
|
+
try:
|
|
295
|
+
doc = json.loads(proc.stdout.strip() or "{}")
|
|
296
|
+
except ValueError:
|
|
297
|
+
doc = {}
|
|
298
|
+
# the child ran with --json, so its planned commands come back in the document: the pack
|
|
299
|
+
# is the one path that writes seven files, and --dry-run has to show all of them
|
|
300
|
+
for line in proc.stderr.splitlines():
|
|
301
|
+
if line.startswith("$ ") or line.startswith("[dry-run]"):
|
|
302
|
+
STATE.commands.append(line[2:] if line.startswith("$ ") else line)
|
|
303
|
+
STATE.commands.extend(str(c) for c in (doc.get("commands") or []))
|
|
304
|
+
out = doc.get("output") or dest_out
|
|
305
|
+
chk = doc.get("check") or {}
|
|
306
|
+
ok = proc.returncode == 0 and doc.get("status") == "completed"
|
|
307
|
+
probe_doc = doc.get("probe") or {}
|
|
308
|
+
if STATE.dry_run:
|
|
309
|
+
# A dry run encoded nothing and verified nothing. Reading a size off a file left over
|
|
310
|
+
# from an earlier real run, or calling an unrun check "pass", reports a verification
|
|
311
|
+
# result for a run that never happened (review 12).
|
|
312
|
+
rows.append({"platform": name, "file": os.path.basename(out), "path": out,
|
|
313
|
+
"size_bytes": None, "duration": None, "check": "planned", "ok": bool(ok)})
|
|
314
|
+
continue
|
|
315
|
+
size = os.path.getsize(out) if os.path.exists(out) else 0
|
|
316
|
+
rows.append({"platform": name, "file": os.path.basename(out), "path": out,
|
|
317
|
+
"size_bytes": size, "duration": probe_doc.get("duration"),
|
|
318
|
+
"check": ("pass" if chk.get("ok") else ("%d FAIL" % chk["failed"]) if chk.get("failed") else ("pass" if ok else "failed")),
|
|
319
|
+
"ok": bool(ok)})
|
|
320
|
+
outputs.append(out)
|
|
321
|
+
if not ok:
|
|
322
|
+
failed.append(name)
|
|
323
|
+
pack = str(outdir / f"{stem}_pack.md")
|
|
324
|
+
lines = [f"# Social pack — {stem}", "",
|
|
325
|
+
"| platform | file | size | duration | check |", "|---|---|---|---|---|"]
|
|
326
|
+
for r in rows:
|
|
327
|
+
dur = f"{r['duration']:.2f} s" if r.get("duration") else "-"
|
|
328
|
+
mb = f"{r['size_bytes'] / 1024 / 1024:.1f} MB" if r.get("size_bytes") else "-"
|
|
329
|
+
lines.append(f"| {r['platform']} | {r['file']} | {mb} | {dur} | {r['check']} |")
|
|
330
|
+
lines += ["", f"{len(rows)} destinations from one edit ({os.path.basename(args.input)}).",
|
|
331
|
+
"Rendered by ffmpeg-skill; `report.py --pack` turns this table into an HTML report."]
|
|
332
|
+
if not STATE.dry_run:
|
|
333
|
+
Path(pack).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
334
|
+
info(("[dry-run] would write " if STATE.dry_run else "wrote ") + pack)
|
|
335
|
+
if failed:
|
|
336
|
+
die(f"pack: {len(failed)} of {len(rows)} destinations failed: {', '.join(failed)}",
|
|
337
|
+
kind="verification", output=pack, dry_run=STATE.dry_run, pack=rows, outputs=outputs)
|
|
338
|
+
emit(pack, pack=rows, outputs=outputs, stages=["pack"],
|
|
339
|
+
verification=[{"step": "check", "ok": r["ok"], "platform": r["platform"]} for r in rows])
|
|
340
|
+
return 0
|
|
110
341
|
|
|
111
342
|
|
|
112
343
|
def check_keys(obj: Any, schema: str, label: str) -> None:
|
|
@@ -126,6 +357,14 @@ def validate_project(proj: Dict[str, Any]) -> None:
|
|
|
126
357
|
check_keys(proj, "project", "project")
|
|
127
358
|
for name in ("frame", "transition", "silence", "captions", "audio", "loudness", "fit", "export", "check"):
|
|
128
359
|
check_keys(proj.get(name), name, name)
|
|
360
|
+
check_keys((proj.get("audio") or {}).get("stems"), "audio.stems", "audio.stems")
|
|
361
|
+
if isinstance(proj.get("chapters"), list):
|
|
362
|
+
# Every other project error is raised here, before the first ffmpeg call; a chapter typo
|
|
363
|
+
# found inside the last stage costs a whole render and leaves an unchaptered file behind.
|
|
364
|
+
for i, item in enumerate(proj["chapters"]):
|
|
365
|
+
check_keys(item, "chapters[]", f"chapters[{i}]")
|
|
366
|
+
if not isinstance(item, dict) or item.get("at") is None or not str(item.get("title") or "").strip():
|
|
367
|
+
die(f'chapters[{i}]: needs {{"at": TIME, "title": STR}}')
|
|
129
368
|
for name in ("clips", "graphics", "overlays"):
|
|
130
369
|
items = proj.get(name)
|
|
131
370
|
if isinstance(items, list):
|
|
@@ -259,34 +498,82 @@ def main() -> int:
|
|
|
259
498
|
ap.add_argument("--work", help="work directory for intermediates (default: <output>_work)")
|
|
260
499
|
ap.add_argument("--keep", action="store_true", help="keep intermediates (default: kept only when --work is given)")
|
|
261
500
|
ap.add_argument("--stop-after", choices=["clips", "join", "silence", "fit", "captions", "graphics", "overlays", "audio", "loudness", "export"], help="stop after this stage (for iterating)")
|
|
501
|
+
tpl = ap.add_argument_group("delivery templates (one command per destination)")
|
|
502
|
+
tpl.add_argument("--template", metavar="NAME", help="render INPUT with a shipped template: " + ", ".join(template_names())
|
|
503
|
+
+ ", a comma-separated list, or 'all' (the social pack)")
|
|
504
|
+
tpl.add_argument("--list-templates", action="store_true", help="print the templates with their frames, limits and safe zones, and exit")
|
|
505
|
+
tpl.add_argument("--cues", help="cue file for the template's captions (caption.py --text format)")
|
|
506
|
+
tpl.add_argument("--srt", help="SRT file for the template's captions instead of --cues")
|
|
507
|
+
tpl.add_argument("--logo", help="logo image the template overlays")
|
|
508
|
+
tpl.add_argument("--title", help="title text for the template's opening card / lower third")
|
|
509
|
+
tpl.add_argument("--brand", help="brand.json the template's captions, graphics and overlays use")
|
|
510
|
+
tpl.add_argument("--chapters", help="chapter file (podcast template)")
|
|
511
|
+
tpl.add_argument("--fit", choices=["crop", "pad", "blur"], help="override how the template reaches its aspect")
|
|
512
|
+
tpl.add_argument("-o", "--output", help="output file (default: next to the input, <input>_<template>.mp4, "
|
|
513
|
+
"or .m4a for an audio-only destination); for a list of templates "
|
|
514
|
+
"or 'all' its directory is where the pack is written")
|
|
515
|
+
tpl.add_argument("--write-project", metavar="FILE", help="write the filled project.json for editing and stop (no render)")
|
|
262
516
|
add_common(ap)
|
|
263
517
|
args = ap.parse_args()
|
|
264
518
|
apply_common(args)
|
|
265
519
|
|
|
520
|
+
if args.list_templates:
|
|
521
|
+
list_templates()
|
|
522
|
+
return 0
|
|
266
523
|
if args.init:
|
|
267
524
|
Path(args.init).write_text(json.dumps(TEMPLATE, indent=2) + "\n", encoding="utf-8")
|
|
268
525
|
info(f"wrote {args.init}; edit clips/src and run: render.py {args.init}")
|
|
269
526
|
print(args.init)
|
|
270
527
|
return 0
|
|
271
528
|
if not args.project:
|
|
272
|
-
die("give a project.json (or --init FILE)")
|
|
529
|
+
die("give a project.json (or --init FILE, or --template NAME INPUT)")
|
|
273
530
|
if STATE.plan:
|
|
274
531
|
die("render.py has no --plan: the project file is the plan (use --dry-run to preview it)")
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
532
|
+
if args.template:
|
|
533
|
+
# `render.py --template tiktok talk.mp4 ...`: the positional is the footage, not a project.
|
|
534
|
+
args.input = args.project
|
|
535
|
+
if not os.path.isfile(args.input):
|
|
536
|
+
die(f"input not found: {args.input}")
|
|
537
|
+
names = expand_templates(args.template)
|
|
538
|
+
if len(names) > 1:
|
|
539
|
+
if args.write_project:
|
|
540
|
+
die("--write-project writes one project; name a single template")
|
|
541
|
+
return render_pack(names, args)
|
|
542
|
+
proj: Dict[str, Any] = template_project(names[0], args)
|
|
543
|
+
base = Path.cwd()
|
|
544
|
+
else:
|
|
545
|
+
for flag, value in (("--cues", args.cues), ("--srt", args.srt), ("--logo", args.logo),
|
|
546
|
+
("--title", args.title), ("--brand", args.brand), ("--chapters", args.chapters),
|
|
547
|
+
("--fit", args.fit), ("--write-project", args.write_project)):
|
|
548
|
+
if value:
|
|
549
|
+
die(f"{flag} belongs to --template NAME INPUT; a project.json states it in the project itself")
|
|
550
|
+
try:
|
|
551
|
+
proj = json.loads(Path(args.project).read_text(encoding="utf-8"))
|
|
552
|
+
except (OSError, ValueError) as exc:
|
|
553
|
+
die(f"cannot read project: {exc}")
|
|
554
|
+
if not isinstance(proj, dict):
|
|
555
|
+
die(f"{args.project}: not a project or plan object (top level is {type(proj).__name__})")
|
|
556
|
+
if "plan_version" in proj:
|
|
557
|
+
return execute_plan(proj, os.path.abspath(args.project))
|
|
558
|
+
base = Path(args.project).resolve().parent
|
|
283
559
|
validate_project(proj)
|
|
284
|
-
|
|
560
|
+
if args.write_project:
|
|
561
|
+
# Only the filled project: the point is to edit it before rendering, so nothing runs.
|
|
562
|
+
try:
|
|
563
|
+
Path(args.write_project).write_text(json.dumps(proj, indent=2) + "\n", encoding="utf-8")
|
|
564
|
+
except OSError as exc:
|
|
565
|
+
die(f"cannot write {args.write_project}: {exc}", kind="output")
|
|
566
|
+
info(f"wrote {args.write_project}; edit it and run: render.py {args.write_project}")
|
|
567
|
+
emit(args.write_project, template=args.template, stages=[], check=None)
|
|
568
|
+
return 0
|
|
285
569
|
|
|
286
570
|
def rel(p: Any) -> str:
|
|
287
571
|
p = str(p)
|
|
288
572
|
return p if os.path.isabs(p) else str(base / p)
|
|
289
573
|
|
|
574
|
+
if isinstance(proj.get("chapters"), str) and not os.path.exists(rel(proj["chapters"])):
|
|
575
|
+
die(f"chapters file not found: {rel(proj['chapters'])}")
|
|
576
|
+
|
|
290
577
|
clips = proj.get("clips") or []
|
|
291
578
|
if not clips:
|
|
292
579
|
die("project.clips is empty")
|
|
@@ -311,10 +598,20 @@ def main() -> int:
|
|
|
311
598
|
import atexit
|
|
312
599
|
import shutil
|
|
313
600
|
atexit.register(lambda: shutil.rmtree(work, ignore_errors=True))
|
|
601
|
+
# Intermediates keep the delivery's media kind: a .mp4 project is unchanged (every stage file
|
|
602
|
+
# is still clipNN.mp4 / fit.mp4 / loudnorm.mp4), while an audio-only delivery (the podcast
|
|
603
|
+
# template) carries its stages through the audio container instead of a video one.
|
|
604
|
+
mid = Path(output).suffix.lower() if Path(output).suffix.lower() in AUDIO_EXT else ".mp4"
|
|
314
605
|
frame = dict(proj.get("frame") or {})
|
|
315
606
|
trans = proj.get("transition") or {}
|
|
316
607
|
frame_from_preset(frame, proj.get("export") or {})
|
|
317
608
|
brand_args: List[str] = ["--brand", rel(proj["brand"])] if proj.get("brand") else []
|
|
609
|
+
# A project written from a delivery template names its destination, so the caption and
|
|
610
|
+
# graphics stages are told which zones that app's UI covers; the template's own explicit
|
|
611
|
+
# margin/size still win inside those tools. A hand-written project (no "template" key) is
|
|
612
|
+
# unchanged -- it never gets a --platform it did not ask for.
|
|
613
|
+
dest = str(((proj.get("check") or {}).get("platform") or "")) if proj.get("template") else ""
|
|
614
|
+
platform_args: List[str] = ["--platform", dest] if dest in PLATFORMS and PLATFORMS[dest].get("frame") else []
|
|
318
615
|
stages_done: List[str] = []
|
|
319
616
|
|
|
320
617
|
# ---- clips
|
|
@@ -326,7 +623,7 @@ def main() -> int:
|
|
|
326
623
|
if not STATE.dry_run:
|
|
327
624
|
probe(src)
|
|
328
625
|
needs_cut = c.get("in") is not None or c.get("out") is not None
|
|
329
|
-
part = str(work / f"clip{i:02d}
|
|
626
|
+
part = str(work / f"clip{i:02d}{mid}")
|
|
330
627
|
if needs_cut:
|
|
331
628
|
argv: List[Any] = [src, "-o", part, "--accurate"]
|
|
332
629
|
if c.get("in") is not None:
|
|
@@ -342,7 +639,7 @@ def main() -> int:
|
|
|
342
639
|
die(f"clip {i}: speed must be a positive number, got {c['speed']!r}")
|
|
343
640
|
if abs(spd - 1.0) > 1e-6: # speed 1.0 used to cost a full re-encode for nothing
|
|
344
641
|
dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else 10.0
|
|
345
|
-
fitted = str(work / f"clip{i:02d}_speed
|
|
642
|
+
fitted = str(work / f"clip{i:02d}_speed{mid}")
|
|
346
643
|
sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
|
|
347
644
|
part = fitted
|
|
348
645
|
parts.append(part)
|
|
@@ -354,7 +651,7 @@ def main() -> int:
|
|
|
354
651
|
|
|
355
652
|
# ---- join
|
|
356
653
|
if len(parts) > 1:
|
|
357
|
-
current = str(work / "joined
|
|
654
|
+
current = str(work / f"joined{mid}")
|
|
358
655
|
argv = list(parts) + ["-o", current, "--transition", trans.get("type", "fade"), "--duration", str(trans.get("duration", 0.5))]
|
|
359
656
|
if frame.get("width"):
|
|
360
657
|
argv += ["--width", str(frame["width"])]
|
|
@@ -371,7 +668,7 @@ def main() -> int:
|
|
|
371
668
|
# ---- silence
|
|
372
669
|
sil = proj.get("silence")
|
|
373
670
|
if sil:
|
|
374
|
-
nxt = str(work / "tight
|
|
671
|
+
nxt = str(work / f"tight{mid}")
|
|
375
672
|
argv = [current, "-o", nxt]
|
|
376
673
|
for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin")):
|
|
377
674
|
if sil.get(k) is not None:
|
|
@@ -387,6 +684,8 @@ def main() -> int:
|
|
|
387
684
|
fit = dict(proj.get("fit") or {})
|
|
388
685
|
if frame.get("aspect"):
|
|
389
686
|
fit.setdefault("aspect", frame["aspect"])
|
|
687
|
+
if frame.get("fit"):
|
|
688
|
+
fit.setdefault("fit", frame["fit"])
|
|
390
689
|
if frame.get("width") and len(parts) == 1:
|
|
391
690
|
fit.setdefault("width", frame["width"])
|
|
392
691
|
if frame.get("height") and len(parts) == 1:
|
|
@@ -394,7 +693,7 @@ def main() -> int:
|
|
|
394
693
|
if frame.get("fps") and len(parts) == 1:
|
|
395
694
|
fit.setdefault("fps", frame["fps"])
|
|
396
695
|
if fit:
|
|
397
|
-
nxt = str(work / "fit
|
|
696
|
+
nxt = str(work / f"fit{mid}")
|
|
398
697
|
argv = [current, "-o", nxt]
|
|
399
698
|
for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("height", "--height"), ("fps", "--fps"), ("smooth", "--smooth")):
|
|
400
699
|
if fit.get(k) is not None:
|
|
@@ -409,7 +708,7 @@ def main() -> int:
|
|
|
409
708
|
# ---- captions
|
|
410
709
|
cap = proj.get("captions")
|
|
411
710
|
if cap:
|
|
412
|
-
nxt = str(work / "captioned
|
|
711
|
+
nxt = str(work / f"captioned{mid}")
|
|
413
712
|
argv = [current, "-o", nxt]
|
|
414
713
|
if cap.get("text"):
|
|
415
714
|
argv += ["--text", rel(cap["text"])]
|
|
@@ -425,7 +724,7 @@ def main() -> int:
|
|
|
425
724
|
for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
|
|
426
725
|
if cap.get(k):
|
|
427
726
|
argv.append(flag)
|
|
428
|
-
sh("caption.py", *(argv + brand_args))
|
|
727
|
+
sh("caption.py", *(argv + brand_args + platform_args))
|
|
429
728
|
current = nxt
|
|
430
729
|
stages_done.append("captions")
|
|
431
730
|
if args.stop_after == "captions":
|
|
@@ -434,14 +733,16 @@ def main() -> int:
|
|
|
434
733
|
|
|
435
734
|
# ---- graphics
|
|
436
735
|
for i, g in enumerate(proj.get("graphics") or []):
|
|
437
|
-
nxt = str(work / f"graphics{i:02d}
|
|
736
|
+
nxt = str(work / f"graphics{i:02d}{mid}")
|
|
438
737
|
if not g.get("template"):
|
|
439
738
|
die(f"graphics[{i}] needs a template")
|
|
440
739
|
argv = [current, "-o", nxt, "--template", g["template"]]
|
|
441
|
-
for k, flag in (("name", "--name"), ("title", "--title"), ("subtitle", "--subtitle"), ("start", "--start"), ("end", "--end"), ("position", "--position"), ("from", "--from"), ("scale", "--scale"), ("primary", "--primary"), ("text_color", "--text-color"), ("lang", "--lang")
|
|
740
|
+
for k, flag in (("name", "--name"), ("title", "--title"), ("subtitle", "--subtitle"), ("start", "--start"), ("end", "--end"), ("position", "--position"), ("from", "--from"), ("scale", "--scale"), ("primary", "--primary"), ("text_color", "--text-color"), ("lang", "--lang"),
|
|
741
|
+
("text", "--text"), ("top", "--top"), ("bottom", "--bottom"), ("duration", "--duration"), ("margin", "--margin"), ("platform", "--platform")):
|
|
442
742
|
if g.get(k) is not None:
|
|
443
743
|
argv += [flag, str(g[k])]
|
|
444
|
-
|
|
744
|
+
# the entry's own "platform" is the more specific statement than the template's destination
|
|
745
|
+
sh("graphics.py", *(argv + brand_args + ([] if g.get("platform") else platform_args)))
|
|
445
746
|
current = nxt
|
|
446
747
|
if "graphics" not in stages_done:
|
|
447
748
|
stages_done.append("graphics")
|
|
@@ -451,7 +752,7 @@ def main() -> int:
|
|
|
451
752
|
|
|
452
753
|
# ---- overlays
|
|
453
754
|
for i, ov in enumerate(proj.get("overlays") or []):
|
|
454
|
-
nxt = str(work / f"overlay{i:02d}
|
|
755
|
+
nxt = str(work / f"overlay{i:02d}{mid}")
|
|
455
756
|
argv = [current, "-o", nxt]
|
|
456
757
|
if ov.get("logo"):
|
|
457
758
|
argv.append("--logo")
|
|
@@ -461,12 +762,14 @@ def main() -> int:
|
|
|
461
762
|
argv += ["--text", ov["text"]]
|
|
462
763
|
else:
|
|
463
764
|
die(f"overlays[{i}] needs image or text")
|
|
464
|
-
for k, flag in (("position", "--position"), ("start", "--start"), ("end", "--end"), ("fade", "--fade"), ("opacity", "--opacity"), ("scale", "--scale"), ("font_size", "--font-size"), ("font", "--font"), ("font_file", "--font-file"), ("margin", "--margin")):
|
|
765
|
+
for k, flag in (("position", "--position"), ("start", "--start"), ("end", "--end"), ("fade", "--fade"), ("opacity", "--opacity"), ("scale", "--scale"), ("font_size", "--font-size"), ("font", "--font"), ("font_file", "--font-file"), ("margin", "--margin"), ("platform", "--platform")):
|
|
465
766
|
if ov.get(k) is not None:
|
|
466
767
|
argv += [flag, str(ov[k])]
|
|
467
768
|
if ov.get("box"):
|
|
468
769
|
argv.append("--box")
|
|
469
|
-
|
|
770
|
+
# review 12: the overlay stage was the one stage that never heard which destination this
|
|
771
|
+
# is, so a template's top-left logo landed 24 px in -- under TikTok's own status bar.
|
|
772
|
+
sh("overlay.py", *(argv + brand_args + ([] if ov.get("platform") else platform_args)))
|
|
470
773
|
current = nxt
|
|
471
774
|
if "overlays" not in stages_done:
|
|
472
775
|
stages_done.append("overlays")
|
|
@@ -475,17 +778,33 @@ def main() -> int:
|
|
|
475
778
|
return 0
|
|
476
779
|
|
|
477
780
|
# ---- audio
|
|
478
|
-
au = proj.get("audio")
|
|
781
|
+
au = dict(proj.get("audio") or {})
|
|
479
782
|
if au:
|
|
480
|
-
|
|
783
|
+
# "stems": one level per element of the mix, the way a mixing desk names them. Each maps
|
|
784
|
+
# to the flag that already exists (dialogue = the main track's gain, music = the bed's
|
|
785
|
+
# level, effects = the third file's level), so a stems block is a vocabulary, not a
|
|
786
|
+
# second code path -- and an explicit flag next to it wins, since it is the more specific
|
|
787
|
+
# statement of the same thing.
|
|
788
|
+
stems = au.pop("stems", None) or {}
|
|
789
|
+
if stems.get("effects") is not None and not au.get("effects"):
|
|
790
|
+
die('audio.stems.effects sets the level of "audio": {"effects": "sfx.wav"}, which this project does not have')
|
|
791
|
+
if stems.get("music") is not None and not au.get("music"):
|
|
792
|
+
die('audio.stems.music sets the level of "audio": {"music": "bed.mp3"}, which this project does not have')
|
|
793
|
+
for stem, key in (("dialogue", "gain"), ("music", "music_volume"), ("effects", "effects_volume")):
|
|
794
|
+
if stems.get(stem) is not None:
|
|
795
|
+
au.setdefault(key, stems[stem])
|
|
796
|
+
nxt = str(work / f"audio{mid}")
|
|
481
797
|
argv = [current, "-o", nxt]
|
|
482
|
-
for k, flag in (("music", "--music"), ("replace", "--replace")):
|
|
798
|
+
for k, flag in (("music", "--music"), ("replace", "--replace"), ("effects", "--effects")):
|
|
483
799
|
if au.get(k):
|
|
484
800
|
argv += [flag, rel(au[k])]
|
|
485
|
-
for k, flag in (("music_volume", "--music-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("music_fade_out", "--music-fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount")):
|
|
801
|
+
for k, flag in (("music_volume", "--music-volume"), ("effects_volume", "--effects-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("music_fade_out", "--music-fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount"), ("duck_threshold", "--duck-threshold"), ("duck_attack", "--duck-attack"), ("duck_release", "--duck-release"), ("stereo_widen", "--stereo-widen")):
|
|
486
802
|
if au.get(k) is not None:
|
|
487
803
|
argv += [flag, str(au[k])]
|
|
488
|
-
|
|
804
|
+
# "voice": true is the medium chain; "voice": "light"|"medium"|"strong" names one
|
|
805
|
+
if au.get("voice") is not None and au.get("voice") is not False:
|
|
806
|
+
argv += ["--voice"] + ([] if au["voice"] is True else [str(au["voice"])])
|
|
807
|
+
for k, flag in (("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
|
|
489
808
|
if au.get(k):
|
|
490
809
|
argv.append(flag)
|
|
491
810
|
sh("audio.py", *argv)
|
|
@@ -498,7 +817,7 @@ def main() -> int:
|
|
|
498
817
|
# ---- loudness
|
|
499
818
|
ld = proj.get("loudness")
|
|
500
819
|
if ld:
|
|
501
|
-
nxt = str(work / "loudnorm
|
|
820
|
+
nxt = str(work / f"loudnorm{mid}")
|
|
502
821
|
argv = [current, "-o", nxt]
|
|
503
822
|
if ld.get("lufs") is not None:
|
|
504
823
|
argv += ["-I", str(ld["lufs"])]
|
|
@@ -536,6 +855,24 @@ def main() -> int:
|
|
|
536
855
|
info(("[dry-run] would copy" if STATE.dry_run else "copied") + f" final stage to {output}")
|
|
537
856
|
current = output
|
|
538
857
|
|
|
858
|
+
# ---- chapters (metadata.py on the delivered file: streams copied, markers written)
|
|
859
|
+
ch = proj.get("chapters")
|
|
860
|
+
if ch:
|
|
861
|
+
# Planned exactly like the audio stage: the metadata.py command names the export's output,
|
|
862
|
+
# which a dry run has not written either. The plan is the run, so --dry-run shows the
|
|
863
|
+
# command and lists the stage (the child's own --dry-run prints rather than writes).
|
|
864
|
+
if isinstance(ch, list):
|
|
865
|
+
chapter_file = str(work / "chapters.txt")
|
|
866
|
+
lines = [f"{entry['at']} {entry['title']}" for entry in ch]
|
|
867
|
+
Path(chapter_file).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
868
|
+
else:
|
|
869
|
+
chapter_file = rel(ch)
|
|
870
|
+
tagged = str(work / ("chapters" + Path(output).suffix))
|
|
871
|
+
sh("metadata.py", output, "--chapters", chapter_file, "-o", tagged)
|
|
872
|
+
if not STATE.dry_run:
|
|
873
|
+
place_output(tagged, output)
|
|
874
|
+
stages_done.append("chapters")
|
|
875
|
+
|
|
539
876
|
# ---- check
|
|
540
877
|
ck = proj.get("check")
|
|
541
878
|
check_result = None
|