# 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 #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 +{ + /// + /// 見せるための録画(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/ がもうあれば撮らない(上書きしない)。 + /// + 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 _views = new List(); + + 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(); + 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(); + if (_headless == null) _headless = FindObjectOfType(); + 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(); + 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 +{ + /// + /// 録画に使う実行ファイルを、夜の実行ファイル(Builds/headless)とは別の場所に作る + /// (2026-10-29・依頼 26。中身は BuildHeadless と同じ。置き場所だけ違う)。 + /// + /// 夜の実行係が使う Builds/headless を、録画のための版で上書きしないため。 + /// 回すときは -nographics を付けない(-batchmode だけ)。付けると描けないので撮れない。 + /// + /// Unity -batchmode -quit -projectPath … \ + /// -executeMethod ServiceSim.EditorTools.RecordBuild.Build -ssBuildOut Builds/rec_after + /// + 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: