@ksp-gonogo/kerbcast 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/dist/__generated__/types.d.ts +23 -0
- package/dist/client.d.ts +56 -0
- package/dist/client.js +43 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/recording.d.ts +426 -0
- package/dist/recording.js +898 -0
- package/dist/testing/index.d.ts +88 -1
- package/dist/testing/index.js +200 -3
- package/package.json +4 -1
- package/src/__generated__/types.ts +21 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +74 -0
- package/src/index.ts +22 -0
- package/src/recording.test.ts +1471 -0
- package/src/recording.ts +1233 -0
- package/src/testing/index.ts +239 -1
- package/src/testing.test.ts +147 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased (next minor)
|
|
4
|
+
|
|
5
|
+
Client-side camera recording (pure SDK; no sidecar/plugin/protocol change).
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `KerbcastClient.recording`: a `RecordingController` that records a camera's
|
|
10
|
+
already-received WebRTC track with the browser's `MediaRecorder`, entirely
|
|
11
|
+
client-local (the sidecar never learns a recording happened)
|
|
12
|
+
- Per-feed API: `startRecording(flightId, opts?)` returns a client-minted
|
|
13
|
+
`recordingId`; `stopRecording(recordingId)` resolves a `RecordingHandle`;
|
|
14
|
+
`fetchRecording` / `discardRecording` / `isRecording`
|
|
15
|
+
- `RecordingHandle = { recordingId, blob, mimeType, utStart?, utEnd?,
|
|
16
|
+
utSamples, byteSize, durationMs }`, where `utSamples` is a ~1Hz
|
|
17
|
+
(recording-time, UT) series so a consumer can interpolate any frame's
|
|
18
|
+
mission-time near-frame-tight; out of flight it falls back to wall-clock
|
|
19
|
+
timing (`ut` absent) and upgrades automatically once UT appears
|
|
20
|
+
- Mime negotiation prefers `video/mp4;codecs=avc1` where
|
|
21
|
+
`MediaRecorder.isTypeSupported`, else `video/webm;codecs=vp9`, else
|
|
22
|
+
`video/webm`; the chosen type is on the handle. `opts.maxDurationMs` auto-stops
|
|
23
|
+
- Grouped API: `startGroupedRecording(flightIds, opts?)` (all recorders open in
|
|
24
|
+
one tick) and `stopGroupedRecording(groupId, { targetUt? })`. `targetUt`
|
|
25
|
+
enforces a coverage guarantee: finalization waits until every feed's latest
|
|
26
|
+
UT sample passes it, so no feed falls short of the common window
|
|
27
|
+
- `GroupedRecordingHandle = { groupId, recordings, commonUtWindow? }`, where
|
|
28
|
+
`commonUtWindow` is the intersection of the feeds' `[utStart, utEnd]`
|
|
29
|
+
- Grouped clips are physically remux-trimmed to `commonUtWindow` by default
|
|
30
|
+
(keyframe-boundary cut, no re-encode) via a lazily `import()`-ed Mediabunny,
|
|
31
|
+
loaded only on grouped-record start (single recordings stay dependency-free);
|
|
32
|
+
a clip that can't be cut cleanly (webm, or a remux error) degrades to
|
|
33
|
+
metadata-only and never fails the recording. `utSamples` + `commonUtWindow`
|
|
34
|
+
still ride along after trimming so a consumer can align to telemetry
|
|
35
|
+
- Helpers `negotiateMimeType`, `commonUtWindow`, `utToRecordingTimeMs`
|
|
36
|
+
- `StubMediaRecorder`: a jsdom `MediaRecorder` shim installed by
|
|
37
|
+
`installDomStubs`, so the full record -> UT-sample -> stop -> handle path is
|
|
38
|
+
headless-testable (its `isTypeSupported` is controllable for mime tests)
|
|
39
|
+
- `mediabunny` as a dependency, dynamically imported for grouped-clip trimming
|
|
40
|
+
only (never pulled by single recordings or non-recording consumers)
|
|
41
|
+
|
|
3
42
|
## 1.7.0 - 2026-07-24
|
|
4
43
|
|
|
5
44
|
Crew face cameras, auto-resolution, and camera targeting.
|
|
@@ -260,6 +260,17 @@ export interface SetDegradePayload {
|
|
|
260
260
|
*/
|
|
261
261
|
level: number;
|
|
262
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* Forces a camera to render at the operator ceiling regardless of
|
|
265
|
+
* display-size demand. Per-viewer, OR-aggregated by the sidecar: a feed
|
|
266
|
+
* stays forced while any connected viewer forces it, and releases when
|
|
267
|
+
* the last one clears it (or disconnects). Still yields to the adaptive
|
|
268
|
+
* framerate shed; overrides only the demand term.
|
|
269
|
+
*/
|
|
270
|
+
export interface SetForceFullResolutionPayload {
|
|
271
|
+
flightId: number;
|
|
272
|
+
force: boolean;
|
|
273
|
+
}
|
|
263
274
|
export interface SetFovPayload {
|
|
264
275
|
flightId: number;
|
|
265
276
|
fov: number;
|
|
@@ -435,6 +446,18 @@ export type ClientMessage =
|
|
|
435
446
|
type: "report-display-size";
|
|
436
447
|
content: ReportDisplaySizePayload;
|
|
437
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* Force a camera to render at the operator ceiling regardless of
|
|
451
|
+
* display-size demand. Per-viewer, OR-aggregated by the sidecar: a
|
|
452
|
+
* feed stays forced while any connected viewer forces it, and
|
|
453
|
+
* releases when the last one clears it (or disconnects). Still
|
|
454
|
+
* yields to the adaptive framerate shed; overrides only the demand
|
|
455
|
+
* term, never the perf-protection ceiling.
|
|
456
|
+
*/
|
|
457
|
+
| {
|
|
458
|
+
type: "set-force-full-resolution";
|
|
459
|
+
content: SetForceFullResolutionPayload;
|
|
460
|
+
}
|
|
438
461
|
/**
|
|
439
462
|
* Set the camera's field-of-view (degrees). Silently ignored for
|
|
440
463
|
* parts whose Hullcam module is the fixed base (`supportsZoom ==
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, Layer, QualityPreset, SettingsStatePayload, TrackMode } from "./__generated__/types";
|
|
2
2
|
import { CameraLifecycle } from "./__generated__/types";
|
|
3
|
+
import { RecordingController, type RecordingControllerOptions } from "./recording";
|
|
3
4
|
/** Controls the digital-static noise overlay baked into `cam.mediaStream`. */
|
|
4
5
|
export interface NoiseConfig {
|
|
5
6
|
/** When false, the pipeline is bypassed and `mediaStream` is the raw WebRTC track. */
|
|
@@ -22,6 +23,19 @@ export interface KerbcastCameraHandle {
|
|
|
22
23
|
readonly stalled: boolean;
|
|
23
24
|
/** Current {@link setShowStatic} setting. */
|
|
24
25
|
readonly showStatic: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* The feed's physical/ceiling maximum render size (`DiscoveredCamera.maxWidth`
|
|
28
|
+
* / `maxHeight`), or `null` until known. Populated from a prior
|
|
29
|
+
* `client.discover()` call (there is no live-pushed field for it), so a
|
|
30
|
+
* consumer that never calls `discover()` sees `null` here indefinitely.
|
|
31
|
+
* Used by `RecordingController` to recognise a force-full-resolution feed
|
|
32
|
+
* that has actually reached its ceiling, as opposed to one merely
|
|
33
|
+
* unchanged since before the force was sent.
|
|
34
|
+
*/
|
|
35
|
+
readonly maxRenderSize: {
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
} | null;
|
|
25
39
|
setLayers(layers: Layer[]): Promise<void>;
|
|
26
40
|
setRenderSize(width: number, height: number): Promise<void>;
|
|
27
41
|
setFov(fov: number): Promise<void>;
|
|
@@ -52,6 +66,15 @@ export interface KerbcastCameraHandle {
|
|
|
52
66
|
* machinery keeps overriding it downward while throttled.
|
|
53
67
|
*/
|
|
54
68
|
setQuality(preset: QualityPreset | null): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Force this feed to render at the operator's configured maximum
|
|
71
|
+
* regardless of display size, until released by calling this with
|
|
72
|
+
* `false`. Per-viewer, OR-aggregated server-side across viewers: a feed
|
|
73
|
+
* stays forced while any viewer forces it, and releases when the last one
|
|
74
|
+
* clears it (or disconnects). Still yields to the adaptive framerate
|
|
75
|
+
* shed; it only overrides the display-size demand term.
|
|
76
|
+
*/
|
|
77
|
+
setForceFullResolution(force: boolean): Promise<void>;
|
|
55
78
|
requestKeyframe(): Promise<void>;
|
|
56
79
|
/**
|
|
57
80
|
* Override noise settings for this camera only. Takes precedence over the
|
|
@@ -121,6 +144,15 @@ export interface KerbcastClientConfig {
|
|
|
121
144
|
sdp: string;
|
|
122
145
|
cameras: number[];
|
|
123
146
|
}>;
|
|
147
|
+
/**
|
|
148
|
+
* Options for the lazily-built {@link RecordingController} (`client.recording`).
|
|
149
|
+
* The load-bearing use is `loadTrimmer`: a consumer that can't rely on the
|
|
150
|
+
* default bare `import("mediabunny")` resolving (the sidecar's embedded web
|
|
151
|
+
* page serves its own copy of the package locally, offline/LAN, no CDN)
|
|
152
|
+
* supplies its own loader here. Bundler consumers (e.g. gonogo) can omit
|
|
153
|
+
* this and get the default.
|
|
154
|
+
*/
|
|
155
|
+
recording?: RecordingControllerOptions;
|
|
124
156
|
}
|
|
125
157
|
/** WebRTC connection state surface. */
|
|
126
158
|
export type KerbcastConnectionState = "disconnected" | "connecting" | "connected" | "failed";
|
|
@@ -328,6 +360,15 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
|
|
|
328
360
|
private _warpRate;
|
|
329
361
|
/** Whether KSP is in a flight scene. `undefined` until the first signal. */
|
|
330
362
|
private _inFlight;
|
|
363
|
+
/** Client-side recording controller, lazily built on first `recording` access. */
|
|
364
|
+
private _recording;
|
|
365
|
+
/**
|
|
366
|
+
* Physical/ceiling max render size per camera, cached from the last
|
|
367
|
+
* `discover()` call. Empty until `discover()` has been called at least
|
|
368
|
+
* once; a camera absent from the last discovery response is simply
|
|
369
|
+
* missing here (its handle's `maxRenderSize` reads `null`).
|
|
370
|
+
*/
|
|
371
|
+
private readonly maxRenderSizeByFlight;
|
|
331
372
|
constructor(cfg: KerbcastClientConfig, transport?: KerbcastTransport);
|
|
332
373
|
get state(): KerbcastConnectionState;
|
|
333
374
|
get cameras(): ReadonlyArray<CameraState>;
|
|
@@ -352,6 +393,13 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
|
|
|
352
393
|
epoch: number;
|
|
353
394
|
warpRate: number;
|
|
354
395
|
};
|
|
396
|
+
/**
|
|
397
|
+
* Client-side recording controller. Records a camera's already-received
|
|
398
|
+
* track with the browser's `MediaRecorder`, annotated with the mission-time
|
|
399
|
+
* (UT) clock; purely local, no sidecar involvement. Lazily created on first
|
|
400
|
+
* access and stable thereafter. See {@link RecordingController}.
|
|
401
|
+
*/
|
|
402
|
+
get recording(): RecordingController;
|
|
355
403
|
/**
|
|
356
404
|
* Whether KSP is currently in a flight scene, from the sidecar's
|
|
357
405
|
* `scene-state-changed`. `undefined` until the first signal (so a
|
|
@@ -371,6 +419,14 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
|
|
|
371
419
|
* to subscribe to before opening a peer connection.
|
|
372
420
|
*/
|
|
373
421
|
discover(): Promise<DiscoveredCamera[]>;
|
|
422
|
+
/**
|
|
423
|
+
* Internal: backs {@link KerbcastCameraHandle.maxRenderSize}. `null` when
|
|
424
|
+
* `discover()` has never been called or never reported this camera.
|
|
425
|
+
*/
|
|
426
|
+
_maxRenderSize(flightId: number): {
|
|
427
|
+
width: number;
|
|
428
|
+
height: number;
|
|
429
|
+
} | null;
|
|
374
430
|
/**
|
|
375
431
|
* Open the WebRTC peer and subscribe to cameras. Idempotent: calling
|
|
376
432
|
* `connect` while already connected disconnects first.
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CameraLifecycle, ErrorSource } from "./__generated__/types";
|
|
2
2
|
import { tryCreateNoisePipeline } from "./noise";
|
|
3
|
+
import { RecordingController } from "./recording";
|
|
3
4
|
/** Intensity the static runs at when a camera has no live source (signal lost). */
|
|
4
5
|
const SOURCELESS_INTENSITY = 1.0;
|
|
5
6
|
/** Floor applied to the degrade-driven intensity of a live feed. */
|
|
@@ -185,6 +186,9 @@ class CameraHandle extends TypedEmitter {
|
|
|
185
186
|
get showStatic() {
|
|
186
187
|
return this._showStatic;
|
|
187
188
|
}
|
|
189
|
+
get maxRenderSize() {
|
|
190
|
+
return this.client._maxRenderSize(this.flightId);
|
|
191
|
+
}
|
|
188
192
|
configure(options) {
|
|
189
193
|
this._noiseOverride = options.noise ?? null;
|
|
190
194
|
// Re-evaluate with the new noise setting: rebuild from whatever source
|
|
@@ -390,6 +394,12 @@ class CameraHandle extends TypedEmitter {
|
|
|
390
394
|
content: { flightId: this.flightId, preset: preset ?? undefined },
|
|
391
395
|
});
|
|
392
396
|
}
|
|
397
|
+
async setForceFullResolution(force) {
|
|
398
|
+
await this.client._send({
|
|
399
|
+
type: "set-force-full-resolution",
|
|
400
|
+
content: { flightId: this.flightId, force },
|
|
401
|
+
});
|
|
402
|
+
}
|
|
393
403
|
async requestKeyframe() {
|
|
394
404
|
await this.client._send({
|
|
395
405
|
type: "request-keyframe",
|
|
@@ -465,6 +475,15 @@ export class KerbcastClient extends TypedEmitter {
|
|
|
465
475
|
this._warpRate = 1;
|
|
466
476
|
/** Whether KSP is in a flight scene. `undefined` until the first signal. */
|
|
467
477
|
this._inFlight = undefined;
|
|
478
|
+
/** Client-side recording controller, lazily built on first `recording` access. */
|
|
479
|
+
this._recording = null;
|
|
480
|
+
/**
|
|
481
|
+
* Physical/ceiling max render size per camera, cached from the last
|
|
482
|
+
* `discover()` call. Empty until `discover()` has been called at least
|
|
483
|
+
* once; a camera absent from the last discovery response is simply
|
|
484
|
+
* missing here (its handle's `maxRenderSize` reads `null`).
|
|
485
|
+
*/
|
|
486
|
+
this.maxRenderSizeByFlight = new Map();
|
|
468
487
|
this.cfg = cfg;
|
|
469
488
|
this.transport =
|
|
470
489
|
transport ??
|
|
@@ -507,6 +526,17 @@ export class KerbcastClient extends TypedEmitter {
|
|
|
507
526
|
warpRate: this._warpRate,
|
|
508
527
|
};
|
|
509
528
|
}
|
|
529
|
+
/**
|
|
530
|
+
* Client-side recording controller. Records a camera's already-received
|
|
531
|
+
* track with the browser's `MediaRecorder`, annotated with the mission-time
|
|
532
|
+
* (UT) clock; purely local, no sidecar involvement. Lazily created on first
|
|
533
|
+
* access and stable thereafter. See {@link RecordingController}.
|
|
534
|
+
*/
|
|
535
|
+
get recording() {
|
|
536
|
+
if (!this._recording)
|
|
537
|
+
this._recording = new RecordingController(this, this.cfg.recording);
|
|
538
|
+
return this._recording;
|
|
539
|
+
}
|
|
510
540
|
/**
|
|
511
541
|
* Whether KSP is currently in a flight scene, from the sidecar's
|
|
512
542
|
* `scene-state-changed`. `undefined` until the first signal (so a
|
|
@@ -537,8 +567,21 @@ export class KerbcastClient extends TypedEmitter {
|
|
|
537
567
|
if (!res.ok)
|
|
538
568
|
throw new Error(`/cameras returned ${res.status}`);
|
|
539
569
|
const body = (await res.json());
|
|
570
|
+
for (const cam of body.cameras) {
|
|
571
|
+
this.maxRenderSizeByFlight.set(cam.flightId, {
|
|
572
|
+
width: cam.maxWidth,
|
|
573
|
+
height: cam.maxHeight,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
540
576
|
return body.cameras;
|
|
541
577
|
}
|
|
578
|
+
/**
|
|
579
|
+
* Internal: backs {@link KerbcastCameraHandle.maxRenderSize}. `null` when
|
|
580
|
+
* `discover()` has never been called or never reported this camera.
|
|
581
|
+
*/
|
|
582
|
+
_maxRenderSize(flightId) {
|
|
583
|
+
return this.maxRenderSizeByFlight.get(flightId) ?? null;
|
|
584
|
+
}
|
|
542
585
|
/**
|
|
543
586
|
* Open the WebRTC peer and subscribe to cameras. Idempotent: calling
|
|
544
587
|
* `connect` while already connected disconnects first.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +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";
|
package/dist/index.js
CHANGED
|
@@ -5,3 +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";
|