@ksp-gonogo/kerbcast-react 1.11.0 → 1.12.0-rc.1

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.
Files changed (42) hide show
  1. package/README.md +59 -0
  2. package/dist/AnchorMarkers.d.ts +128 -0
  3. package/dist/AnchorMarkers.js +162 -0
  4. package/dist/CameraFeed.d.ts +45 -4
  5. package/dist/CameraFeed.js +78 -10
  6. package/dist/CommandBrackets.d.ts +61 -12
  7. package/dist/CommandBrackets.js +106 -53
  8. package/dist/KerbalFaceFeed.d.ts +1 -1
  9. package/dist/KerbalFaceFeed.js +1 -1
  10. package/dist/RecordingsTray.d.ts +6 -0
  11. package/dist/RecordingsTray.js +17 -1
  12. package/dist/hooks/useCommandDisplayUt.d.ts +16 -3
  13. package/dist/hooks/useCommandDisplayUt.js +57 -16
  14. package/dist/hooks/useKerbcastWaitingForSlot.d.ts +5 -0
  15. package/dist/hooks/useKerbcastWaitingForSlot.js +20 -0
  16. package/dist/hooks/useTargetBracketsPreference.d.ts +3 -2
  17. package/dist/hooks/useTargetBracketsPreference.js +18 -5
  18. package/dist/index.d.ts +4 -1
  19. package/dist/index.js +5 -1
  20. package/dist/sharedAnchors.d.ts +3 -0
  21. package/dist/sharedAnchors.js +61 -0
  22. package/dist/videoBox.d.ts +10 -0
  23. package/dist/videoBox.js +40 -0
  24. package/package.json +2 -2
  25. package/src/AnchorMarkers.test.tsx +173 -0
  26. package/src/AnchorMarkers.tsx +341 -0
  27. package/src/CameraFeed.test.tsx +223 -6
  28. package/src/CameraFeed.tsx +145 -14
  29. package/src/CommandBrackets.test.tsx +204 -4
  30. package/src/CommandBrackets.tsx +170 -53
  31. package/src/KerbalFaceFeed.tsx +1 -1
  32. package/src/RecordingsTray.test.tsx +15 -0
  33. package/src/RecordingsTray.tsx +25 -1
  34. package/src/hooks/useCommandDisplayUt.test.tsx +74 -3
  35. package/src/hooks/useCommandDisplayUt.ts +61 -15
  36. package/src/hooks/useKerbcastWaitingForSlot.ts +25 -0
  37. package/src/hooks/useRecordings.sharedState.test.tsx +6 -3
  38. package/src/hooks/useRecordings.test.tsx +6 -3
  39. package/src/hooks/useTargetBracketsPreference.ts +15 -5
  40. package/src/index.ts +23 -1
  41. package/src/sharedAnchors.ts +67 -0
  42. package/src/videoBox.ts +49 -0
@@ -1,10 +1,19 @@
1
1
  import {
2
+ COMMAND_AIM_ANCHOR_ID,
3
+ COMMAND_SUBJECT_ANCHOR_ID,
4
+ KERBCAST_ANCHOR_OWNER,
5
+ anchorPoint,
2
6
  commandReadout,
7
+ findAnchor,
8
+ frameToScreen,
3
9
  type EffectiveCommandTrack,
10
+ type FrameAnchor,
4
11
  type FrameUtSource,
5
12
  } from "@ksp-gonogo/kerbcast";
6
- import { useEffect, useState, type ReactNode } from "react";
13
+ import { type ReactNode } from "react";
7
14
  import styled from "styled-components";
15
+ import { useDisplayedFrame } from "./AnchorMarkers";
16
+ import { useVideoBox } from "./videoBox";
8
17
 
9
18
  const BRACKET_STROKE_PX = 2;
10
19
  const BRACKET_MIN_HALF_PX = 14;
@@ -51,7 +60,10 @@ export interface BracketPoint {
51
60
 
52
61
  /** The bracket box, in the pixel space of the bracket SVG (the video element's box). */
53
62
  export interface BracketGeometry {
54
- /** Box centre, which is the aim point. */
63
+ /**
64
+ * Box centre: where the subject is in the displayed frame, or the frame
65
+ * centre (the aim point) while that is not known.
66
+ */
55
67
  cx: number;
56
68
  cy: number;
57
69
  /** Half the box side in display px. */
@@ -65,20 +77,22 @@ export interface BracketGeometry {
65
77
  }
66
78
 
67
79
  /**
68
- * The bracket box centred on the displayed frame. `bracketHalfPx` is in
69
- * capture px of a `frameHeightPx`-tall frame, so it scales by the displayed
70
- * height; a missing half-size draws the minimum box.
80
+ * The bracket box, centred on the displayed frame or on `centre` (in the
81
+ * same pixel space as `rect`). `bracketHalfPx` is in capture px of a
82
+ * `frameHeightPx`-tall frame, so it scales by the displayed height; a
83
+ * missing half-size draws the minimum box.
71
84
  */
72
85
  export function bracketGeometry(
73
86
  rect: ContentRect,
74
87
  bracketHalfPx: number | null,
75
88
  frameHeightPx: number,
89
+ centre?: BracketPoint | null,
76
90
  ): BracketGeometry {
77
91
  const scale = frameHeightPx > 0 ? rect.height / frameHeightPx : 1;
78
92
  const half = Math.max(BRACKET_MIN_HALF_PX, (bracketHalfPx ?? 0) * scale);
79
93
  const arm = Math.max(BRACKET_MIN_ARM_PX, BRACKET_ARM_FRACTION * half * 2);
80
- const cx = rect.x + rect.width / 2;
81
- const cy = rect.y + rect.height / 2;
94
+ const cx = centre ? centre.x : rect.x + rect.width / 2;
95
+ const cy = centre ? centre.y : rect.y + rect.height / 2;
82
96
  return {
83
97
  cx,
84
98
  cy,
@@ -118,6 +132,54 @@ export interface CommandBracketContext {
118
132
  displayUt: number;
119
133
  utSource: FrameUtSource | "override" | null;
120
134
  state: EffectiveCommandTrack;
135
+ /**
136
+ * The subject's anchor in the displayed frame: where the game projected
137
+ * the craft, whether it is in frame and whether something hides it.
138
+ * `null` without one (an older sidecar or plugin, or before the first
139
+ * stamped frame), when the box is centred on the frame instead.
140
+ */
141
+ subject?: FrameAnchor | null;
142
+ /**
143
+ * True while the aim lies off the frame (the view is still turning onto
144
+ * it): the box then sits on the frame's edge nearest the aim, with a
145
+ * chevron pointing on toward it.
146
+ */
147
+ aimOffscreen?: boolean;
148
+ }
149
+
150
+ /** Whether `p` lies inside the box `g`. */
151
+ function holds(g: BracketGeometry, p: BracketPoint): boolean {
152
+ return p.x >= g.topLeft.x && p.x <= g.bottomRight.x && p.y >= g.topLeft.y && p.y <= g.bottomRight.y;
153
+ }
154
+
155
+ /** `g` moved so the whole box lies inside `rect`. */
156
+ function clampToRect(g: BracketGeometry, rect: ContentRect): BracketGeometry {
157
+ const clamp = (v: number, lo: number, hi: number) => (lo > hi ? (lo + hi) / 2 : Math.min(hi, Math.max(lo, v)));
158
+ const cx = clamp(g.cx, rect.x + g.half, rect.x + rect.width - g.half);
159
+ const cy = clamp(g.cy, rect.y + g.half, rect.y + rect.height - g.half);
160
+ return {
161
+ ...g,
162
+ cx,
163
+ cy,
164
+ topLeft: { x: cx - g.half, y: cy - g.half },
165
+ bottomRight: { x: cx + g.half, y: cy + g.half },
166
+ };
167
+ }
168
+
169
+ /** A chevron just outside the box, pointing from its centre toward `toward`. */
170
+ function edgeCue(g: BracketGeometry, toward: BracketPoint): string | null {
171
+ const dx = toward.x - g.cx;
172
+ const dy = toward.y - g.cy;
173
+ const len = Math.hypot(dx, dy);
174
+ if (!(len > 0)) return null;
175
+ const ux = dx / len;
176
+ const uy = dy / len;
177
+ const tipX = g.cx + ux * (g.half + g.arm);
178
+ const tipY = g.cy + uy * (g.half + g.arm);
179
+ const backX = tipX - ux * g.arm;
180
+ const backY = tipY - uy * g.arm;
181
+ const w = g.arm * 0.6;
182
+ return `M${backX - uy * w} ${backY + ux * w}L${tipX} ${tipY}L${backX + uy * w} ${backY - ux * w}`;
121
183
  }
122
184
 
123
185
  /**
@@ -143,8 +205,8 @@ export interface CommandBracketOverlay {
143
205
  readout?: (defaultText: string, ctx: CommandBracketContext) => string | null;
144
206
  }
145
207
 
146
- function cameraCornerStyle(state: EffectiveCommandTrack): CommandBracketCornerStyle {
147
- return state.occluded
208
+ function cameraCornerStyle(occluded: boolean): CommandBracketCornerStyle {
209
+ return occluded
148
210
  ? { colour: COMMAND_BRACKET_COLOURS.occluded, dashed: true }
149
211
  : { colour: COMMAND_BRACKET_COLOURS.clear, dashed: false };
150
212
  }
@@ -160,41 +222,6 @@ function applyCorner(
160
222
  };
161
223
  }
162
224
 
163
- /** Tracks the rendered box and intrinsic size of a video element. */
164
- function useVideoBox(video: HTMLVideoElement | null) {
165
- const [box, setBox] = useState({ boxW: 0, boxH: 0, mediaW: 0, mediaH: 0 });
166
- useEffect(() => {
167
- if (!video) return;
168
- const measure = () => {
169
- const next = {
170
- boxW: video.clientWidth,
171
- boxH: video.clientHeight,
172
- mediaW: video.videoWidth,
173
- mediaH: video.videoHeight,
174
- };
175
- setBox((prev) =>
176
- prev.boxW === next.boxW &&
177
- prev.boxH === next.boxH &&
178
- prev.mediaW === next.mediaW &&
179
- prev.mediaH === next.mediaH
180
- ? prev
181
- : next,
182
- );
183
- };
184
- measure();
185
- const observer = new ResizeObserver(measure);
186
- observer.observe(video);
187
- video.addEventListener("loadedmetadata", measure);
188
- video.addEventListener("resize", measure);
189
- return () => {
190
- observer.disconnect();
191
- video.removeEventListener("loadedmetadata", measure);
192
- video.removeEventListener("resize", measure);
193
- };
194
- }, [video]);
195
- return box;
196
- }
197
-
198
225
  export interface CommandBracketsProps {
199
226
  state: EffectiveCommandTrack;
200
227
  displayUt: number;
@@ -204,14 +231,35 @@ export interface CommandBracketsProps {
204
231
  /** Frame aspect to assume until the video reports its own size. */
205
232
  fallbackSize?: { width: number; height: number } | null;
206
233
  overlay?: CommandBracketOverlay | null;
234
+ /**
235
+ * The subject's anchor in the displayed frame. While it is visible and no
236
+ * command lock is held the box sits on it and takes its size from it.
237
+ */
238
+ subject?: FrameAnchor | null;
239
+ /** The capture frame size `subject` is in (the stamp's width and height). */
240
+ subjectFrameSize?: { width: number; height: number } | null;
241
+ /**
242
+ * The aim anchor in the displayed frame. The aimed box sits on it rather
243
+ * than on the frame centre, so it stays on the aim while the view turns
244
+ * onto a moved one.
245
+ */
246
+ aim?: FrameAnchor | null;
207
247
  }
208
248
 
209
249
  /**
210
- * Target brackets for a command camera feed: the aim point sits at frame
211
- * centre, so the box is always centred on the displayed video. Both corners
212
- * show the camera axis by default; an overlay can restyle either and draw
213
- * its own marks. Without a fix, or over a blanked feed, the default corners
214
- * are left out but the overlay and readout still run.
250
+ * Target brackets for a command camera feed. With no command lock held and
251
+ * the subject's anchor visible in the displayed frame, the box sits on the
252
+ * craft where the game saw it, so it stays on the craft when an aim points
253
+ * elsewhere; while the aimed box is in frame and holds the craft it is drawn
254
+ * instead, since it frames the craft's whole extent as the view does. Otherwise (the anchor out of frame or occluded, which then
255
+ * carries no position, no anchor at all, or a command authority holding the
256
+ * lock) the box is the 1.11 one: on the aim (the frame centre, or the aim
257
+ * anchor while the view turns onto a moved aim), sized from the aimed range,
258
+ * red and dashed when the aim line is occluded. `ctx.subject` always
259
+ * carries the anchor, so a consumer can draw it as a second mark. An overlay
260
+ * can restyle either corner and draw its own marks. Without a fix, or over a
261
+ * blanked feed, the default corners are left out but the overlay and readout
262
+ * still run.
215
263
  */
216
264
  export function CommandBrackets({
217
265
  state,
@@ -220,6 +268,9 @@ export function CommandBrackets({
220
268
  video,
221
269
  fallbackSize,
222
270
  overlay,
271
+ subject,
272
+ subjectFrameSize,
273
+ aim,
223
274
  }: CommandBracketsProps) {
224
275
  const { boxW, boxH, mediaW, mediaH } = useVideoBox(video);
225
276
  const rect = containedContentRect(
@@ -228,7 +279,36 @@ export function CommandBrackets({
228
279
  mediaW || fallbackSize?.width || 0,
229
280
  mediaH || fallbackSize?.height || 0,
230
281
  );
231
- const g = bracketGeometry(rect, state.bracketHalfPx, state.frameHeightPx || mediaH);
282
+ const frameW = subjectFrameSize?.width || mediaW || fallbackSize?.width || 0;
283
+ const frameH = subjectFrameSize?.height || mediaH || fallbackSize?.height || 0;
284
+ /* Under the lock the box is the authority's aim, as in 1.11. */
285
+ const point =
286
+ subject && !state.authority && frameW > 0 && frameH > 0 ? anchorPoint(subject) : null;
287
+ const placed = point !== null;
288
+ const aimPoint = aim && frameW > 0 && frameH > 0 ? anchorPoint(aim) : null;
289
+ const aimScreen = aimPoint ? frameToScreen(aimPoint, frameW, frameH, { ...rect, scale: 1 }) : null;
290
+ const aimOffscreen = !point && !!aimScreen && (!aim?.inFrame || !!aim?.behind);
291
+ const aimed = bracketGeometry(rect, state.bracketHalfPx, state.frameHeightPx || mediaH, aimScreen);
292
+ const subjectScreen = point ? frameToScreen(point, frameW, frameH, { ...rect, scale: 1 }) : null;
293
+ /*
294
+ * The aimed box, when it is in frame and holds the subject, frames the
295
+ * craft as the view does: around a craft and its open parachute rather
296
+ * than a sphere about its centre of mass.
297
+ */
298
+ const framed =
299
+ !!subjectScreen &&
300
+ !!aimScreen &&
301
+ !!aim?.inFrame &&
302
+ !aim?.behind &&
303
+ state.bracketHalfPx != null &&
304
+ holds(aimed, subjectScreen);
305
+ const unclamped =
306
+ subjectScreen && !framed
307
+ ? bracketGeometry(rect, subject?.apparentRadiusPx ?? null, frameH, subjectScreen)
308
+ : aimed;
309
+ /* An aim off the frame (mid-slew) keeps the box on the edge nearest it, with a cue pointing on. */
310
+ const g = aimOffscreen ? clampToRect(unclamped, rect) : unclamped;
311
+ const cue = aimOffscreen && aimScreen ? edgeCue(g, aimScreen) : null;
232
312
  const ctx: CommandBracketContext = {
233
313
  geometry: g,
234
314
  width: boxW,
@@ -236,23 +316,32 @@ export function CommandBrackets({
236
316
  displayUt,
237
317
  utSource: utSource ?? null,
238
318
  state,
319
+ subject: subject ?? null,
320
+ aimOffscreen,
239
321
  };
240
322
  const overrides =
241
323
  typeof overlay?.corners === "function" ? overlay.corners(ctx) : overlay?.corners;
242
- const camera = cameraCornerStyle(state);
324
+ const camera = cameraCornerStyle(placed ? !!subject?.occluded : state.occluded);
243
325
  const topLeft = applyCorner(camera, overrides?.topLeft);
244
326
  const bottomRight = applyCorner(camera, overrides?.bottomRight);
245
327
  const { x: left, y: top } = g.topLeft;
246
328
  const { x: right, y: bottom } = g.bottomRight;
247
329
  const blanked = !!state.blank;
248
330
  const drawCorners = state.hasFix && !blanked;
249
- const defaultReadout = blanked ? "" : commandReadout(state);
331
+ /* A feed waiting for its target says so itself; NO FIX would only repeat it. */
332
+ const defaultReadout = blanked || state.waitingForTarget ? "" : commandReadout(state);
250
333
  const readout = overlay?.readout ? overlay.readout(defaultReadout, ctx) : defaultReadout;
251
334
 
252
335
  return (
253
336
  <>
254
337
  {(drawCorners || overlay?.render) && (
255
- <BracketSvg aria-hidden="true" data-camera-axis={state.camera} width={boxW} height={boxH}>
338
+ <BracketSvg
339
+ aria-hidden="true"
340
+ data-camera-axis={state.camera}
341
+ data-subject={subject ? (placed ? "placed" : "centred") : undefined}
342
+ width={boxW}
343
+ height={boxH}
344
+ >
256
345
  {drawCorners && !topLeft.hidden && (
257
346
  <path
258
347
  data-corner="topLeft"
@@ -273,6 +362,15 @@ export function CommandBrackets({
273
362
  style={{ stroke: bottomRight.colour }}
274
363
  />
275
364
  )}
365
+ {drawCorners && cue && (
366
+ <path
367
+ data-aim-cue=""
368
+ d={cue}
369
+ fill="none"
370
+ strokeWidth={g.stroke}
371
+ style={{ stroke: camera.colour }}
372
+ />
373
+ )}
276
374
  {overlay?.render && <g data-bracket-overlay="">{overlay.render(ctx)}</g>}
277
375
  </BracketSvg>
278
376
  )}
@@ -288,6 +386,25 @@ export function CommandBrackets({
288
386
  );
289
387
  }
290
388
 
389
+ /**
390
+ * CommandBrackets fed with the subject's and the aim's anchors from each frame of
391
+ * `flightId` that `video` presents. Re-renders at the frame rate, which is
392
+ * why it is its own component.
393
+ */
394
+ export function LiveCommandBrackets({
395
+ flightId,
396
+ ...props
397
+ }: CommandBracketsProps & { flightId: number | null }) {
398
+ const frame = useDisplayedFrame(true, flightId, props.video);
399
+ const subject = findAnchor(frame?.anchors, COMMAND_SUBJECT_ANCHOR_ID, KERBCAST_ANCHOR_OWNER);
400
+ const aim = findAnchor(frame?.anchors, COMMAND_AIM_ANCHOR_ID, KERBCAST_ANCHOR_OWNER);
401
+ const size =
402
+ frame?.frameWidth && frame.frameHeight
403
+ ? { width: frame.frameWidth, height: frame.frameHeight }
404
+ : null;
405
+ return <CommandBrackets {...props} subject={subject} subjectFrameSize={size} aim={aim} />;
406
+ }
407
+
291
408
  /** Reason a command feed is blanked, centred on black over the feed. */
292
409
  export function CommandBlank({ reason }: { reason: string }) {
293
410
  return (
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Renders ONE kerbal's face-camera stream in a fixed SQUARE frame. It is the
5
5
  * reusable building block a crew bar (kerbcast web) or a mission-control
6
- * surface (gonogo) composes: identity-keyed by `flightId`, it binds the live
6
+ * surface composes: identity-keyed by `flightId`, it binds the live
7
7
  * `MediaStream` to a persistent `<video>` and NEVER remounts that element on
8
8
  * re-layout (the "never remount a live feed" rule — the same srcObject-via-ref
9
9
  * technique `CameraFeed` uses).
@@ -153,6 +153,21 @@ describe("RecordingsTray - single clips", () => {
153
153
  expect(screen.getByText(/unsaved clips/i)).toBeTruthy();
154
154
  });
155
155
 
156
+ it("says why a clip stopped on its own, and nothing for one a person stopped", () => {
157
+ fakeStoreSeed.recordings = [
158
+ makeRecording({ recordingId: "rec-1", endedBy: "camera-gone" }),
159
+ makeRecording({ recordingId: "rec-2", endedBy: "disconnected" }),
160
+ makeRecording({ recordingId: "rec-3", endedBy: "feed-closed" }),
161
+ makeRecording({ recordingId: "rec-4", endedBy: "user" }),
162
+ ];
163
+ render(<RecordingsTray />);
164
+ expect(screen.getAllByText(/stopped:/i).map((el) => el.textContent)).toEqual([
165
+ "Stopped: the camera went away",
166
+ "Stopped: the connection was lost",
167
+ "Stopped: its feed was closed",
168
+ ]);
169
+ });
170
+
156
171
  it("falls back to 'Camera <flightId>' when no cameraLabel prop is given", () => {
157
172
  fakeStoreSeed.recordings = [makeRecording({ recordingId: "rec-1", flightId: 7 })];
158
173
  render(<RecordingsTray />);
@@ -22,7 +22,11 @@
22
22
  * camera registry; omit it for a plain "Camera <flightId>" fallback.
23
23
  */
24
24
 
25
- import type { GroupedRecordingHandle, RecordingHandle } from "@ksp-gonogo/kerbcast";
25
+ import type {
26
+ GroupedRecordingHandle,
27
+ RecordingEndReason,
28
+ RecordingHandle,
29
+ } from "@ksp-gonogo/kerbcast";
26
30
  import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
27
31
  import styled from "styled-components";
28
32
  import { useRecordings } from "./hooks/useRecordings";
@@ -123,6 +127,9 @@ function ClipRow({ rec, label, url, onDiscard }: ClipRowProps) {
123
127
  <ClipDetail>
124
128
  {formatDuration(rec.durationMs)} &middot; {formatBytes(rec.byteSize)}
125
129
  </ClipDetail>
130
+ {rec.endedBy && recordingEndNote(rec.endedBy) && (
131
+ <ClipDetail>{recordingEndNote(rec.endedBy)}</ClipDetail>
132
+ )}
126
133
  </ClipMeta>
127
134
  <ClipActions>
128
135
  {url && (
@@ -243,6 +250,23 @@ function formatUt(ut: number): string {
243
250
  return `T+${Math.round(ut)}s`;
244
251
  }
245
252
 
253
+ /**
254
+ * A short line saying why a recording stopped on its own, or null for one a
255
+ * person stopped or that ran its set length.
256
+ */
257
+ export function recordingEndNote(endedBy: RecordingEndReason): string | null {
258
+ switch (endedBy) {
259
+ case "camera-gone":
260
+ return "Stopped: the camera went away";
261
+ case "feed-closed":
262
+ return "Stopped: its feed was closed";
263
+ case "disconnected":
264
+ return "Stopped: the connection was lost";
265
+ default:
266
+ return null;
267
+ }
268
+ }
269
+
246
270
  function formatDuration(ms: number): string {
247
271
  const totalSeconds = Math.max(0, Math.round(ms / 1000));
248
272
  const hours = Math.floor(totalSeconds / 3600);
@@ -4,12 +4,12 @@
4
4
  * which it is, and a host override beats both.
5
5
  */
6
6
 
7
- import { KerbcastClient, type CommandTrackEvent } from "@ksp-gonogo/kerbcast";
7
+ import { KerbcastClient, type CommandTrackEvent, type FrameCommandState } from "@ksp-gonogo/kerbcast";
8
8
  import { MockSidecar } from "@ksp-gonogo/kerbcast/testing";
9
9
  import { act, cleanup, render } from "@testing-library/react";
10
10
  import { afterEach, describe, expect, it } from "vitest";
11
11
  import { KerbcastProvider } from "../context";
12
- import { crossesEvent, useCommandDisplayUt, type CommandDisplayUt } from "./useCommandDisplayUt";
12
+ import { commandShownKey, crossesEvent, useCommandDisplayUt, type CommandDisplayUt } from "./useCommandDisplayUt";
13
13
  import { useDisplayedFrameUt } from "./useDisplayedFrameUt";
14
14
 
15
15
  async function connected() {
@@ -54,10 +54,11 @@ const event = (ut: number): CommandTrackEvent =>
54
54
  let last: CommandDisplayUt | null | undefined;
55
55
  function CommandProbe(props: {
56
56
  override?: number;
57
+ overrideCommand?: FrameCommandState;
57
58
  events?: CommandTrackEvent[];
58
59
  video: HTMLVideoElement | null;
59
60
  }) {
60
- last = useCommandDisplayUt(true, props.override, props.events, 7, props.video);
61
+ last = useCommandDisplayUt(true, props.override, props.events, 7, props.video, props.overrideCommand);
61
62
  return null;
62
63
  }
63
64
 
@@ -106,6 +107,76 @@ describe("useCommandDisplayUt", () => {
106
107
  expect(last?.ut).toBe(999.95);
107
108
  });
108
109
 
110
+ it("carries the displayed frame's command state and re-renders when what it shows changes", async () => {
111
+ const { client, sidecar } = await connected();
112
+ const { video, present } = fakeVideo();
113
+ render(
114
+ <KerbcastProvider client={client}>
115
+ <CommandProbe video={video} />
116
+ </KerbcastProvider>,
117
+ );
118
+ const command = (rangeM: number, occluded = false) => ({
119
+ hasFix: true,
120
+ rangeM,
121
+ frameHeightPx: 720,
122
+ framingFits: true,
123
+ occluded,
124
+ occluder: occluded ? "Mun" : "",
125
+ sunlit: true,
126
+ subjectPersistentId: 42,
127
+ });
128
+ sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 1, captureUt: 999.8, captureEpoch: 0, command: command(1500) });
129
+ sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 2, captureUt: 999.85, captureEpoch: 0, command: command(1500.4) });
130
+ sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 3, captureUt: 999.9, captureEpoch: 0, command: command(1500.4, true) });
131
+ present(1);
132
+ expect(last).toMatchObject({ ut: 999.8, source: "exact", command: { rangeM: 1500 } });
133
+ /* A range change below what the readout shows keeps the frame shown. */
134
+ present(2);
135
+ expect(last?.ut).toBe(999.8);
136
+ present(3);
137
+ expect(last).toMatchObject({ ut: 999.9, command: { occluded: true, occluder: "Mun" } });
138
+ });
139
+
140
+ it("keeps the command state of a frame it can only estimate, which lags the picture", async () => {
141
+ const { client, sidecar } = await connected();
142
+ const { video, present } = fakeVideo();
143
+ render(
144
+ <KerbcastProvider client={client}>
145
+ <CommandProbe video={video} />
146
+ </KerbcastProvider>,
147
+ );
148
+ const command: FrameCommandState = {
149
+ hasFix: true, rangeM: 900, frameHeightPx: 720, framingFits: true, occluded: false, occluder: "", sunlit: true, subjectPersistentId: 42,
150
+ };
151
+ sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 1, captureUt: 999.8, captureEpoch: 0, command });
152
+ present(1);
153
+ /* A frame the browser cannot identify falls back to the last exact one: estimated, but its state still applies. */
154
+ present(55555);
155
+ expect(last).toMatchObject({ ut: 999.8, source: "estimated", command: { rangeM: 900 } });
156
+ });
157
+
158
+ it("takes a delayed host's own frame state with its override", async () => {
159
+ const { client } = await connected();
160
+ const command: FrameCommandState = {
161
+ hasFix: true, rangeM: 1200, frameHeightPx: 720, framingFits: true, occluded: true, occluder: "Mun", sunlit: true, subjectPersistentId: 42,
162
+ };
163
+ render(
164
+ <KerbcastProvider client={client}>
165
+ <CommandProbe video={null} override={950} overrideCommand={command} />
166
+ </KerbcastProvider>,
167
+ );
168
+ expect(last).toEqual({ ut: 950, source: "override", command });
169
+ });
170
+
171
+ it("matches the command state's shown key to what a viewer sees", () => {
172
+ const base = { hasFix: true, frameHeightPx: 720, framingFits: true, occluded: false, occluder: "", sunlit: true, subjectPersistentId: 1 };
173
+ expect(commandShownKey({ ...base, rangeM: 1500 })).toBe(commandShownKey({ ...base, rangeM: 1501 }));
174
+ expect(commandShownKey({ ...base, rangeM: 1500 })).not.toBe(commandShownKey({ ...base, rangeM: 1600 }));
175
+ expect(commandShownKey({ ...base, refusal: "stale" })).not.toBe(commandShownKey(base));
176
+ expect(commandShownKey({ ...base, waitingForTarget: true })).not.toBe(commandShownKey(base));
177
+ expect(commandShownKey(undefined)).toBe("");
178
+ });
179
+
109
180
  it("prefers a host override over the displayed frame", async () => {
110
181
  const { client, sidecar } = await connected();
111
182
  const { video, present } = fakeVideo();
@@ -4,6 +4,7 @@ import {
4
4
  type DisplayedFrameUt,
5
5
  type FrameUtSource,
6
6
  type CommandTrackEvent,
7
+ type FrameCommandState,
7
8
  } from "@ksp-gonogo/kerbcast";
8
9
  import { useKerbcastClient } from "../context";
9
10
  import { nowMs } from "../timing";
@@ -40,6 +41,37 @@ export function crossesEvent(
40
41
  export interface CommandDisplayUt {
41
42
  ut: number;
42
43
  source: FrameUtSource | "override";
44
+ /** The command state that rode with the displayed frame (1.12 and later), or the host's own with an override. */
45
+ command?: FrameCommandState;
46
+ }
47
+
48
+ /**
49
+ * What of a frame's command state a viewer can see change: the discrete
50
+ * fields whole, ranges to 3 significant figures, pixel sizes to half a
51
+ * pixel. Frames that agree on it draw the same, so they skip a re-render.
52
+ */
53
+ export function commandShownKey(c: FrameCommandState | undefined): string {
54
+ if (!c) return "";
55
+ const sig = (v: number | undefined) => (v == null ? "" : Number(v.toPrecision(3)));
56
+ const half = (v: number | undefined) => (v == null ? "" : Math.round(v * 2));
57
+ return JSON.stringify([
58
+ c.hasFix,
59
+ sig(c.rangeM),
60
+ half(c.bracketHalfPx),
61
+ c.frameHeightPx,
62
+ c.framingFits,
63
+ half(c.trueOffsetPx),
64
+ c.occluded,
65
+ c.occluder,
66
+ c.sunlit,
67
+ c.blank ?? null,
68
+ c.authority?.owner ?? null,
69
+ c.subjectPersistentId,
70
+ c.refusal ?? null,
71
+ c.refusalCode ?? null,
72
+ c.refusalText ?? null,
73
+ c.waitingForTarget ?? false,
74
+ ]);
43
75
  }
44
76
 
45
77
  /**
@@ -50,7 +82,12 @@ export interface CommandDisplayUt {
50
82
  * interpolated to now (`"estimated"`). Re-evaluated twice a second while
51
83
  * `enabled` so readout ages keep counting, and again the moment the
52
84
  * displayed UT reaches the next of `events`, so a state change lands on its
53
- * frame rather than on the next tick. `null` when nothing is known.
85
+ * frame rather than on the next tick. The displayed frame's own command
86
+ * state comes back as `command`, re-rendering when what it shows changes:
87
+ * an exact frame's, an estimated one's (which lags the picture, so is never
88
+ * early; its own UT is used too, so events and lists stay on that frame),
89
+ * or with an override the host's own `overrideCommand`. `null` when
90
+ * nothing is known.
54
91
  */
55
92
  export function useCommandDisplayUt(
56
93
  enabled: boolean,
@@ -58,6 +95,7 @@ export function useCommandDisplayUt(
58
95
  events?: readonly CommandTrackEvent[],
59
96
  flightId?: number | null,
60
97
  video?: HTMLVideoElement | null,
98
+ overrideCommand?: FrameCommandState | null,
61
99
  ): CommandDisplayUt | null {
62
100
  const client = useKerbcastClient();
63
101
  const sample = useRef<{ captureUt: number | null; warpRate: number; at: number }>({
@@ -86,23 +124,27 @@ export function useCommandDisplayUt(
86
124
  return () => clearInterval(interval);
87
125
  }, [enabled]);
88
126
 
89
- /* Exact frames re-render only when they change what is drawn: a switch of
90
- source, or a step across an event. Ages catch up on the tick. */
127
+ /* Frames re-render only when they change what is drawn: a switch of
128
+ source, a step across an event, or a change the command state shows.
129
+ Ages catch up on the tick. An estimated frame is kept only for the
130
+ command state it carries. */
91
131
  const overriding = override !== undefined;
92
132
  useEffect(() => {
93
133
  frame.current = null;
94
134
  if (!enabled || overriding || flightId == null || !video) return;
95
135
  return client.watchDisplayedFrames(flightId, video, (f) => {
96
- frame.current = f.source === "exact" ? f : null;
136
+ frame.current = f.source === "exact" || f.command ? f : null;
97
137
  const prev = shown.current;
98
138
  if (!frame.current) {
99
- if (prev?.source === "exact") tick();
139
+ if (prev?.source === "exact" || prev?.command) tick();
100
140
  return;
101
141
  }
102
142
  if (
103
143
  prev === null ||
104
- prev.source !== "exact" ||
105
- crossesEvent(eventsRef.current, prev.ut, f.ut)
144
+ (f.source === "exact" && prev.source !== "exact") ||
145
+ (f.source !== "exact" && prev.source === "exact") ||
146
+ crossesEvent(eventsRef.current, prev.ut, f.ut) ||
147
+ commandShownKey(prev.command) !== commandShownKey(f.command)
106
148
  ) {
107
149
  tick();
108
150
  }
@@ -110,17 +152,21 @@ export function useCommandDisplayUt(
110
152
  }, [client, enabled, overriding, flightId, video]);
111
153
 
112
154
  const { captureUt, warpRate, at } = sample.current;
113
- const exact = frame.current;
155
+ const seen = frame.current;
156
+ const withCommand = (d: CommandDisplayUt, c: FrameCommandState | null | undefined): CommandDisplayUt =>
157
+ c ? { ...d, command: c } : d;
114
158
  const displayUt: CommandDisplayUt | null = overriding
115
- ? { ut: override, source: "override" }
116
- : exact
117
- ? { ut: exact.ut, source: "exact" }
118
- : captureUt === null
119
- ? null
120
- : { ut: extrapolateCaptureUt(captureUt, warpRate, at, nowMs()), source: "estimated" };
159
+ ? withCommand({ ut: override, source: "override" }, overrideCommand)
160
+ : seen?.source === "exact"
161
+ ? withCommand({ ut: seen.ut, source: "exact" }, seen.command)
162
+ : seen
163
+ ? withCommand({ ut: seen.ut, source: "estimated" }, seen.command)
164
+ : captureUt === null
165
+ ? null
166
+ : { ut: extrapolateCaptureUt(captureUt, warpRate, at, nowMs()), source: "estimated" };
121
167
  shown.current = displayUt;
122
168
  const untilEdge =
123
- enabled && displayUt?.source === "estimated"
169
+ enabled && displayUt?.source === "estimated" && !seen
124
170
  ? msUntilNextEvent(events, displayUt.ut, warpRate)
125
171
  : null;
126
172
 
@@ -0,0 +1,25 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useKerbcastClient } from "../context";
3
+
4
+ /**
5
+ * Whether this client wants camera `flightId` but has no video slot to carry
6
+ * it (see `KerbcastCameraHandle.waitingForSlot`). False for a null id.
7
+ */
8
+ export function useKerbcastWaitingForSlot(flightId: number | null): boolean {
9
+ const client = useKerbcastClient();
10
+ const [waiting, setWaiting] = useState(() =>
11
+ flightId === null ? false : client.camera(flightId).waitingForSlot,
12
+ );
13
+
14
+ useEffect(() => {
15
+ if (flightId === null) {
16
+ setWaiting(false);
17
+ return;
18
+ }
19
+ const cam = client.camera(flightId);
20
+ setWaiting(cam.waitingForSlot);
21
+ return cam.on("waiting", setWaiting);
22
+ }, [client, flightId]);
23
+
24
+ return waiting;
25
+ }