@ksp-gonogo/kerbcast-react 1.11.0-rc.4 → 1.11.0
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 +19 -4
- package/dist/CameraFeed.js +77 -39
- package/dist/KerbalFaceFeed.js +2 -2
- package/dist/hooks/useReportDisplaySize.d.ts +46 -5
- package/dist/hooks/useReportDisplaySize.js +186 -31
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +247 -6
- package/src/CameraFeed.tsx +337 -289
- package/src/KerbalFaceFeed.tsx +2 -2
- package/src/hooks/useReportDisplaySize.loop.test.tsx +290 -0
- package/src/hooks/useReportDisplaySize.test.tsx +14 -11
- package/src/hooks/useReportDisplaySize.ts +230 -32
package/src/KerbalFaceFeed.tsx
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The render-size request must never feed the stream back into itself. A host
|
|
3
|
+
* that sizes the measured box from the stream (a frame at the video's aspect,
|
|
4
|
+
* a `<video>` at its own size) closes a loop: stream -> box -> request ->
|
|
5
|
+
* stream. These tests run that loop against a stand-in sidecar that delivers
|
|
6
|
+
* a stream of whatever size it is asked for (or smaller, or late) and assert
|
|
7
|
+
* the request settles instead of spiralling.
|
|
8
|
+
*
|
|
9
|
+
* Layout is faked: a ResizeObserver stub reports the px size in the measured
|
|
10
|
+
* element's inline style (or the tile's when it has none), once per change,
|
|
11
|
+
* on each animation frame the test steps through.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
15
|
+
import { act, cleanup, render } from "@testing-library/react";
|
|
16
|
+
import { useEffect, useRef, useState } from "react";
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
|
|
19
|
+
import { KerbcastProvider, type KerbcastDisplaySizes } from "../context";
|
|
20
|
+
import { useReportDisplaySize, type Size } from "./useReportDisplaySize";
|
|
21
|
+
|
|
22
|
+
const FRAME_MS = 16;
|
|
23
|
+
const fakeClient = { on: () => () => {} } as unknown as KerbcastClient;
|
|
24
|
+
|
|
25
|
+
let tile: Size = { width: 170, height: 400 };
|
|
26
|
+
/* Re-renders the host after the tile changes, as a real layout change would. */
|
|
27
|
+
let relayout = () => {};
|
|
28
|
+
|
|
29
|
+
function resizeTile(size: Size) {
|
|
30
|
+
tile = size;
|
|
31
|
+
act(() => relayout());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Watched {
|
|
35
|
+
el: Element;
|
|
36
|
+
cb: ResizeObserverCallback;
|
|
37
|
+
last: string | null;
|
|
38
|
+
}
|
|
39
|
+
let watched: Watched[] = [];
|
|
40
|
+
|
|
41
|
+
function boxOf(el: Element): Size {
|
|
42
|
+
const style = (el as HTMLElement).style;
|
|
43
|
+
if (style?.width.endsWith("px") && style.height.endsWith("px")) {
|
|
44
|
+
return { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };
|
|
45
|
+
}
|
|
46
|
+
return tile;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function layout() {
|
|
50
|
+
for (const w of watched) {
|
|
51
|
+
const box = boxOf(w.el);
|
|
52
|
+
const key = `${box.width}x${box.height}`;
|
|
53
|
+
if (key === w.last) continue;
|
|
54
|
+
w.last = key;
|
|
55
|
+
w.cb(
|
|
56
|
+
[{ target: w.el, contentRect: box as DOMRectReadOnly } as ResizeObserverEntry],
|
|
57
|
+
{} as ResizeObserver,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Step `ms` of time a frame at a time, laying out after each. */
|
|
63
|
+
function run(ms: number) {
|
|
64
|
+
for (let t = 0; t < ms; t += FRAME_MS) {
|
|
65
|
+
act(() => {
|
|
66
|
+
vi.advanceTimersByTime(FRAME_MS);
|
|
67
|
+
});
|
|
68
|
+
act(() => layout());
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const FIRST_RENDER: Size = { width: 1280, height: 720 };
|
|
73
|
+
|
|
74
|
+
type Mode = "fit" | "intrinsic" | "late-intrinsic" | "tile";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The stand-in sidecar: after `latencyMs` the stream becomes `deliver(request)`,
|
|
78
|
+
* seen both as the video's own size and as the camera's render size.
|
|
79
|
+
*/
|
|
80
|
+
interface Sidecar {
|
|
81
|
+
reports: Size[];
|
|
82
|
+
deliver: (req: Size) => Size;
|
|
83
|
+
latencyMs: number;
|
|
84
|
+
streamBecomes: (size: Size) => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function makeSidecar(): Sidecar {
|
|
88
|
+
return {
|
|
89
|
+
reports: [],
|
|
90
|
+
deliver: (req) => req,
|
|
91
|
+
latencyMs: 300,
|
|
92
|
+
streamBecomes: () => {},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function registry(sidecar: Sidecar): KerbcastDisplaySizes {
|
|
97
|
+
return {
|
|
98
|
+
report: (_flightId, _id, width, height) => {
|
|
99
|
+
sidecar.reports.push({ width, height });
|
|
100
|
+
setTimeout(() => sidecar.streamBecomes(sidecar.deliver({ width, height })), sidecar.latencyMs);
|
|
101
|
+
},
|
|
102
|
+
clear: () => {},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A host whose measured box comes from the stream in one of a few natural
|
|
108
|
+
* ways, or ("tile") from layout alone.
|
|
109
|
+
*/
|
|
110
|
+
function Host({ mode, sidecar }: { mode: Mode; sidecar: Sidecar }) {
|
|
111
|
+
const frameRef = useRef<HTMLDivElement>(null);
|
|
112
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
113
|
+
const [render, setRender] = useState<Size>(FIRST_RENDER);
|
|
114
|
+
const [shown, setShown] = useState<Size | null>(null);
|
|
115
|
+
const [, bump] = useState(0);
|
|
116
|
+
relayout = () => bump((n) => n + 1);
|
|
117
|
+
|
|
118
|
+
sidecar.streamBecomes = (size) => {
|
|
119
|
+
const v = videoRef.current;
|
|
120
|
+
if (!v) return;
|
|
121
|
+
act(() => {
|
|
122
|
+
Object.defineProperty(v, "videoWidth", { value: size.width, configurable: true });
|
|
123
|
+
Object.defineProperty(v, "videoHeight", { value: size.height, configurable: true });
|
|
124
|
+
setRender(size);
|
|
125
|
+
v.dispatchEvent(new Event("resize"));
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const v = videoRef.current;
|
|
131
|
+
if (!v) return;
|
|
132
|
+
const onResize = () => {
|
|
133
|
+
bump((n) => n + 1);
|
|
134
|
+
const size = { width: v.videoWidth, height: v.videoHeight };
|
|
135
|
+
setTimeout(() => setShown(size), 1500);
|
|
136
|
+
};
|
|
137
|
+
v.addEventListener("resize", onResize);
|
|
138
|
+
return () => v.removeEventListener("resize", onResize);
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
useReportDisplaySize(7, frameRef, { videoRef, streamSize: render });
|
|
142
|
+
|
|
143
|
+
const v = videoRef.current;
|
|
144
|
+
const stream = v && v.videoWidth > 0 ? { width: v.videoWidth, height: v.videoHeight } : render;
|
|
145
|
+
let style: React.CSSProperties | undefined;
|
|
146
|
+
if (mode === "fit") {
|
|
147
|
+
const aspect = stream.width / stream.height;
|
|
148
|
+
const w = Math.min(tile.width, tile.height * aspect);
|
|
149
|
+
style = { width: `${Math.floor(w)}px`, height: `${Math.floor(w / aspect)}px` };
|
|
150
|
+
} else if (mode === "intrinsic") {
|
|
151
|
+
style = { width: `${stream.width}px`, height: `${stream.height}px` };
|
|
152
|
+
} else if (mode === "late-intrinsic") {
|
|
153
|
+
const s = shown ?? FIRST_RENDER;
|
|
154
|
+
style = { width: `${s.width}px`, height: `${s.height}px` };
|
|
155
|
+
}
|
|
156
|
+
return (
|
|
157
|
+
<div ref={frameRef} style={style}>
|
|
158
|
+
<video ref={videoRef} />
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function mount(mode: Mode, sidecar = makeSidecar()) {
|
|
164
|
+
render(
|
|
165
|
+
<KerbcastProvider client={fakeClient} displaySizes={registry(sidecar)}>
|
|
166
|
+
<Host mode={mode} sidecar={sidecar} />
|
|
167
|
+
</KerbcastProvider>,
|
|
168
|
+
);
|
|
169
|
+
run(FRAME_MS * 2);
|
|
170
|
+
return sidecar;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
beforeEach(() => {
|
|
174
|
+
vi.useFakeTimers();
|
|
175
|
+
tile = { width: 170, height: 400 };
|
|
176
|
+
watched = [];
|
|
177
|
+
globalThis.ResizeObserver = class {
|
|
178
|
+
private readonly cb: ResizeObserverCallback;
|
|
179
|
+
constructor(cb: ResizeObserverCallback) {
|
|
180
|
+
this.cb = cb;
|
|
181
|
+
}
|
|
182
|
+
observe(el: Element) {
|
|
183
|
+
watched.push({ el, cb: this.cb, last: null });
|
|
184
|
+
}
|
|
185
|
+
unobserve() {}
|
|
186
|
+
disconnect() {
|
|
187
|
+
watched = watched.filter((w) => w.cb !== this.cb);
|
|
188
|
+
}
|
|
189
|
+
} as unknown as typeof ResizeObserver;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
afterEach(() => {
|
|
193
|
+
cleanup();
|
|
194
|
+
vi.useRealTimers();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("useReportDisplaySize: a box sized from the stream", () => {
|
|
198
|
+
it("asks once for a tile sized by layout, at 16:9 whatever the tile's shape", () => {
|
|
199
|
+
const sidecar = mount("tile");
|
|
200
|
+
run(5000);
|
|
201
|
+
expect(sidecar.reports).toEqual([{ width: 176, height: 100 }]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("settles after the first request when the frame keeps the stream's aspect", () => {
|
|
205
|
+
const sidecar = mount("fit");
|
|
206
|
+
run(10_000);
|
|
207
|
+
expect(sidecar.reports).toEqual([{ width: 176, height: 100 }]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("does not ratchet down when a video at its own size arrives smaller than asked", () => {
|
|
211
|
+
const sidecar = makeSidecar();
|
|
212
|
+
sidecar.deliver = (req) => ({ width: req.width / 2, height: req.height / 2 });
|
|
213
|
+
mount("intrinsic", sidecar);
|
|
214
|
+
run(20_000);
|
|
215
|
+
expect(sidecar.reports).toEqual([{ width: 1280, height: 720 }]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("sends nothing when the stream changes size or shape on its own", () => {
|
|
219
|
+
const sidecar = mount("fit");
|
|
220
|
+
const before = sidecar.reports.length;
|
|
221
|
+
for (const size of [
|
|
222
|
+
{ width: 96, height: 54 },
|
|
223
|
+
{ width: 1920, height: 1080 },
|
|
224
|
+
{ width: 160, height: 120 },
|
|
225
|
+
{ width: 176, height: 400 },
|
|
226
|
+
]) {
|
|
227
|
+
sidecar.streamBecomes(size);
|
|
228
|
+
run(3000);
|
|
229
|
+
}
|
|
230
|
+
expect(sidecar.reports.length - before).toBe(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("sends nothing while the stream flips between two sizes", () => {
|
|
234
|
+
const sidecar = mount("fit");
|
|
235
|
+
const before = sidecar.reports.length;
|
|
236
|
+
for (let i = 0; i < 6; i++) {
|
|
237
|
+
sidecar.streamBecomes(i % 2 ? { width: 176, height: 128 } : { width: 176, height: 96 });
|
|
238
|
+
run(300);
|
|
239
|
+
}
|
|
240
|
+
run(5000);
|
|
241
|
+
expect(sidecar.reports.length - before).toBe(0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("lets at most one more request through when the box follows too late to be recognised", () => {
|
|
245
|
+
const sidecar = makeSidecar();
|
|
246
|
+
sidecar.deliver = (req) => ({ width: (req.width * 3) / 4, height: (req.height * 3) / 4 });
|
|
247
|
+
mount("late-intrinsic", sidecar);
|
|
248
|
+
run(30_000);
|
|
249
|
+
expect(sidecar.reports.length).toBeLessThanOrEqual(2);
|
|
250
|
+
for (const r of sidecar.reports) {
|
|
251
|
+
expect(r.width).toBeGreaterThanOrEqual(960);
|
|
252
|
+
expect(r.height).toBeGreaterThanOrEqual(540);
|
|
253
|
+
}
|
|
254
|
+
const settled = sidecar.reports.length;
|
|
255
|
+
run(30_000);
|
|
256
|
+
expect(sidecar.reports.length).toBe(settled);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("useReportDisplaySize: a genuine layout change", () => {
|
|
261
|
+
it("sends exactly one request, sized from the tile, for a resize dragged across several sizes", () => {
|
|
262
|
+
const sidecar = mount("tile");
|
|
263
|
+
const before = sidecar.reports.length;
|
|
264
|
+
for (const width of [240, 280, 320]) {
|
|
265
|
+
resizeTile({ width, height: 400 });
|
|
266
|
+
act(() => layout());
|
|
267
|
+
}
|
|
268
|
+
run(1000);
|
|
269
|
+
expect(sidecar.reports.slice(before)).toEqual([{ width: 320, height: 180 }]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("still reports once a stream-sized frame has settled", () => {
|
|
273
|
+
const sidecar = mount("fit");
|
|
274
|
+
run(10_000);
|
|
275
|
+
const before = sidecar.reports.length;
|
|
276
|
+
resizeTile({ width: 320, height: 400 });
|
|
277
|
+
run(1000);
|
|
278
|
+
expect(sidecar.reports.slice(before)).toEqual([{ width: 320, height: 180 }]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("still reports a resize right after a stream change that the stream can not explain", () => {
|
|
282
|
+
const sidecar = mount("fit");
|
|
283
|
+
run(10_000);
|
|
284
|
+
const before = sidecar.reports.length;
|
|
285
|
+
sidecar.streamBecomes({ width: 160, height: 90 });
|
|
286
|
+
resizeTile({ width: 640, height: 400 });
|
|
287
|
+
run(1000);
|
|
288
|
+
expect(sidecar.reports.slice(before)).toEqual([{ width: 640, height: 360 }]);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -89,15 +89,18 @@ describe("useReportDisplaySize", () => {
|
|
|
89
89
|
<Harness flightId={7} />
|
|
90
90
|
</KerbcastProvider>,
|
|
91
91
|
);
|
|
92
|
-
// 40x30 -> width ceiled to a 16-multiple (48), height at
|
|
92
|
+
// 40x30 -> width ceiled to a 16-multiple (48), height at 16:9 rounded to even (28), reported without advancing timers.
|
|
93
93
|
fireResize(40, 30);
|
|
94
|
-
expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h:
|
|
94
|
+
expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 28 }]);
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
-
it("
|
|
98
|
-
expect(displayReportSize(441, 246)).toEqual({ width: 448, height: 250 });
|
|
99
|
-
expect(displayReportSize(372, 208)).toEqual({ width: 384, height: 214 });
|
|
97
|
+
it("asks for the 16:9 picture that fits the box, never the box's shape", () => {
|
|
100
98
|
expect(displayReportSize(1024, 576)).toEqual({ width: 1024, height: 576 });
|
|
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 });
|
|
102
|
+
// A wide, short one by its height: the letterboxed picture is ~356x200.
|
|
103
|
+
expect(displayReportSize(800, 200)).toEqual({ width: 368, height: 208 });
|
|
101
104
|
});
|
|
102
105
|
|
|
103
106
|
it("reports a square (min edge) when square is set", () => {
|
|
@@ -119,12 +122,12 @@ describe("useReportDisplaySize", () => {
|
|
|
119
122
|
<Harness flightId={7} />
|
|
120
123
|
</KerbcastProvider>,
|
|
121
124
|
);
|
|
122
|
-
fireResize(48, 48); // prompt first ->
|
|
125
|
+
fireResize(48, 48); // prompt first -> 48x28
|
|
123
126
|
fireResize(200, 200); // debounced, not yet applied
|
|
124
127
|
expect(reports).toHaveLength(1);
|
|
125
128
|
act(() => vi.advanceTimersByTime(200));
|
|
126
|
-
// 200 -> 208; the debounced resize now lands.
|
|
127
|
-
expect(reports.at(-1)).toMatchObject({ w: 208, h:
|
|
129
|
+
// 200 -> 208 wide at 16:9; the debounced resize now lands.
|
|
130
|
+
expect(reports.at(-1)).toMatchObject({ w: 208, h: 118 });
|
|
128
131
|
});
|
|
129
132
|
|
|
130
133
|
it("does not re-report a sub-bucket change", () => {
|
|
@@ -134,8 +137,8 @@ describe("useReportDisplaySize", () => {
|
|
|
134
137
|
<Harness flightId={7} />
|
|
135
138
|
</KerbcastProvider>,
|
|
136
139
|
);
|
|
137
|
-
fireResize(40, 40); // ->
|
|
138
|
-
fireResize(44, 44); // still ceils to
|
|
140
|
+
fireResize(40, 40); // -> 48x28
|
|
141
|
+
fireResize(44, 44); // still ceils to 48x28 -> no new report
|
|
139
142
|
act(() => vi.advanceTimersByTime(200));
|
|
140
143
|
expect(reports).toHaveLength(1);
|
|
141
144
|
});
|
|
@@ -187,6 +190,6 @@ describe("useReportDisplaySize", () => {
|
|
|
187
190
|
expect(resizeCallback).toBeUndefined();
|
|
188
191
|
rerender(view(true));
|
|
189
192
|
fireResize(40, 30);
|
|
190
|
-
expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h:
|
|
193
|
+
expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 28 }]);
|
|
191
194
|
});
|
|
192
195
|
});
|