"""サービスロボット導入シミュレーションの録画ストック(night/20261030_rec)から、2本を左右に並べた比較動画を作る(2026-10-31) 使い方: python3 make_compare.py <左の回の名前> <右の回の名前> <左の見出し> <右の見出し> <上の説明> <出力の mp4> [倍速 既定2] - 録画は 0.5 秒ごと・30 コマ/秒(15 倍速)。倍速 2 なら 1 コマおきに使い 30 倍速 - 上に、店の時刻・入店を待っている組・片付け待ちの卓・動けなくなった(累計)を1分ごとに出す(timeline.csv と stops.csv から) - 最後に、その回の入店待ちの平均(rec_index.csv)を出す """ import csv, sys, os, subprocess, bisect U = os.environ.get('SIM_UNITY', '/Volumes/KIOXIA/r2r2r-data/service_sim/unity') L, R, HL, HR, CAP, OUT = sys.argv[1:7] SPD = int(sys.argv[7]) if len(sys.argv) > 7 else 2 IDX = {r['回の名前']: r for r in csv.DictReader(open(U + '/night/20261030_rec/rec_index.csv', encoding='utf-8'))} W, H, TOP, BOT = 1280, 880, 150, 70 # 片側の大きさ(2560x1760 を半分)・上下の帯 def info(run): d = f'{U}/{run}' T = [(float(r['t_s']), int(r['waiting_groups']), int(r['tables_dirty_untaken']) + int(r['tables_clearing'])) for r in csv.DictReader(open(d + '/timeline.csv'))] S = sorted(float(r['t_s']) for r in csv.DictReader(open(d + '/stops.csv')) if r['resolved'] == '動けなくなった') fr = [float(r[list(r.keys())[1]]) for r in csv.DictReader(open(f'{d}/recordings/{run}_whole_frames.csv'))] return T, S, fr, IDX[run] def ts(v): # 動画の秒 → ASS の時刻 h = int(v // 3600); m = int(v % 3600 // 60); s = v % 60 return f'{h}:{m:02d}:{s:05.2f}' def clock(t): return f'{int(t // 3600)}:{int(t % 3600 // 60):02d}' ev = [] dur = 0 for side, run, head in ((0, L, HL), (1, R, HR)): T, S, fr, ix = info(run) vlen = len(fr) / 30 / SPD dur = max(dur, vlen) x = side * W + 40 ev.append((0, 99999, f'{{\\an7\\pos({x},18)\\fs46\\b1}}{head}')) for i, (t, wg, dirty) in enumerate(T): a = bisect.bisect_left(fr, t) / 30 / SPD b = (bisect.bisect_left(fr, T[i + 1][0]) / 30 / SPD) if i + 1 < len(T) else vlen if a >= vlen: break n = bisect.bisect_right(S, t) ev.append((a, b, f'{{\\an7\\pos({x},84)\\fs30}}店の時刻 {clock(t)} 入店待ちの組 {wg} 片付け待ちの卓 {dirty} 動けなくなった 累計{n}回')) ev.append((vlen, 99999, f'{{\\an7\\pos({x},84)\\fs30\\b1}}おわり 入店待ちの平均 {float(ix["入店待ち(分)"]):.1f}分 動けなくなった {ix["動けなくなった(回)"]}回')) ev.append((0, 99999, f'{{\\an1\\pos(40,{TOP + H + BOT - 18})\\fs26}}{CAP}')) ev.append((0, 99999, f'{{\\an3\\pos({2 * W - 40},{TOP + H + BOT - 18})\\fs26}}{{\\c&HE07A2A&}}●{{\\c&HF0F5F5&}} スタッフ {{\\c&H22C8F5&}}●{{\\c&HF0F5F5&}} お客さん {{\\c&HFFFFFF&}}●{{\\c&HF0F5F5&}} 配膳ロボット')) # 色の見本(Unity の色に合わせた) end = dur + 4 ass = ['[Script Info]', 'ScriptType: v4.00+', f'PlayResX: {2 * W}', f'PlayResY: {TOP + H + BOT}', '', '[V4+ Styles]', 'Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV', 'Style: D,Noto Sans CJK JP,30,&H00F0F5F5,&H000D0D0D,&H000D0D0D,0,0,1,0,0,7,0,0,0', '', '[Events]', 'Format: Layer, Start, End, Style, Text'] for a, b, txt in ev: ass.append(f'Dialogue: 0,{ts(a)},{ts(min(b, end))},D,{txt}') af = '/tmp/' + os.path.basename(OUT)[:-4] + '.ass' # libass が外付けの KIOXIA 上のファイルを読めないため、いったん /tmp に書く open(af, 'w', encoding='utf-8').write('\n'.join(ass) + '\n') open(OUT[:-4] + '.ass', 'w', encoding='utf-8').write('\n'.join(ass) + '\n') # 控え vin = [f'{U}/{r}/recordings/{r}_whole.mp4' for r in (L, R)] sel = f'select=not(mod(n\\,{SPD})),setpts=N/30/TB,' if SPD > 1 else '' fc = (f'[0:v]{sel}scale={W}:{H},tpad=stop_mode=clone:stop_duration=600[a];' f'[1:v]{sel}scale={W}:{H},tpad=stop_mode=clone:stop_duration=600[b];' f'[a][b]hstack=2,pad={2 * W}:{TOP + H + BOT}:0:{TOP}:color=0x0d0d0d,' f'drawbox=x={W - 1}:y=0:w=3:h={TOP + H}:color=0xf5f5f0:t=fill,ass={af},trim=duration={end:.2f}[v]') cmd = ['ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', '-i', vin[0], '-i', vin[1], '-filter_complex', fc, '-map', '[v]', '-r', '30', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', OUT] print(' '.join(cmd[:6]), '...'); subprocess.run(cmd, check=True) print(OUT, f'{end:.1f}秒', os.path.getsize(OUT))