Raffael-Kultyshev commited on
Commit
6f0655f
·
1 Parent(s): 98872c8

Initial viewer for humanoid robots dataset

Browse files
.gitattributes CHANGED
@@ -33,3 +33,43 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ dataset_cache/plots/vid1_pitch_deg.png filter=lfs diff=lfs merge=lfs -text
37
+ dataset_cache/plots/vid1_roll_deg.png filter=lfs diff=lfs merge=lfs -text
38
+ dataset_cache/plots/vid1_x_cm.png filter=lfs diff=lfs merge=lfs -text
39
+ dataset_cache/plots/vid1_y_cm.png filter=lfs diff=lfs merge=lfs -text
40
+ dataset_cache/plots/vid1_yaw_deg.png filter=lfs diff=lfs merge=lfs -text
41
+ dataset_cache/plots/vid1_z_cm.png filter=lfs diff=lfs merge=lfs -text
42
+ dataset_cache/videos/chunk-000/depth/episode_000000.mp4 filter=lfs diff=lfs merge=lfs -text
43
+ dataset_cache/videos/chunk-000/rgb/episode_000000.mp4 filter=lfs diff=lfs merge=lfs -text
44
+ dataset_cache/videos/chunk-000/rgb/episode_000001.mp4 filter=lfs diff=lfs merge=lfs -text
45
+ dataset_cache/videos/chunk-000/rgb/episode_000002.mp4 filter=lfs diff=lfs merge=lfs -text
46
+ dataset_cache/videos/chunk-000/rgb/episode_000003.mp4 filter=lfs diff=lfs merge=lfs -text
47
+ dataset_cache/videos/chunk-000/rgb/episode_000004.mp4 filter=lfs diff=lfs merge=lfs -text
48
+ dataset_cache/videos/chunk-000/rgb/episode_000005.mp4 filter=lfs diff=lfs merge=lfs -text
49
+ dataset_cache/videos/chunk-000/rgb/episode_000006.mp4 filter=lfs diff=lfs merge=lfs -text
50
+ dataset_cache/videos/chunk-000/rgb/episode_000007.mp4 filter=lfs diff=lfs merge=lfs -text
51
+ dataset_cache/videos/chunk-000/rgb/episode_000008.mp4 filter=lfs diff=lfs merge=lfs -text
52
+ dataset_cache/videos/chunk-000/rgb/episode_000009.mp4 filter=lfs diff=lfs merge=lfs -text
53
+ dataset_cache/videos/chunk-000/rgb/episode_000011.mp4 filter=lfs diff=lfs merge=lfs -text
54
+ dataset_cache/videos/chunk-000/rgb/episode_000012.mp4 filter=lfs diff=lfs merge=lfs -text
55
+ dataset_cache/videos/chunk-000/rgb/episode_000013.mp4 filter=lfs diff=lfs merge=lfs -text
56
+ dataset_cache/videos/chunk-000/rgb/episode_000014.mp4 filter=lfs diff=lfs merge=lfs -text
57
+ dataset_cache/videos/chunk-000/rgb/episode_000015.mp4 filter=lfs diff=lfs merge=lfs -text
58
+ dataset_cache/videos/chunk-000/rgb/episode_000016.mp4 filter=lfs diff=lfs merge=lfs -text
59
+ dataset_cache/videos/chunk-000/rgb/episode_000017.mp4 filter=lfs diff=lfs merge=lfs -text
60
+ dataset_cache/videos/chunk-000/rgb/episode_000019.mp4 filter=lfs diff=lfs merge=lfs -text
61
+ dataset_cache/videos/chunk-000/rgb/episode_000020.mp4 filter=lfs diff=lfs merge=lfs -text
62
+ dataset_cache/videos/chunk-000/rgb/episode_000021.mp4 filter=lfs diff=lfs merge=lfs -text
63
+ dataset_cache/videos/chunk-000/rgb/episode_000022.mp4 filter=lfs diff=lfs merge=lfs -text
64
+ dataset_cache/videos/chunk-000/rgb/episode_000023.mp4 filter=lfs diff=lfs merge=lfs -text
65
+ dataset_cache/videos/chunk-000/rgb/episode_000024.mp4 filter=lfs diff=lfs merge=lfs -text
66
+ dataset_cache/videos/chunk-000/rgb/episode_000025.mp4 filter=lfs diff=lfs merge=lfs -text
67
+ dataset_cache/videos/chunk-000/rgb/episode_000026.mp4 filter=lfs diff=lfs merge=lfs -text
68
+ dataset_cache/videos/chunk-000/rgb/episode_000028.mp4 filter=lfs diff=lfs merge=lfs -text
69
+ dataset_cache/videos/chunk-000/rgb/episode_000029.mp4 filter=lfs diff=lfs merge=lfs -text
70
+ dataset_cache/videos/chunk-000/rgb/episode_000030.mp4 filter=lfs diff=lfs merge=lfs -text
71
+ dataset_cache/videos/chunk-000/rgb/episode_000031.mp4 filter=lfs diff=lfs merge=lfs -text
72
+ dataset_cache/videos/chunk-000/rgb/episode_000032.mp4 filter=lfs diff=lfs merge=lfs -text
73
+ dataset_cache/videos/chunk-000/rgb/episode_000033.mp4 filter=lfs diff=lfs merge=lfs -text
74
+ dataset_cache/videos/chunk-000/rgb/episode_000034.mp4 filter=lfs diff=lfs merge=lfs -text
75
+ dataset_cache/videos/chunk-000/rgb/episode_000035.mp4 filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+ /public
20
+
21
+ # production
22
+ /build
23
+
24
+ # misc
25
+ .DS_Store
26
+ *.pem
27
+
28
+ # debug
29
+ npm-debug.log*
30
+ yarn-debug.log*
31
+ yarn-error.log*
32
+ .pnpm-debug.log*
33
+
34
+ # env files (can opt-in for committing if needed)
35
+ .env*
36
+
37
+ # vercel
38
+ .vercel
39
+
40
+ # typescript
41
+ *.tsbuildinfo
42
+ next-env.d.ts
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:18-slim
2
+
3
+ # Install system deps required by hyparquet (Arrow) and ffmpeg for video metadata
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ build-essential \
6
+ git \
7
+ wget \
8
+ ca-certificates \
9
+ ffmpeg \
10
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
11
+
12
+ WORKDIR /app
13
+
14
+ # Install dependencies first (better layer caching)
15
+ COPY package.json package-lock.json ./
16
+ RUN npm ci
17
+
18
+ # Copy application source
19
+ COPY . .
20
+
21
+ # Environment configuration for our dataset + Hugging Face Space port
22
+ ENV PORT=7860
23
+ ENV REPO_ID="raffaelkultyshev/mini_tug_tape_to_bowl"
24
+
25
+ # Build the Next.js app
26
+ RUN npm run build
27
+
28
+ EXPOSE 7860
29
+
30
+ CMD ["npm", "start"]
31
+
GRADIO_NOTES.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Gradio Deployment Notes
2
+
3
+ - `app.py` defines `build_interface()` and **only** launches the demo when run as `__main__`. Hugging Face imports the module to build the config, so expensive work (dataset download) happens lazily via `lru_cache` helpers.
4
+ - Dataset paths follow the LeRobot layout (`data/chunk-000/...`, `videos/chunk-000/rgb/...`). Files are cached inside `dataset_cache/` by `snapshot_download`.
5
+ - Default episode data is preloaded when the Blocks tree is constructed, so all widgets render immediately without calling the API.
6
+ - User interactions run through `episode_dropdown.change` → `load_episode_payload`, which returns `[instruction, video_path, fig_x, ...]`.
7
+ - `demo.queue().launch(show_api=False)` disables the “Use via API” panel (Gradio 5.7.0 had a schema bug with Plot outputs that broke API info).
8
+ - If you need to expose the API again, make sure to upgrade to a Gradio release where plot schema serialization works, then set `show_api=True`.
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
README.md CHANGED
@@ -1,12 +1,66 @@
1
  ---
2
- title: Humanoid Robots Training Viewer V2
3
- emoji: 🌖
4
- colorFrom: green
5
- colorTo: red
6
- sdk: gradio
7
- sdk_version: 6.0.2
8
- app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Mini-TUG Hand Pose Viewer
3
+ emoji: 🎥
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ license: mit
 
8
  pinned: false
9
  ---
10
 
11
+ # Mini-TUG Hand Pose Viewer (LeRobot UI)
12
+
13
+ This Space embeds the official **LeRobot Dataset Visualizer** so we get the exact same UI/UX as the `lerobot/visualize_dataset` Space, but it is pre-configured to load our dataset `raffaelkultyshev/mini_tug_tape_to_bowl`. Videos, language instructions, and 6DoF plots all stream directly from the dataset repository—no local cache required.
14
+
15
+ ## Project Overview
16
+
17
+ This tool is designed to help robotics researchers and practitioners quickly inspect and understand large, complex datasets. It fetches dataset metadata and episode data (including video and sensor/telemetry data), and provides a unified interface for:
18
+
19
+ - Navigating between organizations, datasets, and episodes
20
+ - Watching episode videos
21
+ - Exploring synchronized time-series data with interactive charts
22
+ - Paginating through large datasets efficiently
23
+
24
+ ## Key Features
25
+
26
+ - **Dataset & Episode Navigation:** Quickly jump between organizations, datasets, and episodes using a sidebar and navigation controls.
27
+ - **Synchronized Video & Data:** Video playback is synchronized with interactive data graphs for detailed inspection of sensor and control signals.
28
+ - **Efficient Data Loading:** Uses parquet and JSON loading for large dataset support, with pagination and chunking.
29
+ - **Responsive UI:** Built with React, Next.js, and Tailwind CSS for a fast, modern user experience.
30
+
31
+ ## Technologies Used
32
+
33
+ - **Next.js** (App Router)
34
+ - **React**
35
+ - **Recharts** (for data visualization)
36
+ - **hyparquet** (for reading Parquet files)
37
+ - **Tailwind CSS** (styling)
38
+
39
+ ## Getting Started
40
+
41
+ Install dependencies then run the dev server:
42
+
43
+ ```bash
44
+ npm run dev
45
+ # or
46
+ yarn dev
47
+ # or
48
+ pnpm dev
49
+ # or
50
+ bun dev
51
+ ```
52
+
53
+ The local server will redirect straight to `/raffaelkultyshev/mini_tug_tape_to_bowl/episode_0`. To point the viewer at a different dataset, set the following env vars before running `npm run dev` or `npm run build`:
54
+
55
+ ```bash
56
+ export REPO_ID=some_org/some_dataset
57
+ # optional: space-separated subset of episode indices
58
+ export EPISODES="0 1 2"
59
+ ```
60
+
61
+ ## Contributing
62
+
63
+ Contributions, bug reports, and feature requests are welcome! Please open an issue or submit a pull request.
64
+
65
+ ### Acknowledgement
66
+ The app was orignally created by [@Mishig25](https://github.com/mishig25) and taken from this PR [#1055](https://github.com/huggingface/lerobot/pull/1055)
Requirements.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## hf_space_mini_tug Requirements
2
+
3
+ This directory contains the Streamlit application that runs on Hugging Face Spaces and visualizes each episode.
4
+
5
+ ### Files
6
+
7
+ - `app.py` – Streamlit entrypoint. Downloads the dataset via `snapshot_download`, exposes an episode selector, video player, and Plotly charts.
8
+ - `requirements.txt` – Python dependencies (`streamlit`, `plotly`, `pandas`, `huggingface_hub`).
9
+ - `README.md` – Hub metadata (SDK = Streamlit).
10
+
11
+ ### Data Contract
12
+
13
+ `app.py` expects the dataset `raffaelkultyshev/mini_tug_tape_to_bowl` to have:
14
+
15
+ - `meta/info.json` describing `episodes`, `data_path`, and `video_path`.
16
+ - Parquet files with either:
17
+ 1. LeRobot-style `observation.state` columns (`list<float>`) **or**
18
+ 2. Long-form `frame_idx`, `joint_name`, `x_cm`, etc. produced by `hand_pose_pipeline.py`.
19
+ - RGB MP4s encoded as H.264 / yuv420p / faststart (`videos/chunk-000/rgb/...`).
20
+
21
+ ### Internal Logic
22
+
23
+ 1. `get_dataset_revision()` uses `HfApi.repo_info` so Streamlit caches auto-invalidate when the dataset updates.
24
+ 2. `load_episode()` reads the Parquet file and, via `build_state_dataframe`, pivots the long table back into the `[wrist_x_cm, ...]` format used by the charts.
25
+ - This function enforces accuracy bounds by reindexing frames and rejecting missing joint rows.
26
+ 3. Plots are generated with Plotly (`build_plot`) and share the same axis scaling as the offline PNGs.
27
+
28
+ ### Accuracy Guarantees
29
+
30
+ - Streamlit charts display the exact values computed by `hand_pose_pipeline.py`. No down-sampling is performed.
31
+ - If gaps exist, the app surfaces `NaN` segments directly, ensuring transparency about measurement uncertainty (<3 mm XYZ, <5° angles as validated in `scripts/Requirements.md`).
32
+
33
+ ### Deployment
34
+
35
+ - Use `huggingface_hub.HfApi.upload_folder(folder_path='hf_space_mini_tug', repo_type='space')`.
36
+ - Before deploying, regenerate the `dataset_cache/` by running the app locally or deleting the cache to avoid shipping stale data.***
37
+
__pycache__/app.cpython-313.pyc ADDED
Binary file (18.7 kB). View file
 
app.py ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import logging
4
+ import sys
5
+ import html
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional
8
+ from functools import lru_cache
9
+
10
+ import gradio as gr
11
+ import pandas as pd
12
+ import plotly.graph_objects as go
13
+ import plotly.io as pio
14
+ from huggingface_hub import snapshot_download, HfApi
15
+
16
+ logging.basicConfig(
17
+ level=logging.INFO,
18
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
19
+ handlers=[logging.StreamHandler(sys.stdout)],
20
+ )
21
+ logger = logging.getLogger(__name__)
22
+
23
+ DEFAULT_DATASET_ID = os.getenv(
24
+ "DATASET_ID", "raffaelkultyshev/humanoid-robots-training-dataset"
25
+ )
26
+ LOCAL_DATASET_DIR = Path("dataset_cache")
27
+ HF_TOKEN = os.getenv("HF_TOKEN")
28
+
29
+ JOINT_ALIASES = {
30
+ "wrist": "Wrist",
31
+ "thumb_tip": "Thumb Tip",
32
+ "index_mcp": "Index MCP",
33
+ "index_tip": "Index Tip",
34
+ }
35
+
36
+ JOINT_NAME_MAP = {
37
+ "wrist": "WRIST",
38
+ "thumb_tip": "THUMB_TIP",
39
+ "index_mcp": "INDEX_FINGER_MCP",
40
+ "index_tip": "INDEX_FINGER_TIP",
41
+ }
42
+
43
+ METRIC_LABELS = {
44
+ "x_cm": "X (cm)",
45
+ "y_cm": "Y (cm)",
46
+ "z_cm": "Z (cm)",
47
+ "yaw_deg": "Yaw (°)",
48
+ "pitch_deg": "Pitch (°)",
49
+ "roll_deg": "Roll (°)",
50
+ }
51
+
52
+ PLOT_GRID = [
53
+ ["x_cm", "y_cm", "z_cm"],
54
+ ["yaw_deg", "pitch_deg", "roll_deg"],
55
+ ]
56
+
57
+ PLOT_ORDER = [metric for row in PLOT_GRID for metric in row]
58
+
59
+ CUSTOM_CSS = """
60
+ :root, .gradio-container, body {
61
+ background-color: #050a18 !important;
62
+ color: #f8fafc !important;
63
+ font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
64
+ }
65
+ .side-panel {
66
+ background: #0f172a;
67
+ padding: 20px;
68
+ border-radius: 18px;
69
+ border: 1px solid #1f2b47;
70
+ min-height: 100%;
71
+ }
72
+ .stats-card ul {
73
+ list-style: none;
74
+ padding: 0;
75
+ margin: 0;
76
+ font-size: 0.92rem;
77
+ }
78
+ .stats-card li {
79
+ margin-bottom: 10px;
80
+ color: #e2e8f0;
81
+ }
82
+ .stats-card span {
83
+ display: inline-block;
84
+ margin-right: 6px;
85
+ color: #7dd3fc;
86
+ }
87
+ .episodes-title {
88
+ margin: 18px 0 8px;
89
+ font-size: 0.78rem;
90
+ text-transform: uppercase;
91
+ letter-spacing: 0.14em;
92
+ color: #94a3b8;
93
+ }
94
+ .episode-list .gr-form {
95
+ padding: 0;
96
+ }
97
+ .episode-list .gr-form > div {
98
+ gap: 0;
99
+ }
100
+ .episode-list input[type="radio"] {
101
+ display: none;
102
+ }
103
+ .episode-list label {
104
+ background: transparent !important;
105
+ border: none !important;
106
+ color: #cbd5f5 !important;
107
+ padding: 3px 0 !important;
108
+ justify-content: flex-start;
109
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
110
+ font-size: 0.9rem;
111
+ text-decoration: underline;
112
+ }
113
+ .episode-list label:hover {
114
+ color: #67e8f9 !important;
115
+ cursor: pointer;
116
+ }
117
+ .episode-list input[type="radio"]:checked + label {
118
+ color: #facc15 !important;
119
+ font-weight: 700;
120
+ margin-left: -2px;
121
+ }
122
+ .main-panel {
123
+ padding-top: 8px;
124
+ }
125
+ .instruction-card {
126
+ background: #0f172a;
127
+ padding: 18px 20px;
128
+ border-radius: 18px;
129
+ border: 1px solid #1f2b47;
130
+ }
131
+ .instruction-label {
132
+ font-size: 0.75rem;
133
+ letter-spacing: 0.12em;
134
+ text-transform: uppercase;
135
+ color: #94a3b8;
136
+ margin-bottom: 10px;
137
+ }
138
+ .instruction-text {
139
+ font-size: 1.1rem;
140
+ line-height: 1.5;
141
+ }
142
+ .video-card {
143
+ background: #0f172a;
144
+ border: 1px solid #1f2b47;
145
+ border-radius: 18px;
146
+ padding: 18px 20px;
147
+ margin-top: 18px;
148
+ }
149
+ .video-title {
150
+ font-size: 0.78rem;
151
+ text-transform: uppercase;
152
+ letter-spacing: 0.18em;
153
+ color: #94a3b8;
154
+ margin-bottom: 8px;
155
+ }
156
+ .video-panel video {
157
+ border-radius: 12px;
158
+ border: 1px solid #1f2b47;
159
+ background: #030712;
160
+ }
161
+ .download-button button {
162
+ border-radius: 999px;
163
+ border: 1px solid #334155;
164
+ background: #1e293b;
165
+ color: #f8fafc;
166
+ font-size: 0.85rem;
167
+ padding: 8px 24px;
168
+ }
169
+ .download-button button:hover {
170
+ border-color: #67e8f9;
171
+ color: #67e8f9;
172
+ }
173
+ .plots-wrap {
174
+ margin-top: 18px;
175
+ }
176
+ .plots-wrap .gr-row {
177
+ gap: 16px;
178
+ }
179
+ .plot-html {
180
+ background: #111a2c;
181
+ border-radius: 12px;
182
+ padding: 10px;
183
+ border: 1px solid #1f2b47;
184
+ min-height: 320px;
185
+ }
186
+ .plot-html iframe {
187
+ width: 100%;
188
+ height: 300px;
189
+ border: none;
190
+ }
191
+ """
192
+
193
+
194
+ @lru_cache(maxsize=1)
195
+ def get_dataset_revision(repo_id: str) -> Optional[str]:
196
+ try:
197
+ info = HfApi(token=HF_TOKEN).repo_info(repo_id=repo_id, repo_type="dataset")
198
+ return info.sha
199
+ except Exception as exc:
200
+ logger.warning(f"Could not fetch dataset revision for {repo_id}: {exc}")
201
+ return None
202
+
203
+
204
+ @lru_cache(maxsize=2)
205
+ def get_dataset_root(repo_id: str, revision: Optional[str]) -> Path:
206
+ local_path = snapshot_download(
207
+ repo_id=repo_id,
208
+ repo_type="dataset",
209
+ local_dir=LOCAL_DATASET_DIR,
210
+ local_dir_use_symlinks=False,
211
+ revision=revision,
212
+ token=HF_TOKEN,
213
+ )
214
+ return Path(local_path)
215
+
216
+
217
+ @lru_cache(maxsize=2)
218
+ def load_info(repo_id: str, revision: Optional[str]) -> Dict:
219
+ root = get_dataset_root(repo_id, revision)
220
+ info_path = root / "meta" / "info.json"
221
+ with open(info_path, "r", encoding="utf-8") as f:
222
+ return json.load(f)
223
+
224
+
225
+ def resolve_path(root: Path, template: str, episode_chunk: int, episode_index: int) -> Path:
226
+ if isinstance(template, dict):
227
+ rgb_template = template.get("rgb")
228
+ if rgb_template is None:
229
+ raise ValueError("RGB template missing from metadata")
230
+ return root / rgb_template.format(episode_chunk=episode_chunk, episode_index=episode_index)
231
+ return root / template.format(episode_chunk=episode_chunk, episode_index=episode_index)
232
+
233
+
234
+ @lru_cache(maxsize=64)
235
+ def load_episode(repo_id: str, episode_index: int, revision: Optional[str]) -> Dict:
236
+ info = load_info(repo_id, revision)
237
+ root = get_dataset_root(repo_id, revision)
238
+ episode_meta = next((ep for ep in info["episodes"] if ep["episode_index"] == episode_index), None)
239
+ if not episode_meta:
240
+ raise ValueError(f"Episode {episode_index} not found in metadata")
241
+
242
+ chunk = episode_meta["episode_chunk"]
243
+ parquet_path = resolve_path(root, info["data_path"], chunk, episode_index)
244
+ if not parquet_path.exists():
245
+ raise FileNotFoundError(f"Parquet file not found: {parquet_path}")
246
+
247
+ df = pd.read_parquet(parquet_path)
248
+ timestamps, state_df = build_state_dataframe(df)
249
+
250
+ rgb_path = resolve_path(root, info["video_path"], chunk, episode_index)
251
+
252
+ instruction = (
253
+ episode_meta.get("language_instruction")
254
+ or (
255
+ df["language_instruction"].dropna().iloc[0]
256
+ if "language_instruction" in df.columns and not df["language_instruction"].isna().all()
257
+ else info.get("task", "Tape roll to bowl")
258
+ )
259
+ )
260
+
261
+ return {
262
+ "timestamps": timestamps,
263
+ "state_df": state_df,
264
+ "rgb_path": rgb_path,
265
+ "instruction": instruction,
266
+ }
267
+
268
+
269
+ def build_state_dataframe(df: pd.DataFrame) -> (List[float], pd.DataFrame):
270
+ if "frame_idx" not in df.columns or "timestamp_s" not in df.columns:
271
+ raise ValueError("Episode parquet missing frame timing information.")
272
+
273
+ frame_times = (
274
+ df[["frame_idx", "timestamp_s"]]
275
+ .drop_duplicates("frame_idx")
276
+ .set_index("frame_idx")
277
+ .sort_index()
278
+ )
279
+ frame_indices = frame_times.index.to_list()
280
+
281
+ state_df = pd.DataFrame(index=frame_indices)
282
+ for alias, joint_name in JOINT_NAME_MAP.items():
283
+ joint_df = (
284
+ df[df["joint_name"] == joint_name]
285
+ .set_index("frame_idx")
286
+ .sort_index()
287
+ .reindex(frame_indices)
288
+ )
289
+ for metric in METRIC_LABELS.keys():
290
+ if metric in joint_df.columns:
291
+ state_df[f"{alias}_{metric}"] = joint_df[metric].astype(float)
292
+
293
+ state_df.reset_index(drop=True, inplace=True)
294
+ timestamps = frame_times["timestamp_s"].to_list()
295
+ return timestamps, state_df
296
+
297
+
298
+ def build_plot_fig(data: Dict, metric: str) -> go.Figure:
299
+ timestamps = data["timestamps"]
300
+ state_df = data["state_df"]
301
+ fig = go.Figure()
302
+ for alias, label in JOINT_ALIASES.items():
303
+ col_name = f"{alias}_{metric}"
304
+ if col_name not in state_df.columns:
305
+ continue
306
+ fig.add_trace(
307
+ go.Scatter(
308
+ x=timestamps,
309
+ y=state_df[col_name],
310
+ mode="lines",
311
+ name=label,
312
+ )
313
+ )
314
+ fig.update_layout(
315
+ margin=dict(l=20, r=20, t=30, b=20),
316
+ height=250,
317
+ template="plotly_dark",
318
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
319
+ xaxis_title="Time (s)",
320
+ yaxis_title=METRIC_LABELS[metric],
321
+ )
322
+ fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="rgba(255,255,255,0.1)")
323
+ fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="rgba(255,255,255,0.1)")
324
+ return fig
325
+
326
+
327
+ def build_plot_html(data: Dict, metric: str) -> str:
328
+ fig = build_plot_fig(data, metric)
329
+ return pio.to_html(fig, include_plotlyjs="cdn", full_html=False)
330
+
331
+
332
+ def format_episode_label(idx: int) -> str:
333
+ return f"Episode {idx:02d}"
334
+
335
+
336
+ def parse_episode_label(label: str) -> int:
337
+ return int(label.replace("Episode", "").strip())
338
+
339
+
340
+ def format_instruction_html(text: str) -> str:
341
+ safe_text = html.escape(text)
342
+ return (
343
+ '<div class="instruction-card">'
344
+ '<p class="instruction-label">Language Instruction</p>'
345
+ f'<p class="instruction-text">{safe_text}</p>'
346
+ "</div>"
347
+ )
348
+
349
+
350
+ def build_interface():
351
+ revision = get_dataset_revision(DEFAULT_DATASET_ID)
352
+ info = load_info(DEFAULT_DATASET_ID, revision)
353
+ episode_indices = sorted(ep["episode_index"] for ep in info["episodes"])
354
+ if not episode_indices:
355
+ raise RuntimeError("No episodes found in dataset metadata.")
356
+
357
+ default_idx = episode_indices[0]
358
+ default_label = format_episode_label(default_idx)
359
+ default_data = load_episode(DEFAULT_DATASET_ID, default_idx, revision)
360
+ default_video = str(default_data["rgb_path"])
361
+ default_instruction = default_data["instruction"]
362
+ default_figs = {metric: build_plot_html(default_data, metric) for metric in METRIC_LABELS.keys()}
363
+
364
+ total_frames = sum(ep.get("num_frames", 0) for ep in info["episodes"])
365
+ fps = info.get("fps", 30.0)
366
+ stats_html = f"""
367
+ <div class="stats-card">
368
+ <ul>
369
+ <li><span>Number of samples/frames:</span> {total_frames:,}</li>
370
+ <li><span>Number of episodes:</span> {len(episode_indices)}</li>
371
+ <li><span>Frames per second:</span> {fps:.1f}</li>
372
+ </ul>
373
+ </div>
374
+ """
375
+
376
+ theme = gr.themes.Soft(
377
+ primary_hue="cyan", secondary_hue="blue", neutral_hue="slate"
378
+ ).set(
379
+ body_background_fill="#0c1424",
380
+ body_text_color="#f8fafc",
381
+ block_background_fill="#111a2c",
382
+ block_title_text_color="#f8fafc",
383
+ input_background_fill="#151f33",
384
+ border_color_primary="#1f2b47",
385
+ shadow_drop="none",
386
+ )
387
+
388
+ with gr.Blocks(theme=theme, css=CUSTOM_CSS) as demo:
389
+ gr.Markdown("# Humanoid Robots Hand Pose Viewer")
390
+ gr.Markdown(
391
+ "Visualize RGB + 6DoF hand trajectories for all Moving_Mini tasks "
392
+ "(humanoid-robots-training-dataset)."
393
+ )
394
+
395
+ with gr.Row(equal_height=True):
396
+ with gr.Column(scale=1, min_width=260, elem_classes=["side-panel"]):
397
+ gr.HTML(stats_html)
398
+ gr.HTML('<div class="episodes-title">Episodes</div>')
399
+ episode_radio = gr.Radio(
400
+ choices=[format_episode_label(i) for i in episode_indices],
401
+ value=default_label,
402
+ label="Episodes",
403
+ elem_classes=["episode-list"],
404
+ )
405
+ with gr.Column(scale=2, min_width=640, elem_classes=["main-panel"]):
406
+ instruction_box = gr.HTML(
407
+ format_instruction_html(default_instruction),
408
+ label="Language Instruction",
409
+ )
410
+ with gr.Column(elem_classes=["video-card"]):
411
+ gr.HTML('<div class="video-title">RGB</div>')
412
+ video = gr.Video(
413
+ height=360,
414
+ value=default_video,
415
+ elem_classes=["video-panel"],
416
+ show_label=False,
417
+ show_download_button=False,
418
+ )
419
+ download_button = gr.DownloadButton(
420
+ label="Download",
421
+ value=default_video,
422
+ elem_classes=["download-button"],
423
+ )
424
+
425
+ plot_outputs = []
426
+ gr.Markdown("### Joint trajectories", elem_classes=["plots-title"])
427
+ with gr.Column(elem_classes=["plots-wrap"]):
428
+ for row in PLOT_GRID:
429
+ with gr.Row():
430
+ for metric in row:
431
+ plot = gr.HTML(value=default_figs[metric], elem_classes=["plot-html"])
432
+ plot_outputs.append(plot)
433
+
434
+ outputs = [instruction_box, video, download_button] + plot_outputs
435
+
436
+ def load_episode_payload(label: str):
437
+ idx = parse_episode_label(label)
438
+ data = load_episode(DEFAULT_DATASET_ID, idx, revision)
439
+ video_path = str(data["rgb_path"])
440
+ figs = [build_plot_html(data, metric) for metric in PLOT_ORDER]
441
+ return [
442
+ format_instruction_html(data["instruction"]),
443
+ video_path,
444
+ gr.DownloadButton.update(value=video_path),
445
+ *figs,
446
+ ]
447
+
448
+ episode_radio.change(fn=load_episode_payload, inputs=episode_radio, outputs=outputs)
449
+
450
+ return demo
451
+
452
+
453
+
454
+ def main():
455
+ demo = build_interface()
456
+ demo.queue().launch(show_api=False)
457
+
458
+
459
+ if __name__ == "__main__":
460
+ main()
eslint.config.mjs ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { dirname } from "path";
2
+ import { fileURLToPath } from "url";
3
+ import { FlatCompat } from "@eslint/eslintrc";
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+
8
+ const compat = new FlatCompat({
9
+ baseDirectory: __dirname,
10
+ });
11
+
12
+ const eslintConfig = [
13
+ ...compat.extends("next/core-web-vitals", "next/typescript"),
14
+ ];
15
+
16
+ export default eslintConfig;
next.config.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+ import packageJson from './package.json';
3
+
4
+ const nextConfig: NextConfig = {
5
+ env: {
6
+ REPO_ID: process.env.REPO_ID,
7
+ EPISODES: process.env.EPISODES,
8
+ },
9
+ typescript: {
10
+ ignoreBuildErrors: true,
11
+ },
12
+ eslint: {
13
+ ignoreDuringBuilds: true,
14
+ },
15
+ generateBuildId: () => packageJson.version,
16
+ };
17
+
18
+ export default nextConfig;
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "lerobot-viewer",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "next lint",
10
+ "format": "prettier --write ."
11
+ },
12
+ "dependencies": {
13
+ "hyparquet": "^1.12.1",
14
+ "next": "15.3.1",
15
+ "react": "^19.0.0",
16
+ "react-dom": "^19.0.0",
17
+ "react-icons": "^5.5.0",
18
+ "recharts": "^2.15.3"
19
+ },
20
+ "devDependencies": {
21
+ "@eslint/eslintrc": "^3",
22
+ "@tailwindcss/postcss": "^4",
23
+ "@types/node": "^20",
24
+ "@types/react": "^19",
25
+ "@types/react-dom": "^19",
26
+ "eslint": "^9",
27
+ "eslint-config-next": "15.3.1",
28
+ "prettier": "^3.5.3",
29
+ "tailwindcss": "^4",
30
+ "typescript": "^5"
31
+ }
32
+ }
postcss.config.mjs ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ const config = {
2
+ plugins: ["@tailwindcss/postcss"],
3
+ };
4
+
5
+ export default config;
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio==5.7.0
2
+ pandas
3
+ pyarrow
4
+ plotly
5
+ huggingface_hub==0.25.2
src/app/[org]/[dataset]/[episode]/episode-viewer.tsx ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect, useRef } from "react";
4
+ import { useRouter, useSearchParams } from "next/navigation";
5
+ import { postParentMessageWithParams } from "@/utils/postParentMessage";
6
+ import { SimpleVideosPlayer } from "@/components/simple-videos-player";
7
+ import DataRecharts from "@/components/data-recharts";
8
+ import PlaybackBar from "@/components/playback-bar";
9
+ import { TimeProvider, useTime } from "@/context/time-context";
10
+ import Sidebar from "@/components/side-nav";
11
+ import Loading from "@/components/loading-component";
12
+ import { getAdjacentEpisodesVideoInfo } from "./fetch-data";
13
+
14
+ export default function EpisodeViewer({
15
+ data,
16
+ error,
17
+ org,
18
+ dataset,
19
+ }: {
20
+ data?: any;
21
+ error?: string;
22
+ org?: string;
23
+ dataset?: string;
24
+ }) {
25
+ if (error) {
26
+ return (
27
+ <div className="flex h-screen items-center justify-center bg-slate-950 text-red-400">
28
+ <div className="max-w-xl p-8 rounded bg-slate-900 border border-red-500 shadow-lg">
29
+ <h2 className="text-2xl font-bold mb-4">Something went wrong</h2>
30
+ <p className="text-lg font-mono whitespace-pre-wrap mb-4">{error}</p>
31
+ </div>
32
+ </div>
33
+ );
34
+ }
35
+ return (
36
+ <TimeProvider duration={data.duration}>
37
+ <EpisodeViewerInner data={data} org={org} dataset={dataset} />
38
+ </TimeProvider>
39
+ );
40
+ }
41
+
42
+ function EpisodeViewerInner({ data, org, dataset }: { data: any; org?: string; dataset?: string; }) {
43
+ const {
44
+ datasetInfo,
45
+ episodeId,
46
+ videosInfo,
47
+ chartDataGroups,
48
+ episodes,
49
+ task,
50
+ } = data;
51
+
52
+ const [videosReady, setVideosReady] = useState(!videosInfo.length);
53
+ const [chartsReady, setChartsReady] = useState(false);
54
+ const isLoading = !videosReady || !chartsReady;
55
+
56
+ const router = useRouter();
57
+ const searchParams = useSearchParams();
58
+
59
+ // State
60
+ // Use context for time sync
61
+ const { currentTime, setCurrentTime, setIsPlaying, isPlaying } = useTime();
62
+
63
+ // Pagination state
64
+ const pageSize = 100;
65
+ const [currentPage, setCurrentPage] = useState(1);
66
+ const totalPages = Math.ceil(episodes.length / pageSize);
67
+ const paginatedEpisodes = episodes.slice(
68
+ (currentPage - 1) * pageSize,
69
+ currentPage * pageSize,
70
+ );
71
+
72
+ // Preload adjacent episodes' videos
73
+ useEffect(() => {
74
+ if (!org || !dataset) return;
75
+
76
+ const preloadAdjacent = async () => {
77
+ try {
78
+ await getAdjacentEpisodesVideoInfo(org, dataset, episodeId, 2);
79
+ // Preload adjacent episodes for smoother navigation
80
+ } catch {
81
+ // Skip preloading on error
82
+ }
83
+ };
84
+
85
+ preloadAdjacent();
86
+ }, [org, dataset, episodeId]);
87
+
88
+ // Initialize based on URL time parameter
89
+ useEffect(() => {
90
+ const timeParam = searchParams.get("t");
91
+ if (timeParam) {
92
+ const timeValue = parseFloat(timeParam);
93
+ if (!isNaN(timeValue)) {
94
+ setCurrentTime(timeValue);
95
+ }
96
+ }
97
+ }, []);
98
+
99
+ // sync with parent window hf.co/spaces
100
+ useEffect(() => {
101
+ postParentMessageWithParams((params: URLSearchParams) => {
102
+ params.set("path", window.location.pathname + window.location.search);
103
+ });
104
+ }, []);
105
+
106
+ // Initialize based on URL time parameter
107
+ useEffect(() => {
108
+ // Initialize page based on current episode
109
+ const episodeIndex = episodes.indexOf(episodeId);
110
+ if (episodeIndex !== -1) {
111
+ setCurrentPage(Math.floor(episodeIndex / pageSize) + 1);
112
+ }
113
+
114
+ // Add keyboard event listener
115
+ window.addEventListener("keydown", handleKeyDown);
116
+ return () => {
117
+ window.removeEventListener("keydown", handleKeyDown);
118
+ };
119
+ }, [episodes, episodeId, pageSize, searchParams]);
120
+
121
+ // Only update URL ?t= param when the integer second changes
122
+ const lastUrlSecondRef = useRef<number>(-1);
123
+ useEffect(() => {
124
+ if (isPlaying) return;
125
+ const currentSec = Math.floor(currentTime);
126
+ if (currentTime > 0 && lastUrlSecondRef.current !== currentSec) {
127
+ lastUrlSecondRef.current = currentSec;
128
+ const newParams = new URLSearchParams(searchParams.toString());
129
+ newParams.set("t", currentSec.toString());
130
+ // Replace state instead of pushing to avoid navigation stack bloat
131
+ window.history.replaceState(
132
+ {},
133
+ "",
134
+ `${window.location.pathname}?${newParams.toString()}`,
135
+ );
136
+ postParentMessageWithParams((params: URLSearchParams) => {
137
+ params.set("path", window.location.pathname + window.location.search);
138
+ });
139
+ }
140
+ }, [isPlaying, currentTime, searchParams]);
141
+
142
+ // Handle keyboard shortcuts
143
+ const handleKeyDown = (e: KeyboardEvent) => {
144
+ const { key } = e;
145
+
146
+ if (key === " ") {
147
+ e.preventDefault();
148
+ setIsPlaying((prev: boolean) => !prev);
149
+ } else if (key === "ArrowDown" || key === "ArrowUp") {
150
+ e.preventDefault();
151
+ const nextEpisodeId = key === "ArrowDown" ? episodeId + 1 : episodeId - 1;
152
+ const lowestEpisodeId = episodes[0];
153
+ const highestEpisodeId = episodes[episodes.length - 1];
154
+
155
+ if (
156
+ nextEpisodeId >= lowestEpisodeId &&
157
+ nextEpisodeId <= highestEpisodeId
158
+ ) {
159
+ router.push(`./episode_${nextEpisodeId}`);
160
+ }
161
+ }
162
+ };
163
+
164
+ // Pagination functions
165
+ const nextPage = () => {
166
+ if (currentPage < totalPages) {
167
+ setCurrentPage((prev) => prev + 1);
168
+ }
169
+ };
170
+
171
+ const prevPage = () => {
172
+ if (currentPage > 1) {
173
+ setCurrentPage((prev) => prev - 1);
174
+ }
175
+ };
176
+
177
+ return (
178
+ <div className="flex h-screen max-h-screen bg-slate-950 text-gray-200">
179
+ {/* Sidebar */}
180
+ <Sidebar
181
+ datasetInfo={datasetInfo}
182
+ paginatedEpisodes={paginatedEpisodes}
183
+ episodeId={episodeId}
184
+ totalPages={totalPages}
185
+ currentPage={currentPage}
186
+ prevPage={prevPage}
187
+ nextPage={nextPage}
188
+ />
189
+
190
+ {/* Content */}
191
+ <div
192
+ className={`flex max-h-screen flex-col gap-4 p-4 md:flex-1 relative ${isLoading ? "overflow-hidden" : "overflow-y-auto"}`}
193
+ >
194
+ {isLoading && <Loading />}
195
+
196
+ <div className="flex items-center justify-start my-4">
197
+ <a
198
+ href="https://github.com/huggingface/lerobot"
199
+ target="_blank"
200
+ className="block"
201
+ >
202
+ <img
203
+ src="https://github.com/huggingface/lerobot/raw/main/media/lerobot-logo-thumbnail.png"
204
+ alt="LeRobot Logo"
205
+ className="w-32"
206
+ />
207
+ </a>
208
+
209
+ <div>
210
+ <a
211
+ href={`https://huggingface.co/datasets/${datasetInfo.repoId}`}
212
+ target="_blank"
213
+ >
214
+ <p className="text-lg font-semibold">{datasetInfo.repoId}</p>
215
+ </a>
216
+
217
+ <p className="font-mono text-lg font-semibold">
218
+ episode {episodeId}
219
+ </p>
220
+ </div>
221
+ </div>
222
+
223
+ {/* Videos */}
224
+ {videosInfo.length && (
225
+ <SimpleVideosPlayer
226
+ videosInfo={videosInfo}
227
+ onVideosReady={() => setVideosReady(true)}
228
+ />
229
+ )}
230
+
231
+ {/* Language Instruction */}
232
+ {task && (
233
+ <div className="mb-6 p-4 bg-slate-800 rounded-lg border border-slate-600">
234
+ <p className="text-slate-300">
235
+ <span className="font-semibold text-slate-100">Language Instruction:</span>
236
+ </p>
237
+ <div className="mt-2 text-slate-300">
238
+ {task.split('\n').map((instruction, index) => (
239
+ <p key={index} className="mb-1">
240
+ {instruction}
241
+ </p>
242
+ ))}
243
+ </div>
244
+ </div>
245
+ )}
246
+
247
+ {/* Graph */}
248
+ <div className="mb-4">
249
+ <DataRecharts
250
+ data={chartDataGroups}
251
+ onChartsReady={() => setChartsReady(true)}
252
+ />
253
+
254
+ </div>
255
+
256
+ <PlaybackBar />
257
+ </div>
258
+ </div>
259
+ );
260
+ }
src/app/[org]/[dataset]/[episode]/error.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ export default function Error({
6
+ error,
7
+ reset,
8
+ }: {
9
+ error: Error & { digest?: string };
10
+ reset: () => void;
11
+ }) {
12
+ return (
13
+ <div className="flex h-screen items-center justify-center bg-slate-950 text-red-400">
14
+ <div className="max-w-xl p-8 rounded bg-slate-900 border border-red-500 shadow-lg">
15
+ <h2 className="text-2xl font-bold mb-4">Something went wrong</h2>
16
+ <p className="text-lg font-mono whitespace-pre-wrap mb-4">
17
+ {error.message}
18
+ </p>
19
+ <button
20
+ className="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
21
+ onClick={() => reset()}
22
+ >
23
+ Try Again
24
+ </button>
25
+ </div>
26
+ </div>
27
+ );
28
+ }
src/app/[org]/[dataset]/[episode]/fetch-data.ts ADDED
@@ -0,0 +1,1088 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ DatasetMetadata,
3
+ fetchJson,
4
+ fetchParquetFile,
5
+ formatStringWithVars,
6
+ readParquetColumn,
7
+ readParquetAsObjects,
8
+ } from "@/utils/parquetUtils";
9
+ import { pick } from "@/utils/pick";
10
+ import { getDatasetVersion, buildVersionedUrl } from "@/utils/versionUtils";
11
+
12
+ const SERIES_NAME_DELIMITER = " | ";
13
+
14
+ export async function getEpisodeData(
15
+ org: string,
16
+ dataset: string,
17
+ episodeId: number,
18
+ ) {
19
+ const repoId = `${org}/${dataset}`;
20
+ try {
21
+ // Check for compatible dataset version (v3.0, v2.1, or v2.0)
22
+ const version = await getDatasetVersion(repoId);
23
+ const jsonUrl = buildVersionedUrl(repoId, version, "meta/info.json");
24
+ const info = await fetchJson<DatasetMetadata>(jsonUrl);
25
+
26
+ if (info.video_path === null) {
27
+ throw new Error("Only videos datasets are supported in this visualizer.\nPlease use Rerun visualizer for images datasets.");
28
+ }
29
+
30
+ // Handle different versions
31
+ if (version === "v3.0") {
32
+ return await getEpisodeDataV3(repoId, version, info, episodeId);
33
+ } else {
34
+ return await getEpisodeDataV2(repoId, version, info, episodeId);
35
+ }
36
+ } catch (err) {
37
+ console.error("Error loading episode data:", err);
38
+ throw err;
39
+ }
40
+ }
41
+
42
+ // Get video info for adjacent episodes (for preloading)
43
+ export async function getAdjacentEpisodesVideoInfo(
44
+ org: string,
45
+ dataset: string,
46
+ currentEpisodeId: number,
47
+ radius: number = 2,
48
+ ) {
49
+ const repoId = `${org}/${dataset}`;
50
+ try {
51
+ const version = await getDatasetVersion(repoId);
52
+ const jsonUrl = buildVersionedUrl(repoId, version, "meta/info.json");
53
+ const info = await fetchJson<DatasetMetadata>(jsonUrl);
54
+
55
+ const totalEpisodes = info.total_episodes;
56
+ const adjacentVideos: Array<{episodeId: number; videosInfo: any[]}> = [];
57
+
58
+ // Calculate adjacent episode IDs
59
+ for (let offset = -radius; offset <= radius; offset++) {
60
+ if (offset === 0) continue; // Skip current episode
61
+
62
+ const episodeId = currentEpisodeId + offset;
63
+ if (episodeId >= 0 && episodeId < totalEpisodes) {
64
+ try {
65
+ let videosInfo: any[] = [];
66
+
67
+ if (version === "v3.0") {
68
+ const episodeMetadata = await loadEpisodeMetadataV3Simple(repoId, version, episodeId);
69
+ videosInfo = extractVideoInfoV3WithSegmentation(repoId, version, info, episodeMetadata);
70
+ } else {
71
+ // For v2.x, use simpler video info extraction
72
+ const episode_chunk = Math.floor(0 / 1000);
73
+ videosInfo = Object.entries(info.features)
74
+ .filter(([, value]) => value.dtype === "video")
75
+ .map(([key]) => {
76
+ const videoPath = formatStringWithVars(info.video_path, {
77
+ video_key: key,
78
+ episode_chunk: episode_chunk.toString().padStart(3, "0"),
79
+ episode_index: episodeId.toString().padStart(6, "0"),
80
+ });
81
+ return {
82
+ filename: key,
83
+ url: buildVersionedUrl(repoId, version, videoPath),
84
+ };
85
+ });
86
+ }
87
+
88
+ adjacentVideos.push({ episodeId, videosInfo });
89
+ } catch {
90
+ // Skip failed episodes silently
91
+ }
92
+ }
93
+ }
94
+
95
+ return adjacentVideos;
96
+ } catch {
97
+ // Return empty array on error
98
+ return [];
99
+ }
100
+ }
101
+
102
+ // Legacy v2.x data loading
103
+ async function getEpisodeDataV2(
104
+ repoId: string,
105
+ version: string,
106
+ info: DatasetMetadata,
107
+ episodeId: number,
108
+ ) {
109
+ const episode_chunk = Math.floor(0 / 1000);
110
+
111
+ // Dataset information
112
+ const datasetInfo = {
113
+ repoId,
114
+ total_frames: info.total_frames,
115
+ total_episodes: info.total_episodes,
116
+ fps: info.fps,
117
+ };
118
+
119
+ // Generate list of episodes
120
+ const episodes =
121
+ process.env.EPISODES === undefined
122
+ ? Array.from(
123
+ { length: datasetInfo.total_episodes },
124
+ // episode id starts from 0
125
+ (_, i) => i,
126
+ )
127
+ : process.env.EPISODES
128
+ .split(/\s+/)
129
+ .map((x) => parseInt(x.trim(), 10))
130
+ .filter((x) => !isNaN(x));
131
+
132
+ // Videos information
133
+ const videosInfo = Object.entries(info.features)
134
+ .filter(([, value]) => value.dtype === "video")
135
+ .map(([key]) => {
136
+ const videoPath = formatStringWithVars(info.video_path, {
137
+ video_key: key,
138
+ episode_chunk: episode_chunk.toString().padStart(3, "0"),
139
+ episode_index: episodeId.toString().padStart(6, "0"),
140
+ });
141
+ return {
142
+ filename: key,
143
+ url: buildVersionedUrl(repoId, version, videoPath),
144
+ };
145
+ });
146
+
147
+ // Column data
148
+ const columnNames = Object.entries(info.features)
149
+ .filter(
150
+ ([, value]) =>
151
+ ["float32", "int32"].includes(value.dtype) &&
152
+ value.shape.length === 1,
153
+ )
154
+ .map(([key, { shape }]) => ({ key, length: shape[0] }));
155
+
156
+ // Exclude specific columns
157
+ const excludedColumns = [
158
+ "timestamp",
159
+ "frame_index",
160
+ "episode_index",
161
+ "index",
162
+ "task_index",
163
+ ];
164
+ const filteredColumns = columnNames.filter(
165
+ (column) => !excludedColumns.includes(column.key),
166
+ );
167
+ const filteredColumnNames = [
168
+ "timestamp",
169
+ ...filteredColumns.map((column) => column.key),
170
+ ];
171
+
172
+ const columns = filteredColumns.map(({ key }) => {
173
+ let column_names = info.features[key].names;
174
+ while (typeof column_names === "object") {
175
+ if (Array.isArray(column_names)) break;
176
+ column_names = Object.values(column_names ?? {})[0];
177
+ }
178
+ return {
179
+ key,
180
+ value: Array.isArray(column_names)
181
+ ? column_names.map((name) => `${key}${SERIES_NAME_DELIMITER}${name}`)
182
+ : Array.from(
183
+ { length: columnNames.find((c) => c.key === key)?.length ?? 1 },
184
+ (_, i) => `${key}${SERIES_NAME_DELIMITER}${i}`,
185
+ ),
186
+ };
187
+ });
188
+
189
+ const parquetUrl = buildVersionedUrl(
190
+ repoId,
191
+ version,
192
+ formatStringWithVars(info.data_path, {
193
+ episode_chunk: episode_chunk.toString().padStart(3, "0"),
194
+ episode_index: episodeId.toString().padStart(6, "0"),
195
+ })
196
+ );
197
+
198
+ const arrayBuffer = await fetchParquetFile(parquetUrl);
199
+
200
+ // Extract task - first check for language instructions (preferred), then fallback to task field or tasks.jsonl
201
+ let task: string | undefined;
202
+ let allData: any[] = [];
203
+
204
+ // Load data first
205
+ try {
206
+ allData = await readParquetAsObjects(arrayBuffer, []);
207
+ } catch (error) {
208
+ // Could not read parquet data
209
+ }
210
+
211
+ // First check for language_instruction fields in the data (preferred)
212
+ if (allData.length > 0) {
213
+ const firstRow = allData[0];
214
+ const languageInstructions: string[] = [];
215
+
216
+ // Check for language_instruction field
217
+ if (firstRow.language_instruction) {
218
+ languageInstructions.push(firstRow.language_instruction);
219
+ }
220
+
221
+ // Check for numbered language_instruction fields
222
+ let instructionNum = 2;
223
+ while (firstRow[`language_instruction_${instructionNum}`]) {
224
+ languageInstructions.push(firstRow[`language_instruction_${instructionNum}`]);
225
+ instructionNum++;
226
+ }
227
+
228
+ // Join all instructions with line breaks
229
+ if (languageInstructions.length > 0) {
230
+ task = languageInstructions.join('\n');
231
+ }
232
+ }
233
+
234
+ // If no language instructions found, try direct task field
235
+ if (!task && allData.length > 0 && allData[0].task) {
236
+ task = allData[0].task;
237
+ }
238
+
239
+ // If still no task found, try loading from tasks.jsonl metadata file (v2.x format)
240
+ if (!task && allData.length > 0) {
241
+ try {
242
+ const tasksUrl = buildVersionedUrl(repoId, version, "meta/tasks.jsonl");
243
+ const tasksResponse = await fetch(tasksUrl);
244
+
245
+ if (tasksResponse.ok) {
246
+ const tasksText = await tasksResponse.text();
247
+ // Parse JSONL format (one JSON object per line)
248
+ const tasksData = tasksText
249
+ .split('\n')
250
+ .filter(line => line.trim())
251
+ .map(line => JSON.parse(line));
252
+
253
+ if (tasksData && tasksData.length > 0) {
254
+ const taskIndex = allData[0].task_index;
255
+
256
+ // Convert BigInt to number for comparison
257
+ const taskIndexNum = typeof taskIndex === 'bigint' ? Number(taskIndex) : taskIndex;
258
+
259
+ // Find task by task_index
260
+ const taskData = tasksData.find(t => t.task_index === taskIndexNum);
261
+ if (taskData) {
262
+ task = taskData.task;
263
+ }
264
+ }
265
+ }
266
+ } catch (error) {
267
+ // No tasks metadata file for this v2.x dataset
268
+ }
269
+ }
270
+
271
+ const data = await readParquetColumn(arrayBuffer, filteredColumnNames);
272
+ // Flatten and map to array of objects for chartData
273
+ const seriesNames = [
274
+ "timestamp",
275
+ ...columns.map(({ value }) => value).flat(),
276
+ ];
277
+
278
+ const chartData = data.map((row) => {
279
+ const flatRow = row.flat();
280
+ const obj: Record<string, number> = {};
281
+ seriesNames.forEach((key, idx) => {
282
+ obj[key] = flatRow[idx];
283
+ });
284
+ return obj;
285
+ });
286
+
287
+ // List of columns that are ignored (e.g., 2D or 3D data)
288
+ const ignoredColumns = Object.entries(info.features)
289
+ .filter(
290
+ ([, value]) =>
291
+ ["float32", "int32"].includes(value.dtype) && value.shape.length > 1,
292
+ )
293
+ .map(([key]) => key);
294
+
295
+ // 1. Group all numeric keys by suffix (excluding 'timestamp')
296
+ const numericKeys = seriesNames.filter((k) => k !== "timestamp");
297
+ const suffixGroupsMap: Record<string, string[]> = {};
298
+ for (const key of numericKeys) {
299
+ const parts = key.split(SERIES_NAME_DELIMITER);
300
+ const suffix = parts[1] || parts[0]; // fallback to key if no delimiter
301
+ if (!suffixGroupsMap[suffix]) suffixGroupsMap[suffix] = [];
302
+ suffixGroupsMap[suffix].push(key);
303
+ }
304
+ const suffixGroups = Object.values(suffixGroupsMap);
305
+
306
+ // 2. Compute min/max for each suffix group as a whole
307
+ const groupStats: Record<string, { min: number; max: number }> = {};
308
+ suffixGroups.forEach((group) => {
309
+ let min = Infinity,
310
+ max = -Infinity;
311
+ for (const row of chartData) {
312
+ for (const key of group) {
313
+ const v = row[key];
314
+ if (typeof v === "number" && !isNaN(v)) {
315
+ if (v < min) min = v;
316
+ if (v > max) max = v;
317
+ }
318
+ }
319
+ }
320
+ // Use the first key in the group as the group id
321
+ groupStats[group[0]] = { min, max };
322
+ });
323
+
324
+ // 3. Group suffix groups by similar scale (treat each suffix group as a unit)
325
+ const scaleGroups: Record<string, string[][]> = {};
326
+ const used = new Set<string>();
327
+ const SCALE_THRESHOLD = 2;
328
+ for (const group of suffixGroups) {
329
+ const groupId = group[0];
330
+ if (used.has(groupId)) continue;
331
+ const { min, max } = groupStats[groupId];
332
+ if (!isFinite(min) || !isFinite(max)) continue;
333
+ const logMin = Math.log10(Math.abs(min) + 1e-9);
334
+ const logMax = Math.log10(Math.abs(max) + 1e-9);
335
+ const unit: string[][] = [group];
336
+ used.add(groupId);
337
+ for (const other of suffixGroups) {
338
+ const otherId = other[0];
339
+ if (used.has(otherId) || otherId === groupId) continue;
340
+ const { min: omin, max: omax } = groupStats[otherId];
341
+ if (!isFinite(omin) || !isFinite(omax) || omin === omax) continue;
342
+ const ologMin = Math.log10(Math.abs(omin) + 1e-9);
343
+ const ologMax = Math.log10(Math.abs(omax) + 1e-9);
344
+ if (
345
+ Math.abs(logMin - ologMin) <= SCALE_THRESHOLD &&
346
+ Math.abs(logMax - ologMax) <= SCALE_THRESHOLD
347
+ ) {
348
+ unit.push(other);
349
+ used.add(otherId);
350
+ }
351
+ }
352
+ scaleGroups[groupId] = unit;
353
+ }
354
+
355
+ // 4. Flatten scaleGroups into chartGroups (array of arrays of keys)
356
+ const chartGroups: string[][] = Object.values(scaleGroups)
357
+ .sort((a, b) => b.length - a.length)
358
+ .flatMap((suffixGroupArr) => {
359
+ // suffixGroupArr is array of suffix groups (each is array of keys)
360
+ const merged = suffixGroupArr.flat();
361
+ if (merged.length > 6) {
362
+ const subgroups: string[][] = [];
363
+ for (let i = 0; i < merged.length; i += 6) {
364
+ subgroups.push(merged.slice(i, i + 6));
365
+ }
366
+ return subgroups;
367
+ }
368
+ return [merged];
369
+ });
370
+
371
+ const duration = chartData[chartData.length - 1].timestamp;
372
+
373
+ // Utility: group row keys by suffix
374
+ function groupRowBySuffix(row: Record<string, number>): Record<string, any> {
375
+ const result: Record<string, any> = {};
376
+ const suffixGroups: Record<string, Record<string, number>> = {};
377
+ for (const [key, value] of Object.entries(row)) {
378
+ if (key === "timestamp") {
379
+ result["timestamp"] = value;
380
+ continue;
381
+ }
382
+ const parts = key.split(SERIES_NAME_DELIMITER);
383
+ if (parts.length === 2) {
384
+ const [prefix, suffix] = parts;
385
+ if (!suffixGroups[suffix]) suffixGroups[suffix] = {};
386
+ suffixGroups[suffix][prefix] = value;
387
+ } else {
388
+ result[key] = value;
389
+ }
390
+ }
391
+ for (const [suffix, group] of Object.entries(suffixGroups)) {
392
+ const keys = Object.keys(group);
393
+ if (keys.length === 1) {
394
+ // Use the full original name as the key
395
+ const fullName = `${keys[0]}${SERIES_NAME_DELIMITER}${suffix}`;
396
+ result[fullName] = group[keys[0]];
397
+ } else {
398
+ result[suffix] = group;
399
+ }
400
+ }
401
+ return result;
402
+ }
403
+
404
+ const chartDataGroups = chartGroups.map((group) =>
405
+ chartData.map((row) => groupRowBySuffix(pick(row, [...group, "timestamp"])))
406
+ );
407
+
408
+ return {
409
+ datasetInfo,
410
+ episodeId,
411
+ videosInfo,
412
+ chartDataGroups,
413
+ episodes,
414
+ ignoredColumns,
415
+ duration,
416
+ task,
417
+ };
418
+ }
419
+
420
+ // v3.0 implementation with segmentation support for all episodes
421
+ async function getEpisodeDataV3(
422
+ repoId: string,
423
+ version: string,
424
+ info: DatasetMetadata,
425
+ episodeId: number,
426
+ ) {
427
+ // Create dataset info structure (like v2.x)
428
+ const datasetInfo = {
429
+ repoId,
430
+ total_frames: info.total_frames,
431
+ total_episodes: info.total_episodes,
432
+ fps: info.fps,
433
+ };
434
+
435
+ // Generate episodes list based on total_episodes from dataset info
436
+ const episodes = Array.from({ length: info.total_episodes }, (_, i) => i);
437
+
438
+ // Load episode metadata to get timestamps for episode 0
439
+ const episodeMetadata = await loadEpisodeMetadataV3Simple(repoId, version, episodeId);
440
+
441
+ // Create video info with segmentation using the metadata
442
+ const videosInfo = extractVideoInfoV3WithSegmentation(repoId, version, info, episodeMetadata);
443
+
444
+ // Load episode data for charts
445
+ const { chartDataGroups, ignoredColumns, task } = await loadEpisodeDataV3(repoId, version, info, episodeMetadata);
446
+
447
+ // Calculate duration from episode length and FPS if available
448
+ const duration = episodeMetadata.length ? episodeMetadata.length / info.fps :
449
+ (episodeMetadata.video_to_timestamp - episodeMetadata.video_from_timestamp);
450
+
451
+ return {
452
+ datasetInfo,
453
+ episodeId,
454
+ videosInfo,
455
+ chartDataGroups,
456
+ episodes,
457
+ ignoredColumns,
458
+ duration,
459
+ task,
460
+ };
461
+ }
462
+
463
+ // Load episode data for v3.0 charts
464
+ async function loadEpisodeDataV3(
465
+ repoId: string,
466
+ version: string,
467
+ info: DatasetMetadata,
468
+ episodeMetadata: any,
469
+ ): Promise<{ chartDataGroups: any[]; ignoredColumns: string[]; task?: string }> {
470
+ // Build data file path using chunk and file indices
471
+ const dataChunkIndex = episodeMetadata.data_chunk_index || 0;
472
+ const dataFileIndex = episodeMetadata.data_file_index || 0;
473
+ const dataPath = `data/chunk-${dataChunkIndex.toString().padStart(3, "0")}/file-${dataFileIndex.toString().padStart(3, "0")}.parquet`;
474
+
475
+ try {
476
+ const dataUrl = buildVersionedUrl(repoId, version, dataPath);
477
+ const arrayBuffer = await fetchParquetFile(dataUrl);
478
+ const fullData = await readParquetAsObjects(arrayBuffer, []);
479
+
480
+ // Extract the episode-specific data slice
481
+ // Convert BigInt to number if needed
482
+ const fromIndex = Number(episodeMetadata.dataset_from_index || 0);
483
+ const toIndex = Number(episodeMetadata.dataset_to_index || fullData.length);
484
+
485
+ // Find the starting index of this parquet file by checking the first row's index
486
+ // This handles the case where episodes are split across multiple parquet files
487
+ let fileStartIndex = 0;
488
+ if (fullData.length > 0 && fullData[0].index !== undefined) {
489
+ fileStartIndex = Number(fullData[0].index);
490
+ }
491
+
492
+ // Adjust indices to be relative to this file's starting position
493
+ const localFromIndex = Math.max(0, fromIndex - fileStartIndex);
494
+ const localToIndex = Math.min(fullData.length, toIndex - fileStartIndex);
495
+
496
+ const episodeData = fullData.slice(localFromIndex, localToIndex);
497
+
498
+ if (episodeData.length === 0) {
499
+ return { chartDataGroups: [], ignoredColumns: [], task: undefined };
500
+ }
501
+
502
+ // Convert to the same format as v2.x for compatibility with existing chart code
503
+ const { chartDataGroups, ignoredColumns } = processEpisodeDataForCharts(episodeData, info, episodeMetadata);
504
+
505
+ // First check for language_instruction fields in the data (preferred)
506
+ let task: string | undefined;
507
+ if (episodeData.length > 0) {
508
+ const firstRow = episodeData[0];
509
+ const languageInstructions: string[] = [];
510
+
511
+ // Check for language_instruction field
512
+ if (firstRow.language_instruction) {
513
+ languageInstructions.push(firstRow.language_instruction);
514
+ }
515
+
516
+ // Check for numbered language_instruction fields
517
+ let instructionNum = 2;
518
+ while (firstRow[`language_instruction_${instructionNum}`]) {
519
+ languageInstructions.push(firstRow[`language_instruction_${instructionNum}`]);
520
+ instructionNum++;
521
+ }
522
+
523
+ // If no instructions found in first row, check a few more rows
524
+ if (languageInstructions.length === 0 && episodeData.length > 1) {
525
+ const middleIndex = Math.floor(episodeData.length / 2);
526
+ const lastIndex = episodeData.length - 1;
527
+
528
+ [middleIndex, lastIndex].forEach((idx) => {
529
+ const row = episodeData[idx];
530
+
531
+ if (row.language_instruction && languageInstructions.length === 0) {
532
+ // Use this row's instructions
533
+ if (row.language_instruction) {
534
+ languageInstructions.push(row.language_instruction);
535
+ }
536
+ let num = 2;
537
+ while (row[`language_instruction_${num}`]) {
538
+ languageInstructions.push(row[`language_instruction_${num}`]);
539
+ num++;
540
+ }
541
+ }
542
+ });
543
+ }
544
+
545
+ // Join all instructions with line breaks
546
+ if (languageInstructions.length > 0) {
547
+ task = languageInstructions.join('\n');
548
+ }
549
+ }
550
+
551
+ // If no language instructions found, fall back to tasks metadata
552
+ if (!task) {
553
+ try {
554
+ // Load tasks metadata
555
+ const tasksUrl = buildVersionedUrl(repoId, version, "meta/tasks.parquet");
556
+ const tasksArrayBuffer = await fetchParquetFile(tasksUrl);
557
+ const tasksData = await readParquetAsObjects(tasksArrayBuffer, []);
558
+
559
+ if (episodeData.length > 0 && tasksData && tasksData.length > 0) {
560
+ const taskIndex = episodeData[0].task_index;
561
+
562
+ // Convert BigInt to number for comparison
563
+ const taskIndexNum = typeof taskIndex === 'bigint' ? Number(taskIndex) : taskIndex;
564
+
565
+ // Look up task by index
566
+ if (taskIndexNum !== undefined && taskIndexNum < tasksData.length) {
567
+ const taskData = tasksData[taskIndexNum];
568
+ // Extract task from __index_level_0__ field
569
+ task = taskData.__index_level_0__ || taskData.task || taskData['task'] || taskData[0];
570
+ }
571
+ }
572
+ } catch (error) {
573
+ // Could not load tasks metadata - dataset might not have language tasks
574
+ }
575
+ }
576
+
577
+ return { chartDataGroups, ignoredColumns, task };
578
+ } catch {
579
+ return { chartDataGroups: [], ignoredColumns: [], task: undefined };
580
+ }
581
+ }
582
+
583
+ // Process episode data for charts (v3.0 compatible)
584
+ function processEpisodeDataForCharts(
585
+ episodeData: any[],
586
+ info: DatasetMetadata,
587
+ episodeMetadata?: any,
588
+ ): { chartDataGroups: any[]; ignoredColumns: string[] } {
589
+
590
+ // Get numeric column features
591
+ const columnNames = Object.entries(info.features)
592
+ .filter(
593
+ ([, value]) =>
594
+ ["float32", "int32"].includes(value.dtype) &&
595
+ value.shape.length === 1,
596
+ )
597
+ .map(([key, value]) => ({ key, value }));
598
+
599
+ // Convert parquet data to chart format
600
+ let seriesNames: string[] = [];
601
+
602
+ // Dynamically create a mapping from numeric indices to feature names based on actual dataset features
603
+ const v3IndexToFeatureMap: Record<string, string> = {};
604
+
605
+ // Build mapping based on what features actually exist in the dataset
606
+ const featureKeys = Object.keys(info.features);
607
+
608
+ // Common feature order for v3.0 datasets (but only include if they exist)
609
+ const expectedFeatureOrder = [
610
+ 'observation.state',
611
+ 'action',
612
+ 'timestamp',
613
+ 'episode_index',
614
+ 'frame_index',
615
+ 'next.reward',
616
+ 'next.done',
617
+ 'index',
618
+ 'task_index'
619
+ ];
620
+
621
+ // Map indices to features that actually exist
622
+ let currentIndex = 0;
623
+ expectedFeatureOrder.forEach(feature => {
624
+ if (featureKeys.includes(feature)) {
625
+ v3IndexToFeatureMap[currentIndex.toString()] = feature;
626
+ currentIndex++;
627
+ }
628
+ });
629
+
630
+ // Columns to exclude from charts (note: 'task' is intentionally not excluded as we want to access it)
631
+ const excludedColumns = ['index', 'task_index', 'episode_index', 'frame_index', 'next.done'];
632
+
633
+ // Create columns structure similar to V2.1 for proper hierarchical naming
634
+ const columns = Object.entries(info.features)
635
+ .filter(([key, value]) =>
636
+ ["float32", "int32"].includes(value.dtype) &&
637
+ value.shape.length === 1 &&
638
+ !excludedColumns.includes(key)
639
+ )
640
+ .map(([key, feature]) => {
641
+ let column_names = feature.names;
642
+ while (typeof column_names === "object") {
643
+ if (Array.isArray(column_names)) break;
644
+ column_names = Object.values(column_names ?? {})[0];
645
+ }
646
+ return {
647
+ key,
648
+ value: Array.isArray(column_names)
649
+ ? column_names.map((name) => `${key}${SERIES_NAME_DELIMITER}${name}`)
650
+ : Array.from(
651
+ { length: feature.shape[0] || 1 },
652
+ (_, i) => `${key}${SERIES_NAME_DELIMITER}${i}`,
653
+ ),
654
+ };
655
+ });
656
+
657
+ // First, extract all series from the first data row to understand the structure
658
+ if (episodeData.length > 0) {
659
+ const firstRow = episodeData[0];
660
+ const allKeys: string[] = [];
661
+
662
+ Object.entries(firstRow || {}).forEach(([key, value]) => {
663
+ if (key === 'timestamp') return; // Skip timestamp, we'll add it separately
664
+
665
+ // Map numeric key to feature name if available
666
+ const featureName = v3IndexToFeatureMap[key] || key;
667
+
668
+ // Skip if feature doesn't exist in dataset
669
+ if (!info.features[featureName]) return;
670
+
671
+ // Skip excluded columns
672
+ if (excludedColumns.includes(featureName)) return;
673
+
674
+ // Find the matching column definition to get proper names
675
+ const columnDef = columns.find(col => col.key === featureName);
676
+ if (columnDef && Array.isArray(value) && value.length > 0) {
677
+ // Use the proper hierarchical naming from column definition
678
+ columnDef.value.forEach((seriesName, idx) => {
679
+ if (idx < value.length) {
680
+ allKeys.push(seriesName);
681
+ }
682
+ });
683
+ } else if (typeof value === 'number' && !isNaN(value)) {
684
+ // For scalar numeric values
685
+ allKeys.push(featureName);
686
+ } else if (typeof value === 'bigint') {
687
+ // For BigInt values
688
+ allKeys.push(featureName);
689
+ }
690
+ });
691
+
692
+ seriesNames = ["timestamp", ...allKeys];
693
+ } else {
694
+ // Fallback to column-based approach like V2.1
695
+ seriesNames = [
696
+ "timestamp",
697
+ ...columns.map(({ value }) => value).flat(),
698
+ ];
699
+ }
700
+
701
+ const chartData = episodeData.map((row, index) => {
702
+ const obj: Record<string, number> = {};
703
+
704
+ // Add timestamp aligned with video timing
705
+ // For v3.0, we need to map the episode data index to the actual video duration
706
+ let videoDuration = episodeData.length; // Fallback to data length
707
+ if (episodeMetadata) {
708
+ // Use actual video segment duration if available
709
+ videoDuration = (episodeMetadata.video_to_timestamp || 30) - (episodeMetadata.video_from_timestamp || 0);
710
+ }
711
+ obj["timestamp"] = (index / Math.max(episodeData.length - 1, 1)) * videoDuration;
712
+
713
+ // Add all data columns using hierarchical naming
714
+ if (row && typeof row === 'object') {
715
+ Object.entries(row).forEach(([key, value]) => {
716
+ if (key === 'timestamp') {
717
+ // Timestamp is already handled above
718
+ return;
719
+ }
720
+
721
+ // Map numeric key to feature name if available
722
+ const featureName = v3IndexToFeatureMap[key] || key;
723
+
724
+ // Skip if feature doesn't exist in dataset
725
+ if (!info.features[featureName]) return;
726
+
727
+ // Skip excluded columns
728
+ if (excludedColumns.includes(featureName)) return;
729
+
730
+ // Find the matching column definition to get proper series names
731
+ const columnDef = columns.find(col => col.key === featureName);
732
+
733
+ if (Array.isArray(value) && columnDef) {
734
+ // For array values like observation.state and action, use proper hierarchical naming
735
+ value.forEach((val, idx) => {
736
+ if (idx < columnDef.value.length) {
737
+ const seriesName = columnDef.value[idx];
738
+ obj[seriesName] = typeof val === 'number' ? val : Number(val);
739
+ }
740
+ });
741
+ } else if (typeof value === 'number' && !isNaN(value)) {
742
+ obj[featureName] = value;
743
+ } else if (typeof value === 'bigint') {
744
+ obj[featureName] = Number(value);
745
+ } else if (typeof value === 'boolean') {
746
+ // Convert boolean to number for charts
747
+ obj[featureName] = value ? 1 : 0;
748
+ }
749
+ });
750
+ }
751
+
752
+ return obj;
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) && value.shape.length > 2, // Only ignore 3D+ data
761
+ )
762
+ .map(([key]) => key),
763
+ ...excludedColumns // Also include the manually excluded columns
764
+ ];
765
+
766
+ // Group processing logic (using SERIES_NAME_DELIMITER like v2.1)
767
+ const numericKeys = seriesNames.filter((k) => k !== "timestamp");
768
+ const suffixGroupsMap: Record<string, string[]> = {};
769
+
770
+ for (const key of numericKeys) {
771
+ const parts = key.split(SERIES_NAME_DELIMITER);
772
+ const suffix = parts[1] || parts[0]; // fallback to key if no delimiter
773
+ if (!suffixGroupsMap[suffix]) suffixGroupsMap[suffix] = [];
774
+ suffixGroupsMap[suffix].push(key);
775
+ }
776
+ const suffixGroups = Object.values(suffixGroupsMap);
777
+
778
+
779
+ // Compute min/max for each suffix group
780
+ const groupStats: Record<string, { min: number; max: number }> = {};
781
+ suffixGroups.forEach((group) => {
782
+ let min = Infinity, max = -Infinity;
783
+ for (const row of chartData) {
784
+ for (const key of group) {
785
+ const v = row[key];
786
+ if (typeof v === "number" && !isNaN(v)) {
787
+ if (v < min) min = v;
788
+ if (v > max) max = v;
789
+ }
790
+ }
791
+ }
792
+ groupStats[group[0]] = { min, max };
793
+ });
794
+
795
+ // Group by similar scale
796
+ const scaleGroups: Record<string, string[][]> = {};
797
+ const used = new Set<string>();
798
+ const SCALE_THRESHOLD = 2;
799
+ for (const group of suffixGroups) {
800
+ const groupId = group[0];
801
+ if (used.has(groupId)) continue;
802
+ const { min, max } = groupStats[groupId];
803
+ if (!isFinite(min) || !isFinite(max)) continue;
804
+ const logMin = Math.log10(Math.abs(min) + 1e-9);
805
+ const logMax = Math.log10(Math.abs(max) + 1e-9);
806
+ const unit: string[][] = [group];
807
+ used.add(groupId);
808
+ for (const other of suffixGroups) {
809
+ const otherId = other[0];
810
+ if (used.has(otherId) || otherId === groupId) continue;
811
+ const { min: omin, max: omax } = groupStats[otherId];
812
+ if (!isFinite(omin) || !isFinite(omax) || omin === omax) continue;
813
+ const ologMin = Math.log10(Math.abs(omin) + 1e-9);
814
+ const ologMax = Math.log10(Math.abs(omax) + 1e-9);
815
+ if (
816
+ Math.abs(logMin - ologMin) <= SCALE_THRESHOLD &&
817
+ Math.abs(logMax - ologMax) <= SCALE_THRESHOLD
818
+ ) {
819
+ unit.push(other);
820
+ used.add(otherId);
821
+ }
822
+ }
823
+ scaleGroups[groupId] = unit;
824
+ }
825
+
826
+ // Flatten into chartGroups
827
+ const chartGroups: string[][] = Object.values(scaleGroups)
828
+ .sort((a, b) => b.length - a.length)
829
+ .flatMap((suffixGroupArr) => {
830
+ const merged = suffixGroupArr.flat();
831
+ if (merged.length > 6) {
832
+ const subgroups = [];
833
+ for (let i = 0; i < merged.length; i += 6) {
834
+ subgroups.push(merged.slice(i, i + 6));
835
+ }
836
+ return subgroups;
837
+ }
838
+ return [merged];
839
+ });
840
+
841
+ // Utility function to group row keys by suffix (same as V2.1)
842
+ function groupRowBySuffix(row: Record<string, number>): Record<string, any> {
843
+ const result: Record<string, any> = {};
844
+ const suffixGroups: Record<string, Record<string, number>> = {};
845
+ for (const [key, value] of Object.entries(row)) {
846
+ if (key === "timestamp") {
847
+ result["timestamp"] = value;
848
+ continue;
849
+ }
850
+ const parts = key.split(SERIES_NAME_DELIMITER);
851
+ if (parts.length === 2) {
852
+ const [prefix, suffix] = parts;
853
+ if (!suffixGroups[suffix]) suffixGroups[suffix] = {};
854
+ suffixGroups[suffix][prefix] = value;
855
+ } else {
856
+ result[key] = value;
857
+ }
858
+ }
859
+ for (const [suffix, group] of Object.entries(suffixGroups)) {
860
+ const keys = Object.keys(group);
861
+ if (keys.length === 1) {
862
+ // Use the full original name as the key
863
+ const fullName = `${keys[0]}${SERIES_NAME_DELIMITER}${suffix}`;
864
+ result[fullName] = group[keys[0]];
865
+ } else {
866
+ result[suffix] = group;
867
+ }
868
+ }
869
+ return result;
870
+ }
871
+
872
+ const chartDataGroups = chartGroups.map((group) =>
873
+ chartData.map((row) => groupRowBySuffix(pick(row, [...group, "timestamp"])))
874
+ );
875
+
876
+
877
+ return { chartDataGroups, ignoredColumns };
878
+ }
879
+
880
+
881
+ // Video info extraction with segmentation for v3.0
882
+ function extractVideoInfoV3WithSegmentation(
883
+ repoId: string,
884
+ version: string,
885
+ info: DatasetMetadata,
886
+ episodeMetadata: any,
887
+ ): any[] {
888
+ // Get video features from dataset info
889
+ const videoFeatures = Object.entries(info.features)
890
+ .filter(([, value]) => value.dtype === "video");
891
+
892
+ const videosInfo = videoFeatures.map(([videoKey]) => {
893
+ // Check if we have per-camera metadata in the episode row
894
+ const cameraSpecificKeys = Object.keys(episodeMetadata).filter(key =>
895
+ key.startsWith(`videos/${videoKey}/`)
896
+ );
897
+
898
+ let chunkIndex, fileIndex, segmentStart, segmentEnd;
899
+
900
+ if (cameraSpecificKeys.length > 0) {
901
+ // Use camera-specific metadata
902
+ const chunkValue = episodeMetadata[`videos/${videoKey}/chunk_index`];
903
+ const fileValue = episodeMetadata[`videos/${videoKey}/file_index`];
904
+ chunkIndex = typeof chunkValue === 'bigint' ? Number(chunkValue) : (chunkValue || 0);
905
+ fileIndex = typeof fileValue === 'bigint' ? Number(fileValue) : (fileValue || 0);
906
+ segmentStart = episodeMetadata[`videos/${videoKey}/from_timestamp`] || 0;
907
+ segmentEnd = episodeMetadata[`videos/${videoKey}/to_timestamp`] || 30;
908
+ } else {
909
+ // Fallback to generic video metadata
910
+ chunkIndex = episodeMetadata.video_chunk_index || 0;
911
+ fileIndex = episodeMetadata.video_file_index || 0;
912
+ segmentStart = episodeMetadata.video_from_timestamp || 0;
913
+ segmentEnd = episodeMetadata.video_to_timestamp || 30;
914
+ }
915
+
916
+ const videoPath = `videos/${videoKey}/chunk-${chunkIndex.toString().padStart(3, "0")}/file-${fileIndex.toString().padStart(3, "0")}.mp4`;
917
+ const fullUrl = buildVersionedUrl(repoId, version, videoPath);
918
+
919
+ return {
920
+ filename: videoKey,
921
+ url: fullUrl,
922
+ // Enable segmentation with timestamps from metadata
923
+ isSegmented: true,
924
+ segmentStart: segmentStart,
925
+ segmentEnd: segmentEnd,
926
+ segmentDuration: segmentEnd - segmentStart,
927
+ };
928
+ });
929
+
930
+ return videosInfo;
931
+ }
932
+
933
+ // Metadata loading for v3.0 episodes
934
+ async function loadEpisodeMetadataV3Simple(
935
+ repoId: string,
936
+ version: string,
937
+ episodeId: number,
938
+ ): Promise<any> {
939
+ // Pattern: meta/episodes/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet
940
+ // Most datasets have all episodes in chunk-000/file-000, but episodes can be split across files
941
+
942
+ let episodeRow = null;
943
+ let fileIndex = 0;
944
+ const chunkIndex = 0; // Episodes are typically in chunk-000
945
+
946
+ // Try loading episode metadata files until we find the episode
947
+ while (!episodeRow) {
948
+ const episodesMetadataPath = `meta/episodes/chunk-${chunkIndex.toString().padStart(3, "0")}/file-${fileIndex.toString().padStart(3, "0")}.parquet`;
949
+ const episodesMetadataUrl = buildVersionedUrl(repoId, version, episodesMetadataPath);
950
+
951
+ try {
952
+ const arrayBuffer = await fetchParquetFile(episodesMetadataUrl);
953
+ const episodesData = await readParquetAsObjects(arrayBuffer, []);
954
+
955
+ if (episodesData.length === 0) {
956
+ // Empty file, try next one
957
+ fileIndex++;
958
+ continue;
959
+ }
960
+
961
+ // Find the row for the requested episode by episode_index
962
+ for (const row of episodesData) {
963
+ const parsedRow = parseEpisodeRowSimple(row);
964
+
965
+ if (parsedRow.episode_index === episodeId) {
966
+ episodeRow = row;
967
+ break;
968
+ }
969
+ }
970
+
971
+ if (!episodeRow) {
972
+ // Not in this file, try the next one
973
+ fileIndex++;
974
+ }
975
+ } catch (error) {
976
+ // File doesn't exist - episode not found
977
+ throw new Error(`Episode ${episodeId} not found in metadata (searched up to file-${fileIndex.toString().padStart(3, "0")}.parquet)`);
978
+ }
979
+ }
980
+
981
+ // Convert the row to a usable format
982
+ return parseEpisodeRowSimple(episodeRow);
983
+ }
984
+
985
+ // Simple parser for episode row - focuses on key fields for episodes
986
+ function parseEpisodeRowSimple(row: any): any {
987
+ // v3.0 uses named keys in the episode metadata
988
+ if (row && typeof row === 'object') {
989
+ // Check if this is v3.0 format with named keys
990
+ if ('episode_index' in row) {
991
+ // v3.0 format - use named keys
992
+ // Convert BigInt values to numbers
993
+ const toBigIntSafe = (value: any) => {
994
+ if (typeof value === 'bigint') return Number(value);
995
+ if (typeof value === 'number') return value;
996
+ return parseInt(value) || 0;
997
+ };
998
+
999
+ const episodeData: any = {
1000
+ episode_index: toBigIntSafe(row['episode_index']),
1001
+ data_chunk_index: toBigIntSafe(row['data/chunk_index']),
1002
+ data_file_index: toBigIntSafe(row['data/file_index']),
1003
+ dataset_from_index: toBigIntSafe(row['dataset_from_index']),
1004
+ dataset_to_index: toBigIntSafe(row['dataset_to_index']),
1005
+ length: toBigIntSafe(row['length']),
1006
+ };
1007
+
1008
+ // Handle video metadata - look for video-specific keys
1009
+ const videoKeys = Object.keys(row).filter(key => key.includes('videos/') && key.includes('/chunk_index'));
1010
+ if (videoKeys.length > 0) {
1011
+ // Use the first video stream for basic info
1012
+ const firstVideoKey = videoKeys[0];
1013
+ const videoBaseName = firstVideoKey.replace('/chunk_index', '');
1014
+
1015
+ episodeData.video_chunk_index = toBigIntSafe(row[`${videoBaseName}/chunk_index`]);
1016
+ episodeData.video_file_index = toBigIntSafe(row[`${videoBaseName}/file_index`]);
1017
+ episodeData.video_from_timestamp = row[`${videoBaseName}/from_timestamp`] || 0;
1018
+ episodeData.video_to_timestamp = row[`${videoBaseName}/to_timestamp`] || 0;
1019
+ } else {
1020
+ // Fallback video values
1021
+ episodeData.video_chunk_index = 0;
1022
+ episodeData.video_file_index = 0;
1023
+ episodeData.video_from_timestamp = 0;
1024
+ episodeData.video_to_timestamp = 30;
1025
+ }
1026
+
1027
+ // Store the raw row data to preserve per-camera metadata
1028
+ // This allows extractVideoInfoV3WithSegmentation to access camera-specific timestamps
1029
+ Object.keys(row).forEach(key => {
1030
+ if (key.startsWith('videos/')) {
1031
+ episodeData[key] = row[key];
1032
+ }
1033
+ });
1034
+
1035
+ return episodeData;
1036
+ } else {
1037
+ // Fallback to numeric keys for compatibility
1038
+ const episodeData = {
1039
+ episode_index: row['0'] || 0,
1040
+ data_chunk_index: row['1'] || 0,
1041
+ data_file_index: row['2'] || 0,
1042
+ dataset_from_index: row['3'] || 0,
1043
+ dataset_to_index: row['4'] || 0,
1044
+ video_chunk_index: row['5'] || 0,
1045
+ video_file_index: row['6'] || 0,
1046
+ video_from_timestamp: row['7'] || 0,
1047
+ video_to_timestamp: row['8'] || 30,
1048
+ length: row['9'] || 30,
1049
+ };
1050
+
1051
+ return episodeData;
1052
+ }
1053
+ }
1054
+
1055
+ // Fallback if parsing fails
1056
+ const fallback = {
1057
+ episode_index: 0,
1058
+ data_chunk_index: 0,
1059
+ data_file_index: 0,
1060
+ dataset_from_index: 0,
1061
+ dataset_to_index: 0,
1062
+ video_chunk_index: 0,
1063
+ video_file_index: 0,
1064
+ video_from_timestamp: 0,
1065
+ video_to_timestamp: 30,
1066
+ length: 30,
1067
+ };
1068
+
1069
+ return fallback;
1070
+ }
1071
+
1072
+
1073
+
1074
+
1075
+ // Safe wrapper for UI error display
1076
+ export async function getEpisodeDataSafe(
1077
+ org: string,
1078
+ dataset: string,
1079
+ episodeId: number,
1080
+ ): Promise<{ data?: any; error?: string }> {
1081
+ try {
1082
+ const data = await getEpisodeData(org, dataset, episodeId);
1083
+ return { data };
1084
+ } catch (err: any) {
1085
+ // Only expose the error message, not stack or sensitive info
1086
+ return { error: err?.message || String(err) || "Unknown error" };
1087
+ }
1088
+ }
src/app/[org]/[dataset]/[episode]/page.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import EpisodeViewer from "./episode-viewer";
2
+ import { getEpisodeDataSafe } from "./fetch-data";
3
+ import { Suspense } from "react";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export async function generateMetadata({
8
+ params,
9
+ }: {
10
+ params: Promise<{ org: string; dataset: string; episode: string }>;
11
+ }) {
12
+ const { org, dataset, episode } = await params;
13
+ return {
14
+ title: `${org}/${dataset} | episode ${episode}`,
15
+ };
16
+ }
17
+
18
+ export default async function EpisodePage({
19
+ params,
20
+ }: {
21
+ params: Promise<{ org: string; dataset: string; episode: string }>;
22
+ }) {
23
+ // episode is like 'episode_1'
24
+ const { org, dataset, episode } = await params;
25
+ // fetchData should be updated if needed to support this path pattern
26
+ const episodeNumber = Number(episode.replace(/^episode_/, ""));
27
+ const { data, error } = await getEpisodeDataSafe(org, dataset, episodeNumber);
28
+ return (
29
+ <Suspense fallback={null}>
30
+ <EpisodeViewer data={data} error={error} />
31
+ </Suspense>
32
+ );
33
+ }
src/app/[org]/[dataset]/page.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { redirect } from "next/navigation";
2
+
3
+ export default async function DatasetRootPage({
4
+ params,
5
+ }: {
6
+ params: Promise<{ org: string; dataset: string }>;
7
+ }) {
8
+ const { org, dataset } = await params;
9
+ const episodeN = process.env.EPISODES
10
+ ?.split(/\s+/)
11
+ .map((x) => parseInt(x.trim(), 10))
12
+ .filter((x) => !isNaN(x))[0] ?? 0;
13
+
14
+ redirect(`/${org}/${dataset}/episode_${episodeN}`);
15
+ }
src/app/explore/explore-grid.tsx ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useEffect, useRef } from "react";
4
+ import Link from "next/link";
5
+
6
+ import { useRouter, useSearchParams } from "next/navigation";
7
+ import { postParentMessageWithParams } from "@/utils/postParentMessage";
8
+
9
+ type ExploreGridProps = {
10
+ datasets: Array<{ id: string; videoUrl: string | null }>;
11
+ currentPage: number;
12
+ totalPages: number;
13
+ };
14
+
15
+ export default function ExploreGrid({
16
+ datasets,
17
+ currentPage,
18
+ totalPages,
19
+ }: ExploreGridProps) {
20
+ // sync with parent window hf.co/spaces
21
+ useEffect(() => {
22
+ postParentMessageWithParams((params: URLSearchParams) => {
23
+ params.set("path", window.location.pathname + window.location.search);
24
+ });
25
+ }, []);
26
+
27
+ // Create an array of refs for each video
28
+ const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
29
+
30
+ return (
31
+ <main className="p-8">
32
+ <h1 className="text-2xl font-bold mb-6">Explore LeRobot Datasets</h1>
33
+ <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
34
+ {datasets.map((ds, idx) => (
35
+ <Link
36
+ key={ds.id}
37
+ href={`/${ds.id}`}
38
+ className="relative border rounded-lg p-4 bg-white shadow hover:shadow-lg transition overflow-hidden h-48 flex items-end group"
39
+ onMouseEnter={() => {
40
+ const vid = videoRefs.current[idx];
41
+ if (vid) vid.play();
42
+ }}
43
+ onMouseLeave={() => {
44
+ const vid = videoRefs.current[idx];
45
+ if (vid) {
46
+ vid.pause();
47
+ vid.currentTime = 0;
48
+ }
49
+ }}
50
+ >
51
+ <video
52
+ ref={(el) => {
53
+ videoRefs.current[idx] = el;
54
+ }}
55
+ src={ds.videoUrl || undefined}
56
+ className="absolute top-0 left-0 w-full h-full object-cover object-center z-0"
57
+ loop
58
+ muted
59
+ playsInline
60
+ preload="metadata"
61
+ onTimeUpdate={(e) => {
62
+ const vid = e.currentTarget;
63
+ if (vid.currentTime >= 15) {
64
+ vid.pause();
65
+ vid.currentTime = 0;
66
+ }
67
+ }}
68
+ />
69
+ <div className="absolute top-0 left-0 w-full h-full bg-black/40 z-10 pointer-events-none" />
70
+ <div className="relative z-20 font-mono text-blue-100 break-all text-sm bg-black/60 backdrop-blur px-2 py-1 rounded shadow">
71
+ {ds.id}
72
+ </div>
73
+ </Link>
74
+ ))}
75
+ </div>
76
+ <div className="flex justify-center mt-8 gap-4">
77
+ {currentPage > 1 && (
78
+ <button
79
+ className="px-6 py-2 bg-gray-600 text-white rounded shadow hover:bg-gray-700 transition"
80
+ onClick={() => {
81
+ const params = new URLSearchParams(window.location.search);
82
+ params.set("p", (currentPage - 1).toString());
83
+ window.location.search = params.toString();
84
+ }}
85
+ >
86
+ Previous
87
+ </button>
88
+ )}
89
+ {currentPage < totalPages && (
90
+ <button
91
+ className="px-6 py-2 bg-blue-600 text-white rounded shadow hover:bg-blue-700 transition"
92
+ onClick={() => {
93
+ const params = new URLSearchParams(window.location.search);
94
+ params.set("p", (currentPage + 1).toString());
95
+ window.location.search = params.toString();
96
+ }}
97
+ >
98
+ Next
99
+ </button>
100
+ )}
101
+ </div>
102
+ </main>
103
+ );
104
+ }
src/app/explore/page.tsx ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import ExploreGrid from "./explore-grid";
3
+ import {
4
+ DatasetMetadata,
5
+ fetchJson,
6
+ formatStringWithVars,
7
+ } from "@/utils/parquetUtils";
8
+ import { getDatasetVersion, buildVersionedUrl } from "@/utils/versionUtils";
9
+
10
+ export default async function ExplorePage({
11
+ searchParams,
12
+ }: {
13
+ searchParams: { p?: string };
14
+ }) {
15
+ let datasets: any[] = [];
16
+ let currentPage = 1;
17
+ let totalPages = 1;
18
+ try {
19
+ const res = await fetch(
20
+ "https://huggingface.co/api/datasets?sort=lastModified&filter=LeRobot",
21
+ {
22
+ cache: "no-store",
23
+ },
24
+ );
25
+ if (!res.ok) throw new Error("Failed to fetch datasets");
26
+ const data = await res.json();
27
+ const allDatasets = data.datasets || data;
28
+ // Use searchParams from props
29
+ const page = parseInt(searchParams?.p || "1", 10);
30
+ const perPage = 30;
31
+
32
+ currentPage = page;
33
+ totalPages = Math.ceil(allDatasets.length / perPage);
34
+
35
+ const startIdx = (currentPage - 1) * perPage;
36
+ const endIdx = startIdx + perPage;
37
+ datasets = allDatasets.slice(startIdx, endIdx);
38
+ } catch {
39
+ return <div className="p-8 text-red-600">Failed to load datasets.</div>;
40
+ }
41
+
42
+ // Fetch episode 0 data for each dataset
43
+ const datasetWithVideos = (
44
+ await Promise.all(
45
+ datasets.map(async (ds: any) => {
46
+ try {
47
+ const [org, dataset] = ds.id.split("/");
48
+ const repoId = `${org}/${dataset}`;
49
+
50
+ // Try to get compatible version, but don't fail the entire page if incompatible
51
+ let version: string;
52
+ try {
53
+ version = await getDatasetVersion(repoId);
54
+ } catch (err) {
55
+ // Dataset is not compatible, skip it silently
56
+ console.warn(`Skipping incompatible dataset ${repoId}: ${err instanceof Error ? err.message : err}`);
57
+ return null;
58
+ }
59
+
60
+ const jsonUrl = buildVersionedUrl(repoId, version, "meta/info.json");
61
+ const info = await fetchJson<DatasetMetadata>(jsonUrl);
62
+ const videoEntry = Object.entries(info.features).find(
63
+ ([, value]) => value.dtype === "video",
64
+ );
65
+ let videoUrl: string | null = null;
66
+ if (videoEntry) {
67
+ const [key] = videoEntry;
68
+ const videoPath = formatStringWithVars(info.video_path, {
69
+ video_key: key,
70
+ episode_chunk: "0".padStart(3, "0"),
71
+ episode_index: "0".padStart(6, "0"),
72
+ });
73
+ const url = buildVersionedUrl(repoId, version, videoPath);
74
+ // Check if videoUrl exists (status 200)
75
+ try {
76
+ const headRes = await fetch(url, { method: "HEAD" });
77
+ if (headRes.ok) {
78
+ videoUrl = url;
79
+ }
80
+ } catch {
81
+ // If fetch fails, videoUrl remains null
82
+ }
83
+ }
84
+ return videoUrl ? { id: repoId, videoUrl } : null;
85
+ } catch (err) {
86
+ console.error(
87
+ `Failed to fetch or parse dataset info for ${ds.id}:`,
88
+ err,
89
+ );
90
+ return null;
91
+ }
92
+ }),
93
+ )
94
+ ).filter(Boolean) as { id: string; videoUrl: string | null }[];
95
+
96
+ return (
97
+ <ExploreGrid
98
+ datasets={datasetWithVideos}
99
+ currentPage={currentPage}
100
+ totalPages={totalPages}
101
+ />
102
+ );
103
+ }
src/app/favicon.ico ADDED
src/app/globals.css ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ :root {
4
+ --background: #ffffff;
5
+ --foreground: #171717;
6
+ }
7
+
8
+ @theme inline {
9
+ --color-background: var(--background);
10
+ --color-foreground: var(--foreground);
11
+ --font-sans: var(--font-geist-sans);
12
+ --font-mono: var(--font-geist-mono);
13
+ }
14
+
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --background: #0a0a0a;
18
+ --foreground: #ededed;
19
+ }
20
+ }
21
+
22
+ body {
23
+ background: var(--background);
24
+ color: var(--foreground);
25
+ font-family: Arial, Helvetica, sans-serif;
26
+ }
27
+
28
+ .video-background {
29
+ @apply fixed top-0 right-0 bottom-0 left-0 -z-10 overflow-hidden w-screen h-screen;
30
+ }
31
+ .video-background iframe {
32
+ @apply absolute top-1/2 left-1/2 border-0 pointer-events-none bg-black;
33
+ width: 100vw;
34
+ height: 100vh;
35
+ transform: translate(-50%, -50%);
36
+ }
37
+ @media (min-aspect-ratio: 16/9) {
38
+ .video-background iframe {
39
+ height: 56.25vw;
40
+ }
41
+ }
42
+ @media (max-aspect-ratio: 16/9) {
43
+ .video-background iframe {
44
+ width: 177.78vh;
45
+ }
46
+ }
src/app/home-client.tsx ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useRef, Suspense } from "react";
3
+ import Link from "next/link";
4
+ import { useRouter, useSearchParams } from "next/navigation";
5
+
6
+ export default function HomeClient() {
7
+ return (
8
+ <Suspense fallback={null}>
9
+ <HomeInner />
10
+ </Suspense>
11
+ );
12
+ }
13
+
14
+ function HomeInner() {
15
+ const searchParams = useSearchParams();
16
+ const router = useRouter();
17
+
18
+ useEffect(() => {
19
+ // sync with hf.co/spaces URL params
20
+ if (searchParams.get("path")) {
21
+ router.push(searchParams.get("path")!);
22
+ return;
23
+ }
24
+
25
+ // legacy sync with hf.co/spaces URL params
26
+ let redirectUrl: string | null = null;
27
+ if (searchParams.get("dataset") && searchParams.get("episode")) {
28
+ redirectUrl = `/${searchParams.get("dataset")}/episode_${searchParams.get(
29
+ "episode",
30
+ )}`;
31
+ } else if (searchParams.get("dataset")) {
32
+ redirectUrl = `/${searchParams.get("dataset")}`;
33
+ }
34
+
35
+ if (redirectUrl && searchParams.get("t")) {
36
+ redirectUrl += `?t=${searchParams.get("t")}`;
37
+ }
38
+
39
+ if (redirectUrl) {
40
+ router.push(redirectUrl);
41
+ }
42
+ }, [searchParams, router]);
43
+
44
+ const playerRef = useRef<any>(null);
45
+
46
+ useEffect(() => {
47
+ // Load YouTube IFrame API if not already present
48
+ if (!(window as any).YT) {
49
+ const tag = document.createElement("script");
50
+ tag.src = "https://www.youtube.com/iframe_api";
51
+ document.body.appendChild(tag);
52
+ }
53
+ let interval: NodeJS.Timeout;
54
+ (window as any).onYouTubeIframeAPIReady = () => {
55
+ playerRef.current = new (window as any).YT.Player("yt-bg-player", {
56
+ videoId: "Er8SPJsIYr0",
57
+ playerVars: {
58
+ autoplay: 1,
59
+ mute: 1,
60
+ controls: 0,
61
+ showinfo: 0,
62
+ modestbranding: 1,
63
+ rel: 0,
64
+ loop: 1,
65
+ fs: 0,
66
+ playlist: "Er8SPJsIYr0",
67
+ start: 0,
68
+ },
69
+ events: {
70
+ onReady: (event: any) => {
71
+ event.target.playVideo();
72
+ event.target.mute();
73
+ interval = setInterval(() => {
74
+ const t = event.target.getCurrentTime();
75
+ if (t >= 60) {
76
+ event.target.seekTo(0);
77
+ }
78
+ }, 500);
79
+ },
80
+ },
81
+ });
82
+ };
83
+ return () => {
84
+ if (interval) clearInterval(interval);
85
+ if (playerRef.current && playerRef.current.destroy)
86
+ playerRef.current.destroy();
87
+ };
88
+ }, []);
89
+
90
+ const inputRef = useRef<HTMLInputElement>(null);
91
+
92
+ const handleGo = (e: React.FormEvent) => {
93
+ e.preventDefault();
94
+ const value = inputRef.current?.value.trim();
95
+ if (value) {
96
+ router.push(value);
97
+ }
98
+ };
99
+
100
+ return (
101
+ <div className="relative h-screen w-screen overflow-hidden">
102
+ {/* YouTube Video Background */}
103
+ <div className="video-background">
104
+ <div id="yt-bg-player" />
105
+ </div>
106
+ {/* Overlay */}
107
+ <div className="fixed top-0 right-0 bottom-0 left-0 bg-black/60 -z-0" />
108
+ {/* Centered Content */}
109
+ <div className="relative z-10 h-screen flex flex-col items-center justify-center text-white text-center">
110
+ <h1 className="text-4xl md:text-5xl font-bold mb-6 drop-shadow-lg">
111
+ LeRobot Dataset Visualizer
112
+ </h1>
113
+ <a
114
+ href="https://x.com/RemiCadene/status/1825455895561859185"
115
+ target="_blank"
116
+ rel="noopener noreferrer"
117
+ className="text-sky-400 font-medium text-lg underline mb-8 inline-block hover:text-sky-300 transition-colors"
118
+ >
119
+ create & train your own robots
120
+ </a>
121
+ <form onSubmit={handleGo} className="flex gap-2 justify-center mt-6">
122
+ <input
123
+ ref={inputRef}
124
+ type="text"
125
+ placeholder="Enter dataset id (e.g. lerobot/visualize_dataset)"
126
+ className="px-4 py-2 rounded-md text-base text-white border-white border-1 focus:outline-none min-w-[220px] shadow-md"
127
+ onKeyDown={(e) => {
128
+ if (e.key === "Enter") {
129
+ // Prevent double submission if form onSubmit also fires
130
+ e.preventDefault();
131
+ handleGo(e as any);
132
+ }
133
+ }}
134
+ />
135
+ <button
136
+ type="submit"
137
+ className="px-5 py-2 rounded-md bg-sky-400 text-black font-semibold text-base hover:bg-sky-300 transition-colors shadow-md"
138
+ >
139
+ Go
140
+ </button>
141
+ </form>
142
+ {/* Example Datasets */}
143
+ <div className="mt-8">
144
+ <div className="font-semibold mb-2 text-lg">Example Datasets:</div>
145
+ <div className="flex flex-col gap-2 items-center">
146
+ {[
147
+ "lerobot/aloha_static_cups_open",
148
+ "lerobot/columbia_cairlab_pusht_real",
149
+ "lerobot/taco_play",
150
+ ].map((ds) => (
151
+ <button
152
+ key={ds}
153
+ type="button"
154
+ className="px-4 py-2 rounded bg-slate-700 text-sky-200 hover:bg-sky-700 hover:text-white transition-colors shadow"
155
+ onClick={() => {
156
+ if (inputRef.current) {
157
+ inputRef.current.value = ds;
158
+ inputRef.current.focus();
159
+ }
160
+ router.push(ds);
161
+ }}
162
+ >
163
+ {ds}
164
+ </button>
165
+ ))}
166
+ </div>
167
+ </div>
168
+
169
+ <Link
170
+ href="/explore"
171
+ className="inline-block px-6 py-3 mt-8 rounded-md bg-sky-500 text-white font-semibold text-lg shadow-lg hover:bg-sky-400 transition-colors"
172
+ >
173
+ Explore Open Datasets
174
+ </Link>
175
+ </div>
176
+ </div>
177
+ );
178
+ }
179
+
src/app/layout.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const inter = Inter({ subsets: ["latin"] });
6
+
7
+ export const metadata: Metadata = {
8
+ title: "LeRobot Dataset Visualizer",
9
+ description: "Visualization of LeRobot Datasets",
10
+ };
11
+
12
+ export default function RootLayout({
13
+ children,
14
+ }: {
15
+ children: React.ReactNode;
16
+ }) {
17
+ return (
18
+ <html lang="en">
19
+ <body className={inter.className}>{children}</body>
20
+ </html>
21
+ );
22
+ }
src/app/page.tsx ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useRef, Suspense } from "react";
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useSearchParams } from "next/navigation";
6
+
7
+ export default function Home() {
8
+ return (
9
+ <Suspense fallback={null}>
10
+ <HomeInner />
11
+ </Suspense>
12
+ );
13
+ }
14
+
15
+ function HomeInner() {
16
+ const searchParams = useSearchParams();
17
+ const router = useRouter();
18
+
19
+ // Handle redirects with useEffect instead of direct redirect
20
+ useEffect(() => {
21
+ // Redirect to our dataset directly
22
+ const REPO_ID = "raffaelkultyshev/mini_tug_tape_to_bowl";
23
+ router.push(`/${REPO_ID}/episode_0`);
24
+
25
+ // legacy sync with hf.co/spaces URL params
26
+ let redirectUrl: string | null = null;
27
+ if (searchParams.get('dataset') && searchParams.get('episode')) {
28
+ redirectUrl = `/${searchParams.get('dataset')}/episode_${searchParams.get('episode')}`;
29
+ } else if (searchParams.get('dataset')) {
30
+ redirectUrl = `/${searchParams.get('dataset')}`;
31
+ }
32
+
33
+ if (redirectUrl && searchParams.get('t')) {
34
+ redirectUrl += `?t=${searchParams.get('t')}`;
35
+ }
36
+
37
+ if (redirectUrl) {
38
+ router.push(redirectUrl);
39
+ return;
40
+ }
41
+ }, [searchParams, router]);
42
+
43
+ const playerRef = useRef<any>(null);
44
+
45
+ useEffect(() => {
46
+ // Load YouTube IFrame API if not already present
47
+ if (!(window as any).YT) {
48
+ const tag = document.createElement("script");
49
+ tag.src = "https://www.youtube.com/iframe_api";
50
+ document.body.appendChild(tag);
51
+ }
52
+ let interval: NodeJS.Timeout;
53
+ (window as any).onYouTubeIframeAPIReady = () => {
54
+ playerRef.current = new (window as any).YT.Player("yt-bg-player", {
55
+ videoId: "Er8SPJsIYr0",
56
+ playerVars: {
57
+ autoplay: 1,
58
+ mute: 1,
59
+ controls: 0,
60
+ showinfo: 0,
61
+ modestbranding: 1,
62
+ rel: 0,
63
+ loop: 1,
64
+ fs: 0,
65
+ playlist: "Er8SPJsIYr0",
66
+ start: 0,
67
+ },
68
+ events: {
69
+ onReady: (event: any) => {
70
+ event.target.playVideo();
71
+ event.target.mute();
72
+ interval = setInterval(() => {
73
+ const t = event.target.getCurrentTime();
74
+ if (t >= 60) {
75
+ event.target.seekTo(0);
76
+ }
77
+ }, 500);
78
+ },
79
+ },
80
+ });
81
+ };
82
+ return () => {
83
+ if (interval) clearInterval(interval);
84
+ if (playerRef.current && playerRef.current.destroy)
85
+ playerRef.current.destroy();
86
+ };
87
+ }, []);
88
+
89
+ const inputRef = useRef<HTMLInputElement>(null);
90
+
91
+ const handleGo = (e: React.FormEvent) => {
92
+ e.preventDefault();
93
+ const value = inputRef.current?.value.trim();
94
+ if (value) {
95
+ router.push(value);
96
+ }
97
+ };
98
+
99
+ return (
100
+ <div className="relative h-screen w-screen overflow-hidden">
101
+ {/* YouTube Video Background */}
102
+ <div className="video-background">
103
+ <div id="yt-bg-player" />
104
+ </div>
105
+ {/* Overlay */}
106
+ <div className="fixed top-0 right-0 bottom-0 left-0 bg-black/60 -z-0" />
107
+ {/* Centered Content */}
108
+ <div className="relative z-10 h-screen flex flex-col items-center justify-center text-white text-center">
109
+ <h1 className="text-4xl md:text-5xl font-bold mb-6 drop-shadow-lg">
110
+ LeRobot Dataset Visualizer
111
+ </h1>
112
+ <a
113
+ href="https://x.com/RemiCadene/status/1825455895561859185"
114
+ target="_blank"
115
+ rel="noopener noreferrer"
116
+ className="text-sky-400 font-medium text-lg underline mb-8 inline-block hover:text-sky-300 transition-colors"
117
+ >
118
+ create & train your own robots
119
+ </a>
120
+ <form onSubmit={handleGo} className="flex gap-2 justify-center mt-6">
121
+ <input
122
+ ref={inputRef}
123
+ type="text"
124
+ placeholder="Enter dataset id (e.g. lerobot/visualize_dataset)"
125
+ className="px-4 py-2 rounded-md text-base text-white border-white border-1 focus:outline-none min-w-[220px] shadow-md"
126
+ onKeyDown={(e) => {
127
+ if (e.key === "Enter") {
128
+ // Prevent double submission if form onSubmit also fires
129
+ e.preventDefault();
130
+ handleGo(e as any);
131
+ }
132
+ }}
133
+ />
134
+ <button
135
+ type="submit"
136
+ className="px-5 py-2 rounded-md bg-sky-400 text-black font-semibold text-base hover:bg-sky-300 transition-colors shadow-md"
137
+ >
138
+ Go
139
+ </button>
140
+ </form>
141
+ {/* Example Datasets */}
142
+ <div className="mt-8">
143
+ <div className="font-semibold mb-2 text-lg">Example Datasets:</div>
144
+ <div className="flex flex-col gap-2 items-center">
145
+ {[
146
+ "lerobot/aloha_static_cups_open",
147
+ "lerobot/columbia_cairlab_pusht_real",
148
+ "lerobot/taco_play",
149
+ ].map((ds) => (
150
+ <button
151
+ key={ds}
152
+ type="button"
153
+ className="px-4 py-2 rounded bg-slate-700 text-sky-200 hover:bg-sky-700 hover:text-white transition-colors shadow"
154
+ onClick={() => {
155
+ if (inputRef.current) {
156
+ inputRef.current.value = ds;
157
+ inputRef.current.focus();
158
+ }
159
+ router.push(ds);
160
+ }}
161
+ >
162
+ {ds}
163
+ </button>
164
+ ))}
165
+ </div>
166
+ </div>
167
+
168
+ <Link
169
+ href="/explore"
170
+ className="inline-block px-6 py-3 mt-8 rounded-md bg-sky-500 text-white font-semibold text-lg shadow-lg hover:bg-sky-400 transition-colors"
171
+ >
172
+ Explore Open Datasets
173
+ </Link>
174
+ </div>
175
+ </div>
176
+ );
177
+ }
src/components/data-recharts.tsx ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useTime } from "../context/time-context";
5
+ import {
6
+ LineChart,
7
+ Line,
8
+ XAxis,
9
+ YAxis,
10
+ CartesianGrid,
11
+ ResponsiveContainer,
12
+ Tooltip,
13
+ } from "recharts";
14
+
15
+ type DataGraphProps = {
16
+ data: Array<Array<Record<string, number>>>;
17
+ onChartsReady?: () => void;
18
+ };
19
+
20
+ import React, { useMemo } from "react";
21
+
22
+ // Use the same delimiter as the data processing
23
+ const SERIES_NAME_DELIMITER = " | ";
24
+
25
+ export const DataRecharts = React.memo(
26
+ ({ data, onChartsReady }: DataGraphProps) => {
27
+ // Shared hoveredTime for all graphs
28
+ const [hoveredTime, setHoveredTime] = useState<number | null>(null);
29
+
30
+ if (!Array.isArray(data) || data.length === 0) return null;
31
+
32
+ useEffect(() => {
33
+ if (typeof onChartsReady === "function") {
34
+ onChartsReady();
35
+ }
36
+ }, [onChartsReady]);
37
+
38
+ return (
39
+ <div className="grid md:grid-cols-2 grid-cols-1 gap-4">
40
+ {data.map((group, idx) => (
41
+ <SingleDataGraph
42
+ key={idx}
43
+ data={group}
44
+ hoveredTime={hoveredTime}
45
+ setHoveredTime={setHoveredTime}
46
+ />
47
+ ))}
48
+ </div>
49
+ );
50
+ },
51
+ );
52
+
53
+
54
+ const SingleDataGraph = React.memo(
55
+ ({
56
+ data,
57
+ hoveredTime,
58
+ setHoveredTime,
59
+ }: {
60
+ data: Array<Record<string, number>>;
61
+ hoveredTime: number | null;
62
+ setHoveredTime: (t: number | null) => void;
63
+ }) => {
64
+ const { currentTime, setCurrentTime } = useTime();
65
+ function flattenRow(row: Record<string, any>, prefix = ""): Record<string, number> {
66
+ const result: Record<string, number> = {};
67
+ for (const [key, value] of Object.entries(row)) {
68
+ // Special case: if this is a group value that is a primitive, assign to prefix.key
69
+ if (typeof value === "number") {
70
+ if (prefix) {
71
+ result[`${prefix}${SERIES_NAME_DELIMITER}${key}`] = value;
72
+ } else {
73
+ result[key] = value;
74
+ }
75
+ } else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
76
+ // If it's an object, recurse
77
+ Object.assign(result, flattenRow(value, prefix ? `${prefix}${SERIES_NAME_DELIMITER}${key}` : key));
78
+ }
79
+ }
80
+ // Always keep timestamp at top level if present
81
+ if ("timestamp" in row) {
82
+ result["timestamp"] = row["timestamp"];
83
+ }
84
+ return result;
85
+ }
86
+
87
+ // Flatten all rows for recharts
88
+ const chartData = useMemo(() => data.map(row => flattenRow(row)), [data]);
89
+ const [dataKeys, setDataKeys] = useState<string[]>([]);
90
+ const [visibleKeys, setVisibleKeys] = useState<string[]>([]);
91
+
92
+ useEffect(() => {
93
+ if (!chartData || chartData.length === 0) return;
94
+ // Get all keys except timestamp from the first row
95
+ const keys = Object.keys(chartData[0]).filter((k) => k !== "timestamp");
96
+ setDataKeys(keys);
97
+ setVisibleKeys(keys);
98
+ }, [chartData]);
99
+
100
+ // Parse dataKeys into groups (dot notation)
101
+ const groups: Record<string, string[]> = {};
102
+ const singles: string[] = [];
103
+ dataKeys.forEach((key) => {
104
+ const parts = key.split(SERIES_NAME_DELIMITER);
105
+ if (parts.length > 1) {
106
+ const group = parts[0];
107
+ if (!groups[group]) groups[group] = [];
108
+ groups[group].push(key);
109
+ } else {
110
+ singles.push(key);
111
+ }
112
+ });
113
+
114
+ // Assign a color per group (and for singles)
115
+ const allGroups = [...Object.keys(groups), ...singles];
116
+ const groupColorMap: Record<string, string> = {};
117
+ allGroups.forEach((group, idx) => {
118
+ groupColorMap[group] = `hsl(${idx * (360 / allGroups.length)}, 100%, 50%)`;
119
+ });
120
+
121
+ // Find the closest data point to the current time for highlighting
122
+ const findClosestDataIndex = (time: number) => {
123
+ if (!chartData.length) return 0;
124
+ // Find the index of the first data point whose timestamp is >= time (ceiling)
125
+ const idx = chartData.findIndex((point) => point.timestamp >= time);
126
+ if (idx !== -1) return idx;
127
+ // If all timestamps are less than time, return the last index
128
+ return chartData.length - 1;
129
+ };
130
+
131
+ const handleMouseLeave = () => {
132
+ setHoveredTime(null);
133
+ };
134
+
135
+ const handleClick = (data: any) => {
136
+ if (data && data.activePayload && data.activePayload.length) {
137
+ const timeValue = data.activePayload[0].payload.timestamp;
138
+ setCurrentTime(timeValue);
139
+ }
140
+ };
141
+
142
+ // Custom legend to show current value next to each series
143
+ const CustomLegend = () => {
144
+ const closestIndex = findClosestDataIndex(
145
+ hoveredTime != null ? hoveredTime : currentTime,
146
+ );
147
+ const currentData = chartData[closestIndex] || {};
148
+
149
+ // Parse dataKeys into groups (dot notation)
150
+ const groups: Record<string, string[]> = {};
151
+ const singles: string[] = [];
152
+ dataKeys.forEach((key) => {
153
+ const parts = key.split(SERIES_NAME_DELIMITER);
154
+ if (parts.length > 1) {
155
+ const group = parts[0];
156
+ if (!groups[group]) groups[group] = [];
157
+ groups[group].push(key);
158
+ } else {
159
+ singles.push(key);
160
+ }
161
+ });
162
+
163
+ // Assign a color per group (and for singles)
164
+ const allGroups = [...Object.keys(groups), ...singles];
165
+ const groupColorMap: Record<string, string> = {};
166
+ allGroups.forEach((group, idx) => {
167
+ groupColorMap[group] = `hsl(${idx * (360 / allGroups.length)}, 100%, 50%)`;
168
+ });
169
+
170
+ const isGroupChecked = (group: string) => groups[group].every(k => visibleKeys.includes(k));
171
+ const isGroupIndeterminate = (group: string) => groups[group].some(k => visibleKeys.includes(k)) && !isGroupChecked(group);
172
+
173
+ const handleGroupCheckboxChange = (group: string) => {
174
+ if (isGroupChecked(group)) {
175
+ // Uncheck all children
176
+ setVisibleKeys((prev) => prev.filter(k => !groups[group].includes(k)));
177
+ } else {
178
+ // Check all children
179
+ setVisibleKeys((prev) => Array.from(new Set([...prev, ...groups[group]])));
180
+ }
181
+ };
182
+
183
+ const handleCheckboxChange = (key: string) => {
184
+ setVisibleKeys((prev) =>
185
+ prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
186
+ );
187
+ };
188
+
189
+ return (
190
+ <div className="grid grid-cols-[repeat(auto-fit,250px)] gap-4 mx-8">
191
+ {/* Grouped keys */}
192
+ {Object.entries(groups).map(([group, children]) => {
193
+ const color = groupColorMap[group];
194
+ return (
195
+ <div key={group} className="mb-2">
196
+ <label className="flex gap-2 cursor-pointer select-none font-semibold">
197
+ <input
198
+ type="checkbox"
199
+ checked={isGroupChecked(group)}
200
+ ref={el => { if (el) el.indeterminate = isGroupIndeterminate(group); }}
201
+ onChange={() => handleGroupCheckboxChange(group)}
202
+ className="size-3.5 mt-1"
203
+ style={{ accentColor: color }}
204
+ />
205
+ <span className="text-sm w-40 text-white">{group}</span>
206
+ </label>
207
+ <div className="pl-7 flex flex-col gap-1 mt-1">
208
+ {children.map((key) => (
209
+ <label key={key} className="flex gap-2 cursor-pointer select-none">
210
+ <input
211
+ type="checkbox"
212
+ checked={visibleKeys.includes(key)}
213
+ onChange={() => handleCheckboxChange(key)}
214
+ className="size-3.5 mt-1"
215
+ style={{ accentColor: color }}
216
+ />
217
+ <span className={`text-xs break-all w-36 ${visibleKeys.includes(key) ? "text-white" : "text-gray-400"}`}>{key.slice(group.length + 1)}</span>
218
+ <span className={`text-xs font-mono ml-auto ${visibleKeys.includes(key) ? "text-orange-300" : "text-gray-500"}`}>
219
+ {typeof currentData[key] === "number" ? currentData[key].toFixed(2) : "--"}
220
+ </span>
221
+ </label>
222
+ ))}
223
+ </div>
224
+ </div>
225
+ );
226
+ })}
227
+ {/* Singles (non-grouped) */}
228
+ {singles.map((key) => {
229
+ const color = groupColorMap[key];
230
+ return (
231
+ <label key={key} className="flex gap-2 cursor-pointer select-none">
232
+ <input
233
+ type="checkbox"
234
+ checked={visibleKeys.includes(key)}
235
+ onChange={() => handleCheckboxChange(key)}
236
+ className="size-3.5 mt-1"
237
+ style={{ accentColor: color }}
238
+ />
239
+ <span className={`text-sm break-all w-40 ${visibleKeys.includes(key) ? "text-white" : "text-gray-400"}`}>{key}</span>
240
+ <span className={`text-sm font-mono ml-auto ${visibleKeys.includes(key) ? "text-orange-300" : "text-gray-500"}`}>
241
+ {typeof currentData[key] === "number" ? currentData[key].toFixed(2) : "--"}
242
+ </span>
243
+ </label>
244
+ );
245
+ })}
246
+ </div>
247
+ );
248
+ };
249
+
250
+ return (
251
+ <div className="w-full">
252
+ <div className="w-full h-80" onMouseLeave={handleMouseLeave}>
253
+ <ResponsiveContainer width="100%" height="100%">
254
+ <LineChart
255
+ data={chartData}
256
+ syncId="episode-sync"
257
+ margin={{ top: 24, right: 16, left: 0, bottom: 16 }}
258
+ onClick={handleClick}
259
+ onMouseMove={(state: any) => {
260
+ setHoveredTime(
261
+ state?.activePayload?.[0]?.payload?.timestamp ??
262
+ state?.activeLabel ??
263
+ null,
264
+ );
265
+ }}
266
+ onMouseLeave={handleMouseLeave}
267
+ >
268
+ <CartesianGrid strokeDasharray="3 3" stroke="#444" />
269
+ <XAxis
270
+ dataKey="timestamp"
271
+ label={{
272
+ value: "time",
273
+ position: "insideBottomLeft",
274
+ fill: "#cbd5e1",
275
+ }}
276
+ domain={[
277
+ chartData.at(0)?.timestamp ?? 0,
278
+ chartData.at(-1)?.timestamp ?? 0,
279
+ ]}
280
+ ticks={useMemo(
281
+ () =>
282
+ Array.from(
283
+ new Set(chartData.map((d) => Math.ceil(d.timestamp))),
284
+ ),
285
+ [chartData],
286
+ )}
287
+ stroke="#cbd5e1"
288
+ minTickGap={20} // Increased for fewer ticks
289
+ allowDataOverflow={true}
290
+ />
291
+ <YAxis
292
+ domain={["auto", "auto"]}
293
+ stroke="#cbd5e1"
294
+ interval={0}
295
+ allowDataOverflow={true}
296
+ />
297
+
298
+ <Tooltip
299
+ content={() => null}
300
+ active={true}
301
+ isAnimationActive={false}
302
+ defaultIndex={
303
+ !hoveredTime ? findClosestDataIndex(currentTime) : undefined
304
+ }
305
+ />
306
+
307
+ {/* Render lines for visible dataKeys only */}
308
+ {dataKeys.map((key) => {
309
+ // Use group color for all keys in a group
310
+ const group = key.includes(SERIES_NAME_DELIMITER) ? key.split(SERIES_NAME_DELIMITER)[0] : key;
311
+ const color = groupColorMap[group];
312
+ let strokeDasharray: string | undefined = undefined;
313
+ if (groups[group] && groups[group].length > 1) {
314
+ const idxInGroup = groups[group].indexOf(key);
315
+ if (idxInGroup > 0) strokeDasharray = "5 5";
316
+ }
317
+ return (
318
+ visibleKeys.includes(key) && (
319
+ <Line
320
+ key={key}
321
+ type="monotone"
322
+ dataKey={key}
323
+ name={key}
324
+ stroke={color}
325
+ strokeDasharray={strokeDasharray}
326
+ dot={false}
327
+ activeDot={false}
328
+ strokeWidth={1.5}
329
+ isAnimationActive={false}
330
+ />
331
+ )
332
+ );
333
+ })}
334
+ </LineChart>
335
+ </ResponsiveContainer>
336
+ </div>
337
+ <CustomLegend />
338
+ </div>
339
+ );
340
+ },
341
+ ); // End React.memo
342
+
343
+ SingleDataGraph.displayName = "SingleDataGraph";
344
+ DataRecharts.displayName = "DataGraph";
345
+ export default DataRecharts;
src/components/loading-component.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ export default function Loading() {
4
+ return (
5
+ <div
6
+ className="absolute inset-0 flex flex-col items-center justify-center bg-slate-950/70 z-10 text-slate-100 animate-fade-in"
7
+ tabIndex={-1}
8
+ aria-modal="true"
9
+ role="dialog"
10
+ >
11
+ <svg
12
+ className="animate-spin mb-8"
13
+ width="64"
14
+ height="64"
15
+ viewBox="0 0 24 24"
16
+ fill="none"
17
+ xmlns="http://www.w3.org/2000/svg"
18
+ >
19
+ <circle
20
+ className="opacity-25"
21
+ cx="12"
22
+ cy="12"
23
+ r="10"
24
+ stroke="currentColor"
25
+ strokeWidth="4"
26
+ />
27
+ <path
28
+ className="opacity-75"
29
+ fill="currentColor"
30
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
31
+ />
32
+ </svg>
33
+ <h1 className="text-2xl font-bold mb-2">Loading...</h1>
34
+ <p className="text-slate-400">preparing data & videos</p>
35
+ </div>
36
+ );
37
+ }
src/components/playback-bar.tsx ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { useTime } from "../context/time-context";
3
+ import {
4
+ FaPlay,
5
+ FaPause,
6
+ FaBackward,
7
+ FaForward,
8
+ FaUndoAlt,
9
+ FaArrowDown,
10
+ FaArrowUp,
11
+ } from "react-icons/fa";
12
+
13
+ import { debounce } from "@/utils/debounce";
14
+
15
+ const PlaybackBar: React.FC = () => {
16
+ const { duration, isPlaying, setIsPlaying, currentTime, setCurrentTime } =
17
+ useTime();
18
+
19
+ const sliderActiveRef = React.useRef(false);
20
+ const wasPlayingRef = React.useRef(false);
21
+ const [sliderValue, setSliderValue] = React.useState(currentTime);
22
+
23
+ // Only update sliderValue from context if not dragging
24
+ React.useEffect(() => {
25
+ if (!sliderActiveRef.current) {
26
+ setSliderValue(currentTime);
27
+ }
28
+ }, [currentTime]);
29
+
30
+ const updateTime = debounce((t: number) => {
31
+ setCurrentTime(t);
32
+ }, 200);
33
+
34
+ const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
35
+ const t = Number(e.target.value);
36
+ setSliderValue(t);
37
+ updateTime(t);
38
+ };
39
+
40
+ const handleSliderMouseDown = () => {
41
+ sliderActiveRef.current = true;
42
+ wasPlayingRef.current = isPlaying;
43
+ setIsPlaying(false);
44
+ };
45
+
46
+ const handleSliderMouseUp = () => {
47
+ sliderActiveRef.current = false;
48
+ setCurrentTime(sliderValue); // Snap to final value
49
+ if (wasPlayingRef.current) {
50
+ setIsPlaying(true);
51
+ }
52
+ // If it was paused before, keep it paused
53
+ };
54
+
55
+ return (
56
+ <div className="flex items-center gap-4 w-full max-w-4xl mx-auto sticky bottom-0 bg-slate-900/95 px-4 py-3 rounded-3xl mt-auto">
57
+ <button
58
+ title="Jump backward 5 seconds"
59
+ onClick={() => setCurrentTime(Math.max(0, currentTime - 5))}
60
+ className="text-2xl hidden md:block"
61
+ >
62
+ <FaBackward size={24} />
63
+ </button>
64
+ <button
65
+ className={`text-3xl transition-transform ${isPlaying ? "scale-90 opacity-60" : "scale-110"}`}
66
+ title="Play. Toggle with Space"
67
+ onClick={() => setIsPlaying(true)}
68
+ style={{ display: isPlaying ? "none" : "inline-block" }}
69
+ >
70
+ <FaPlay size={24} />
71
+ </button>
72
+ <button
73
+ className={`text-3xl transition-transform ${!isPlaying ? "scale-90 opacity-60" : "scale-110"}`}
74
+ title="Pause. Toggle with Space"
75
+ onClick={() => setIsPlaying(false)}
76
+ style={{ display: !isPlaying ? "none" : "inline-block" }}
77
+ >
78
+ <FaPause size={24} />
79
+ </button>
80
+ <button
81
+ title="Jump forward 5 seconds"
82
+ onClick={() => setCurrentTime(Math.min(duration, currentTime + 5))}
83
+ className="text-2xl hidden md:block"
84
+ >
85
+ <FaForward size={24} />
86
+ </button>
87
+ <button
88
+ title="Rewind from start"
89
+ onClick={() => setCurrentTime(0)}
90
+ className="text-2xl hidden md:block"
91
+ >
92
+ <FaUndoAlt size={24} />
93
+ </button>
94
+ <input
95
+ type="range"
96
+ min={0}
97
+ max={duration}
98
+ step={0.01}
99
+ value={sliderValue}
100
+ onChange={handleSliderChange}
101
+ onMouseDown={handleSliderMouseDown}
102
+ onMouseUp={handleSliderMouseUp}
103
+ onTouchStart={handleSliderMouseDown}
104
+ onTouchEnd={handleSliderMouseUp}
105
+ className="flex-1 mx-2 accent-orange-500 focus:outline-none focus:ring-0"
106
+ aria-label="Seek video"
107
+ />
108
+ <span className="w-16 text-right tabular-nums text-xs text-slate-200 shrink-0">
109
+ {Math.floor(sliderValue)} / {Math.floor(duration)}
110
+ </span>
111
+
112
+ <div className="text-xs text-slate-300 select-none ml-8 flex-col gap-y-0.5 hidden md:flex">
113
+ <p>
114
+ <span className="inline-flex items-center gap-1 font-mono align-middle">
115
+ <span className="px-2 py-0.5 rounded border border-slate-400 bg-slate-800 text-slate-200 text-xs shadow-inner">
116
+ Space
117
+ </span>
118
+ </span>{" "}
119
+ to pause/unpause
120
+ </p>
121
+ <p>
122
+ <span className="inline-flex items-center gap-1 font-mono align-middle">
123
+ <FaArrowUp size={14} />/<FaArrowDown size={14} />
124
+ </span>{" "}
125
+ to previous/next episode
126
+ </p>
127
+ </div>
128
+ </div>
129
+ );
130
+ };
131
+
132
+ export default PlaybackBar;
src/components/side-nav.tsx ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import React from "react";
5
+
6
+ interface SidebarProps {
7
+ datasetInfo: any;
8
+ paginatedEpisodes: any[];
9
+ episodeId: any;
10
+ totalPages: number;
11
+ currentPage: number;
12
+ prevPage: () => void;
13
+ nextPage: () => void;
14
+ }
15
+
16
+ const Sidebar: React.FC<SidebarProps> = ({
17
+ datasetInfo,
18
+ paginatedEpisodes,
19
+ episodeId,
20
+ totalPages,
21
+ currentPage,
22
+ prevPage,
23
+ nextPage,
24
+ }) => {
25
+ const [sidebarVisible, setSidebarVisible] = React.useState(true);
26
+ const toggleSidebar = () => setSidebarVisible((prev) => !prev);
27
+
28
+ const sidebarRef = React.useRef<HTMLDivElement>(null);
29
+
30
+ React.useEffect(() => {
31
+ if (!sidebarVisible) return;
32
+ function handleClickOutside(event: MouseEvent) {
33
+ // If click is outside the sidebar nav
34
+ if (
35
+ sidebarRef.current &&
36
+ !sidebarRef.current.contains(event.target as Node)
37
+ ) {
38
+ setTimeout(() => setSidebarVisible(false), 500);
39
+ }
40
+ }
41
+ document.addEventListener("mousedown", handleClickOutside);
42
+ return () => {
43
+ document.removeEventListener("mousedown", handleClickOutside);
44
+ };
45
+ }, [sidebarVisible]);
46
+
47
+ return (
48
+ <div className="flex z-10 min-h-screen absolute md:static" ref={sidebarRef}>
49
+ <nav
50
+ className={`shrink-0 overflow-y-auto bg-slate-900 p-5 break-words md:max-h-screen w-60 md:shrink ${
51
+ !sidebarVisible ? "hidden" : ""
52
+ }`}
53
+ aria-label="Sidebar navigation"
54
+ >
55
+ <ul>
56
+ <li>Number of samples/frames: {datasetInfo.total_frames}</li>
57
+ <li>Number of episodes: {datasetInfo.total_episodes}</li>
58
+ <li>Frames per second: {datasetInfo.fps}</li>
59
+ </ul>
60
+
61
+ <p>Episodes:</p>
62
+
63
+ {/* episodes menu for medium & large screens */}
64
+ <div className="ml-2 block">
65
+ <ul>
66
+ {paginatedEpisodes.map((episode) => (
67
+ <li key={episode} className="mt-0.5 font-mono text-sm">
68
+ <Link
69
+ href={`./episode_${episode}`}
70
+ className={`underline ${episode === episodeId ? "-ml-1 font-bold" : ""}`}
71
+ >
72
+ Episode {episode}
73
+ </Link>
74
+ </li>
75
+ ))}
76
+ </ul>
77
+
78
+ {totalPages > 1 && (
79
+ <div className="mt-3 flex items-center text-xs">
80
+ <button
81
+ onClick={prevPage}
82
+ className={`mr-2 rounded bg-slate-800 px-2 py-1 ${
83
+ currentPage === 1 ? "cursor-not-allowed opacity-50" : ""
84
+ }`}
85
+ disabled={currentPage === 1}
86
+ >
87
+ « Prev
88
+ </button>
89
+ <span className="mr-2 font-mono">
90
+ {currentPage} / {totalPages}
91
+ </span>
92
+ <button
93
+ onClick={nextPage}
94
+ className={`rounded bg-slate-800 px-2 py-1 ${
95
+ currentPage === totalPages
96
+ ? "cursor-not-allowed opacity-50"
97
+ : ""
98
+ }`}
99
+ disabled={currentPage === totalPages}
100
+ >
101
+ Next »
102
+ </button>
103
+ </div>
104
+ )}
105
+ </div>
106
+ </nav>
107
+ {/* Toggle sidebar button */}
108
+ <button
109
+ className="mx-1 flex items-center opacity-50 hover:opacity-100 focus:outline-none focus:ring-0"
110
+ onClick={toggleSidebar}
111
+ title="Toggle sidebar"
112
+ >
113
+ <div className="h-10 w-2 rounded-full bg-slate-500"></div>
114
+ </button>
115
+ </div>
116
+ );
117
+ };
118
+
119
+ export default Sidebar;
src/components/simple-videos-player.tsx ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useEffect, useRef } from "react";
4
+ import { useTime } from "../context/time-context";
5
+ import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
6
+
7
+ type VideoInfo = {
8
+ filename: string;
9
+ url: string;
10
+ isSegmented?: boolean;
11
+ segmentStart?: number;
12
+ segmentEnd?: number;
13
+ segmentDuration?: number;
14
+ };
15
+
16
+ type VideoPlayerProps = {
17
+ videosInfo: VideoInfo[];
18
+ onVideosReady?: () => void;
19
+ };
20
+
21
+ export const SimpleVideosPlayer = ({
22
+ videosInfo,
23
+ onVideosReady,
24
+ }: VideoPlayerProps) => {
25
+ const { currentTime, setCurrentTime, isPlaying, setIsPlaying } = useTime();
26
+ const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
27
+ const [hiddenVideos, setHiddenVideos] = React.useState<string[]>([]);
28
+ const [enlargedVideo, setEnlargedVideo] = React.useState<string | null>(null);
29
+ const [showHiddenMenu, setShowHiddenMenu] = React.useState(false);
30
+ const [videosReady, setVideosReady] = React.useState(false);
31
+
32
+ const firstVisibleIdx = videosInfo.findIndex(
33
+ (video) => !hiddenVideos.includes(video.filename)
34
+ );
35
+
36
+ // Initialize video refs array
37
+ useEffect(() => {
38
+ videoRefs.current = videoRefs.current.slice(0, videosInfo.length);
39
+ }, [videosInfo.length]);
40
+
41
+ // Handle videos ready
42
+ useEffect(() => {
43
+ let readyCount = 0;
44
+
45
+ const checkReady = () => {
46
+ readyCount++;
47
+ if (readyCount === videosInfo.length && onVideosReady) {
48
+ setVideosReady(true);
49
+ onVideosReady();
50
+ setIsPlaying(true);
51
+ }
52
+ };
53
+
54
+ videoRefs.current.forEach((video, index) => {
55
+ if (video) {
56
+ const info = videosInfo[index];
57
+
58
+ // Setup segment boundaries
59
+ if (info.isSegmented) {
60
+ const handleTimeUpdate = () => {
61
+ const segmentEnd = info.segmentEnd || video.duration;
62
+ const segmentStart = info.segmentStart || 0;
63
+
64
+ if (video.currentTime >= segmentEnd - 0.05) {
65
+ video.currentTime = segmentStart;
66
+ // Also update the global time to reset to start
67
+ if (index === firstVisibleIdx) {
68
+ setCurrentTime(0);
69
+ }
70
+ }
71
+ };
72
+
73
+ const handleLoadedData = () => {
74
+ video.currentTime = info.segmentStart || 0;
75
+ checkReady();
76
+ };
77
+
78
+ video.addEventListener('timeupdate', handleTimeUpdate);
79
+ video.addEventListener('loadeddata', handleLoadedData);
80
+
81
+ // Store cleanup
82
+ (video as any)._segmentHandlers = () => {
83
+ video.removeEventListener('timeupdate', handleTimeUpdate);
84
+ video.removeEventListener('loadeddata', handleLoadedData);
85
+ };
86
+ } else {
87
+ // For non-segmented videos, handle end of video
88
+ const handleEnded = () => {
89
+ video.currentTime = 0;
90
+ if (index === firstVisibleIdx) {
91
+ setCurrentTime(0);
92
+ }
93
+ };
94
+
95
+ video.addEventListener('ended', handleEnded);
96
+ video.addEventListener('canplaythrough', checkReady, { once: true });
97
+
98
+ // Store cleanup
99
+ (video as any)._segmentHandlers = () => {
100
+ video.removeEventListener('ended', handleEnded);
101
+ };
102
+ }
103
+ }
104
+ });
105
+
106
+ return () => {
107
+ videoRefs.current.forEach((video) => {
108
+ if (video && (video as any)._segmentHandlers) {
109
+ (video as any)._segmentHandlers();
110
+ }
111
+ });
112
+ };
113
+ }, [videosInfo, onVideosReady, setIsPlaying, firstVisibleIdx, setCurrentTime]);
114
+
115
+ // Handle play/pause
116
+ useEffect(() => {
117
+ if (!videosReady) return;
118
+
119
+ videoRefs.current.forEach((video, idx) => {
120
+ if (video && !hiddenVideos.includes(videosInfo[idx].filename)) {
121
+ if (isPlaying) {
122
+ video.play().catch(e => {
123
+ if (e.name !== 'AbortError') {
124
+ console.error("Error playing video");
125
+ }
126
+ });
127
+ } else {
128
+ video.pause();
129
+ }
130
+ }
131
+ });
132
+ }, [isPlaying, videosReady, hiddenVideos, videosInfo]);
133
+
134
+ // Sync video times
135
+ useEffect(() => {
136
+ if (!videosReady) return;
137
+
138
+ videoRefs.current.forEach((video, index) => {
139
+ if (video && !hiddenVideos.includes(videosInfo[index].filename)) {
140
+ const info = videosInfo[index];
141
+ let targetTime = currentTime;
142
+
143
+ if (info.isSegmented) {
144
+ targetTime = (info.segmentStart || 0) + currentTime;
145
+ }
146
+
147
+ if (Math.abs(video.currentTime - targetTime) > 0.2) {
148
+ video.currentTime = targetTime;
149
+ }
150
+ }
151
+ });
152
+ }, [currentTime, videosInfo, videosReady, hiddenVideos]);
153
+
154
+ // Handle time update from first visible video
155
+ const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
156
+ const video = e.target as HTMLVideoElement;
157
+ const videoIndex = videoRefs.current.findIndex(ref => ref === video);
158
+ const info = videosInfo[videoIndex];
159
+
160
+ if (info) {
161
+ let globalTime = video.currentTime;
162
+ if (info.isSegmented) {
163
+ globalTime = video.currentTime - (info.segmentStart || 0);
164
+ }
165
+ setCurrentTime(globalTime);
166
+ }
167
+ };
168
+
169
+ // Handle play click for segmented videos
170
+ const handlePlay = (video: HTMLVideoElement, info: VideoInfo) => {
171
+ if (info.isSegmented) {
172
+ const segmentStart = info.segmentStart || 0;
173
+ const segmentEnd = info.segmentEnd || video.duration;
174
+
175
+ if (video.currentTime < segmentStart || video.currentTime >= segmentEnd) {
176
+ video.currentTime = segmentStart;
177
+ }
178
+ }
179
+ video.play();
180
+ };
181
+
182
+ return (
183
+ <>
184
+ {/* Hidden videos menu */}
185
+ {hiddenVideos.length > 0 && (
186
+ <div className="relative mb-4">
187
+ <button
188
+ className="flex items-center gap-2 rounded bg-slate-800 px-3 py-2 text-sm text-slate-100 hover:bg-slate-700 border border-slate-500"
189
+ onClick={() => setShowHiddenMenu(!showHiddenMenu)}
190
+ >
191
+ <FaEye /> Show Hidden Videos ({hiddenVideos.length})
192
+ </button>
193
+ {showHiddenMenu && (
194
+ <div className="absolute left-0 mt-2 w-max rounded border border-slate-500 bg-slate-900 shadow-lg p-2 z-50">
195
+ <div className="mb-2 text-xs text-slate-300">
196
+ Restore hidden videos:
197
+ </div>
198
+ {hiddenVideos.map((filename) => (
199
+ <button
200
+ key={filename}
201
+ className="block w-full text-left px-2 py-1 rounded hover:bg-slate-700 text-slate-100"
202
+ onClick={() => setHiddenVideos(prev => prev.filter(v => v !== filename))}
203
+ >
204
+ {filename}
205
+ </button>
206
+ ))}
207
+ </div>
208
+ )}
209
+ </div>
210
+ )}
211
+
212
+ {/* Videos */}
213
+ <div className="flex flex-wrap gap-x-2 gap-y-6">
214
+ {videosInfo.map((info, idx) => {
215
+ if (hiddenVideos.includes(info.filename)) return null;
216
+
217
+ const isEnlarged = enlargedVideo === info.filename;
218
+ const isFirstVisible = idx === firstVisibleIdx;
219
+
220
+ return (
221
+ <div
222
+ key={info.filename}
223
+ className={`${
224
+ isEnlarged
225
+ ? "z-40 fixed inset-0 bg-black bg-opacity-90 flex flex-col items-center justify-center"
226
+ : "max-w-96"
227
+ }`}
228
+ >
229
+ <p className="truncate w-full rounded-t-xl bg-gray-800 px-2 text-sm text-gray-300 flex items-center justify-between">
230
+ <span>{info.filename}</span>
231
+ <span className="flex gap-1">
232
+ <button
233
+ title={isEnlarged ? "Minimize" : "Enlarge"}
234
+ className="ml-2 p-1 hover:bg-slate-700 rounded"
235
+ onClick={() => setEnlargedVideo(isEnlarged ? null : info.filename)}
236
+ >
237
+ {isEnlarged ? <FaCompress /> : <FaExpand />}
238
+ </button>
239
+ <button
240
+ title="Hide Video"
241
+ className="ml-1 p-1 hover:bg-slate-700 rounded"
242
+ onClick={() => setHiddenVideos(prev => [...prev, info.filename])}
243
+ disabled={videosInfo.filter(v => !hiddenVideos.includes(v.filename)).length === 1}
244
+ >
245
+ <FaTimes />
246
+ </button>
247
+ </span>
248
+ </p>
249
+ <video
250
+ ref={el => videoRefs.current[idx] = el}
251
+ className={`w-full object-contain ${
252
+ isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""
253
+ }`}
254
+ muted
255
+ preload="auto"
256
+ onPlay={(e) => handlePlay(e.currentTarget, info)}
257
+ onTimeUpdate={isFirstVisible ? handleTimeUpdate : undefined}
258
+ >
259
+ <source src={info.url} type="video/mp4" />
260
+ Your browser does not support the video tag.
261
+ </video>
262
+ </div>
263
+ );
264
+ })}
265
+ </div>
266
+ </>
267
+ );
268
+ };
269
+
270
+ export default SimpleVideosPlayer;
src/components/videos-player.tsx ADDED
@@ -0,0 +1,416 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { useTime } from "../context/time-context";
5
+ import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
6
+
7
+ type VideoInfo = {
8
+ filename: string;
9
+ url: string;
10
+ isSegmented?: boolean;
11
+ segmentStart?: number;
12
+ segmentEnd?: number;
13
+ segmentDuration?: number;
14
+ };
15
+
16
+ type VideoPlayerProps = {
17
+ videosInfo: VideoInfo[];
18
+ onVideosReady?: () => void;
19
+ };
20
+
21
+ export const VideosPlayer = ({
22
+ videosInfo,
23
+ onVideosReady,
24
+ }: VideoPlayerProps) => {
25
+ const { currentTime, setCurrentTime, isPlaying, setIsPlaying } = useTime();
26
+ const videoRefs = useRef<HTMLVideoElement[]>([]);
27
+ // Hidden/enlarged state and hidden menu
28
+ const [hiddenVideos, setHiddenVideos] = useState<string[]>([]);
29
+ // Find the index of the first visible (not hidden) video
30
+ const firstVisibleIdx = videosInfo.findIndex(
31
+ (video) => !hiddenVideos.includes(video.filename),
32
+ );
33
+ // Count of visible videos
34
+ const visibleCount = videosInfo.filter(
35
+ (video) => !hiddenVideos.includes(video.filename),
36
+ ).length;
37
+ const [enlargedVideo, setEnlargedVideo] = useState<string | null>(null);
38
+ // Track previous hiddenVideos for comparison
39
+ const prevHiddenVideosRef = useRef<string[]>([]);
40
+ const videoContainerRefs = useRef<Record<string, HTMLDivElement | null>>({});
41
+ const [showHiddenMenu, setShowHiddenMenu] = useState(false);
42
+ const hiddenMenuRef = useRef<HTMLDivElement | null>(null);
43
+ const showHiddenBtnRef = useRef<HTMLButtonElement | null>(null);
44
+ const [videoCodecError, setVideoCodecError] = useState(false);
45
+
46
+ // Initialize video refs
47
+ useEffect(() => {
48
+ videoRefs.current = videoRefs.current.slice(0, videosInfo.length);
49
+ }, [videosInfo]);
50
+
51
+ // When videos get unhidden, start playing them if it was playing
52
+ useEffect(() => {
53
+ // Find which videos were just unhidden
54
+ const prevHidden = prevHiddenVideosRef.current;
55
+ const newlyUnhidden = prevHidden.filter(
56
+ (filename) => !hiddenVideos.includes(filename),
57
+ );
58
+ if (newlyUnhidden.length > 0) {
59
+ videosInfo.forEach((video, idx) => {
60
+ if (newlyUnhidden.includes(video.filename)) {
61
+ const ref = videoRefs.current[idx];
62
+ if (ref) {
63
+ ref.currentTime = currentTime;
64
+ if (isPlaying) {
65
+ ref.play().catch(() => {});
66
+ }
67
+ }
68
+ }
69
+ });
70
+ }
71
+ prevHiddenVideosRef.current = hiddenVideos;
72
+ }, [hiddenVideos, isPlaying, videosInfo, currentTime]);
73
+
74
+ // Check video codec support
75
+ useEffect(() => {
76
+ const checkCodecSupport = () => {
77
+ const dummyVideo = document.createElement("video");
78
+ const canPlayVideos = dummyVideo.canPlayType(
79
+ 'video/mp4; codecs="av01.0.05M.08"',
80
+ );
81
+ setVideoCodecError(!canPlayVideos);
82
+ };
83
+
84
+ checkCodecSupport();
85
+ }, []);
86
+
87
+ // Handle play/pause
88
+ useEffect(() => {
89
+ videoRefs.current.forEach((video) => {
90
+ if (video) {
91
+ if (isPlaying) {
92
+ video.play().catch(() => console.error("Error playing video"));
93
+ } else {
94
+ video.pause();
95
+ }
96
+ }
97
+ });
98
+ }, [isPlaying]);
99
+
100
+ // Minimize enlarged video on Escape key
101
+ useEffect(() => {
102
+ if (!enlargedVideo) return;
103
+ const handleKeyDown = (e: KeyboardEvent) => {
104
+ if (e.key === "Escape") {
105
+ setEnlargedVideo(null);
106
+ }
107
+ };
108
+ window.addEventListener("keydown", handleKeyDown);
109
+ // Scroll enlarged video into view
110
+ const ref = videoContainerRefs.current[enlargedVideo];
111
+ if (ref) {
112
+ ref.scrollIntoView();
113
+ }
114
+ return () => {
115
+ window.removeEventListener("keydown", handleKeyDown);
116
+ };
117
+ }, [enlargedVideo]);
118
+
119
+ // Close hidden videos dropdown on outside click
120
+ useEffect(() => {
121
+ if (!showHiddenMenu) return;
122
+ function handleClick(e: MouseEvent) {
123
+ const menu = hiddenMenuRef.current;
124
+ const btn = showHiddenBtnRef.current;
125
+ if (
126
+ menu &&
127
+ !menu.contains(e.target as Node) &&
128
+ btn &&
129
+ !btn.contains(e.target as Node)
130
+ ) {
131
+ setShowHiddenMenu(false);
132
+ }
133
+ }
134
+ document.addEventListener("mousedown", handleClick);
135
+ return () => document.removeEventListener("mousedown", handleClick);
136
+ }, [showHiddenMenu]);
137
+
138
+ // Close dropdown if no hidden videos
139
+ useEffect(() => {
140
+ if (hiddenVideos.length === 0 && showHiddenMenu) {
141
+ setShowHiddenMenu(false);
142
+ }
143
+ // Minimize if enlarged video is hidden
144
+ if (enlargedVideo && hiddenVideos.includes(enlargedVideo)) {
145
+ setEnlargedVideo(null);
146
+ }
147
+ }, [hiddenVideos, showHiddenMenu, enlargedVideo]);
148
+
149
+ // Sync video times (with segment awareness)
150
+ useEffect(() => {
151
+ videoRefs.current.forEach((video, index) => {
152
+ if (video && Math.abs(video.currentTime - currentTime) > 0.2) {
153
+ const videoInfo = videosInfo[index];
154
+
155
+ if (videoInfo?.isSegmented) {
156
+ // For segmented videos, map the global time to segment time
157
+ const segmentStart = videoInfo.segmentStart || 0;
158
+ const segmentDuration = videoInfo.segmentDuration || 0;
159
+
160
+ if (segmentDuration > 0) {
161
+ // Map currentTime (0 to segmentDuration) to video time (segmentStart to segmentEnd)
162
+ const segmentTime = segmentStart + currentTime;
163
+ video.currentTime = segmentTime;
164
+ }
165
+ } else {
166
+ // For non-segmented videos, use direct time mapping
167
+ video.currentTime = currentTime;
168
+ }
169
+ }
170
+ });
171
+ }, [currentTime, videosInfo]);
172
+
173
+ // Handle time update
174
+ const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
175
+ const video = e.target as HTMLVideoElement;
176
+ if (video && video.duration) {
177
+ // Find the video info for this video element
178
+ const videoIndex = videoRefs.current.findIndex(ref => ref === video);
179
+ const videoInfo = videosInfo[videoIndex];
180
+
181
+ if (videoInfo?.isSegmented) {
182
+ // For segmented videos, map the video time back to global time (0 to segmentDuration)
183
+ const segmentStart = videoInfo.segmentStart || 0;
184
+ const globalTime = Math.max(0, video.currentTime - segmentStart);
185
+ setCurrentTime(globalTime);
186
+ } else {
187
+ // For non-segmented videos, use direct time mapping
188
+ setCurrentTime(video.currentTime);
189
+ }
190
+ }
191
+ };
192
+
193
+ // Handle video ready and setup segmentation
194
+ useEffect(() => {
195
+ let videosReadyCount = 0;
196
+ const onCanPlayThrough = (videoIndex: number) => {
197
+ const video = videoRefs.current[videoIndex];
198
+ const videoInfo = videosInfo[videoIndex];
199
+
200
+ // Setup video segmentation for v3.0 chunked videos
201
+ if (video && videoInfo?.isSegmented) {
202
+ const segmentStart = videoInfo.segmentStart || 0;
203
+ const segmentEnd = videoInfo.segmentEnd || video.duration || 0;
204
+
205
+
206
+ // Set initial time to segment start if not already set
207
+ if (video.currentTime < segmentStart || video.currentTime > segmentEnd) {
208
+ video.currentTime = segmentStart;
209
+ }
210
+
211
+ // Add event listener to handle segment boundaries
212
+ const handleTimeUpdate = () => {
213
+ if (video.currentTime > segmentEnd) {
214
+ video.currentTime = segmentStart;
215
+ if (!video.loop) {
216
+ video.pause();
217
+ }
218
+ }
219
+ };
220
+
221
+ video.addEventListener('timeupdate', handleTimeUpdate);
222
+
223
+ // Store cleanup function
224
+ (video as any)._segmentCleanup = () => {
225
+ video.removeEventListener('timeupdate', handleTimeUpdate);
226
+ };
227
+ }
228
+
229
+ videosReadyCount += 1;
230
+ if (videosReadyCount === videosInfo.length) {
231
+ if (typeof onVideosReady === "function") {
232
+ onVideosReady();
233
+ setIsPlaying(true);
234
+ }
235
+ }
236
+ };
237
+
238
+ videoRefs.current.forEach((video, index) => {
239
+ if (video) {
240
+ // If already ready, call the handler immediately
241
+ if (video.readyState >= 4) {
242
+ onCanPlayThrough(index);
243
+ } else {
244
+ const readyHandler = () => onCanPlayThrough(index);
245
+ video.addEventListener("canplaythrough", readyHandler);
246
+ (video as any)._readyHandler = readyHandler;
247
+ }
248
+ }
249
+ });
250
+
251
+ return () => {
252
+ videoRefs.current.forEach((video) => {
253
+ if (video) {
254
+ // Remove ready handler
255
+ if ((video as any)._readyHandler) {
256
+ video.removeEventListener("canplaythrough", (video as any)._readyHandler);
257
+ }
258
+ // Remove segment handler
259
+ if ((video as any)._segmentCleanup) {
260
+ (video as any)._segmentCleanup();
261
+ }
262
+ }
263
+ });
264
+ };
265
+ }, [videosInfo, onVideosReady, setIsPlaying]);
266
+
267
+ return (
268
+ <>
269
+ {/* Error message */}
270
+ {videoCodecError && (
271
+ <div className="font-medium text-orange-700">
272
+ <p>
273
+ Videos could NOT play because{" "}
274
+ <a
275
+ href="https://en.wikipedia.org/wiki/AV1"
276
+ target="_blank"
277
+ className="underline"
278
+ >
279
+ AV1
280
+ </a>{" "}
281
+ decoding is not available on your browser.
282
+ </p>
283
+ <ul className="list-inside list-decimal">
284
+ <li>
285
+ If iPhone:{" "}
286
+ <span className="italic">
287
+ It is supported with A17 chip or higher.
288
+ </span>
289
+ </li>
290
+ <li>
291
+ If Mac with Safari:{" "}
292
+ <span className="italic">
293
+ It is supported on most browsers except Safari with M1 chip or
294
+ higher and on Safari with M3 chip or higher.
295
+ </span>
296
+ </li>
297
+ <li>
298
+ Other:{" "}
299
+ <span className="italic">
300
+ Contact the maintainers on LeRobot discord channel:
301
+ </span>
302
+ <a
303
+ href="https://discord.com/invite/s3KuuzsPFb"
304
+ target="_blank"
305
+ className="underline"
306
+ >
307
+ https://discord.com/invite/s3KuuzsPFb
308
+ </a>
309
+ </li>
310
+ </ul>
311
+ </div>
312
+ )}
313
+
314
+ {/* Show Hidden Videos Button */}
315
+ {hiddenVideos.length > 0 && (
316
+ <div className="relative">
317
+ <button
318
+ ref={showHiddenBtnRef}
319
+ className="flex items-center gap-2 rounded bg-slate-800 px-3 py-2 text-sm text-slate-100 hover:bg-slate-700 border border-slate-500"
320
+ onClick={() => setShowHiddenMenu((prev) => !prev)}
321
+ >
322
+ <FaEye /> Show Hidden Videos ({hiddenVideos.length})
323
+ </button>
324
+ {showHiddenMenu && (
325
+ <div
326
+ ref={hiddenMenuRef}
327
+ className="absolute left-0 mt-2 w-max rounded border border-slate-500 bg-slate-900 shadow-lg p-2 z-50"
328
+ >
329
+ <div className="mb-2 text-xs text-slate-300">
330
+ Restore hidden videos:
331
+ </div>
332
+ {hiddenVideos.map((filename) => (
333
+ <button
334
+ key={filename}
335
+ className="block w-full text-left px-2 py-1 rounded hover:bg-slate-700 text-slate-100"
336
+ onClick={() =>
337
+ setHiddenVideos((prev: string[]) =>
338
+ prev.filter((v: string) => v !== filename),
339
+ )
340
+ }
341
+ >
342
+ {filename}
343
+ </button>
344
+ ))}
345
+ </div>
346
+ )}
347
+ </div>
348
+ )}
349
+
350
+ {/* Videos */}
351
+ <div className="flex flex-wrap gap-x-2 gap-y-6">
352
+ {videosInfo.map((video, idx) => {
353
+ if (hiddenVideos.includes(video.filename) || videoCodecError)
354
+ return null;
355
+ const isEnlarged = enlargedVideo === video.filename;
356
+ return (
357
+ <div
358
+ key={video.filename}
359
+ ref={(el) => {
360
+ videoContainerRefs.current[video.filename] = el;
361
+ }}
362
+ className={`${isEnlarged ? "z-40 fixed inset-0 bg-black bg-opacity-90 flex flex-col items-center justify-center" : "max-w-96"}`}
363
+ style={isEnlarged ? { height: "100vh", width: "100vw" } : {}}
364
+ >
365
+ <p className="truncate w-full rounded-t-xl bg-gray-800 px-2 text-sm text-gray-300 flex items-center justify-between">
366
+ <span>{video.filename}</span>
367
+ <span className="flex gap-1">
368
+ <button
369
+ title={isEnlarged ? "Minimize" : "Enlarge"}
370
+ className="ml-2 p-1 hover:bg-slate-700 rounded focus:outline-none focus:ring-0"
371
+ onClick={() =>
372
+ setEnlargedVideo(isEnlarged ? null : video.filename)
373
+ }
374
+ >
375
+ {isEnlarged ? <FaCompress /> : <FaExpand />}
376
+ </button>
377
+ <button
378
+ title="Hide Video"
379
+ className="ml-1 p-1 hover:bg-slate-700 rounded focus:outline-none focus:ring-0"
380
+ onClick={() =>
381
+ setHiddenVideos((prev: string[]) => [
382
+ ...prev,
383
+ video.filename,
384
+ ])
385
+ }
386
+ disabled={visibleCount === 1}
387
+ >
388
+ <FaTimes />
389
+ </button>
390
+ </span>
391
+ </p>
392
+ <video
393
+ ref={(el) => {
394
+ if (el) videoRefs.current[idx] = el;
395
+ }}
396
+ muted
397
+ loop
398
+ preload="auto"
399
+ className={`w-full object-contain ${isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""}`}
400
+ onTimeUpdate={
401
+ idx === firstVisibleIdx ? handleTimeUpdate : undefined
402
+ }
403
+ style={isEnlarged ? { zIndex: 41 } : {}}
404
+ >
405
+ <source src={video.url} type="video/mp4" />
406
+ Your browser does not support the video tag.
407
+ </video>
408
+ </div>
409
+ );
410
+ })}
411
+ </div>
412
+ </>
413
+ );
414
+ };
415
+
416
+ export default VideosPlayer;
src/context/time-context.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useRef,
5
+ useState,
6
+ useCallback,
7
+ } from "react";
8
+
9
+ // The shape of our context
10
+ type TimeContextType = {
11
+ currentTime: number;
12
+ setCurrentTime: (t: number) => void;
13
+ subscribe: (cb: (t: number) => void) => () => void;
14
+ isPlaying: boolean;
15
+ setIsPlaying: React.Dispatch<React.SetStateAction<boolean>>;
16
+ duration: number;
17
+ setDuration: React.Dispatch<React.SetStateAction<number>>;
18
+ };
19
+
20
+ const TimeContext = createContext<TimeContextType | undefined>(undefined);
21
+
22
+ export const useTime = () => {
23
+ const ctx = useContext(TimeContext);
24
+ if (!ctx) throw new Error("useTime must be used within a TimeProvider");
25
+ return ctx;
26
+ };
27
+
28
+ export const TimeProvider: React.FC<{
29
+ children: React.ReactNode;
30
+ duration: number;
31
+ }> = ({ children, duration: initialDuration }) => {
32
+ const [currentTime, setCurrentTime] = useState(0);
33
+ const [isPlaying, setIsPlaying] = useState(false);
34
+ const [duration, setDuration] = useState(initialDuration);
35
+ const listeners = useRef<Set<(t: number) => void>>(new Set());
36
+
37
+ // Call this to update time and notify all listeners
38
+ const updateTime = useCallback((t: number) => {
39
+ setCurrentTime(t);
40
+ listeners.current.forEach((fn) => fn(t));
41
+ }, []);
42
+
43
+ // Components can subscribe to time changes (for imperative updates)
44
+ const subscribe = useCallback((cb: (t: number) => void) => {
45
+ listeners.current.add(cb);
46
+ return () => listeners.current.delete(cb);
47
+ }, []);
48
+
49
+ return (
50
+ <TimeContext.Provider
51
+ value={{
52
+ currentTime,
53
+ setCurrentTime: updateTime,
54
+ subscribe,
55
+ isPlaying,
56
+ setIsPlaying,
57
+ duration,
58
+ setDuration,
59
+ }}
60
+ >
61
+ {children}
62
+ </TimeContext.Provider>
63
+ );
64
+ };
src/utils/debounce.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ export function debounce<F extends (...args: any[]) => any>(
2
+ func: F,
3
+ waitFor: number,
4
+ ): (...args: Parameters<F>) => void {
5
+ let timeoutId: number;
6
+ return (...args: Parameters<F>) => {
7
+ clearTimeout(timeoutId);
8
+ timeoutId = window.setTimeout(() => func(...args), waitFor);
9
+ };
10
+ }
src/utils/parquetUtils.ts ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { parquetRead, parquetReadObjects } from "hyparquet";
2
+
3
+ export interface DatasetMetadata {
4
+ codebase_version: string;
5
+ robot_type: string;
6
+ total_episodes: number;
7
+ total_frames: number;
8
+ total_tasks: number;
9
+ total_videos: number;
10
+ total_chunks: number;
11
+ chunks_size: number;
12
+ fps: number;
13
+ splits: Record<string, string>;
14
+ data_path: string;
15
+ video_path: string;
16
+ features: Record<
17
+ string,
18
+ {
19
+ dtype: string;
20
+ shape: any[];
21
+ names: any[] | Record<string, any> | null;
22
+ info?: Record<string, any>;
23
+ }
24
+ >;
25
+ }
26
+
27
+ export async function fetchJson<T>(url: string): Promise<T> {
28
+ const res = await fetch(url);
29
+ if (!res.ok) {
30
+ throw new Error(
31
+ `Failed to fetch JSON ${url}: ${res.status} ${res.statusText}`,
32
+ );
33
+ }
34
+ return res.json() as Promise<T>;
35
+ }
36
+
37
+ export function formatStringWithVars(
38
+ format: string,
39
+ vars: Record<string, any>,
40
+ ): string {
41
+ return format.replace(/{(\w+)(?::\d+d)?}/g, (_, key) => vars[key]);
42
+ }
43
+
44
+ // Fetch and parse the Parquet file
45
+ export async function fetchParquetFile(url: string): Promise<ArrayBuffer> {
46
+ const res = await fetch(url);
47
+
48
+ if (!res.ok) {
49
+ throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
50
+ }
51
+
52
+ return res.arrayBuffer();
53
+ }
54
+
55
+ // Read specific columns from the Parquet file
56
+ export async function readParquetColumn(
57
+ fileBuffer: ArrayBuffer,
58
+ columns: string[],
59
+ ): Promise<any[]> {
60
+ return new Promise((resolve, reject) => {
61
+ try {
62
+ parquetRead({
63
+ file: fileBuffer,
64
+ columns: columns.length > 0 ? columns : undefined, // Let hyparquet read all columns if empty array
65
+ onComplete: (data: any[]) => {
66
+ resolve(data);
67
+ }
68
+ });
69
+ } catch (error) {
70
+ reject(error);
71
+ }
72
+ });
73
+ }
74
+
75
+ // Read parquet file and return objects with column names as keys
76
+ export async function readParquetAsObjects(
77
+ fileBuffer: ArrayBuffer,
78
+ columns: string[] = [],
79
+ ): Promise<Record<string, any>[]> {
80
+ return parquetReadObjects({
81
+ file: fileBuffer,
82
+ columns: columns.length > 0 ? columns : undefined,
83
+ });
84
+ }
85
+
86
+ // Convert a 2D array to a CSV string
87
+ export function arrayToCSV(data: (number | string)[][]): string {
88
+ return data.map((row) => row.join(",")).join("\n");
89
+ }
90
+
91
+ // Get rows from the current frame data
92
+ export function getRows(currentFrameData: any[], columns: any[]) {
93
+ if (!currentFrameData || currentFrameData.length === 0) {
94
+ return [];
95
+ }
96
+
97
+ const rows = [];
98
+ const nRows = Math.max(...columns.map((column) => column.value.length));
99
+ let rowIndex = 0;
100
+
101
+ while (rowIndex < nRows) {
102
+ const row = [];
103
+ // number of states may NOT match number of actions. In this case, we null-pad the 2D array
104
+ const nullCell = { isNull: true };
105
+ // row consists of [state value, action value]
106
+ let idx = rowIndex;
107
+
108
+ for (const column of columns) {
109
+ const nColumn = column.value.length;
110
+ row.push(rowIndex < nColumn ? currentFrameData[idx] : nullCell);
111
+ idx += nColumn; // because currentFrameData = [state0, state1, ..., stateN, action0, action1, ..., actionN]
112
+ }
113
+
114
+ rowIndex += 1;
115
+ rows.push(row);
116
+ }
117
+
118
+ return rows;
119
+ }
src/utils/pick.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Return copy of object, only keeping whitelisted properties.
3
+ *
4
+ * This doesn't add {p: undefined} anymore, for props not in the o object.
5
+ */
6
+ export function pick<T, K extends keyof T>(
7
+ o: T,
8
+ props: K[] | ReadonlyArray<K>,
9
+ ): Pick<T, K> {
10
+ // inspired by stackoverflow.com/questions/25553910/one-liner-to-take-some-properties-from-object-in-es-6
11
+ return Object.assign(
12
+ {},
13
+ ...props.map((prop) => {
14
+ if (o[prop] !== undefined) {
15
+ return { [prop]: o[prop] };
16
+ }
17
+ }),
18
+ );
19
+ }
src/utils/postParentMessage.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Utility to post a message to the parent window with custom URLSearchParams
2
+ export function postParentMessageWithParams(
3
+ setParams: (params: URLSearchParams) => void,
4
+ ) {
5
+ const parentOrigin = "https://huggingface.co";
6
+ const searchParams = new URLSearchParams();
7
+ setParams(searchParams);
8
+ window.parent.postMessage(
9
+ { queryString: searchParams.toString() },
10
+ parentOrigin,
11
+ );
12
+ }
src/utils/versionUtils.ts ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Utility functions for checking dataset version compatibility
3
+ */
4
+
5
+ const DATASET_URL = process.env.DATASET_URL || "https://huggingface.co/datasets";
6
+
7
+ /**
8
+ * Dataset information structure from info.json
9
+ */
10
+ interface DatasetInfo {
11
+ codebase_version?: string;
12
+ robot_type?: string | null;
13
+ total_episodes: number;
14
+ total_frames: number;
15
+ total_tasks?: number;
16
+ chunks_size?: number;
17
+ data_files_size_in_mb?: number;
18
+ video_files_size_in_mb?: number;
19
+ fps: number;
20
+ splits?: Record<string, string>;
21
+ data_path: string;
22
+ video_path: string;
23
+ features: Record<string, any>;
24
+ }
25
+
26
+ /**
27
+ * Fetches dataset information from the main revision
28
+ */
29
+ export async function getDatasetInfo(repoId: string): Promise<DatasetInfo> {
30
+ try {
31
+ const testUrl = `${DATASET_URL}/${repoId}/resolve/main/meta/info.json`;
32
+
33
+ const controller = new AbortController();
34
+ const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
35
+
36
+ const response = await fetch(testUrl, {
37
+ method: "GET",
38
+ cache: "no-store",
39
+ signal: controller.signal
40
+ });
41
+
42
+ clearTimeout(timeoutId);
43
+
44
+ if (!response.ok) {
45
+ throw new Error(`Failed to fetch dataset info: ${response.status}`);
46
+ }
47
+
48
+ const data = await response.json();
49
+
50
+ // Check if it has the required structure
51
+ if (!data.features) {
52
+ throw new Error("Dataset info.json does not have the expected features structure");
53
+ }
54
+
55
+ return data as DatasetInfo;
56
+ } catch (error) {
57
+ if (error instanceof Error) {
58
+ throw error;
59
+ }
60
+ throw new Error(
61
+ `Dataset ${repoId} is not compatible with this visualizer. ` +
62
+ "Failed to read dataset information from the main revision."
63
+ );
64
+ }
65
+ }
66
+
67
+
68
+ /**
69
+ * Gets the dataset version by reading the codebase_version from the main revision's info.json
70
+ */
71
+ export async function getDatasetVersion(repoId: string): Promise<string> {
72
+ try {
73
+ const datasetInfo = await getDatasetInfo(repoId);
74
+
75
+ // Extract codebase_version
76
+ const codebaseVersion = datasetInfo.codebase_version ?? "v2.0";
77
+
78
+ // Validate that it's a supported version
79
+ const supportedVersions = ["v3.0", "v2.1", "v2.0"];
80
+ if (!supportedVersions.includes(codebaseVersion)) {
81
+ throw new Error(
82
+ `Dataset ${repoId} has codebase version ${codebaseVersion}, which is not supported. ` +
83
+ "This tool only works with dataset versions 3.0, 2.1, or 2.0. " +
84
+ "Please use a compatible dataset version."
85
+ );
86
+ }
87
+
88
+ return codebaseVersion;
89
+ } catch (error) {
90
+ if (error instanceof Error) {
91
+ throw error;
92
+ }
93
+ throw new Error(
94
+ `Dataset ${repoId} is not compatible with this visualizer. ` +
95
+ "Failed to read dataset information from the main revision."
96
+ );
97
+ }
98
+ }
99
+
100
+ export function buildVersionedUrl(repoId: string, version: string, path: string): string {
101
+ return `${DATASET_URL}/${repoId}/resolve/main/${path}`;
102
+ }
103
+
tsconfig.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./src/*"]
23
+ }
24
+ },
25
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26
+ "exclude": ["node_modules"]
27
+ }