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.
- package/README.md +12 -0
- package/SKILL.md +80 -7
- package/package.json +2 -2
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.cpython-311.pyc +0 -0
- package/scripts/__pycache__/report.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/_common.py +52 -2
- package/scripts/caption.py +27 -9
- package/scripts/check.py +163 -0
- package/scripts/export.py +3 -1
- package/scripts/fit.py +2 -0
- package/scripts/graphics.py +166 -0
- package/scripts/join.py +13 -6
- package/scripts/loudness.py +3 -1
- package/scripts/overlay.py +24 -2
- package/scripts/render.py +362 -0
- package/scripts/report.py +162 -0
- package/scripts/scenes.py +155 -0
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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()
|
package/scripts/loudness.py
CHANGED
|
@@ -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)
|
package/scripts/overlay.py
CHANGED
|
@@ -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(
|
|
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())
|