"""比較動画のストックを、録画ストック(night/{夜の名前})から順に作る(2026-10-31) 使い方: python3 make_compare_stock.py [--night 20261031_rec] [--out フォルダ] [--only N] ※ できている mp4 は飛ばす - 既定の夜は 20261031_rec(入口番・持ち場ありで撮り直した素材)。出力は stock/{夜の名前}/ - 1店7本:空いている・スタッフが多い・混んでいる・スタッフが少ない・普通(0台と2台)、普通の0台と1台・1台と2台 - 並べる順は、店 ③⑦④⑤⑥②、店の中は上の順(図の右上から) - 片側は元の録画の半分の大きさ(幅1280)。縦は店の形に合わせる(縦横比を保つ) - 30倍速(録画は15倍速なので1コマおき) 出力: videos/service_sim_compare/stock/{店}_{条件}_r{左}_vs_r{右}.mp4(字幕の控え .ass も)・進み具合は stock/progress.log """ import csv, sys, os, subprocess, bisect, time, tempfile TMP = tempfile.mkdtemp(prefix='cmp_') # 作業用(前の回の残りとぶつからないよう、毎回新しく作る) FONT = os.environ.get('CMP_FONT', 'Noto Sans CJK JP') # Mac で Noto がなければ CMP_FONT='Hiragino Sans' にする MAXSEC = float(sys.argv[sys.argv.index('--max-sec') + 1]) if '--max-sec' in sys.argv else None # 試しに頭の数秒だけ作る HOME = os.path.expanduser('~') U = os.environ.get('SIM_UNITY', HOME + '/mnt/KIOXIA/r2r2r-data/service_sim/unity') NIGHT = sys.argv[sys.argv.index('--night') + 1] if '--night' in sys.argv else '20261031_rec' OUTD = sys.argv[sys.argv.index('--out') + 1] if '--out' in sys.argv else os.environ.get('CMP_OUT', HOME + '/mnt/KIOXIA/r2r2r-data/videos/service_sim_compare/stock/' + NIGHT) os.makedirs(OUTD, exist_ok=True) NAME = {'fam_p0': '② 定番の郊外型', 'fam_p1': '③ 効率重視・入口が角', 'fam_p2': '④ 効率重視・小さめの店', 'fam_p3': '⑤ ゆったり・入口が正面', 'fam_p4': '⑥ ゆったり・入口が横', 'fam_p5': '⑦ 効率重視・席を詰めた店'} STORES = ['fam_p1', 'fam_p5', 'fam_p2', 'fam_p3', 'fam_p4', 'fam_p0'] CONDS = [('空いている', 'aki', 0, 2), ('スタッフが多い', 'stmany', 0, 2), ('混んでいる', 'komu', 0, 2), ('スタッフが少ない', 'stfew', 0, 2), ('普通・真ん中の人数', 'futsu', 0, 2), ('普通・真ん中の人数', 'futsu', 0, 1), ('普通・真ん中の人数', 'futsu', 1, 2)] SPD = 2 rows = list(csv.DictReader(open(U + '/night/' + NIGHT + '/rec_index.csv', encoding='utf-8'))) IDX = {r['回の名前']: r for r in rows} def find(store, cond, r): for x in rows: if store in x['条件の名前'] and x['条件の種類'] == cond and int(x['ロボット台数']) == r: return x raise KeyError((store, cond, r)) def ts(v): return f'{int(v // 3600)}:{int(v % 3600 // 60):02d}:{v % 60:05.2f}' def clock(t): return f'{int(t // 3600)}:{int(t % 3600 // 60):02d}' 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 def make(L, R, out): W = 1280 sw, sh = map(int, L['解像度'].split('x')); H = round(sh * W / sw / 2) * 2 TOP, BOT = 150, 70 store = L['条件の名前'].split('_')[1]; store = 'fam_' + store cap = f'{NAME[store]} {L["条件の種類"]}:スタッフ{L["スタッフ"]}人・1時間{L["来客/時"]}組 同じお客さんの来方(種{L["種"]})で並べた Unity の3Dシミュレーション 30倍速' ev = []; dur = 0 for side, x in ((0, L), (1, R)): run = x['回の名前']; T, S, fr = info(run) vlen = len(fr) / 30 / SPD; dur = max(dur, vlen); px = side * W + 40 ev.append((0, 99999, f'{{\\an7\\pos({px},18)\\fs46\\b1}}ロボット{x["ロボット台数"]}台')) 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({px},84)\\fs30}}店の時刻 {clock(t)} 入店待ちの組 {wg} 片付け待ちの卓 {dirty} 動けなくなった 累計{n}回')) ev.append((vlen, 99999, f'{{\\an7\\pos({px},84)\\fs30\\b1}}おわり 入店待ちの平均 {float(x["入店待ち(分)"]):.1f}分 動けなくなった {x["動けなくなった(回)"]}回')) 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&}} 配膳ロボット')) 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', f'Style: D,{FONT},30,&H00F0F5F5,&H000D0D0D,&H000D0D0D,0,0,1,0,0,7,0,0,0', '', '[Events]', 'Format: Layer, Start, End, Style, Text'] ass += [f'Dialogue: 0,{ts(a)},{ts(min(b, end))},D,{t}' for a, b, t in ev] af = os.path.join(TMP, os.path.basename(out)[:-4] + '.ass') 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}/{x["回の名前"]}/recordings/{x["回の名前"]}_whole.mp4' for x in (L, R)] sel = f'select=not(mod(n\\,{SPD})),setpts=N/30/TB,' fc = (f'[0:v]{sel}scale={W}:{H},tpad=stop_mode=clone:stop_duration=900[a];[1:v]{sel}scale={W}:{H},tpad=stop_mode=clone:stop_duration=900[b];' f'[a][b]hstack=2,pad={2 * W}:{TOP + H + BOT}:0:{TOP}:color=0x0d0d0d,drawbox=x={W - 1}:y=0:w=3:h={TOP + H}:color=0xf5f5f0:t=fill,ass={af},trim=duration={(min(end, MAXSEC) if MAXSEC else end):.2f}[v]') tmp = os.path.join(TMP, os.path.basename(out)) subprocess.run(['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', tmp], check=True) subprocess.run(['cp', tmp, out], check=True); os.remove(tmp) return end jobs = [(s, c, k, a, b) for s in STORES for c, k, a, b in CONDS] only = int(sys.argv[sys.argv.index('--only') + 1]) if '--only' in sys.argv else None log = open(OUTD + '/progress.log', 'a', encoding='utf-8') for n, (s, c, k, a, b) in enumerate(jobs, 1): if only and n != only: continue out = f'{OUTD}/{s.replace("fam_", "")}_{k}_r{a}_vs_r{b}.mp4' if os.path.exists(out): continue t0 = time.time() try: end = make(find(s, c, a), find(s, c, b), out) log.write(f'{time.strftime("%m-%d %H:%M")} できた {n}/{len(jobs)} {os.path.basename(out)} 長さ{end:.0f}秒 作るのに{time.time() - t0:.0f}秒\n') except Exception as e: log.write(f'{time.strftime("%m-%d %H:%M")} 失敗 {n}/{len(jobs)} {os.path.basename(out)} {e}\n') log.flush() log.write(f'{time.strftime("%m-%d %H:%M")} 全部おわり\n')