Skip to content
Open
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
67 changes: 64 additions & 3 deletions frontend/src/components/landing/DatasetPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +44,7 @@ const DatasetPicker: React.FC<DatasetPickerProps> = ({
datasets,
loading,
onPickExisting,
onResumeExisting,
onCreateNew,
onOpenCustom,
children,
Expand Down Expand Up @@ -87,22 +101,69 @@ const DatasetPicker: React.FC<DatasetPickerProps> = ({
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 (
<CommandItem
key={d.repo_id}
value={d.repo_id}
onSelect={() => handlePick(d)}
className="text-white aria-selected:bg-gray-700"
>
<span className="flex-1 truncate">{d.repo_id}</span>
<div className="flex-1 min-w-0">
<span className="block truncate">{d.repo_id}</span>
{detailParts.length > 0 && (
<span className="block text-xs text-gray-400">
{detailParts.join(" · ")}
</span>
)}
{progress !== null && (
<span className="mt-1 block h-1 w-full rounded bg-gray-700">
<span
className={`block h-1 rounded ${progress >= 100 ? "bg-green-500" : "bg-red-400"}`}
style={{ width: `${progress}%` }}
/>
</span>
)}
</div>
{d.source === "both" && (
<span className="text-xs text-gray-400 mr-2">on Hub</span>
)}
{d.private && (
<span className="text-xs text-amber-400">private</span>
)}
{(d.source === "local" || d.source === "both") && onResumeExisting && (
<button
type="button"
title="Continue recording: append new episodes to this dataset"
onClick={(e) => {
e.stopPropagation();
onResumeExisting(d);
reset();
}}
className="ml-2 flex shrink-0 items-center gap-1 rounded-full border border-gray-600 px-2 py-0.5 text-xs text-gray-300 hover:border-red-400 hover:text-red-400 hover:bg-gray-700"
>
<Plus className="h-3 w-3" />
episodes
</button>
)}
</CommandItem>
);
);
};

return (
<Popover open={open} onOpenChange={setOpen}>
Expand Down
178 changes: 151 additions & 27 deletions frontend/src/components/landing/RecordingModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -50,6 +55,10 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
open,
onOpenChange,
robot,
resumeMode = false,
resumeInfo = null,
episodeTarget = null,
setEpisodeTarget,
datasetName,
setDatasetName,
singleTask,
Expand All @@ -69,7 +78,17 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
}) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
Expand All @@ -81,7 +100,7 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
</div>
</div>
<DialogTitle className="text-white text-center text-2xl font-bold">
Configure Recording
{resumeMode ? "Continue Recording" : "Configure Recording"}
</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
Expand Down Expand Up @@ -124,6 +143,84 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
<h3 className="text-lg font-semibold text-white border-b border-gray-700 pb-2">
Dataset Configuration
</h3>
{resumeMode && (
<Alert className="bg-blue-900/40 border-blue-700 text-blue-100">
<Disc className="h-4 w-4" />
<AlertDescription>
New episodes will be appended to{" "}
<strong className="font-mono">{datasetName}</strong>
{resumeInfo && (
<> ({resumeInfo.numEpisodes} episodes so far)</>
)}
. The setup must match the original recording exactly.
</AlertDescription>
</Alert>
)}
{resumeMode && resumeInfo && (
<div className="rounded-lg border border-gray-700 bg-gray-800/60 p-3 space-y-2">
<p className="text-sm font-medium text-gray-200">
Required setup (from the dataset)
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 text-xs">
<div className="rounded bg-gray-800 border border-gray-700 px-2 py-1.5">
<span className="text-gray-500 block">Robot type</span>
<span className="text-gray-200 font-mono">
{resumeInfo.robotType}
</span>
</div>
<div className="rounded bg-gray-800 border border-gray-700 px-2 py-1.5">
<span className="text-gray-500 block">FPS</span>
<span className="text-gray-200 font-mono">
{resumeInfo.fps}
</span>
</div>
<div
className={`rounded px-2 py-1.5 border ${
camerasMatch
? "bg-gray-800 border-gray-700"
: "bg-red-900/40 border-red-700"
}`}
>
<span className="text-gray-500 block">Cameras</span>
<span
className={`font-mono ${camerasMatch ? "text-gray-200" : "text-red-300"}`}
>
{resumeInfo.cameras.join(", ") || "none"}
</span>
</div>
</div>
{!camerasMatch && (
<Alert className="bg-red-900/40 border-red-700 text-red-100">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-xs">
Camera setup doesn&apos;t match the dataset.
{missingCameras.length > 0 && (
<>
{" "}
Missing:{" "}
<strong className="font-mono">
{missingCameras.join(", ")}
</strong>
.
</>
)}
{extraCameras.length > 0 && (
<>
{" "}
Not in the dataset:{" "}
<strong className="font-mono">
{extraCameras.join(", ")}
</strong>
.
</>
)}{" "}
Rename or adjust your cameras below to match, then the
button will unlock.
</AlertDescription>
</Alert>
)}
</div>
)}
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label
Expand All @@ -135,20 +232,24 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
<Input
id="datasetName"
value={datasetName}
disabled={resumeMode}
onChange={(e) =>
setDatasetName(
e.target.value.replace(/[^A-Za-z0-9._-]/g, "_")
)
}
placeholder="my_dataset"
className="bg-gray-800 border-gray-700 text-white"
className="bg-gray-800 border-gray-700 text-white disabled:opacity-60"
/>
<p className="text-xs text-gray-500">
Letters, numbers, <code>.</code> <code>_</code>{" "}
<code>-</code> only — other characters become{" "}
<code>_</code>.
</p>
{!resumeMode && (
<p className="text-xs text-gray-500">
Letters, numbers, <code>.</code> <code>_</code>{" "}
<code>-</code> only — other characters become{" "}
<code>_</code>.
</p>
)}
{datasetName &&
!resumeMode &&
(auth.status === "authenticated" ? (
<p className="text-xs text-gray-500">
Will be saved as{" "}
Expand Down Expand Up @@ -177,23 +278,46 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
className="bg-gray-800 border-gray-700 text-white"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="numEpisodes"
className="text-sm font-medium text-gray-300"
>
Number of Episodes
</Label>
<NumberInput
id="numEpisodes"
min="1"
max="100"
value={numEpisodes}
onChange={(v) => {
if (v !== undefined) setNumEpisodes(v);
}}
className="bg-gray-800 border-gray-700 text-white"
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label
htmlFor="numEpisodes"
className="text-sm font-medium text-gray-300"
>
{resumeMode ? "Episodes to add" : "Number of Episodes"}
</Label>
<NumberInput
id="numEpisodes"
min="1"
max="100"
value={numEpisodes}
onChange={(v) => {
if (v !== undefined) setNumEpisodes(v);
}}
className="bg-gray-800 border-gray-700 text-white"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="episodeTarget"
className="text-sm font-medium text-gray-300"
>
Episode goal (optional)
</Label>
<NumberInput
id="episodeTarget"
min="1"
value={episodeTarget ?? undefined}
onChange={(v) => {
setEpisodeTarget?.(v ?? null);
}}
className="bg-gray-800 border-gray-700 text-white"
/>
<p className="text-xs text-gray-500">
Total episodes you aim for across all sessions — shows
progress in the dataset list.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
Expand Down Expand Up @@ -286,7 +410,7 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
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"}
</Button>
<Button
onClick={() => onOpenChange(false)}
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/lib/episodeTargets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const STORAGE_KEY = "lelab-episode-targets";

type TargetMap = Record<string, number>;

function readMap(): TargetMap {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as TargetMap) : {};
} catch {
return {};
}
}

function writeMap(map: TargetMap) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
} catch {
// localStorage unavailable (private mode, etc.) — targets are a comfort
// feature, silently skip.
}
}

export function getEpisodeTarget(repoId: string): number | null {
const value = readMap()[repoId];
return typeof value === "number" && value > 0 ? value : null;
}

export function setEpisodeTarget(repoId: string, target: number | null) {
const map = readMap();
if (target && target > 0) {
map[repoId] = target;
} else {
delete map[repoId];
}
writeMap(map);
}
Loading