@ksp-gonogo/kerbcast 1.6.3 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ import type { CameraState } from "./__generated__/types";
2
+ /**
3
+ * One point on a recording's UT track. `t` is milliseconds since recording
4
+ * start; `ut` is the KSP universal time (seconds) the capture clock reported
5
+ * at that instant, or absent when out of flight (no clock).
6
+ */
7
+ export interface UtSample {
8
+ t: number;
9
+ ut?: number;
10
+ }
11
+ /** The finished, in-memory result of a single-feed recording. */
12
+ export interface RecordingHandle {
13
+ recordingId: string;
14
+ /** The camera this clip was recorded from. */
15
+ flightId: number;
16
+ /** The recorded media, in `mimeType`. */
17
+ blob: Blob;
18
+ /** The negotiated container/codec (e.g. `video/mp4;codecs=avc1`). */
19
+ mimeType: string;
20
+ /** UT (seconds) at recording start, or absent when out of flight. */
21
+ utStart?: number;
22
+ /** UT (seconds) at recording stop, or absent when out of flight. */
23
+ utEnd?: number;
24
+ /** ~1Hz (recording-time, UT) series across the clip. Always present. */
25
+ utSamples: UtSample[];
26
+ /** `blob.size` in bytes. */
27
+ byteSize: number;
28
+ /** Wall-clock recording duration in milliseconds. */
29
+ durationMs: number;
30
+ }
31
+ /** Options for {@link RecordingController.startRecording}. */
32
+ export interface StartRecordingOptions {
33
+ /**
34
+ * Preferred container/codec. Tried first; if MediaRecorder can't support
35
+ * it, negotiation falls back (mp4 -> webm/vp9 -> webm). Omit to let
36
+ * negotiation choose.
37
+ */
38
+ mimeType?: string;
39
+ /**
40
+ * Auto-stop after this many milliseconds. The clip finalizes on its own and
41
+ * is retrievable via {@link RecordingController.fetchRecording}. Omit for
42
+ * manual stop only.
43
+ */
44
+ maxDurationMs?: number;
45
+ /**
46
+ * Force this feed to the operator's full render size for the duration of
47
+ * the recording (overrides the display-size demand only; still yields to
48
+ * the adaptive framerate shed). Off by default. A single recording arms
49
+ * and waits (bounded) for the bump before the clip starts, so it opens on
50
+ * full-res frames; a grouped recording starts immediately and the lead-in
51
+ * is cut via the existing UT-window trim instead. Released automatically
52
+ * on stop, discard, or auto-stop.
53
+ */
54
+ forceFullResolution?: boolean;
55
+ }
56
+ /**
57
+ * Bound on the single-recording arm-and-wait: how long
58
+ * {@link RecordingController.startRecording} waits for a forced feed to
59
+ * reach full resolution before starting the recorder anyway. Never hangs a
60
+ * start indefinitely (feed out of flight, wedged, or the sidecar/plugin
61
+ * round trip taking longer than expected).
62
+ */
63
+ export declare const ARM_TIMEOUT_MS = 3000;
64
+ /** Options for {@link RecordingController.stopGroupedRecording}. */
65
+ export interface StopGroupedRecordingOptions {
66
+ /**
67
+ * Coverage guarantee: hold finalization until every feed's latest UT sample
68
+ * has passed this UT (seconds), so no feed falls short of the common window.
69
+ * Omit to stop all feeds at the current moment (best-effort).
70
+ *
71
+ * The guarantee only holds for members that are still recording when the
72
+ * target arrives. A member started with its own `maxDurationMs` can
73
+ * auto-stop before reaching `targetUt`; that member's `utEnd` then falls
74
+ * short, which silently narrows (or, if the ranges no longer overlap,
75
+ * clears) the resulting `commonUtWindow`. Pair a grouped `targetUt` with an
76
+ * unbounded (or generously long) per-member `maxDurationMs`, or none at all.
77
+ */
78
+ targetUt?: number;
79
+ /**
80
+ * Bound on how long to wait for `targetUt` coverage before giving up and
81
+ * finalizing with whatever each feed has captured so far. Guards against a
82
+ * UT that never arrives (feed out of flight, sim frozen, warp reversed)
83
+ * hanging the stop forever. Ignored when `targetUt` is omitted. Defaults to
84
+ * {@link DEFAULT_COVERAGE_TIMEOUT_MS}.
85
+ */
86
+ coverageTimeoutMs?: number;
87
+ }
88
+ /** Default {@link StopGroupedRecordingOptions.coverageTimeoutMs}. */
89
+ export declare const DEFAULT_COVERAGE_TIMEOUT_MS = 10000;
90
+ /** The result of {@link RecordingController.startGroupedRecording}. */
91
+ export interface GroupedRecordingStart {
92
+ groupId: string;
93
+ /**
94
+ * The real per-feed recording ids the controller minted internally, in the
95
+ * order `flightIds` was given. These are the ids that work with the
96
+ * single-recording surface (`isRecording`, `stopRecording`,
97
+ * `discardRecording`) for cancelling one member of the group.
98
+ */
99
+ recordingIds: string[];
100
+ }
101
+ /** The finished, in-memory result of a grouped (multi-feed) recording. */
102
+ export interface GroupedRecordingHandle {
103
+ groupId: string;
104
+ /** One handle per feed, in the order the group was started. */
105
+ recordings: RecordingHandle[];
106
+ /**
107
+ * The UT span all feeds cover: [max(utStart), min(utEnd)] across the clips.
108
+ * Absent when any clip lacks UT or the ranges don't overlap. Each clip's
109
+ * own `utSamples` let a consumer map this window to that clip's
110
+ * recording-time and trim/seek itself (metadata alignment; no physical cut).
111
+ */
112
+ commonUtWindow?: [number, number];
113
+ }
114
+ /**
115
+ * An in-progress recording, surfaced so the UI can draw a REC dot and an
116
+ * elapsed timer. `groupId` is set when the recording is part of a grouped set.
117
+ */
118
+ export interface ActiveRecordingInfo {
119
+ recordingId: string;
120
+ flightId: number;
121
+ /** `performance.now()`/`Date.now()` at start, for an elapsed timer. */
122
+ startedAt: number;
123
+ groupId?: string;
124
+ /**
125
+ * True while a single forced recording is armed (force sent, waiting for
126
+ * the feed to reach full resolution) but the recorder has not started
127
+ * yet. Always false for a non-forced recording and for grouped members
128
+ * (which record immediately). A UI shows a distinct "arming" state while
129
+ * this is true.
130
+ */
131
+ arming: boolean;
132
+ }
133
+ /**
134
+ * Everything {@link RecordingController} currently knows, as one referentially
135
+ * stable object: the same reference between mutations, a new one after each.
136
+ * That stability is what makes it safe to hand straight to
137
+ * `useSyncExternalStore` -- a fresh object on every call would spin components
138
+ * into an infinite render loop.
139
+ */
140
+ export interface RecordingsSnapshot {
141
+ /** Finished standalone (non-grouped) clips, newest last. */
142
+ recordings: RecordingHandle[];
143
+ /** Finished grouped sets, newest last. */
144
+ groups: GroupedRecordingHandle[];
145
+ /** Recordings currently in progress (single + grouped members). */
146
+ active: ActiveRecordingInfo[];
147
+ }
148
+ /**
149
+ * Per-camera surface {@link RecordingController} needs: the live stream plus
150
+ * enough of the full camera handle to drive a forced full-resolution
151
+ * recording (send the force, observe the resulting render size).
152
+ */
153
+ export interface RecordingClientCamera {
154
+ readonly mediaStream: MediaStream | null;
155
+ readonly state: CameraState | null;
156
+ /**
157
+ * The feed's physical/ceiling maximum render size, or `null` when not
158
+ * known to the client (e.g. `discover()` was never called). When known,
159
+ * lets the controller recognise a forced feed that has actually reached
160
+ * its ceiling rather than one merely unchanged since before the force.
161
+ */
162
+ readonly maxRenderSize: {
163
+ width: number;
164
+ height: number;
165
+ } | null;
166
+ setForceFullResolution(force: boolean): Promise<void>;
167
+ on(event: "change", handler: (state: CameraState) => void): () => void;
168
+ }
169
+ /**
170
+ * Minimal surface {@link RecordingController} needs from the client. The full
171
+ * `KerbcastClient` satisfies it; a narrow interface keeps the controller
172
+ * unit-testable without a whole client.
173
+ */
174
+ export interface RecordingClient {
175
+ camera(flightId: number): RecordingClientCamera;
176
+ readonly clock: {
177
+ readonly captureUt: number | null;
178
+ };
179
+ on(event: "settings-change", handler: (data: unknown) => void): () => void;
180
+ }
181
+ /**
182
+ * Pick a MediaRecorder mime type. Tries `preferred` first (when given), then
183
+ * the standard preference order, using `MediaRecorder.isTypeSupported`. Falls
184
+ * back to `video/webm` when nothing reports support (or the API is absent).
185
+ */
186
+ export declare function negotiateMimeType(preferred?: string): string;
187
+ /**
188
+ * Intersection of the feeds' UT ranges: [max(utStart), min(utEnd)]. Returns
189
+ * undefined if any clip lacks a full range or the spans don't overlap.
190
+ */
191
+ export declare function commonUtWindow(recordings: RecordingHandle[]): [number, number] | undefined;
192
+ /**
193
+ * Recording-time (ms since clip start) for a mission UT, linearly interpolated
194
+ * off a clip's `utSamples`. UT is linear in recording time at constant
195
+ * time-warp (the launch/landing/docking moments a group captures), so this maps
196
+ * the common UT window onto each clip's own timeline for a keyframe trim.
197
+ * Clamps to the sampled span; returns null when the clip has no UT samples.
198
+ */
199
+ export declare function utToRecordingTimeMs(samples: UtSample[], ut: number): number | null;
200
+ /**
201
+ * Remux-trims a grouped clip to a recording-time window. Implemented by a
202
+ * lazily-loaded package (Mediabunny) so single recordings and non-recording
203
+ * consumers never pull it. Keyframe-boundary remux only: no decode/re-encode.
204
+ */
205
+ export interface GroupTrimmer {
206
+ /** Cut `blob` to [startMs, endMs] of its own timeline; resolves a new Blob. */
207
+ trim(blob: Blob, mimeType: string, startMs: number, endMs: number): Promise<Blob>;
208
+ }
209
+ /** Loads a {@link GroupTrimmer}, or resolves null when trimming is unavailable. */
210
+ export type TrimmerLoader = () => Promise<GroupTrimmer | null>;
211
+ /** Options for {@link RecordingController}. */
212
+ export interface RecordingControllerOptions {
213
+ /**
214
+ * Override the grouped-recording trimmer loader (tests inject a fake; a
215
+ * consumer could supply mp4box.js instead). Defaults to a dynamic
216
+ * `import("mediabunny")` keyframe remux.
217
+ */
218
+ loadTrimmer?: TrimmerLoader;
219
+ }
220
+ /**
221
+ * Builds a {@link TrimmerLoader} around a caller-supplied module import.
222
+ * `loadMediabunnyTrimmer` (the default, below) resolves the bare
223
+ * `import("mediabunny")` specifier for bundler consumers (e.g. gonogo). A
224
+ * consumer that can't rely on that resolution -- the sidecar's embedded web
225
+ * page, which serves its own copy of the package locally for an offline/LAN
226
+ * install with no CDN -- supplies a different `importModule` (e.g. a dynamic
227
+ * import of a served URL) and passes the result as
228
+ * {@link RecordingControllerOptions.loadTrimmer}. Resolves null on any
229
+ * failure, so a group degrades to metadata-only rather than failing.
230
+ */
231
+ export declare function createMediabunnyTrimmerLoader(importModule: () => Promise<unknown>): TrimmerLoader;
232
+ /**
233
+ * Owns every in-flight and finished recording for one client. Reached via
234
+ * `client.recording`. All state is in-memory: a page reload discards it.
235
+ *
236
+ * Observable: {@link subscribe} + {@link getSnapshot} let every call site
237
+ * (a tile's own REC control, a RecordingsTray mounted elsewhere) read the same
238
+ * state, so a clip started or finished through one is visible to all the
239
+ * others -- no per-instance state to fall out of sync.
240
+ */
241
+ export declare class RecordingController {
242
+ private readonly client;
243
+ private readonly active;
244
+ private readonly finished;
245
+ /** flightId -> recordingId, so a second start on a busy feed is rejected. */
246
+ private readonly byFlight;
247
+ private readonly groups;
248
+ private readonly finishedGroups;
249
+ /** Per-group trimmer load kicked off at group-start, awaited at stop. */
250
+ private readonly groupTrimmers;
251
+ private readonly loadTrimmer;
252
+ /** recordingIds in a grouped set, so they stay out of the standalone `recordings` list. */
253
+ private readonly groupMemberIds;
254
+ /** recordingId -> groupId, so discarding one member can pull it out of its group's array. */
255
+ private readonly memberOfGroup;
256
+ /**
257
+ * Per-feed force ref-count. The sidecar OR-aggregates force across
258
+ * viewers already; this is the local mirror so two overlapping
259
+ * recordings of the same feed (in this one controller) release force
260
+ * only when the last of them stops.
261
+ */
262
+ private readonly forceCounts;
263
+ /**
264
+ * Per-feed force-true send status: "pending" while a send is in flight,
265
+ * "sent" once it has actually succeeded. Absent means neither: either
266
+ * never attempted, or a prior attempt failed. Distinct from
267
+ * {@link forceCounts} so a failed send never masquerades as a completed
268
+ * one; see {@link acquireForce}.
269
+ */
270
+ private readonly forceSendState;
271
+ private readonly listeners;
272
+ private snapshot;
273
+ /** While true, {@link rebuild} is a no-op; used to batch startGroupedRecording's
274
+ * per-member starts into a single notification once the group is fully set up. */
275
+ private suppressRebuild;
276
+ constructor(client: RecordingClient, options?: RecordingControllerOptions);
277
+ /** Subscribe to snapshot changes. Returns an unsubscribe function. */
278
+ subscribe: (fn: () => void) => (() => void);
279
+ /** The current snapshot; the same object reference until the next mutation. */
280
+ getSnapshot: () => RecordingsSnapshot;
281
+ /**
282
+ * Recompute the snapshot from current state and notify subscribers. Called
283
+ * at the end of every state-mutating operation, including the async settle
284
+ * points (recorder stop, grouped stop) where the data actually changes.
285
+ */
286
+ private rebuild;
287
+ /**
288
+ * Acquire the local force ref-count for a feed; sends
289
+ * `setForceFullResolution(true)` unless one is already in flight or has
290
+ * already succeeded for this feed (tracked in {@link forceSendState}, not
291
+ * just the ref-count reaching 1). The sidecar OR-aggregates force across
292
+ * viewers already; this ref-count is the belt-and-braces local mirror for
293
+ * two overlapping recordings of the same feed in this one controller.
294
+ *
295
+ * A send failure clears `forceSendState` instead of leaving it "sent", so
296
+ * it never masquerades as done: a later acquire (e.g. a second overlapping
297
+ * recording's 1->2 transition) sees the feed as not-yet-forced and retries
298
+ * the send, rather than silently leaving the feed un-forced forever.
299
+ */
300
+ private acquireForce;
301
+ /**
302
+ * Release one holder of a feed's force ref-count; sends
303
+ * `setForceFullResolution(false)` once the count reaches zero. Never goes
304
+ * below zero (a stray extra release is a no-op, not an underflow). Clears
305
+ * `forceSendState` too, so a later, unrelated recording of this feed
306
+ * starts its own send from a clean slate.
307
+ */
308
+ private releaseForce;
309
+ /**
310
+ * Whether `state` shows a feed actually rendering at its full target size,
311
+ * as opposed to merely having caught up to whatever small size it already
312
+ * had. Two ways to know the target:
313
+ *
314
+ * - `target` known (from `maxRenderSize`): ready once the operator size
315
+ * has reached it and the rendered size has caught up to the operator
316
+ * size. Resolves promptly even when the feed was already at its ceiling
317
+ * before the force was sent (nothing ever "increases").
318
+ * - `target` unknown: falls back to requiring the operator size to have
319
+ * increased past `baseline` (the first REAL state observed at or after
320
+ * watch-start; see {@link watchResolutionReady}) and the rendered size
321
+ * to have caught up. `baseline` is `null` until that first real state
322
+ * arrives, and a `null` baseline is never ready: a placeholder `{0,0}`
323
+ * would let any positive operator size look like an increase, so no
324
+ * verdict is possible until a genuine baseline exists. This is a
325
+ * strictly weaker guarantee than the known-target path: a feed already
326
+ * at its true ceiling when forced will not be recognised as ready until
327
+ * something else changes its operator size, or the wait simply times
328
+ * out, but it still refuses to treat the pre-force snapshot itself as
329
+ * ready.
330
+ *
331
+ * Either way, a state whose rendered size has not yet caught up to its own
332
+ * operator size (adaptive shed still catching up) is never ready.
333
+ */
334
+ private isAtFullResolution;
335
+ /**
336
+ * Seeds a fallback baseline from an observed state, or returns `null` if
337
+ * the state isn't a REAL observation (operator size still zero, i.e. no
338
+ * dimensions have ever been reported for this feed). A `null` result
339
+ * means the caller must keep waiting for a state worth basing readiness
340
+ * on, rather than latching onto a `{0,0}` placeholder.
341
+ */
342
+ private seedResolutionBaseline;
343
+ /**
344
+ * Waits for a forced feed to actually render at full resolution.
345
+ * `timeoutMs` omitted means no bound: the wait only ends when the feed
346
+ * becomes ready or the caller cancels it (the grouped path, bounded by the
347
+ * recording's own lifetime instead; see {@link startRecording}).
348
+ * Resolves `true` once ready, `false` on timeout. Returns a `cancel` next
349
+ * to the promise so a caller can abandon the wait (stop/discard) without
350
+ * leaking the underlying "change" subscription or timer; cancelling never
351
+ * settles the promise.
352
+ */
353
+ private watchResolutionReady;
354
+ /**
355
+ * Start recording a feed's live track. Returns a client-minted recordingId
356
+ * synchronously. Throws if the feed has no live track or is already
357
+ * recording.
358
+ *
359
+ * `inGroup` is internal (set by {@link startGroupedRecording}) and governs
360
+ * how `opts.forceFullResolution` behaves: a single recording (`inGroup`
361
+ * false) arms and waits, bounded by {@link ARM_TIMEOUT_MS}, for the feed to
362
+ * reach full resolution before the recorder actually starts, so the clip
363
+ * opens on full-res frames. A grouped member starts immediately and
364
+ * instead records the moment it reaches full resolution
365
+ * (`resolutionReadyAt`/`resolutionReadyUt`) so {@link stopGroupedRecording}
366
+ * can fold it into the common trim window.
367
+ */
368
+ startRecording(flightId: number, opts?: StartRecordingOptions, inGroup?: boolean): string;
369
+ /**
370
+ * Stop a recording and resolve its finished handle. Idempotent per id: a
371
+ * second call (or a race with maxDurationMs auto-stop) returns the same
372
+ * pending/settled result.
373
+ */
374
+ stopRecording(recordingId: string): Promise<RecordingHandle>;
375
+ /** Return a finished recording's handle, or undefined if unknown/still active. */
376
+ fetchRecording(recordingId: string): RecordingHandle | undefined;
377
+ /** Whether a feed currently has a recording in progress. */
378
+ isRecording(flightId: number): boolean;
379
+ /**
380
+ * Drop a recording. If still active, tears it down without producing a
381
+ * handle; if finished, frees the stored handle/blob reference. If the id
382
+ * is a member of a still-active group, pulls it out of that group's member
383
+ * set too, so a later `stopGroupedRecording` only waits on (and stops) the
384
+ * ids that remain -- discarding one member no longer corrupts the group.
385
+ */
386
+ discardRecording(recordingId: string): void;
387
+ /**
388
+ * Start recording several feeds as one synchronised moment. All recorders
389
+ * open in a single tick to minimise skew. Returns the groupId plus the real
390
+ * per-feed recordingIds (so a caller can cancel one member individually via
391
+ * `stopRecording`/`discardRecording`, the same ids that end up on each
392
+ * clip's `RecordingHandle`). If any feed can't start (no track / already
393
+ * recording), the ones already started are discarded and the error
394
+ * rethrows.
395
+ */
396
+ startGroupedRecording(flightIds: number[], opts?: StartRecordingOptions): GroupedRecordingStart;
397
+ /**
398
+ * Stop a grouped recording. With `targetUt`, holds until every feed's latest
399
+ * UT sample has passed it (coverage guarantee) before stopping any feed;
400
+ * without it, stops all now. Resolves the grouped handle with the intersected
401
+ * `commonUtWindow`. See {@link StopGroupedRecordingOptions.targetUt} for the
402
+ * coverage caveat around a member's own `maxDurationMs`.
403
+ */
404
+ stopGroupedRecording(groupId: string, opts?: StopGroupedRecordingOptions): Promise<GroupedRecordingHandle>;
405
+ /**
406
+ * Remux-trim one clip to the common UT window, or return it untouched
407
+ * (metadata-only) when it can't be cut cleanly: a non-mp4 container, an
408
+ * unmappable window, or a trimmer error. The stored per-clip handle is
409
+ * replaced with the trimmed one so fetch/discard stay consistent.
410
+ */
411
+ private maybeTrim;
412
+ /** Return a finished grouped recording, or undefined if unknown/still active. */
413
+ fetchGroupedRecording(groupId: string): GroupedRecordingHandle | undefined;
414
+ /** Drop a grouped recording and each of its clips. */
415
+ discardGroupedRecording(groupId: string): void;
416
+ /**
417
+ * Resolve once every listed recording's latest UT sample has reached
418
+ * `targetUt`. UT arrives on the shared ~1Hz clock feed, so this polls the
419
+ * settings-change stream rather than a wall-clock timer. Bounded by
420
+ * `timeoutMs`: if coverage never arrives (feed out of flight, sim frozen,
421
+ * warp reversed), gives up and resolves anyway so the group finalizes with
422
+ * whatever UT each feed has captured, rather than hanging forever. Always
423
+ * releases the settings-change subscription, on either exit path.
424
+ */
425
+ private awaitCoverage;
426
+ }