@mflrevan/ucp 0.6.1 → 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mflrevan/ucp
2
2
 
3
- Version `0.6.1` of the Unity Control Protocol CLI.
3
+ Version `0.6.2` of the Unity Control Protocol CLI.
4
4
 
5
5
  This package installs the `ucp` command, downloads the matching published binary for your platform during `postinstall`, and ships the matching Unity bridge payload inside the npm package.
6
6
 
@@ -1,5 +1,67 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.2] - 2026-08-31
4
+
5
+ ### Added
6
+
7
+ - Added native, scene-object-free `record/start`, `record/stop`, `record/status`, `record/arm`, and
8
+ `record/signal` RPCs for Game and Scene view video capture.
9
+ - Added aspect-preserving longest-edge sizing and explicit width/height canvases with letterboxing,
10
+ even-dimension normalization, configurable FPS/bitrate/duration, H.264/MP4 and VP8/WebM encoding,
11
+ and platform-aware format selection.
12
+ - Added play-enter, play-exit, bounded log-regex, and named-signal triggers. Armed play triggers use
13
+ `SessionState` to survive domain reloads, and `record/stop` also cancels a pending trigger.
14
+ - Added a `slowdown` parameter to `record/start` and `record/arm`. Frames are still captured at the
15
+ requested cadence in real time; only the encoder's declared frame rate is divided by the factor, so
16
+ playback is stretched without duplicating frames or re-encoding. `record/status` reports both
17
+ `slowdown` and the resulting `playbackFps`. Values outside 1-20 are rejected. This raises effective
18
+ temporal resolution for consumers that sample a clip at a fixed low rate rather than playing it.
19
+ - Documented that `record/start --view game` resolves `Camera.main`, not the Game view's composited
20
+ camera stack. Projects rendering through several enabled cameras record only the `MainCamera`-tagged
21
+ one, and camera depth does not change the selection; `--view scene` captures the Scene view camera,
22
+ which is unaffected by gameplay.
23
+
24
+ ### Fixed
25
+
26
+ - Fixed `tests/run` counting an empty root suite as a passed test. When a filter matched nothing,
27
+ the run finished with a childless root whose result was collected as a leaf, so the summary
28
+ reported one passed test named after the project. Suite results are now skipped, so a run that
29
+ executed nothing reports a total of zero.
30
+ - Changed `tests/run` filtering from `Filter.testNames` to `Filter.groupNames`. `testNames` requires
31
+ an exact fully-qualified match, which silently selected nothing for a class or method name;
32
+ `groupNames` is matched as a regular expression against each test's full name, so partial names
33
+ select what a caller expects and fully-qualified names still match.
34
+ - Fixed `object/get-property` double-converting values that `SerializedPropertyToValue` had already
35
+ shaped for JSON. `GetPropertyValue` resolves a name through `SerializedObject.FindProperty` first
36
+ and returns a ready `List`/`Dictionary`; `ConvertToJson` then ran over that result, matched none
37
+ of its cases, and fell through to `value.ToString()`. A `Vector3` field came back as
38
+ ``"System.Collections.Generic.List`1[System.Object]"`` with
39
+ ``"type": "List`1"``. Every serialized Vector2/3/4, Quaternion, Color, Rect, Bounds and
40
+ object-reference field was affected, and float fields reported a `Double` type name.
41
+ `get-property` now takes its type name from the `SerializedProperty` so it agrees with
42
+ `get-fields`, converts only values read through the reflection fallback, and `ConvertToJson` is
43
+ idempotent for already-shaped `IList`/`IDictionary` values.
44
+
45
+ ### Performance
46
+
47
+ - Frames render through a hidden reusable `RenderTexture` and reusable CPU readback texture into
48
+ Unity's native `MediaEncoder`; recording creates no scene objects or scripts and avoids per-frame
49
+ managed texture allocation.
50
+
51
+ ### Reliability
52
+
53
+ - Added sibling `.partial` output and final rename, explicit overwrite handling, extension/format
54
+ validation, encoder and temporary-file cleanup on failure, detached safety deadlines, trigger wait
55
+ deadlines, dropped-frame accounting, and structured completed/failed status metadata.
56
+ - Active encoders finalize during assembly reload or editor shutdown. Play-boundary capture is
57
+ handled by the persisted `play-enter`/`play-exit` arm flow rather than attempting to retain a
58
+ native encoder across a domain reload.
59
+
60
+ ### Tests
61
+
62
+ - Added editor smoke tests for recording RPC registration and idle status, aspect-preserving even
63
+ dimensions, signal matching, and cancellation of armed recordings.
64
+
3
65
  ## [0.4.1] - 2026-03-21
4
66
 
5
67
  ### Added
@@ -26,7 +26,7 @@ namespace UCP.Bridge
26
26
  private const int DefaultPort = 21342;
27
27
  private const int MaxPort = 21352;
28
28
  private const int MaxConnections = 4;
29
- private const string ProtocolVersion = "0.6.1";
29
+ private const string ProtocolVersion = "0.6.2";
30
30
 
31
31
  private static TcpListener s_listener;
32
32
  private static CancellationTokenSource s_cts;
@@ -142,6 +142,9 @@ namespace UCP.Bridge
142
142
  // Screenshots
143
143
  ScreenshotController.Register(s_router);
144
144
 
145
+ // Lightweight video recording
146
+ RecordingController.Register(s_router);
147
+
145
148
  // Logs
146
149
  LogsController.Register(s_router);
147
150
 
@@ -509,6 +512,7 @@ namespace UCP.Bridge
509
512
 
510
513
  private static void OnLogMessage(string message, string stackTrace, LogType type)
511
514
  {
515
+ RecordingController.NotifyLog(message);
512
516
  // Don't forward our own log messages to avoid infinite recursion
513
517
  if (message.StartsWith("[UCP]")) return;
514
518
 
@@ -575,6 +579,8 @@ namespace UCP.Bridge
575
579
 
576
580
  Debug.Log("[UCP] Bridge server shutting down");
577
581
 
582
+ RecordingController.Shutdown();
583
+
578
584
  s_cts?.Cancel();
579
585
 
580
586
  // Stop listener first to release port immediately
@@ -77,14 +77,14 @@ namespace UCP.Bridge
77
77
  var comp = FindComponent(go, cObj.ToString());
78
78
  string propName = propObj.ToString();
79
79
 
80
- var value = GetPropertyValue(comp, propName);
80
+ var value = GetPropertyValue(comp, propName, out var typeName);
81
81
  return new Dictionary<string, object>
82
82
  {
83
83
  ["instanceId"] = instanceId,
84
84
  ["component"] = cObj.ToString(),
85
85
  ["property"] = propName,
86
- ["value"] = ConvertToJson(value),
87
- ["type"] = value != null ? value.GetType().Name : "null"
86
+ ["value"] = value,
87
+ ["type"] = typeName
88
88
  };
89
89
  }
90
90
 
@@ -277,7 +277,7 @@ namespace UCP.Bridge
277
277
  }
278
278
  }
279
279
 
280
- private static object GetPropertyValue(Component comp, string propertyName)
280
+ private static object GetPropertyValue(Component comp, string propertyName, out string typeName)
281
281
  {
282
282
  var so = new SerializedObject(comp);
283
283
  try
@@ -285,22 +285,35 @@ namespace UCP.Bridge
285
285
  so.Update();
286
286
  var prop = so.FindProperty(propertyName);
287
287
  if (prop != null)
288
+ {
289
+ // Already JSON-shaped -- reporting its type from the SerializedProperty keeps
290
+ // `get-property` and `get-fields` describing the same field the same way.
291
+ typeName = prop.propertyType.ToString();
288
292
  return SerializedPropertyToValue(prop);
293
+ }
289
294
  }
290
295
  finally
291
296
  {
292
297
  so.Dispose();
293
298
  }
294
299
 
295
- // Fallback to reflection
300
+ // Fallback to reflection for names Unity does not serialize (e.g. Transform.position).
296
301
  var type = comp.GetType();
297
302
  var fi = type.GetField(propertyName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
298
303
  if (fi != null)
299
- return fi.GetValue(comp);
304
+ {
305
+ var raw = fi.GetValue(comp);
306
+ typeName = raw != null ? raw.GetType().Name : fi.FieldType.Name;
307
+ return ConvertToJson(raw);
308
+ }
300
309
 
301
310
  var pi = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
302
311
  if (pi != null && pi.CanRead)
303
- return pi.GetValue(comp);
312
+ {
313
+ var raw = pi.GetValue(comp);
314
+ typeName = raw != null ? raw.GetType().Name : pi.PropertyType.Name;
315
+ return ConvertToJson(raw);
316
+ }
304
317
 
305
318
  throw new ArgumentException($"Property '{propertyName}' not found on {type.Name}");
306
319
  }
@@ -488,6 +501,9 @@ namespace UCP.Bridge
488
501
  return new List<object> { (double)c.r, (double)c.g, (double)c.b, (double)c.a };
489
502
  if (value is UnityEngine.Object uObj)
490
503
  return ObjectReferenceResolver.Serialize(uObj);
504
+ // Values that are already JSON-shaped pass through untouched.
505
+ if (value is IList || value is IDictionary)
506
+ return value;
491
507
  return value.ToString();
492
508
  }
493
509
 
@@ -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
+ }
@@ -0,0 +1,11 @@
1
+ fileFormatVersion: 2
2
+ guid: 7b8f17a452e84a2eb71a124ba55cf321
3
+ MonoImporter:
4
+ externalObjects: {}
5
+ serializedVersion: 2
6
+ defaultReferences: []
7
+ executionOrder: 0
8
+ icon: {instanceID: 0}
9
+ userData:
10
+ assetBundleName:
11
+ assetBundleVariant:
@@ -54,7 +54,10 @@ namespace UCP.Bridge
54
54
 
55
55
  if (!string.IsNullOrEmpty(filter))
56
56
  {
57
- executionSettings.filters[0].testNames = new[] { filter };
57
+ // groupNames is regex-matched against each test's full name, so a bare class or
58
+ // method name selects what a caller expects. testNames requires an exact
59
+ // fully-qualified match, which silently selected nothing for "ControllerSmokeTests".
60
+ executionSettings.filters[0].groupNames = new[] { filter };
58
61
  }
59
62
 
60
63
  var shouldWaitForPlayModeExit =
@@ -208,6 +211,12 @@ namespace UCP.Bridge
208
211
  return;
209
212
  }
210
213
 
214
+ // A filter that matches nothing still yields a root suite -- childless, and named
215
+ // after the project. Counting it as a leaf reported "1 passed" for a run that
216
+ // executed no tests, so a typo'd filter looked like a green suite.
217
+ if (result.Test != null && result.Test.IsSuite)
218
+ return;
219
+
211
220
  string status;
212
221
  switch (result.TestStatus)
213
222
  {
@@ -46,6 +46,7 @@ namespace UCP.Bridge.Tests
46
46
  PlayModeController.Register(_router);
47
47
  ReferenceController.Register(_router);
48
48
  LogsController.Register(_router);
49
+ RecordingController.Register(_router);
49
50
  HierarchyController.Register(_router);
50
51
  ProfilerController.Register(_router);
51
52
  PropertyController.Register(_router);
@@ -72,6 +73,7 @@ namespace UCP.Bridge.Tests
72
73
  DeleteTempLocalPackage();
73
74
  RemoveTempLocalPackageDependencyIfPresent();
74
75
  LogsController.ClearHistoryForTests();
76
+ RecordingController.ResetForTests();
75
77
  AssetImportSupport.ClearTestState();
76
78
  Profiler.enabled = false;
77
79
  Profiler.enableBinaryLog = false;
@@ -97,6 +99,7 @@ namespace UCP.Bridge.Tests
97
99
  DeleteTempLocalPackage();
98
100
  RemoveTempLocalPackageDependencyIfPresent();
99
101
  LogsController.ClearHistoryForTests();
102
+ RecordingController.ResetForTests();
100
103
  AssetImportSupport.ClearTestState();
101
104
  Profiler.enabled = false;
102
105
  Profiler.enableBinaryLog = false;
@@ -123,6 +126,115 @@ namespace UCP.Bridge.Tests
123
126
  Assert.That(Convert.ToBoolean(capabilities["sessionControl"]), Is.True);
124
127
  }
125
128
 
129
+ [Test]
130
+ public void RecordingController_RegistersLifecycleMethodsAndReportsIdle()
131
+ {
132
+ Assert.That(_router.HasMethod("record/start"), Is.True);
133
+ Assert.That(_router.HasMethod("record/stop"), Is.True);
134
+ Assert.That(_router.HasMethod("record/status"), Is.True);
135
+ Assert.That(_router.HasMethod("record/arm"), Is.True);
136
+ Assert.That(_router.HasMethod("record/signal"), Is.True);
137
+
138
+ var response = _router.Dispatch("record/status", 1, "{}");
139
+ Assert.That(response.error, Is.Null);
140
+ var result = (Dictionary<string, object>)response.result;
141
+ Assert.That(result["state"], Is.EqualTo("idle"));
142
+ }
143
+
144
+ [Test]
145
+ public void RecordingController_ResolvesEvenAspectPreservingDimensions()
146
+ {
147
+ Assert.That(RecordingController.ResolveDimensionsForTests(16f / 9f, 960, null, null),
148
+ Is.EqualTo((960, 540)));
149
+ Assert.That(RecordingController.ResolveDimensionsForTests(9f / 16f, 960, null, null),
150
+ Is.EqualTo((540, 960)));
151
+ Assert.That(RecordingController.ResolveDimensionsForTests(4f / 3f, 960, 640, null),
152
+ Is.EqualTo((640, 480)));
153
+ Assert.That(RecordingController.ResolveDimensionsForTests(4f / 3f, 960, null, 600),
154
+ Is.EqualTo((800, 600)));
155
+ }
156
+
157
+ [Test]
158
+ public void RecordingController_ArmsAndMatchesNamedSignal()
159
+ {
160
+ var response = _router.Dispatch(
161
+ "record/arm",
162
+ 1,
163
+ "{\"trigger\":\"signal:not-this-one\",\"duration\":1,\"timeout\":5}");
164
+ Assert.That(response.error, Is.Null);
165
+
166
+ var signal = _router.Dispatch("record/signal", 2, "{\"name\":\"different\"}");
167
+ Assert.That(signal.error, Is.Null);
168
+ var result = (Dictionary<string, object>)signal.result;
169
+ Assert.That(Convert.ToBoolean(result["matched"]), Is.False);
170
+ Assert.That(result["state"], Is.EqualTo("armed"));
171
+
172
+ var stop = _router.Dispatch("record/stop", 3, "{}");
173
+ Assert.That(stop.error, Is.Null);
174
+ var stopped = (Dictionary<string, object>)stop.result;
175
+ Assert.That(stopped["state"], Is.EqualTo("idle"));
176
+ }
177
+
178
+ [Test]
179
+ public void GetProperty_ReturnsCompositeValuesRatherThanTypeNames()
180
+ {
181
+ // Regression: when SerializedObject.FindProperty resolves a name, the value it returns
182
+ // is already JSON-shaped. That result used to be converted a second time, match no
183
+ // case, and fall through to value.ToString() -- so a Vector3 arrived as
184
+ // "System.Collections.Generic.List`1[System.Object]". Querying the serialized name
185
+ // (m_LocalPosition) is what forces the FindProperty path; the public alias (position)
186
+ // has no serialized entry and takes the reflection fallback instead, which is why that
187
+ // one never looked broken.
188
+ var go = new GameObject("UcpPropertyProbe");
189
+ try
190
+ {
191
+ go.transform.localPosition = new Vector3(1.5f, 2.5f, 3.5f);
192
+ var id = go.GetInstanceID();
193
+
194
+ var serialized = _router.Dispatch("object/get-property", 1, "{\"instanceId\":ID,\"component\":\"Transform\",\"property\":\"m_LocalPosition\"}".Replace("ID", id.ToString()));
195
+ Assert.That(serialized.error, Is.Null);
196
+ var serializedResult = (Dictionary<string, object>)serialized.result;
197
+ var localPosition = serializedResult["value"] as IList;
198
+ Assert.That(localPosition, Is.Not.Null,
199
+ "a serialized Vector3 must come back as an array, not a stringified type name");
200
+ Assert.That(localPosition.Count, Is.EqualTo(3));
201
+ Assert.That(Convert.ToDouble(localPosition[0]), Is.EqualTo(1.5d).Within(1e-4));
202
+ Assert.That(Convert.ToDouble(localPosition[2]), Is.EqualTo(3.5d).Within(1e-4));
203
+ Assert.That(serializedResult["type"], Is.EqualTo("Vector3"),
204
+ "type must describe the field, not the container it was shaped into");
205
+
206
+ // Reflection fallback: no serialized entry named "position".
207
+ var reflected = _router.Dispatch("object/get-property", 2, "{\"instanceId\":ID,\"component\":\"Transform\",\"property\":\"position\"}".Replace("ID", id.ToString()));
208
+ Assert.That(reflected.error, Is.Null);
209
+ var reflectedResult = (Dictionary<string, object>)reflected.result;
210
+ Assert.That(reflectedResult["value"] as IList, Is.Not.Null);
211
+ Assert.That(reflectedResult["type"], Is.EqualTo("Vector3"));
212
+ }
213
+ finally
214
+ {
215
+ UnityEngine.Object.DestroyImmediate(go);
216
+ }
217
+ }
218
+
219
+ [Test]
220
+ public void RecordingController_ValidatesSlowdownRange()
221
+ {
222
+ // --slowdown exists to raise effective temporal resolution for models that sample a
223
+ // clip at a fixed low rate, so an out-of-range factor must fail loudly rather than
224
+ // silently producing a file whose playback rate is meaningless.
225
+ var tooLarge = _router.Dispatch("record/arm", 1, "{\"trigger\":\"signal:sd\",\"duration\":1,\"timeout\":5,\"slowdown\":40}");
226
+ Assert.That(tooLarge.error, Is.Not.Null, "a slowdown above the supported range must be rejected");
227
+
228
+ var accepted = _router.Dispatch("record/arm", 2, "{\"trigger\":\"signal:sd\",\"duration\":1,\"timeout\":5,\"slowdown\":6}");
229
+ Assert.That(accepted.error, Is.Null);
230
+ var armed = (Dictionary<string, object>)accepted.result;
231
+ Assert.That(armed["state"], Is.EqualTo("armed"));
232
+
233
+ var stop = _router.Dispatch("record/stop", 3, "{}");
234
+ Assert.That(stop.error, Is.Null);
235
+ Assert.That(((Dictionary<string, object>)stop.result)["state"], Is.EqualTo("idle"));
236
+ }
237
+
126
238
  [Test]
127
239
  public void ProfilerSessionStartStop_TogglesProfilerState()
128
240
  {
@@ -1647,4 +1759,5 @@ namespace UCP.Bridge.Tests
1647
1759
  public SearchRootAsset referenceAsset;
1648
1760
  }
1649
1761
  }
1762
+
1650
1763
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "com.ucp.bridge",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "displayName": "Unity Control Protocol Bridge",
5
5
  "description": "WebSocket bridge for programmatic Unity Editor control via CLI and AI agents.",
6
6
  "unity": "2021.3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mflrevan/ucp",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Unity Control Protocol - CLI for programmatic Unity Editor control",
5
5
  "license": "MIT",
6
6
  "repository": {