Spaces:
Running
Running
Added files from Mishig PR
Browse filesCo-authored-by: Mishig <dmishig@gmail.com>
- .gitignore +42 -0
- README.md +57 -0
- eslint.config.mjs +16 -0
- next.config.ts +15 -0
- package-lock.json +0 -0
- package.json +32 -0
- postcss.config.mjs +5 -0
- src/app/[org]/[dataset]/[episode]/episode-viewer.tsx +231 -0
- src/app/[org]/[dataset]/[episode]/error.tsx +28 -0
- src/app/[org]/[dataset]/[episode]/fetch-data.ts +284 -0
- src/app/[org]/[dataset]/[episode]/page.tsx +28 -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/layout.tsx +22 -0
- src/app/page.tsx +182 -0
- src/components/data-recharts.tsx +343 -0
- src/components/loading-component.tsx +37 -0
- src/components/playback-bar.tsx +132 -0
- src/components/side-nav.tsx +119 -0
- src/components/videos-player.tsx +343 -0
- src/context/time-context.tsx +64 -0
- src/utils/debounce.ts +10 -0
- src/utils/parquetUtils.ts +97 -0
- src/utils/pick.ts +19 -0
- src/utils/postParentMessage.ts +12 -0
- src/utils/versionUtils.ts +85 -0
- tsconfig.json +27 -0
.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
|
README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LeRobot Dataset Visualizer
|
| 2 |
+
|
| 3 |
+
LeRobot Dataset Visualizer is a web application for interactive exploration and visualization of robotics datasets, particularly those in the LeRobot format. It enables users to browse, view, and analyze episodes from large-scale robotics datasets, combining synchronized video playback with rich, interactive data graphs.
|
| 4 |
+
|
| 5 |
+
## Project Overview
|
| 6 |
+
|
| 7 |
+
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:
|
| 8 |
+
|
| 9 |
+
- Navigating between organizations, datasets, and episodes
|
| 10 |
+
- Watching episode videos
|
| 11 |
+
- Exploring synchronized time-series data with interactive charts
|
| 12 |
+
- Paginating through large datasets efficiently
|
| 13 |
+
|
| 14 |
+
## Key Features
|
| 15 |
+
|
| 16 |
+
- **Dataset & Episode Navigation:** Quickly jump between organizations, datasets, and episodes using a sidebar and navigation controls.
|
| 17 |
+
- **Synchronized Video & Data:** Video playback is synchronized with interactive data graphs for detailed inspection of sensor and control signals.
|
| 18 |
+
- **Efficient Data Loading:** Uses parquet and JSON loading for large dataset support, with pagination and chunking.
|
| 19 |
+
- **Responsive UI:** Built with React, Next.js, and Tailwind CSS for a fast, modern user experience.
|
| 20 |
+
|
| 21 |
+
## Technologies Used
|
| 22 |
+
|
| 23 |
+
- **Next.js** (App Router)
|
| 24 |
+
- **React**
|
| 25 |
+
- **Recharts** (for data visualization)
|
| 26 |
+
- **hyparquet** (for reading Parquet files)
|
| 27 |
+
- **Tailwind CSS** (styling)
|
| 28 |
+
|
| 29 |
+
## Getting Started
|
| 30 |
+
|
| 31 |
+
First, run the development server:
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
npm run dev
|
| 35 |
+
# or
|
| 36 |
+
yarn dev
|
| 37 |
+
# or
|
| 38 |
+
pnpm dev
|
| 39 |
+
# or
|
| 40 |
+
bun dev
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 44 |
+
|
| 45 |
+
You can start editing the page by modifying `src/app/page.tsx` or other files in the `src/` directory. The app supports hot-reloading for rapid development.
|
| 46 |
+
|
| 47 |
+
### Environment Variables
|
| 48 |
+
|
| 49 |
+
- `DATASET_URL`: (optional) Base URL for dataset hosting (defaults to HuggingFace Datasets).
|
| 50 |
+
|
| 51 |
+
## Contributing
|
| 52 |
+
|
| 53 |
+
Contributions, bug reports, and feature requests are welcome! Please open an issue or submit a pull request.
|
| 54 |
+
|
| 55 |
+
## License
|
| 56 |
+
|
| 57 |
+
This project is licensed under the MIT License.
|
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,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
import packageJson from './package.json';
|
| 3 |
+
|
| 4 |
+
const nextConfig: NextConfig = {
|
| 5 |
+
|
| 6 |
+
typescript: {
|
| 7 |
+
ignoreBuildErrors: true,
|
| 8 |
+
},
|
| 9 |
+
eslint: {
|
| 10 |
+
ignoreDuringBuilds: true,
|
| 11 |
+
},
|
| 12 |
+
generateBuildId: () => packageJson.version,
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
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;
|
src/app/[org]/[dataset]/[episode]/episode-viewer.tsx
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 VideosPlayer from "@/components/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 |
+
|
| 13 |
+
export default function EpisodeViewer({
|
| 14 |
+
data,
|
| 15 |
+
error,
|
| 16 |
+
}: {
|
| 17 |
+
data?: any;
|
| 18 |
+
error?: string;
|
| 19 |
+
}) {
|
| 20 |
+
if (error) {
|
| 21 |
+
return (
|
| 22 |
+
<div className="flex h-screen items-center justify-center bg-slate-950 text-red-400">
|
| 23 |
+
<div className="max-w-xl p-8 rounded bg-slate-900 border border-red-500 shadow-lg">
|
| 24 |
+
<h2 className="text-2xl font-bold mb-4">Something went wrong</h2>
|
| 25 |
+
<p className="text-lg font-mono whitespace-pre-wrap mb-4">{error}</p>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
return (
|
| 31 |
+
<TimeProvider duration={data.duration}>
|
| 32 |
+
<EpisodeViewerInner data={data} />
|
| 33 |
+
</TimeProvider>
|
| 34 |
+
);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function EpisodeViewerInner({ data }: { data: any }) {
|
| 38 |
+
const {
|
| 39 |
+
datasetInfo,
|
| 40 |
+
episodeId,
|
| 41 |
+
videosInfo,
|
| 42 |
+
chartDataGroups,
|
| 43 |
+
episodes,
|
| 44 |
+
ignoredColumns,
|
| 45 |
+
} = data;
|
| 46 |
+
|
| 47 |
+
const [videosReady, setVideosReady] = useState(!videosInfo.length);
|
| 48 |
+
const [chartsReady, setChartsReady] = useState(false);
|
| 49 |
+
const isLoading = !videosReady || !chartsReady;
|
| 50 |
+
|
| 51 |
+
const router = useRouter();
|
| 52 |
+
const searchParams = useSearchParams();
|
| 53 |
+
|
| 54 |
+
// State
|
| 55 |
+
// Use context for time sync
|
| 56 |
+
const { currentTime, setCurrentTime, setIsPlaying, isPlaying } = useTime();
|
| 57 |
+
|
| 58 |
+
// Pagination state
|
| 59 |
+
const pageSize = 100;
|
| 60 |
+
const [currentPage, setCurrentPage] = useState(1);
|
| 61 |
+
const totalPages = Math.ceil(episodes.length / pageSize);
|
| 62 |
+
const paginatedEpisodes = episodes.slice(
|
| 63 |
+
(currentPage - 1) * pageSize,
|
| 64 |
+
currentPage * pageSize,
|
| 65 |
+
);
|
| 66 |
+
|
| 67 |
+
// Initialize based on URL time parameter
|
| 68 |
+
useEffect(() => {
|
| 69 |
+
const timeParam = searchParams.get("t");
|
| 70 |
+
if (timeParam) {
|
| 71 |
+
const timeValue = parseFloat(timeParam);
|
| 72 |
+
if (!isNaN(timeValue)) {
|
| 73 |
+
setCurrentTime(timeValue);
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
}, []);
|
| 77 |
+
|
| 78 |
+
// sync with parent window hf.co/spaces
|
| 79 |
+
useEffect(() => {
|
| 80 |
+
postParentMessageWithParams((params: URLSearchParams) => {
|
| 81 |
+
params.set("path", window.location.pathname + window.location.search);
|
| 82 |
+
});
|
| 83 |
+
}, []);
|
| 84 |
+
|
| 85 |
+
// Initialize based on URL time parameter
|
| 86 |
+
useEffect(() => {
|
| 87 |
+
// Initialize page based on current episode
|
| 88 |
+
const episodeIndex = episodes.indexOf(episodeId);
|
| 89 |
+
if (episodeIndex !== -1) {
|
| 90 |
+
setCurrentPage(Math.floor(episodeIndex / pageSize) + 1);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Add keyboard event listener
|
| 94 |
+
window.addEventListener("keydown", handleKeyDown);
|
| 95 |
+
return () => {
|
| 96 |
+
window.removeEventListener("keydown", handleKeyDown);
|
| 97 |
+
};
|
| 98 |
+
}, [episodes, episodeId, pageSize, searchParams]);
|
| 99 |
+
|
| 100 |
+
// Only update URL ?t= param when the integer second changes
|
| 101 |
+
const lastUrlSecondRef = useRef<number>(-1);
|
| 102 |
+
useEffect(() => {
|
| 103 |
+
if (isPlaying) return;
|
| 104 |
+
const currentSec = Math.floor(currentTime);
|
| 105 |
+
if (currentTime > 0 && lastUrlSecondRef.current !== currentSec) {
|
| 106 |
+
lastUrlSecondRef.current = currentSec;
|
| 107 |
+
const newParams = new URLSearchParams(searchParams.toString());
|
| 108 |
+
newParams.set("t", currentSec.toString());
|
| 109 |
+
// Replace state instead of pushing to avoid navigation stack bloat
|
| 110 |
+
window.history.replaceState(
|
| 111 |
+
{},
|
| 112 |
+
"",
|
| 113 |
+
`${window.location.pathname}?${newParams.toString()}`,
|
| 114 |
+
);
|
| 115 |
+
postParentMessageWithParams((params: URLSearchParams) => {
|
| 116 |
+
params.set("path", window.location.pathname + window.location.search);
|
| 117 |
+
});
|
| 118 |
+
}
|
| 119 |
+
}, [isPlaying, currentTime, searchParams]);
|
| 120 |
+
|
| 121 |
+
// Handle keyboard shortcuts
|
| 122 |
+
const handleKeyDown = (e: KeyboardEvent) => {
|
| 123 |
+
const { key } = e;
|
| 124 |
+
|
| 125 |
+
if (key === " ") {
|
| 126 |
+
e.preventDefault();
|
| 127 |
+
setIsPlaying((prev: boolean) => !prev);
|
| 128 |
+
} else if (key === "ArrowDown" || key === "ArrowUp") {
|
| 129 |
+
e.preventDefault();
|
| 130 |
+
const nextEpisodeId = key === "ArrowDown" ? episodeId + 1 : episodeId - 1;
|
| 131 |
+
const lowestEpisodeId = episodes[0];
|
| 132 |
+
const highestEpisodeId = episodes[episodes.length - 1];
|
| 133 |
+
|
| 134 |
+
if (
|
| 135 |
+
nextEpisodeId >= lowestEpisodeId &&
|
| 136 |
+
nextEpisodeId <= highestEpisodeId
|
| 137 |
+
) {
|
| 138 |
+
router.push(`./episode_${nextEpisodeId}`);
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
};
|
| 142 |
+
|
| 143 |
+
// Pagination functions
|
| 144 |
+
const nextPage = () => {
|
| 145 |
+
if (currentPage < totalPages) {
|
| 146 |
+
setCurrentPage((prev) => prev + 1);
|
| 147 |
+
}
|
| 148 |
+
};
|
| 149 |
+
|
| 150 |
+
const prevPage = () => {
|
| 151 |
+
if (currentPage > 1) {
|
| 152 |
+
setCurrentPage((prev) => prev - 1);
|
| 153 |
+
}
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
return (
|
| 157 |
+
<div className="flex h-screen max-h-screen bg-slate-950 text-gray-200">
|
| 158 |
+
{/* Sidebar */}
|
| 159 |
+
<Sidebar
|
| 160 |
+
datasetInfo={datasetInfo}
|
| 161 |
+
paginatedEpisodes={paginatedEpisodes}
|
| 162 |
+
episodeId={episodeId}
|
| 163 |
+
totalPages={totalPages}
|
| 164 |
+
currentPage={currentPage}
|
| 165 |
+
prevPage={prevPage}
|
| 166 |
+
nextPage={nextPage}
|
| 167 |
+
/>
|
| 168 |
+
|
| 169 |
+
{/* Content */}
|
| 170 |
+
<div
|
| 171 |
+
className={`flex max-h-screen flex-col gap-4 p-4 md:flex-1 relative ${isLoading ? "overflow-hidden" : "overflow-y-auto"}`}
|
| 172 |
+
>
|
| 173 |
+
{isLoading && <Loading />}
|
| 174 |
+
|
| 175 |
+
<div className="flex items-center justify-start my-4">
|
| 176 |
+
<a
|
| 177 |
+
href="https://github.com/huggingface/lerobot"
|
| 178 |
+
target="_blank"
|
| 179 |
+
className="block"
|
| 180 |
+
>
|
| 181 |
+
<img
|
| 182 |
+
src="https://github.com/huggingface/lerobot/raw/main/media/lerobot-logo-thumbnail.png"
|
| 183 |
+
alt="LeRobot Logo"
|
| 184 |
+
className="w-32"
|
| 185 |
+
/>
|
| 186 |
+
</a>
|
| 187 |
+
|
| 188 |
+
<div>
|
| 189 |
+
<a
|
| 190 |
+
href={`https://huggingface.co/datasets/${datasetInfo.repoId}`}
|
| 191 |
+
target="_blank"
|
| 192 |
+
>
|
| 193 |
+
<p className="text-lg font-semibold">{datasetInfo.repoId}</p>
|
| 194 |
+
</a>
|
| 195 |
+
|
| 196 |
+
<p className="font-mono text-lg font-semibold">
|
| 197 |
+
episode {episodeId}
|
| 198 |
+
</p>
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
{/* Videos */}
|
| 203 |
+
{videosInfo.length && (
|
| 204 |
+
<VideosPlayer
|
| 205 |
+
videosInfo={videosInfo}
|
| 206 |
+
onVideosReady={() => setVideosReady(true)}
|
| 207 |
+
/>
|
| 208 |
+
)}
|
| 209 |
+
|
| 210 |
+
{/* Graph */}
|
| 211 |
+
<div className="mb-4">
|
| 212 |
+
<DataRecharts
|
| 213 |
+
data={chartDataGroups}
|
| 214 |
+
onChartsReady={() => setChartsReady(true)}
|
| 215 |
+
/>
|
| 216 |
+
|
| 217 |
+
{ignoredColumns.length > 0 && (
|
| 218 |
+
<p className="mt-2 text-orange-700">
|
| 219 |
+
Columns{" "}
|
| 220 |
+
<span className="font-mono">{ignoredColumns.join(", ")}</span> are
|
| 221 |
+
NOT shown since the visualizer currently does not support 2D or 3D
|
| 222 |
+
data.
|
| 223 |
+
</p>
|
| 224 |
+
)}
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
<PlaybackBar />
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
);
|
| 231 |
+
}
|
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,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
DatasetMetadata,
|
| 3 |
+
fetchJson,
|
| 4 |
+
fetchParquetFile,
|
| 5 |
+
formatStringWithVars,
|
| 6 |
+
readParquetColumn,
|
| 7 |
+
} from "@/utils/parquetUtils";
|
| 8 |
+
import { pick } from "@/utils/pick";
|
| 9 |
+
import { getDatasetVersion, buildVersionedUrl } from "@/utils/versionUtils";
|
| 10 |
+
|
| 11 |
+
const DATASET_URL =
|
| 12 |
+
process.env.DATASET_URL || "https://huggingface.co/datasets";
|
| 13 |
+
|
| 14 |
+
const SERIES_NAME_DELIMITER = " | ";
|
| 15 |
+
|
| 16 |
+
export async function getEpisodeData(
|
| 17 |
+
org: string,
|
| 18 |
+
dataset: string,
|
| 19 |
+
episodeId: number,
|
| 20 |
+
) {
|
| 21 |
+
const repoId = `${org}/${dataset}`;
|
| 22 |
+
try {
|
| 23 |
+
const episode_chunk = Math.floor(0 / 1000);
|
| 24 |
+
|
| 25 |
+
// Check for compatible dataset version (v2.1 or v2.0)
|
| 26 |
+
const version = await getDatasetVersion(repoId);
|
| 27 |
+
const jsonUrl = buildVersionedUrl(repoId, version, "meta/info.json");
|
| 28 |
+
|
| 29 |
+
const info = await fetchJson<DatasetMetadata>(jsonUrl);
|
| 30 |
+
|
| 31 |
+
// Dataset information
|
| 32 |
+
const datasetInfo = {
|
| 33 |
+
repoId,
|
| 34 |
+
total_frames: info.total_frames,
|
| 35 |
+
total_episodes: info.total_episodes,
|
| 36 |
+
fps: info.fps,
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
// Generate list of episodes
|
| 40 |
+
const episodes =
|
| 41 |
+
process.env.EPISODES === undefined
|
| 42 |
+
? Array.from(
|
| 43 |
+
{ length: datasetInfo.total_episodes },
|
| 44 |
+
// episode id starts from 0
|
| 45 |
+
(_, i) => i,
|
| 46 |
+
)
|
| 47 |
+
: process.env.EPISODES
|
| 48 |
+
.split(/\s+/)
|
| 49 |
+
.map((x) => parseInt(x.trim(), 10))
|
| 50 |
+
.filter((x) => !isNaN(x));
|
| 51 |
+
|
| 52 |
+
// Videos information
|
| 53 |
+
const videosInfo = Object.entries(info.features)
|
| 54 |
+
.filter(([key, value]) => value.dtype === "video")
|
| 55 |
+
.map(([key, _]) => {
|
| 56 |
+
const videoPath = formatStringWithVars(info.video_path, {
|
| 57 |
+
video_key: key,
|
| 58 |
+
episode_chunk: episode_chunk.toString().padStart(3, "0"),
|
| 59 |
+
episode_index: episodeId.toString().padStart(6, "0"),
|
| 60 |
+
});
|
| 61 |
+
return {
|
| 62 |
+
filename: key,
|
| 63 |
+
url: buildVersionedUrl(repoId, version, videoPath),
|
| 64 |
+
};
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
// Column data
|
| 68 |
+
const columnNames = Object.entries(info.features)
|
| 69 |
+
.filter(
|
| 70 |
+
([key, value]) =>
|
| 71 |
+
["float32", "int32"].includes(value.dtype) &&
|
| 72 |
+
value.shape.length === 1,
|
| 73 |
+
)
|
| 74 |
+
.map(([key, { shape }]) => ({ key, length: shape[0] }));
|
| 75 |
+
|
| 76 |
+
// Exclude specific columns
|
| 77 |
+
const excludedColumns = [
|
| 78 |
+
"timestamp",
|
| 79 |
+
"frame_index",
|
| 80 |
+
"episode_index",
|
| 81 |
+
"index",
|
| 82 |
+
"task_index",
|
| 83 |
+
];
|
| 84 |
+
const filteredColumns = columnNames.filter(
|
| 85 |
+
(column) => !excludedColumns.includes(column.key),
|
| 86 |
+
);
|
| 87 |
+
const filteredColumnNames = [
|
| 88 |
+
"timestamp",
|
| 89 |
+
...filteredColumns.map((column) => column.key),
|
| 90 |
+
];
|
| 91 |
+
|
| 92 |
+
const columns = filteredColumns.map(({ key }) => {
|
| 93 |
+
let column_names = info.features[key].names;
|
| 94 |
+
while (typeof column_names === "object") {
|
| 95 |
+
if (Array.isArray(column_names)) break;
|
| 96 |
+
column_names = Object.values(column_names ?? {})[0];
|
| 97 |
+
}
|
| 98 |
+
return {
|
| 99 |
+
key,
|
| 100 |
+
value: Array.isArray(column_names)
|
| 101 |
+
? column_names.map((name) => `${key}${SERIES_NAME_DELIMITER}${name}`)
|
| 102 |
+
: Array.from(
|
| 103 |
+
{ length: columnNames.find((c) => c.key === key)?.length ?? 1 },
|
| 104 |
+
(_, i) => `${key}${SERIES_NAME_DELIMITER}${i}`,
|
| 105 |
+
),
|
| 106 |
+
};
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
const parquetUrl = buildVersionedUrl(
|
| 110 |
+
repoId,
|
| 111 |
+
version,
|
| 112 |
+
formatStringWithVars(info.data_path, {
|
| 113 |
+
episode_chunk: episode_chunk.toString().padStart(3, "0"),
|
| 114 |
+
episode_index: episodeId.toString().padStart(6, "0"),
|
| 115 |
+
})
|
| 116 |
+
);
|
| 117 |
+
|
| 118 |
+
const arrayBuffer = await fetchParquetFile(parquetUrl);
|
| 119 |
+
const data = await readParquetColumn(arrayBuffer, filteredColumnNames);
|
| 120 |
+
// Flatten and map to array of objects for chartData
|
| 121 |
+
const seriesNames = [
|
| 122 |
+
"timestamp",
|
| 123 |
+
...columns.map(({ value }) => value).flat(),
|
| 124 |
+
];
|
| 125 |
+
|
| 126 |
+
const chartData = data.map((row) => {
|
| 127 |
+
const flatRow = row.flat();
|
| 128 |
+
const obj: Record<string, number> = {};
|
| 129 |
+
seriesNames.forEach((key, idx) => {
|
| 130 |
+
obj[key] = flatRow[idx];
|
| 131 |
+
});
|
| 132 |
+
return obj;
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
// List of columns that are ignored (e.g., 2D or 3D data)
|
| 136 |
+
const ignoredColumns = Object.entries(info.features)
|
| 137 |
+
.filter(
|
| 138 |
+
([key, value]) =>
|
| 139 |
+
["float32", "int32"].includes(value.dtype) && value.shape.length > 1,
|
| 140 |
+
)
|
| 141 |
+
.map(([key]) => key);
|
| 142 |
+
|
| 143 |
+
// 1. Group all numeric keys by suffix (excluding 'timestamp')
|
| 144 |
+
const numericKeys = seriesNames.filter((k) => k !== "timestamp");
|
| 145 |
+
const suffixGroupsMap: Record<string, string[]> = {};
|
| 146 |
+
for (const key of numericKeys) {
|
| 147 |
+
const parts = key.split(SERIES_NAME_DELIMITER);
|
| 148 |
+
const suffix = parts[1] || parts[0]; // fallback to key if no delimiter
|
| 149 |
+
if (!suffixGroupsMap[suffix]) suffixGroupsMap[suffix] = [];
|
| 150 |
+
suffixGroupsMap[suffix].push(key);
|
| 151 |
+
}
|
| 152 |
+
const suffixGroups = Object.values(suffixGroupsMap);
|
| 153 |
+
|
| 154 |
+
// 2. Compute min/max for each suffix group as a whole
|
| 155 |
+
const groupStats: Record<string, { min: number; max: number }> = {};
|
| 156 |
+
suffixGroups.forEach((group) => {
|
| 157 |
+
let min = Infinity,
|
| 158 |
+
max = -Infinity;
|
| 159 |
+
for (const row of chartData) {
|
| 160 |
+
for (const key of group) {
|
| 161 |
+
const v = row[key];
|
| 162 |
+
if (typeof v === "number" && !isNaN(v)) {
|
| 163 |
+
if (v < min) min = v;
|
| 164 |
+
if (v > max) max = v;
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
// Use the first key in the group as the group id
|
| 169 |
+
groupStats[group[0]] = { min, max };
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
// 3. Group suffix groups by similar scale (treat each suffix group as a unit)
|
| 173 |
+
const scaleGroups: Record<string, string[][]> = {};
|
| 174 |
+
const used = new Set<string>();
|
| 175 |
+
const SCALE_THRESHOLD = 2;
|
| 176 |
+
for (const group of suffixGroups) {
|
| 177 |
+
const groupId = group[0];
|
| 178 |
+
if (used.has(groupId)) continue;
|
| 179 |
+
const { min, max } = groupStats[groupId];
|
| 180 |
+
if (!isFinite(min) || !isFinite(max)) continue;
|
| 181 |
+
const logMin = Math.log10(Math.abs(min) + 1e-9);
|
| 182 |
+
const logMax = Math.log10(Math.abs(max) + 1e-9);
|
| 183 |
+
const unit: string[][] = [group];
|
| 184 |
+
used.add(groupId);
|
| 185 |
+
for (const other of suffixGroups) {
|
| 186 |
+
const otherId = other[0];
|
| 187 |
+
if (used.has(otherId) || otherId === groupId) continue;
|
| 188 |
+
const { min: omin, max: omax } = groupStats[otherId];
|
| 189 |
+
if (!isFinite(omin) || !isFinite(omax) || omin === omax) continue;
|
| 190 |
+
const ologMin = Math.log10(Math.abs(omin) + 1e-9);
|
| 191 |
+
const ologMax = Math.log10(Math.abs(omax) + 1e-9);
|
| 192 |
+
if (
|
| 193 |
+
Math.abs(logMin - ologMin) <= SCALE_THRESHOLD &&
|
| 194 |
+
Math.abs(logMax - ologMax) <= SCALE_THRESHOLD
|
| 195 |
+
) {
|
| 196 |
+
unit.push(other);
|
| 197 |
+
used.add(otherId);
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
scaleGroups[groupId] = unit;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// 4. Flatten scaleGroups into chartGroups (array of arrays of keys)
|
| 204 |
+
const chartGroups: string[][] = Object.values(scaleGroups)
|
| 205 |
+
.sort((a, b) => b.length - a.length)
|
| 206 |
+
.flatMap((suffixGroupArr) => {
|
| 207 |
+
// suffixGroupArr is array of suffix groups (each is array of keys)
|
| 208 |
+
const merged = suffixGroupArr.flat();
|
| 209 |
+
if (merged.length > 6) {
|
| 210 |
+
const subgroups = [];
|
| 211 |
+
for (let i = 0; i < merged.length; i += 6) {
|
| 212 |
+
subgroups.push(merged.slice(i, i + 6));
|
| 213 |
+
}
|
| 214 |
+
return subgroups;
|
| 215 |
+
}
|
| 216 |
+
return [merged];
|
| 217 |
+
});
|
| 218 |
+
|
| 219 |
+
const duration = chartData[chartData.length - 1].timestamp;
|
| 220 |
+
|
| 221 |
+
// Utility: group row keys by suffix
|
| 222 |
+
function groupRowBySuffix(row: Record<string, number>): Record<string, any> {
|
| 223 |
+
const result: Record<string, any> = {};
|
| 224 |
+
const suffixGroups: Record<string, Record<string, number>> = {};
|
| 225 |
+
for (const [key, value] of Object.entries(row)) {
|
| 226 |
+
if (key === "timestamp") {
|
| 227 |
+
result["timestamp"] = value;
|
| 228 |
+
continue;
|
| 229 |
+
}
|
| 230 |
+
const parts = key.split(SERIES_NAME_DELIMITER);
|
| 231 |
+
if (parts.length === 2) {
|
| 232 |
+
const [prefix, suffix] = parts;
|
| 233 |
+
if (!suffixGroups[suffix]) suffixGroups[suffix] = {};
|
| 234 |
+
suffixGroups[suffix][prefix] = value;
|
| 235 |
+
} else {
|
| 236 |
+
result[key] = value;
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
for (const [suffix, group] of Object.entries(suffixGroups)) {
|
| 240 |
+
const keys = Object.keys(group);
|
| 241 |
+
if (keys.length === 1) {
|
| 242 |
+
// Use the full original name as the key
|
| 243 |
+
const fullName = `${keys[0]}${SERIES_NAME_DELIMITER}${suffix}`;
|
| 244 |
+
result[fullName] = group[keys[0]];
|
| 245 |
+
} else {
|
| 246 |
+
result[suffix] = group;
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
return result;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
const chartDataGroups = chartGroups.map((group) =>
|
| 253 |
+
chartData.map((row) => groupRowBySuffix(pick(row, [...group, "timestamp"])))
|
| 254 |
+
);
|
| 255 |
+
|
| 256 |
+
return {
|
| 257 |
+
datasetInfo,
|
| 258 |
+
episodeId,
|
| 259 |
+
videosInfo,
|
| 260 |
+
chartDataGroups,
|
| 261 |
+
episodes,
|
| 262 |
+
ignoredColumns,
|
| 263 |
+
duration,
|
| 264 |
+
};
|
| 265 |
+
} catch (err) {
|
| 266 |
+
console.error("Error loading episode data:", err);
|
| 267 |
+
throw err;
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
// Safe wrapper for UI error display
|
| 272 |
+
export async function getEpisodeDataSafe(
|
| 273 |
+
org: string,
|
| 274 |
+
dataset: string,
|
| 275 |
+
episodeId: number,
|
| 276 |
+
): Promise<{ data?: any; error?: string }> {
|
| 277 |
+
try {
|
| 278 |
+
const data = await getEpisodeData(org, dataset, episodeId);
|
| 279 |
+
return { data };
|
| 280 |
+
} catch (err: any) {
|
| 281 |
+
// Only expose the error message, not stack or sensitive info
|
| 282 |
+
return { error: err?.message || String(err) || "Unknown error" };
|
| 283 |
+
}
|
| 284 |
+
}
|
src/app/[org]/[dataset]/[episode]/page.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import EpisodeViewer from "./episode-viewer";
|
| 2 |
+
import { getEpisodeDataSafe } from "./fetch-data";
|
| 3 |
+
|
| 4 |
+
export const dynamic = "force-dynamic";
|
| 5 |
+
|
| 6 |
+
export async function generateMetadata({
|
| 7 |
+
params,
|
| 8 |
+
}: {
|
| 9 |
+
params: Promise<{ org: string; dataset: string; episode: string }>;
|
| 10 |
+
}) {
|
| 11 |
+
const { org, dataset, episode } = await params;
|
| 12 |
+
return {
|
| 13 |
+
title: `${org}/${dataset} | episode ${episode}`,
|
| 14 |
+
};
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export default async function EpisodePage({
|
| 18 |
+
params,
|
| 19 |
+
}: {
|
| 20 |
+
params: Promise<{ org: string; dataset: string; episode: string }>;
|
| 21 |
+
}) {
|
| 22 |
+
// episode is like 'episode_1'
|
| 23 |
+
const { org, dataset, episode } = await params;
|
| 24 |
+
// fetchData should be updated if needed to support this path pattern
|
| 25 |
+
const episodeNumber = Number(episode.replace(/^episode_/, ""));
|
| 26 |
+
const { data, error } = await getEpisodeDataSafe(org, dataset, episodeNumber);
|
| 27 |
+
return <EpisodeViewer data={data} error={error} />;
|
| 28 |
+
}
|
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 (e) {
|
| 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 |
+
([key, 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 (e) {
|
| 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/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,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useRef } 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 |
+
const searchParams = useSearchParams();
|
| 9 |
+
const router = useRouter();
|
| 10 |
+
|
| 11 |
+
// Handle redirects with useEffect instead of direct redirect
|
| 12 |
+
useEffect(() => {
|
| 13 |
+
// Redirect to the first episode of the dataset if REPO_ID is defined
|
| 14 |
+
if (process.env.REPO_ID) {
|
| 15 |
+
const episodeN = process.env.EPISODES
|
| 16 |
+
?.split(/\s+/)
|
| 17 |
+
.map((x) => parseInt(x.trim(), 10))
|
| 18 |
+
.filter((x) => !isNaN(x))[0] ?? 0;
|
| 19 |
+
|
| 20 |
+
router.push(`/${process.env.REPO_ID}/episode_${episodeN}`);
|
| 21 |
+
return;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// sync with hf.co/spaces URL params
|
| 25 |
+
if (searchParams.get('path')) {
|
| 26 |
+
router.push(searchParams.get('path')!);
|
| 27 |
+
return;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// legacy sync with hf.co/spaces URL params
|
| 31 |
+
let redirectUrl: string | null = null;
|
| 32 |
+
if (searchParams.get('dataset') && searchParams.get('episode')) {
|
| 33 |
+
redirectUrl = `/${searchParams.get('dataset')}/episode_${searchParams.get('episode')}`;
|
| 34 |
+
} else if (searchParams.get('dataset')) {
|
| 35 |
+
redirectUrl = `/${searchParams.get('dataset')}`;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
if (redirectUrl && searchParams.get('t')) {
|
| 39 |
+
redirectUrl += `?t=${searchParams.get('t')}`;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
if (redirectUrl) {
|
| 43 |
+
router.push(redirectUrl);
|
| 44 |
+
return;
|
| 45 |
+
}
|
| 46 |
+
}, [searchParams, router]);
|
| 47 |
+
|
| 48 |
+
const playerRef = useRef<any>(null);
|
| 49 |
+
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
// Load YouTube IFrame API if not already present
|
| 52 |
+
if (!(window as any).YT) {
|
| 53 |
+
const tag = document.createElement("script");
|
| 54 |
+
tag.src = "https://www.youtube.com/iframe_api";
|
| 55 |
+
document.body.appendChild(tag);
|
| 56 |
+
}
|
| 57 |
+
let interval: NodeJS.Timeout;
|
| 58 |
+
(window as any).onYouTubeIframeAPIReady = () => {
|
| 59 |
+
playerRef.current = new (window as any).YT.Player("yt-bg-player", {
|
| 60 |
+
videoId: "Er8SPJsIYr0",
|
| 61 |
+
playerVars: {
|
| 62 |
+
autoplay: 1,
|
| 63 |
+
mute: 1,
|
| 64 |
+
controls: 0,
|
| 65 |
+
showinfo: 0,
|
| 66 |
+
modestbranding: 1,
|
| 67 |
+
rel: 0,
|
| 68 |
+
loop: 1,
|
| 69 |
+
fs: 0,
|
| 70 |
+
playlist: "Er8SPJsIYr0",
|
| 71 |
+
start: 0,
|
| 72 |
+
},
|
| 73 |
+
events: {
|
| 74 |
+
onReady: (event: any) => {
|
| 75 |
+
event.target.playVideo();
|
| 76 |
+
event.target.mute();
|
| 77 |
+
interval = setInterval(() => {
|
| 78 |
+
const t = event.target.getCurrentTime();
|
| 79 |
+
if (t >= 60) {
|
| 80 |
+
event.target.seekTo(0);
|
| 81 |
+
}
|
| 82 |
+
}, 500);
|
| 83 |
+
},
|
| 84 |
+
},
|
| 85 |
+
});
|
| 86 |
+
};
|
| 87 |
+
return () => {
|
| 88 |
+
if (interval) clearInterval(interval);
|
| 89 |
+
if (playerRef.current && playerRef.current.destroy)
|
| 90 |
+
playerRef.current.destroy();
|
| 91 |
+
};
|
| 92 |
+
}, []);
|
| 93 |
+
|
| 94 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
| 95 |
+
|
| 96 |
+
const handleGo = (e: React.FormEvent) => {
|
| 97 |
+
e.preventDefault();
|
| 98 |
+
const value = inputRef.current?.value.trim();
|
| 99 |
+
if (value) {
|
| 100 |
+
router.push(value);
|
| 101 |
+
}
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
return (
|
| 105 |
+
<div className="relative h-screen w-screen overflow-hidden">
|
| 106 |
+
{/* YouTube Video Background */}
|
| 107 |
+
<div className="video-background">
|
| 108 |
+
<div id="yt-bg-player" />
|
| 109 |
+
</div>
|
| 110 |
+
{/* Overlay */}
|
| 111 |
+
<div className="fixed top-0 right-0 bottom-0 left-0 bg-black/60 -z-0" />
|
| 112 |
+
{/* Centered Content */}
|
| 113 |
+
<div className="relative z-10 h-screen flex flex-col items-center justify-center text-white text-center">
|
| 114 |
+
<h1 className="text-4xl md:text-5xl font-bold mb-6 drop-shadow-lg">
|
| 115 |
+
LeRobot Dataset Visualizer
|
| 116 |
+
</h1>
|
| 117 |
+
<a
|
| 118 |
+
href="https://x.com/RemiCadene/status/1825455895561859185"
|
| 119 |
+
target="_blank"
|
| 120 |
+
rel="noopener noreferrer"
|
| 121 |
+
className="text-sky-400 font-medium text-lg underline mb-8 inline-block hover:text-sky-300 transition-colors"
|
| 122 |
+
>
|
| 123 |
+
create & train your own robots
|
| 124 |
+
</a>
|
| 125 |
+
<form onSubmit={handleGo} className="flex gap-2 justify-center mt-6">
|
| 126 |
+
<input
|
| 127 |
+
ref={inputRef}
|
| 128 |
+
type="text"
|
| 129 |
+
placeholder="Enter dataset id (e.g. lerobot/visualize_dataset)"
|
| 130 |
+
className="px-4 py-2 rounded-md text-base text-white border-white border-1 focus:outline-none min-w-[220px] shadow-md"
|
| 131 |
+
onKeyDown={(e) => {
|
| 132 |
+
if (e.key === "Enter") {
|
| 133 |
+
// Prevent double submission if form onSubmit also fires
|
| 134 |
+
e.preventDefault();
|
| 135 |
+
handleGo(e as any);
|
| 136 |
+
}
|
| 137 |
+
}}
|
| 138 |
+
/>
|
| 139 |
+
<button
|
| 140 |
+
type="submit"
|
| 141 |
+
className="px-5 py-2 rounded-md bg-sky-400 text-black font-semibold text-base hover:bg-sky-300 transition-colors shadow-md"
|
| 142 |
+
>
|
| 143 |
+
Go
|
| 144 |
+
</button>
|
| 145 |
+
</form>
|
| 146 |
+
{/* Example Datasets */}
|
| 147 |
+
<div className="mt-8">
|
| 148 |
+
<div className="font-semibold mb-2 text-lg">Example Datasets:</div>
|
| 149 |
+
<div className="flex flex-col gap-2 items-center">
|
| 150 |
+
{[
|
| 151 |
+
"lerobot/aloha_static_cups_open",
|
| 152 |
+
"lerobot/columbia_cairlab_pusht_real",
|
| 153 |
+
"lerobot/taco_play",
|
| 154 |
+
].map((ds) => (
|
| 155 |
+
<button
|
| 156 |
+
key={ds}
|
| 157 |
+
type="button"
|
| 158 |
+
className="px-4 py-2 rounded bg-slate-700 text-sky-200 hover:bg-sky-700 hover:text-white transition-colors shadow"
|
| 159 |
+
onClick={() => {
|
| 160 |
+
if (inputRef.current) {
|
| 161 |
+
inputRef.current.value = ds;
|
| 162 |
+
inputRef.current.focus();
|
| 163 |
+
}
|
| 164 |
+
router.push(ds);
|
| 165 |
+
}}
|
| 166 |
+
>
|
| 167 |
+
{ds}
|
| 168 |
+
</button>
|
| 169 |
+
))}
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<Link
|
| 174 |
+
href="/explore"
|
| 175 |
+
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"
|
| 176 |
+
>
|
| 177 |
+
Explore Open Datasets
|
| 178 |
+
</Link>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
);
|
| 182 |
+
}
|
src/components/data-recharts.tsx
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
export const DataRecharts = React.memo(
|
| 23 |
+
({ data, onChartsReady }: DataGraphProps) => {
|
| 24 |
+
// Shared hoveredTime for all graphs
|
| 25 |
+
const [hoveredTime, setHoveredTime] = useState<number | null>(null);
|
| 26 |
+
|
| 27 |
+
if (!Array.isArray(data) || data.length === 0) return null;
|
| 28 |
+
|
| 29 |
+
useEffect(() => {
|
| 30 |
+
if (typeof onChartsReady === "function") {
|
| 31 |
+
onChartsReady();
|
| 32 |
+
}
|
| 33 |
+
}, [onChartsReady]);
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div className="grid md:grid-cols-2 grid-cols-1 gap-4">
|
| 37 |
+
{data.map((group, idx) => (
|
| 38 |
+
<SingleDataGraph
|
| 39 |
+
key={idx}
|
| 40 |
+
data={group}
|
| 41 |
+
hoveredTime={hoveredTime}
|
| 42 |
+
setHoveredTime={setHoveredTime}
|
| 43 |
+
/>
|
| 44 |
+
))}
|
| 45 |
+
</div>
|
| 46 |
+
);
|
| 47 |
+
},
|
| 48 |
+
);
|
| 49 |
+
|
| 50 |
+
const NESTED_KEY_DELIMITER = ",";
|
| 51 |
+
|
| 52 |
+
const SingleDataGraph = React.memo(
|
| 53 |
+
({
|
| 54 |
+
data,
|
| 55 |
+
hoveredTime,
|
| 56 |
+
setHoveredTime,
|
| 57 |
+
}: {
|
| 58 |
+
data: Array<Record<string, number>>;
|
| 59 |
+
hoveredTime: number | null;
|
| 60 |
+
setHoveredTime: (t: number | null) => void;
|
| 61 |
+
}) => {
|
| 62 |
+
const { currentTime, setCurrentTime } = useTime();
|
| 63 |
+
function flattenRow(row: Record<string, any>, prefix = ""): Record<string, number> {
|
| 64 |
+
const result: Record<string, number> = {};
|
| 65 |
+
for (const [key, value] of Object.entries(row)) {
|
| 66 |
+
// Special case: if this is a group value that is a primitive, assign to prefix.key
|
| 67 |
+
if (typeof value === "number") {
|
| 68 |
+
if (prefix) {
|
| 69 |
+
result[`${prefix}${NESTED_KEY_DELIMITER}${key}`] = value;
|
| 70 |
+
} else {
|
| 71 |
+
result[key] = value;
|
| 72 |
+
}
|
| 73 |
+
} else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
| 74 |
+
// If it's an object, recurse
|
| 75 |
+
Object.assign(result, flattenRow(value, prefix ? `${prefix}${NESTED_KEY_DELIMITER}${key}` : key));
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
// Always keep timestamp at top level if present
|
| 79 |
+
if ("timestamp" in row) {
|
| 80 |
+
result["timestamp"] = row["timestamp"];
|
| 81 |
+
}
|
| 82 |
+
return result;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Flatten all rows for recharts
|
| 86 |
+
const chartData = useMemo(() => data.map(row => flattenRow(row)), [data]);
|
| 87 |
+
const [dataKeys, setDataKeys] = useState<string[]>([]);
|
| 88 |
+
const [visibleKeys, setVisibleKeys] = useState<string[]>([]);
|
| 89 |
+
|
| 90 |
+
useEffect(() => {
|
| 91 |
+
if (!chartData || chartData.length === 0) return;
|
| 92 |
+
// Get all keys except timestamp from the first row
|
| 93 |
+
const keys = Object.keys(chartData[0]).filter((k) => k !== "timestamp");
|
| 94 |
+
setDataKeys(keys);
|
| 95 |
+
setVisibleKeys(keys);
|
| 96 |
+
}, [chartData]);
|
| 97 |
+
|
| 98 |
+
// Parse dataKeys into groups (dot notation)
|
| 99 |
+
const groups: Record<string, string[]> = {};
|
| 100 |
+
const singles: string[] = [];
|
| 101 |
+
dataKeys.forEach((key) => {
|
| 102 |
+
const parts = key.split(NESTED_KEY_DELIMITER);
|
| 103 |
+
if (parts.length > 1) {
|
| 104 |
+
const group = parts[0];
|
| 105 |
+
if (!groups[group]) groups[group] = [];
|
| 106 |
+
groups[group].push(key);
|
| 107 |
+
} else {
|
| 108 |
+
singles.push(key);
|
| 109 |
+
}
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
// Assign a color per group (and for singles)
|
| 113 |
+
const allGroups = [...Object.keys(groups), ...singles];
|
| 114 |
+
const groupColorMap: Record<string, string> = {};
|
| 115 |
+
allGroups.forEach((group, idx) => {
|
| 116 |
+
groupColorMap[group] = `hsl(${idx * (360 / allGroups.length)}, 100%, 50%)`;
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
// Find the closest data point to the current time for highlighting
|
| 120 |
+
const findClosestDataIndex = (time: number) => {
|
| 121 |
+
if (!chartData.length) return 0;
|
| 122 |
+
// Find the index of the first data point whose timestamp is >= time (ceiling)
|
| 123 |
+
const idx = chartData.findIndex((point) => point.timestamp >= time);
|
| 124 |
+
if (idx !== -1) return idx;
|
| 125 |
+
// If all timestamps are less than time, return the last index
|
| 126 |
+
return chartData.length - 1;
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
const handleMouseLeave = () => {
|
| 130 |
+
setHoveredTime(null);
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const handleClick = (data: any) => {
|
| 134 |
+
if (data && data.activePayload && data.activePayload.length) {
|
| 135 |
+
const timeValue = data.activePayload[0].payload.timestamp;
|
| 136 |
+
setCurrentTime(timeValue);
|
| 137 |
+
}
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
// Custom legend to show current value next to each series
|
| 141 |
+
const CustomLegend = () => {
|
| 142 |
+
const closestIndex = findClosestDataIndex(
|
| 143 |
+
hoveredTime != null ? hoveredTime : currentTime,
|
| 144 |
+
);
|
| 145 |
+
const currentData = chartData[closestIndex] || {};
|
| 146 |
+
|
| 147 |
+
// Parse dataKeys into groups (dot notation)
|
| 148 |
+
const groups: Record<string, string[]> = {};
|
| 149 |
+
const singles: string[] = [];
|
| 150 |
+
dataKeys.forEach((key) => {
|
| 151 |
+
const parts = key.split(NESTED_KEY_DELIMITER);
|
| 152 |
+
if (parts.length > 1) {
|
| 153 |
+
const group = parts[0];
|
| 154 |
+
if (!groups[group]) groups[group] = [];
|
| 155 |
+
groups[group].push(key);
|
| 156 |
+
} else {
|
| 157 |
+
singles.push(key);
|
| 158 |
+
}
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
// Assign a color per group (and for singles)
|
| 162 |
+
const allGroups = [...Object.keys(groups), ...singles];
|
| 163 |
+
const groupColorMap: Record<string, string> = {};
|
| 164 |
+
allGroups.forEach((group, idx) => {
|
| 165 |
+
groupColorMap[group] = `hsl(${idx * (360 / allGroups.length)}, 100%, 50%)`;
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
const isGroupChecked = (group: string) => groups[group].every(k => visibleKeys.includes(k));
|
| 169 |
+
const isGroupIndeterminate = (group: string) => groups[group].some(k => visibleKeys.includes(k)) && !isGroupChecked(group);
|
| 170 |
+
|
| 171 |
+
const handleGroupCheckboxChange = (group: string) => {
|
| 172 |
+
if (isGroupChecked(group)) {
|
| 173 |
+
// Uncheck all children
|
| 174 |
+
setVisibleKeys((prev) => prev.filter(k => !groups[group].includes(k)));
|
| 175 |
+
} else {
|
| 176 |
+
// Check all children
|
| 177 |
+
setVisibleKeys((prev) => Array.from(new Set([...prev, ...groups[group]])));
|
| 178 |
+
}
|
| 179 |
+
};
|
| 180 |
+
|
| 181 |
+
const handleCheckboxChange = (key: string) => {
|
| 182 |
+
setVisibleKeys((prev) =>
|
| 183 |
+
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
|
| 184 |
+
);
|
| 185 |
+
};
|
| 186 |
+
|
| 187 |
+
return (
|
| 188 |
+
<div className="grid grid-cols-[repeat(auto-fit,250px)] gap-4 mx-8">
|
| 189 |
+
{/* Grouped keys */}
|
| 190 |
+
{Object.entries(groups).map(([group, children]) => {
|
| 191 |
+
const color = groupColorMap[group];
|
| 192 |
+
return (
|
| 193 |
+
<div key={group} className="mb-2">
|
| 194 |
+
<label className="flex gap-2 cursor-pointer select-none font-semibold">
|
| 195 |
+
<input
|
| 196 |
+
type="checkbox"
|
| 197 |
+
checked={isGroupChecked(group)}
|
| 198 |
+
ref={el => { if (el) el.indeterminate = isGroupIndeterminate(group); }}
|
| 199 |
+
onChange={() => handleGroupCheckboxChange(group)}
|
| 200 |
+
className="size-3.5 mt-1"
|
| 201 |
+
style={{ accentColor: color }}
|
| 202 |
+
/>
|
| 203 |
+
<span className="text-sm w-40 text-white">{group}</span>
|
| 204 |
+
</label>
|
| 205 |
+
<div className="pl-7 flex flex-col gap-1 mt-1">
|
| 206 |
+
{children.map((key) => (
|
| 207 |
+
<label key={key} className="flex gap-2 cursor-pointer select-none">
|
| 208 |
+
<input
|
| 209 |
+
type="checkbox"
|
| 210 |
+
checked={visibleKeys.includes(key)}
|
| 211 |
+
onChange={() => handleCheckboxChange(key)}
|
| 212 |
+
className="size-3.5 mt-1"
|
| 213 |
+
style={{ accentColor: color }}
|
| 214 |
+
/>
|
| 215 |
+
<span className={`text-xs break-all w-36 ${visibleKeys.includes(key) ? "text-white" : "text-gray-400"}`}>{key.slice(group.length + 1)}</span>
|
| 216 |
+
<span className={`text-xs font-mono ml-auto ${visibleKeys.includes(key) ? "text-orange-300" : "text-gray-500"}`}>
|
| 217 |
+
{typeof currentData[key] === "number" ? currentData[key].toFixed(2) : "--"}
|
| 218 |
+
</span>
|
| 219 |
+
</label>
|
| 220 |
+
))}
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
);
|
| 224 |
+
})}
|
| 225 |
+
{/* Singles (non-grouped) */}
|
| 226 |
+
{singles.map((key) => {
|
| 227 |
+
const color = groupColorMap[key];
|
| 228 |
+
return (
|
| 229 |
+
<label key={key} className="flex gap-2 cursor-pointer select-none">
|
| 230 |
+
<input
|
| 231 |
+
type="checkbox"
|
| 232 |
+
checked={visibleKeys.includes(key)}
|
| 233 |
+
onChange={() => handleCheckboxChange(key)}
|
| 234 |
+
className="size-3.5 mt-1"
|
| 235 |
+
style={{ accentColor: color }}
|
| 236 |
+
/>
|
| 237 |
+
<span className={`text-sm break-all w-40 ${visibleKeys.includes(key) ? "text-white" : "text-gray-400"}`}>{key}</span>
|
| 238 |
+
<span className={`text-sm font-mono ml-auto ${visibleKeys.includes(key) ? "text-orange-300" : "text-gray-500"}`}>
|
| 239 |
+
{typeof currentData[key] === "number" ? currentData[key].toFixed(2) : "--"}
|
| 240 |
+
</span>
|
| 241 |
+
</label>
|
| 242 |
+
);
|
| 243 |
+
})}
|
| 244 |
+
</div>
|
| 245 |
+
);
|
| 246 |
+
};
|
| 247 |
+
|
| 248 |
+
return (
|
| 249 |
+
<div className="w-full">
|
| 250 |
+
<div className="w-full h-80" onMouseLeave={handleMouseLeave}>
|
| 251 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 252 |
+
<LineChart
|
| 253 |
+
data={chartData}
|
| 254 |
+
syncId="episode-sync"
|
| 255 |
+
margin={{ top: 24, right: 16, left: 0, bottom: 16 }}
|
| 256 |
+
onClick={handleClick}
|
| 257 |
+
onMouseMove={(state: any) => {
|
| 258 |
+
setHoveredTime(
|
| 259 |
+
state?.activePayload?.[0]?.payload?.timestamp ??
|
| 260 |
+
state?.activeLabel ??
|
| 261 |
+
null,
|
| 262 |
+
);
|
| 263 |
+
}}
|
| 264 |
+
onMouseLeave={handleMouseLeave}
|
| 265 |
+
>
|
| 266 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#444" />
|
| 267 |
+
<XAxis
|
| 268 |
+
dataKey="timestamp"
|
| 269 |
+
label={{
|
| 270 |
+
value: "time",
|
| 271 |
+
position: "insideBottomLeft",
|
| 272 |
+
fill: "#cbd5e1",
|
| 273 |
+
}}
|
| 274 |
+
domain={[
|
| 275 |
+
chartData.at(0)?.timestamp ?? 0,
|
| 276 |
+
chartData.at(-1)?.timestamp ?? 0,
|
| 277 |
+
]}
|
| 278 |
+
ticks={useMemo(
|
| 279 |
+
() =>
|
| 280 |
+
Array.from(
|
| 281 |
+
new Set(chartData.map((d) => Math.ceil(d.timestamp))),
|
| 282 |
+
),
|
| 283 |
+
[chartData],
|
| 284 |
+
)}
|
| 285 |
+
stroke="#cbd5e1"
|
| 286 |
+
minTickGap={20} // Increased for fewer ticks
|
| 287 |
+
allowDataOverflow={true}
|
| 288 |
+
/>
|
| 289 |
+
<YAxis
|
| 290 |
+
domain={["auto", "auto"]}
|
| 291 |
+
stroke="#cbd5e1"
|
| 292 |
+
interval={0}
|
| 293 |
+
allowDataOverflow={true}
|
| 294 |
+
/>
|
| 295 |
+
|
| 296 |
+
<Tooltip
|
| 297 |
+
content={() => null}
|
| 298 |
+
active={true}
|
| 299 |
+
isAnimationActive={false}
|
| 300 |
+
defaultIndex={
|
| 301 |
+
!hoveredTime ? findClosestDataIndex(currentTime) : undefined
|
| 302 |
+
}
|
| 303 |
+
/>
|
| 304 |
+
|
| 305 |
+
{/* Render lines for visible dataKeys only */}
|
| 306 |
+
{dataKeys.map((key) => {
|
| 307 |
+
// Use group color for all keys in a group
|
| 308 |
+
const group = key.includes(NESTED_KEY_DELIMITER) ? key.split(NESTED_KEY_DELIMITER)[0] : key;
|
| 309 |
+
const color = groupColorMap[group];
|
| 310 |
+
let strokeDasharray: string | undefined = undefined;
|
| 311 |
+
if (groups[group] && groups[group].length > 1) {
|
| 312 |
+
const idxInGroup = groups[group].indexOf(key);
|
| 313 |
+
if (idxInGroup > 0) strokeDasharray = "5 5";
|
| 314 |
+
}
|
| 315 |
+
return (
|
| 316 |
+
visibleKeys.includes(key) && (
|
| 317 |
+
<Line
|
| 318 |
+
key={key}
|
| 319 |
+
type="monotone"
|
| 320 |
+
dataKey={key}
|
| 321 |
+
name={key}
|
| 322 |
+
stroke={color}
|
| 323 |
+
strokeDasharray={strokeDasharray}
|
| 324 |
+
dot={false}
|
| 325 |
+
activeDot={false}
|
| 326 |
+
strokeWidth={1.5}
|
| 327 |
+
isAnimationActive={false}
|
| 328 |
+
/>
|
| 329 |
+
)
|
| 330 |
+
);
|
| 331 |
+
})}
|
| 332 |
+
</LineChart>
|
| 333 |
+
</ResponsiveContainer>
|
| 334 |
+
</div>
|
| 335 |
+
<CustomLegend />
|
| 336 |
+
</div>
|
| 337 |
+
);
|
| 338 |
+
},
|
| 339 |
+
); // End React.memo
|
| 340 |
+
|
| 341 |
+
SingleDataGraph.displayName = "SingleDataGraph";
|
| 342 |
+
DataRecharts.displayName = "DataGraph";
|
| 343 |
+
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/videos-player.tsx
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
};
|
| 11 |
+
|
| 12 |
+
type VideoPlayerProps = {
|
| 13 |
+
videosInfo: VideoInfo[];
|
| 14 |
+
onVideosReady?: () => void;
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export const VideosPlayer = ({
|
| 18 |
+
videosInfo,
|
| 19 |
+
onVideosReady,
|
| 20 |
+
}: VideoPlayerProps) => {
|
| 21 |
+
const { currentTime, setCurrentTime, isPlaying, setIsPlaying } = useTime();
|
| 22 |
+
const videoRefs = useRef<HTMLVideoElement[]>([]);
|
| 23 |
+
// Hidden/enlarged state and hidden menu
|
| 24 |
+
const [hiddenVideos, setHiddenVideos] = useState<string[]>([]);
|
| 25 |
+
// Find the index of the first visible (not hidden) video
|
| 26 |
+
const firstVisibleIdx = videosInfo.findIndex(
|
| 27 |
+
(video) => !hiddenVideos.includes(video.filename),
|
| 28 |
+
);
|
| 29 |
+
// Count of visible videos
|
| 30 |
+
const visibleCount = videosInfo.filter(
|
| 31 |
+
(video) => !hiddenVideos.includes(video.filename),
|
| 32 |
+
).length;
|
| 33 |
+
const [enlargedVideo, setEnlargedVideo] = useState<string | null>(null);
|
| 34 |
+
// Track previous hiddenVideos for comparison
|
| 35 |
+
const prevHiddenVideosRef = useRef<string[]>([]);
|
| 36 |
+
const videoContainerRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
| 37 |
+
const [showHiddenMenu, setShowHiddenMenu] = useState(false);
|
| 38 |
+
const hiddenMenuRef = useRef<HTMLDivElement | null>(null);
|
| 39 |
+
const showHiddenBtnRef = useRef<HTMLButtonElement | null>(null);
|
| 40 |
+
const [videoCodecError, setVideoCodecError] = useState(false);
|
| 41 |
+
|
| 42 |
+
// Initialize video refs
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
videoRefs.current = videoRefs.current.slice(0, videosInfo.length);
|
| 45 |
+
}, [videosInfo]);
|
| 46 |
+
|
| 47 |
+
// When videos get unhidden, start playing them if it was playing
|
| 48 |
+
useEffect(() => {
|
| 49 |
+
// Find which videos were just unhidden
|
| 50 |
+
const prevHidden = prevHiddenVideosRef.current;
|
| 51 |
+
const newlyUnhidden = prevHidden.filter(
|
| 52 |
+
(filename) => !hiddenVideos.includes(filename),
|
| 53 |
+
);
|
| 54 |
+
if (newlyUnhidden.length > 0) {
|
| 55 |
+
videosInfo.forEach((video, idx) => {
|
| 56 |
+
if (newlyUnhidden.includes(video.filename)) {
|
| 57 |
+
const ref = videoRefs.current[idx];
|
| 58 |
+
if (ref) {
|
| 59 |
+
ref.currentTime = currentTime;
|
| 60 |
+
if (isPlaying) {
|
| 61 |
+
ref.play().catch(() => {});
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
});
|
| 66 |
+
}
|
| 67 |
+
prevHiddenVideosRef.current = hiddenVideos;
|
| 68 |
+
}, [hiddenVideos, isPlaying, videosInfo, currentTime]);
|
| 69 |
+
|
| 70 |
+
// Check video codec support
|
| 71 |
+
useEffect(() => {
|
| 72 |
+
const checkCodecSupport = () => {
|
| 73 |
+
const dummyVideo = document.createElement("video");
|
| 74 |
+
const canPlayVideos = dummyVideo.canPlayType(
|
| 75 |
+
'video/mp4; codecs="av01.0.05M.08"',
|
| 76 |
+
);
|
| 77 |
+
setVideoCodecError(!canPlayVideos);
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
checkCodecSupport();
|
| 81 |
+
}, []);
|
| 82 |
+
|
| 83 |
+
// Handle play/pause
|
| 84 |
+
useEffect(() => {
|
| 85 |
+
videoRefs.current.forEach((video) => {
|
| 86 |
+
if (video) {
|
| 87 |
+
if (isPlaying) {
|
| 88 |
+
video.play().catch((e) => console.error("Error playing video:", e));
|
| 89 |
+
} else {
|
| 90 |
+
video.pause();
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
});
|
| 94 |
+
}, [isPlaying]);
|
| 95 |
+
|
| 96 |
+
// Minimize enlarged video on Escape key
|
| 97 |
+
useEffect(() => {
|
| 98 |
+
if (!enlargedVideo) return;
|
| 99 |
+
const handleKeyDown = (e: KeyboardEvent) => {
|
| 100 |
+
if (e.key === "Escape") {
|
| 101 |
+
setEnlargedVideo(null);
|
| 102 |
+
}
|
| 103 |
+
};
|
| 104 |
+
window.addEventListener("keydown", handleKeyDown);
|
| 105 |
+
// Scroll enlarged video into view
|
| 106 |
+
const ref = videoContainerRefs.current[enlargedVideo];
|
| 107 |
+
if (ref) {
|
| 108 |
+
ref.scrollIntoView();
|
| 109 |
+
}
|
| 110 |
+
return () => {
|
| 111 |
+
window.removeEventListener("keydown", handleKeyDown);
|
| 112 |
+
};
|
| 113 |
+
}, [enlargedVideo]);
|
| 114 |
+
|
| 115 |
+
// Close hidden videos dropdown on outside click
|
| 116 |
+
useEffect(() => {
|
| 117 |
+
if (!showHiddenMenu) return;
|
| 118 |
+
function handleClick(e: MouseEvent) {
|
| 119 |
+
const menu = hiddenMenuRef.current;
|
| 120 |
+
const btn = showHiddenBtnRef.current;
|
| 121 |
+
if (
|
| 122 |
+
menu &&
|
| 123 |
+
!menu.contains(e.target as Node) &&
|
| 124 |
+
btn &&
|
| 125 |
+
!btn.contains(e.target as Node)
|
| 126 |
+
) {
|
| 127 |
+
setShowHiddenMenu(false);
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
document.addEventListener("mousedown", handleClick);
|
| 131 |
+
return () => document.removeEventListener("mousedown", handleClick);
|
| 132 |
+
}, [showHiddenMenu]);
|
| 133 |
+
|
| 134 |
+
// Close dropdown if no hidden videos
|
| 135 |
+
useEffect(() => {
|
| 136 |
+
if (hiddenVideos.length === 0 && showHiddenMenu) {
|
| 137 |
+
setShowHiddenMenu(false);
|
| 138 |
+
}
|
| 139 |
+
// Minimize if enlarged video is hidden
|
| 140 |
+
if (enlargedVideo && hiddenVideos.includes(enlargedVideo)) {
|
| 141 |
+
setEnlargedVideo(null);
|
| 142 |
+
}
|
| 143 |
+
}, [hiddenVideos, showHiddenMenu, enlargedVideo]);
|
| 144 |
+
|
| 145 |
+
// Sync video times
|
| 146 |
+
useEffect(() => {
|
| 147 |
+
videoRefs.current.forEach((video) => {
|
| 148 |
+
if (video && Math.abs(video.currentTime - currentTime) > 0.2) {
|
| 149 |
+
video.currentTime = currentTime;
|
| 150 |
+
}
|
| 151 |
+
});
|
| 152 |
+
}, [currentTime]);
|
| 153 |
+
|
| 154 |
+
// Handle time update
|
| 155 |
+
const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
| 156 |
+
const video = e.target as HTMLVideoElement;
|
| 157 |
+
if (video && video.duration) {
|
| 158 |
+
setCurrentTime(video.currentTime);
|
| 159 |
+
}
|
| 160 |
+
};
|
| 161 |
+
|
| 162 |
+
// Handle video ready
|
| 163 |
+
useEffect(() => {
|
| 164 |
+
let videosReadyCount = 0;
|
| 165 |
+
const onCanPlayThrough = () => {
|
| 166 |
+
videosReadyCount += 1;
|
| 167 |
+
if (videosReadyCount === videosInfo.length) {
|
| 168 |
+
if (typeof onVideosReady === "function") {
|
| 169 |
+
onVideosReady();
|
| 170 |
+
setIsPlaying(true);
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
videoRefs.current.forEach((video) => {
|
| 176 |
+
if (video) {
|
| 177 |
+
// If already ready, call the handler immediately
|
| 178 |
+
if (video.readyState >= 4) {
|
| 179 |
+
onCanPlayThrough();
|
| 180 |
+
} else {
|
| 181 |
+
video.addEventListener("canplaythrough", onCanPlayThrough);
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
return () => {
|
| 187 |
+
videoRefs.current.forEach((video) => {
|
| 188 |
+
if (video) {
|
| 189 |
+
video.removeEventListener("canplaythrough", onCanPlayThrough);
|
| 190 |
+
}
|
| 191 |
+
});
|
| 192 |
+
};
|
| 193 |
+
}, []);
|
| 194 |
+
|
| 195 |
+
return (
|
| 196 |
+
<>
|
| 197 |
+
{/* Error message */}
|
| 198 |
+
{videoCodecError && (
|
| 199 |
+
<div className="font-medium text-orange-700">
|
| 200 |
+
<p>
|
| 201 |
+
Videos could NOT play because{" "}
|
| 202 |
+
<a
|
| 203 |
+
href="https://en.wikipedia.org/wiki/AV1"
|
| 204 |
+
target="_blank"
|
| 205 |
+
className="underline"
|
| 206 |
+
>
|
| 207 |
+
AV1
|
| 208 |
+
</a>{" "}
|
| 209 |
+
decoding is not available on your browser.
|
| 210 |
+
</p>
|
| 211 |
+
<ul className="list-inside list-decimal">
|
| 212 |
+
<li>
|
| 213 |
+
If iPhone:{" "}
|
| 214 |
+
<span className="italic">
|
| 215 |
+
It is supported with A17 chip or higher.
|
| 216 |
+
</span>
|
| 217 |
+
</li>
|
| 218 |
+
<li>
|
| 219 |
+
If Mac with Safari:{" "}
|
| 220 |
+
<span className="italic">
|
| 221 |
+
It is supported on most browsers except Safari with M1 chip or
|
| 222 |
+
higher and on Safari with M3 chip or higher.
|
| 223 |
+
</span>
|
| 224 |
+
</li>
|
| 225 |
+
<li>
|
| 226 |
+
Other:{" "}
|
| 227 |
+
<span className="italic">
|
| 228 |
+
Contact the maintainers on LeRobot discord channel:
|
| 229 |
+
</span>
|
| 230 |
+
<a
|
| 231 |
+
href="https://discord.com/invite/s3KuuzsPFb"
|
| 232 |
+
target="_blank"
|
| 233 |
+
className="underline"
|
| 234 |
+
>
|
| 235 |
+
https://discord.com/invite/s3KuuzsPFb
|
| 236 |
+
</a>
|
| 237 |
+
</li>
|
| 238 |
+
</ul>
|
| 239 |
+
</div>
|
| 240 |
+
)}
|
| 241 |
+
|
| 242 |
+
{/* Show Hidden Videos Button */}
|
| 243 |
+
{hiddenVideos.length > 0 && (
|
| 244 |
+
<div className="relative">
|
| 245 |
+
<button
|
| 246 |
+
ref={showHiddenBtnRef}
|
| 247 |
+
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"
|
| 248 |
+
onClick={() => setShowHiddenMenu((prev) => !prev)}
|
| 249 |
+
>
|
| 250 |
+
<FaEye /> Show Hidden Videos ({hiddenVideos.length})
|
| 251 |
+
</button>
|
| 252 |
+
{showHiddenMenu && (
|
| 253 |
+
<div
|
| 254 |
+
ref={hiddenMenuRef}
|
| 255 |
+
className="absolute left-0 mt-2 w-max rounded border border-slate-500 bg-slate-900 shadow-lg p-2 z-50"
|
| 256 |
+
>
|
| 257 |
+
<div className="mb-2 text-xs text-slate-300">
|
| 258 |
+
Restore hidden videos:
|
| 259 |
+
</div>
|
| 260 |
+
{hiddenVideos.map((filename) => (
|
| 261 |
+
<button
|
| 262 |
+
key={filename}
|
| 263 |
+
className="block w-full text-left px-2 py-1 rounded hover:bg-slate-700 text-slate-100"
|
| 264 |
+
onClick={() =>
|
| 265 |
+
setHiddenVideos((prev: string[]) =>
|
| 266 |
+
prev.filter((v: string) => v !== filename),
|
| 267 |
+
)
|
| 268 |
+
}
|
| 269 |
+
>
|
| 270 |
+
{filename}
|
| 271 |
+
</button>
|
| 272 |
+
))}
|
| 273 |
+
</div>
|
| 274 |
+
)}
|
| 275 |
+
</div>
|
| 276 |
+
)}
|
| 277 |
+
|
| 278 |
+
{/* Videos */}
|
| 279 |
+
<div className="flex flex-wrap gap-x-2 gap-y-6">
|
| 280 |
+
{videosInfo.map((video, idx) => {
|
| 281 |
+
if (hiddenVideos.includes(video.filename) || videoCodecError)
|
| 282 |
+
return null;
|
| 283 |
+
const isEnlarged = enlargedVideo === video.filename;
|
| 284 |
+
return (
|
| 285 |
+
<div
|
| 286 |
+
key={video.filename}
|
| 287 |
+
ref={(el) => {
|
| 288 |
+
videoContainerRefs.current[video.filename] = el;
|
| 289 |
+
}}
|
| 290 |
+
className={`${isEnlarged ? "z-40 fixed inset-0 bg-black bg-opacity-90 flex flex-col items-center justify-center" : "max-w-96"}`}
|
| 291 |
+
style={isEnlarged ? { height: "100vh", width: "100vw" } : {}}
|
| 292 |
+
>
|
| 293 |
+
<p className="truncate w-full rounded-t-xl bg-gray-800 px-2 text-sm text-gray-300 flex items-center justify-between">
|
| 294 |
+
<span>{video.filename}</span>
|
| 295 |
+
<span className="flex gap-1">
|
| 296 |
+
<button
|
| 297 |
+
title={isEnlarged ? "Minimize" : "Enlarge"}
|
| 298 |
+
className="ml-2 p-1 hover:bg-slate-700 rounded focus:outline-none focus:ring-0"
|
| 299 |
+
onClick={() =>
|
| 300 |
+
setEnlargedVideo(isEnlarged ? null : video.filename)
|
| 301 |
+
}
|
| 302 |
+
>
|
| 303 |
+
{isEnlarged ? <FaCompress /> : <FaExpand />}
|
| 304 |
+
</button>
|
| 305 |
+
<button
|
| 306 |
+
title="Hide Video"
|
| 307 |
+
className="ml-1 p-1 hover:bg-slate-700 rounded focus:outline-none focus:ring-0"
|
| 308 |
+
onClick={() =>
|
| 309 |
+
setHiddenVideos((prev: string[]) => [
|
| 310 |
+
...prev,
|
| 311 |
+
video.filename,
|
| 312 |
+
])
|
| 313 |
+
}
|
| 314 |
+
disabled={visibleCount === 1}
|
| 315 |
+
>
|
| 316 |
+
<FaTimes />
|
| 317 |
+
</button>
|
| 318 |
+
</span>
|
| 319 |
+
</p>
|
| 320 |
+
<video
|
| 321 |
+
ref={(el) => {
|
| 322 |
+
if (el) videoRefs.current[idx] = el;
|
| 323 |
+
}}
|
| 324 |
+
muted
|
| 325 |
+
loop
|
| 326 |
+
className={`w-full object-contain ${isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""}`}
|
| 327 |
+
onTimeUpdate={
|
| 328 |
+
idx === firstVisibleIdx ? handleTimeUpdate : undefined
|
| 329 |
+
}
|
| 330 |
+
style={isEnlarged ? { zIndex: 41 } : {}}
|
| 331 |
+
>
|
| 332 |
+
<source src={video.url} type="video/mp4" />
|
| 333 |
+
Your browser does not support the video tag.
|
| 334 |
+
</video>
|
| 335 |
+
</div>
|
| 336 |
+
);
|
| 337 |
+
})}
|
| 338 |
+
</div>
|
| 339 |
+
</>
|
| 340 |
+
);
|
| 341 |
+
};
|
| 342 |
+
|
| 343 |
+
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,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { parquetRead } 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 |
+
return res.arrayBuffer();
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// Read specific columns from the Parquet file
|
| 51 |
+
export async function readParquetColumn(
|
| 52 |
+
fileBuffer: ArrayBuffer,
|
| 53 |
+
columns: string[],
|
| 54 |
+
): Promise<any[]> {
|
| 55 |
+
return new Promise((resolve) => {
|
| 56 |
+
parquetRead({
|
| 57 |
+
file: fileBuffer,
|
| 58 |
+
columns,
|
| 59 |
+
onComplete: (data: any[]) => resolve(data),
|
| 60 |
+
});
|
| 61 |
+
});
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// Convert a 2D array to a CSV string
|
| 65 |
+
export function arrayToCSV(data: (number | string)[][]): string {
|
| 66 |
+
return data.map((row) => row.join(",")).join("\n");
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// Get rows from the current frame data
|
| 70 |
+
export function getRows(currentFrameData: any[], columns: any[]) {
|
| 71 |
+
if (!currentFrameData || currentFrameData.length === 0) {
|
| 72 |
+
return [];
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const rows = [];
|
| 76 |
+
const nRows = Math.max(...columns.map((column) => column.value.length));
|
| 77 |
+
let rowIndex = 0;
|
| 78 |
+
|
| 79 |
+
while (rowIndex < nRows) {
|
| 80 |
+
const row = [];
|
| 81 |
+
// number of states may NOT match number of actions. In this case, we null-pad the 2D array
|
| 82 |
+
const nullCell = { isNull: true };
|
| 83 |
+
// row consists of [state value, action value]
|
| 84 |
+
let idx = rowIndex;
|
| 85 |
+
|
| 86 |
+
for (const column of columns) {
|
| 87 |
+
const nColumn = column.value.length;
|
| 88 |
+
row.push(rowIndex < nColumn ? currentFrameData[idx] : nullCell);
|
| 89 |
+
idx += nColumn; // because currentFrameData = [state0, state1, ..., stateN, action0, action1, ..., actionN]
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
rowIndex += 1;
|
| 93 |
+
rows.push(row);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return rows;
|
| 97 |
+
}
|
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,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
* Checks if a specific version/branch exists for a dataset
|
| 9 |
+
*/
|
| 10 |
+
async function checkVersionExists(repoId: string, version: string): Promise<boolean> {
|
| 11 |
+
try {
|
| 12 |
+
const testUrl = `${DATASET_URL}/${repoId}/resolve/${version}/meta/info.json`;
|
| 13 |
+
|
| 14 |
+
// Try a simple GET request with a timeout
|
| 15 |
+
const controller = new AbortController();
|
| 16 |
+
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
| 17 |
+
|
| 18 |
+
const response = await fetch(testUrl, {
|
| 19 |
+
method: "GET",
|
| 20 |
+
cache: "no-store",
|
| 21 |
+
signal: controller.signal
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
clearTimeout(timeoutId);
|
| 25 |
+
|
| 26 |
+
// Check if it's a successful response
|
| 27 |
+
if (response.ok) {
|
| 28 |
+
// Try to parse a bit of the JSON to make sure it's valid
|
| 29 |
+
try {
|
| 30 |
+
const text = await response.text();
|
| 31 |
+
const data = JSON.parse(text);
|
| 32 |
+
return !!data.features; // Only return true if it has features
|
| 33 |
+
} catch (parseError) {
|
| 34 |
+
return false;
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return false;
|
| 39 |
+
} catch (error) {
|
| 40 |
+
return false;
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* Determines the best available version for a dataset.
|
| 46 |
+
* Prefers v2.1, falls back to v2.0, or throws an error if neither exists.
|
| 47 |
+
*/
|
| 48 |
+
export async function getDatasetVersion(repoId: string): Promise<string> {
|
| 49 |
+
// Check for v2.1 first
|
| 50 |
+
if (await checkVersionExists(repoId, "v2.1")) {
|
| 51 |
+
return "v2.1";
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// Fall back to v2.0
|
| 55 |
+
if (await checkVersionExists(repoId, "v2.0")) {
|
| 56 |
+
return "v2.0";
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// If neither v2.1 nor v2.0 exists, throw an error
|
| 60 |
+
throw new Error(
|
| 61 |
+
`Dataset ${repoId} is not compatible with this visualizer. ` +
|
| 62 |
+
"This tool only works with dataset version 2.x (v2.1 or v2.0). " +
|
| 63 |
+
"Please use a compatible dataset version."
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/**
|
| 68 |
+
* Constructs a versioned URL for dataset resources
|
| 69 |
+
*/
|
| 70 |
+
export function buildVersionedUrl(repoId: string, version: string, path: string): string {
|
| 71 |
+
return `${DATASET_URL}/${repoId}/resolve/${version}/${path}`;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* Debug function to test version checking manually
|
| 76 |
+
*/
|
| 77 |
+
export async function testVersionCheck(repoId: string): Promise<void> {
|
| 78 |
+
console.log(`Testing version check for: ${repoId}`);
|
| 79 |
+
try {
|
| 80 |
+
const version = await getDatasetVersion(repoId);
|
| 81 |
+
console.log(`Success! Best version found: ${version}`);
|
| 82 |
+
} catch (error) {
|
| 83 |
+
console.error(`Failed:`, error);
|
| 84 |
+
}
|
| 85 |
+
}
|
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 |
+
}
|