Aditya Shankar
feat: added dataset recording; hf uploader, s3 uploader; runpod trainer (#6)
4384839 unverified
"use client";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
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 { LeRobotDatasetRecorder, LeRobotDatasetRow, NonIndexedLeRobotDatasetRow, LeRobotEpisode } from "@lerobot/web";
import { TeleoperatorEpisodesView } from "./teleoperator-episodes-view";
interface RecorderProps {
teleoperators: any[];
robot: any; // eslint-disable-line @typescript-eslint/no-explicit-any
onNeedsTeleoperation: () => Promise<boolean>;
videoStreams?: { [key: string]: MediaStream };
}
interface RecorderSettings {
huggingfaceApiKey: string;
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: "",
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,
}: RecorderProps) {
const [isRecording, setIsRecording] = useState(false);
const [currentEpisode, setCurrentEpisode] = useState(0);
// 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 [showCameraConfig, setShowCameraConfig] = useState(false);
const [showConfigure, setShowConfigure] = useState(false);
const [recorderSettings, setRecorderSettings] = useState<RecorderSettings>(
() => getRecorderSettings()
);
const [hasRecordedFrames, setHasRecordedFrames] = useState(false);
const [editingCameraName, setEditingCameraName] = useState<string | null>(
null
);
const [editingCameraNewName, setEditingCameraNewName] = useState("");
const [huggingfaceApiKey, setHuggingfaceApiKey] = useState("")
const recorderRef = useRef<LeRobotDatasetRecorder | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const { toast } = useToast();
// Initialize the recorder when teleoperators are available
useEffect(() => {
if (teleoperators.length > 0) {
recorderRef.current = new LeRobotDatasetRecorder(
teleoperators,
additionalCameras,
30, // fps
"Robot teleoperation recording"
);
}
}, [teleoperators, additionalCameras]);
const handleStartRecording = async () => {
// If teleoperators aren't available, initialize teleoperation first
if (teleoperators.length === 0) {
toast({
title: "Initializing...",
description: `Setting up robot control for ${robot.robotId || "robot"}`,
});
const success = await onNeedsTeleoperation();
if (!success) {
toast({
title: "Recording Error",
description: "Failed to initialize robot control",
variant: "destructive",
});
return;
}
// Wait a moment for the recorder to initialize with new teleoperators
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (!recorderRef.current) {
toast({
title: "Recording Error",
description: "Recorder not ready yet. Please try again.",
variant: "destructive",
});
return;
}
try {
// Set the episode index
recorderRef.current.setEpisodeIndex(currentEpisode);
recorderRef.current.setTaskIndex(0); // Default task index
// Start recording
recorderRef.current.startRecording();
setIsRecording(true);
setHasRecordedFrames(true);
toast({
title: "Recording Started",
description: `Episode ${currentEpisode} is now recording`,
});
} 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 (!recorderRef.current || !isRecording) {
return;
}
try {
const result = await recorderRef.current.stopRecording();
setIsRecording(false);
toast({
title: "Recording Stopped",
description: `Episode ${currentEpisode} completed with ${result.teleoperatorData.length} frames`,
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to stop recording";
toast({
title: "Recording Error",
description: errorMessage,
variant: "destructive",
});
}
};
const handleNextEpisode = () => {
// Make sure we're not recording
if (isRecording) {
handleStopRecording();
}
// Increment episode counter
setCurrentEpisode((prev) => prev + 1);
toast({
title: "New Episode",
description: `Ready to record episode ${currentEpisode + 1}`,
});
};
// Reset frames by clearing the recorder data
const handleResetFrames = useCallback(() => {
if (isRecording) {
handleStopRecording();
}
if (recorderRef.current) {
recorderRef.current.clearRecording();
setHasRecordedFrames(false);
toast({
title: "Frames Reset",
description: "All recorded frames have been cleared",
});
}
}, [isRecording, toast]);
// 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]
);
// 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 (!recorderRef.current) {
toast({
title: "Download Error",
description: "Recorder not initialized",
variant: "destructive",
});
return;
}
await recorderRef.current.exportForLeRobot("zip-download");
toast({
title: "Download Started",
description: "Your dataset is being downloaded as a ZIP file",
});
};
const handleUploadToHuggingFace = async () => {
if (!recorderRef.current) {
toast({
title: "Upload Error",
description: "Recorder not initialized",
variant: "destructive",
});
return;
}
if (!recorderSettings.huggingfaceApiKey) {
toast({
title: "Upload Error",
description: "Please enter your Hugging Face API key in Configure",
variant: "destructive",
});
return;
}
try {
toast({
title: "Upload Started",
description: "Uploading dataset to Hugging Face...",
});
// Generate a unique repository name
const repoName = `lerobot-recording-${Date.now()}`;
const uploader = await recorderRef.current.exportForLeRobot(
"huggingface",
{
repoName,
accessToken: recorderSettings.huggingfaceApiKey,
}
);
uploader.addEventListener("progress", (event: Event) => {
console.log(event);
});
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: "Failed to upload to Hugging Face";
toast({
title: "Upload Error",
description: errorMessage,
variant: "destructive",
});
}
};
// Helper function to format duration
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
};
return (
<Card className="border-0 rounded-none mt-6">
<div className="p-4 border-b border-white/10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-1 h-8 bg-primary"></div>
<div>
<h3 className="text-xl font-bold text-foreground font-mono tracking-wider uppercase">
robot movement recorder
</h3>
<p className="text-sm text-muted-foreground font-mono">
dataset <span className="text-muted-foreground">recording</span>{" "}
interface
</p>
</div>
</div>
<div className="flex items-center gap-6">
<div className="border-l border-white/10 pl-6 flex items-center gap-4">
<Button
variant="outline"
size="lg"
className="gap-2"
onClick={() => setShowConfigure(!showConfigure)}
>
<Settings className="w-5 h-5" />
Configure
</Button>
<Button
variant={isRecording ? "destructive" : "default"}
size="lg"
className="gap-2"
onClick={
isRecording ? handleStopRecording : handleStartRecording
}
>
{isRecording ? (
<>
<Square className="w-5 h-5" />
Stop Recording
</>
) : (
<>
<Record className="w-5 h-5" />
Start Recording
</>
)}
</Button>
</div>
</div>
</div>
</div>
<div className="p-6 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>
</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) {
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>
)}
<button
onClick={() => handleRemoveCamera(cameraName)}
className="text-red-400 hover:text-red-300 p-1 ml-2"
disabled={hasRecordedFrames}
title="Remove camera"
>
<X className="w-4 h-4" />
</button>
</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={handleResetFrames}
disabled={isRecording || !hasRecordedFrames}
>
<Trash2 className="w-4 h-4" />
Reset Frames
</Button>
<Button
variant="outline"
className="gap-2"
onClick={handleNextEpisode}
disabled={isRecording}
>
<PlusCircle className="w-4 h-4" />
Next Episode
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="gap-2"
onClick={handleDownloadZip}
disabled={recorderRef.current?.teleoperatorData.length === 0 || isRecording}
>
<Download className="w-4 h-4" />
Download as ZIP
</Button>
<Button
variant="outline"
className="gap-2"
onClick={handleUploadToHuggingFace}
disabled={
recorderRef.current?.teleoperatorData.length === 0 ||
isRecording ||
!recorderSettings.huggingfaceApiKey
}
>
<Upload className="w-4 h-4" />
Upload to Hugging Face
</Button>
</div>
</div>
<div className="border border-white/10 rounded-md overflow-hidden">
<TeleoperatorEpisodesView teleoperatorData={recorderRef.current?.teleoperatorData} />
</div>
{/* Camera Configuration */}
<div className="border-t border-white/10 pt-4 mt-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Camera Setup</h3>
<Button
onClick={() => setShowCameraConfig(!showCameraConfig)}
variant="outline"
size="sm"
className="gap-2"
>
<Camera className="w-4 h-4" />
{showCameraConfig ? "Hide Camera Config" : "Configure Cameras"}
</Button>
</div>
{showCameraConfig && (
<div className="bg-black/40 border border-white/20 rounded-lg p-6 mb-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column: Camera Preview & Selection */}
<div className="space-y-4">
<h4 className="text-md font-semibold text-white/90">
Camera Preview
</h4>
{/* Camera Preview */}
<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" />
{isLoadingCameras ? (
<p>Loading cameras...</p>
) : cameraPermissionState === "denied" ? (
<div>
<p>Camera access denied</p>
<p className="text-xs mt-1">
Please allow camera access and refresh
</p>
</div>
) : availableCameras.length === 0 ? (
<p>Click "Load Cameras" to start</p>
) : (
<p>Select a camera to preview</p>
)}
</div>
</div>
)}
</div>
{/* Camera Controls */}
<div className="space-y-3">
<Button
onClick={() => loadAvailableCameras(false)}
variant="outline"
className="w-full gap-2"
disabled={hasRecordedFrames || isLoadingCameras}
>
<Camera className="w-4 h-4" />
{isLoadingCameras
? "Loading..."
: availableCameras.length > 0
? "Refresh Cameras"
: "Load Cameras"}
</Button>
{availableCameras.length > 0 && (
<div className="space-y-2">
<label className="text-sm text-white/70">
Select Camera:
</label>
<Select
value={selectedCameraId}
onValueChange={switchCameraPreview}
disabled={hasRecordedFrames}
>
<SelectTrigger className="w-full 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>
</div>
)}
</div>
</div>
{/* Right Column: Camera Naming & Adding */}
<div className="space-y-4">
<h4 className="text-md font-semibold text-white/90">
Add to Recorder
</h4>
<div className="space-y-4">
<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>
<Button
onClick={handleAddCamera}
className="w-full gap-2"
disabled={
hasRecordedFrames ||
!cameraName.trim() ||
!selectedCameraId ||
!previewStream
}
>
<PlusCircle className="w-4 h-4" />
Add Camera to Recorder
</Button>
</div>
</div>
</div>
</div>
)}
{/* Display added cameras */}
{Object.keys(additionalCameras).length > 0 && (
<div className="mb-4 space-y-2">
<p className="text-sm text-muted-foreground">Added Cameras:</p>
<div className="flex flex-wrap gap-2">
{Object.keys(additionalCameras).map((name) => (
<Badge
key={name}
variant="secondary"
className="flex items-center gap-1 py-1 px-2"
>
{name}
<button
onClick={() => handleRemoveCamera(name)}
className="ml-1 text-muted-foreground hover:text-destructive"
disabled={hasRecordedFrames}
>
×
</button>
</Badge>
))}
</div>
</div>
)}
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant="outline"
className="gap-2"
onClick={handleDownloadZip}
disabled={recorderRef.current?.teleoperatorData.length === 0 || isRecording}
>
<Download className="w-4 h-4" />
Download as ZIP
</Button>
{/* Reset Frames button moved to top bar */}
</div>
<div className="flex items-center gap-2 flex-1">
<Button
variant="outline"
className="gap-2"
onClick={handleUploadToHuggingFace}
disabled={
recorderRef.current?.teleoperatorData.length === 0 || isRecording || !huggingfaceApiKey
}
>
<Upload className="w-4 h-4" />
Upload to HuggingFace
</Button>
<Input
placeholder="HuggingFace API Key"
value={huggingfaceApiKey}
onChange={(e) => setHuggingfaceApiKey(e.target.value)}
className="flex-1 bg-black/20 border-white/10"
type="password"
/>
</div>
</div>
</div>
</Card>
);
}