@ksp-gonogo/kerbcast-react 1.12.0-rc.4 → 1.12.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -181,7 +181,11 @@ quality follows how large it is shown (pass `reportSize={false}` to opt out).
181
181
  feed primitives use it internally; multiple views of one camera from a
182
182
  client collapse to a single MAX report. Pass `aspect` for the shape to ask
183
183
  for (default 16:9), and `videoRef` / `streamSize` so a resize that follows
184
- the stream is not reported.
184
+ the stream is not reported. A request is never under `MIN_DISPLAY_REPORT_PX`
185
+ (128) on either axis: a smaller box asks for that much at the camera's
186
+ shape. An empty box (a hidden or not yet laid out tile) reports nothing.
187
+ When the last feed of a camera empties, the client tells the sidecar it no
188
+ longer needs a size, so the camera stops rendering for a box nobody sees.
185
189
 
186
190
  - `useKerbcastCameras(): CameraState[]`. The live camera registry,
187
191
  re-rendering as cameras appear, change, or are destroyed.
package/dist/context.js CHANGED
@@ -158,11 +158,15 @@ export function createClientDisplaySizes(client) {
158
158
  function recompute(flightId) {
159
159
  const instances = perCamera.get(flightId);
160
160
  if (!instances || instances.size === 0) {
161
- // No reporters left. Drop bookkeeping and send nothing: the subscription
162
- // release (unsubscribe -> sidecar remove_track -> forget_display_size)
163
- // clears the sidecar side. Sending a spurious size here would fight it.
161
+ /*
162
+ * No reporters left, as when the last feed showing the camera is hidden
163
+ * while still subscribed. A 0x0 report is no demand to the sidecar, so
164
+ * the camera stops rendering at a size nobody sees. An unsubscribe that
165
+ * follows clears the same entry, so the two never disagree.
166
+ */
164
167
  perCamera.delete(flightId);
165
- lastSent.delete(flightId);
168
+ if (lastSent.delete(flightId))
169
+ client.reportDisplaySize(flightId, 0, 0).catch(() => { });
166
170
  return;
167
171
  }
168
172
  let maxW = 0;
@@ -1,3 +1,8 @@
1
+ /**
2
+ * The smallest request on either axis: a small box asks for this much, at the
3
+ * camera's shape, because hardware encoders refuse to encode tinier frames.
4
+ */
5
+ export declare const MIN_DISPLAY_REPORT_PX = 128;
1
6
  /** Shape of a camera whose configured capture size is not known. */
2
7
  export declare const DEFAULT_FEED_ASPECT: number;
3
8
  /**
@@ -38,6 +38,13 @@ const SUSPECT_MS = 2500;
38
38
  const HOLD_MS = 5000;
39
39
  const ASPECT_TOLERANCE = 0.06;
40
40
  const MAGNITUDE_SLACK = 1.15;
41
+ /**
42
+ * The smallest request on either axis: a small box asks for this much, at the
43
+ * camera's shape, because hardware encoders refuse to encode tinier frames.
44
+ */
45
+ export const MIN_DISPLAY_REPORT_PX = 128;
46
+ /* A box under this on either axis (hidden, or not yet laid out) is no demand, matching the sidecar. */
47
+ const EMPTY_BOX_PX = 2;
41
48
  function bucket(px) {
42
49
  const b = Math.ceil(px / BUCKET_PX) * BUCKET_PX;
43
50
  return b < BUCKET_PX ? BUCKET_PX : b;
@@ -51,8 +58,15 @@ export const DEFAULT_FEED_ASPECT = 16 / 9;
51
58
  export function displayReportSize(width, height, aspect = DEFAULT_FEED_ASPECT) {
52
59
  const a = aspect > 0 && Number.isFinite(aspect) ? aspect : DEFAULT_FEED_ASPECT;
53
60
  const fit = width > 0 && height > 0 ? Math.min(width, height * a) : width;
54
- const w = bucket(fit);
55
- return { width: w, height: Math.max(2, Math.round(w / a / 2) * 2) };
61
+ let w = bucket(fit);
62
+ if (w < MIN_DISPLAY_REPORT_PX)
63
+ w = bucket(MIN_DISPLAY_REPORT_PX);
64
+ if (w / a < MIN_DISPLAY_REPORT_PX)
65
+ w = bucket(MIN_DISPLAY_REPORT_PX * a);
66
+ return { width: w, height: Math.round(w / a / 2) * 2 };
67
+ }
68
+ function isEmpty(box) {
69
+ return !(box.width >= EMPTY_BOX_PX) || !(box.height >= EMPTY_BOX_PX);
56
70
  }
57
71
  function spread(a, b) {
58
72
  return a > b ? a / b : b / a;
@@ -179,7 +193,7 @@ export function useReportDisplaySize(flightId, ref, { enabled = true, square = f
179
193
  const sizeFor = (box) => {
180
194
  if (!square)
181
195
  return displayReportSize(box.width, box.height, aspectRef.current);
182
- const side = bucket(Math.min(box.width, box.height));
196
+ const side = Math.max(MIN_DISPLAY_REPORT_PX, bucket(Math.min(box.width, box.height)));
183
197
  return { width: side, height: side };
184
198
  };
185
199
  const send = ({ width, height }) => {
@@ -187,6 +201,13 @@ export function useReportDisplaySize(flightId, ref, { enabled = true, square = f
187
201
  lastH = height;
188
202
  displaySizes.report(flightId, instanceId, width, height);
189
203
  };
204
+ const withdraw = () => {
205
+ if (lastW < 0)
206
+ return;
207
+ lastW = -1;
208
+ lastH = -1;
209
+ displaySizes.clear(flightId, instanceId);
210
+ };
190
211
  /*
191
212
  * Judged when the debounce settles, so a stream change noted a little after
192
213
  * the observer fired (a host that commits the stream and the box in a
@@ -200,10 +221,15 @@ export function useReportDisplaySize(flightId, ref, { enabled = true, square = f
200
221
  if (prev && near.some((c) => c.at >= seenAt - FOLLOW_MS && followsStream(prev, box, c))) {
201
222
  return;
202
223
  }
224
+ if (isEmpty(box)) {
225
+ withdraw();
226
+ return;
227
+ }
203
228
  const next = sizeFor(box);
204
229
  if (next.width === lastW && next.height === lastH)
205
230
  return;
206
- if (near.length > 0) {
231
+ /* With nothing reported since a withdraw there is no loop to break. */
232
+ if (near.length > 0 && lastW >= 0) {
207
233
  const held = now - lastSuspectAt < HOLD_MS;
208
234
  lastSuspectAt = now;
209
235
  if (held)
@@ -216,6 +242,9 @@ export function useReportDisplaySize(flightId, ref, { enabled = true, square = f
216
242
  if (!entry)
217
243
  return;
218
244
  const box = { width: entry.contentRect.width, height: entry.contentRect.height };
245
+ /* Until the box is first laid out, every measurement counts as the first, so it reports promptly. */
246
+ if (!firstReported && isEmpty(box))
247
+ return;
219
248
  if (!firstReported) {
220
249
  firstReported = true;
221
250
  baseline = box;
@@ -231,7 +260,7 @@ export function useReportDisplaySize(flightId, ref, { enabled = true, square = f
231
260
  });
232
261
  observer.observe(el);
233
262
  reapply.current = () => {
234
- if (!baseline)
263
+ if (!baseline || isEmpty(baseline))
235
264
  return;
236
265
  const next = sizeFor(baseline);
237
266
  if (next.width !== lastW || next.height !== lastH)
package/dist/index.d.ts CHANGED
@@ -13,7 +13,7 @@ export { useKerbcastClock } from "./hooks/useKerbcastClock";
13
13
  export { useDisplayedFrameUt } from "./hooks/useDisplayedFrameUt";
14
14
  export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
15
15
  export { useKerbcastWaitingForSlot } from "./hooks/useKerbcastWaitingForSlot";
16
- export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
16
+ export { useReportDisplaySize, MIN_DISPLAY_REPORT_PX } from "./hooks/useReportDisplaySize";
17
17
  export { useRecordings } from "./hooks/useRecordings";
18
18
  export type { RecordingsStore, ActiveRecordingInfo } from "./hooks/useRecordings";
19
19
  export { nowMs, formatElapsed } from "./timing";
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ export { useKerbcastClock } from "./hooks/useKerbcastClock";
14
14
  export { useDisplayedFrameUt } from "./hooks/useDisplayedFrameUt";
15
15
  export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
16
16
  export { useKerbcastWaitingForSlot } from "./hooks/useKerbcastWaitingForSlot";
17
- export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
17
+ export { useReportDisplaySize, MIN_DISPLAY_REPORT_PX } from "./hooks/useReportDisplaySize";
18
18
  export { useRecordings } from "./hooks/useRecordings";
19
19
  // Shared elapsed-timer helpers (REC badges, RecGroupBar's toolbar)
20
20
  export { nowMs, formatElapsed } from "./timing";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksp-gonogo/kerbcast-react",
3
- "version": "1.12.0-rc.4",
3
+ "version": "1.12.0-rc.5",
4
4
  "description": "React components and hooks for kerbcast camera feeds.",
5
5
  "license": "MIT",
6
6
  "author": "Jon Pepler",
@@ -45,7 +45,7 @@
45
45
  "access": "public"
46
46
  },
47
47
  "dependencies": {
48
- "@ksp-gonogo/kerbcast": "^1.12.0-rc.4"
48
+ "@ksp-gonogo/kerbcast": "^1.12.0-rc.5"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": "^18 || ^19",
@@ -1043,6 +1043,85 @@ describe("CameraFeed - stall presentation (showStatic)", () => {
1043
1043
  }
1044
1044
  });
1045
1045
 
1046
+ it("data-kc-signal reads none while an auto-reconnect rebuilds the peer, then stalled, then live", async () => {
1047
+ const env = installStallEnv();
1048
+ try {
1049
+ const sidecar = new MockSidecar();
1050
+ sidecar.addCamera(toInit(makeCamera({ flightId: 42, cameraName: "Starboard Cam" })));
1051
+ const client = new KerbcastClient(
1052
+ { host: "h", port: 1, negotiate: (o) => sidecar.negotiate(o), autoReconnect: { initialDelayMs: 1 } },
1053
+ sidecar.createTransport(),
1054
+ );
1055
+ createdClients.push(client);
1056
+ await act(async () => {
1057
+ await client.connect([], { slots: 4 });
1058
+ });
1059
+ await act(async () => {
1060
+ sidecar.open();
1061
+ sidecar.setConnectionState("connected");
1062
+ });
1063
+ const { container } = renderFeed(client, { flightId: 42 });
1064
+ const signal = () => container.querySelector("[data-kc-signal]")?.getAttribute("data-kc-signal");
1065
+ await act(async () => {
1066
+ sidecar.deliverTrack(sidecar.slotMidFor(42) as string, {} as MediaStreamTrack);
1067
+ });
1068
+ env.setNow(1000);
1069
+ await act(async () => {
1070
+ env.fireFrame();
1071
+ env.drawOnce();
1072
+ });
1073
+ expect(signal()).toBe("live");
1074
+
1075
+ const reconnected = new Promise<void>((resolve) => {
1076
+ const off = client.on("reconnect-attempt", () => {
1077
+ off();
1078
+ resolve();
1079
+ });
1080
+ });
1081
+ await act(async () => {
1082
+ sidecar.setConnectionState("failed");
1083
+ });
1084
+ expect(signal()).toBe("none");
1085
+ await act(async () => {
1086
+ await reconnected;
1087
+ });
1088
+ expect(signal()).toBe("none");
1089
+
1090
+ /* The mock outlives the peer, so the re-subscribe binds a slot of its own. */
1091
+ let reboundMid: string | undefined;
1092
+ sidecar.onSubscribe((flightId, mid) => {
1093
+ if (flightId === 42) reboundMid = mid;
1094
+ });
1095
+ await act(async () => {
1096
+ sidecar.open();
1097
+ sidecar.setConnectionState("connected");
1098
+ });
1099
+ expect(signal()).toBe("none");
1100
+ await act(async () => {
1101
+ sidecar.deliverTrack(reboundMid as string, {} as MediaStreamTrack);
1102
+ });
1103
+ env.setNow(2000);
1104
+ await act(async () => {
1105
+ env.drawOnce();
1106
+ });
1107
+ /* A fresh source with no frame yet never reads live. */
1108
+ expect(signal()).toBe("stalled");
1109
+ env.setNow(2601);
1110
+ await act(async () => {
1111
+ env.drawOnce();
1112
+ });
1113
+ expect(signal()).toBe("stalled");
1114
+
1115
+ await act(async () => {
1116
+ env.fireFrame();
1117
+ env.drawOnce();
1118
+ });
1119
+ expect(signal()).toBe("live");
1120
+ } finally {
1121
+ env.restore();
1122
+ }
1123
+ });
1124
+
1046
1125
  it("data-kc-signal reads stalled for a source that never delivers a frame", async () => {
1047
1126
  const env = installStallEnv();
1048
1127
  try {
@@ -1848,7 +1927,8 @@ describe("CameraFeed - render size in a narrow, tall tile", () => {
1848
1927
  const { client, sidecar } = await connect();
1849
1928
  renderFeed(client, { flightId: 42 });
1850
1929
  await run(500);
1851
- expect(sizeReports(sidecar)).toEqual([{ flightId: 42, width: 176, height: 100 }]);
1930
+ /* 176x99 at 16:9, raised so the height meets the floor. */
1931
+ expect(sizeReports(sidecar)).toEqual([{ flightId: 42, width: 240, height: 136 }]);
1852
1932
  });
1853
1933
 
1854
1934
  it("sends no request when the stream comes back smaller, larger or another shape", async () => {
@@ -152,14 +152,14 @@ describe("KerbalFaceFeed - reporting + showActions", () => {
152
152
  </KerbcastProvider>,
153
153
  );
154
154
  // A non-square measured box still reports square (min edge, bucketed): the
155
- // face capture is square, so 50x40 -> min 40 -> 48x48.
155
+ // face capture is square, so 50x40 -> min 40 -> 48x48, raised to the 128 floor.
156
156
  act(() => {
157
157
  resizeCallback?.(
158
158
  [{ contentRect: { width: 50, height: 40 } }] as ResizeObserverEntry[],
159
159
  {} as ResizeObserver,
160
160
  );
161
161
  });
162
- expect(reports.at(-1)).toEqual({ flightId: KERBAL_ID, w: 48, h: 48 });
162
+ expect(reports.at(-1)).toEqual({ flightId: KERBAL_ID, w: 128, h: 128 });
163
163
  });
164
164
 
165
165
  it("does not report when reportSize is false", async () => {
@@ -80,13 +80,27 @@ describe("createClientDisplaySizes", () => {
80
80
  expect(reports.at(-1)).toEqual({ flightId: 1, width: 40, height: 40 });
81
81
  });
82
82
 
83
- it("sends nothing more once the last reporter departs (backend forget clears it)", () => {
83
+ it("withdraws the size with a 0x0 report once the last reporter departs, once", () => {
84
+ const { client, reports } = fakeClient();
85
+ const reg = createClientDisplaySizes(client);
86
+ reg.report(1, "a", 400, 400);
87
+ reg.report(1, "b", 200, 200);
88
+ reg.clear(1, "b");
89
+ expect(reports).toEqual([{ flightId: 1, width: 400, height: 400 }]);
90
+ reg.clear(1, "a");
91
+ expect(reports.at(-1)).toEqual({ flightId: 1, width: 0, height: 0 });
92
+ reg.clear(1, "a");
93
+ expect(reports).toHaveLength(2);
94
+ /* A feed laid out again reports afresh. */
95
+ reg.report(1, "a", 400, 400);
96
+ expect(reports.at(-1)).toEqual({ flightId: 1, width: 400, height: 400 });
97
+ });
98
+
99
+ it("sends nothing for a camera that never reported", () => {
84
100
  const { client, reports } = fakeClient();
85
101
  const reg = createClientDisplaySizes(client);
86
- reg.report(1, "a", 40, 40);
87
- const countBefore = reports.length;
88
102
  reg.clear(1, "a");
89
- expect(reports.length).toBe(countBefore); // no extra report on empty
103
+ expect(reports).toEqual([]);
90
104
  });
91
105
 
92
106
  it("keeps separate maxes per flightId", () => {
package/src/context.tsx CHANGED
@@ -226,11 +226,14 @@ export function createClientDisplaySizes(
226
226
  function recompute(flightId: number): void {
227
227
  const instances = perCamera.get(flightId);
228
228
  if (!instances || instances.size === 0) {
229
- // No reporters left. Drop bookkeeping and send nothing: the subscription
230
- // release (unsubscribe -> sidecar remove_track -> forget_display_size)
231
- // clears the sidecar side. Sending a spurious size here would fight it.
229
+ /*
230
+ * No reporters left, as when the last feed showing the camera is hidden
231
+ * while still subscribed. A 0x0 report is no demand to the sidecar, so
232
+ * the camera stops rendering at a size nobody sees. An unsubscribe that
233
+ * follows clears the same entry, so the two never disagree.
234
+ */
232
235
  perCamera.delete(flightId);
233
- lastSent.delete(flightId);
236
+ if (lastSent.delete(flightId)) client.reportDisplaySize(flightId, 0, 0).catch(() => {});
234
237
  return;
235
238
  }
236
239
  let maxW = 0;
@@ -172,7 +172,7 @@ function mount(mode: Mode, sidecar = makeSidecar()) {
172
172
 
173
173
  beforeEach(() => {
174
174
  vi.useFakeTimers();
175
- tile = { width: 170, height: 400 };
175
+ tile = { width: 340, height: 800 };
176
176
  watched = [];
177
177
  globalThis.ResizeObserver = class {
178
178
  private readonly cb: ResizeObserverCallback;
@@ -198,13 +198,13 @@ describe("useReportDisplaySize: a box sized from the stream", () => {
198
198
  it("asks once for a tile sized by layout, at 16:9 whatever the tile's shape", () => {
199
199
  const sidecar = mount("tile");
200
200
  run(5000);
201
- expect(sidecar.reports).toEqual([{ width: 176, height: 100 }]);
201
+ expect(sidecar.reports).toEqual([{ width: 352, height: 198 }]);
202
202
  });
203
203
 
204
204
  it("settles after the first request when the frame keeps the stream's aspect", () => {
205
205
  const sidecar = mount("fit");
206
206
  run(10_000);
207
- expect(sidecar.reports).toEqual([{ width: 176, height: 100 }]);
207
+ expect(sidecar.reports).toEqual([{ width: 352, height: 198 }]);
208
208
  });
209
209
 
210
210
  it("does not ratchet down when a video at its own size arrives smaller than asked", () => {
@@ -278,11 +278,32 @@ describe("useReportDisplaySize: a genuine layout change", () => {
278
278
  expect(sidecar.reports.slice(before)).toEqual([{ width: 320, height: 180 }]);
279
279
  });
280
280
 
281
+ it("reports a tile shown again right after a stream change, even inside the hold", () => {
282
+ const sidecar = mount("tile");
283
+ run(5000);
284
+ const before = sidecar.reports.length;
285
+ /* A resize near a stream change uses up the hold. */
286
+ sidecar.streamBecomes({ width: 400, height: 300 });
287
+ resizeTile({ width: 640, height: 400 });
288
+ run(500);
289
+ expect(sidecar.reports.slice(before)).toEqual([{ width: 640, height: 360 }]);
290
+ /* Hidden, then shown again next to another stream change. */
291
+ resizeTile({ width: 0, height: 0 });
292
+ run(500);
293
+ sidecar.streamBecomes({ width: 200, height: 150 });
294
+ resizeTile({ width: 960, height: 600 });
295
+ run(500);
296
+ expect(sidecar.reports.slice(before)).toEqual([
297
+ { width: 640, height: 360 },
298
+ { width: 960, height: 540 },
299
+ ]);
300
+ });
301
+
281
302
  it("still reports a resize right after a stream change that the stream can not explain", () => {
282
303
  const sidecar = mount("fit");
283
304
  run(10_000);
284
305
  const before = sidecar.reports.length;
285
- sidecar.streamBecomes({ width: 160, height: 90 });
306
+ sidecar.streamBecomes({ width: 320, height: 180 });
286
307
  resizeTile({ width: 640, height: 400 });
287
308
  run(1000);
288
309
  expect(sidecar.reports.slice(before)).toEqual([{ width: 640, height: 360 }]);
@@ -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 { displayReportSize, useReportDisplaySize } from "./useReportDisplaySize";
14
+ import { displayReportSize, MIN_DISPLAY_REPORT_PX, 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,16 +89,16 @@ describe("useReportDisplaySize", () => {
89
89
  <Harness flightId={7} />
90
90
  </KerbcastProvider>,
91
91
  );
92
- // 40x30 -> width ceiled to a 16-multiple (48), height at 16:9 rounded to even (28), reported without advancing timers.
93
- fireResize(40, 30);
94
- expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 28 }]);
92
+ // 440x330 -> width ceiled to a 16-multiple (448), height at 16:9 rounded to even (252), reported without advancing timers.
93
+ fireResize(440, 330);
94
+ expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 448, h: 252 }]);
95
95
  });
96
96
 
97
97
  it("asks for the 16:9 picture that fits the box, never the box's shape", () => {
98
98
  expect(displayReportSize(1024, 576)).toEqual({ width: 1024, height: 576 });
99
99
  expect(displayReportSize(441, 246)).toEqual({ width: 448, height: 252 });
100
- // A narrow, tall tile is limited by its width.
101
- expect(displayReportSize(170, 400)).toEqual({ width: 176, height: 100 });
100
+ // A narrow, tall tile is limited by its width, then raised so the height meets the floor.
101
+ expect(displayReportSize(170, 400)).toEqual({ width: 240, height: 136 });
102
102
  // A wide, short one by its height: the letterboxed picture is ~356x200.
103
103
  expect(displayReportSize(800, 200)).toEqual({ width: 368, height: 208 });
104
104
  });
@@ -110,9 +110,9 @@ describe("useReportDisplaySize", () => {
110
110
  <Harness flightId={7} square />
111
111
  </KerbcastProvider>,
112
112
  );
113
- // min(50,40)=40 -> 48x48 square.
114
- fireResize(50, 40);
115
- expect(reports.at(-1)).toMatchObject({ w: 48, h: 48 });
113
+ // min(250,200)=200 -> 208x208 square.
114
+ fireResize(250, 200);
115
+ expect(reports.at(-1)).toMatchObject({ w: 208, h: 208 });
116
116
  });
117
117
 
118
118
  it("debounces churn after the first report", () => {
@@ -122,12 +122,12 @@ describe("useReportDisplaySize", () => {
122
122
  <Harness flightId={7} />
123
123
  </KerbcastProvider>,
124
124
  );
125
- fireResize(48, 48); // prompt first -> 48x28
126
- fireResize(200, 200); // debounced, not yet applied
125
+ fireResize(300, 300); // prompt first -> 304x172
126
+ fireResize(600, 600); // debounced, not yet applied
127
127
  expect(reports).toHaveLength(1);
128
128
  act(() => vi.advanceTimersByTime(200));
129
- // 200 -> 208 wide at 16:9; the debounced resize now lands.
130
- expect(reports.at(-1)).toMatchObject({ w: 208, h: 118 });
129
+ // 600 -> 608 wide at 16:9; the debounced resize now lands.
130
+ expect(reports.at(-1)).toMatchObject({ w: 608, h: 342 });
131
131
  });
132
132
 
133
133
  it("does not re-report a sub-bucket change", () => {
@@ -137,8 +137,8 @@ describe("useReportDisplaySize", () => {
137
137
  <Harness flightId={7} />
138
138
  </KerbcastProvider>,
139
139
  );
140
- fireResize(40, 40); // -> 48x28
141
- fireResize(44, 44); // still ceils to 48x28 -> no new report
140
+ fireResize(290, 290); // -> 304x172
141
+ fireResize(300, 300); // still ceils to 304x172 -> no new report
142
142
  act(() => vi.advanceTimersByTime(200));
143
143
  expect(reports).toHaveLength(1);
144
144
  });
@@ -150,7 +150,7 @@ describe("useReportDisplaySize", () => {
150
150
  <Harness flightId={7} />
151
151
  </KerbcastProvider>,
152
152
  );
153
- fireResize(40, 40);
153
+ fireResize(400, 400);
154
154
  const id = reports[0].id;
155
155
  act(() => unmount());
156
156
  expect(clears).toEqual([{ flightId: 7, id }]);
@@ -189,7 +189,74 @@ describe("useReportDisplaySize", () => {
189
189
  const { rerender } = render(view(false));
190
190
  expect(resizeCallback).toBeUndefined();
191
191
  rerender(view(true));
192
- fireResize(40, 30);
193
- expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 28 }]);
192
+ fireResize(440, 330);
193
+ expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 448, h: 252 }]);
194
+ });
195
+
196
+ it("never asks for less than the floor on either axis", () => {
197
+ for (const [w, h] of [[16, 10], [1, 1], [140, 130], [130, 2000], [4000, 130]]) {
198
+ const size = displayReportSize(w, h);
199
+ expect(size.width).toBeGreaterThanOrEqual(MIN_DISPLAY_REPORT_PX);
200
+ expect(size.height).toBeGreaterThanOrEqual(MIN_DISPLAY_REPORT_PX);
201
+ }
202
+ // A tall camera is held to the floor by its width.
203
+ expect(displayReportSize(16, 10, 9 / 16)).toEqual({ width: 128, height: 228 });
204
+ });
205
+
206
+ it("reports nothing for an empty box, then reports the first laid out box promptly", () => {
207
+ const { displaySizes, reports, clears } = recordingRegistry();
208
+ render(
209
+ <KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
210
+ <Harness flightId={7} />
211
+ </KerbcastProvider>,
212
+ );
213
+ fireResize(0, 0);
214
+ fireResize(400, 1);
215
+ act(() => vi.advanceTimersByTime(1000));
216
+ expect(reports).toEqual([]);
217
+ expect(clears).toEqual([]);
218
+ fireResize(440, 330);
219
+ expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 448, h: 252 }]);
220
+ });
221
+
222
+ it("withdraws its size when the box empties, and reports again when it is laid out again", () => {
223
+ const { displaySizes, reports, clears } = recordingRegistry();
224
+ render(
225
+ <KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
226
+ <Harness flightId={7} />
227
+ </KerbcastProvider>,
228
+ );
229
+ fireResize(440, 330);
230
+ const id = reports[0].id;
231
+ fireResize(0, 0);
232
+ act(() => vi.advanceTimersByTime(200));
233
+ expect(clears).toEqual([{ flightId: 7, id }]);
234
+ expect(reports).toHaveLength(1);
235
+ fireResize(440, 330);
236
+ act(() => vi.advanceTimersByTime(200));
237
+ expect(reports).toHaveLength(2);
238
+ expect(reports.at(-1)).toMatchObject({ w: 448, h: 252 });
239
+ });
240
+
241
+ it("raises a small box's request to the floor", () => {
242
+ const { displaySizes, reports } = recordingRegistry();
243
+ render(
244
+ <KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
245
+ <Harness flightId={7} />
246
+ </KerbcastProvider>,
247
+ );
248
+ fireResize(18, 10);
249
+ expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 240, h: 136 }]);
250
+ });
251
+
252
+ it("raises a small square box's request to the floor", () => {
253
+ const { displaySizes, reports } = recordingRegistry();
254
+ render(
255
+ <KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
256
+ <Harness flightId={7} square />
257
+ </KerbcastProvider>,
258
+ );
259
+ fireResize(50, 40);
260
+ expect(reports.at(-1)).toMatchObject({ w: 128, h: 128 });
194
261
  });
195
262
  });
@@ -41,6 +41,15 @@ const HOLD_MS = 5000;
41
41
  const ASPECT_TOLERANCE = 0.06;
42
42
  const MAGNITUDE_SLACK = 1.15;
43
43
 
44
+ /**
45
+ * The smallest request on either axis: a small box asks for this much, at the
46
+ * camera's shape, because hardware encoders refuse to encode tinier frames.
47
+ */
48
+ export const MIN_DISPLAY_REPORT_PX = 128;
49
+
50
+ /* A box under this on either axis (hidden, or not yet laid out) is no demand, matching the sidecar. */
51
+ const EMPTY_BOX_PX = 2;
52
+
44
53
  function bucket(px: number): number {
45
54
  const b = Math.ceil(px / BUCKET_PX) * BUCKET_PX;
46
55
  return b < BUCKET_PX ? BUCKET_PX : b;
@@ -60,8 +69,14 @@ export function displayReportSize(
60
69
  ): { width: number; height: number } {
61
70
  const a = aspect > 0 && Number.isFinite(aspect) ? aspect : DEFAULT_FEED_ASPECT;
62
71
  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) };
72
+ let w = bucket(fit);
73
+ if (w < MIN_DISPLAY_REPORT_PX) w = bucket(MIN_DISPLAY_REPORT_PX);
74
+ if (w / a < MIN_DISPLAY_REPORT_PX) w = bucket(MIN_DISPLAY_REPORT_PX * a);
75
+ return { width: w, height: Math.round(w / a / 2) * 2 };
76
+ }
77
+
78
+ function isEmpty(box: Size): boolean {
79
+ return !(box.width >= EMPTY_BOX_PX) || !(box.height >= EMPTY_BOX_PX);
65
80
  }
66
81
 
67
82
  export interface Size {
@@ -246,7 +261,7 @@ export function useReportDisplaySize(
246
261
 
247
262
  const sizeFor = (box: Size): Size => {
248
263
  if (!square) return displayReportSize(box.width, box.height, aspectRef.current);
249
- const side = bucket(Math.min(box.width, box.height));
264
+ const side = Math.max(MIN_DISPLAY_REPORT_PX, bucket(Math.min(box.width, box.height)));
250
265
  return { width: side, height: side };
251
266
  };
252
267
 
@@ -256,6 +271,13 @@ export function useReportDisplaySize(
256
271
  displaySizes.report(flightId, instanceId, width, height);
257
272
  };
258
273
 
274
+ const withdraw = () => {
275
+ if (lastW < 0) return;
276
+ lastW = -1;
277
+ lastH = -1;
278
+ displaySizes.clear(flightId, instanceId);
279
+ };
280
+
259
281
  /*
260
282
  * Judged when the debounce settles, so a stream change noted a little after
261
283
  * the observer fired (a host that commits the stream and the box in a
@@ -269,9 +291,14 @@ export function useReportDisplaySize(
269
291
  if (prev && near.some((c) => c.at >= seenAt - FOLLOW_MS && followsStream(prev, box, c))) {
270
292
  return;
271
293
  }
294
+ if (isEmpty(box)) {
295
+ withdraw();
296
+ return;
297
+ }
272
298
  const next = sizeFor(box);
273
299
  if (next.width === lastW && next.height === lastH) return;
274
- if (near.length > 0) {
300
+ /* With nothing reported since a withdraw there is no loop to break. */
301
+ if (near.length > 0 && lastW >= 0) {
275
302
  const held = now - lastSuspectAt < HOLD_MS;
276
303
  lastSuspectAt = now;
277
304
  if (held) return;
@@ -283,6 +310,8 @@ export function useReportDisplaySize(
283
310
  const entry = entries[0];
284
311
  if (!entry) return;
285
312
  const box = { width: entry.contentRect.width, height: entry.contentRect.height };
313
+ /* Until the box is first laid out, every measurement counts as the first, so it reports promptly. */
314
+ if (!firstReported && isEmpty(box)) return;
286
315
  if (!firstReported) {
287
316
  firstReported = true;
288
317
  baseline = box;
@@ -296,7 +325,7 @@ export function useReportDisplaySize(
296
325
  });
297
326
  observer.observe(el);
298
327
  reapply.current = () => {
299
- if (!baseline) return;
328
+ if (!baseline || isEmpty(baseline)) return;
300
329
  const next = sizeFor(baseline);
301
330
  if (next.width !== lastW || next.height !== lastH) send(next);
302
331
  };
package/src/index.ts CHANGED
@@ -30,7 +30,7 @@ export { useKerbcastClock } from "./hooks/useKerbcastClock";
30
30
  export { useDisplayedFrameUt } from "./hooks/useDisplayedFrameUt";
31
31
  export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
32
32
  export { useKerbcastWaitingForSlot } from "./hooks/useKerbcastWaitingForSlot";
33
- export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
33
+ export { useReportDisplaySize, MIN_DISPLAY_REPORT_PX } from "./hooks/useReportDisplaySize";
34
34
  export { useRecordings } from "./hooks/useRecordings";
35
35
  export type { RecordingsStore, ActiveRecordingInfo } from "./hooks/useRecordings";
36
36