@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,1471 @@
1
+ /*
2
+ * Client-side recording controller.
3
+ *
4
+ * Drives the controller against a hand-rolled RecordingClient (the minimal
5
+ * clock + camera + settings-change surface it needs) and the SDK's
6
+ * StubMediaRecorder, so the whole start -> UT-sample -> stop -> handle ->
7
+ * fetch -> Blob path runs headless. Covers the per-feed state machine, UT
8
+ * sampling (incl. the no-UT fallback), mime negotiation, and the grouped
9
+ * coverage-guarantee + commonUtWindow intersection.
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+ import type { CameraState } from "./__generated__/types";
14
+ import {
15
+ ARM_TIMEOUT_MS,
16
+ RecordingController,
17
+ commonUtWindow,
18
+ createMediabunnyTrimmerLoader,
19
+ negotiateMimeType,
20
+ utToRecordingTimeMs,
21
+ type GroupTrimmer,
22
+ type RecordingClient,
23
+ type RecordingClientCamera,
24
+ type RecordingHandle,
25
+ type RecordingsSnapshot,
26
+ } from "./recording";
27
+ import { installDomStubs, StubMediaRecorder } from "./testing/index";
28
+
29
+ /**
30
+ * A MediaRecorder stand-in whose stop() defers the "stop"/"dataavailable"
31
+ * events to a later, manually-triggered tick -- modelling the real
32
+ * MediaRecorder, where `state` flips to "inactive" synchronously on stop()
33
+ * but the events fire asynchronously. StubMediaRecorder (the SDK's usual
34
+ * test double) fires them synchronously, which can't exercise the
35
+ * discard-while-settling race this file tests.
36
+ */
37
+ class DeferredStubMediaRecorder {
38
+ static supportedTypes: string[] | null = null;
39
+ static isTypeSupported(type: string): boolean {
40
+ return DeferredStubMediaRecorder.supportedTypes === null
41
+ ? true
42
+ : DeferredStubMediaRecorder.supportedTypes.includes(type);
43
+ }
44
+ /** Queued stop flushes, oldest first; call and pop to settle one. */
45
+ static pendingStops: (() => void)[] = [];
46
+
47
+ state: "inactive" | "recording" | "paused" = "inactive";
48
+ readonly mimeType: string;
49
+ private readonly listeners = new Map<string, Set<(e: { data?: Blob }) => void>>();
50
+
51
+ constructor(_stream: MediaStream, options?: { mimeType?: string }) {
52
+ this.mimeType = options?.mimeType ?? "";
53
+ }
54
+
55
+ addEventListener(type: string, cb: (e: { data?: Blob }) => void): void {
56
+ let set = this.listeners.get(type);
57
+ if (!set) {
58
+ set = new Set();
59
+ this.listeners.set(type, set);
60
+ }
61
+ set.add(cb);
62
+ }
63
+
64
+ removeEventListener(type: string, cb: (e: { data?: Blob }) => void): void {
65
+ this.listeners.get(type)?.delete(cb);
66
+ }
67
+
68
+ private emit(type: string, e: { data?: Blob }): void {
69
+ this.listeners.get(type)?.forEach((cb) => cb(e));
70
+ }
71
+
72
+ start(): void {
73
+ this.state = "recording";
74
+ }
75
+
76
+ stop(): void {
77
+ if (this.state === "inactive") return;
78
+ this.state = "inactive"; // flips synchronously, same as the real API
79
+ DeferredStubMediaRecorder.pendingStops.push(() => {
80
+ const data = new Blob([new Uint8Array(8)], { type: this.mimeType || "video/webm" });
81
+ this.emit("dataavailable", { data });
82
+ this.emit("stop", {});
83
+ });
84
+ }
85
+ }
86
+
87
+ installDomStubs();
88
+
89
+ /** A stream whose getVideoTracks() reports one live video track. */
90
+ function liveStream(): MediaStream {
91
+ const track = { kind: "video" } as MediaStreamTrack;
92
+ return {
93
+ getVideoTracks: () => [track],
94
+ getTracks: () => [track],
95
+ } as unknown as MediaStream;
96
+ }
97
+
98
+ /**
99
+ * Minimal `CameraState` fixture with every required field filled in with a
100
+ * plausible default; override just the fields a test cares about (usually
101
+ * `renderWidth`/`renderHeight`/`operatorWidth`/`operatorHeight`).
102
+ */
103
+ function makeCameraState(overrides: Partial<CameraState> = {}): CameraState {
104
+ return {
105
+ flightId: 0,
106
+ partName: "part",
107
+ partTitle: "Part",
108
+ cameraName: "cam",
109
+ vesselName: "Vessel",
110
+ layers: [],
111
+ operatorLayers: [],
112
+ renderWidth: 320,
113
+ renderHeight: 240,
114
+ operatorWidth: 320,
115
+ operatorHeight: 240,
116
+ supportsZoom: false,
117
+ fov: 60,
118
+ fovMin: 10,
119
+ fovMax: 90,
120
+ supportsPan: false,
121
+ panYaw: 0,
122
+ panPitch: 0,
123
+ panYawMin: 0,
124
+ panYawMax: 0,
125
+ panPitchMin: 0,
126
+ panPitchMax: 0,
127
+ encoderBitrateBps: 0,
128
+ targetBitrateBps: 0,
129
+ degradeLevel: 0,
130
+ ...overrides,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * A per-flight camera fake: a settable `mediaStream`/`maxRenderSize`, a
136
+ * `setForceFullResolution` call log, and an `emitState` test hook that
137
+ * mirrors a live `camera-state-changed` push (mutates `state`, notifies
138
+ * `on("change")` subscribers). Stable per flightId across repeated
139
+ * `client.camera(flightId)` calls, as the real client's handle is.
140
+ */
141
+ class FakeCamera implements RecordingClientCamera {
142
+ mediaStream: MediaStream | null;
143
+ state: CameraState | null = null;
144
+ maxRenderSize: { width: number; height: number } | null = null;
145
+ readonly forceCalls: boolean[] = [];
146
+ /** When > 0, the next N calls to setForceFullResolution reject instead of
147
+ * resolving (consumed one per call), to simulate a dropped send. */
148
+ forceRejectCount = 0;
149
+ private readonly listeners = new Set<(state: CameraState) => void>();
150
+
151
+ constructor(stream: MediaStream | null) {
152
+ this.mediaStream = stream;
153
+ }
154
+
155
+ async setForceFullResolution(force: boolean): Promise<void> {
156
+ this.forceCalls.push(force);
157
+ if (this.forceRejectCount > 0) {
158
+ this.forceRejectCount -= 1;
159
+ throw new Error("simulated setForceFullResolution failure");
160
+ }
161
+ }
162
+
163
+ on(_event: "change", handler: (state: CameraState) => void): () => void {
164
+ this.listeners.add(handler);
165
+ return () => this.listeners.delete(handler);
166
+ }
167
+
168
+ /** Test hook: push a new state and notify subscribers. */
169
+ emitState(state: CameraState): void {
170
+ this.state = state;
171
+ for (const l of [...this.listeners]) l(state);
172
+ }
173
+
174
+ /** Live "change" subscription count, to assert a wait releases its listener. */
175
+ get changeListenerCount(): number {
176
+ return this.listeners.size;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Minimal RecordingClient: a settable global capture clock and a per-flight
182
+ * media stream. `tick(ut)` sets the clock and fires settings-change, modelling
183
+ * the sidecar's ~1Hz push.
184
+ */
185
+ class FakeRecordingClient implements RecordingClient {
186
+ captureUt: number | null = null;
187
+ private readonly streams = new Map<number, MediaStream | null>();
188
+ private readonly cams = new Map<number, FakeCamera>();
189
+ private readonly listeners = new Set<(data: unknown) => void>();
190
+
191
+ setStream(flightId: number, stream: MediaStream | null): void {
192
+ this.streams.set(flightId, stream);
193
+ const cam = this.cams.get(flightId);
194
+ if (cam) cam.mediaStream = stream;
195
+ }
196
+
197
+ camera(flightId: number): FakeCamera {
198
+ let cam = this.cams.get(flightId);
199
+ if (!cam) {
200
+ cam = new FakeCamera(this.streams.get(flightId) ?? null);
201
+ this.cams.set(flightId, cam);
202
+ }
203
+ return cam;
204
+ }
205
+
206
+ get clock(): { readonly captureUt: number | null } {
207
+ return { captureUt: this.captureUt };
208
+ }
209
+
210
+ on(_event: "settings-change", handler: (data: unknown) => void): () => void {
211
+ this.listeners.add(handler);
212
+ return () => this.listeners.delete(handler);
213
+ }
214
+
215
+ /** Advance the shared capture clock and fire a settings-change push. */
216
+ tick(ut: number | null): void {
217
+ this.captureUt = ut;
218
+ for (const h of [...this.listeners]) h(undefined);
219
+ }
220
+
221
+ /** Live settings-change subscription count, to assert a wait releases its subscription. */
222
+ get listenerCount(): number {
223
+ return this.listeners.size;
224
+ }
225
+ }
226
+
227
+ const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
228
+
229
+ afterEach(() => {
230
+ StubMediaRecorder.supportedTypes = null;
231
+ });
232
+
233
+ describe("negotiateMimeType", () => {
234
+ beforeEach(() => {
235
+ StubMediaRecorder.supportedTypes = null;
236
+ });
237
+
238
+ it("prefers mp4/avc1 when supported", () => {
239
+ expect(negotiateMimeType()).toBe("video/mp4;codecs=avc1");
240
+ });
241
+
242
+ it("falls back to webm/vp9 when mp4 is unsupported", () => {
243
+ StubMediaRecorder.supportedTypes = ["video/webm;codecs=vp9", "video/webm"];
244
+ expect(negotiateMimeType()).toBe("video/webm;codecs=vp9");
245
+ });
246
+
247
+ it("falls back to plain webm when only webm is supported", () => {
248
+ StubMediaRecorder.supportedTypes = ["video/webm"];
249
+ expect(negotiateMimeType()).toBe("video/webm");
250
+ });
251
+
252
+ it("tries a caller-supplied preferred type first", () => {
253
+ StubMediaRecorder.supportedTypes = ["video/webm", "video/x-matroska"];
254
+ expect(negotiateMimeType("video/x-matroska")).toBe("video/x-matroska");
255
+ });
256
+
257
+ it("skips the preferred type when it is unsupported", () => {
258
+ StubMediaRecorder.supportedTypes = ["video/webm"];
259
+ expect(negotiateMimeType("video/x-unsupported")).toBe("video/webm");
260
+ });
261
+ });
262
+
263
+ describe("commonUtWindow", () => {
264
+ const handle = (utStart?: number, utEnd?: number): RecordingHandle =>
265
+ ({ utStart, utEnd } as RecordingHandle);
266
+
267
+ it("intersects overlapping ranges to [max start, min end]", () => {
268
+ expect(commonUtWindow([handle(100, 130), handle(102, 128)])).toEqual([102, 128]);
269
+ });
270
+
271
+ it("is undefined when a clip lacks a full range", () => {
272
+ expect(commonUtWindow([handle(100, 130), handle(undefined, 128)])).toBeUndefined();
273
+ });
274
+
275
+ it("is undefined when the ranges do not overlap", () => {
276
+ expect(commonUtWindow([handle(100, 110), handle(120, 130)])).toBeUndefined();
277
+ });
278
+
279
+ it("is undefined for an empty set", () => {
280
+ expect(commonUtWindow([])).toBeUndefined();
281
+ });
282
+ });
283
+
284
+ describe("RecordingController per-feed", () => {
285
+ let client: FakeRecordingClient;
286
+ let ctrl: RecordingController;
287
+
288
+ beforeEach(() => {
289
+ client = new FakeRecordingClient();
290
+ client.setStream(42, liveStream());
291
+ ctrl = new RecordingController(client);
292
+ });
293
+
294
+ it("records start -> stop -> fetch -> Blob", async () => {
295
+ client.captureUt = 100;
296
+ const id = ctrl.startRecording(42);
297
+ expect(ctrl.isRecording(42)).toBe(true);
298
+
299
+ client.captureUt = 103;
300
+ const handle = await ctrl.stopRecording(id);
301
+
302
+ expect(handle.recordingId).toBe(id);
303
+ expect(handle.blob).toBeInstanceOf(Blob);
304
+ expect(handle.byteSize).toBeGreaterThan(0);
305
+ expect(handle.blob.size).toBe(handle.byteSize);
306
+ expect(handle.mimeType).toBe("video/mp4;codecs=avc1");
307
+ expect(handle.durationMs).toBeGreaterThanOrEqual(0);
308
+ expect(ctrl.isRecording(42)).toBe(false);
309
+ expect(ctrl.fetchRecording(id)).toBe(handle);
310
+ });
311
+
312
+ it("puts the negotiated mimeType on the handle and the blob", async () => {
313
+ StubMediaRecorder.supportedTypes = ["video/webm"];
314
+ const id = ctrl.startRecording(42);
315
+ const handle = await ctrl.stopRecording(id);
316
+ expect(handle.mimeType).toBe("video/webm");
317
+ expect(handle.blob.type).toBe("video/webm");
318
+ });
319
+
320
+ it("rejects a second recording of a feed already recording", () => {
321
+ ctrl.startRecording(42);
322
+ expect(() => ctrl.startRecording(42)).toThrow(/already recording/);
323
+ });
324
+
325
+ it("rejects recording a feed with no live track", () => {
326
+ client.setStream(7, null);
327
+ expect(() => ctrl.startRecording(7)).toThrow(/no live track/);
328
+ });
329
+
330
+ it("allows re-recording a feed after it has stopped", async () => {
331
+ const id1 = ctrl.startRecording(42);
332
+ await ctrl.stopRecording(id1);
333
+ const id2 = ctrl.startRecording(42);
334
+ expect(id2).not.toBe(id1);
335
+ await ctrl.stopRecording(id2);
336
+ });
337
+
338
+ it("captures utStart, utEnd, and a continuous utSamples series", async () => {
339
+ client.captureUt = 100;
340
+ const id = ctrl.startRecording(42);
341
+
342
+ client.tick(101);
343
+ client.tick(102);
344
+ client.captureUt = 103;
345
+ const handle = await ctrl.stopRecording(id);
346
+
347
+ expect(handle.utStart).toBe(100);
348
+ expect(handle.utEnd).toBe(103);
349
+ /* start sample + two ticks + stop sample. */
350
+ const uts = handle.utSamples.map((s) => s.ut);
351
+ expect(uts).toEqual([100, 101, 102, 103]);
352
+ /* t is ms-since-start and starts at 0. */
353
+ expect(handle.utSamples[0].t).toBe(0);
354
+ for (const s of handle.utSamples) expect(s.t).toBeGreaterThanOrEqual(0);
355
+ });
356
+
357
+ it("falls back to wall-clock when out of flight and upgrades when UT appears", async () => {
358
+ /* No clock at start: utStart absent, samples carry t only. */
359
+ client.captureUt = null;
360
+ const id = ctrl.startRecording(42);
361
+ client.tick(null);
362
+ /* UT appears mid-recording: later samples carry it. */
363
+ client.tick(200);
364
+ const handle = await ctrl.stopRecording(id);
365
+
366
+ expect(handle.utStart).toBeUndefined();
367
+ expect(handle.utEnd).toBe(200);
368
+ expect(handle.utSamples[0].ut).toBeUndefined();
369
+ expect(handle.utSamples.some((s) => s.ut === 200)).toBe(true);
370
+ /* Every sample still has a monotonic-ish recording time. */
371
+ expect(handle.utSamples[0].t).toBe(0);
372
+ });
373
+
374
+ it("auto-stops at maxDurationMs and leaves the clip fetchable", async () => {
375
+ client.captureUt = 50;
376
+ const id = ctrl.startRecording(42, { maxDurationMs: 5 });
377
+ await new Promise((r) => setTimeout(r, 30));
378
+ expect(ctrl.isRecording(42)).toBe(false);
379
+ const handle = ctrl.fetchRecording(id);
380
+ expect(handle?.recordingId).toBe(id);
381
+ });
382
+
383
+ it("discards an active recording without producing a handle", () => {
384
+ const id = ctrl.startRecording(42);
385
+ ctrl.discardRecording(id);
386
+ expect(ctrl.isRecording(42)).toBe(false);
387
+ expect(ctrl.fetchRecording(id)).toBeUndefined();
388
+ });
389
+
390
+ it("discards a finished recording, freeing its handle", async () => {
391
+ const id = ctrl.startRecording(42);
392
+ await ctrl.stopRecording(id);
393
+ ctrl.discardRecording(id);
394
+ expect(ctrl.fetchRecording(id)).toBeUndefined();
395
+ });
396
+
397
+ it("stopping an unknown recording rejects", async () => {
398
+ await expect(ctrl.stopRecording("nope")).rejects.toThrow(/no recording/);
399
+ });
400
+
401
+ it("carries the recording's flightId on the finished handle", async () => {
402
+ const id = ctrl.startRecording(42);
403
+ const handle = await ctrl.stopRecording(id);
404
+ expect(handle.flightId).toBe(42);
405
+ });
406
+
407
+ it("does not resurrect a recording discarded while its stop event is still settling", async () => {
408
+ const original = (globalThis as { MediaRecorder?: unknown }).MediaRecorder;
409
+ (globalThis as { MediaRecorder?: unknown }).MediaRecorder = DeferredStubMediaRecorder;
410
+ try {
411
+ const id = ctrl.startRecording(42);
412
+ const stopPromise = ctrl.stopRecording(id);
413
+ /* recorder.state is already "inactive" here (set synchronously by
414
+ stop()), but the "stop" event hasn't fired yet -- exactly the window
415
+ where a discard used to get silently undone. */
416
+ ctrl.discardRecording(id);
417
+
418
+ expect(DeferredStubMediaRecorder.pendingStops).toHaveLength(1);
419
+ DeferredStubMediaRecorder.pendingStops.pop()!();
420
+ await stopPromise;
421
+
422
+ expect(ctrl.fetchRecording(id)).toBeUndefined();
423
+ expect(ctrl.getSnapshot().recordings.some((r) => r.recordingId === id)).toBe(false);
424
+ } finally {
425
+ (globalThis as { MediaRecorder?: unknown }).MediaRecorder = original;
426
+ DeferredStubMediaRecorder.pendingStops = [];
427
+ }
428
+ });
429
+ });
430
+
431
+ describe("RecordingController force-full-resolution", () => {
432
+ let client: FakeRecordingClient;
433
+ let ctrl: RecordingController;
434
+ let trimCalls: { startMs: number; endMs: number; mimeType: string }[];
435
+ let fakeTrimmer: GroupTrimmer;
436
+
437
+ beforeEach(() => {
438
+ client = new FakeRecordingClient();
439
+ for (const flightId of [1, 2, 42, 43]) client.setStream(flightId, liveStream());
440
+ trimCalls = [];
441
+ fakeTrimmer = {
442
+ async trim(_blob, mimeType, startMs, endMs): Promise<Blob> {
443
+ trimCalls.push({ startMs, endMs, mimeType });
444
+ return new Blob([new Uint8Array(16)], { type: mimeType });
445
+ },
446
+ };
447
+ ctrl = new RecordingController(client, { loadTrimmer: () => Promise.resolve(fakeTrimmer) });
448
+ });
449
+
450
+ describe("single (arm-and-wait)", () => {
451
+ it("sends force and shows arming immediately", () => {
452
+ const cam = client.camera(42);
453
+ const id = ctrl.startRecording(42, { forceFullResolution: true });
454
+
455
+ expect(cam.forceCalls).toEqual([true]);
456
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(true);
457
+ });
458
+
459
+ it("delays recorder.start() until the feed reaches full resolution, ignoring a stale pre-force snapshot (pitfall 1)", async () => {
460
+ const cam = client.camera(42);
461
+ cam.maxRenderSize = { width: 1920, height: 1080 };
462
+ cam.emitState(
463
+ makeCameraState({
464
+ flightId: 42,
465
+ renderWidth: 160,
466
+ renderHeight: 120,
467
+ operatorWidth: 160,
468
+ operatorHeight: 120,
469
+ }),
470
+ );
471
+
472
+ const id = ctrl.startRecording(42, { forceFullResolution: true });
473
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(true);
474
+
475
+ /* A stray broadcast still reflecting the pre-force (small) size must
476
+ not be mistaken for readiness. */
477
+ cam.emitState(
478
+ makeCameraState({
479
+ flightId: 42,
480
+ renderWidth: 160,
481
+ renderHeight: 120,
482
+ operatorWidth: 160,
483
+ operatorHeight: 120,
484
+ }),
485
+ );
486
+ await flush();
487
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(true);
488
+
489
+ /* The genuine post-force broadcast: operator + render both at the
490
+ ceiling. */
491
+ cam.emitState(
492
+ makeCameraState({
493
+ flightId: 42,
494
+ renderWidth: 1920,
495
+ renderHeight: 1080,
496
+ operatorWidth: 1920,
497
+ operatorHeight: 1080,
498
+ }),
499
+ );
500
+ await flush();
501
+
502
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(false);
503
+ const handle = await ctrl.stopRecording(id);
504
+ /* The recorder actually started and captured frames. */
505
+ expect(handle.byteSize).toBeGreaterThan(0);
506
+ });
507
+
508
+ it("ignores a stray duplicate of the baseline size even when the ceiling is unknown to the client (pitfall 1, no maxRenderSize)", async () => {
509
+ const cam = client.camera(42);
510
+ cam.maxRenderSize = null;
511
+ cam.emitState(
512
+ makeCameraState({
513
+ flightId: 42,
514
+ renderWidth: 160,
515
+ renderHeight: 120,
516
+ operatorWidth: 160,
517
+ operatorHeight: 120,
518
+ }),
519
+ );
520
+
521
+ const id = ctrl.startRecording(42, { forceFullResolution: true });
522
+
523
+ /* Duplicate of the pre-force baseline: not an increase, must not ready. */
524
+ cam.emitState(
525
+ makeCameraState({
526
+ flightId: 42,
527
+ renderWidth: 160,
528
+ renderHeight: 120,
529
+ operatorWidth: 160,
530
+ operatorHeight: 120,
531
+ }),
532
+ );
533
+ await flush();
534
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(true);
535
+
536
+ /* A genuine increase past the baseline, caught up. */
537
+ cam.emitState(
538
+ makeCameraState({
539
+ flightId: 42,
540
+ renderWidth: 800,
541
+ renderHeight: 600,
542
+ operatorWidth: 800,
543
+ operatorHeight: 600,
544
+ }),
545
+ );
546
+ await flush();
547
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(false);
548
+ });
549
+
550
+ it("does not treat the first post-force broadcast as ready when no state was observed before watch-start (unknown ceiling, no prior discover())", async () => {
551
+ const cam = client.camera(42);
552
+ cam.maxRenderSize = null;
553
+ /* No emitState before startRecording: cam.state is genuinely null,
554
+ unlike the "stray duplicate" test above where a pre-force state was
555
+ already observed. This is the reachable no-discover() case: state
556
+ (data channel) and the track (RTP) are independent transports with
557
+ no ordering guarantee. */
558
+ expect(cam.state).toBeNull();
559
+
560
+ const id = ctrl.startRecording(42, { forceFullResolution: true });
561
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(true);
562
+
563
+ /* The FIRST state the client ever observes still reports the small
564
+ pre-force size. A {0,0} placeholder baseline would treat any
565
+ operatorWidth > 0 as "ready"; it must instead become the baseline. */
566
+ cam.emitState(
567
+ makeCameraState({
568
+ flightId: 42,
569
+ renderWidth: 160,
570
+ renderHeight: 120,
571
+ operatorWidth: 160,
572
+ operatorHeight: 120,
573
+ }),
574
+ );
575
+ await flush();
576
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(true);
577
+
578
+ /* A repeat of that same (now-baseline) size still must not ready. */
579
+ cam.emitState(
580
+ makeCameraState({
581
+ flightId: 42,
582
+ renderWidth: 160,
583
+ renderHeight: 120,
584
+ operatorWidth: 160,
585
+ operatorHeight: 120,
586
+ }),
587
+ );
588
+ await flush();
589
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(true);
590
+
591
+ /* A genuine increase past the established baseline, caught up. */
592
+ cam.emitState(
593
+ makeCameraState({
594
+ flightId: 42,
595
+ renderWidth: 800,
596
+ renderHeight: 600,
597
+ operatorWidth: 800,
598
+ operatorHeight: 600,
599
+ }),
600
+ );
601
+ await flush();
602
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(false);
603
+ });
604
+
605
+ it("starts promptly when the feed is already at its ceiling when forced (pitfall 2)", async () => {
606
+ const cam = client.camera(42);
607
+ cam.maxRenderSize = { width: 1920, height: 1080 };
608
+ cam.emitState(
609
+ makeCameraState({
610
+ flightId: 42,
611
+ renderWidth: 1920,
612
+ renderHeight: 1080,
613
+ operatorWidth: 1920,
614
+ operatorHeight: 1080,
615
+ }),
616
+ );
617
+
618
+ const id = ctrl.startRecording(42, { forceFullResolution: true });
619
+ /* Resolves on a microtask, not the multi-second arm timeout. */
620
+ await flush();
621
+
622
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(false);
623
+ const handle = await ctrl.stopRecording(id);
624
+ expect(handle.byteSize).toBeGreaterThan(0);
625
+ });
626
+
627
+ it("starts anyway after ARM_TIMEOUT_MS when the feed never reaches full resolution", async () => {
628
+ vi.useFakeTimers();
629
+ try {
630
+ const cam = client.camera(42);
631
+ cam.maxRenderSize = { width: 1920, height: 1080 };
632
+ cam.emitState(
633
+ makeCameraState({
634
+ flightId: 42,
635
+ renderWidth: 160,
636
+ renderHeight: 120,
637
+ operatorWidth: 160,
638
+ operatorHeight: 120,
639
+ }),
640
+ );
641
+
642
+ const id = ctrl.startRecording(42, { forceFullResolution: true });
643
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(true);
644
+
645
+ await vi.advanceTimersByTimeAsync(ARM_TIMEOUT_MS);
646
+
647
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(false);
648
+ } finally {
649
+ vi.useRealTimers();
650
+ }
651
+ });
652
+ });
653
+
654
+ describe("grouped (record immediately)", () => {
655
+ it("starts every member's recorder immediately, without arming", () => {
656
+ const cam1 = client.camera(1);
657
+ const cam2 = client.camera(2);
658
+
659
+ const { recordingIds } = ctrl.startGroupedRecording([1, 2], {
660
+ forceFullResolution: true,
661
+ });
662
+
663
+ expect(cam1.forceCalls).toEqual([true]);
664
+ expect(cam2.forceCalls).toEqual([true]);
665
+ const active = ctrl.getSnapshot().active;
666
+ expect(active).toHaveLength(2);
667
+ expect(active.every((a) => a.arming === false)).toBe(true);
668
+ expect(recordingIds).toHaveLength(2);
669
+ });
670
+
671
+ it("captures each member's resolution-ready UT and folds it into the common trim window", async () => {
672
+ client.captureUt = 500;
673
+ /* maxRenderSize must be set BEFORE starting the group: watchResolutionReady
674
+ reads it synchronously at watch-start, so setting it afterwards would
675
+ silently exercise the (weaker) unknown-ceiling fallback instead of the
676
+ known-ceiling path this test is meant to cover. */
677
+ const cam1 = client.camera(1);
678
+ const cam2 = client.camera(2);
679
+ cam1.maxRenderSize = { width: 1920, height: 1080 };
680
+ cam2.maxRenderSize = { width: 1920, height: 1080 };
681
+ const { groupId, recordingIds } = ctrl.startGroupedRecording([1, 2], {
682
+ forceFullResolution: true,
683
+ });
684
+ const [id1, id2] = recordingIds;
685
+
686
+ /* Feed 1 reaches full resolution quickly. */
687
+ client.tick(502);
688
+ cam1.emitState(
689
+ makeCameraState({
690
+ flightId: 1,
691
+ renderWidth: 1920,
692
+ renderHeight: 1080,
693
+ operatorWidth: 1920,
694
+ operatorHeight: 1080,
695
+ }),
696
+ );
697
+ await flush();
698
+
699
+ /* Feed 2 reaches full resolution later; this later UT should win the fold. */
700
+ client.tick(506);
701
+ cam2.emitState(
702
+ makeCameraState({
703
+ flightId: 2,
704
+ renderWidth: 1920,
705
+ renderHeight: 1080,
706
+ operatorWidth: 1920,
707
+ operatorHeight: 1080,
708
+ }),
709
+ );
710
+ await flush();
711
+
712
+ client.captureUt = 510;
713
+ const handle = await ctrl.stopGroupedRecording(groupId);
714
+
715
+ expect(handle.commonUtWindow).toEqual([500, 510]);
716
+ expect(trimCalls).toHaveLength(2);
717
+
718
+ /* recordings/trimCalls preserve the [id1, id2] order Promise.all was
719
+ given, regardless of which member's trim settles first. */
720
+ const rec1 = handle.recordings.find((r) => r.recordingId === id1)!;
721
+ const rec2 = handle.recordings.find((r) => r.recordingId === id2)!;
722
+ const [call1, call2] = trimCalls;
723
+ /* The window start used is the LATEST resolution-ready UT (506), not
724
+ the raw common window start (500), for both clips alike (one common
725
+ window, UT-synced). */
726
+ expect(call1.startMs).toBe(utToRecordingTimeMs(rec1.utSamples, 506));
727
+ expect(call2.startMs).toBe(utToRecordingTimeMs(rec2.utSamples, 506));
728
+ /* Confirms the fold actually moved the start later than the untouched
729
+ common window would have. */
730
+ expect(call1.startMs).toBeGreaterThan(utToRecordingTimeMs(rec1.utSamples, 500)!);
731
+ });
732
+
733
+ it("leaves the lead-in untrimmed when no member ever reaches full resolution", async () => {
734
+ client.captureUt = 100;
735
+ const { groupId } = ctrl.startGroupedRecording([1, 2], { forceFullResolution: true });
736
+ client.captureUt = 105;
737
+ const handle = await ctrl.stopGroupedRecording(groupId);
738
+
739
+ expect(handle.commonUtWindow).toEqual([100, 105]);
740
+ expect(trimCalls).toHaveLength(2);
741
+ for (const call of trimCalls) {
742
+ expect(call.startMs).toBe(utToRecordingTimeMs(handle.recordings[0].utSamples, 100));
743
+ }
744
+ });
745
+ });
746
+
747
+ describe("release / ref-counting", () => {
748
+ it("releases force on stopRecording", async () => {
749
+ const cam = client.camera(42);
750
+ cam.maxRenderSize = { width: 320, height: 240 };
751
+ cam.emitState(makeCameraState({ flightId: 42 })); // matches the fixture's default (already "full")
752
+ const id = ctrl.startRecording(42, { forceFullResolution: true });
753
+ await flush();
754
+ await ctrl.stopRecording(id);
755
+ expect(cam.forceCalls).toEqual([true, false]);
756
+ });
757
+
758
+ it("releases force on discardRecording", async () => {
759
+ const cam = client.camera(42);
760
+ cam.maxRenderSize = { width: 320, height: 240 };
761
+ cam.emitState(makeCameraState({ flightId: 42 }));
762
+ const id = ctrl.startRecording(42, { forceFullResolution: true });
763
+ await flush();
764
+ ctrl.discardRecording(id);
765
+ expect(cam.forceCalls).toEqual([true, false]);
766
+ });
767
+
768
+ it("releases force when a maxDurationMs auto-stop fires after the recorder has started", async () => {
769
+ const cam = client.camera(42);
770
+ cam.maxRenderSize = { width: 320, height: 240 };
771
+ cam.emitState(makeCameraState({ flightId: 42 }));
772
+ ctrl.startRecording(42, { forceFullResolution: true, maxDurationMs: 5 });
773
+ await new Promise((r) => setTimeout(r, 30));
774
+
775
+ expect(ctrl.isRecording(42)).toBe(false);
776
+ expect(cam.forceCalls).toEqual([true, false]);
777
+ });
778
+
779
+ it("releases force cleanly when a maxDurationMs auto-stop fires while still arming", async () => {
780
+ const cam = client.camera(42);
781
+ cam.maxRenderSize = { width: 1920, height: 1080 };
782
+ cam.emitState(
783
+ makeCameraState({
784
+ flightId: 42,
785
+ renderWidth: 160,
786
+ renderHeight: 120,
787
+ operatorWidth: 160,
788
+ operatorHeight: 120,
789
+ }),
790
+ ); // never reaches full on its own
791
+
792
+ const id = ctrl.startRecording(42, {
793
+ forceFullResolution: true,
794
+ maxDurationMs: 5,
795
+ });
796
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(true);
797
+
798
+ await new Promise((r) => setTimeout(r, 30));
799
+
800
+ expect(ctrl.isRecording(42)).toBe(false);
801
+ expect(cam.forceCalls).toEqual([true, false]);
802
+ const handle = ctrl.fetchRecording(id);
803
+ expect(handle?.recordingId).toBe(id);
804
+ /* The recorder was never actually started. */
805
+ expect(handle?.byteSize).toBe(0);
806
+ });
807
+
808
+ it("discarding mid-arm releases force and never starts the recorder", async () => {
809
+ const cam = client.camera(42);
810
+ cam.maxRenderSize = { width: 1920, height: 1080 };
811
+ cam.emitState(
812
+ makeCameraState({
813
+ flightId: 42,
814
+ renderWidth: 160,
815
+ renderHeight: 120,
816
+ operatorWidth: 160,
817
+ operatorHeight: 120,
818
+ }),
819
+ );
820
+
821
+ const id = ctrl.startRecording(42, { forceFullResolution: true });
822
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(true);
823
+
824
+ ctrl.discardRecording(id);
825
+
826
+ expect(cam.forceCalls).toEqual([true, false]);
827
+ expect(ctrl.isRecording(42)).toBe(false);
828
+ /* The arm-wait's "change" subscription was cancelled, not leaked. */
829
+ expect(cam.changeListenerCount).toBe(0);
830
+
831
+ /* Even if the feed reaches full resolution after the discard, nothing
832
+ resurrects this recording or starts its recorder. */
833
+ cam.emitState(
834
+ makeCameraState({
835
+ flightId: 42,
836
+ renderWidth: 1920,
837
+ renderHeight: 1080,
838
+ operatorWidth: 1920,
839
+ operatorHeight: 1080,
840
+ }),
841
+ );
842
+ await flush();
843
+ expect(ctrl.fetchRecording(id)).toBeUndefined();
844
+ expect(ctrl.getSnapshot().active).toHaveLength(0);
845
+ });
846
+
847
+ it("releases force only on the second stop of two overlapping recordings of the same feed", async () => {
848
+ const original = (globalThis as { MediaRecorder?: unknown }).MediaRecorder;
849
+ (globalThis as { MediaRecorder?: unknown }).MediaRecorder = DeferredStubMediaRecorder;
850
+ try {
851
+ const cam = client.camera(42);
852
+ cam.maxRenderSize = { width: 320, height: 240 };
853
+ cam.emitState(makeCameraState({ flightId: 42 })); // already "full", so it arms instantly
854
+
855
+ const id1 = ctrl.startRecording(42, { forceFullResolution: true });
856
+ await flush();
857
+ expect(cam.forceCalls).toEqual([true]);
858
+
859
+ /* id1's recorder.state flips to "inactive" synchronously, but its
860
+ "stop" event (and its force release) is deferred. */
861
+ const stop1 = ctrl.stopRecording(id1);
862
+ /* Discarding it (rather than awaiting stop1) frees byFlight/active
863
+ immediately, same as the existing discard-while-settling case,
864
+ while id1's own release stays pending until its deferred stop
865
+ actually fires below. */
866
+ ctrl.discardRecording(id1);
867
+
868
+ /* A new recording of the same feed starts while id1's belated
869
+ release is still outstanding: a genuine overlap. The ref-count is
870
+ already at 1 (id1's, not yet released), so this second acquire
871
+ (1 -> 2) sends no redundant `true`, since the feed is already
872
+ forced. */
873
+ const id2 = ctrl.startRecording(42, { forceFullResolution: true });
874
+ await flush();
875
+ expect(cam.forceCalls).toEqual([true]);
876
+
877
+ /* id1's deferred stop finally fires: the FIRST release. The
878
+ ref-count (2 -> 1) must not clear the force while id2 still holds it. */
879
+ expect(DeferredStubMediaRecorder.pendingStops).toHaveLength(1);
880
+ DeferredStubMediaRecorder.pendingStops.pop()!();
881
+ await stop1;
882
+ expect(cam.forceCalls).toEqual([true]);
883
+
884
+ /* Only id2's stop, the SECOND release, actually clears it. */
885
+ const stop2 = ctrl.stopRecording(id2);
886
+ expect(DeferredStubMediaRecorder.pendingStops).toHaveLength(1);
887
+ DeferredStubMediaRecorder.pendingStops.pop()!();
888
+ await stop2;
889
+ expect(cam.forceCalls).toEqual([true, false]);
890
+ } finally {
891
+ (globalThis as { MediaRecorder?: unknown }).MediaRecorder = original;
892
+ DeferredStubMediaRecorder.pendingStops = [];
893
+ }
894
+ });
895
+
896
+ it("retries the force-true send on a later overlapping acquire after the first send failed", async () => {
897
+ const original = (globalThis as { MediaRecorder?: unknown }).MediaRecorder;
898
+ (globalThis as { MediaRecorder?: unknown }).MediaRecorder = DeferredStubMediaRecorder;
899
+ try {
900
+ const cam = client.camera(42);
901
+ cam.maxRenderSize = { width: 320, height: 240 };
902
+ cam.forceRejectCount = 1; // id1's force-true send fails silently
903
+ cam.emitState(makeCameraState({ flightId: 42 })); // already "full", so it arms instantly
904
+
905
+ const id1 = ctrl.startRecording(42, { forceFullResolution: true });
906
+ await flush();
907
+ /* The send was attempted (0 -> 1 acquire) but rejected: the feed
908
+ was never actually forced. */
909
+ expect(cam.forceCalls).toEqual([true]);
910
+
911
+ const stop1 = ctrl.stopRecording(id1);
912
+ ctrl.discardRecording(id1);
913
+
914
+ /* A second, overlapping recording of the same feed acquires while
915
+ id1's belated release is still outstanding (ref-count 1 -> 2).
916
+ Because the first send failed, this acquire must retry the true
917
+ send rather than assuming the feed is already forced. */
918
+ const id2 = ctrl.startRecording(42, { forceFullResolution: true });
919
+ await flush();
920
+ expect(cam.forceCalls).toEqual([true, true]);
921
+
922
+ /* id1's deferred stop finally fires: the FIRST release. The
923
+ ref-count (2 -> 1) must not clear the force while id2 still
924
+ holds it. */
925
+ expect(DeferredStubMediaRecorder.pendingStops).toHaveLength(1);
926
+ DeferredStubMediaRecorder.pendingStops.pop()!();
927
+ await stop1;
928
+ expect(cam.forceCalls).toEqual([true, true]);
929
+
930
+ /* Only id2's stop, the SECOND release, actually clears it. */
931
+ const stop2 = ctrl.stopRecording(id2);
932
+ expect(DeferredStubMediaRecorder.pendingStops).toHaveLength(1);
933
+ DeferredStubMediaRecorder.pendingStops.pop()!();
934
+ await stop2;
935
+ expect(cam.forceCalls).toEqual([true, true, false]);
936
+ } finally {
937
+ (globalThis as { MediaRecorder?: unknown }).MediaRecorder = original;
938
+ DeferredStubMediaRecorder.pendingStops = [];
939
+ }
940
+ });
941
+ });
942
+
943
+ describe("not forced", () => {
944
+ it("sends no force message and never arms", async () => {
945
+ const cam = client.camera(42);
946
+ const id = ctrl.startRecording(42);
947
+
948
+ expect(cam.forceCalls).toEqual([]);
949
+ expect(ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming).toBe(false);
950
+
951
+ await ctrl.stopRecording(id);
952
+ expect(cam.forceCalls).toEqual([]);
953
+ });
954
+ });
955
+ });
956
+
957
+ describe("RecordingController grouped", () => {
958
+ let client: FakeRecordingClient;
959
+ let ctrl: RecordingController;
960
+ /** Records every trim call and returns a short canned Blob. */
961
+ let trimCalls: { startMs: number; endMs: number; mimeType: string }[];
962
+ let fakeTrimmer: GroupTrimmer;
963
+ let loadTrimmer: () => Promise<GroupTrimmer | null>;
964
+
965
+ beforeEach(() => {
966
+ client = new FakeRecordingClient();
967
+ client.setStream(1, liveStream());
968
+ client.setStream(2, liveStream());
969
+ trimCalls = [];
970
+ fakeTrimmer = {
971
+ async trim(_blob, mimeType, startMs, endMs): Promise<Blob> {
972
+ trimCalls.push({ startMs, endMs, mimeType });
973
+ /* Stand in for a shorter, remuxed clip. */
974
+ return new Blob([new Uint8Array(16)], { type: mimeType });
975
+ },
976
+ };
977
+ loadTrimmer = () => Promise.resolve(fakeTrimmer);
978
+ ctrl = new RecordingController(client, { loadTrimmer });
979
+ });
980
+
981
+ it("records a group and returns a handle per feed with a commonUtWindow", async () => {
982
+ client.captureUt = 500;
983
+ const { groupId: g } = ctrl.startGroupedRecording([1, 2]);
984
+ expect(ctrl.isRecording(1)).toBe(true);
985
+ expect(ctrl.isRecording(2)).toBe(true);
986
+
987
+ client.captureUt = 510;
988
+ const handle = await ctrl.stopGroupedRecording(g);
989
+
990
+ expect(handle.groupId).toBe(g);
991
+ expect(handle.recordings).toHaveLength(2);
992
+ /* Feeds share the global clock, so the intersection is the common span. */
993
+ expect(handle.commonUtWindow).toEqual([500, 510]);
994
+ expect(ctrl.fetchGroupedRecording(g)).toBe(handle);
995
+ });
996
+
997
+ it("returns the real per-feed recording ids minted for each member, not a synthetic id", () => {
998
+ const { groupId, recordingIds } = ctrl.startGroupedRecording([1, 2]);
999
+ expect(recordingIds).toHaveLength(2);
1000
+ /* Each returned id is exactly what the controller minted internally: not
1001
+ derived from groupId/flightId, and each independently resolves via the
1002
+ single-recording surface (isRecording / discardRecording / stopRecording). */
1003
+ for (const id of recordingIds) {
1004
+ expect(id).not.toContain(groupId);
1005
+ }
1006
+ expect(new Set(recordingIds).size).toBe(2);
1007
+ });
1008
+
1009
+ it("lets a single member of an active group be cancelled without disturbing the rest", async () => {
1010
+ client.captureUt = 100;
1011
+ const { groupId, recordingIds } = ctrl.startGroupedRecording([1, 2]);
1012
+ const [firstId, secondId] = recordingIds;
1013
+
1014
+ ctrl.discardRecording(firstId);
1015
+
1016
+ /* The cancelled feed's flightId unlocks and its recorder actually stops.. */
1017
+ expect(ctrl.isRecording(1)).toBe(false);
1018
+ /* The other member keeps recording. */
1019
+ expect(ctrl.isRecording(2)).toBe(true);
1020
+
1021
+ /* A later stopGroupedRecording must not try to stop the discarded member
1022
+ (that used to reject with "no recording <id>" and corrupt the whole
1023
+ group): it resolves carrying only the survivor. */
1024
+ client.captureUt = 110;
1025
+ const handle = await ctrl.stopGroupedRecording(groupId);
1026
+
1027
+ expect(handle.recordings).toHaveLength(1);
1028
+ expect(handle.recordings[0].recordingId).toBe(secondId);
1029
+ expect(ctrl.fetchGroupedRecording(groupId)).toBe(handle);
1030
+ /* The discarded member never resurfaces, standalone or otherwise. */
1031
+ expect(ctrl.fetchRecording(firstId)).toBeUndefined();
1032
+ expect(ctrl.getSnapshot().recordings.some((r) => r.recordingId === firstId)).toBe(false);
1033
+ });
1034
+
1035
+ it("resolves an empty grouped handle, cleanly, when every member was discarded before stop", async () => {
1036
+ client.captureUt = 100;
1037
+ const { groupId, recordingIds } = ctrl.startGroupedRecording([1, 2]);
1038
+ for (const id of recordingIds) ctrl.discardRecording(id);
1039
+
1040
+ expect(ctrl.isRecording(1)).toBe(false);
1041
+ expect(ctrl.isRecording(2)).toBe(false);
1042
+
1043
+ const handle = await ctrl.stopGroupedRecording(groupId);
1044
+
1045
+ expect(handle.recordings).toEqual([]);
1046
+ expect(handle.commonUtWindow).toBeUndefined();
1047
+ expect(ctrl.fetchGroupedRecording(groupId)).toBe(handle);
1048
+ });
1049
+
1050
+ it("lets a single member of an active group be stopped individually", async () => {
1051
+ client.captureUt = 100;
1052
+ const { recordingIds } = ctrl.startGroupedRecording([1, 2]);
1053
+ const [firstId] = recordingIds;
1054
+
1055
+ const handle = await ctrl.stopRecording(firstId);
1056
+
1057
+ expect(handle.recordingId).toBe(firstId);
1058
+ expect(ctrl.isRecording(1)).toBe(false);
1059
+ expect(ctrl.isRecording(2)).toBe(true);
1060
+ });
1061
+
1062
+ it("rolls back started feeds if one cannot start", () => {
1063
+ client.setStream(3, null);
1064
+ expect(() => ctrl.startGroupedRecording([1, 3])).toThrow(/no live track/);
1065
+ /* Feed 1 must have been rolled back. */
1066
+ expect(ctrl.isRecording(1)).toBe(false);
1067
+ });
1068
+
1069
+ it("honors the coverage guarantee: does not finalize until every feed passes targetUt", async () => {
1070
+ client.captureUt = 100;
1071
+ const { groupId: g } = ctrl.startGroupedRecording([1, 2]);
1072
+
1073
+ let resolved = false;
1074
+ const done = ctrl.stopGroupedRecording(g, { targetUt: 105 }).then((h) => {
1075
+ resolved = true;
1076
+ return h;
1077
+ });
1078
+
1079
+ await flush();
1080
+ expect(resolved).toBe(false);
1081
+
1082
+ client.tick(103); // still short of 105
1083
+ await flush();
1084
+ expect(resolved).toBe(false);
1085
+
1086
+ client.tick(106); // every feed's latest sample now passes 105
1087
+ const handle = await done;
1088
+ expect(resolved).toBe(true);
1089
+ expect(handle.recordings).toHaveLength(2);
1090
+ /* Both feeds saw the covering sample, so utEnd is at/after the target. */
1091
+ for (const rec of handle.recordings) {
1092
+ expect(rec.utEnd).toBeGreaterThanOrEqual(105);
1093
+ }
1094
+ });
1095
+
1096
+ it("gives up waiting for coverage after coverageTimeoutMs and finalizes with what's captured", async () => {
1097
+ client.captureUt = 100;
1098
+ const { groupId: g } = ctrl.startGroupedRecording([1, 2]);
1099
+
1100
+ /* targetUt is never reached (sim frozen / out of flight / warp reversed);
1101
+ without a bound this would hang forever. */
1102
+ const handle = await ctrl.stopGroupedRecording(g, {
1103
+ targetUt: 999,
1104
+ coverageTimeoutMs: 20,
1105
+ });
1106
+
1107
+ expect(handle.recordings).toHaveLength(2);
1108
+ for (const rec of handle.recordings) {
1109
+ expect(rec.utEnd).toBe(100);
1110
+ }
1111
+ /* Both feeds' clock subscriptions and the coverage wait's own subscription
1112
+ are released, not leaked. */
1113
+ expect(client.listenerCount).toBe(0);
1114
+ });
1115
+
1116
+ it("does not wait out the full timeout once coverage arrives first", async () => {
1117
+ client.captureUt = 100;
1118
+ const { groupId: g } = ctrl.startGroupedRecording([1, 2]);
1119
+
1120
+ const done = ctrl.stopGroupedRecording(g, { targetUt: 105, coverageTimeoutMs: 5000 });
1121
+ client.tick(106);
1122
+ const handle = await done;
1123
+
1124
+ for (const rec of handle.recordings) {
1125
+ expect(rec.utEnd).toBeGreaterThanOrEqual(105);
1126
+ }
1127
+ expect(client.listenerCount).toBe(0);
1128
+ });
1129
+
1130
+ it("finalizes immediately when no targetUt is given", async () => {
1131
+ client.captureUt = 100;
1132
+ const { groupId: g } = ctrl.startGroupedRecording([1, 2]);
1133
+ const handle = await ctrl.stopGroupedRecording(g);
1134
+ expect(handle.recordings).toHaveLength(2);
1135
+ });
1136
+
1137
+ it("utSamples let a consumer map the common window to each clip's recording-time", async () => {
1138
+ client.captureUt = 100;
1139
+ const { groupId: g } = ctrl.startGroupedRecording([1, 2]);
1140
+ client.tick(101);
1141
+ client.tick(102);
1142
+ client.captureUt = 103;
1143
+ const handle = await ctrl.stopGroupedRecording(g);
1144
+
1145
+ expect(handle.commonUtWindow).toBeDefined();
1146
+ const [winStart] = handle.commonUtWindow as [number, number];
1147
+ /* Linear UT<->recording-time interpolation off the samples. */
1148
+ for (const rec of handle.recordings) {
1149
+ const offset = interpolateT(rec.utSamples, winStart);
1150
+ expect(offset).not.toBeNull();
1151
+ expect(offset as number).toBeGreaterThanOrEqual(0);
1152
+ expect(offset as number).toBeLessThanOrEqual(rec.durationMs);
1153
+ }
1154
+ });
1155
+
1156
+ it("physically trims each grouped clip to the common window", async () => {
1157
+ client.captureUt = 100;
1158
+ const { groupId: g } = ctrl.startGroupedRecording([1, 2]);
1159
+ client.tick(101);
1160
+ client.tick(102);
1161
+ client.captureUt = 103;
1162
+ const handle = await ctrl.stopGroupedRecording(g);
1163
+
1164
+ /* Every mp4 clip was cut. */
1165
+ expect(trimCalls).toHaveLength(2);
1166
+ for (const call of trimCalls) {
1167
+ expect(call.mimeType).toBe("video/mp4;codecs=avc1");
1168
+ expect(call.startMs).toBeGreaterThanOrEqual(0);
1169
+ expect(call.endMs).toBeGreaterThan(call.startMs);
1170
+ }
1171
+ /* Clips carry the remuxed (shorter) blob + the trimmed-window duration,
1172
+ and the UT metadata still rides along after the physical trim. */
1173
+ for (const rec of handle.recordings) {
1174
+ expect(rec.byteSize).toBe(16);
1175
+ expect(rec.durationMs).toBeGreaterThanOrEqual(0);
1176
+ expect(rec.utSamples.length).toBeGreaterThan(0);
1177
+ expect(rec.utStart).toBe(100);
1178
+ expect(rec.utEnd).toBe(103);
1179
+ }
1180
+ expect(handle.commonUtWindow).toEqual([100, 103]);
1181
+ });
1182
+
1183
+ it("loads the trim package only for grouped recordings, not single ones", () => {
1184
+ const loader = vi.fn(() => Promise.resolve(fakeTrimmer));
1185
+ const solo = new RecordingController(client, { loadTrimmer: loader });
1186
+
1187
+ const id = solo.startRecording(1);
1188
+ expect(loader).not.toHaveBeenCalled();
1189
+
1190
+ solo.startGroupedRecording([2]);
1191
+ expect(loader).toHaveBeenCalledTimes(1);
1192
+
1193
+ void solo.stopRecording(id);
1194
+ });
1195
+
1196
+ it("degrades to metadata-only for a webm clip (no trim call)", async () => {
1197
+ StubMediaRecorder.supportedTypes = ["video/webm"];
1198
+ client.captureUt = 100;
1199
+ const { groupId: g } = ctrl.startGroupedRecording([1, 2]);
1200
+ client.captureUt = 105;
1201
+ const handle = await ctrl.stopGroupedRecording(g);
1202
+
1203
+ expect(trimCalls).toHaveLength(0);
1204
+ for (const rec of handle.recordings) {
1205
+ expect(rec.mimeType).toBe("video/webm");
1206
+ /* Original (untrimmed) canned blob size from the stub. */
1207
+ expect(rec.byteSize).toBe(2048);
1208
+ }
1209
+ });
1210
+
1211
+ it("never fails the group when the trimmer throws (metadata-only degrade)", async () => {
1212
+ fakeTrimmer.trim = () => Promise.reject(new Error("cannot cut"));
1213
+ client.captureUt = 100;
1214
+ const { groupId: g } = ctrl.startGroupedRecording([1, 2]);
1215
+ client.captureUt = 105;
1216
+ const handle = await ctrl.stopGroupedRecording(g);
1217
+
1218
+ expect(handle.recordings).toHaveLength(2);
1219
+ for (const rec of handle.recordings) {
1220
+ expect(rec.byteSize).toBe(2048); // original, untrimmed
1221
+ }
1222
+ });
1223
+
1224
+ it("degrades to metadata-only when no trimmer is available", async () => {
1225
+ const solo = new RecordingController(client, {
1226
+ loadTrimmer: () => Promise.resolve(null),
1227
+ });
1228
+ client.captureUt = 100;
1229
+ const { groupId: g } = solo.startGroupedRecording([1, 2]);
1230
+ client.captureUt = 105;
1231
+ const handle = await solo.stopGroupedRecording(g);
1232
+
1233
+ expect(handle.recordings).toHaveLength(2);
1234
+ expect(handle.commonUtWindow).toEqual([100, 105]);
1235
+ for (const rec of handle.recordings) {
1236
+ expect(rec.byteSize).toBe(2048);
1237
+ }
1238
+ });
1239
+
1240
+ it("discards a grouped recording and all its clips", async () => {
1241
+ client.captureUt = 100;
1242
+ const { groupId: g } = ctrl.startGroupedRecording([1, 2]);
1243
+ const handle = await ctrl.stopGroupedRecording(g);
1244
+ ctrl.discardGroupedRecording(g);
1245
+ expect(ctrl.fetchGroupedRecording(g)).toBeUndefined();
1246
+ for (const rec of handle.recordings) {
1247
+ expect(ctrl.fetchRecording(rec.recordingId)).toBeUndefined();
1248
+ }
1249
+ });
1250
+ });
1251
+
1252
+ describe("RecordingController observable snapshot", () => {
1253
+ let client: FakeRecordingClient;
1254
+ let ctrl: RecordingController;
1255
+
1256
+ beforeEach(() => {
1257
+ client = new FakeRecordingClient();
1258
+ client.setStream(1, liveStream());
1259
+ client.setStream(2, liveStream());
1260
+ ctrl = new RecordingController(client, { loadTrimmer: () => Promise.resolve(null) });
1261
+ });
1262
+
1263
+ it("returns a referentially stable snapshot between mutations", () => {
1264
+ const a = ctrl.getSnapshot();
1265
+ const b = ctrl.getSnapshot();
1266
+ expect(a).toBe(b);
1267
+ });
1268
+
1269
+ it("returns a new snapshot reference after each mutation, notifying subscribers", async () => {
1270
+ const listener = vi.fn();
1271
+ ctrl.subscribe(listener);
1272
+
1273
+ const before = ctrl.getSnapshot();
1274
+ const id = ctrl.startRecording(1);
1275
+ expect(listener).toHaveBeenCalledTimes(1);
1276
+ const afterStart = ctrl.getSnapshot();
1277
+ expect(afterStart).not.toBe(before);
1278
+ expect(afterStart.active).toHaveLength(1);
1279
+
1280
+ const handle = await ctrl.stopRecording(id);
1281
+ expect(listener.mock.calls.length).toBeGreaterThanOrEqual(2);
1282
+ const afterStop = ctrl.getSnapshot();
1283
+ expect(afterStop).not.toBe(afterStart);
1284
+ expect(afterStop.active).toHaveLength(0);
1285
+ expect(afterStop.recordings.map((r) => r.recordingId)).toEqual([handle.recordingId]);
1286
+
1287
+ ctrl.discardRecording(id);
1288
+ expect(listener.mock.calls.length).toBeGreaterThanOrEqual(3);
1289
+ const afterDiscard = ctrl.getSnapshot();
1290
+ expect(afterDiscard).not.toBe(afterStop);
1291
+ expect(afterDiscard.recordings).toHaveLength(0);
1292
+ });
1293
+
1294
+ it("stops notifying once unsubscribed", () => {
1295
+ const listener = vi.fn();
1296
+ const unsubscribe = ctrl.subscribe(listener);
1297
+ unsubscribe();
1298
+ ctrl.startRecording(1);
1299
+ expect(listener).not.toHaveBeenCalled();
1300
+ });
1301
+
1302
+ it("notifies on group start/stop and keeps group members out of standalone recordings", async () => {
1303
+ const listener = vi.fn();
1304
+ ctrl.subscribe(listener);
1305
+
1306
+ const { groupId, recordingIds } = ctrl.startGroupedRecording([1, 2]);
1307
+ expect(listener).toHaveBeenCalled();
1308
+ const afterStart = ctrl.getSnapshot();
1309
+ expect(afterStart.active).toHaveLength(2);
1310
+ expect(afterStart.active.every((a) => a.groupId === groupId)).toBe(true);
1311
+ expect(afterStart.recordings).toHaveLength(0);
1312
+
1313
+ const callsBeforeStop = listener.mock.calls.length;
1314
+ const handle = await ctrl.stopGroupedRecording(groupId);
1315
+ expect(listener.mock.calls.length).toBeGreaterThan(callsBeforeStop);
1316
+
1317
+ const afterStop = ctrl.getSnapshot();
1318
+ expect(afterStop.active).toHaveLength(0);
1319
+ /* Group members never show up in the standalone list. */
1320
+ expect(afterStop.recordings).toHaveLength(0);
1321
+ expect(afterStop.groups).toHaveLength(1);
1322
+ expect(afterStop.groups[0].groupId).toBe(handle.groupId);
1323
+ expect(afterStop.groups[0].recordings.map((r) => r.recordingId)).toEqual(recordingIds);
1324
+ });
1325
+
1326
+ it("never notifies a subscriber with a group member missing its groupId", () => {
1327
+ const seen: RecordingsSnapshot[] = [];
1328
+ ctrl.subscribe(() => seen.push(ctrl.getSnapshot()));
1329
+
1330
+ ctrl.startGroupedRecording([1, 2]);
1331
+
1332
+ expect(seen.length).toBeGreaterThan(0);
1333
+ for (const snap of seen) {
1334
+ for (const a of snap.active) {
1335
+ if (a.flightId === 1 || a.flightId === 2) {
1336
+ expect(a.groupId).toBeDefined();
1337
+ }
1338
+ }
1339
+ }
1340
+ });
1341
+
1342
+ it("carries flightId on a grouped clip's finished handle", async () => {
1343
+ const { groupId } = ctrl.startGroupedRecording([1, 2]);
1344
+ const handle = await ctrl.stopGroupedRecording(groupId);
1345
+ expect(handle.recordings.map((r) => r.flightId).sort()).toEqual([1, 2]);
1346
+ });
1347
+
1348
+ it("notifies on discardGroupedRecording", async () => {
1349
+ const { groupId } = ctrl.startGroupedRecording([1, 2]);
1350
+ await ctrl.stopGroupedRecording(groupId);
1351
+ const listener = vi.fn();
1352
+ ctrl.subscribe(listener);
1353
+
1354
+ ctrl.discardGroupedRecording(groupId);
1355
+ expect(listener).toHaveBeenCalled();
1356
+ expect(ctrl.getSnapshot().groups).toHaveLength(0);
1357
+ });
1358
+
1359
+ it("keeps a standalone (non-grouped) finished recording in the recordings list", async () => {
1360
+ const id = ctrl.startRecording(1);
1361
+ await ctrl.stopRecording(id);
1362
+ expect(ctrl.getSnapshot().recordings.map((r) => r.recordingId)).toEqual([id]);
1363
+ });
1364
+ });
1365
+
1366
+ describe("utToRecordingTimeMs", () => {
1367
+ /* A clip that started 2 UT-seconds before the window and samples ~1Hz. */
1368
+ const samples = [
1369
+ { t: 0, ut: 100 },
1370
+ { t: 1000, ut: 101 },
1371
+ { t: 2000, ut: 102 },
1372
+ { t: 3000, ut: 103 },
1373
+ ];
1374
+
1375
+ it("interpolates a UT that falls between two samples", () => {
1376
+ /* UT 101.5 is halfway between the t=1000 and t=2000 samples. */
1377
+ expect(utToRecordingTimeMs(samples, 101.5)).toBe(1500);
1378
+ });
1379
+
1380
+ it("maps a window start after the clip start to a nonzero offset", () => {
1381
+ expect(utToRecordingTimeMs(samples, 102)).toBe(2000);
1382
+ });
1383
+
1384
+ it("clamps below the first and above the last sample", () => {
1385
+ expect(utToRecordingTimeMs(samples, 90)).toBe(0);
1386
+ expect(utToRecordingTimeMs(samples, 200)).toBe(3000);
1387
+ });
1388
+
1389
+ it("is null when the clip has no UT samples", () => {
1390
+ expect(utToRecordingTimeMs([{ t: 0 }, { t: 1000 }], 100)).toBeNull();
1391
+ });
1392
+ });
1393
+
1394
+ /** Linear interpolation of recording-time (ms) for a UT, off a utSamples series. */
1395
+ function interpolateT(
1396
+ samples: { t: number; ut?: number }[],
1397
+ ut: number,
1398
+ ): number | null {
1399
+ const pts = samples.filter((s): s is { t: number; ut: number } => s.ut != null);
1400
+ for (let i = 0; i < pts.length - 1; i++) {
1401
+ const a = pts[i];
1402
+ const b = pts[i + 1];
1403
+ if (ut >= a.ut && ut <= b.ut && b.ut !== a.ut) {
1404
+ const frac = (ut - a.ut) / (b.ut - a.ut);
1405
+ return a.t + frac * (b.t - a.t);
1406
+ }
1407
+ }
1408
+ if (pts.length > 0 && ut === pts[0].ut) return pts[0].t;
1409
+ return null;
1410
+ }
1411
+
1412
+ describe("createMediabunnyTrimmerLoader", () => {
1413
+ /** A fake Mediabunny module namespace, shaped like the real package's
1414
+ exports, that records what it was asked to do and returns a fixed
1415
+ output buffer. */
1416
+ function fakeMediabunnyModule(outputBytes: Uint8Array) {
1417
+ const calls: { start: number; end: number }[] = [];
1418
+ const mb = {
1419
+ Input: class {
1420
+ constructor(public opts: unknown) {}
1421
+ },
1422
+ BlobSource: class {
1423
+ constructor(public blob: Blob) {}
1424
+ },
1425
+ ALL_FORMATS: Symbol("ALL_FORMATS"),
1426
+ BufferTarget: class {
1427
+ buffer: ArrayBuffer | null = outputBytes.buffer as ArrayBuffer;
1428
+ },
1429
+ Output: class {
1430
+ constructor(public opts: unknown) {}
1431
+ },
1432
+ Mp4OutputFormat: class {},
1433
+ Conversion: {
1434
+ init: (opts: { trim: { start: number; end: number } }) => {
1435
+ calls.push(opts.trim);
1436
+ return Promise.resolve({ execute: () => Promise.resolve() });
1437
+ },
1438
+ },
1439
+ };
1440
+ return { mb, calls };
1441
+ }
1442
+
1443
+ it("builds a trimmer that remuxes through the resolved module's Conversion API", async () => {
1444
+ const { mb, calls } = fakeMediabunnyModule(new Uint8Array([1, 2, 3]));
1445
+ const loader = createMediabunnyTrimmerLoader(() => Promise.resolve(mb));
1446
+
1447
+ const trimmer = await loader();
1448
+ expect(trimmer).not.toBeNull();
1449
+ const blob = await trimmer!.trim(new Blob(["x"]), "video/mp4;codecs=avc1", 1000, 2000);
1450
+
1451
+ expect(calls).toEqual([{ start: 1, end: 2 }]);
1452
+ expect(blob.size).toBe(3);
1453
+ expect(blob.type).toBe("video/mp4;codecs=avc1");
1454
+ });
1455
+
1456
+ it("resolves null when the module import rejects", async () => {
1457
+ const loader = createMediabunnyTrimmerLoader(() => Promise.reject(new Error("network down")));
1458
+ await expect(loader()).resolves.toBeNull();
1459
+ });
1460
+
1461
+ it("propagates a trim() failure to the caller (no swallow inside the built trimmer)", async () => {
1462
+ const { mb } = fakeMediabunnyModule(new Uint8Array([1]));
1463
+ mb.Conversion.init = () => Promise.reject(new Error("cannot cut"));
1464
+ const loader = createMediabunnyTrimmerLoader(() => Promise.resolve(mb));
1465
+
1466
+ const trimmer = await loader();
1467
+ await expect(trimmer!.trim(new Blob(["x"]), "video/mp4", 0, 1000)).rejects.toThrow(
1468
+ "cannot cut",
1469
+ );
1470
+ });
1471
+ });