ffmpeg-skill 0.4.1 → 0.6.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.
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env python3
2
+ """Motion-graphics templates rendered with drawbox/drawtext expressions —
3
+ no After Effects, no image assets, brand colours from brand.json.
4
+
5
+ Templates:
6
+ lower-third name + title bar sliding in from the left (--name, --title)
7
+ title centred title card with optional subtitle, fade in/out (--title, --subtitle)
8
+ chapter small chip in a corner (--title), e.g. "Part 2 — Setup"
9
+ progress thin progress bar along the bottom that fills over the clip (or --start/--end)
10
+ countdown big numbers counting down from --from to 0 (--start/--end define the window)
11
+ bug persistent text bug (--title) in a corner, e.g. "@handle" or "LIVE"
12
+
13
+ Examples:
14
+ python3 graphics.py talk.mp4 --template lower-third --name "Ada Lovelace" --title "Analyst" --start 2 --end 8
15
+ python3 graphics.py talk.mp4 --template title --title "Episode 12" --subtitle "The math of video" --start 0 --end 4
16
+ python3 graphics.py talk.mp4 --template progress --brand brand.json
17
+ python3 graphics.py intro.mp4 --template countdown --from 5 --start 1 --end 6
18
+ python3 graphics.py clip.mp4 --template chapter --title "Part 2 — Setup" --position top-left --start 0 --end 5
19
+ """
20
+ import argparse
21
+ import sys
22
+ from typing import List, Optional
23
+
24
+ from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, video_args
25
+
26
+ TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
27
+
28
+
29
+ def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
30
+ return f"0x{color_hex(hex_rgb)}@{alpha:g}"
31
+
32
+
33
+ def font_opts(brand: dict, font: Optional[str], font_file: Optional[str]) -> str:
34
+ if font_file or brand.get("font_file"):
35
+ return f"fontfile={escape_filter_path(font_file or brand['font_file'])}"
36
+ return f"font='{font or brand.get('font', 'DejaVu Sans')}'"
37
+
38
+
39
+ def main() -> int:
40
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
41
+ ap.add_argument("input")
42
+ ap.add_argument("-o", "--output", help="output file (default: <name>_gfx.<ext>)")
43
+ ap.add_argument("--template", choices=TEMPLATES, required=True)
44
+ ap.add_argument("--brand", help="brand.json for colours, font, safe margin")
45
+ ap.add_argument("--name", help="lower-third: name line")
46
+ ap.add_argument("--title", help="title / chapter / bug text, or lower-third second line")
47
+ ap.add_argument("--subtitle", help="title: smaller second line")
48
+ ap.add_argument("--from", dest="count_from", type=int, default=5, help="countdown start number (default 5)")
49
+ ap.add_argument("--start", help="show from (default 0)")
50
+ ap.add_argument("--end", help="hide after (default end of clip)")
51
+ ap.add_argument("--position", choices=["top-left", "top-right", "bottom-left", "bottom-right"], default=None, help="corner for chapter/bug (default bottom-left / top-right)")
52
+ ap.add_argument("--primary", help="override brand primary colour RRGGBB")
53
+ ap.add_argument("--text-color", help="override text colour RRGGBB")
54
+ ap.add_argument("--font")
55
+ ap.add_argument("--font-file")
56
+ ap.add_argument("--scale", type=float, default=1.0, help="size multiplier (default 1)")
57
+ ap.add_argument("--crf", type=int, default=18)
58
+ ap.add_argument("--preset", default="medium")
59
+ add_common(ap)
60
+ args = ap.parse_args()
61
+ apply_common(args)
62
+
63
+ brand = load_brand(args.brand)
64
+ primary = color_hex(args.primary or brand["colors"]["primary"])
65
+ text_c = color_hex(args.text_color or brand["colors"]["text"])
66
+ bg = color_hex(brand["colors"].get("background", "101418"))
67
+ margin = int(brand.get("safe_margin", 48))
68
+ fo = font_opts(brand, args.font, args.font_file)
69
+
70
+ meta = probe(args.input)
71
+ if not meta.get("video"):
72
+ die("input has no video stream")
73
+ W, H = meta["video"]["width"], meta["video"]["height"]
74
+ if meta["video"].get("rotation") in (90, -90, 270, -270):
75
+ W, H = H, W
76
+ dur = meta.get("duration") or 0.0
77
+ s = parse_time(args.start) if args.start else 0.0
78
+ e = parse_time(args.end) if args.end else dur
79
+ if e <= s:
80
+ die("--end must be after --start")
81
+ en = f"enable='between(t,{s:.3f},{e:.3f})'"
82
+ base = min(W, H) * args.scale # scale everything from the short side
83
+ filters: List[str] = []
84
+ fade_a = f"if(lt(t,{s:.3f}+0.3),(t-{s:.3f})/0.3,if(gt(t,{e:.3f}-0.3),({e:.3f}-t)/0.3,1))"
85
+
86
+ extra_inputs: List[str] = []
87
+ fc: List[str] = [] # filter_complex chains (used by templates that need animated boxes)
88
+ if args.template == "lower-third":
89
+ if not args.name:
90
+ die("lower-third needs --name")
91
+ h1 = int(base * 0.055)
92
+ h2 = int(base * 0.038)
93
+ pad = int(base * 0.02)
94
+ bar_h = h1 + (h2 + pad if args.title else 0) + pad * 2
95
+ bar_w = int(base * 0.62)
96
+ y0 = H - margin - bar_h
97
+ # slide in from the left over 0.4 s, slide out over 0.3 s (overlay evaluates x per frame)
98
+ x_expr = f"if(lt(t,{s:.3f}+0.4),-{bar_w}+({bar_w}+{margin})*((t-{s:.3f})/0.4),if(gt(t,{e:.3f}-0.3),{margin}-({bar_w}+{margin})*(1-({e:.3f}-t)/0.3),{margin}))"
99
+ fc.append(f"color=c=0x{bg}@0.85:s={bar_w}x{bar_h}:r={meta['video'].get('fps') or 30:g},format=rgba[bar]")
100
+ fc.append(f"color=c=0x{primary}:s={int(base * 0.012)}x{bar_h}:r={meta['video'].get('fps') or 30:g},format=rgba[acc]")
101
+ fc.append(f"[0:v][bar]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v1]")
102
+ fc.append(f"[v1][acc]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v2]")
103
+ tx = f"({x_expr})+{int(base * 0.035)}"
104
+ chain = f"drawtext=text='{escape_drawtext(args.name)}':{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:x='{tx}':y={y0 + pad}:{en}"
105
+ if args.title:
106
+ chain += f",drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={h2}:fontcolor={ff_color(primary)}:x='{tx}':y={y0 + pad + h1 + pad // 2}:{en}"
107
+ fc.append(f"[v2]{chain}[vout]")
108
+
109
+ elif args.template == "title":
110
+ if not args.title:
111
+ die("title needs --title")
112
+ h1 = int(base * 0.11)
113
+ h2 = int(base * 0.045)
114
+ filters.append(f"drawbox=x=0:y=0:w=iw:h=ih:color={ff_color(bg, 0.55)}:t=fill:{en}")
115
+ filters.append(f"drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:x=(w-text_w)/2:y=(h-text_h)/2-{h2 if args.subtitle else 0}:alpha='{fade_a}':{en}")
116
+ filters.append(f"drawbox=x=(iw-{int(base * 0.12)})/2:y=(ih)/2+{h1 // 2 + (0 if args.subtitle else 0)}:w={int(base * 0.12)}:h={max(2, int(base * 0.006))}:color={ff_color(primary)}:t=fill:{en}")
117
+ if args.subtitle:
118
+ filters.append(f"drawtext=text='{escape_drawtext(args.subtitle)}':{fo}:fontsize={h2}:fontcolor={ff_color(primary)}:x=(w-text_w)/2:y=(h-text_h)/2+{h1 // 2 + int(base * 0.03)}:alpha='{fade_a}':{en}")
119
+
120
+ elif args.template in ("chapter", "bug"):
121
+ if not args.title:
122
+ die(f"{args.template} needs --title")
123
+ pos = args.position or ("bottom-left" if args.template == "chapter" else "top-right")
124
+ fs = int(base * (0.04 if args.template == "chapter" else 0.032))
125
+ padx, pady = int(fs * 0.6), int(fs * 0.35)
126
+ xe = f"{margin}" if "left" in pos else f"w-text_w-{margin}"
127
+ ye = f"{margin}" if "top" in pos else f"h-text_h-{margin}"
128
+ box_color = ff_color(primary if args.template == "chapter" else bg, 0.9 if args.template == "chapter" else 0.7)
129
+ txt_color = ff_color(bg if args.template == "chapter" else text_c)
130
+ filters.append(f"drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={fs}:fontcolor={txt_color}:x={xe}:y={ye}:box=1:boxcolor={box_color}:boxborderw={pady}|{padx}:alpha='{fade_a}':{en}")
131
+
132
+ elif args.template == "progress":
133
+ h = max(3, int(base * 0.008))
134
+ fps = meta['video'].get('fps') or 30
135
+ fc.append(f"color=c=0x{primary}:s={W}x{h}:r={fps:g},format=rgba[pb]")
136
+ fc.append(f"[0:v]drawbox=x=0:y=ih-{h}:w=iw:h={h}:color={ff_color(bg, 0.5)}:t=fill:{en}[v1]")
137
+ fc.append(f"[v1][pb]overlay=x='-w+w*min(1,max(0,(t-{s:.3f})/{e - s:.3f}))':y={H - h}:{en}:eof_action=pass[vout]")
138
+
139
+ elif args.template == "countdown":
140
+ n = args.count_from
141
+ seg = (e - s) / (n + 1)
142
+ fs = int(base * 0.32)
143
+ for k in range(n, -1, -1):
144
+ ks = s + (n - k) * seg
145
+ ke = ks + seg
146
+ pulse = f"1-0.15*min(1,(t-{ks:.3f})/{seg * 0.5:.3f})"
147
+ filters.append(f"drawtext=text='{k}':{fo}:fontsize={fs}:fontcolor={ff_color(primary)}:borderw={max(2, fs // 40)}:bordercolor={ff_color(bg)}:x=(w-text_w)/2:y=(h-text_h)/2:alpha='{pulse}':enable='between(t,{ks:.3f},{ke:.3f})'")
148
+
149
+ output = args.output or default_output(args.input, "gfx")
150
+ cmd = ffmpeg_base() + ["-i", args.input]
151
+ if fc:
152
+ cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", "0:a:0?"]
153
+ else:
154
+ cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", "0:a:0?"]
155
+ cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
156
+ cmd += aac_args() if meta.get("audio") else ["-an"]
157
+ cmd.append(output)
158
+ run(cmd)
159
+ r = probe(output)
160
+ info(f"wrote {output} ({r['duration']:.3f}s, {args.template})")
161
+ emit(output, template=args.template)
162
+ return 0
163
+
164
+
165
+ if __name__ == "__main__":
166
+ sys.exit(main())
package/scripts/join.py CHANGED
@@ -14,7 +14,7 @@ import argparse
14
14
  import sys
15
15
  from typing import List
16
16
 
17
- from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, x264_args
17
+ from _common import STATE, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, x264_args
18
18
 
19
19
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
20
20
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -44,17 +44,24 @@ def main() -> int:
44
44
  if not m.get("video"):
45
45
  die(f"{p} has no video stream")
46
46
  first = metas[0]["video"]
47
- w = args.width or first["width"]
48
- h = args.height or first["height"]
49
- if first.get("rotation") in (90, -90, 270, -270) and not (args.width or args.height):
50
- w, h = h, w
47
+ fw, fh = first["width"], first["height"]
48
+ if first.get("rotation") in (90, -90, 270, -270):
49
+ fw, fh = fh, fw
50
+ if args.width and args.height:
51
+ w, h = args.width, args.height
52
+ elif args.width:
53
+ w, h = args.width, int(round(args.width * fh / fw))
54
+ elif args.height:
55
+ w, h = int(round(args.height * fw / fh)), args.height
56
+ else:
57
+ w, h = fw, fh
51
58
  fps = args.fps or first.get("fps") or 30.0
52
59
  fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
53
60
  w, h = w - (w % 2), h - (h % 2)
54
61
  durs = [m.get("duration") or 0.0 for m in metas]
55
62
  d = args.duration if args.transition != "none" else 0.0
56
63
  for p, dur in zip(args.inputs, durs):
57
- if d and dur <= d * 2:
64
+ if d and dur <= d * 2 and not STATE["dry_run"]:
58
65
  die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
59
66
 
60
67
  cmd = ffmpeg_base()
@@ -18,11 +18,13 @@ import os
18
18
  import re
19
19
  import sys
20
20
 
21
- from _common import add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
21
+ from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
22
22
 
23
23
 
24
24
 
25
25
  def measure(path: str, I: float, tp: float, lra: float) -> dict:
26
+ if STATE["dry_run"]:
27
+ return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0"}
26
28
  ffmpeg = require_tool("ffmpeg")
27
29
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
28
30
  proc = run(cmd, check=False)
@@ -15,7 +15,7 @@ import argparse
15
15
  import sys
16
16
  from typing import List, Optional
17
17
 
18
- from _common import video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
18
+ from _common import load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
19
19
 
20
20
  POS = {
21
21
  "top-left": ("{m}", "{m}"),
@@ -76,9 +76,11 @@ def main() -> int:
76
76
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
77
77
  ap.add_argument("input")
78
78
  ap.add_argument("-o", "--output", help="output file (default: <name>_overlay.<ext>)")
79
- src = ap.add_mutually_exclusive_group(required=True)
79
+ src = ap.add_mutually_exclusive_group()
80
80
  src.add_argument("--image", help="PNG/JPG (alpha respected) to composite")
81
81
  src.add_argument("--text", help="text to draw (drawtext)")
82
+ src.add_argument("--logo", action="store_true", help="composite the brand logo from --brand (position/scale/opacity from brand.json)")
83
+ ap.add_argument("--brand", help="brand.json (logo, font, colours, safe margin)")
82
84
  ap.add_argument("--position", default="top-right", help="named position or X,Y (default top-right)")
83
85
  ap.add_argument("--margin", type=int, default=24, help="margin from the edges in px (default 24)")
84
86
  ap.add_argument("--start", help="show from this time (default: whole video)")
@@ -104,6 +106,26 @@ def main() -> int:
104
106
  args = ap.parse_args()
105
107
  apply_common(args)
106
108
 
109
+ brand = load_brand(args.brand)
110
+ if args.logo:
111
+ if not brand.get("logo"):
112
+ die("--logo needs a brand.json with a 'logo' entry")
113
+ args.image = brand["logo"]
114
+ if args.position == ap.get_default("position"):
115
+ args.position = brand.get("logo_position", "top-right")
116
+ if not args.scale and not args.scale_percent:
117
+ args.scale = int(brand.get("logo_scale", 160))
118
+ if args.opacity == 1.0:
119
+ args.opacity = float(brand.get("logo_opacity", 1.0))
120
+ if not (args.image or args.text):
121
+ die("give --image, --text or --logo")
122
+ if args.brand:
123
+ if args.margin == ap.get_default("margin"):
124
+ args.margin = int(brand.get("safe_margin", args.margin))
125
+ if args.font == ap.get_default("font"):
126
+ args.font = brand.get("font", args.font)
127
+ if not args.font_file and brand.get("font_file"):
128
+ args.font_file = brand["font_file"]
107
129
  meta = probe(args.input)
108
130
  if not meta.get("video"):
109
131
  die("input has no video stream")
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env python3
2
+ """Declarative edits: describe the whole edit in one project.json and render it
3
+ in one command. Change a number, re-render. Non-destructive: sources are never
4
+ touched, intermediates live in a work directory.
5
+
6
+ Project format (all keys optional except clips):
7
+ {
8
+ "output": "final.mp4",
9
+ "frame": {"aspect": "9:16", "width": 1080, "fps": 30},
10
+ "clips": [
11
+ {"src": "a.mp4", "in": "0:05", "out": "0:20"},
12
+ {"src": "b.mp4", "in": 3, "out": 12, "speed": 1.25},
13
+ {"src": "c.mp4"}
14
+ ],
15
+ "transition": {"type": "fade", "duration": 0.5},
16
+ "silence": {"threshold": -38, "min_silence": 0.8},
17
+ "captions": {"text": "cues.txt", "srt": null, "animate": "pop", "karaoke": true, "font": "Noto Sans CJK JP", "size": 28, "position": "bottom"},
18
+ "brand": "brand.json",
19
+ "graphics": [
20
+ {"template": "title", "title": "Episode 12", "subtitle": "The math of video", "start": 0, "end": 4},
21
+ {"template": "lower-third", "name": "Ada Lovelace", "title": "Analyst", "start": 5, "end": 11}
22
+ ],
23
+ "overlays": [
24
+ {"logo": true},
25
+ {"text": "Episode 12", "position": "bottom", "start": 1, "end": 5, "fade": 0.3, "box": true}
26
+ ],
27
+ "audio": {"voice": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "fade_out": 2},
28
+ "loudness": {"lufs": -14, "tp": -1},
29
+ "fit": {"duration": 60},
30
+ "export": {"preset": "reels"},
31
+ "check": {"platform": "reels"}
32
+ }
33
+
34
+ Stages run in this order: clips (cut) → join → silence → fit → captions →
35
+ graphics → overlays → audio → loudness → export → check. Missing stages are
36
+ skipped. "brand" points caption/graphics/overlay at a brand.json (fonts,
37
+ colours, logo, safe margin); {"logo": true} in overlays places the brand logo.
38
+
39
+ Examples:
40
+ python3 render.py --init project.json # write a commented starter project
41
+ python3 render.py project.json # render
42
+ python3 render.py project.json --dry-run # show every command without rendering
43
+ python3 render.py project.json --fast # preview quality
44
+ """
45
+ import argparse
46
+ import json
47
+ import os
48
+ import subprocess
49
+ import sys
50
+ from pathlib import Path
51
+ from typing import Any, Dict, List
52
+
53
+ from _common import STATE, add_common, apply_common, die, emit, info, probe
54
+
55
+ HERE = Path(__file__).resolve().parent
56
+
57
+ TEMPLATE = {
58
+ "output": "final.mp4",
59
+ "frame": {"aspect": "16:9", "width": 1920, "fps": 30},
60
+ "clips": [{"src": "REPLACE_ME.mp4", "in": "0:00", "out": "0:30"}],
61
+ "transition": {"type": "fade", "duration": 0.5},
62
+ "silence": None,
63
+ "brand": None,
64
+ "captions": None,
65
+ "graphics": [],
66
+ "overlays": [],
67
+ "audio": None,
68
+ "loudness": {"lufs": -14, "tp": -1},
69
+ "fit": None,
70
+ "export": {"preset": "youtube"},
71
+ "check": {"platform": "youtube"},
72
+ }
73
+
74
+
75
+ def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
76
+ """Run a sibling script, forwarding --fast / --dry-run, returning its printed output path."""
77
+ cmd = [sys.executable, str(HERE / script)] + [str(a) for a in argv] + (extra or [])
78
+ if STATE["fast"]:
79
+ cmd.append("--fast")
80
+ if STATE["dry_run"]:
81
+ cmd.append("--dry-run")
82
+ info("→ " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
83
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
84
+ for line in proc.stderr.splitlines():
85
+ if line.startswith("$ ") or line.startswith("[dry-run]"):
86
+ STATE["commands"].append(line[2:] if line.startswith("$ ") else line)
87
+ elif line.strip():
88
+ info(" " + line)
89
+ if proc.returncode != 0:
90
+ die(f"{script} failed")
91
+ out = proc.stdout.strip().splitlines()
92
+ return out[-1] if out else ""
93
+
94
+
95
+ def main() -> int:
96
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
97
+ ap.add_argument("project", nargs="?", help="project.json")
98
+ ap.add_argument("--init", metavar="FILE", help="write a starter project file and exit")
99
+ ap.add_argument("--work", help="work directory for intermediates (default: <output>_work)")
100
+ ap.add_argument("--keep", action="store_true", help="keep intermediates (default: kept only when --work is given)")
101
+ ap.add_argument("--stop-after", choices=["clips", "join", "silence", "fit", "captions", "graphics", "overlays", "audio", "loudness", "export"], help="stop after this stage (for iterating)")
102
+ add_common(ap)
103
+ args = ap.parse_args()
104
+ apply_common(args)
105
+
106
+ if args.init:
107
+ Path(args.init).write_text(json.dumps(TEMPLATE, indent=2) + "\n", encoding="utf-8")
108
+ info(f"wrote {args.init}; edit clips/src and run: render.py {args.init}")
109
+ print(args.init)
110
+ return 0
111
+ if not args.project:
112
+ die("give a project.json (or --init FILE)")
113
+ try:
114
+ proj: Dict[str, Any] = json.loads(Path(args.project).read_text(encoding="utf-8"))
115
+ except (OSError, ValueError) as exc:
116
+ die(f"cannot read project: {exc}")
117
+ base = Path(args.project).resolve().parent
118
+
119
+ def rel(p: Any) -> str:
120
+ p = str(p)
121
+ return p if os.path.isabs(p) else str(base / p)
122
+
123
+ clips = proj.get("clips") or []
124
+ if not clips:
125
+ die("project.clips is empty")
126
+ output = rel(proj.get("output") or "final.mp4")
127
+ work = Path(args.work) if args.work else Path(str(Path(output).with_suffix("")) + "_work")
128
+ work.mkdir(parents=True, exist_ok=True)
129
+ frame = proj.get("frame") or {}
130
+ trans = proj.get("transition") or {}
131
+ brand_args: List[str] = ["--brand", rel(proj["brand"])] if proj.get("brand") else []
132
+ stages_done: List[str] = []
133
+
134
+ # ---- clips
135
+ parts: List[str] = []
136
+ for i, c in enumerate(clips):
137
+ src = rel(c["src"])
138
+ if not STATE["dry_run"]:
139
+ probe(src)
140
+ needs_cut = c.get("in") is not None or c.get("out") is not None
141
+ part = str(work / f"clip{i:02d}.mp4")
142
+ if needs_cut:
143
+ argv: List[Any] = [src, "-o", part, "--accurate"]
144
+ if c.get("in") is not None:
145
+ argv += ["--start", c["in"]]
146
+ if c.get("out") is not None:
147
+ argv += ["--end", c["out"]]
148
+ sh("cut.py", *argv)
149
+ else:
150
+ part = src
151
+ if c.get("speed"):
152
+ spd = float(c["speed"])
153
+ dur = (probe(part).get("duration") or 0.0) if not STATE["dry_run"] else 10.0
154
+ fitted = str(work / f"clip{i:02d}_speed.mp4")
155
+ sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
156
+ part = fitted
157
+ parts.append(part)
158
+ stages_done.append("clips")
159
+ current = parts[0]
160
+ if args.stop_after == "clips":
161
+ emit(current, stages=stages_done)
162
+ return 0
163
+
164
+ # ---- join
165
+ if len(parts) > 1:
166
+ current = str(work / "joined.mp4")
167
+ argv = list(parts) + ["-o", current, "--transition", trans.get("type", "fade"), "--duration", str(trans.get("duration", 0.5))]
168
+ if frame.get("width"):
169
+ argv += ["--width", str(frame["width"])]
170
+ if frame.get("height"):
171
+ argv += ["--height", str(frame["height"])]
172
+ if frame.get("fps"):
173
+ argv += ["--fps", str(frame["fps"])]
174
+ sh("join.py", *argv)
175
+ stages_done.append("join")
176
+ if args.stop_after == "join":
177
+ emit(current, stages=stages_done)
178
+ return 0
179
+
180
+ # ---- silence
181
+ sil = proj.get("silence")
182
+ if sil:
183
+ nxt = str(work / "tight.mp4")
184
+ argv = [current, "-o", nxt]
185
+ for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin")):
186
+ if sil.get(k) is not None:
187
+ argv += [flag, str(sil[k])]
188
+ sh("silence.py", *argv)
189
+ current = nxt
190
+ stages_done.append("silence")
191
+ if args.stop_after == "silence":
192
+ emit(current, stages=stages_done)
193
+ return 0
194
+
195
+ # ---- fit (duration and/or frame)
196
+ fit = dict(proj.get("fit") or {})
197
+ if frame.get("aspect"):
198
+ fit.setdefault("aspect", frame["aspect"])
199
+ if frame.get("width") and len(parts) == 1:
200
+ fit.setdefault("width", frame["width"])
201
+ if frame.get("fps") and len(parts) == 1:
202
+ fit.setdefault("fps", frame["fps"])
203
+ if fit:
204
+ nxt = str(work / "fit.mp4")
205
+ argv = [current, "-o", nxt]
206
+ for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("fps", "--fps"), ("smooth", "--smooth")):
207
+ if fit.get(k) is not None:
208
+ argv += [flag, str(fit[k])]
209
+ sh("fit.py", *argv)
210
+ current = nxt
211
+ stages_done.append("fit")
212
+ if args.stop_after == "fit":
213
+ emit(current, stages=stages_done)
214
+ return 0
215
+
216
+ # ---- captions
217
+ cap = proj.get("captions")
218
+ if cap:
219
+ nxt = str(work / "captioned.mp4")
220
+ argv = [current, "-o", nxt]
221
+ if cap.get("text"):
222
+ argv += ["--text", rel(cap["text"])]
223
+ elif cap.get("srt"):
224
+ argv += ["--srt", rel(cap["srt"])]
225
+ elif cap.get("ass"):
226
+ argv += ["--ass", rel(cap["ass"])]
227
+ else:
228
+ die("captions needs text, srt or ass")
229
+ for k, flag in (("font", "--font"), ("size", "--size"), ("color", "--color"), ("position", "--position"), ("margin", "--margin"), ("animate", "--animate"), ("highlight_color", "--highlight-color"), ("outline", "--outline")):
230
+ if cap.get(k) is not None:
231
+ argv += [flag, str(cap[k])]
232
+ for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
233
+ if cap.get(k):
234
+ argv.append(flag)
235
+ sh("caption.py", *(argv + brand_args))
236
+ current = nxt
237
+ stages_done.append("captions")
238
+ if args.stop_after == "captions":
239
+ emit(current, stages=stages_done)
240
+ return 0
241
+
242
+ # ---- graphics
243
+ for i, g in enumerate(proj.get("graphics") or []):
244
+ nxt = str(work / f"graphics{i:02d}.mp4")
245
+ if not g.get("template"):
246
+ die(f"graphics[{i}] needs a template")
247
+ argv = [current, "-o", nxt, "--template", g["template"]]
248
+ 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")):
249
+ if g.get(k) is not None:
250
+ argv += [flag, str(g[k])]
251
+ sh("graphics.py", *(argv + brand_args))
252
+ current = nxt
253
+ if "graphics" not in stages_done:
254
+ stages_done.append("graphics")
255
+ if args.stop_after == "graphics":
256
+ emit(current, stages=stages_done)
257
+ return 0
258
+
259
+ # ---- overlays
260
+ for i, ov in enumerate(proj.get("overlays") or []):
261
+ nxt = str(work / f"overlay{i:02d}.mp4")
262
+ argv = [current, "-o", nxt]
263
+ if ov.get("logo"):
264
+ argv.append("--logo")
265
+ elif ov.get("image"):
266
+ argv += ["--image", rel(ov["image"])]
267
+ elif ov.get("text"):
268
+ argv += ["--text", ov["text"]]
269
+ else:
270
+ die(f"overlays[{i}] needs image or text")
271
+ 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")):
272
+ if ov.get(k) is not None:
273
+ argv += [flag, str(ov[k])]
274
+ if ov.get("box"):
275
+ argv.append("--box")
276
+ sh("overlay.py", *(argv + brand_args))
277
+ current = nxt
278
+ if "overlays" not in stages_done:
279
+ stages_done.append("overlays")
280
+ if args.stop_after == "overlays":
281
+ emit(current, stages=stages_done)
282
+ return 0
283
+
284
+ # ---- audio
285
+ au = proj.get("audio")
286
+ if au:
287
+ nxt = str(work / "audio.mp4")
288
+ argv = [current, "-o", nxt]
289
+ for k, flag in (("music", "--music"), ("replace", "--replace")):
290
+ if au.get(k):
291
+ argv += [flag, rel(au[k])]
292
+ for k, flag in (("music_volume", "--music-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount")):
293
+ if au.get(k) is not None:
294
+ argv += [flag, str(au[k])]
295
+ for k, flag in (("voice", "--voice"), ("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
296
+ if au.get(k):
297
+ argv.append(flag)
298
+ sh("audio.py", *argv)
299
+ current = nxt
300
+ stages_done.append("audio")
301
+ if args.stop_after == "audio":
302
+ emit(current, stages=stages_done)
303
+ return 0
304
+
305
+ # ---- loudness
306
+ ld = proj.get("loudness")
307
+ if ld:
308
+ nxt = str(work / "loudnorm.mp4")
309
+ argv = [current, "-o", nxt]
310
+ if ld.get("lufs") is not None:
311
+ argv += ["-I", str(ld["lufs"])]
312
+ if ld.get("tp") is not None:
313
+ argv += ["--tp", str(ld["tp"])]
314
+ sh("loudness.py", *argv)
315
+ current = nxt
316
+ stages_done.append("loudness")
317
+ if args.stop_after == "loudness":
318
+ emit(current, stages=stages_done)
319
+ return 0
320
+
321
+ # ---- export
322
+ ex = proj.get("export")
323
+ if ex and ex.get("preset"):
324
+ argv = [current, "--preset", ex["preset"], "-o", output]
325
+ if ex.get("fit"):
326
+ argv += ["--fit", ex["fit"]]
327
+ if ex.get("crf") is not None:
328
+ argv += ["--crf", str(ex["crf"])]
329
+ sh("export.py", *argv)
330
+ stages_done.append("export")
331
+ else:
332
+ if not STATE["dry_run"]:
333
+ import shutil
334
+ shutil.copyfile(current, output)
335
+ info(f"copied final stage to {output}")
336
+ current = output
337
+
338
+ # ---- check
339
+ ck = proj.get("check")
340
+ check_result = None
341
+ if ck and ck.get("platform") and not STATE["dry_run"]:
342
+ proc = subprocess.run([sys.executable, str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
343
+ try:
344
+ check_result = json.loads(proc.stdout)
345
+ except ValueError:
346
+ check_result = {"error": proc.stderr.strip()[-300:]}
347
+ if check_result.get("failed"):
348
+ info(f"check: {check_result['failed']} FAIL — " + "; ".join(f"{r['check']}={r['value']} ({r['fix']})" for r in check_result["checks"] if r["status"] == "FAIL"))
349
+ else:
350
+ info(f"check: OK for {ck['platform']}")
351
+ stages_done.append("check")
352
+
353
+ if not args.keep and not args.work and not STATE["dry_run"]:
354
+ import shutil
355
+ shutil.rmtree(work, ignore_errors=True)
356
+ info(f"rendered {output} via {' → '.join(stages_done)}")
357
+ emit(output, stages=stages_done, check=check_result)
358
+ return 0
359
+
360
+
361
+ if __name__ == "__main__":
362
+ sys.exit(main())