From afde30f31e6151bd6c9a00ba80e2483a6eb8348c Mon Sep 17 00:00:00 2001 From: ZouzouWP Date: Fri, 17 Jul 2026 17:14:21 +0200 Subject: [PATCH] feat: resume recording into an existing dataset across sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recording a full dataset rarely happens in one sitting. Before this, adding more episodes to an existing dataset later meant starting a brand new one and merging them by hand — nothing stopped you from silently mixing sessions with different FPS, cameras, or task wording into a broken combined dataset. ## What changed - `DatasetPicker` lists existing local/Hub datasets with their episode count, and offers a "Resume" action next to "Create new". - Resuming pre-fills FPS, cameras, robot type, and the last task description from the dataset's own metadata (via a new `tasks` field on `/dataset-info`), and **blocks recording** if the current camera/robot setup doesn't match what the dataset was originally recorded with — this is exactly the mismatch that used to fail silently. - An optional episode-count target (stored in `localStorage`, not the dataset itself) drives a progress bar across sessions, e.g. "14 / 45 episodes". - Backend fix: `LeRobotDataset.resume()` was being called with `root=None`, which current `lerobot` rejects. Now resolves the same default local directory `LeRobotDataset.create()` uses. ```mermaid sequenceDiagram participant U as User participant L as Landing page participant API as LeLab backend participant DS as LeRobotDataset U->>L: Click "Resume" on a dataset L->>API: POST /dataset-info {repo_id} API->>DS: LeRobotDataset(repo_id) DS-->>API: fps, cameras, robot_type, tasks API-->>L: pre-fill form Note over L: Blocks start if current setup mismatches U->>L: Confirm, start recording L->>API: POST /start-recording {resume: true, repo_id} API->>DS: LeRobotDataset.resume(repo_id, root=resolved_root) DS-->>API: existing episodes preserved API-->>L: new episodes appended after the last one ``` ## Testing Validated end-to-end on the physical SO-101: episodes appended to a 5-episode dataset across two separate sessions on different days, episode count and video/frame continuity confirmed on the Hub afterward. --- .../src/components/landing/DatasetPicker.tsx | 67 ++++++- .../src/components/landing/RecordingModal.tsx | 178 +++++++++++++++--- frontend/src/lib/episodeTargets.ts | 36 ++++ frontend/src/lib/replayApi.ts | 1 + frontend/src/pages/Landing.tsx | 73 ++++++- frontend/src/pages/Recording.tsx | 8 + lelab/datasets.py | 15 ++ lelab/record.py | 15 +- 8 files changed, 358 insertions(+), 35 deletions(-) create mode 100644 frontend/src/lib/episodeTargets.ts diff --git a/frontend/src/components/landing/DatasetPicker.tsx b/frontend/src/components/landing/DatasetPicker.tsx index aca33fef..5c771559 100644 --- a/frontend/src/components/landing/DatasetPicker.tsx +++ b/frontend/src/components/landing/DatasetPicker.tsx @@ -14,11 +14,24 @@ import { CommandList, } from "@/components/ui/command"; import { DatasetItem } from "@/lib/replayApi"; +import { getEpisodeTarget } from "@/lib/episodeTargets"; + +function relativeDate(iso: string | null): string | null { + if (!iso) return null; + const diffMs = Date.now() - new Date(iso).getTime(); + const days = Math.floor(diffMs / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + return months < 12 ? `${months}mo ago` : `${Math.floor(months / 12)}y ago`; +} interface DatasetPickerProps { datasets: DatasetItem[]; loading: boolean; onPickExisting: (item: DatasetItem) => void; + onResumeExisting?: (item: DatasetItem) => void; onCreateNew: (name: string) => void; onOpenCustom: (repoId: string) => void; children: React.ReactNode; @@ -31,6 +44,7 @@ const DatasetPicker: React.FC = ({ datasets, loading, onPickExisting, + onResumeExisting, onCreateNew, onOpenCustom, children, @@ -87,22 +101,69 @@ const DatasetPicker: React.FC = ({ reset(); }; - const renderItem = (d: DatasetItem) => ( + const renderItem = (d: DatasetItem) => { + const target = getEpisodeTarget(d.repo_id); + const detailParts: string[] = []; + if (d.num_episodes != null) { + detailParts.push( + target + ? `${d.num_episodes}/${target} episodes` + : `${d.num_episodes} episodes`, + ); + } + const rel = relativeDate(d.last_modified); + if (rel) detailParts.push(rel); + const progress = + target && d.num_episodes != null + ? Math.min(100, Math.round((d.num_episodes / target) * 100)) + : null; + return ( handlePick(d)} className="text-white aria-selected:bg-gray-700" > - {d.repo_id} +
+ {d.repo_id} + {detailParts.length > 0 && ( + + {detailParts.join(" · ")} + + )} + {progress !== null && ( + + = 100 ? "bg-green-500" : "bg-red-400"}`} + style={{ width: `${progress}%` }} + /> + + )} +
{d.source === "both" && ( on Hub )} {d.private && ( private )} + {(d.source === "local" || d.source === "both") && onResumeExisting && ( + + )}
- ); + ); + }; return ( diff --git a/frontend/src/components/landing/RecordingModal.tsx b/frontend/src/components/landing/RecordingModal.tsx index b22824eb..6d0192ad 100644 --- a/frontend/src/components/landing/RecordingModal.tsx +++ b/frontend/src/components/landing/RecordingModal.tsx @@ -17,17 +17,22 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { AlertTriangle, CheckCircle, ChevronDown } from "lucide-react"; +import { AlertTriangle, CheckCircle, ChevronDown, Disc } from "lucide-react"; import CameraConfiguration, { CameraConfig, } from "@/components/recording/CameraConfiguration"; import { useHfAuth } from "@/contexts/HfAuthContext"; import { RobotRecord } from "@/hooks/useRobots"; +import { ResumeDatasetInfo } from "@/pages/Landing"; interface RecordingModalProps { open: boolean; onOpenChange: (open: boolean) => void; robot: RobotRecord | null; + resumeMode?: boolean; + resumeInfo?: ResumeDatasetInfo | null; + episodeTarget?: number | null; + setEpisodeTarget?: (value: number | null) => void; datasetName: string; setDatasetName: (value: string) => void; singleTask: string; @@ -50,6 +55,10 @@ const RecordingModal: React.FC = ({ open, onOpenChange, robot, + resumeMode = false, + resumeInfo = null, + episodeTarget = null, + setEpisodeTarget, datasetName, setDatasetName, singleTask, @@ -69,7 +78,17 @@ const RecordingModal: React.FC = ({ }) => { const { auth } = useHfAuth(); - const canStart = !!robot && robot.is_clean; + const configuredCameraNames = cameras.map((c) => c.name); + const missingCameras = resumeInfo + ? resumeInfo.cameras.filter((c) => !configuredCameraNames.includes(c)) + : []; + const extraCameras = resumeInfo + ? configuredCameraNames.filter((c) => !resumeInfo.cameras.includes(c)) + : []; + const camerasMatch = missingCameras.length === 0 && extraCameras.length === 0; + const setupOk = !resumeMode || !resumeInfo || camerasMatch; + + const canStart = !!robot && robot.is_clean && setupOk; return ( @@ -81,7 +100,7 @@ const RecordingModal: React.FC = ({ - Configure Recording + {resumeMode ? "Continue Recording" : "Configure Recording"}
@@ -124,6 +143,84 @@ const RecordingModal: React.FC = ({

Dataset Configuration

+ {resumeMode && ( + + + + New episodes will be appended to{" "} + {datasetName} + {resumeInfo && ( + <> ({resumeInfo.numEpisodes} episodes so far) + )} + . The setup must match the original recording exactly. + + + )} + {resumeMode && resumeInfo && ( +
+

+ Required setup (from the dataset) +

+
+
+ Robot type + + {resumeInfo.robotType} + +
+
+ FPS + + {resumeInfo.fps} + +
+
+ Cameras + + {resumeInfo.cameras.join(", ") || "none"} + +
+
+ {!camerasMatch && ( + + + + Camera setup doesn't match the dataset. + {missingCameras.length > 0 && ( + <> + {" "} + Missing:{" "} + + {missingCameras.join(", ")} + + . + + )} + {extraCameras.length > 0 && ( + <> + {" "} + Not in the dataset:{" "} + + {extraCameras.join(", ")} + + . + + )}{" "} + Rename or adjust your cameras below to match, then the + button will unlock. + + + )} +
+ )}
-
- - { - if (v !== undefined) setNumEpisodes(v); - }} - className="bg-gray-800 border-gray-700 text-white" - /> +
+
+ + { + if (v !== undefined) setNumEpisodes(v); + }} + className="bg-gray-800 border-gray-700 text-white" + /> +
+
+ + { + setEpisodeTarget?.(v ?? null); + }} + className="bg-gray-800 border-gray-700 text-white" + /> +

+ Total episodes you aim for across all sessions — shows + progress in the dataset list. +

+
@@ -286,7 +410,7 @@ const RecordingModal: React.FC = ({ disabled={!canStart} className="w-full sm:w-auto bg-red-500 hover:bg-red-600 text-white px-10 py-6 text-lg transition-all shadow-md shadow-red-500/30 hover:shadow-lg hover:shadow-red-500/40 disabled:opacity-40 disabled:cursor-not-allowed" > - Start Recording + {resumeMode ? "Resume Recording" : "Start Recording"}