@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.
@@ -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 -> ceil to a 16-multiple = 48x32, reported without advancing timers.
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: 32 }]);
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: 32 }]);
190
+ expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 36 }]);
185
191
  });
186
192
  });