@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/CameraFeed.test.tsx
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
screen,
|
|
45
45
|
waitFor,
|
|
46
46
|
} from "@testing-library/react";
|
|
47
|
-
import { createRef } from "react";
|
|
47
|
+
import { createRef, useEffect } from "react";
|
|
48
48
|
import { useState } from "react";
|
|
49
49
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
50
50
|
import {
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
type FeedAction,
|
|
56
56
|
} from "./CameraFeed";
|
|
57
57
|
import { KerbcastProvider } from "./context";
|
|
58
|
+
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
58
59
|
|
|
59
60
|
/*
|
|
60
61
|
* The REC control reads/drives `useRecordings()` (see CameraFeed - REC below).
|
|
@@ -220,6 +221,8 @@ function toInit(c: CameraStateLike): MockCameraInit {
|
|
|
220
221
|
renderHeight: c.renderHeight as number | undefined,
|
|
221
222
|
operatorWidth: c.operatorWidth as number | undefined,
|
|
222
223
|
operatorHeight: c.operatorHeight as number | undefined,
|
|
224
|
+
maxWidth: c.maxWidth as number | undefined,
|
|
225
|
+
maxHeight: c.maxHeight as number | undefined,
|
|
223
226
|
supportsZoom: c.supportsZoom as boolean | undefined,
|
|
224
227
|
fov: c.fov as number | undefined,
|
|
225
228
|
fovMin: c.fovMin as number | undefined,
|
|
@@ -1498,8 +1501,10 @@ describe("CameraFeed - ResizeObserver render-size feedback", () => {
|
|
|
1498
1501
|
resizeCallback = undefined;
|
|
1499
1502
|
});
|
|
1500
1503
|
|
|
1501
|
-
it("reports the measured display size (report-display-size
|
|
1502
|
-
const { client, sidecar } = await buildConnectedSource(
|
|
1504
|
+
it("reports the measured display size at 16:9 (report-display-size)", async () => {
|
|
1505
|
+
const { client, sidecar } = await buildConnectedSource([
|
|
1506
|
+
makeCamera({ flightId: 42, cameraName: "Starboard Cam", maxWidth: 1280, maxHeight: 720 }),
|
|
1507
|
+
]);
|
|
1503
1508
|
|
|
1504
1509
|
renderFeed(client, { flightId: 42 });
|
|
1505
1510
|
|
|
@@ -1513,15 +1518,45 @@ describe("CameraFeed - ResizeObserver render-size feedback", () => {
|
|
|
1513
1518
|
|
|
1514
1519
|
const msg = sidecar.lastCommand("report-display-size");
|
|
1515
1520
|
expect(msg).toBeTruthy();
|
|
1516
|
-
//
|
|
1517
|
-
//
|
|
1521
|
+
// The 16:9 picture that fits 400x224 is ~398 wide: bucketed to 400, height
|
|
1522
|
+
// 225 rounded to even.
|
|
1518
1523
|
expect(msg?.content.width).toBe(400);
|
|
1519
|
-
expect(msg?.content.height).toBe(
|
|
1524
|
+
expect(msg?.content.height).toBe(226);
|
|
1520
1525
|
// Measurement no longer drives the operator set-render-size (that path is
|
|
1521
1526
|
// the manual quality cap only).
|
|
1522
1527
|
expect(sidecar.commands.some((c) => c.type === "set-render-size")).toBe(false);
|
|
1523
1528
|
});
|
|
1524
1529
|
|
|
1530
|
+
it("asks for the camera's configured shape, not the box's, and gives the feed that shape", async () => {
|
|
1531
|
+
const { client, sidecar } = await buildConnectedSource([
|
|
1532
|
+
makeCamera({ flightId: 42, cameraName: "Starboard Cam", maxWidth: 1024, maxHeight: 768 }),
|
|
1533
|
+
]);
|
|
1534
|
+
|
|
1535
|
+
const { container } = renderFeed(client, { flightId: 42 });
|
|
1536
|
+
|
|
1537
|
+
await act(async () => {
|
|
1538
|
+
resizeCallback?.(
|
|
1539
|
+
[{ contentRect: { width: 400, height: 224 } }] as ResizeObserverEntry[],
|
|
1540
|
+
{} as ResizeObserver,
|
|
1541
|
+
);
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
// The 4:3 picture that fits 400x224 is ~299 wide: 304x228.
|
|
1545
|
+
const msg = sidecar.lastCommand("report-display-size");
|
|
1546
|
+
expect(msg?.content).toMatchObject({ width: 304, height: 228 });
|
|
1547
|
+
const box = container.firstElementChild as HTMLElement;
|
|
1548
|
+
expect(box.style.getPropertyValue("--kc-feed-w")).toBe("1024");
|
|
1549
|
+
expect(box.style.getPropertyValue("--kc-feed-h")).toBe("768");
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
it("falls back to 16:9 while no configured size is known", async () => {
|
|
1553
|
+
const { client } = await buildConnectedSource([]);
|
|
1554
|
+
const { container } = renderFeed(client, { flightId: null });
|
|
1555
|
+
const box = container.firstElementChild as HTMLElement;
|
|
1556
|
+
expect(box.style.getPropertyValue("--kc-feed-w")).toBe("16");
|
|
1557
|
+
expect(box.style.getPropertyValue("--kc-feed-h")).toBe("9");
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1525
1560
|
it("does not report when renderSize is none", async () => {
|
|
1526
1561
|
const { client, sidecar } = await buildConnectedSource();
|
|
1527
1562
|
|
|
@@ -1533,6 +1568,212 @@ describe("CameraFeed - ResizeObserver render-size feedback", () => {
|
|
|
1533
1568
|
});
|
|
1534
1569
|
});
|
|
1535
1570
|
|
|
1571
|
+
// ---------------------------------------------------------------------------
|
|
1572
|
+
// Render size in a narrow, tall tile, with a frame sized from the stream
|
|
1573
|
+
// ---------------------------------------------------------------------------
|
|
1574
|
+
|
|
1575
|
+
describe("CameraFeed - render size in a narrow, tall tile", () => {
|
|
1576
|
+
const PHONE_HALF_TILE = { width: 170, height: 400 };
|
|
1577
|
+
let tile = PHONE_HALF_TILE;
|
|
1578
|
+
let watched: Array<{ el: Element; cb: ResizeObserverCallback; last: string | null }> = [];
|
|
1579
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
1580
|
+
|
|
1581
|
+
/* An element sized in px by its own style has that size, anything inside it fills it, anything else is the tile. */
|
|
1582
|
+
function boxOf(el: Element): { width: number; height: number } {
|
|
1583
|
+
for (let at: Element | null = el; at; at = at.parentElement) {
|
|
1584
|
+
const style = (at as HTMLElement).style;
|
|
1585
|
+
if (style?.width.endsWith("px") && style.height.endsWith("px")) {
|
|
1586
|
+
return { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
return tile;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
function layout() {
|
|
1593
|
+
for (const w of watched) {
|
|
1594
|
+
const box = boxOf(w.el);
|
|
1595
|
+
const key = `${box.width}x${box.height}`;
|
|
1596
|
+
if (key === w.last) continue;
|
|
1597
|
+
w.last = key;
|
|
1598
|
+
w.cb(
|
|
1599
|
+
[{ target: w.el, contentRect: box as DOMRectReadOnly } as ResizeObserverEntry],
|
|
1600
|
+
{} as ResizeObserver,
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
async function run(ms: number) {
|
|
1606
|
+
for (let t = 0; t < ms; t += 16) {
|
|
1607
|
+
await act(async () => {
|
|
1608
|
+
vi.advanceTimersByTime(16);
|
|
1609
|
+
});
|
|
1610
|
+
act(() => layout());
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
beforeEach(() => {
|
|
1615
|
+
vi.useFakeTimers();
|
|
1616
|
+
tile = PHONE_HALF_TILE;
|
|
1617
|
+
watched = [];
|
|
1618
|
+
globalThis.ResizeObserver = class {
|
|
1619
|
+
private readonly cb: ResizeObserverCallback;
|
|
1620
|
+
constructor(cb: ResizeObserverCallback) {
|
|
1621
|
+
this.cb = cb;
|
|
1622
|
+
}
|
|
1623
|
+
observe(el: Element) {
|
|
1624
|
+
watched.push({ el, cb: this.cb, last: null });
|
|
1625
|
+
}
|
|
1626
|
+
unobserve() {}
|
|
1627
|
+
disconnect() {
|
|
1628
|
+
watched = watched.filter((w) => w.cb !== this.cb);
|
|
1629
|
+
}
|
|
1630
|
+
} as unknown as typeof ResizeObserver;
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
afterEach(() => {
|
|
1634
|
+
vi.useRealTimers();
|
|
1635
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
/*
|
|
1639
|
+
* The natural way to keep a video's shape: a frame whose height comes from
|
|
1640
|
+
* the playing video's size, or the camera's render size before it plays.
|
|
1641
|
+
*/
|
|
1642
|
+
function StreamShapedFrame({ client }: { client: KerbcastClient }) {
|
|
1643
|
+
const [root, setRoot] = useState<HTMLDivElement | null>(null);
|
|
1644
|
+
const [, bump] = useState(0);
|
|
1645
|
+
useEffect(() => {
|
|
1646
|
+
if (!root) return;
|
|
1647
|
+
const sync = () => bump((n) => n + 1);
|
|
1648
|
+
root.addEventListener("resize", sync, true);
|
|
1649
|
+
root.addEventListener("loadedmetadata", sync, true);
|
|
1650
|
+
return () => {
|
|
1651
|
+
root.removeEventListener("resize", sync, true);
|
|
1652
|
+
root.removeEventListener("loadedmetadata", sync, true);
|
|
1653
|
+
};
|
|
1654
|
+
}, [root]);
|
|
1655
|
+
return (
|
|
1656
|
+
<KerbcastProvider client={client}>
|
|
1657
|
+
<Shaped root={root} setRoot={setRoot} />
|
|
1658
|
+
</KerbcastProvider>
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function Shaped({
|
|
1663
|
+
root,
|
|
1664
|
+
setRoot,
|
|
1665
|
+
}: {
|
|
1666
|
+
root: HTMLDivElement | null;
|
|
1667
|
+
setRoot: (el: HTMLDivElement | null) => void;
|
|
1668
|
+
}) {
|
|
1669
|
+
const camera = useKerbcastCameras().find((c) => c.flightId === 42);
|
|
1670
|
+
const video = root?.querySelector("video");
|
|
1671
|
+
const aspect =
|
|
1672
|
+
video && video.videoWidth > 0
|
|
1673
|
+
? video.videoWidth / video.videoHeight
|
|
1674
|
+
: camera
|
|
1675
|
+
? camera.renderWidth / camera.renderHeight
|
|
1676
|
+
: 16 / 9;
|
|
1677
|
+
const width = Math.min(tile.width, tile.height * aspect);
|
|
1678
|
+
return (
|
|
1679
|
+
<div
|
|
1680
|
+
ref={setRoot}
|
|
1681
|
+
style={{ width: `${Math.floor(width)}px`, height: `${Math.floor(width / aspect)}px` }}
|
|
1682
|
+
>
|
|
1683
|
+
<CameraFeed flightId={42} />
|
|
1684
|
+
</div>
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
async function connect() {
|
|
1689
|
+
return buildConnectedSource([
|
|
1690
|
+
makeCamera({
|
|
1691
|
+
flightId: 42,
|
|
1692
|
+
cameraName: "Starboard Cam",
|
|
1693
|
+
renderWidth: 1280,
|
|
1694
|
+
renderHeight: 720,
|
|
1695
|
+
maxWidth: 1280,
|
|
1696
|
+
maxHeight: 720,
|
|
1697
|
+
}),
|
|
1698
|
+
]);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
function sizeReports(sidecar: MockSidecar) {
|
|
1702
|
+
return sidecar.commands
|
|
1703
|
+
.filter((c) => c.type === "report-display-size")
|
|
1704
|
+
.map((c) => (c as { content: { flightId: number; width: number; height: number } }).content);
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
async function mountShaped() {
|
|
1708
|
+
const { client, sidecar } = await connect();
|
|
1709
|
+
const view = render(<StreamShapedFrame client={client} />);
|
|
1710
|
+
await run(500);
|
|
1711
|
+
const video = view.container.querySelector("video");
|
|
1712
|
+
if (!video) throw new Error("no video element");
|
|
1713
|
+
return { sidecar, video, view, client };
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
async function streamBecomes(
|
|
1717
|
+
sidecar: MockSidecar,
|
|
1718
|
+
video: HTMLVideoElement,
|
|
1719
|
+
width: number,
|
|
1720
|
+
height: number,
|
|
1721
|
+
) {
|
|
1722
|
+
Object.defineProperty(video, "videoWidth", { value: width, configurable: true });
|
|
1723
|
+
Object.defineProperty(video, "videoHeight", { value: height, configurable: true });
|
|
1724
|
+
await act(async () => {
|
|
1725
|
+
sidecar.updateCamera(42, { renderWidth: width, renderHeight: height });
|
|
1726
|
+
video.dispatchEvent(new Event("loadedmetadata"));
|
|
1727
|
+
video.dispatchEvent(new Event("resize"));
|
|
1728
|
+
});
|
|
1729
|
+
await run(300);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
it("asks a layout-sized tile for one size at the camera's shape", async () => {
|
|
1733
|
+
const { client, sidecar } = await connect();
|
|
1734
|
+
renderFeed(client, { flightId: 42 });
|
|
1735
|
+
await run(500);
|
|
1736
|
+
expect(sizeReports(sidecar)).toEqual([{ flightId: 42, width: 176, height: 100 }]);
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
it("sends no request when the stream comes back smaller, larger or another shape", async () => {
|
|
1740
|
+
const { sidecar, video } = await mountShaped();
|
|
1741
|
+
const before = sizeReports(sidecar).length;
|
|
1742
|
+
for (const [w, h] of [
|
|
1743
|
+
[96, 54],
|
|
1744
|
+
[1920, 1080],
|
|
1745
|
+
[160, 120],
|
|
1746
|
+
[176, 400],
|
|
1747
|
+
] as const) {
|
|
1748
|
+
await streamBecomes(sidecar, video, w, h);
|
|
1749
|
+
await run(2000);
|
|
1750
|
+
}
|
|
1751
|
+
expect(sizeReports(sidecar).length - before).toBe(0);
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1754
|
+
it("sends nothing while the tile holds still and the stream flips between two sizes", async () => {
|
|
1755
|
+
const { sidecar, video } = await mountShaped();
|
|
1756
|
+
const before = sizeReports(sidecar);
|
|
1757
|
+
for (let i = 0; i < 6; i++) {
|
|
1758
|
+
await streamBecomes(sidecar, video, 176, i % 2 ? 128 : 96);
|
|
1759
|
+
}
|
|
1760
|
+
await run(3000);
|
|
1761
|
+
expect(sizeReports(sidecar)).toEqual(before);
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
it("sends exactly one request, sized from the tile, for a resize dragged across several sizes", async () => {
|
|
1765
|
+
const { sidecar, view, client } = await mountShaped();
|
|
1766
|
+
const before = sizeReports(sidecar).length;
|
|
1767
|
+
for (const width of [240, 280, 320]) {
|
|
1768
|
+
tile = { width, height: 400 };
|
|
1769
|
+
view.rerender(<StreamShapedFrame client={client} />);
|
|
1770
|
+
act(() => layout());
|
|
1771
|
+
}
|
|
1772
|
+
await run(1000);
|
|
1773
|
+
expect(sizeReports(sidecar).slice(before)).toEqual([{ flightId: 42, width: 320, height: 180 }]);
|
|
1774
|
+
});
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1536
1777
|
// ---------------------------------------------------------------------------
|
|
1537
1778
|
// showActions
|
|
1538
1779
|
// ---------------------------------------------------------------------------
|