ffmpeg-skill 1.12.0 → 1.14.0

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