# cm diff cs:40 cs:41 （ServiceSim）
# 1. cm diff の出力そのまま

A "Assets/ServiceSim/Recording/SimTimeRecorder.cs"
A "Assets/ServiceSim/Recording/SimTimeRecorder.cs.meta"
A "Assets/ServiceSim/Editor/RecordBuild.cs"
A "Assets/ServiceSim/Editor/RecordBuild.cs.meta"

# 2. 中身の違い（補足。上の各ファイルを cm cat <path>#cs:N で取り出し、diff -u で比べたもの）

--- /dev/null (cs:40 には無い)
+++ Assets/ServiceSim/Recording/SimTimeRecorder.cs#cs:41
@@ -0,0 +1,283 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Reflection;
+using System.Text;
+using UnityEngine;
+using Debug = UnityEngine.Debug;
+
+namespace ServiceSim
+{
+    /// <summary>
+    /// 見せるための録画（2026-10-29・依頼 26「LP用に店2の直す前と後を録画する」）。
+    ///
+    /// 引数に -ssRecord が付いたときだけ立つ。夜の回・段階B・エディタの Play では何もしない。
+    /// シーンには置かない（起動したあとに自分で GameObject を1つ作る）。
+    ///
+    /// 撮り方
+    ///   ・店の中の時刻（stops.csv の t_s と同じ。Time.time − 回し始めの時刻）が
+    ///     from 〜 to の間、step 秒ごとに1コマ撮る。
+    ///     回は1コマ 0.05 秒固定（Time.captureDeltaTime）で進むので、
+    ///     撮る重さで店の中の動きは変わらない（実時間が延びるだけ）。
+    ///   ・自分で作った真上のカメラ（正投影・ふだんは切）を、撮るコマだけ
+    ///     RenderTexture に描いて読み、ffmpeg に生のまま渡して mp4 にする。
+    ///     画面の上の文字（OnGUI）は RenderTexture に入らない。
+    ///   ・店のもの・人・ロボット・光・ほかのカメラには触らない。
+    ///     見た目は Main Camera の設定（背景色・映すもの）を写して使う。
+    ///
+    /// 引数
+    ///   -ssRecord {名前の頭}     … mp4 の名前の頭（例 20261029_1930_lpdemo_after）
+    ///   -ssRecFrom 900 -ssRecTo 1650 -ssRecStep 0.2
+    ///   -ssRecFps 30 -ssRecCrf 16
+    ///   -ssRecFfmpeg {ffmpeg のパス}
+    ///   -ssRecView name,x0,x1,y0,y1,w,h   … 撮る範囲（店の座標 m）と大きさ。何本でも
+    ///
+    /// 置き場所：その回の出力フォルダ（HeadlessRun が決めた run_stem）の下の recordings/。
+    ///   recordings/ がもうあれば撮らない（上書きしない）。
+    /// </summary>
+    public class SimTimeRecorder : MonoBehaviour
+    {
+        const float CamHeight = 60f;
+
+        class View
+        {
+            public string name;
+            public float x0, x1, y0, y1;
+            public int w, h;
+            public Camera cam;
+            public RenderTexture rt;
+            public Texture2D tex;
+            public Process proc;
+            public Stream stdin;
+            public string mp4;
+            public string err = "";
+            public StringBuilder ffLog = new StringBuilder();
+        }
+
+        string _prefix = "", _ffmpeg = "ffmpeg";
+        float _from = 900f, _to = 1650f, _step = 0.2f;
+        int _fps = 30, _crf = 16;
+        readonly List<View> _views = new List<View>();
+
+        ExperimentRunner _runner;
+        HeadlessRun _headless;
+        FieldInfo _fOffset, _fJobRunning, _fRunDir;
+
+        bool _started, _done, _failed;
+        int _next;                // 次に撮るコマの番号（0 から）
+        string _dir = "";
+        StringWriter _csv;
+        int _frames;
+
+        [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
+        static void Boot()
+        {
+            string[] argv;
+            try { argv = Environment.GetCommandLineArgs(); }
+            catch (Exception) { return; }
+            if (Find(argv, "-ssRecord") == null) return;
+
+            GameObject go = new GameObject("SimTimeRecorder");
+            SimTimeRecorder r = go.AddComponent<SimTimeRecorder>();
+            r.Setup(argv);
+        }
+
+        void Setup(string[] argv)
+        {
+            _prefix = Find(argv, "-ssRecord") ?? "";
+            _ffmpeg = Find(argv, "-ssRecFfmpeg") ?? "ffmpeg";
+            _from = F(Find(argv, "-ssRecFrom"), _from);
+            _to = F(Find(argv, "-ssRecTo"), _to);
+            _step = F(Find(argv, "-ssRecStep"), _step);
+            _fps = (int)F(Find(argv, "-ssRecFps"), _fps);
+            _crf = (int)F(Find(argv, "-ssRecCrf"), _crf);
+
+            for (int i = 0; i < argv.Length - 1; i++)
+            {
+                if (argv[i] != "-ssRecView") continue;
+                string[] p = argv[i + 1].Split(',');
+                if (p.Length != 7) { Log("-ssRecView が読めません: " + argv[i + 1]); continue; }
+                View v = new View();
+                v.name = p[0];
+                v.x0 = F(p[1], 0); v.x1 = F(p[2], 0); v.y0 = F(p[3], 0); v.y1 = F(p[4], 0);
+                v.w = (int)F(p[5], 0); v.h = (int)F(p[6], 0);
+                _views.Add(v);
+            }
+
+            _fOffset = typeof(ExperimentRunner).GetField("_timeOffset", BindingFlags.Instance | BindingFlags.NonPublic);
+            _fJobRunning = typeof(ExperimentRunner).GetField("_jobRunning", BindingFlags.Instance | BindingFlags.NonPublic);
+            _fRunDir = typeof(HeadlessRun).GetField("_runDir", BindingFlags.Instance | BindingFlags.NonPublic);
+
+            if (_prefix.Length == 0 || _views.Count == 0 || _step <= 0f || _to <= _from ||
+                _fOffset == null || _fJobRunning == null || _fRunDir == null)
+            {
+                Log("撮れません（引数か、ExperimentRunner / HeadlessRun の中の名前が合いません）。この回は撮らずに回します");
+                _failed = true;
+                return;
+            }
+            if (SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null)
+            {
+                Log("描画の装置がありません（-nographics では撮れません）。この回は撮らずに回します");
+                _failed = true;
+                return;
+            }
+            Log("撮ります: 店の時刻 " + _from + "〜" + _to + " 秒・" + _step + " 秒ごと・" + _fps + " コマ/秒・CRF " + _crf +
+                "・" + _views.Count + " 本");
+        }
+
+        void LateUpdate()
+        {
+            if (_failed || _done) return;
+
+            if (_runner == null) _runner = FindObjectOfType<ExperimentRunner>();
+            if (_headless == null) _headless = FindObjectOfType<HeadlessRun>();
+            if (_runner == null || _headless == null) return;
+            if (!(bool)_fJobRunning.GetValue(_runner)) return;
+
+            float t = Time.time - (float)_fOffset.GetValue(_runner);
+            float target = _from + _next * _step;
+            if (target > _to + 1e-3f) { Close(); return; }
+            if (t < target - 1e-3f) return;
+
+            if (!_started && !Begin()) { _failed = true; return; }
+
+            for (int i = 0; i < _views.Count; i++) Grab(_views[i]);
+            _csv.Write(_frames.ToString(CultureInfo.InvariantCulture));
+            _csv.Write(',');
+            _csv.Write(t.ToString("0.###", CultureInfo.InvariantCulture));
+            _csv.Write('\n');
+            _frames++;
+            _next++;
+            // 1コマ 0.05 秒で進むので、ふつうは1つずつ。万一飛んでも、次の目標へ進める
+            while (_from + _next * _step <= t + 1e-3f) _next++;
+        }
+
+        bool Begin()
+        {
+            _started = true;
+            string runDir = _fRunDir.GetValue(_headless) as string;
+            // 出力フォルダは回の終わりに ExperimentRunner が作る（Directory.CreateDirectory なので、
+            // 先にあっても困らない）。無いことは HeadlessRun が回し始める前に確かめてある
+            if (string.IsNullOrEmpty(runDir))
+            {
+                Log("出力フォルダの名前が分かりません。撮りません");
+                return false;
+            }
+            _dir = Path.Combine(runDir, "recordings");
+            if (Directory.Exists(_dir))
+            {
+                Log("recordings/ がもうあります。上書きしないので撮りません: " + _dir);
+                return false;
+            }
+            Directory.CreateDirectory(_dir);
+
+            Camera main = Camera.main;
+            for (int i = 0; i < _views.Count; i++)
+            {
+                View v = _views[i];
+                GameObject go = new GameObject("RecCam_" + v.name);
+                go.transform.SetParent(transform, false);
+                Camera c = go.AddComponent<Camera>();
+                if (main != null) c.CopyFrom(main);
+                c.enabled = false;
+                c.orthographic = true;
+                c.orthographicSize = (v.y1 - v.y0) * 0.5f;
+                c.aspect = (float)v.w / v.h;
+                c.nearClipPlane = 0.1f;
+                c.farClipPlane = CamHeight + 20f;
+                c.transform.position = new Vector3((v.x0 + v.x1) * 0.5f, CamHeight, (v.y0 + v.y1) * 0.5f);
+                c.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
+                v.rt = new RenderTexture(v.w, v.h, 24, RenderTextureFormat.ARGB32);
+                v.rt.antiAliasing = 8;
+                v.rt.Create();
+                c.targetTexture = v.rt;
+                v.cam = c;
+                v.tex = new Texture2D(v.w, v.h, TextureFormat.RGB24, false);
+
+                v.mp4 = Path.Combine(_dir, _prefix + "_" + v.name + ".mp4");
+                string args = "-hide_banner -loglevel error -y -f rawvideo -pix_fmt rgb24 -s " + v.w + "x" + v.h +
+                              " -r " + _fps + " -i - -vf vflip -c:v libx264 -preset slow -crf " + _crf +
+                              " -pix_fmt yuv420p -movflags +faststart \"" + v.mp4 + "\"";
+                ProcessStartInfo psi = new ProcessStartInfo(_ffmpeg, args);
+                psi.UseShellExecute = false;
+                psi.RedirectStandardInput = true;
+                psi.RedirectStandardError = true;
+                psi.CreateNoWindow = true;
+                v.proc = new Process();
+                v.proc.StartInfo = psi;
+                View vv = v;
+                v.proc.ErrorDataReceived += (s, e) => { if (e.Data != null) lock (vv.ffLog) vv.ffLog.AppendLine(e.Data); };
+                v.proc.Start();
+                v.proc.BeginErrorReadLine();
+                v.stdin = v.proc.StandardInput.BaseStream;
+            }
+
+            _csv = new StringWriter(CultureInfo.InvariantCulture);
+            _csv.Write("frame,t_s\n");
+            Log("撮り始めました: " + _dir);
+            return true;
+        }
+
+        void Grab(View v)
+        {
+            if (v.stdin == null) return;
+            v.cam.Render();
+            RenderTexture prev = RenderTexture.active;
+            RenderTexture.active = v.rt;
+            v.tex.ReadPixels(new Rect(0, 0, v.w, v.h), 0, 0, false);
+            RenderTexture.active = prev;
+            byte[] raw = v.tex.GetRawTextureData();
+            try { v.stdin.Write(raw, 0, raw.Length); }
+            catch (Exception e) { v.err = e.Message; v.stdin = null; }
+        }
+
+        void Close()
+        {
+            if (_done) return;
+            _done = true;
+            if (!_started) return;
+
+            for (int i = 0; i < _views.Count; i++)
+            {
+                View v = _views[i];
+                try { if (v.stdin != null) { v.stdin.Flush(); v.stdin.Close(); } } catch (Exception e) { v.err = e.Message; }
+            }
+            StringBuilder note = new StringBuilder();
+            for (int i = 0; i < _views.Count; i++)
+            {
+                View v = _views[i];
+                try { if (v.proc != null) v.proc.WaitForExit(); } catch (Exception) { }
+                int code = -1;
+                try { code = v.proc.ExitCode; } catch (Exception) { }
+                File.WriteAllText(Path.Combine(_dir, _prefix + "_" + v.name + "_frames.csv"), _csv.ToString(),
+                                  new UTF8Encoding(false));
+                note.Append("\n  ").Append(v.mp4).Append("（").Append(v.w).Append('×').Append(v.h)
+                    .Append("・ffmpeg 終了コード ").Append(code)
+                    .Append(v.err.Length > 0 ? "・" + v.err : "")
+                    .Append(v.ffLog.Length > 0 ? "・" + v.ffLog.ToString().Trim() : "").Append('）');
+                if (v.rt != null) v.rt.Release();
+            }
+            Log("撮り終わりました: " + _frames + " コマ" + note);
+        }
+
+        void OnApplicationQuit() { Close(); }
+        void OnDestroy() { Close(); }
+
+        static string Find(string[] argv, string name)
+        {
+            for (int i = 0; i < argv.Length - 1; i++)
+                if (argv[i] == name) return argv[i + 1];
+            return null;
+        }
+
+        static float F(string s, float def)
+        {
+            float v;
+            return s != null && float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out v) ? v : def;
+        }
+
+        static void Log(string msg) { Debug.Log("[ServiceSim/rec] " + msg); }
+    }
+}

--- /dev/null (cs:40 には無い)
+++ Assets/ServiceSim/Recording/SimTimeRecorder.cs.meta#cs:41
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: dd1c5fd6727c74a45aa312ded978a13e
+MonoImporter:
+  externalObjects: {}
+  serializedVersion: 2
+  defaultReferences: []
+  executionOrder: 0
+  icon: {instanceID: 0}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

--- /dev/null (cs:40 には無い)
+++ Assets/ServiceSim/Editor/RecordBuild.cs#cs:41
@@ -0,0 +1,45 @@
+using System;
+using UnityEditor;
+using UnityEngine;
+
+namespace ServiceSim.EditorTools
+{
+    /// <summary>
+    /// 録画に使う実行ファイルを、夜の実行ファイル（Builds/headless）とは別の場所に作る
+    /// （2026-10-29・依頼 26。中身は BuildHeadless と同じ。置き場所だけ違う）。
+    ///
+    /// 夜の実行係が使う Builds/headless を、録画のための版で上書きしないため。
+    /// 回すときは -nographics を付けない（-batchmode だけ）。付けると描けないので撮れない。
+    ///
+    ///   Unity -batchmode -quit -projectPath … \
+    ///     -executeMethod ServiceSim.EditorTools.RecordBuild.Build -ssBuildOut Builds/rec_after
+    /// </summary>
+    public static class RecordBuild
+    {
+        public static void Build()
+        {
+            string outDir = Find(Environment.GetCommandLineArgs(), "-ssBuildOut");
+            if (string.IsNullOrEmpty(outDir) || outDir.TrimEnd('/') == BuildHeadless.OutDir)
+            {
+                Debug.LogError("[ServiceSim] -ssBuildOut に Builds/headless 以外の置き場所を入れてください");
+                EditorApplication.Exit(1);
+                return;
+            }
+            string err;
+            if (!BuildHeadless.BuildTo(outDir, out err))
+            {
+                Debug.LogError("[ServiceSim] 録画用の実行ファイルを作れませんでした\n  " + err);
+                EditorApplication.Exit(1);
+                return;
+            }
+            EditorApplication.Exit(0);
+        }
+
+        static string Find(string[] argv, string name)
+        {
+            for (int i = 0; i < argv.Length - 1; i++)
+                if (argv[i] == name) return argv[i + 1];
+            return null;
+        }
+    }
+}

--- /dev/null (cs:40 には無い)
+++ Assets/ServiceSim/Editor/RecordBuild.cs.meta#cs:41
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 27b57d3c45af542a18bb1d5b524e2f63
+MonoImporter:
+  externalObjects: {}
+  serializedVersion: 2
+  defaultReferences: []
+  executionOrder: 0
+  icon: {instanceID: 0}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
