leLab / src /pages /Recording.tsx
Nicolas Rabault
Add working camera
42d8fb8
raw
history blame
23.9 kB
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;