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.
@@ -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": ("{m}", "{m}"),
31
- "top": ("(W-w)/2", "{m}"),
32
- "top-right": ("W-w-{m}", "{m}"),
33
- "left": ("{m}", "(H-h)/2"),
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-{m}", "(H-h)/2"),
36
- "bottom-left": ("{m}", "H-h-{m}"),
37
- "bottom": ("(W-w)/2", "H-h-{m}"),
38
- "bottom-right": ("W-w-{m}", "H-h-{m}"),
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=margin) for e in POS[pos])
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
- try:
292
- proj: Dict[str, Any] = json.loads(Path(args.project).read_text(encoding="utf-8"))
293
- except (OSError, ValueError) as exc:
294
- die(f"cannot read project: {exc}")
295
- if not isinstance(proj, dict):
296
- die(f"{args.project}: not a project or plan object (top level is {type(proj).__name__})")
297
- if "plan_version" in proj:
298
- return execute_plan(proj, os.path.abspath(args.project))
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
- base = Path(args.project).resolve().parent
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}.mp4")
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.mp4")
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.mp4")
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.mp4")
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.mp4")
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.mp4")
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}.mp4")
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
- sh("graphics.py", *(argv + brand_args))
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}.mp4")
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
- sh("overlay.py", *(argv + brand_args))
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.mp4")
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.mp4")
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"])]