"""ServiceSim の報告動画を作る。spec.json(スライドの並び)→ 1920x1080 の mp4。
使い方: python3 make_video.py spec.json out.mp4
スライドの種類: title / chart / rows / steps
"""
import json, sys, subprocess, html, pathlib, tempfile
from playwright.sync_api import sync_playwright
W, H = 1920, 1080
INK, QUIET, LINE, BG = "#1d1d1b", "#6b6a64", "#d9d6cc", "#faf9f5"
ACC, GOOD, BAD, SITE = "#2f6fdf", "#1d9e75", "#d85a30", "#9a9890"
E = html.escape
CSS = f"""
*{{margin:0;padding:0;box-sizing:border-box}}
body{{width:{W}px;height:{H}px;background:{BG};font-family:'Noto Sans CJK JP';color:{INK};position:relative;overflow:hidden}}
.top{{position:absolute;left:96px;top:64px;font-size:30px;color:{QUIET}}}
h1{{position:absolute;left:96px;top:120px;right:96px;font-size:60px;font-weight:700;line-height:1.3}}
.main{{position:absolute;left:96px;right:96px;top:260px;bottom:220px}}
.sub{{position:absolute;left:0;right:0;bottom:0;height:180px;background:#1d1d1b;color:#fff;font-size:40px;line-height:1.5;padding:30px 120px;display:flex;align-items:center;justify-content:center;text-align:center}}
.row{{display:flex;align-items:center;gap:28px;font-size:38px;line-height:1.4;margin-bottom:30px}}
.tag{{flex:none;min-width:150px;padding:8px 20px;border-radius:10px;font-size:30px;font-weight:700;text-align:center;color:#fff}}
.note{{font-size:28px;color:{QUIET};margin-top:16px}}
"""
def svg_chart(s):
xs = s["x"]; series = s["series"]; unit = s.get("unit", "")
cw, ch = 1728, 500; l, r, t, b = 120, 360, 30, 70
vals = [v for se in series for v in se["values"] if v is not None]
vmax = s.get("ymax") or max(vals) * 1.15
X = lambda i: l + i * (cw - l - r) / (len(xs) - 1)
Y = lambda v: t + (ch - t - b) * (1 - v / vmax)
out = [f'')
return "".join(out)
def body(s):
if s["kind"] == "title":
return f'
{E(s["title"])}
{"
".join(E(x) for x in s.get("lines", []))}
'
h = f'{E(s["title"])}
'
if s["kind"] == "chart":
note = f'{E(s["note"])}
' if s.get("note") else ""
return h + f'{svg_chart(s)}{note}
'
if s["kind"] in ("rows", "steps"):
cols = {"good": GOOD, "bad": BAD, "acc": ACC, "quiet": SITE}
rows = "".join(f'{E(r["tag"])}{E(r["text"])}
' for r in s["rows"])
return h + f'{rows}
'
raise ValueError(s["kind"])
def main(spec_path, out):
spec = json.load(open(spec_path, encoding="utf-8"))
tmp = pathlib.Path(tempfile.mkdtemp())
segs = []
with sync_playwright() as p:
br = p.chromium.launch(); pg = br.new_page(viewport={"width": W, "height": H})
for i, s in enumerate(spec["slides"]):
doc = f'{E(spec["header"])} {i+1}/{len(spec["slides"])}
{body(s)}{E(s["sub"])}
'
pg.set_content(doc); pg.wait_for_timeout(150)
png = tmp / f"s{i:02d}.png"; pg.screenshot(path=str(png))
seg = tmp / f"s{i:02d}.mp4"
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-loop", "1", "-i", str(png), "-t", str(s.get("sec", 10)),
"-vf", "fade=in:0:8,format=yuv420p", "-r", "30", "-c:v", "libx264", "-preset", "veryfast", str(seg)], check=True)
segs.append(seg)
br.close()
lst = tmp / "list.txt"; lst.write_text("".join(f"file '{x}'\n" for x in segs))
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", str(lst), "-c", "copy", out], check=True)
print(out)
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])