@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.
@@ -1,22 +1,110 @@
1
- import { useEffect, useId, useLayoutEffect, useState } from "react";
1
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { useKerbcastDisplaySizes } from "../context";
3
3
 
4
4
  /*
5
5
  * Debounce + bucket for self-measured display-size reporting. The first
6
6
  * measurement after mount reports promptly (so a feed never sits at a stale
7
- * resolution until it is resized); later churn is debounced. Sizes are ceiled
8
- * to a BUCKET_PX multiple: that meets-the-minimum-need (never under-serve),
9
- * yields an even value (H.264-friendly), and gives a material-change threshold
10
- * so a feed re-reports only when a resize crosses a bucket boundary.
7
+ * resolution until it is resized); later churn is debounced. Auto sets the
8
+ * resolution, never the shape: the request keeps the camera's configured
9
+ * aspect and is sized to the picture of that aspect that fits the box (the
10
+ * feed letterboxes with object-fit: contain). The width is ceiled to a
11
+ * BUCKET_PX multiple: that meets-the-minimum-need (never under-serve) and
12
+ * gives a material-change threshold so a feed re-reports only when a resize
13
+ * crosses a bucket boundary. The height follows at the aspect, rounded to
14
+ * even for H.264.
11
15
  */
12
16
  const DEBOUNCE_MS = 200;
13
17
  const BUCKET_PX = 16;
14
18
 
19
+ /*
20
+ * A resolution change must never cause another one. A box sized from the
21
+ * stream (a frame kept at the video's aspect, a `<video>` at its intrinsic
22
+ * size) would otherwise close a loop: the stream sets the box, the box sets
23
+ * the request, the request sets the stream. A stream that arrives smaller
24
+ * than asked (adaptive quality, a sidecar cap) would then shrink the next
25
+ * request, and so on down.
26
+ *
27
+ * FOLLOW_MS: a box change that starts this soon after the stream changed
28
+ * shape, and matches what that change explains, is the stream's doing. It is
29
+ * adopted as the new baseline and not reported. Layout reacts to a stream
30
+ * change within a frame or two; this leaves room for a slow phone.
31
+ *
32
+ * SUSPECT_MS / HOLD_MS: the backstop for a follow the check above misses (a
33
+ * very late layout, or one that does not keep the stream's shape). A box
34
+ * change this close to a stream change may still be a loop, so only one such
35
+ * report is let through per HOLD_MS; further ones are refused. Box changes
36
+ * away from any stream change always report.
37
+ */
38
+ const FOLLOW_MS = 1000;
39
+ const SUSPECT_MS = 2500;
40
+ const HOLD_MS = 5000;
41
+ const ASPECT_TOLERANCE = 0.06;
42
+ const MAGNITUDE_SLACK = 1.15;
43
+
15
44
  function bucket(px: number): number {
16
45
  const b = Math.ceil(px / BUCKET_PX) * BUCKET_PX;
17
46
  return b < BUCKET_PX ? BUCKET_PX : b;
18
47
  }
19
48
 
49
+ /** Shape of a camera whose configured capture size is not known. */
50
+ export const DEFAULT_FEED_ASPECT = 16 / 9;
51
+
52
+ /**
53
+ * Report size for a `width` x `height` box: the `aspect` picture that fits it,
54
+ * width bucketed. Never the box's own shape.
55
+ */
56
+ export function displayReportSize(
57
+ width: number,
58
+ height: number,
59
+ aspect: number = DEFAULT_FEED_ASPECT,
60
+ ): { width: number; height: number } {
61
+ const a = aspect > 0 && Number.isFinite(aspect) ? aspect : DEFAULT_FEED_ASPECT;
62
+ const fit = width > 0 && height > 0 ? Math.min(width, height * a) : width;
63
+ const w = bucket(fit);
64
+ return { width: w, height: Math.max(2, Math.round(w / a / 2) * 2) };
65
+ }
66
+
67
+ export interface Size {
68
+ width: number;
69
+ height: number;
70
+ }
71
+
72
+ /** The stream changing from `from` (null when nothing was known before) to `to`. */
73
+ export interface StreamChange {
74
+ from: Size | null;
75
+ to: Size;
76
+ }
77
+
78
+ function spread(a: number, b: number): number {
79
+ return a > b ? a / b : b / a;
80
+ }
81
+
82
+ /**
83
+ * Whether a box going from `prev` to `box` is explained by `change`: the new
84
+ * box has the new stream's aspect, and it grew or shrank no more than the
85
+ * stream's own change in size or aspect accounts for. A layout that fits the
86
+ * stream into a fixed tile changes with the aspect; one that shows the video
87
+ * at its own size changes with the size. A box that jumps further (entering
88
+ * fullscreen at the same aspect) is a layout change.
89
+ */
90
+ export function followsStream(prev: Size, box: Size, change: StreamChange): boolean {
91
+ const { to, from } = change;
92
+ if (!(to.width > 0) || !(to.height > 0)) return false;
93
+ if (!(prev.width > 0) || !(prev.height > 0)) return false;
94
+ const aspect = to.width / to.height;
95
+ const heightFits =
96
+ Math.abs(box.height - box.width / aspect) <= Math.max(2, ASPECT_TOLERANCE * box.height);
97
+ const widthFits =
98
+ Math.abs(box.width - box.height * aspect) <= Math.max(2, ASPECT_TOLERANCE * box.width);
99
+ if (!heightFits && !widthFits) return false;
100
+ if (!from) return true;
101
+ const explained = Math.max(
102
+ spread(to.width * to.height, from.width * from.height),
103
+ spread(aspect, from.width / from.height),
104
+ );
105
+ return spread(box.width * box.height, prev.width * prev.height) <= explained * MAGNITUDE_SLACK;
106
+ }
107
+
20
108
  export interface ReportDisplaySizeOptions {
21
109
  /**
22
110
  * Report while mounted. Default true. Set false for a fixed-resolution feed
@@ -25,10 +113,29 @@ export interface ReportDisplaySizeOptions {
25
113
  */
26
114
  enabled?: boolean;
27
115
  /**
28
- * Report a square (side x side, the min edge) rather than the raw w x h.
29
- * Face cams are square; part cams are not.
116
+ * Report a square (side x side, the min edge) rather than a picture at
117
+ * `aspect`. Face cams are square; part cams are not.
30
118
  */
31
119
  square?: boolean;
120
+ /**
121
+ * Width over height of the picture to ask for, normally the camera's
122
+ * configured capture size. Default 16:9. Ignored when `square` is set.
123
+ */
124
+ aspect?: number;
125
+ /**
126
+ * The `<video>` playing this camera. Its resolution changes are watched so a
127
+ * box resize that only follows the stream is not reported back.
128
+ */
129
+ videoRef?: React.RefObject<HTMLVideoElement | null>;
130
+ /**
131
+ * The size the sidecar says it is rendering this camera at, for the same
132
+ * purpose: a layout may size itself from this rather than from the video.
133
+ */
134
+ streamSize?: Size | null;
135
+ }
136
+
137
+ interface TimedChange extends StreamChange {
138
+ at: number;
32
139
  }
33
140
 
34
141
  /**
@@ -40,26 +147,92 @@ export interface ReportDisplaySizeOptions {
40
147
  * mount lifecycle: on unmount / `flightId` change / `enabled` flip it
41
148
  * deregisters, and the sidecar's clear-on-departure (unsubscribe) does the
42
149
  * server-side clear.
150
+ *
151
+ * A box resize that follows a change in the incoming stream is not reported,
152
+ * so a layout sized from the stream cannot feed it back into its own request.
43
153
  */
44
154
  export function useReportDisplaySize(
45
155
  flightId: number | null,
46
156
  ref: React.RefObject<Element | null>,
47
- { enabled = true, square = false }: ReportDisplaySizeOptions = {},
157
+ {
158
+ enabled = true,
159
+ square = false,
160
+ aspect = DEFAULT_FEED_ASPECT,
161
+ videoRef,
162
+ streamSize,
163
+ }: ReportDisplaySizeOptions = {},
48
164
  ): void {
49
165
  const displaySizes = useKerbcastDisplaySizes();
50
166
  const instanceId = useId();
51
167
 
52
168
  /*
53
- * Track the element behind the ref as state so an element that mounts or is
54
- * swapped after the first render (conditional render, a feed that appears
55
- * once a stream arrives) is observed. Checked after every commit of the
56
- * host component; the state only changes when the element does.
169
+ * Track the elements behind the refs as state so an element that mounts or
170
+ * is swapped after the first render (conditional render, a feed that appears
171
+ * once a stream arrives) is observed. Checked after every commit of the host
172
+ * component; the state only changes when the element does.
57
173
  */
58
174
  const [el, setEl] = useState<Element | null>(null);
175
+ const [video, setVideo] = useState<HTMLVideoElement | null>(null);
59
176
  useLayoutEffect(() => {
60
177
  if (ref.current !== el) setEl(ref.current);
178
+ const v = videoRef?.current ?? null;
179
+ if (v !== video) setVideo(v);
61
180
  });
62
181
 
182
+ const changes = useRef<TimedChange[]>([]);
183
+ const lastStream = useRef<Size | null>(null);
184
+ const lastBySource = useRef<Record<"video" | "render", Size | null>>({ video: null, render: null });
185
+
186
+ /*
187
+ * Each source is compared with its own last value, so the video and the
188
+ * sidecar's render size reporting the same new shape at different moments
189
+ * are both noted. The first render size is only a baseline: a layout built
190
+ * from it was already in place when the first measurement went out.
191
+ */
192
+ const noteStream = useCallback((source: "video" | "render", width: number, height: number) => {
193
+ if (!(width > 0) || !(height > 0)) return;
194
+ const seen = lastBySource.current[source];
195
+ if (seen && seen.width === width && seen.height === height) return;
196
+ const to = { width, height };
197
+ lastBySource.current[source] = to;
198
+ const from = lastStream.current;
199
+ lastStream.current = to;
200
+ if (!from && source === "render") return;
201
+ const now = Date.now();
202
+ changes.current = changes.current.filter((c) => now - c.at <= SUSPECT_MS + DEBOUNCE_MS);
203
+ changes.current.push({ at: now, from, to });
204
+ }, []);
205
+
206
+ useEffect(() => {
207
+ if (!video) return;
208
+ const sync = () => noteStream("video", video.videoWidth, video.videoHeight);
209
+ sync();
210
+ video.addEventListener("loadedmetadata", sync);
211
+ video.addEventListener("resize", sync);
212
+ return () => {
213
+ video.removeEventListener("loadedmetadata", sync);
214
+ video.removeEventListener("resize", sync);
215
+ };
216
+ }, [video, noteStream]);
217
+
218
+ /* A layout effect, so it is noted before the observer sees the box this commit lays out. */
219
+ const streamW = streamSize?.width ?? 0;
220
+ const streamH = streamSize?.height ?? 0;
221
+ useLayoutEffect(() => {
222
+ noteStream("render", streamW, streamH);
223
+ }, [streamW, streamH, noteStream]);
224
+
225
+ /*
226
+ * The aspect is read through a ref so the camera's configured size arriving
227
+ * after mount re-sizes the current request without a clear and re-register.
228
+ */
229
+ const aspectRef = useRef(aspect);
230
+ const reapply = useRef<(() => void) | null>(null);
231
+ useEffect(() => {
232
+ aspectRef.current = aspect;
233
+ reapply.current?.();
234
+ }, [aspect]);
235
+
63
236
  useEffect(() => {
64
237
  if (flightId === null || !enabled) return;
65
238
  if (!el) return;
@@ -68,32 +241,68 @@ export function useReportDisplaySize(
68
241
  let lastW = -1;
69
242
  let lastH = -1;
70
243
  let firstReported = false;
244
+ let baseline: Size | null = null;
245
+ let lastSuspectAt = Number.NEGATIVE_INFINITY;
246
+
247
+ const sizeFor = (box: Size): Size => {
248
+ if (!square) return displayReportSize(box.width, box.height, aspectRef.current);
249
+ const side = bucket(Math.min(box.width, box.height));
250
+ return { width: side, height: side };
251
+ };
71
252
 
72
- const report = (width: number, height: number) => {
73
- const side = square ? Math.min(width, height) : 0;
74
- const bw = bucket(square ? side : width);
75
- const bh = bucket(square ? side : height);
76
- if (bw === lastW && bh === lastH) return;
77
- lastW = bw;
78
- lastH = bh;
79
- displaySizes.report(flightId, instanceId, bw, bh);
253
+ const send = ({ width, height }: Size) => {
254
+ lastW = width;
255
+ lastH = height;
256
+ displaySizes.report(flightId, instanceId, width, height);
257
+ };
258
+
259
+ /*
260
+ * Judged when the debounce settles, so a stream change noted a little after
261
+ * the observer fired (a host that commits the stream and the box in a
262
+ * different order) still counts.
263
+ */
264
+ const settle = (box: Size, seenAt: number) => {
265
+ const prev = baseline;
266
+ baseline = box;
267
+ const now = Date.now();
268
+ const near = changes.current.filter((c) => c.at <= now && c.at >= seenAt - SUSPECT_MS);
269
+ if (prev && near.some((c) => c.at >= seenAt - FOLLOW_MS && followsStream(prev, box, c))) {
270
+ return;
271
+ }
272
+ const next = sizeFor(box);
273
+ if (next.width === lastW && next.height === lastH) return;
274
+ if (near.length > 0) {
275
+ const held = now - lastSuspectAt < HOLD_MS;
276
+ lastSuspectAt = now;
277
+ if (held) return;
278
+ }
279
+ send(next);
80
280
  };
81
281
 
82
282
  const observer = new ResizeObserver((entries) => {
83
283
  const entry = entries[0];
84
284
  if (!entry) return;
85
- const { width, height } = entry.contentRect;
285
+ const box = { width: entry.contentRect.width, height: entry.contentRect.height };
86
286
  if (!firstReported) {
87
287
  firstReported = true;
88
- report(width, height);
288
+ baseline = box;
289
+ const next = sizeFor(box);
290
+ if (next.width !== lastW || next.height !== lastH) send(next);
89
291
  return;
90
292
  }
293
+ const seenAt = Date.now();
91
294
  if (timer !== null) clearTimeout(timer);
92
- timer = setTimeout(() => report(width, height), DEBOUNCE_MS);
295
+ timer = setTimeout(() => settle(box, seenAt), DEBOUNCE_MS);
93
296
  });
94
297
  observer.observe(el);
298
+ reapply.current = () => {
299
+ if (!baseline) return;
300
+ const next = sizeFor(baseline);
301
+ if (next.width !== lastW || next.height !== lastH) send(next);
302
+ };
95
303
 
96
304
  return () => {
305
+ reapply.current = null;
97
306
  observer.disconnect();
98
307
  if (timer !== null) clearTimeout(timer);
99
308
  displaySizes.clear(flightId, instanceId);
@@ -0,0 +1,38 @@
1
+ import { useCallback, useMemo, useState } from "react";
2
+
3
+ const STORAGE_PREFIX = "kerbcast:target-brackets:";
4
+
5
+ function readPreference(key: string): boolean {
6
+ try {
7
+ return globalThis.localStorage?.getItem(STORAGE_PREFIX + key) !== "0";
8
+ } catch {
9
+ return true;
10
+ }
11
+ }
12
+
13
+ function writePreference(key: string, on: boolean): void {
14
+ try {
15
+ if (on) globalThis.localStorage?.removeItem(STORAGE_PREFIX + key);
16
+ else globalThis.localStorage?.setItem(STORAGE_PREFIX + key, "0");
17
+ } catch {
18
+ /* Storage blocked or full: the toggle still works for this session. */
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Whether target brackets are shown for one command camera, remembered per
24
+ * camera in this browser. Defaults on. `key` is null for a feed with no
25
+ * command camera, which reads as on and ignores toggles.
26
+ */
27
+ export function useTargetBracketsPreference(key: string | null): [boolean, () => void] {
28
+ const [overrides, setOverrides] = useState<Record<string, boolean>>({});
29
+ const stored = useMemo(() => (key === null ? true : readPreference(key)), [key]);
30
+ const on = key !== null && key in overrides ? overrides[key]! : stored;
31
+ const toggle = useCallback(() => {
32
+ if (key === null) return;
33
+ const next = !on;
34
+ writePreference(key, next);
35
+ setOverrides((o) => ({ ...o, [key]: next }));
36
+ }, [key, on]);
37
+ return [on, toggle];
38
+ }
package/src/index.test.ts CHANGED
@@ -3,8 +3,9 @@ import { describe, it, expect } from "vitest";
3
3
  // Smoke test: the package module resolves and its shape is what consumers
4
4
  // expect. Real component tests land with the CameraFeed extraction.
5
5
  describe("@ksp-gonogo/kerbcast-react", () => {
6
+ /* A cold import transforms the whole package, which can exceed the default timeout on a busy machine. */
6
7
  it("module resolves", async () => {
7
8
  const mod = await import("./index");
8
9
  expect(mod).toBeDefined();
9
- });
10
+ }, 30_000);
10
11
  });
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ export type {
25
25
  export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
26
26
  export { useKerbcastStream } from "./hooks/useKerbcastStream";
27
27
  export { useKerbcastClock } from "./hooks/useKerbcastClock";
28
+ export { useDisplayedFrameUt } from "./hooks/useDisplayedFrameUt";
28
29
  export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
29
30
  export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
30
31
  export { useRecordings } from "./hooks/useRecordings";
@@ -53,6 +54,20 @@ export type {
53
54
  FeedAction,
54
55
  } from "./CameraFeed";
55
56
 
57
+ // Target brackets geometry and the overlay API for drawing on top of them
58
+ export { bracketGeometry, containedContentRect, COMMAND_BRACKET_COLOURS } from "./CommandBrackets";
59
+ export type {
60
+ BracketGeometry,
61
+ BracketPoint,
62
+ CommandBracketContext,
63
+ CommandBracketCorner,
64
+ CommandBracketCornerOverride,
65
+ CommandBracketCornerOverrides,
66
+ CommandBracketCornerStyle,
67
+ CommandBracketOverlay,
68
+ ContentRect,
69
+ } from "./CommandBrackets";
70
+
56
71
  // FeedActionBar: the shared action row (tracking, fullscreen, PiP, quality,
57
72
  // close, REC) both CameraFeed and KerbalFaceFeed render, with the issue-#6
58
73
  // overflow (⋮) engine.