Spaces:
Running
Running
| "use client"; | |
| import { useState, useEffect, useRef, useCallback, memo } from "react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from "@/components/ui/select"; | |
| import { useToast } from "@/hooks/use-toast"; | |
| import { | |
| Disc as Record, | |
| Download, | |
| Upload, | |
| PlusCircle, | |
| Square, | |
| Camera, | |
| Trash2, | |
| Settings, | |
| RefreshCw, | |
| X, | |
| Edit2, | |
| Check, | |
| } from "lucide-react"; | |
| import { | |
| AlertDialog, | |
| AlertDialogAction, | |
| AlertDialogCancel, | |
| AlertDialogContent, | |
| AlertDialogDescription, | |
| AlertDialogFooter, | |
| AlertDialogHeader, | |
| AlertDialogTitle, | |
| } from "@/components/ui/alert-dialog"; | |
| import { record, LeRobotEpisode } from "@lerobot/web"; | |
| import type { RecordProcess } from "@lerobot/web"; | |
| import { TeleoperatorEpisodesView } from "./teleoperator-episodes-view"; | |
| import { uploadToHuggingFace } from "@/utils/dataset-uploader"; | |
| interface RecorderProps { | |
| teleoperators: any[]; | |
| robot: any; // eslint-disable-line @typescript-eslint/no-explicit-any | |
| onNeedsTeleoperation: () => Promise<boolean>; | |
| showConfigure: boolean; | |
| onRecorderReady?: (callbacks: { | |
| startRecording: () => Promise<void>; | |
| stopRecording: () => Promise<void>; | |
| isRecording: boolean; | |
| }) => void; | |
| videoStreams?: { [key: string]: MediaStream }; | |
| } | |
| interface RecorderSettings { | |
| huggingfaceApiKey: string; | |
| huggingfaceRepoName?: string; | |
| huggingfacePrivate?: boolean; | |
| cameraConfigs: { | |
| [cameraName: string]: { | |
| deviceId: string; | |
| deviceLabel: string; | |
| }; | |
| }; | |
| } | |
| // Storage functions for recorder settings | |
| const RECORDER_SETTINGS_KEY = "lerobot-recorder-settings"; | |
| function getRecorderSettings(): RecorderSettings { | |
| try { | |
| const stored = localStorage.getItem(RECORDER_SETTINGS_KEY); | |
| if (stored) { | |
| return JSON.parse(stored); | |
| } | |
| } catch (error) { | |
| console.warn("Failed to load recorder settings:", error); | |
| } | |
| return { | |
| huggingfaceApiKey: "", | |
| huggingfaceRepoName: "", | |
| huggingfacePrivate: true, | |
| cameraConfigs: {}, | |
| }; | |
| } | |
| function saveRecorderSettings(settings: RecorderSettings): void { | |
| try { | |
| localStorage.setItem(RECORDER_SETTINGS_KEY, JSON.stringify(settings)); | |
| } catch (error) { | |
| console.warn("Failed to save recorder settings:", error); | |
| } | |
| } | |
| export function Recorder({ | |
| teleoperators, | |
| robot, | |
| onNeedsTeleoperation, | |
| showConfigure, | |
| onRecorderReady, | |
| }: RecorderProps) { | |
| const [isRecording, setIsRecording] = useState(false); | |
| const [currentEpisode, setCurrentEpisode] = useState(0); | |
| const [persistedEpisodes, setPersistedEpisodes] = useState<any[]>([]); | |
| const [uiTick, setUiTick] = useState(0); | |
| const [showDeleteEpisodesDialog, setShowDeleteEpisodesDialog] = | |
| useState(false); | |
| // Use huggingfaceApiKey from recorderSettings instead of separate state | |
| const [cameraName, setCameraName] = useState(""); | |
| const [additionalCameras, setAdditionalCameras] = useState<{ | |
| [key: string]: MediaStream; | |
| }>({}); | |
| const [availableCameras, setAvailableCameras] = useState<MediaDeviceInfo[]>( | |
| [] | |
| ); | |
| const [selectedCameraId, setSelectedCameraId] = useState<string>(""); | |
| const [previewStream, setPreviewStream] = useState<MediaStream | null>(null); | |
| const [isLoadingCameras, setIsLoadingCameras] = useState(false); | |
| const [cameraPermissionState, setCameraPermissionState] = useState< | |
| "unknown" | "granted" | "denied" | |
| >("unknown"); | |
| const [recorderSettings, setRecorderSettings] = useState<RecorderSettings>( | |
| () => getRecorderSettings() | |
| ); | |
| const [hasRecordedFrames, setHasRecordedFrames] = useState(false); | |
| const [editingCameraName, setEditingCameraName] = useState<string | null>( | |
| null | |
| ); | |
| const [editingCameraNewName, setEditingCameraNewName] = useState(""); | |
| const [sourceSelectorOpenFor, setSourceSelectorOpenFor] = useState< | |
| string | null | |
| >(null); | |
| const [uploadState, setUploadState] = useState< | |
| { status: "idle" } | { status: "uploading" } | { status: "done" } | |
| >({ status: "idle" }); | |
| const recordProcessRef = useRef<RecordProcess | null>(null); | |
| const videoRef = useRef<HTMLVideoElement | null>(null); | |
| const { toast } = useToast(); | |
| // Initialize the recorder when teleoperators are available | |
| useEffect(() => { | |
| if (teleoperators.length > 0 && !recordProcessRef.current) { | |
| (async () => { | |
| // Get robot type/family for metadata | |
| let robotType = "unknown"; | |
| try { | |
| const type = (robot?.robotType || "").toString(); | |
| robotType = type.split("_")[0] || type || "unknown"; | |
| } catch {} | |
| // Create record process with upfront config | |
| const recordProcess = await record({ | |
| teleoperator: teleoperators[0], | |
| videoStreams: additionalCameras, | |
| robotType, | |
| options: { | |
| fps: 30, | |
| taskDescription: "Robot teleoperation recording", | |
| }, | |
| }); | |
| recordProcessRef.current = recordProcess; | |
| // Restore episodes if any were persisted | |
| if (persistedEpisodes.length > 0) { | |
| recordProcess.restoreEpisodes(persistedEpisodes); | |
| setCurrentEpisode(persistedEpisodes.length - 1); | |
| } | |
| // Attach any cameras that were configured before control was enabled | |
| for (const [key, stream] of Object.entries(additionalCameras)) { | |
| try { | |
| recordProcess.addCamera(key, stream as MediaStream); | |
| } catch (e) { | |
| console.warn("[Recorder] init: addCamera failed", key, e); | |
| } | |
| } | |
| })(); | |
| } | |
| }, [teleoperators, persistedEpisodes, additionalCameras]); | |
| // Sync additional cameras into recorder without re-creating it | |
| useEffect(() => { | |
| if (!recordProcessRef.current) return; | |
| if (isRecording) return; // don't change streams during recording | |
| const recordProcess = recordProcessRef.current; | |
| // Add any new cameras | |
| for (const [key, stream] of Object.entries(additionalCameras)) { | |
| try { | |
| recordProcess.addCamera(key, stream); | |
| } catch (e) { | |
| console.warn("Failed to add camera", key, e); | |
| } | |
| } | |
| }, [additionalCameras, isRecording]); | |
| // Notify parent of recorder state changes | |
| useEffect(() => { | |
| if (onRecorderReady) { | |
| onRecorderReady({ | |
| startRecording: handleStartRecordingClick, | |
| stopRecording: handleStopRecording, | |
| isRecording: isRecording, | |
| }); | |
| } | |
| }, [isRecording, onRecorderReady]); | |
| // Simple recording start - just like the original working version | |
| const handleStartRecordingClick = async () => { | |
| if (!recordProcessRef.current) { | |
| toast({ | |
| title: "Recording Error", | |
| description: "Recorder not ready yet. Please enable control first.", | |
| variant: "destructive", | |
| }); | |
| return; | |
| } | |
| try { | |
| // Start recording | |
| recordProcessRef.current.start(); | |
| setIsRecording(true); | |
| setHasRecordedFrames(true); | |
| // Sync current episode to recorder's latest | |
| setCurrentEpisode( | |
| Math.max(0, (recordProcessRef.current.getEpisodeCount() || 1) - 1) | |
| ); | |
| } catch (error) { | |
| const errorMessage = | |
| error instanceof Error ? error.message : "Failed to start recording"; | |
| toast({ | |
| title: "Recording Error", | |
| description: errorMessage, | |
| variant: "destructive", | |
| }); | |
| } | |
| }; | |
| const handleStopRecording = async () => { | |
| if (!recordProcessRef.current || !isRecording) { | |
| return; | |
| } | |
| try { | |
| const result = await recordProcessRef.current.stop(); | |
| setIsRecording(false); | |
| // No need to manually persist - episodes are managed by record process | |
| // Force a small delay to ensure videoBlobs populated before export | |
| await new Promise((r) => setTimeout(r, 50)); | |
| } catch (error) { | |
| const errorMessage = | |
| error instanceof Error ? error.message : "Failed to stop recording"; | |
| toast({ | |
| title: "Recording Error", | |
| description: errorMessage, | |
| variant: "destructive", | |
| }); | |
| } | |
| }; | |
| const handleDeleteEpisodes = async () => { | |
| if (recordProcessRef.current) { | |
| recordProcessRef.current.clearEpisodes(); | |
| } | |
| // Clear persisted episodes only if not recording | |
| if (!isRecording) { | |
| setPersistedEpisodes([]); | |
| } | |
| setCurrentEpisode(0); | |
| setHasRecordedFrames(isRecording); // Keep true if recording, false if not | |
| setShowDeleteEpisodesDialog(false); | |
| // No toast needed; dialog confirmation provides sufficient feedback | |
| }; | |
| const handleNextEpisode = () => { | |
| if (!isRecording || !recordProcessRef.current) { | |
| return; | |
| } | |
| // Finalize current video segment and start a new one | |
| recordProcessRef.current | |
| .nextEpisode() | |
| .then((newIndex: number) => setCurrentEpisode(newIndex)) | |
| .catch(() => { | |
| /* noop */ | |
| }); | |
| }; | |
| // Force lightweight UI refresh while recording so episode table updates in near real-time | |
| useEffect(() => { | |
| if (!isRecording) return; | |
| const id = setInterval(() => { | |
| setUiTick((t) => (t + 1) % 1_000_000); | |
| }, 500); | |
| return () => clearInterval(id); | |
| }, [isRecording]); | |
| // (Removed) Reset frames button in favor of Delete Episodes with confirmation | |
| // Load available cameras | |
| const loadAvailableCameras = useCallback( | |
| async (isAutoLoad = false) => { | |
| if (isLoadingCameras) return; | |
| setIsLoadingCameras(true); | |
| try { | |
| // Check if we already have permission | |
| const permission = await navigator.permissions.query({ | |
| name: "camera" as PermissionName, | |
| }); | |
| setCameraPermissionState( | |
| permission.state === "granted" | |
| ? "granted" | |
| : permission.state === "denied" | |
| ? "denied" | |
| : "unknown" | |
| ); | |
| let tempStream: MediaStream | null = null; | |
| // Try to enumerate devices first (works if we have permission) | |
| const devices = await navigator.mediaDevices.enumerateDevices(); | |
| const videoDevices = devices.filter( | |
| (device) => device.kind === "videoinput" | |
| ); | |
| // If devices have labels, we already have permission | |
| const hasLabels = videoDevices.some((device) => device.label); | |
| let finalVideoDevices = videoDevices; | |
| if (!hasLabels && videoDevices.length > 0) { | |
| // Need to request permission to get device labels | |
| tempStream = await navigator.mediaDevices.getUserMedia({ | |
| video: true, | |
| }); | |
| // Re-enumerate to get labels | |
| const devicesWithLabels = | |
| await navigator.mediaDevices.enumerateDevices(); | |
| const videoDevicesWithLabels = devicesWithLabels.filter( | |
| (device) => device.kind === "videoinput" | |
| ); | |
| finalVideoDevices = videoDevicesWithLabels; | |
| setAvailableCameras(videoDevicesWithLabels); | |
| if (!isAutoLoad) { | |
| console.log( | |
| `Found ${videoDevicesWithLabels.length} video devices:`, | |
| videoDevicesWithLabels.map((d) => d.label || d.deviceId) | |
| ); | |
| } | |
| } else { | |
| setAvailableCameras(videoDevices); | |
| if (!isAutoLoad) { | |
| console.log( | |
| `Found ${videoDevices.length} video devices:`, | |
| videoDevices.map((d) => d.label || d.deviceId) | |
| ); | |
| } | |
| } | |
| // Auto-select and preview first camera if none selected | |
| if (finalVideoDevices.length > 0 && !selectedCameraId) { | |
| const firstCameraId = finalVideoDevices[0].deviceId; | |
| // Stop temp stream since we'll create a fresh one with switchCameraPreview | |
| if (tempStream) { | |
| tempStream.getTracks().forEach((track) => track.stop()); | |
| } | |
| setCameraPermissionState("granted"); | |
| // Use the same logic as manual camera switching | |
| await switchCameraPreview(firstCameraId); | |
| } else if (tempStream) { | |
| // Stop temp stream if we didn't use it | |
| tempStream.getTracks().forEach((track) => track.stop()); | |
| } | |
| } catch (error) { | |
| setCameraPermissionState("denied"); | |
| if (!isAutoLoad) { | |
| toast({ | |
| title: "Camera Error", | |
| description: `Failed to load cameras: ${ | |
| error instanceof Error ? error.message : String(error) | |
| }`, | |
| variant: "destructive", | |
| }); | |
| } | |
| } finally { | |
| setIsLoadingCameras(false); | |
| } | |
| }, | |
| [selectedCameraId, toast] | |
| ); | |
| // Switch camera preview | |
| const switchCameraPreview = useCallback( | |
| async (deviceId: string) => { | |
| try { | |
| // Stop current preview stream | |
| if (previewStream) { | |
| previewStream.getTracks().forEach((track) => track.stop()); | |
| } | |
| // Start new stream with selected camera | |
| const newStream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| deviceId: { exact: deviceId }, | |
| width: { ideal: 1280 }, | |
| height: { ideal: 720 }, | |
| }, | |
| }); | |
| setPreviewStream(newStream); | |
| setSelectedCameraId(deviceId); | |
| } catch (error) { | |
| toast({ | |
| title: "Camera Error", | |
| description: `Failed to switch camera: ${ | |
| error instanceof Error ? error.message : String(error) | |
| }`, | |
| variant: "destructive", | |
| }); | |
| } | |
| }, | |
| [previewStream, toast] | |
| ); | |
| // Change camera source for an already-added camera card | |
| const handleChangeCameraSourceForCard = useCallback( | |
| async (cameraName: string, deviceId: string) => { | |
| if (hasRecordedFrames) { | |
| toast({ | |
| title: "Camera Error", | |
| description: | |
| "Cannot change camera source after recording has started", | |
| variant: "destructive", | |
| }); | |
| return; | |
| } | |
| try { | |
| // Create new stream for selected device | |
| const newStream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| deviceId: { exact: deviceId }, | |
| width: { ideal: 1280 }, | |
| height: { ideal: 720 }, | |
| }, | |
| }); | |
| // Replace existing stream in additionalCameras | |
| setAdditionalCameras((prev) => { | |
| const next = { ...prev }; | |
| const old = next[cameraName]; | |
| if (old) { | |
| old.getTracks().forEach((t) => t.stop()); | |
| } | |
| next[cameraName] = newStream; | |
| return next; | |
| }); | |
| // Update persistent config | |
| const selected = availableCameras.find((c) => c.deviceId === deviceId); | |
| const newSettings = { | |
| ...recorderSettings, | |
| cameraConfigs: { ...recorderSettings.cameraConfigs }, | |
| }; | |
| if (!newSettings.cameraConfigs[cameraName]) { | |
| newSettings.cameraConfigs[cameraName] = { | |
| deviceId, | |
| deviceLabel: selected?.label || `Camera ${deviceId.slice(0, 8)}...`, | |
| }; | |
| } else { | |
| newSettings.cameraConfigs[cameraName].deviceId = deviceId; | |
| newSettings.cameraConfigs[cameraName].deviceLabel = | |
| selected?.label || `Camera ${deviceId.slice(0, 8)}...`; | |
| } | |
| setRecorderSettings(newSettings); | |
| saveRecorderSettings(newSettings); | |
| setSourceSelectorOpenFor(null); | |
| toast({ | |
| title: "Camera Source Updated", | |
| description: `\"${cameraName}\" now uses \"${ | |
| selected?.label || deviceId | |
| }\"`, | |
| }); | |
| } catch (error) { | |
| toast({ | |
| title: "Camera Error", | |
| description: `Failed to switch camera: ${ | |
| error instanceof Error ? error.message : String(error) | |
| }`, | |
| variant: "destructive", | |
| }); | |
| } | |
| }, | |
| [hasRecordedFrames, availableCameras, recorderSettings, toast] | |
| ); | |
| // Add a new camera to the recorder | |
| const handleAddCamera = useCallback(async () => { | |
| if (!cameraName.trim()) { | |
| toast({ | |
| title: "Camera Error", | |
| description: "Please enter a camera name", | |
| variant: "destructive", | |
| }); | |
| return; | |
| } | |
| if (hasRecordedFrames) { | |
| toast({ | |
| title: "Camera Error", | |
| description: "Cannot add cameras after recording has started", | |
| variant: "destructive", | |
| }); | |
| return; | |
| } | |
| if (!selectedCameraId) { | |
| toast({ | |
| title: "Camera Error", | |
| description: "Please select a camera first", | |
| variant: "destructive", | |
| }); | |
| return; | |
| } | |
| try { | |
| // Use the current preview stream (already running with correct camera) | |
| if (!previewStream) { | |
| throw new Error("No camera preview available"); | |
| } | |
| // Clone the stream for recording (keep preview running) | |
| const recordingStream = previewStream.clone(); | |
| // Add the new camera to our state | |
| setAdditionalCameras((prev) => ({ | |
| ...prev, | |
| [cameraName]: recordingStream, | |
| })); | |
| // Save camera configuration to persistent storage | |
| const selectedCamera = availableCameras.find( | |
| (cam) => cam.deviceId === selectedCameraId | |
| ); | |
| const newSettings = { | |
| ...recorderSettings, | |
| cameraConfigs: { | |
| ...recorderSettings.cameraConfigs, | |
| [cameraName]: { | |
| deviceId: selectedCameraId, | |
| deviceLabel: | |
| selectedCamera?.label || | |
| `Camera ${selectedCameraId.slice(0, 8)}...`, | |
| }, | |
| }, | |
| }; | |
| setRecorderSettings(newSettings); | |
| saveRecorderSettings(newSettings); | |
| setCameraName(""); // Clear the input | |
| toast({ | |
| title: "Camera Added", | |
| description: `Camera "${cameraName}" has been added to the recorder`, | |
| }); | |
| } catch (error) { | |
| toast({ | |
| title: "Camera Error", | |
| description: `Failed to access camera: ${ | |
| error instanceof Error ? error.message : String(error) | |
| }`, | |
| variant: "destructive", | |
| }); | |
| } | |
| }, [ | |
| cameraName, | |
| hasRecordedFrames, | |
| selectedCameraId, | |
| previewStream, | |
| availableCameras, | |
| recorderSettings, | |
| toast, | |
| ]); | |
| // Remove a camera from the recorder | |
| const handleRemoveCamera = useCallback( | |
| (name: string) => { | |
| if (hasRecordedFrames) { | |
| toast({ | |
| title: "Camera Error", | |
| description: "Cannot remove cameras after recording has started", | |
| variant: "destructive", | |
| }); | |
| return; | |
| } | |
| setAdditionalCameras((prev) => { | |
| const newCameras = { ...prev }; | |
| if (newCameras[name]) { | |
| // Stop the stream tracks | |
| newCameras[name].getTracks().forEach((track) => track.stop()); | |
| delete newCameras[name]; | |
| } | |
| return newCameras; | |
| }); | |
| // Remove camera configuration from persistent storage | |
| const newSettings = { | |
| ...recorderSettings, | |
| cameraConfigs: { ...recorderSettings.cameraConfigs }, | |
| }; | |
| delete newSettings.cameraConfigs[name]; | |
| setRecorderSettings(newSettings); | |
| saveRecorderSettings(newSettings); | |
| toast({ | |
| title: "Camera Removed", | |
| description: `Camera "${name}" has been removed`, | |
| }); | |
| }, | |
| [hasRecordedFrames, recorderSettings, toast] | |
| ); | |
| // Camera name editing functions | |
| const handleStartEditingCameraName = (cameraName: string) => { | |
| setEditingCameraName(cameraName); | |
| setEditingCameraNewName(cameraName); | |
| }; | |
| const handleConfirmCameraNameEdit = (oldName: string) => { | |
| if (editingCameraNewName.trim() && editingCameraNewName !== oldName) { | |
| const stream = additionalCameras[oldName]; | |
| if (stream) { | |
| // Update camera streams | |
| setAdditionalCameras((prev) => { | |
| const newCameras = { ...prev }; | |
| delete newCameras[oldName]; | |
| newCameras[editingCameraNewName.trim()] = stream; | |
| return newCameras; | |
| }); | |
| // Update camera configuration in persistent storage | |
| const oldConfig = recorderSettings.cameraConfigs[oldName]; | |
| if (oldConfig) { | |
| const newSettings = { | |
| ...recorderSettings, | |
| cameraConfigs: { ...recorderSettings.cameraConfigs }, | |
| }; | |
| delete newSettings.cameraConfigs[oldName]; | |
| newSettings.cameraConfigs[editingCameraNewName.trim()] = oldConfig; | |
| setRecorderSettings(newSettings); | |
| saveRecorderSettings(newSettings); | |
| } | |
| } | |
| } | |
| setEditingCameraName(null); | |
| setEditingCameraNewName(""); | |
| }; | |
| const handleCancelCameraNameEdit = () => { | |
| setEditingCameraName(null); | |
| setEditingCameraNewName(""); | |
| }; | |
| // Restore cameras from saved configurations | |
| const restoreSavedCameras = useCallback(async () => { | |
| const savedConfigs = recorderSettings.cameraConfigs; | |
| if (!savedConfigs || Object.keys(savedConfigs).length === 0) { | |
| return; | |
| } | |
| for (const [cameraName, config] of Object.entries(savedConfigs)) { | |
| try { | |
| // Check if this camera is still available | |
| const isDeviceAvailable = availableCameras.some( | |
| (cam) => cam.deviceId === config.deviceId | |
| ); | |
| if (!isDeviceAvailable) { | |
| console.warn( | |
| `Saved camera "${cameraName}" (${config.deviceId}) is no longer available` | |
| ); | |
| continue; | |
| } | |
| // Create stream for this saved camera | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| deviceId: { exact: config.deviceId }, | |
| width: { ideal: 1280 }, | |
| height: { ideal: 720 }, | |
| }, | |
| }); | |
| // Add to additional cameras | |
| setAdditionalCameras((prev) => ({ | |
| ...prev, | |
| [cameraName]: stream, | |
| })); | |
| } catch (error) { | |
| console.error(`Failed to restore camera "${cameraName}":`, error); | |
| // Remove invalid configuration | |
| const newSettings = { | |
| ...recorderSettings, | |
| cameraConfigs: { ...recorderSettings.cameraConfigs }, | |
| }; | |
| delete newSettings.cameraConfigs[cameraName]; | |
| setRecorderSettings(newSettings); | |
| saveRecorderSettings(newSettings); | |
| } | |
| } | |
| }, [availableCameras, recorderSettings]); | |
| // Auto-load cameras on component mount (only once) | |
| useEffect(() => { | |
| loadAvailableCameras(true); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, []); // Empty dependency array to run only once | |
| // Handle video stream assignment - runs when stream changes OR when settings panel opens | |
| useEffect(() => { | |
| if (videoRef.current && previewStream) { | |
| videoRef.current.srcObject = previewStream; | |
| } | |
| }, [previewStream, showConfigure]); // Also depend on showConfigure so it re-runs when video element appears | |
| // Cleanup preview stream on unmount | |
| useEffect(() => { | |
| return () => { | |
| if (previewStream) { | |
| previewStream.getTracks().forEach((track) => track.stop()); | |
| } | |
| }; | |
| }, [previewStream]); | |
| // Restore saved cameras when available cameras are loaded | |
| useEffect(() => { | |
| if (availableCameras.length > 0 && cameraPermissionState === "granted") { | |
| restoreSavedCameras(); | |
| } | |
| }, [availableCameras, cameraPermissionState, restoreSavedCameras]); | |
| const handleDownloadZip = async () => { | |
| if (!recordProcessRef.current) return; | |
| try { | |
| if (isRecording) { | |
| await handleStopRecording(); | |
| } | |
| await recordProcessRef.current.exportForLeRobot("zip-download"); | |
| } catch (e) { | |
| toast({ | |
| title: "Export Error", | |
| description: | |
| e instanceof Error ? e.message : "Failed to export the dataset", | |
| variant: "destructive", | |
| }); | |
| return; | |
| } | |
| // No toast; the browser download prompt is sufficient feedback | |
| }; | |
| const handleUploadToHuggingFace = async () => { | |
| if (!recordProcessRef.current) return; | |
| if (!recorderSettings.huggingfaceApiKey) { | |
| toast({ | |
| title: "Upload Error", | |
| description: "Please enter your Hugging Face API key in Configure", | |
| variant: "destructive", | |
| }); | |
| return; | |
| } | |
| try { | |
| // Use provided repo name or generate one | |
| const repoName = | |
| (recorderSettings.huggingfaceRepoName || "").trim() || | |
| `lerobot-recording-${Date.now()}`; | |
| // Get blobs from recorder | |
| const blobArray = await recordProcessRef.current.exportForLeRobot( | |
| "blobs" | |
| ); | |
| // Upload using demo utility | |
| const uploader = await uploadToHuggingFace( | |
| blobArray, | |
| recorderSettings.huggingfaceApiKey, | |
| repoName, | |
| !!recorderSettings.huggingfacePrivate | |
| ); | |
| // Progress UI next to button | |
| setUploadState({ status: "uploading" }); | |
| const onProgress = (_event: Event) => { | |
| // Spinner-only UI; keep uploading state | |
| setUploadState({ status: "uploading" }); | |
| }; | |
| const onFinished = () => setUploadState({ status: "done" }); | |
| const onError = () => setUploadState({ status: "idle" }); | |
| uploader.addEventListener("progress", onProgress); | |
| uploader.addEventListener("finished", onFinished); | |
| uploader.addEventListener("error", onError); | |
| // No fake progress; spinner indicates activity | |
| } catch (error) { | |
| const errorMessage = | |
| error instanceof Error | |
| ? error.message | |
| : "Failed to upload to Hugging Face"; | |
| toast({ | |
| title: "Upload Error", | |
| description: errorMessage, | |
| variant: "destructive", | |
| }); | |
| } | |
| }; | |
| return ( | |
| <div className="space-y-6"> | |
| {/* Recorder Settings - Toggleable Inline */} | |
| {showConfigure && ( | |
| <div className="space-y-6"> | |
| {/* Hugging Face Settings */} | |
| <div className="space-y-3"> | |
| <h3 className="text-lg font-semibold text-foreground">Settings</h3> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <div className="space-y-2"> | |
| <label className="text-sm text-muted-foreground"> | |
| Hugging Face API Key | |
| </label> | |
| <Input | |
| placeholder="Enter your Hugging Face API key" | |
| value={recorderSettings.huggingfaceApiKey} | |
| onChange={(e) => { | |
| const newSettings = { | |
| ...recorderSettings, | |
| huggingfaceApiKey: e.target.value, | |
| }; | |
| setRecorderSettings(newSettings); | |
| saveRecorderSettings(newSettings); | |
| }} | |
| type="password" | |
| className="bg-black/20 border-white/10" | |
| /> | |
| <p className="text-xs text-white/50"> | |
| Required to upload datasets to Hugging Face Hub | |
| </p> | |
| </div> | |
| <div className="space-y-2"> | |
| <label className="text-sm text-muted-foreground"> | |
| Hugging Face Repo Name | |
| </label> | |
| <Input | |
| placeholder="e.g. my-dataset-name" | |
| value={recorderSettings.huggingfaceRepoName || ""} | |
| onChange={(e) => { | |
| const newSettings = { | |
| ...recorderSettings, | |
| huggingfaceRepoName: e.target.value, | |
| }; | |
| setRecorderSettings(newSettings); | |
| saveRecorderSettings(newSettings); | |
| }} | |
| className="bg-black/20 border-white/10" | |
| /> | |
| <label className="flex items-center gap-2 text-sm text-muted-foreground"> | |
| <input | |
| type="checkbox" | |
| className="accent-white" | |
| checked={!!recorderSettings.huggingfacePrivate} | |
| onChange={(e) => { | |
| const newSettings = { | |
| ...recorderSettings, | |
| huggingfacePrivate: e.target.checked, | |
| }; | |
| setRecorderSettings(newSettings); | |
| saveRecorderSettings(newSettings); | |
| }} | |
| /> | |
| Private repository | |
| </label> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Camera Configuration */} | |
| <div className="space-y-4"> | |
| <h3 className="text-lg font-semibold text-foreground"> | |
| Camera Setup | |
| </h3> | |
| <div className="bg-black/40 border border-white/20 p-6"> | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> | |
| {/* Left Column: Camera Selection & Adding */} | |
| <div className="space-y-4"> | |
| {/* Camera Selection and Refresh */} | |
| {/* Camera Access Request */} | |
| {cameraPermissionState === "unknown" && ( | |
| <div className="space-y-3"> | |
| <div className="bg-black/60 border border-white/20 rounded-lg p-4 text-center"> | |
| <Camera className="w-8 h-8 mx-auto mb-2 opacity-50" /> | |
| <p className="text-sm text-white/70 mb-3"> | |
| Camera access needed to configure cameras | |
| </p> | |
| <Button | |
| onClick={() => loadAvailableCameras(false)} | |
| variant="outline" | |
| className="gap-2" | |
| disabled={isLoadingCameras} | |
| > | |
| <Camera className="w-4 h-4" /> | |
| {isLoadingCameras | |
| ? "Loading..." | |
| : "Request Camera Access"} | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Camera Access Denied */} | |
| {cameraPermissionState === "denied" && ( | |
| <div className="space-y-3"> | |
| <div className="bg-red-900/20 border border-red-500/20 rounded-lg p-4 text-center"> | |
| <Camera className="w-8 h-8 mx-auto mb-2 opacity-50 text-red-400" /> | |
| <p className="text-sm text-red-300 mb-1"> | |
| Camera access denied | |
| </p> | |
| <p className="text-xs text-red-400"> | |
| Please allow camera access in your browser settings | |
| and refresh | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| {/* Camera List with Refresh Button */} | |
| {cameraPermissionState === "granted" && | |
| availableCameras.length > 0 && ( | |
| <div className="space-y-2"> | |
| <label className="text-sm text-white/70"> | |
| Select Camera: | |
| </label> | |
| <div className="flex items-center gap-2"> | |
| <Select | |
| value={selectedCameraId} | |
| onValueChange={switchCameraPreview} | |
| disabled={hasRecordedFrames} | |
| > | |
| <SelectTrigger className="flex-1 bg-black/20 border-white/10"> | |
| <SelectValue placeholder="Choose a camera" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| {availableCameras.map((camera) => ( | |
| <SelectItem | |
| key={camera.deviceId} | |
| value={camera.deviceId} | |
| > | |
| {camera.label || | |
| `Camera ${camera.deviceId.slice(0, 8)}...`} | |
| </SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| <Button | |
| onClick={() => loadAvailableCameras(false)} | |
| variant="ghost" | |
| size="sm" | |
| className="gap-2 text-white/70 hover:text-white" | |
| disabled={isLoadingCameras} | |
| > | |
| <RefreshCw | |
| className={`w-4 h-4 ${ | |
| isLoadingCameras ? "animate-spin" : "" | |
| }`} | |
| /> | |
| Refresh | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Camera Name Input */} | |
| {selectedCameraId && ( | |
| <div className="space-y-2"> | |
| <label className="text-sm text-white/70"> | |
| Camera Name: | |
| </label> | |
| <Input | |
| placeholder="e.g., 'Overhead View', 'Side Angle', 'Close-up'" | |
| value={cameraName} | |
| onChange={(e) => setCameraName(e.target.value)} | |
| className="bg-black/20 border-white/10" | |
| disabled={hasRecordedFrames} | |
| /> | |
| <p className="text-xs text-white/50"> | |
| Give this camera a descriptive name for your recording | |
| setup | |
| </p> | |
| </div> | |
| )} | |
| {/* Add Camera Button */} | |
| {selectedCameraId && ( | |
| <div className="flex justify-end"> | |
| <Button | |
| onClick={handleAddCamera} | |
| className="gap-2" | |
| disabled={ | |
| hasRecordedFrames || | |
| !cameraName.trim() || | |
| !selectedCameraId || | |
| !previewStream | |
| } | |
| > | |
| <PlusCircle className="w-4 h-4" /> | |
| Add Camera to Recorder | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| {/* Right Column: Camera Preview */} | |
| <div className="space-y-4"> | |
| <div className="aspect-video bg-black/60 border border-white/20 rounded-lg overflow-hidden"> | |
| {previewStream ? ( | |
| <video | |
| ref={videoRef} | |
| autoPlay | |
| muted | |
| playsInline | |
| className="w-full h-full object-cover" | |
| /> | |
| ) : ( | |
| <div className="w-full h-full flex items-center justify-center text-white/60"> | |
| <div className="text-center"> | |
| <Camera className="w-12 h-12 mx-auto mb-2 opacity-50" /> | |
| {cameraPermissionState === "unknown" ? ( | |
| <p className="text-sm"> | |
| Request camera access to preview | |
| </p> | |
| ) : cameraPermissionState === "denied" ? ( | |
| <p className="text-sm">Camera access denied</p> | |
| ) : availableCameras.length === 0 ? ( | |
| <p className="text-sm">No cameras available</p> | |
| ) : !selectedCameraId ? ( | |
| <p className="text-sm"> | |
| Select a camera to preview | |
| </p> | |
| ) : ( | |
| <p className="text-sm">Loading preview...</p> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Added Camera Previews */} | |
| {Object.keys(additionalCameras).length > 0 && ( | |
| <div className="space-y-4"> | |
| <h3 className="text-lg font-semibold text-foreground"> | |
| Active Cameras | |
| </h3> | |
| <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> | |
| {Object.entries(additionalCameras).map(([cameraName, stream]) => ( | |
| <div | |
| key={cameraName} | |
| className="bg-black/40 border border-white/20 rounded-lg p-3 space-y-2" | |
| > | |
| <div className="aspect-video bg-black/60 border border-white/10 rounded overflow-hidden"> | |
| <video | |
| autoPlay | |
| muted | |
| playsInline | |
| className="w-full h-full object-cover" | |
| ref={(video) => { | |
| if (video && stream) { | |
| const current = video.srcObject as MediaStream | null; | |
| if (current !== stream) { | |
| video.srcObject = stream; | |
| } | |
| } | |
| }} | |
| /> | |
| </div> | |
| <div className="flex items-center justify-between"> | |
| {editingCameraName === cameraName ? ( | |
| <div className="flex items-center gap-1 flex-1"> | |
| <Input | |
| value={editingCameraNewName} | |
| onChange={(e) => | |
| setEditingCameraNewName(e.target.value) | |
| } | |
| className="text-xs h-6 bg-black/20 border-white/10" | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter") { | |
| handleConfirmCameraNameEdit(cameraName); | |
| } else if (e.key === "Escape") { | |
| handleCancelCameraNameEdit(); | |
| } | |
| }} | |
| autoFocus | |
| /> | |
| <button | |
| onClick={() => handleConfirmCameraNameEdit(cameraName)} | |
| className="text-green-400 hover:text-green-300 p-1" | |
| > | |
| <Check className="w-3 h-3" /> | |
| </button> | |
| <button | |
| onClick={handleCancelCameraNameEdit} | |
| className="text-red-400 hover:text-red-300 p-1" | |
| > | |
| <X className="w-3 h-3" /> | |
| </button> | |
| </div> | |
| ) : ( | |
| <button | |
| onClick={() => handleStartEditingCameraName(cameraName)} | |
| className="text-sm font-medium text-white/90 truncate hover:text-white cursor-pointer flex items-center gap-1 flex-1" | |
| disabled={hasRecordedFrames} | |
| > | |
| {cameraName} | |
| <Edit2 className="w-3 h-3 opacity-50" /> | |
| </button> | |
| )} | |
| <div className="flex items-center gap-2 ml-2"> | |
| {/* Inline camera source selector trigger */} | |
| <div className="relative"> | |
| <button | |
| onClick={() => | |
| setSourceSelectorOpenFor((prev) => | |
| prev === cameraName ? null : cameraName | |
| ) | |
| } | |
| className="text-white/80 hover:text-white p-1" | |
| disabled={hasRecordedFrames} | |
| title="Change camera source" | |
| > | |
| <Camera className="w-4 h-4" /> | |
| </button> | |
| {sourceSelectorOpenFor === cameraName && ( | |
| <div className="absolute right-0 mt-1 z-20 bg-black/90 border border-white/20 rounded p-2 w-64"> | |
| <label className="text-xs text-white/70"> | |
| Camera Source | |
| </label> | |
| <Select | |
| value={ | |
| recorderSettings.cameraConfigs[cameraName] | |
| ?.deviceId || "" | |
| } | |
| onValueChange={(deviceId) => | |
| handleChangeCameraSourceForCard( | |
| cameraName, | |
| deviceId | |
| ) | |
| } | |
| disabled={hasRecordedFrames} | |
| > | |
| <SelectTrigger className="w-full h-8 bg-black/20 border-white/10"> | |
| <SelectValue placeholder="Choose a camera" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| {availableCameras.map((cam) => ( | |
| <SelectItem | |
| key={cam.deviceId} | |
| value={cam.deviceId} | |
| > | |
| {cam.label || | |
| `Camera ${cam.deviceId.slice(0, 8)}...`} | |
| </SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| )} | |
| </div> | |
| <button | |
| onClick={() => handleRemoveCamera(cameraName)} | |
| className="text-red-400 hover:text-red-300 p-1" | |
| disabled={hasRecordedFrames} | |
| title="Remove camera" | |
| > | |
| <X className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Episode Management & Dataset Actions */} | |
| <div className="flex justify-between items-center"> | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| variant="outline" | |
| className="gap-2" | |
| onClick={handleNextEpisode} | |
| disabled={!isRecording} | |
| > | |
| <PlusCircle className="w-4 h-4" /> | |
| Next Episode | |
| </Button> | |
| <Button | |
| variant="outline" | |
| className="gap-2" | |
| onClick={() => setShowDeleteEpisodesDialog(true)} | |
| disabled={ | |
| persistedEpisodes.length === 0 && | |
| (recordProcessRef.current?.getEpisodeCount() || 0) === 0 | |
| } | |
| > | |
| <Trash2 className="w-4 h-4" /> | |
| Delete Episodes | |
| </Button> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| variant="outline" | |
| className="gap-2" | |
| onClick={handleDownloadZip} | |
| disabled={ | |
| (recordProcessRef.current?.getEpisodeCount() || 0) === 0 || | |
| isRecording | |
| } | |
| > | |
| <Download className="w-4 h-4" /> | |
| Download as ZIP | |
| </Button> | |
| <Button | |
| variant="outline" | |
| className="gap-2 relative" | |
| onClick={handleUploadToHuggingFace} | |
| disabled={ | |
| (recordProcessRef.current?.getEpisodeCount() || 0) === 0 || | |
| isRecording || | |
| !recorderSettings.huggingfaceApiKey || | |
| uploadState.status === "uploading" | |
| } | |
| > | |
| <Upload className="w-4 h-4" /> | |
| {uploadState.status === "uploading" ? ( | |
| <span className="inline-flex items-center gap-2"> | |
| Uploading… | |
| <span className="inline-block w-4 h-4 border-2 border-white/50 border-t-white rounded-full animate-spin" /> | |
| </span> | |
| ) : uploadState.status === "done" ? ( | |
| <span>Uploaded ✓</span> | |
| ) : ( | |
| <span>Upload to Hugging Face</span> | |
| )} | |
| </Button> | |
| </div> | |
| </div> | |
| <div className="border border-white/10 rounded-md overflow-hidden"> | |
| <TeleoperatorEpisodesView | |
| teleoperatorData={ | |
| (uiTick, | |
| recordProcessRef.current?.getEpisodes() || persistedEpisodes) | |
| } | |
| isRecording={isRecording} | |
| refreshTick={uiTick} | |
| /> | |
| </div> | |
| {/* Delete Episodes Confirmation Dialog */} | |
| <AlertDialog | |
| open={showDeleteEpisodesDialog} | |
| onOpenChange={setShowDeleteEpisodesDialog} | |
| > | |
| <AlertDialogContent> | |
| <AlertDialogHeader> | |
| <AlertDialogTitle>Delete All Episodes</AlertDialogTitle> | |
| <AlertDialogDescription> | |
| Are you sure you want to delete all recorded episodes? This action | |
| cannot be undone. | |
| </AlertDialogDescription> | |
| </AlertDialogHeader> | |
| <AlertDialogFooter> | |
| <AlertDialogCancel>Cancel</AlertDialogCancel> | |
| <AlertDialogAction | |
| onClick={handleDeleteEpisodes} | |
| className="bg-destructive text-destructive-foreground hover:bg-destructive/90" | |
| > | |
| Delete Episodes | |
| </AlertDialogAction> | |
| </AlertDialogFooter> | |
| </AlertDialogContent> | |
| </AlertDialog> | |
| </div> | |
| ); | |
| } | |
| export const MemoRecorder = memo(Recorder); | |