@ksp-gonogo/kerbcast 1.10.2 → 1.11.0-rc.10

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/README.md CHANGED
@@ -59,6 +59,10 @@ client.on("adaptive-shed", (event) => {
59
59
  client.disconnect();
60
60
  ```
61
61
 
62
+ Commands can be sent as soon as `connect` resolves. Any sent before the
63
+ control channel has opened wait for it, and reject if it has not opened
64
+ within 10 s or the client disconnects first.
65
+
62
66
  ### Pre-handshake discovery
63
67
 
64
68
  ```ts
@@ -85,6 +89,65 @@ const setLayers: ClientMessage = {
85
89
  controlChannel.send(JSON.stringify(setLayers));
86
90
  ```
87
91
 
92
+ ### Encoded-frame transforms
93
+
94
+ `cam.setEncodedTransform(factory)` runs your transform on a camera's encoded
95
+ frames before the browser decodes them. It always runs before Kerbcast's
96
+ static and frame lock, so anything it does (holding frames back, dropping
97
+ them) shows up in `cam.mediaStream` with the static still drawn on top.
98
+
99
+ Kerbcast installs the transform on whichever receiver carries the camera. A
100
+ transform instance works on one receiver only, so you pass a factory:
101
+ Kerbcast calls it for a fresh instance when the camera is bound, again when a
102
+ slot rebind moves the camera to another receiver, and again after a
103
+ reconnect. A receiver the camera leaves goes back to passthrough, and frames
104
+ the old instance still holds are dropped. `setEncodedTransform(null)`
105
+ detaches. It works in both legacy and dynamic slot modes.
106
+
107
+ It needs `encodedTransforms: true` when the client is constructed, and
108
+ `setEncodedTransform` throws without it. The option gives every receiver a
109
+ passthrough transform from the start, because Chrome only honours a receiver
110
+ transform that was in place before negotiation. It costs a worker hop per
111
+ frame for every camera, so leave it off unless you use this. The passthrough
112
+ worker loads from a `blob:` URL, so a Content Security Policy must allow
113
+ `worker-src blob:`. `setEncodedTransform` also throws when the browser has no
114
+ install path; `client.encodedTransformPath` reports which one you will get:
115
+
116
+ - `"script"`: return an `RTCRtpScriptTransform` (Chrome, Safari and Firefox)
117
+ - `"streams"`: return a `TransformStream` of `RTCEncodedVideoFrame`, run on the main thread over `createEncodedStreams()` (older Chrome)
118
+
119
+ A one-second delay on the `"script"` path:
120
+
121
+ ```ts
122
+ const client = new KerbcastClient({ host, port, encodedTransforms: true });
123
+ const worker = new Worker(new URL("./delay-worker.js", import.meta.url));
124
+
125
+ client.camera(2592004302).setEncodedTransform(({ flightId, path }) =>
126
+ path === "script"
127
+ ? new RTCRtpScriptTransform(worker, { flightId, delayMs: 1000 })
128
+ : delayStream(1000),
129
+ );
130
+
131
+ // delay-worker.js
132
+ onrtctransform = ({ transformer }) => {
133
+ const { delayMs } = transformer.options;
134
+ transformer.readable
135
+ .pipeThrough(
136
+ new TransformStream({
137
+ transform(frame, controller) {
138
+ setTimeout(() => controller.enqueue(frame), delayMs);
139
+ },
140
+ }),
141
+ )
142
+ .pipeTo(transformer.writable)
143
+ .catch(() => {}); // a replaced instance is aborted
144
+ };
145
+ ```
146
+
147
+ `delayStream` is the same `TransformStream` built on the main thread. A
148
+ factory that throws, or returns the wrong kind for the path, is reported as a
149
+ client `error` event carrying the camera's `flightId`.
150
+
88
151
  ## Per-camera capabilities
89
152
 
90
153
  `CameraState` includes capability flags so consumers render only the
@@ -106,8 +169,9 @@ controls each part actually supports.
106
169
  skip, producing the macroblocking and stuttering aesthetic of
107
170
  in-game CommNet signal loss. Also a real CPU optimisation when
108
171
  every consumer wants degraded video.
109
- - `kind` (`"part"` | `"kerbal"`) and, for a kerbal face camera,
110
- `crewLocation` (`"seat"` | `"eva"`). Distinguish crew face cameras
172
+ - `kind` (`"part"` | `"kerbal"` | `"command"`) and, for a kerbal face
173
+ camera, `crewLocation` (`"seat"` | `"eva"`). Distinguish crew face
174
+ cameras and command cameras (see [Command cameras](#command-cameras))
111
175
  from part cameras.
112
176
  - `trackMode` (`"none"` | `"activeVessel"` | `"target"`). Server-authoritative
113
177
  auto-track state on a pan+zoom camera, so every consumer reflects the same
@@ -123,6 +187,254 @@ controls each part actually supports.
123
187
  the active vessel or its target (`TrackMode`), or `"none"` to stop. The
124
188
  chosen mode is published back on `CameraState.trackMode`.
125
189
 
190
+ ## Command cameras
191
+
192
+ A command camera is a camera that stands on the ground (or rides a craft)
193
+ and watches a craft from there, the way a tracking station would. The
194
+ game's settings give each scene one, the Launchpad camera, and a client can
195
+ create more at any vantage. Each appears in `client.cameras` with
196
+ `kind: "command"` and a `commandCentre` describing where it stands and what
197
+ it watches.
198
+
199
+ A created camera lasts for the rest of the game scene (a flight, or the
200
+ Space Center while the game's command camera setting is on); leaving the
201
+ scene removes it. Every connected client sees it, and any of them can aim
202
+ or destroy it.
203
+
204
+ The vantage is one of:
205
+
206
+ - `launchSite`: beside a launch site. `site` names it (`"LaunchPad"`,
207
+ `"Runway"`, a Making History or Kerbal Konstructs site); omit it for the
208
+ active craft's launch site. `distance` (default 500 m), `bearing` from the
209
+ site (default 180, due south) and `height` (default 5 m)
210
+ - `station`: at a CommNet ground station, by its node name (`"KSC"`), with
211
+ `height` above it (default 30 m)
212
+ - `surface`: at any `latitude` and `longitude` on a `body`, `height` metres
213
+ above the ground there (above the sea over water; default 5 m)
214
+ - `vessel`: riding the craft with that `persistentId`. Keep that craft
215
+ beyond physics range of the active one, or the lens sits inside its parts.
216
+ If the craft docks into another (or is grabbed by a claw), the camera rides
217
+ the combined craft. If the craft is destroyed or recovered, the camera is
218
+ removed and its feed ends
219
+
220
+ `createCommandCamera` resolves with the new camera's `CameraState` once the
221
+ game has built it and reported what it is watching, normally within a
222
+ second or two, so `commandCentre.subjectPersistentId` and
223
+ `commandCentre.targets` are already filled in. If that report has not come
224
+ 5 s after the camera appeared, it resolves with the state as it stands. The
225
+ request carries a token (random unless you pass `token`) that the camera
226
+ reports back as `commandCentre.token`; that is how the promise finds it,
227
+ and how any other code can. A vantage the game can not find (an unknown
228
+ body, site, station or craft) builds nothing, and the promise rejects after
229
+ `timeoutMs` (default 35 s, since the game waits up to 30 s for a site to be
230
+ placed).
231
+
232
+ Lens reach depends on the vantage: a launch site camera has the tracking
233
+ camera's lens at every Tracking Station level, a station's lens follows the
234
+ Tracking Station's level in career, and surface and vessel vantages zoom
235
+ fully.
236
+
237
+ ### Aiming
238
+
239
+ `client.setCommandAim(flightId, aim)` points any command camera, and `null`
240
+ returns it to its default: the active craft in flight, the configured craft
241
+ at the Space Center. `createCommandCamera` takes the same `aim` as an option.
242
+ The aim is one of:
243
+
244
+ - `vessel`: follows the craft with that `persistentId` (any entry of
245
+ `commandCentre.targets`, whatever its eligibility)
246
+ - `orbit`: follows a conic (`body`, `inclination`, `eccentricity`,
247
+ `semiMajorAxis`, `lan`, `argumentOfPeriapsis` in degrees and metres,
248
+ `meanAnomalyAtEpoch` in radians, `epoch`)
249
+ - `positionAt`: follows a position you propagate yourself: `position` and
250
+ `velocity` as `[x, y, z]` relative to `body` in its non-rotating frame at
251
+ `ut`, carried on linearly
252
+ - `surface`: holds a point on a `body` (`latitude`, `longitude`, `altitude`
253
+ in metres above sea level), turning with the planet
254
+ - `direction`: holds a direction from the camera's local horizon,
255
+ `azimuth` degrees clockwise from north and `elevation` degrees above the
256
+ horizon
257
+
258
+ The subject is the craft the camera draws (where it truly is, so an aim off
259
+ the craft shows it off centre), brackets, sizes its zoom by and reads out. A
260
+ `vessel` aim's craft is its subject; `orbit` and `positionAt` take an
261
+ optional `subjectPersistentId`; otherwise the default craft stays the
262
+ subject. A `direction` aim zooms for the subject's range, so the subject is
263
+ framed as it passes through. Every kind takes `framingMarginM`, metres of
264
+ view kept around the subject's own size. `commandCentre.aimKind` (absent for
265
+ the default) and `commandCentre.subjectPersistentId` report what the game
266
+ applied.
267
+
268
+ ### A launchpad camera
269
+
270
+ ```ts
271
+ import { CommandVantageKind, KerbcastClient } from "@ksp-gonogo/kerbcast";
272
+
273
+ const client = new KerbcastClient({ host: "192.168.1.74", port: 8088 });
274
+ await client.connect([], { slots: 2 });
275
+
276
+ // 500 m south of the active craft's launch site, 5 m up.
277
+ const pad = await client.createCommandCamera(
278
+ { kind: CommandVantageKind.LaunchSite },
279
+ { name: "Pad south" },
280
+ );
281
+
282
+ const video = document.querySelector("video")!;
283
+ client.camera(pad.flightId).on("stream", (stream) => {
284
+ video.srcObject = stream;
285
+ });
286
+ await client.subscribe(pad.flightId);
287
+
288
+ // Later: the feed ends for every client.
289
+ await client.destroyCommandCamera(pad.flightId);
290
+ ```
291
+
292
+ `{ kind: CommandVantageKind.LaunchSite, site: "Runway", distance: 800,
293
+ bearing: 90, height: 20 }` stands one 800 m east of the runway, 20 m up.
294
+
295
+ ### Between two vantages
296
+
297
+ Here a camera stands on a rise west of the KSC and watches the launch pad,
298
+ then a particular craft, then the sky above the pad, then whichever craft
299
+ is active. The coordinates are illustrative.
300
+
301
+ ```ts
302
+ import {
303
+ CommandAimKind,
304
+ CommandVantageKind,
305
+ type KerbcastClient,
306
+ } from "@ksp-gonogo/kerbcast";
307
+
308
+ declare const client: KerbcastClient; // connected as above
309
+
310
+ const hill = await client.createCommandCamera(
311
+ {
312
+ kind: CommandVantageKind.Surface,
313
+ body: "Kerbin",
314
+ latitude: -0.08,
315
+ longitude: -74.75,
316
+ height: 10,
317
+ },
318
+ {
319
+ name: "West ridge",
320
+ // The pad: altitude is metres above sea level, and the margin keeps
321
+ // 60 m of view around the subject.
322
+ aim: {
323
+ kind: CommandAimKind.Surface,
324
+ body: "Kerbin",
325
+ latitude: -0.0972,
326
+ longitude: -74.5577,
327
+ altitude: 70,
328
+ framingMarginM: 60,
329
+ },
330
+ },
331
+ );
332
+
333
+ // Follow a particular craft, by Vessel.persistentId.
334
+ await client.setCommandAim(hill.flightId, {
335
+ kind: CommandAimKind.Vessel,
336
+ persistentId: 4000000001,
337
+ });
338
+
339
+ // Hold still, looking east and 30 degrees up, for a launch to climb into.
340
+ await client.setCommandAim(hill.flightId, {
341
+ kind: CommandAimKind.Direction,
342
+ azimuth: 90,
343
+ elevation: 30,
344
+ });
345
+
346
+ // Back to the default: the active craft.
347
+ await client.setCommandAim(hill.flightId, null);
348
+ ```
349
+
350
+ A station watching a craft is the same with
351
+ `{ kind: CommandVantageKind.Station, station: "KSC" }` and a `vessel` aim.
352
+
353
+ ### The command lock
354
+
355
+ A mod in game can take a lock on every command camera's aim
356
+ (`KerbcastControl.SetCommandAuthority(owner)`). While it holds the lock only
357
+ its own aims are accepted: `setCommandAim` rejects with a
358
+ `CommandLockedError` (its `owner` names the holder) whenever the camera's
359
+ `commandTrack.authority` reports one, and a call that races the lock being
360
+ taken comes back as an `error` event with code `ErrorCode.CommandLocked`.
361
+ `createCommandCamera` still creates the camera, at its default aim. When the
362
+ lock is released every command camera returns to its default; aims set
363
+ before or during the lock are not restored.
364
+
365
+ The lock holds until its holder releases it (or the scene changes); it never
366
+ times out. A holder that stops calling for 5 s blanks every command feed
367
+ ("gonogo not responding") but keeps the lock, so aims from anyone else are
368
+ still refused. This is deliberate: a holder that delays commands (gonogo
369
+ applies signal delay) can not be bypassed while it is unresponsive.
370
+
371
+ ### In game (C#)
372
+
373
+ Another KSP mod can do the same through `Kerbcast.KerbcastControl`, with
374
+ no sidecar round trip:
375
+
376
+ ```csharp
377
+ using Kerbcast;
378
+
379
+ // Returns the flightId the feed will have, or 0 when refused.
380
+ uint pad = KerbcastControl.CreateCommandCamera(CommandVantage.LaunchSite());
381
+
382
+ uint ridge = KerbcastControl.CreateCommandCamera(
383
+ CommandVantage.Surface("Kerbin", -0.08, -74.75, height: 10), "West ridge",
384
+ CommandAim.Surface("Kerbin", -0.0972, -74.5577, 70, framingMarginM: 60));
385
+ KerbcastControl.SetCommandAim(ridge, CommandAim.Vessel(vessel.persistentId));
386
+ KerbcastControl.SetCommandAim(ridge, CommandAim.Direction(azimuth: 90, elevation: 30));
387
+ KerbcastControl.SetCommandAim(ridge, null); // back to the default
388
+
389
+ KerbcastControl.DestroyCommandCamera(ridge);
390
+ ```
391
+
392
+ Holding the lock:
393
+
394
+ ```csharp
395
+ // Re-take it on every scene load; a different owner is refused.
396
+ KerbcastControl.SetCommandAuthority("gonogo");
397
+
398
+ // Only calls passing the owner are accepted now, and each is a heartbeat.
399
+ KerbcastControl.SetCommandAim(pad, CommandAim.PositionAt("Kerbin", ut, pos, vel,
400
+ subject: craft.persistentId, framingMarginM: uncertaintyM), "gonogo");
401
+ KerbcastControl.SetCommandAim(pad, CommandAim.Vessel(other)); // false: locked
402
+
403
+ // Every command camera returns to its default.
404
+ KerbcastControl.ClearCommandAuthority("gonogo");
405
+ ```
406
+
407
+ - `CommandVantage.LaunchSite(site, distance, bearing, height)`,
408
+ `CommandVantage.Station(nodeName, height)`,
409
+ `CommandVantage.Surface(body, latitude, longitude, height)` and
410
+ `CommandVantage.Vessel(persistentId)` mirror the vantage kinds above, with
411
+ the same defaults
412
+ - `CommandAim.Vessel(persistentId, framingMarginM = 0)`,
413
+ `Orbit(body, inc, e, sma, lan, argPe, meanAnomalyAtEpoch, epoch, subject = 0, framingMarginM = 0)`,
414
+ `PositionAt(body, ut, pos, vel, subject = 0, framingMarginM = 0)`,
415
+ `Surface(body, lat, lon, alt, framingMarginM = 0)` and
416
+ `Direction(azimuth, elevation, framingMarginM = 0)` mirror the aim kinds
417
+ - `CreateCommandCamera(vantage, name = null, aim = null)` returns the
418
+ flightId at once; the feed appears when the vantage resolves, and
419
+ `SetCommandAim` works on it before then too. Under a held lock the aim is
420
+ not applied
421
+ - `SetCommandAim(flightId, aim, owner = null)` returns false when the lock
422
+ refuses it, the feed is not a command feed, or the aim is unusable (a
423
+ craft that no longer exists, such as one docked into another, is
424
+ unusable)
425
+ - The lock holds until `ClearCommandAuthority` or a scene change, and fails
426
+ closed: a holder that makes no call (`SetCommandAuthority`, `SetCommandAim`
427
+ with its owner, `SetCommandBlank`) for 5 s of real time blanks every
428
+ command feed "gonogo not responding" and keeps the lock, so everyone else
429
+ stays refused. `SetCommandBlank(flightId, reason)` blanks one feed
430
+ - `DestroyCommandCamera(flightId)` returns false for the settings camera or
431
+ an unknown id
432
+ - `CommandFeeds()` lists every built command camera, with its `Aim`,
433
+ `SubjectPersistentId`, and `Created` set on the ones made at runtime;
434
+ `CommandTargets(flightId)` lists the craft it could follow (a craft docked
435
+ into another drops out; the combined craft keeps the survivor's
436
+ `persistentId`)
437
+
126
438
  ## Pan/zoom input helper
127
439
 
128
440
  Driving pan and zoom from a gamepad stick, nudge buttons, or an FoV