@mflrevan/ucp 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bridge/com.ucp.bridge/CHANGELOG.md +62 -0
- package/bridge/com.ucp.bridge/Editor/Bridge/BridgeServer.cs +42 -5
- package/bridge/com.ucp.bridge/Editor/Controllers/CompilationController.cs +137 -1
- package/bridge/com.ucp.bridge/Editor/Controllers/ProfilerController.cs +80 -5
- 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/SceneController.cs +24 -2
- package/bridge/com.ucp.bridge/Editor/Controllers/ScriptController.cs +82 -23
- package/bridge/com.ucp.bridge/Editor/Controllers/TestRunnerController.cs +10 -1
- package/bridge/com.ucp.bridge/Editor/Protocol/MiniJson.cs +409 -63
- package/bridge/com.ucp.bridge/Tests/Editor/ControllerSmokeTests.cs +181 -6
- package/bridge/com.ucp.bridge/Tests/Editor/MiniJsonSerializerTests.cs +221 -0
- package/bridge/com.ucp.bridge/Tests/Editor/MiniJsonSerializerTests.cs.meta +2 -0
- package/bridge/com.ucp.bridge/package.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|