"""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 W, H, PW = 1920, 1080, 560 # 全体・パネルの幅 VW = W - PW # 録画の幅 import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import style as st # 色と字は style.py(決まりは STYLE.md)。ここで色や字を決めない(2026-11-08) BG, INK, QUIET, LINE = st.PAPER, st.INK, st.MUTED, st.RULE BAD = st.BAD T = st.text # 日本語は Noto Sans、英数字は Inter PAD = "#66605b" # --fit の余白(Unity の背景に近い色。録画側の色なので style には入れない) STAFF_C, ROBOT_C = st.STAFF, st.ROBOT # 立体の色に合わせる(スタッフ青・ロボット白) STAFF = [("staff_guide", "案内"), ("staff_serve", "料理"), ("staff_call", "呼び出し"), ("staff_pay", "会計"), ("staff_clear", "片付け"), ("staff_rescue", "ロボットの助け"), ("staff_idle", "手すき")] 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 T(d, (x0, 36), title, 26, QUIET) T(d, (x0, 76), f"開店から {hm(row['t_s'])}", 52, INK, bold=True) y = 170 # スタッフ n = sum(int(row[k]) for k, _ in STAFF) T(d, (x0, y), f"スタッフ {n}人 がしていること", 28, INK, bold=True); y += 52 for k, name in STAFF: v = int(row[k]) T(d, (x0, y), name, 26, 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 += 42 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) T(d, (x0, y), f"ロボット {robots}台", 28, INK, bold=True); y += 52 # 1段に3台まで(パネルの幅 560px に収める。4台以上は段を足す。2026-11-08) for i in range(robots): busy = i < rb bx, by = x0 + (i % 3) * 160, y + (i // 3) * 62 d.rounded_rectangle((bx, by, bx + 146, by + 50), 10, fill=ROBOT_C if busy else BG, outline=INK if busy else LINE, width=3) T(d, (bx + 73, by + 25), ("下げ膳" if i >= rb - bus else "運んでいる") if busy else "待機", 24, INK if busy else QUIET, bold=True, anchor="mm") if robots == 0: T(d, (x0, y + 8), "入れていない", 26, QUIET) y += 62 * max(0, (robots - 1) // 3) y += 76 # 数字 for k, name, c in [("waiting_groups", "入口で待つ組", BAD), ("seated_groups", "席にいる組", INK), ("tables_dirty_untaken", "片付けを待つ卓", BAD)]: v = int(row[k]) T(d, (x0, y + 8), name, 28, INK) T(d, (PW - 50, y), str(v), 48, c if v else QUIET, bold=True, anchor="ra") y += 60 # 入口で待つ組の一日 y += 14; gx0, gx1 = x0, PW - 40 T(d, (x0, y), "入口で待つ組(営業の始めから終わり)", 22, QUIET); y += 36 gh = min(120, H - 24 - y) # 下の端からはみ出さない高さ(ロボットが4台以上で段が増えたとき) if gh < 50: raise SystemExit("パネルが縦に収まらない(グラフの高さ %d px)。形を見直す" % gh) 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=st.BAND) 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), (247, 247, 247, 230)); d = ImageDraw.Draw(im); x = 20 for c, name in [(st.GUEST, "お客さん"), (st.STAFF, "スタッフ"), (st.ROBOT_SWATCH, "ロボット")]: d.ellipse((x, 18, x + 28, 46), fill=c, outline=st.MUTED, width=2); T(d, (x + 38, 32), name, 26, 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()