"""ServiceSim のパネル付き動画を作る(サイトに出す動画・報告に使う動画はすべてこの形)。 使い方: python3 make_panel_video.py rec.mp4 frames.csv timeline.csv out.mp4 --title "③ 標準ファミレス・ロボット2台" [--from 店の秒] [--to 店の秒] [--speed 倍] [--trial] 決まった形(2026-11-03 エイイチ了承。変えるときはエイイチに確かめてから) 画面 1920x1080・30fps。左 1360px が録画、右 560px がパネル 色 立体と同じ。お客さん=黄・スタッフ=青・ロボット=白。録画の左上に色の説明(お客さん/スタッフ/ロボット) パネル 上から:題(店・台数)→ 開店からの時間(「1時間54分」の形。1:54 とは書かない) → スタッフ N人がしていること(案内・料理・呼び出し・会計・片付け・ ロボットの助け・手すき。1人=青い四角1つ)→ ロボット N台(運んでいる=白・黒縁/待機=薄い)→ 入口で待つ組・席にいる組・片付けを待つ卓 → 入口で待つ組の一日の線(いまの時刻に縦線・映っている範囲を薄く) 言葉 「客」と書かずに「お客さん」。数字の欄は「〜組」「〜卓」 数字 録画と同じ回の timeline.csv を使う(違う回の数字を重ねるのは --trial のときだけ。題に(試作)が付く) """ import csv, argparse, subprocess, tempfile, pathlib, json from PIL import Image, ImageDraw, ImageFont W, H, PW = 1920, 1080, 560 VW = W - PW FPS = 30 BG, INK, QUIET, LINE, SHADE = "#faf9f5", "#1d1d1b", "#6b6a64", "#d9d6cc", "#eeece4" BAD, WARN = "#d85a30", "#c98a1a" STAFF_C, ROBOT_C, GUEST_C = "#2a55c8", "#ffffff", "#e8c520" # 立体の色 FONT = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" FONTB = "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc" F = lambda s, b=False: ImageFont.truetype(FONTB if b else FONT, s, index=0) STAFF = [("staff_guide", "案内"), ("staff_serve", "料理"), ("staff_call", "呼び出し"), ("staff_pay", "会計"), ("staff_clear", "片付け"), ("staff_rescue", "ロボットの助け"), ("staff_idle", "手すき")] COUNTS = [("waiting_groups", "入口で待つ組", BAD), ("seated_groups", "席にいる組", INK), ("tables_dirty_untaken", "片付けを待つ卓", WARN)] NEED = ["t_s", "robots_busy"] + [k for k, _ in STAFF] + [k for k, _, _ in COUNTS] def hm(t): t = int(t); h, m = t // 3600, t % 3600 // 60 return f"{h}時間{m:02d}分" if h else f"{m}分" def fit(d, text, size, maxw, bold=False): """幅に収まるまで字を小さくする(崩れ防止)""" while size > 14 and d.textlength(text, font=F(size, bold)) > maxw: size -= 1 return F(size, bold) def panel(row, rows, robots, staff_n, t_from, t_to, title): im = Image.new("RGB", (PW, H), BG); d = ImageDraw.Draw(im) x0, xr = 40, PW - 40; inner = xr - x0 d.text((x0, 36), title, font=fit(d, title, 26, inner), fill=QUIET) clock = f"開店から {hm(row['t_s'])}" d.text((x0, 76), clock, font=fit(d, clock, 52, inner, True), fill=INK) y = 170 d.text((x0, y), f"スタッフ {staff_n}人 がしていること", font=F(28, True), fill=INK); y += 52 bx = x0 + 210; step = min(46, (xr - bx) // max(staff_n, 1)); box = step - 10 for k, name in STAFF: v = int(float(row[k])) d.text((x0, y), name, font=F(26), fill=INK if v else QUIET) for i in range(v): d.rounded_rectangle((bx + i * step, y + 4, bx + i * step + box, y + 4 + min(box, 32)), 6, fill=STAFF_C) y += 44 y += 16 rb = int(float(row["robots_busy"])) d.text((x0, y), f"ロボット {robots}台", font=F(28, True), fill=INK); y += 52 if robots == 0: d.text((x0, y + 8), "入れていない", font=F(26), fill=QUIET) else: rstep = min(150, inner // robots); rw = rstep - 14 for i in range(robots): busy = i < rb; lab = "運んでいる" if busy else "待機" d.rounded_rectangle((x0 + i * rstep, y, x0 + i * rstep + rw, y + 50), 10, fill=ROBOT_C if busy else BG, outline=INK if busy else LINE, width=3) d.text((x0 + i * rstep + rw / 2, y + 25), lab, font=fit(d, lab, 24, rw - 12, True), fill=INK if busy else QUIET, anchor="mm") y += 86 for k, name, c in COUNTS: v = int(float(row[k])) d.text((x0, y + 8), name, font=F(28), fill=INK) d.text((xr - 10, y), str(v), font=F(48, True), fill=c if v else QUIET, anchor="ra") y += 66 y += 14; gh = 120 d.text((x0, y), "入口で待つ組(営業の始めから終わり)", font=F(22), fill=QUIET); y += 36 ts = [r["t_s"] for r in rows]; vs = [int(float(r["waiting_groups"])) for r in rows] vmax = max(max(vs), 1); tmax = ts[-1] or 1 X = lambda t: x0 + inner * min(max(t, 0), tmax) / tmax; Y = lambda v: y + gh - gh * v / vmax d.rectangle((X(t_from), y, X(t_to), y + gh), fill=SHADE) d.line([(X(t), Y(v)) for t, v in zip(ts, vs)], fill=BAD, width=3) d.line((x0, y + gh, xr, y + gh), fill=LINE, width=2) cx = X(row["t_s"]); d.line((cx, y, cx, y + gh), fill=INK, width=3) assert y + gh <= H - 20, "パネルが下にはみ出す" return im def legend(path): items = [(GUEST_C, "お客さん"), (STAFF_C, "スタッフ"), ("#f2f2f2", "ロボット")] d0 = ImageDraw.Draw(Image.new("RGB", (1, 1))); f = F(26) w = 20 + sum(38 + d0.textlength(n, font=f) + 34 for _, n in items) im = Image.new("RGBA", (int(w), 64), (250, 249, 245, 230)); d = ImageDraw.Draw(im); x = 20 for c, name in items: d.ellipse((x, 18, x + 28, 46), fill=c, outline=QUIET, width=2) d.text((x + 38, 32), name, font=f, fill=INK, anchor="lm") x += 38 + d.textlength(name, font=f) + 34 im.save(path) def main(): ap = argparse.ArgumentParser() ap.add_argument("rec"); ap.add_argument("frames"); ap.add_argument("timeline"); ap.add_argument("out") ap.add_argument("--title", required=True) ap.add_argument("--from", dest="t_from", type=float, default=0); ap.add_argument("--to", dest="t_to", type=float, default=None) ap.add_argument("--speed", type=float, default=1.0) ap.add_argument("--trial", action="store_true", help="録画と数字が別の回のとき(題に(試作)を付ける)") a = ap.parse_args() title = a.title + ("(試作)" if a.trial else "") fr = [(int(r["frame"]), float(r["t_s"])) for r in csv.DictReader(open(a.frames))] dt = fr[1][1] - fr[0][1] # 1コマが店の何秒か rows = [] for r in csv.DictReader(open(a.timeline)): r = dict(r); r["t_s"] = float(r["t_s"]); rows.append(r) miss = [k for k in NEED if k not in rows[0]] assert not miss, f"timeline.csv に列がない: {miss}" cid = rows[0].get("condition_id", "") robots = int(cid.split("_r")[1].split("_")[0]) if "_r" in cid else 0 staff_n = max(sum(int(float(r[k])) for k, _ in STAFF) for r in rows) probe = json.loads(subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "json", a.rec], capture_output=True, text=True, check=True).stdout)["streams"][0] assert probe["width"] * 9 == probe["height"] * 16, "録画は 16:9 にする" t_to = a.t_to if a.t_to is not None else fr[-1][1] f_from = next(f for f, t in fr if t >= a.t_from); f_to = max(f for f, t in fr if t <= t_to) v_from, v_dur = f_from / FPS, (f_to - f_from) / FPS tmp = pathlib.Path(tempfile.mkdtemp()) sel = [r for r in rows if a.t_from - (rows[1]["t_s"] - rows[0]["t_s"]) < r["t_s"] <= t_to] lst, p = [], None for i, r in enumerate(sel): t0 = max(r["t_s"], a.t_from); t1 = sel[i + 1]["t_s"] if i + 1 < len(sel) else t_to if t1 <= t0: continue p = tmp / f"p{i:04d}.png"; panel(r, rows, robots, staff_n, a.t_from, t_to, title).save(p) lst.append(f"file '{p}'\nduration {(t1 - t0) / dt / FPS / a.speed:.4f}\n") lst.append(f"file '{p}'\n") (tmp / "p.txt").write_text("".join(lst)) legend(tmp / "lg.png") vf = (f"[0:v]trim=start={v_from}:duration={v_dur},setpts=(PTS-STARTPTS)/{a.speed}," f"scale=-2:{H},crop={VW}:{H}[v0];[2:v]format=rgba[lg];[v0][lg]overlay=32:32[v];" f"[1:v]fps={FPS},setpts=PTS-STARTPTS[p];[v][p]hstack=inputs=2,format=yuv420p") subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", a.rec, "-f", "concat", "-safe", "0", "-i", str(tmp / "p.txt"), "-loop", "1", "-i", str(tmp / "lg.png"), "-filter_complex", vf, "-t", f"{v_dur / a.speed:.3f}", "-r", str(FPS), "-c:v", "libx264", "-preset", "veryfast", "-crf", "22", a.out], check=True) out = json.loads(subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "json", a.out], capture_output=True, text=True, check=True).stdout)["streams"][0] assert (out["width"], out["height"]) == (W, H), "出来た動画の大きさが違う" print(a.out, f"{v_dur / a.speed:.1f}s") if __name__ == "__main__": main()