@ksp-gonogo/kerbcast 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/dist/__generated__/types.d.ts +23 -0
- package/dist/client.d.ts +56 -0
- package/dist/client.js +43 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/recording.d.ts +426 -0
- package/dist/recording.js +898 -0
- package/dist/testing/index.d.ts +88 -1
- package/dist/testing/index.js +200 -3
- package/package.json +4 -1
- package/src/__generated__/types.ts +21 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +74 -0
- package/src/index.ts +22 -0
- package/src/recording.test.ts +1471 -0
- package/src/recording.ts +1233 -0
- package/src/testing/index.ts +239 -1
- package/src/testing.test.ts +147 -1
|
@@ -0,0 +1,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
|
+
});
|