ffmpeg-skill 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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"])]
package/scripts/report.py CHANGED
@@ -7,6 +7,7 @@ Examples:
7
7
  python3 report.py --before raw.mov --after final.mp4 -o report.html
8
8
  python3 report.py --after final.mp4 --platform reels --title "Episode 12 — Reels cut" -o report.html
9
9
  python3 report.py --before raw.mov --after final.mp4 --commands commands.txt --notes notes.md
10
+ python3 report.py --pack talk_pack.md -o pack.html # the social pack table as HTML
10
11
  """
11
12
  import argparse
12
13
  import base64
@@ -79,9 +80,76 @@ def media_rows(meta: Dict[str, Any], ld: Dict[str, Any]) -> List[List[str]]:
79
80
  return rows
80
81
 
81
82
 
83
+ PACK_CSS = """
84
+ :root{--bg:#F4F6F8;--paper:#fff;--ink:#161B21;--ink2:#4B5661;--line:#D8DEE4;--ok:#2C8A5B;--bad:#B4362F}
85
+ @media (prefers-color-scheme:dark){:root{--bg:#111518;--paper:#191E23;--ink:#E8ECEF;--ink2:#AEB6BE;--line:#2A3138;--ok:#5CC38C;--bad:#F07A73}}
86
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans JP",sans-serif}
87
+ .wrap{max-width:900px;margin:0 auto;padding:32px 20px 60px}
88
+ h1{font-size:24px;margin:0 0 16px}
89
+ table{border-collapse:collapse;width:100%;font-size:14px;background:var(--paper);border:1px solid var(--line);border-radius:6px;overflow:hidden}
90
+ th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line)}th{color:var(--ink2);font-weight:500}
91
+ td.pass{color:var(--ok);font-weight:600}td.bad{color:var(--bad);font-weight:600}
92
+ p.note{color:var(--ink2);font-size:13px}
93
+ .foot{color:var(--ink2);font-size:12px;margin-top:30px;border-top:1px solid var(--line);padding-top:10px}
94
+ """
95
+
96
+
97
+ def pack_report(args) -> int:
98
+ """render.py --template all writes <stem>_pack.md: one row per destination. This renders the
99
+ same rows as HTML, so a pack can be handed over the way a single delivery report is -- no
100
+ re-measurement, because the pack table is what the renders actually produced."""
101
+ text = read_text_or_die(args.pack, "--pack")
102
+ rows: List[List[str]] = []
103
+ header: List[str] = []
104
+ for line in text.splitlines():
105
+ line = line.strip()
106
+ if not line.startswith("|"):
107
+ continue
108
+ cells = [c.strip() for c in line.strip("|").split("|")]
109
+ if all(set(c) <= set("-: ") for c in cells):
110
+ continue
111
+ if not header:
112
+ header = cells
113
+ else:
114
+ rows.append(cells)
115
+ if not rows:
116
+ die(f"{args.pack}: no pack table found (expected the Markdown table render.py --template all writes)")
117
+ title = args.title or f"Social pack — {Path(args.pack).stem.replace('_pack', '')}"
118
+ output = args.output or str(Path(args.pack).with_suffix(".html"))
119
+ head = "".join(f"<th>{html.escape(h)}</th>" for h in header)
120
+ body = ""
121
+ for r in rows:
122
+ cells = ""
123
+ for i, c in enumerate(r):
124
+ cls = ""
125
+ if header[i:i + 1] == ["check"]:
126
+ cls = " class='pass'" if c.lower() == "pass" else " class='bad'"
127
+ cells += f"<td{cls}>{html.escape(c)}</td>"
128
+ body += f"<tr>{cells}</tr>"
129
+ doc = ("<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
130
+ f"<title>{html.escape(title)}</title><style>{PACK_CSS}</style></head><body><div class='wrap'>"
131
+ f"<h1>{html.escape(title)}</h1><table><tr>{head}</tr>{body}</table>"
132
+ f"<p class='note'>{len(rows)} destination(s) from one edit.</p>"
133
+ "<p class='foot'>Generated by ffmpeg-skill · local FFmpeg, no cloud.</p></div></body></html>")
134
+ if STATE.dry_run:
135
+ info(f"wrote {output}")
136
+ else:
137
+ try:
138
+ Path(output).write_text(doc, encoding="utf-8")
139
+ except OSError as e:
140
+ die(f"cannot write {output}: {e}", kind="output")
141
+ info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
142
+ emit(None, report=output, pack=[dict(zip(header, r)) for r in rows], check=None,
143
+ verification=([{"step": "exists", "ok": True}] if not STATE.dry_run else []))
144
+ if not args.json:
145
+ print(output)
146
+ return 0
147
+
148
+
82
149
  def main() -> int:
83
150
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
84
- ap.add_argument("--after", required=True, help="the deliverable")
151
+ ap.add_argument("--after", help="the deliverable (required unless --pack is given)")
152
+ ap.add_argument("--pack", metavar="FILE", help="a social pack table written by render.py --template all (<stem>_pack.md): render it as HTML")
85
153
  ap.add_argument("--before", help="the source (optional)")
86
154
  ap.add_argument("-o", "--output", help="report path (default: <after>_report.html)")
87
155
  ap.add_argument("--title", help="report title")
@@ -93,6 +161,10 @@ def main() -> int:
93
161
  args = ap.parse_args()
94
162
  apply_common(args)
95
163
 
164
+ if args.pack:
165
+ return pack_report(args)
166
+ if not args.after:
167
+ die("--after is required (or --pack FILE for a social pack table)")
96
168
  after = probe(args.after)
97
169
  before = probe(args.before) if args.before else None
98
170
  ld_after = loudness(args.after) if after.get("audio") else {}
@@ -0,0 +1,47 @@
1
+ {
2
+ "template": "facebook",
3
+ "output": "$OUTPUT",
4
+ "clips": [
5
+ {
6
+ "src": "$INPUT"
7
+ }
8
+ ],
9
+ "frame": {
10
+ "aspect": "16:9",
11
+ "fit": "pad"
12
+ },
13
+ "brand": "$BRAND",
14
+ "captions": {
15
+ "text": "$CUES",
16
+ "animate": "none",
17
+ "karaoke": false,
18
+ "size": 20,
19
+ "position": "bottom",
20
+ "margin": 14
21
+ },
22
+ "graphics": [
23
+ {
24
+ "template": "lower-third",
25
+ "name": "$TITLE",
26
+ "start": 1,
27
+ "end": 6
28
+ }
29
+ ],
30
+ "overlays": [
31
+ {
32
+ "image": "$LOGO",
33
+ "position": "top-left"
34
+ }
35
+ ],
36
+ "loudness": {
37
+ "lufs": -14,
38
+ "tp": -1.0
39
+ },
40
+ "export": {
41
+ "preset": "facebook",
42
+ "normalize": true
43
+ },
44
+ "check": {
45
+ "platform": "facebook"
46
+ }
47
+ }