ffmpeg-skill 1.13.0 → 1.15.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 -29
- package/SKILL.md +46 -41
- package/bin/install.js +1 -1
- package/docs/contract.md +72 -10
- package/package.json +4 -2
- package/references/gotchas.md +85 -1
- package/references/scripts.md +146 -15
- package/scripts/_ass_overlay.py +155 -0
- package/scripts/_common.py +596 -37
- package/scripts/_contract.py +27 -9
- package/scripts/_platforms.py +251 -0
- package/scripts/caption.py +343 -93
- package/scripts/check.py +13 -16
- package/scripts/export.py +87 -11
- package/scripts/fit.py +21 -2
- package/scripts/graphics.py +381 -20
- package/scripts/look.py +35 -0
- package/scripts/overlay.py +72 -15
- package/scripts/render.py +313 -29
- 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
|
@@ -60,9 +60,17 @@ from pathlib import Path
|
|
|
60
60
|
from typing import Any, Dict, List
|
|
61
61
|
|
|
62
62
|
from export import PRESETS, PLATFORM_OF
|
|
63
|
+
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
63
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
|
|
64
65
|
|
|
65
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"})
|
|
66
74
|
|
|
67
75
|
TEMPLATE = {
|
|
68
76
|
"output": "final.mp4",
|
|
@@ -88,18 +96,23 @@ TEMPLATE = {
|
|
|
88
96
|
# untrimmed, and a mistyped stage name dropped the stage -- both reported as a success (review 9).
|
|
89
97
|
OBJECT_KEYS: Dict[str, frozenset] = {
|
|
90
98
|
"project": frozenset({"output", "frame", "clips", "transition", "silence", "brand", "captions",
|
|
91
|
-
"graphics", "overlays", "audio", "loudness", "fit", "export", "check", "chapters"
|
|
99
|
+
"graphics", "overlays", "audio", "loudness", "fit", "export", "check", "chapters",
|
|
100
|
+
"template"}),
|
|
92
101
|
"clips[]": frozenset({"src", "in", "out", "speed"}),
|
|
93
|
-
"frame": frozenset({"aspect", "width", "height", "fps"}),
|
|
102
|
+
"frame": frozenset({"aspect", "width", "height", "fps", "fit"}),
|
|
94
103
|
"transition": frozenset({"type", "duration"}),
|
|
95
104
|
"silence": frozenset({"threshold", "min_silence", "margin"}),
|
|
96
105
|
"captions": frozenset({"text", "srt", "ass", "font", "size", "color", "position", "margin",
|
|
97
106
|
"animate", "highlight_color", "outline", "karaoke", "bold", "box",
|
|
98
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)
|
|
99
111
|
"graphics[]": frozenset({"template", "name", "title", "subtitle", "start", "end", "position",
|
|
100
|
-
"from", "scale", "primary", "text_color", "lang"
|
|
112
|
+
"from", "scale", "primary", "text_color", "lang",
|
|
113
|
+
"text", "top", "bottom", "duration", "margin", "platform"}),
|
|
101
114
|
"overlays[]": frozenset({"logo", "image", "text", "position", "start", "end", "fade", "opacity",
|
|
102
|
-
"scale", "font_size", "font", "font_file", "margin", "box"}),
|
|
115
|
+
"scale", "font_size", "font", "font_file", "margin", "box", "platform"}),
|
|
103
116
|
"audio": frozenset({"music", "replace", "music_volume", "fade_in", "fade_out", "music_fade_out",
|
|
104
117
|
"gain", "duck_amount", "duck_threshold", "duck_attack", "duck_release",
|
|
105
118
|
"voice", "denoise", "duck", "music_loop", "stereo", "mono", "downmix",
|
|
@@ -117,6 +130,216 @@ NEAR_KEYS: Dict[str, Dict[str, str]] = {"clips[]": {"start": "in", "end": "out",
|
|
|
117
130
|
"chapters[]": {"start": "at", "time": "at", "name": "title"}}
|
|
118
131
|
|
|
119
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
|
|
341
|
+
|
|
342
|
+
|
|
120
343
|
def check_keys(obj: Any, schema: str, label: str) -> None:
|
|
121
344
|
"""Refuse an unrecognised key, naming the object, the key and the nearest valid one."""
|
|
122
345
|
if not isinstance(obj, dict):
|
|
@@ -275,29 +498,74 @@ def main() -> int:
|
|
|
275
498
|
ap.add_argument("--work", help="work directory for intermediates (default: <output>_work)")
|
|
276
499
|
ap.add_argument("--keep", action="store_true", help="keep intermediates (default: kept only when --work is given)")
|
|
277
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)")
|
|
278
516
|
add_common(ap)
|
|
279
517
|
args = ap.parse_args()
|
|
280
518
|
apply_common(args)
|
|
281
519
|
|
|
520
|
+
if args.list_templates:
|
|
521
|
+
list_templates()
|
|
522
|
+
return 0
|
|
282
523
|
if args.init:
|
|
283
524
|
Path(args.init).write_text(json.dumps(TEMPLATE, indent=2) + "\n", encoding="utf-8")
|
|
284
525
|
info(f"wrote {args.init}; edit clips/src and run: render.py {args.init}")
|
|
285
526
|
print(args.init)
|
|
286
527
|
return 0
|
|
287
528
|
if not args.project:
|
|
288
|
-
die("give a project.json (or --init FILE)")
|
|
529
|
+
die("give a project.json (or --init FILE, or --template NAME INPUT)")
|
|
289
530
|
if STATE.plan:
|
|
290
531
|
die("render.py has no --plan: the project file is the plan (use --dry-run to preview it)")
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
299
559
|
validate_project(proj)
|
|
300
|
-
|
|
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
|
|
301
569
|
|
|
302
570
|
def rel(p: Any) -> str:
|
|
303
571
|
p = str(p)
|
|
@@ -330,10 +598,20 @@ def main() -> int:
|
|
|
330
598
|
import atexit
|
|
331
599
|
import shutil
|
|
332
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"
|
|
333
605
|
frame = dict(proj.get("frame") or {})
|
|
334
606
|
trans = proj.get("transition") or {}
|
|
335
607
|
frame_from_preset(frame, proj.get("export") or {})
|
|
336
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 []
|
|
337
615
|
stages_done: List[str] = []
|
|
338
616
|
|
|
339
617
|
# ---- clips
|
|
@@ -345,7 +623,7 @@ def main() -> int:
|
|
|
345
623
|
if not STATE.dry_run:
|
|
346
624
|
probe(src)
|
|
347
625
|
needs_cut = c.get("in") is not None or c.get("out") is not None
|
|
348
|
-
part = str(work / f"clip{i:02d}
|
|
626
|
+
part = str(work / f"clip{i:02d}{mid}")
|
|
349
627
|
if needs_cut:
|
|
350
628
|
argv: List[Any] = [src, "-o", part, "--accurate"]
|
|
351
629
|
if c.get("in") is not None:
|
|
@@ -361,7 +639,7 @@ def main() -> int:
|
|
|
361
639
|
die(f"clip {i}: speed must be a positive number, got {c['speed']!r}")
|
|
362
640
|
if abs(spd - 1.0) > 1e-6: # speed 1.0 used to cost a full re-encode for nothing
|
|
363
641
|
dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else 10.0
|
|
364
|
-
fitted = str(work / f"clip{i:02d}_speed
|
|
642
|
+
fitted = str(work / f"clip{i:02d}_speed{mid}")
|
|
365
643
|
sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
|
|
366
644
|
part = fitted
|
|
367
645
|
parts.append(part)
|
|
@@ -373,7 +651,7 @@ def main() -> int:
|
|
|
373
651
|
|
|
374
652
|
# ---- join
|
|
375
653
|
if len(parts) > 1:
|
|
376
|
-
current = str(work / "joined
|
|
654
|
+
current = str(work / f"joined{mid}")
|
|
377
655
|
argv = list(parts) + ["-o", current, "--transition", trans.get("type", "fade"), "--duration", str(trans.get("duration", 0.5))]
|
|
378
656
|
if frame.get("width"):
|
|
379
657
|
argv += ["--width", str(frame["width"])]
|
|
@@ -390,7 +668,7 @@ def main() -> int:
|
|
|
390
668
|
# ---- silence
|
|
391
669
|
sil = proj.get("silence")
|
|
392
670
|
if sil:
|
|
393
|
-
nxt = str(work / "tight
|
|
671
|
+
nxt = str(work / f"tight{mid}")
|
|
394
672
|
argv = [current, "-o", nxt]
|
|
395
673
|
for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin")):
|
|
396
674
|
if sil.get(k) is not None:
|
|
@@ -406,6 +684,8 @@ def main() -> int:
|
|
|
406
684
|
fit = dict(proj.get("fit") or {})
|
|
407
685
|
if frame.get("aspect"):
|
|
408
686
|
fit.setdefault("aspect", frame["aspect"])
|
|
687
|
+
if frame.get("fit"):
|
|
688
|
+
fit.setdefault("fit", frame["fit"])
|
|
409
689
|
if frame.get("width") and len(parts) == 1:
|
|
410
690
|
fit.setdefault("width", frame["width"])
|
|
411
691
|
if frame.get("height") and len(parts) == 1:
|
|
@@ -413,7 +693,7 @@ def main() -> int:
|
|
|
413
693
|
if frame.get("fps") and len(parts) == 1:
|
|
414
694
|
fit.setdefault("fps", frame["fps"])
|
|
415
695
|
if fit:
|
|
416
|
-
nxt = str(work / "fit
|
|
696
|
+
nxt = str(work / f"fit{mid}")
|
|
417
697
|
argv = [current, "-o", nxt]
|
|
418
698
|
for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("height", "--height"), ("fps", "--fps"), ("smooth", "--smooth")):
|
|
419
699
|
if fit.get(k) is not None:
|
|
@@ -428,7 +708,7 @@ def main() -> int:
|
|
|
428
708
|
# ---- captions
|
|
429
709
|
cap = proj.get("captions")
|
|
430
710
|
if cap:
|
|
431
|
-
nxt = str(work / "captioned
|
|
711
|
+
nxt = str(work / f"captioned{mid}")
|
|
432
712
|
argv = [current, "-o", nxt]
|
|
433
713
|
if cap.get("text"):
|
|
434
714
|
argv += ["--text", rel(cap["text"])]
|
|
@@ -444,7 +724,7 @@ def main() -> int:
|
|
|
444
724
|
for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
|
|
445
725
|
if cap.get(k):
|
|
446
726
|
argv.append(flag)
|
|
447
|
-
sh("caption.py", *(argv + brand_args))
|
|
727
|
+
sh("caption.py", *(argv + brand_args + platform_args))
|
|
448
728
|
current = nxt
|
|
449
729
|
stages_done.append("captions")
|
|
450
730
|
if args.stop_after == "captions":
|
|
@@ -453,14 +733,16 @@ def main() -> int:
|
|
|
453
733
|
|
|
454
734
|
# ---- graphics
|
|
455
735
|
for i, g in enumerate(proj.get("graphics") or []):
|
|
456
|
-
nxt = str(work / f"graphics{i:02d}
|
|
736
|
+
nxt = str(work / f"graphics{i:02d}{mid}")
|
|
457
737
|
if not g.get("template"):
|
|
458
738
|
die(f"graphics[{i}] needs a template")
|
|
459
739
|
argv = [current, "-o", nxt, "--template", g["template"]]
|
|
460
|
-
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")):
|
|
461
742
|
if g.get(k) is not None:
|
|
462
743
|
argv += [flag, str(g[k])]
|
|
463
|
-
|
|
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)))
|
|
464
746
|
current = nxt
|
|
465
747
|
if "graphics" not in stages_done:
|
|
466
748
|
stages_done.append("graphics")
|
|
@@ -470,7 +752,7 @@ def main() -> int:
|
|
|
470
752
|
|
|
471
753
|
# ---- overlays
|
|
472
754
|
for i, ov in enumerate(proj.get("overlays") or []):
|
|
473
|
-
nxt = str(work / f"overlay{i:02d}
|
|
755
|
+
nxt = str(work / f"overlay{i:02d}{mid}")
|
|
474
756
|
argv = [current, "-o", nxt]
|
|
475
757
|
if ov.get("logo"):
|
|
476
758
|
argv.append("--logo")
|
|
@@ -480,12 +762,14 @@ def main() -> int:
|
|
|
480
762
|
argv += ["--text", ov["text"]]
|
|
481
763
|
else:
|
|
482
764
|
die(f"overlays[{i}] needs image or text")
|
|
483
|
-
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")):
|
|
484
766
|
if ov.get(k) is not None:
|
|
485
767
|
argv += [flag, str(ov[k])]
|
|
486
768
|
if ov.get("box"):
|
|
487
769
|
argv.append("--box")
|
|
488
|
-
|
|
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)))
|
|
489
773
|
current = nxt
|
|
490
774
|
if "overlays" not in stages_done:
|
|
491
775
|
stages_done.append("overlays")
|
|
@@ -509,7 +793,7 @@ def main() -> int:
|
|
|
509
793
|
for stem, key in (("dialogue", "gain"), ("music", "music_volume"), ("effects", "effects_volume")):
|
|
510
794
|
if stems.get(stem) is not None:
|
|
511
795
|
au.setdefault(key, stems[stem])
|
|
512
|
-
nxt = str(work / "audio
|
|
796
|
+
nxt = str(work / f"audio{mid}")
|
|
513
797
|
argv = [current, "-o", nxt]
|
|
514
798
|
for k, flag in (("music", "--music"), ("replace", "--replace"), ("effects", "--effects")):
|
|
515
799
|
if au.get(k):
|
|
@@ -533,7 +817,7 @@ def main() -> int:
|
|
|
533
817
|
# ---- loudness
|
|
534
818
|
ld = proj.get("loudness")
|
|
535
819
|
if ld:
|
|
536
|
-
nxt = str(work / "loudnorm
|
|
820
|
+
nxt = str(work / f"loudnorm{mid}")
|
|
537
821
|
argv = [current, "-o", nxt]
|
|
538
822
|
if ld.get("lufs") is not None:
|
|
539
823
|
argv += ["-I", str(ld["lufs"])]
|
package/scripts/report.py
CHANGED
|
@@ -7,6 +7,7 @@ Examples:
|
|
|
7
7
|
python3 report.py --before raw.mov --after final.mp4 -o report.html
|
|
8
8
|
python3 report.py --after final.mp4 --platform reels --title "Episode 12 — Reels cut" -o report.html
|
|
9
9
|
python3 report.py --before raw.mov --after final.mp4 --commands commands.txt --notes notes.md
|
|
10
|
+
python3 report.py --pack talk_pack.md -o pack.html # the social pack table as HTML
|
|
10
11
|
"""
|
|
11
12
|
import argparse
|
|
12
13
|
import base64
|
|
@@ -79,9 +80,76 @@ def media_rows(meta: Dict[str, Any], ld: Dict[str, Any]) -> List[List[str]]:
|
|
|
79
80
|
return rows
|
|
80
81
|
|
|
81
82
|
|
|
83
|
+
PACK_CSS = """
|
|
84
|
+
:root{--bg:#F4F6F8;--paper:#fff;--ink:#161B21;--ink2:#4B5661;--line:#D8DEE4;--ok:#2C8A5B;--bad:#B4362F}
|
|
85
|
+
@media (prefers-color-scheme:dark){:root{--bg:#111518;--paper:#191E23;--ink:#E8ECEF;--ink2:#AEB6BE;--line:#2A3138;--ok:#5CC38C;--bad:#F07A73}}
|
|
86
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans JP",sans-serif}
|
|
87
|
+
.wrap{max-width:900px;margin:0 auto;padding:32px 20px 60px}
|
|
88
|
+
h1{font-size:24px;margin:0 0 16px}
|
|
89
|
+
table{border-collapse:collapse;width:100%;font-size:14px;background:var(--paper);border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
|
90
|
+
th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line)}th{color:var(--ink2);font-weight:500}
|
|
91
|
+
td.pass{color:var(--ok);font-weight:600}td.bad{color:var(--bad);font-weight:600}
|
|
92
|
+
p.note{color:var(--ink2);font-size:13px}
|
|
93
|
+
.foot{color:var(--ink2);font-size:12px;margin-top:30px;border-top:1px solid var(--line);padding-top:10px}
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def pack_report(args) -> int:
|
|
98
|
+
"""render.py --template all writes <stem>_pack.md: one row per destination. This renders the
|
|
99
|
+
same rows as HTML, so a pack can be handed over the way a single delivery report is -- no
|
|
100
|
+
re-measurement, because the pack table is what the renders actually produced."""
|
|
101
|
+
text = read_text_or_die(args.pack, "--pack")
|
|
102
|
+
rows: List[List[str]] = []
|
|
103
|
+
header: List[str] = []
|
|
104
|
+
for line in text.splitlines():
|
|
105
|
+
line = line.strip()
|
|
106
|
+
if not line.startswith("|"):
|
|
107
|
+
continue
|
|
108
|
+
cells = [c.strip() for c in line.strip("|").split("|")]
|
|
109
|
+
if all(set(c) <= set("-: ") for c in cells):
|
|
110
|
+
continue
|
|
111
|
+
if not header:
|
|
112
|
+
header = cells
|
|
113
|
+
else:
|
|
114
|
+
rows.append(cells)
|
|
115
|
+
if not rows:
|
|
116
|
+
die(f"{args.pack}: no pack table found (expected the Markdown table render.py --template all writes)")
|
|
117
|
+
title = args.title or f"Social pack — {Path(args.pack).stem.replace('_pack', '')}"
|
|
118
|
+
output = args.output or str(Path(args.pack).with_suffix(".html"))
|
|
119
|
+
head = "".join(f"<th>{html.escape(h)}</th>" for h in header)
|
|
120
|
+
body = ""
|
|
121
|
+
for r in rows:
|
|
122
|
+
cells = ""
|
|
123
|
+
for i, c in enumerate(r):
|
|
124
|
+
cls = ""
|
|
125
|
+
if header[i:i + 1] == ["check"]:
|
|
126
|
+
cls = " class='pass'" if c.lower() == "pass" else " class='bad'"
|
|
127
|
+
cells += f"<td{cls}>{html.escape(c)}</td>"
|
|
128
|
+
body += f"<tr>{cells}</tr>"
|
|
129
|
+
doc = ("<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
|
|
130
|
+
f"<title>{html.escape(title)}</title><style>{PACK_CSS}</style></head><body><div class='wrap'>"
|
|
131
|
+
f"<h1>{html.escape(title)}</h1><table><tr>{head}</tr>{body}</table>"
|
|
132
|
+
f"<p class='note'>{len(rows)} destination(s) from one edit.</p>"
|
|
133
|
+
"<p class='foot'>Generated by ffmpeg-skill · local FFmpeg, no cloud.</p></div></body></html>")
|
|
134
|
+
if STATE.dry_run:
|
|
135
|
+
info(f"wrote {output}")
|
|
136
|
+
else:
|
|
137
|
+
try:
|
|
138
|
+
Path(output).write_text(doc, encoding="utf-8")
|
|
139
|
+
except OSError as e:
|
|
140
|
+
die(f"cannot write {output}: {e}", kind="output")
|
|
141
|
+
info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
|
|
142
|
+
emit(None, report=output, pack=[dict(zip(header, r)) for r in rows], check=None,
|
|
143
|
+
verification=([{"step": "exists", "ok": True}] if not STATE.dry_run else []))
|
|
144
|
+
if not args.json:
|
|
145
|
+
print(output)
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
82
149
|
def main() -> int:
|
|
83
150
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
84
|
-
ap.add_argument("--after",
|
|
151
|
+
ap.add_argument("--after", help="the deliverable (required unless --pack is given)")
|
|
152
|
+
ap.add_argument("--pack", metavar="FILE", help="a social pack table written by render.py --template all (<stem>_pack.md): render it as HTML")
|
|
85
153
|
ap.add_argument("--before", help="the source (optional)")
|
|
86
154
|
ap.add_argument("-o", "--output", help="report path (default: <after>_report.html)")
|
|
87
155
|
ap.add_argument("--title", help="report title")
|
|
@@ -93,6 +161,10 @@ def main() -> int:
|
|
|
93
161
|
args = ap.parse_args()
|
|
94
162
|
apply_common(args)
|
|
95
163
|
|
|
164
|
+
if args.pack:
|
|
165
|
+
return pack_report(args)
|
|
166
|
+
if not args.after:
|
|
167
|
+
die("--after is required (or --pack FILE for a social pack table)")
|
|
96
168
|
after = probe(args.after)
|
|
97
169
|
before = probe(args.before) if args.before else None
|
|
98
170
|
ld_after = loudness(args.after) if after.get("audio") else {}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"template": "facebook",
|
|
3
|
+
"output": "$OUTPUT",
|
|
4
|
+
"clips": [
|
|
5
|
+
{
|
|
6
|
+
"src": "$INPUT"
|
|
7
|
+
}
|
|
8
|
+
],
|
|
9
|
+
"frame": {
|
|
10
|
+
"aspect": "16:9",
|
|
11
|
+
"fit": "pad"
|
|
12
|
+
},
|
|
13
|
+
"brand": "$BRAND",
|
|
14
|
+
"captions": {
|
|
15
|
+
"text": "$CUES",
|
|
16
|
+
"animate": "none",
|
|
17
|
+
"karaoke": false,
|
|
18
|
+
"size": 20,
|
|
19
|
+
"position": "bottom",
|
|
20
|
+
"margin": 14
|
|
21
|
+
},
|
|
22
|
+
"graphics": [
|
|
23
|
+
{
|
|
24
|
+
"template": "lower-third",
|
|
25
|
+
"name": "$TITLE",
|
|
26
|
+
"start": 1,
|
|
27
|
+
"end": 6
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"overlays": [
|
|
31
|
+
{
|
|
32
|
+
"image": "$LOGO",
|
|
33
|
+
"position": "top-left"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"loudness": {
|
|
37
|
+
"lufs": -14,
|
|
38
|
+
"tp": -1.0
|
|
39
|
+
},
|
|
40
|
+
"export": {
|
|
41
|
+
"preset": "facebook",
|
|
42
|
+
"normalize": true
|
|
43
|
+
},
|
|
44
|
+
"check": {
|
|
45
|
+
"platform": "facebook"
|
|
46
|
+
}
|
|
47
|
+
}
|