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