From 08bc2ba24c5fb75880574399160a20bef2d037c8 Mon Sep 17 00:00:00 2001 From: sysradium Date: Sat, 24 Oct 2026 23:49:06 +0200 Subject: [PATCH] fix(cameras): recover stalled browser streams Request the recorded capture size first, then retry at fallback resolutions when the stream delivers no frames. Keep previews framed to the recorded aspect ratio and crop mismatched streams. --- .../control/BrowserCameraPreview.test.tsx | 21 +++---- .../control/BrowserCameraPreview.tsx | 20 +++--- .../components/control/TeleopCameraPanel.tsx | 14 +++-- .../recording/CameraConfiguration.tsx | 15 ++++- frontend/src/hooks/useCameraBindings.ts | 6 +- frontend/src/hooks/useCameraStream.test.tsx | 61 +++++++++++++++++++ frontend/src/hooks/useCameraStream.ts | 60 ++++++++++++++---- frontend/src/lib/cameraConfig.test.ts | 10 ++- frontend/src/lib/cameraConfig.ts | 31 +++++++++- 9 files changed, 187 insertions(+), 51 deletions(-) create mode 100644 frontend/src/hooks/useCameraStream.test.tsx 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