@ksp-gonogo/kerbcast 1.7.0 → 1.8.0
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/CHANGELOG.md +39 -0
- package/dist/__generated__/types.d.ts +23 -0
- package/dist/client.d.ts +56 -0
- package/dist/client.js +43 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/recording.d.ts +426 -0
- package/dist/recording.js +898 -0
- package/dist/testing/index.d.ts +88 -1
- package/dist/testing/index.js +200 -3
- package/package.json +4 -1
- package/src/__generated__/types.ts +21 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +74 -0
- package/src/index.ts +22 -0
- package/src/recording.test.ts +1471 -0
- package/src/recording.ts +1233 -0
- package/src/testing/index.ts +239 -1
- package/src/testing.test.ts +147 -1
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Client-side camera recording.
|
|
3
|
+
*
|
|
4
|
+
* Records an already-received WebRTC camera track with the browser's
|
|
5
|
+
* MediaRecorder, annotating the clip with the mission-time (UT) clock so a
|
|
6
|
+
* consumer can align frames to telemetry. Pure client-side: no sidecar,
|
|
7
|
+
* plugin, protocol, or network involvement. The sidecar never learns a
|
|
8
|
+
* recording happened.
|
|
9
|
+
*
|
|
10
|
+
* The value-add over a bare MediaRecorder is the UT track: `utStart` /
|
|
11
|
+
* `utEnd` bracket the clip, and `utSamples` is a ~1Hz series of
|
|
12
|
+
* (recording-time, UT) points sampled from the client's capture clock, so a
|
|
13
|
+
* consumer can interpolate any frame's UT near-frame-tight. Out of flight the
|
|
14
|
+
* clock is unknown, so samples fall back to wall-clock timing (`ut` absent)
|
|
15
|
+
* and upgrade automatically once UT appears.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Bound on the single-recording arm-and-wait: how long
|
|
19
|
+
* {@link RecordingController.startRecording} waits for a forced feed to
|
|
20
|
+
* reach full resolution before starting the recorder anyway. Never hangs a
|
|
21
|
+
* start indefinitely (feed out of flight, wedged, or the sidecar/plugin
|
|
22
|
+
* round trip taking longer than expected).
|
|
23
|
+
*/
|
|
24
|
+
export const ARM_TIMEOUT_MS = 3000;
|
|
25
|
+
/** Default {@link StopGroupedRecordingOptions.coverageTimeoutMs}. */
|
|
26
|
+
export const DEFAULT_COVERAGE_TIMEOUT_MS = 10000;
|
|
27
|
+
/** Preference order for mime negotiation, after any caller-supplied choice. */
|
|
28
|
+
const MIME_CANDIDATES = [
|
|
29
|
+
"video/mp4;codecs=avc1",
|
|
30
|
+
"video/webm;codecs=vp9",
|
|
31
|
+
"video/webm",
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* Pick a MediaRecorder mime type. Tries `preferred` first (when given), then
|
|
35
|
+
* the standard preference order, using `MediaRecorder.isTypeSupported`. Falls
|
|
36
|
+
* back to `video/webm` when nothing reports support (or the API is absent).
|
|
37
|
+
*/
|
|
38
|
+
export function negotiateMimeType(preferred) {
|
|
39
|
+
const ctor = globalThis.MediaRecorder;
|
|
40
|
+
const candidates = preferred ? [preferred, ...MIME_CANDIDATES] : MIME_CANDIDATES;
|
|
41
|
+
if (ctor && typeof ctor.isTypeSupported === "function") {
|
|
42
|
+
for (const candidate of candidates) {
|
|
43
|
+
if (ctor.isTypeSupported(candidate))
|
|
44
|
+
return candidate;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return "video/webm";
|
|
48
|
+
}
|
|
49
|
+
/** Monotonic clock for recording-time. Falls back to Date.now under jsdom. */
|
|
50
|
+
function now() {
|
|
51
|
+
return typeof performance !== "undefined" && typeof performance.now === "function"
|
|
52
|
+
? performance.now()
|
|
53
|
+
: Date.now();
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Intersection of the feeds' UT ranges: [max(utStart), min(utEnd)]. Returns
|
|
57
|
+
* undefined if any clip lacks a full range or the spans don't overlap.
|
|
58
|
+
*/
|
|
59
|
+
export function commonUtWindow(recordings) {
|
|
60
|
+
if (recordings.length === 0)
|
|
61
|
+
return undefined;
|
|
62
|
+
let lo = -Infinity;
|
|
63
|
+
let hi = Infinity;
|
|
64
|
+
for (const rec of recordings) {
|
|
65
|
+
if (rec.utStart == null || rec.utEnd == null)
|
|
66
|
+
return undefined;
|
|
67
|
+
lo = Math.max(lo, rec.utStart);
|
|
68
|
+
hi = Math.min(hi, rec.utEnd);
|
|
69
|
+
}
|
|
70
|
+
return lo <= hi ? [lo, hi] : undefined;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Recording-time (ms since clip start) for a mission UT, linearly interpolated
|
|
74
|
+
* off a clip's `utSamples`. UT is linear in recording time at constant
|
|
75
|
+
* time-warp (the launch/landing/docking moments a group captures), so this maps
|
|
76
|
+
* the common UT window onto each clip's own timeline for a keyframe trim.
|
|
77
|
+
* Clamps to the sampled span; returns null when the clip has no UT samples.
|
|
78
|
+
*/
|
|
79
|
+
export function utToRecordingTimeMs(samples, ut) {
|
|
80
|
+
const pts = samples.filter((s) => s.ut != null);
|
|
81
|
+
if (pts.length === 0)
|
|
82
|
+
return null;
|
|
83
|
+
if (ut <= pts[0].ut)
|
|
84
|
+
return pts[0].t;
|
|
85
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
86
|
+
const a = pts[i];
|
|
87
|
+
const b = pts[i + 1];
|
|
88
|
+
if (ut >= a.ut && ut <= b.ut) {
|
|
89
|
+
if (b.ut === a.ut)
|
|
90
|
+
return a.t;
|
|
91
|
+
const frac = (ut - a.ut) / (b.ut - a.ut);
|
|
92
|
+
return a.t + frac * (b.t - a.t);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return pts[pts.length - 1].t;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Builds a {@link GroupTrimmer} from an already-resolved Mediabunny module
|
|
99
|
+
* namespace: keyframe-boundary remux via its Conversion API, no re-encode.
|
|
100
|
+
*/
|
|
101
|
+
function mediabunnyTrimmer(mb) {
|
|
102
|
+
return {
|
|
103
|
+
async trim(blob, mimeType, startMs, endMs) {
|
|
104
|
+
const input = new mb.Input({
|
|
105
|
+
source: new mb.BlobSource(blob),
|
|
106
|
+
formats: mb.ALL_FORMATS,
|
|
107
|
+
});
|
|
108
|
+
const target = new mb.BufferTarget();
|
|
109
|
+
const output = new mb.Output({ format: new mb.Mp4OutputFormat(), target });
|
|
110
|
+
const conversion = await mb.Conversion.init({
|
|
111
|
+
input,
|
|
112
|
+
output,
|
|
113
|
+
trim: { start: startMs / 1000, end: endMs / 1000 },
|
|
114
|
+
});
|
|
115
|
+
await conversion.execute();
|
|
116
|
+
if (!target.buffer)
|
|
117
|
+
throw new Error("mediabunny produced no output buffer");
|
|
118
|
+
return new Blob([target.buffer], { type: mimeType });
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Builds a {@link TrimmerLoader} around a caller-supplied module import.
|
|
124
|
+
* `loadMediabunnyTrimmer` (the default, below) resolves the bare
|
|
125
|
+
* `import("mediabunny")` specifier for bundler consumers (e.g. gonogo). A
|
|
126
|
+
* consumer that can't rely on that resolution -- the sidecar's embedded web
|
|
127
|
+
* page, which serves its own copy of the package locally for an offline/LAN
|
|
128
|
+
* install with no CDN -- supplies a different `importModule` (e.g. a dynamic
|
|
129
|
+
* import of a served URL) and passes the result as
|
|
130
|
+
* {@link RecordingControllerOptions.loadTrimmer}. Resolves null on any
|
|
131
|
+
* failure, so a group degrades to metadata-only rather than failing.
|
|
132
|
+
*/
|
|
133
|
+
export function createMediabunnyTrimmerLoader(importModule) {
|
|
134
|
+
return async () => {
|
|
135
|
+
try {
|
|
136
|
+
return mediabunnyTrimmer((await importModule()));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Default trimmer: dynamically imports Mediabunny by its package name and
|
|
145
|
+
* remuxes with its Conversion API. Loaded on group-start so it's ready by
|
|
146
|
+
* stop.
|
|
147
|
+
*/
|
|
148
|
+
const loadMediabunnyTrimmer = createMediabunnyTrimmerLoader(() => import("mediabunny"));
|
|
149
|
+
let idCounter = 0;
|
|
150
|
+
function mintId(prefix) {
|
|
151
|
+
idCounter += 1;
|
|
152
|
+
return `${prefix}_${Date.now().toString(36)}_${idCounter.toString(36)}`;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Owns every in-flight and finished recording for one client. Reached via
|
|
156
|
+
* `client.recording`. All state is in-memory: a page reload discards it.
|
|
157
|
+
*
|
|
158
|
+
* Observable: {@link subscribe} + {@link getSnapshot} let every call site
|
|
159
|
+
* (a tile's own REC control, a RecordingsTray mounted elsewhere) read the same
|
|
160
|
+
* state, so a clip started or finished through one is visible to all the
|
|
161
|
+
* others -- no per-instance state to fall out of sync.
|
|
162
|
+
*/
|
|
163
|
+
export class RecordingController {
|
|
164
|
+
constructor(client, options) {
|
|
165
|
+
this.active = new Map();
|
|
166
|
+
this.finished = new Map();
|
|
167
|
+
/** flightId -> recordingId, so a second start on a busy feed is rejected. */
|
|
168
|
+
this.byFlight = new Map();
|
|
169
|
+
this.groups = new Map();
|
|
170
|
+
this.finishedGroups = new Map();
|
|
171
|
+
/** Per-group trimmer load kicked off at group-start, awaited at stop. */
|
|
172
|
+
this.groupTrimmers = new Map();
|
|
173
|
+
/** recordingIds in a grouped set, so they stay out of the standalone `recordings` list. */
|
|
174
|
+
this.groupMemberIds = new Set();
|
|
175
|
+
/** recordingId -> groupId, so discarding one member can pull it out of its group's array. */
|
|
176
|
+
this.memberOfGroup = new Map();
|
|
177
|
+
/**
|
|
178
|
+
* Per-feed force ref-count. The sidecar OR-aggregates force across
|
|
179
|
+
* viewers already; this is the local mirror so two overlapping
|
|
180
|
+
* recordings of the same feed (in this one controller) release force
|
|
181
|
+
* only when the last of them stops.
|
|
182
|
+
*/
|
|
183
|
+
this.forceCounts = new Map();
|
|
184
|
+
/**
|
|
185
|
+
* Per-feed force-true send status: "pending" while a send is in flight,
|
|
186
|
+
* "sent" once it has actually succeeded. Absent means neither: either
|
|
187
|
+
* never attempted, or a prior attempt failed. Distinct from
|
|
188
|
+
* {@link forceCounts} so a failed send never masquerades as a completed
|
|
189
|
+
* one; see {@link acquireForce}.
|
|
190
|
+
*/
|
|
191
|
+
this.forceSendState = new Map();
|
|
192
|
+
this.listeners = new Set();
|
|
193
|
+
this.snapshot = { recordings: [], groups: [], active: [] };
|
|
194
|
+
/** While true, {@link rebuild} is a no-op; used to batch startGroupedRecording's
|
|
195
|
+
* per-member starts into a single notification once the group is fully set up. */
|
|
196
|
+
this.suppressRebuild = false;
|
|
197
|
+
/** Subscribe to snapshot changes. Returns an unsubscribe function. */
|
|
198
|
+
this.subscribe = (fn) => {
|
|
199
|
+
this.listeners.add(fn);
|
|
200
|
+
return () => {
|
|
201
|
+
this.listeners.delete(fn);
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
/** The current snapshot; the same object reference until the next mutation. */
|
|
205
|
+
this.getSnapshot = () => this.snapshot;
|
|
206
|
+
this.client = client;
|
|
207
|
+
this.loadTrimmer = options?.loadTrimmer ?? loadMediabunnyTrimmer;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Recompute the snapshot from current state and notify subscribers. Called
|
|
211
|
+
* at the end of every state-mutating operation, including the async settle
|
|
212
|
+
* points (recorder stop, grouped stop) where the data actually changes.
|
|
213
|
+
*/
|
|
214
|
+
rebuild() {
|
|
215
|
+
if (this.suppressRebuild)
|
|
216
|
+
return;
|
|
217
|
+
const recordings = [...this.finished.values()].filter((r) => !this.groupMemberIds.has(r.recordingId));
|
|
218
|
+
const groups = [...this.finishedGroups.values()];
|
|
219
|
+
const active = [...this.active.values()].map((rec) => {
|
|
220
|
+
let groupId;
|
|
221
|
+
for (const [gid, ids] of this.groups) {
|
|
222
|
+
if (ids.includes(rec.recordingId)) {
|
|
223
|
+
groupId = gid;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
recordingId: rec.recordingId,
|
|
229
|
+
flightId: rec.flightId,
|
|
230
|
+
startedAt: rec.startedAt,
|
|
231
|
+
groupId,
|
|
232
|
+
arming: rec.arming ?? false,
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
this.snapshot = { recordings, groups, active };
|
|
236
|
+
for (const l of this.listeners)
|
|
237
|
+
l();
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Acquire the local force ref-count for a feed; sends
|
|
241
|
+
* `setForceFullResolution(true)` unless one is already in flight or has
|
|
242
|
+
* already succeeded for this feed (tracked in {@link forceSendState}, not
|
|
243
|
+
* just the ref-count reaching 1). The sidecar OR-aggregates force across
|
|
244
|
+
* viewers already; this ref-count is the belt-and-braces local mirror for
|
|
245
|
+
* two overlapping recordings of the same feed in this one controller.
|
|
246
|
+
*
|
|
247
|
+
* A send failure clears `forceSendState` instead of leaving it "sent", so
|
|
248
|
+
* it never masquerades as done: a later acquire (e.g. a second overlapping
|
|
249
|
+
* recording's 1->2 transition) sees the feed as not-yet-forced and retries
|
|
250
|
+
* the send, rather than silently leaving the feed un-forced forever.
|
|
251
|
+
*/
|
|
252
|
+
acquireForce(flightId) {
|
|
253
|
+
const count = (this.forceCounts.get(flightId) ?? 0) + 1;
|
|
254
|
+
this.forceCounts.set(flightId, count);
|
|
255
|
+
if (this.forceSendState.has(flightId))
|
|
256
|
+
return;
|
|
257
|
+
this.forceSendState.set(flightId, "pending");
|
|
258
|
+
void this.client
|
|
259
|
+
.camera(flightId)
|
|
260
|
+
.setForceFullResolution(true)
|
|
261
|
+
.then(() => {
|
|
262
|
+
this.forceSendState.set(flightId, "sent");
|
|
263
|
+
})
|
|
264
|
+
.catch((err) => {
|
|
265
|
+
this.forceSendState.delete(flightId);
|
|
266
|
+
console.error(`kerbcast: setForceFullResolution(true) failed for feed ${flightId}; will retry on next acquire`, err);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Release one holder of a feed's force ref-count; sends
|
|
271
|
+
* `setForceFullResolution(false)` once the count reaches zero. Never goes
|
|
272
|
+
* below zero (a stray extra release is a no-op, not an underflow). Clears
|
|
273
|
+
* `forceSendState` too, so a later, unrelated recording of this feed
|
|
274
|
+
* starts its own send from a clean slate.
|
|
275
|
+
*/
|
|
276
|
+
releaseForce(flightId) {
|
|
277
|
+
const count = (this.forceCounts.get(flightId) ?? 0) - 1;
|
|
278
|
+
if (count <= 0) {
|
|
279
|
+
this.forceCounts.delete(flightId);
|
|
280
|
+
this.forceSendState.delete(flightId);
|
|
281
|
+
void this.client
|
|
282
|
+
.camera(flightId)
|
|
283
|
+
.setForceFullResolution(false)
|
|
284
|
+
.catch(() => { });
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
this.forceCounts.set(flightId, count);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Whether `state` shows a feed actually rendering at its full target size,
|
|
292
|
+
* as opposed to merely having caught up to whatever small size it already
|
|
293
|
+
* had. Two ways to know the target:
|
|
294
|
+
*
|
|
295
|
+
* - `target` known (from `maxRenderSize`): ready once the operator size
|
|
296
|
+
* has reached it and the rendered size has caught up to the operator
|
|
297
|
+
* size. Resolves promptly even when the feed was already at its ceiling
|
|
298
|
+
* before the force was sent (nothing ever "increases").
|
|
299
|
+
* - `target` unknown: falls back to requiring the operator size to have
|
|
300
|
+
* increased past `baseline` (the first REAL state observed at or after
|
|
301
|
+
* watch-start; see {@link watchResolutionReady}) and the rendered size
|
|
302
|
+
* to have caught up. `baseline` is `null` until that first real state
|
|
303
|
+
* arrives, and a `null` baseline is never ready: a placeholder `{0,0}`
|
|
304
|
+
* would let any positive operator size look like an increase, so no
|
|
305
|
+
* verdict is possible until a genuine baseline exists. This is a
|
|
306
|
+
* strictly weaker guarantee than the known-target path: a feed already
|
|
307
|
+
* at its true ceiling when forced will not be recognised as ready until
|
|
308
|
+
* something else changes its operator size, or the wait simply times
|
|
309
|
+
* out, but it still refuses to treat the pre-force snapshot itself as
|
|
310
|
+
* ready.
|
|
311
|
+
*
|
|
312
|
+
* Either way, a state whose rendered size has not yet caught up to its own
|
|
313
|
+
* operator size (adaptive shed still catching up) is never ready.
|
|
314
|
+
*/
|
|
315
|
+
isAtFullResolution(state, target, baseline) {
|
|
316
|
+
if (!state)
|
|
317
|
+
return false;
|
|
318
|
+
if (state.renderWidth !== state.operatorWidth || state.renderHeight !== state.operatorHeight) {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
if (target) {
|
|
322
|
+
return state.operatorWidth >= target.width && state.operatorHeight >= target.height;
|
|
323
|
+
}
|
|
324
|
+
if (!baseline)
|
|
325
|
+
return false;
|
|
326
|
+
return state.operatorWidth > baseline.width && state.operatorHeight > baseline.height;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Seeds a fallback baseline from an observed state, or returns `null` if
|
|
330
|
+
* the state isn't a REAL observation (operator size still zero, i.e. no
|
|
331
|
+
* dimensions have ever been reported for this feed). A `null` result
|
|
332
|
+
* means the caller must keep waiting for a state worth basing readiness
|
|
333
|
+
* on, rather than latching onto a `{0,0}` placeholder.
|
|
334
|
+
*/
|
|
335
|
+
seedResolutionBaseline(state) {
|
|
336
|
+
if (!state)
|
|
337
|
+
return null;
|
|
338
|
+
if (state.operatorWidth <= 0 && state.operatorHeight <= 0)
|
|
339
|
+
return null;
|
|
340
|
+
return { width: state.operatorWidth, height: state.operatorHeight };
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Waits for a forced feed to actually render at full resolution.
|
|
344
|
+
* `timeoutMs` omitted means no bound: the wait only ends when the feed
|
|
345
|
+
* becomes ready or the caller cancels it (the grouped path, bounded by the
|
|
346
|
+
* recording's own lifetime instead; see {@link startRecording}).
|
|
347
|
+
* Resolves `true` once ready, `false` on timeout. Returns a `cancel` next
|
|
348
|
+
* to the promise so a caller can abandon the wait (stop/discard) without
|
|
349
|
+
* leaking the underlying "change" subscription or timer; cancelling never
|
|
350
|
+
* settles the promise.
|
|
351
|
+
*/
|
|
352
|
+
watchResolutionReady(flightId, timeoutMs) {
|
|
353
|
+
const cam = this.client.camera(flightId);
|
|
354
|
+
const target = cam.maxRenderSize;
|
|
355
|
+
/* Known-ceiling path never consults baseline; unknown-ceiling path
|
|
356
|
+
starts with no baseline until a real state seeds one, so a null
|
|
357
|
+
cam.state at watch-start (e.g. no discover() yet, since state and
|
|
358
|
+
the track are independent transports with no ordering guarantee)
|
|
359
|
+
can never masquerade as a {0,0} baseline. */
|
|
360
|
+
let baseline = target ? null : this.seedResolutionBaseline(cam.state);
|
|
361
|
+
let settled = false;
|
|
362
|
+
let unsubscribe = () => { };
|
|
363
|
+
let timer;
|
|
364
|
+
const ready = new Promise((resolve) => {
|
|
365
|
+
const finish = (reached) => {
|
|
366
|
+
if (settled)
|
|
367
|
+
return;
|
|
368
|
+
settled = true;
|
|
369
|
+
if (timer != null)
|
|
370
|
+
clearTimeout(timer);
|
|
371
|
+
unsubscribe();
|
|
372
|
+
resolve(reached);
|
|
373
|
+
};
|
|
374
|
+
if (this.isAtFullResolution(cam.state, target, baseline)) {
|
|
375
|
+
finish(true);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
unsubscribe = cam.on("change", (state) => {
|
|
379
|
+
if (!target && !baseline) {
|
|
380
|
+
/* This is the first real state we've ever seen: it becomes the
|
|
381
|
+
baseline, but a state can't ready itself against its own
|
|
382
|
+
just-established baseline, so wait for the NEXT state. */
|
|
383
|
+
baseline = this.seedResolutionBaseline(state);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (this.isAtFullResolution(state, target, baseline))
|
|
387
|
+
finish(true);
|
|
388
|
+
});
|
|
389
|
+
if (timeoutMs != null) {
|
|
390
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
return {
|
|
394
|
+
ready,
|
|
395
|
+
cancel: () => {
|
|
396
|
+
if (settled)
|
|
397
|
+
return;
|
|
398
|
+
settled = true;
|
|
399
|
+
if (timer != null)
|
|
400
|
+
clearTimeout(timer);
|
|
401
|
+
unsubscribe();
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Start recording a feed's live track. Returns a client-minted recordingId
|
|
407
|
+
* synchronously. Throws if the feed has no live track or is already
|
|
408
|
+
* recording.
|
|
409
|
+
*
|
|
410
|
+
* `inGroup` is internal (set by {@link startGroupedRecording}) and governs
|
|
411
|
+
* how `opts.forceFullResolution` behaves: a single recording (`inGroup`
|
|
412
|
+
* false) arms and waits, bounded by {@link ARM_TIMEOUT_MS}, for the feed to
|
|
413
|
+
* reach full resolution before the recorder actually starts, so the clip
|
|
414
|
+
* opens on full-res frames. A grouped member starts immediately and
|
|
415
|
+
* instead records the moment it reaches full resolution
|
|
416
|
+
* (`resolutionReadyAt`/`resolutionReadyUt`) so {@link stopGroupedRecording}
|
|
417
|
+
* can fold it into the common trim window.
|
|
418
|
+
*/
|
|
419
|
+
startRecording(flightId, opts, inGroup = false) {
|
|
420
|
+
if (this.byFlight.has(flightId)) {
|
|
421
|
+
throw new Error(`camera ${flightId} is already recording`);
|
|
422
|
+
}
|
|
423
|
+
const stream = this.client.camera(flightId).mediaStream;
|
|
424
|
+
if (!stream || stream.getVideoTracks().length === 0) {
|
|
425
|
+
throw new Error(`camera ${flightId} has no live track to record`);
|
|
426
|
+
}
|
|
427
|
+
const mimeType = negotiateMimeType(opts?.mimeType);
|
|
428
|
+
const ctor = globalThis.MediaRecorder;
|
|
429
|
+
if (!ctor)
|
|
430
|
+
throw new Error("MediaRecorder is not available in this environment");
|
|
431
|
+
const recorder = new ctor(stream, { mimeType });
|
|
432
|
+
const recordingId = mintId("rec");
|
|
433
|
+
const startedAt = now();
|
|
434
|
+
const utStart = this.client.clock.captureUt ?? undefined;
|
|
435
|
+
const rec = {
|
|
436
|
+
recordingId,
|
|
437
|
+
flightId,
|
|
438
|
+
mimeType,
|
|
439
|
+
recorder,
|
|
440
|
+
chunks: [],
|
|
441
|
+
startedAt,
|
|
442
|
+
utStart,
|
|
443
|
+
utSamples: [{ t: 0, ut: utStart }],
|
|
444
|
+
latestUt: utStart,
|
|
445
|
+
unsubscribeClock: () => { },
|
|
446
|
+
};
|
|
447
|
+
recorder.addEventListener("dataavailable", (e) => {
|
|
448
|
+
if (e.data && e.data.size > 0)
|
|
449
|
+
rec.chunks.push(e.data);
|
|
450
|
+
});
|
|
451
|
+
/* Sample the capture clock on each ~1Hz settings-change push. Duplicates
|
|
452
|
+
(a paused sim) are kept so `t` still advances; ut upgrades from absent
|
|
453
|
+
to present automatically once out-of-flight becomes in-flight. */
|
|
454
|
+
rec.unsubscribeClock = this.client.on("settings-change", () => {
|
|
455
|
+
const ut = this.client.clock.captureUt ?? undefined;
|
|
456
|
+
rec.utSamples.push({ t: now() - rec.startedAt, ut });
|
|
457
|
+
if (ut != null)
|
|
458
|
+
rec.latestUt = ut;
|
|
459
|
+
});
|
|
460
|
+
this.active.set(recordingId, rec);
|
|
461
|
+
this.byFlight.set(flightId, recordingId);
|
|
462
|
+
const forceRequested = opts?.forceFullResolution === true;
|
|
463
|
+
if (forceRequested) {
|
|
464
|
+
rec.forced = true;
|
|
465
|
+
this.acquireForce(flightId);
|
|
466
|
+
}
|
|
467
|
+
if (opts?.maxDurationMs != null) {
|
|
468
|
+
rec.maxDurationTimer = setTimeout(() => {
|
|
469
|
+
void this.stopRecording(recordingId).catch(() => { });
|
|
470
|
+
}, opts.maxDurationMs);
|
|
471
|
+
}
|
|
472
|
+
if (forceRequested && !inGroup) {
|
|
473
|
+
/* Single path: arm-and-wait. Show "arming" immediately; the recorder
|
|
474
|
+
itself doesn't start until the feed reaches full resolution (or the
|
|
475
|
+
bounded wait gives up), so the clip never opens on low-res frames. */
|
|
476
|
+
rec.arming = true;
|
|
477
|
+
const watch = this.watchResolutionReady(flightId, ARM_TIMEOUT_MS);
|
|
478
|
+
rec.cancelResolutionWatch = watch.cancel;
|
|
479
|
+
void watch.ready.then(() => {
|
|
480
|
+
rec.cancelResolutionWatch = undefined;
|
|
481
|
+
/* Discarded while arming: the recorder was never started and this
|
|
482
|
+
id is gone from `active`, so there is nothing left to do. */
|
|
483
|
+
if (!this.active.has(recordingId))
|
|
484
|
+
return;
|
|
485
|
+
rec.arming = false;
|
|
486
|
+
/* The real recording starts now: reset the clock base so the
|
|
487
|
+
elapsed timer and utSamples track begin at actual record start,
|
|
488
|
+
not at arm time. */
|
|
489
|
+
rec.startedAt = now();
|
|
490
|
+
rec.utStart = this.client.clock.captureUt ?? undefined;
|
|
491
|
+
rec.utSamples = [{ t: 0, ut: rec.utStart }];
|
|
492
|
+
rec.latestUt = rec.utStart;
|
|
493
|
+
rec.recorder.start();
|
|
494
|
+
this.rebuild();
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
recorder.start();
|
|
499
|
+
if (forceRequested) {
|
|
500
|
+
/* Grouped path: record immediately. Watch for the moment this feed
|
|
501
|
+
actually reaches full resolution so stopGroupedRecording can fold
|
|
502
|
+
it into the common trim window and cut the low-res lead-in. No
|
|
503
|
+
bound here: the wait is naturally bounded by the recording's own
|
|
504
|
+
lifetime (cancelled on stop/discard); a feed that never reaches
|
|
505
|
+
full resolution simply keeps its lead-in untrimmed. */
|
|
506
|
+
const watch = this.watchResolutionReady(flightId);
|
|
507
|
+
rec.cancelResolutionWatch = watch.cancel;
|
|
508
|
+
void watch.ready.then((reached) => {
|
|
509
|
+
rec.cancelResolutionWatch = undefined;
|
|
510
|
+
if (!reached)
|
|
511
|
+
return;
|
|
512
|
+
if (!this.active.has(recordingId))
|
|
513
|
+
return;
|
|
514
|
+
if (rec.resolutionReadyAt == null) {
|
|
515
|
+
rec.resolutionReadyAt = now() - rec.startedAt;
|
|
516
|
+
rec.resolutionReadyUt = this.client.clock.captureUt ?? undefined;
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
this.rebuild();
|
|
522
|
+
return recordingId;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Stop a recording and resolve its finished handle. Idempotent per id: a
|
|
526
|
+
* second call (or a race with maxDurationMs auto-stop) returns the same
|
|
527
|
+
* pending/settled result.
|
|
528
|
+
*/
|
|
529
|
+
stopRecording(recordingId) {
|
|
530
|
+
const rec = this.active.get(recordingId);
|
|
531
|
+
if (!rec) {
|
|
532
|
+
const done = this.finished.get(recordingId);
|
|
533
|
+
if (done)
|
|
534
|
+
return Promise.resolve(done);
|
|
535
|
+
return Promise.reject(new Error(`no recording ${recordingId}`));
|
|
536
|
+
}
|
|
537
|
+
if (rec.stopPromise)
|
|
538
|
+
return rec.stopPromise;
|
|
539
|
+
if (rec.maxDurationTimer)
|
|
540
|
+
clearTimeout(rec.maxDurationTimer);
|
|
541
|
+
rec.unsubscribeClock();
|
|
542
|
+
rec.cancelResolutionWatch?.();
|
|
543
|
+
rec.cancelResolutionWatch = undefined;
|
|
544
|
+
if (rec.arming) {
|
|
545
|
+
/* Stopped (e.g. a short maxDurationMs firing) before the arm-wait
|
|
546
|
+
ever let the recorder start: there is nothing to flush from
|
|
547
|
+
MediaRecorder, and calling stop() on a never-started recorder would
|
|
548
|
+
never fire its "stop" event (hanging this promise forever). Settle
|
|
549
|
+
immediately with an empty clip instead. Force is still released
|
|
550
|
+
unconditionally. */
|
|
551
|
+
if (rec.forced)
|
|
552
|
+
this.releaseForce(rec.flightId);
|
|
553
|
+
const utEnd = this.client.clock.captureUt ?? undefined;
|
|
554
|
+
const handle = {
|
|
555
|
+
recordingId,
|
|
556
|
+
flightId: rec.flightId,
|
|
557
|
+
blob: new Blob([], { type: rec.mimeType }),
|
|
558
|
+
mimeType: rec.mimeType,
|
|
559
|
+
utStart: rec.utStart,
|
|
560
|
+
utEnd,
|
|
561
|
+
utSamples: [...rec.utSamples, { t: now() - rec.startedAt, ut: utEnd }],
|
|
562
|
+
byteSize: 0,
|
|
563
|
+
durationMs: now() - rec.startedAt,
|
|
564
|
+
};
|
|
565
|
+
rec.stopPromise = Promise.resolve(handle);
|
|
566
|
+
this.finished.set(recordingId, handle);
|
|
567
|
+
this.active.delete(recordingId);
|
|
568
|
+
this.byFlight.delete(rec.flightId);
|
|
569
|
+
this.rebuild();
|
|
570
|
+
return rec.stopPromise;
|
|
571
|
+
}
|
|
572
|
+
const utEnd = this.client.clock.captureUt ?? undefined;
|
|
573
|
+
rec.utSamples.push({ t: now() - rec.startedAt, ut: utEnd });
|
|
574
|
+
rec.stopPromise = new Promise((resolve) => {
|
|
575
|
+
const onStop = () => {
|
|
576
|
+
rec.recorder.removeEventListener("stop", onStop);
|
|
577
|
+
const blob = new Blob(rec.chunks, { type: rec.mimeType });
|
|
578
|
+
const handle = {
|
|
579
|
+
recordingId,
|
|
580
|
+
flightId: rec.flightId,
|
|
581
|
+
blob,
|
|
582
|
+
mimeType: rec.mimeType,
|
|
583
|
+
utStart: rec.utStart,
|
|
584
|
+
utEnd,
|
|
585
|
+
utSamples: rec.utSamples,
|
|
586
|
+
byteSize: blob.size,
|
|
587
|
+
durationMs: now() - rec.startedAt,
|
|
588
|
+
};
|
|
589
|
+
/* Unconditional: a discarded-while-settling recording still held the
|
|
590
|
+
force ref-count and must release it, even though it won't be
|
|
591
|
+
resurrected into `finished` below. */
|
|
592
|
+
if (rec.forced)
|
|
593
|
+
this.releaseForce(rec.flightId);
|
|
594
|
+
/* Discarded while this stop was settling: don't resurrect it into
|
|
595
|
+
`finished`, standalone or grouped. Still resolve so a caller
|
|
596
|
+
awaiting this same promise doesn't hang. */
|
|
597
|
+
if (!rec.discarded) {
|
|
598
|
+
this.finished.set(recordingId, handle);
|
|
599
|
+
this.active.delete(recordingId);
|
|
600
|
+
this.byFlight.delete(rec.flightId);
|
|
601
|
+
this.rebuild();
|
|
602
|
+
}
|
|
603
|
+
resolve(handle);
|
|
604
|
+
};
|
|
605
|
+
rec.recorder.addEventListener("stop", onStop);
|
|
606
|
+
rec.recorder.stop();
|
|
607
|
+
});
|
|
608
|
+
return rec.stopPromise;
|
|
609
|
+
}
|
|
610
|
+
/** Return a finished recording's handle, or undefined if unknown/still active. */
|
|
611
|
+
fetchRecording(recordingId) {
|
|
612
|
+
return this.finished.get(recordingId);
|
|
613
|
+
}
|
|
614
|
+
/** Whether a feed currently has a recording in progress. */
|
|
615
|
+
isRecording(flightId) {
|
|
616
|
+
return this.byFlight.has(flightId);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Drop a recording. If still active, tears it down without producing a
|
|
620
|
+
* handle; if finished, frees the stored handle/blob reference. If the id
|
|
621
|
+
* is a member of a still-active group, pulls it out of that group's member
|
|
622
|
+
* set too, so a later `stopGroupedRecording` only waits on (and stops) the
|
|
623
|
+
* ids that remain -- discarding one member no longer corrupts the group.
|
|
624
|
+
*/
|
|
625
|
+
discardRecording(recordingId) {
|
|
626
|
+
const rec = this.active.get(recordingId);
|
|
627
|
+
let changed = false;
|
|
628
|
+
if (rec) {
|
|
629
|
+
if (rec.stopPromise) {
|
|
630
|
+
/* stopRecording already called: the recorder is "inactive" but its
|
|
631
|
+
"stop" event hasn't fired. Flag it so that pending onStop bails
|
|
632
|
+
instead of resurrecting a "discarded" recording once it settles.
|
|
633
|
+
That same onStop releases the force ref-count unconditionally, so
|
|
634
|
+
it must not be released again here. */
|
|
635
|
+
rec.discarded = true;
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
if (rec.maxDurationTimer)
|
|
639
|
+
clearTimeout(rec.maxDurationTimer);
|
|
640
|
+
rec.unsubscribeClock();
|
|
641
|
+
rec.cancelResolutionWatch?.();
|
|
642
|
+
rec.cancelResolutionWatch = undefined;
|
|
643
|
+
try {
|
|
644
|
+
if (rec.recorder.state !== "inactive")
|
|
645
|
+
rec.recorder.stop();
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
/* a recorder already torn down by the browser is fine to ignore */
|
|
649
|
+
}
|
|
650
|
+
/* No pending stopRecording to release it later (this is the only
|
|
651
|
+
teardown path this recording will see): release the force
|
|
652
|
+
ref-count now, unconditionally. Covers a discard mid-arm too --
|
|
653
|
+
the recorder above was never started (still "inactive"), so this
|
|
654
|
+
is the only place its force gets released. */
|
|
655
|
+
if (rec.forced)
|
|
656
|
+
this.releaseForce(rec.flightId);
|
|
657
|
+
}
|
|
658
|
+
this.active.delete(recordingId);
|
|
659
|
+
this.byFlight.delete(rec.flightId);
|
|
660
|
+
changed = true;
|
|
661
|
+
}
|
|
662
|
+
if (this.finished.delete(recordingId))
|
|
663
|
+
changed = true;
|
|
664
|
+
const groupId = this.memberOfGroup.get(recordingId);
|
|
665
|
+
if (groupId != null) {
|
|
666
|
+
this.memberOfGroup.delete(recordingId);
|
|
667
|
+
this.groupMemberIds.delete(recordingId);
|
|
668
|
+
const ids = this.groups.get(groupId);
|
|
669
|
+
if (ids) {
|
|
670
|
+
const idx = ids.indexOf(recordingId);
|
|
671
|
+
if (idx !== -1) {
|
|
672
|
+
ids.splice(idx, 1);
|
|
673
|
+
changed = true;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (changed)
|
|
678
|
+
this.rebuild();
|
|
679
|
+
}
|
|
680
|
+
// -------------------------------------------------------------------------
|
|
681
|
+
// Grouped recording
|
|
682
|
+
// -------------------------------------------------------------------------
|
|
683
|
+
/**
|
|
684
|
+
* Start recording several feeds as one synchronised moment. All recorders
|
|
685
|
+
* open in a single tick to minimise skew. Returns the groupId plus the real
|
|
686
|
+
* per-feed recordingIds (so a caller can cancel one member individually via
|
|
687
|
+
* `stopRecording`/`discardRecording`, the same ids that end up on each
|
|
688
|
+
* clip's `RecordingHandle`). If any feed can't start (no track / already
|
|
689
|
+
* recording), the ones already started are discarded and the error
|
|
690
|
+
* rethrows.
|
|
691
|
+
*/
|
|
692
|
+
startGroupedRecording(flightIds, opts) {
|
|
693
|
+
const started = [];
|
|
694
|
+
/* Suppress the per-member rebuild() each startRecording call would fire:
|
|
695
|
+
without this, a synchronous subscriber could observe a member as
|
|
696
|
+
active with groupId undefined (this.groups doesn't have the entry yet).
|
|
697
|
+
One rebuild happens below, once the group is fully set up. */
|
|
698
|
+
this.suppressRebuild = true;
|
|
699
|
+
try {
|
|
700
|
+
try {
|
|
701
|
+
for (const flightId of flightIds) {
|
|
702
|
+
started.push(this.startRecording(flightId, opts, /* inGroup */ true));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
for (const id of started)
|
|
707
|
+
this.discardRecording(id);
|
|
708
|
+
throw err;
|
|
709
|
+
}
|
|
710
|
+
const groupId = mintId("grp");
|
|
711
|
+
this.groups.set(groupId, started);
|
|
712
|
+
for (const id of started) {
|
|
713
|
+
this.groupMemberIds.add(id);
|
|
714
|
+
this.memberOfGroup.set(id, groupId);
|
|
715
|
+
}
|
|
716
|
+
/* Grouped output is physically trimmed to the common window by default, so
|
|
717
|
+
load the trim package now (never fail start over a loader error) so it is
|
|
718
|
+
ready by stop. A single recording never reaches here, so it stays
|
|
719
|
+
dependency-free. Accepted tradeoff: this eagerly imports Mediabunny even
|
|
720
|
+
when every feed ends up negotiating webm (no mp4 clip to trim). */
|
|
721
|
+
this.groupTrimmers.set(groupId, (async () => {
|
|
722
|
+
try {
|
|
723
|
+
return await this.loadTrimmer();
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
})());
|
|
729
|
+
return { groupId, recordingIds: [...started] };
|
|
730
|
+
}
|
|
731
|
+
finally {
|
|
732
|
+
this.suppressRebuild = false;
|
|
733
|
+
this.rebuild();
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Stop a grouped recording. With `targetUt`, holds until every feed's latest
|
|
738
|
+
* UT sample has passed it (coverage guarantee) before stopping any feed;
|
|
739
|
+
* without it, stops all now. Resolves the grouped handle with the intersected
|
|
740
|
+
* `commonUtWindow`. See {@link StopGroupedRecordingOptions.targetUt} for the
|
|
741
|
+
* coverage caveat around a member's own `maxDurationMs`.
|
|
742
|
+
*/
|
|
743
|
+
async stopGroupedRecording(groupId, opts) {
|
|
744
|
+
const done = this.finishedGroups.get(groupId);
|
|
745
|
+
if (done)
|
|
746
|
+
return done;
|
|
747
|
+
const ids = this.groups.get(groupId);
|
|
748
|
+
if (!ids)
|
|
749
|
+
throw new Error(`no grouped recording ${groupId}`);
|
|
750
|
+
if (opts?.targetUt != null) {
|
|
751
|
+
await this.awaitCoverage(ids, opts.targetUt, opts.coverageTimeoutMs ?? DEFAULT_COVERAGE_TIMEOUT_MS);
|
|
752
|
+
}
|
|
753
|
+
/* Capture each forced member's resolution-ready UT before stopRecording
|
|
754
|
+
tears down its ActiveRecording bookkeeping below. */
|
|
755
|
+
const resolutionReadyUts = [];
|
|
756
|
+
for (const id of ids) {
|
|
757
|
+
const ut = this.active.get(id)?.resolutionReadyUt;
|
|
758
|
+
if (ut != null)
|
|
759
|
+
resolutionReadyUts.push(ut);
|
|
760
|
+
}
|
|
761
|
+
const recordings = await Promise.all(ids.map((id) => this.stopRecording(id)));
|
|
762
|
+
const window = commonUtWindow(recordings);
|
|
763
|
+
/* Fold the low-res lead-in cut into the same common-window trim: extend
|
|
764
|
+
the window's start to the latest point any forced member actually
|
|
765
|
+
reached full resolution, so every clip stays cut to one shared window
|
|
766
|
+
(UT-synced) rather than each clip getting its own start. A member that
|
|
767
|
+
never reached full resolution before stop simply doesn't contribute
|
|
768
|
+
(its own lead-in stays untrimmed, per the documented edge case). This
|
|
769
|
+
only widens the physical trim cut: `commonUtWindow` on the returned
|
|
770
|
+
handle keeps reporting the clips' own raw UT intersection, unchanged,
|
|
771
|
+
since that's metadata about what the clips cover independent of
|
|
772
|
+
whether (or how) they got physically cut. */
|
|
773
|
+
let trimWindow = window;
|
|
774
|
+
if (trimWindow && resolutionReadyUts.length > 0) {
|
|
775
|
+
const windowStart = Math.max(trimWindow[0], ...resolutionReadyUts);
|
|
776
|
+
trimWindow = [windowStart, trimWindow[1]];
|
|
777
|
+
}
|
|
778
|
+
/* Physically remux-trim each clip to the common window so the grouped
|
|
779
|
+
output is pre-aligned by default. The UT metadata (utSamples +
|
|
780
|
+
commonUtWindow) still rides along so a consumer can align the trimmed
|
|
781
|
+
clips to telemetry. Trimming never fails the recording: any clip that
|
|
782
|
+
can't be cut cleanly degrades to metadata-only. */
|
|
783
|
+
let finalRecordings = recordings;
|
|
784
|
+
if (trimWindow) {
|
|
785
|
+
const trimmer = await (this.groupTrimmers.get(groupId) ?? Promise.resolve(null));
|
|
786
|
+
if (trimmer) {
|
|
787
|
+
finalRecordings = await Promise.all(recordings.map((rec) => this.maybeTrim(rec, trimWindow, trimmer)));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
const handle = {
|
|
791
|
+
groupId,
|
|
792
|
+
recordings: finalRecordings,
|
|
793
|
+
commonUtWindow: window,
|
|
794
|
+
};
|
|
795
|
+
this.groups.delete(groupId);
|
|
796
|
+
this.groupTrimmers.delete(groupId);
|
|
797
|
+
this.finishedGroups.set(groupId, handle);
|
|
798
|
+
this.rebuild();
|
|
799
|
+
return handle;
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Remux-trim one clip to the common UT window, or return it untouched
|
|
803
|
+
* (metadata-only) when it can't be cut cleanly: a non-mp4 container, an
|
|
804
|
+
* unmappable window, or a trimmer error. The stored per-clip handle is
|
|
805
|
+
* replaced with the trimmed one so fetch/discard stay consistent.
|
|
806
|
+
*/
|
|
807
|
+
async maybeTrim(rec, window, trimmer) {
|
|
808
|
+
/* MediaRecorder webm output isn't remuxed to mp4 here; degrade to
|
|
809
|
+
metadata-only for those clips (mp4 is the default container). */
|
|
810
|
+
if (!/mp4/i.test(rec.mimeType))
|
|
811
|
+
return rec;
|
|
812
|
+
const startMs = utToRecordingTimeMs(rec.utSamples, window[0]);
|
|
813
|
+
const endMs = utToRecordingTimeMs(rec.utSamples, window[1]);
|
|
814
|
+
if (startMs == null || endMs == null || endMs <= startMs)
|
|
815
|
+
return rec;
|
|
816
|
+
try {
|
|
817
|
+
const blob = await trimmer.trim(rec.blob, rec.mimeType, startMs, endMs);
|
|
818
|
+
const trimmed = {
|
|
819
|
+
...rec,
|
|
820
|
+
blob,
|
|
821
|
+
byteSize: blob.size,
|
|
822
|
+
durationMs: endMs - startMs,
|
|
823
|
+
};
|
|
824
|
+
this.finished.set(rec.recordingId, trimmed);
|
|
825
|
+
return trimmed;
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
return rec;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/** Return a finished grouped recording, or undefined if unknown/still active. */
|
|
832
|
+
fetchGroupedRecording(groupId) {
|
|
833
|
+
return this.finishedGroups.get(groupId);
|
|
834
|
+
}
|
|
835
|
+
/** Drop a grouped recording and each of its clips. */
|
|
836
|
+
discardGroupedRecording(groupId) {
|
|
837
|
+
/* Accepted gap: racing this against an in-flight stopGroupedRecording(groupId)
|
|
838
|
+
is untested and unlikely misuse (a caller wouldn't normally discard and
|
|
839
|
+
stop the same group at once). */
|
|
840
|
+
let changed = false;
|
|
841
|
+
const active = this.groups.get(groupId);
|
|
842
|
+
if (active) {
|
|
843
|
+
/* discardRecording now splices ids out of this very array as it goes
|
|
844
|
+
(fix for cancelling one grouped member); iterate a copy so removals
|
|
845
|
+
mid-loop can't skip an id. */
|
|
846
|
+
for (const id of [...active])
|
|
847
|
+
this.discardRecording(id);
|
|
848
|
+
this.groups.delete(groupId);
|
|
849
|
+
this.groupTrimmers.delete(groupId);
|
|
850
|
+
changed = true;
|
|
851
|
+
}
|
|
852
|
+
const finished = this.finishedGroups.get(groupId);
|
|
853
|
+
if (finished) {
|
|
854
|
+
for (const rec of finished.recordings)
|
|
855
|
+
this.discardRecording(rec.recordingId);
|
|
856
|
+
this.finishedGroups.delete(groupId);
|
|
857
|
+
changed = true;
|
|
858
|
+
}
|
|
859
|
+
if (changed)
|
|
860
|
+
this.rebuild();
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Resolve once every listed recording's latest UT sample has reached
|
|
864
|
+
* `targetUt`. UT arrives on the shared ~1Hz clock feed, so this polls the
|
|
865
|
+
* settings-change stream rather than a wall-clock timer. Bounded by
|
|
866
|
+
* `timeoutMs`: if coverage never arrives (feed out of flight, sim frozen,
|
|
867
|
+
* warp reversed), gives up and resolves anyway so the group finalizes with
|
|
868
|
+
* whatever UT each feed has captured, rather than hanging forever. Always
|
|
869
|
+
* releases the settings-change subscription, on either exit path.
|
|
870
|
+
*/
|
|
871
|
+
awaitCoverage(recordingIds, targetUt, timeoutMs) {
|
|
872
|
+
const covered = () => recordingIds.every((id) => {
|
|
873
|
+
const rec = this.active.get(id);
|
|
874
|
+
/* A recording already stopped counts as covered (nothing to wait on). */
|
|
875
|
+
if (!rec)
|
|
876
|
+
return true;
|
|
877
|
+
return rec.latestUt != null && rec.latestUt >= targetUt;
|
|
878
|
+
});
|
|
879
|
+
if (covered())
|
|
880
|
+
return Promise.resolve();
|
|
881
|
+
return new Promise((resolve) => {
|
|
882
|
+
let settled = false;
|
|
883
|
+
const finish = () => {
|
|
884
|
+
if (settled)
|
|
885
|
+
return;
|
|
886
|
+
settled = true;
|
|
887
|
+
clearTimeout(timer);
|
|
888
|
+
unsubscribe();
|
|
889
|
+
resolve();
|
|
890
|
+
};
|
|
891
|
+
const unsubscribe = this.client.on("settings-change", () => {
|
|
892
|
+
if (covered())
|
|
893
|
+
finish();
|
|
894
|
+
});
|
|
895
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
}
|