@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.
@@ -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
+ }