Spaces:
Running
User Story 007: Clean Record API Implementation
Story
As a robotics developer building teleoperation recording systems
I want to record robot motor positions and control data using a clean record() function API
So that I can capture teleoperation sessions for training AI models, analysis, and replay with the same simple patterns as other LeRobot.js functions
Background
A community contributor has successfully implemented comprehensive recording functionality, including a LeRobotDatasetRecorder class with video recording, data export, and LeRobot dataset format support. The implementation is functional and well-integrated, but doesn't follow our established simple function API patterns from calibrate(), teleoperate(), and findPort().
Current Recording Implementation (From README)
The existing system works as documented in the web package README:
import { LeRobotDatasetRecorder } from "@lerobot/web";
// Create a recorder with teleoperator and video streams
const recorder = new LeRobotDatasetRecorder(
[teleoperator], // Array of teleoperators to record
{ main: videoStream }, // Video streams by camera key
30, // Target FPS
"Pick and place task" // Task description
);
// Start recording
await recorder.startRecording();
// ... robot performs task ...
const recordingData = await recorder.stopRecording();
// Export the dataset in various formats
await recorder.exportForLeRobot("zip-download");
await recorder.exportForLeRobot("huggingface", { repoName, accessToken });
await recorder.exportForLeRobot("s3", { bucketName, credentials });
This implementation has excellent architecture - the explicit teleoperator dependency makes it clear, testable, and flexible.
Current Implementation Status
The existing recording system is fully functional with these components:
β Already Working Well:
LeRobotDatasetRecorderclass with complete functionality- Proper callback-based integration with teleoperators (no polling issues)
- Full LeRobot dataset format support with Parquet export
- Video recording and synchronization capabilities
- Complete cyberpunk example integration with camera management
- Clean separation between recording logic and teleoperator classes
- Export to ZIP, Hugging Face, and S3
What Works Well (Keep This)
The current implementation has excellent architectural decisions:
- Explicit Teleoperator Dependency:
LeRobotDatasetRecorder([teleoperator], ...)makes dependencies clear and predictable - Clean Separation: Recording subscribes to teleoperator callbacks without tight coupling
- Flexible Architecture: Can record from any teleoperator, multiple teleoperators, or no teleoperator at all
Areas for Improvement
The only issues are API consistency and UI organization:
- Missing Simple Function API: Users must instantiate
LeRobotDatasetRecorderclass directly instead of callingrecord()like other functions - UI Integration Pattern: Recording is embedded within teleoperation view instead of being its own separate component/page
- Library vs Demo Boundary: Complex export functionality (video processing, cloud uploads) should be in demo layer, not standard library
Convention Alignment Needed
Our established patterns from calibrate(), teleoperate(), and findPort() follow these principles:
- Simple Function API:
const process = await record(config)(currently requires class instantiation) - Clean Process Objects: Consistent
start(),stop(),getState(),resultinterface (class hasstartRecording(),stopRecording()) - Hardware-Only Library: Standard library handles only robotics hardware (currently includes video/export)
- Demo Handles UI: Examples handle video, export formats, browser storage, file downloads
- Direct Usage: End users call library functions directly without complex setup
Acceptance Criteria
Core Functionality
- Standard Library API: Clean
record(config)function matching our established patterns (wrap existingLeRobotDatasetRecorder) - Process Object Interface: Consistent
RecordProcesswithstart(),stop(),getState(),resultmethods (adapt existing methods) - Hardware-Only Recording: Library captures only robot motor positions and teleoperation data (move video/export to demo)
- Clean Teleoperator Integration: Recording uses existing callback system in
BaseWebTeleoperator - Preserve Advanced Features: Keep
LeRobotDatasetRecorderclass for users who need full control
User Experience
- Simple Integration: Easy to add recording to existing teleoperation workflows
- Consistent API: Same patterns as
calibrate()andteleoperate()for familiar developer experience - Separate UI Component: Move recording from teleoperation view to its own dedicated page/section in cyberpunk example
- Error Handling: Clear error messages for recording failures or invalid configurations
- Resource Management: Proper cleanup of recording resources on stop/disconnect
Technical Requirements
- Library/Demo Separation: Move video recording, complex export logic (HF, S3) to examples/demo layer
- Wrapper Function: Create simple
record()function that wraps existingLeRobotDatasetRecorder - Preserve Existing Integration: Keep current callback system in
BaseWebTeleoperator(it works well) - TypeScript: Fully typed with proper interfaces for recording configuration and data
- No Breaking Changes: Existing
LeRobotDatasetRecorderclass should remain available for advanced users
Expected User Flow
Basic Robot Recording (Proposed Simple API)
import { teleoperate, record } from "@lerobot/web";
// 1. Create teleoperation first (existing pattern)
const teleoperationProcess = await teleoperate({
robot: connectedRobot,
teleop: { type: "keyboard" },
calibrationData: calibrationData,
});
// 2. NEW: Clean API with explicit teleoperator dependency
const recordProcess = await record({
teleoperator: teleoperationProcess.teleoperator, // β Explicit dependency
options: {
fps: 30,
taskDescription: "Pick and place task",
onDataUpdate: (data) => {
// Real-time recording data for UI feedback
console.log(`Recorded ${data.frameCount} frames`);
updateRecordingUI(data);
},
onStateUpdate: (state) => {
// Recording state changes
console.log(`Recording: ${state.isActive}`);
updateRecordingStatus(state);
},
},
});
// 3. Start both processes
teleoperationProcess.start();
recordProcess.start();
// 4. Recording captures teleoperation automatically via callbacks
setTimeout(() => {
recordProcess.stop();
}, 30000);
// 5. Get pure robot recording data (no video/export complexity)
const robotData = await recordProcess.result;
console.log("Episodes:", robotData.episodes);
console.log("Metadata:", robotData.metadata);
Current Implementation (Works Well, Just Different API Style)
import { LeRobotDatasetRecorder } from "@lerobot/web";
// CURRENT: Class-based API with explicit dependencies (good architecture!)
const recorder = new LeRobotDatasetRecorder(
[teleoperator], // β GOOD: Explicit teleoperator dependency
{ main: videoStream }, // Video complexity in library (to be moved)
30, // fps
"Pick and place task" // Task description
);
// Different method names than our conventions (but functional)
await recorder.startRecording();
// ... robot performs task ...
const result = await recorder.stopRecording();
// Complex export in standard library (should be demo-only)
await recorder.exportForLeRobot("zip-download");
await recorder.exportForLeRobot("huggingface", { repoName, accessToken });
What's Good About Current Implementation:
- β Explicit Dependencies: Clear what the recorder needs to work
- β Clean Architecture: Recording subscribes to teleoperator via callbacks
- β Full Functionality: Complete LeRobot dataset format support
- β Flexible: Can record from any teleoperator instance
Recording with Teleoperation (Proposed Simple API)
import { teleoperate, record } from "@lerobot/web";
// 1. Start teleoperation (existing pattern)
const teleoperationProcess = await teleoperate({
robot: connectedRobot,
teleop: { type: "keyboard" },
calibrationData: calibrationData,
onStateUpdate: (state) => {
updateTeleoperationUI(state);
},
});
// 2. NEW: Add recording with explicit teleoperator dependency
const recordProcess = await record({
teleoperator: teleoperationProcess.teleoperator, // β Explicit dependency (good!)
options: {
fps: 30,
taskDescription: "Pick and place task",
onDataUpdate: (data) => {
console.log(`Recording frame ${data.frameCount}`);
},
},
});
// 3. Both run independently
teleoperationProcess.start();
recordProcess.start();
// 4. Control independently
setTimeout(() => {
recordProcess.stop(); // Stop recording, keep teleoperation
}, 60000);
setTimeout(() => {
teleoperationProcess.stop(); // Stop teleoperation
}, 120000);
Why Explicit Teleoperator Dependency is Good:
- π― Clear: You know exactly what gets recorded
- π§ Flexible: Can record from any teleoperator
- π§ͺ Testable: Easy to mock teleoperator for testing
- π¦ Reusable: Same teleoperator can serve multiple recorders
Demo-Layer Dataset Export (Proposed Architecture)
// In examples/demo - NOT in standard library
import { record } from "@lerobot/web";
import { DatasetExporter } from "./dataset-exporter"; // MOVE complex logic here
const recordProcess = await record({ robot, options });
recordProcess.start();
// ... recording session ...
recordProcess.stop();
const robotData = await recordProcess.result; // Pure motor data only
// MOVE TO DEMO: Complex export logic with video/cloud features
const exporter = new DatasetExporter({
robotData,
videoStreams: cameraStreams, // Demo manages video
taskDescription: "Pick and place task",
});
// MOVE TO DEMO: Export options
await exporter.downloadZip();
await exporter.uploadToHuggingFace({ apiKey, repoName });
await exporter.uploadToS3({ credentials });
Current Cyberpunk Example Integration
// CURRENT: Recording embedded in teleoperation view
// examples/cyberpunk-standalone/src/components/teleoperation-view.tsx
<TeleoperationView robot={robot} />
// ^ Contains embedded <Recorder /> component
// PROPOSED: Separate recording page/component
// examples/cyberpunk-standalone/src/components/recording-view.tsx
<RecordingView robot={robot} />
// ^ Dedicated component with full recording interface
Component Integration
// React component - direct library usage like calibration
const [recordingState, setRecordingState] = useState<RecordingState>();
const [recordingData, setRecordingData] = useState<RecordingData>();
const recordProcessRef = useRef<RecordProcess | null>(null);
useEffect(() => {
const initRecording = async () => {
const process = await record({
robot,
options: {
onStateUpdate: setRecordingState,
onDataUpdate: setRecordingData,
},
});
recordProcessRef.current = process;
};
initRecording();
}, [robot]);
const handleStartRecording = () => {
recordProcessRef.current?.start();
};
const handleStopRecording = async () => {
recordProcessRef.current?.stop();
const data = await recordProcessRef.current?.result;
// Handle recorded data
};
Implementation Details
File Structure Changes
packages/web/src/
βββ record.ts # UPDATE: Add simple record() function wrapper
βββ record-class.ts # RENAME: Move LeRobotDatasetRecorder here
βββ types/
β βββ recording.ts # NEW: Recording-specific types for simple API
βββ teleoperators/
β βββ base-teleoperator.ts # KEEP: Current callback system works well
βββ [MOVE TO EXAMPLES]
βββ dataset-exporter.ts # Video recording + export functionality
βββ hf_uploader.ts # HuggingFace upload logic
βββ s3_uploader.ts # S3 upload logic
Current vs Proposed Architecture
Current (Working):
LeRobotDatasetRecorderclass with full functionality- Integrated in cyberpunk example within teleoperation view
- Video, HF, S3 export in standard library
Proposed (Convention-Aligned):
- Simple
record()function wrapping existing class - Separate recording component/page in cyberpunk example
- Video, HF, S3 export moved to demo layer
- Keep existing class available for advanced users
Key Dependencies
Standard Library (Minimal Changes)
- Keep Existing: All current dependencies for core recording functionality
- Wrapper Only: Simple
record()function is just a wrapper, no new dependencies
Demo Dependencies (To Be Moved)
- Video/Export:
parquet-wasm,apache-arrow,jszip- move to examples - Upload:
@huggingface/hub, AWS SDK - move to examples - Keep Available: Advanced users can still import
LeRobotDatasetRecorderfor full features
Core Functions to Implement
Simple Record API (Wrapper)
// record.ts - Simple wrapper around existing LeRobotDatasetRecorder
interface RecordConfig {
teleoperator: WebTeleoperator; // β Explicit dependency (keep this!)
options?: {
fps?: number; // Default: 30
taskDescription?: string;
onDataUpdate?: (data: RecordingData) => void;
onStateUpdate?: (state: RecordingState) => void;
};
}
interface RecordProcess {
start(): void;
stop(): void;
getState(): RecordingState;
result: Promise<RobotRecordingData>;
}
interface RecordingState {
isActive: boolean;
frameCount: number;
episodeCount: number;
duration: number; // milliseconds
lastUpdate: number;
}
interface RecordingData {
frameCount: number;
currentEpisode: number;
recentFrames: any[]; // Simplified for basic API
}
interface RobotRecordingData {
episodes: any[]; // Pure motor data only (no video)
metadata: {
fps: number;
robotType: string;
startTime: number;
endTime: number;
totalFrames: number;
totalEpisodes: number;
};
}
// Simple wrapper function - internally uses LeRobotDatasetRecorder
// Preserves the excellent explicit dependency architecture
export async function record(config: RecordConfig): Promise<RecordProcess>;
Implementation Strategy
Phase 1: Simple Wrapper (Preserves Current Architecture)
// record.ts - Simple wrapper implementation
import { LeRobotDatasetRecorder } from "./record-class.js";
export async function record(config: RecordConfig): Promise<RecordProcess> {
// Use the provided teleoperator (explicit dependency - good!)
const recorder = new LeRobotDatasetRecorder(
[config.teleoperator], // β Use explicit teleoperator dependency
{}, // No video streams in simple API (move to demo)
config.options?.fps || 30,
config.options?.taskDescription || "Robot recording"
);
return {
start: () => {
recorder.startRecording();
if (config.options?.onStateUpdate) {
// Set up state update polling for simple API
const updateLoop = () => {
if (recorder.isRecording) {
config.options.onStateUpdate!({
isActive: recorder.isRecording,
frameCount: recorder.teleoperatorData.length,
episodeCount: recorder.teleoperatorData.length,
duration: Date.now() - (recorder as any).startTime,
lastUpdate: Date.now(),
});
setTimeout(updateLoop, 100);
}
};
updateLoop();
}
},
stop: () => {
return recorder.stopRecording();
},
getState: () => ({
isActive: recorder.isRecording,
frameCount: recorder.teleoperatorData.length,
episodeCount: recorder.teleoperatorData.length,
duration: 0, // Calculate from recorder
lastUpdate: Date.now(),
}),
result: recorder.stopRecording().then(() => ({
episodes: recorder.episodes, // Pure motor data
metadata: {
fps: config.options?.fps || 30,
robotType: "unknown", // Get from teleoperator if possible
startTime: Date.now(),
endTime: Date.now(),
totalFrames: recorder.teleoperatorData.length,
totalEpisodes: recorder.teleoperatorData.length,
},
})),
};
}
Key Benefits of This Approach:
- β
Preserves Explicit Dependencies: Keeps the excellent
teleoperatorparameter - β
Minimal Changes: Just wraps existing
LeRobotDatasetRecorder - β No Breaking Changes: Current class remains available
- β
Consistent API: Follows
start(),stop(),getState(),resultpattern
Updated Teleoperate Integration
// teleoperate.ts - Remove 100ms polling, add immediate callbacks
export async function teleoperate(
config: TeleoperateConfig
): Promise<TeleoperationProcess> {
const teleoperator = await createTeleoperatorProcess(config);
return {
start: () => {
teleoperator.start();
// NO MORE 100ms polling! Use immediate callbacks
if (config.onStateUpdate) {
teleoperator.setStateUpdateCallback(config.onStateUpdate);
}
},
// ... rest of interface
};
}
Clean Teleoperator Base
// teleoperators/base-teleoperator.ts - Remove recording logic
export abstract class BaseWebTeleoperator extends WebTeleoperator {
protected port: MotorCommunicationPort;
public motorConfigs: MotorConfig[] = [];
protected isActive: boolean = false;
// REMOVED: All recording-related properties
// REMOVED: dispatchMotorPositionChanged events
// REMOVED: recordedMotorPositions, episodeIndex, etc.
private stateUpdateCallback?: (state: TeleoperationState) => void;
setStateUpdateCallback(callback: (state: TeleoperationState) => void): void {
this.stateUpdateCallback = callback;
}
protected motorPositionsChanged(): void {
// Call immediately when motors change - no events, no 100ms delay
if (this.stateUpdateCallback) {
const state = this.buildCurrentState();
this.stateUpdateCallback(state);
}
}
// Clean implementation without recording concerns
}
Technical Considerations
Migration Strategy
Preserve Existing Functionality:
- Move Complex Logic:
LeRobotDatasetRecordermoves toexamples/as demo code - Extract Clean Core: Create new
record()function for standard library - Update Examples: Cyberpunk demo uses new API with demo-layer export functionality
- Remove Event System: Clean up unused
dispatchMotorPositionChangedevents - Fix Polling: Replace 100ms polling with immediate callbacks
Performance Improvements
- Remove Polling: Eliminate artificial 100ms delays in favor of immediate callbacks
- Event-Driven: Only fire callbacks when robot state actually changes
- Memory Efficiency: No unused event listeners or redundant data structures
- Responsive UI: Immediate feedback for recording status and data updates
Future Extensibility
The clean architecture supports advanced recording features as demo enhancements:
// Future: Advanced demo features (NOT in standard library)
class AdvancedDatasetExporter extends DatasetExporter {
// Video synchronization, multi-camera support
// Cloud storage, data preprocessing
// Visualization, playback, analysis tools
}
Definition of Done
Phase 1: Simple Function API (Priority)
- Clean Record API:
record(config)function implemented as wrapper around existingLeRobotDatasetRecorder - Process Interface:
RecordProcesswith consistentstart(),stop(),getState(),resultmethods - Hardware-Only Simple API: Simple
record()function captures only robot motor data (no video) - Preserve Advanced Features: Keep existing
LeRobotDatasetRecorderclass available for full functionality - TypeScript Coverage: Full type safety with proper interfaces for simple recording API
Phase 2: UI Separation (Secondary)
- Separate Recording Component: Move recording from teleoperation view to dedicated component/page
- Clean Navigation: Add recording as separate section in cyberpunk example navigation
- No Breaking Changes: Existing functionality continues to work during transition
Phase 3: Library/Demo Boundary (Future)
- Demo Separation: Video recording, export formats moved to examples layer (optional enhancement)
- Advanced Export Demo: Create demo showing complex export features using
LeRobotDatasetRecorder - Documentation: Clear examples showing simple API vs advanced class usage
Success Criteria
- API Consistency:
record()function follows same patterns ascalibrate()andteleoperate() - No Regression: All existing recording functionality preserved and working
- Easy Migration: Users can easily switch between simple API and advanced class
- Clean Example: Recording has its own dedicated UI section in cyberpunk demo