@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.
- package/README.md +19 -4
- package/dist/CameraFeed.d.ts +21 -0
- package/dist/CameraFeed.js +172 -33
- package/dist/CommandBrackets.d.ts +110 -0
- package/dist/CommandBrackets.js +167 -0
- package/dist/KerbalFaceFeed.js +2 -2
- package/dist/hooks/useCommandDisplayUt.d.ts +22 -0
- package/dist/hooks/useCommandDisplayUt.js +104 -0
- package/dist/hooks/useDisplayedFrameUt.d.ts +11 -0
- package/dist/hooks/useDisplayedFrameUt.js +22 -0
- package/dist/hooks/useReportDisplaySize.d.ts +49 -3
- package/dist/hooks/useReportDisplaySize.js +187 -21
- package/dist/hooks/useTargetBracketsPreference.d.ts +6 -0
- package/dist/hooks/useTargetBracketsPreference.js +39 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +538 -8
- package/src/CameraFeed.tsx +520 -242
- package/src/CommandBrackets.test.tsx +259 -0
- package/src/CommandBrackets.tsx +341 -0
- package/src/KerbalFaceFeed.tsx +2 -2
- package/src/hooks/useCommandDisplayUt.test.tsx +156 -0
- package/src/hooks/useCommandDisplayUt.ts +135 -0
- package/src/hooks/useDisplayedFrameUt.ts +26 -0
- package/src/hooks/useReportDisplaySize.loop.test.tsx +290 -0
- package/src/hooks/useReportDisplaySize.test.tsx +18 -9
- package/src/hooks/useReportDisplaySize.ts +232 -23
- package/src/hooks/useTargetBracketsPreference.ts +38 -0
- package/src/index.test.ts +2 -1
- package/src/index.ts +15 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display UT for command feeds and the displayed-frame UT hook: the presented
|
|
3
|
+
* frame's own capture stamp wins over the capture clock, every value says
|
|
4
|
+
* which it is, and a host override beats both.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { KerbcastClient, type CommandTrackEvent } from "@ksp-gonogo/kerbcast";
|
|
8
|
+
import { MockSidecar } from "@ksp-gonogo/kerbcast/testing";
|
|
9
|
+
import { act, cleanup, render } from "@testing-library/react";
|
|
10
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
11
|
+
import { KerbcastProvider } from "../context";
|
|
12
|
+
import { crossesEvent, useCommandDisplayUt, type CommandDisplayUt } from "./useCommandDisplayUt";
|
|
13
|
+
import { useDisplayedFrameUt } from "./useDisplayedFrameUt";
|
|
14
|
+
|
|
15
|
+
async function connected() {
|
|
16
|
+
const sidecar = new MockSidecar();
|
|
17
|
+
const client = new KerbcastClient(
|
|
18
|
+
{ host: "h", port: 1, negotiate: (o) => sidecar.negotiate(o) },
|
|
19
|
+
sidecar.createTransport(),
|
|
20
|
+
);
|
|
21
|
+
await act(async () => {
|
|
22
|
+
await client.connect([], { slots: 4 });
|
|
23
|
+
sidecar.open();
|
|
24
|
+
sidecar.setConnectionState("connected");
|
|
25
|
+
});
|
|
26
|
+
sidecar.fireSettingsState({ throttleMainScreen: false, captureUt: 1000, timeWarpRate: 1 });
|
|
27
|
+
return { client, sidecar };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A video element whose frames the test presents by hand. */
|
|
31
|
+
function fakeVideo() {
|
|
32
|
+
let pending: ((now: number, meta: object) => void) | null = null;
|
|
33
|
+
const video = {
|
|
34
|
+
requestVideoFrameCallback: (cb: (now: number, meta: object) => void) => {
|
|
35
|
+
pending = cb;
|
|
36
|
+
return 1;
|
|
37
|
+
},
|
|
38
|
+
cancelVideoFrameCallback: () => {
|
|
39
|
+
pending = null;
|
|
40
|
+
},
|
|
41
|
+
} as unknown as HTMLVideoElement;
|
|
42
|
+
const present = (rtpTimestamp?: number) =>
|
|
43
|
+
act(() => {
|
|
44
|
+
const cb = pending;
|
|
45
|
+
pending = null;
|
|
46
|
+
cb?.(0, rtpTimestamp === undefined ? {} : { rtpTimestamp });
|
|
47
|
+
});
|
|
48
|
+
return { video, present };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const event = (ut: number): CommandTrackEvent =>
|
|
52
|
+
({ ut, occluded: true, sunlit: true, occluder: "", blank: "" }) as CommandTrackEvent;
|
|
53
|
+
|
|
54
|
+
let last: CommandDisplayUt | null | undefined;
|
|
55
|
+
function CommandProbe(props: {
|
|
56
|
+
override?: number;
|
|
57
|
+
events?: CommandTrackEvent[];
|
|
58
|
+
video: HTMLVideoElement | null;
|
|
59
|
+
}) {
|
|
60
|
+
last = useCommandDisplayUt(true, props.override, props.events, 7, props.video);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
cleanup();
|
|
66
|
+
last = undefined;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("useCommandDisplayUt", () => {
|
|
70
|
+
it("is estimated from the clock until an exactly stamped frame is shown", async () => {
|
|
71
|
+
const { client, sidecar } = await connected();
|
|
72
|
+
const { video, present } = fakeVideo();
|
|
73
|
+
render(
|
|
74
|
+
<KerbcastProvider client={client}>
|
|
75
|
+
<CommandProbe video={video} />
|
|
76
|
+
</KerbcastProvider>,
|
|
77
|
+
);
|
|
78
|
+
expect(last?.source).toBe("estimated");
|
|
79
|
+
|
|
80
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 4500, captureUt: 999.8, captureEpoch: 0 });
|
|
81
|
+
present(4500);
|
|
82
|
+
expect(last).toEqual({ ut: 999.8, source: "exact" });
|
|
83
|
+
|
|
84
|
+
/* An unstamped frame falls back to the clock, and says so. */
|
|
85
|
+
present(9000);
|
|
86
|
+
expect(last?.source).toBe("estimated");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("re-renders when an exact frame steps across an event", async () => {
|
|
90
|
+
const { client, sidecar } = await connected();
|
|
91
|
+
const { video, present } = fakeVideo();
|
|
92
|
+
render(
|
|
93
|
+
<KerbcastProvider client={client}>
|
|
94
|
+
<CommandProbe video={video} events={[event(999.9)]} />
|
|
95
|
+
</KerbcastProvider>,
|
|
96
|
+
);
|
|
97
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 1, captureUt: 999.8, captureEpoch: 0 });
|
|
98
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 2, captureUt: 999.85, captureEpoch: 0 });
|
|
99
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 3, captureUt: 999.95, captureEpoch: 0 });
|
|
100
|
+
present(1);
|
|
101
|
+
expect(last?.ut).toBe(999.8);
|
|
102
|
+
/* Same side of the event: no re-render needed, the value shown holds. */
|
|
103
|
+
present(2);
|
|
104
|
+
expect(last?.ut).toBe(999.8);
|
|
105
|
+
present(3);
|
|
106
|
+
expect(last?.ut).toBe(999.95);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("prefers a host override over the displayed frame", async () => {
|
|
110
|
+
const { client, sidecar } = await connected();
|
|
111
|
+
const { video, present } = fakeVideo();
|
|
112
|
+
render(
|
|
113
|
+
<KerbcastProvider client={client}>
|
|
114
|
+
<CommandProbe video={video} override={990} />
|
|
115
|
+
</KerbcastProvider>,
|
|
116
|
+
);
|
|
117
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 1, captureUt: 999.8, captureEpoch: 0 });
|
|
118
|
+
present(1);
|
|
119
|
+
expect(last).toEqual({ ut: 990, source: "override" });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("crossesEvent", () => {
|
|
124
|
+
it("counts an event the display moves onto or past, in either direction", () => {
|
|
125
|
+
const events = [event(10)];
|
|
126
|
+
expect(crossesEvent(events, 9, 10)).toBe(true);
|
|
127
|
+
expect(crossesEvent(events, 11, 9)).toBe(true);
|
|
128
|
+
expect(crossesEvent(events, 10, 11)).toBe(false);
|
|
129
|
+
expect(crossesEvent(undefined, 0, 100)).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("useDisplayedFrameUt", () => {
|
|
134
|
+
let frame: ReturnType<typeof useDisplayedFrameUt> | undefined;
|
|
135
|
+
function FrameProbe({ video }: { video: HTMLVideoElement }) {
|
|
136
|
+
frame = useDisplayedFrameUt(7, video);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
it("reports every presented frame with its source", async () => {
|
|
141
|
+
const { client, sidecar } = await connected();
|
|
142
|
+
const { video, present } = fakeVideo();
|
|
143
|
+
render(
|
|
144
|
+
<KerbcastProvider client={client}>
|
|
145
|
+
<FrameProbe video={video} />
|
|
146
|
+
</KerbcastProvider>,
|
|
147
|
+
);
|
|
148
|
+
expect(frame).toBeNull();
|
|
149
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 77, captureUt: 999.5, captureEpoch: 4 });
|
|
150
|
+
present(77);
|
|
151
|
+
expect(frame).toMatchObject({ ut: 999.5, epoch: 4, source: "exact", rtpTimestamp: 77 });
|
|
152
|
+
/* Unstamped: bounded by the last exactly identified frame. */
|
|
153
|
+
present(78);
|
|
154
|
+
expect(frame).toMatchObject({ ut: 999.5, source: "estimated", rtpTimestamp: 77 });
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useEffect, useReducer, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
extrapolateCaptureUt,
|
|
4
|
+
type DisplayedFrameUt,
|
|
5
|
+
type FrameUtSource,
|
|
6
|
+
type CommandTrackEvent,
|
|
7
|
+
} from "@ksp-gonogo/kerbcast";
|
|
8
|
+
import { useKerbcastClient } from "../context";
|
|
9
|
+
import { nowMs } from "../timing";
|
|
10
|
+
|
|
11
|
+
export { MAX_UT_EXTRAPOLATION_MS, extrapolateCaptureUt } from "@ksp-gonogo/kerbcast";
|
|
12
|
+
|
|
13
|
+
const TICK_MS = 500;
|
|
14
|
+
|
|
15
|
+
/** Real milliseconds until the first event after `displayUt`, or `null` when none is ahead. */
|
|
16
|
+
export function msUntilNextEvent(
|
|
17
|
+
events: readonly CommandTrackEvent[] | undefined,
|
|
18
|
+
displayUt: number,
|
|
19
|
+
warpRate: number,
|
|
20
|
+
): number | null {
|
|
21
|
+
if (!events?.length || !(warpRate > 0)) return null;
|
|
22
|
+
let next = Infinity;
|
|
23
|
+
for (const e of events) if (e.ut > displayUt && e.ut < next) next = e.ut;
|
|
24
|
+
return next === Infinity ? null : ((next - displayUt) / warpRate) * 1000;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether an event's UT lies in `(from, to]`, i.e. moving the display from `from` to `to` changes the state in force. */
|
|
28
|
+
export function crossesEvent(
|
|
29
|
+
events: readonly CommandTrackEvent[] | undefined,
|
|
30
|
+
from: number,
|
|
31
|
+
to: number,
|
|
32
|
+
): boolean {
|
|
33
|
+
if (!events?.length) return false;
|
|
34
|
+
const lo = Math.min(from, to);
|
|
35
|
+
const hi = Math.max(from, to);
|
|
36
|
+
return events.some((e) => e.ut > lo && e.ut <= hi);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The UT a command feed is showing and where it came from; `"override"` is a host's own value. */
|
|
40
|
+
export interface CommandDisplayUt {
|
|
41
|
+
ut: number;
|
|
42
|
+
source: FrameUtSource | "override";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The UT a command feed is showing. An explicit `override` (a host with its
|
|
47
|
+
* own delayed playout) wins. Otherwise, given the feed's `flightId` and the
|
|
48
|
+
* `video` element showing it, the presented frame's own capture stamp is
|
|
49
|
+
* used (`source: "exact"`); without one, the client's capture clock is
|
|
50
|
+
* interpolated to now (`"estimated"`). Re-evaluated twice a second while
|
|
51
|
+
* `enabled` so readout ages keep counting, and again the moment the
|
|
52
|
+
* displayed UT reaches the next of `events`, so a state change lands on its
|
|
53
|
+
* frame rather than on the next tick. `null` when nothing is known.
|
|
54
|
+
*/
|
|
55
|
+
export function useCommandDisplayUt(
|
|
56
|
+
enabled: boolean,
|
|
57
|
+
override?: number,
|
|
58
|
+
events?: readonly CommandTrackEvent[],
|
|
59
|
+
flightId?: number | null,
|
|
60
|
+
video?: HTMLVideoElement | null,
|
|
61
|
+
): CommandDisplayUt | null {
|
|
62
|
+
const client = useKerbcastClient();
|
|
63
|
+
const sample = useRef<{ captureUt: number | null; warpRate: number; at: number }>({
|
|
64
|
+
captureUt: client.clock.captureUt,
|
|
65
|
+
warpRate: client.clock.warpRate,
|
|
66
|
+
at: nowMs(),
|
|
67
|
+
});
|
|
68
|
+
const frame = useRef<DisplayedFrameUt | null>(null);
|
|
69
|
+
const shown = useRef<CommandDisplayUt | null>(null);
|
|
70
|
+
const eventsRef = useRef(events);
|
|
71
|
+
eventsRef.current = events;
|
|
72
|
+
const [, tick] = useReducer((c: number) => c + 1, 0);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const take = () => {
|
|
76
|
+
const { captureUt, warpRate } = client.clock;
|
|
77
|
+
sample.current = { captureUt, warpRate, at: nowMs() };
|
|
78
|
+
};
|
|
79
|
+
take();
|
|
80
|
+
return client.on("settings-change", take);
|
|
81
|
+
}, [client]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!enabled) return;
|
|
85
|
+
const interval = setInterval(tick, TICK_MS);
|
|
86
|
+
return () => clearInterval(interval);
|
|
87
|
+
}, [enabled]);
|
|
88
|
+
|
|
89
|
+
/* Exact frames re-render only when they change what is drawn: a switch of
|
|
90
|
+
source, or a step across an event. Ages catch up on the tick. */
|
|
91
|
+
const overriding = override !== undefined;
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
frame.current = null;
|
|
94
|
+
if (!enabled || overriding || flightId == null || !video) return;
|
|
95
|
+
return client.watchDisplayedFrames(flightId, video, (f) => {
|
|
96
|
+
frame.current = f.source === "exact" ? f : null;
|
|
97
|
+
const prev = shown.current;
|
|
98
|
+
if (!frame.current) {
|
|
99
|
+
if (prev?.source === "exact") tick();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
prev === null ||
|
|
104
|
+
prev.source !== "exact" ||
|
|
105
|
+
crossesEvent(eventsRef.current, prev.ut, f.ut)
|
|
106
|
+
) {
|
|
107
|
+
tick();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}, [client, enabled, overriding, flightId, video]);
|
|
111
|
+
|
|
112
|
+
const { captureUt, warpRate, at } = sample.current;
|
|
113
|
+
const exact = frame.current;
|
|
114
|
+
const displayUt: CommandDisplayUt | null = overriding
|
|
115
|
+
? { ut: override, source: "override" }
|
|
116
|
+
: exact
|
|
117
|
+
? { ut: exact.ut, source: "exact" }
|
|
118
|
+
: captureUt === null
|
|
119
|
+
? null
|
|
120
|
+
: { ut: extrapolateCaptureUt(captureUt, warpRate, at, nowMs()), source: "estimated" };
|
|
121
|
+
shown.current = displayUt;
|
|
122
|
+
const untilEdge =
|
|
123
|
+
enabled && displayUt?.source === "estimated"
|
|
124
|
+
? msUntilNextEvent(events, displayUt.ut, warpRate)
|
|
125
|
+
: null;
|
|
126
|
+
|
|
127
|
+
/* Edges further out than a tick are caught by a later render's timer. */
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (untilEdge === null || untilEdge > TICK_MS) return;
|
|
130
|
+
const timer = setTimeout(tick, Math.max(0, untilEdge) + 1);
|
|
131
|
+
return () => clearTimeout(timer);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return displayUt;
|
|
135
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { DisplayedFrameUt } from "@ksp-gonogo/kerbcast";
|
|
3
|
+
import { useKerbcastClient } from "../context";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mission time of the frame `video` is showing for camera `flightId`,
|
|
7
|
+
* updated on every presented frame (so the calling component re-renders at
|
|
8
|
+
* the video's frame rate). `source` says whether the UT is the frame's own
|
|
9
|
+
* capture stamp (`"exact"`) or the capture clock extrapolated
|
|
10
|
+
* (`"estimated"`). `null` until the first frame with a known UT, and while
|
|
11
|
+
* either argument is null. For per-frame work without re-rendering, use
|
|
12
|
+
* `client.watchDisplayedFrames` directly.
|
|
13
|
+
*/
|
|
14
|
+
export function useDisplayedFrameUt(
|
|
15
|
+
flightId: number | null,
|
|
16
|
+
video: HTMLVideoElement | null,
|
|
17
|
+
): DisplayedFrameUt | null {
|
|
18
|
+
const client = useKerbcastClient();
|
|
19
|
+
const [frame, setFrame] = useState<DisplayedFrameUt | null>(null);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
setFrame(null);
|
|
22
|
+
if (flightId === null || !video) return;
|
|
23
|
+
return client.watchDisplayedFrames(flightId, video, setFrame);
|
|
24
|
+
}, [client, flightId, video]);
|
|
25
|
+
return frame;
|
|
26
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -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 { useReportDisplaySize } from "./useReportDisplaySize";
|
|
14
|
+
import { displayReportSize, 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,9 +89,18 @@ describe("useReportDisplaySize", () => {
|
|
|
89
89
|
<Harness flightId={7} />
|
|
90
90
|
</KerbcastProvider>,
|
|
91
91
|
);
|
|
92
|
-
// 40x30 ->
|
|
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
|
+
});
|
|
96
|
+
|
|
97
|
+
it("asks for the 16:9 picture that fits the box, never the box's shape", () => {
|
|
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 });
|
|
95
104
|
});
|
|
96
105
|
|
|
97
106
|
it("reports a square (min edge) when square is set", () => {
|
|
@@ -113,12 +122,12 @@ describe("useReportDisplaySize", () => {
|
|
|
113
122
|
<Harness flightId={7} />
|
|
114
123
|
</KerbcastProvider>,
|
|
115
124
|
);
|
|
116
|
-
fireResize(48, 48); // prompt first ->
|
|
125
|
+
fireResize(48, 48); // prompt first -> 48x28
|
|
117
126
|
fireResize(200, 200); // debounced, not yet applied
|
|
118
127
|
expect(reports).toHaveLength(1);
|
|
119
128
|
act(() => vi.advanceTimersByTime(200));
|
|
120
|
-
// 200 -> 208; the debounced resize now lands.
|
|
121
|
-
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 });
|
|
122
131
|
});
|
|
123
132
|
|
|
124
133
|
it("does not re-report a sub-bucket change", () => {
|
|
@@ -128,8 +137,8 @@ describe("useReportDisplaySize", () => {
|
|
|
128
137
|
<Harness flightId={7} />
|
|
129
138
|
</KerbcastProvider>,
|
|
130
139
|
);
|
|
131
|
-
fireResize(40, 40); // ->
|
|
132
|
-
fireResize(44, 44); // still ceils to
|
|
140
|
+
fireResize(40, 40); // -> 48x28
|
|
141
|
+
fireResize(44, 44); // still ceils to 48x28 -> no new report
|
|
133
142
|
act(() => vi.advanceTimersByTime(200));
|
|
134
143
|
expect(reports).toHaveLength(1);
|
|
135
144
|
});
|
|
@@ -181,6 +190,6 @@ describe("useReportDisplaySize", () => {
|
|
|
181
190
|
expect(resizeCallback).toBeUndefined();
|
|
182
191
|
rerender(view(true));
|
|
183
192
|
fireResize(40, 30);
|
|
184
|
-
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 }]);
|
|
185
194
|
});
|
|
186
195
|
});
|