diff --git a/CLAUDE.md b/CLAUDE.md index 0e539c40..4c304af1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index a1c966e1..4532e391 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ A page will automatically open in your browser and you are ready to go. 📹 Record Capture episodes into a LeRobotDataset, with cameras. + + 🔎 Browse + Watch the episodes of a dataset on disk — every camera, frame by frame, with a joint-motion trace. No upload required. + 🧠 Train Kick off a LeRobot training job, watch logs live. diff --git a/frontend/src/components/dataset/EpisodeList.tsx b/frontend/src/components/dataset/EpisodeList.tsx new file mode 100644 index 00000000..120a24ac --- /dev/null +++ b/frontend/src/components/dataset/EpisodeList.tsx @@ -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 = ({ episodes, selected, onSelect }) => { + const activeRef = useRef(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 ( +
+ No episodes in this dataset. +
+ ); + } + + return ( +
+
+ + Episodes + + {episodes.length} +
+ {/* Tailwind arbitrary values take underscores where the CSS needs spaces: + `calc(100vh-280px)` is not valid CSS and the rule is dropped. */} +
+ {episodes.map((ep) => { + const active = ep.episode_index === selected; + return ( + + ); + })} +
+
+ ); +}; + +export default EpisodeList; diff --git a/frontend/src/components/dataset/EpisodeViewer.tsx b/frontend/src/components/dataset/EpisodeViewer.tsx new file mode 100644 index 00000000..6d139d33 --- /dev/null +++ b/frontend/src/components/dataset/EpisodeViewer.tsx @@ -0,0 +1,391 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ChevronLeft, + ChevronRight, + Grid2X2, + Loader2, + Maximize2, + Pause, + Play, + SkipBack, + SkipForward, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useApi } from "@/contexts/ApiContext"; +import { EpisodeDetail, videoUrl } from "@/lib/datasetApi"; +import FilmStrip from "./FilmStrip"; +import MotionTrace from "./MotionTrace"; + +const RATES = [0.5, 1, 2] as const; +type Rate = (typeof RATES)[number]; + +/** + * Secondaries are nudged only when they drift past this. Chasing every + * timeupdate would thrash the decoder for sub-frame corrections nobody sees. + */ +const SYNC_DEADBAND_S = 0.2; + +interface EpisodeViewerProps { + repoId: string; + detail: EpisodeDetail; + onNavigate: (episodeIndex: number) => void; +} + +const EpisodeViewer: React.FC = ({ repoId, detail, onNavigate }) => { + const { baseUrl } = useApi(); + const cams = detail.cameras; + + const [activeCam, setActiveCam] = useState(cams[0]?.name ?? ""); + const [showAll, setShowAll] = useState(false); + const [playing, setPlaying] = useState(false); + const [rate, setRate] = useState(1); + const [currentFrame, setCurrentFrame] = useState(0); + + const driverRef = useRef(null); + const secondaryRefs = useRef>({}); + + // Episode changed (or its cameras did) → fall back to a camera that exists. + useEffect(() => { + if (!cams.some((c) => c.name === activeCam)) setActiveCam(cams[0]?.name ?? ""); + }, [cams, activeCam]); + + const activeInfo = useMemo( + () => cams.find((c) => c.name === activeCam) ?? cams[0] ?? null, + [cams, activeCam], + ); + + const fps = detail.fps ?? 20; + // The mp4 holds several episodes back to back; this one occupies + // [fromTs, toTs). Everything below is expressed against that window. + const fromTs = activeInfo?.from_timestamp ?? 0; + const toTs = activeInfo?.to_timestamp ?? fromTs + (detail.duration_s ?? 0); + + const seekToFrame = useCallback( + (frameIndex: number) => { + const v = driverRef.current; + if (!v) return; + const clamped = Math.max(0, Math.min(frameIndex, Math.max(detail.length - 1, 0))); + v.currentTime = fromTs + clamped / fps; + setCurrentFrame(clamped); + }, + [fromTs, fps, detail.length], + ); + + const handleLoadedMetadata = useCallback(() => { + const v = driverRef.current; + if (!v) return; + v.currentTime = fromTs; + // A fresh element resets playbackRate to 1, which used to leave the pills + // reading "2x" while playback ran at 1x. + v.playbackRate = rate; + setCurrentFrame(0); + }, [fromTs, rate]); + + // Move to the new episode's window when the selection changes. + // + // The element is keyed on the *file*, so paging between episodes that share + // an mp4 doesn't remount it and `loadedMetadata` won't fire again — this is + // what actually puts the player on the new episode. Keying on the episode + // instead would rebuild the player every time, which silently drops playback + // (a new element is always paused) and leaves a frozen frame behind. + useEffect(() => { + const v = driverRef.current; + if (!v) return; + const wasPlaying = !v.paused; + const goToEpisode = () => { + v.currentTime = fromTs; + v.playbackRate = rate; + setCurrentFrame(0); + // Keep playing across the switch rather than stranding the user on a + // still frame with the button claiming it's playing. + if (wasPlaying || playing) void v.play().catch(() => setPlaying(false)); + }; + if (v.readyState >= 1) { + goToEpisode(); + } else { + v.addEventListener("loadedmetadata", goToEpisode, { once: true }); + return () => v.removeEventListener("loadedmetadata", goToEpisode); + } + // `playing` is deliberately not a dep: this runs on episode/camera change, + // not every play/pause. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [detail.episode_index, activeCam, fromTs]); + + const handleTimeUpdate = useCallback(() => { + const v = driverRef.current; + if (!v) return; + // Loop inside this episode's window rather than running on into the next + // episode's footage, which shares the file. + if (v.currentTime >= toTs) { + v.currentTime = fromTs; + setCurrentFrame(0); + return; + } + setCurrentFrame(Math.max(0, Math.floor((v.currentTime - fromTs) * fps))); + + if (!showAll) return; + // Each camera sits at its own offset inside its own file, so a secondary is + // synced by mapping through *its* from_timestamp — not by copying + // currentTime, which would land it in a neighbouring episode. + for (const c of cams) { + if (c.name === activeCam) continue; + const el = secondaryRefs.current[c.name]; + if (!el) continue; + const target = (c.from_timestamp ?? 0) + (v.currentTime - fromTs); + if (Math.abs(el.currentTime - target) > SYNC_DEADBAND_S) el.currentTime = target; + el.playbackRate = v.playbackRate; + if (playing && el.paused) void el.play().catch(() => undefined); + if (!playing && !el.paused) el.pause(); + } + }, [toTs, fromTs, fps, showAll, cams, activeCam, playing]); + + const togglePlay = useCallback(() => { + const v = driverRef.current; + if (!v) return; + if (v.paused) { + void v.play().then( + () => setPlaying(true), + () => setPlaying(false), + ); + } else { + v.pause(); + setPlaying(false); + } + }, []); + + // Keep the element's rate in step with the pills across re-renders and + // camera swaps. + useEffect(() => { + if (driverRef.current) driverRef.current.playbackRate = rate; + }, [rate, activeCam]); + + // Space toggles playback, except while typing in a field. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const el = e.target as HTMLElement | null; + if (el && ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName)) return; + if (e.code === "Space") { + e.preventDefault(); + togglePlay(); + } else if (e.code === "ArrowLeft") { + seekToFrame(currentFrame - 1); + } else if (e.code === "ArrowRight") { + seekToFrame(currentFrame + 1); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [togglePlay, seekToFrame, currentFrame]); + + if (!activeInfo) { + return ( +
+ This episode has no camera videos on disk. +
+ ); + } + + const secondaries = cams.filter((c) => c.name !== activeCam); + + return ( +
+ {/* Camera + view controls */} +
+
+ {cams.map((c) => ( + + ))} +
+ + {cams.length > 1 && ( + + )} + +
+ {RATES.map((r) => ( + + ))} +
+
+ + {/* Viewport */} +
1 + ? "grid grid-cols-1 gap-2 sm:grid-cols-2" + : "grid grid-cols-1" + } + > +
+
+ + {showAll && + secondaries.map((c) => ( +
+
+ ))} +
+ + {/* Transport */} +
+ + + + + seekToFrame(Number(e.target.value))} + className="mx-2 h-1 flex-1 cursor-pointer appearance-none rounded bg-gray-800 accent-orange-500" + aria-label="Scrub episode" + /> + + + {currentFrame} / {Math.max(detail.length - 1, 0)} + +
+ + + + + + {/* Episode paging */} +
+ + + Episode {detail.episode_index} of {detail.total_episodes} + + +
+
+ ); +}; + +export default EpisodeViewer; diff --git a/frontend/src/components/dataset/FilmStrip.tsx b/frontend/src/components/dataset/FilmStrip.tsx new file mode 100644 index 00000000..9475aa24 --- /dev/null +++ b/frontend/src/components/dataset/FilmStrip.tsx @@ -0,0 +1,105 @@ +import React, { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { useApi } from "@/contexts/ApiContext"; +import { Thumbnail, getThumbnails } from "@/lib/datasetApi"; + +interface FilmStripProps { + repoId: string; + episodeIndex: number; + camera: string; + /** Episode-relative frame currently on screen; the nearest thumb highlights. */ + currentFrame: number; + onSeekFrame: (frameIndex: number) => void; + count?: number; +} + +/** + * Thumbnails across the episode. One request for the whole strip — the backend + * decodes them in a single pass over the mp4 rather than reopening it per frame. + */ +const FilmStrip: React.FC = ({ + repoId, + episodeIndex, + camera, + currentFrame, + onSeekFrame, + count = 12, +}) => { + const { baseUrl, fetchWithHeaders } = useApi(); + const [thumbs, setThumbs] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + getThumbnails(baseUrl, fetchWithHeaders, repoId, episodeIndex, camera, count, controller.signal) + .then((r) => { + if (controller.signal.aborted) return; + setThumbs(r.success ? r.thumbnails : []); + }) + .catch(() => { + if (!controller.signal.aborted) setThumbs([]); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [baseUrl, fetchWithHeaders, repoId, episodeIndex, camera, count]); + + // Highlight the thumb nearest the playhead rather than the one before it, so + // the marker tracks what's actually on screen. + const activeIdx = thumbs.reduce( + (best, t, i) => + Math.abs(t.frame_index - currentFrame) < + Math.abs((thumbs[best]?.frame_index ?? Infinity) - currentFrame) + ? i + : best, + 0, + ); + + if (loading && thumbs.length === 0) { + return ( +
+ +
+ ); + } + + if (thumbs.length === 0) { + return ( +
+ No thumbnails +
+ ); + } + + return ( +
+ {thumbs.map((t, i) => ( + + ))} +
+ ); +}; + +export default FilmStrip; diff --git a/frontend/src/components/dataset/MotionTrace.tsx b/frontend/src/components/dataset/MotionTrace.tsx new file mode 100644 index 00000000..de3f5045 --- /dev/null +++ b/frontend/src/components/dataset/MotionTrace.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Area, + AreaChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + YAxis, +} from "recharts"; +import { useApi } from "@/contexts/ApiContext"; +import { getMotion } from "@/lib/datasetApi"; + +interface MotionTraceProps { + repoId: string; + episodeIndex: number; + currentFrame: number; + onSeekFrame: (frameIndex: number) => void; +} + +/** + * Aggregate joint motion across the episode — one value per frame, summing the + * absolute change of every joint in the commanded action vector. + * + * Collapsing all joints into a single trace is the point: it makes the shape of + * a demonstration legible at a glance (idle head, the busy middle, idle tail) + * without asking anyone to read six overlapping lines. + */ +const MotionTrace: React.FC = ({ + repoId, + episodeIndex, + currentFrame, + onSeekFrame, +}) => { + const { baseUrl, fetchWithHeaders } = useApi(); + const [motion, setMotion] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setError(null); + getMotion(baseUrl, fetchWithHeaders, repoId, episodeIndex, controller.signal) + .then((r) => { + if (controller.signal.aborted) return; + if (!r.success) { + setMotion(null); + setError(r.message ?? "No action stream"); + return; + } + setMotion(r.motion); + }) + .catch((e) => { + if (controller.signal.aborted) return; + setMotion(null); + setError(e instanceof Error ? e.message : "No action stream"); + }); + return () => controller.abort(); + }, [baseUrl, fetchWithHeaders, repoId, episodeIndex]); + + const data = useMemo( + () => (motion ?? []).map((v, i) => ({ frame: i, motion: v })), + [motion], + ); + + if (error) { + return ( +
+ Joint motion unavailable — {error} +
+ ); + } + + if (!motion) { + return
; + } + + return ( +
+
+ + Joint motion + + + frame {currentFrame} + +
+
+ + { + // activeLabel is the `frame` value under the cursor. + const f = e?.activeLabel; + if (f !== undefined && f !== null) onSeekFrame(Number(f)); + }} + > + + + + + + + + [v.toFixed(2), "motion"]} + labelFormatter={(l) => `Frame ${l}`} + /> + + + + +
+
+ ); +}; + +export default MotionTrace; diff --git a/frontend/src/hooks/useEpisodes.ts b/frontend/src/hooks/useEpisodes.ts new file mode 100644 index 00000000..7ec11fd0 --- /dev/null +++ b/frontend/src/hooks/useEpisodes.ts @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useState } from "react"; +import { useApi } from "@/contexts/ApiContext"; +import { + EpisodeDetail, + EpisodeListResponse, + getEpisode, + listEpisodes, +} from "@/lib/datasetApi"; + +/** Episode listing for one local dataset. `repoId` null → idle. */ +export const useEpisodes = (repoId: string | null) => { + const { baseUrl, fetchWithHeaders } = useApi(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(() => { + if (!repoId) { + setData(null); + setError(null); + return; + } + setLoading(true); + setError(null); + listEpisodes(baseUrl, fetchWithHeaders, repoId) + .then((r) => { + // The backend reports a readable dataset it couldn't parse as + // success:false rather than an HTTP error, so check the flag too. + if (!r.success) { + setData(null); + setError(r.message ?? "Could not read dataset"); + return; + } + setData(r); + }) + .catch((e) => { + setData(null); + setError(e instanceof Error ? e.message : "Could not read dataset"); + }) + .finally(() => setLoading(false)); + }, [baseUrl, fetchWithHeaders, repoId]); + + useEffect(() => { + refresh(); + }, [refresh]); + + return { data, loading, error, refresh }; +}; + +/** One episode's detail. Either arg null → idle. */ +export const useEpisodeDetail = (repoId: string | null, episodeIndex: number | null) => { + const { baseUrl, fetchWithHeaders } = useApi(); + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!repoId || episodeIndex === null) { + setDetail(null); + return; + } + const controller = new AbortController(); + setLoading(true); + setError(null); + getEpisode(baseUrl, fetchWithHeaders, repoId, episodeIndex, controller.signal) + .then((r) => { + if (controller.signal.aborted) return; + if (!r.success) { + setDetail(null); + setError(r.message ?? "Could not load episode"); + return; + } + setDetail(r); + }) + .catch((e) => { + if (controller.signal.aborted) return; + setDetail(null); + setError(e instanceof Error ? e.message : "Could not load episode"); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + // Clicking through episodes quickly would otherwise let a slow earlier + // response land after a faster later one. + return () => controller.abort(); + }, [baseUrl, fetchWithHeaders, repoId, episodeIndex]); + + return { detail, loading, error }; +}; diff --git a/frontend/src/lib/datasetApi.ts b/frontend/src/lib/datasetApi.ts new file mode 100644 index 00000000..68108bfa --- /dev/null +++ b/frontend/src/lib/datasetApi.ts @@ -0,0 +1,190 @@ +import { Fetcher, apiRequest } from "./apiClient"; + +export interface EpisodeSummary { + episode_index: number; + length: number; + duration_s: number | null; + tasks: string[]; +} + +export interface EpisodeListResponse { + success: boolean; + message?: string; + repo_id: string; + fps: number | null; + robot_type: string | null; + total_episodes: number; + total_frames: number | null; + cameras: string[]; + episodes: EpisodeSummary[]; +} + +export interface EpisodeCamera { + name: string; + /** + * Where this episode starts inside the camera's mp4. Several episodes share + * one file, so this is almost never 0 — and each camera carries its own + * offset, which is why syncing secondaries means mapping through this value + * rather than copying the driver's currentTime. + */ + from_timestamp: number | null; + to_timestamp: number | null; + chunk: number; + file_index: number; +} + +export interface EpisodeDetail { + success: boolean; + message?: string; + repo_id: string; + episode_index: number; + length: number; + duration_s: number | null; + tasks: string[]; + fps: number | null; + robot_type: string | null; + cameras: EpisodeCamera[]; + prev_episode: number | null; + next_episode: number | null; + total_episodes: number; +} + +export interface MotionResponse { + success: boolean; + message?: string; + episode_index: number; + /** One aggregate value per frame: the sum of absolute joint deltas. */ + motion: number[]; + max_motion: number; +} + +export interface Thumbnail { + frame_index: number; + data_uri: string; +} + +export interface ThumbnailsResponse { + success: boolean; + message?: string; + episode_index: number; + camera: string; + thumbnails: Thumbnail[]; +} + +export async function listEpisodes( + baseUrl: string, + fetcher: Fetcher, + repoId: string, + signal?: AbortSignal, +): Promise { + const qs = new URLSearchParams({ repo_id: repoId }); + return apiRequest(baseUrl, fetcher, `/dataset-episodes?${qs}`, { + signal, + action: "List episodes", + }); +} + +export async function getEpisode( + baseUrl: string, + fetcher: Fetcher, + repoId: string, + episodeIndex: number, + signal?: AbortSignal, +): Promise { + const qs = new URLSearchParams({ + repo_id: repoId, + episode_index: String(episodeIndex), + }); + return apiRequest(baseUrl, fetcher, `/dataset-episode?${qs}`, { + signal, + action: "Load episode", + }); +} + +export async function getMotion( + baseUrl: string, + fetcher: Fetcher, + repoId: string, + episodeIndex: number, + signal?: AbortSignal, +): Promise { + const qs = new URLSearchParams({ + repo_id: repoId, + episode_index: String(episodeIndex), + }); + return apiRequest(baseUrl, fetcher, `/dataset-motion?${qs}`, { + signal, + action: "Load motion trace", + }); +} + +export async function getThumbnails( + baseUrl: string, + fetcher: Fetcher, + repoId: string, + episodeIndex: number, + camera: string, + count: number, + signal?: AbortSignal, +): Promise { + const qs = new URLSearchParams({ + repo_id: repoId, + episode_index: String(episodeIndex), + camera, + count: String(count), + }); + return apiRequest(baseUrl, fetcher, `/dataset-thumbnails?${qs}`, { + signal, + action: "Load thumbnails", + }); +} + +/** + * URL for one camera's mp4. Goes straight into