"""ServiceSim の録画に、店の様子のパネル(timeline.csv)を横に付ける。 使い方: python3 make_panel_video.py rec.mp4 frames.csv timeline.csv out.mp4 [--from 秒] [--to 秒] [--speed 倍] [--title 文字] 録画は 1コマ = frames.csv の t_s(店の時刻・秒)。パネルは timeline.csv の t_s ごと(60秒おき)に描き直す。 """ import csv, sys, argparse, subprocess, tempfile, pathlib, bisect from PIL import Image, ImageDraw, ImageFont W, H, PW = 1920, 1080, 560 # 全体・パネルの幅 VW = W - PW # 録画の幅 BG, INK, QUIET, LINE = "#faf9f5", "#1d1d1b", "#6b6a64", "#d9d6cc" ACC, GOOD, BAD, WARN = "#2f6fdf", "#1d9e75", "#d85a30", "#c98a1a" import os # 字は Noto Sans CJK JP。Linux の場所が無ければ Mac(Homebrew の font-noto-sans-cjk-jp)の場所(2026-11-04・依頼87) _MAC = os.path.expanduser("~/Library/Fonts") FONT = next((f for f in ("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", _MAC + "/NotoSansCJKjp-Regular.otf") if os.path.exists(f)), None) FONTB = next((f for f in ("/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", _MAC + "/NotoSansCJKjp-Bold.otf") if os.path.exists(f)), None) F = lambda s, b=False: ImageFont.truetype(FONTB if b else FONT, s, index=0) PAD = "#66605b" # --fit の余白(Unity の背景に近い色) STAFF_C, ROBOT_C = "#2a55c8", "#ffffff" # 立体の色に合わせる(スタッフ青・ロボット白) STAFF = [("staff_guide", "案内", ACC), ("staff_serve", "料理", GOOD), ("staff_call", "呼び出し", "#7a5cc7"), ("staff_pay", "会計", WARN), ("staff_clear", "片付け", "#8a6d3b"), ("staff_rescue", "ロボットの助け", BAD), ("staff_idle", "手すき", "#b8b5ab")] def hm(t): t = int(t); h, m = t // 3600, t % 3600 // 60 return f"{h}時間{m:02d}分" if h else f"{m}分" def panel(row, rows, robots, t_from, t_to, title): im = Image.new("RGB", (PW, H), BG); d = ImageDraw.Draw(im) x0 = 40 d.text((x0, 36), title, font=F(26), fill=QUIET) d.text((x0, 76), f"開店から {hm(row['t_s'])}", font=F(52, True), fill=INK) y = 170 # スタッフ n = sum(int(row[k]) for k, _, _ in STAFF) d.text((x0, y), f"スタッフ {n}人 がしていること", font=F(28, True), fill=INK); y += 52 for k, name, c in STAFF: v = int(row[k]) d.text((x0, y), name, font=F(26), fill=INK if v else QUIET) for i in range(v): d.rounded_rectangle((x0 + 210 + i * 46, y + 4, x0 + 246 + i * 46, y + 38), 6, fill=STAFF_C) y += 46 y += 20 # ロボット rb = int(row["robots_busy"]) # 下げ膳にも使う回(timeline.csv に robots_bussing の列がある回)だけ、動いている台のうち下げ膳の台を「下げ膳」と出す(2026-11-04・依頼91) bus = min(int(row.get("robots_bussing") or 0), rb) d.text((x0, y), f"ロボット {robots}台", font=F(28, True), fill=INK); y += 52 for i in range(robots): busy = i < rb d.rounded_rectangle((x0 + i * 150, y, x0 + 136 + i * 150, y + 50), 10, fill=ROBOT_C if busy else BG, outline=INK if busy else LINE, width=3) d.text((x0 + 68 + i * 150, y + 25), ("下げ膳" if i >= rb - bus else "運んでいる") if busy else "待機", font=F(24, True), fill=INK if busy else QUIET, anchor="mm") if robots == 0: d.text((x0, y + 8), "入れていない", font=F(26), fill=QUIET) y += 86 # 数字 for k, name, c in [("waiting_groups", "入口で待つ組", BAD), ("seated_groups", "席にいる組", INK), ("tables_dirty_untaken", "片付けを待つ卓", WARN)]: v = int(row[k]) d.text((x0, y + 8), name, font=F(28), fill=INK) d.text((PW - 50, y), str(v), font=F(48, True), fill=c if v else QUIET, anchor="ra") y += 66 # 入口で待つ組の一日 y += 14; gh = 120; gx0, gx1 = x0, PW - 40 d.text((x0, y), "入口で待つ組(営業の始めから終わり)", font=F(22), fill=QUIET); y += 36 ts = [r["t_s"] for r in rows]; vs = [int(r["waiting_groups"]) for r in rows] vmax = max(max(vs), 1); tmax = ts[-1] or 1 X = lambda t: gx0 + (gx1 - gx0) * t / tmax; Y = lambda v: y + gh - gh * v / vmax d.rectangle((X(t_from), y, X(t_to), y + gh), fill="#eeece4") d.line([(X(t), Y(v)) for t, v in zip(ts, vs)], fill=BAD, width=3) d.line((gx0, y + gh, gx1, y + gh), fill=LINE, width=2) cx = X(row["t_s"]); d.line((cx, y, cx, y + gh), fill=INK, width=3) return im def legend(path): im = Image.new("RGBA", (560, 64), (250, 249, 245, 230)); d = ImageDraw.Draw(im); x = 20 for c, name in [("#e8c520", "お客さん"), ("#2a55c8", "スタッフ"), ("#f2f2f2", "ロボット")]: d.ellipse((x, 18, x + 28, 46), fill=c, outline="#6b6a64", width=2); d.text((x + 38, 32), name, font=F(26), fill=INK, anchor="lm") x += 70 + 30 * len(name) 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("--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("--title", default="ServiceSim") ap.add_argument("--fit", action="store_true", help="録画を切らずに横幅に合わせて縮め、上下に余白を足す(字幕の入った寄りの部品用)") a = ap.parse_args() fr = [(int(r["frame"]), float(r["t_s"])) for r in csv.DictReader(open(a.frames))] rows = [] for r in csv.DictReader(open(a.timeline)): r = dict(r); r["t_s"] = float(r["t_s"]); rows.append(r) robots = int(rows[0]["condition_id"].split("_r")[1].split("_")[0]) if "_r" in rows[0]["condition_id"] else 0 t_to = a.t_to if a.t_to is not None else fr[-1][1] fps = 30 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()) # パネル:timeline の行ごと。その行が見える録画の長さ(速さで割る)だけ出す sel = [r for r in rows if a.t_from - 60 < r["t_s"] <= t_to] lst = [] 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 dur = (t1 - t0) / 0.5 / fps / a.speed p = tmp / f"p{i:04d}.png"; panel(r, rows, robots, a.t_from, t_to, a.title).save(p) lst.append(f"file '{p}'\nduration {dur:.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={VW}:-2,pad={VW}:{H}:0:(oh-ih)/2:color={PAD}[v0];" if a.fit else f"scale=-2:{H},crop={VW}:{H}[v0];") + f"[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) print(a.out, f"{v_dur / a.speed:.1f}s") if __name__ == "__main__": main()