Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Sync from GitHub via hub-sync
Browse files- CLAUDE.md +26 -2
- README.md +4 -0
- bun.lock +17 -0
- package.json +1 -0
- src/app/api/auth/session/route.ts +39 -0
- src/app/api/proxy/[...path]/route.ts +120 -0
- src/app/layout.tsx +10 -1
- src/components/hf-auth-button.tsx +58 -0
- src/components/simple-videos-player.tsx +2 -1
- src/context/auth-context.tsx +138 -0
- src/utils/auth.ts +42 -0
- src/utils/parquetUtils.ts +6 -2
- src/utils/versionUtils.ts +3 -0
CLAUDE.md
CHANGED
|
@@ -58,6 +58,9 @@ Three versions are supported. Version is detected from `meta/info.json` → `cod
|
|
| 58 |
- Integer columns from parquet come out as **BigInt** — always use `bigIntToNumber()` from `src/utils/typeGuards.ts`
|
| 59 |
- Row-range selection: `dataset_from_index` / `dataset_to_index` allow reading only the episode's rows from a shared parquet file
|
| 60 |
- Fallback format uses numeric keys `"0"`.."9"` when column names are unavailable
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
### v2.x path construction
|
| 63 |
|
|
@@ -112,5 +115,26 @@ Built by `buildVersionedUrl(repoId, version, path)`. The `version` param is acce
|
|
| 112 |
|
| 113 |
## Excluded columns (not shown in charts)
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
- Integer columns from parquet come out as **BigInt** — always use `bigIntToNumber()` from `src/utils/typeGuards.ts`
|
| 59 |
- Row-range selection: `dataset_from_index` / `dataset_to_index` allow reading only the episode's rows from a shared parquet file
|
| 60 |
- Fallback format uses numeric keys `"0"`.."9"` when column names are unavailable
|
| 61 |
+
- Episode metadata can span **multiple chunks** (when episode count exceeds `chunks_size`). Always walk via the `iterateEpisodeMetadataFilesV3(repoId, version)` async generator in `fetch-data.ts` — it advances chunk-000 → chunk-001 → … and stops on the first missing `file-000`. Never hardcode `chunk-000`.
|
| 62 |
+
- Multi-task episodes: episode-metadata rows carry a `tasks` field (`list[str]`) — prefer it over the legacy single `task_index` lookup. `EpisodeMetadataV3.tasks?: string[]` exposes it.
|
| 63 |
+
- `meta/tasks.parquet` lookup: rows are **not** ordered by `task_index`, and the task string lives in a named pandas index (`__index_level_0__`). Always filter by the `task_index` **column** (`row.task_index === taskIndexNum`), never by row position.
|
| 64 |
|
| 65 |
### v2.x path construction
|
| 66 |
|
|
|
|
| 115 |
|
| 116 |
## Excluded columns (not shown in charts)
|
| 117 |
|
| 118 |
+
Reserved/bookkeeping columns from lerobot — see `EXCLUDED_COLUMNS` in `src/utils/constants.ts`:
|
| 119 |
+
|
| 120 |
+
- v2.x: `timestamp`, `frame_index`, `episode_index`, `index`, `task_index`, `next.reward`, `next.done`, `next.truncated`
|
| 121 |
+
- v3.0: `index`, `task_index`, `episode_index`, `frame_index`, `next.reward`, `next.done`, `next.truncated`, `subtask_index`
|
| 122 |
+
|
| 123 |
+
## 3D URDF viewer (`src/components/urdf-viewer.tsx`)
|
| 124 |
+
|
| 125 |
+
- URDFs and meshes are hosted in the HF bucket `lerobot/robot-urdfs` — base URL `https://huggingface.co/buckets/lerobot/robot-urdfs/resolve` (no `/main` segment; buckets are unbranched). Override with `NEXT_PUBLIC_URDF_BASE_URL` for local development.
|
| 126 |
+
- Asset layout under the bucket: `g1/`, `openarm/`, `so101/` (both SO-100 and SO-101 live here).
|
| 127 |
+
- **URDFLoader gotcha**: after our `loadMeshCb` returns, `URDFLoader.js` does `if (obj instanceof THREE.Mesh) obj.material = <urdf-material>`, overwriting any material we set. Workaround: wrap the loaded mesh in a `THREE.Group` so the `instanceof Mesh` check fails. DAE returns a Group already; STL must be wrapped explicitly.
|
| 128 |
+
- **STLLoader event ordering**: `manager.itemEnd(url)` fires _before_ the user `onLoad` callback, so `manager.onLoad` can fire before meshes are attached to the robot tree. Defer post-load work (auto-fit camera, shadow flags) with `setTimeout(..., 0)`. Don't try to rebuild materials in `manager.onLoad` — pick the archetype color directly inside `loadMeshCb`.
|
| 129 |
+
- **OpenArm DAE files ship 23 stray `PointLight`s** that drown out scene lighting. Strip non-`AmbientLight` lights from `collada.scene` before adding it to the robot.
|
| 130 |
+
- Scene setup: `<Canvas shadows>` with `ACESFilmicToneMapping` (exposure 0.9), 3-point directional + ambient lights, `<Environment preset="studio" background={false} />`, `<color attach="background" args={["#1a2433"]} />`. `<OrbitControls makeDefault />` is required so `useThree().controls` exposes the controls for auto-fit.
|
| 131 |
+
|
| 132 |
+
## Design system
|
| 133 |
+
|
| 134 |
+
CSS tokens in `src/app/globals.css` (Tailwind v4 `@theme inline`):
|
| 135 |
+
|
| 136 |
+
- Surfaces: `--bg #0a0e17`, `--surface-0`, `--surface-1`, `--surface-2`
|
| 137 |
+
- Text: `--text-primary`, `--text-muted`, `--text-faint`
|
| 138 |
+
- Accent: `--accent #38bdf8` (cyan) — primary interactive color across UI
|
| 139 |
+
- Helpers: `.panel`, `.panel-raised`, `.tabular` (tabular-nums)
|
| 140 |
+
- **Color semantics**: cyan = primary/active, orange (`orange-400/500`) is reserved for **flagged-episode** UI only — don't reuse it for generic accents.
|
README.md
CHANGED
|
@@ -7,6 +7,10 @@ sdk: docker
|
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
license: apache-2.0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
# LeRobot Dataset Visualizer
|
|
|
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
license: apache-2.0
|
| 10 |
+
hf_oauth: true
|
| 11 |
+
hf_oauth_scopes:
|
| 12 |
+
- read-repos
|
| 13 |
+
hf_oauth_expiration_minutes: 480
|
| 14 |
---
|
| 15 |
|
| 16 |
# LeRobot Dataset Visualizer
|
bun.lock
CHANGED
|
@@ -5,6 +5,7 @@
|
|
| 5 |
"": {
|
| 6 |
"name": "lerobot-viewer",
|
| 7 |
"dependencies": {
|
|
|
|
| 8 |
"@react-three/drei": "^10.7.7",
|
| 9 |
"@react-three/fiber": "^9.5.0",
|
| 10 |
"hyparquet": "^1.12.1",
|
|
@@ -63,6 +64,10 @@
|
|
| 63 |
|
| 64 |
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
| 67 |
|
| 68 |
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
|
@@ -325,6 +330,8 @@
|
|
| 325 |
|
| 326 |
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
| 327 |
|
|
|
|
|
|
|
| 328 |
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
| 329 |
|
| 330 |
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
|
@@ -387,6 +394,8 @@
|
|
| 387 |
|
| 388 |
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
| 389 |
|
|
|
|
|
|
|
| 390 |
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
| 391 |
|
| 392 |
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
|
@@ -621,6 +630,8 @@
|
|
| 621 |
|
| 622 |
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
|
| 623 |
|
|
|
|
|
|
|
| 624 |
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
|
| 625 |
|
| 626 |
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
|
@@ -877,6 +888,8 @@
|
|
| 877 |
|
| 878 |
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
|
| 879 |
|
|
|
|
|
|
|
| 880 |
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
|
| 881 |
|
| 882 |
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
|
|
@@ -889,6 +902,8 @@
|
|
| 889 |
|
| 890 |
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
| 891 |
|
|
|
|
|
|
|
| 892 |
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
| 893 |
|
| 894 |
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
|
@@ -1021,6 +1036,8 @@
|
|
| 1021 |
|
| 1022 |
"stats-gl/three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="],
|
| 1023 |
|
|
|
|
|
|
|
| 1024 |
"three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="],
|
| 1025 |
|
| 1026 |
"tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
|
|
|
| 5 |
"": {
|
| 6 |
"name": "lerobot-viewer",
|
| 7 |
"dependencies": {
|
| 8 |
+
"@huggingface/hub": "^2.11.0",
|
| 9 |
"@react-three/drei": "^10.7.7",
|
| 10 |
"@react-three/fiber": "^9.5.0",
|
| 11 |
"hyparquet": "^1.12.1",
|
|
|
|
| 64 |
|
| 65 |
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
| 66 |
|
| 67 |
+
"@huggingface/hub": ["@huggingface/hub@2.11.0", "", { "dependencies": { "@huggingface/tasks": "^0.19.90" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-WS6QGaXYeBVFlaB4SOn6z4LGUpLB5kRZNL08uUni4izX353KxiwwZMK5+/AWX86MJh8SMZNa/JFcvFCcQsbszQ=="],
|
| 68 |
+
|
| 69 |
+
"@huggingface/tasks": ["@huggingface/tasks@0.19.90", "", {}, "sha512-nfV9luJbvwGQ/5oKXkKhCV9h4X7mwh1YaGG3ORd6UMLDSwr1OFSSatcBX0O9OtBtmNK19aGSjbLFqqgcIR6+IA=="],
|
| 70 |
+
|
| 71 |
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
| 72 |
|
| 73 |
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
|
|
|
| 330 |
|
| 331 |
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
| 332 |
|
| 333 |
+
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
| 334 |
+
|
| 335 |
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
| 336 |
|
| 337 |
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
|
|
|
| 394 |
|
| 395 |
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
| 396 |
|
| 397 |
+
"cli-progress": ["cli-progress@3.12.0", "", { "dependencies": { "string-width": "^4.2.3" } }, "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A=="],
|
| 398 |
+
|
| 399 |
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
| 400 |
|
| 401 |
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
|
|
|
| 630 |
|
| 631 |
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
|
| 632 |
|
| 633 |
+
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
| 634 |
+
|
| 635 |
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
|
| 636 |
|
| 637 |
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
|
|
|
| 888 |
|
| 889 |
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
|
| 890 |
|
| 891 |
+
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
| 892 |
+
|
| 893 |
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
|
| 894 |
|
| 895 |
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
|
|
|
|
| 902 |
|
| 903 |
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
| 904 |
|
| 905 |
+
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
| 906 |
+
|
| 907 |
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
| 908 |
|
| 909 |
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
|
|
|
| 1036 |
|
| 1037 |
"stats-gl/three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="],
|
| 1038 |
|
| 1039 |
+
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
| 1040 |
+
|
| 1041 |
"three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="],
|
| 1042 |
|
| 1043 |
"tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
package.json
CHANGED
|
@@ -15,6 +15,7 @@
|
|
| 15 |
"validate": "bun run type-check && bun run lint && bun run format:check && bun test"
|
| 16 |
},
|
| 17 |
"dependencies": {
|
|
|
|
| 18 |
"@react-three/drei": "^10.7.7",
|
| 19 |
"@react-three/fiber": "^9.5.0",
|
| 20 |
"hyparquet": "^1.12.1",
|
|
|
|
| 15 |
"validate": "bun run type-check && bun run lint && bun run format:check && bun test"
|
| 16 |
},
|
| 17 |
"dependencies": {
|
| 18 |
+
"@huggingface/hub": "^2.11.0",
|
| 19 |
"@react-three/drei": "^10.7.7",
|
| 20 |
"@react-three/fiber": "^9.5.0",
|
| 21 |
"hyparquet": "^1.12.1",
|
src/app/api/auth/session/route.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
// Mirror of Hugging Face's default OAuth token lifetime so the cookie expires
|
| 4 |
+
// alongside the upstream token. README sets hf_oauth_expiration_minutes: 480.
|
| 5 |
+
const COOKIE_NAME = "hf_access_token";
|
| 6 |
+
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 8;
|
| 7 |
+
|
| 8 |
+
export const runtime = "nodejs";
|
| 9 |
+
export const dynamic = "force-dynamic";
|
| 10 |
+
|
| 11 |
+
// POST sets the auth cookie from the Authorization header sent by the client
|
| 12 |
+
// after a successful OAuth flow. The cookie is HttpOnly so the access token
|
| 13 |
+
// is never exposed to JS — only the proxy route reads it.
|
| 14 |
+
export async function POST(req: NextRequest) {
|
| 15 |
+
const auth = req.headers.get("authorization");
|
| 16 |
+
if (!auth?.toLowerCase().startsWith("bearer ")) {
|
| 17 |
+
return new NextResponse("Missing bearer token", { status: 400 });
|
| 18 |
+
}
|
| 19 |
+
const token = auth.slice("bearer ".length).trim();
|
| 20 |
+
if (!token) {
|
| 21 |
+
return new NextResponse("Empty token", { status: 400 });
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const res = new NextResponse(null, { status: 204 });
|
| 25 |
+
res.cookies.set(COOKIE_NAME, token, {
|
| 26 |
+
httpOnly: true,
|
| 27 |
+
secure: process.env.NODE_ENV === "production",
|
| 28 |
+
sameSite: "lax",
|
| 29 |
+
path: "/",
|
| 30 |
+
maxAge: COOKIE_MAX_AGE_SECONDS,
|
| 31 |
+
});
|
| 32 |
+
return res;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export async function DELETE() {
|
| 36 |
+
const res = new NextResponse(null, { status: 204 });
|
| 37 |
+
res.cookies.delete(COOKIE_NAME);
|
| 38 |
+
return res;
|
| 39 |
+
}
|
src/app/api/proxy/[...path]/route.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest } from "next/server";
|
| 2 |
+
|
| 3 |
+
// Same-origin streaming proxy for huggingface.co. The native <video> element
|
| 4 |
+
// can't carry an Authorization header, so we proxy through this route, which
|
| 5 |
+
// pulls the user's HF access token from the HttpOnly `hf_access_token` cookie
|
| 6 |
+
// (set by /api/auth/session after OAuth) and forwards Range requests upstream.
|
| 7 |
+
//
|
| 8 |
+
// Public datasets work too — the upstream simply ignores the bearer token.
|
| 9 |
+
//
|
| 10 |
+
// Allowed path prefixes are constrained so this can't be turned into an open
|
| 11 |
+
// proxy for arbitrary huggingface.co URLs (e.g. user profile, billing pages).
|
| 12 |
+
|
| 13 |
+
const HF_HOST = "https://huggingface.co";
|
| 14 |
+
const COOKIE_NAME = "hf_access_token";
|
| 15 |
+
const ALLOWED_PREFIXES = ["datasets/", "buckets/"];
|
| 16 |
+
|
| 17 |
+
export const runtime = "nodejs";
|
| 18 |
+
export const dynamic = "force-dynamic";
|
| 19 |
+
|
| 20 |
+
const FORWARD_REQUEST_HEADERS = [
|
| 21 |
+
"range",
|
| 22 |
+
"if-modified-since",
|
| 23 |
+
"if-none-match",
|
| 24 |
+
"accept",
|
| 25 |
+
"accept-encoding",
|
| 26 |
+
];
|
| 27 |
+
|
| 28 |
+
const FORWARD_RESPONSE_HEADERS = [
|
| 29 |
+
"content-type",
|
| 30 |
+
"content-length",
|
| 31 |
+
"content-range",
|
| 32 |
+
"accept-ranges",
|
| 33 |
+
"etag",
|
| 34 |
+
"last-modified",
|
| 35 |
+
"cache-control",
|
| 36 |
+
];
|
| 37 |
+
|
| 38 |
+
export async function GET(
|
| 39 |
+
req: NextRequest,
|
| 40 |
+
ctx: { params: Promise<{ path: string[] }> },
|
| 41 |
+
) {
|
| 42 |
+
const { path } = await ctx.params;
|
| 43 |
+
const subPath = path.join("/");
|
| 44 |
+
|
| 45 |
+
if (!ALLOWED_PREFIXES.some((p) => subPath.startsWith(p))) {
|
| 46 |
+
return new Response("Forbidden", { status: 403 });
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const upstreamUrl = new URL(`${HF_HOST}/${subPath}`);
|
| 50 |
+
for (const [k, v] of req.nextUrl.searchParams) {
|
| 51 |
+
upstreamUrl.searchParams.set(k, v);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const headers = new Headers();
|
| 55 |
+
const token = req.cookies.get(COOKIE_NAME)?.value;
|
| 56 |
+
if (token) headers.set("authorization", `Bearer ${token}`);
|
| 57 |
+
for (const h of FORWARD_REQUEST_HEADERS) {
|
| 58 |
+
const v = req.headers.get(h);
|
| 59 |
+
if (v) headers.set(h, v);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const upstream = await fetch(upstreamUrl, {
|
| 63 |
+
method: "GET",
|
| 64 |
+
headers,
|
| 65 |
+
redirect: "follow",
|
| 66 |
+
cache: "no-store",
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
const respHeaders = new Headers();
|
| 70 |
+
for (const h of FORWARD_RESPONSE_HEADERS) {
|
| 71 |
+
const v = upstream.headers.get(h);
|
| 72 |
+
if (v) respHeaders.set(h, v);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
return new Response(upstream.body, {
|
| 76 |
+
status: upstream.status,
|
| 77 |
+
statusText: upstream.statusText,
|
| 78 |
+
headers: respHeaders,
|
| 79 |
+
});
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export async function HEAD(
|
| 83 |
+
req: NextRequest,
|
| 84 |
+
ctx: { params: Promise<{ path: string[] }> },
|
| 85 |
+
) {
|
| 86 |
+
const { path } = await ctx.params;
|
| 87 |
+
const subPath = path.join("/");
|
| 88 |
+
|
| 89 |
+
if (!ALLOWED_PREFIXES.some((p) => subPath.startsWith(p))) {
|
| 90 |
+
return new Response(null, { status: 403 });
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
const upstreamUrl = new URL(`${HF_HOST}/${subPath}`);
|
| 94 |
+
for (const [k, v] of req.nextUrl.searchParams) {
|
| 95 |
+
upstreamUrl.searchParams.set(k, v);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const headers = new Headers();
|
| 99 |
+
const token = req.cookies.get(COOKIE_NAME)?.value;
|
| 100 |
+
if (token) headers.set("authorization", `Bearer ${token}`);
|
| 101 |
+
|
| 102 |
+
const upstream = await fetch(upstreamUrl, {
|
| 103 |
+
method: "HEAD",
|
| 104 |
+
headers,
|
| 105 |
+
redirect: "follow",
|
| 106 |
+
cache: "no-store",
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
const respHeaders = new Headers();
|
| 110 |
+
for (const h of FORWARD_RESPONSE_HEADERS) {
|
| 111 |
+
const v = upstream.headers.get(h);
|
| 112 |
+
if (v) respHeaders.set(h, v);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
return new Response(null, {
|
| 116 |
+
status: upstream.status,
|
| 117 |
+
statusText: upstream.statusText,
|
| 118 |
+
headers: respHeaders,
|
| 119 |
+
});
|
| 120 |
+
}
|
src/app/layout.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 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 |
|
|
@@ -16,7 +18,14 @@ export default function RootLayout({
|
|
| 16 |
}) {
|
| 17 |
return (
|
| 18 |
<html lang="en">
|
| 19 |
-
<body className={inter.className}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
</html>
|
| 21 |
);
|
| 22 |
}
|
|
|
|
| 1 |
import type { Metadata } from "next";
|
| 2 |
import { Inter } from "next/font/google";
|
| 3 |
import "./globals.css";
|
| 4 |
+
import { AuthProvider } from "@/context/auth-context";
|
| 5 |
+
import HfAuthButton from "@/components/hf-auth-button";
|
| 6 |
|
| 7 |
const inter = Inter({ subsets: ["latin"] });
|
| 8 |
|
|
|
|
| 18 |
}) {
|
| 19 |
return (
|
| 20 |
<html lang="en">
|
| 21 |
+
<body className={inter.className}>
|
| 22 |
+
<AuthProvider>
|
| 23 |
+
<div className="fixed top-3 right-3 z-50">
|
| 24 |
+
<HfAuthButton />
|
| 25 |
+
</div>
|
| 26 |
+
{children}
|
| 27 |
+
</AuthProvider>
|
| 28 |
+
</body>
|
| 29 |
</html>
|
| 30 |
);
|
| 31 |
}
|
src/components/hf-auth-button.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import { useAuth } from "@/context/auth-context";
|
| 5 |
+
|
| 6 |
+
const SIGNIN_BADGE_URL =
|
| 7 |
+
"https://huggingface.co/datasets/huggingface/badges/resolve/main/sign-in-with-huggingface-md-dark.svg";
|
| 8 |
+
|
| 9 |
+
export default function HfAuthButton() {
|
| 10 |
+
const { oauth, isAuthAvailable, signIn, signOut } = useAuth();
|
| 11 |
+
|
| 12 |
+
if (!isAuthAvailable) return null;
|
| 13 |
+
|
| 14 |
+
if (oauth) {
|
| 15 |
+
const name =
|
| 16 |
+
oauth.userInfo?.preferred_username ?? oauth.userInfo?.name ?? "signed in";
|
| 17 |
+
const avatar = oauth.userInfo?.picture;
|
| 18 |
+
return (
|
| 19 |
+
<div className="flex items-center gap-2 panel-raised bg-[var(--surface-0)]/85 backdrop-blur px-2 py-1 text-xs text-slate-300">
|
| 20 |
+
{avatar && (
|
| 21 |
+
// eslint-disable-next-line @next/next/no-img-element
|
| 22 |
+
<img
|
| 23 |
+
src={avatar}
|
| 24 |
+
alt=""
|
| 25 |
+
width={18}
|
| 26 |
+
height={18}
|
| 27 |
+
className="rounded-full"
|
| 28 |
+
/>
|
| 29 |
+
)}
|
| 30 |
+
<span className="tabular max-w-[10rem] truncate">{name}</span>
|
| 31 |
+
<button
|
| 32 |
+
onClick={signOut}
|
| 33 |
+
className="rounded-md px-2 py-0.5 text-[10px] uppercase tracking-wide text-slate-400 hover:text-slate-100 hover:bg-white/5 transition-colors"
|
| 34 |
+
title="Sign out of Hugging Face"
|
| 35 |
+
>
|
| 36 |
+
Sign out
|
| 37 |
+
</button>
|
| 38 |
+
</div>
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<button
|
| 44 |
+
onClick={signIn}
|
| 45 |
+
title="Sign in to access your private datasets"
|
| 46 |
+
className="flex items-center gap-2 rounded-md transition-opacity hover:opacity-90"
|
| 47 |
+
>
|
| 48 |
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
| 49 |
+
<img
|
| 50 |
+
src={SIGNIN_BADGE_URL}
|
| 51 |
+
alt="Sign in with Hugging Face"
|
| 52 |
+
height={32}
|
| 53 |
+
className="h-8 w-auto"
|
| 54 |
+
/>
|
| 55 |
+
<span className="text-xs text-slate-300">to access private datasets</span>
|
| 56 |
+
</button>
|
| 57 |
+
);
|
| 58 |
+
}
|
src/components/simple-videos-player.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import React, { useEffect, useRef, useCallback } from "react";
|
|
| 4 |
import { useTime } from "../context/time-context";
|
| 5 |
import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
|
| 6 |
import type { VideoInfo } from "@/types";
|
|
|
|
| 7 |
|
| 8 |
const THRESHOLDS = {
|
| 9 |
VIDEO_SYNC_TOLERANCE: 0.2,
|
|
@@ -316,7 +317,7 @@ export const SimpleVideosPlayer = ({
|
|
| 316 |
isFirstVisible ? makeTimeUpdateHandler(idx) : undefined
|
| 317 |
}
|
| 318 |
>
|
| 319 |
-
<source src={info.url} type="video/mp4" />
|
| 320 |
Your browser does not support the video tag.
|
| 321 |
</video>
|
| 322 |
</div>
|
|
|
|
| 4 |
import { useTime } from "../context/time-context";
|
| 5 |
import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
|
| 6 |
import type { VideoInfo } from "@/types";
|
| 7 |
+
import { proxyHfUrl } from "@/utils/auth";
|
| 8 |
|
| 9 |
const THRESHOLDS = {
|
| 10 |
VIDEO_SYNC_TOLERANCE: 0.2,
|
|
|
|
| 317 |
isFirstVisible ? makeTimeUpdateHandler(idx) : undefined
|
| 318 |
}
|
| 319 |
>
|
| 320 |
+
<source src={proxyHfUrl(info.url)} type="video/mp4" />
|
| 321 |
Your browser does not support the video tag.
|
| 322 |
</video>
|
| 323 |
</div>
|
src/context/auth-context.tsx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, {
|
| 4 |
+
createContext,
|
| 5 |
+
useContext,
|
| 6 |
+
useEffect,
|
| 7 |
+
useState,
|
| 8 |
+
useCallback,
|
| 9 |
+
} from "react";
|
| 10 |
+
import {
|
| 11 |
+
oauthLoginUrl,
|
| 12 |
+
oauthHandleRedirectIfPresent,
|
| 13 |
+
type OAuthResult,
|
| 14 |
+
} from "@huggingface/hub";
|
| 15 |
+
import { AUTH_STORAGE_KEY } from "@/utils/auth";
|
| 16 |
+
|
| 17 |
+
interface HfSpaceVariables {
|
| 18 |
+
OAUTH_CLIENT_ID?: string;
|
| 19 |
+
OAUTH_SCOPES?: string;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
interface HfWindow extends Window {
|
| 23 |
+
huggingface?: { variables?: HfSpaceVariables };
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
interface AuthContextValue {
|
| 27 |
+
oauth: OAuthResult | null;
|
| 28 |
+
// Whether OAuth is configured for this deployment (i.e. running on an HF
|
| 29 |
+
// Space with hf_oauth enabled). When false, the button hides itself.
|
| 30 |
+
isAuthAvailable: boolean;
|
| 31 |
+
signIn: () => Promise<void>;
|
| 32 |
+
signOut: () => void;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const AuthContext = createContext<AuthContextValue>({
|
| 36 |
+
oauth: null,
|
| 37 |
+
isAuthAvailable: false,
|
| 38 |
+
signIn: async () => {},
|
| 39 |
+
signOut: () => {},
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
// Mirror the access token into an HttpOnly cookie so the same-origin
|
| 43 |
+
// /api/proxy route can attach it to <video> requests, which can't carry an
|
| 44 |
+
// Authorization header from JS.
|
| 45 |
+
async function setSessionCookie(accessToken: string): Promise<void> {
|
| 46 |
+
try {
|
| 47 |
+
await fetch("/api/auth/session", {
|
| 48 |
+
method: "POST",
|
| 49 |
+
headers: { Authorization: `Bearer ${accessToken}` },
|
| 50 |
+
});
|
| 51 |
+
} catch (err) {
|
| 52 |
+
console.error("Failed to set session cookie", err);
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
async function clearSessionCookie(): Promise<void> {
|
| 57 |
+
try {
|
| 58 |
+
await fetch("/api/auth/session", { method: "DELETE" });
|
| 59 |
+
} catch (err) {
|
| 60 |
+
console.error("Failed to clear session cookie", err);
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function isExpired(result: OAuthResult): boolean {
|
| 65 |
+
const exp = result.accessTokenExpiresAt;
|
| 66 |
+
if (!exp) return false;
|
| 67 |
+
const expDate = exp instanceof Date ? exp : new Date(exp);
|
| 68 |
+
return expDate.getTime() <= Date.now();
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
| 72 |
+
const [oauth, setOauth] = useState<OAuthResult | null>(null);
|
| 73 |
+
const [isAuthAvailable, setIsAuthAvailable] = useState(false);
|
| 74 |
+
|
| 75 |
+
useEffect(() => {
|
| 76 |
+
const w = window as HfWindow;
|
| 77 |
+
const available = !!w.huggingface?.variables?.OAUTH_CLIENT_ID;
|
| 78 |
+
setIsAuthAvailable(available);
|
| 79 |
+
if (!available) return;
|
| 80 |
+
|
| 81 |
+
const stored = window.localStorage.getItem(AUTH_STORAGE_KEY);
|
| 82 |
+
if (stored) {
|
| 83 |
+
try {
|
| 84 |
+
const parsed = JSON.parse(stored) as OAuthResult;
|
| 85 |
+
if (isExpired(parsed)) {
|
| 86 |
+
window.localStorage.removeItem(AUTH_STORAGE_KEY);
|
| 87 |
+
clearSessionCookie();
|
| 88 |
+
} else {
|
| 89 |
+
setOauth(parsed);
|
| 90 |
+
setSessionCookie(parsed.accessToken);
|
| 91 |
+
return;
|
| 92 |
+
}
|
| 93 |
+
} catch {
|
| 94 |
+
window.localStorage.removeItem(AUTH_STORAGE_KEY);
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
oauthHandleRedirectIfPresent()
|
| 99 |
+
.then((result) => {
|
| 100 |
+
if (result) {
|
| 101 |
+
window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(result));
|
| 102 |
+
setOauth(result);
|
| 103 |
+
setSessionCookie(result.accessToken);
|
| 104 |
+
}
|
| 105 |
+
})
|
| 106 |
+
.catch((err) => {
|
| 107 |
+
console.error("OAuth redirect handling failed", err);
|
| 108 |
+
});
|
| 109 |
+
}, []);
|
| 110 |
+
|
| 111 |
+
const signIn = useCallback(async () => {
|
| 112 |
+
const w = window as HfWindow;
|
| 113 |
+
const scopes = w.huggingface?.variables?.OAUTH_SCOPES;
|
| 114 |
+
const url = await oauthLoginUrl(scopes ? { scopes } : {});
|
| 115 |
+
window.location.href = url + "&prompt=consent";
|
| 116 |
+
}, []);
|
| 117 |
+
|
| 118 |
+
const signOut = useCallback(() => {
|
| 119 |
+
window.localStorage.removeItem(AUTH_STORAGE_KEY);
|
| 120 |
+
setOauth(null);
|
| 121 |
+
clearSessionCookie();
|
| 122 |
+
// Strip ?code=... left in the URL by the OAuth redirect, if any.
|
| 123 |
+
const cleanUrl = window.location.href.replace(/\?.*$/, "");
|
| 124 |
+
if (cleanUrl !== window.location.href) {
|
| 125 |
+
window.history.replaceState(null, "", cleanUrl);
|
| 126 |
+
}
|
| 127 |
+
}, []);
|
| 128 |
+
|
| 129 |
+
return (
|
| 130 |
+
<AuthContext.Provider value={{ oauth, isAuthAvailable, signIn, signOut }}>
|
| 131 |
+
{children}
|
| 132 |
+
</AuthContext.Provider>
|
| 133 |
+
);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
export function useAuth() {
|
| 137 |
+
return useContext(AuthContext);
|
| 138 |
+
}
|
src/utils/auth.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Client-side helpers for the HF OAuth flow. The token is owned by
|
| 2 |
+
// AuthProvider (src/context/auth-context.tsx); these read-only helpers exist
|
| 3 |
+
// so non-React fetch utilities can attach `Authorization: Bearer <token>`
|
| 4 |
+
// without going through React.
|
| 5 |
+
|
| 6 |
+
const STORAGE_KEY = "lerobot-viz-oauth";
|
| 7 |
+
|
| 8 |
+
export function getAuthToken(): string | null {
|
| 9 |
+
if (typeof window === "undefined") return null;
|
| 10 |
+
try {
|
| 11 |
+
const stored = window.localStorage.getItem(STORAGE_KEY);
|
| 12 |
+
if (!stored) return null;
|
| 13 |
+
const parsed = JSON.parse(stored) as { accessToken?: string };
|
| 14 |
+
return parsed.accessToken ?? null;
|
| 15 |
+
} catch {
|
| 16 |
+
return null;
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function authHeaders(): Record<string, string> {
|
| 21 |
+
const token = getAuthToken();
|
| 22 |
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export const AUTH_STORAGE_KEY = STORAGE_KEY;
|
| 26 |
+
|
| 27 |
+
// Native <video> elements can't carry an Authorization header. To play videos
|
| 28 |
+
// from private datasets, we route them through our same-origin /api/proxy
|
| 29 |
+
// endpoint, which reads the access token from an HttpOnly cookie set during
|
| 30 |
+
// sign-in and forwards the request to huggingface.co. Returns the original
|
| 31 |
+
// URL when running server-side or when the user is not signed in.
|
| 32 |
+
export function proxyHfUrl(url: string): string {
|
| 33 |
+
if (typeof window === "undefined") return url;
|
| 34 |
+
if (!getAuthToken()) return url;
|
| 35 |
+
try {
|
| 36 |
+
const parsed = new URL(url);
|
| 37 |
+
if (parsed.hostname !== "huggingface.co") return url;
|
| 38 |
+
return `/api/proxy${parsed.pathname}${parsed.search}`;
|
| 39 |
+
} catch {
|
| 40 |
+
return url;
|
| 41 |
+
}
|
| 42 |
+
}
|
src/utils/parquetUtils.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
| 5 |
parquetReadObjects,
|
| 6 |
type AsyncBuffer,
|
| 7 |
} from "hyparquet";
|
|
|
|
| 8 |
|
| 9 |
export interface DatasetMetadata {
|
| 10 |
codebase_version: string;
|
|
@@ -31,7 +32,10 @@ export interface DatasetMetadata {
|
|
| 31 |
}
|
| 32 |
|
| 33 |
export async function fetchJson<T>(url: string): Promise<T> {
|
| 34 |
-
const res = await fetch(url, {
|
|
|
|
|
|
|
|
|
|
| 35 |
if (!res.ok) {
|
| 36 |
throw new Error(
|
| 37 |
`Failed to fetch JSON ${url}: ${res.status} ${res.statusText}`,
|
|
@@ -58,7 +62,7 @@ export async function fetchParquetFile(url: string): Promise<ParquetFile> {
|
|
| 58 |
|
| 59 |
const file = await asyncBufferFromUrl({
|
| 60 |
url,
|
| 61 |
-
requestInit: { cache: "no-store" },
|
| 62 |
});
|
| 63 |
const wrapped = cachedAsyncBuffer(file);
|
| 64 |
parquetFileCache.set(url, wrapped);
|
|
|
|
| 5 |
parquetReadObjects,
|
| 6 |
type AsyncBuffer,
|
| 7 |
} from "hyparquet";
|
| 8 |
+
import { authHeaders } from "./auth";
|
| 9 |
|
| 10 |
export interface DatasetMetadata {
|
| 11 |
codebase_version: string;
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
export async function fetchJson<T>(url: string): Promise<T> {
|
| 35 |
+
const res = await fetch(url, {
|
| 36 |
+
cache: "no-store",
|
| 37 |
+
headers: authHeaders(),
|
| 38 |
+
});
|
| 39 |
if (!res.ok) {
|
| 40 |
throw new Error(
|
| 41 |
`Failed to fetch JSON ${url}: ${res.status} ${res.statusText}`,
|
|
|
|
| 62 |
|
| 63 |
const file = await asyncBufferFromUrl({
|
| 64 |
url,
|
| 65 |
+
requestInit: { cache: "no-store", headers: authHeaders() },
|
| 66 |
});
|
| 67 |
const wrapped = cachedAsyncBuffer(file);
|
| 68 |
parquetFileCache.set(url, wrapped);
|
src/utils/versionUtils.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
| 2 |
* Utility functions for checking dataset version compatibility
|
| 3 |
*/
|
| 4 |
|
|
|
|
|
|
|
| 5 |
const DATASET_URL =
|
| 6 |
process.env.DATASET_URL || "https://huggingface.co/datasets";
|
| 7 |
|
|
@@ -82,6 +84,7 @@ export async function getDatasetInfo(repoId: string): Promise<DatasetInfo> {
|
|
| 82 |
method: "GET",
|
| 83 |
cache: "no-store",
|
| 84 |
signal: controller.signal,
|
|
|
|
| 85 |
});
|
| 86 |
|
| 87 |
clearTimeout(timeoutId);
|
|
|
|
| 2 |
* Utility functions for checking dataset version compatibility
|
| 3 |
*/
|
| 4 |
|
| 5 |
+
import { authHeaders } from "./auth";
|
| 6 |
+
|
| 7 |
const DATASET_URL =
|
| 8 |
process.env.DATASET_URL || "https://huggingface.co/datasets";
|
| 9 |
|
|
|
|
| 84 |
method: "GET",
|
| 85 |
cache: "no-store",
|
| 86 |
signal: controller.signal,
|
| 87 |
+
headers: authHeaders(),
|
| 88 |
});
|
| 89 |
|
| 90 |
clearTimeout(timeoutId);
|