# 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: ```typescript 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:** - `LeRobotDatasetRecorder` class 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 `LeRobotDatasetRecorder` class directly instead of calling `record()` 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()`, `result` interface (class has `startRecording()`, `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 existing `LeRobotDatasetRecorder`) - [ ] **Process Object Interface**: Consistent `RecordProcess` with `start()`, `stop()`, `getState()`, `result` methods (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 `LeRobotDatasetRecorder` class for users who need full control ### User Experience - [ ] **Simple Integration**: Easy to add recording to existing teleoperation workflows - [ ] **Consistent API**: Same patterns as `calibrate()` and `teleoperate()` 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 existing `LeRobotDatasetRecorder` - [ ] **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 `LeRobotDatasetRecorder` class should remain available for advanced users ## Expected User Flow ### Basic Robot Recording (Proposed Simple API) ```typescript 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) ```typescript 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) ```typescript 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) ```typescript // 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 ```typescript // CURRENT: Recording embedded in teleoperation view // examples/cyberpunk-standalone/src/components/teleoperation-view.tsx // ^ Contains embedded component // PROPOSED: Separate recording page/component // examples/cyberpunk-standalone/src/components/recording-view.tsx // ^ Dedicated component with full recording interface ``` ### Component Integration ```typescript // React component - direct library usage like calibration const [recordingState, setRecordingState] = useState(); const [recordingData, setRecordingData] = useState(); const recordProcessRef = useRef(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):** - `LeRobotDatasetRecorder` class 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 `LeRobotDatasetRecorder` for full features ### Core Functions to Implement #### Simple Record API (Wrapper) ```typescript // 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; } 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; ``` #### Implementation Strategy **Phase 1: Simple Wrapper (Preserves Current Architecture)** ```typescript // record.ts - Simple wrapper implementation import { LeRobotDatasetRecorder } from "./record-class.js"; export async function record(config: RecordConfig): Promise { // 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 `teleoperator` parameter - ✅ **Minimal Changes**: Just wraps existing `LeRobotDatasetRecorder` - ✅ **No Breaking Changes**: Current class remains available - ✅ **Consistent API**: Follows `start()`, `stop()`, `getState()`, `result` pattern #### Updated Teleoperate Integration ```typescript // teleoperate.ts - Remove 100ms polling, add immediate callbacks export async function teleoperate( config: TeleoperateConfig ): Promise { 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 ```typescript // 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:** 1. **Move Complex Logic**: `LeRobotDatasetRecorder` moves to `examples/` as demo code 2. **Extract Clean Core**: Create new `record()` function for standard library 3. **Update Examples**: Cyberpunk demo uses new API with demo-layer export functionality 4. **Remove Event System**: Clean up unused `dispatchMotorPositionChanged` events 5. **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: ```typescript // 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 existing `LeRobotDatasetRecorder` - [ ] **Process Interface**: `RecordProcess` with consistent `start()`, `stop()`, `getState()`, `result` methods - [ ] **Hardware-Only Simple API**: Simple `record()` function captures only robot motor data (no video) - [ ] **Preserve Advanced Features**: Keep existing `LeRobotDatasetRecorder` class 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 as `calibrate()` and `teleoperate()` - [ ] **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