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 の設定(背景色・映すもの)を写して使う。 /// ・光:影を落とす Directional Light(Setup の Sun)の高さだけを上げて影を短くする /// (2026-10-29・依頼 28。地面からの角度 52° → 75°。方角・やわらかさ・濃さ・色・明るさは今のまま)。 /// 撮り始めるときに変える。-ssRecord の無い回(夜の回・確かめの回・エディタの Play)では変わらない。 /// /// 引数 /// -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)と大きさ。何本でも /// -ssRecSunPitch 75 … 録画のときの光の地面からの角度(既定 75。依頼 28) /// -ssRecStillAt 1500 -ssRecStillPitches 70,75,80 /// … 静止画だけ書く回(依頼 28)。店の時刻がこの秒に来たコマを、 /// 光の角度ごとに PNG で書いて、回を途中で終える(mp4 は作らない。 /// 台帳には書かれず、記録は {run_stem}_stopped に残る) /// /// 置き場所:その回の出力フォルダ(HeadlessRun が決めた run_stem)の下の recordings/。 /// recordings/ がもうあれば撮らない(上書きしない)。 /// public class SimTimeRecorder : MonoBehaviour { const float CamHeight = 60f; /// 録画のときの光の地面からの角度(依頼 28。ふだんの Sun は 52°) const float DefaultSunPitch = 75f; 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; float _sunPitch = DefaultSunPitch; float _stillAt = -1f; // 0 以上なら静止画だけ書く回 readonly List _stillPitches = new List(); Light _sun; 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); _sunPitch = F(Find(argv, "-ssRecSunPitch"), _sunPitch); _stillAt = F(Find(argv, "-ssRecStillAt"), -1f); string pitches = Find(argv, "-ssRecStillPitches"); if (pitches != null) foreach (string p in pitches.Split(',')) _stillPitches.Add(F(p, _sunPitch)); if (_stillAt >= 0f) { if (_stillPitches.Count == 0) _stillPitches.Add(_sunPitch); _from = _stillAt; _to = _stillAt + _step; // 1コマだけ撮る(_to <= _from の確かめに掛からないように) } 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 の中の名前が合いません)。この回は撮らずに回します"); FailSetup(); return; } if (SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null) { Log("描画の装置がありません(-nographics では撮れません)。この回は撮らずに回します"); FailSetup(); return; } if (_stillAt >= 0f) { Log("静止画だけ書きます: 店の時刻 " + _stillAt + " 秒・光の角度 " + string.Join(",", _stillPitches) + "°・" + _views.Count + " 本。書いたら回を途中で終えます"); 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; QuitIfStill(); return; } if (_stillAt >= 0f) { WriteStills(t); 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); _sun = FindSun(); if (_sun == null) Log("影を落とす Directional Light が1つに決まりません。光は変えずに撮ります"); else SetSunPitch(_sunPitch); 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); if (_stillAt >= 0f) continue; // 静止画だけの回は ffmpeg を立てない 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 FailSetup() { _failed = true; QuitIfStill(); } /// 静止画だけの回は、撮れないなら最後まで回さずに終える(台帳に行を残さない) void QuitIfStill() { if (_stillAt < 0f) return; Log("静止画だけの回なので、撮れないまま回さずに終えます"); Application.Quit(1); } /// 影を落とす Directional Light(Setup の Sun)。1つに決まらなければ null static Light FindSun() { Light found = null; foreach (Light l in FindObjectsOfType()) { if (l.type != LightType.Directional || l.shadows == LightShadows.None) continue; if (found != null) return null; found = l; } return found; } /// 地面からの角度だけを変える(方角=y の回転・z の回転はそのまま) void SetSunPitch(float pitch) { if (_sun == null) return; Vector3 e = _sun.transform.eulerAngles; float before = e.x; _sun.transform.rotation = Quaternion.Euler(pitch, e.y, e.z); Log("光の角度: " + _sun.name + " " + before.ToString("0.#", CultureInfo.InvariantCulture) + "° → " + pitch.ToString("0.#", CultureInfo.InvariantCulture) + "°(方角 " + e.y.ToString("0.#", CultureInfo.InvariantCulture) + "° のまま)"); } /// 静止画だけの回:光の角度ごとに PNG を書いて、回を途中で終える void WriteStills(float t) { _done = true; StringBuilder note = new StringBuilder(); for (int k = 0; k < _stillPitches.Count; k++) { float pitch = _stillPitches[k]; SetSunPitch(pitch); for (int i = 0; i < _views.Count; i++) { View v = _views[i]; 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); v.tex.Apply(false); RenderTexture.active = prev; string png = Path.Combine(_dir, _prefix + "_" + v.name + "_t" + t.ToString("0.##", CultureInfo.InvariantCulture) + "_sun" + pitch.ToString("0.#", CultureInfo.InvariantCulture) + ".png"); File.WriteAllBytes(png, v.tex.EncodeToPNG()); note.Append("\n ").Append(png); } } for (int i = 0; i < _views.Count; i++) if (_views[i].rt != null) _views[i].rt.Release(); Log("静止画を書きました(店の時刻 " + t.ToString("0.###", CultureInfo.InvariantCulture) + " 秒)" + note + "\n 回を途中で終えます(台帳には書きません)"); Application.Quit(0); } 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); } } }