"""ServiceSim の報告動画を作る。spec.json(スライドの並び)→ 1920x1080 の mp4。 使い方: python3 make_video.py spec.json out.mp4 スライドの種類: title / chart / rows / steps """ import json, sys, subprocess, html, pathlib, tempfile, math, os from playwright.sync_api import sync_playwright sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import style as st # 色と字は style.py(決まりは STYLE.md)。ここで色を決めない W, H = 1920, 1080 INK, QUIET, LINE, BG = st.INK, st.MUTED, st.RULE, st.PAPER E = html.escape def tag_color(c): c = st.TAG_ALIAS.get(c or "next", c or "next") if c not in st.TAG: raise SystemExit("札の色 %r は使えない。good / bad / quiet / next のどれか" % c) return st.TAG[c] def series_color(c): c = st.SERIES_ALIAS.get(c or "unity", c or "unity") if c not in st.SERIES: raise SystemExit("線の色 %r は使えない。unity(main)/ site / bad のどれか" % c) return st.SERIES[c], c == "site" def nice_step(v): """v 以上で一番小さい 1・2・2.5・5 ×10^n""" if v <= 0: return 1.0 e = 10 ** math.floor(math.log10(v)) for m in (1, 2, 2.5, 5, 10): if v <= m * e + 1e-12: return m * e def ticks(top): """目盛りはきりのいい数で 4〜5 本(0 から)。上端は目盛りの数に切り上げる""" step = nice_step(top / 4) n = math.ceil(top / step - 1e-9) return [round(step * k, 6) for k in range(n + 1)] CSS = f""" *{{margin:0;padding:0;box-sizing:border-box}} {st.CSS_FACE} body{{width:{W}px;height:{H}px;background:{BG};font-family:{st.CSS_FONT};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:{INK};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, 56, 70 vals = [v for se in series for v in se["values"] if v is not None] tk = ticks(s.get("ymax") or max(vals) * 1.1) vmax = tk[-1] pad = 48 # 最初の点を目盛りの字から離す X = lambda i: l + pad + i * (cw - l - r - pad) / (len(xs) - 1) Y = lambda v: t + (ch - t - b) * (1 - v / vmax) out = [f''] for v in tk: y = Y(v) out.append(f'') out.append(f'{v:g}') for i, x in enumerate(xs): out.append(f'{E(x)}') for se in series: c, is_site = series_color(se.get("color")) dash = ' stroke-dasharray="12 10"' if (se.get("dash") or is_site) else "" pts = [(X(i), Y(v)) for i, v in enumerate(se["values"]) if v is not None] out.append(f'') for i, v in enumerate(se["values"]): if v is None: continue out.append(f'') if se.get("labels", True): out.append(f'{v:.{s.get("dp",1)}f}') lx, ly = pts[-1] out.append(f'{E(se["name"])}') out.append(f'{E(unit)}') 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"): 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])