@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 +1 -1
- package/bridge/com.ucp.bridge/CHANGELOG.md +62 -0
- package/bridge/com.ucp.bridge/Editor/Bridge/BridgeServer.cs +7 -1
- package/bridge/com.ucp.bridge/Editor/Controllers/PropertyController.cs +23 -7
- package/bridge/com.ucp.bridge/Editor/Controllers/RecordingController.cs +724 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/RecordingController.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/TestRunnerController.cs +10 -1
- package/bridge/com.ucp.bridge/Tests/Editor/ControllerSmokeTests.cs +113 -0
- package/bridge/com.ucp.bridge/package.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @mflrevan/ucp
|
|
2
2
|
|
|
3
|
-
Version `0.6.
|
|
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.
|
|
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"] =
|
|
87
|
-
["type"] =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -54,7 +54,10 @@ namespace UCP.Bridge
|
|
|
54
54
|
|
|
55
55
|
if (!string.IsNullOrEmpty(filter))
|
|
56
56
|
{
|
|
57
|
-
|
|
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
|
}
|