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); }
}
}