@ksp-gonogo/kerbcast-react 1.10.3 → 1.11.0-rc.4
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/dist/CameraFeed.d.ts +21 -0
- package/dist/CameraFeed.js +106 -5
- package/dist/CommandBrackets.d.ts +110 -0
- package/dist/CommandBrackets.js +167 -0
- package/dist/hooks/useCommandDisplayUt.d.ts +22 -0
- package/dist/hooks/useCommandDisplayUt.js +104 -0
- package/dist/hooks/useDisplayedFrameUt.d.ts +11 -0
- package/dist/hooks/useDisplayedFrameUt.js +22 -0
- package/dist/hooks/useReportDisplaySize.d.ts +5 -0
- package/dist/hooks/useReportDisplaySize.js +18 -7
- package/dist/hooks/useTargetBracketsPreference.d.ts +6 -0
- package/dist/hooks/useTargetBracketsPreference.js +39 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +291 -2
- package/src/CameraFeed.tsx +234 -4
- package/src/CommandBrackets.test.tsx +259 -0
- package/src/CommandBrackets.tsx +341 -0
- package/src/hooks/useCommandDisplayUt.test.tsx +156 -0
- package/src/hooks/useCommandDisplayUt.ts +135 -0
- package/src/hooks/useDisplayedFrameUt.ts +26 -0
- package/src/hooks/useReportDisplaySize.test.tsx +10 -4
- package/src/hooks/useReportDisplaySize.ts +18 -7
- package/src/hooks/useTargetBracketsPreference.ts +38 -0
- package/src/index.test.ts +2 -1
- package/src/index.ts +15 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import {
|
|
2
|
+
commandReadout,
|
|
3
|
+
type EffectiveCommandTrack,
|
|
4
|
+
type FrameUtSource,
|
|
5
|
+
} from "@ksp-gonogo/kerbcast";
|
|
6
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
7
|
+
import styled from "styled-components";
|
|
8
|
+
|
|
9
|
+
const BRACKET_STROKE_PX = 2;
|
|
10
|
+
const BRACKET_MIN_HALF_PX = 14;
|
|
11
|
+
const BRACKET_ARM_FRACTION = 0.3;
|
|
12
|
+
const BRACKET_MIN_ARM_PX = 6;
|
|
13
|
+
const READOUT_GAP_PX = 11;
|
|
14
|
+
|
|
15
|
+
/** Default colours; each is overridable through the named CSS custom property. */
|
|
16
|
+
export const COMMAND_BRACKET_COLOURS = {
|
|
17
|
+
clear: "var(--kerbcast-bracket-clear, #4caf50)",
|
|
18
|
+
occluded: "var(--kerbcast-bracket-occluded, #f44336)",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export interface ContentRect {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Where `object-fit: contain` media actually lands inside its box. Falls back
|
|
30
|
+
* to the whole box while the media size is unknown.
|
|
31
|
+
*/
|
|
32
|
+
export function containedContentRect(
|
|
33
|
+
boxWidth: number,
|
|
34
|
+
boxHeight: number,
|
|
35
|
+
mediaWidth: number,
|
|
36
|
+
mediaHeight: number,
|
|
37
|
+
): ContentRect {
|
|
38
|
+
if (mediaWidth <= 0 || mediaHeight <= 0 || boxWidth <= 0 || boxHeight <= 0) {
|
|
39
|
+
return { x: 0, y: 0, width: Math.max(0, boxWidth), height: Math.max(0, boxHeight) };
|
|
40
|
+
}
|
|
41
|
+
const scale = Math.min(boxWidth / mediaWidth, boxHeight / mediaHeight);
|
|
42
|
+
const width = mediaWidth * scale;
|
|
43
|
+
const height = mediaHeight * scale;
|
|
44
|
+
return { x: (boxWidth - width) / 2, y: (boxHeight - height) / 2, width, height };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface BracketPoint {
|
|
48
|
+
x: number;
|
|
49
|
+
y: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The bracket box, in the pixel space of the bracket SVG (the video element's box). */
|
|
53
|
+
export interface BracketGeometry {
|
|
54
|
+
/** Box centre, which is the aim point. */
|
|
55
|
+
cx: number;
|
|
56
|
+
cy: number;
|
|
57
|
+
/** Half the box side in display px. */
|
|
58
|
+
half: number;
|
|
59
|
+
/** Length of each corner stroke. */
|
|
60
|
+
arm: number;
|
|
61
|
+
stroke: number;
|
|
62
|
+
/** The box's top-left and bottom-right corner points. */
|
|
63
|
+
topLeft: BracketPoint;
|
|
64
|
+
bottomRight: BracketPoint;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The bracket box centred on the displayed frame. `bracketHalfPx` is in
|
|
69
|
+
* capture px of a `frameHeightPx`-tall frame, so it scales by the displayed
|
|
70
|
+
* height; a missing half-size draws the minimum box.
|
|
71
|
+
*/
|
|
72
|
+
export function bracketGeometry(
|
|
73
|
+
rect: ContentRect,
|
|
74
|
+
bracketHalfPx: number | null,
|
|
75
|
+
frameHeightPx: number,
|
|
76
|
+
): BracketGeometry {
|
|
77
|
+
const scale = frameHeightPx > 0 ? rect.height / frameHeightPx : 1;
|
|
78
|
+
const half = Math.max(BRACKET_MIN_HALF_PX, (bracketHalfPx ?? 0) * scale);
|
|
79
|
+
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;
|
|
82
|
+
return {
|
|
83
|
+
cx,
|
|
84
|
+
cy,
|
|
85
|
+
half,
|
|
86
|
+
arm,
|
|
87
|
+
stroke: BRACKET_STROKE_PX,
|
|
88
|
+
topLeft: { x: cx - half, y: cy - half },
|
|
89
|
+
bottomRight: { x: cx + half, y: cy + half },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type CommandBracketCorner = "topLeft" | "bottomRight";
|
|
94
|
+
|
|
95
|
+
export interface CommandBracketCornerStyle {
|
|
96
|
+
colour: string;
|
|
97
|
+
dashed: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Changes to one default corner; unset fields keep the default. */
|
|
101
|
+
export interface CommandBracketCornerOverride {
|
|
102
|
+
colour?: string;
|
|
103
|
+
dashed?: boolean;
|
|
104
|
+
hidden?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type CommandBracketCornerOverrides = Partial<
|
|
108
|
+
Record<CommandBracketCorner, CommandBracketCornerOverride>
|
|
109
|
+
>;
|
|
110
|
+
|
|
111
|
+
/** Everything a bracket overlay needs to line up with the drawn brackets. */
|
|
112
|
+
export interface CommandBracketContext {
|
|
113
|
+
geometry: BracketGeometry;
|
|
114
|
+
/** Size of the bracket SVG, the video element's box. */
|
|
115
|
+
width: number;
|
|
116
|
+
height: number;
|
|
117
|
+
/** Frame UT the brackets describe. */
|
|
118
|
+
displayUt: number;
|
|
119
|
+
utSource: FrameUtSource | "override" | null;
|
|
120
|
+
state: EffectiveCommandTrack;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Consumer additions to a command feed's brackets. Each part is optional and
|
|
125
|
+
* is called on every render with the context for the displayed frame.
|
|
126
|
+
*/
|
|
127
|
+
export interface CommandBracketOverlay {
|
|
128
|
+
/**
|
|
129
|
+
* SVG content drawn inside the bracket SVG above the default corners, in
|
|
130
|
+
* the same pixel space. Runs in every state, with or without a fix and
|
|
131
|
+
* over a blanked feed; `ctx.state` says which.
|
|
132
|
+
*/
|
|
133
|
+
render?: (ctx: CommandBracketContext) => ReactNode;
|
|
134
|
+
/** Restyle or hide the default corners. */
|
|
135
|
+
corners?:
|
|
136
|
+
| CommandBracketCornerOverrides
|
|
137
|
+
| ((ctx: CommandBracketContext) => CommandBracketCornerOverrides | null | undefined);
|
|
138
|
+
/**
|
|
139
|
+
* The readout text under the box. Receives the default text, which is
|
|
140
|
+
* empty while the feed is blanked; return a replacement (append to keep
|
|
141
|
+
* it), or null to hide the readout. Runs in every state.
|
|
142
|
+
*/
|
|
143
|
+
readout?: (defaultText: string, ctx: CommandBracketContext) => string | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function cameraCornerStyle(state: EffectiveCommandTrack): CommandBracketCornerStyle {
|
|
147
|
+
return state.occluded
|
|
148
|
+
? { colour: COMMAND_BRACKET_COLOURS.occluded, dashed: true }
|
|
149
|
+
: { colour: COMMAND_BRACKET_COLOURS.clear, dashed: false };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function applyCorner(
|
|
153
|
+
base: CommandBracketCornerStyle,
|
|
154
|
+
override: CommandBracketCornerOverride | undefined,
|
|
155
|
+
): CommandBracketCornerStyle & { hidden: boolean } {
|
|
156
|
+
return {
|
|
157
|
+
colour: override?.colour ?? base.colour,
|
|
158
|
+
dashed: override?.dashed ?? base.dashed,
|
|
159
|
+
hidden: override?.hidden ?? false,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
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
|
+
export interface CommandBracketsProps {
|
|
199
|
+
state: EffectiveCommandTrack;
|
|
200
|
+
displayUt: number;
|
|
201
|
+
/** Where `displayUt` came from, mirrored to the readout as `data-ut-source`. */
|
|
202
|
+
utSource?: FrameUtSource | "override" | null;
|
|
203
|
+
video: HTMLVideoElement | null;
|
|
204
|
+
/** Frame aspect to assume until the video reports its own size. */
|
|
205
|
+
fallbackSize?: { width: number; height: number } | null;
|
|
206
|
+
overlay?: CommandBracketOverlay | null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Target brackets for a command camera feed: the aim point sits at frame
|
|
211
|
+
* centre, so the box is always centred on the displayed video. Both corners
|
|
212
|
+
* show the camera axis by default; an overlay can restyle either and draw
|
|
213
|
+
* its own marks. Without a fix, or over a blanked feed, the default corners
|
|
214
|
+
* are left out but the overlay and readout still run.
|
|
215
|
+
*/
|
|
216
|
+
export function CommandBrackets({
|
|
217
|
+
state,
|
|
218
|
+
displayUt,
|
|
219
|
+
utSource,
|
|
220
|
+
video,
|
|
221
|
+
fallbackSize,
|
|
222
|
+
overlay,
|
|
223
|
+
}: CommandBracketsProps) {
|
|
224
|
+
const { boxW, boxH, mediaW, mediaH } = useVideoBox(video);
|
|
225
|
+
const rect = containedContentRect(
|
|
226
|
+
boxW,
|
|
227
|
+
boxH,
|
|
228
|
+
mediaW || fallbackSize?.width || 0,
|
|
229
|
+
mediaH || fallbackSize?.height || 0,
|
|
230
|
+
);
|
|
231
|
+
const g = bracketGeometry(rect, state.bracketHalfPx, state.frameHeightPx || mediaH);
|
|
232
|
+
const ctx: CommandBracketContext = {
|
|
233
|
+
geometry: g,
|
|
234
|
+
width: boxW,
|
|
235
|
+
height: boxH,
|
|
236
|
+
displayUt,
|
|
237
|
+
utSource: utSource ?? null,
|
|
238
|
+
state,
|
|
239
|
+
};
|
|
240
|
+
const overrides =
|
|
241
|
+
typeof overlay?.corners === "function" ? overlay.corners(ctx) : overlay?.corners;
|
|
242
|
+
const camera = cameraCornerStyle(state);
|
|
243
|
+
const topLeft = applyCorner(camera, overrides?.topLeft);
|
|
244
|
+
const bottomRight = applyCorner(camera, overrides?.bottomRight);
|
|
245
|
+
const { x: left, y: top } = g.topLeft;
|
|
246
|
+
const { x: right, y: bottom } = g.bottomRight;
|
|
247
|
+
const blanked = !!state.blank;
|
|
248
|
+
const drawCorners = state.hasFix && !blanked;
|
|
249
|
+
const defaultReadout = blanked ? "" : commandReadout(state);
|
|
250
|
+
const readout = overlay?.readout ? overlay.readout(defaultReadout, ctx) : defaultReadout;
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<>
|
|
254
|
+
{(drawCorners || overlay?.render) && (
|
|
255
|
+
<BracketSvg aria-hidden="true" data-camera-axis={state.camera} width={boxW} height={boxH}>
|
|
256
|
+
{drawCorners && !topLeft.hidden && (
|
|
257
|
+
<path
|
|
258
|
+
data-corner="topLeft"
|
|
259
|
+
d={`M${left} ${top + g.arm}L${left} ${top}L${left + g.arm} ${top}`}
|
|
260
|
+
fill="none"
|
|
261
|
+
strokeWidth={g.stroke}
|
|
262
|
+
strokeDasharray={topLeft.dashed ? "3 2" : undefined}
|
|
263
|
+
style={{ stroke: topLeft.colour }}
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
266
|
+
{drawCorners && !bottomRight.hidden && (
|
|
267
|
+
<path
|
|
268
|
+
data-corner="bottomRight"
|
|
269
|
+
d={`M${right} ${bottom - g.arm}L${right} ${bottom}L${right - g.arm} ${bottom}`}
|
|
270
|
+
fill="none"
|
|
271
|
+
strokeWidth={g.stroke}
|
|
272
|
+
strokeDasharray={bottomRight.dashed ? "3 2" : undefined}
|
|
273
|
+
style={{ stroke: bottomRight.colour }}
|
|
274
|
+
/>
|
|
275
|
+
)}
|
|
276
|
+
{overlay?.render && <g data-bracket-overlay="">{overlay.render(ctx)}</g>}
|
|
277
|
+
</BracketSvg>
|
|
278
|
+
)}
|
|
279
|
+
{readout && (
|
|
280
|
+
<Readout
|
|
281
|
+
data-ut-source={utSource ?? undefined}
|
|
282
|
+
style={{ left: g.cx, top: bottom + READOUT_GAP_PX }}
|
|
283
|
+
>
|
|
284
|
+
{readout}
|
|
285
|
+
</Readout>
|
|
286
|
+
)}
|
|
287
|
+
</>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Reason a command feed is blanked, centred on black over the feed. */
|
|
292
|
+
export function CommandBlank({ reason }: { reason: string }) {
|
|
293
|
+
return (
|
|
294
|
+
<BlankOverlay role="status" aria-label="Feed blanked">
|
|
295
|
+
<BlankText>{reason}</BlankText>
|
|
296
|
+
</BlankOverlay>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const BracketSvg = styled.svg`
|
|
301
|
+
position: absolute;
|
|
302
|
+
top: 0;
|
|
303
|
+
left: 0;
|
|
304
|
+
z-index: 1;
|
|
305
|
+
overflow: visible;
|
|
306
|
+
pointer-events: none;
|
|
307
|
+
`;
|
|
308
|
+
|
|
309
|
+
const Readout = styled.div`
|
|
310
|
+
position: absolute;
|
|
311
|
+
z-index: 1;
|
|
312
|
+
transform: translateX(-50%);
|
|
313
|
+
white-space: nowrap;
|
|
314
|
+
color: #fff;
|
|
315
|
+
font-size: 10px;
|
|
316
|
+
font-weight: 600;
|
|
317
|
+
letter-spacing: 0.12em;
|
|
318
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
319
|
+
pointer-events: none;
|
|
320
|
+
`;
|
|
321
|
+
|
|
322
|
+
const BlankOverlay = styled.div`
|
|
323
|
+
position: absolute;
|
|
324
|
+
inset: 0;
|
|
325
|
+
z-index: 1;
|
|
326
|
+
display: flex;
|
|
327
|
+
align-items: center;
|
|
328
|
+
justify-content: center;
|
|
329
|
+
padding: 1rem;
|
|
330
|
+
background: #000;
|
|
331
|
+
pointer-events: none;
|
|
332
|
+
`;
|
|
333
|
+
|
|
334
|
+
const BlankText = styled.span`
|
|
335
|
+
color: rgba(255, 255, 255, 0.85);
|
|
336
|
+
font-size: 0.85rem;
|
|
337
|
+
font-weight: 600;
|
|
338
|
+
letter-spacing: 0.12em;
|
|
339
|
+
text-align: center;
|
|
340
|
+
text-transform: uppercase;
|
|
341
|
+
`;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display UT for command feeds and the displayed-frame UT hook: the presented
|
|
3
|
+
* frame's own capture stamp wins over the capture clock, every value says
|
|
4
|
+
* which it is, and a host override beats both.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { KerbcastClient, type CommandTrackEvent } from "@ksp-gonogo/kerbcast";
|
|
8
|
+
import { MockSidecar } from "@ksp-gonogo/kerbcast/testing";
|
|
9
|
+
import { act, cleanup, render } from "@testing-library/react";
|
|
10
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
11
|
+
import { KerbcastProvider } from "../context";
|
|
12
|
+
import { crossesEvent, useCommandDisplayUt, type CommandDisplayUt } from "./useCommandDisplayUt";
|
|
13
|
+
import { useDisplayedFrameUt } from "./useDisplayedFrameUt";
|
|
14
|
+
|
|
15
|
+
async function connected() {
|
|
16
|
+
const sidecar = new MockSidecar();
|
|
17
|
+
const client = new KerbcastClient(
|
|
18
|
+
{ host: "h", port: 1, negotiate: (o) => sidecar.negotiate(o) },
|
|
19
|
+
sidecar.createTransport(),
|
|
20
|
+
);
|
|
21
|
+
await act(async () => {
|
|
22
|
+
await client.connect([], { slots: 4 });
|
|
23
|
+
sidecar.open();
|
|
24
|
+
sidecar.setConnectionState("connected");
|
|
25
|
+
});
|
|
26
|
+
sidecar.fireSettingsState({ throttleMainScreen: false, captureUt: 1000, timeWarpRate: 1 });
|
|
27
|
+
return { client, sidecar };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A video element whose frames the test presents by hand. */
|
|
31
|
+
function fakeVideo() {
|
|
32
|
+
let pending: ((now: number, meta: object) => void) | null = null;
|
|
33
|
+
const video = {
|
|
34
|
+
requestVideoFrameCallback: (cb: (now: number, meta: object) => void) => {
|
|
35
|
+
pending = cb;
|
|
36
|
+
return 1;
|
|
37
|
+
},
|
|
38
|
+
cancelVideoFrameCallback: () => {
|
|
39
|
+
pending = null;
|
|
40
|
+
},
|
|
41
|
+
} as unknown as HTMLVideoElement;
|
|
42
|
+
const present = (rtpTimestamp?: number) =>
|
|
43
|
+
act(() => {
|
|
44
|
+
const cb = pending;
|
|
45
|
+
pending = null;
|
|
46
|
+
cb?.(0, rtpTimestamp === undefined ? {} : { rtpTimestamp });
|
|
47
|
+
});
|
|
48
|
+
return { video, present };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const event = (ut: number): CommandTrackEvent =>
|
|
52
|
+
({ ut, occluded: true, sunlit: true, occluder: "", blank: "" }) as CommandTrackEvent;
|
|
53
|
+
|
|
54
|
+
let last: CommandDisplayUt | null | undefined;
|
|
55
|
+
function CommandProbe(props: {
|
|
56
|
+
override?: number;
|
|
57
|
+
events?: CommandTrackEvent[];
|
|
58
|
+
video: HTMLVideoElement | null;
|
|
59
|
+
}) {
|
|
60
|
+
last = useCommandDisplayUt(true, props.override, props.events, 7, props.video);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
cleanup();
|
|
66
|
+
last = undefined;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("useCommandDisplayUt", () => {
|
|
70
|
+
it("is estimated from the clock until an exactly stamped frame is shown", async () => {
|
|
71
|
+
const { client, sidecar } = await connected();
|
|
72
|
+
const { video, present } = fakeVideo();
|
|
73
|
+
render(
|
|
74
|
+
<KerbcastProvider client={client}>
|
|
75
|
+
<CommandProbe video={video} />
|
|
76
|
+
</KerbcastProvider>,
|
|
77
|
+
);
|
|
78
|
+
expect(last?.source).toBe("estimated");
|
|
79
|
+
|
|
80
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 4500, captureUt: 999.8, captureEpoch: 0 });
|
|
81
|
+
present(4500);
|
|
82
|
+
expect(last).toEqual({ ut: 999.8, source: "exact" });
|
|
83
|
+
|
|
84
|
+
/* An unstamped frame falls back to the clock, and says so. */
|
|
85
|
+
present(9000);
|
|
86
|
+
expect(last?.source).toBe("estimated");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("re-renders when an exact frame steps across an event", async () => {
|
|
90
|
+
const { client, sidecar } = await connected();
|
|
91
|
+
const { video, present } = fakeVideo();
|
|
92
|
+
render(
|
|
93
|
+
<KerbcastProvider client={client}>
|
|
94
|
+
<CommandProbe video={video} events={[event(999.9)]} />
|
|
95
|
+
</KerbcastProvider>,
|
|
96
|
+
);
|
|
97
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 1, captureUt: 999.8, captureEpoch: 0 });
|
|
98
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 2, captureUt: 999.85, captureEpoch: 0 });
|
|
99
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 3, captureUt: 999.95, captureEpoch: 0 });
|
|
100
|
+
present(1);
|
|
101
|
+
expect(last?.ut).toBe(999.8);
|
|
102
|
+
/* Same side of the event: no re-render needed, the value shown holds. */
|
|
103
|
+
present(2);
|
|
104
|
+
expect(last?.ut).toBe(999.8);
|
|
105
|
+
present(3);
|
|
106
|
+
expect(last?.ut).toBe(999.95);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("prefers a host override over the displayed frame", async () => {
|
|
110
|
+
const { client, sidecar } = await connected();
|
|
111
|
+
const { video, present } = fakeVideo();
|
|
112
|
+
render(
|
|
113
|
+
<KerbcastProvider client={client}>
|
|
114
|
+
<CommandProbe video={video} override={990} />
|
|
115
|
+
</KerbcastProvider>,
|
|
116
|
+
);
|
|
117
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 1, captureUt: 999.8, captureEpoch: 0 });
|
|
118
|
+
present(1);
|
|
119
|
+
expect(last).toEqual({ ut: 990, source: "override" });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("crossesEvent", () => {
|
|
124
|
+
it("counts an event the display moves onto or past, in either direction", () => {
|
|
125
|
+
const events = [event(10)];
|
|
126
|
+
expect(crossesEvent(events, 9, 10)).toBe(true);
|
|
127
|
+
expect(crossesEvent(events, 11, 9)).toBe(true);
|
|
128
|
+
expect(crossesEvent(events, 10, 11)).toBe(false);
|
|
129
|
+
expect(crossesEvent(undefined, 0, 100)).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("useDisplayedFrameUt", () => {
|
|
134
|
+
let frame: ReturnType<typeof useDisplayedFrameUt> | undefined;
|
|
135
|
+
function FrameProbe({ video }: { video: HTMLVideoElement }) {
|
|
136
|
+
frame = useDisplayedFrameUt(7, video);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
it("reports every presented frame with its source", async () => {
|
|
141
|
+
const { client, sidecar } = await connected();
|
|
142
|
+
const { video, present } = fakeVideo();
|
|
143
|
+
render(
|
|
144
|
+
<KerbcastProvider client={client}>
|
|
145
|
+
<FrameProbe video={video} />
|
|
146
|
+
</KerbcastProvider>,
|
|
147
|
+
);
|
|
148
|
+
expect(frame).toBeNull();
|
|
149
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 77, captureUt: 999.5, captureEpoch: 4 });
|
|
150
|
+
present(77);
|
|
151
|
+
expect(frame).toMatchObject({ ut: 999.5, epoch: 4, source: "exact", rtpTimestamp: 77 });
|
|
152
|
+
/* Unstamped: bounded by the last exactly identified frame. */
|
|
153
|
+
present(78);
|
|
154
|
+
expect(frame).toMatchObject({ ut: 999.5, source: "estimated", rtpTimestamp: 77 });
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useEffect, useReducer, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
extrapolateCaptureUt,
|
|
4
|
+
type DisplayedFrameUt,
|
|
5
|
+
type FrameUtSource,
|
|
6
|
+
type CommandTrackEvent,
|
|
7
|
+
} from "@ksp-gonogo/kerbcast";
|
|
8
|
+
import { useKerbcastClient } from "../context";
|
|
9
|
+
import { nowMs } from "../timing";
|
|
10
|
+
|
|
11
|
+
export { MAX_UT_EXTRAPOLATION_MS, extrapolateCaptureUt } from "@ksp-gonogo/kerbcast";
|
|
12
|
+
|
|
13
|
+
const TICK_MS = 500;
|
|
14
|
+
|
|
15
|
+
/** Real milliseconds until the first event after `displayUt`, or `null` when none is ahead. */
|
|
16
|
+
export function msUntilNextEvent(
|
|
17
|
+
events: readonly CommandTrackEvent[] | undefined,
|
|
18
|
+
displayUt: number,
|
|
19
|
+
warpRate: number,
|
|
20
|
+
): number | null {
|
|
21
|
+
if (!events?.length || !(warpRate > 0)) return null;
|
|
22
|
+
let next = Infinity;
|
|
23
|
+
for (const e of events) if (e.ut > displayUt && e.ut < next) next = e.ut;
|
|
24
|
+
return next === Infinity ? null : ((next - displayUt) / warpRate) * 1000;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether an event's UT lies in `(from, to]`, i.e. moving the display from `from` to `to` changes the state in force. */
|
|
28
|
+
export function crossesEvent(
|
|
29
|
+
events: readonly CommandTrackEvent[] | undefined,
|
|
30
|
+
from: number,
|
|
31
|
+
to: number,
|
|
32
|
+
): boolean {
|
|
33
|
+
if (!events?.length) return false;
|
|
34
|
+
const lo = Math.min(from, to);
|
|
35
|
+
const hi = Math.max(from, to);
|
|
36
|
+
return events.some((e) => e.ut > lo && e.ut <= hi);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The UT a command feed is showing and where it came from; `"override"` is a host's own value. */
|
|
40
|
+
export interface CommandDisplayUt {
|
|
41
|
+
ut: number;
|
|
42
|
+
source: FrameUtSource | "override";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The UT a command feed is showing. An explicit `override` (a host with its
|
|
47
|
+
* own delayed playout) wins. Otherwise, given the feed's `flightId` and the
|
|
48
|
+
* `video` element showing it, the presented frame's own capture stamp is
|
|
49
|
+
* used (`source: "exact"`); without one, the client's capture clock is
|
|
50
|
+
* interpolated to now (`"estimated"`). Re-evaluated twice a second while
|
|
51
|
+
* `enabled` so readout ages keep counting, and again the moment the
|
|
52
|
+
* displayed UT reaches the next of `events`, so a state change lands on its
|
|
53
|
+
* frame rather than on the next tick. `null` when nothing is known.
|
|
54
|
+
*/
|
|
55
|
+
export function useCommandDisplayUt(
|
|
56
|
+
enabled: boolean,
|
|
57
|
+
override?: number,
|
|
58
|
+
events?: readonly CommandTrackEvent[],
|
|
59
|
+
flightId?: number | null,
|
|
60
|
+
video?: HTMLVideoElement | null,
|
|
61
|
+
): CommandDisplayUt | null {
|
|
62
|
+
const client = useKerbcastClient();
|
|
63
|
+
const sample = useRef<{ captureUt: number | null; warpRate: number; at: number }>({
|
|
64
|
+
captureUt: client.clock.captureUt,
|
|
65
|
+
warpRate: client.clock.warpRate,
|
|
66
|
+
at: nowMs(),
|
|
67
|
+
});
|
|
68
|
+
const frame = useRef<DisplayedFrameUt | null>(null);
|
|
69
|
+
const shown = useRef<CommandDisplayUt | null>(null);
|
|
70
|
+
const eventsRef = useRef(events);
|
|
71
|
+
eventsRef.current = events;
|
|
72
|
+
const [, tick] = useReducer((c: number) => c + 1, 0);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const take = () => {
|
|
76
|
+
const { captureUt, warpRate } = client.clock;
|
|
77
|
+
sample.current = { captureUt, warpRate, at: nowMs() };
|
|
78
|
+
};
|
|
79
|
+
take();
|
|
80
|
+
return client.on("settings-change", take);
|
|
81
|
+
}, [client]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!enabled) return;
|
|
85
|
+
const interval = setInterval(tick, TICK_MS);
|
|
86
|
+
return () => clearInterval(interval);
|
|
87
|
+
}, [enabled]);
|
|
88
|
+
|
|
89
|
+
/* Exact frames re-render only when they change what is drawn: a switch of
|
|
90
|
+
source, or a step across an event. Ages catch up on the tick. */
|
|
91
|
+
const overriding = override !== undefined;
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
frame.current = null;
|
|
94
|
+
if (!enabled || overriding || flightId == null || !video) return;
|
|
95
|
+
return client.watchDisplayedFrames(flightId, video, (f) => {
|
|
96
|
+
frame.current = f.source === "exact" ? f : null;
|
|
97
|
+
const prev = shown.current;
|
|
98
|
+
if (!frame.current) {
|
|
99
|
+
if (prev?.source === "exact") tick();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
prev === null ||
|
|
104
|
+
prev.source !== "exact" ||
|
|
105
|
+
crossesEvent(eventsRef.current, prev.ut, f.ut)
|
|
106
|
+
) {
|
|
107
|
+
tick();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}, [client, enabled, overriding, flightId, video]);
|
|
111
|
+
|
|
112
|
+
const { captureUt, warpRate, at } = sample.current;
|
|
113
|
+
const exact = frame.current;
|
|
114
|
+
const displayUt: CommandDisplayUt | null = overriding
|
|
115
|
+
? { ut: override, source: "override" }
|
|
116
|
+
: exact
|
|
117
|
+
? { ut: exact.ut, source: "exact" }
|
|
118
|
+
: captureUt === null
|
|
119
|
+
? null
|
|
120
|
+
: { ut: extrapolateCaptureUt(captureUt, warpRate, at, nowMs()), source: "estimated" };
|
|
121
|
+
shown.current = displayUt;
|
|
122
|
+
const untilEdge =
|
|
123
|
+
enabled && displayUt?.source === "estimated"
|
|
124
|
+
? msUntilNextEvent(events, displayUt.ut, warpRate)
|
|
125
|
+
: null;
|
|
126
|
+
|
|
127
|
+
/* Edges further out than a tick are caught by a later render's timer. */
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (untilEdge === null || untilEdge > TICK_MS) return;
|
|
130
|
+
const timer = setTimeout(tick, Math.max(0, untilEdge) + 1);
|
|
131
|
+
return () => clearTimeout(timer);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return displayUt;
|
|
135
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { DisplayedFrameUt } from "@ksp-gonogo/kerbcast";
|
|
3
|
+
import { useKerbcastClient } from "../context";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mission time of the frame `video` is showing for camera `flightId`,
|
|
7
|
+
* updated on every presented frame (so the calling component re-renders at
|
|
8
|
+
* the video's frame rate). `source` says whether the UT is the frame's own
|
|
9
|
+
* capture stamp (`"exact"`) or the capture clock extrapolated
|
|
10
|
+
* (`"estimated"`). `null` until the first frame with a known UT, and while
|
|
11
|
+
* either argument is null. For per-frame work without re-rendering, use
|
|
12
|
+
* `client.watchDisplayedFrames` directly.
|
|
13
|
+
*/
|
|
14
|
+
export function useDisplayedFrameUt(
|
|
15
|
+
flightId: number | null,
|
|
16
|
+
video: HTMLVideoElement | null,
|
|
17
|
+
): DisplayedFrameUt | null {
|
|
18
|
+
const client = useKerbcastClient();
|
|
19
|
+
const [frame, setFrame] = useState<DisplayedFrameUt | null>(null);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
setFrame(null);
|
|
22
|
+
if (flightId === null || !video) return;
|
|
23
|
+
return client.watchDisplayedFrames(flightId, video, setFrame);
|
|
24
|
+
}, [client, flightId, video]);
|
|
25
|
+
return frame;
|
|
26
|
+
}
|
|
@@ -11,7 +11,7 @@ import { useRef } from "react";
|
|
|
11
11
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
12
|
|
|
13
13
|
import { KerbcastProvider, type KerbcastDisplaySizes } from "../context";
|
|
14
|
-
import { useReportDisplaySize } from "./useReportDisplaySize";
|
|
14
|
+
import { displayReportSize, useReportDisplaySize } from "./useReportDisplaySize";
|
|
15
15
|
|
|
16
16
|
// A controllable ResizeObserver: captures the callback so a test can fire a
|
|
17
17
|
// resize with a chosen contentRect.
|
|
@@ -89,9 +89,15 @@ describe("useReportDisplaySize", () => {
|
|
|
89
89
|
<Harness flightId={7} />
|
|
90
90
|
</KerbcastProvider>,
|
|
91
91
|
);
|
|
92
|
-
// 40x30 ->
|
|
92
|
+
// 40x30 -> width ceiled to a 16-multiple (48), height at the box's 4:3 (36), reported without advancing timers.
|
|
93
93
|
fireResize(40, 30);
|
|
94
|
-
expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h:
|
|
94
|
+
expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 36 }]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps the box's aspect so the frame fills it", () => {
|
|
98
|
+
expect(displayReportSize(441, 246)).toEqual({ width: 448, height: 250 });
|
|
99
|
+
expect(displayReportSize(372, 208)).toEqual({ width: 384, height: 214 });
|
|
100
|
+
expect(displayReportSize(1024, 576)).toEqual({ width: 1024, height: 576 });
|
|
95
101
|
});
|
|
96
102
|
|
|
97
103
|
it("reports a square (min edge) when square is set", () => {
|
|
@@ -181,6 +187,6 @@ describe("useReportDisplaySize", () => {
|
|
|
181
187
|
expect(resizeCallback).toBeUndefined();
|
|
182
188
|
rerender(view(true));
|
|
183
189
|
fireResize(40, 30);
|
|
184
|
-
expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h:
|
|
190
|
+
expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 36 }]);
|
|
185
191
|
});
|
|
186
192
|
});
|