@ksp-gonogo/kerbcast 1.10.1 → 1.10.2

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.
@@ -99,6 +99,13 @@ export declare enum QualityPreset {
99
99
  Half = "half",
100
100
  Quarter = "quarter"
101
101
  }
102
+ /** The per-dimension MAX display size held for a camera across consumers. */
103
+ export interface DisplaySizeReport {
104
+ width: number;
105
+ height: number;
106
+ /** Milliseconds since the most recent report that contributed to the max. */
107
+ ageMs: number;
108
+ }
102
109
  /**
103
110
  * Per-camera snapshot pushed by the sidecar on every state change
104
111
  * (operator API call, adaptive shed, vessel change). Same shape served
@@ -238,6 +245,44 @@ export interface CameraState {
238
245
  * Absent/None = the request is fully honored.
239
246
  */
240
247
  qualityLimitedBy?: string;
248
+ /**
249
+ * Rate in fps Kerbcast is currently pacing this camera's capture to: the
250
+ * plugin's primary rate (MaxCaptureFps) while displayed, the background
251
+ * rate while subscribed but undisplayed, 0 when not capturing.
252
+ */
253
+ targetCaptureFps?: number;
254
+ /**
255
+ * Whether any consumer reports a non-zero display size for this camera,
256
+ * or a consumer forces it (e.g. a full-resolution recording).
257
+ */
258
+ displayed?: boolean;
259
+ /**
260
+ * Frames the plugin produced into this camera's ring per second, over
261
+ * roughly the last two seconds, as observed by the sidecar's encode loop.
262
+ */
263
+ captureFps?: number;
264
+ /**
265
+ * Encoded frames handed to this camera's tracks per second, over the same
266
+ * window. Below `capture_fps` when the encoder skips frames (encode pacing,
267
+ * degrade); a viewer receiving less than this is losing frames in transit.
268
+ */
269
+ sentFps?: number;
270
+ /**
271
+ * Keyframes the encoder emitted for this camera (periodic or requested)
272
+ * since its ring was attached.
273
+ */
274
+ keyframesSent?: number;
275
+ /**
276
+ * PLI/FIR keyframe requests received from viewers for this camera, across
277
+ * peers, since its ring was attached. Counted before the per-slot debounce,
278
+ * so it can exceed the keyframes forced in response.
279
+ */
280
+ keyframeRequestsReceived?: number;
281
+ /**
282
+ * Largest display size currently held for this camera across consumers.
283
+ * Absent when no consumer reports one (`GET /cameras` sends null instead).
284
+ */
285
+ displaySize?: DisplaySizeReport;
241
286
  }
242
287
  export interface CameraSnapshotPayload {
243
288
  cameras: CameraState[];
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, Layer, QualityPreset, SettingsStatePayload, TrackMode } from "./__generated__/types";
1
+ import type { AdaptiveShedPayload, CameraState, ClientMessage, DisplaySizeReport, ErrorPayload, Layer, QualityPreset, SettingsStatePayload, TrackMode } from "./__generated__/types";
2
2
  import { CameraLifecycle } from "./__generated__/types";
3
3
  import { RecordingController, type RecordingControllerOptions } from "./recording";
4
4
  /** Controls the digital-static noise overlay baked into `cam.mediaStream`. */
@@ -214,6 +214,21 @@ export interface DiscoveredCamera {
214
214
  targetBitrateBps: number;
215
215
  /** Effective degrade level (max across active subscribers). 0.0 to 1.0. */
216
216
  degradeLevel: number;
217
+ /** Rate Kerbcast paces this camera's capture to: the primary rate while
218
+ * displayed, the background rate while undisplayed, 0 when not capturing. */
219
+ targetCaptureFps?: number;
220
+ /** Any consumer reports a non-zero display size, or the feed is forced. */
221
+ displayed?: boolean;
222
+ /** Frames the plugin produced per second over roughly the last 2 s. */
223
+ captureFps?: number;
224
+ /** Encoded frames handed to this camera's tracks per second, same window. */
225
+ sentFps?: number;
226
+ /** Keyframes the encoder emitted since the camera's stream started. */
227
+ keyframesSent?: number;
228
+ /** PLI/FIR keyframe requests received across peers since the stream started. */
229
+ keyframeRequestsReceived?: number;
230
+ /** Largest display size held across consumers; null when none is held. */
231
+ displaySize?: DisplaySizeReport | null;
217
232
  }
218
233
  /** Event payloads emitted by a {@link KerbcastClient}. */
219
234
  export interface KerbcastClientEvents {
@@ -240,6 +255,13 @@ export interface KerbcastClientEvents {
240
255
  * instead of per-camera SIGNAL LOST.
241
256
  */
242
257
  "scene-change": boolean | undefined;
258
+ /**
259
+ * Fired each time the control channel of a freshly (re)connected peer
260
+ * opens, i.e. the earliest moment it can carry messages. Consumers that
261
+ * hold per-peer state on the sidecar (display sizes, subscriptions) re-send
262
+ * it here, because the sidecar forgets a peer's state when it leaves.
263
+ */
264
+ "control-open": undefined;
243
265
  }
244
266
  /**
245
267
  * Pluggable WebRTC transport. Production code uses the default
package/dist/client.js CHANGED
@@ -626,6 +626,7 @@ export class KerbcastClient extends TypedEmitter {
626
626
  this.control = peer.createDataChannel("kerbcast-control");
627
627
  this.control.onOpen(() => {
628
628
  void this._send({ type: "hello" });
629
+ this.emit("control-open", undefined);
629
630
  });
630
631
  this.control.onMessage((raw) => {
631
632
  this.handleServerMessage(raw);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export * from "./__generated__/types";
2
2
  export { KerbcastClient, BrowserKerbcastTransport, type BrowserKerbcastTransportOptions, type InboundVideoStats, type KerbcastCameraHandle, type KerbcastClientConfig, type KerbcastConnectionState, type KerbcastClientEvents, type KerbcastCameraEvents, type KerbcastTransport, type KerbcastPeer, type KerbcastDataChannel, type DiscoveredCamera, type NoiseConfig, } from "./client";
3
3
  export { PanZoomController, type PanZoomCommandSink, type PanZoomBounds, type PanZoomControllerOptions, } from "./panZoom";
4
- export { RecordingController, createMediabunnyTrimmerLoader, negotiateMimeType, commonUtWindow, utToRecordingTimeMs, DEFAULT_COVERAGE_TIMEOUT_MS, ARM_TIMEOUT_MS, type ActiveRecordingInfo, type RecordingClient, type RecordingControllerOptions, type RecordingHandle, type RecordingsSnapshot, type GroupedRecordingHandle, type GroupedRecordingStart, type GroupTrimmer, type TrimmerLoader, type UtSample, type StartRecordingOptions, type StopGroupedRecordingOptions, } from "./recording";
4
+ export { RecordingController, createMediabunnyTrimmerLoader, negotiateMimeType, commonUtWindow, utToRecordingTimeMs, DEFAULT_COVERAGE_TIMEOUT_MS, ARM_TIMEOUT_MS, videoElementDecodedSizeProbe, type ActiveRecordingInfo, type DecodedSizeProbe, type RecordingClient, type RecordingControllerOptions, type RecordingHandle, type RecordingsSnapshot, type GroupedRecordingHandle, type GroupedRecordingStart, type GroupTrimmer, type TrimmerLoader, type UtSample, type StartRecordingOptions, type StopGroupedRecordingOptions, } from "./recording";
package/dist/index.js CHANGED
@@ -5,4 +5,4 @@
5
5
  export * from "./__generated__/types";
6
6
  export { KerbcastClient, BrowserKerbcastTransport, } from "./client";
7
7
  export { PanZoomController, } from "./panZoom";
8
- export { RecordingController, createMediabunnyTrimmerLoader, negotiateMimeType, commonUtWindow, utToRecordingTimeMs, DEFAULT_COVERAGE_TIMEOUT_MS, ARM_TIMEOUT_MS, } from "./recording";
8
+ export { RecordingController, createMediabunnyTrimmerLoader, negotiateMimeType, commonUtWindow, utToRecordingTimeMs, DEFAULT_COVERAGE_TIMEOUT_MS, ARM_TIMEOUT_MS, videoElementDecodedSizeProbe, } from "./recording";
package/dist/noise.js CHANGED
@@ -145,6 +145,17 @@ export function tryCreateNoisePipeline(rawStream, initialIntensity, options) {
145
145
  if (destroyed)
146
146
  return;
147
147
  const now = performance.now();
148
+ /*
149
+ * Follow mid-stream size changes. loadedmetadata fires once per source,
150
+ * so without this the canvas (and the processed stream every consumer
151
+ * displays and records) stays at the first decoded size forever, even
152
+ * after the sidecar raises the render size.
153
+ */
154
+ if (video.srcObject &&
155
+ video.videoWidth > 0 &&
156
+ (video.videoWidth !== canvas.width || video.videoHeight !== canvas.height)) {
157
+ onMeta();
158
+ }
148
159
  const cw = canvas.width;
149
160
  const ch = canvas.height;
150
161
  const nw = noiseCanvas.width;
@@ -45,20 +45,22 @@ export interface StartRecordingOptions {
45
45
  /**
46
46
  * Force this feed to the operator's full render size for the duration of
47
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.
48
+ * the adaptive framerate shed). Off by default. The recording arms and
49
+ * waits (bounded) until the browser is decoding full-size frames before
50
+ * the clip starts: MediaRecorder fixes the clip's frame size when it
51
+ * starts, so a later bump would never reach the file. A grouped recording
52
+ * arms every member and starts all their recorders together once every
53
+ * feed is ready (or the shared bound passes). Released automatically on
54
+ * stop, discard, or auto-stop.
53
55
  */
54
56
  forceFullResolution?: boolean;
55
57
  }
56
58
  /**
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).
59
+ * Bound on the arm-and-wait: how long a forced recording waits for its feed
60
+ * (or, for a group, every member's feed) to reach full resolution before
61
+ * starting the recorder anyway. Never hangs a start indefinitely (feed out
62
+ * of flight, wedged, or the sidecar/plugin round trip taking longer than
63
+ * expected).
62
64
  */
63
65
  export declare const ARM_TIMEOUT_MS = 3000;
64
66
  /** Options for {@link RecordingController.stopGroupedRecording}. */
@@ -122,11 +124,11 @@ export interface ActiveRecordingInfo {
122
124
  startedAt: number;
123
125
  groupId?: string;
124
126
  /**
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.
127
+ * True while a forced recording is armed (force sent, waiting for the
128
+ * feed to reach full resolution) but the recorder has not started yet.
129
+ * Forced grouped members stay arming until every member is ready, then
130
+ * flip together. Always false for a non-forced recording. A UI shows a
131
+ * distinct "arming" state while this is true.
130
132
  */
131
133
  arming: boolean;
132
134
  }
@@ -208,6 +210,25 @@ export interface GroupTrimmer {
208
210
  }
209
211
  /** Loads a {@link GroupTrimmer}, or resolves null when trimming is unavailable. */
210
212
  export type TrimmerLoader = () => Promise<GroupTrimmer | null>;
213
+ /**
214
+ * Watches the decoded frame size of a feed's live stream, calling `onSize`
215
+ * whenever it is known or changes (repeats are fine). Returns a stop function
216
+ * that releases everything the probe holds. The arm-and-wait gates a forced
217
+ * recording on this, not on the reported camera state: the state flips to
218
+ * the full size before the decoder delivers full-size frames, and
219
+ * MediaRecorder locks the clip to whatever size it sees first.
220
+ */
221
+ export type DecodedSizeProbe = (flightId: number, stream: MediaStream, onSize: (width: number, height: number) => void) => () => void;
222
+ /**
223
+ * Default {@link DecodedSizeProbe}: plays the stream into an off-DOM muted
224
+ * `<video>` and reads the decoded size from each frame
225
+ * (requestVideoFrameCallback where available) and from `resize` /
226
+ * `loadedmetadata`. `track.getSettings()` is not used because Chromium often
227
+ * reports stale or missing dimensions for remote WebRTC tracks. Outside a
228
+ * browser it never reports, so a forced recording falls back to the arm
229
+ * timeout.
230
+ */
231
+ export declare const videoElementDecodedSizeProbe: DecodedSizeProbe;
211
232
  /** Options for {@link RecordingController}. */
212
233
  export interface RecordingControllerOptions {
213
234
  /**
@@ -216,6 +237,12 @@ export interface RecordingControllerOptions {
216
237
  * `import("mediabunny")` keyframe remux.
217
238
  */
218
239
  loadTrimmer?: TrimmerLoader;
240
+ /**
241
+ * Override how a forced recording observes its feed's decoded frame size
242
+ * (tests inject a fake; a harness without real decoding can follow the
243
+ * camera state instead). Defaults to {@link videoElementDecodedSizeProbe}.
244
+ */
245
+ probeDecodedSize?: DecodedSizeProbe;
219
246
  }
220
247
  /**
221
248
  * Builds a {@link TrimmerLoader} around a caller-supplied module import.
@@ -249,6 +276,7 @@ export declare class RecordingController {
249
276
  /** Per-group trimmer load kicked off at group-start, awaited at stop. */
250
277
  private readonly groupTrimmers;
251
278
  private readonly loadTrimmer;
279
+ private readonly probeDecodedSize;
252
280
  /** recordingIds in a grouped set, so they stay out of the standalone `recordings` list. */
253
281
  private readonly groupMemberIds;
254
282
  /** recordingId -> groupId, so discarding one member can pull it out of its group's array. */
@@ -268,6 +296,8 @@ export declare class RecordingController {
268
296
  * one; see {@link acquireForce}.
269
297
  */
270
298
  private readonly forceSendState;
299
+ /** groupId -> arm state, present only while a forced group is still arming. */
300
+ private readonly groupArms;
271
301
  private readonly listeners;
272
302
  private snapshot;
273
303
  /** While true, {@link rebuild} is a no-op; used to batch startGroupedRecording's
@@ -307,31 +337,20 @@ export declare class RecordingController {
307
337
  */
308
338
  private releaseForce;
309
339
  /**
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.
340
+ * Fallback readiness for a feed whose ceiling is unknown to the client (no
341
+ * `maxRenderSize`), judged from the reported camera state alone. Ready once
342
+ * the operator size has increased past `baseline` (the first REAL state
343
+ * observed at or after watch-start; see {@link watchResolutionReady}) and
344
+ * the rendered size has caught up to it. `baseline` is `null` until that
345
+ * first real state arrives, and a `null` baseline is never ready: a
346
+ * placeholder `{0,0}` would let any positive operator size look like an
347
+ * increase. A feed already at its true ceiling when forced is not
348
+ * recognised until something else changes its operator size, so the wait
349
+ * usually times out instead, but the pre-force snapshot itself is never
350
+ * treated as ready. A state whose rendered size has not yet caught up to
351
+ * its own operator size (adaptive shed still catching up) is never ready.
333
352
  */
334
- private isAtFullResolution;
353
+ private stateIncreasedPastBaseline;
335
354
  /**
336
355
  * Seeds a fallback baseline from an observed state, or returns `null` if
337
356
  * the state isn't a REAL observation (operator size still zero, i.e. no
@@ -341,14 +360,24 @@ export declare class RecordingController {
341
360
  */
342
361
  private seedResolutionBaseline;
343
362
  /**
344
- * Waits for a forced feed to actually render at full resolution.
363
+ * Waits for a forced feed to actually deliver full-resolution frames.
364
+ *
365
+ * With the ceiling known (`maxRenderSize`), the gate is the decoded frame
366
+ * size from {@link probeDecodedSize} reaching it (within
367
+ * {@link DECODED_SIZE_TOLERANCE_PX}). The camera state is deliberately not
368
+ * trusted here: it reports the new size before the decoder delivers
369
+ * full-size frames, and a recorder started in that gap locks the clip to
370
+ * the old size. A feed already at its ceiling resolves on the probe's
371
+ * first report. With the ceiling unknown, falls back to the state-only
372
+ * check ({@link stateIncreasedPastBaseline}).
373
+ *
345
374
  * `timeoutMs` omitted means no bound: the wait only ends when the feed
346
375
  * 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.
376
+ * group's one shared timer instead; see {@link armGroup}). Resolves `true`
377
+ * once ready, `false` on timeout. `cancel` abandons the wait (stop/discard)
378
+ * without settling the promise; every exit path releases the "change"
379
+ * subscription, the probe, and the timer. `lastDecoded` reports the most
380
+ * recent decoded size seen, for logging.
352
381
  */
353
382
  private watchResolutionReady;
354
383
  /**
@@ -356,16 +385,38 @@ export declare class RecordingController {
356
385
  * synchronously. Throws if the feed has no live track or is already
357
386
  * recording.
358
387
  *
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.
388
+ * With `opts.forceFullResolution`, the recording arms and waits, bounded by
389
+ * {@link ARM_TIMEOUT_MS}, for the feed to reach full resolution before the
390
+ * recorder actually starts, so the clip opens on full-res frames.
391
+ * `inGroup` is internal (set by {@link startGroupedRecording}): a forced
392
+ * grouped member arms the same way but leaves the wait to the group, which
393
+ * starts every member's recorder together (see {@link armGroup}).
367
394
  */
368
395
  startRecording(flightId: number, opts?: StartRecordingOptions, inGroup?: boolean): string;
396
+ /**
397
+ * Arms a forced group: watches every member's feed and starts all their
398
+ * recorders together once each has reached full resolution, or once the
399
+ * one shared {@link ARM_TIMEOUT_MS} bound passes, whichever comes first.
400
+ * Members stopped or discarded mid-arm drop out of the wait, so a slow
401
+ * feed that gets cancelled no longer holds the rest back.
402
+ */
403
+ private armGroup;
404
+ /** Members of a group still armed and waiting to start (not stopped or discarded). */
405
+ private armingMembers;
406
+ /** Starts an arming group once every member still waiting is ready. */
407
+ private checkGroupArm;
408
+ /**
409
+ * Ends a group's arm and starts every still-armed member's recorder in the
410
+ * same tick, on one shared clock base (`startedAt`, `utStart`), so the
411
+ * clips open together. Cancels any outstanding per-member watches (the
412
+ * timeout path).
413
+ */
414
+ private startArmedGroup;
415
+ /**
416
+ * Abandons a group's arm without starting anything: the caller is tearing
417
+ * the whole group down (stop or discard), so no member should start mid-way.
418
+ */
419
+ private cancelGroupArm;
369
420
  /**
370
421
  * Stop a recording and resolve its finished handle. Idempotent per id: a
371
422
  * second call (or a race with maxDurationMs auto-stop) returns the same
@@ -386,12 +437,15 @@ export declare class RecordingController {
386
437
  discardRecording(recordingId: string): void;
387
438
  /**
388
439
  * 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.
440
+ * open in a single tick to minimise skew. With `opts.forceFullResolution`,
441
+ * every member arms first and that single tick comes later, once all feeds
442
+ * have reached full resolution or {@link ARM_TIMEOUT_MS} passes (see
443
+ * {@link armGroup}); the members report `arming` until then. Returns the
444
+ * groupId plus the real per-feed recordingIds (so a caller can cancel one
445
+ * member individually via `stopRecording`/`discardRecording`, the same ids
446
+ * that end up on each clip's `RecordingHandle`). If any feed can't start
447
+ * (no track / already recording), the ones already started are discarded
448
+ * and the error rethrows.
395
449
  */
396
450
  startGroupedRecording(flightIds: number[], opts?: StartRecordingOptions): GroupedRecordingStart;
397
451
  /**