@ksp-gonogo/kerbcast-react 1.11.1 → 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
package/README.md CHANGED
@@ -92,6 +92,65 @@ while a camera is tracking. `cameraFilter` narrows the selectable cameras
92
92
  (picker, stepper, auto-latch). `showActions={false}` hides the whole action
93
93
  bar without affecting resolution reporting.
94
94
 
95
+ ## Anchored markers
96
+
97
+ `anchors` marks things in the game on the feed's picture (see the core
98
+ SDK's "Anchored markers" for the kinds and fields). The feed sets them on
99
+ the camera it shows, clears them when it moves on or unmounts, and draws a
100
+ marker where each one lands in every displayed frame: four corner marks
101
+ sized from `apparentRadiusPx` (never below 14 px), green when clear, red and
102
+ dashed when occluded.
103
+
104
+ `anchorOverlay` has the same shape as `targetBracketOverlay`: `corners`
105
+ restyles the four marks (`top`, `right`, `bottom`, `left`, each
106
+ `{ colour, dashed, hidden, shape }`), `render` adds SVG in screen pixels,
107
+ `readout` puts text under the marker, and `shape` sets every mark to
108
+ `"corners"` or `"triangles"`. Each is called per anchor with the raw result
109
+ and a context: its screen geometry, the fitted content rect, the displayed
110
+ frame's UT and the screen position of every drawn anchor. `owners` also
111
+ draws anchors set in game by those owners. What a colour means is yours to
112
+ decide; Kerbcast only reports geometry.
113
+
114
+ A direction reticle on a docking port: four triangles, each side coloured
115
+ from the port's axes.
116
+
117
+ ```tsx
118
+ const anchors = useMemo(
119
+ () => [{ id: "port", kind: AnchorKind.Part, vessel: targetPid, part: portPid, orientation: true }],
120
+ [targetPid, portPid],
121
+ );
122
+
123
+ <CameraFeed
124
+ flightId={dockingCam}
125
+ anchors={anchors}
126
+ anchorOverlay={{
127
+ shape: "triangles",
128
+ corners: (a) => {
129
+ /* The port faces the lens along -z; tilt shows in its forward's x and y. */
130
+ const [fx = 0, fy = 0] = a.forward ?? [];
131
+ const side = (off: number) => ({ colour: Math.abs(off) < 0.02 ? "lime" : "orange" });
132
+ return { top: side(fy), bottom: side(fy), left: side(fx), right: side(fx) };
133
+ },
134
+ readout: (a) => (a.distanceM != null ? `${a.distanceM.toFixed(1)} m` : null),
135
+ }}
136
+ />
137
+ ```
138
+
139
+ For a video of your own, `AnchorMarkers` draws a frame's anchors over any
140
+ `<video>` and `useDisplayedFrame(enabled, flightId, video)` gives the
141
+ displayed frame with its results.
142
+
143
+ On command feeds the target brackets follow the subject's anchor while it
144
+ is visible and no command lock is held, so the box stays on the craft where
145
+ the picture shows it even while an aim points elsewhere. With the craft out
146
+ of frame or occluded, or under the lock, the box is the 1.11 one: centred on
147
+ the aim, sized from the aimed range, red and dashed when the aim line is
148
+ occluded. `targetBracketOverlay` is unchanged; its context gains `subject`,
149
+ the observed anchor, so you can draw it as a second mark.
150
+
151
+ An observed anchor out of view (see the core SDK) carries no position and
152
+ draws no marker; it is still in `ctx.frame.anchors`.
153
+
95
154
  ## `KerbalFaceFeed`
96
155
 
97
156
  A single-kerbal face-camera primitive: one crew face in a square frame, keyed
@@ -0,0 +1,128 @@
1
+ import { type DisplayedFrameUt, type FittedFrame, type FrameAnchor, type FrameFit, type FrameUtSource } from "@ksp-gonogo/kerbcast";
2
+ import { type ReactNode } from "react";
3
+ /** Default colours; each is overridable through the named CSS custom property. */
4
+ export declare const ANCHOR_MARKER_COLOURS: {
5
+ readonly clear: "var(--kerbcast-anchor-clear, #4caf50)";
6
+ readonly occluded: "var(--kerbcast-anchor-occluded, #f44336)";
7
+ };
8
+ /** The four marks around an anchor, clockwise from the top. */
9
+ export type AnchorCorner = "top" | "right" | "bottom" | "left";
10
+ /**
11
+ * How a mark is drawn. `corners`: a right-angle corner of a box around the
12
+ * anchor, like the command brackets; each key names the corner that starts
13
+ * its side going clockwise, so `top` is the top left, `right` the top right,
14
+ * `bottom` the bottom right and `left` the bottom left. `triangles`: a
15
+ * triangle outside the box on that side, pointing at the anchor.
16
+ */
17
+ export type AnchorMarkerShape = "corners" | "triangles";
18
+ export interface AnchorCornerStyle {
19
+ colour: string;
20
+ dashed: boolean;
21
+ shape: AnchorMarkerShape;
22
+ }
23
+ /** Changes to one default mark; unset fields keep the default. */
24
+ export interface AnchorCornerOverride {
25
+ colour?: string;
26
+ dashed?: boolean;
27
+ hidden?: boolean;
28
+ shape?: AnchorMarkerShape;
29
+ }
30
+ export type AnchorCornerOverrides = Partial<Record<AnchorCorner, AnchorCornerOverride>>;
31
+ /** The marker box for one anchor, in the overlay SVG's pixels (the video element's box). */
32
+ export interface AnchorMarkerGeometry {
33
+ /** Where the anchor is on screen. Off the box when it is out of frame. */
34
+ cx: number;
35
+ cy: number;
36
+ /** Half the box side: the anchor's apparent radius on screen, never below a minimum. */
37
+ half: number;
38
+ /** Length of each corner stroke, and the side of each triangle. */
39
+ arm: number;
40
+ stroke: number;
41
+ }
42
+ /** An anchor with its place on screen. */
43
+ export interface PlacedAnchor {
44
+ anchor: FrameAnchor;
45
+ x: number;
46
+ y: number;
47
+ }
48
+ /** Everything an anchor overlay needs to line up with the drawn markers. */
49
+ export interface AnchorContext {
50
+ geometry: AnchorMarkerGeometry;
51
+ /** Where the frame lands in the element, after `object-fit`. */
52
+ content: FittedFrame;
53
+ /** Size of the overlay SVG, the video element's box. */
54
+ width: number;
55
+ height: number;
56
+ /** Mission UT of the displayed frame the anchors describe. */
57
+ displayUt: number;
58
+ utSource: FrameUtSource;
59
+ /** Every drawn anchor in this frame with its screen position. */
60
+ anchors: readonly PlacedAnchor[];
61
+ /** The displayed frame, with its raw per-frame results and size. */
62
+ frame: DisplayedFrameUt;
63
+ }
64
+ /**
65
+ * Consumer changes to a feed's anchor markers. Each part is optional and is
66
+ * called for every drawn anchor on every displayed frame. What an anchor
67
+ * means, and which colour says so, is the consumer's: Kerbcast only reports
68
+ * where it is.
69
+ */
70
+ export interface AnchorOverlay {
71
+ /** Default shape of every mark. Default `"corners"`. */
72
+ shape?: AnchorMarkerShape;
73
+ /** Restyle or hide the four marks of one anchor. */
74
+ corners?: AnchorCornerOverrides | ((anchor: FrameAnchor, ctx: AnchorContext) => AnchorCornerOverrides | null | undefined);
75
+ /**
76
+ * SVG content drawn above the marks, in the same pixel space. Runs for an
77
+ * anchor out of frame or behind the lens too (its marks are left out then),
78
+ * so an overlay can point to it from the edge. An observed result out of
79
+ * view carries no position and is not drawn at all; it is still in
80
+ * `ctx.frame.anchors`.
81
+ */
82
+ render?: (anchor: FrameAnchor, ctx: AnchorContext) => ReactNode;
83
+ /** Text under the marker, or null for none. Default none. */
84
+ readout?: (anchor: FrameAnchor, ctx: AnchorContext) => string | null;
85
+ /**
86
+ * Game-side owners whose anchors are drawn as well as this client's own,
87
+ * e.g. `["my-mod"]`. Default: only this client's.
88
+ */
89
+ owners?: readonly string[];
90
+ /**
91
+ * Draw Kerbcast's own `aim` anchor too when `"kerbcast"` is among
92
+ * `owners`. Off by default: the brackets already mark the aim.
93
+ */
94
+ showAim?: boolean;
95
+ }
96
+ /** The marker box for an anchor at `centre` with `radiusPx` capture pixels on a `frameHeight`-tall frame. */
97
+ export declare function anchorMarkerGeometry(centre: {
98
+ x: number;
99
+ y: number;
100
+ }, radiusPx: number, frameHeight: number, content: FittedFrame): AnchorMarkerGeometry;
101
+ export interface AnchorMarkersProps {
102
+ /** The displayed frame whose anchors to draw. */
103
+ frame: DisplayedFrameUt | null;
104
+ video: HTMLVideoElement | null;
105
+ overlay?: AnchorOverlay | null;
106
+ /** How the video fits its box. Default: read from the element's CSS. */
107
+ fit?: FrameFit;
108
+ }
109
+ /**
110
+ * Markers for the anchors in one displayed frame: by default four corner
111
+ * marks around each anchor sized from its apparent radius, green when clear
112
+ * and red and dashed when occluded. Positions come from the frame's own
113
+ * stamp, so they match the picture under any delay.
114
+ */
115
+ export declare function AnchorMarkers({ frame, video, overlay, fit }: AnchorMarkersProps): import("react").JSX.Element | null;
116
+ /**
117
+ * The displayed frame of `flightId` on `video`, re-rendering on every
118
+ * presented frame while `enabled`. Kept to the component that draws from it,
119
+ * so a feed's other UI does not re-render at the frame rate.
120
+ */
121
+ export declare function useDisplayedFrame(enabled: boolean, flightId: number | null, video: HTMLVideoElement | null): DisplayedFrameUt | null;
122
+ /** Self-subscribing markers for a feed: watches its displayed frames and draws their anchors. */
123
+ export declare function LiveAnchorMarkers({ flightId, video, overlay, fit, }: {
124
+ flightId: number | null;
125
+ video: HTMLVideoElement | null;
126
+ overlay?: AnchorOverlay | null;
127
+ fit?: FrameFit;
128
+ }): import("react").JSX.Element;
@@ -0,0 +1,162 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { COMMAND_AIM_ANCHOR_ID, KERBCAST_ANCHOR_OWNER, anchorPoint, fitFrame, frameToScreen, } from "@ksp-gonogo/kerbcast";
3
+ import { useEffect, useState } from "react";
4
+ import styled from "styled-components";
5
+ import { useKerbcastClient } from "./context";
6
+ import { useVideoBox, videoFit } from "./videoBox";
7
+ const MARKER_STROKE_PX = 2;
8
+ const MARKER_MIN_HALF_PX = 14;
9
+ const MARKER_ARM_FRACTION = 0.3;
10
+ const MARKER_MIN_ARM_PX = 6;
11
+ const READOUT_GAP_PX = 11;
12
+ /** Default colours; each is overridable through the named CSS custom property. */
13
+ export const ANCHOR_MARKER_COLOURS = {
14
+ clear: "var(--kerbcast-anchor-clear, #4caf50)",
15
+ occluded: "var(--kerbcast-anchor-occluded, #f44336)",
16
+ };
17
+ /** The marker box for an anchor at `centre` with `radiusPx` capture pixels on a `frameHeight`-tall frame. */
18
+ export function anchorMarkerGeometry(centre, radiusPx, frameHeight, content) {
19
+ const scale = frameHeight > 0 ? content.height / frameHeight : 1;
20
+ const half = Math.max(MARKER_MIN_HALF_PX, radiusPx * scale);
21
+ const arm = Math.max(MARKER_MIN_ARM_PX, MARKER_ARM_FRACTION * half * 2);
22
+ return { cx: centre.x, cy: centre.y, half, arm, stroke: MARKER_STROKE_PX };
23
+ }
24
+ const CORNERS = ["top", "right", "bottom", "left"];
25
+ /** SVG path data for one mark. Corners are open strokes; triangles are closed. */
26
+ function markPath(corner, shape, g) {
27
+ const { cx, cy, half: h, arm: a } = g;
28
+ if (shape === "triangles") {
29
+ const t = a * 0.8;
30
+ const w = a / 2;
31
+ switch (corner) {
32
+ case "top":
33
+ return `M${cx} ${cy - h}L${cx - w} ${cy - h - t}L${cx + w} ${cy - h - t}Z`;
34
+ case "right":
35
+ return `M${cx + h} ${cy}L${cx + h + t} ${cy - w}L${cx + h + t} ${cy + w}Z`;
36
+ case "bottom":
37
+ return `M${cx} ${cy + h}L${cx + w} ${cy + h + t}L${cx - w} ${cy + h + t}Z`;
38
+ default:
39
+ return `M${cx - h} ${cy}L${cx - h - t} ${cy + w}L${cx - h - t} ${cy - w}Z`;
40
+ }
41
+ }
42
+ const l = cx - h, r = cx + h, tp = cy - h, b = cy + h;
43
+ switch (corner) {
44
+ case "top":
45
+ return `M${l} ${tp + a}L${l} ${tp}L${l + a} ${tp}`;
46
+ case "right":
47
+ return `M${r - a} ${tp}L${r} ${tp}L${r} ${tp + a}`;
48
+ case "bottom":
49
+ return `M${r} ${b - a}L${r} ${b}L${r - a} ${b}`;
50
+ default:
51
+ return `M${l + a} ${b}L${l} ${b}L${l} ${b - a}`;
52
+ }
53
+ }
54
+ /** Whether a feed shows this anchor: this client's own, or one from a listed owner, Kerbcast's aim only on request. */
55
+ function drawn(a, overlay) {
56
+ if (a.owner === undefined)
57
+ return true;
58
+ if (a.owner === KERBCAST_ANCHOR_OWNER && a.id === COMMAND_AIM_ANCHOR_ID && !overlay?.showAim)
59
+ return false;
60
+ return overlay?.owners?.includes(a.owner) ?? false;
61
+ }
62
+ /**
63
+ * Markers for the anchors in one displayed frame: by default four corner
64
+ * marks around each anchor sized from its apparent radius, green when clear
65
+ * and red and dashed when occluded. Positions come from the frame's own
66
+ * stamp, so they match the picture under any delay.
67
+ */
68
+ export function AnchorMarkers({ frame, video, overlay, fit }) {
69
+ const { boxW, boxH, mediaW, mediaH } = useVideoBox(video);
70
+ if (!frame?.anchors?.length)
71
+ return null;
72
+ const frameW = frame.frameWidth || mediaW;
73
+ const frameH = frame.frameHeight || mediaH;
74
+ if (!(frameW > 0 && frameH > 0 && boxW > 0 && boxH > 0))
75
+ return null;
76
+ const content = fitFrame(boxW, boxH, frameW, frameH, fit ?? videoFit(video));
77
+ /* An observed result out of view has no position, so it has no marker. */
78
+ const placed = [];
79
+ for (const a of frame.anchors) {
80
+ const point = drawn(a, overlay) ? anchorPoint(a) : null;
81
+ if (point)
82
+ placed.push({ anchor: a, ...frameToScreen(point, frameW, frameH, content) });
83
+ }
84
+ if (placed.length === 0)
85
+ return null;
86
+ const shape = overlay?.shape ?? "corners";
87
+ const items = placed.map(({ anchor, x, y }) => {
88
+ const geometry = anchorMarkerGeometry({ x, y }, anchor.apparentRadiusPx ?? 0, frameH, content);
89
+ const ctx = {
90
+ geometry,
91
+ content,
92
+ width: boxW,
93
+ height: boxH,
94
+ displayUt: frame.ut,
95
+ utSource: frame.source,
96
+ anchors: placed,
97
+ frame,
98
+ };
99
+ const overrides = typeof overlay?.corners === "function" ? overlay.corners(anchor, ctx) : overlay?.corners;
100
+ const base = anchor.occluded
101
+ ? { colour: ANCHOR_MARKER_COLOURS.occluded, dashed: true, shape }
102
+ : { colour: ANCHOR_MARKER_COLOURS.clear, dashed: false, shape };
103
+ const visible = anchor.inFrame && !anchor.behind;
104
+ const readout = overlay?.readout ? overlay.readout(anchor, ctx) : null;
105
+ return { anchor, geometry, ctx, overrides, base, visible, readout };
106
+ });
107
+ return (_jsxs(_Fragment, { children: [_jsx(MarkerSvg, { "aria-hidden": "true", "data-anchor-markers": "", width: boxW, height: boxH, children: items.map(({ anchor, geometry, ctx, overrides, base, visible }) => (_jsxs("g", { "data-anchor": anchor.id, "data-owner": anchor.owner, children: [visible &&
108
+ CORNERS.map((corner) => {
109
+ const o = overrides?.[corner];
110
+ if (o?.hidden)
111
+ return null;
112
+ const s = o?.shape ?? base.shape;
113
+ const colour = o?.colour ?? base.colour;
114
+ const dashed = o?.dashed ?? base.dashed;
115
+ const filled = s === "triangles";
116
+ return (_jsx("path", { "data-corner": corner, "data-shape": s, d: markPath(corner, s, geometry), strokeWidth: geometry.stroke, strokeDasharray: dashed ? "3 2" : undefined, style: filled ? { fill: dashed ? "none" : colour, stroke: colour } : { fill: "none", stroke: colour } }, corner));
117
+ }), overlay?.render && _jsx("g", { "data-anchor-overlay": "", children: overlay.render(anchor, ctx) })] }, `${anchor.owner ?? ""}/${anchor.id}`))) }), items.map(({ anchor, geometry, readout, visible }) => readout &&
118
+ visible && (_jsx(Readout, { "data-anchor-readout": anchor.id, style: { left: geometry.cx, top: geometry.cy + geometry.half + READOUT_GAP_PX }, children: readout }, `${anchor.owner ?? ""}/${anchor.id}`)))] }));
119
+ }
120
+ /**
121
+ * The displayed frame of `flightId` on `video`, re-rendering on every
122
+ * presented frame while `enabled`. Kept to the component that draws from it,
123
+ * so a feed's other UI does not re-render at the frame rate.
124
+ */
125
+ export function useDisplayedFrame(enabled, flightId, video) {
126
+ const client = useKerbcastClient();
127
+ const [frame, setFrame] = useState(null);
128
+ useEffect(() => {
129
+ setFrame(null);
130
+ if (!enabled || flightId === null || !video)
131
+ return;
132
+ return client.watchDisplayedFrames(flightId, video, (f) =>
133
+ /* Skip re-renders between frames that carry the same results. */
134
+ setFrame((prev) => prev && prev.anchors === f.anchors && prev.rtpTimestamp === f.rtpTimestamp ? prev : f));
135
+ }, [client, enabled, flightId, video]);
136
+ return frame;
137
+ }
138
+ /** Self-subscribing markers for a feed: watches its displayed frames and draws their anchors. */
139
+ export function LiveAnchorMarkers({ flightId, video, overlay, fit, }) {
140
+ const frame = useDisplayedFrame(true, flightId, video);
141
+ return _jsx(AnchorMarkers, { frame: frame, video: video, overlay: overlay, fit: fit });
142
+ }
143
+ const MarkerSvg = styled.svg `
144
+ position: absolute;
145
+ top: 0;
146
+ left: 0;
147
+ z-index: 1;
148
+ overflow: visible;
149
+ pointer-events: none;
150
+ `;
151
+ const Readout = styled.div `
152
+ position: absolute;
153
+ z-index: 1;
154
+ transform: translateX(-50%);
155
+ white-space: nowrap;
156
+ color: #fff;
157
+ font-size: 10px;
158
+ font-weight: 600;
159
+ letter-spacing: 0.12em;
160
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
161
+ pointer-events: none;
162
+ `;
@@ -1,6 +1,7 @@
1
- import type { CameraState, KerbcastClient } from "@ksp-gonogo/kerbcast";
1
+ import type { Anchor, CameraState, DisplayedFrameUt, KerbcastClient } from "@ksp-gonogo/kerbcast";
2
2
  import { type FeedAction } from "./FeedActionBar";
3
3
  import { type CommandBracketOverlay } from "./CommandBrackets";
4
+ import { type AnchorOverlay } from "./AnchorMarkers";
4
5
  /**
5
6
  * The consumer-supplied action shape, and the shared top-right action bar
6
7
  * both `CameraFeed` and `KerbalFaceFeed` render, live in `FeedActionBar`.
@@ -150,8 +151,9 @@ export interface CameraFeedProps {
150
151
  enableRecording?: boolean;
151
152
  /**
152
153
  * Record this feed at the operator's full render size for the duration of
153
- * the built-in REC control's recording (overrides only the display-size
154
- * demand; still yields to the adaptive framerate shed). Off by default.
154
+ * the built-in REC control's recording (overrides the display-size
155
+ * demand and any viewer quality preset; still capped by an operator
156
+ * render-size cap and the adaptive shed). Off by default.
155
157
  * Threaded straight into `start(flightId, { forceFullResolution })`; has
156
158
  * no effect unless `enableRecording` is also set. While the feed is armed
157
159
  * (force sent, waiting for the resolution bump) the tile shows an
@@ -199,14 +201,53 @@ export interface CameraFeedProps {
199
201
  * brackets. See {@link CommandBracketOverlay}.
200
202
  */
201
203
  targetBracketOverlay?: CommandBracketOverlay;
204
+ /**
205
+ * What a command camera feed says while the camera has no subject to aim
206
+ * at yet and shows its placeholder framing (a launch site camera on its
207
+ * pad, a station looking at the horizon). Drawn over the picture, never
208
+ * into the video, so recordings stay clean. Default "Waiting for target";
209
+ * `null` or an empty string shows nothing.
210
+ */
211
+ waitingForTargetLabel?: string | null;
212
+ /**
213
+ * What the feed says while its camera has no video slot on this connection
214
+ * (`KerbcastCameraHandle.waitingForSlot`): the pool is at its ceiling or
215
+ * cannot grow. The feed takes the next slot another feed gives up. Drawn
216
+ * over the picture; `null` or an empty string shows nothing.
217
+ */
218
+ noSlotLabel?: string | null;
202
219
  /**
203
220
  * Mission UT the displayed video was captured at, for a host running its
204
221
  * own delayed playout. Picks which command-track transition is in force and
205
222
  * ages the readout. Defaults to the displayed frame's own capture UT where
206
223
  * the browser reports frame RTP timestamps, else the client's capture
207
- * clock interpolated to now.
224
+ * clock interpolated to now. While a command lock is held the brackets and
225
+ * readout come from the displayed frame's own state, which a UT alone does
226
+ * not carry: such a host passes `displayFrame` instead.
208
227
  */
209
228
  displayUt?: number;
229
+ /**
230
+ * The frame a host's own delayed playout is showing, as
231
+ * `watchDisplayedFrames` reported it when it arrived. Wins over
232
+ * `displayUt`: its `ut` picks the transition in force and its `command`
233
+ * draws the brackets and readout, so they never run ahead of the picture.
234
+ */
235
+ displayFrame?: DisplayedFrameUt | null;
236
+ /**
237
+ * Things to mark on this feed's picture: parts, craft, points on a body or
238
+ * directions. Set for the displayed camera as this client's anchors and
239
+ * cleared when the feed shows another camera or unmounts; the game
240
+ * projects each through the camera that rendered every frame, and a
241
+ * default marker is drawn where it lands. One set per camera per client:
242
+ * two feeds on the same camera share it, the last to set winning. At
243
+ * most 16. Pass a stable or memoised array; it is compared by value.
244
+ */
245
+ anchors?: Anchor[];
246
+ /**
247
+ * Restyle the anchor markers, add SVG of your own, put text under them,
248
+ * or draw anchors set in game too. See {@link AnchorOverlay}.
249
+ */
250
+ anchorOverlay?: AnchorOverlay;
210
251
  }
211
252
  export interface CameraFeedHandle {
212
253
  stepCamera(delta: number): void;
@@ -6,16 +6,19 @@ import styled, { css } from "styled-components";
6
6
  import { buildCameraLabeler } from "./cameraLabels";
7
7
  import { KerbcastProvider, useKerbcastClient } from "./context";
8
8
  import { ActionBarRow, FeedActionBar, OverlayIconButton, feedActionToEntry, } from "./FeedActionBar";
9
- import { CommandBlank, CommandBrackets } from "./CommandBrackets";
9
+ import { CommandBlank, LiveCommandBrackets } from "./CommandBrackets";
10
+ import { LiveAnchorMarkers } from "./AnchorMarkers";
10
11
  import { useCommandDisplayUt } from "./hooks/useCommandDisplayUt";
11
12
  import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
12
13
  import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
13
14
  import { useKerbcastStream } from "./hooks/useKerbcastStream";
15
+ import { useKerbcastWaitingForSlot } from "./hooks/useKerbcastWaitingForSlot";
14
16
  import { useRecordings } from "./hooks/useRecordings";
15
17
  import { DEFAULT_FEED_ASPECT, useReportDisplaySize } from "./hooks/useReportDisplaySize";
16
18
  import { useTargetBracketsPreference } from "./hooks/useTargetBracketsPreference";
17
19
  import { StandbyIcon } from "./StandbyIcon";
18
20
  import { isCameraDestroyed } from "./lifecycle";
21
+ import { claimAnchors } from "./sharedAnchors";
19
22
  import { usePortalMenu } from "./menuPositioning";
20
23
  import { formatElapsed, nowMs } from "./timing";
21
24
  // ---------------------------------------------------------------------------
@@ -62,7 +65,7 @@ function exitFullscreen() {
62
65
  // ---------------------------------------------------------------------------
63
66
  // Inner component (reads from context)
64
67
  // ---------------------------------------------------------------------------
65
- const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, cameraFilter, onDisplayedCameraChange, showDebugInfo = false, showStatic, inFlight: inFlightProp, showStandbyIcon = true, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, enableTracking = false, disableManualControls = false, enableRecording = false, recordFullResolution = false, actions, trailingActions, showActions = true, useStream, enableTargetBrackets = true, targetBracketOverlay, displayUt: displayUtProp, }, ref) {
68
+ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, cameraFilter, onDisplayedCameraChange, showDebugInfo = false, showStatic, inFlight: inFlightProp, showStandbyIcon = true, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, enableTracking = false, disableManualControls = false, enableRecording = false, recordFullResolution = false, actions, trailingActions, showActions = true, useStream, enableTargetBrackets = true, targetBracketOverlay, waitingForTargetLabel = "Waiting for target", noSlotLabel = "Not streaming: too many feeds are open. Close a feed to watch this one.", displayUt: displayUtProp, displayFrame, anchors, anchorOverlay, }, ref) {
66
69
  const client = useKerbcastClient();
67
70
  // Always called (rules of hooks); `enableRecording` only gates whether the
68
71
  // REC entry/badge render, not whether the store is subscribed.
@@ -96,7 +99,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
96
99
  }, [showStatic]);
97
100
  const effectiveShowStatic = showStatic === undefined ? !reducedMotion : showStatic;
98
101
  // -------------------------------------------------------------------------
99
- // Selection model (mirrors gonogo's CameraFeed)
102
+ // Selection model
100
103
  //
101
104
  // Resolution order:
102
105
  // 1. Explicit pick, if still present in the list (destroyed or not).
@@ -242,6 +245,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
242
245
  }
243
246
  }, []);
244
247
  const isDestroyed = camera ? isCameraDestroyed(camera) : false;
248
+ const waitingForSlot = useKerbcastWaitingForSlot(flightId);
245
249
  /* Gating here rather than at the render sites covers the whole manual
246
250
  path at once: the controls unmount, the effects below zero any in-flight
247
251
  rate, and the imperative handle's pan/zoom methods no-op. */
@@ -259,11 +263,23 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
259
263
  const commandCamera = camera?.kind === CameraKind.Command && !isDestroyed ? camera : null;
260
264
  const isCommandFeed = commandCamera !== null;
261
265
  const commandTrack = commandCamera?.commandTrack ?? null;
262
- const commandDisplayUt = useCommandDisplayUt(commandTrack !== null, displayUtProp, commandTrack?.events, commandCamera?.flightId ?? null, videoEl);
266
+ const commandDisplayUt = useCommandDisplayUt(commandTrack !== null, displayFrame ? displayFrame.ut : displayUtProp, commandTrack?.events, commandCamera?.flightId ?? null, videoEl, displayFrame?.command);
263
267
  const commandState = commandTrack
264
- ? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut)
268
+ ? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut, commandDisplayUt?.command)
265
269
  : null;
266
- const [bracketsOn, toggleBrackets] = useTargetBracketsPreference(commandCamera ? (commandCamera.commandCentre?.stationId ?? String(commandCamera.flightId)) : null);
270
+ /* This feed's anchors go to the camera it displays, and leave with it. */
271
+ const anchorsKey = anchors?.length ? JSON.stringify(anchors) : "";
272
+ useEffect(() => {
273
+ if (flightId === null || !anchorsKey)
274
+ return;
275
+ return claimAnchors(client, flightId, JSON.parse(anchorsKey));
276
+ }, [client, flightId, anchorsKey]);
277
+ const drawsAnchors = !!anchorsKey || !!anchorOverlay?.owners?.length;
278
+ const [bracketsOn, toggleBrackets] = useTargetBracketsPreference(commandCamera
279
+ ? (commandCamera.commandCentre?.siteKey || commandCamera.commandCentre?.nodeName || String(commandCamera.flightId))
280
+ : null,
281
+ // 1.11 keyed the preference by stationId; carry a saved choice over.
282
+ commandCamera?.commandCentre?.stationId ?? null);
267
283
  const targetBracketsAvailable = enableTargetBrackets && isCommandFeed;
268
284
  // -------------------------------------------------------------------------
269
285
  // Camera selection callbacks (Next/Prev, picker)
@@ -489,8 +505,12 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
489
505
  // one aims the camera at it. Hidden while a command authority holds the
490
506
  // aim lock, since only it can aim then.
491
507
  // -------------------------------------------------------------------------
492
- const commandTargets = commandCamera?.commandCentre?.targets ?? [];
493
- const commandTargetId = commandCamera?.commandCentre?.subjectPersistentId ?? 0;
508
+ /* The list as it stood at the displayed frame, so a delayed feed never offers what its picture has not reached. */
509
+ const shownCentre = commandCamera
510
+ ? client.commandCentreAt(commandCamera.flightId, commandDisplayUt?.ut)
511
+ : null;
512
+ const commandTargets = shownCentre?.targets ?? [];
513
+ const commandTargetId = shownCentre?.subjectPersistentId ?? 0;
494
514
  const commandLocked = commandCamera?.commandTrack?.authority != null;
495
515
  const commandTargetAvailable = isCommandFeed && !disableManualControls && !commandLocked && commandTargets.length > 0;
496
516
  const commandTargetMenuId = useId();
@@ -523,6 +543,8 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
523
543
  yet (see ActiveRecordingInfo.arming). Drives the ARMING pill below in
524
544
  place of the REC badge. */
525
545
  const armingActive = activeRecording?.arming === true;
546
+ /* Stopped, clip not in the store yet (ActiveRecordingInfo.stopping). */
547
+ const savingActive = activeRecording?.stopping === true;
526
548
  // Ticks once a second while recording so the elapsed timer stays live;
527
549
  // the timer itself is derived from activeRecording.startedAt each render.
528
550
  const [, tickRecordingTimer] = useReducer((c) => c + 1, 0);
@@ -708,7 +730,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
708
730
  return (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": current, "$selected": current, disabled: !eligible, title: eligible ? undefined : t.eligibilityText, onClick: () => selectCommandTarget(t.persistentId), children: [t.vesselName, t.rangeM != null ? ` · ${formatCommandRange(t.rangeM)}` : "", eligible ? "" : ` · ${t.eligibilityText}`] }, t.persistentId));
709
731
  }) }), document.body), commandState &&
710
732
  !outOfFlight &&
711
- (_jsxs(_Fragment, { children: [commandState.blank && _jsx(CommandBlank, { reason: commandState.blank }), targetBracketsAvailable && bracketsOn && (_jsx(CommandBrackets, { state: commandState, displayUt: commandDisplayUt?.ut ?? commandTrack.ut, utSource: commandDisplayUt?.source ?? null, video: videoEl, fallbackSize: camera ? { width: camera.renderWidth, height: camera.renderHeight } : null, overlay: targetBracketOverlay }))] })), outOfFlight ? (showStandbyIcon && (_jsx(StandbyOverlay, { role: "status", "aria-label": "Standby, no active flight", children: _jsx(StandbyIconWrap, { children: _jsx(StandbyIcon, { size: 40 }) }) }))) : (isDestroyed && (_jsx(SignalLostOverlay, { role: "status", "aria-label": "Signal lost", children: _jsx(SignalLostText, { "$animated": effectiveShowStatic, children: "SIGNAL LOST" }) }))), !outOfFlight && !effectiveShowStatic && isStale && !isDestroyed && (_jsxs(_Fragment, { children: [_jsx(StaleScrim, { "aria-hidden": "true" }), _jsxs(StaleBadge, { role: "status", "aria-label": "Feed stale", children: [_jsx(StaleIcon, {}), "Stale"] })] })), recordingActive && armingActive && (_jsxs(ArmingBadge, { role: "status", "aria-label": "Arming", children: [_jsx(ArmingDot, { "aria-hidden": "true" }), "ARMING"] })), recordingActive && !armingActive && (_jsxs(RecBadge, { role: "status", "aria-label": "Recording", children: [_jsx(RecDot, { "aria-hidden": "true" }), "REC ", formatElapsed(recordingElapsedMs)] })), showZoom && (_jsxs(ZoomControlsWrap, { "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(ZoomButton, { type: "button", "aria-label": "Zoom in", "$pos": "top", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
733
+ (_jsxs(_Fragment, { children: [commandState.blank && _jsx(CommandBlank, { reason: commandState.blank }), commandState.waitingForTarget && !commandState.blank && !isDestroyed && waitingForTargetLabel && (_jsx(WaitingBadge, { role: "status", children: waitingForTargetLabel })), targetBracketsAvailable && bracketsOn && (_jsx(LiveCommandBrackets, { flightId: commandCamera?.flightId ?? null, state: commandState, displayUt: commandDisplayUt?.ut ?? commandTrack.ut, utSource: commandDisplayUt?.source ?? null, video: videoEl, fallbackSize: camera ? { width: camera.renderWidth, height: camera.renderHeight } : null, overlay: targetBracketOverlay }))] })), waitingForSlot && !outOfFlight && !isDestroyed && noSlotLabel && (_jsx(NoSlotNotice, { role: "status", children: noSlotLabel })), drawsAnchors && !outOfFlight && !isDestroyed && !commandState?.blank && (_jsx(LiveAnchorMarkers, { flightId: flightId, video: videoEl, overlay: anchorOverlay })), outOfFlight ? (showStandbyIcon && (_jsx(StandbyOverlay, { role: "status", "aria-label": "Standby, no active flight", children: _jsx(StandbyIconWrap, { children: _jsx(StandbyIcon, { size: 40 }) }) }))) : (isDestroyed && (_jsx(SignalLostOverlay, { role: "status", "aria-label": "Signal lost", children: _jsx(SignalLostText, { "$animated": effectiveShowStatic, children: "SIGNAL LOST" }) }))), !outOfFlight && !effectiveShowStatic && isStale && !isDestroyed && (_jsxs(_Fragment, { children: [_jsx(StaleScrim, { "aria-hidden": "true" }), _jsxs(StaleBadge, { role: "status", "aria-label": "Feed stale", children: [_jsx(StaleIcon, {}), "Stale"] })] })), recordingActive && armingActive && (_jsxs(ArmingBadge, { role: "status", "aria-label": "Arming", children: [_jsx(ArmingDot, { "aria-hidden": "true" }), "ARMING"] })), recordingActive && !armingActive && !savingActive && (_jsxs(RecBadge, { role: "status", "aria-label": "Recording", children: [_jsx(RecDot, { "aria-hidden": "true" }), "REC ", formatElapsed(recordingElapsedMs)] })), recordingActive && savingActive && (_jsx(RecBadge, { role: "status", "aria-label": "Saving recording", children: "SAVING" })), showZoom && (_jsxs(ZoomControlsWrap, { "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(ZoomButton, { type: "button", "aria-label": "Zoom in", "$pos": "top", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
712
734
  if (e.detail === 0)
713
735
  controllerRef.current?.nudgeZoom(-1);
714
736
  }, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
@@ -742,7 +764,7 @@ export const CameraFeed = forwardRef(function CameraFeed({ client, ...rest }, re
742
764
  return _jsx(CameraFeedInner, { ref: ref, ...rest });
743
765
  });
744
766
  // ---------------------------------------------------------------------------
745
- // Inline UI primitives (replacing @gonogo/ui dependencies)
767
+ // Inline UI primitives
746
768
  // ---------------------------------------------------------------------------
747
769
  function ChevronDownIcon(props) {
748
770
  return (_jsx("svg", { viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": "true", ...props, children: _jsx("path", { d: "M4 6l4 4 4-4", stroke: "currentColor", strokeWidth: 1.5, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" }) }));
@@ -1366,6 +1388,52 @@ const StaleScrim = styled.div `
1366
1388
  background: rgba(0, 0, 0, 0.32);
1367
1389
  pointer-events: none;
1368
1390
  `;
1391
+ /* Centred in the picture, clear of the chrome along the edges, and wrapping so
1392
+ a small tile still shows the whole sentence. */
1393
+ const NoSlotNotice = styled.div `
1394
+ position: absolute;
1395
+ top: 50%;
1396
+ left: 50%;
1397
+ transform: translate(-50%, -50%);
1398
+ z-index: 2;
1399
+ max-width: calc(100% - 32px);
1400
+ padding: 6px 10px;
1401
+ background: rgba(0, 0, 0, 0.6);
1402
+ border: 1px solid rgba(255, 255, 255, 0.4);
1403
+ border-radius: 3px;
1404
+ color: rgba(255, 255, 255, 0.9);
1405
+ font-size: 12px;
1406
+ line-height: 1.4;
1407
+ text-align: center;
1408
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
1409
+ pointer-events: none;
1410
+ `;
1411
+ /* Bottom centre: the zoom controls take the bottom left, pan and the stale
1412
+ badge the bottom right, the title and REC badge the top left and the
1413
+ action bar the top right. */
1414
+ const WaitingBadge = styled.div `
1415
+ position: absolute;
1416
+ bottom: 8px;
1417
+ left: 50%;
1418
+ transform: translateX(-50%);
1419
+ z-index: 2;
1420
+ /* Clear of the stale badge on the bottom right, even on a small tile. */
1421
+ max-width: calc(100% - 180px);
1422
+ overflow: hidden;
1423
+ text-overflow: ellipsis;
1424
+ white-space: nowrap;
1425
+ padding: 2px 6px;
1426
+ background: rgba(0, 0, 0, 0.6);
1427
+ border: 1px solid rgba(255, 255, 255, 0.4);
1428
+ border-radius: 3px;
1429
+ color: rgba(255, 255, 255, 0.9);
1430
+ font-size: 10px;
1431
+ font-weight: 600;
1432
+ letter-spacing: 0.12em;
1433
+ text-transform: uppercase;
1434
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
1435
+ pointer-events: none;
1436
+ `;
1369
1437
  const StaleBadge = styled.div `
1370
1438
  position: absolute;
1371
1439
  bottom: 8px;