@mflrevan/ucp 0.6.1 → 0.6.3

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