@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/client.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
ErrorSource,
|
|
5
5
|
Layer,
|
|
6
6
|
QualityPreset,
|
|
7
|
+
TrackMode,
|
|
7
8
|
} from "./__generated__/types";
|
|
8
9
|
import {
|
|
9
10
|
BrowserKerbcastTransport,
|
|
@@ -381,6 +382,68 @@ describe("KerbcastClient", () => {
|
|
|
381
382
|
]);
|
|
382
383
|
});
|
|
383
384
|
|
|
385
|
+
it("set-force-full-resolution routes onto the control channel via the handle", async () => {
|
|
386
|
+
const { transport, captured } = makeFakeTransport();
|
|
387
|
+
const client = new KerbcastClient({ host: "h", port: 1 }, transport);
|
|
388
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
|
|
389
|
+
|
|
390
|
+
await client.connect();
|
|
391
|
+
captured.dc?._open();
|
|
392
|
+
captured.dc!.sent.length = 0; // drop the hello
|
|
393
|
+
|
|
394
|
+
await client.camera(42).setForceFullResolution(true);
|
|
395
|
+
await client.camera(42).setForceFullResolution(false);
|
|
396
|
+
|
|
397
|
+
expect(captured.dc?.sent).toEqual([
|
|
398
|
+
JSON.stringify({
|
|
399
|
+
type: "set-force-full-resolution",
|
|
400
|
+
content: { flightId: 42, force: true },
|
|
401
|
+
}),
|
|
402
|
+
JSON.stringify({
|
|
403
|
+
type: "set-force-full-resolution",
|
|
404
|
+
content: { flightId: 42, force: false },
|
|
405
|
+
}),
|
|
406
|
+
]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("reportDisplaySize sends a report-display-size command", async () => {
|
|
410
|
+
const { transport, captured } = makeFakeTransport();
|
|
411
|
+
const client = new KerbcastClient({ host: "h", port: 1 }, transport);
|
|
412
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
|
|
413
|
+
|
|
414
|
+
await client.connect();
|
|
415
|
+
captured.dc?._open();
|
|
416
|
+
captured.dc!.sent.length = 0;
|
|
417
|
+
|
|
418
|
+
await client.reportDisplaySize(3, 40, 40);
|
|
419
|
+
|
|
420
|
+
expect(captured.dc?.sent).toEqual([
|
|
421
|
+
JSON.stringify({
|
|
422
|
+
type: "report-display-size",
|
|
423
|
+
content: { flightId: 3, width: 40, height: 40 },
|
|
424
|
+
}),
|
|
425
|
+
]);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("setTrackTarget sends a set-track-target command", async () => {
|
|
429
|
+
const { transport, captured } = makeFakeTransport();
|
|
430
|
+
const client = new KerbcastClient({ host: "h", port: 1 }, transport);
|
|
431
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
|
|
432
|
+
|
|
433
|
+
await client.connect();
|
|
434
|
+
captured.dc?._open();
|
|
435
|
+
captured.dc!.sent.length = 0;
|
|
436
|
+
|
|
437
|
+
await client.setTrackTarget(5, TrackMode.ActiveVessel);
|
|
438
|
+
|
|
439
|
+
expect(captured.dc?.sent).toEqual([
|
|
440
|
+
JSON.stringify({
|
|
441
|
+
type: "set-track-target",
|
|
442
|
+
content: { flightId: 5, mode: TrackMode.ActiveVessel },
|
|
443
|
+
}),
|
|
444
|
+
]);
|
|
445
|
+
});
|
|
446
|
+
|
|
384
447
|
it("disconnect tears down peer + emits null MediaStream events", async () => {
|
|
385
448
|
const { transport, captured } = makeFakeTransport();
|
|
386
449
|
const client = new KerbcastClient({ host: "h", port: 1 }, transport);
|
package/src/client.ts
CHANGED
|
@@ -7,9 +7,11 @@ import type {
|
|
|
7
7
|
QualityPreset,
|
|
8
8
|
ServerMessage,
|
|
9
9
|
SettingsStatePayload,
|
|
10
|
+
TrackMode,
|
|
10
11
|
} from "./__generated__/types";
|
|
11
12
|
import { CameraLifecycle, ErrorSource } from "./__generated__/types";
|
|
12
13
|
import { type NoisePipeline, tryCreateNoisePipeline } from "./noise";
|
|
14
|
+
import { RecordingController, type RecordingControllerOptions } from "./recording";
|
|
13
15
|
|
|
14
16
|
/** Intensity the static runs at when a camera has no live source (signal lost). */
|
|
15
17
|
const SOURCELESS_INTENSITY = 1.0;
|
|
@@ -46,6 +48,16 @@ export interface KerbcastCameraHandle {
|
|
|
46
48
|
readonly stalled: boolean;
|
|
47
49
|
/** Current {@link setShowStatic} setting. */
|
|
48
50
|
readonly showStatic: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* The feed's physical/ceiling maximum render size (`DiscoveredCamera.maxWidth`
|
|
53
|
+
* / `maxHeight`), or `null` until known. Populated from a prior
|
|
54
|
+
* `client.discover()` call (there is no live-pushed field for it), so a
|
|
55
|
+
* consumer that never calls `discover()` sees `null` here indefinitely.
|
|
56
|
+
* Used by `RecordingController` to recognise a force-full-resolution feed
|
|
57
|
+
* that has actually reached its ceiling, as opposed to one merely
|
|
58
|
+
* unchanged since before the force was sent.
|
|
59
|
+
*/
|
|
60
|
+
readonly maxRenderSize: { width: number; height: number } | null;
|
|
49
61
|
|
|
50
62
|
setLayers(layers: Layer[]): Promise<void>;
|
|
51
63
|
setRenderSize(width: number, height: number): Promise<void>;
|
|
@@ -77,6 +89,15 @@ export interface KerbcastCameraHandle {
|
|
|
77
89
|
* machinery keeps overriding it downward while throttled.
|
|
78
90
|
*/
|
|
79
91
|
setQuality(preset: QualityPreset | null): Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* Force this feed to render at the operator's configured maximum
|
|
94
|
+
* regardless of display size, until released by calling this with
|
|
95
|
+
* `false`. Per-viewer, OR-aggregated server-side across viewers: a feed
|
|
96
|
+
* stays forced while any viewer forces it, and releases when the last one
|
|
97
|
+
* clears it (or disconnects). Still yields to the adaptive framerate
|
|
98
|
+
* shed; it only overrides the display-size demand term.
|
|
99
|
+
*/
|
|
100
|
+
setForceFullResolution(force: boolean): Promise<void>;
|
|
80
101
|
requestKeyframe(): Promise<void>;
|
|
81
102
|
|
|
82
103
|
/**
|
|
@@ -149,6 +170,15 @@ export interface KerbcastClientConfig {
|
|
|
149
170
|
cameras: number[];
|
|
150
171
|
slots?: number;
|
|
151
172
|
}) => Promise<{ sdp: string; cameras: number[] }>;
|
|
173
|
+
/**
|
|
174
|
+
* Options for the lazily-built {@link RecordingController} (`client.recording`).
|
|
175
|
+
* The load-bearing use is `loadTrimmer`: a consumer that can't rely on the
|
|
176
|
+
* default bare `import("mediabunny")` resolving (the sidecar's embedded web
|
|
177
|
+
* page serves its own copy of the package locally, offline/LAN, no CDN)
|
|
178
|
+
* supplies its own loader here. Bundler consumers (e.g. gonogo) can omit
|
|
179
|
+
* this and get the default.
|
|
180
|
+
*/
|
|
181
|
+
recording?: RecordingControllerOptions;
|
|
152
182
|
}
|
|
153
183
|
|
|
154
184
|
/** WebRTC connection state surface. */
|
|
@@ -508,6 +538,10 @@ class CameraHandle
|
|
|
508
538
|
return this._showStatic;
|
|
509
539
|
}
|
|
510
540
|
|
|
541
|
+
get maxRenderSize(): { width: number; height: number } | null {
|
|
542
|
+
return this.client._maxRenderSize(this.flightId);
|
|
543
|
+
}
|
|
544
|
+
|
|
511
545
|
configure(options: { noise?: Partial<NoiseConfig> }): void {
|
|
512
546
|
this._noiseOverride = options.noise ?? null;
|
|
513
547
|
// Re-evaluate with the new noise setting: rebuild from whatever source
|
|
@@ -738,6 +772,13 @@ class CameraHandle
|
|
|
738
772
|
});
|
|
739
773
|
}
|
|
740
774
|
|
|
775
|
+
async setForceFullResolution(force: boolean): Promise<void> {
|
|
776
|
+
await this.client._send({
|
|
777
|
+
type: "set-force-full-resolution",
|
|
778
|
+
content: { flightId: this.flightId, force },
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
741
782
|
async requestKeyframe(): Promise<void> {
|
|
742
783
|
await this.client._send({
|
|
743
784
|
type: "request-keyframe",
|
|
@@ -815,6 +856,15 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
|
|
|
815
856
|
private _warpRate = 1;
|
|
816
857
|
/** Whether KSP is in a flight scene. `undefined` until the first signal. */
|
|
817
858
|
private _inFlight: boolean | undefined = undefined;
|
|
859
|
+
/** Client-side recording controller, lazily built on first `recording` access. */
|
|
860
|
+
private _recording: RecordingController | null = null;
|
|
861
|
+
/**
|
|
862
|
+
* Physical/ceiling max render size per camera, cached from the last
|
|
863
|
+
* `discover()` call. Empty until `discover()` has been called at least
|
|
864
|
+
* once; a camera absent from the last discovery response is simply
|
|
865
|
+
* missing here (its handle's `maxRenderSize` reads `null`).
|
|
866
|
+
*/
|
|
867
|
+
private readonly maxRenderSizeByFlight = new Map<number, { width: number; height: number }>();
|
|
818
868
|
|
|
819
869
|
constructor(cfg: KerbcastClientConfig, transport?: KerbcastTransport) {
|
|
820
870
|
super();
|
|
@@ -867,6 +917,17 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
|
|
|
867
917
|
};
|
|
868
918
|
}
|
|
869
919
|
|
|
920
|
+
/**
|
|
921
|
+
* Client-side recording controller. Records a camera's already-received
|
|
922
|
+
* track with the browser's `MediaRecorder`, annotated with the mission-time
|
|
923
|
+
* (UT) clock; purely local, no sidecar involvement. Lazily created on first
|
|
924
|
+
* access and stable thereafter. See {@link RecordingController}.
|
|
925
|
+
*/
|
|
926
|
+
get recording(): RecordingController {
|
|
927
|
+
if (!this._recording) this._recording = new RecordingController(this, this.cfg.recording);
|
|
928
|
+
return this._recording;
|
|
929
|
+
}
|
|
930
|
+
|
|
870
931
|
/**
|
|
871
932
|
* Whether KSP is currently in a flight scene, from the sidecar's
|
|
872
933
|
* `scene-state-changed`. `undefined` until the first signal (so a
|
|
@@ -898,9 +959,23 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
|
|
|
898
959
|
const res = await fetch(`http://${this.cfg.host}:${this.cfg.port}/cameras`);
|
|
899
960
|
if (!res.ok) throw new Error(`/cameras returned ${res.status}`);
|
|
900
961
|
const body = (await res.json()) as { cameras: DiscoveredCamera[] };
|
|
962
|
+
for (const cam of body.cameras) {
|
|
963
|
+
this.maxRenderSizeByFlight.set(cam.flightId, {
|
|
964
|
+
width: cam.maxWidth,
|
|
965
|
+
height: cam.maxHeight,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
901
968
|
return body.cameras;
|
|
902
969
|
}
|
|
903
970
|
|
|
971
|
+
/**
|
|
972
|
+
* Internal: backs {@link KerbcastCameraHandle.maxRenderSize}. `null` when
|
|
973
|
+
* `discover()` has never been called or never reported this camera.
|
|
974
|
+
*/
|
|
975
|
+
_maxRenderSize(flightId: number): { width: number; height: number } | null {
|
|
976
|
+
return this.maxRenderSizeByFlight.get(flightId) ?? null;
|
|
977
|
+
}
|
|
978
|
+
|
|
904
979
|
/**
|
|
905
980
|
* Open the WebRTC peer and subscribe to cameras. Idempotent: calling
|
|
906
981
|
* `connect` while already connected disconnects first.
|
|
@@ -1040,6 +1115,29 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
|
|
|
1040
1115
|
await this._send({ type: "unsubscribe", content: { flightId } });
|
|
1041
1116
|
}
|
|
1042
1117
|
|
|
1118
|
+
/**
|
|
1119
|
+
* Report this consumer's own displayed pixel size for a camera, driving the
|
|
1120
|
+
* sidecar's auto-resolution. Unlike `setRenderSize` (an operator command that
|
|
1121
|
+
* sets the shared render size directly, last-writer-wins), this is a
|
|
1122
|
+
* per-consumer input the sidecar aggregates MAX-across-consumers, so a big
|
|
1123
|
+
* spotlight bumps the stream up while tiny avatars leave it small. The SDK's
|
|
1124
|
+
* feed primitives self-measure and call this; consumers do not report by hand.
|
|
1125
|
+
*/
|
|
1126
|
+
async reportDisplaySize(flightId: number, width: number, height: number): Promise<void> {
|
|
1127
|
+
await this._send({ type: "report-display-size", content: { flightId, width, height } });
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* Ask a pan+zoom camera to auto-track a moving vessel (or stop). Sends the
|
|
1132
|
+
* intent; the sidecar holds the chosen mode per camera and publishes it in
|
|
1133
|
+
* `CameraState.trackMode`, so the UI must reflect that server-confirmed value
|
|
1134
|
+
* rather than this call (two browsers stay in agreement). While tracking, the
|
|
1135
|
+
* camera also auto-zooms; `TrackMode.None` hands aiming/zoom back.
|
|
1136
|
+
*/
|
|
1137
|
+
async setTrackTarget(flightId: number, mode: TrackMode): Promise<void> {
|
|
1138
|
+
await this._send({ type: "set-track-target", content: { flightId, mode } });
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1043
1141
|
/**
|
|
1044
1142
|
* Internal — resolves the effective noise enabled state for a camera,
|
|
1045
1143
|
* merging the per-camera override (if any) with the client-level default.
|
package/src/index.ts
CHANGED
|
@@ -27,3 +27,25 @@ export {
|
|
|
27
27
|
type PanZoomBounds,
|
|
28
28
|
type PanZoomControllerOptions,
|
|
29
29
|
} from "./panZoom";
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
RecordingController,
|
|
33
|
+
createMediabunnyTrimmerLoader,
|
|
34
|
+
negotiateMimeType,
|
|
35
|
+
commonUtWindow,
|
|
36
|
+
utToRecordingTimeMs,
|
|
37
|
+
DEFAULT_COVERAGE_TIMEOUT_MS,
|
|
38
|
+
ARM_TIMEOUT_MS,
|
|
39
|
+
type ActiveRecordingInfo,
|
|
40
|
+
type RecordingClient,
|
|
41
|
+
type RecordingControllerOptions,
|
|
42
|
+
type RecordingHandle,
|
|
43
|
+
type RecordingsSnapshot,
|
|
44
|
+
type GroupedRecordingHandle,
|
|
45
|
+
type GroupedRecordingStart,
|
|
46
|
+
type GroupTrimmer,
|
|
47
|
+
type TrimmerLoader,
|
|
48
|
+
type UtSample,
|
|
49
|
+
type StartRecordingOptions,
|
|
50
|
+
type StopGroupedRecordingOptions,
|
|
51
|
+
} from "./recording";
|