@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.
- package/README.md +59 -0
- package/dist/AnchorMarkers.d.ts +128 -0
- package/dist/AnchorMarkers.js +162 -0
- package/dist/CameraFeed.d.ts +45 -4
- package/dist/CameraFeed.js +78 -10
- package/dist/CommandBrackets.d.ts +61 -12
- package/dist/CommandBrackets.js +106 -53
- package/dist/KerbalFaceFeed.d.ts +1 -1
- package/dist/KerbalFaceFeed.js +1 -1
- package/dist/RecordingsTray.d.ts +6 -0
- package/dist/RecordingsTray.js +17 -1
- package/dist/hooks/useCommandDisplayUt.d.ts +16 -3
- package/dist/hooks/useCommandDisplayUt.js +57 -16
- package/dist/hooks/useKerbcastWaitingForSlot.d.ts +5 -0
- package/dist/hooks/useKerbcastWaitingForSlot.js +20 -0
- package/dist/hooks/useTargetBracketsPreference.d.ts +3 -2
- package/dist/hooks/useTargetBracketsPreference.js +18 -5
- package/dist/index.d.ts +4 -1
- package/dist/index.js +5 -1
- package/dist/sharedAnchors.d.ts +3 -0
- package/dist/sharedAnchors.js +61 -0
- package/dist/videoBox.d.ts +10 -0
- package/dist/videoBox.js +40 -0
- package/package.json +2 -2
- package/src/AnchorMarkers.test.tsx +173 -0
- package/src/AnchorMarkers.tsx +341 -0
- package/src/CameraFeed.test.tsx +223 -6
- package/src/CameraFeed.tsx +145 -14
- package/src/CommandBrackets.test.tsx +204 -4
- package/src/CommandBrackets.tsx +170 -53
- package/src/KerbalFaceFeed.tsx +1 -1
- package/src/RecordingsTray.test.tsx +15 -0
- package/src/RecordingsTray.tsx +25 -1
- package/src/hooks/useCommandDisplayUt.test.tsx +74 -3
- package/src/hooks/useCommandDisplayUt.ts +61 -15
- package/src/hooks/useKerbcastWaitingForSlot.ts +25 -0
- package/src/hooks/useRecordings.sharedState.test.tsx +6 -3
- package/src/hooks/useRecordings.test.tsx +6 -3
- package/src/hooks/useTargetBracketsPreference.ts +15 -5
- package/src/index.ts +23 -1
- package/src/sharedAnchors.ts +67 -0
- package/src/videoBox.ts +49 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COMMAND_AIM_ANCHOR_ID,
|
|
3
|
+
KERBCAST_ANCHOR_OWNER,
|
|
4
|
+
anchorPoint,
|
|
5
|
+
fitFrame,
|
|
6
|
+
frameToScreen,
|
|
7
|
+
type DisplayedFrameUt,
|
|
8
|
+
type FittedFrame,
|
|
9
|
+
type FrameAnchor,
|
|
10
|
+
type FrameFit,
|
|
11
|
+
type FrameUtSource,
|
|
12
|
+
} from "@ksp-gonogo/kerbcast";
|
|
13
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
14
|
+
import styled from "styled-components";
|
|
15
|
+
import { useKerbcastClient } from "./context";
|
|
16
|
+
import { useVideoBox, videoFit } from "./videoBox";
|
|
17
|
+
|
|
18
|
+
const MARKER_STROKE_PX = 2;
|
|
19
|
+
const MARKER_MIN_HALF_PX = 14;
|
|
20
|
+
const MARKER_ARM_FRACTION = 0.3;
|
|
21
|
+
const MARKER_MIN_ARM_PX = 6;
|
|
22
|
+
const READOUT_GAP_PX = 11;
|
|
23
|
+
|
|
24
|
+
/** Default colours; each is overridable through the named CSS custom property. */
|
|
25
|
+
export const ANCHOR_MARKER_COLOURS = {
|
|
26
|
+
clear: "var(--kerbcast-anchor-clear, #4caf50)",
|
|
27
|
+
occluded: "var(--kerbcast-anchor-occluded, #f44336)",
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
/** The four marks around an anchor, clockwise from the top. */
|
|
31
|
+
export type AnchorCorner = "top" | "right" | "bottom" | "left";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How a mark is drawn. `corners`: a right-angle corner of a box around the
|
|
35
|
+
* anchor, like the command brackets; each key names the corner that starts
|
|
36
|
+
* its side going clockwise, so `top` is the top left, `right` the top right,
|
|
37
|
+
* `bottom` the bottom right and `left` the bottom left. `triangles`: a
|
|
38
|
+
* triangle outside the box on that side, pointing at the anchor.
|
|
39
|
+
*/
|
|
40
|
+
export type AnchorMarkerShape = "corners" | "triangles";
|
|
41
|
+
|
|
42
|
+
export interface AnchorCornerStyle {
|
|
43
|
+
colour: string;
|
|
44
|
+
dashed: boolean;
|
|
45
|
+
shape: AnchorMarkerShape;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Changes to one default mark; unset fields keep the default. */
|
|
49
|
+
export interface AnchorCornerOverride {
|
|
50
|
+
colour?: string;
|
|
51
|
+
dashed?: boolean;
|
|
52
|
+
hidden?: boolean;
|
|
53
|
+
shape?: AnchorMarkerShape;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type AnchorCornerOverrides = Partial<Record<AnchorCorner, AnchorCornerOverride>>;
|
|
57
|
+
|
|
58
|
+
/** The marker box for one anchor, in the overlay SVG's pixels (the video element's box). */
|
|
59
|
+
export interface AnchorMarkerGeometry {
|
|
60
|
+
/** Where the anchor is on screen. Off the box when it is out of frame. */
|
|
61
|
+
cx: number;
|
|
62
|
+
cy: number;
|
|
63
|
+
/** Half the box side: the anchor's apparent radius on screen, never below a minimum. */
|
|
64
|
+
half: number;
|
|
65
|
+
/** Length of each corner stroke, and the side of each triangle. */
|
|
66
|
+
arm: number;
|
|
67
|
+
stroke: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** An anchor with its place on screen. */
|
|
71
|
+
export interface PlacedAnchor {
|
|
72
|
+
anchor: FrameAnchor;
|
|
73
|
+
x: number;
|
|
74
|
+
y: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Everything an anchor overlay needs to line up with the drawn markers. */
|
|
78
|
+
export interface AnchorContext {
|
|
79
|
+
geometry: AnchorMarkerGeometry;
|
|
80
|
+
/** Where the frame lands in the element, after `object-fit`. */
|
|
81
|
+
content: FittedFrame;
|
|
82
|
+
/** Size of the overlay SVG, the video element's box. */
|
|
83
|
+
width: number;
|
|
84
|
+
height: number;
|
|
85
|
+
/** Mission UT of the displayed frame the anchors describe. */
|
|
86
|
+
displayUt: number;
|
|
87
|
+
utSource: FrameUtSource;
|
|
88
|
+
/** Every drawn anchor in this frame with its screen position. */
|
|
89
|
+
anchors: readonly PlacedAnchor[];
|
|
90
|
+
/** The displayed frame, with its raw per-frame results and size. */
|
|
91
|
+
frame: DisplayedFrameUt;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Consumer changes to a feed's anchor markers. Each part is optional and is
|
|
96
|
+
* called for every drawn anchor on every displayed frame. What an anchor
|
|
97
|
+
* means, and which colour says so, is the consumer's: Kerbcast only reports
|
|
98
|
+
* where it is.
|
|
99
|
+
*/
|
|
100
|
+
export interface AnchorOverlay {
|
|
101
|
+
/** Default shape of every mark. Default `"corners"`. */
|
|
102
|
+
shape?: AnchorMarkerShape;
|
|
103
|
+
/** Restyle or hide the four marks of one anchor. */
|
|
104
|
+
corners?:
|
|
105
|
+
| AnchorCornerOverrides
|
|
106
|
+
| ((anchor: FrameAnchor, ctx: AnchorContext) => AnchorCornerOverrides | null | undefined);
|
|
107
|
+
/**
|
|
108
|
+
* SVG content drawn above the marks, in the same pixel space. Runs for an
|
|
109
|
+
* anchor out of frame or behind the lens too (its marks are left out then),
|
|
110
|
+
* so an overlay can point to it from the edge. An observed result out of
|
|
111
|
+
* view carries no position and is not drawn at all; it is still in
|
|
112
|
+
* `ctx.frame.anchors`.
|
|
113
|
+
*/
|
|
114
|
+
render?: (anchor: FrameAnchor, ctx: AnchorContext) => ReactNode;
|
|
115
|
+
/** Text under the marker, or null for none. Default none. */
|
|
116
|
+
readout?: (anchor: FrameAnchor, ctx: AnchorContext) => string | null;
|
|
117
|
+
/**
|
|
118
|
+
* Game-side owners whose anchors are drawn as well as this client's own,
|
|
119
|
+
* e.g. `["my-mod"]`. Default: only this client's.
|
|
120
|
+
*/
|
|
121
|
+
owners?: readonly string[];
|
|
122
|
+
/**
|
|
123
|
+
* Draw Kerbcast's own `aim` anchor too when `"kerbcast"` is among
|
|
124
|
+
* `owners`. Off by default: the brackets already mark the aim.
|
|
125
|
+
*/
|
|
126
|
+
showAim?: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The marker box for an anchor at `centre` with `radiusPx` capture pixels on a `frameHeight`-tall frame. */
|
|
130
|
+
export function anchorMarkerGeometry(
|
|
131
|
+
centre: { x: number; y: number },
|
|
132
|
+
radiusPx: number,
|
|
133
|
+
frameHeight: number,
|
|
134
|
+
content: FittedFrame,
|
|
135
|
+
): AnchorMarkerGeometry {
|
|
136
|
+
const scale = frameHeight > 0 ? content.height / frameHeight : 1;
|
|
137
|
+
const half = Math.max(MARKER_MIN_HALF_PX, radiusPx * scale);
|
|
138
|
+
const arm = Math.max(MARKER_MIN_ARM_PX, MARKER_ARM_FRACTION * half * 2);
|
|
139
|
+
return { cx: centre.x, cy: centre.y, half, arm, stroke: MARKER_STROKE_PX };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const CORNERS: readonly AnchorCorner[] = ["top", "right", "bottom", "left"];
|
|
143
|
+
|
|
144
|
+
/** SVG path data for one mark. Corners are open strokes; triangles are closed. */
|
|
145
|
+
function markPath(corner: AnchorCorner, shape: AnchorMarkerShape, g: AnchorMarkerGeometry): string {
|
|
146
|
+
const { cx, cy, half: h, arm: a } = g;
|
|
147
|
+
if (shape === "triangles") {
|
|
148
|
+
const t = a * 0.8;
|
|
149
|
+
const w = a / 2;
|
|
150
|
+
switch (corner) {
|
|
151
|
+
case "top":
|
|
152
|
+
return `M${cx} ${cy - h}L${cx - w} ${cy - h - t}L${cx + w} ${cy - h - t}Z`;
|
|
153
|
+
case "right":
|
|
154
|
+
return `M${cx + h} ${cy}L${cx + h + t} ${cy - w}L${cx + h + t} ${cy + w}Z`;
|
|
155
|
+
case "bottom":
|
|
156
|
+
return `M${cx} ${cy + h}L${cx + w} ${cy + h + t}L${cx - w} ${cy + h + t}Z`;
|
|
157
|
+
default:
|
|
158
|
+
return `M${cx - h} ${cy}L${cx - h - t} ${cy + w}L${cx - h - t} ${cy - w}Z`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const l = cx - h, r = cx + h, tp = cy - h, b = cy + h;
|
|
162
|
+
switch (corner) {
|
|
163
|
+
case "top":
|
|
164
|
+
return `M${l} ${tp + a}L${l} ${tp}L${l + a} ${tp}`;
|
|
165
|
+
case "right":
|
|
166
|
+
return `M${r - a} ${tp}L${r} ${tp}L${r} ${tp + a}`;
|
|
167
|
+
case "bottom":
|
|
168
|
+
return `M${r} ${b - a}L${r} ${b}L${r - a} ${b}`;
|
|
169
|
+
default:
|
|
170
|
+
return `M${l + a} ${b}L${l} ${b}L${l} ${b - a}`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Whether a feed shows this anchor: this client's own, or one from a listed owner, Kerbcast's aim only on request. */
|
|
175
|
+
function drawn(a: FrameAnchor, overlay: AnchorOverlay | null | undefined): boolean {
|
|
176
|
+
if (a.owner === undefined) return true;
|
|
177
|
+
if (a.owner === KERBCAST_ANCHOR_OWNER && a.id === COMMAND_AIM_ANCHOR_ID && !overlay?.showAim) return false;
|
|
178
|
+
return overlay?.owners?.includes(a.owner) ?? false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface AnchorMarkersProps {
|
|
182
|
+
/** The displayed frame whose anchors to draw. */
|
|
183
|
+
frame: DisplayedFrameUt | null;
|
|
184
|
+
video: HTMLVideoElement | null;
|
|
185
|
+
overlay?: AnchorOverlay | null;
|
|
186
|
+
/** How the video fits its box. Default: read from the element's CSS. */
|
|
187
|
+
fit?: FrameFit;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Markers for the anchors in one displayed frame: by default four corner
|
|
192
|
+
* marks around each anchor sized from its apparent radius, green when clear
|
|
193
|
+
* and red and dashed when occluded. Positions come from the frame's own
|
|
194
|
+
* stamp, so they match the picture under any delay.
|
|
195
|
+
*/
|
|
196
|
+
export function AnchorMarkers({ frame, video, overlay, fit }: AnchorMarkersProps) {
|
|
197
|
+
const { boxW, boxH, mediaW, mediaH } = useVideoBox(video);
|
|
198
|
+
if (!frame?.anchors?.length) return null;
|
|
199
|
+
const frameW = frame.frameWidth || mediaW;
|
|
200
|
+
const frameH = frame.frameHeight || mediaH;
|
|
201
|
+
if (!(frameW > 0 && frameH > 0 && boxW > 0 && boxH > 0)) return null;
|
|
202
|
+
const content = fitFrame(boxW, boxH, frameW, frameH, fit ?? videoFit(video));
|
|
203
|
+
/* An observed result out of view has no position, so it has no marker. */
|
|
204
|
+
const placed: PlacedAnchor[] = [];
|
|
205
|
+
for (const a of frame.anchors) {
|
|
206
|
+
const point = drawn(a, overlay) ? anchorPoint(a) : null;
|
|
207
|
+
if (point) placed.push({ anchor: a, ...frameToScreen(point, frameW, frameH, content) });
|
|
208
|
+
}
|
|
209
|
+
if (placed.length === 0) return null;
|
|
210
|
+
const shape = overlay?.shape ?? "corners";
|
|
211
|
+
|
|
212
|
+
const items = placed.map(({ anchor, x, y }) => {
|
|
213
|
+
const geometry = anchorMarkerGeometry({ x, y }, anchor.apparentRadiusPx ?? 0, frameH, content);
|
|
214
|
+
const ctx: AnchorContext = {
|
|
215
|
+
geometry,
|
|
216
|
+
content,
|
|
217
|
+
width: boxW,
|
|
218
|
+
height: boxH,
|
|
219
|
+
displayUt: frame.ut,
|
|
220
|
+
utSource: frame.source,
|
|
221
|
+
anchors: placed,
|
|
222
|
+
frame,
|
|
223
|
+
};
|
|
224
|
+
const overrides =
|
|
225
|
+
typeof overlay?.corners === "function" ? overlay.corners(anchor, ctx) : overlay?.corners;
|
|
226
|
+
const base: AnchorCornerStyle = anchor.occluded
|
|
227
|
+
? { colour: ANCHOR_MARKER_COLOURS.occluded, dashed: true, shape }
|
|
228
|
+
: { colour: ANCHOR_MARKER_COLOURS.clear, dashed: false, shape };
|
|
229
|
+
const visible = anchor.inFrame && !anchor.behind;
|
|
230
|
+
const readout = overlay?.readout ? overlay.readout(anchor, ctx) : null;
|
|
231
|
+
return { anchor, geometry, ctx, overrides, base, visible, readout };
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<>
|
|
236
|
+
<MarkerSvg aria-hidden="true" data-anchor-markers="" width={boxW} height={boxH}>
|
|
237
|
+
{items.map(({ anchor, geometry, ctx, overrides, base, visible }) => (
|
|
238
|
+
<g key={`${anchor.owner ?? ""}/${anchor.id}`} data-anchor={anchor.id} data-owner={anchor.owner}>
|
|
239
|
+
{visible &&
|
|
240
|
+
CORNERS.map((corner) => {
|
|
241
|
+
const o = overrides?.[corner];
|
|
242
|
+
if (o?.hidden) return null;
|
|
243
|
+
const s = o?.shape ?? base.shape;
|
|
244
|
+
const colour = o?.colour ?? base.colour;
|
|
245
|
+
const dashed = o?.dashed ?? base.dashed;
|
|
246
|
+
const filled = s === "triangles";
|
|
247
|
+
return (
|
|
248
|
+
<path
|
|
249
|
+
key={corner}
|
|
250
|
+
data-corner={corner}
|
|
251
|
+
data-shape={s}
|
|
252
|
+
d={markPath(corner, s, geometry)}
|
|
253
|
+
strokeWidth={geometry.stroke}
|
|
254
|
+
strokeDasharray={dashed ? "3 2" : undefined}
|
|
255
|
+
style={filled ? { fill: dashed ? "none" : colour, stroke: colour } : { fill: "none", stroke: colour }}
|
|
256
|
+
/>
|
|
257
|
+
);
|
|
258
|
+
})}
|
|
259
|
+
{overlay?.render && <g data-anchor-overlay="">{overlay.render(anchor, ctx)}</g>}
|
|
260
|
+
</g>
|
|
261
|
+
))}
|
|
262
|
+
</MarkerSvg>
|
|
263
|
+
{items.map(
|
|
264
|
+
({ anchor, geometry, readout, visible }) =>
|
|
265
|
+
readout &&
|
|
266
|
+
visible && (
|
|
267
|
+
<Readout
|
|
268
|
+
key={`${anchor.owner ?? ""}/${anchor.id}`}
|
|
269
|
+
data-anchor-readout={anchor.id}
|
|
270
|
+
style={{ left: geometry.cx, top: geometry.cy + geometry.half + READOUT_GAP_PX }}
|
|
271
|
+
>
|
|
272
|
+
{readout}
|
|
273
|
+
</Readout>
|
|
274
|
+
),
|
|
275
|
+
)}
|
|
276
|
+
</>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The displayed frame of `flightId` on `video`, re-rendering on every
|
|
282
|
+
* presented frame while `enabled`. Kept to the component that draws from it,
|
|
283
|
+
* so a feed's other UI does not re-render at the frame rate.
|
|
284
|
+
*/
|
|
285
|
+
export function useDisplayedFrame(
|
|
286
|
+
enabled: boolean,
|
|
287
|
+
flightId: number | null,
|
|
288
|
+
video: HTMLVideoElement | null,
|
|
289
|
+
): DisplayedFrameUt | null {
|
|
290
|
+
const client = useKerbcastClient();
|
|
291
|
+
const [frame, setFrame] = useState<DisplayedFrameUt | null>(null);
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
setFrame(null);
|
|
294
|
+
if (!enabled || flightId === null || !video) return;
|
|
295
|
+
return client.watchDisplayedFrames(flightId, video, (f) =>
|
|
296
|
+
/* Skip re-renders between frames that carry the same results. */
|
|
297
|
+
setFrame((prev) =>
|
|
298
|
+
prev && prev.anchors === f.anchors && prev.rtpTimestamp === f.rtpTimestamp ? prev : f,
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
}, [client, enabled, flightId, video]);
|
|
302
|
+
return frame;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Self-subscribing markers for a feed: watches its displayed frames and draws their anchors. */
|
|
306
|
+
export function LiveAnchorMarkers({
|
|
307
|
+
flightId,
|
|
308
|
+
video,
|
|
309
|
+
overlay,
|
|
310
|
+
fit,
|
|
311
|
+
}: {
|
|
312
|
+
flightId: number | null;
|
|
313
|
+
video: HTMLVideoElement | null;
|
|
314
|
+
overlay?: AnchorOverlay | null;
|
|
315
|
+
fit?: FrameFit;
|
|
316
|
+
}) {
|
|
317
|
+
const frame = useDisplayedFrame(true, flightId, video);
|
|
318
|
+
return <AnchorMarkers frame={frame} video={video} overlay={overlay} fit={fit} />;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const MarkerSvg = styled.svg`
|
|
322
|
+
position: absolute;
|
|
323
|
+
top: 0;
|
|
324
|
+
left: 0;
|
|
325
|
+
z-index: 1;
|
|
326
|
+
overflow: visible;
|
|
327
|
+
pointer-events: none;
|
|
328
|
+
`;
|
|
329
|
+
|
|
330
|
+
const Readout = styled.div`
|
|
331
|
+
position: absolute;
|
|
332
|
+
z-index: 1;
|
|
333
|
+
transform: translateX(-50%);
|
|
334
|
+
white-space: nowrap;
|
|
335
|
+
color: #fff;
|
|
336
|
+
font-size: 10px;
|
|
337
|
+
font-weight: 600;
|
|
338
|
+
letter-spacing: 0.12em;
|
|
339
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
340
|
+
pointer-events: none;
|
|
341
|
+
`;
|
package/src/CameraFeed.test.tsx
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import {
|
|
26
|
+
AnchorKind,
|
|
26
27
|
CameraKind,
|
|
27
28
|
CommandTargetEligibility,
|
|
28
29
|
KerbcastClient,
|
|
@@ -30,10 +31,12 @@ import {
|
|
|
30
31
|
TrackMode,
|
|
31
32
|
} from "@ksp-gonogo/kerbcast";
|
|
32
33
|
import type {
|
|
34
|
+
Anchor,
|
|
33
35
|
CameraLifecycle,
|
|
34
36
|
ClientMessage,
|
|
35
37
|
CommandCentreInfo,
|
|
36
38
|
CommandTrackState,
|
|
39
|
+
DisplayedFrameUt,
|
|
37
40
|
} from "@ksp-gonogo/kerbcast";
|
|
38
41
|
import { type MockCameraInit, MockSidecar } from "@ksp-gonogo/kerbcast/testing";
|
|
39
42
|
import {
|
|
@@ -2747,9 +2750,54 @@ describe("CameraFeed - recording locks source and close", () => {
|
|
|
2747
2750
|
// Command camera target brackets
|
|
2748
2751
|
// ---------------------------------------------------------------------------
|
|
2749
2752
|
|
|
2753
|
+
|
|
2754
|
+
describe("CameraFeed: no video slot", () => {
|
|
2755
|
+
const NOTICE = /Not streaming: too many feeds are open/;
|
|
2756
|
+
const ids = [1, 2, 3, 4, 5];
|
|
2757
|
+
const feeds = (client: KerbcastClient, shown: number[], label?: string | null) => (
|
|
2758
|
+
<KerbcastProvider client={client}>
|
|
2759
|
+
{shown.map((id) => (
|
|
2760
|
+
<CameraFeed
|
|
2761
|
+
key={id}
|
|
2762
|
+
flightId={id}
|
|
2763
|
+
{...(label !== undefined ? { noSlotLabel: label } : {})}
|
|
2764
|
+
/>
|
|
2765
|
+
))}
|
|
2766
|
+
</KerbcastProvider>
|
|
2767
|
+
);
|
|
2768
|
+
|
|
2769
|
+
it("says so on the feed that found every slot taken, until another feed closes", async () => {
|
|
2770
|
+
const { client } = await buildConnectedSource(
|
|
2771
|
+
ids.map((id) => makeCamera({ flightId: id, cameraName: `Cam ${id}` })),
|
|
2772
|
+
);
|
|
2773
|
+
const view = render(feeds(client, ids));
|
|
2774
|
+
await act(async () => {});
|
|
2775
|
+
expect(screen.getAllByText(NOTICE)).toHaveLength(1);
|
|
2776
|
+
expect(client.camera(5).waitingForSlot).toBe(true);
|
|
2777
|
+
|
|
2778
|
+
view.rerender(feeds(client, [2, 3, 4, 5]));
|
|
2779
|
+
await act(async () => {});
|
|
2780
|
+
expect(screen.queryByText(NOTICE)).toBeNull();
|
|
2781
|
+
expect(client.camera(5).waitingForSlot).toBe(false);
|
|
2782
|
+
});
|
|
2783
|
+
|
|
2784
|
+
it("takes a host's own words, or none", async () => {
|
|
2785
|
+
const { client } = await buildConnectedSource(
|
|
2786
|
+
ids.map((id) => makeCamera({ flightId: id, cameraName: `Cam ${id}` })),
|
|
2787
|
+
);
|
|
2788
|
+
const view = render(feeds(client, ids, "No room"));
|
|
2789
|
+
await act(async () => {});
|
|
2790
|
+
expect(screen.getByText("No room")).toBeTruthy();
|
|
2791
|
+
view.rerender(feeds(client, ids, null));
|
|
2792
|
+
expect(screen.queryByText("No room")).toBeNull();
|
|
2793
|
+
expect(screen.queryByText(NOTICE)).toBeNull();
|
|
2794
|
+
});
|
|
2795
|
+
});
|
|
2796
|
+
|
|
2750
2797
|
describe("CameraFeed - command camera target brackets", () => {
|
|
2751
2798
|
const STATION: CommandCentreInfo = {
|
|
2752
2799
|
stationId: "ground:KSC",
|
|
2800
|
+
siteKey: "KSC",
|
|
2753
2801
|
nodeName: "KSC",
|
|
2754
2802
|
displayName: "Kerbal Space Center",
|
|
2755
2803
|
body: "Kerbin",
|
|
@@ -2809,7 +2857,7 @@ describe("CameraFeed - command camera target brackets", () => {
|
|
|
2809
2857
|
const first = renderFeed(client, { flightId: 301 });
|
|
2810
2858
|
fireEvent.click(screen.getByRole("button", { name: "Target brackets" }));
|
|
2811
2859
|
expect(screen.queryByText("IN VIEW · 11,508 km")).toBeNull();
|
|
2812
|
-
expect(localStorage.getItem("kerbcast:target-brackets:
|
|
2860
|
+
expect(localStorage.getItem("kerbcast:target-brackets:KSC")).toBe("0");
|
|
2813
2861
|
first.unmount();
|
|
2814
2862
|
|
|
2815
2863
|
renderFeed(client, { flightId: 301 });
|
|
@@ -2819,22 +2867,87 @@ describe("CameraFeed - command camera target brackets", () => {
|
|
|
2819
2867
|
).toBe("false");
|
|
2820
2868
|
});
|
|
2821
2869
|
|
|
2822
|
-
it("
|
|
2870
|
+
it("carries a brackets-off choice saved under the 1.11 key over to the site key", async () => {
|
|
2823
2871
|
localStorage.setItem("kerbcast:target-brackets:ground:KSC", "0");
|
|
2872
|
+
const { client } = await buildConnectedSource([commandCamera()]);
|
|
2873
|
+
renderFeed(client, { flightId: 301 });
|
|
2874
|
+
expect(
|
|
2875
|
+
screen.getByRole("button", { name: "Target brackets" }).getAttribute("aria-pressed"),
|
|
2876
|
+
).toBe("false");
|
|
2877
|
+
expect(localStorage.getItem("kerbcast:target-brackets:KSC")).toBe("0");
|
|
2878
|
+
expect(localStorage.getItem("kerbcast:target-brackets:ground:KSC")).toBeNull();
|
|
2879
|
+
});
|
|
2880
|
+
|
|
2881
|
+
it("still labels a blanked feed with the brackets toggled off", async () => {
|
|
2882
|
+
localStorage.setItem("kerbcast:target-brackets:KSC", "0");
|
|
2824
2883
|
const { client, sidecar } = await buildConnectedSource([commandCamera()]);
|
|
2825
2884
|
renderFeed(client, { flightId: 301 });
|
|
2826
2885
|
expect(screen.queryByRole("status", { name: "Feed blanked" })).toBeNull();
|
|
2827
2886
|
await act(async () => {
|
|
2828
|
-
sidecar.updateCamera(301, { commandTrack: commandTrack({ blank: "Waiting for
|
|
2887
|
+
sidecar.updateCamera(301, { commandTrack: commandTrack({ blank: "Waiting for mission control" }) });
|
|
2829
2888
|
});
|
|
2830
2889
|
expect(screen.getByRole("status", { name: "Feed blanked" }).textContent).toBe(
|
|
2831
|
-
"Waiting for
|
|
2890
|
+
"Waiting for mission control",
|
|
2832
2891
|
);
|
|
2833
2892
|
});
|
|
2834
2893
|
|
|
2894
|
+
describe("waiting for a target", () => {
|
|
2895
|
+
const waitingFrame = (waitingForTarget: boolean): DisplayedFrameUt => ({
|
|
2896
|
+
flightId: 301,
|
|
2897
|
+
ut: 1000,
|
|
2898
|
+
epoch: 0,
|
|
2899
|
+
source: "exact",
|
|
2900
|
+
rtpTimestamp: 1,
|
|
2901
|
+
presentedAt: 0,
|
|
2902
|
+
command: {
|
|
2903
|
+
hasFix: false,
|
|
2904
|
+
frameHeightPx: 576,
|
|
2905
|
+
framingFits: true,
|
|
2906
|
+
occluded: false,
|
|
2907
|
+
occluder: "",
|
|
2908
|
+
sunlit: false,
|
|
2909
|
+
subjectPersistentId: 0,
|
|
2910
|
+
waitingForTarget,
|
|
2911
|
+
},
|
|
2912
|
+
});
|
|
2913
|
+
const feed = (client: KerbcastClient, frame: DisplayedFrameUt, label?: string | null) => (
|
|
2914
|
+
<KerbcastProvider client={client}>
|
|
2915
|
+
<CameraFeed
|
|
2916
|
+
flightId={301}
|
|
2917
|
+
displayFrame={frame}
|
|
2918
|
+
{...(label !== undefined ? { waitingForTargetLabel: label } : {})}
|
|
2919
|
+
/>
|
|
2920
|
+
</KerbcastProvider>
|
|
2921
|
+
);
|
|
2922
|
+
|
|
2923
|
+
it("says so while the frame does, and stops once the target arrives", async () => {
|
|
2924
|
+
const { client } = await buildConnectedSource([commandCamera()]);
|
|
2925
|
+
const view = render(feed(client, waitingFrame(true)));
|
|
2926
|
+
expect(screen.getByText("Waiting for target")).toBeTruthy();
|
|
2927
|
+
view.rerender(feed(client, waitingFrame(false)));
|
|
2928
|
+
expect(screen.queryByText("Waiting for target")).toBeNull();
|
|
2929
|
+
});
|
|
2930
|
+
|
|
2931
|
+
it("takes a host's own words, or none", async () => {
|
|
2932
|
+
const { client } = await buildConnectedSource([commandCamera()]);
|
|
2933
|
+
const view = render(feed(client, waitingFrame(true), "Acquiring"));
|
|
2934
|
+
expect(screen.getByText("Acquiring")).toBeTruthy();
|
|
2935
|
+
view.rerender(feed(client, waitingFrame(true), null));
|
|
2936
|
+
expect(screen.queryByText("Acquiring")).toBeNull();
|
|
2937
|
+
expect(screen.queryByText("Waiting for target")).toBeNull();
|
|
2938
|
+
});
|
|
2939
|
+
|
|
2940
|
+
it("gives way to a blank", async () => {
|
|
2941
|
+
const { client } = await buildConnectedSource([commandCamera()]);
|
|
2942
|
+
const frame = waitingFrame(true);
|
|
2943
|
+
render(feed(client, { ...frame, command: { ...frame.command!, blank: "Held" } }));
|
|
2944
|
+
expect(screen.getByRole("status", { name: "Feed blanked" })).toBeTruthy();
|
|
2945
|
+
expect(screen.queryByText("Waiting for target")).toBeNull();
|
|
2946
|
+
});
|
|
2947
|
+
});
|
|
2948
|
+
|
|
2835
2949
|
it("shows the transition in force at the displayed UT", async () => {
|
|
2836
2950
|
const track = commandTrack({
|
|
2837
|
-
authority: { owner: "gonogo" },
|
|
2838
2951
|
events: [
|
|
2839
2952
|
{ ut: 990, occluded: true, occluder: "Mun", sunlit: true },
|
|
2840
2953
|
{ ut: 999, occluded: false, occluder: "", sunlit: true },
|
|
@@ -2873,6 +2986,46 @@ describe("CameraFeed - command camera target brackets", () => {
|
|
|
2873
2986
|
expect(screen.getByText(/ · T\+1060$/)).toBeTruthy();
|
|
2874
2987
|
});
|
|
2875
2988
|
|
|
2989
|
+
it("gives the brackets the subject's anchor from the displayed frame's stamp", async () => {
|
|
2990
|
+
const { client, sidecar } = await buildConnectedSource([commandCamera()]);
|
|
2991
|
+
const subjects: (string | undefined)[] = [];
|
|
2992
|
+
renderFeed(client, {
|
|
2993
|
+
flightId: 301,
|
|
2994
|
+
targetBracketOverlay: {
|
|
2995
|
+
render: (ctx) => {
|
|
2996
|
+
subjects.push(ctx.subject ? `${ctx.subject.owner}/${ctx.subject.id}` : undefined);
|
|
2997
|
+
return null;
|
|
2998
|
+
},
|
|
2999
|
+
},
|
|
3000
|
+
});
|
|
3001
|
+
await act(async () => {
|
|
3002
|
+
sidecar.fireFrameStamp({
|
|
3003
|
+
flightId: 301,
|
|
3004
|
+
rtpTimestamp: 1,
|
|
3005
|
+
captureUt: 1000,
|
|
3006
|
+
captureEpoch: 0,
|
|
3007
|
+
width: 1024,
|
|
3008
|
+
height: 576,
|
|
3009
|
+
anchors: [
|
|
3010
|
+
{
|
|
3011
|
+
id: "subject",
|
|
3012
|
+
owner: "kerbcast",
|
|
3013
|
+
x: 100,
|
|
3014
|
+
y: 100,
|
|
3015
|
+
inFrame: true,
|
|
3016
|
+
behind: false,
|
|
3017
|
+
distanceM: 11_507_905,
|
|
3018
|
+
apparentRadiusPx: 4,
|
|
3019
|
+
angleOffAxis: 3,
|
|
3020
|
+
occluded: false,
|
|
3021
|
+
},
|
|
3022
|
+
],
|
|
3023
|
+
});
|
|
3024
|
+
});
|
|
3025
|
+
/* Without frame callbacks (jsdom) a stamp is taken as on screen once it is old enough. */
|
|
3026
|
+
await waitFor(() => expect(subjects).toContain("kerbcast/subject"), { timeout: 2000 });
|
|
3027
|
+
});
|
|
3028
|
+
|
|
2876
3029
|
describe("target picker", () => {
|
|
2877
3030
|
const TARGETS: CommandCentreInfo = {
|
|
2878
3031
|
...STATION,
|
|
@@ -2993,7 +3146,7 @@ describe("CameraFeed - command camera target brackets", () => {
|
|
|
2993
3146
|
kind: CameraKind.Command,
|
|
2994
3147
|
cameraName: "KSC tracking",
|
|
2995
3148
|
commandCentre: TARGETS,
|
|
2996
|
-
commandTrack: commandTrack({ authority: { owner: "
|
|
3149
|
+
commandTrack: commandTrack({ authority: { owner: "mission-control" } }),
|
|
2997
3150
|
}),
|
|
2998
3151
|
]);
|
|
2999
3152
|
const lockedView = renderFeed(locked.client, { flightId: 301 });
|
|
@@ -3013,3 +3166,67 @@ describe("CameraFeed - command camera target brackets", () => {
|
|
|
3013
3166
|
expect(screen.queryByRole("button", { name: "Target brackets" })).toBeNull();
|
|
3014
3167
|
});
|
|
3015
3168
|
});
|
|
3169
|
+
|
|
3170
|
+
describe("CameraFeed - anchors", () => {
|
|
3171
|
+
const port = { id: "port", kind: AnchorKind.Part, vessel: 11, part: 22, orientation: true };
|
|
3172
|
+
|
|
3173
|
+
function Feed({ client, flightId, anchors }: { client: KerbcastClient; flightId: number; anchors?: Anchor[] }) {
|
|
3174
|
+
return (
|
|
3175
|
+
<KerbcastProvider client={client}>
|
|
3176
|
+
<CameraFeed flightId={flightId} anchors={anchors} />
|
|
3177
|
+
</KerbcastProvider>
|
|
3178
|
+
);
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
it("sets its anchors on the displayed camera and clears them when it moves or unmounts", async () => {
|
|
3182
|
+
const { client, sidecar } = await buildConnectedSource([
|
|
3183
|
+
makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
|
|
3184
|
+
makeCamera({ flightId: 43, cameraName: "Port Cam" }),
|
|
3185
|
+
]);
|
|
3186
|
+
const view = render(<Feed client={client} flightId={42} anchors={[port]} />);
|
|
3187
|
+
await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port]));
|
|
3188
|
+
|
|
3189
|
+
/* A new array with the same anchors is no change. */
|
|
3190
|
+
const sent = () => sidecar.commands.filter((c) => c.type === "set-anchors").length;
|
|
3191
|
+
const before = sent();
|
|
3192
|
+
view.rerender(<Feed client={client} flightId={42} anchors={[{ ...port }]} />);
|
|
3193
|
+
expect(sent()).toBe(before);
|
|
3194
|
+
|
|
3195
|
+
view.rerender(<Feed client={client} flightId={43} anchors={[port]} />);
|
|
3196
|
+
await waitFor(() => expect(sidecar.anchorsFor(43)).toEqual([port]));
|
|
3197
|
+
expect(sidecar.anchorsFor(42)).toEqual([]);
|
|
3198
|
+
|
|
3199
|
+
view.unmount();
|
|
3200
|
+
await waitFor(() => expect(sidecar.anchorsFor(43)).toEqual([]));
|
|
3201
|
+
});
|
|
3202
|
+
|
|
3203
|
+
it("gives two feeds on one camera both their anchors, until each unmounts", async () => {
|
|
3204
|
+
const { client, sidecar } = await buildConnectedSource([
|
|
3205
|
+
makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
|
|
3206
|
+
]);
|
|
3207
|
+
const dock = { id: "dock", kind: AnchorKind.Vessel, vessel: 12 };
|
|
3208
|
+
const first = render(<Feed client={client} flightId={42} anchors={[port]} />);
|
|
3209
|
+
const second = render(<Feed client={client} flightId={42} anchors={[port, dock]} />);
|
|
3210
|
+
await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port, dock]));
|
|
3211
|
+
|
|
3212
|
+
first.unmount();
|
|
3213
|
+
await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port, dock]));
|
|
3214
|
+
second.rerender(<Feed client={client} flightId={42} anchors={[dock]} />);
|
|
3215
|
+
await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([dock]));
|
|
3216
|
+
second.unmount();
|
|
3217
|
+
await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([]));
|
|
3218
|
+
});
|
|
3219
|
+
|
|
3220
|
+
it("warns when two feeds on one camera use one anchor id for different things", async () => {
|
|
3221
|
+
const { client, sidecar } = await buildConnectedSource([makeCamera({ flightId: 42 })]);
|
|
3222
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
3223
|
+
const other = { ...port, part: 23 };
|
|
3224
|
+
render(<Feed client={client} flightId={42} anchors={[port]} />);
|
|
3225
|
+
render(<Feed client={client} flightId={42} anchors={[port]} />);
|
|
3226
|
+
expect(warn).not.toHaveBeenCalled();
|
|
3227
|
+
render(<Feed client={client} flightId={42} anchors={[other]} />);
|
|
3228
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("id port"));
|
|
3229
|
+
await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port]));
|
|
3230
|
+
warn.mockRestore();
|
|
3231
|
+
});
|
|
3232
|
+
});
|