diff --git a/frontend/src/components/control/BrowserCameraPreview.test.tsx b/frontend/src/components/control/BrowserCameraPreview.test.tsx index 6617378ae..818c762a7 100644 --- a/frontend/src/components/control/BrowserCameraPreview.test.tsx +++ b/frontend/src/components/control/BrowserCameraPreview.test.tsx @@ -19,30 +19,23 @@ test("the Rotation control exposes four clockwise values and emits a number", () act(() => { select.value = "90"; select.dispatchEvent(new Event("change", { bubbles: true })); }); expect(change).toHaveBeenCalledWith(90); }); -test("a quarter turn fits the whole non-square frame and uses actual stream dimensions", () => { +test("a quarter turn fills the rotated recorded frame", () => { act(() => root.render()); const video = container.querySelector("video")!, wrapper = video.parentElement!; expect(wrapper.style.aspectRatio).toBe("480 / 640"); - expect(video.style.objectFit).toBe("contain"); + expect(video.style.objectFit).toBe("cover"); expect(video.style.maxWidth).toBe("none"); expect(video.style.transform).toBe("translate(-50%, -50%) rotate(90deg)"); expect(parseFloat(video.style.width)).toBeCloseTo(640 / 480 * 100); expect(parseFloat(video.style.height)).toBeCloseTo(480 / 640 * 100); - Object.defineProperty(video, "videoWidth", { value: 1280 }); - Object.defineProperty(video, "videoHeight", { value: 720 }); - act(() => video.dispatchEvent(new Event("loadedmetadata"))); - expect(wrapper.style.aspectRatio).toBe("720 / 1280"); - expect(parseFloat(video.style.width)).toBeCloseTo(1280 / 720 * 100); - expect(parseFloat(video.style.height)).toBeCloseTo(720 / 1280 * 100); }); -test("device reassignment does not reuse the old device's frame geometry", () => { - act(() => root.render()); +test("a stream at another size keeps the recorded aspect ratio", () => { + act(() => root.render()); const video = container.querySelector("video")!; - Object.defineProperty(video, "videoWidth", { value: 1920 }); - Object.defineProperty(video, "videoHeight", { value: 1080 }); + Object.defineProperty(video, "videoWidth", { value: 1280 }); + Object.defineProperty(video, "videoHeight", { value: 720 }); act(() => video.dispatchEvent(new Event("loadedmetadata"))); - act(() => root.render()); expect(video.parentElement!.style.aspectRatio).toBe("640 / 480"); expect(video.style.width).toBe("100%"); - expect(video.style.transform).toContain("rotate(180deg)"); + expect(video.style.objectFit).toBe("cover"); }); diff --git a/frontend/src/components/control/BrowserCameraPreview.tsx b/frontend/src/components/control/BrowserCameraPreview.tsx index 0ec1a8a28..de6107d99 100644 --- a/frontend/src/components/control/BrowserCameraPreview.tsx +++ b/frontend/src/components/control/BrowserCameraPreview.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React from "react"; import { VideoOff } from "lucide-react"; import { useCameraStream } from "@/hooks/useCameraStream"; import { CameraRotation, cameraPreviewGeometry, cameraRotationError } from "@/lib/cameraConfig"; @@ -13,7 +13,11 @@ interface Props { fallback?: string; } -/** Browser streams contain raw frames, so only browser previews use CSS rotation. */ +/** + * Browser streams contain raw frames, so only browser previews use CSS rotation. + * The box keeps the recorded capture size's shape even when the browser stream + * has another size, so the preview frames the scene the way recording will. + */ export default function BrowserCameraPreview({ deviceId, paused = false, @@ -24,12 +28,10 @@ export default function BrowserCameraPreview({ fallback, }: Props) { const rotationError = cameraRotationError({ rotation }); - const { videoRef, hasError } = useCameraStream(deviceId, paused || !!rotationError); - const [metadata, setMetadata] = useState<{ deviceId: string; width: number; height: number } | null>(null); - const dimensions = metadata?.deviceId === deviceId ? metadata : { width, height }; + const { videoRef, hasError } = useCameraStream(deviceId, paused || !!rotationError, { width, height }); const geometry = rotationError ? { aspectRatio: `${width} / ${height}`, videoStyle: {} } - : cameraPreviewGeometry(dimensions.width, dimensions.height, rotation); + : cameraPreviewGeometry(width, height, rotation); const showVideo = !rotationError && !paused && deviceId && !hasError; return (
{ - const video = event.currentTarget; - if (video.videoWidth > 0 && video.videoHeight > 0) { - setMetadata({ deviceId, width: video.videoWidth, height: video.videoHeight }); - } - }} /> ) : (
diff --git a/frontend/src/components/control/TeleopCameraPanel.tsx b/frontend/src/components/control/TeleopCameraPanel.tsx index e386554e4..4e61377dc 100644 --- a/frontend/src/components/control/TeleopCameraPanel.tsx +++ b/frontend/src/components/control/TeleopCameraPanel.tsx @@ -4,6 +4,8 @@ import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { useRobots } from "@/hooks/useRobots"; +import { useAvailableCameras } from "@/hooks/useAvailableCameras"; +import { previewDeviceId } from "@/lib/cameraConfig"; import CameraFeed from "./CameraFeed"; /** @@ -23,16 +25,18 @@ const TeleopCameraPanel: React.FC = () => { // attempt) — useful if a camera was unplugged and reconnected. const [reloadKey, setReloadKey] = useState(0); const { selectedRecord, isLoading: robotsLoading } = useRobots(); + const { cameras: availableCameras } = useAvailableCameras({ enabled }); - // Feeds come solely from the robot's configured cameras; each carries a stored - // browser device_id we stream directly. A configured camera whose device is - // currently absent still shows (name + failed-preview placeholder), so the - // user can tell it's expected but not detected. + // Feeds come solely from the robot's configured cameras, streamed by their + // stored browser device_id or, in a browser that doesn't know that ID, by + // their cv2 index. A configured camera whose device is currently absent still + // shows (name + failed-preview placeholder), so the user can tell it's + // expected but not detected. const configured = selectedRecord?.cameras ?? []; const feeds = configured.map((c) => ({ key: c.id, name: c.name, - deviceId: c.device_id, + deviceId: previewDeviceId(c, availableCameras), rotation: c.rotation, width: c.width, height: c.height, diff --git a/frontend/src/components/recording/CameraConfiguration.tsx b/frontend/src/components/recording/CameraConfiguration.tsx index 7337c09c2..aef2d80b9 100644 --- a/frontend/src/components/recording/CameraConfiguration.tsx +++ b/frontend/src/components/recording/CameraConfiguration.tsx @@ -20,7 +20,13 @@ import { useToast } from "@/hooks/use-toast"; import { useAvailableCameras } from "@/hooks/useAvailableCameras"; import BrowserCameraPreview from "@/components/control/BrowserCameraPreview"; import CameraRotationSelect from "@/components/control/CameraRotationSelect"; -import { CameraConfig, cameraRotation, cameraRotationError, rotatedDimensions } from "@/lib/cameraConfig"; +import { + CameraConfig, + cameraRotation, + cameraRotationError, + previewDeviceId, + rotatedDimensions, +} from "@/lib/cameraConfig"; export type { CameraConfig } from "@/lib/cameraConfig"; // Sentinels distinguish "leave unset" (auto-detect / platform default) from an @@ -298,6 +304,7 @@ const CameraConfiguration: React.FC = ({ removeCamera(camera.id)} @@ -324,6 +331,7 @@ const CameraConfiguration: React.FC = ({ interface CameraPreviewProps { camera: CameraConfig; + deviceId: string; paused: boolean; readOnly: boolean; onRemove: () => void; @@ -332,6 +340,7 @@ interface CameraPreviewProps { const CameraPreview: React.FC = ({ camera, + deviceId, paused, readOnly, onRemove, @@ -343,12 +352,12 @@ const CameraPreview: React.FC = ({ return (
{/* Camera Info */} diff --git a/frontend/src/hooks/useCameraBindings.ts b/frontend/src/hooks/useCameraBindings.ts index f8081d332..74b3e329a 100644 --- a/frontend/src/hooks/useCameraBindings.ts +++ b/frontend/src/hooks/useCameraBindings.ts @@ -5,6 +5,7 @@ import { ImageFeatures } from "@/lib/checkpointsApi"; import { CameraRequest, CameraRotation, + liveCameraFor, PhysicalCamera, physicalCameraKey, rotatedDimensions, @@ -43,10 +44,7 @@ export function useCameraBindings( (c) => c.name.toLowerCase() === expectedName.toLowerCase(), ); if (!robotCam) continue; - const live = - (robotCam.device_id && - availableCameras.find((c) => c.deviceId === robotCam.device_id)) || - availableCameras.find((c) => c.index === robotCam.camera_index); + const live = liveCameraFor(robotCam, availableCameras); if (live) { next[expectedName] = live.index; changed = true; diff --git a/frontend/src/hooks/useCameraStream.test.tsx b/frontend/src/hooks/useCameraStream.test.tsx new file mode 100644 index 000000000..eb6c1f328 --- /dev/null +++ b/frontend/src/hooks/useCameraStream.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot, Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { useCameraStream } from "./useCameraStream"; + +let root: Root; +let container: HTMLDivElement; +const getUserMedia = vi.fn(); +const stop = vi.fn(); + +const Preview = () => { + const { videoRef } = useCameraStream("cam", false, { width: 640, height: 480 }); + return