@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
package/src/CommandBrackets.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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(
|
|
147
|
-
return
|
|
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
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
package/src/KerbalFaceFeed.tsx
CHANGED
|
@@ -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
|
|
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 />);
|
package/src/RecordingsTray.tsx
CHANGED
|
@@ -22,7 +22,11 @@
|
|
|
22
22
|
* camera registry; omit it for a plain "Camera <flightId>" fallback.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import type {
|
|
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)} · {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.
|
|
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
|
-
/*
|
|
90
|
-
source,
|
|
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
|
-
|
|
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
|
|
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:
|
|
118
|
-
:
|
|
119
|
-
?
|
|
120
|
-
:
|
|
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
|
+
}
|