Spaces:
Running
Running
import React, { useState, useEffect } from "react"; | |
import { useNavigate, useLocation } from "react-router-dom"; | |
import { Button } from "@/components/ui/button"; | |
import { useToast } from "@/hooks/use-toast"; | |
import { ArrowLeft, Square, SkipForward, RotateCcw, Play } from "lucide-react"; | |
import UrdfViewer from "@/components/UrdfViewer"; | |
import UrdfProcessorInitializer from "@/components/UrdfProcessorInitializer"; | |
import { useApi } from "@/contexts/ApiContext"; | |
interface RecordingConfig { | |
leader_port: string; | |
follower_port: string; | |
leader_config: string; | |
follower_config: string; | |
dataset_repo_id: string; | |
single_task: string; | |
num_episodes: number; | |
episode_time_s: number; | |
reset_time_s: number; | |
fps: number; | |
video: boolean; | |
push_to_hub: boolean; | |
resume: boolean; | |
} | |
interface BackendStatus { | |
recording_active: boolean; | |
current_phase: string; | |
current_episode?: number; | |
total_episodes?: number; | |
saved_episodes?: number; | |
phase_elapsed_seconds?: number; | |
phase_time_limit_s?: number; | |
session_elapsed_seconds?: number; | |
session_ended?: boolean; | |
available_controls: { | |
stop_recording: boolean; | |
exit_early: boolean; | |
rerecord_episode: boolean; | |
}; | |
} | |
const Recording = () => { | |
const location = useLocation(); | |
const navigate = useNavigate(); | |
const { toast } = useToast(); | |
const { baseUrl, wsBaseUrl, fetchWithHeaders } = useApi(); | |
// Get recording config from navigation state | |
const recordingConfig = location.state?.recordingConfig as RecordingConfig; | |
// Backend status state - this is the single source of truth | |
const [backendStatus, setBackendStatus] = useState<BackendStatus | null>( | |
null | |
); | |
const [recordingSessionStarted, setRecordingSessionStarted] = useState(false); | |
// Local UI state for immediate user feedback | |
const [transitioningToReset, setTransitioningToReset] = useState(false); | |
const [transitioningToNext, setTransitioningToNext] = useState(false); | |
// Redirect if no config provided | |
useEffect(() => { | |
if (!recordingConfig) { | |
toast({ | |
title: "No Configuration", | |
description: "Please start recording from the main page.", | |
variant: "destructive", | |
}); | |
navigate("/"); | |
} | |
}, [recordingConfig, navigate, toast]); | |
// Start recording session when component loads | |
useEffect(() => { | |
if (recordingConfig && !recordingSessionStarted) { | |
startRecordingSession(); | |
} | |
}, [recordingConfig, recordingSessionStarted]); | |
// Poll backend status continuously to stay in sync | |
useEffect(() => { | |
let statusInterval: NodeJS.Timeout; | |
if (recordingSessionStarted) { | |
const pollStatus = async () => { | |
try { | |
const response = await fetchWithHeaders( | |
`${baseUrl}/recording-status` | |
); | |
if (response.ok) { | |
const status = await response.json(); | |
console.log( | |
`📊 Backend Status: ${status.current_phase} | Transition States: reset=${transitioningToReset}, next=${transitioningToNext}` | |
); | |
setBackendStatus(status); | |
// 🎯 CLEAR TRANSITION STATES: Only clear when backend actually reaches the expected phase | |
if (status.current_phase === "resetting" && transitioningToReset) { | |
console.log( | |
"✅ Clearing transitioningToReset - backend reached resetting phase" | |
); | |
setTransitioningToReset(false); | |
} | |
if (status.current_phase === "recording" && transitioningToNext) { | |
console.log( | |
"✅ Clearing transitioningToNext - backend reached recording phase" | |
); | |
setTransitioningToNext(false); | |
} | |
// If backend recording stopped and session ended, navigate to upload | |
if ( | |
!status.recording_active && | |
status.session_ended && | |
recordingSessionStarted | |
) { | |
// Navigate to upload window with dataset info | |
const datasetInfo = { | |
dataset_repo_id: recordingConfig.dataset_repo_id, | |
single_task: recordingConfig.single_task, | |
num_episodes: recordingConfig.num_episodes, | |
saved_episodes: status.saved_episodes || 0, | |
session_elapsed_seconds: status.session_elapsed_seconds || 0, | |
}; | |
navigate("/upload", { state: { datasetInfo } }); | |
return; // Stop polling after navigation | |
} | |
} | |
} catch (error) { | |
console.error("Error polling recording status:", error); | |
} | |
}; | |
// Poll immediately and then every second for real-time updates | |
pollStatus(); | |
statusInterval = setInterval(pollStatus, 1000); | |
} | |
return () => { | |
if (statusInterval) clearInterval(statusInterval); | |
}; | |
}, [ | |
recordingSessionStarted, | |
recordingConfig, | |
navigate, | |
toast, | |
transitioningToReset, | |
transitioningToNext, | |
]); | |
const formatTime = (seconds: number): string => { | |
const mins = Math.floor(seconds / 60); | |
const secs = seconds % 60; | |
return `${mins.toString().padStart(2, "0")}:${secs | |
.toString() | |
.padStart(2, "0")}`; | |
}; | |
const startRecordingSession = async () => { | |
try { | |
const response = await fetchWithHeaders(`${baseUrl}/start-recording`, { | |
method: "POST", | |
body: JSON.stringify(recordingConfig), | |
}); | |
const data = await response.json(); | |
if (response.ok) { | |
setRecordingSessionStarted(true); | |
toast({ | |
title: "Recording Started", | |
description: `Started recording ${recordingConfig.num_episodes} episodes`, | |
}); | |
} else { | |
toast({ | |
title: "Error Starting Recording", | |
description: data.message || "Failed to start recording session.", | |
variant: "destructive", | |
}); | |
navigate("/"); | |
} | |
} catch (error) { | |
toast({ | |
title: "Connection Error", | |
description: "Could not connect to the backend server.", | |
variant: "destructive", | |
}); | |
navigate("/"); | |
} | |
}; | |
// Equivalent to pressing RIGHT ARROW key in original record.py | |
const handleExitEarly = async () => { | |
if (!backendStatus?.available_controls.exit_early) return; | |
// 🎯 IMMEDIATE UI FEEDBACK: Show transition state before backend response | |
const currentPhase = backendStatus.current_phase; | |
if (currentPhase === "recording") { | |
console.log("🎯 Setting transitioningToReset = true"); | |
setTransitioningToReset(true); | |
toast({ | |
title: "Ending Episode Recording", | |
description: `Moving to reset phase for episode ${backendStatus.current_episode}...`, | |
}); | |
} else if (currentPhase === "resetting") { | |
console.log("🎯 Setting transitioningToNext = true"); | |
setTransitioningToNext(true); | |
toast({ | |
title: "Reset Complete", | |
description: `Moving to next episode...`, | |
}); | |
} | |
try { | |
const response = await fetchWithHeaders( | |
`${baseUrl}/recording-exit-early`, | |
{ | |
method: "POST", | |
} | |
); | |
const data = await response.json(); | |
if (response.ok) { | |
// ✅ SUCCESS: Don't clear transition states here - let them persist until backend phase changes | |
// The transition states will be cleared when the backend status actually updates to the new phase | |
} else { | |
// Clear transition states on error | |
setTransitioningToReset(false); | |
setTransitioningToNext(false); | |
toast({ | |
title: "Error", | |
description: data.message, | |
variant: "destructive", | |
}); | |
} | |
} catch (error) { | |
// Clear transition states on error | |
setTransitioningToReset(false); | |
setTransitioningToNext(false); | |
toast({ | |
title: "Connection Error", | |
description: "Could not connect to the backend server.", | |
variant: "destructive", | |
}); | |
} | |
}; | |
// Equivalent to pressing LEFT ARROW key in original record.py | |
const handleRerecordEpisode = async () => { | |
if (!backendStatus?.available_controls.rerecord_episode) return; | |
try { | |
const response = await fetchWithHeaders( | |
`${baseUrl}/recording-rerecord-episode`, | |
{ | |
method: "POST", | |
} | |
); | |
const data = await response.json(); | |
if (response.ok) { | |
toast({ | |
title: "Re-recording Episode", | |
description: `Episode ${backendStatus.current_episode} will be re-recorded.`, | |
}); | |
} else { | |
toast({ | |
title: "Error", | |
description: data.message, | |
variant: "destructive", | |
}); | |
} | |
} catch (error) { | |
toast({ | |
title: "Connection Error", | |
description: "Could not connect to the backend server.", | |
variant: "destructive", | |
}); | |
} | |
}; | |
// Equivalent to pressing ESC key in original record.py | |
const handleStopRecording = async () => { | |
try { | |
const response = await fetchWithHeaders(`${baseUrl}/stop-recording`, { | |
method: "POST", | |
}); | |
toast({ | |
title: "Recording Stopped", | |
description: "Recording session has been stopped.", | |
}); | |
// Navigate to upload window with current dataset info | |
const datasetInfo = { | |
dataset_repo_id: recordingConfig.dataset_repo_id, | |
single_task: recordingConfig.single_task, | |
num_episodes: recordingConfig.num_episodes, | |
saved_episodes: backendStatus?.saved_episodes || 0, | |
session_elapsed_seconds: backendStatus?.session_elapsed_seconds || 0, | |
}; | |
navigate("/upload", { state: { datasetInfo } }); | |
} catch (error) { | |
toast({ | |
title: "Error", | |
description: "Failed to stop recording.", | |
variant: "destructive", | |
}); | |
} | |
}; | |
if (!recordingConfig) { | |
return ( | |
<div className="min-h-screen bg-black text-white flex items-center justify-center"> | |
<div className="text-center"> | |
<p className="text-lg">No recording configuration found.</p> | |
<Button onClick={() => navigate("/")} className="mt-4"> | |
Return to Home | |
</Button> | |
</div> | |
</div> | |
); | |
} | |
// Show loading state while waiting for backend status | |
if (!backendStatus) { | |
return ( | |
<div className="min-h-screen bg-black text-white flex items-center justify-center"> | |
<div className="text-center"> | |
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-500 mx-auto mb-4"></div> | |
<p className="text-lg">Connecting to recording session...</p> | |
</div> | |
</div> | |
); | |
} | |
const currentPhase = backendStatus.current_phase; | |
const currentEpisode = backendStatus.current_episode || 1; | |
const totalEpisodes = | |
backendStatus.total_episodes || recordingConfig.num_episodes; | |
const phaseElapsedTime = backendStatus.phase_elapsed_seconds || 0; | |
const phaseTimeLimit = | |
backendStatus.phase_time_limit_s || | |
(currentPhase === "recording" | |
? recordingConfig.episode_time_s | |
: recordingConfig.reset_time_s); | |
const sessionElapsedTime = backendStatus.session_elapsed_seconds || 0; | |
const getPhaseTitle = () => { | |
// 🎯 IMMEDIATE FEEDBACK: Show transition titles | |
if (transitioningToReset) return "Transitioning to Reset"; | |
if (transitioningToNext) return "Moving to Next Episode"; | |
if (currentPhase === "recording") return "Episode Recording Time"; | |
if (currentPhase === "resetting") return "Environment Reset Time"; | |
return "Phase Time"; | |
}; | |
const getStatusText = () => { | |
// 🎯 IMMEDIATE FEEDBACK: Show transition states | |
if (transitioningToReset) return "MOVING TO RESET PHASE"; | |
if (transitioningToNext) return "MOVING TO NEXT EPISODE"; | |
if (currentPhase === "recording") | |
return `RECORDING EPISODE ${currentEpisode}`; | |
if (currentPhase === "resetting") return "RESET THE ENVIRONMENT"; | |
if (currentPhase === "preparing") return "PREPARING SESSION"; | |
return "SESSION COMPLETE"; | |
}; | |
const getStatusColor = () => { | |
// 🎯 IMMEDIATE FEEDBACK: Show transition state colors | |
if (transitioningToReset) return "text-blue-400"; // Blue for transition | |
if (transitioningToNext) return "text-blue-400"; // Blue for transition | |
if (currentPhase === "recording") return "text-red-400"; | |
if (currentPhase === "resetting") return "text-orange-400"; | |
if (currentPhase === "preparing") return "text-yellow-400"; | |
return "text-gray-400"; | |
}; | |
const getDotColor = () => { | |
// 🎯 IMMEDIATE FEEDBACK: Show transition state dots with animation | |
if (transitioningToReset) return "bg-blue-500 animate-pulse"; // Blue pulsing for transition | |
if (transitioningToNext) return "bg-blue-500 animate-pulse"; // Blue pulsing for transition | |
if (currentPhase === "recording") return "bg-red-500 animate-pulse"; | |
if (currentPhase === "resetting") return "bg-orange-500 animate-pulse"; | |
if (currentPhase === "preparing") return "bg-yellow-500"; | |
return "bg-gray-500"; | |
}; | |
return ( | |
<div className="min-h-screen bg-black text-white p-8"> | |
<div className="max-w-6xl mx-auto"> | |
{/* Header */} | |
<div className="flex items-center justify-between mb-8"> | |
<Button | |
onClick={() => navigate("/")} | |
variant="outline" | |
className="border-gray-500 hover:border-gray-200 text-gray-300 hover:text-white" | |
> | |
<ArrowLeft className="w-4 h-4 mr-2" /> | |
Back to Home | |
</Button> | |
<div className="flex items-center gap-6"> | |
<div className="flex items-center gap-3"> | |
<div className={`w-3 h-3 rounded-full ${getDotColor()}`}></div> | |
<h1 className="text-3xl font-bold">Recording Session</h1> | |
</div> | |
</div> | |
</div> | |
{/* Main Recording Dashboard */} | |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8"> | |
{/* Phase Timer */} | |
<div className="bg-gray-900 rounded-lg p-6 border border-gray-700 text-center"> | |
<h2 className="text-sm text-gray-400 mb-2">{getPhaseTitle()}</h2> | |
<div | |
className={`text-4xl font-mono font-bold mb-2 ${ | |
currentPhase === "recording" | |
? "text-green-400" | |
: "text-orange-400" | |
}`} | |
> | |
{formatTime(phaseElapsedTime)} | |
</div> | |
<div className="text-sm text-gray-400"> | |
/ {formatTime(phaseTimeLimit)} | |
</div> | |
<div className="w-full bg-gray-700 rounded-full h-2 mt-3"> | |
<div | |
className={`h-2 rounded-full transition-all duration-1000 ${ | |
currentPhase === "recording" | |
? "bg-green-500" | |
: "bg-orange-500" | |
}`} | |
style={{ | |
width: `${Math.min( | |
(phaseElapsedTime / phaseTimeLimit) * 100, | |
100 | |
)}%`, | |
}} | |
></div> | |
</div> | |
</div> | |
{/* Episode Progress */} | |
<div className="bg-gray-900 rounded-lg p-6 border border-gray-700 text-center"> | |
<h2 className="text-sm text-gray-400 mb-2">Episode Progress</h2> | |
<div className="text-4xl font-bold text-blue-400 mb-2"> | |
{currentEpisode} of {totalEpisodes} | |
</div> | |
<div className="text-sm text-gray-400"> | |
{recordingConfig.single_task} | |
</div> | |
<div className="w-full bg-gray-700 rounded-full h-2 mt-3"> | |
<div | |
className="bg-blue-500 h-2 rounded-full transition-all duration-500" | |
style={{ width: `${(currentEpisode / totalEpisodes) * 100}%` }} | |
></div> | |
</div> | |
</div> | |
{/* Session Timer */} | |
<div className="bg-gray-900 rounded-lg p-6 border border-gray-700 text-center"> | |
<h2 className="text-sm text-gray-400 mb-2">Total Session Time</h2> | |
<div className="text-4xl font-mono font-bold text-yellow-400 mb-2"> | |
{formatTime(sessionElapsedTime)} | |
</div> | |
<div className="text-sm text-gray-400"> | |
Dataset: {recordingConfig.dataset_repo_id} | |
</div> | |
</div> | |
</div> | |
{/* Status and Controls */} | |
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 mb-8"> | |
{/* Recording Status - takes up 3 columns */} | |
<div className="lg:col-span-3 bg-gray-900 rounded-lg p-6 border border-gray-700"> | |
{/* Status header */} | |
<div className="flex items-center justify-between mb-6"> | |
<div> | |
<h2 className="text-xl font-semibold text-white mb-2"> | |
Recording Status | |
</h2> | |
<div className="flex items-center gap-3"> | |
<div | |
className={`w-2 h-2 rounded-full ${getDotColor()}`} | |
></div> | |
<span className={`font-semibold ${getStatusColor()}`}> | |
{getStatusText()} | |
</span> | |
</div> | |
</div> | |
</div> | |
{/* Recording Phase Controls */} | |
{currentPhase === "recording" && ( | |
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> | |
<Button | |
onClick={handleExitEarly} | |
disabled={ | |
!backendStatus.available_controls.exit_early || | |
transitioningToReset | |
} | |
className="bg-green-500 hover:bg-green-600 text-white font-semibold py-4 text-lg disabled:opacity-50" | |
> | |
{transitioningToReset ? ( | |
<> | |
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div> | |
Moving to Reset... | |
</> | |
) : ( | |
<> | |
<SkipForward className="w-5 h-5 mr-2" /> | |
End Episode | |
</> | |
)} | |
</Button> | |
<Button | |
onClick={handleRerecordEpisode} | |
disabled={!backendStatus.available_controls.rerecord_episode} | |
className="bg-orange-500 hover:bg-orange-600 text-white font-semibold py-4 text-lg disabled:opacity-50" | |
> | |
<RotateCcw className="w-5 h-5 mr-2" /> | |
Re-record Episode | |
</Button> | |
<Button | |
onClick={handleStopRecording} | |
disabled={!backendStatus.available_controls.stop_recording} | |
className="bg-red-500 hover:bg-red-600 text-white font-semibold py-4 text-lg disabled:opacity-50" | |
> | |
<Square className="w-5 h-5 mr-2" /> | |
Stop Recording | |
</Button> | |
</div> | |
)} | |
{/* Reset Phase Controls */} | |
{currentPhase === "resetting" && ( | |
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> | |
<Button | |
onClick={handleExitEarly} | |
disabled={ | |
!backendStatus.available_controls.exit_early || | |
transitioningToNext | |
} | |
className="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-6 text-xl disabled:opacity-50" | |
> | |
{transitioningToNext ? ( | |
<> | |
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white mr-2"></div> | |
Moving to Next Episode... | |
</> | |
) : ( | |
<> | |
<Play className="w-6 h-6 mr-2" /> | |
Continue to Next Phase | |
</> | |
)} | |
</Button> | |
<Button | |
onClick={handleStopRecording} | |
disabled={!backendStatus.available_controls.stop_recording} | |
className="bg-red-500 hover:bg-red-600 text-white font-semibold py-6 text-xl disabled:opacity-50" | |
> | |
<Square className="w-5 h-5 mr-2" /> | |
Stop Recording | |
</Button> | |
</div> | |
)} | |
{currentPhase === "completed" && ( | |
<div className="text-center"> | |
<p className="text-lg text-green-400 mb-6"> | |
✅ Recording session completed successfully! | |
</p> | |
<p className="text-gray-400 mb-6"> | |
Dataset:{" "} | |
<span className="text-white font-semibold"> | |
{recordingConfig.dataset_repo_id} | |
</span> | |
</p> | |
<p className="text-gray-400 mb-6"> | |
You will be redirected to the upload window shortly... | |
</p> | |
</div> | |
)} | |
{/* Instructions */} | |
<div className="mt-6 p-4 bg-gray-800 rounded-lg"> | |
<h3 className="font-semibold mb-2"> | |
{currentPhase === "recording" | |
? "Episode Recording Instructions:" | |
: currentPhase === "resetting" | |
? "Environment Reset Instructions:" | |
: "Session Instructions:"} | |
</h3> | |
{currentPhase === "recording" && ( | |
<ul className="text-sm text-gray-400 space-y-1"> | |
<li> | |
• <strong>End Episode:</strong> Complete current episode and | |
enter reset phase (Right Arrow) | |
</li> | |
<li> | |
• <strong>Re-record Episode:</strong> Restart current | |
episode after reset phase (Left Arrow) | |
</li> | |
<li> | |
• <strong>Auto-end:</strong> Episode ends automatically | |
after {formatTime(phaseTimeLimit)} | |
</li> | |
<li> | |
• <strong>Stop Recording:</strong> End entire session (ESC | |
key) | |
</li> | |
</ul> | |
)} | |
{currentPhase === "resetting" && ( | |
<ul className="text-sm text-gray-400 space-y-1"> | |
<li> | |
• <strong>Continue to Next Phase:</strong> Skip reset phase | |
and continue (Right Arrow) | |
</li> | |
<li> | |
• <strong>Auto-continue:</strong> Automatically continues | |
after {formatTime(phaseTimeLimit)} | |
</li> | |
<li> | |
• <strong>Reset Phase:</strong> Use this time to prepare | |
your environment for the next episode | |
</li> | |
<li> | |
• <strong>Stop Recording:</strong> End entire session (ESC | |
key) | |
</li> | |
</ul> | |
)} | |
</div> | |
</div> | |
</div> | |
{/* URDF Viewer Section */} | |
<div className="bg-gray-900 rounded-lg p-6 border border-gray-700 mb-8"> | |
<h2 className="text-xl font-semibold text-white mb-4"> | |
Robot Visualizer | |
</h2> | |
<div className="h-96 bg-gray-800 rounded-lg overflow-hidden"> | |
<UrdfViewer /> | |
<UrdfProcessorInitializer /> | |
</div> | |
</div> | |
</div> | |
</div> | |
); | |
}; | |
export default Recording; | |