@mflrevan/ucp 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,724 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.IO;
4
+ using System.Text.RegularExpressions;
5
+ using UnityEditor;
6
+ using UnityEditor.Media;
7
+ using UnityEngine;
8
+
9
+ namespace UCP.Bridge
10
+ {
11
+ /// <summary>
12
+ /// Lightweight editor video capture for agent workflows. Frames are rendered without scene
13
+ /// objects, read back into a reusable texture, and written directly through Unity's native encoder.
14
+ /// </summary>
15
+ public static class RecordingController
16
+ {
17
+ private const string ArmedSessionKey = "UCP.Recording.Armed";
18
+ private static MediaEncoder s_encoder;
19
+ private static RenderTexture s_target;
20
+ private static Texture2D s_readback;
21
+ private static RecordingSettings s_settings;
22
+ private static Dictionary<string, object> s_lastResult;
23
+ private static string s_state = "idle";
24
+ private static string s_path;
25
+ private static string s_tempPath;
26
+ private static string s_error;
27
+ private static double s_startedAt;
28
+ private static double s_stopAt;
29
+ private static double s_hardStopAt;
30
+ private static double s_nextCaptureAt;
31
+ private static int s_frameCount;
32
+ private static int s_droppedFrames;
33
+ private static bool s_stopRequested;
34
+ private static ArmedRecording s_armed;
35
+
36
+ static RecordingController()
37
+ {
38
+ EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
39
+ AssemblyReloadEvents.beforeAssemblyReload += Shutdown;
40
+ EditorApplication.quitting += Shutdown;
41
+ RestoreArmedRecording();
42
+ }
43
+
44
+ public static void Register(CommandRouter router)
45
+ {
46
+ router.Register("record/start", HandleStart);
47
+ router.Register("record/stop", HandleStop);
48
+ router.Register("record/status", _ => BuildStatus());
49
+ router.Register("record/arm", HandleArm);
50
+ router.Register("record/signal", HandleSignal);
51
+ }
52
+
53
+ public static void Signal(string name)
54
+ {
55
+ if (s_armed == null || !s_armed.MatchesSignal(name)) return;
56
+ StartArmedRecording();
57
+ }
58
+
59
+ public static void NotifyLog(string message)
60
+ {
61
+ if (s_armed == null || s_armed.LogRegex == null) return;
62
+ try
63
+ {
64
+ if (s_armed.LogRegex.IsMatch(message ?? string.Empty)) StartArmedRecording();
65
+ }
66
+ catch (RegexMatchTimeoutException)
67
+ {
68
+ FailArm("Log trigger regex timed out");
69
+ }
70
+ }
71
+
72
+ public static void Shutdown()
73
+ {
74
+ if (s_encoder == null) return;
75
+ try { FinalizeRecording(); }
76
+ catch { DisposeResources(); }
77
+ }
78
+
79
+ internal static void ResetForTests()
80
+ {
81
+ Shutdown();
82
+ ClearArm();
83
+ s_lastResult = null;
84
+ s_state = "idle";
85
+ s_error = null;
86
+ }
87
+
88
+ internal static (int width, int height) ResolveDimensionsForTests(
89
+ float aspect, int maxEdge, int? width, int? height)
90
+ {
91
+ return ResolveDimensions(aspect, maxEdge, width, height);
92
+ }
93
+
94
+ private static object HandleStart(string paramsJson)
95
+ {
96
+ if (s_encoder != null || s_state == "finalizing")
97
+ throw new InvalidOperationException("A recording is already active. Use `ucp record status` or `ucp record stop`.");
98
+
99
+ ClearArm();
100
+ var settings = ParseSettings(paramsJson);
101
+ StartRecording(settings);
102
+ return BuildStatus();
103
+ }
104
+
105
+ private static object HandleStop(string paramsJson)
106
+ {
107
+ if (s_armed != null)
108
+ {
109
+ ClearArm();
110
+ s_state = "idle";
111
+ s_error = null;
112
+ s_settings = null;
113
+ s_path = null;
114
+ s_tempPath = null;
115
+ return BuildStatus();
116
+ }
117
+ if (s_encoder == null)
118
+ return BuildStatus();
119
+
120
+ s_stopRequested = true;
121
+ s_state = "finalizing";
122
+ FinalizeRecording();
123
+ return BuildStatus();
124
+ }
125
+
126
+ private static object HandleArm(string paramsJson)
127
+ {
128
+ if (s_encoder != null || s_state == "finalizing")
129
+ throw new InvalidOperationException("Cannot arm while a recording is active");
130
+
131
+ var parameters = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
132
+ if (parameters == null || !TryString(parameters, "trigger", out var trigger))
133
+ throw new ArgumentException("Missing 'trigger' parameter");
134
+
135
+ var settings = ParseSettings(paramsJson);
136
+ var timeout = ReadDouble(parameters, "timeout", 60d);
137
+ s_settings = settings;
138
+ s_lastResult = null;
139
+ s_path = null;
140
+ s_tempPath = null;
141
+ s_frameCount = 0;
142
+ s_droppedFrames = 0;
143
+ s_armed = ArmedRecording.Create(trigger, settings,
144
+ timeout > 0d ? EditorApplication.timeSinceStartup + timeout : 0d);
145
+ PersistArm();
146
+ s_state = "armed";
147
+ s_error = null;
148
+ SubscribeTick();
149
+ return BuildStatus();
150
+ }
151
+
152
+ private static object HandleSignal(string paramsJson)
153
+ {
154
+ var parameters = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
155
+ if (parameters == null || !TryString(parameters, "name", out var name))
156
+ throw new ArgumentException("Missing 'name' parameter");
157
+ var matched = s_armed != null && s_armed.MatchesSignal(name);
158
+ Signal(name);
159
+ var result = BuildStatus();
160
+ result["matched"] = matched;
161
+ result["signal"] = name;
162
+ return result;
163
+ }
164
+
165
+ private static void StartRecording(RecordingSettings settings)
166
+ {
167
+ var camera = ResolveCamera(settings.View);
168
+ var sourceAspect = ResolveSourceAspect(camera);
169
+ var dimensions = ResolveDimensions(sourceAspect, settings.MaxEdge, settings.Width, settings.Height);
170
+ settings.Width = dimensions.width;
171
+ settings.Height = dimensions.height;
172
+ settings.SourceAspect = sourceAspect;
173
+
174
+ ResolveOutputPaths(settings, out s_path, out s_tempPath);
175
+ var parent = Path.GetDirectoryName(s_path);
176
+ if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(parent);
177
+ if (File.Exists(s_path) && !settings.Overwrite)
178
+ throw new IOException($"Recording output already exists: {s_path}. Pass --overwrite to replace it.");
179
+ if (File.Exists(s_tempPath)) File.Delete(s_tempPath);
180
+
181
+ try
182
+ {
183
+ s_encoder = CreateEncoder(s_tempPath, settings);
184
+ s_target = new RenderTexture(settings.Width.Value, settings.Height.Value, 24,
185
+ RenderTextureFormat.ARGB32)
186
+ {
187
+ antiAliasing = 1,
188
+ name = "__ucp_recording_target",
189
+ hideFlags = HideFlags.HideAndDontSave
190
+ };
191
+ s_target.Create();
192
+ s_readback = new Texture2D(settings.Width.Value, settings.Height.Value,
193
+ TextureFormat.RGBA32, false)
194
+ {
195
+ name = "__ucp_recording_readback",
196
+ hideFlags = HideFlags.HideAndDontSave
197
+ };
198
+ }
199
+ catch
200
+ {
201
+ try { s_encoder?.Dispose(); } catch { }
202
+ s_encoder = null;
203
+ DisposeResources();
204
+ TryDeleteTempFile();
205
+ throw;
206
+ }
207
+
208
+ s_settings = settings;
209
+ s_startedAt = EditorApplication.timeSinceStartup;
210
+ s_stopAt = settings.Duration > 0d ? s_startedAt + settings.Duration : 0d;
211
+ s_hardStopAt = settings.MaxDuration > 0d ? s_startedAt + settings.MaxDuration : 0d;
212
+ s_nextCaptureAt = s_startedAt;
213
+ s_frameCount = 0;
214
+ s_droppedFrames = 0;
215
+ s_stopRequested = false;
216
+ s_error = null;
217
+ s_state = "recording";
218
+ s_lastResult = null;
219
+ SubscribeTick();
220
+ }
221
+
222
+ private static MediaEncoder CreateEncoder(string path, RecordingSettings settings)
223
+ {
224
+ // Capture cadence stays real time; only the declared playback rate changes. No frames
225
+ // are duplicated and nothing is re-encoded -- the container simply spaces the captured
226
+ // frames further apart, which is what raises effective temporal resolution for a
227
+ // consumer that samples the file at a fixed rate.
228
+ var frameRate = new MediaRational(settings.Fps);
229
+ if (settings.Slowdown > 1.0000001d)
230
+ {
231
+ frameRate.numerator = Mathf.RoundToInt(settings.Fps * 1000f);
232
+ frameRate.denominator = Mathf.RoundToInt((float)settings.Slowdown * 1000f);
233
+ }
234
+ var bitrate = (uint)(settings.BitrateKbps * 1000);
235
+ VideoTrackEncoderAttributes attributes;
236
+ if (settings.Format == "webm")
237
+ {
238
+ attributes = new VideoTrackEncoderAttributes(new VP8EncoderAttributes
239
+ {
240
+ keyframeDistance = (uint)(settings.Fps * 2)
241
+ });
242
+ }
243
+ else
244
+ {
245
+ attributes = new VideoTrackEncoderAttributes(new H264EncoderAttributes
246
+ {
247
+ gopSize = (uint)(settings.Fps * 2),
248
+ numConsecutiveBFrames = 0,
249
+ profile = VideoEncodingProfile.H264Baseline
250
+ });
251
+ }
252
+ attributes.frameRate = frameRate;
253
+ attributes.width = (uint)settings.Width.Value;
254
+ attributes.height = (uint)settings.Height.Value;
255
+ attributes.includeAlpha = false;
256
+ attributes.targetBitRate = bitrate;
257
+ attributes.bitRateMode = VideoBitrateMode.Medium;
258
+ return new MediaEncoder(path, attributes);
259
+ }
260
+
261
+ private static void Tick()
262
+ {
263
+ var now = EditorApplication.timeSinceStartup;
264
+ if (s_armed != null)
265
+ {
266
+ if (s_armed.Deadline > 0d && now >= s_armed.Deadline)
267
+ FailArm("Recording trigger timed out");
268
+ return;
269
+ }
270
+ if (s_encoder == null) { UnsubscribeTick(); return; }
271
+
272
+ if ((s_stopAt > 0d && now >= s_stopAt) || (s_hardStopAt > 0d && now >= s_hardStopAt))
273
+ {
274
+ s_stopRequested = true;
275
+ s_state = "finalizing";
276
+ FinalizeRecording();
277
+ return;
278
+ }
279
+ if (s_stopRequested || now < s_nextCaptureAt) return;
280
+
281
+ try
282
+ {
283
+ var frameInterval = 1d / s_settings.Fps;
284
+ if (now - s_nextCaptureAt >= frameInterval)
285
+ {
286
+ var missed = (int)((now - s_nextCaptureAt) / frameInterval);
287
+ s_droppedFrames += missed;
288
+ s_nextCaptureAt += missed * frameInterval;
289
+ }
290
+ RenderFrame();
291
+ s_nextCaptureAt += frameInterval;
292
+ EncodeSynchronous();
293
+ }
294
+ catch (Exception ex)
295
+ {
296
+ FailRecording(ex.Message);
297
+ }
298
+ }
299
+
300
+ private static void RenderFrame()
301
+ {
302
+ var camera = ResolveCamera(s_settings.View);
303
+ var previousTarget = camera.targetTexture;
304
+ var previousRect = camera.rect;
305
+ var previousAspect = camera.aspect;
306
+ var previousActive = RenderTexture.active;
307
+ try
308
+ {
309
+ RenderTexture.active = s_target;
310
+ GL.Clear(true, true, Color.black);
311
+ camera.targetTexture = s_target;
312
+ camera.aspect = s_settings.SourceAspect;
313
+ camera.rect = ContainRect(s_settings.SourceAspect,
314
+ (float)s_settings.Width.Value / s_settings.Height.Value);
315
+ camera.Render();
316
+ }
317
+ finally
318
+ {
319
+ camera.targetTexture = previousTarget;
320
+ camera.rect = previousRect;
321
+ camera.aspect = previousAspect;
322
+ RenderTexture.active = previousActive;
323
+ }
324
+ }
325
+
326
+ private static void EncodeSynchronous()
327
+ {
328
+ var previousActive = RenderTexture.active;
329
+ try
330
+ {
331
+ RenderTexture.active = s_target;
332
+ s_readback.ReadPixels(new Rect(0, 0, s_readback.width, s_readback.height), 0, 0);
333
+ s_readback.Apply(false, false);
334
+ if (!s_encoder.AddFrame(s_readback)) throw new IOException("Native video encoder rejected a frame");
335
+ s_frameCount++;
336
+ }
337
+ finally
338
+ {
339
+ RenderTexture.active = previousActive;
340
+ }
341
+ }
342
+
343
+ private static void FinalizeRecording()
344
+ {
345
+ if (s_encoder == null) return;
346
+ s_state = "finalizing";
347
+ var elapsed = Math.Max(0d, EditorApplication.timeSinceStartup - s_startedAt);
348
+ try
349
+ {
350
+ var encoder = s_encoder;
351
+ s_encoder = null;
352
+ encoder.Dispose();
353
+ if (File.Exists(s_path))
354
+ {
355
+ if (!s_settings.Overwrite) throw new IOException($"Recording output already exists: {s_path}");
356
+ File.Delete(s_path);
357
+ }
358
+ File.Move(s_tempPath, s_path);
359
+ var size = new FileInfo(s_path).Length;
360
+ s_state = "completed";
361
+ s_lastResult = Result("completed", elapsed, size);
362
+ }
363
+ catch (Exception ex)
364
+ {
365
+ s_error = ex.Message;
366
+ s_state = "failed";
367
+ s_lastResult = Result("failed", elapsed, 0);
368
+ TryDeleteTempFile();
369
+ }
370
+ finally
371
+ {
372
+ DisposeResources();
373
+ UnsubscribeTick();
374
+ }
375
+ }
376
+
377
+ private static void FailRecording(string message)
378
+ {
379
+ s_error = message;
380
+ s_state = "failed";
381
+ try { s_encoder?.Dispose(); } catch { }
382
+ s_encoder = null;
383
+ s_lastResult = Result("failed", Math.Max(0d, EditorApplication.timeSinceStartup - s_startedAt), 0);
384
+ DisposeResources();
385
+ TryDeleteTempFile();
386
+ UnsubscribeTick();
387
+ }
388
+
389
+ private static void DisposeResources()
390
+ {
391
+ if (s_target != null)
392
+ {
393
+ s_target.Release();
394
+ UnityEngine.Object.DestroyImmediate(s_target);
395
+ }
396
+ s_target = null;
397
+ if (s_readback != null) UnityEngine.Object.DestroyImmediate(s_readback);
398
+ s_readback = null;
399
+ s_stopRequested = false;
400
+ }
401
+
402
+ private static void TryDeleteTempFile()
403
+ {
404
+ try
405
+ {
406
+ if (!string.IsNullOrEmpty(s_tempPath) && File.Exists(s_tempPath)) File.Delete(s_tempPath);
407
+ }
408
+ catch { }
409
+ }
410
+
411
+ private static Dictionary<string, object> BuildStatus()
412
+ {
413
+ if (s_lastResult != null && s_encoder == null && s_armed == null)
414
+ return new Dictionary<string, object>(s_lastResult);
415
+ var result = Result(s_state,
416
+ s_encoder != null ? Math.Max(0d, EditorApplication.timeSinceStartup - s_startedAt) : 0d,
417
+ 0);
418
+ if (s_armed != null) result["trigger"] = s_armed.Trigger;
419
+ return result;
420
+ }
421
+
422
+ private static Dictionary<string, object> Result(string state, double elapsed, long size)
423
+ {
424
+ var result = new Dictionary<string, object>
425
+ {
426
+ ["state"] = state,
427
+ ["path"] = s_path ?? string.Empty,
428
+ ["durationSeconds"] = elapsed,
429
+ ["frames"] = s_frameCount,
430
+ ["droppedFrames"] = s_droppedFrames,
431
+ ["size"] = size
432
+ };
433
+ if (s_settings != null)
434
+ {
435
+ result["view"] = s_settings.View;
436
+ result["width"] = s_settings.Width ?? 0;
437
+ result["height"] = s_settings.Height ?? 0;
438
+ result["fps"] = s_settings.Fps;
439
+ // Capture cadence and playback rate diverge whenever slowdown is in play, and a
440
+ // caller needs both: `fps` is what was sampled, `playbackFps` is what the file
441
+ // declares.
442
+ result["slowdown"] = s_settings.Slowdown;
443
+ result["playbackFps"] = s_settings.Slowdown > 0d
444
+ ? s_settings.Fps / s_settings.Slowdown
445
+ : (double)s_settings.Fps;
446
+ result["format"] = s_settings.Format;
447
+ if (s_settings.Format != "auto")
448
+ result["codec"] = s_settings.Format == "webm" ? "vp8" : "h264";
449
+ }
450
+ if (!string.IsNullOrEmpty(s_error)) result["error"] = s_error;
451
+ return result;
452
+ }
453
+
454
+ private static RecordingSettings ParseSettings(string paramsJson)
455
+ {
456
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>
457
+ ?? new Dictionary<string, object>();
458
+ var settings = new RecordingSettings
459
+ {
460
+ View = ReadString(p, "view", "game").ToLowerInvariant(),
461
+ MaxEdge = Mathf.Clamp(ReadInt(p, "maxEdge", 960), 64, 4096),
462
+ Width = ReadNullableInt(p, "width"),
463
+ Height = ReadNullableInt(p, "height"),
464
+ Fps = Mathf.Clamp(ReadInt(p, "fps", 15), 1, 60),
465
+ Format = ReadString(p, "format", "auto").ToLowerInvariant(),
466
+ BitrateKbps = Mathf.Clamp(ReadInt(p, "bitrateKbps", 2000), 128, 50000),
467
+ Overwrite = ReadBool(p, "overwrite", false),
468
+ Path = ReadString(p, "path", null),
469
+ Duration = ReadDouble(p, "duration", 0d),
470
+ MaxDuration = ReadDouble(p, "maxDuration", 60d),
471
+ Slowdown = ReadDouble(p, "slowdown", 1d)
472
+ };
473
+ if (settings.View != "game" && settings.View != "scene")
474
+ throw new ArgumentException("View must be 'game' or 'scene'");
475
+ if (settings.Format != "auto" && settings.Format != "mp4" && settings.Format != "webm")
476
+ throw new ArgumentException("Format must be 'auto', 'mp4', or 'webm'");
477
+ if (double.IsNaN(settings.Duration) || double.IsInfinity(settings.Duration) || settings.Duration < 0d)
478
+ throw new ArgumentException("Duration must be zero or greater");
479
+ if (double.IsNaN(settings.MaxDuration) || double.IsInfinity(settings.MaxDuration) || settings.MaxDuration < 0d)
480
+ throw new ArgumentException("Max duration must be zero or greater");
481
+ if (double.IsNaN(settings.Slowdown) || double.IsInfinity(settings.Slowdown)
482
+ || settings.Slowdown < 1d || settings.Slowdown > 20d)
483
+ throw new ArgumentException("Slowdown must be between 1 and 20");
484
+ if (settings.Width.HasValue) settings.Width = MakeEven(Mathf.Clamp(settings.Width.Value, 64, 4096));
485
+ if (settings.Height.HasValue) settings.Height = MakeEven(Mathf.Clamp(settings.Height.Value, 64, 4096));
486
+ return settings;
487
+ }
488
+
489
+ private static void ResolveOutputPaths(RecordingSettings settings, out string path, out string tempPath)
490
+ {
491
+ var format = settings.Format;
492
+ var requestedExtension = string.IsNullOrEmpty(settings.Path) ? string.Empty
493
+ : Path.GetExtension(settings.Path).ToLowerInvariant();
494
+ if (format == "auto")
495
+ {
496
+ if (requestedExtension == ".webm") format = "webm";
497
+ else if (requestedExtension == ".mp4") format = "mp4";
498
+ else format = Application.platform == RuntimePlatform.LinuxEditor ? "webm" : "mp4";
499
+ }
500
+ var extension = format == "webm" ? ".webm" : ".mp4";
501
+ if (!string.IsNullOrEmpty(requestedExtension) && requestedExtension != extension)
502
+ throw new ArgumentException($"Output extension '{requestedExtension}' does not match --format {format}");
503
+ settings.Format = format;
504
+ var projectRoot = Path.GetDirectoryName(Application.dataPath);
505
+ var rawPath = string.IsNullOrEmpty(settings.Path)
506
+ ? Path.Combine(projectRoot, ".ucp", "recordings", $"recording-{DateTime.UtcNow:yyyyMMdd-HHmmss-fff}{extension}")
507
+ : settings.Path;
508
+ if (string.IsNullOrEmpty(Path.GetExtension(rawPath))) rawPath += extension;
509
+ path = Path.GetFullPath(Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(projectRoot, rawPath));
510
+ tempPath = Path.Combine(Path.GetDirectoryName(path),
511
+ Path.GetFileNameWithoutExtension(path) + ".partial" + extension);
512
+ }
513
+
514
+ private static Camera ResolveCamera(string view)
515
+ {
516
+ if (view == "scene")
517
+ {
518
+ var sceneView = SceneView.lastActiveSceneView;
519
+ if (sceneView == null || sceneView.camera == null)
520
+ throw new InvalidOperationException("No active Scene view is available to record");
521
+ return sceneView.camera;
522
+ }
523
+ var camera = Camera.main;
524
+ #if UNITY_2023_1_OR_NEWER
525
+ if (camera == null) camera = UnityEngine.Object.FindAnyObjectByType<Camera>();
526
+ #else
527
+ if (camera == null) camera = UnityEngine.Object.FindObjectOfType<Camera>();
528
+ #endif
529
+ if (camera == null) throw new InvalidOperationException("No camera is available for Game view recording");
530
+ return camera;
531
+ }
532
+
533
+ private static float ResolveSourceAspect(Camera camera)
534
+ {
535
+ if (camera.pixelWidth > 0 && camera.pixelHeight > 0)
536
+ return (float)camera.pixelWidth / camera.pixelHeight;
537
+ return camera.aspect > 0f ? camera.aspect : 16f / 9f;
538
+ }
539
+
540
+ private static (int width, int height) ResolveDimensions(float aspect, int maxEdge, int? width, int? height)
541
+ {
542
+ aspect = Mathf.Clamp(aspect, 0.1f, 10f);
543
+ if (width.HasValue && height.HasValue) return (MakeEven(width.Value), MakeEven(height.Value));
544
+ if (width.HasValue) return (MakeEven(width.Value), MakeEven(Mathf.RoundToInt(width.Value / aspect)));
545
+ if (height.HasValue) return (MakeEven(Mathf.RoundToInt(height.Value * aspect)), MakeEven(height.Value));
546
+ maxEdge = Mathf.Clamp(maxEdge, 64, 4096);
547
+ return aspect >= 1f
548
+ ? (MakeEven(maxEdge), MakeEven(Mathf.RoundToInt(maxEdge / aspect)))
549
+ : (MakeEven(Mathf.RoundToInt(maxEdge * aspect)), MakeEven(maxEdge));
550
+ }
551
+
552
+ private static Rect ContainRect(float sourceAspect, float outputAspect)
553
+ {
554
+ if (Mathf.Approximately(sourceAspect, outputAspect)) return new Rect(0f, 0f, 1f, 1f);
555
+ if (sourceAspect > outputAspect)
556
+ {
557
+ var height = outputAspect / sourceAspect;
558
+ return new Rect(0f, (1f - height) * 0.5f, 1f, height);
559
+ }
560
+ var width = sourceAspect / outputAspect;
561
+ return new Rect((1f - width) * 0.5f, 0f, width, 1f);
562
+ }
563
+
564
+ private static void OnPlayModeStateChanged(PlayModeStateChange state)
565
+ {
566
+ if (s_armed == null) return;
567
+ if (s_armed.Trigger == "play-exit" && state == PlayModeStateChange.EnteredPlayMode)
568
+ {
569
+ s_armed.ObservedPlayMode = true;
570
+ PersistArm();
571
+ }
572
+ if ((s_armed.Trigger == "play-enter" && state == PlayModeStateChange.EnteredPlayMode)
573
+ || (s_armed.Trigger == "play-exit" && s_armed.ObservedPlayMode
574
+ && state == PlayModeStateChange.EnteredEditMode))
575
+ StartArmedRecording();
576
+ }
577
+
578
+ private static void StartArmedRecording()
579
+ {
580
+ if (s_armed == null) return;
581
+ var armed = s_armed;
582
+ ClearArm();
583
+ try { StartRecording(armed.Settings); }
584
+ catch (Exception ex) { s_state = "failed"; s_error = ex.Message; }
585
+ }
586
+
587
+ private static void RestoreArmedRecording()
588
+ {
589
+ var json = SessionState.GetString(ArmedSessionKey, string.Empty);
590
+ if (string.IsNullOrEmpty(json)) return;
591
+ try
592
+ {
593
+ var data = MiniJson.Deserialize(json) as Dictionary<string, object>;
594
+ s_armed = ArmedRecording.Deserialize(data);
595
+ s_state = "armed";
596
+ SubscribeTick();
597
+ if (s_armed.Trigger == "play-enter" && EditorApplication.isPlaying)
598
+ EditorApplication.delayCall += StartArmedRecording;
599
+ else if (s_armed.Trigger == "play-exit" && EditorApplication.isPlaying)
600
+ {
601
+ s_armed.ObservedPlayMode = true;
602
+ PersistArm();
603
+ }
604
+ else if (s_armed.Trigger == "play-exit" && s_armed.ObservedPlayMode
605
+ && !EditorApplication.isPlayingOrWillChangePlaymode)
606
+ EditorApplication.delayCall += StartArmedRecording;
607
+ }
608
+ catch { SessionState.EraseString(ArmedSessionKey); }
609
+ }
610
+
611
+ private static void PersistArm()
612
+ {
613
+ if (s_armed != null)
614
+ SessionState.SetString(ArmedSessionKey, MiniJson.Serialize(s_armed.Serialize()));
615
+ }
616
+
617
+ private static void FailArm(string message)
618
+ {
619
+ ClearArm();
620
+ s_state = "failed";
621
+ s_error = message;
622
+ }
623
+
624
+ private static void ClearArm()
625
+ {
626
+ s_armed = null;
627
+ SessionState.EraseString(ArmedSessionKey);
628
+ if (s_encoder == null) UnsubscribeTick();
629
+ }
630
+
631
+ private static void SubscribeTick()
632
+ {
633
+ EditorApplication.update -= Tick;
634
+ EditorApplication.update += Tick;
635
+ }
636
+
637
+ private static void UnsubscribeTick() => EditorApplication.update -= Tick;
638
+ private static int MakeEven(int value) => Mathf.Max(64, value & ~1);
639
+ private static int ReadInt(Dictionary<string, object> p, string key, int fallback) =>
640
+ p.TryGetValue(key, out var value) && value != null ? Convert.ToInt32(value) : fallback;
641
+ private static int? ReadNullableInt(Dictionary<string, object> p, string key) =>
642
+ p.TryGetValue(key, out var value) && value != null ? Convert.ToInt32(value) : (int?)null;
643
+ private static double ReadDouble(Dictionary<string, object> p, string key, double fallback) =>
644
+ p.TryGetValue(key, out var value) && value != null ? Convert.ToDouble(value) : fallback;
645
+ private static bool ReadBool(Dictionary<string, object> p, string key, bool fallback) =>
646
+ p.TryGetValue(key, out var value) && value is bool boolean ? boolean : fallback;
647
+ private static string ReadString(Dictionary<string, object> p, string key, string fallback) =>
648
+ p.TryGetValue(key, out var value) && value != null ? value.ToString() : fallback;
649
+ private static bool TryString(Dictionary<string, object> p, string key, out string value)
650
+ {
651
+ value = ReadString(p, key, null);
652
+ return !string.IsNullOrWhiteSpace(value);
653
+ }
654
+
655
+ private sealed class RecordingSettings
656
+ {
657
+ public string View;
658
+ public int MaxEdge;
659
+ public int? Width;
660
+ public int? Height;
661
+ public int Fps;
662
+ public string Format;
663
+ public int BitrateKbps;
664
+ public bool Overwrite;
665
+ public string Path;
666
+ public double Duration;
667
+ public double MaxDuration;
668
+ public double Slowdown;
669
+ public float SourceAspect;
670
+
671
+ public Dictionary<string, object> Serialize() => new Dictionary<string, object>
672
+ {
673
+ ["view"] = View, ["maxEdge"] = MaxEdge, ["width"] = Width, ["height"] = Height,
674
+ ["fps"] = Fps, ["format"] = Format, ["bitrateKbps"] = BitrateKbps,
675
+ ["overwrite"] = Overwrite, ["path"] = Path, ["duration"] = Duration,
676
+ ["maxDuration"] = MaxDuration, ["slowdown"] = Slowdown,
677
+ ["playbackFps"] = Slowdown > 0d ? Fps / Slowdown : (double)Fps
678
+ };
679
+ }
680
+
681
+ private sealed class ArmedRecording
682
+ {
683
+ public string Trigger;
684
+ public RecordingSettings Settings;
685
+ public double Deadline;
686
+ public Regex LogRegex;
687
+ public bool ObservedPlayMode;
688
+
689
+ public static ArmedRecording Create(string trigger, RecordingSettings settings, double deadline)
690
+ {
691
+ var armed = new ArmedRecording
692
+ {
693
+ Trigger = trigger,
694
+ Settings = settings,
695
+ Deadline = deadline,
696
+ ObservedPlayMode = trigger == "play-exit" && EditorApplication.isPlaying
697
+ };
698
+ if (trigger.StartsWith("log:", StringComparison.Ordinal))
699
+ armed.LogRegex = new Regex(trigger.Substring(4), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
700
+ TimeSpan.FromMilliseconds(100));
701
+ else if (trigger != "play-enter" && trigger != "play-exit"
702
+ && !trigger.StartsWith("signal:", StringComparison.Ordinal))
703
+ throw new ArgumentException("Trigger must be play-enter, play-exit, log:<regex>, or signal:<name>");
704
+ return armed;
705
+ }
706
+
707
+ public bool MatchesSignal(string name) => Trigger == "signal:" + name;
708
+ public Dictionary<string, object> Serialize() => new Dictionary<string, object>
709
+ {
710
+ ["trigger"] = Trigger, ["deadline"] = Deadline, ["settings"] = Settings.Serialize(),
711
+ ["observedPlayMode"] = ObservedPlayMode
712
+ };
713
+ public static ArmedRecording Deserialize(Dictionary<string, object> data)
714
+ {
715
+ var settingsData = data["settings"] as Dictionary<string, object>;
716
+ var settings = ParseSettings(MiniJson.Serialize(settingsData));
717
+ var armed = Create(data["trigger"].ToString(), settings, Convert.ToDouble(data["deadline"]));
718
+ armed.ObservedPlayMode = data.TryGetValue("observedPlayMode", out var observed)
719
+ && observed is bool value && value;
720
+ return armed;
721
+ }
722
+ }
723
+ }
724
+ }