mishig HF Staff commited on
Commit
2a0e168
Β·
verified Β·
1 Parent(s): 273fc04

Sync from huggingface/lerobot-dataset-visualizer via hub-sync

Browse files
Files changed (5) hide show
  1. AGENTS.md +147 -0
  2. CLAUDE.md +1 -140
  3. bun.lock +2 -2
  4. package.json +1 -1
  5. src/app/[org]/[dataset]/[episode]/fetch-data.ts +145 -391
AGENTS.md ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AGENTS.md β€” LeRobot Dataset Visualizer
2
+
3
+ ## Package manager
4
+
5
+ Always use **bun** (`bun install`, `bun dev`, `bun run build`, `bun test`). Never use npm or yarn.
6
+
7
+ ## Post-process β€” run after every code change
8
+
9
+ After making any code changes, always run these commands in order and fix any errors before finishing:
10
+
11
+ ```
12
+ bun run format # auto-fix formatting (prettier)
13
+ bun run type-check # TypeScript: app + test files
14
+ bun run lint # ESLint (next lint)
15
+ bun test # unit tests
16
+ ```
17
+
18
+ Or run them all at once (format first, then the full validate suite):
19
+
20
+ ```
21
+ bun run format && bun run validate
22
+ ```
23
+
24
+ `bun run validate` runs: type-check β†’ lint β†’ format:check β†’ test
25
+
26
+ ## Key scripts
27
+
28
+ ```
29
+ bun dev # Next.js dev server
30
+ bun test # Run all unit tests (bun:test)
31
+ bun run type-check # tsc --noEmit (app) + tsc -p tsconfig.test.json --noEmit (tests)
32
+ bun run lint # next lint
33
+ bun run validate # type-check + lint + format:check + test
34
+ ```
35
+
36
+ ## Architecture
37
+
38
+ ### Dataset version support
39
+
40
+ Three versions are supported. Version is detected from `meta/info.json` β†’ `codebase_version`.
41
+
42
+ | Version | Path pattern | Episode metadata | Video |
43
+ | -------- | -------------------------------------------------------------- | ------------------------------------------ | ---------------------------------------------- |
44
+ | **v2.0** | `data/{episode_chunk:03d}/episode_{episode_index:06d}.parquet` | None (computed from `chunks_size`) | Full file per episode |
45
+ | **v2.1** | Same as v2.0 | None | Full file per episode |
46
+ | **v3.0** | `data/chunk-{N:03d}/file-{N:03d}.parquet` | `meta/episodes/chunk-{N}/file-{N}.parquet` | Segmented (timestamps per episode, per camera) |
47
+
48
+ ### Routing to parsers
49
+
50
+ `src/app/[org]/[dataset]/[episode]/fetch-data.ts` β†’ `getEpisodeData()` dispatches to:
51
+
52
+ - `getEpisodeDataV2()` for v2.0 and v2.1
53
+ - `getEpisodeDataV3()` for v3.0
54
+
55
+ ### `@huggingface/lerobot`
56
+
57
+ Locating an episode (its video URLs and offsets, its data file and row range) and reading camera sizes go through [`@huggingface/lerobot`](https://github.com/huggingface/huggingface.js/tree/main/packages/lerobot). Don't build v3 data, video or episode-metadata paths by hand.
58
+
59
+ - Create datasets with `leRobotDataset(repoId)` in `fetch-data.ts`: it passes the `DATASET_URL` endpoint and `authHeaders()`.
60
+ - `episodes({ offset, limit })` reads the v3 episode index across every metadata chunk. `offset` is a position in the listing, not an `episode_index`.
61
+ - `episode.data.fromRow` / `toRow` are row offsets **inside `episode.data.url`**, not dataset-wide frame indexes. Pass them straight to `rowStart` / `rowEnd`; never subtract the file's first `index`. `episode.data` is optional: it is absent when the data file can't be read.
62
+ - Episode charts come from `frames()` (numeric and boolean series, bookkeeping columns left out). `loadEpisodeFrames` reads the task and language columns (`TEXT_COLUMNS`) from the same file alongside it, since `frames()` returns numbers only.
63
+ - Camera sizes come from `parseInfo(...).cameras`. Never read them from `shape[0]` / `shape[1]`: some datasets declare channel-first shapes (`[3, H, W]`).
64
+ - To upgrade, bump the version in `package.json` and run `bun install`. Read the package's changes first: 0.0.4 changed what `fromRow` / `toRow` mean.
65
+
66
+ ### v3.0 specifics
67
+
68
+ - Integer columns from parquet come out as **BigInt** β€” always use `bigIntToNumber()` from `src/utils/typeGuards.ts`
69
+ - Fallback format uses numeric keys `"0"`.."9"` when column names are unavailable
70
+ - Multi-task episodes: episodes carry a `tasks` list (`list[str]`) β€” prefer it over the legacy single `task_index` lookup. `EpisodeMetadataV3.tasks?: string[]` exposes it.
71
+ - `meta/tasks.parquet` lookup: rows are **not** ordered by `task_index`, and the task string lives in a named pandas index (`__index_level_0__`). Always filter by the `task_index` **column** (`row.task_index === taskIndexNum`), never by row position.
72
+
73
+ ### v2.x path construction
74
+
75
+ ```ts
76
+ formatStringWithVars(info.data_path, {
77
+ episode_chunk: Math.floor(episodeId / chunkSize)
78
+ .toString()
79
+ .padStart(3, "0"),
80
+ episode_index: episodeId.toString().padStart(6, "0"),
81
+ });
82
+ // β†’ "data/000/episode_000042.parquet"
83
+ ```
84
+
85
+ `formatStringWithVars` strips `:03d` format specifiers β€” padding must be done by the caller.
86
+
87
+ ## Key files
88
+
89
+ | File | Purpose |
90
+ | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
91
+ | `src/app/[org]/[dataset]/[episode]/fetch-data.ts` | Main data-loading entry point; v2/v3 parsers; `computeColumnMinMax` |
92
+ | `src/utils/versionUtils.ts` | `getDatasetInfo`, `getDatasetVersionAndInfo`, `buildVersionedUrl` |
93
+ | `src/utils/parquetUtils.ts` | `fetchParquetFile`, `readParquetAsObjects`, `formatStringWithVars` |
94
+ | `src/utils/dataProcessing.ts` | Chart grouping pipeline: `buildSuffixGroupsMap` β†’ `computeGroupStats` β†’ `groupByScale` β†’ `flattenScaleGroups` β†’ `processChartDataGroups` |
95
+ | `src/utils/typeGuards.ts` | `bigIntToNumber`, `isNumeric`, `isValidTaskIndex`, etc. |
96
+ | `src/utils/constants.ts` | `PADDING`, `EXCLUDED_COLUMNS`, `CHART_CONFIG`, `THRESHOLDS` |
97
+ | `src/types/` | TypeScript types: `DatasetVersion`, `EpisodeMetadataV3`, `VideoInfo`, `ChartDataGroup`, etc. |
98
+
99
+ ## Chart data pipeline
100
+
101
+ Series keys use `" | "` as delimiter (e.g. `observation.state | 0`).
102
+ `groupRowBySuffix` groups by **suffix**: if two different prefixes share suffix `"0"` (e.g. `observation.state | 0` and `action | 0`), they are merged under `result["0"] = { "observation.state": ..., "action": ... }`. A series with a unique suffix stays flat with its full original key.
103
+
104
+ ## Testing
105
+
106
+ - Test files live in `**/__tests__/` directories alongside source
107
+ - Uses `bun:test` (built-in, no extra install)
108
+ - BigInt literals (`42n`) require `tsconfig.test.json` (target ES2020) β€” test files are excluded from `tsconfig.json`
109
+ - `@types/bun` is installed as a devDependency for `bun:test` type resolution
110
+ - Mocking fetch: `globalThis.fetch = mock(() => Promise.resolve(new Response(...))) as unknown as typeof fetch`
111
+ - CI: `.github/workflows/test.yml` runs `bun test` on push/PR to main
112
+
113
+ ## URL structure
114
+
115
+ All dataset URLs:
116
+
117
+ ```
118
+ https://huggingface.co/datasets/{org}/{dataset}/resolve/main/{path}
119
+ ```
120
+
121
+ Built by `buildVersionedUrl(repoId, version, path)`. The `version` param is accepted but currently unused in the URL (always `main` revision).
122
+
123
+ ## Excluded columns (not shown in charts)
124
+
125
+ Reserved/bookkeeping columns from lerobot β€” see `EXCLUDED_COLUMNS` in `src/utils/constants.ts`:
126
+
127
+ - v2.x: `timestamp`, `frame_index`, `episode_index`, `index`, `task_index`, `next.reward`, `next.done`, `next.truncated`
128
+ - v3.0: `index`, `task_index`, `episode_index`, `frame_index`, `next.reward`, `next.done`, `next.truncated`, `subtask_index`
129
+
130
+ ## 3D URDF viewer (`src/components/urdf-viewer.tsx`)
131
+
132
+ - URDFs and meshes are hosted in the HF bucket `lerobot/robot-urdfs` β€” base URL `https://huggingface.co/buckets/lerobot/robot-urdfs/resolve` (no `/main` segment; buckets are unbranched). Override with `NEXT_PUBLIC_URDF_BASE_URL` for local development.
133
+ - Asset layout under the bucket: `g1/`, `openarm/`, `so101/` (both SO-100 and SO-101 live here).
134
+ - **URDFLoader gotcha**: after our `loadMeshCb` returns, `URDFLoader.js` does `if (obj instanceof THREE.Mesh) obj.material = <urdf-material>`, overwriting any material we set. Workaround: wrap the loaded mesh in a `THREE.Group` so the `instanceof Mesh` check fails. DAE returns a Group already; STL must be wrapped explicitly.
135
+ - **STLLoader event ordering**: `manager.itemEnd(url)` fires _before_ the user `onLoad` callback, so `manager.onLoad` can fire before meshes are attached to the robot tree. Defer post-load work (auto-fit camera, shadow flags) with `setTimeout(..., 0)`. Don't try to rebuild materials in `manager.onLoad` β€” pick the archetype color directly inside `loadMeshCb`.
136
+ - **OpenArm DAE files ship 23 stray `PointLight`s** that drown out scene lighting. Strip non-`AmbientLight` lights from `collada.scene` before adding it to the robot.
137
+ - Scene setup: `<Canvas shadows>` with `ACESFilmicToneMapping` (exposure 0.9), 3-point directional + ambient lights, `<Environment preset="studio" background={false} />`, `<color attach="background" args={["#1a2433"]} />`. `<OrbitControls makeDefault />` is required so `useThree().controls` exposes the controls for auto-fit.
138
+
139
+ ## Design system
140
+
141
+ CSS tokens in `src/app/globals.css` (Tailwind v4 `@theme inline`):
142
+
143
+ - Surfaces: `--bg #0a0e17`, `--surface-0`, `--surface-1`, `--surface-2`
144
+ - Text: `--text-primary`, `--text-muted`, `--text-faint`
145
+ - Accent: `--accent #38bdf8` (cyan) β€” primary interactive color across UI
146
+ - Helpers: `.panel`, `.panel-raised`, `.tabular` (tabular-nums)
147
+ - **Color semantics**: cyan = primary/active, orange (`orange-400/500`) is reserved for **flagged-episode** UI only β€” don't reuse it for generic accents.
CLAUDE.md CHANGED
@@ -1,140 +1 @@
1
- # CLAUDE.md β€” LeRobot Dataset Visualizer
2
-
3
- ## Package manager
4
-
5
- Always use **bun** (`bun install`, `bun dev`, `bun run build`, `bun test`). Never use npm or yarn.
6
-
7
- ## Post-process β€” run after every code change
8
-
9
- After making any code changes, always run these commands in order and fix any errors before finishing:
10
-
11
- ```
12
- bun run format # auto-fix formatting (prettier)
13
- bun run type-check # TypeScript: app + test files
14
- bun run lint # ESLint (next lint)
15
- bun test # unit tests
16
- ```
17
-
18
- Or run them all at once (format first, then the full validate suite):
19
-
20
- ```
21
- bun run format && bun run validate
22
- ```
23
-
24
- `bun run validate` runs: type-check β†’ lint β†’ format:check β†’ test
25
-
26
- ## Key scripts
27
-
28
- ```
29
- bun dev # Next.js dev server
30
- bun test # Run all unit tests (bun:test)
31
- bun run type-check # tsc --noEmit (app) + tsc -p tsconfig.test.json --noEmit (tests)
32
- bun run lint # next lint
33
- bun run validate # type-check + lint + format:check
34
- ```
35
-
36
- ## Architecture
37
-
38
- ### Dataset version support
39
-
40
- Three versions are supported. Version is detected from `meta/info.json` β†’ `codebase_version`.
41
-
42
- | Version | Path pattern | Episode metadata | Video |
43
- | -------- | ----------------------------------------------------------------- | ------------------------------------------ | ---------------------------------------------- |
44
- | **v2.0** | `data/{episode_chunk:03d}/episode_{episode_index:06d}.parquet` | None (computed from `chunks_size`) | Full file per episode |
45
- | **v2.1** | Same as v2.0 | None | Full file per episode |
46
- | **v3.0** | `data/chunk-{N:03d}/file-{N:03d}.parquet` (via `buildV3DataPath`) | `meta/episodes/chunk-{N}/file-{N}.parquet` | Segmented (timestamps per episode, per camera) |
47
-
48
- ### Routing to parsers
49
-
50
- `src/app/[org]/[dataset]/[episode]/fetch-data.ts` β†’ `getEpisodeData()` dispatches to:
51
-
52
- - `getEpisodeDataV2()` for v2.0 and v2.1
53
- - `getEpisodeDataV3()` for v3.0
54
-
55
- ### v3.0 specifics
56
-
57
- - Episode metadata row has named keys (`episode_index`, `data/chunk_index`, `data/file_index`, `dataset_from_index`, `dataset_to_index`, `videos/{key}/chunk_index`, etc.)
58
- - Integer columns from parquet come out as **BigInt** β€” always use `bigIntToNumber()` from `src/utils/typeGuards.ts`
59
- - Row-range selection: `dataset_from_index` / `dataset_to_index` allow reading only the episode's rows from a shared parquet file
60
- - Fallback format uses numeric keys `"0"`.."9"` when column names are unavailable
61
- - Episode metadata can span **multiple chunks** (when episode count exceeds `chunks_size`). Always walk via the `iterateEpisodeMetadataFilesV3(repoId, version)` async generator in `fetch-data.ts` β€” it advances chunk-000 β†’ chunk-001 β†’ … and stops on the first missing `file-000`. Never hardcode `chunk-000`.
62
- - Multi-task episodes: episode-metadata rows carry a `tasks` field (`list[str]`) β€” prefer it over the legacy single `task_index` lookup. `EpisodeMetadataV3.tasks?: string[]` exposes it.
63
- - `meta/tasks.parquet` lookup: rows are **not** ordered by `task_index`, and the task string lives in a named pandas index (`__index_level_0__`). Always filter by the `task_index` **column** (`row.task_index === taskIndexNum`), never by row position.
64
-
65
- ### v2.x path construction
66
-
67
- ```ts
68
- formatStringWithVars(info.data_path, {
69
- episode_chunk: Math.floor(episodeId / chunkSize)
70
- .toString()
71
- .padStart(3, "0"),
72
- episode_index: episodeId.toString().padStart(6, "0"),
73
- });
74
- // β†’ "data/000/episode_000042.parquet"
75
- ```
76
-
77
- `formatStringWithVars` strips `:03d` format specifiers β€” padding must be done by the caller.
78
-
79
- ## Key files
80
-
81
- | File | Purpose |
82
- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
83
- | `src/app/[org]/[dataset]/[episode]/fetch-data.ts` | Main data-loading entry point; v2/v3 parsers; `computeColumnMinMax` |
84
- | `src/utils/versionUtils.ts` | `getDatasetInfo`, `getDatasetVersionAndInfo`, `buildVersionedUrl` |
85
- | `src/utils/stringFormatting.ts` | `buildV3DataPath`, `buildV3VideoPath`, `buildV3EpisodesMetadataPath`, padding helpers |
86
- | `src/utils/parquetUtils.ts` | `fetchParquetFile`, `readParquetAsObjects`, `formatStringWithVars` |
87
- | `src/utils/dataProcessing.ts` | Chart grouping pipeline: `buildSuffixGroupsMap` β†’ `computeGroupStats` β†’ `groupByScale` β†’ `flattenScaleGroups` β†’ `processChartDataGroups` |
88
- | `src/utils/typeGuards.ts` | `bigIntToNumber`, `isNumeric`, `isValidTaskIndex`, etc. |
89
- | `src/utils/constants.ts` | `PADDING`, `EXCLUDED_COLUMNS`, `CHART_CONFIG`, `THRESHOLDS` |
90
- | `src/types/` | TypeScript types: `DatasetVersion`, `EpisodeMetadataV3`, `VideoInfo`, `ChartDataGroup`, etc. |
91
-
92
- ## Chart data pipeline
93
-
94
- Series keys use `" | "` as delimiter (e.g. `observation.state | 0`).
95
- `groupRowBySuffix` groups by **suffix**: if two different prefixes share suffix `"0"` (e.g. `observation.state | 0` and `action | 0`), they are merged under `result["0"] = { "observation.state": ..., "action": ... }`. A series with a unique suffix stays flat with its full original key.
96
-
97
- ## Testing
98
-
99
- - Test files live in `**/__tests__/` directories alongside source
100
- - Uses `bun:test` (built-in, no extra install)
101
- - BigInt literals (`42n`) require `tsconfig.test.json` (target ES2020) β€” test files are excluded from `tsconfig.json`
102
- - `@types/bun` is installed as a devDependency for `bun:test` type resolution
103
- - Mocking fetch: `globalThis.fetch = mock(() => Promise.resolve(new Response(...))) as unknown as typeof fetch`
104
- - CI: `.github/workflows/test.yml` runs `bun test` on push/PR to main
105
-
106
- ## URL structure
107
-
108
- All dataset URLs:
109
-
110
- ```
111
- https://huggingface.co/datasets/{org}/{dataset}/resolve/main/{path}
112
- ```
113
-
114
- Built by `buildVersionedUrl(repoId, version, path)`. The `version` param is accepted but currently unused in the URL (always `main` revision).
115
-
116
- ## Excluded columns (not shown in charts)
117
-
118
- Reserved/bookkeeping columns from lerobot β€” see `EXCLUDED_COLUMNS` in `src/utils/constants.ts`:
119
-
120
- - v2.x: `timestamp`, `frame_index`, `episode_index`, `index`, `task_index`, `next.reward`, `next.done`, `next.truncated`
121
- - v3.0: `index`, `task_index`, `episode_index`, `frame_index`, `next.reward`, `next.done`, `next.truncated`, `subtask_index`
122
-
123
- ## 3D URDF viewer (`src/components/urdf-viewer.tsx`)
124
-
125
- - URDFs and meshes are hosted in the HF bucket `lerobot/robot-urdfs` β€” base URL `https://huggingface.co/buckets/lerobot/robot-urdfs/resolve` (no `/main` segment; buckets are unbranched). Override with `NEXT_PUBLIC_URDF_BASE_URL` for local development.
126
- - Asset layout under the bucket: `g1/`, `openarm/`, `so101/` (both SO-100 and SO-101 live here).
127
- - **URDFLoader gotcha**: after our `loadMeshCb` returns, `URDFLoader.js` does `if (obj instanceof THREE.Mesh) obj.material = <urdf-material>`, overwriting any material we set. Workaround: wrap the loaded mesh in a `THREE.Group` so the `instanceof Mesh` check fails. DAE returns a Group already; STL must be wrapped explicitly.
128
- - **STLLoader event ordering**: `manager.itemEnd(url)` fires _before_ the user `onLoad` callback, so `manager.onLoad` can fire before meshes are attached to the robot tree. Defer post-load work (auto-fit camera, shadow flags) with `setTimeout(..., 0)`. Don't try to rebuild materials in `manager.onLoad` β€” pick the archetype color directly inside `loadMeshCb`.
129
- - **OpenArm DAE files ship 23 stray `PointLight`s** that drown out scene lighting. Strip non-`AmbientLight` lights from `collada.scene` before adding it to the robot.
130
- - Scene setup: `<Canvas shadows>` with `ACESFilmicToneMapping` (exposure 0.9), 3-point directional + ambient lights, `<Environment preset="studio" background={false} />`, `<color attach="background" args={["#1a2433"]} />`. `<OrbitControls makeDefault />` is required so `useThree().controls` exposes the controls for auto-fit.
131
-
132
- ## Design system
133
-
134
- CSS tokens in `src/app/globals.css` (Tailwind v4 `@theme inline`):
135
-
136
- - Surfaces: `--bg #0a0e17`, `--surface-0`, `--surface-1`, `--surface-2`
137
- - Text: `--text-primary`, `--text-muted`, `--text-faint`
138
- - Accent: `--accent #38bdf8` (cyan) β€” primary interactive color across UI
139
- - Helpers: `.panel`, `.panel-raised`, `.tabular` (tabular-nums)
140
- - **Color semantics**: cyan = primary/active, orange (`orange-400/500`) is reserved for **flagged-episode** UI only β€” don't reuse it for generic accents.
 
1
+ @AGENTS.md
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bun.lock CHANGED
@@ -6,7 +6,7 @@
6
  "name": "lerobot-viewer",
7
  "dependencies": {
8
  "@huggingface/hub": "^2.11.0",
9
- "@huggingface/lerobot": "^0.0.7",
10
  "@react-three/drei": "^10.7.7",
11
  "@react-three/fiber": "^9.5.0",
12
  "hyparquet": "^1.12.1",
@@ -67,7 +67,7 @@
67
 
68
  "@huggingface/hub": ["@huggingface/hub@2.11.0", "", { "dependencies": { "@huggingface/tasks": "^0.19.90" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-WS6QGaXYeBVFlaB4SOn6z4LGUpLB5kRZNL08uUni4izX353KxiwwZMK5+/AWX86MJh8SMZNa/JFcvFCcQsbszQ=="],
69
 
70
- "@huggingface/lerobot": ["@huggingface/lerobot@0.0.7", "", { "dependencies": { "hyparquet": "^1.25.1" } }, "sha512-BJ2CsTW5Z0dRxRqEED70IWK1w9vWTdZu48W36ttFIr+qT5mKRtj+MfrOozf4qbBl/okauyXpHBfVqVqPCTudgA=="],
71
 
72
  "@huggingface/tasks": ["@huggingface/tasks@0.19.90", "", {}, "sha512-nfV9luJbvwGQ/5oKXkKhCV9h4X7mwh1YaGG3ORd6UMLDSwr1OFSSatcBX0O9OtBtmNK19aGSjbLFqqgcIR6+IA=="],
73
 
 
6
  "name": "lerobot-viewer",
7
  "dependencies": {
8
  "@huggingface/hub": "^2.11.0",
9
+ "@huggingface/lerobot": "^0.0.8",
10
  "@react-three/drei": "^10.7.7",
11
  "@react-three/fiber": "^9.5.0",
12
  "hyparquet": "^1.12.1",
 
67
 
68
  "@huggingface/hub": ["@huggingface/hub@2.11.0", "", { "dependencies": { "@huggingface/tasks": "^0.19.90" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-WS6QGaXYeBVFlaB4SOn6z4LGUpLB5kRZNL08uUni4izX353KxiwwZMK5+/AWX86MJh8SMZNa/JFcvFCcQsbszQ=="],
69
 
70
+ "@huggingface/lerobot": ["@huggingface/lerobot@0.0.8", "", { "dependencies": { "hyparquet": "^1.25.1" } }, "sha512-TeA8U5/idkQH0dmhb79+wkZHvrxkjB5Y+TllirJRg3vExl5SC2/E8LXZbRWSmfZibr7YD4w1kSGxeRL0f8/2/Q=="],
71
 
72
  "@huggingface/tasks": ["@huggingface/tasks@0.19.90", "", {}, "sha512-nfV9luJbvwGQ/5oKXkKhCV9h4X7mwh1YaGG3ORd6UMLDSwr1OFSSatcBX0O9OtBtmNK19aGSjbLFqqgcIR6+IA=="],
73
 
package.json CHANGED
@@ -16,7 +16,7 @@
16
  },
17
  "dependencies": {
18
  "@huggingface/hub": "^2.11.0",
19
- "@huggingface/lerobot": "^0.0.7",
20
  "@react-three/drei": "^10.7.7",
21
  "@react-three/fiber": "^9.5.0",
22
  "hyparquet": "^1.12.1",
 
16
  },
17
  "dependencies": {
18
  "@huggingface/hub": "^2.11.0",
19
+ "@huggingface/lerobot": "^0.0.8",
20
  "@react-three/drei": "^10.7.7",
21
  "@react-three/fiber": "^9.5.0",
22
  "hyparquet": "^1.12.1",
src/app/[org]/[dataset]/[episode]/fetch-data.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
  buildVersionedUrl,
11
  getDatasetStats,
12
  } from "@/utils/versionUtils";
13
- import { PADDING, CHART_CONFIG, EXCLUDED_COLUMNS } from "@/utils/constants";
14
  import {
15
  processChartDataGroups,
16
  groupRowBySuffix,
@@ -23,7 +23,7 @@ import {
23
  } from "@/utils/colormaps";
24
  import type { VideoInfo, AdjacentEpisodeVideos } from "@/types";
25
  import { LeRobotDataset, parseInfo } from "@huggingface/lerobot";
26
- import type { LeRobotEpisode, LeRobotEpisodeData } from "@huggingface/lerobot";
27
  import { authHeaders } from "@/utils/auth";
28
 
29
  const SERIES_NAME_DELIMITER = CHART_CONFIG.SERIES_NAME_DELIMITER;
@@ -197,11 +197,6 @@ type EpisodeMetadataV3 = {
197
  [key: string]: string | number | string[] | undefined;
198
  };
199
 
200
- type ColumnDef = {
201
- key: string;
202
- value: string[];
203
- };
204
-
205
  function parsePositiveIntEnv(
206
  value: string | undefined,
207
  fallback: number,
@@ -526,9 +521,6 @@ async function getEpisodeDataV2(
526
  info: DatasetMetadata,
527
  episodeId: number,
528
  ): Promise<EpisodeData> {
529
- const chunkSize = Math.max(1, info.chunks_size || 1000);
530
- const episode_chunk = Math.floor(episodeId / chunkSize);
531
-
532
  const datasetInfo: DatasetDisplayInfo = {
533
  repoId,
534
  total_frames: info.total_frames,
@@ -553,96 +545,19 @@ async function getEpisodeDataV2(
553
  .map((x) => parseInt(x.trim(), 10))
554
  .filter((x) => !isNaN(x));
555
 
556
- // Videos information
557
  const videosInfo =
558
- info.video_path !== null
559
- ? toVideosInfo(await loadLeRobotEpisode(repoId, episodeId), info, false)
560
- : [];
561
-
562
- // Column data
563
- const columnNames = Object.entries(info.features)
564
- .filter(
565
- ([, value]) =>
566
- ["float32", "int32"].includes(value.dtype) && value.shape.length === 1,
567
- )
568
- .map(([key, { shape }]) => ({ key, length: shape[0] }));
569
-
570
- // Exclude specific columns
571
- const excludedColumns = EXCLUDED_COLUMNS.V2 as readonly string[];
572
- const filteredColumns = columnNames.filter(
573
- (column) => !excludedColumns.includes(column.key),
574
- );
575
- const columns: ColumnDef[] = filteredColumns.map(({ key }) => {
576
- let column_names: unknown = info.features[key].names;
577
- while (typeof column_names === "object" && column_names !== null) {
578
- if (Array.isArray(column_names)) break;
579
- column_names = Object.values(column_names)[0];
580
- }
581
- return {
582
- key,
583
- value: Array.isArray(column_names)
584
- ? column_names.map(
585
- (name: string) => `${key}${SERIES_NAME_DELIMITER}${name}`,
586
- )
587
- : Array.from(
588
- { length: columnNames.find((c) => c.key === key)?.length ?? 1 },
589
- (_, i) => `${key}${CHART_CONFIG.SERIES_NAME_DELIMITER}${i}`,
590
- ),
591
- };
592
- });
593
 
594
- const parquetUrl = buildVersionedUrl(
595
- repoId,
596
- version,
597
- formatStringWithVars(info.data_path, {
598
- episode_chunk: episode_chunk
599
- .toString()
600
- .padStart(PADDING.CHUNK_INDEX, "0"),
601
- episode_index: episodeId.toString().padStart(PADDING.EPISODE_INDEX, "0"),
602
- }),
603
- );
604
-
605
- const arrayBuffer = await fetchParquetFile(parquetUrl);
606
- const parquetColumns = Array.from(
607
- new Set([
608
- "timestamp",
609
- "task",
610
- "task_index",
611
- "language_instruction",
612
- // v3.1 language schema (lerobot#3467) β€” list<struct{...}> columns the
613
- // annotations panel renders / edits. lerobot can write these onto v2.x
614
- // datasets too (the codebase_version stays 2.x), so a v2 episode may
615
- // carry annotations. Requesting columns absent from the parquet schema
616
- // is a no-op in hyparquet, so this is safe for un-annotated datasets.
617
- "language_persistent",
618
- "language_events",
619
- ...filteredColumns.map((c) => c.key),
620
- ]),
621
- );
622
- const allData = await readParquetAsObjects(arrayBuffer, parquetColumns);
623
-
624
- // Frame timestamps (sorted, seconds) let the annotations editor snap atoms
625
- // to exact frames. v3.1 language atoms are broadcast in `language_persistent`
626
- // and fired per-row in `language_events`; extract them so annotations written
627
- // onto v2.x datasets render in the panel/timeline just like on v3.0.
628
- const frameTimestamps = allData
629
- .map((r) => {
630
- const t = r.timestamp;
631
- return typeof t === "number"
632
- ? t
633
- : typeof t === "bigint"
634
- ? Number(t)
635
- : Number.NaN;
636
- })
637
- .filter((t) => Number.isFinite(t))
638
- .sort((a, b) => a - b);
639
- const languageAtoms = extractLanguageAtoms(allData);
640
 
641
  // Extract task from language_instruction fields, task field, or tasks.jsonl
642
  let task: string | undefined;
643
 
644
- if (allData.length > 0) {
645
- const firstRow = allData[0];
646
  const languageInstructions: string[] = [];
647
 
648
  if (typeof firstRow.language_instruction === "string") {
@@ -664,11 +579,11 @@ async function getEpisodeDataV2(
664
  }
665
  }
666
 
667
- if (!task && allData.length > 0 && typeof allData[0].task === "string") {
668
- task = allData[0].task;
669
  }
670
 
671
- if (!task && allData.length > 0) {
672
  try {
673
  const tasksUrl = buildVersionedUrl(repoId, version, "meta/tasks.jsonl");
674
  const tasksResponse = await fetch(tasksUrl, { cache: "no-store" });
@@ -681,7 +596,7 @@ async function getEpisodeDataV2(
681
  .map((line) => JSON.parse(line));
682
 
683
  if (tasksData && tasksData.length > 0) {
684
- const taskIndex = allData[0].task_index;
685
  const taskIndexNum =
686
  typeof taskIndex === "bigint" ? Number(taskIndex) : taskIndex;
687
  const taskData = tasksData.find(
@@ -697,28 +612,14 @@ async function getEpisodeDataV2(
697
  }
698
  }
699
 
700
- // Build chart data from already-parsed allData (no second parquet parse)
701
- const seriesNames = [
702
- "timestamp",
703
- ...columns.map(({ value }) => value).flat(),
704
- ];
705
-
706
- const chartData = allData.map((row) => {
707
- const obj: Record<string, number> = {};
708
- obj["timestamp"] = Number(row.timestamp);
709
- for (const col of columns) {
710
- const rawVal = row[col.key];
711
- if (Array.isArray(rawVal)) {
712
- rawVal.forEach((v: unknown, i: number) => {
713
- if (i < col.value.length) obj[col.value[i]] = Number(v);
714
- });
715
- } else if (rawVal !== undefined) {
716
- obj[col.value[0]] = Number(rawVal);
717
- }
718
- }
719
- return obj;
720
- });
721
- const sampledChartData = evenlySampleArray(chartData, MAX_EPISODE_POINTS);
722
 
723
  // List of columns that are ignored (e.g., 2D or 3D data)
724
  const ignoredColumns = Object.entries(info.features)
@@ -728,32 +629,17 @@ async function getEpisodeDataV2(
728
  )
729
  .map(([key]) => key);
730
 
731
- // Process chart data into organized groups using utility function
732
- const chartGroups = processChartDataGroups(seriesNames, sampledChartData);
733
-
734
  const duration =
735
- sampledChartData.length > 0
736
- ? sampledChartData[sampledChartData.length - 1].timestamp
737
  : 0;
738
 
739
- const chartDataGroups = chartGroups.map((group) =>
740
- sampledChartData.map((row) => {
741
- const grouped = groupRowBySuffix(pick(row, [...group, "timestamp"]));
742
- // Ensure timestamp is always a number at the top level
743
- return {
744
- ...grouped,
745
- timestamp:
746
- typeof grouped.timestamp === "number" ? grouped.timestamp : 0,
747
- };
748
- }),
749
- );
750
-
751
  return {
752
  datasetInfo,
753
  episodeId,
754
  videosInfo,
755
  chartDataGroups,
756
- flatChartData: sampledChartData,
757
  episodes,
758
  ignoredColumns,
759
  duration,
@@ -801,7 +687,7 @@ async function getEpisodeDataV3(
801
  version,
802
  info,
803
  episodeMetadata,
804
- leRobotEpisode.data,
805
  );
806
 
807
  const duration = episodeMetadata.length
@@ -829,7 +715,7 @@ async function loadEpisodeDataV3(
829
  version: string,
830
  info: DatasetMetadata,
831
  episodeMetadata: EpisodeMetadataV3,
832
- data: LeRobotEpisodeData | undefined,
833
  ): Promise<{
834
  chartDataGroups: ChartRow[][];
835
  flatChartData: Record<string, number>[];
@@ -839,78 +725,44 @@ async function loadEpisodeDataV3(
839
  frameTimestamps?: number[];
840
  }> {
841
  try {
842
- if (!data) throw new Error("Episode has no data rows");
843
- const parquetFile = await fetchParquetFile(data.url);
844
- const v3DataColumns = Array.from(
845
- new Set([
846
- "index",
847
- "timestamp",
848
- "task_index",
849
- "language_instruction",
850
- "language_instruction_2",
851
- "language_instruction_3",
852
- // v3.1 language schema (lerobot#3467) β€” list<struct{...}> columns
853
- // that the annotations panel renders / edits.
854
- "language_persistent",
855
- "language_events",
856
- ...Object.entries(info.features)
857
- .filter(([, feature]) => {
858
- const dtype = feature.dtype.toLowerCase();
859
- const isNumericOrBool = [
860
- "float32",
861
- "float64",
862
- "int8",
863
- "int16",
864
- "int32",
865
- "int64",
866
- "uint8",
867
- "uint16",
868
- "uint32",
869
- "uint64",
870
- "bool",
871
- "boolean",
872
- ].includes(dtype);
873
- return isNumericOrBool && feature.shape.length <= 1;
874
- })
875
- .map(([key]) => key),
876
- ]),
877
- );
878
- // `fromRow`/`toRow` are offsets inside `data.url`, not dataset-wide frame indexes.
879
- const episodeRows = await readParquetAsObjects(parquetFile, v3DataColumns, {
880
- rowStart: data.fromRow,
881
- rowEnd: data.toRow,
882
- });
883
-
884
- // Extract frame timestamps from the *full* (non-sampled) row set so the
885
- // annotations editor can snap to the exact frame the user is on.
886
- const frameTimestamps = episodeRows
887
- .map((r) => {
888
- const t = r.timestamp;
889
- return typeof t === "number"
890
- ? t
891
- : typeof t === "bigint"
892
- ? Number(t)
893
- : Number.NaN;
894
- })
895
- .filter((t) => Number.isFinite(t))
896
- .sort((a, b) => a - b);
897
 
898
- const episodeData = evenlySampleArray(episodeRows, MAX_EPISODE_POINTS);
899
-
900
- if (episodeData.length === 0) {
901
  return {
902
  chartDataGroups: [],
903
  flatChartData: [],
904
  ignoredColumns: [],
905
  task: undefined,
906
- languageAtoms: extractLanguageAtoms(episodeRows),
907
  frameTimestamps,
908
  };
909
  }
910
 
911
- // Convert to the same format as v2.x for compatibility with existing chart code
912
- const { chartDataGroups, flatChartData, ignoredColumns } =
913
- processEpisodeDataForCharts(episodeData, info, episodeMetadata);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
914
 
915
  // Prefer the authoritative `tasks` list on the episode's own metadata
916
  // (v3.0 stores it as list[str] β€” see lerobot dataset_metadata.save_episode).
@@ -920,7 +772,7 @@ async function loadEpisodeDataV3(
920
  }
921
 
922
  // Fall back to per-frame language_instruction fields
923
- if (!task && episodeData.length > 0) {
924
  const languageInstructions: string[] = [];
925
 
926
  const extractInstructions = (row: Record<string, unknown>) => {
@@ -936,15 +788,15 @@ async function loadEpisodeDataV3(
936
  }
937
  };
938
 
939
- extractInstructions(episodeData[0]);
940
 
941
  // If no instructions in first row, check middle and last rows
942
- if (languageInstructions.length === 0 && episodeData.length > 1) {
943
  for (const idx of [
944
- Math.floor(episodeData.length / 2),
945
- episodeData.length - 1,
946
  ]) {
947
- extractInstructions(episodeData[idx]);
948
  if (languageInstructions.length > 0) break;
949
  }
950
  }
@@ -955,7 +807,7 @@ async function loadEpisodeDataV3(
955
  }
956
 
957
  // Fall back to tasks metadata parquet
958
- if (!task && episodeData.length > 0) {
959
  try {
960
  const tasksUrl = buildVersionedUrl(
961
  repoId,
@@ -966,7 +818,7 @@ async function loadEpisodeDataV3(
966
  const tasksData = await readParquetAsObjects(tasksArrayBuffer, []);
967
 
968
  if (tasksData.length > 0) {
969
- const taskIndexNum = bigIntToNumber(episodeData[0].task_index, -1);
970
 
971
  if (taskIndexNum >= 0) {
972
  // lerobot writes tasks.parquet from a DataFrame with the task
@@ -992,7 +844,7 @@ async function loadEpisodeDataV3(
992
  flatChartData,
993
  ignoredColumns,
994
  task,
995
- languageAtoms: extractLanguageAtoms(episodeRows),
996
  frameTimestamps,
997
  };
998
  } catch {
@@ -1086,191 +938,93 @@ export function extractLanguageAtoms(
1086
  return atoms;
1087
  }
1088
 
1089
- // Process episode data for charts (v3.0 compatible)
1090
- function processEpisodeDataForCharts(
1091
- episodeData: Record<string, unknown>[],
1092
- info: DatasetMetadata,
1093
- episodeMetadata?: EpisodeMetadataV3,
1094
- ): {
1095
- chartDataGroups: ChartRow[][];
1096
- flatChartData: Record<string, number>[];
1097
- ignoredColumns: string[];
1098
- } {
1099
- // Convert parquet data to chart format
1100
- let seriesNames: string[] = [];
1101
-
1102
- // Dynamically create a mapping from numeric indices to feature names based on actual dataset features
1103
- const v3IndexToFeatureMap: Record<string, string> = {};
1104
-
1105
- // Build mapping based on what features actually exist in the dataset
1106
- const featureKeys = Object.keys(info.features);
1107
-
1108
- // Common feature order for v3.0 datasets (but only include if they exist)
1109
- const expectedFeatureOrder = [
1110
- "observation.state",
1111
- "action",
1112
- "timestamp",
1113
- "episode_index",
1114
- "frame_index",
1115
- "next.reward",
1116
- "next.done",
1117
- "index",
1118
- "task_index",
1119
- ];
1120
-
1121
- // Map indices to features that actually exist
1122
- let currentIndex = 0;
1123
- expectedFeatureOrder.forEach((feature) => {
1124
- if (featureKeys.includes(feature)) {
1125
- v3IndexToFeatureMap[currentIndex.toString()] = feature;
1126
- currentIndex++;
1127
- }
1128
- });
1129
-
1130
- // Columns to exclude from charts (note: 'task' is intentionally not excluded as we want to access it)
1131
- const excludedColumns = EXCLUDED_COLUMNS.V3 as readonly string[];
1132
-
1133
- // Create columns structure similar to V2.1 for proper hierarchical naming
1134
- const columns: ColumnDef[] = Object.entries(info.features)
1135
- .filter(
1136
- ([key, value]) =>
1137
- ["float32", "int32"].includes(value.dtype) &&
1138
- value.shape.length === 1 &&
1139
- !excludedColumns.includes(key),
1140
- )
1141
- .map(([key, feature]) => {
1142
- let column_names: unknown = feature.names;
1143
- while (typeof column_names === "object" && column_names !== null) {
1144
- if (Array.isArray(column_names)) break;
1145
- column_names = Object.values(column_names)[0];
1146
- }
1147
- return {
1148
- key,
1149
- value: Array.isArray(column_names)
1150
- ? column_names.map(
1151
- (name: string) => `${key}${SERIES_NAME_DELIMITER}${name}`,
1152
- )
1153
- : Array.from(
1154
- { length: feature.shape[0] || 1 },
1155
- (_, i) => `${key}${CHART_CONFIG.SERIES_NAME_DELIMITER}${i}`,
1156
- ),
1157
- };
1158
- });
1159
-
1160
- // First, extract all series from the first data row to understand the structure
1161
- if (episodeData.length > 0) {
1162
- const firstRow = episodeData[0];
1163
- const allKeys: string[] = [];
1164
-
1165
- Object.entries(firstRow || {}).forEach(([key, value]) => {
1166
- if (key === "timestamp") return; // Skip timestamp, we'll add it separately
1167
-
1168
- // Map numeric key to feature name if available
1169
- const featureName = v3IndexToFeatureMap[key] || key;
1170
-
1171
- // Skip if feature doesn't exist in dataset
1172
- if (!info.features[featureName]) return;
1173
 
1174
- // Skip excluded columns
1175
- if (excludedColumns.includes(featureName)) return;
 
 
 
 
1176
 
1177
- // Find the matching column definition to get proper names
1178
- const columnDef = columns.find((col) => col.key === featureName);
1179
- if (columnDef && Array.isArray(value) && value.length > 0) {
1180
- // Use the proper hierarchical naming from column definition
1181
- columnDef.value.forEach((seriesName, idx) => {
1182
- if (idx < value.length) {
1183
- allKeys.push(seriesName);
1184
- }
1185
- });
1186
- } else if (typeof value === "number" && !isNaN(value)) {
1187
- // For scalar numeric values
1188
- allKeys.push(featureName);
1189
- } else if (typeof value === "bigint") {
1190
- // For BigInt values
1191
- allKeys.push(featureName);
1192
- }
 
 
 
 
 
 
 
 
1193
  });
1194
-
1195
- seriesNames = ["timestamp", ...allKeys];
1196
- } else {
1197
- // Fallback to column-based approach like V2.1
1198
- seriesNames = ["timestamp", ...columns.map(({ value }) => value).flat()];
1199
  }
1200
 
1201
- const chartData = episodeData.map((row, index) => {
1202
- const obj: Record<string, number> = {};
1203
-
1204
- // Add timestamp aligned with video timing
1205
- // For v3.0, we need to map the episode data index to the actual video duration
1206
- let videoDuration = episodeData.length; // Fallback to data length
1207
- if (episodeMetadata) {
1208
- // Use actual video segment duration if available
1209
- videoDuration =
1210
- (episodeMetadata.video_to_timestamp || 30) -
1211
- (episodeMetadata.video_from_timestamp || 0);
1212
- }
1213
- obj["timestamp"] =
1214
- (index / Math.max(episodeData.length - 1, 1)) * videoDuration;
1215
-
1216
- // Add all data columns using hierarchical naming
1217
- if (row && typeof row === "object") {
1218
- Object.entries(row).forEach(([key, value]) => {
1219
- if (key === "timestamp") {
1220
- // Timestamp is already handled above
1221
- return;
1222
- }
1223
-
1224
- // Map numeric key to feature name if available
1225
- const featureName = v3IndexToFeatureMap[key] || key;
1226
-
1227
- // Skip if feature doesn't exist in dataset
1228
- if (!info.features[featureName]) return;
1229
-
1230
- // Skip excluded columns
1231
- if (excludedColumns.includes(featureName)) return;
1232
-
1233
- // Find the matching column definition to get proper series names
1234
- const columnDef = columns.find((col) => col.key === featureName);
1235
-
1236
- if (Array.isArray(value) && columnDef) {
1237
- // For array values like observation.state and action, use proper hierarchical naming
1238
- value.forEach((val, idx) => {
1239
- if (idx < columnDef.value.length) {
1240
- const seriesName = columnDef.value[idx];
1241
- obj[seriesName] = typeof val === "number" ? val : Number(val);
1242
- }
1243
- });
1244
- } else if (typeof value === "number" && !isNaN(value)) {
1245
- obj[featureName] = value;
1246
- } else if (typeof value === "bigint") {
1247
- obj[featureName] = Number(value);
1248
- } else if (typeof value === "boolean") {
1249
- // Convert boolean to number for charts
1250
- obj[featureName] = value ? 1 : 0;
1251
- }
1252
- });
1253
- }
1254
-
1255
- return obj;
1256
  });
1257
 
1258
- // List of columns that are ignored (now we handle 2D data by flattening)
1259
- const ignoredColumns = [
1260
- ...Object.entries(info.features)
1261
- .filter(
1262
- ([, value]) =>
1263
- ["float32", "int32"].includes(value.dtype) && value.shape.length > 2, // Only ignore 3D+ data
1264
- )
1265
- .map(([key]) => key),
1266
- ...excludedColumns, // Also include the manually excluded columns
1267
- ];
1268
-
1269
- // Process chart data into organized groups using utility function
1270
- const chartGroups = processChartDataGroups(seriesNames, chartData);
1271
-
1272
- const chartDataGroups = chartGroups.map((group) =>
1273
- chartData.map((row) => {
1274
  const grouped = groupRowBySuffix(pick(row, [...group, "timestamp"]));
1275
  // Ensure timestamp is always a number at the top level
1276
  return {
@@ -1281,7 +1035,7 @@ function processEpisodeDataForCharts(
1281
  }),
1282
  );
1283
 
1284
- return { chartDataGroups, flatChartData: chartData, ignoredColumns };
1285
  }
1286
 
1287
  // ─── Stats computation ───────────────────────────────────────────
@@ -2252,7 +2006,7 @@ export async function loadEpisodeFlatChartData(
2252
  version,
2253
  info,
2254
  toEpisodeMetadataV3(episode),
2255
- episode.data,
2256
  );
2257
  return flatChartData;
2258
  }
 
10
  buildVersionedUrl,
11
  getDatasetStats,
12
  } from "@/utils/versionUtils";
13
+ import { CHART_CONFIG, EXCLUDED_COLUMNS } from "@/utils/constants";
14
  import {
15
  processChartDataGroups,
16
  groupRowBySuffix,
 
23
  } from "@/utils/colormaps";
24
  import type { VideoInfo, AdjacentEpisodeVideos } from "@/types";
25
  import { LeRobotDataset, parseInfo } from "@huggingface/lerobot";
26
+ import type { LeRobotEpisode, LeRobotFrames } from "@huggingface/lerobot";
27
  import { authHeaders } from "@/utils/auth";
28
 
29
  const SERIES_NAME_DELIMITER = CHART_CONFIG.SERIES_NAME_DELIMITER;
 
197
  [key: string]: string | number | string[] | undefined;
198
  };
199
 
 
 
 
 
 
200
  function parsePositiveIntEnv(
201
  value: string | undefined,
202
  fallback: number,
 
521
  info: DatasetMetadata,
522
  episodeId: number,
523
  ): Promise<EpisodeData> {
 
 
 
524
  const datasetInfo: DatasetDisplayInfo = {
525
  repoId,
526
  total_frames: info.total_frames,
 
545
  .map((x) => parseInt(x.trim(), 10))
546
  .filter((x) => !isNaN(x));
547
 
548
+ const leRobotEpisode = await loadLeRobotEpisode(repoId, episodeId);
549
  const videosInfo =
550
+ info.video_path !== null ? toVideosInfo(leRobotEpisode, info, false) : [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
 
552
+ const { frames, textRows } = await loadEpisodeFrames(repoId, leRobotEpisode);
553
+ const frameTimestamps = sortedTimestamps(frames);
554
+ const languageAtoms = extractLanguageAtoms(textRows);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
555
 
556
  // Extract task from language_instruction fields, task field, or tasks.jsonl
557
  let task: string | undefined;
558
 
559
+ if (textRows.length > 0) {
560
+ const firstRow = textRows[0];
561
  const languageInstructions: string[] = [];
562
 
563
  if (typeof firstRow.language_instruction === "string") {
 
579
  }
580
  }
581
 
582
+ if (!task && textRows.length > 0 && typeof textRows[0].task === "string") {
583
+ task = textRows[0].task;
584
  }
585
 
586
+ if (!task && textRows.length > 0) {
587
  try {
588
  const tasksUrl = buildVersionedUrl(repoId, version, "meta/tasks.jsonl");
589
  const tasksResponse = await fetch(tasksUrl, { cache: "no-store" });
 
596
  .map((line) => JSON.parse(line));
597
 
598
  if (tasksData && tasksData.length > 0) {
599
+ const taskIndex = textRows[0].task_index;
600
  const taskIndexNum =
601
  typeof taskIndex === "bigint" ? Number(taskIndex) : taskIndex;
602
  const taskData = tasksData.find(
 
612
  }
613
  }
614
 
615
+ const { chartDataGroups, flatChartData } = frames
616
+ ? chartDataFromFrames(
617
+ frames,
618
+ info,
619
+ EXCLUDED_COLUMNS.V2,
620
+ (frameIndex) => frames.timestamps[frameIndex],
621
+ )
622
+ : { chartDataGroups: [], flatChartData: [] };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
623
 
624
  // List of columns that are ignored (e.g., 2D or 3D data)
625
  const ignoredColumns = Object.entries(info.features)
 
629
  )
630
  .map(([key]) => key);
631
 
 
 
 
632
  const duration =
633
+ flatChartData.length > 0
634
+ ? flatChartData[flatChartData.length - 1].timestamp
635
  : 0;
636
 
 
 
 
 
 
 
 
 
 
 
 
 
637
  return {
638
  datasetInfo,
639
  episodeId,
640
  videosInfo,
641
  chartDataGroups,
642
+ flatChartData,
643
  episodes,
644
  ignoredColumns,
645
  duration,
 
687
  version,
688
  info,
689
  episodeMetadata,
690
+ leRobotEpisode,
691
  );
692
 
693
  const duration = episodeMetadata.length
 
715
  version: string,
716
  info: DatasetMetadata,
717
  episodeMetadata: EpisodeMetadataV3,
718
+ episode: LeRobotEpisode,
719
  ): Promise<{
720
  chartDataGroups: ChartRow[][];
721
  flatChartData: Record<string, number>[];
 
725
  frameTimestamps?: number[];
726
  }> {
727
  try {
728
+ const { frames, textRows } = await loadEpisodeFrames(repoId, episode);
729
+ const frameTimestamps = sortedTimestamps(frames);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
730
 
731
+ if (!frames) {
 
 
732
  return {
733
  chartDataGroups: [],
734
  flatChartData: [],
735
  ignoredColumns: [],
736
  task: undefined,
737
+ languageAtoms: extractLanguageAtoms(textRows),
738
  frameTimestamps,
739
  };
740
  }
741
 
742
+ /// Stretched over the video segment rather than read from `timestamp`, so the chart cursor
743
+ /// stays in step with the player.
744
+ const videoDuration =
745
+ (episodeMetadata.video_to_timestamp || 30) -
746
+ (episodeMetadata.video_from_timestamp || 0);
747
+ const { chartDataGroups, flatChartData } = chartDataFromFrames(
748
+ frames,
749
+ info,
750
+ EXCLUDED_COLUMNS.V3,
751
+ (_, position, count) =>
752
+ (position / Math.max(count - 1, 1)) * videoDuration,
753
+ );
754
+
755
+ // List of columns that are ignored (now we handle 2D data by flattening)
756
+ const ignoredColumns = [
757
+ ...Object.entries(info.features)
758
+ .filter(
759
+ ([, value]) =>
760
+ ["float32", "int32"].includes(value.dtype) &&
761
+ value.shape.length > 2, // Only ignore 3D+ data
762
+ )
763
+ .map(([key]) => key),
764
+ ...EXCLUDED_COLUMNS.V3,
765
+ ];
766
 
767
  // Prefer the authoritative `tasks` list on the episode's own metadata
768
  // (v3.0 stores it as list[str] β€” see lerobot dataset_metadata.save_episode).
 
772
  }
773
 
774
  // Fall back to per-frame language_instruction fields
775
+ if (!task && textRows.length > 0) {
776
  const languageInstructions: string[] = [];
777
 
778
  const extractInstructions = (row: Record<string, unknown>) => {
 
788
  }
789
  };
790
 
791
+ extractInstructions(textRows[0]);
792
 
793
  // If no instructions in first row, check middle and last rows
794
+ if (languageInstructions.length === 0 && textRows.length > 1) {
795
  for (const idx of [
796
+ Math.floor(textRows.length / 2),
797
+ textRows.length - 1,
798
  ]) {
799
+ extractInstructions(textRows[idx]);
800
  if (languageInstructions.length > 0) break;
801
  }
802
  }
 
807
  }
808
 
809
  // Fall back to tasks metadata parquet
810
+ if (!task && textRows.length > 0) {
811
  try {
812
  const tasksUrl = buildVersionedUrl(
813
  repoId,
 
818
  const tasksData = await readParquetAsObjects(tasksArrayBuffer, []);
819
 
820
  if (tasksData.length > 0) {
821
+ const taskIndexNum = bigIntToNumber(textRows[0].task_index, -1);
822
 
823
  if (taskIndexNum >= 0) {
824
  // lerobot writes tasks.parquet from a DataFrame with the task
 
844
  flatChartData,
845
  ignoredColumns,
846
  task,
847
+ languageAtoms: extractLanguageAtoms(textRows),
848
  frameTimestamps,
849
  };
850
  } catch {
 
938
  return atoms;
939
  }
940
 
941
+ /**
942
+ * Columns `frames()` leaves out that the episode view still needs: the task, and the v3.1 language
943
+ * atoms (lerobot#3467), which lerobot can also write onto v2.x datasets. hyparquet skips requested
944
+ * columns that are not in the file, so un-annotated datasets cost nothing extra.
945
+ */
946
+ const TEXT_COLUMNS = [
947
+ "timestamp",
948
+ "task",
949
+ "task_index",
950
+ "language_instruction",
951
+ "language_instruction_2",
952
+ "language_instruction_3",
953
+ "language_persistent",
954
+ "language_events",
955
+ ];
956
+
957
+ /** The episode's numeric series from `@huggingface/lerobot`, plus its text columns read alongside. */
958
+ async function loadEpisodeFrames(
959
+ repoId: string,
960
+ episode: LeRobotEpisode,
961
+ ): Promise<{
962
+ frames: LeRobotFrames | undefined;
963
+ textRows: Record<string, unknown>[];
964
+ }> {
965
+ const data = episode.data;
966
+ if (!data) return { frames: undefined, textRows: [] };
967
+ const [frames, textRows] = await Promise.all([
968
+ leRobotDataset(repoId).frames(episode),
969
+ fetchParquetFile(data.url).then((file) =>
970
+ readParquetAsObjects(file, TEXT_COLUMNS, {
971
+ rowStart: data.fromRow,
972
+ rowEnd: data.toRow,
973
+ }),
974
+ ),
975
+ ]);
976
+ return { frames, textRows };
977
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
978
 
979
+ /** Source-frame timestamps, sorted, for the annotations editor to snap to. */
980
+ function sortedTimestamps(frames: LeRobotFrames | undefined): number[] {
981
+ return (frames?.timestamps ?? [])
982
+ .filter((t) => Number.isFinite(t))
983
+ .sort((a, b) => a - b);
984
+ }
985
 
986
+ /**
987
+ * Chart rows for an episode, sampled down to MAX_EPISODE_POINTS. An array feature gets one series
988
+ * per component, named from `info.json` when it lists one name per component and by index otherwise;
989
+ * a single unnamed component (a reward, a success flag) keeps its bare feature key.
990
+ */
991
+ function chartDataFromFrames(
992
+ frames: LeRobotFrames,
993
+ info: DatasetMetadata,
994
+ excludedColumns: readonly string[],
995
+ timestampAt: (frameIndex: number, position: number, count: number) => number,
996
+ ): { chartDataGroups: ChartRow[][]; flatChartData: Record<string, number>[] } {
997
+ const series: [string, number[]][] = [];
998
+ for (const [key, components] of Object.entries(frames.series)) {
999
+ if (!info.features[key] || excludedColumns.includes(key)) continue;
1000
+ const names =
1001
+ frames.names[key]?.length === components.length
1002
+ ? frames.names[key]
1003
+ : undefined;
1004
+ components.forEach((values, i) => {
1005
+ const suffix = names?.[i] ?? (components.length > 1 ? i : undefined);
1006
+ series.push([
1007
+ suffix === undefined ? key : `${key}${SERIES_NAME_DELIMITER}${suffix}`,
1008
+ values,
1009
+ ]);
1010
  });
 
 
 
 
 
1011
  }
1012
 
1013
+ const sampled = evenlySampleIndices(frames.length, MAX_EPISODE_POINTS);
1014
+ const flatChartData = sampled.map((frameIndex, position) => {
1015
+ const row: Record<string, number> = {
1016
+ timestamp: timestampAt(frameIndex, position, sampled.length),
1017
+ };
1018
+ for (const [name, values] of series) row[name] = values[frameIndex];
1019
+ return row;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1020
  });
1021
 
1022
+ const seriesNames = ["timestamp", ...series.map(([name]) => name)];
1023
+ const chartDataGroups = processChartDataGroups(
1024
+ seriesNames,
1025
+ flatChartData,
1026
+ ).map((group) =>
1027
+ flatChartData.map((row) => {
 
 
 
 
 
 
 
 
 
 
1028
  const grouped = groupRowBySuffix(pick(row, [...group, "timestamp"]));
1029
  // Ensure timestamp is always a number at the top level
1030
  return {
 
1035
  }),
1036
  );
1037
 
1038
+ return { chartDataGroups, flatChartData };
1039
  }
1040
 
1041
  // ─── Stats computation ───────────────────────────────────────────
 
2006
  version,
2007
  info,
2008
  toEpisodeMetadataV3(episode),
2009
+ episode,
2010
  );
2011
  return flatChartData;
2012
  }