ffmpeg-skill 1.13.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 +68 -29
- package/SKILL.md +44 -39
- package/bin/install.js +1 -1
- package/docs/contract.md +51 -9
- package/package.json +4 -2
- package/references/gotchas.md +15 -0
- package/references/scripts.md +98 -9
- package/scripts/_common.py +6 -3
- package/scripts/_contract.py +8 -5
- package/scripts/_platforms.py +251 -0
- package/scripts/caption.py +17 -1
- package/scripts/check.py +13 -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/overlay.py +37 -13
- 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/overlay.py
CHANGED
|
@@ -24,24 +24,30 @@ import argparse
|
|
|
24
24
|
import sys
|
|
25
25
|
from typing import List, Optional
|
|
26
26
|
|
|
27
|
+
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
27
28
|
from _common import STATE, script_font_for_text, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
28
29
|
|
|
30
|
+
# Per-edge margins: a platform's UI does not cover the same fraction of every edge (TikTok's
|
|
31
|
+
# like column is 14 % of the width, its description block 22 % of the height), so a position
|
|
32
|
+
# names the edges it is measured from rather than one --margin for all four.
|
|
29
33
|
POS = {
|
|
30
|
-
"top-left": ("{
|
|
31
|
-
"top": ("(W-w)/2", "{
|
|
32
|
-
"top-right": ("W-w-{
|
|
33
|
-
"left": ("{
|
|
34
|
+
"top-left": ("{left}", "{top}"),
|
|
35
|
+
"top": ("(W-w)/2", "{top}"),
|
|
36
|
+
"top-right": ("W-w-{right}", "{top}"),
|
|
37
|
+
"left": ("{left}", "(H-h)/2"),
|
|
34
38
|
"center": ("(W-w)/2", "(H-h)/2"),
|
|
35
|
-
"right": ("W-w-{
|
|
36
|
-
"bottom-left": ("{
|
|
37
|
-
"bottom": ("(W-w)/2", "H-h-{
|
|
38
|
-
"bottom-right": ("W-w-{
|
|
39
|
+
"right": ("W-w-{right}", "(H-h)/2"),
|
|
40
|
+
"bottom-left": ("{left}", "H-h-{bottom}"),
|
|
41
|
+
"bottom": ("(W-w)/2", "H-h-{bottom}"),
|
|
42
|
+
"bottom-right": ("W-w-{right}", "H-h-{bottom}"),
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
|
|
42
|
-
def position_exprs(pos: str, margin: int, text_mode: bool):
|
|
46
|
+
def position_exprs(pos: str, margin: int, text_mode: bool, margins: Optional[dict] = None):
|
|
47
|
+
edges = margins or {}
|
|
48
|
+
m = {edge: int(edges.get(edge, margin)) for edge in ("top", "bottom", "left", "right")}
|
|
43
49
|
if pos in POS:
|
|
44
|
-
x, y = (e.format(m
|
|
50
|
+
x, y = (e.format(**m) for e in POS[pos])
|
|
45
51
|
else:
|
|
46
52
|
try:
|
|
47
53
|
xs, ys = pos.split(",")
|
|
@@ -101,6 +107,10 @@ def main() -> int:
|
|
|
101
107
|
ap.add_argument("--brand", help="brand.json (logo, font, colours, safe margin)")
|
|
102
108
|
ap.add_argument("--position", default="top-right", help="named position or X,Y (default top-right)")
|
|
103
109
|
ap.add_argument("--margin", type=int, default=24, help="margin from the edges in px (default 24)")
|
|
110
|
+
ap.add_argument("--platform", choices=PLATFORM_CHOICES, default=None,
|
|
111
|
+
help="keep the overlay out of this destination's UI: each edge's margin becomes that "
|
|
112
|
+
"platform's safe zone (scripts/_platforms.py), so a top-left logo clears TikTok's "
|
|
113
|
+
"status bar and a right-hand one clears the like column. An explicit --margin wins")
|
|
104
114
|
ap.add_argument("--start", help="show from this time (default: whole video)")
|
|
105
115
|
ap.add_argument("--end", help="hide after this time")
|
|
106
116
|
ap.add_argument("--fade", type=float, default=0.0, help="fade-in duration in seconds (at --start or 0); the fade-out happens only at --end")
|
|
@@ -163,6 +173,20 @@ def main() -> int:
|
|
|
163
173
|
if args.audio_stream and not audio_streams:
|
|
164
174
|
die("--audio-stream needs an input with audio streams")
|
|
165
175
|
vw = meta["video"]["width"]
|
|
176
|
+
# --platform: the edges this destination's own UI covers, in pixels of this frame. An
|
|
177
|
+
# explicit --margin (or a brand safe_margin, applied above) is the more specific statement
|
|
178
|
+
# and wins; without either, the historical 24 px default is unchanged.
|
|
179
|
+
safe_margins = None
|
|
180
|
+
args.platform = resolve_platform(args.platform)
|
|
181
|
+
if args.platform and args.margin == ap.get_default("margin") and PLATFORMS[args.platform].get("frame"):
|
|
182
|
+
# a dry run has no real frame to measure (the probe is stubbed rather than guessed), so
|
|
183
|
+
# fall back to the destination's own delivery frame -- which is what the fitted
|
|
184
|
+
# intermediate this stage runs on will be anyway
|
|
185
|
+
frame = PLATFORMS[args.platform]["frame"]
|
|
186
|
+
pw, ph = vw or frame["w"], meta["video"].get("height") or frame["h"]
|
|
187
|
+
safe_margins = safe_margins_px(args.platform, pw, ph)
|
|
188
|
+
info(f"--platform {args.platform}: safe margins top {safe_margins['top']} / bottom {safe_margins['bottom']} / "
|
|
189
|
+
f"left {safe_margins['left']} / right {safe_margins['right']} px (clear of the app's own UI)")
|
|
166
190
|
fps = meta["video"].get("fps")
|
|
167
191
|
start = time_arg(args.start, "--start", fps) if args.start else None
|
|
168
192
|
end = time_arg(args.end, "--end", fps) if args.end else None
|
|
@@ -203,7 +227,7 @@ def main() -> int:
|
|
|
203
227
|
chain.append(f"fade=t=in:st={s:.3f}:d={args.fade:g}:alpha=1")
|
|
204
228
|
if end is not None and end > args.fade:
|
|
205
229
|
chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
|
|
206
|
-
x, y = position_exprs(args.position, args.margin, text_mode=False)
|
|
230
|
+
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
207
231
|
ov = f"overlay={x}:{y}:format=auto"
|
|
208
232
|
if enable:
|
|
209
233
|
ov += f":enable='{enable}'"
|
|
@@ -237,7 +261,7 @@ def main() -> int:
|
|
|
237
261
|
chain.append(f"chromakey={args.chromakey}:{args.chromakey_similarity:g}:{args.chromakey_blend:g}")
|
|
238
262
|
if args.opacity < 1:
|
|
239
263
|
chain.append(f"colorchannelmixer=aa={args.opacity:g}")
|
|
240
|
-
x, y = position_exprs(args.position, args.margin, text_mode=False)
|
|
264
|
+
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
241
265
|
ov = f"overlay={x}:{y}:format=auto"
|
|
242
266
|
if enable:
|
|
243
267
|
ov += f":enable='{enable}'"
|
|
@@ -251,7 +275,7 @@ def main() -> int:
|
|
|
251
275
|
else:
|
|
252
276
|
cmd += ["-shortest"]
|
|
253
277
|
else:
|
|
254
|
-
x, y = position_exprs(args.position, args.margin, text_mode=True)
|
|
278
|
+
x, y = position_exprs(args.position, args.margin, text_mode=True, margins=safe_margins)
|
|
255
279
|
opts = [f"text='{escape_drawtext(args.text)}'", f"fontsize={args.font_size}", f"x={x}", f"y={y}",
|
|
256
280
|
f"borderw={args.border}", f"bordercolor={args.border_color}"]
|
|
257
281
|
if args.font_file:
|
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"])]
|