@ksp-gonogo/kerbcast-react 1.10.2 → 1.11.0-rc.10

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,259 @@
1
+ import { effectiveCommandTrack, type CommandTrackState } from "@ksp-gonogo/kerbcast";
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import {
5
+ CommandBlank,
6
+ CommandBrackets,
7
+ bracketGeometry,
8
+ containedContentRect,
9
+ type CommandBracketContext,
10
+ type CommandBracketOverlay,
11
+ } from "./CommandBrackets";
12
+ import {
13
+ MAX_UT_EXTRAPOLATION_MS,
14
+ extrapolateCaptureUt,
15
+ msUntilNextEvent,
16
+ } from "./hooks/useCommandDisplayUt";
17
+
18
+ afterEach(cleanup);
19
+
20
+ function track(overrides: Partial<CommandTrackState> = {}): CommandTrackState {
21
+ return {
22
+ ut: 1000,
23
+ hasFix: true,
24
+ rangeM: 4200,
25
+ occluded: false,
26
+ occluder: "",
27
+ sunlit: true,
28
+ bracketHalfPx: 20,
29
+ frameHeightPx: 576,
30
+ framingFits: true,
31
+ events: [],
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ const AUTHORITY = { owner: "gonogo" };
37
+
38
+ describe("containedContentRect", () => {
39
+ it("letterboxes wide media in a tall box", () => {
40
+ expect(containedContentRect(400, 400, 1600, 900)).toEqual({
41
+ x: 0,
42
+ y: 87.5,
43
+ width: 400,
44
+ height: 225,
45
+ });
46
+ });
47
+
48
+ it("pillarboxes tall media in a wide box", () => {
49
+ expect(containedContentRect(800, 300, 400, 400)).toEqual({
50
+ x: 250,
51
+ y: 0,
52
+ width: 300,
53
+ height: 300,
54
+ });
55
+ });
56
+
57
+ it("uses the whole box while the media size is unknown", () => {
58
+ expect(containedContentRect(640, 360, 0, 0)).toEqual({ x: 0, y: 0, width: 640, height: 360 });
59
+ });
60
+ });
61
+
62
+ describe("bracketGeometry", () => {
63
+ const rect = { x: 10, y: 20, width: 1024, height: 288 };
64
+
65
+ it("scales capture px by the displayed height and centres on the frame", () => {
66
+ const g = bracketGeometry(rect, 40, 576);
67
+ expect(g.cx).toBe(522);
68
+ expect(g.cy).toBe(164);
69
+ expect(g.half).toBe(20);
70
+ expect(g.arm).toBeCloseTo(12);
71
+ });
72
+
73
+ it("never draws a box smaller than 14 display px half-size", () => {
74
+ const g = bracketGeometry(rect, 2, 576);
75
+ expect(g.half).toBe(14);
76
+ expect(g.arm).toBeCloseTo(8.4);
77
+ expect(bracketGeometry(rect, null, 576).half).toBe(14);
78
+ });
79
+
80
+ it("reports both corners around the centre", () => {
81
+ const g = bracketGeometry(rect, 40, 576);
82
+ expect(g.topLeft).toEqual({ x: 502, y: 144 });
83
+ expect(g.bottomRight).toEqual({ x: 542, y: 184 });
84
+ });
85
+ });
86
+
87
+ describe("extrapolateCaptureUt", () => {
88
+ it("advances at the warp rate and stops at the cap", () => {
89
+ expect(extrapolateCaptureUt(100, 4, 1000, 1500)).toBe(102);
90
+ expect(extrapolateCaptureUt(100, 1, 0, MAX_UT_EXTRAPOLATION_MS * 10)).toBe(
91
+ 100 + MAX_UT_EXTRAPOLATION_MS / 1000,
92
+ );
93
+ expect(extrapolateCaptureUt(100, 1, 1000, 500)).toBe(100);
94
+ });
95
+ });
96
+
97
+ describe("msUntilNextEvent", () => {
98
+ const ev = (ut: number) => ({ ut, occluded: false, occluder: "", sunlit: true });
99
+ it("times the first event after the displayed UT at the warp rate", () => {
100
+ expect(msUntilNextEvent([ev(99), ev(103), ev(101)], 100, 2)).toBe(500);
101
+ expect(msUntilNextEvent([ev(99)], 100, 1)).toBeNull();
102
+ expect(msUntilNextEvent([], 100, 1)).toBeNull();
103
+ expect(msUntilNextEvent([ev(101)], 100, 0)).toBeNull();
104
+ });
105
+ });
106
+
107
+ function renderBrackets(
108
+ t: CommandTrackState,
109
+ displayUt = 1000,
110
+ overlay?: CommandBracketOverlay,
111
+ ) {
112
+ return render(
113
+ <CommandBrackets
114
+ state={effectiveCommandTrack(t, displayUt)}
115
+ displayUt={displayUt}
116
+ utSource="override"
117
+ video={null}
118
+ fallbackSize={{ width: 1024, height: 576 }}
119
+ overlay={overlay}
120
+ />,
121
+ );
122
+ }
123
+
124
+ function corner(container: HTMLElement, which: "topLeft" | "bottomRight") {
125
+ return container.querySelector(`path[data-corner="${which}"]`);
126
+ }
127
+
128
+ describe("CommandBrackets", () => {
129
+ it("draws only the two corners, solid, when in view", () => {
130
+ const { container } = renderBrackets(track());
131
+ expect(container.querySelectorAll("path")).toHaveLength(2);
132
+ expect(corner(container, "topLeft")!.getAttribute("stroke-dasharray")).toBeNull();
133
+ expect(corner(container, "bottomRight")!.getAttribute("stroke-dasharray")).toBeNull();
134
+ expect(screen.getByText("IN VIEW · 4.2 km")).toBeTruthy();
135
+ });
136
+
137
+ it("dashes both corners when occluded", () => {
138
+ const { container } = renderBrackets(track({ occluded: true, occluder: "Mun" }));
139
+ expect(corner(container, "topLeft")!.getAttribute("stroke-dasharray")).toBe("3 2");
140
+ expect(corner(container, "bottomRight")!.getAttribute("stroke-dasharray")).toBe("3 2");
141
+ expect(screen.getByText("OCCLUDED MUN · 4.2 km")).toBeTruthy();
142
+ });
143
+
144
+ it("mirrors the camera corner under an authority too", () => {
145
+ const { container } = renderBrackets(
146
+ track({ occluded: true, occluder: "Mun", authority: AUTHORITY }),
147
+ );
148
+ expect(corner(container, "bottomRight")!.getAttribute("stroke-dasharray")).toBe("3 2");
149
+ expect(screen.getByText("OCCLUDED MUN · 4.2 km")).toBeTruthy();
150
+ });
151
+
152
+ it("draws no box without a fix, only the readout", () => {
153
+ const { container } = renderBrackets(track({ hasFix: false, rangeM: undefined }));
154
+ expect(container.querySelector("svg")).toBeNull();
155
+ expect(screen.getByText("NO FIX")).toBeTruthy();
156
+ });
157
+ });
158
+
159
+ describe("CommandBrackets overlay", () => {
160
+ it("still runs the overlay and readout without a fix, with no default corners", () => {
161
+ const { container } = renderBrackets(track({ hasFix: false, rangeM: undefined }), 1000, {
162
+ render: (ctx) => (ctx.state.hasFix ? null : <circle data-testid="held" r={3} />),
163
+ readout: (text) => `${text} · HELD`,
164
+ });
165
+ expect(container.querySelectorAll("path")).toHaveLength(0);
166
+ expect(screen.getByTestId("held").closest("svg")).toBe(container.querySelector("svg"));
167
+ expect(screen.getByText("NO FIX · HELD")).toBeTruthy();
168
+ });
169
+
170
+ it("runs over a blanked feed with an empty default readout", () => {
171
+ let readoutDefault: string | null = null;
172
+ const { container } = renderBrackets(track({ blank: "Lost" }), 1000, {
173
+ render: (ctx) => (ctx.state.blank ? <circle data-testid="held" r={3} /> : null),
174
+ readout: (text) => {
175
+ readoutDefault = text;
176
+ return "HELD 2m";
177
+ },
178
+ });
179
+ expect(readoutDefault).toBe("");
180
+ expect(container.querySelectorAll("path")).toHaveLength(0);
181
+ expect(screen.getByTestId("held")).toBeTruthy();
182
+ expect(screen.getByText("HELD 2m")).toBeTruthy();
183
+ });
184
+
185
+ it("draws nothing over a blanked feed without an overlay", () => {
186
+ const { container } = renderBrackets(track({ blank: "Lost" }));
187
+ expect(container.querySelector("svg")).toBeNull();
188
+ expect(container.textContent).toBe("");
189
+ });
190
+
191
+ it("renders inside the bracket SVG with the geometry, UT and state", () => {
192
+ let seen: CommandBracketContext | null = null;
193
+ const { container } = renderBrackets(track({ authority: AUTHORITY }), 1234, {
194
+ render: (ctx) => {
195
+ seen = ctx;
196
+ const { x, y } = ctx.geometry.bottomRight;
197
+ return <rect data-testid="mark" x={x - 6} y={y - 6} width={4} height={4} />;
198
+ },
199
+ });
200
+ const ctx = seen as CommandBracketContext | null;
201
+ expect(ctx).not.toBeNull();
202
+ expect(ctx!.displayUt).toBe(1234);
203
+ expect(ctx!.utSource).toBe("override");
204
+ expect(ctx!.state.authority).toEqual(AUTHORITY);
205
+ expect(ctx!.geometry.stroke).toBe(2);
206
+ expect(ctx!.geometry.bottomRight.x).toBe(ctx!.geometry.cx + ctx!.geometry.half);
207
+ const path = corner(container, "bottomRight")!.getAttribute("d")!;
208
+ const { x, y } = ctx!.geometry.bottomRight;
209
+ expect(path).toContain(`L${x} ${y}`);
210
+ const mark = screen.getByTestId("mark");
211
+ expect(mark.closest("svg")).toBe(container.querySelector("svg"));
212
+ expect(mark.getAttribute("x")).toBe(String(x - 6));
213
+ });
214
+
215
+ it("applies static corner overrides", () => {
216
+ const { container } = renderBrackets(track(), 1000, {
217
+ corners: { bottomRight: { colour: "rgb(1, 2, 3)", dashed: true } },
218
+ });
219
+ const br = corner(container, "bottomRight") as SVGPathElement;
220
+ expect(br.getAttribute("stroke-dasharray")).toBe("3 2");
221
+ expect(br.style.stroke).toBe("rgb(1, 2, 3)");
222
+ expect(corner(container, "topLeft")!.getAttribute("stroke-dasharray")).toBeNull();
223
+ });
224
+
225
+ it("hides a corner and takes overrides from a function of the context", () => {
226
+ const { container } = renderBrackets(track({ occluded: true, occluder: "Mun" }), 1000, {
227
+ corners: (ctx) => ({
228
+ bottomRight: { hidden: true },
229
+ topLeft: { dashed: !ctx.state.occluded },
230
+ }),
231
+ });
232
+ expect(corner(container, "bottomRight")).toBeNull();
233
+ expect(corner(container, "topLeft")!.getAttribute("stroke-dasharray")).toBeNull();
234
+ });
235
+
236
+ it("extends the readout with the displayed UT", () => {
237
+ renderBrackets(track(), 1060, {
238
+ readout: (text, ctx) => `${text} · AGE ${ctx.displayUt - 1000}s`,
239
+ });
240
+ expect(screen.getByText("IN VIEW · 4.2 km · AGE 60s")).toBeTruthy();
241
+ });
242
+
243
+ it("replaces the readout, or hides it with null", () => {
244
+ renderBrackets(track(), 1000, { readout: () => "CUSTOM" });
245
+ expect(screen.getByText("CUSTOM")).toBeTruthy();
246
+ cleanup();
247
+ renderBrackets(track(), 1000, { readout: () => null });
248
+ expect(screen.queryByText(/IN VIEW/)).toBeNull();
249
+ });
250
+ });
251
+
252
+ describe("CommandBlank", () => {
253
+ it("labels the blank with its reason", () => {
254
+ render(<CommandBlank reason="Waiting for gonogo" />);
255
+ expect(screen.getByRole("status", { name: "Feed blanked" }).textContent).toBe(
256
+ "Waiting for gonogo",
257
+ );
258
+ });
259
+ });
@@ -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
+ `;
@@ -177,12 +177,12 @@ export function KerbalFaceFeed({
177
177
  // Reporting follows the mount lifecycle; the registry collapses multiple
178
178
  // feeds of the same kerbal to one MAX report.
179
179
  const frameRef = useRef<HTMLDivElement>(null);
180
- useReportDisplaySize(flightId, frameRef, { square: true, enabled: reportSize });
180
+ const videoRef = useRef<HTMLVideoElement>(null);
181
+ useReportDisplaySize(flightId, frameRef, { square: true, enabled: reportSize, videoRef });
181
182
 
182
183
  // Bind the stream to the persistent <video> without remounting it — the
183
184
  // element stays mounted across every re-layout / prop change, only its
184
185
  // srcObject is (re)assigned. A stall (stream -> null) leaves the last frame.
185
- const videoRef = useRef<HTMLVideoElement>(null);
186
186
  useEffect(() => {
187
187
  if (videoRef.current && stream) {
188
188
  videoRef.current.srcObject = stream;