@ksp-gonogo/kerbcast 1.6.3 → 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,1233 @@
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
+ import type { CameraState } from "./__generated__/types";
19
+
20
+ /**
21
+ * One point on a recording's UT track. `t` is milliseconds since recording
22
+ * start; `ut` is the KSP universal time (seconds) the capture clock reported
23
+ * at that instant, or absent when out of flight (no clock).
24
+ */
25
+ export interface UtSample {
26
+ t: number;
27
+ ut?: number;
28
+ }
29
+
30
+ /** The finished, in-memory result of a single-feed recording. */
31
+ export interface RecordingHandle {
32
+ recordingId: string;
33
+ /** The camera this clip was recorded from. */
34
+ flightId: number;
35
+ /** The recorded media, in `mimeType`. */
36
+ blob: Blob;
37
+ /** The negotiated container/codec (e.g. `video/mp4;codecs=avc1`). */
38
+ mimeType: string;
39
+ /** UT (seconds) at recording start, or absent when out of flight. */
40
+ utStart?: number;
41
+ /** UT (seconds) at recording stop, or absent when out of flight. */
42
+ utEnd?: number;
43
+ /** ~1Hz (recording-time, UT) series across the clip. Always present. */
44
+ utSamples: UtSample[];
45
+ /** `blob.size` in bytes. */
46
+ byteSize: number;
47
+ /** Wall-clock recording duration in milliseconds. */
48
+ durationMs: number;
49
+ }
50
+
51
+ /** Options for {@link RecordingController.startRecording}. */
52
+ export interface StartRecordingOptions {
53
+ /**
54
+ * Preferred container/codec. Tried first; if MediaRecorder can't support
55
+ * it, negotiation falls back (mp4 -> webm/vp9 -> webm). Omit to let
56
+ * negotiation choose.
57
+ */
58
+ mimeType?: string;
59
+ /**
60
+ * Auto-stop after this many milliseconds. The clip finalizes on its own and
61
+ * is retrievable via {@link RecordingController.fetchRecording}. Omit for
62
+ * manual stop only.
63
+ */
64
+ maxDurationMs?: number;
65
+ /**
66
+ * Force this feed to the operator's full render size for the duration of
67
+ * the recording (overrides the display-size demand only; still yields to
68
+ * the adaptive framerate shed). Off by default. A single recording arms
69
+ * and waits (bounded) for the bump before the clip starts, so it opens on
70
+ * full-res frames; a grouped recording starts immediately and the lead-in
71
+ * is cut via the existing UT-window trim instead. Released automatically
72
+ * on stop, discard, or auto-stop.
73
+ */
74
+ forceFullResolution?: boolean;
75
+ }
76
+
77
+ /**
78
+ * Bound on the single-recording arm-and-wait: how long
79
+ * {@link RecordingController.startRecording} waits for a forced feed to
80
+ * reach full resolution before starting the recorder anyway. Never hangs a
81
+ * start indefinitely (feed out of flight, wedged, or the sidecar/plugin
82
+ * round trip taking longer than expected).
83
+ */
84
+ export const ARM_TIMEOUT_MS = 3000;
85
+
86
+ /** Options for {@link RecordingController.stopGroupedRecording}. */
87
+ export interface StopGroupedRecordingOptions {
88
+ /**
89
+ * Coverage guarantee: hold finalization until every feed's latest UT sample
90
+ * has passed this UT (seconds), so no feed falls short of the common window.
91
+ * Omit to stop all feeds at the current moment (best-effort).
92
+ *
93
+ * The guarantee only holds for members that are still recording when the
94
+ * target arrives. A member started with its own `maxDurationMs` can
95
+ * auto-stop before reaching `targetUt`; that member's `utEnd` then falls
96
+ * short, which silently narrows (or, if the ranges no longer overlap,
97
+ * clears) the resulting `commonUtWindow`. Pair a grouped `targetUt` with an
98
+ * unbounded (or generously long) per-member `maxDurationMs`, or none at all.
99
+ */
100
+ targetUt?: number;
101
+ /**
102
+ * Bound on how long to wait for `targetUt` coverage before giving up and
103
+ * finalizing with whatever each feed has captured so far. Guards against a
104
+ * UT that never arrives (feed out of flight, sim frozen, warp reversed)
105
+ * hanging the stop forever. Ignored when `targetUt` is omitted. Defaults to
106
+ * {@link DEFAULT_COVERAGE_TIMEOUT_MS}.
107
+ */
108
+ coverageTimeoutMs?: number;
109
+ }
110
+
111
+ /** Default {@link StopGroupedRecordingOptions.coverageTimeoutMs}. */
112
+ export const DEFAULT_COVERAGE_TIMEOUT_MS = 10_000;
113
+
114
+ /** The result of {@link RecordingController.startGroupedRecording}. */
115
+ export interface GroupedRecordingStart {
116
+ groupId: string;
117
+ /**
118
+ * The real per-feed recording ids the controller minted internally, in the
119
+ * order `flightIds` was given. These are the ids that work with the
120
+ * single-recording surface (`isRecording`, `stopRecording`,
121
+ * `discardRecording`) for cancelling one member of the group.
122
+ */
123
+ recordingIds: string[];
124
+ }
125
+
126
+ /** The finished, in-memory result of a grouped (multi-feed) recording. */
127
+ export interface GroupedRecordingHandle {
128
+ groupId: string;
129
+ /** One handle per feed, in the order the group was started. */
130
+ recordings: RecordingHandle[];
131
+ /**
132
+ * The UT span all feeds cover: [max(utStart), min(utEnd)] across the clips.
133
+ * Absent when any clip lacks UT or the ranges don't overlap. Each clip's
134
+ * own `utSamples` let a consumer map this window to that clip's
135
+ * recording-time and trim/seek itself (metadata alignment; no physical cut).
136
+ */
137
+ commonUtWindow?: [number, number];
138
+ }
139
+
140
+ /**
141
+ * An in-progress recording, surfaced so the UI can draw a REC dot and an
142
+ * elapsed timer. `groupId` is set when the recording is part of a grouped set.
143
+ */
144
+ export interface ActiveRecordingInfo {
145
+ recordingId: string;
146
+ flightId: number;
147
+ /** `performance.now()`/`Date.now()` at start, for an elapsed timer. */
148
+ startedAt: number;
149
+ groupId?: string;
150
+ /**
151
+ * True while a single forced recording is armed (force sent, waiting for
152
+ * the feed to reach full resolution) but the recorder has not started
153
+ * yet. Always false for a non-forced recording and for grouped members
154
+ * (which record immediately). A UI shows a distinct "arming" state while
155
+ * this is true.
156
+ */
157
+ arming: boolean;
158
+ }
159
+
160
+ /**
161
+ * Everything {@link RecordingController} currently knows, as one referentially
162
+ * stable object: the same reference between mutations, a new one after each.
163
+ * That stability is what makes it safe to hand straight to
164
+ * `useSyncExternalStore` -- a fresh object on every call would spin components
165
+ * into an infinite render loop.
166
+ */
167
+ export interface RecordingsSnapshot {
168
+ /** Finished standalone (non-grouped) clips, newest last. */
169
+ recordings: RecordingHandle[];
170
+ /** Finished grouped sets, newest last. */
171
+ groups: GroupedRecordingHandle[];
172
+ /** Recordings currently in progress (single + grouped members). */
173
+ active: ActiveRecordingInfo[];
174
+ }
175
+
176
+ /**
177
+ * Per-camera surface {@link RecordingController} needs: the live stream plus
178
+ * enough of the full camera handle to drive a forced full-resolution
179
+ * recording (send the force, observe the resulting render size).
180
+ */
181
+ export interface RecordingClientCamera {
182
+ readonly mediaStream: MediaStream | null;
183
+ readonly state: CameraState | null;
184
+ /**
185
+ * The feed's physical/ceiling maximum render size, or `null` when not
186
+ * known to the client (e.g. `discover()` was never called). When known,
187
+ * lets the controller recognise a forced feed that has actually reached
188
+ * its ceiling rather than one merely unchanged since before the force.
189
+ */
190
+ readonly maxRenderSize: { width: number; height: number } | null;
191
+ setForceFullResolution(force: boolean): Promise<void>;
192
+ on(event: "change", handler: (state: CameraState) => void): () => void;
193
+ }
194
+
195
+ /**
196
+ * Minimal surface {@link RecordingController} needs from the client. The full
197
+ * `KerbcastClient` satisfies it; a narrow interface keeps the controller
198
+ * unit-testable without a whole client.
199
+ */
200
+ export interface RecordingClient {
201
+ camera(flightId: number): RecordingClientCamera;
202
+ readonly clock: { readonly captureUt: number | null };
203
+ on(event: "settings-change", handler: (data: unknown) => void): () => void;
204
+ }
205
+
206
+ /* MediaRecorder is a DOM lib global; keep a minimal structural view so this
207
+ module builds without dom.iterable and stays mockable in jsdom. */
208
+ interface MediaRecorderLike {
209
+ state: string;
210
+ start(timeslice?: number): void;
211
+ stop(): void;
212
+ addEventListener(type: string, cb: (e: { data?: Blob }) => void): void;
213
+ removeEventListener(type: string, cb: (e: { data?: Blob }) => void): void;
214
+ }
215
+ interface MediaRecorderCtor {
216
+ new (stream: MediaStream, options?: { mimeType?: string }): MediaRecorderLike;
217
+ isTypeSupported?(type: string): boolean;
218
+ }
219
+
220
+ /** Preference order for mime negotiation, after any caller-supplied choice. */
221
+ const MIME_CANDIDATES = [
222
+ "video/mp4;codecs=avc1",
223
+ "video/webm;codecs=vp9",
224
+ "video/webm",
225
+ ];
226
+
227
+ /**
228
+ * Pick a MediaRecorder mime type. Tries `preferred` first (when given), then
229
+ * the standard preference order, using `MediaRecorder.isTypeSupported`. Falls
230
+ * back to `video/webm` when nothing reports support (or the API is absent).
231
+ */
232
+ export function negotiateMimeType(preferred?: string): string {
233
+ const ctor = (globalThis as { MediaRecorder?: MediaRecorderCtor }).MediaRecorder;
234
+ const candidates = preferred ? [preferred, ...MIME_CANDIDATES] : MIME_CANDIDATES;
235
+ if (ctor && typeof ctor.isTypeSupported === "function") {
236
+ for (const candidate of candidates) {
237
+ if (ctor.isTypeSupported(candidate)) return candidate;
238
+ }
239
+ }
240
+ return "video/webm";
241
+ }
242
+
243
+ /** Monotonic clock for recording-time. Falls back to Date.now under jsdom. */
244
+ function now(): number {
245
+ return typeof performance !== "undefined" && typeof performance.now === "function"
246
+ ? performance.now()
247
+ : Date.now();
248
+ }
249
+
250
+ /**
251
+ * Intersection of the feeds' UT ranges: [max(utStart), min(utEnd)]. Returns
252
+ * undefined if any clip lacks a full range or the spans don't overlap.
253
+ */
254
+ export function commonUtWindow(recordings: RecordingHandle[]): [number, number] | undefined {
255
+ if (recordings.length === 0) return undefined;
256
+ let lo = -Infinity;
257
+ let hi = Infinity;
258
+ for (const rec of recordings) {
259
+ if (rec.utStart == null || rec.utEnd == null) return undefined;
260
+ lo = Math.max(lo, rec.utStart);
261
+ hi = Math.min(hi, rec.utEnd);
262
+ }
263
+ return lo <= hi ? [lo, hi] : undefined;
264
+ }
265
+
266
+ /**
267
+ * Recording-time (ms since clip start) for a mission UT, linearly interpolated
268
+ * off a clip's `utSamples`. UT is linear in recording time at constant
269
+ * time-warp (the launch/landing/docking moments a group captures), so this maps
270
+ * the common UT window onto each clip's own timeline for a keyframe trim.
271
+ * Clamps to the sampled span; returns null when the clip has no UT samples.
272
+ */
273
+ export function utToRecordingTimeMs(samples: UtSample[], ut: number): number | null {
274
+ const pts = samples.filter((s): s is { t: number; ut: number } => s.ut != null);
275
+ if (pts.length === 0) return null;
276
+ if (ut <= pts[0].ut) return pts[0].t;
277
+ for (let i = 0; i < pts.length - 1; i++) {
278
+ const a = pts[i];
279
+ const b = pts[i + 1];
280
+ if (ut >= a.ut && ut <= b.ut) {
281
+ if (b.ut === a.ut) return a.t;
282
+ const frac = (ut - a.ut) / (b.ut - a.ut);
283
+ return a.t + frac * (b.t - a.t);
284
+ }
285
+ }
286
+ return pts[pts.length - 1].t;
287
+ }
288
+
289
+ /**
290
+ * Remux-trims a grouped clip to a recording-time window. Implemented by a
291
+ * lazily-loaded package (Mediabunny) so single recordings and non-recording
292
+ * consumers never pull it. Keyframe-boundary remux only: no decode/re-encode.
293
+ */
294
+ export interface GroupTrimmer {
295
+ /** Cut `blob` to [startMs, endMs] of its own timeline; resolves a new Blob. */
296
+ trim(blob: Blob, mimeType: string, startMs: number, endMs: number): Promise<Blob>;
297
+ }
298
+
299
+ /** Loads a {@link GroupTrimmer}, or resolves null when trimming is unavailable. */
300
+ export type TrimmerLoader = () => Promise<GroupTrimmer | null>;
301
+
302
+ /** Options for {@link RecordingController}. */
303
+ export interface RecordingControllerOptions {
304
+ /**
305
+ * Override the grouped-recording trimmer loader (tests inject a fake; a
306
+ * consumer could supply mp4box.js instead). Defaults to a dynamic
307
+ * `import("mediabunny")` keyframe remux.
308
+ */
309
+ loadTrimmer?: TrimmerLoader;
310
+ }
311
+
312
+ /**
313
+ * Structural view of the Mediabunny module surface the remux trim needs.
314
+ * Deliberately not `typeof import("mediabunny")`: keeping it a plain
315
+ * structural type means a caller can hand in the module however it got
316
+ * resolved -- the bare package (bundler consumers), or a pre-built bundle
317
+ * served from elsewhere (the sidecar's embedded web page serves its own
318
+ * copy locally; see {@link createMediabunnyTrimmerLoader}).
319
+ */
320
+ interface MediabunnyModule {
321
+ Input: new (opts: { source: unknown; formats: unknown }) => unknown;
322
+ BlobSource: new (blob: Blob) => unknown;
323
+ ALL_FORMATS: unknown;
324
+ BufferTarget: new () => { buffer: ArrayBuffer | null };
325
+ Output: new (opts: { format: unknown; target: unknown }) => unknown;
326
+ Mp4OutputFormat: new () => unknown;
327
+ Conversion: {
328
+ init(opts: {
329
+ input: unknown;
330
+ output: unknown;
331
+ trim: { start: number; end: number };
332
+ }): Promise<{ execute(): Promise<void> }>;
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Builds a {@link GroupTrimmer} from an already-resolved Mediabunny module
338
+ * namespace: keyframe-boundary remux via its Conversion API, no re-encode.
339
+ */
340
+ function mediabunnyTrimmer(mb: MediabunnyModule): GroupTrimmer {
341
+ return {
342
+ async trim(blob, mimeType, startMs, endMs): Promise<Blob> {
343
+ const input = new mb.Input({
344
+ source: new mb.BlobSource(blob),
345
+ formats: mb.ALL_FORMATS,
346
+ });
347
+ const target = new mb.BufferTarget();
348
+ const output = new mb.Output({ format: new mb.Mp4OutputFormat(), target });
349
+ const conversion = await mb.Conversion.init({
350
+ input,
351
+ output,
352
+ trim: { start: startMs / 1000, end: endMs / 1000 },
353
+ });
354
+ await conversion.execute();
355
+ if (!target.buffer) throw new Error("mediabunny produced no output buffer");
356
+ return new Blob([target.buffer], { type: mimeType });
357
+ },
358
+ };
359
+ }
360
+
361
+ /**
362
+ * Builds a {@link TrimmerLoader} around a caller-supplied module import.
363
+ * `loadMediabunnyTrimmer` (the default, below) resolves the bare
364
+ * `import("mediabunny")` specifier for bundler consumers (e.g. gonogo). A
365
+ * consumer that can't rely on that resolution -- the sidecar's embedded web
366
+ * page, which serves its own copy of the package locally for an offline/LAN
367
+ * install with no CDN -- supplies a different `importModule` (e.g. a dynamic
368
+ * import of a served URL) and passes the result as
369
+ * {@link RecordingControllerOptions.loadTrimmer}. Resolves null on any
370
+ * failure, so a group degrades to metadata-only rather than failing.
371
+ */
372
+ export function createMediabunnyTrimmerLoader(
373
+ importModule: () => Promise<unknown>,
374
+ ): TrimmerLoader {
375
+ return async () => {
376
+ try {
377
+ return mediabunnyTrimmer((await importModule()) as MediabunnyModule);
378
+ } catch {
379
+ return null;
380
+ }
381
+ };
382
+ }
383
+
384
+ /**
385
+ * Default trimmer: dynamically imports Mediabunny by its package name and
386
+ * remuxes with its Conversion API. Loaded on group-start so it's ready by
387
+ * stop.
388
+ */
389
+ const loadMediabunnyTrimmer: TrimmerLoader = createMediabunnyTrimmerLoader(
390
+ () => import("mediabunny"),
391
+ );
392
+
393
+ /** Live per-feed recording state, held between start and stop. */
394
+ interface ActiveRecording {
395
+ recordingId: string;
396
+ flightId: number;
397
+ mimeType: string;
398
+ recorder: MediaRecorderLike;
399
+ chunks: Blob[];
400
+ startedAt: number;
401
+ utStart?: number;
402
+ utSamples: UtSample[];
403
+ /** Most recent UT seen (for the grouped coverage guarantee); undefined until first UT. */
404
+ latestUt?: number;
405
+ /** Detach from the client's settings-change clock feed. */
406
+ unsubscribeClock: () => void;
407
+ maxDurationTimer?: ReturnType<typeof setTimeout>;
408
+ /** Resolves once the recorder's stop event has flushed the final chunk. */
409
+ stopPromise?: Promise<RecordingHandle>;
410
+ /**
411
+ * Set when discarded while `stopPromise` was already settling (recorder
412
+ * `inactive`, "stop" event not yet fired). Tells the pending `onStop` to
413
+ * bail rather than resurrect a handle for a recording that was discarded.
414
+ */
415
+ discarded?: boolean;
416
+ /**
417
+ * True while a single forced recording is armed (force sent, recorder not
418
+ * started yet). Never set for grouped members, which record immediately.
419
+ */
420
+ arming?: boolean;
421
+ /** True once this recording has acquired the per-feed force ref-count. */
422
+ forced?: boolean;
423
+ /**
424
+ * Grouped path only: recording-time (ms since `startedAt`) at which this
425
+ * feed was observed at full resolution. Undefined until reached (or
426
+ * always, if it never is before stop).
427
+ */
428
+ resolutionReadyAt?: number;
429
+ /** Grouped path only: `client.clock.captureUt` at the same moment. */
430
+ resolutionReadyUt?: number;
431
+ /**
432
+ * Cancels a pending arm-wait (single) or ready-watch (grouped)
433
+ * subscription. Set while a {@link RecordingController.watchResolutionReady}
434
+ * wait is outstanding; cleared once it settles or is cancelled. Always
435
+ * cancelled on stop/discard so a torn-down recording never leaks a
436
+ * "change" listener or starts its recorder late.
437
+ */
438
+ cancelResolutionWatch?: () => void;
439
+ }
440
+
441
+ let idCounter = 0;
442
+ function mintId(prefix: string): string {
443
+ idCounter += 1;
444
+ return `${prefix}_${Date.now().toString(36)}_${idCounter.toString(36)}`;
445
+ }
446
+
447
+ /**
448
+ * Owns every in-flight and finished recording for one client. Reached via
449
+ * `client.recording`. All state is in-memory: a page reload discards it.
450
+ *
451
+ * Observable: {@link subscribe} + {@link getSnapshot} let every call site
452
+ * (a tile's own REC control, a RecordingsTray mounted elsewhere) read the same
453
+ * state, so a clip started or finished through one is visible to all the
454
+ * others -- no per-instance state to fall out of sync.
455
+ */
456
+ export class RecordingController {
457
+ private readonly client: RecordingClient;
458
+ private readonly active = new Map<string, ActiveRecording>();
459
+ private readonly finished = new Map<string, RecordingHandle>();
460
+ /** flightId -> recordingId, so a second start on a busy feed is rejected. */
461
+ private readonly byFlight = new Map<number, string>();
462
+ private readonly groups = new Map<string, string[]>();
463
+ private readonly finishedGroups = new Map<string, GroupedRecordingHandle>();
464
+ /** Per-group trimmer load kicked off at group-start, awaited at stop. */
465
+ private readonly groupTrimmers = new Map<string, Promise<GroupTrimmer | null>>();
466
+ private readonly loadTrimmer: TrimmerLoader;
467
+ /** recordingIds in a grouped set, so they stay out of the standalone `recordings` list. */
468
+ private readonly groupMemberIds = new Set<string>();
469
+ /** recordingId -> groupId, so discarding one member can pull it out of its group's array. */
470
+ private readonly memberOfGroup = new Map<string, string>();
471
+ /**
472
+ * Per-feed force ref-count. The sidecar OR-aggregates force across
473
+ * viewers already; this is the local mirror so two overlapping
474
+ * recordings of the same feed (in this one controller) release force
475
+ * only when the last of them stops.
476
+ */
477
+ private readonly forceCounts = new Map<number, number>();
478
+ /**
479
+ * Per-feed force-true send status: "pending" while a send is in flight,
480
+ * "sent" once it has actually succeeded. Absent means neither: either
481
+ * never attempted, or a prior attempt failed. Distinct from
482
+ * {@link forceCounts} so a failed send never masquerades as a completed
483
+ * one; see {@link acquireForce}.
484
+ */
485
+ private readonly forceSendState = new Map<number, "pending" | "sent">();
486
+ private readonly listeners = new Set<() => void>();
487
+ private snapshot: RecordingsSnapshot = { recordings: [], groups: [], active: [] };
488
+ /** While true, {@link rebuild} is a no-op; used to batch startGroupedRecording's
489
+ * per-member starts into a single notification once the group is fully set up. */
490
+ private suppressRebuild = false;
491
+
492
+ constructor(client: RecordingClient, options?: RecordingControllerOptions) {
493
+ this.client = client;
494
+ this.loadTrimmer = options?.loadTrimmer ?? loadMediabunnyTrimmer;
495
+ }
496
+
497
+ /** Subscribe to snapshot changes. Returns an unsubscribe function. */
498
+ subscribe = (fn: () => void): (() => void) => {
499
+ this.listeners.add(fn);
500
+ return () => {
501
+ this.listeners.delete(fn);
502
+ };
503
+ };
504
+
505
+ /** The current snapshot; the same object reference until the next mutation. */
506
+ getSnapshot = (): RecordingsSnapshot => this.snapshot;
507
+
508
+ /**
509
+ * Recompute the snapshot from current state and notify subscribers. Called
510
+ * at the end of every state-mutating operation, including the async settle
511
+ * points (recorder stop, grouped stop) where the data actually changes.
512
+ */
513
+ private rebuild(): void {
514
+ if (this.suppressRebuild) return;
515
+ const recordings = [...this.finished.values()].filter(
516
+ (r) => !this.groupMemberIds.has(r.recordingId),
517
+ );
518
+ const groups = [...this.finishedGroups.values()];
519
+ const active: ActiveRecordingInfo[] = [...this.active.values()].map((rec) => {
520
+ let groupId: string | undefined;
521
+ for (const [gid, ids] of this.groups) {
522
+ if (ids.includes(rec.recordingId)) {
523
+ groupId = gid;
524
+ break;
525
+ }
526
+ }
527
+ return {
528
+ recordingId: rec.recordingId,
529
+ flightId: rec.flightId,
530
+ startedAt: rec.startedAt,
531
+ groupId,
532
+ arming: rec.arming ?? false,
533
+ };
534
+ });
535
+ this.snapshot = { recordings, groups, active };
536
+ for (const l of this.listeners) l();
537
+ }
538
+
539
+ /**
540
+ * Acquire the local force ref-count for a feed; sends
541
+ * `setForceFullResolution(true)` unless one is already in flight or has
542
+ * already succeeded for this feed (tracked in {@link forceSendState}, not
543
+ * just the ref-count reaching 1). The sidecar OR-aggregates force across
544
+ * viewers already; this ref-count is the belt-and-braces local mirror for
545
+ * two overlapping recordings of the same feed in this one controller.
546
+ *
547
+ * A send failure clears `forceSendState` instead of leaving it "sent", so
548
+ * it never masquerades as done: a later acquire (e.g. a second overlapping
549
+ * recording's 1->2 transition) sees the feed as not-yet-forced and retries
550
+ * the send, rather than silently leaving the feed un-forced forever.
551
+ */
552
+ private acquireForce(flightId: number): void {
553
+ const count = (this.forceCounts.get(flightId) ?? 0) + 1;
554
+ this.forceCounts.set(flightId, count);
555
+ if (this.forceSendState.has(flightId)) return;
556
+ this.forceSendState.set(flightId, "pending");
557
+ void this.client
558
+ .camera(flightId)
559
+ .setForceFullResolution(true)
560
+ .then(() => {
561
+ this.forceSendState.set(flightId, "sent");
562
+ })
563
+ .catch((err: unknown) => {
564
+ this.forceSendState.delete(flightId);
565
+ console.error(
566
+ `kerbcast: setForceFullResolution(true) failed for feed ${flightId}; will retry on next acquire`,
567
+ err,
568
+ );
569
+ });
570
+ }
571
+
572
+ /**
573
+ * Release one holder of a feed's force ref-count; sends
574
+ * `setForceFullResolution(false)` once the count reaches zero. Never goes
575
+ * below zero (a stray extra release is a no-op, not an underflow). Clears
576
+ * `forceSendState` too, so a later, unrelated recording of this feed
577
+ * starts its own send from a clean slate.
578
+ */
579
+ private releaseForce(flightId: number): void {
580
+ const count = (this.forceCounts.get(flightId) ?? 0) - 1;
581
+ if (count <= 0) {
582
+ this.forceCounts.delete(flightId);
583
+ this.forceSendState.delete(flightId);
584
+ void this.client
585
+ .camera(flightId)
586
+ .setForceFullResolution(false)
587
+ .catch(() => {});
588
+ } else {
589
+ this.forceCounts.set(flightId, count);
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Whether `state` shows a feed actually rendering at its full target size,
595
+ * as opposed to merely having caught up to whatever small size it already
596
+ * had. Two ways to know the target:
597
+ *
598
+ * - `target` known (from `maxRenderSize`): ready once the operator size
599
+ * has reached it and the rendered size has caught up to the operator
600
+ * size. Resolves promptly even when the feed was already at its ceiling
601
+ * before the force was sent (nothing ever "increases").
602
+ * - `target` unknown: falls back to requiring the operator size to have
603
+ * increased past `baseline` (the first REAL state observed at or after
604
+ * watch-start; see {@link watchResolutionReady}) and the rendered size
605
+ * to have caught up. `baseline` is `null` until that first real state
606
+ * arrives, and a `null` baseline is never ready: a placeholder `{0,0}`
607
+ * would let any positive operator size look like an increase, so no
608
+ * verdict is possible until a genuine baseline exists. This is a
609
+ * strictly weaker guarantee than the known-target path: a feed already
610
+ * at its true ceiling when forced will not be recognised as ready until
611
+ * something else changes its operator size, or the wait simply times
612
+ * out, but it still refuses to treat the pre-force snapshot itself as
613
+ * ready.
614
+ *
615
+ * Either way, a state whose rendered size has not yet caught up to its own
616
+ * operator size (adaptive shed still catching up) is never ready.
617
+ */
618
+ private isAtFullResolution(
619
+ state: CameraState | null,
620
+ target: { width: number; height: number } | null,
621
+ baseline: { width: number; height: number } | null,
622
+ ): boolean {
623
+ if (!state) return false;
624
+ if (state.renderWidth !== state.operatorWidth || state.renderHeight !== state.operatorHeight) {
625
+ return false;
626
+ }
627
+ if (target) {
628
+ return state.operatorWidth >= target.width && state.operatorHeight >= target.height;
629
+ }
630
+ if (!baseline) return false;
631
+ return state.operatorWidth > baseline.width && state.operatorHeight > baseline.height;
632
+ }
633
+
634
+ /**
635
+ * Seeds a fallback baseline from an observed state, or returns `null` if
636
+ * the state isn't a REAL observation (operator size still zero, i.e. no
637
+ * dimensions have ever been reported for this feed). A `null` result
638
+ * means the caller must keep waiting for a state worth basing readiness
639
+ * on, rather than latching onto a `{0,0}` placeholder.
640
+ */
641
+ private seedResolutionBaseline(
642
+ state: CameraState | null,
643
+ ): { width: number; height: number } | null {
644
+ if (!state) return null;
645
+ if (state.operatorWidth <= 0 && state.operatorHeight <= 0) return null;
646
+ return { width: state.operatorWidth, height: state.operatorHeight };
647
+ }
648
+
649
+ /**
650
+ * Waits for a forced feed to actually render at full resolution.
651
+ * `timeoutMs` omitted means no bound: the wait only ends when the feed
652
+ * becomes ready or the caller cancels it (the grouped path, bounded by the
653
+ * recording's own lifetime instead; see {@link startRecording}).
654
+ * Resolves `true` once ready, `false` on timeout. Returns a `cancel` next
655
+ * to the promise so a caller can abandon the wait (stop/discard) without
656
+ * leaking the underlying "change" subscription or timer; cancelling never
657
+ * settles the promise.
658
+ */
659
+ private watchResolutionReady(
660
+ flightId: number,
661
+ timeoutMs?: number,
662
+ ): { ready: Promise<boolean>; cancel: () => void } {
663
+ const cam = this.client.camera(flightId);
664
+ const target = cam.maxRenderSize;
665
+ /* Known-ceiling path never consults baseline; unknown-ceiling path
666
+ starts with no baseline until a real state seeds one, so a null
667
+ cam.state at watch-start (e.g. no discover() yet, since state and
668
+ the track are independent transports with no ordering guarantee)
669
+ can never masquerade as a {0,0} baseline. */
670
+ let baseline = target ? null : this.seedResolutionBaseline(cam.state);
671
+
672
+ let settled = false;
673
+ let unsubscribe: () => void = () => {};
674
+ let timer: ReturnType<typeof setTimeout> | undefined;
675
+
676
+ const ready = new Promise<boolean>((resolve) => {
677
+ const finish = (reached: boolean): void => {
678
+ if (settled) return;
679
+ settled = true;
680
+ if (timer != null) clearTimeout(timer);
681
+ unsubscribe();
682
+ resolve(reached);
683
+ };
684
+
685
+ if (this.isAtFullResolution(cam.state, target, baseline)) {
686
+ finish(true);
687
+ return;
688
+ }
689
+
690
+ unsubscribe = cam.on("change", (state) => {
691
+ if (!target && !baseline) {
692
+ /* This is the first real state we've ever seen: it becomes the
693
+ baseline, but a state can't ready itself against its own
694
+ just-established baseline, so wait for the NEXT state. */
695
+ baseline = this.seedResolutionBaseline(state);
696
+ return;
697
+ }
698
+ if (this.isAtFullResolution(state, target, baseline)) finish(true);
699
+ });
700
+ if (timeoutMs != null) {
701
+ timer = setTimeout(() => finish(false), timeoutMs);
702
+ }
703
+ });
704
+
705
+ return {
706
+ ready,
707
+ cancel: (): void => {
708
+ if (settled) return;
709
+ settled = true;
710
+ if (timer != null) clearTimeout(timer);
711
+ unsubscribe();
712
+ },
713
+ };
714
+ }
715
+
716
+ /**
717
+ * Start recording a feed's live track. Returns a client-minted recordingId
718
+ * synchronously. Throws if the feed has no live track or is already
719
+ * recording.
720
+ *
721
+ * `inGroup` is internal (set by {@link startGroupedRecording}) and governs
722
+ * how `opts.forceFullResolution` behaves: a single recording (`inGroup`
723
+ * false) arms and waits, bounded by {@link ARM_TIMEOUT_MS}, for the feed to
724
+ * reach full resolution before the recorder actually starts, so the clip
725
+ * opens on full-res frames. A grouped member starts immediately and
726
+ * instead records the moment it reaches full resolution
727
+ * (`resolutionReadyAt`/`resolutionReadyUt`) so {@link stopGroupedRecording}
728
+ * can fold it into the common trim window.
729
+ */
730
+ startRecording(flightId: number, opts?: StartRecordingOptions, inGroup = false): string {
731
+ if (this.byFlight.has(flightId)) {
732
+ throw new Error(`camera ${flightId} is already recording`);
733
+ }
734
+ const stream = this.client.camera(flightId).mediaStream;
735
+ if (!stream || stream.getVideoTracks().length === 0) {
736
+ throw new Error(`camera ${flightId} has no live track to record`);
737
+ }
738
+
739
+ const mimeType = negotiateMimeType(opts?.mimeType);
740
+ const ctor = (globalThis as { MediaRecorder?: MediaRecorderCtor }).MediaRecorder;
741
+ if (!ctor) throw new Error("MediaRecorder is not available in this environment");
742
+ const recorder = new ctor(stream, { mimeType });
743
+
744
+ const recordingId = mintId("rec");
745
+ const startedAt = now();
746
+ const utStart = this.client.clock.captureUt ?? undefined;
747
+
748
+ const rec: ActiveRecording = {
749
+ recordingId,
750
+ flightId,
751
+ mimeType,
752
+ recorder,
753
+ chunks: [],
754
+ startedAt,
755
+ utStart,
756
+ utSamples: [{ t: 0, ut: utStart }],
757
+ latestUt: utStart,
758
+ unsubscribeClock: () => {},
759
+ };
760
+
761
+ recorder.addEventListener("dataavailable", (e) => {
762
+ if (e.data && e.data.size > 0) rec.chunks.push(e.data);
763
+ });
764
+
765
+ /* Sample the capture clock on each ~1Hz settings-change push. Duplicates
766
+ (a paused sim) are kept so `t` still advances; ut upgrades from absent
767
+ to present automatically once out-of-flight becomes in-flight. */
768
+ rec.unsubscribeClock = this.client.on("settings-change", () => {
769
+ const ut = this.client.clock.captureUt ?? undefined;
770
+ rec.utSamples.push({ t: now() - rec.startedAt, ut });
771
+ if (ut != null) rec.latestUt = ut;
772
+ });
773
+
774
+ this.active.set(recordingId, rec);
775
+ this.byFlight.set(flightId, recordingId);
776
+
777
+ const forceRequested = opts?.forceFullResolution === true;
778
+ if (forceRequested) {
779
+ rec.forced = true;
780
+ this.acquireForce(flightId);
781
+ }
782
+
783
+ if (opts?.maxDurationMs != null) {
784
+ rec.maxDurationTimer = setTimeout(() => {
785
+ void this.stopRecording(recordingId).catch(() => {});
786
+ }, opts.maxDurationMs);
787
+ }
788
+
789
+ if (forceRequested && !inGroup) {
790
+ /* Single path: arm-and-wait. Show "arming" immediately; the recorder
791
+ itself doesn't start until the feed reaches full resolution (or the
792
+ bounded wait gives up), so the clip never opens on low-res frames. */
793
+ rec.arming = true;
794
+ const watch = this.watchResolutionReady(flightId, ARM_TIMEOUT_MS);
795
+ rec.cancelResolutionWatch = watch.cancel;
796
+ void watch.ready.then(() => {
797
+ rec.cancelResolutionWatch = undefined;
798
+ /* Discarded while arming: the recorder was never started and this
799
+ id is gone from `active`, so there is nothing left to do. */
800
+ if (!this.active.has(recordingId)) return;
801
+ rec.arming = false;
802
+ /* The real recording starts now: reset the clock base so the
803
+ elapsed timer and utSamples track begin at actual record start,
804
+ not at arm time. */
805
+ rec.startedAt = now();
806
+ rec.utStart = this.client.clock.captureUt ?? undefined;
807
+ rec.utSamples = [{ t: 0, ut: rec.utStart }];
808
+ rec.latestUt = rec.utStart;
809
+ rec.recorder.start();
810
+ this.rebuild();
811
+ });
812
+ } else {
813
+ recorder.start();
814
+ if (forceRequested) {
815
+ /* Grouped path: record immediately. Watch for the moment this feed
816
+ actually reaches full resolution so stopGroupedRecording can fold
817
+ it into the common trim window and cut the low-res lead-in. No
818
+ bound here: the wait is naturally bounded by the recording's own
819
+ lifetime (cancelled on stop/discard); a feed that never reaches
820
+ full resolution simply keeps its lead-in untrimmed. */
821
+ const watch = this.watchResolutionReady(flightId);
822
+ rec.cancelResolutionWatch = watch.cancel;
823
+ void watch.ready.then((reached) => {
824
+ rec.cancelResolutionWatch = undefined;
825
+ if (!reached) return;
826
+ if (!this.active.has(recordingId)) return;
827
+ if (rec.resolutionReadyAt == null) {
828
+ rec.resolutionReadyAt = now() - rec.startedAt;
829
+ rec.resolutionReadyUt = this.client.clock.captureUt ?? undefined;
830
+ }
831
+ });
832
+ }
833
+ }
834
+
835
+ this.rebuild();
836
+ return recordingId;
837
+ }
838
+
839
+ /**
840
+ * Stop a recording and resolve its finished handle. Idempotent per id: a
841
+ * second call (or a race with maxDurationMs auto-stop) returns the same
842
+ * pending/settled result.
843
+ */
844
+ stopRecording(recordingId: string): Promise<RecordingHandle> {
845
+ const rec = this.active.get(recordingId);
846
+ if (!rec) {
847
+ const done = this.finished.get(recordingId);
848
+ if (done) return Promise.resolve(done);
849
+ return Promise.reject(new Error(`no recording ${recordingId}`));
850
+ }
851
+ if (rec.stopPromise) return rec.stopPromise;
852
+
853
+ if (rec.maxDurationTimer) clearTimeout(rec.maxDurationTimer);
854
+ rec.unsubscribeClock();
855
+ rec.cancelResolutionWatch?.();
856
+ rec.cancelResolutionWatch = undefined;
857
+
858
+ if (rec.arming) {
859
+ /* Stopped (e.g. a short maxDurationMs firing) before the arm-wait
860
+ ever let the recorder start: there is nothing to flush from
861
+ MediaRecorder, and calling stop() on a never-started recorder would
862
+ never fire its "stop" event (hanging this promise forever). Settle
863
+ immediately with an empty clip instead. Force is still released
864
+ unconditionally. */
865
+ if (rec.forced) this.releaseForce(rec.flightId);
866
+ const utEnd = this.client.clock.captureUt ?? undefined;
867
+ const handle: RecordingHandle = {
868
+ recordingId,
869
+ flightId: rec.flightId,
870
+ blob: new Blob([], { type: rec.mimeType }),
871
+ mimeType: rec.mimeType,
872
+ utStart: rec.utStart,
873
+ utEnd,
874
+ utSamples: [...rec.utSamples, { t: now() - rec.startedAt, ut: utEnd }],
875
+ byteSize: 0,
876
+ durationMs: now() - rec.startedAt,
877
+ };
878
+ rec.stopPromise = Promise.resolve(handle);
879
+ this.finished.set(recordingId, handle);
880
+ this.active.delete(recordingId);
881
+ this.byFlight.delete(rec.flightId);
882
+ this.rebuild();
883
+ return rec.stopPromise;
884
+ }
885
+
886
+ const utEnd = this.client.clock.captureUt ?? undefined;
887
+ rec.utSamples.push({ t: now() - rec.startedAt, ut: utEnd });
888
+
889
+ rec.stopPromise = new Promise<RecordingHandle>((resolve) => {
890
+ const onStop = (): void => {
891
+ rec.recorder.removeEventListener("stop", onStop);
892
+ const blob = new Blob(rec.chunks, { type: rec.mimeType });
893
+ const handle: RecordingHandle = {
894
+ recordingId,
895
+ flightId: rec.flightId,
896
+ blob,
897
+ mimeType: rec.mimeType,
898
+ utStart: rec.utStart,
899
+ utEnd,
900
+ utSamples: rec.utSamples,
901
+ byteSize: blob.size,
902
+ durationMs: now() - rec.startedAt,
903
+ };
904
+ /* Unconditional: a discarded-while-settling recording still held the
905
+ force ref-count and must release it, even though it won't be
906
+ resurrected into `finished` below. */
907
+ if (rec.forced) this.releaseForce(rec.flightId);
908
+ /* Discarded while this stop was settling: don't resurrect it into
909
+ `finished`, standalone or grouped. Still resolve so a caller
910
+ awaiting this same promise doesn't hang. */
911
+ if (!rec.discarded) {
912
+ this.finished.set(recordingId, handle);
913
+ this.active.delete(recordingId);
914
+ this.byFlight.delete(rec.flightId);
915
+ this.rebuild();
916
+ }
917
+ resolve(handle);
918
+ };
919
+ rec.recorder.addEventListener("stop", onStop);
920
+ rec.recorder.stop();
921
+ });
922
+
923
+ return rec.stopPromise;
924
+ }
925
+
926
+ /** Return a finished recording's handle, or undefined if unknown/still active. */
927
+ fetchRecording(recordingId: string): RecordingHandle | undefined {
928
+ return this.finished.get(recordingId);
929
+ }
930
+
931
+ /** Whether a feed currently has a recording in progress. */
932
+ isRecording(flightId: number): boolean {
933
+ return this.byFlight.has(flightId);
934
+ }
935
+
936
+ /**
937
+ * Drop a recording. If still active, tears it down without producing a
938
+ * handle; if finished, frees the stored handle/blob reference. If the id
939
+ * is a member of a still-active group, pulls it out of that group's member
940
+ * set too, so a later `stopGroupedRecording` only waits on (and stops) the
941
+ * ids that remain -- discarding one member no longer corrupts the group.
942
+ */
943
+ discardRecording(recordingId: string): void {
944
+ const rec = this.active.get(recordingId);
945
+ let changed = false;
946
+ if (rec) {
947
+ if (rec.stopPromise) {
948
+ /* stopRecording already called: the recorder is "inactive" but its
949
+ "stop" event hasn't fired. Flag it so that pending onStop bails
950
+ instead of resurrecting a "discarded" recording once it settles.
951
+ That same onStop releases the force ref-count unconditionally, so
952
+ it must not be released again here. */
953
+ rec.discarded = true;
954
+ } else {
955
+ if (rec.maxDurationTimer) clearTimeout(rec.maxDurationTimer);
956
+ rec.unsubscribeClock();
957
+ rec.cancelResolutionWatch?.();
958
+ rec.cancelResolutionWatch = undefined;
959
+ try {
960
+ if (rec.recorder.state !== "inactive") rec.recorder.stop();
961
+ } catch {
962
+ /* a recorder already torn down by the browser is fine to ignore */
963
+ }
964
+ /* No pending stopRecording to release it later (this is the only
965
+ teardown path this recording will see): release the force
966
+ ref-count now, unconditionally. Covers a discard mid-arm too --
967
+ the recorder above was never started (still "inactive"), so this
968
+ is the only place its force gets released. */
969
+ if (rec.forced) this.releaseForce(rec.flightId);
970
+ }
971
+ this.active.delete(recordingId);
972
+ this.byFlight.delete(rec.flightId);
973
+ changed = true;
974
+ }
975
+ if (this.finished.delete(recordingId)) changed = true;
976
+
977
+ const groupId = this.memberOfGroup.get(recordingId);
978
+ if (groupId != null) {
979
+ this.memberOfGroup.delete(recordingId);
980
+ this.groupMemberIds.delete(recordingId);
981
+ const ids = this.groups.get(groupId);
982
+ if (ids) {
983
+ const idx = ids.indexOf(recordingId);
984
+ if (idx !== -1) {
985
+ ids.splice(idx, 1);
986
+ changed = true;
987
+ }
988
+ }
989
+ }
990
+
991
+ if (changed) this.rebuild();
992
+ }
993
+
994
+ // -------------------------------------------------------------------------
995
+ // Grouped recording
996
+ // -------------------------------------------------------------------------
997
+
998
+ /**
999
+ * Start recording several feeds as one synchronised moment. All recorders
1000
+ * open in a single tick to minimise skew. Returns the groupId plus the real
1001
+ * per-feed recordingIds (so a caller can cancel one member individually via
1002
+ * `stopRecording`/`discardRecording`, the same ids that end up on each
1003
+ * clip's `RecordingHandle`). If any feed can't start (no track / already
1004
+ * recording), the ones already started are discarded and the error
1005
+ * rethrows.
1006
+ */
1007
+ startGroupedRecording(
1008
+ flightIds: number[],
1009
+ opts?: StartRecordingOptions,
1010
+ ): GroupedRecordingStart {
1011
+ const started: string[] = [];
1012
+ /* Suppress the per-member rebuild() each startRecording call would fire:
1013
+ without this, a synchronous subscriber could observe a member as
1014
+ active with groupId undefined (this.groups doesn't have the entry yet).
1015
+ One rebuild happens below, once the group is fully set up. */
1016
+ this.suppressRebuild = true;
1017
+ try {
1018
+ try {
1019
+ for (const flightId of flightIds) {
1020
+ started.push(this.startRecording(flightId, opts, /* inGroup */ true));
1021
+ }
1022
+ } catch (err) {
1023
+ for (const id of started) this.discardRecording(id);
1024
+ throw err;
1025
+ }
1026
+ const groupId = mintId("grp");
1027
+ this.groups.set(groupId, started);
1028
+ for (const id of started) {
1029
+ this.groupMemberIds.add(id);
1030
+ this.memberOfGroup.set(id, groupId);
1031
+ }
1032
+ /* Grouped output is physically trimmed to the common window by default, so
1033
+ load the trim package now (never fail start over a loader error) so it is
1034
+ ready by stop. A single recording never reaches here, so it stays
1035
+ dependency-free. Accepted tradeoff: this eagerly imports Mediabunny even
1036
+ when every feed ends up negotiating webm (no mp4 clip to trim). */
1037
+ this.groupTrimmers.set(
1038
+ groupId,
1039
+ (async (): Promise<GroupTrimmer | null> => {
1040
+ try {
1041
+ return await this.loadTrimmer();
1042
+ } catch {
1043
+ return null;
1044
+ }
1045
+ })(),
1046
+ );
1047
+ return { groupId, recordingIds: [...started] };
1048
+ } finally {
1049
+ this.suppressRebuild = false;
1050
+ this.rebuild();
1051
+ }
1052
+ }
1053
+
1054
+ /**
1055
+ * Stop a grouped recording. With `targetUt`, holds until every feed's latest
1056
+ * UT sample has passed it (coverage guarantee) before stopping any feed;
1057
+ * without it, stops all now. Resolves the grouped handle with the intersected
1058
+ * `commonUtWindow`. See {@link StopGroupedRecordingOptions.targetUt} for the
1059
+ * coverage caveat around a member's own `maxDurationMs`.
1060
+ */
1061
+ async stopGroupedRecording(
1062
+ groupId: string,
1063
+ opts?: StopGroupedRecordingOptions,
1064
+ ): Promise<GroupedRecordingHandle> {
1065
+ const done = this.finishedGroups.get(groupId);
1066
+ if (done) return done;
1067
+ const ids = this.groups.get(groupId);
1068
+ if (!ids) throw new Error(`no grouped recording ${groupId}`);
1069
+
1070
+ if (opts?.targetUt != null) {
1071
+ await this.awaitCoverage(
1072
+ ids,
1073
+ opts.targetUt,
1074
+ opts.coverageTimeoutMs ?? DEFAULT_COVERAGE_TIMEOUT_MS,
1075
+ );
1076
+ }
1077
+
1078
+ /* Capture each forced member's resolution-ready UT before stopRecording
1079
+ tears down its ActiveRecording bookkeeping below. */
1080
+ const resolutionReadyUts: number[] = [];
1081
+ for (const id of ids) {
1082
+ const ut = this.active.get(id)?.resolutionReadyUt;
1083
+ if (ut != null) resolutionReadyUts.push(ut);
1084
+ }
1085
+
1086
+ const recordings = await Promise.all(ids.map((id) => this.stopRecording(id)));
1087
+ const window = commonUtWindow(recordings);
1088
+
1089
+ /* Fold the low-res lead-in cut into the same common-window trim: extend
1090
+ the window's start to the latest point any forced member actually
1091
+ reached full resolution, so every clip stays cut to one shared window
1092
+ (UT-synced) rather than each clip getting its own start. A member that
1093
+ never reached full resolution before stop simply doesn't contribute
1094
+ (its own lead-in stays untrimmed, per the documented edge case). This
1095
+ only widens the physical trim cut: `commonUtWindow` on the returned
1096
+ handle keeps reporting the clips' own raw UT intersection, unchanged,
1097
+ since that's metadata about what the clips cover independent of
1098
+ whether (or how) they got physically cut. */
1099
+ let trimWindow = window;
1100
+ if (trimWindow && resolutionReadyUts.length > 0) {
1101
+ const windowStart = Math.max(trimWindow[0], ...resolutionReadyUts);
1102
+ trimWindow = [windowStart, trimWindow[1]];
1103
+ }
1104
+
1105
+ /* Physically remux-trim each clip to the common window so the grouped
1106
+ output is pre-aligned by default. The UT metadata (utSamples +
1107
+ commonUtWindow) still rides along so a consumer can align the trimmed
1108
+ clips to telemetry. Trimming never fails the recording: any clip that
1109
+ can't be cut cleanly degrades to metadata-only. */
1110
+ let finalRecordings = recordings;
1111
+ if (trimWindow) {
1112
+ const trimmer = await (this.groupTrimmers.get(groupId) ?? Promise.resolve(null));
1113
+ if (trimmer) {
1114
+ finalRecordings = await Promise.all(
1115
+ recordings.map((rec) => this.maybeTrim(rec, trimWindow, trimmer)),
1116
+ );
1117
+ }
1118
+ }
1119
+
1120
+ const handle: GroupedRecordingHandle = {
1121
+ groupId,
1122
+ recordings: finalRecordings,
1123
+ commonUtWindow: window,
1124
+ };
1125
+ this.groups.delete(groupId);
1126
+ this.groupTrimmers.delete(groupId);
1127
+ this.finishedGroups.set(groupId, handle);
1128
+ this.rebuild();
1129
+ return handle;
1130
+ }
1131
+
1132
+ /**
1133
+ * Remux-trim one clip to the common UT window, or return it untouched
1134
+ * (metadata-only) when it can't be cut cleanly: a non-mp4 container, an
1135
+ * unmappable window, or a trimmer error. The stored per-clip handle is
1136
+ * replaced with the trimmed one so fetch/discard stay consistent.
1137
+ */
1138
+ private async maybeTrim(
1139
+ rec: RecordingHandle,
1140
+ window: [number, number],
1141
+ trimmer: GroupTrimmer,
1142
+ ): Promise<RecordingHandle> {
1143
+ /* MediaRecorder webm output isn't remuxed to mp4 here; degrade to
1144
+ metadata-only for those clips (mp4 is the default container). */
1145
+ if (!/mp4/i.test(rec.mimeType)) return rec;
1146
+ const startMs = utToRecordingTimeMs(rec.utSamples, window[0]);
1147
+ const endMs = utToRecordingTimeMs(rec.utSamples, window[1]);
1148
+ if (startMs == null || endMs == null || endMs <= startMs) return rec;
1149
+ try {
1150
+ const blob = await trimmer.trim(rec.blob, rec.mimeType, startMs, endMs);
1151
+ const trimmed: RecordingHandle = {
1152
+ ...rec,
1153
+ blob,
1154
+ byteSize: blob.size,
1155
+ durationMs: endMs - startMs,
1156
+ };
1157
+ this.finished.set(rec.recordingId, trimmed);
1158
+ return trimmed;
1159
+ } catch {
1160
+ return rec;
1161
+ }
1162
+ }
1163
+
1164
+ /** Return a finished grouped recording, or undefined if unknown/still active. */
1165
+ fetchGroupedRecording(groupId: string): GroupedRecordingHandle | undefined {
1166
+ return this.finishedGroups.get(groupId);
1167
+ }
1168
+
1169
+ /** Drop a grouped recording and each of its clips. */
1170
+ discardGroupedRecording(groupId: string): void {
1171
+ /* Accepted gap: racing this against an in-flight stopGroupedRecording(groupId)
1172
+ is untested and unlikely misuse (a caller wouldn't normally discard and
1173
+ stop the same group at once). */
1174
+ let changed = false;
1175
+ const active = this.groups.get(groupId);
1176
+ if (active) {
1177
+ /* discardRecording now splices ids out of this very array as it goes
1178
+ (fix for cancelling one grouped member); iterate a copy so removals
1179
+ mid-loop can't skip an id. */
1180
+ for (const id of [...active]) this.discardRecording(id);
1181
+ this.groups.delete(groupId);
1182
+ this.groupTrimmers.delete(groupId);
1183
+ changed = true;
1184
+ }
1185
+ const finished = this.finishedGroups.get(groupId);
1186
+ if (finished) {
1187
+ for (const rec of finished.recordings) this.discardRecording(rec.recordingId);
1188
+ this.finishedGroups.delete(groupId);
1189
+ changed = true;
1190
+ }
1191
+ if (changed) this.rebuild();
1192
+ }
1193
+
1194
+ /**
1195
+ * Resolve once every listed recording's latest UT sample has reached
1196
+ * `targetUt`. UT arrives on the shared ~1Hz clock feed, so this polls the
1197
+ * settings-change stream rather than a wall-clock timer. Bounded by
1198
+ * `timeoutMs`: if coverage never arrives (feed out of flight, sim frozen,
1199
+ * warp reversed), gives up and resolves anyway so the group finalizes with
1200
+ * whatever UT each feed has captured, rather than hanging forever. Always
1201
+ * releases the settings-change subscription, on either exit path.
1202
+ */
1203
+ private awaitCoverage(
1204
+ recordingIds: string[],
1205
+ targetUt: number,
1206
+ timeoutMs: number,
1207
+ ): Promise<void> {
1208
+ const covered = (): boolean =>
1209
+ recordingIds.every((id) => {
1210
+ const rec = this.active.get(id);
1211
+ /* A recording already stopped counts as covered (nothing to wait on). */
1212
+ if (!rec) return true;
1213
+ return rec.latestUt != null && rec.latestUt >= targetUt;
1214
+ });
1215
+
1216
+ if (covered()) return Promise.resolve();
1217
+
1218
+ return new Promise<void>((resolve) => {
1219
+ let settled = false;
1220
+ const finish = (): void => {
1221
+ if (settled) return;
1222
+ settled = true;
1223
+ clearTimeout(timer);
1224
+ unsubscribe();
1225
+ resolve();
1226
+ };
1227
+ const unsubscribe = this.client.on("settings-change", () => {
1228
+ if (covered()) finish();
1229
+ });
1230
+ const timer = setTimeout(finish, timeoutMs);
1231
+ });
1232
+ }
1233
+ }