mishig HF Staff commited on
Commit
254aedd
·
verified ·
1 Parent(s): d3c7d9b

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

Browse files
AGENTS.md CHANGED
@@ -59,7 +59,7 @@ Locating an episode (its video URLs and offsets, its data file and row range) an
59
  - Create datasets with `leRobotDataset(repoId)` in `fetch-data.ts`: it passes the `DATASET_URL` endpoint and `authHeaders()`.
60
  - `episodes({ offset, limit })` reads the v3 episode index across every metadata chunk. `offset` is a position in the listing, not an `episode_index`.
61
  - `episode.data.fromRow` / `toRow` are row offsets **inside `episode.data.url`**, not dataset-wide frame indexes. Pass them straight to `rowStart` / `rowEnd`; never subtract the file's first `index`. `episode.data` is optional: it is absent when the data file can't be read.
62
- - Episode charts come from `frames()` (numeric and boolean series, bookkeeping columns left out). `loadEpisodeFrames` reads the task and language columns (`TEXT_COLUMNS`) from the same file alongside it, since `frames()` returns numbers only.
63
  - Camera sizes come from `parseInfo(...).cameras`. Never read them from `shape[0]` / `shape[1]`: some datasets declare channel-first shapes (`[3, H, W]`).
64
  - To upgrade, bump the version in `package.json` and run `bun install`. Read the package's changes first: 0.0.4 changed what `fromRow` / `toRow` mean.
65
 
@@ -131,9 +131,9 @@ Reserved/bookkeeping columns from lerobot — see `EXCLUDED_COLUMNS` in `src/uti
131
 
132
  - URDFs and meshes are hosted in the HF bucket `lerobot/robot-urdfs` — base URL `https://huggingface.co/buckets/lerobot/robot-urdfs/resolve` (no `/main` segment; buckets are unbranched). Override with `NEXT_PUBLIC_URDF_BASE_URL` for local development.
133
  - Asset layout under the bucket: `g1/`, `openarm/`, `so101/` (both SO-100 and SO-101 live here).
134
- - **URDFLoader gotcha**: after our `loadMeshCb` returns, `URDFLoader.js` does `if (obj instanceof THREE.Mesh) obj.material = <urdf-material>`, overwriting any material we set. Workaround: wrap the loaded mesh in a `THREE.Group` so the `instanceof Mesh` check fails. DAE returns a Group already; STL must be wrapped explicitly.
 
135
  - **STLLoader event ordering**: `manager.itemEnd(url)` fires _before_ the user `onLoad` callback, so `manager.onLoad` can fire before meshes are attached to the robot tree. Defer post-load work (auto-fit camera, shadow flags) with `setTimeout(..., 0)`. Don't try to rebuild materials in `manager.onLoad` — pick the archetype color directly inside `loadMeshCb`.
136
- - **OpenArm DAE files ship 23 stray `PointLight`s** that drown out scene lighting. Strip non-`AmbientLight` lights from `collada.scene` before adding it to the robot.
137
  - Scene setup: `<Canvas shadows>` with `ACESFilmicToneMapping` (exposure 0.9), 3-point directional + ambient lights, `<Environment preset="studio" background={false} />`, `<color attach="background" args={["#1a2433"]} />`. `<OrbitControls makeDefault />` is required so `useThree().controls` exposes the controls for auto-fit.
138
 
139
  ## Design system
 
59
  - Create datasets with `leRobotDataset(repoId)` in `fetch-data.ts`: it passes the `DATASET_URL` endpoint and `authHeaders()`.
60
  - `episodes({ offset, limit })` reads the v3 episode index across every metadata chunk. `offset` is a position in the listing, not an `episode_index`.
61
  - `episode.data.fromRow` / `toRow` are row offsets **inside `episode.data.url`**, not dataset-wide frame indexes. Pass them straight to `rowStart` / `rowEnd`; never subtract the file's first `index`. `episode.data` is optional: it is absent when the data file can't be read.
62
+ - Episode charts come from `frames()` (numeric and boolean series, bookkeeping columns left out). `loadEpisodeFrames` reads the task and language columns (`TEXT_COLUMNS`) from the same file alongside it, since `frames()` returns numbers only. hyparquet decodes a nested column for its whole row group, so a nested language column is left out past `MAX_NESTED_TEXT_VALUES` values.
63
  - Camera sizes come from `parseInfo(...).cameras`. Never read them from `shape[0]` / `shape[1]`: some datasets declare channel-first shapes (`[3, H, W]`).
64
  - To upgrade, bump the version in `package.json` and run `bun install`. Read the package's changes first: 0.0.4 changed what `fromRow` / `toRow` mean.
65
 
 
131
 
132
  - URDFs and meshes are hosted in the HF bucket `lerobot/robot-urdfs` — base URL `https://huggingface.co/buckets/lerobot/robot-urdfs/resolve` (no `/main` segment; buckets are unbranched). Override with `NEXT_PUBLIC_URDF_BASE_URL` for local development.
133
  - Asset layout under the bucket: `g1/`, `openarm/`, `so101/` (both SO-100 and SO-101 live here).
134
+ - SO-100/SO-101 and OpenArm are drawn from the USDs in the dataset [`lerobot/robot-usds`](https://huggingface.co/datasets/lerobot/robot-usds), loaded with `@huggingface/usd-web-viewer`. Their URDFs share the USDs' joint and link names, link frames and zero pose, and only move them: `attachUsdMeshes` puts each USD mesh under the URDF link its prim path names. G1 still loads its URDF's STL meshes.
135
+ - **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.
136
  - **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`.
 
137
  - Scene setup: `<Canvas shadows>` with `ACESFilmicToneMapping` (exposure 0.9), 3-point directional + ambient lights, `<Environment preset="studio" background={false} />`, `<color attach="background" args={["#1a2433"]} />`. `<OrbitControls makeDefault />` is required so `useThree().controls` exposes the controls for auto-fit.
138
 
139
  ## Design system
README.md CHANGED
@@ -37,10 +37,33 @@ This tool is designed to help robotics researchers and practitioners quickly ins
37
  - **Action Insights Panel:** Data-driven analysis tools to guide training configuration — includes autocorrelation, state-action alignment, speed distribution, and cross-episode variance heatmap.
38
  - **Filtering Panel:** Identify and flag problematic episodes (low movement, jerky motion, outlier length) for removal. Exports flagged episode IDs as a ready-to-run LeRobot CLI command.
39
  - **3D URDF Viewer:** Visualize robot joint poses frame-by-frame in an interactive 3D scene, with end-effector trail rendering. Supports SO-100, SO-101, and OpenArm bimanual robots.
 
40
  - **Annotations Panel:** Hand-edit the v3.1 language schema (`language_persistent` + `language_events`) — subtask, plan, memory, interjection + paired speech, and VQA atoms with bounding-box / keypoint / count / attribute / spatial answers. VQA bboxes and keypoints render as overlays on the video player; drag or click on a camera to draw new ones. Backed by an optional FastAPI service (in `backend/`) for parquet rewrites and HF Hub push.
41
  - **Efficient Data Loading:** Uses parquet and JSON loading for large dataset support, with pagination, chunking, and lazy-loaded panels for fast initial load.
42
  - **Responsive UI:** Built with React, Next.js, and Tailwind CSS for a fast, modern user experience.
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  ## Technologies Used
45
 
46
  - **Next.js** (App Router)
@@ -48,6 +71,7 @@ This tool is designed to help robotics researchers and practitioners quickly ins
48
  - **Recharts** (for data visualization)
49
  - **Three.js** + **@react-three/fiber** + **@react-three/drei** (for 3D URDF visualization)
50
  - **urdf-loader** (for parsing URDF robot models)
 
51
  - **hyparquet** (for reading Parquet files)
52
  - **Tailwind CSS** (styling)
53
 
 
37
  - **Action Insights Panel:** Data-driven analysis tools to guide training configuration — includes autocorrelation, state-action alignment, speed distribution, and cross-episode variance heatmap.
38
  - **Filtering Panel:** Identify and flag problematic episodes (low movement, jerky motion, outlier length) for removal. Exports flagged episode IDs as a ready-to-run LeRobot CLI command.
39
  - **3D URDF Viewer:** Visualize robot joint poses frame-by-frame in an interactive 3D scene, with end-effector trail rendering. Supports SO-100, SO-101, and OpenArm bimanual robots.
40
+ - **3D Hands:** Automatically detects native SOMA156 hand annotations. Shows wrists, 21-joint hand skeletons, wrist trails and the saved camera trajectory alongside a synchronized camera overlay, with a Follow camera control.
41
  - **Annotations Panel:** Hand-edit the v3.1 language schema (`language_persistent` + `language_events`) — subtask, plan, memory, interjection + paired speech, and VQA atoms with bounding-box / keypoint / count / attribute / spatial answers. VQA bboxes and keypoints render as overlays on the video player; drag or click on a camera to draw new ones. Backed by an optional FastAPI service (in `backend/`) for parquet rewrites and HF Hub push.
42
  - **Efficient Data Loading:** Uses parquet and JSON loading for large dataset support, with pagination, chunking, and lazy-loaded panels for fast initial load.
43
  - **Responsive UI:** Built with React, Next.js, and Tailwind CSS for a fast, modern user experience.
44
 
45
+ ## Hand annotations
46
+
47
+ Choose **Advanced → 3D Hands** (below **Annotations**) or open an annotated
48
+ episode with `?tab=hands`. Detection requires the 156D
49
+ `action` plus `annotation.hand_joints_camera` and `annotation.hand_joints_world`
50
+ (126 values each), `annotation.hand_action_valid`, `annotation.hand_observed`
51
+ (left/right) and `annotation.hand_reference_frame`. No model weights or meshes
52
+ are required. Missing or masked poses remain gaps in both views.
53
+
54
+ When `annotation.camera_world_from_optical` contains saved rigid transforms,
55
+ the viewer reconstructs recording-wide hand positions from camera-space joints,
56
+ anchored to the first saved camera pose. The camera path also breaks at missing
57
+ transforms. Without them, it displays the stored segment-start FLU coordinates
58
+ and breaks wrist trails at reference resets. Positions are in meters.
59
+
60
+ The overlay uses the source camera and per-source-episode calibration saved in
61
+ `annotation_studio/run.json`, resolving exported episodes through
62
+ `source_episode_map`; a video feature's `info["camera.intrinsics"]` is also
63
+ supported. Pinhole and OpenCV fisheye distortion are projected onto the original
64
+ video at its saved resolution. Unknown cameras, missing calibration and mismatched
65
+ video dimensions disable the overlay. Estimated calibration is labeled.
66
+
67
  ## Technologies Used
68
 
69
  - **Next.js** (App Router)
 
71
  - **Recharts** (for data visualization)
72
  - **Three.js** + **@react-three/fiber** + **@react-three/drei** (for 3D URDF visualization)
73
  - **urdf-loader** (for parsing URDF robot models)
74
+ - **@huggingface/usd-web-viewer** (for the robot USD models in [lerobot/robot-usds](https://huggingface.co/datasets/lerobot/robot-usds))
75
  - **hyparquet** (for reading Parquet files)
76
  - **Tailwind CSS** (styling)
77
 
bun.lock CHANGED
@@ -6,9 +6,11 @@
6
  "name": "lerobot-viewer",
7
  "dependencies": {
8
  "@huggingface/hub": "^2.11.0",
9
- "@huggingface/lerobot": "^0.0.11",
 
10
  "@react-three/drei": "^10.7.7",
11
  "@react-three/fiber": "^9.5.0",
 
12
  "hyparquet": "^1.12.1",
13
  "next": "15.3.6",
14
  "react": "^19.0.0",
@@ -28,6 +30,7 @@
28
  "@types/three": "^0.182.0",
29
  "eslint": "^9",
30
  "eslint-config-next": "15.3.1",
 
31
  "prettier": "3.8.2",
32
  "tailwindcss": "^4",
33
  "typescript": "^5",
@@ -67,10 +70,12 @@
67
 
68
  "@huggingface/hub": ["@huggingface/hub@2.11.0", "", { "dependencies": { "@huggingface/tasks": "^0.19.90" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-WS6QGaXYeBVFlaB4SOn6z4LGUpLB5kRZNL08uUni4izX353KxiwwZMK5+/AWX86MJh8SMZNa/JFcvFCcQsbszQ=="],
69
 
70
- "@huggingface/lerobot": ["@huggingface/lerobot@0.0.11", "", { "dependencies": { "hyparquet": "^1.31.2" } }, "sha512-HlU2b8PC+khySlnTeRWxIUSB41eN83H0Gpci0GK/folPWrAppLD4v0dqAXq1NDTloh5elBxX+rfhf+TQD12fkw=="],
71
 
72
  "@huggingface/tasks": ["@huggingface/tasks@0.19.90", "", {}, "sha512-nfV9luJbvwGQ/5oKXkKhCV9h4X7mwh1YaGG3ORd6UMLDSwr1OFSSatcBX0O9OtBtmNK19aGSjbLFqqgcIR6+IA=="],
73
 
 
 
74
  "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
75
 
76
  "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@@ -263,6 +268,10 @@
263
 
264
  "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
265
 
 
 
 
 
266
  "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.55.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/type-utils": "8.55.0", "@typescript-eslint/utils": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ=="],
267
 
268
  "@typescript-eslint/parser": ["@typescript-eslint/parser@8.55.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw=="],
@@ -379,6 +388,8 @@
379
 
380
  "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
381
 
 
 
382
  "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
383
 
384
  "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
@@ -471,6 +482,8 @@
471
 
472
  "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
473
 
 
 
474
  "es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
475
 
476
  "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
@@ -557,6 +570,8 @@
557
 
558
  "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
559
 
 
 
560
  "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
561
 
562
  "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -579,6 +594,8 @@
579
 
580
  "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
581
 
 
 
582
  "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
583
 
584
  "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
@@ -981,6 +998,8 @@
981
 
982
  "webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="],
983
 
 
 
984
  "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
985
 
986
  "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -993,6 +1012,8 @@
993
 
994
  "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
995
 
 
 
996
  "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
997
 
998
  "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
 
6
  "name": "lerobot-viewer",
7
  "dependencies": {
8
  "@huggingface/hub": "^2.11.0",
9
+ "@huggingface/lerobot": "^0.0.13",
10
+ "@huggingface/usd-web-viewer": "0.2.0",
11
  "@react-three/drei": "^10.7.7",
12
  "@react-three/fiber": "^9.5.0",
13
+ "fzstd": "0.1.1",
14
  "hyparquet": "^1.12.1",
15
  "next": "15.3.6",
16
  "react": "^19.0.0",
 
30
  "@types/three": "^0.182.0",
31
  "eslint": "^9",
32
  "eslint-config-next": "15.3.1",
33
+ "happy-dom": "21.0.4",
34
  "prettier": "3.8.2",
35
  "tailwindcss": "^4",
36
  "typescript": "^5",
 
70
 
71
  "@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=="],
72
 
73
+ "@huggingface/lerobot": ["@huggingface/lerobot@0.0.13", "", { "dependencies": { "fzstd": "0.1.1", "hyparquet": "^1.31.2" } }, "sha512-FWjICnB9ndm4eRWH52xAybT4bDhK+JQZCMJrF3ivVYtG/b8H81I+vcZ5Yw22NNCQVZ2Ib7FgOLYo0rmyxJrUoQ=="],
74
 
75
  "@huggingface/tasks": ["@huggingface/tasks@0.19.90", "", {}, "sha512-nfV9luJbvwGQ/5oKXkKhCV9h4X7mwh1YaGG3ORd6UMLDSwr1OFSSatcBX0O9OtBtmNK19aGSjbLFqqgcIR6+IA=="],
76
 
77
+ "@huggingface/usd-web-viewer": ["@huggingface/usd-web-viewer@0.2.0", "", { "peerDependencies": { "@types/three": ">=0.162.0 <0.187.0", "three": ">=0.162.0 <0.187.0" }, "optionalPeers": ["@types/three"] }, "sha512-2VpC0Gwbq0n7cl4/Lb5YpxQeUdFg6rvM2ZpIkU/Hc5eYaM5nsHqcDgwy2RVaZ/DOJcgDpmwkxWAlrVXhq3rpqg=="],
78
+
79
  "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
80
 
81
  "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
 
268
 
269
  "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
270
 
271
+ "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
272
+
273
+ "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="],
274
+
275
  "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.55.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/type-utils": "8.55.0", "@typescript-eslint/utils": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ=="],
276
 
277
  "@typescript-eslint/parser": ["@typescript-eslint/parser@8.55.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw=="],
 
388
 
389
  "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
390
 
391
+ "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
392
+
393
  "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
394
 
395
  "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
 
482
 
483
  "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
484
 
485
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
486
+
487
  "es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
488
 
489
  "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
 
570
 
571
  "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
572
 
573
+ "fzstd": ["fzstd@0.1.1", "", {}, "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA=="],
574
+
575
  "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
576
 
577
  "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
 
594
 
595
  "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
596
 
597
+ "happy-dom": ["happy-dom@21.0.4", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw=="],
598
+
599
  "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
600
 
601
  "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
 
998
 
999
  "webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="],
1000
 
1001
+ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
1002
+
1003
  "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
1004
 
1005
  "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
 
1012
 
1013
  "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
1014
 
1015
+ "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="],
1016
+
1017
  "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
1018
 
1019
  "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
package.json CHANGED
@@ -16,9 +16,11 @@
16
  },
17
  "dependencies": {
18
  "@huggingface/hub": "^2.11.0",
19
- "@huggingface/lerobot": "^0.0.11",
 
20
  "@react-three/drei": "^10.7.7",
21
  "@react-three/fiber": "^9.5.0",
 
22
  "hyparquet": "^1.12.1",
23
  "next": "15.3.6",
24
  "react": "^19.0.0",
@@ -38,6 +40,7 @@
38
  "@types/three": "^0.182.0",
39
  "eslint": "^9",
40
  "eslint-config-next": "15.3.1",
 
41
  "prettier": "3.8.2",
42
  "tailwindcss": "^4",
43
  "typescript": "^5"
 
16
  },
17
  "dependencies": {
18
  "@huggingface/hub": "^2.11.0",
19
+ "@huggingface/lerobot": "^0.0.13",
20
+ "@huggingface/usd-web-viewer": "0.2.0",
21
  "@react-three/drei": "^10.7.7",
22
  "@react-three/fiber": "^9.5.0",
23
+ "fzstd": "0.1.1",
24
  "hyparquet": "^1.12.1",
25
  "next": "15.3.6",
26
  "react": "^19.0.0",
 
40
  "@types/three": "^0.182.0",
41
  "eslint": "^9",
42
  "eslint-config-next": "15.3.1",
43
+ "happy-dom": "21.0.4",
44
  "prettier": "3.8.2",
45
  "tailwindcss": "^4",
46
  "typescript": "^5"
src/app/[org]/[dataset]/[episode]/episode-viewer.tsx CHANGED
@@ -6,6 +6,8 @@ import { postParentMessageWithParams } from "@/utils/postParentMessage";
6
  import { SimpleVideosPlayer } from "@/components/simple-videos-player";
7
  import PlaybackBar from "@/components/playback-bar";
8
  import { TimeProvider, useTime, useTimeControls } from "@/context/time-context";
 
 
9
  import { FlaggedEpisodesProvider } from "@/context/flagged-episodes-context";
10
  import {
11
  AnnotationsProvider,
@@ -37,6 +39,7 @@ import { getDatasetVersionAndInfo } from "@/utils/versionUtils";
37
  import type { DatasetMetadata } from "@/utils/parquetUtils";
38
 
39
  const URDFViewer = lazy(() => import("@/components/urdf-viewer"));
 
40
  const ActionInsightsPanel = lazy(
41
  () => import("@/components/action-insights-panel"),
42
  );
@@ -71,6 +74,7 @@ const TABS = [
71
  "filtering",
72
  "doctor",
73
  "urdf",
 
74
  ] as const;
75
  type ActiveTab = (typeof TABS)[number];
76
 
@@ -118,6 +122,11 @@ const ADVANCED_TABS: { tab: ActiveTab; label: string; title?: string }[] = [
118
  title:
119
  "Edit subtask / plan / memory / interjection / VQA atoms (lerobot v3.1 schema)",
120
  },
 
 
 
 
 
121
  { tab: "statistics", label: "Statistics" },
122
  { tab: "filtering", label: "Filtering" },
123
  { tab: "frames", label: "Frames" },
@@ -132,13 +141,16 @@ const ADVANCED_TABS: { tab: ActiveTab; label: string; title?: string }[] = [
132
  function AdvancedTabsMenu({
133
  activeTab,
134
  onSelect,
 
135
  }: {
136
  activeTab: ActiveTab;
137
  onSelect: (tab: ActiveTab) => void;
 
138
  }) {
139
  const [open, setOpen] = useState(false);
140
  const rootRef = useRef<HTMLDivElement>(null);
141
- const active = ADVANCED_TABS.find((t) => t.tab === activeTab);
 
142
 
143
  useEffect(() => {
144
  if (!open) return;
@@ -193,7 +205,7 @@ function AdvancedTabsMenu({
193
  role="menu"
194
  className="absolute left-0 top-full z-50 mt-1 min-w-[12rem] panel-raised bg-[var(--surface-1)] p-1.5 shadow-xl animate-menu-pop [transform-origin:top_left]"
195
  >
196
- {ADVANCED_TABS.map(({ tab, label, title }) => (
197
  <button
198
  key={tab}
199
  role="menuitem"
@@ -265,6 +277,9 @@ export default function EpisodeViewer({
265
  const [error, setError] = useState<string | null>(null);
266
  const [noEpisodes, setNoEpisodes] = useState(false);
267
  const requestIdRef = useRef(0);
 
 
 
268
 
269
  // Opening straight onto 3D Replay: start the robot meshes downloading now,
270
  // alongside the episode data, rather than once it has loaded and the viewer
@@ -291,6 +306,14 @@ export default function EpisodeViewer({
291
  setData(null);
292
  return;
293
  }
 
 
 
 
 
 
 
 
294
  const requestId = ++requestIdRef.current;
295
  setError(null);
296
  setNoEpisodes(false);
@@ -315,7 +338,7 @@ export default function EpisodeViewer({
315
  setError(message || "Unknown error");
316
  setData(null);
317
  });
318
- }, [org, dataset, episodeId]);
319
 
320
  if (noEpisodes) {
321
  return (
@@ -347,6 +370,9 @@ export default function EpisodeViewer({
347
  <p className="text-sm font-mono whitespace-pre-wrap text-red-700/90 dark:text-red-200/90">
348
  {error}
349
  </p>
 
 
 
350
  </div>
351
  </div>
352
  );
@@ -426,11 +452,19 @@ function EpisodeViewerInner({
426
  // Safe because EpisodeViewerInner only mounts client-side (behind a loading gate).
427
  const [activeTab, setActiveTab] = useState<ActiveTab>(() => {
428
  if (typeof window !== "undefined") {
 
 
 
 
 
 
 
429
  const stored = sessionStorage.getItem("activeTab");
430
  /// A tab saved on another dataset may not exist here (3D Replay needs a supported robot).
431
  if (
432
  TABS.includes(stored as ActiveTab) &&
433
- (stored !== "urdf" || urdfSupported)
 
434
  ) {
435
  return stored as ActiveTab;
436
  }
@@ -741,7 +775,11 @@ function EpisodeViewerInner({
741
  <div className="relative z-30 flex items-center border-b border-line-subtle bg-[var(--surface-0)] shrink-0">
742
  {renderTab("episodes", "Episodes")}
743
  {urdfSupported && renderTab("urdf", "3D Replay")}
744
- <AdvancedTabsMenu activeTab={activeTab} onSelect={handleTabChange} />
 
 
 
 
745
  <div className="ml-auto flex shrink-0 items-center gap-1 pl-2">
746
  <ThemeToggle />
747
  <HfAuthButton variant="tab" />
@@ -753,6 +791,7 @@ function EpisodeViewerInner({
753
  {/* Sidebar — on Episodes and 3D Replay tabs */}
754
  {(activeTab === "episodes" ||
755
  activeTab === "annotations" ||
 
756
  activeTab === "urdf") && (
757
  <Sidebar
758
  datasetInfo={datasetInfo}
@@ -795,6 +834,7 @@ function EpisodeViewerInner({
795
  <SimpleVideosPlayer
796
  videosInfo={videosInfo}
797
  onVideosReady={() => setVideosReady(true)}
 
798
  />
799
  )}
800
 
@@ -846,6 +886,7 @@ function EpisodeViewerInner({
846
  <SimpleVideosPlayer
847
  videosInfo={videosInfo}
848
  onVideosReady={() => setVideosReady(true)}
 
849
  />
850
  </div>
851
  )}
@@ -942,6 +983,20 @@ function EpisodeViewerInner({
942
  />
943
  </Suspense>
944
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
945
  </div>
946
  </div>
947
  </div>
 
6
  import { SimpleVideosPlayer } from "@/components/simple-videos-player";
7
  import PlaybackBar from "@/components/playback-bar";
8
  import { TimeProvider, useTime, useTimeControls } from "@/context/time-context";
9
+ import { useAuth } from "@/context/auth-context";
10
+ import { getAuthToken } from "@/utils/auth";
11
  import { FlaggedEpisodesProvider } from "@/context/flagged-episodes-context";
12
  import {
13
  AnnotationsProvider,
 
39
  import type { DatasetMetadata } from "@/utils/parquetUtils";
40
 
41
  const URDFViewer = lazy(() => import("@/components/urdf-viewer"));
42
+ const HandReplay = lazy(() => import("@/components/hand-replay"));
43
  const ActionInsightsPanel = lazy(
44
  () => import("@/components/action-insights-panel"),
45
  );
 
74
  "filtering",
75
  "doctor",
76
  "urdf",
77
+ "hands",
78
  ] as const;
79
  type ActiveTab = (typeof TABS)[number];
80
 
 
122
  title:
123
  "Edit subtask / plan / memory / interjection / VQA atoms (lerobot v3.1 schema)",
124
  },
125
+ {
126
+ tab: "hands",
127
+ label: "3D Hands",
128
+ title: "Synchronized wrists, hands and camera overlays",
129
+ },
130
  { tab: "statistics", label: "Statistics" },
131
  { tab: "filtering", label: "Filtering" },
132
  { tab: "frames", label: "Frames" },
 
141
  function AdvancedTabsMenu({
142
  activeTab,
143
  onSelect,
144
+ handsAvailable,
145
  }: {
146
  activeTab: ActiveTab;
147
  onSelect: (tab: ActiveTab) => void;
148
+ handsAvailable: boolean;
149
  }) {
150
  const [open, setOpen] = useState(false);
151
  const rootRef = useRef<HTMLDivElement>(null);
152
+ const tabs = ADVANCED_TABS.filter((t) => t.tab !== "hands" || handsAvailable);
153
+ const active = tabs.find((t) => t.tab === activeTab);
154
 
155
  useEffect(() => {
156
  if (!open) return;
 
205
  role="menu"
206
  className="absolute left-0 top-full z-50 mt-1 min-w-[12rem] panel-raised bg-[var(--surface-1)] p-1.5 shadow-xl animate-menu-pop [transform-origin:top_left]"
207
  >
208
+ {tabs.map(({ tab, label, title }) => (
209
  <button
210
  key={tab}
211
  role="menuitem"
 
277
  const [error, setError] = useState<string | null>(null);
278
  const [noEpisodes, setNoEpisodes] = useState(false);
279
  const requestIdRef = useRef(0);
280
+ const loadedRef = useRef<{ key: string; token: string | null } | null>(null);
281
+ const { oauth } = useAuth();
282
+ const accessToken = oauth?.accessToken ?? null;
283
 
284
  // Opening straight onto 3D Replay: start the robot meshes downloading now,
285
  // alongside the episode data, rather than once it has loaded and the viewer
 
306
  setData(null);
307
  return;
308
  }
309
+ // Requests read the token from storage, so the first load already uses a
310
+ // saved sign-in, which AuthProvider restores into `accessToken` only
311
+ // afterwards. Reload for a new token, not for that one.
312
+ const key = `${org}/${dataset}/${episodeId}`;
313
+ const token = getAuthToken();
314
+ if (loadedRef.current?.key === key && loadedRef.current.token === token)
315
+ return;
316
+ loadedRef.current = { key, token };
317
  const requestId = ++requestIdRef.current;
318
  setError(null);
319
  setNoEpisodes(false);
 
338
  setError(message || "Unknown error");
339
  setData(null);
340
  });
341
+ }, [org, dataset, episodeId, accessToken]);
342
 
343
  if (noEpisodes) {
344
  return (
 
370
  <p className="text-sm font-mono whitespace-pre-wrap text-red-700/90 dark:text-red-200/90">
371
  {error}
372
  </p>
373
+ <div className="mt-4">
374
+ <HfAuthButton variant="ghost" />
375
+ </div>
376
  </div>
377
  </div>
378
  );
 
452
  // Safe because EpisodeViewerInner only mounts client-side (behind a loading gate).
453
  const [activeTab, setActiveTab] = useState<ActiveTab>(() => {
454
  if (typeof window !== "undefined") {
455
+ const requested = new URLSearchParams(window.location.search).get("tab");
456
+ if (
457
+ TABS.includes(requested as ActiveTab) &&
458
+ (requested !== "hands" || data.handTracking) &&
459
+ (requested !== "urdf" || urdfSupported)
460
+ )
461
+ return requested as ActiveTab;
462
  const stored = sessionStorage.getItem("activeTab");
463
  /// A tab saved on another dataset may not exist here (3D Replay needs a supported robot).
464
  if (
465
  TABS.includes(stored as ActiveTab) &&
466
+ (stored !== "urdf" || urdfSupported) &&
467
+ (stored !== "hands" || data.handTracking)
468
  ) {
469
  return stored as ActiveTab;
470
  }
 
775
  <div className="relative z-30 flex items-center border-b border-line-subtle bg-[var(--surface-0)] shrink-0">
776
  {renderTab("episodes", "Episodes")}
777
  {urdfSupported && renderTab("urdf", "3D Replay")}
778
+ <AdvancedTabsMenu
779
+ activeTab={activeTab}
780
+ onSelect={handleTabChange}
781
+ handsAvailable={!!data.handTracking}
782
+ />
783
  <div className="ml-auto flex shrink-0 items-center gap-1 pl-2">
784
  <ThemeToggle />
785
  <HfAuthButton variant="tab" />
 
791
  {/* Sidebar — on Episodes and 3D Replay tabs */}
792
  {(activeTab === "episodes" ||
793
  activeTab === "annotations" ||
794
+ activeTab === "hands" ||
795
  activeTab === "urdf") && (
796
  <Sidebar
797
  datasetInfo={datasetInfo}
 
834
  <SimpleVideosPlayer
835
  videosInfo={videosInfo}
836
  onVideosReady={() => setVideosReady(true)}
837
+ handTracking={data.handTracking}
838
  />
839
  )}
840
 
 
886
  <SimpleVideosPlayer
887
  videosInfo={videosInfo}
888
  onVideosReady={() => setVideosReady(true)}
889
+ handTracking={data.handTracking}
890
  />
891
  </div>
892
  )}
 
983
  />
984
  </Suspense>
985
  )}
986
+ {activeTab === "hands" && data.handTracking && (
987
+ <>
988
+ <EpisodeHeader
989
+ repoId={datasetInfo.repoId}
990
+ episodeId={episodeId}
991
+ />
992
+ <Suspense fallback={<Loading />}>
993
+ <HandReplay
994
+ tracking={data.handTracking}
995
+ videosInfo={videosInfo}
996
+ />
997
+ </Suspense>
998
+ </>
999
+ )}
1000
  </div>
1001
  </div>
1002
  </div>
src/app/[org]/[dataset]/[episode]/fetch-data.ts CHANGED
@@ -2,8 +2,10 @@ import {
2
  DatasetMetadata,
3
  fetchParquetFile,
4
  formatStringWithVars,
 
5
  readParquetAsObjects,
6
  } from "@/utils/parquetUtils";
 
7
  import { pick } from "@/utils/pick";
8
  import {
9
  getDatasetVersionAndInfo,
@@ -31,6 +33,13 @@ import {
31
  } from "@huggingface/lerobot";
32
  import type { LeRobotEpisode, LeRobotFrames } from "@huggingface/lerobot";
33
  import { authHeaders, getAuthToken } from "@/utils/auth";
 
 
 
 
 
 
 
34
 
35
  const SERIES_NAME_DELIMITER = CHART_CONFIG.SERIES_NAME_DELIMITER;
36
 
@@ -279,6 +288,8 @@ export type EpisodeData = {
279
  * events compatible with the writer in lerobot#3471.
280
  */
281
  frameTimestamps?: number[];
 
 
282
  };
283
 
284
  type EpisodeMetadataV3 = {
@@ -745,7 +756,11 @@ async function getEpisodeDataV2(
745
  const videosInfo =
746
  info.video_path !== null ? toVideosInfo(leRobotEpisode, info, false) : [];
747
 
748
- const { frames, textRows } = await loadEpisodeFrames(repoId, leRobotEpisode);
 
 
 
 
749
  const frameTimestamps = sortedTimestamps(frames);
750
  const languageAtoms = extractLanguageAtoms(textRows);
751
 
@@ -842,6 +857,7 @@ async function getEpisodeDataV2(
842
  task,
843
  languageAtoms,
844
  frameTimestamps,
 
845
  };
846
  }
847
 
@@ -877,6 +893,7 @@ async function getEpisodeDataV3(
877
  task,
878
  languageAtoms,
879
  frameTimestamps,
 
880
  } = await loadEpisodeDataV3(
881
  repoId,
882
  version,
@@ -901,6 +918,7 @@ async function getEpisodeDataV3(
901
  task,
902
  languageAtoms,
903
  frameTimestamps,
 
904
  };
905
  }
906
 
@@ -918,9 +936,14 @@ async function loadEpisodeDataV3(
918
  task?: string;
919
  languageAtoms?: import("@/types/language.types").LanguageAtom[];
920
  frameTimestamps?: number[];
 
921
  }> {
922
  try {
923
- const { frames, textRows } = await loadEpisodeFrames(repoId, episode);
 
 
 
 
924
  const frameTimestamps = sortedTimestamps(frames);
925
 
926
  if (!frames) {
@@ -931,6 +954,7 @@ async function loadEpisodeDataV3(
931
  task: undefined,
932
  languageAtoms: extractLanguageAtoms(textRows),
933
  frameTimestamps,
 
934
  };
935
  }
936
 
@@ -1041,6 +1065,7 @@ async function loadEpisodeDataV3(
1041
  task,
1042
  languageAtoms: extractLanguageAtoms(textRows),
1043
  frameTimestamps,
 
1044
  };
1045
  } catch {
1046
  return {
@@ -1149,26 +1174,70 @@ const TEXT_COLUMNS = [
1149
  "language_events",
1150
  ];
1151
 
 
 
 
 
 
 
 
1152
  /** The episode's numeric series from `@huggingface/lerobot`, plus its text columns read alongside. */
1153
  async function loadEpisodeFrames(
1154
  repoId: string,
1155
  episode: LeRobotEpisode,
 
1156
  ): Promise<{
1157
  frames: LeRobotFrames | undefined;
1158
  textRows: Record<string, unknown>[];
 
1159
  }> {
1160
  const data = episode.data;
1161
  if (!data) return { frames: undefined, textRows: [] };
1162
- const [frames, textRows] = await Promise.all([
1163
  leRobotDataset(repoId).frames(episode),
1164
- fetchParquetFile(data.url).then((file) =>
1165
- readParquetAsObjects(file, TEXT_COLUMNS, {
1166
- rowStart: data.fromRow,
1167
- rowEnd: data.toRow,
1168
- }),
1169
- ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1170
  ]);
1171
- return { frames, textRows };
 
 
 
 
 
 
 
 
1172
  }
1173
 
1174
  /** Source-frame timestamps, sorted, for the annotations editor to snap to. */
@@ -1192,6 +1261,7 @@ function chartDataFromFrames(
1192
  const series: [string, number[]][] = [];
1193
  for (const [key, components] of Object.entries(frames.series)) {
1194
  if (!info.features[key] || excludedColumns.includes(key)) continue;
 
1195
  const names =
1196
  frames.names[key]?.length === components.length
1197
  ? frames.names[key]
 
2
  DatasetMetadata,
3
  fetchParquetFile,
4
  formatStringWithVars,
5
+ nestedColumnValues,
6
  readParquetAsObjects,
7
  } from "@/utils/parquetUtils";
8
+ import { parquetMetadataAsync } from "hyparquet";
9
  import { pick } from "@/utils/pick";
10
  import {
11
  getDatasetVersionAndInfo,
 
33
  } from "@huggingface/lerobot";
34
  import type { LeRobotEpisode, LeRobotFrames } from "@huggingface/lerobot";
35
  import { authHeaders, getAuthToken } from "@/utils/auth";
36
+ import {
37
+ hasHandTracking,
38
+ handProjectionMetadata,
39
+ isHandGeometryColumn,
40
+ readHandTracking,
41
+ type HandTracking,
42
+ } from "@/lib/hand-tracking";
43
 
44
  const SERIES_NAME_DELIMITER = CHART_CONFIG.SERIES_NAME_DELIMITER;
45
 
 
288
  * events compatible with the writer in lerobot#3471.
289
  */
290
  frameTimestamps?: number[];
291
+ /** Full-resolution native hand geometry, independent of sampled chart rows. */
292
+ handTracking?: HandTracking;
293
  };
294
 
295
  type EpisodeMetadataV3 = {
 
756
  const videosInfo =
757
  info.video_path !== null ? toVideosInfo(leRobotEpisode, info, false) : [];
758
 
759
+ const { frames, textRows, handTracking } = await loadEpisodeFrames(
760
+ repoId,
761
+ leRobotEpisode,
762
+ info,
763
+ );
764
  const frameTimestamps = sortedTimestamps(frames);
765
  const languageAtoms = extractLanguageAtoms(textRows);
766
 
 
857
  task,
858
  languageAtoms,
859
  frameTimestamps,
860
+ handTracking,
861
  };
862
  }
863
 
 
893
  task,
894
  languageAtoms,
895
  frameTimestamps,
896
+ handTracking,
897
  } = await loadEpisodeDataV3(
898
  repoId,
899
  version,
 
918
  task,
919
  languageAtoms,
920
  frameTimestamps,
921
+ handTracking,
922
  };
923
  }
924
 
 
936
  task?: string;
937
  languageAtoms?: import("@/types/language.types").LanguageAtom[];
938
  frameTimestamps?: number[];
939
+ handTracking?: HandTracking;
940
  }> {
941
  try {
942
+ const { frames, textRows, handTracking } = await loadEpisodeFrames(
943
+ repoId,
944
+ episode,
945
+ info,
946
+ );
947
  const frameTimestamps = sortedTimestamps(frames);
948
 
949
  if (!frames) {
 
954
  task: undefined,
955
  languageAtoms: extractLanguageAtoms(textRows),
956
  frameTimestamps,
957
+ handTracking,
958
  };
959
  }
960
 
 
1065
  task,
1066
  languageAtoms: extractLanguageAtoms(textRows),
1067
  frameTimestamps,
1068
+ handTracking,
1069
  };
1070
  } catch {
1071
  return {
 
1174
  "language_events",
1175
  ];
1176
 
1177
+ /**
1178
+ * Past this many values in its row groups, a nested text column is left out: hyparquet decodes it
1179
+ * for the whole row group, and a file written as one row group can hold millions of atoms. 22M
1180
+ * took 2-3 GB of heap; 44M (MinhDuk/final_dataset_annotated) and 66M crashed the tab.
1181
+ */
1182
+ const MAX_NESTED_TEXT_VALUES = 25_000_000;
1183
+
1184
  /** The episode's numeric series from `@huggingface/lerobot`, plus its text columns read alongside. */
1185
  async function loadEpisodeFrames(
1186
  repoId: string,
1187
  episode: LeRobotEpisode,
1188
+ info: DatasetMetadata,
1189
  ): Promise<{
1190
  frames: LeRobotFrames | undefined;
1191
  textRows: Record<string, unknown>[];
1192
+ handTracking?: HandTracking;
1193
  }> {
1194
  const data = episode.data;
1195
  if (!data) return { frames: undefined, textRows: [] };
1196
+ const [frames, textRows, report] = await Promise.all([
1197
  leRobotDataset(repoId).frames(episode),
1198
+ fetchParquetFile(data.url).then(async (file) => {
1199
+ const values = nestedColumnValues(
1200
+ await parquetMetadataAsync(file),
1201
+ data.fromRow,
1202
+ data.toRow,
1203
+ );
1204
+ const skipped = TEXT_COLUMNS.filter(
1205
+ (column) => (values.get(column) ?? 0) > MAX_NESTED_TEXT_VALUES,
1206
+ );
1207
+ if (skipped.length > 0)
1208
+ console.warn(
1209
+ `[language] ${skipped.join(", ")} left out: its row group is too large to decode`,
1210
+ );
1211
+ return readParquetAsObjects(
1212
+ file,
1213
+ TEXT_COLUMNS.filter((column) => !skipped.includes(column)),
1214
+ { rowStart: data.fromRow, rowEnd: data.toRow },
1215
+ );
1216
+ }),
1217
+ hasHandTracking(info)
1218
+ ? fetch(
1219
+ buildVersionedUrl(
1220
+ repoId,
1221
+ info.codebase_version,
1222
+ "annotation_studio/run.json",
1223
+ ),
1224
+ { headers: authHeaders(), signal: AbortSignal.timeout(10_000) },
1225
+ )
1226
+ .then((response) =>
1227
+ response.ok ? (response.json() as Promise<unknown>) : null,
1228
+ )
1229
+ .catch(() => null)
1230
+ : Promise.resolve(null),
1231
  ]);
1232
+ return {
1233
+ frames,
1234
+ textRows,
1235
+ handTracking: readHandTracking(
1236
+ info,
1237
+ frames,
1238
+ handProjectionMetadata(info, episode.index, report),
1239
+ ),
1240
+ };
1241
  }
1242
 
1243
  /** Source-frame timestamps, sorted, for the annotations editor to snap to. */
 
1261
  const series: [string, number[]][] = [];
1262
  for (const [key, components] of Object.entries(frames.series)) {
1263
  if (!info.features[key] || excludedColumns.includes(key)) continue;
1264
+ if (hasHandTracking(info) && isHandGeometryColumn(key)) continue;
1265
  const names =
1266
  frames.names[key]?.length === components.length
1267
  ? frames.names[key]
src/components/__tests__/simple-videos-player.test.tsx ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ describe,
6
+ expect,
7
+ test,
8
+ } from "bun:test";
9
+ import { Window } from "happy-dom";
10
+ import { act, useEffect, useState } from "react";
11
+ import { createRoot, type Root } from "react-dom/client";
12
+ import { AnnotationsProvider } from "@/context/annotations-context";
13
+ import { TimeProvider, useTime } from "@/context/time-context";
14
+ import type { VideoInfo } from "@/types";
15
+ import type { HandTracking } from "@/lib/hand-tracking";
16
+ import { SimpleVideosPlayer } from "../simple-videos-player";
17
+
18
+ const dom = new Window({ url: "http://localhost" });
19
+ const originals = new Map<string, PropertyDescriptor | undefined>();
20
+ let root: Root;
21
+ let container: HTMLDivElement;
22
+ let time: ReturnType<typeof useTime>;
23
+
24
+ beforeAll(() => {
25
+ const globals = {
26
+ window: dom,
27
+ document: dom.document,
28
+ HTMLVideoElement: dom.HTMLVideoElement,
29
+ HTMLMediaElement: dom.HTMLMediaElement,
30
+ Event: dom.Event,
31
+ ResizeObserver: dom.ResizeObserver,
32
+ requestAnimationFrame: dom.requestAnimationFrame.bind(dom),
33
+ cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom),
34
+ IS_REACT_ACT_ENVIRONMENT: true,
35
+ };
36
+ for (const [key, value] of Object.entries(globals)) {
37
+ originals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
38
+ Object.defineProperty(globalThis, key, {
39
+ configurable: true,
40
+ writable: true,
41
+ value,
42
+ });
43
+ }
44
+ });
45
+
46
+ afterEach(async () => {
47
+ if (root) await act(async () => root.unmount());
48
+ container?.remove();
49
+ });
50
+
51
+ afterAll(async () => {
52
+ await dom.happyDOM.close();
53
+ for (const [key, descriptor] of originals) {
54
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
55
+ else Reflect.deleteProperty(globalThis, key);
56
+ }
57
+ });
58
+
59
+ const videos: VideoInfo[] = [
60
+ {
61
+ filename: "ego",
62
+ url: "http://localhost/ego.mp4",
63
+ isSegmented: true,
64
+ segmentStart: 12.5,
65
+ segmentEnd: 22.5,
66
+ },
67
+ {
68
+ filename: "other",
69
+ url: "http://localhost/other.mp4",
70
+ isSegmented: true,
71
+ segmentStart: 30,
72
+ segmentEnd: 40,
73
+ },
74
+ ];
75
+
76
+ async function mount(initialTime = 0, handTracking?: HandTracking) {
77
+ let showPlayer: () => void;
78
+ function Controls() {
79
+ time = useTime();
80
+ const { seek } = time;
81
+ useEffect(() => seek(initialTime), [seek]);
82
+ return null;
83
+ }
84
+ function DeferredPlayer() {
85
+ const [visible, setVisible] = useState(false);
86
+ showPlayer = () => setVisible(true);
87
+ return visible ? (
88
+ <SimpleVideosPlayer videosInfo={videos} handTracking={handTracking} />
89
+ ) : null;
90
+ }
91
+ container = document.createElement("div");
92
+ document.body.append(container);
93
+ root = createRoot(container);
94
+ await act(async () => {
95
+ root.render(
96
+ <TimeProvider duration={10}>
97
+ <AnnotationsProvider>
98
+ <Controls />
99
+ <DeferredPlayer />
100
+ </AnnotationsProvider>
101
+ </TimeProvider>,
102
+ );
103
+ });
104
+ // The lazy hands panel can mount after the page has already applied ?t=.
105
+ await act(async () => showPlayer());
106
+ return Array.from(container.querySelectorAll("video"));
107
+ }
108
+
109
+ async function ready(elements: HTMLVideoElement[]) {
110
+ await act(async () => {
111
+ for (const video of elements) video.dispatchEvent(new Event("loadeddata"));
112
+ });
113
+ await act(async () => time.setIsPlaying(false));
114
+ }
115
+
116
+ describe("hand replay video synchronization", () => {
117
+ test("restores the URL timestamp on every camera after media loads", async () => {
118
+ const elements = await mount(3.25);
119
+ await ready(elements);
120
+ expect(time.currentTime).toBe(3.25);
121
+ expect(elements.map((video) => video.currentTime)).toEqual([15.75, 33.25]);
122
+ });
123
+
124
+ test("uses a seek made during loading instead of the mount timestamp", async () => {
125
+ const elements = await mount(3);
126
+ await act(async () => time.seek(5));
127
+ await ready(elements);
128
+ expect(time.currentTime).toBe(5);
129
+ expect(elements.map((video) => video.currentTime)).toEqual([17.5, 35]);
130
+ });
131
+
132
+ test("paused single-frame seeks update both source videos", async () => {
133
+ const elements = await mount(1);
134
+ await ready(elements);
135
+ await act(async () => time.seek(2));
136
+ await act(async () => time.seek(2 + 1 / 30));
137
+ expect(elements[0].currentTime).toBeCloseTo(14.5 + 1 / 30, 8);
138
+ expect(elements[1].currentTime).toBeCloseTo(32 + 1 / 30, 8);
139
+ expect(time.isPlaying).toBe(false);
140
+ });
141
+
142
+ test("labels estimated calibration beside the overlay controls", async () => {
143
+ await mount(0, {
144
+ frames: [],
145
+ fps: 30,
146
+ cameraKey: "ego",
147
+ coordinateFrame: "segment",
148
+ overlayIssue: null,
149
+ calibration: {
150
+ model: "pinhole",
151
+ width: 640,
152
+ height: 480,
153
+ fx: 600,
154
+ fy: 600,
155
+ cx: 320,
156
+ cy: 240,
157
+ distortion: [],
158
+ estimated: true,
159
+ },
160
+ });
161
+ expect(container.textContent).toContain("Estimated camera calibration");
162
+ });
163
+ });
src/components/hand-overlay-canvas.tsx ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import {
5
+ HAND_COLORS,
6
+ HAND_EDGES,
7
+ HAND_INDICES,
8
+ handFrameIndex,
9
+ projectHandPoint,
10
+ type HandTracking,
11
+ } from "@/lib/hand-tracking";
12
+
13
+ /** Follow this camera's displayed frames, including paused seeks and shared-file offsets. */
14
+ export function HandOverlayCanvas({
15
+ video,
16
+ tracking,
17
+ cameraKey,
18
+ segmentStart = 0,
19
+ enabled,
20
+ }: {
21
+ video: HTMLVideoElement | null;
22
+ tracking: HandTracking;
23
+ cameraKey: string;
24
+ segmentStart?: number;
25
+ enabled: boolean;
26
+ }) {
27
+ const canvasRef = useRef<HTMLCanvasElement>(null);
28
+ useEffect(() => {
29
+ const canvas = canvasRef.current;
30
+ const calibration = tracking.calibration;
31
+ if (
32
+ !canvas ||
33
+ !video ||
34
+ !calibration ||
35
+ cameraKey !== tracking.cameraKey ||
36
+ !enabled
37
+ )
38
+ return;
39
+ let callback = 0;
40
+ let mediaTime = video.currentTime;
41
+ const draw = (time = mediaTime) => {
42
+ mediaTime = time;
43
+ const width = video.clientWidth,
44
+ height = video.clientHeight;
45
+ if (!width || !height) return;
46
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
47
+ if (
48
+ canvas.width !== Math.round(width * dpr) ||
49
+ canvas.height !== Math.round(height * dpr)
50
+ ) {
51
+ canvas.width = Math.round(width * dpr);
52
+ canvas.height = Math.round(height * dpr);
53
+ }
54
+ const ctx = canvas.getContext("2d");
55
+ if (!ctx) return;
56
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
57
+ ctx.clearRect(0, 0, width, height);
58
+ // A changed crop/resolution needs a corresponding calibration, not an implicit guess.
59
+ if (
60
+ video.videoWidth !== calibration.width ||
61
+ video.videoHeight !== calibration.height
62
+ )
63
+ return;
64
+ const scale = Math.min(
65
+ width / calibration.width,
66
+ height / calibration.height,
67
+ );
68
+ const left = (width - calibration.width * scale) / 2;
69
+ const top = (height - calibration.height * scale) / 2;
70
+ const frame =
71
+ tracking.frames[handFrameIndex(tracking.frames, time - segmentStart)];
72
+ if (!frame) return;
73
+ ctx.save();
74
+ ctx.beginPath();
75
+ ctx.rect(
76
+ left,
77
+ top,
78
+ calibration.width * scale,
79
+ calibration.height * scale,
80
+ );
81
+ ctx.clip();
82
+ HAND_INDICES.forEach((hand) => {
83
+ const joints = frame.camera[hand];
84
+ if (!joints) return;
85
+ const points = joints.map((p): [number, number] | null => {
86
+ const pixel = projectHandPoint(p, calibration);
87
+ return pixel
88
+ ? [left + pixel[0] * scale, top + pixel[1] * scale]
89
+ : null;
90
+ });
91
+ const path = () => {
92
+ ctx.beginPath();
93
+ for (const [a, b] of HAND_EDGES) {
94
+ const p = points[a],
95
+ q = points[b];
96
+ if (p && q) {
97
+ ctx.moveTo(p[0], p[1]);
98
+ ctx.lineTo(q[0], q[1]);
99
+ }
100
+ }
101
+ ctx.stroke();
102
+ };
103
+ ctx.strokeStyle = "#111827";
104
+ ctx.lineWidth = 5;
105
+ path();
106
+ ctx.strokeStyle = HAND_COLORS[hand];
107
+ ctx.lineWidth = 2.5;
108
+ path();
109
+ points.forEach((p, joint) => {
110
+ if (!p) return;
111
+ ctx.beginPath();
112
+ ctx.arc(p[0], p[1], joint === 0 ? 5 : 2.5, 0, Math.PI * 2);
113
+ ctx.fillStyle = HAND_COLORS[hand];
114
+ ctx.fill();
115
+ });
116
+ const wrist = points[0];
117
+ if (wrist) {
118
+ ctx.font = "bold 13px sans-serif";
119
+ ctx.lineWidth = 3;
120
+ ctx.strokeStyle = "#111827";
121
+ ctx.strokeText(hand === 0 ? "L" : "R", wrist[0] + 8, wrist[1] - 8);
122
+ ctx.fillText(hand === 0 ? "L" : "R", wrist[0] + 8, wrist[1] - 8);
123
+ }
124
+ });
125
+ ctx.restore();
126
+ };
127
+ const update = () => draw(video.currentTime);
128
+ const observer = new ResizeObserver(() => draw());
129
+ observer.observe(video);
130
+ const events = ["loadeddata", "seeked", "resize"];
131
+ events.forEach((event) => video.addEventListener(event, update));
132
+ if ("requestVideoFrameCallback" in HTMLVideoElement.prototype) {
133
+ const frame = (_now: number, metadata: VideoFrameCallbackMetadata) => {
134
+ draw(metadata.mediaTime);
135
+ callback = video.requestVideoFrameCallback(frame);
136
+ };
137
+ callback = video.requestVideoFrameCallback(frame);
138
+ } else video.addEventListener("timeupdate", update);
139
+ update();
140
+ return () => {
141
+ observer.disconnect();
142
+ events.forEach((event) => video.removeEventListener(event, update));
143
+ video.removeEventListener("timeupdate", update);
144
+ if (callback) video.cancelVideoFrameCallback(callback);
145
+ const ctx = canvas.getContext("2d");
146
+ ctx?.clearRect(0, 0, canvas.width, canvas.height);
147
+ };
148
+ }, [video, tracking, cameraKey, segmentStart, enabled]);
149
+ return (
150
+ <canvas
151
+ ref={canvasRef}
152
+ aria-hidden="true"
153
+ className="absolute inset-0 h-full w-full pointer-events-none"
154
+ />
155
+ );
156
+ }
src/components/hand-replay.tsx ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ComponentRef,
9
+ type RefObject,
10
+ } from "react";
11
+ import { Canvas, useThree } from "@react-three/fiber";
12
+ import { Bounds, Grid, Html, Line, OrbitControls } from "@react-three/drei";
13
+ import { Vector3 } from "three";
14
+ import { useTime } from "@/context/time-context";
15
+ import { useTheme } from "@/utils/theme";
16
+ import {
17
+ HAND_COLORS,
18
+ HAND_EDGES,
19
+ HAND_INDICES,
20
+ cameraPathRuns,
21
+ fluToScene,
22
+ handFrameIndex,
23
+ wristTrail,
24
+ type HandTracking,
25
+ type HandFrame,
26
+ type CameraPose,
27
+ type Point3,
28
+ } from "@/lib/hand-tracking";
29
+ import { SimpleVideosPlayer } from "./simple-videos-player";
30
+ import PlaybackBar from "./playback-bar";
31
+ import type { VideoInfo } from "@/types";
32
+
33
+ /** A fixed episode-wide fit keeps the orbit camera still while the hands move. */
34
+ function RecordingBounds({ tracking }: { tracking: HandTracking }) {
35
+ const bounds = useMemo(() => {
36
+ const lo: Point3 = [Infinity, Infinity, Infinity],
37
+ hi: Point3 = [-Infinity, -Infinity, -Infinity];
38
+ for (const frame of tracking.frames) {
39
+ const positions = frame[tracking.coordinateFrame].flatMap(
40
+ (hand) => hand ?? [],
41
+ );
42
+ if (frame.cameraPose) positions.push(frame.cameraPose.position);
43
+ for (const p of positions) {
44
+ const point = fluToScene(p);
45
+ for (let a = 0; a < 3; a++) {
46
+ lo[a] = Math.min(lo[a], point[a]);
47
+ hi[a] = Math.max(hi[a], point[a]);
48
+ }
49
+ }
50
+ }
51
+ if (!Number.isFinite(lo[0]))
52
+ return { center: [0, 0, 0] as Point3, size: [1, 1, 1] as Point3 };
53
+ return {
54
+ center: lo.map((v, a) => (v + hi[a]) / 2) as Point3,
55
+ size: lo.map((v, a) => Math.max(hi[a] - v, 0.4)) as Point3,
56
+ };
57
+ }, [tracking]);
58
+ return (
59
+ <mesh position={bounds.center} visible={false}>
60
+ <boxGeometry args={bounds.size} />
61
+ <meshBasicMaterial />
62
+ </mesh>
63
+ );
64
+ }
65
+
66
+ function Hands({
67
+ tracking,
68
+ index,
69
+ trails,
70
+ }: {
71
+ tracking: HandTracking;
72
+ index: number;
73
+ trails: boolean;
74
+ }) {
75
+ const frame = tracking.frames[index];
76
+ return (
77
+ <>
78
+ {HAND_INDICES.map((hand) => {
79
+ const joints = frame?.[tracking.coordinateFrame][hand];
80
+ if (!joints) return null;
81
+ const points = joints.map(fluToScene),
82
+ color = HAND_COLORS[hand];
83
+ const trail = trails
84
+ ? wristTrail(
85
+ tracking.frames,
86
+ index,
87
+ hand,
88
+ 2,
89
+ tracking.coordinateFrame,
90
+ )
91
+ : [];
92
+ return (
93
+ <group key={hand}>
94
+ {HAND_EDGES.map(([a, b]) => (
95
+ <Line
96
+ key={`${a}-${b}`}
97
+ points={[points[a], points[b]]}
98
+ color={color}
99
+ lineWidth={3}
100
+ />
101
+ ))}
102
+ {points.map((p, joint) => (
103
+ <mesh key={joint} position={p}>
104
+ <sphereGeometry args={[joint === 0 ? 0.012 : 0.004, 12, 8]} />
105
+ <meshBasicMaterial color={color} />
106
+ </mesh>
107
+ ))}
108
+ {trail.length > 1 && (
109
+ <Line
110
+ points={trail}
111
+ color={color}
112
+ lineWidth={1.5}
113
+ transparent
114
+ opacity={0.5}
115
+ />
116
+ )}
117
+ </group>
118
+ );
119
+ })}
120
+ </>
121
+ );
122
+ }
123
+
124
+ function RecordedCamera({
125
+ tracking,
126
+ index,
127
+ }: {
128
+ tracking: HandTracking;
129
+ index: number;
130
+ }) {
131
+ const paths = useMemo(
132
+ () => cameraPathRuns(tracking.frames, index),
133
+ [tracking.frames, index],
134
+ );
135
+ const pose = tracking.frames[index]?.cameraPose;
136
+ const origin = pose ? fluToScene(pose.position) : null;
137
+ const at = (pose: CameraPose, x: number, y: number, z: number): Point3 => {
138
+ const coordinate = (axis: 0 | 1 | 2) =>
139
+ pose.position[axis] +
140
+ pose.axes[0][axis] * x +
141
+ pose.axes[1][axis] * y +
142
+ pose.axes[2][axis] * z;
143
+ return fluToScene([coordinate(0), coordinate(1), coordinate(2)]);
144
+ };
145
+ const corners = pose
146
+ ? [
147
+ at(pose, -0.055, -0.035, 0.08),
148
+ at(pose, 0.055, -0.035, 0.08),
149
+ at(pose, 0.055, 0.035, 0.08),
150
+ at(pose, -0.055, 0.035, 0.08),
151
+ ]
152
+ : [];
153
+ return (
154
+ <>
155
+ {paths
156
+ .filter((path) => path.length > 1)
157
+ .map((path, i) => (
158
+ <Line key={i} points={path} color="#8391a4" lineWidth={1.5} />
159
+ ))}
160
+ {origin && pose && (
161
+ <>
162
+ <mesh position={origin}>
163
+ <sphereGeometry args={[0.012, 12, 8]} />
164
+ <meshBasicMaterial color="#ffffff" />
165
+ </mesh>
166
+ {(["#ed6464", "#54ba7c", "#579cff"] as const).map((color, axis) => (
167
+ <Line
168
+ key={axis}
169
+ points={[
170
+ origin,
171
+ at(
172
+ pose,
173
+ axis === 0 ? 0.12 : 0,
174
+ axis === 1 ? 0.12 : 0,
175
+ axis === 2 ? 0.12 : 0,
176
+ ),
177
+ ]}
178
+ color={color}
179
+ lineWidth={2}
180
+ />
181
+ ))}
182
+ {corners.map((corner, i) => (
183
+ <Line
184
+ key={i}
185
+ points={[origin, corner, corners[(i + 1) % 4]]}
186
+ color="#8391a4"
187
+ lineWidth={1}
188
+ />
189
+ ))}
190
+ <Html position={origin} style={{ pointerEvents: "none" }}>
191
+ <span className="ml-3 text-xs text-fg-muted">Camera</span>
192
+ </Html>
193
+ </>
194
+ )}
195
+ </>
196
+ );
197
+ }
198
+
199
+ /** Follow translation while preserving the user's orbit angle and zoom. */
200
+ function FollowCamera({
201
+ frame,
202
+ enabled,
203
+ orbitRef,
204
+ }: {
205
+ frame: HandFrame | undefined;
206
+ enabled: boolean;
207
+ orbitRef: RefObject<ComponentRef<typeof OrbitControls> | null>;
208
+ }) {
209
+ const { camera, invalidate } = useThree();
210
+ const following = useRef(false);
211
+ useEffect(() => {
212
+ if (!enabled) {
213
+ following.current = false;
214
+ return;
215
+ }
216
+ if (!frame?.cameraPose) return;
217
+ const orbit = orbitRef.current;
218
+ if (!orbit) return;
219
+ const { position, axes } = frame.cameraPose;
220
+ const focus = new Vector3(
221
+ ...fluToScene([
222
+ position[0] + axes[2][0] * 0.4,
223
+ position[1] + axes[2][1] * 0.4,
224
+ position[2] + axes[2][2] * 0.4,
225
+ ]),
226
+ );
227
+ if (!following.current) {
228
+ camera.position
229
+ .sub(orbit.target)
230
+ .normalize()
231
+ .multiplyScalar(1.1)
232
+ .add(focus);
233
+ following.current = true;
234
+ } else camera.position.add(focus.clone().sub(orbit.target));
235
+ orbit.target.copy(focus);
236
+ orbit.update();
237
+ invalidate();
238
+ }, [camera, orbitRef, enabled, frame, invalidate]);
239
+ return null;
240
+ }
241
+
242
+ export default function HandReplay({
243
+ tracking,
244
+ videosInfo,
245
+ }: {
246
+ tracking: HandTracking;
247
+ videosInfo: VideoInfo[];
248
+ }) {
249
+ const { currentTime } = useTime();
250
+ const orbitRef = useRef<ComponentRef<typeof OrbitControls>>(null);
251
+ const theme = useTheme();
252
+ const [trails, setTrails] = useState(true);
253
+ const [follow, setFollow] = useState(false);
254
+ const index = handFrameIndex(tracking.frames, currentTime),
255
+ frame = tracking.frames[index];
256
+ const videos = useMemo(
257
+ () =>
258
+ [...videosInfo].sort(
259
+ (a, b) =>
260
+ Number(b.filename === tracking.cameraKey) -
261
+ Number(a.filename === tracking.cameraKey),
262
+ ),
263
+ [videosInfo, tracking.cameraKey],
264
+ );
265
+ return (
266
+ <div className="space-y-4">
267
+ <div className="flex flex-wrap items-center justify-between gap-3 text-sm">
268
+ <div className="flex items-center gap-4">
269
+ {HAND_COLORS.map((color, hand) => (
270
+ <span key={hand} className="flex items-center gap-1.5">
271
+ <i
272
+ className="h-2 w-2 rounded-full"
273
+ style={{ backgroundColor: color }}
274
+ />
275
+ {hand === 0 ? "Left" : "Right"} hand
276
+ </span>
277
+ ))}
278
+ <span className="font-mono text-xs text-fg-muted">
279
+ Frame {index >= 0 ? index : "—"}
280
+ </span>
281
+ </div>
282
+ <div className="flex items-center gap-4">
283
+ {tracking.coordinateFrame === "recording" && (
284
+ <button
285
+ type="button"
286
+ className={`btn text-xs ${follow ? "btn-active" : ""}`}
287
+ aria-pressed={follow}
288
+ onClick={() => setFollow((value) => !value)}
289
+ >
290
+ Follow camera
291
+ </button>
292
+ )}
293
+ <label className="flex items-center gap-2 text-fg-soft">
294
+ <input
295
+ type="checkbox"
296
+ checked={trails}
297
+ onChange={(e) => setTrails(e.target.checked)}
298
+ />
299
+ Wrist trails
300
+ </label>
301
+ </div>
302
+ </div>
303
+ <div className="grid gap-4 md:grid-cols-2 items-start">
304
+ <div className="panel overflow-hidden">
305
+ <div
306
+ className="relative h-[min(45vh,420px)] min-h-72"
307
+ aria-label="3D recorded hand and wrist positions"
308
+ >
309
+ <Canvas
310
+ frameloop="demand"
311
+ camera={{ position: [0.8, 0.6, 1.1], near: 0.001, far: 1000 }}
312
+ >
313
+ <color
314
+ attach="background"
315
+ args={[theme === "dark" ? "#151b24" : "#f3f5f7"]}
316
+ />
317
+ <OrbitControls ref={orbitRef} makeDefault />
318
+ <Bounds fit clip margin={1.5}>
319
+ <RecordingBounds tracking={tracking} />
320
+ </Bounds>
321
+ <Grid
322
+ infiniteGrid
323
+ cellSize={0.1}
324
+ sectionSize={0.5}
325
+ fadeDistance={12}
326
+ cellColor={theme === "dark" ? "#354052" : "#cbd2dc"}
327
+ sectionColor={theme === "dark" ? "#536278" : "#9aa6b5"}
328
+ />
329
+ <axesHelper args={[0.15]} />
330
+ <Hands tracking={tracking} index={index} trails={trails} />
331
+ <RecordedCamera tracking={tracking} index={index} />
332
+ <FollowCamera
333
+ frame={frame}
334
+ enabled={follow}
335
+ orbitRef={orbitRef}
336
+ />
337
+ </Canvas>
338
+ {!frame?.[tracking.coordinateFrame].some(Boolean) && (
339
+ <div className="absolute inset-x-0 top-4 text-center pointer-events-none">
340
+ <span className="rounded bg-[var(--surface-0)] px-3 py-2 text-sm text-fg-muted">
341
+ No observed hand pose at this frame
342
+ </span>
343
+ </div>
344
+ )}
345
+ </div>
346
+ <div className="border-t border-line-subtle p-3 text-xs text-fg-muted space-y-1">
347
+ <p>Drag to orbit · scroll to zoom · grid cells 10 cm</p>
348
+ <p>
349
+ {tracking.coordinateFrame === "recording"
350
+ ? "Recording frame anchored to the first saved camera pose, in meters. Camera paths and wrist trails break where poses are missing."
351
+ : "Segment-start frame, in meters. Coordinates and trails reset at each valid span. Camera transforms are unavailable."}
352
+ </p>
353
+ {(["Left", "Right"] as const).map((side, hand) => (
354
+ <p key={side} className="font-mono">
355
+ {side} wrist:{" "}
356
+ {frame?.[tracking.coordinateFrame][hand]?.[0]
357
+ .map((n) => n.toFixed(3))
358
+ .join(", ") ?? "unobserved"}{" "}
359
+ {frame?.[tracking.coordinateFrame][hand]
360
+ ? "(forward, left, up)"
361
+ : ""}
362
+ </p>
363
+ ))}
364
+ </div>
365
+ </div>
366
+ <div className="space-y-3">
367
+ <SimpleVideosPlayer videosInfo={videos} handTracking={tracking} />
368
+ {tracking.overlayIssue && (
369
+ <p className="text-sm text-fg-muted">{tracking.overlayIssue}</p>
370
+ )}
371
+ </div>
372
+ </div>
373
+ <PlaybackBar />
374
+ </div>
375
+ );
376
+ }
src/components/simple-videos-player.tsx CHANGED
@@ -7,11 +7,10 @@ import type { VideoInfo } from "@/types";
7
  import { proxyHfUrl } from "@/utils/auth";
8
  import { VideoOverlayCanvas } from "./video-overlay-canvas";
9
  import { ColormappedVideo } from "./colormapped-video";
 
 
10
 
11
- const THRESHOLDS = {
12
- VIDEO_SYNC_TOLERANCE: 0.2,
13
- VIDEO_SEGMENT_BOUNDARY: 0.05,
14
- };
15
 
16
  const VIDEO_READY_TIMEOUT_MS = 10_000;
17
 
@@ -32,6 +31,7 @@ const SUPPORTS_RVFC =
32
  type VideoPlayerProps = {
33
  videosInfo: VideoInfo[];
34
  onVideosReady?: () => void;
 
35
  };
36
 
37
  const videoEventCleanup = new WeakMap<HTMLVideoElement, () => void>();
@@ -39,7 +39,9 @@ const videoEventCleanup = new WeakMap<HTMLVideoElement, () => void>();
39
  export const SimpleVideosPlayer = ({
40
  videosInfo,
41
  onVideosReady,
 
42
  }: VideoPlayerProps) => {
 
43
  const { currentTime, seek, externalSeekVersion, isPlaying, setIsPlaying } =
44
  useTime();
45
  const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
@@ -126,6 +128,20 @@ export const SimpleVideosPlayer = ({
126
  // context's version moves past this, an external seek happened and we
127
  // need to drive every video to the new position.
128
  const lastSeekVersionRef = useRef(externalSeekVersion);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  // Mirror firstVisibleIdx into a ref so the videos-ready effect doesn't have
131
  // to depend on it. If it did, hiding the first camera would tear the whole
@@ -166,6 +182,7 @@ export const SimpleVideosPlayer = ({
166
  if (resolved) return;
167
  resolved = true;
168
  setVideosReady(true);
 
169
  onVideosReadyRef.current?.();
170
  setIsPlaying(true);
171
  };
@@ -426,10 +443,7 @@ export const SimpleVideosPlayer = ({
426
  targetTime = (info.segmentStart ?? 0) + currentTime;
427
  }
428
 
429
- if (
430
- Math.abs(video.currentTime - targetTime) >
431
- THRESHOLDS.VIDEO_SYNC_TOLERANCE
432
- ) {
433
  video.currentTime = targetTime;
434
  }
435
  });
@@ -437,6 +451,29 @@ export const SimpleVideosPlayer = ({
437
 
438
  return (
439
  <>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  {/* Hidden videos menu */}
441
  {hiddenVideos.length > 0 && (
442
  <div className="relative mb-4">
@@ -573,6 +610,15 @@ export const SimpleVideosPlayer = ({
573
  videoEl={videoEls[idx] ?? null}
574
  cameraKey={info.filename}
575
  />
 
 
 
 
 
 
 
 
 
576
  </div>
577
  </div>
578
  </div>
 
7
  import { proxyHfUrl } from "@/utils/auth";
8
  import { VideoOverlayCanvas } from "./video-overlay-canvas";
9
  import { ColormappedVideo } from "./colormapped-video";
10
+ import { HandOverlayCanvas } from "./hand-overlay-canvas";
11
+ import type { HandTracking } from "@/lib/hand-tracking";
12
 
13
+ const THRESHOLDS = { VIDEO_SEGMENT_BOUNDARY: 0.05 };
 
 
 
14
 
15
  const VIDEO_READY_TIMEOUT_MS = 10_000;
16
 
 
31
  type VideoPlayerProps = {
32
  videosInfo: VideoInfo[];
33
  onVideosReady?: () => void;
34
+ handTracking?: HandTracking;
35
  };
36
 
37
  const videoEventCleanup = new WeakMap<HTMLVideoElement, () => void>();
 
39
  export const SimpleVideosPlayer = ({
40
  videosInfo,
41
  onVideosReady,
42
+ handTracking,
43
  }: VideoPlayerProps) => {
44
+ const [showHands, setShowHands] = React.useState(true);
45
  const { currentTime, seek, externalSeekVersion, isPlaying, setIsPlaying } =
46
  useTime();
47
  const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
 
128
  // context's version moves past this, an external seek happened and we
129
  // need to drive every video to the new position.
130
  const lastSeekVersionRef = useRef(externalSeekVersion);
131
+ // Media may take seconds to load. Keep the latest external seek, including
132
+ // the initial URL timestamp and seeks made while loading, until it is ready.
133
+ const pendingSeekRef = useRef({
134
+ version: externalSeekVersion,
135
+ time: currentTime,
136
+ });
137
+ useEffect(() => {
138
+ if (pendingSeekRef.current.version !== externalSeekVersion) {
139
+ pendingSeekRef.current = {
140
+ version: externalSeekVersion,
141
+ time: currentTime,
142
+ };
143
+ }
144
+ }, [externalSeekVersion, currentTime]);
145
 
146
  // Mirror firstVisibleIdx into a ref so the videos-ready effect doesn't have
147
  // to depend on it. If it did, hiding the first camera would tear the whole
 
182
  if (resolved) return;
183
  resolved = true;
184
  setVideosReady(true);
185
+ seek(pendingSeekRef.current.time);
186
  onVideosReadyRef.current?.();
187
  setIsPlaying(true);
188
  };
 
443
  targetTime = (info.segmentStart ?? 0) + currentTime;
444
  }
445
 
446
+ if (video.currentTime !== targetTime) {
 
 
 
447
  video.currentTime = targetTime;
448
  }
449
  });
 
451
 
452
  return (
453
  <>
454
+ {handTracking && (
455
+ <div className="flex flex-wrap items-center gap-3 text-xs text-fg-muted">
456
+ <label className="flex items-center gap-2 text-fg-soft">
457
+ <input
458
+ type="checkbox"
459
+ checked={showHands}
460
+ disabled={!handTracking.cameraKey || !handTracking.calibration}
461
+ onChange={(e) => setShowHands(e.target.checked)}
462
+ />
463
+ Hand overlay
464
+ </label>
465
+ <span>
466
+ {handTracking.calibration && handTracking.cameraKey
467
+ ? handTracking.cameraKey
468
+ : handTracking.overlayIssue}
469
+ </span>
470
+ {handTracking.calibration?.estimated && (
471
+ <span>
472
+ Estimated camera calibration · metric accuracy unvalidated
473
+ </span>
474
+ )}
475
+ </div>
476
+ )}
477
  {/* Hidden videos menu */}
478
  {hiddenVideos.length > 0 && (
479
  <div className="relative mb-4">
 
610
  videoEl={videoEls[idx] ?? null}
611
  cameraKey={info.filename}
612
  />
613
+ {handTracking && (
614
+ <HandOverlayCanvas
615
+ video={videoEls[idx] ?? null}
616
+ tracking={handTracking}
617
+ cameraKey={info.filename}
618
+ segmentStart={info.isSegmented ? info.segmentStart : 0}
619
+ enabled={showHands}
620
+ />
621
+ )}
622
  </div>
623
  </div>
624
  </div>
src/components/urdf-viewer.tsx CHANGED
@@ -18,10 +18,8 @@ import {
18
  import * as THREE from "three";
19
  import URDFLoader from "urdf-loader";
20
  import type { URDFRobot } from "urdf-loader";
 
21
  import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
22
- import { ColladaLoader } from "three/examples/jsm/loaders/ColladaLoader.js";
23
- import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
24
- import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
25
  import { Line2 } from "three/examples/jsm/lines/Line2.js";
26
  import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
27
  import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
@@ -59,136 +57,6 @@ function loadStlGeometry(url: string): Promise<THREE.BufferGeometry> {
59
  return loading;
60
  }
61
 
62
- /**
63
- * Rebuilds every mesh with a MeshStandardMaterial bucketed into one of three archetypes (carbon-black,
64
- * brushed metal, off-white paint) by the original base-colour lightness. `flatShading` is for the GLB
65
- * meshes, which carry no normals.
66
- */
67
- function applyOpenArmMaterials(root: THREE.Object3D, flatShading: boolean) {
68
- root.traverse((child) => {
69
- if (!(child instanceof THREE.Mesh) || !child.material) return;
70
-
71
- const originals = Array.isArray(child.material)
72
- ? child.material
73
- : [child.material];
74
-
75
- const rebuilt = originals.map((orig) => {
76
- const srcColor =
77
- (orig as THREE.MeshStandardMaterial).color ??
78
- new THREE.Color("#c0c4cc");
79
- const hsl = { h: 0, s: 0, l: 0 };
80
- srcColor.getHSL(hsl);
81
-
82
- // Archetype classification by original lightness.
83
- let color: THREE.Color;
84
- let metalness: number;
85
- let roughness: number;
86
- let envMapIntensity: number;
87
- if (hsl.l < 0.3) {
88
- // Carbon / anodised structural parts
89
- color = new THREE.Color().setHSL(hsl.h, 0.02, 0.09);
90
- metalness = 0.15;
91
- roughness = 0.75;
92
- envMapIntensity = 0.6;
93
- } else if (hsl.l < 0.7) {
94
- // Brushed metal joint collars / accents
95
- color = new THREE.Color().setHSL(hsl.h, 0.04, 0.42);
96
- metalness = 0.75;
97
- roughness = 0.35;
98
- envMapIntensity = 1.1;
99
- } else {
100
- // Off-white painted plates
101
- color = new THREE.Color().setHSL(hsl.h, 0.03, 0.6);
102
- metalness = 0.1;
103
- roughness = 0.5;
104
- envMapIntensity = 0.9;
105
- }
106
-
107
- const mat = new THREE.MeshStandardMaterial({
108
- color,
109
- metalness,
110
- roughness,
111
- envMapIntensity,
112
- side: THREE.DoubleSide,
113
- flatShading,
114
- });
115
- orig.dispose?.();
116
- return mat;
117
- });
118
-
119
- child.material = Array.isArray(child.material) ? rebuilt : rebuilt[0];
120
- child.castShadow = true;
121
- child.receiveShadow = true;
122
- });
123
- }
124
-
125
- /// One download and parse per DAE file per session: the OpenArm URDF references finger.dae 4 times
126
- /// and each arm link twice, and the viewer remounts on every visit to the 3D Replay tab.
127
- const daeSceneCache = new Map<string, Promise<THREE.Object3D>>();
128
-
129
- function loadDaeScene(url: string): Promise<THREE.Object3D> {
130
- let loading = daeSceneCache.get(url);
131
- if (!loading) {
132
- loading = new ColladaLoader()
133
- .loadAsync(url)
134
- .then((collada) => {
135
- // DAE (Collada) files — ColladaLoader yields whatever the .dae author
136
- // baked in: flat MeshPhongMaterial/MeshBasicMaterial colors plus, in
137
- // OpenArm's case, ~23 per-file PointLight/SpotLight nodes. The stray
138
- // lights caused the scene to look pure-white everywhere regardless of
139
- // our own lighting, and the flat materials looked cartoonish. We strip
140
- // both and rebuild every mesh with a MeshStandardMaterial bucketed into
141
- // one of three archetypes (carbon-black, brushed metal, off-white paint)
142
- // based on the original base-color lightness.
143
- if (url.includes("openarm")) {
144
- const strayLights: THREE.Object3D[] = [];
145
- collada.scene.traverse((child) => {
146
- if (
147
- (child as THREE.Light).isLight &&
148
- !(child instanceof THREE.AmbientLight)
149
- ) {
150
- strayLights.push(child);
151
- }
152
- });
153
- for (const l of strayLights) l.parent?.remove(l);
154
-
155
- applyOpenArmMaterials(collada.scene, false);
156
- }
157
- return collada.scene;
158
- })
159
- .catch((err) => {
160
- daeSceneCache.delete(url);
161
- throw err;
162
- });
163
- daeSceneCache.set(url, loading);
164
- }
165
- return loading;
166
- }
167
-
168
- /// The bucket holds meshopt-compressed GLB copies of the SO-101 and OpenArm meshes, about 16x and 20x
169
- /// smaller than the STL/DAE originals. They carry no normals: the originals' are flat per face, and
170
- /// flatShading derives the same ones.
171
- const glbSceneCache = new Map<string, Promise<THREE.Object3D>>();
172
-
173
- function loadGlbScene(url: string): Promise<THREE.Object3D> {
174
- let loading = glbSceneCache.get(url);
175
- if (!loading) {
176
- loading = new GLTFLoader()
177
- .setMeshoptDecoder(MeshoptDecoder)
178
- .loadAsync(url)
179
- .then((gltf) => {
180
- if (url.includes("openarm")) applyOpenArmMaterials(gltf.scene, true);
181
- return gltf.scene;
182
- })
183
- .catch((err) => {
184
- glbSceneCache.delete(url);
185
- throw err;
186
- });
187
- glbSceneCache.set(url, loading);
188
- }
189
- return loading;
190
- }
191
-
192
  // URDFs + meshes are hosted in the Hub bucket at
193
  // https://huggingface.co/buckets/lerobot/robot-urdfs. URDFLoader resolves
194
  // relative mesh paths against the URDF's own URL, so the bucket layout
@@ -222,34 +90,90 @@ function loadUrdfText(url: string): Promise<string> {
222
  return loading;
223
  }
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  /**
226
  * Starts downloading a robot's visual meshes into the caches. The viewer only mounts once the episode
227
  * data has loaded, and the meshes used to start downloading only then; calling this earlier overlaps
228
  * the two.
229
  */
230
  export function prefetchRobotModel(robotType: string | null) {
231
- const { urdfUrl } = getRobotConfig(robotType);
232
  if (prefetchedUrdfs.has(urdfUrl)) return;
233
  prefetchedUrdfs.add(urdfUrl);
 
 
 
 
 
 
234
  const base = THREE.LoaderUtils.extractUrlBase(urdfUrl);
235
  loadUrdfText(urdfUrl)
236
  .then((text) => {
237
  const doc = new DOMParser().parseFromString(text, "application/xml");
238
  for (const mesh of doc.querySelectorAll("visual mesh")) {
239
  const filename = mesh.getAttribute("filename");
240
- /// Same resolution URDFLoader applies to relative paths; package:// and DAE meshes are left to it.
241
  if (!filename || filename.startsWith("package://")) continue;
242
- const lower = filename.toLowerCase();
243
- if (lower.endsWith(".glb"))
244
- loadGlbScene(base + filename).catch(() => {});
245
- else if (lower.endsWith(".stl"))
246
  loadStlGeometry(base + filename).catch(() => {});
247
  }
248
  })
249
  .catch(() => prefetchedUrdfs.delete(urdfUrl));
250
  }
251
 
252
- function getRobotConfig(robotType: string | null) {
 
 
 
 
253
  const lower = (robotType ?? "").toLowerCase();
254
  if (lower.includes("g1") || lower.includes("unitree")) {
255
  return { urdfUrl: `${URDF_BASE_URL}/g1/g1_body29_hand14.urdf`, scale: 1 };
@@ -257,11 +181,13 @@ function getRobotConfig(robotType: string | null) {
257
  if (lower.includes("openarm")) {
258
  return {
259
  urdfUrl: `${URDF_BASE_URL}/openarm/openarm_bimanual_web.urdf`,
 
260
  scale: 3,
261
  };
262
  }
263
  return {
264
  urdfUrl: `${URDF_BASE_URL}/so101/so101_new_calib_web.urdf`,
 
265
  scale: 10,
266
  };
267
  }
@@ -353,11 +279,14 @@ function autoMatchJoints(
353
  continue;
354
  }
355
 
356
- // OpenArm: openarm_(left|right)_joint(\d+) → (left|right)_joint_(\d+)
 
357
  const armMatch = lower.match(/^openarm_(left|right)_joint(\d+)$/);
358
  if (armMatch) {
359
- const pattern = `${armMatch[1]}_joint_${armMatch[2]}`;
360
- const idx = suffixes.findIndex((s) => s.includes(pattern));
 
 
361
  if (idx >= 0) {
362
  mapping[jointName] = columnKeys[idx];
363
  continue;
@@ -430,6 +359,7 @@ const MAX_FIT_WAIT_FRAMES = 30;
430
  // ─── Robot scene (imperative, inside Canvas) ───
431
  function RobotScene({
432
  urdfUrl,
 
433
  jointValues,
434
  onJointsLoaded,
435
  trailEnabled,
@@ -437,6 +367,7 @@ function RobotScene({
437
  scale,
438
  }: {
439
  urdfUrl: string;
 
440
  jointValues: Record<string, number>;
441
  onJointsLoaded: (names: string[]) => void;
442
  trailEnabled: boolean;
@@ -537,20 +468,22 @@ function RobotScene({
537
  // `if (obj instanceof THREE.Mesh) obj.material = material;`
538
  // on every mesh we hand back — overwriting our PBR material with the
539
  // URDF's <material rgba="...">-derived MeshPhongMaterial. Wrapping the
540
- // returned mesh in a Group dodges that check (same way DAE's collada.scene
541
- // already avoids it) so our carefully-tuned materials survive.
542
  const wrapForUrdf = (obj: THREE.Object3D): THREE.Object3D => {
543
  const group = new THREE.Group();
544
  group.add(obj);
545
  return group;
546
  };
547
  loader.loadMeshCb = (url, mgr, onLoad) => {
548
- const makeMaterial = (flatShading: boolean) => {
549
- // Defaults: neutral off-white plastic, matches OpenArm "light" archetype
 
 
 
 
550
  let color = "#9ba1ab";
551
  let metalness = 0.1;
552
  let roughness = 0.5;
553
- let side: THREE.Side = THREE.FrontSide;
554
  if (isG1) {
555
  const lower = url.toLowerCase();
556
  const isWhitePart =
@@ -563,54 +496,9 @@ function RobotScene({
563
  color = isWhitePart ? "#9ca3af" : "#1f2937";
564
  metalness = 0.25;
565
  roughness = 0.6;
566
- } else if (url.includes("sts3215")) {
567
- // SO-arm / any STL servo housing — carbon-black archetype
568
- color = "#171a20";
569
- metalness = 0.15;
570
- roughness = 0.75;
571
- } else if (isOpenArm) {
572
- color = url.includes("body_link0") ? "#3a3a4a" : "#f5f5f5";
573
- metalness = 0.15;
574
- roughness = 0.6;
575
- side = THREE.DoubleSide;
576
  }
577
- return new THREE.MeshStandardMaterial({
578
- color,
579
- metalness,
580
- roughness,
581
- side,
582
- flatShading,
583
- });
584
  };
585
- const makeMesh = (geometry: THREE.BufferGeometry) =>
586
- new THREE.Mesh(geometry, makeMaterial(false));
587
- if (url.endsWith(".glb")) {
588
- mgr.itemStart(url);
589
- loadGlbScene(url)
590
- .then((glbScene) => {
591
- const obj = glbScene.clone();
592
- if (!isOpenArm) {
593
- obj.traverse((child) => {
594
- if (child instanceof THREE.Mesh)
595
- child.material = makeMaterial(true);
596
- });
597
- }
598
- onLoad(obj);
599
- })
600
- .catch((err) => onLoad(new THREE.Object3D(), err as Error))
601
- .finally(() => mgr.itemEnd(url));
602
- return;
603
- }
604
- if (url.endsWith(".dae")) {
605
- /// URDFLoader re-parents and re-orients what it is handed, so it gets a clone (sharing
606
- /// geometry and materials) and the cached scene stays intact.
607
- mgr.itemStart(url);
608
- loadDaeScene(url)
609
- .then((daeScene) => onLoad(daeScene.clone()))
610
- .catch((err) => onLoad(new THREE.Object3D(), err as Error))
611
- .finally(() => mgr.itemEnd(url));
612
- return;
613
- }
614
  // STL files — apply final PBR materials directly here. We used to do a
615
  // post-load archetype rebuild in manager.onLoad, but STLLoader calls
616
  // `manager.itemEnd` *before* our Promise resolves — so when the last
@@ -628,7 +516,9 @@ function RobotScene({
628
  /// with every mesh prefetched it would otherwise never fire.
629
  mgr.itemStart(url);
630
  loadStlGeometry(url)
631
- .then((geometry) => onLoad(wrapForUrdf(makeMesh(geometry))))
 
 
632
  .catch((err) => onLoad(new THREE.Object3D(), err as Error))
633
  .finally(() => mgr.itemEnd(url));
634
  };
@@ -654,6 +544,7 @@ function RobotScene({
654
 
655
  loader.workingPath = THREE.LoaderUtils.extractUrlBase(urdfUrl);
656
  let cancelled = false;
 
657
  manager.itemStart(urdfUrl);
658
  loadUrdfText(urdfUrl)
659
  .then((text) => {
@@ -700,11 +591,24 @@ function RobotScene({
700
  j.jointType === "prismatic",
701
  )
702
  .map((j) => j.name);
703
- onJointsLoaded(movable);
 
 
 
 
 
 
 
 
 
 
 
 
 
704
  })
705
  .catch((err) => {
706
  if (cancelled) return;
707
- console.error("Error loading URDF:", err);
708
  setError(String(err));
709
  })
710
  .finally(() => {
@@ -716,9 +620,10 @@ function RobotScene({
716
  scene.remove(robotRef.current);
717
  robotRef.current = null;
718
  }
 
719
  tipLinksRef.current = [];
720
  };
721
- }, [urdfUrl, scale, scene, onJointsLoaded, ensureTrails, invalidate]);
722
 
723
  const tipWorldPos = useMemo(() => new THREE.Vector3(), []);
724
 
@@ -811,7 +716,7 @@ function RobotScene({
811
  if (error)
812
  return (
813
  <Html center>
814
- <span className="text-red-400">Failed to load URDF</span>
815
  </Html>
816
  );
817
  return null;
@@ -921,7 +826,7 @@ export default function URDFViewer({
921
  () => getRobotConfig(datasetInfo.robot_type),
922
  [datasetInfo.robot_type],
923
  );
924
- const { urdfUrl, scale } = robotConfig;
925
  const isG1 = urdfUrl.includes("g1");
926
  const isOpenArm = urdfUrl.includes("openarm");
927
  const repoId = org && dataset ? `${org}/${dataset}` : null;
@@ -1222,6 +1127,7 @@ export default function URDFViewer({
1222
  </mesh>
1223
  <RobotScene
1224
  urdfUrl={urdfUrl}
 
1225
  jointValues={jointValues}
1226
  onJointsLoaded={onJointsLoaded}
1227
  trailEnabled={trailEnabled}
 
18
  import * as THREE from "three";
19
  import URDFLoader from "urdf-loader";
20
  import type { URDFRobot } from "urdf-loader";
21
+ import { loadUsd } from "@huggingface/usd-web-viewer";
22
  import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
 
 
 
23
  import { Line2 } from "three/examples/jsm/lines/Line2.js";
24
  import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
25
  import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
 
57
  return loading;
58
  }
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  // URDFs + meshes are hosted in the Hub bucket at
61
  // https://huggingface.co/buckets/lerobot/robot-urdfs. URDFLoader resolves
62
  // relative mesh paths against the URDF's own URL, so the bucket layout
 
90
  return loading;
91
  }
92
 
93
+ /// USD models of the robots with their URDF's joint and link names, link frames and zero pose: the
94
+ /// URDF moves the robot and the USD draws it.
95
+ const USD_BASE_URL =
96
+ "https://huggingface.co/datasets/lerobot/robot-usds/resolve/main";
97
+
98
+ /// Kept for the session like the URDF text: the viewer remounts on every visit to the 3D Replay tab.
99
+ const usdBlobCache = new Map<string, Promise<Blob>>();
100
+
101
+ function loadUsdBlob(url: string): Promise<Blob> {
102
+ let loading = usdBlobCache.get(url);
103
+ if (!loading) {
104
+ loading = fetch(url)
105
+ .then((res) => {
106
+ if (!res.ok)
107
+ throw new Error(`Failed to load USD ${url} (HTTP ${res.status})`);
108
+ return res.blob();
109
+ })
110
+ .catch((err) => {
111
+ usdBlobCache.delete(url);
112
+ throw err;
113
+ });
114
+ usdBlobCache.set(url, loading);
115
+ }
116
+ return loading;
117
+ }
118
+
119
+ /**
120
+ * Moves each USD mesh under the URDF link its prim path names, where the URDF's zero pose puts it.
121
+ * USD stage space is the URDF's root frame, so the robot's joints then move the meshes.
122
+ */
123
+ function attachUsdMeshes(robot: URDFRobot, usdRoot: THREE.Object3D) {
124
+ for (const name of Object.keys(robot.joints)) robot.setJointValue(name, 0);
125
+ robot.updateMatrixWorld(true);
126
+ for (const mesh of [...usdRoot.children]) {
127
+ const segments = mesh.name.split("/");
128
+ let link: THREE.Object3D = robot;
129
+ for (let i = segments.length - 1; i >= 0; i--) {
130
+ if (robot.links[segments[i]]) {
131
+ link = robot.links[segments[i]];
132
+ break;
133
+ }
134
+ }
135
+ mesh.matrix
136
+ .premultiply(robot.matrixWorld)
137
+ .premultiply(link.matrixWorld.clone().invert());
138
+ link.add(mesh);
139
+ }
140
+ }
141
+
142
  /**
143
  * Starts downloading a robot's visual meshes into the caches. The viewer only mounts once the episode
144
  * data has loaded, and the meshes used to start downloading only then; calling this earlier overlaps
145
  * the two.
146
  */
147
  export function prefetchRobotModel(robotType: string | null) {
148
+ const { urdfUrl, usdUrl } = getRobotConfig(robotType);
149
  if (prefetchedUrdfs.has(urdfUrl)) return;
150
  prefetchedUrdfs.add(urdfUrl);
151
+ if (usdUrl) {
152
+ Promise.all([loadUrdfText(urdfUrl), loadUsdBlob(usdUrl)]).catch(() =>
153
+ prefetchedUrdfs.delete(urdfUrl),
154
+ );
155
+ return;
156
+ }
157
  const base = THREE.LoaderUtils.extractUrlBase(urdfUrl);
158
  loadUrdfText(urdfUrl)
159
  .then((text) => {
160
  const doc = new DOMParser().parseFromString(text, "application/xml");
161
  for (const mesh of doc.querySelectorAll("visual mesh")) {
162
  const filename = mesh.getAttribute("filename");
163
+ /// Same resolution URDFLoader applies to relative paths; package:// meshes are left to it.
164
  if (!filename || filename.startsWith("package://")) continue;
165
+ if (filename.toLowerCase().endsWith(".stl"))
 
 
 
166
  loadStlGeometry(base + filename).catch(() => {});
167
  }
168
  })
169
  .catch(() => prefetchedUrdfs.delete(urdfUrl));
170
  }
171
 
172
+ function getRobotConfig(robotType: string | null): {
173
+ urdfUrl: string;
174
+ usdUrl?: string;
175
+ scale: number;
176
+ } {
177
  const lower = (robotType ?? "").toLowerCase();
178
  if (lower.includes("g1") || lower.includes("unitree")) {
179
  return { urdfUrl: `${URDF_BASE_URL}/g1/g1_body29_hand14.urdf`, scale: 1 };
 
181
  if (lower.includes("openarm")) {
182
  return {
183
  urdfUrl: `${URDF_BASE_URL}/openarm/openarm_bimanual_web.urdf`,
184
+ usdUrl: `${USD_BASE_URL}/openarm_bimanual/simready_usd/openarm_bimanual.usd`,
185
  scale: 3,
186
  };
187
  }
188
  return {
189
  urdfUrl: `${URDF_BASE_URL}/so101/so101_new_calib_web.urdf`,
190
+ usdUrl: `${USD_BASE_URL}/so101_new_calib/simready_usd/so101_new_calib.usd`,
191
  scale: 10,
192
  };
193
  }
 
279
  continue;
280
  }
281
 
282
+ // OpenArm: openarm_(left|right)_joint(\d+) → (left|right)_joint_(\d+) or (left|right)_joint(\d+),
283
+ // whole words so joint1 doesn't take joint10
284
  const armMatch = lower.match(/^openarm_(left|right)_joint(\d+)$/);
285
  if (armMatch) {
286
+ const pattern = new RegExp(
287
+ `(^|_)${armMatch[1]}_joint_?${armMatch[2]}(\\D|$)`,
288
+ );
289
+ const idx = suffixes.findIndex((s) => pattern.test(s));
290
  if (idx >= 0) {
291
  mapping[jointName] = columnKeys[idx];
292
  continue;
 
359
  // ─── Robot scene (imperative, inside Canvas) ───
360
  function RobotScene({
361
  urdfUrl,
362
+ usdUrl,
363
  jointValues,
364
  onJointsLoaded,
365
  trailEnabled,
 
367
  scale,
368
  }: {
369
  urdfUrl: string;
370
+ usdUrl?: string;
371
  jointValues: Record<string, number>;
372
  onJointsLoaded: (names: string[]) => void;
373
  trailEnabled: boolean;
 
468
  // `if (obj instanceof THREE.Mesh) obj.material = material;`
469
  // on every mesh we hand back — overwriting our PBR material with the
470
  // URDF's <material rgba="...">-derived MeshPhongMaterial. Wrapping the
471
+ // returned mesh in a Group dodges that check so our materials survive.
 
472
  const wrapForUrdf = (obj: THREE.Object3D): THREE.Object3D => {
473
  const group = new THREE.Group();
474
  group.add(obj);
475
  return group;
476
  };
477
  loader.loadMeshCb = (url, mgr, onLoad) => {
478
+ /// The USD draws these robots: their URDF only moves it.
479
+ if (usdUrl) {
480
+ onLoad(new THREE.Object3D());
481
+ return;
482
+ }
483
+ const makeMaterial = () => {
484
  let color = "#9ba1ab";
485
  let metalness = 0.1;
486
  let roughness = 0.5;
 
487
  if (isG1) {
488
  const lower = url.toLowerCase();
489
  const isWhitePart =
 
496
  color = isWhitePart ? "#9ca3af" : "#1f2937";
497
  metalness = 0.25;
498
  roughness = 0.6;
 
 
 
 
 
 
 
 
 
 
499
  }
500
+ return new THREE.MeshStandardMaterial({ color, metalness, roughness });
 
 
 
 
 
 
501
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  // STL files — apply final PBR materials directly here. We used to do a
503
  // post-load archetype rebuild in manager.onLoad, but STLLoader calls
504
  // `manager.itemEnd` *before* our Promise resolves — so when the last
 
516
  /// with every mesh prefetched it would otherwise never fire.
517
  mgr.itemStart(url);
518
  loadStlGeometry(url)
519
+ .then((geometry) =>
520
+ onLoad(wrapForUrdf(new THREE.Mesh(geometry, makeMaterial()))),
521
+ )
522
  .catch((err) => onLoad(new THREE.Object3D(), err as Error))
523
  .finally(() => mgr.itemEnd(url));
524
  };
 
544
 
545
  loader.workingPath = THREE.LoaderUtils.extractUrlBase(urdfUrl);
546
  let cancelled = false;
547
+ let disposeUsd: (() => void) | undefined;
548
  manager.itemStart(urdfUrl);
549
  loadUrdfText(urdfUrl)
550
  .then((text) => {
 
591
  j.jointType === "prismatic",
592
  )
593
  .map((j) => j.name);
594
+ if (!usdUrl) {
595
+ onJointsLoaded(movable);
596
+ return;
597
+ }
598
+ /// Loaded from memory, so the session cache spares a download on every visit to the tab.
599
+ const name = usdUrl.slice(usdUrl.lastIndexOf("/") + 1);
600
+ return loadUsdBlob(usdUrl)
601
+ .then((blob) => loadUsd(name, { files: new Map([[name, blob]]) }))
602
+ .then(({ root, dispose }) => {
603
+ if (cancelled) return dispose();
604
+ disposeUsd = dispose;
605
+ attachUsdMeshes(robot, root);
606
+ onJointsLoaded(movable);
607
+ });
608
  })
609
  .catch((err) => {
610
  if (cancelled) return;
611
+ console.error("Error loading the robot model:", err);
612
  setError(String(err));
613
  })
614
  .finally(() => {
 
620
  scene.remove(robotRef.current);
621
  robotRef.current = null;
622
  }
623
+ disposeUsd?.();
624
  tipLinksRef.current = [];
625
  };
626
+ }, [urdfUrl, usdUrl, scale, scene, onJointsLoaded, ensureTrails, invalidate]);
627
 
628
  const tipWorldPos = useMemo(() => new THREE.Vector3(), []);
629
 
 
716
  if (error)
717
  return (
718
  <Html center>
719
+ <span className="text-red-400">Failed to load the 3D model</span>
720
  </Html>
721
  );
722
  return null;
 
826
  () => getRobotConfig(datasetInfo.robot_type),
827
  [datasetInfo.robot_type],
828
  );
829
+ const { urdfUrl, usdUrl, scale } = robotConfig;
830
  const isG1 = urdfUrl.includes("g1");
831
  const isOpenArm = urdfUrl.includes("openarm");
832
  const repoId = org && dataset ? `${org}/${dataset}` : null;
 
1127
  </mesh>
1128
  <RobotScene
1129
  urdfUrl={urdfUrl}
1130
+ usdUrl={usdUrl}
1131
  jointValues={jointValues}
1132
  onJointsLoaded={onJointsLoaded}
1133
  trailEnabled={trailEnabled}
src/lib/__tests__/hand-tracking.test.ts ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ fluToScene,
4
+ cameraPathRuns,
5
+ handFrameIndex,
6
+ handProjectionMetadata,
7
+ hasHandTracking,
8
+ parseHandCalibration,
9
+ projectHandPoint,
10
+ readHandTracking,
11
+ wristTrail,
12
+ } from "../hand-tracking";
13
+ import type { DatasetMetadata } from "@/utils/parquetUtils";
14
+
15
+ function info(): DatasetMetadata {
16
+ const widths = {
17
+ action: 156,
18
+ "annotation.hand_joints_camera": 126,
19
+ "annotation.hand_joints_world": 126,
20
+ "annotation.hand_action_valid": 1,
21
+ "annotation.hand_observed": 2,
22
+ "annotation.hand_reference_frame": 1,
23
+ };
24
+ return {
25
+ codebase_version: "v3.0",
26
+ robot_type: "human_egocentric",
27
+ fps: 10,
28
+ total_episodes: 1,
29
+ total_frames: 6,
30
+ total_tasks: 1,
31
+ total_videos: 1,
32
+ total_chunks: 1,
33
+ chunks_size: 1000,
34
+ splits: {},
35
+ data_path: "",
36
+ video_path: "",
37
+ features: {
38
+ ...Object.fromEntries(
39
+ Object.entries(widths).map(([key, width]) => [
40
+ key,
41
+ { dtype: "float32", shape: [width], names: null },
42
+ ]),
43
+ ),
44
+ "observation.images.ego": {
45
+ dtype: "video",
46
+ shape: [480, 640, 3],
47
+ names: null,
48
+ },
49
+ },
50
+ };
51
+ }
52
+ const calibration = {
53
+ model: "pinhole",
54
+ width: 640,
55
+ height: 480,
56
+ fx: 600,
57
+ fy: 610,
58
+ cx: 320,
59
+ cy: 240,
60
+ distortion: [],
61
+ };
62
+ const metadata = {
63
+ cameraKey: "observation.images.ego",
64
+ calibration: parseHandCalibration(calibration),
65
+ overlayIssue: null,
66
+ };
67
+ function numericFrames(): {
68
+ length: number;
69
+ timestamps: number[];
70
+ series: Record<string, number[][]>;
71
+ } {
72
+ const vector = () =>
73
+ Array.from({ length: 126 }, (_, component) =>
74
+ Array.from({ length: 6 }, () =>
75
+ component % 3 === 2 ? 1 : component / 100,
76
+ ),
77
+ );
78
+ return {
79
+ length: 6,
80
+ timestamps: [20, 20.1, 20.2, 20.3, 20.4, 20.5],
81
+ series: {
82
+ "annotation.hand_joints_camera": vector(),
83
+ "annotation.hand_joints_world": vector(),
84
+ "annotation.hand_action_valid": [[1, 1, 0, 1, 1, 1]],
85
+ "annotation.hand_observed": [
86
+ [1, 1, 1, 1, 1, 1],
87
+ [1, 1, 1, 1, 1, 1],
88
+ ],
89
+ "annotation.hand_reference_frame": [[0, 0, -1, 3, 3, 5]],
90
+ },
91
+ };
92
+ }
93
+
94
+ describe("native hand schema and time alignment", () => {
95
+ test("an arbitrary 156D action is not detected as hand tracking", () => {
96
+ const metadata = info();
97
+ expect(hasHandTracking(metadata)).toBe(true);
98
+ delete metadata.features["annotation.hand_joints_camera"];
99
+ expect(hasHandTracking(metadata)).toBe(false);
100
+ });
101
+ test("all source frames survive, with local timestamps and independently masked hands", () => {
102
+ const frames = numericFrames();
103
+ frames.series["annotation.hand_observed"][1][1] = 0;
104
+ const data = readHandTracking(info(), frames, metadata)!;
105
+ expect(data.frames).toHaveLength(6);
106
+ expect(data.frames[1].time).toBeCloseTo(0.1);
107
+ expect(data.frames[0].camera[0]).toHaveLength(21);
108
+ expect(data.frames[1].camera[1]).toBeNull();
109
+ expect(data.frames[2].camera).toEqual([null, null]);
110
+ expect(data.frames[2].segment).toEqual([null, null]);
111
+ });
112
+ test("a non-finite point hides the affected hand in both views", () => {
113
+ const frames = numericFrames();
114
+ frames.series["annotation.hand_joints_camera"][0][0] = NaN;
115
+ const data = readHandTracking(info(), frames, metadata)!;
116
+ expect(data.frames[0].camera[0]).toBeNull();
117
+ expect(data.frames[0].segment[0]).toBeNull();
118
+ expect(data.frames[0].segment[1]).not.toBeNull();
119
+ });
120
+ test("selects the displayed frame without interpolating or looking ahead", () => {
121
+ const frames = readHandTracking(info(), numericFrames(), metadata)!.frames;
122
+ expect(handFrameIndex(frames, 0.099)).toBe(0);
123
+ expect(handFrameIndex(frames, 0.1)).toBe(1);
124
+ expect(handFrameIndex(frames, 0.2)).toBe(2);
125
+ // A frame at 0.2 s lives at media time 12.7 s in a shared video starting at 12.5 s.
126
+ expect(handFrameIndex(frames, 12.7 - 12.5)).toBe(2);
127
+ expect(handFrameIndex(frames, -1)).toBe(-1);
128
+ });
129
+ test("trails stop at gaps and segment-reference resets", () => {
130
+ const frames = readHandTracking(info(), numericFrames(), metadata)!.frames;
131
+ expect(wristTrail(frames, 1, 0)).toHaveLength(2);
132
+ expect(wristTrail(frames, 2, 0)).toHaveLength(0);
133
+ expect(wristTrail(frames, 4, 0)).toHaveLength(2);
134
+ expect(wristTrail(frames, 5, 0)).toHaveLength(1);
135
+ expect(fluToScene([1, 2, 3])).toEqual([-2, 3, -1]);
136
+ });
137
+ });
138
+
139
+ describe("recording-wide camera and wrist coordinates", () => {
140
+ const rigid = (x = 0) => [1, 0, 0, x, 0, 1, 0, 20, 0, 0, 1, 30, 0, 0, 0, 1];
141
+ const withPoses = (poses: number[][]) => {
142
+ const frames = numericFrames();
143
+ frames.series["annotation.camera_world_from_optical"] = Array.from(
144
+ { length: 16 },
145
+ (_, a) => poses.map((pose) => pose[a]),
146
+ );
147
+ return readHandTracking(info(), frames, metadata)!;
148
+ };
149
+ test("removes the first camera's translation and rotation from the recording frame", () => {
150
+ const pose = [0, -1, 0, 10, 1, 0, 0, 20, 0, 0, 1, 30, 0, 0, 0, 1];
151
+ const moved = [...pose];
152
+ moved[7] += 1;
153
+ const tracking = withPoses([pose, moved, pose, pose, pose, pose]);
154
+ expect(tracking.coordinateFrame).toBe("recording");
155
+ expect(tracking.frames[0].cameraPose?.position).toEqual([0, -0, -0]);
156
+ expect(tracking.frames[1].cameraPose?.position).toEqual([0, -1, -0]);
157
+ expect(tracking.frames[0].recording[0]?.[0][0]).toBeCloseTo(1);
158
+ expect(tracking.frames[1].recording[0]?.[0][1]).toBeCloseTo(-1);
159
+ expect(tracking.frames[1].recording[0]?.[0][2]).toBeCloseTo(-0.01);
160
+ });
161
+ test("rotates optical joints with each camera pose instead of displaying span-local joints as global", () => {
162
+ const rotated = [0, -1, 0, 1, 1, 0, 0, 20, 0, 0, 1, 30, 0, 0, 0, 1];
163
+ const tracking = withPoses([
164
+ rigid(),
165
+ rotated,
166
+ rigid(),
167
+ rigid(),
168
+ rigid(),
169
+ rigid(),
170
+ ]);
171
+ expect(tracking.frames[1].recording[0]?.[0][1]).toBeCloseTo(-0.99);
172
+ expect(tracking.frames[1].recording[0]?.[0][2]).toBeCloseTo(0);
173
+ expect(tracking.frames[1].cameraPose?.axes[0]).toEqual([0, 0, -1]);
174
+ // Reference frame changes at frame 5, but the saved global trajectory remains continuous.
175
+ expect(wristTrail(tracking.frames, 5, 0, 2, "recording")).toHaveLength(3);
176
+ expect(wristTrail(tracking.frames, 5, 0)).toHaveLength(1);
177
+ });
178
+ test("zero, reflected and non-rigid camera placeholders split the trajectory and hide global hands", () => {
179
+ const reflection = rigid();
180
+ reflection[0] = -1;
181
+ const scaled = rigid();
182
+ scaled[0] = 2;
183
+ const tracking = withPoses([
184
+ rigid(),
185
+ rigid(1),
186
+ Array(16).fill(0),
187
+ reflection,
188
+ scaled,
189
+ rigid(2),
190
+ ]);
191
+ expect(cameraPathRuns(tracking.frames, 5).map((run) => run.length)).toEqual(
192
+ [2, 1],
193
+ );
194
+ for (const i of [2, 3, 4]) {
195
+ expect(tracking.frames[i].cameraPose).toBeNull();
196
+ expect(tracking.frames[i].recording).toEqual([null, null]);
197
+ }
198
+ expect(tracking.frames[5].recording[0]).not.toBeNull();
199
+ });
200
+ test("older native exports without saved camera transforms retain span-local 3D", () => {
201
+ const tracking = readHandTracking(info(), numericFrames(), metadata)!;
202
+ expect(tracking.coordinateFrame).toBe("segment");
203
+ expect(tracking.frames[0].segment[0]).not.toBeNull();
204
+ expect(cameraPathRuns(tracking.frames, 5)).toEqual([]);
205
+ });
206
+ });
207
+
208
+ describe("episode-specific source camera binding", () => {
209
+ test("an incomplete episode mapping cannot select an unindexed report entry", () => {
210
+ const selected = handProjectionMetadata(info(), 0, {
211
+ source_episode_map: [{ episode_index: 1, source_episode_index: 7 }],
212
+ episodes: [{ hand_actions: { camera_intrinsics: calibration } }],
213
+ });
214
+ expect(selected.calibration).toBeNull();
215
+ expect(selected.overlayIssue).toContain("No camera calibration");
216
+ });
217
+ test("uses the source episode map, not completion order or output index", () => {
218
+ const report = {
219
+ manifest: { camera: "observation.images.ego" },
220
+ source_episode_map: [{ episode_index: 0, source_episode_index: 7 }],
221
+ episodes: [
222
+ {
223
+ episode_index: 0,
224
+ hand_actions: { camera_intrinsics: { ...calibration, fx: 100 } },
225
+ },
226
+ {
227
+ episode_index: 7,
228
+ hand_actions: {
229
+ camera_intrinsics: { ...calibration, fx: 700, measured: false },
230
+ },
231
+ },
232
+ ],
233
+ };
234
+ const selected = handProjectionMetadata(info(), 0, report);
235
+ expect(selected.calibration?.fx).toBe(700);
236
+ expect(selected.calibration?.estimated).toBe(true);
237
+ });
238
+ test("never projects source-camera coordinates onto another camera", () => {
239
+ expect(
240
+ handProjectionMetadata(info(), 0, {
241
+ manifest: { camera: "missing.camera" },
242
+ }).cameraKey,
243
+ ).toBeNull();
244
+ const data = info();
245
+ data.features["observation.images.other"] =
246
+ data.features["observation.images.ego"];
247
+ expect(handProjectionMetadata(data, 0, null).cameraKey).toBeNull();
248
+ });
249
+ test("missing calibration keeps 3D available and explains why overlays are unavailable", () => {
250
+ const selected = handProjectionMetadata(info(), 0, null);
251
+ expect(selected.calibration).toBeNull();
252
+ expect(selected.overlayIssue).toContain("No camera calibration");
253
+ expect(
254
+ readHandTracking(info(), numericFrames(), selected)?.frames[0].segment[0],
255
+ ).not.toBeNull();
256
+ });
257
+ test("accepts measured calibration JSON stored on the video feature", () => {
258
+ const data = info();
259
+ data.features["observation.images.ego"].info = {
260
+ "camera.intrinsics": JSON.stringify(calibration),
261
+ };
262
+ expect(handProjectionMetadata(data, 0, null).calibration?.fx).toBe(600);
263
+ expect(parseHandCalibration({ ...calibration, fx: Infinity })).toBeNull();
264
+ });
265
+ test("rejects malformed calibration at the unknown-data boundary", () => {
266
+ for (const value of [
267
+ null,
268
+ [],
269
+ "invalid JSON",
270
+ { ...calibration, width: 640.5 },
271
+ { ...calibration, fx: "600" },
272
+ { ...calibration, distortion: [0, 0, 0, "0"] },
273
+ ]) {
274
+ expect(parseHandCalibration(value)).toBeNull();
275
+ }
276
+ });
277
+ });
278
+
279
+ describe("source camera projection", () => {
280
+ // Reference outputs generated with OpenCV projectPoints, not the implementation under test.
281
+ const d = [
282
+ 0.1, -0.02, 0.003, -0.004, 0.01, 0.02, -0.01, 0.005, 0.001, -0.002, 0.003,
283
+ -0.004, 0.02, -0.03,
284
+ ];
285
+ const expected = [
286
+ [0, 360, 321.3333333333333],
287
+ [4, 360.04582716049384, 321.57562633744857],
288
+ [5, 360.04583155006856, 321.5756352629172],
289
+ [8, 360.02821795025153, 321.53982094328916],
290
+ [12, 360.0409586909922, 321.5792826716842],
291
+ [14, 360.2472660915072, 322.00366115201376],
292
+ ];
293
+ for (const [count, x, y] of expected)
294
+ test(`pinhole ${count}-coefficient projection matches OpenCV`, () => {
295
+ const c = parseHandCalibration({
296
+ ...calibration,
297
+ distortion: d.slice(0, count),
298
+ })!;
299
+ const p = projectHandPoint([0.1, 0.2, 1.5], c)!;
300
+ expect(p[0]).toBeCloseTo(x, 8);
301
+ expect(p[1]).toBeCloseTo(y, 8);
302
+ });
303
+ test("fisheye projection matches OpenCV", () => {
304
+ const c = parseHandCalibration({
305
+ ...calibration,
306
+ model: "opencv_fisheye",
307
+ distortion: d.slice(0, 4),
308
+ })!;
309
+ const p = projectHandPoint([0.1, 0.2, 1.5], c)!;
310
+ expect(p[0]).toBeCloseTo(359.7941667826154, 8);
311
+ expect(p[1]).toBeCloseTo(320.9148057913179, 8);
312
+ });
313
+ test("points behind the image plane never appear", () => {
314
+ expect(projectHandPoint([0, 0, -1], metadata.calibration!)).toBeNull();
315
+ expect(projectHandPoint([0, 0, 0], metadata.calibration!)).toBeNull();
316
+ });
317
+ });
src/lib/hand-tracking.ts ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { DatasetMetadata } from "@/utils/parquetUtils";
2
+
3
+ export type Point3 = [number, number, number];
4
+ export type HandIndex = 0 | 1;
5
+ export type HandPair = [Point3[] | null, Point3[] | null];
6
+ export type CameraPose = { position: Point3; axes: [Point3, Point3, Point3] };
7
+ export type HandFrame = {
8
+ time: number;
9
+ referenceFrame: number;
10
+ camera: HandPair;
11
+ segment: HandPair;
12
+ recording: HandPair;
13
+ cameraPose: CameraPose | null;
14
+ };
15
+ export type HandCalibration = {
16
+ model: "pinhole" | "opencv_fisheye";
17
+ width: number;
18
+ height: number;
19
+ fx: number;
20
+ fy: number;
21
+ cx: number;
22
+ cy: number;
23
+ distortion: number[];
24
+ estimated: boolean;
25
+ };
26
+ export type HandTracking = {
27
+ frames: HandFrame[];
28
+ fps: number;
29
+ cameraKey: string | null;
30
+ calibration: HandCalibration | null;
31
+ overlayIssue: string | null;
32
+ coordinateFrame: "recording" | "segment";
33
+ };
34
+ type NumericFrames = {
35
+ length: number;
36
+ timestamps: number[];
37
+ series: Record<string, number[][]>;
38
+ };
39
+
40
+ export const HAND_COLORS = ["#59b5ff", "#ffb454"] as const;
41
+ export const HAND_INDICES = [0, 1] as const;
42
+ export const HAND_EDGES = [1, 5, 9, 13, 17].flatMap(
43
+ (base): [number, number][] => [
44
+ [0, base],
45
+ [base, base + 1],
46
+ [base + 1, base + 2],
47
+ [base + 2, base + 3],
48
+ ],
49
+ );
50
+ const HAND_WIDTHS: Record<string, number> = {
51
+ "annotation.hand_joints_camera": 126,
52
+ "annotation.hand_joints_world": 126,
53
+ "annotation.hand_action_valid": 1,
54
+ "annotation.hand_observed": 2,
55
+ "annotation.hand_reference_frame": 1,
56
+ };
57
+
58
+ /** The SOMA156 contract, rather than a robot name or an arbitrary 156D action. */
59
+ export function hasHandTracking(info: DatasetMetadata): boolean {
60
+ return (
61
+ info.features.action?.shape.join() === "156" &&
62
+ Object.entries(HAND_WIDTHS).every(
63
+ ([key, width]) => info.features[key]?.shape.join() === String(width),
64
+ )
65
+ );
66
+ }
67
+
68
+ /** Native auxiliary arrays have their own viewer, rather than hundreds of chart series. */
69
+ export function isHandGeometryColumn(key: string): boolean {
70
+ return (
71
+ key.startsWith("annotation.hand_") ||
72
+ key.startsWith("annotation.soma_") ||
73
+ key === "annotation.wrist_rotation_camera" ||
74
+ key === "annotation.camera_world_from_optical"
75
+ );
76
+ }
77
+
78
+ function record(value: unknown): Record<string, unknown> {
79
+ return value !== null && typeof value === "object" && !Array.isArray(value)
80
+ ? (value as Record<string, unknown>)
81
+ : {};
82
+ }
83
+
84
+ function isFiniteNumber(value: unknown): value is number {
85
+ return typeof value === "number" && Number.isFinite(value);
86
+ }
87
+
88
+ export function parseHandCalibration(value: unknown): HandCalibration | null {
89
+ if (typeof value === "string") {
90
+ try {
91
+ value = JSON.parse(value);
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+ const c = record(value);
97
+ const { width, height, fx, fy, cx, cy } = c;
98
+ if (
99
+ !isFiniteNumber(width) ||
100
+ !Number.isInteger(width) ||
101
+ width <= 0 ||
102
+ !isFiniteNumber(height) ||
103
+ !Number.isInteger(height) ||
104
+ height <= 0 ||
105
+ !isFiniteNumber(fx) ||
106
+ fx <= 0 ||
107
+ !isFiniteNumber(fy) ||
108
+ fy <= 0 ||
109
+ !isFiniteNumber(cx) ||
110
+ !isFiniteNumber(cy)
111
+ )
112
+ return null;
113
+ if (c.model !== "pinhole" && c.model !== "opencv_fisheye") return null;
114
+ const d = c.distortion ?? [];
115
+ if (
116
+ !Array.isArray(d) ||
117
+ !d.every(isFiniteNumber) ||
118
+ !(c.model === "pinhole" ? [0, 4, 5, 8, 12, 14] : [4]).includes(d.length)
119
+ )
120
+ return null;
121
+ return {
122
+ model: c.model,
123
+ width,
124
+ height,
125
+ fx,
126
+ fy,
127
+ cx,
128
+ cy,
129
+ distortion: d,
130
+ estimated: c.measured === false || c.source === "moge-estimated",
131
+ };
132
+ }
133
+
134
+ /** Run episodes are source IDs and can finish out of order; exports can be reindexed. */
135
+ export function handProjectionMetadata(
136
+ info: DatasetMetadata,
137
+ episodeId: number,
138
+ reportValue: unknown,
139
+ ): Pick<HandTracking, "cameraKey" | "calibration" | "overlayIssue"> {
140
+ const report = record(reportValue);
141
+ const manifest = record(report.manifest);
142
+ const cameras = Object.keys(info.features).filter(
143
+ (key) => info.features[key].dtype === "video",
144
+ );
145
+ const cameraKey =
146
+ typeof manifest.camera === "string"
147
+ ? cameras.includes(manifest.camera)
148
+ ? manifest.camera
149
+ : null
150
+ : cameras.length === 1
151
+ ? cameras[0]
152
+ : null;
153
+ const mapping = Array.isArray(report.source_episode_map)
154
+ ? report.source_episode_map.map(record)
155
+ : null;
156
+ const mapped = mapping?.find((entry) => entry.episode_index === episodeId);
157
+ const sourceId = mapping ? mapped?.source_episode_index : episodeId;
158
+ const episodes = Array.isArray(report.episodes)
159
+ ? report.episodes.map(record)
160
+ : [];
161
+ const sourceEpisode =
162
+ isFiniteNumber(sourceId) && Number.isSafeInteger(sourceId) && sourceId >= 0
163
+ ? episodes.find((entry) => entry.episode_index === sourceId)
164
+ : undefined;
165
+ const handResult = record(sourceEpisode?.hand_actions);
166
+ const calibration =
167
+ parseHandCalibration(handResult.camera_intrinsics) ??
168
+ parseHandCalibration(
169
+ cameraKey ? info.features[cameraKey].info?.["camera.intrinsics"] : null,
170
+ );
171
+ return {
172
+ cameraKey,
173
+ calibration,
174
+ overlayIssue: !cameraKey
175
+ ? "The annotation camera is unknown; camera overlays are unavailable."
176
+ : !calibration
177
+ ? "No camera calibration is saved for this episode; 3D hands remain available."
178
+ : null,
179
+ };
180
+ }
181
+
182
+ /** Keep the full source frame grid. Never turn masked zero placeholders into hands. */
183
+ export function readHandTracking(
184
+ info: DatasetMetadata,
185
+ frames: NumericFrames | undefined,
186
+ metadata: ReturnType<typeof handProjectionMetadata>,
187
+ ): HandTracking | undefined {
188
+ if (!hasHandTracking(info)) return undefined;
189
+ const rows: HandFrame[] = [];
190
+ const cameraMatrices: (number[] | null)[] = [];
191
+ const timeOrigin = frames?.timestamps[0] ?? 0;
192
+ const points = (
193
+ key: string,
194
+ frame: number,
195
+ hand: HandIndex,
196
+ ): Point3[] | null => {
197
+ const components = frames?.series[key];
198
+ if (components?.length !== 126) return null;
199
+ const joints = Array.from({ length: 21 }, (_, joint): Point3 => {
200
+ const offset = hand * 63 + joint * 3;
201
+ return [
202
+ components[offset]?.[frame],
203
+ components[offset + 1]?.[frame],
204
+ components[offset + 2]?.[frame],
205
+ ];
206
+ });
207
+ return joints.every((p) => p.every(Number.isFinite)) ? joints : null;
208
+ };
209
+ if (
210
+ frames &&
211
+ frames.timestamps.length === frames.length &&
212
+ Number.isFinite(timeOrigin)
213
+ ) {
214
+ for (let i = 0; i < frames.length; i++) {
215
+ const time = frames.timestamps[i] - timeOrigin;
216
+ if (!Number.isFinite(time) || (i > 0 && time <= rows[i - 1].time))
217
+ return undefined;
218
+ const reference =
219
+ frames.series["annotation.hand_reference_frame"]?.[0]?.[i];
220
+ const valid =
221
+ frames.series["annotation.hand_action_valid"]?.[0]?.[i] === 1 &&
222
+ Number.isInteger(reference) &&
223
+ reference >= 0 &&
224
+ reference <= i;
225
+ const observed = (hand: HandIndex) =>
226
+ valid && frames.series["annotation.hand_observed"]?.[hand]?.[i] === 1;
227
+ const camera: HandPair = [null, null],
228
+ segment: HandPair = [null, null];
229
+ for (const hand of HAND_INDICES) {
230
+ if (!observed(hand)) continue;
231
+ const cameraPoints = points("annotation.hand_joints_camera", i, hand);
232
+ const segmentPoints = points("annotation.hand_joints_world", i, hand);
233
+ if (cameraPoints && segmentPoints) {
234
+ camera[hand] = cameraPoints;
235
+ segment[hand] = segmentPoints;
236
+ }
237
+ }
238
+ rows.push({
239
+ time,
240
+ referenceFrame: valid ? reference : -1,
241
+ camera,
242
+ segment,
243
+ recording: [null, null],
244
+ cameraPose: null,
245
+ });
246
+ const matrix = frames.series["annotation.camera_world_from_optical"];
247
+ const pose =
248
+ matrix?.length === 16 ? matrix.map((component) => component[i]) : null;
249
+ cameraMatrices.push(pose && isCameraTransform(pose) ? pose : null);
250
+ }
251
+ }
252
+ const origin = cameraMatrices.find((matrix) => matrix !== null);
253
+ if (origin)
254
+ rows.forEach((frame, i) => {
255
+ const pose = cameraMatrices[i];
256
+ if (!pose) return;
257
+ // Only saved rigid transforms are used. Masked zero matrices are not camera poses.
258
+ const relative = (p: Point3): Point3 => {
259
+ const world = [0, 1, 2].map(
260
+ (a) =>
261
+ pose[a * 4] * p[0] +
262
+ pose[a * 4 + 1] * p[1] +
263
+ pose[a * 4 + 2] * p[2] +
264
+ pose[a * 4 + 3] -
265
+ origin[a * 4 + 3],
266
+ );
267
+ const optical = [0, 1, 2].map(
268
+ (a) =>
269
+ origin[a] * world[0] +
270
+ origin[a + 4] * world[1] +
271
+ origin[a + 8] * world[2],
272
+ );
273
+ return [optical[2], -optical[0], -optical[1]];
274
+ };
275
+ const position = relative([0, 0, 0]);
276
+ const axis = (p: Point3): Point3 => {
277
+ const end = relative(p);
278
+ return [
279
+ end[0] - position[0],
280
+ end[1] - position[1],
281
+ end[2] - position[2],
282
+ ];
283
+ };
284
+ frame.cameraPose = {
285
+ position,
286
+ axes: [axis([1, 0, 0]), axis([0, 1, 0]), axis([0, 0, 1])],
287
+ };
288
+ frame.recording = [
289
+ frame.camera[0]?.map(relative) ?? null,
290
+ frame.camera[1]?.map(relative) ?? null,
291
+ ];
292
+ });
293
+ return {
294
+ frames: rows,
295
+ fps: info.fps,
296
+ coordinateFrame: origin ? "recording" : "segment",
297
+ ...metadata,
298
+ };
299
+ }
300
+
301
+ function isCameraTransform(m: number[]): boolean {
302
+ if (
303
+ !m.every(Number.isFinite) ||
304
+ [m[12], m[13], m[14], m[15] - 1].some((n) => Math.abs(n) > 1e-4)
305
+ )
306
+ return false;
307
+ for (let a = 0; a < 3; a++)
308
+ for (let b = 0; b < 3; b++) {
309
+ const dot = [0, 1, 2].reduce(
310
+ (sum, k) => sum + m[k * 4 + a] * m[k * 4 + b],
311
+ 0,
312
+ );
313
+ if (Math.abs(dot - Number(a === b)) > 2e-3) return false;
314
+ }
315
+ const determinant =
316
+ m[0] * (m[5] * m[10] - m[6] * m[9]) -
317
+ m[1] * (m[4] * m[10] - m[6] * m[8]) +
318
+ m[2] * (m[4] * m[9] - m[5] * m[8]);
319
+ return Math.abs(determinant - 1) < 2e-3;
320
+ }
321
+
322
+ /** The frame currently on screen, without interpolation or looking ahead to the next pose. */
323
+ export function handFrameIndex(frames: HandFrame[], time: number): number {
324
+ let lo = 0,
325
+ hi = frames.length;
326
+ while (lo < hi) {
327
+ const mid = (lo + hi) >>> 1;
328
+ if (frames[mid].time <= time + 1e-7) lo = mid + 1;
329
+ else hi = mid;
330
+ }
331
+ return lo - 1;
332
+ }
333
+
334
+ /** The stored "world" joints are segment-start FLU, not a continuous global trajectory. */
335
+ export function fluToScene([forward, left, up]: Point3): Point3 {
336
+ return [-left, up, -forward];
337
+ }
338
+
339
+ /** Break trails both at validity gaps and at each segment-reference reset. */
340
+ export function wristTrail(
341
+ frames: HandFrame[],
342
+ index: number,
343
+ hand: HandIndex,
344
+ seconds = 2,
345
+ space: "segment" | "recording" = "segment",
346
+ ): Point3[] {
347
+ const trail: Point3[] = [];
348
+ const current = frames[index];
349
+ if (!current?.[space][hand]) return trail;
350
+ for (let i = index; i >= 0; i--) {
351
+ const f = frames[i];
352
+ const joints = f[space][hand];
353
+ if (
354
+ !joints ||
355
+ (space === "segment" && f.referenceFrame !== current.referenceFrame) ||
356
+ current.time - f.time > seconds
357
+ )
358
+ break;
359
+ trail.push(fluToScene(joints[0]));
360
+ }
361
+ return trail.reverse();
362
+ }
363
+
364
+ /** Separate camera-path runs wherever no transform was saved; never draw through zero placeholders. */
365
+ export function cameraPathRuns(frames: HandFrame[], index: number): Point3[][] {
366
+ const runs: Point3[][] = [];
367
+ let run: Point3[] = [];
368
+ for (let i = 0; i <= index; i++) {
369
+ const pose = frames[i]?.cameraPose;
370
+ if (pose) run.push(fluToScene(pose.position));
371
+ else if (run.length) {
372
+ runs.push(run);
373
+ run = [];
374
+ }
375
+ }
376
+ if (run.length) runs.push(run);
377
+ return runs;
378
+ }
379
+
380
+ /** Project optical-camera XYZ onto the original, possibly distorted source image. */
381
+ export function projectHandPoint(
382
+ point: Point3,
383
+ c: HandCalibration,
384
+ ): [number, number] | null {
385
+ if (!point.every(Number.isFinite) || point[2] <= 1e-6) return null;
386
+ let x = point[0] / point[2],
387
+ y = point[1] / point[2];
388
+ const d = c.distortion;
389
+ const r2 = x * x + y * y;
390
+ if (c.model === "opencv_fisheye") {
391
+ const r = Math.sqrt(r2),
392
+ theta = Math.atan(r),
393
+ t2 = theta * theta;
394
+ const scale =
395
+ r < 1e-10
396
+ ? 1
397
+ : (theta * (1 + t2 * (d[0] + t2 * (d[1] + t2 * (d[2] + t2 * d[3]))))) /
398
+ r;
399
+ x *= scale;
400
+ y *= scale;
401
+ } else if (d.length) {
402
+ const [
403
+ k1 = 0,
404
+ k2 = 0,
405
+ p1 = 0,
406
+ p2 = 0,
407
+ k3 = 0,
408
+ k4 = 0,
409
+ k5 = 0,
410
+ k6 = 0,
411
+ s1 = 0,
412
+ s2 = 0,
413
+ s3 = 0,
414
+ s4 = 0,
415
+ tx = 0,
416
+ ty = 0,
417
+ ] = d;
418
+ const denominator = 1 + r2 * (k4 + r2 * (k5 + r2 * k6));
419
+ if (Math.abs(denominator) < 1e-12) return null;
420
+ const radial = (1 + r2 * (k1 + r2 * (k2 + r2 * k3))) / denominator;
421
+ const dx =
422
+ x * radial +
423
+ 2 * p1 * x * y +
424
+ p2 * (r2 + 2 * x * x) +
425
+ s1 * r2 +
426
+ s2 * r2 * r2;
427
+ const dy =
428
+ y * radial +
429
+ p1 * (r2 + 2 * y * y) +
430
+ 2 * p2 * x * y +
431
+ s3 * r2 +
432
+ s4 * r2 * r2;
433
+ // OpenCV's tilted-sensor projection (tau_x, tau_y), after radial/tangential distortion.
434
+ const sx = Math.sin(tx),
435
+ cx = Math.cos(tx),
436
+ sy = Math.sin(ty),
437
+ cy = Math.cos(ty);
438
+ const vx = cy * dx + sy * sx * dy - sy * cx;
439
+ const vy = cx * dy + sx;
440
+ const vz = sy * dx - cy * sx * dy + cy * cx;
441
+ if (Math.abs(vz) < 1e-12) return null;
442
+ x = (cy * cx * vx + sy * cx * vz) / vz;
443
+ y = (cy * cx * vy - sx * vz) / vz;
444
+ }
445
+ const pixel: [number, number] = [c.fx * x + c.cx, c.fy * y + c.cy];
446
+ return pixel.every(Number.isFinite) ? pixel : null;
447
+ }
src/utils/__tests__/parquetUtils.test.ts CHANGED
@@ -3,7 +3,9 @@ import {
3
  formatStringWithVars,
4
  arrayToCSV,
5
  getRows,
 
6
  } from "@/utils/parquetUtils";
 
7
  import { PADDING } from "@/utils/constants";
8
 
9
  // ---------------------------------------------------------------------------
@@ -145,3 +147,47 @@ describe("getRows", () => {
145
  expect(rows[0]).toEqual([0.75]);
146
  });
147
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  formatStringWithVars,
4
  arrayToCSV,
5
  getRows,
6
+ nestedColumnValues,
7
  } from "@/utils/parquetUtils";
8
+ import type { FileMetaData } from "hyparquet";
9
  import { PADDING } from "@/utils/constants";
10
 
11
  // ---------------------------------------------------------------------------
 
147
  expect(rows[0]).toEqual([0.75]);
148
  });
149
  });
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // nestedColumnValues — what decoding a nested column costs for a row range
153
+ // ---------------------------------------------------------------------------
154
+ describe("nestedColumnValues", () => {
155
+ const chunk = (path: string[], values: number) => ({
156
+ meta_data: { path_in_schema: path, num_values: BigInt(values) },
157
+ });
158
+ // Two row groups of 100 rows: a flat column, a list column and a list of
159
+ // structs whose leaves are counted together.
160
+ const metadata = {
161
+ row_groups: [0, 1].map((group) => ({
162
+ num_rows: 100n,
163
+ columns: [
164
+ chunk(["timestamp"], 100),
165
+ chunk(["language_events", "list", "element"], 10 + group),
166
+ chunk(["language_persistent", "list", "element", "role"], 1000),
167
+ chunk(["language_persistent", "list", "element", "content"], 1000),
168
+ ],
169
+ })),
170
+ } as unknown as FileMetaData;
171
+
172
+ test("counts the whole row group, however few rows are read", () => {
173
+ expect(nestedColumnValues(metadata, 10, 20)).toEqual(
174
+ new Map([
175
+ ["language_events", 10],
176
+ ["language_persistent", 2000],
177
+ ]),
178
+ );
179
+ });
180
+
181
+ test("sums every row group the range overlaps", () => {
182
+ expect(nestedColumnValues(metadata, 90, 110).get("language_events")).toBe(
183
+ 21,
184
+ );
185
+ expect(nestedColumnValues(metadata, 100, 200).get("language_events")).toBe(
186
+ 11,
187
+ );
188
+ });
189
+
190
+ test("leaves flat columns out", () => {
191
+ expect(nestedColumnValues(metadata, 0, 200).has("timestamp")).toBe(false);
192
+ });
193
+ });
src/utils/parquetUtils.ts CHANGED
@@ -4,7 +4,9 @@ import {
4
  parquetRead,
5
  parquetReadObjects,
6
  type AsyncBuffer,
 
7
  } from "hyparquet";
 
8
  import { authHeaders } from "./auth";
9
 
10
  export interface DatasetMetadata {
@@ -43,6 +45,10 @@ type ParquetFile = ArrayBuffer | AsyncBuffer;
43
 
44
  const parquetFileCache = new Map<string, AsyncBuffer>();
45
 
 
 
 
 
46
  export async function fetchParquetFile(url: string): Promise<ParquetFile> {
47
  const cached = parquetFileCache.get(url);
48
  if (cached) return cached;
@@ -69,6 +75,7 @@ export async function readParquetColumn(
69
  columns: columns.length > 0 ? columns : undefined,
70
  rowStart: options?.rowStart,
71
  rowEnd: options?.rowEnd,
 
72
  onComplete: (data: unknown[][]) => {
73
  resolve(data);
74
  },
@@ -89,9 +96,38 @@ export async function readParquetAsObjects(
89
  columns: columns.length > 0 ? columns : undefined,
90
  rowStart: options?.rowStart,
91
  rowEnd: options?.rowEnd,
 
92
  }) as Promise<Record<string, unknown>[]>;
93
  }
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  // Convert a 2D array to a CSV string
96
  export function arrayToCSV(data: (number | string)[][]): string {
97
  return data.map((row) => row.join(",")).join("\n");
 
4
  parquetRead,
5
  parquetReadObjects,
6
  type AsyncBuffer,
7
+ type FileMetaData,
8
  } from "hyparquet";
9
+ import { decompress } from "fzstd";
10
  import { authHeaders } from "./auth";
11
 
12
  export interface DatasetMetadata {
 
45
 
46
  const parquetFileCache = new Map<string, AsyncBuffer>();
47
 
48
+ // hyparquet only decodes SNAPPY. No output buffer: fzstd throws on a page of
49
+ // several zstd frames.
50
+ const compressors = { ZSTD: (input: Uint8Array) => decompress(input) };
51
+
52
  export async function fetchParquetFile(url: string): Promise<ParquetFile> {
53
  const cached = parquetFileCache.get(url);
54
  if (cached) return cached;
 
75
  columns: columns.length > 0 ? columns : undefined,
76
  rowStart: options?.rowStart,
77
  rowEnd: options?.rowEnd,
78
+ compressors,
79
  onComplete: (data: unknown[][]) => {
80
  resolve(data);
81
  },
 
96
  columns: columns.length > 0 ? columns : undefined,
97
  rowStart: options?.rowStart,
98
  rowEnd: options?.rowEnd,
99
+ compressors,
100
  }) as Promise<Record<string, unknown>[]>;
101
  }
102
 
103
+ /**
104
+ * Leaf values per nested column in the row groups overlapping
105
+ * [rowStart, rowEnd). hyparquet decodes a nested column across its whole row
106
+ * group, so this is what reading it costs, however few rows are asked for.
107
+ */
108
+ export function nestedColumnValues(
109
+ metadata: FileMetaData,
110
+ rowStart: number,
111
+ rowEnd: number,
112
+ ): Map<string, number> {
113
+ const values = new Map<string, number>();
114
+ let groupEnd = 0;
115
+ for (const group of metadata.row_groups) {
116
+ const groupStart = groupEnd;
117
+ groupEnd += Number(group.num_rows);
118
+ if (groupStart >= rowEnd || groupEnd <= rowStart) continue;
119
+ for (const { meta_data } of group.columns) {
120
+ if (!meta_data || meta_data.path_in_schema.length === 1) continue;
121
+ const [column] = meta_data.path_in_schema;
122
+ values.set(
123
+ column,
124
+ (values.get(column) ?? 0) + Number(meta_data.num_values),
125
+ );
126
+ }
127
+ }
128
+ return values;
129
+ }
130
+
131
  // Convert a 2D array to a CSV string
132
  export function arrayToCSV(data: (number | string)[][]): string {
133
  return data.map((row) => row.join(",")).join("\n");
tsconfig.test.json CHANGED
@@ -6,5 +6,6 @@
6
  "types": ["bun-types"],
7
  "incremental": false
8
  },
9
- "include": ["**/__tests__/**/*.ts", "src/**/*.ts"]
 
10
  }
 
6
  "types": ["bun-types"],
7
  "incremental": false
8
  },
9
+ "include": ["**/__tests__/**/*.ts", "**/__tests__/**/*.tsx", "src/**/*.ts"],
10
+ "exclude": ["node_modules"]
11
  }