@ksp-gonogo/kerbcast-react 1.12.0-rc.3 → 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 +5 -1
- package/dist/context.js +8 -4
- package/dist/hooks/useReportDisplaySize.d.ts +5 -0
- package/dist/hooks/useReportDisplaySize.js +34 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +81 -1
- package/src/KerbalFaceFeed.test.tsx +2 -2
- package/src/context.test.tsx +18 -4
- package/src/context.tsx +7 -4
- package/src/hooks/useReportDisplaySize.loop.test.tsx +25 -4
- package/src/hooks/useReportDisplaySize.test.tsx +85 -18
- package/src/hooks/useReportDisplaySize.ts +34 -5
- package/src/index.ts +1 -1
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
48
|
+
"@ksp-gonogo/kerbcast": "^1.12.0-rc.5"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"react": "^18 || ^19",
|
package/src/CameraFeed.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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 () => {
|
package/src/context.test.tsx
CHANGED
|
@@ -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("
|
|
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
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
93
|
-
fireResize(
|
|
94
|
-
expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w:
|
|
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:
|
|
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(
|
|
114
|
-
fireResize(
|
|
115
|
-
expect(reports.at(-1)).toMatchObject({ w:
|
|
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(
|
|
126
|
-
fireResize(
|
|
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
|
-
//
|
|
130
|
-
expect(reports.at(-1)).toMatchObject({ w:
|
|
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(
|
|
141
|
-
fireResize(
|
|
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(
|
|
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(
|
|
193
|
-
expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w:
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|