@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.
@@ -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, real w x h)", async () => {
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
- // Measured w x h (no synthetic 16:9 crop), bucketed to a 16-multiple:
1517
- // 400 -> 400, 224 -> 224.
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(224);
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
  // ---------------------------------------------------------------------------