Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ Run the Python tests with `pytest` (config in [pyproject.toml](pyproject.toml);
- [teleoperate.py](lelab/teleoperate.py) — leader→follower teleoperation (wraps `lerobot.teleoperate`).
- [calibrate.py](lelab/calibrate.py) — step-by-step web calibration with a `CalibrationManager` singleton and `_step_complete` threading.Event.
- [train.py](lelab/train.py) — wraps the LeRobot training CLI as a subprocess (psutil for lifecycle, queue for log streaming).
- [datasets.py](lelab/datasets.py) — dataset listing (local cache + Hub, merged under a `source` field) plus the episode-browsing handlers behind `/dataset-episodes`, `/dataset-episode`, `/dataset-frame`, `/dataset-thumbnails`, `/dataset-motion` and `/dataset-video`.
- [episode_media.py](lelab/episode_media.py) — reads the LeRobot v3.0 on-disk layout (`meta/episodes/*.parquet` + `videos/`) and decodes frames with PyAV. Deliberately does **not** go through `LeRobotDataset`, so browsing a dataset never builds a robot config or imports torch. Several episodes share one mp4, so locating an episode resolves both the file and the `from_/to_timestamp` window inside it — which is why `/dataset-video` is addressed by chunk/file rather than by episode.
- [utils/config.py](lelab/utils/config.py) — shared paths and persistence: calibration JSON, saved ports, saved config selections. **Import shared constants from here, do not hardcode paths in feature modules.**

### State model
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ A page will automatically open in your browser and you are ready to go.
<td>📹 <b>Record</b></td>
<td>Capture episodes into a LeRobotDataset, with cameras.</td>
</tr>
<tr>
<td>🔎 <b>Browse</b></td>
<td>Watch the episodes of a dataset on disk — every camera, frame by frame, with a joint-motion trace. No upload required.</td>
</tr>
<tr>
<td>🧠 <b>Train</b></td>
<td>Kick off a LeRobot training job, watch logs live.</td>
Expand Down
72 changes: 72 additions & 0 deletions frontend/src/components/dataset/EpisodeList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React, { useEffect, useRef } from "react";
import { EpisodeSummary, formatDuration } from "@/lib/datasetApi";

interface EpisodeListProps {
episodes: EpisodeSummary[];
selected: number | null;
onSelect: (episodeIndex: number) => void;
}

const EpisodeList: React.FC<EpisodeListProps> = ({ episodes, selected, onSelect }) => {
const activeRef = useRef<HTMLButtonElement | null>(null);

// Follow the selection. Paging with Previous/Next, or opening a link straight
// to episode 40, would otherwise leave the highlighted row scrolled out of
// sight — the list would look like it never moved.
useEffect(() => {
activeRef.current?.scrollIntoView({ block: "nearest" });
}, [selected]);

if (episodes.length === 0) {
return (
<div className="rounded-lg border border-gray-800 bg-gray-950 p-4 text-xs text-gray-500">
No episodes in this dataset.
</div>
);
}

return (
<div className="overflow-hidden rounded-lg border border-gray-800 bg-gray-950">
<div className="flex items-center justify-between border-b border-gray-800 px-3 py-2">
<span className="text-[11px] font-medium uppercase tracking-wider text-gray-500">
Episodes
</span>
<span className="text-[11px] tabular-nums text-gray-600">{episodes.length}</span>
</div>
{/* Tailwind arbitrary values take underscores where the CSS needs spaces:
`calc(100vh-280px)` is not valid CSS and the rule is dropped. */}
<div className="max-h-[calc(100vh_-_280px)] overflow-y-auto">
{episodes.map((ep) => {
const active = ep.episode_index === selected;
return (
<button
key={ep.episode_index}
ref={active ? activeRef : undefined}
type="button"
onClick={() => onSelect(ep.episode_index)}
className={`flex w-full items-center gap-2 border-b border-gray-900 px-3 py-2 text-left transition last:border-b-0 ${
active ? "bg-gray-800/80" : "hover:bg-gray-900"
} focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-orange-500`}
>
<span
className={`w-8 text-xs tabular-nums ${
active ? "font-semibold text-orange-400" : "text-gray-500"
}`}
>
{ep.episode_index}
</span>
<span className="flex-1 truncate text-xs text-gray-300">
{ep.tasks.length > 0 ? ep.tasks.join(", ") : "—"}
</span>
<span className="text-[11px] tabular-nums text-gray-500">
{formatDuration(ep.duration_s)}
</span>
</button>
);
})}
</div>
</div>
);
};

export default EpisodeList;
Loading