Commit
·
6f0655f
1
Parent(s):
98872c8
Initial viewer for humanoid robots dataset
Browse files- .gitattributes +40 -0
- .gitignore +42 -0
- Dockerfile +31 -0
- GRADIO_NOTES.md +8 -0
- LICENSE +201 -0
- README.md +62 -8
- Requirements.md +37 -0
- __pycache__/app.cpython-313.pyc +0 -0
- app.py +460 -0
- eslint.config.mjs +16 -0
- next.config.ts +18 -0
- package-lock.json +0 -0
- package.json +32 -0
- postcss.config.mjs +5 -0
- requirements.txt +5 -0
- src/app/[org]/[dataset]/[episode]/episode-viewer.tsx +260 -0
- src/app/[org]/[dataset]/[episode]/error.tsx +28 -0
- src/app/[org]/[dataset]/[episode]/fetch-data.ts +1088 -0
- src/app/[org]/[dataset]/[episode]/page.tsx +33 -0
- src/app/[org]/[dataset]/page.tsx +15 -0
- src/app/explore/explore-grid.tsx +104 -0
- src/app/explore/page.tsx +103 -0
- src/app/favicon.ico +0 -0
- src/app/globals.css +46 -0
- src/app/home-client.tsx +179 -0
- src/app/layout.tsx +22 -0
- src/app/page.tsx +177 -0
- src/components/data-recharts.tsx +345 -0
- src/components/loading-component.tsx +37 -0
- src/components/playback-bar.tsx +132 -0
- src/components/side-nav.tsx +119 -0
- src/components/simple-videos-player.tsx +270 -0
- src/components/videos-player.tsx +416 -0
- src/context/time-context.tsx +64 -0
- src/utils/debounce.ts +10 -0
- src/utils/parquetUtils.ts +119 -0
- src/utils/pick.ts +19 -0
- src/utils/postParentMessage.ts +12 -0
- src/utils/versionUtils.ts +103 -0
- tsconfig.json +27 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|