Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Sync from GitHub via hub-sync
Browse files- .env.example +8 -0
- .prettierignore +13 -0
- README.md +38 -0
- backend/README.md +72 -0
- backend/app.py +871 -0
- backend/requirements.txt +6 -0
- src/app/[org]/[dataset]/[episode]/episode-viewer.tsx +133 -29
- src/app/[org]/[dataset]/[episode]/fetch-data.ts +135 -3
- src/components/annotations-panel.tsx +1021 -0
- src/components/annotations-skin.css +1044 -0
- src/components/annotations-timeline.tsx +818 -0
- src/components/playback-bar.tsx +4 -1
- src/components/simple-videos-player.tsx +47 -14
- src/components/video-overlay-canvas.tsx +799 -0
- src/context/annotations-context.tsx +404 -0
- src/context/time-context.tsx +3 -0
- src/types/language.types.ts +266 -0
- src/utils/annotationsClient.ts +186 -0
.env.example
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dataset host URL (defaults to https://huggingface.co/datasets)
|
| 2 |
+
# DATASET_URL=https://huggingface.co/datasets
|
| 3 |
+
|
| 4 |
+
# Annotation backend (optional). When set, the Annotations tab persists
|
| 5 |
+
# edits, rewrites parquet shards with `language_persistent` + `language_events`
|
| 6 |
+
# columns (lerobot#3467 / #3471), and can push to the HF Hub. When unset, the
|
| 7 |
+
# Annotations tab is read/edit-only with sessionStorage persistence.
|
| 8 |
+
# NEXT_PUBLIC_ANNOTATE_BACKEND_URL=http://127.0.0.1:7861
|
.prettierignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Build output
|
| 2 |
+
.next/
|
| 3 |
+
out/
|
| 4 |
+
node_modules/
|
| 5 |
+
|
| 6 |
+
# Backend (Python)
|
| 7 |
+
backend/.venv/
|
| 8 |
+
backend/__pycache__/
|
| 9 |
+
backend/**/*.pyc
|
| 10 |
+
|
| 11 |
+
# Lockfiles
|
| 12 |
+
bun.lock
|
| 13 |
+
package-lock.json
|
README.md
CHANGED
|
@@ -37,6 +37,7 @@ 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 |
- **Efficient Data Loading:** Uses parquet and JSON loading for large dataset support, with pagination, chunking, and lazy-loaded panels for fast initial load.
|
| 41 |
- **Responsive UI:** Built with React, Next.js, and Tailwind CSS for a fast, modern user experience.
|
| 42 |
|
|
@@ -100,6 +101,43 @@ bun run format
|
|
| 100 |
### Environment Variables
|
| 101 |
|
| 102 |
- `DATASET_URL`: (optional) Base URL for dataset hosting (defaults to HuggingFace Datasets).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
## Docker Deployment
|
| 105 |
|
|
|
|
| 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 |
|
|
|
|
| 101 |
### Environment Variables
|
| 102 |
|
| 103 |
- `DATASET_URL`: (optional) Base URL for dataset hosting (defaults to HuggingFace Datasets).
|
| 104 |
+
- `NEXT_PUBLIC_ANNOTATE_BACKEND_URL`: (optional) URL of the FastAPI annotation
|
| 105 |
+
backend (`backend/app.py`). When set, the Annotations tab can save edits and
|
| 106 |
+
rewrite parquet shards / push to the Hub. When unset the tab is read/edit
|
| 107 |
+
only with sessionStorage persistence.
|
| 108 |
+
|
| 109 |
+
## Annotations backend (optional)
|
| 110 |
+
|
| 111 |
+
The Annotations tab edits LeRobot v3.1 language atoms — `language_persistent`
|
| 112 |
+
(broadcast subtask/plan/memory) and `language_events` (per-frame
|
| 113 |
+
interjection / vqa / speech) — and renders existing bbox/keypoint atoms over
|
| 114 |
+
the video player. Edits live in `sessionStorage` by default; to write the
|
| 115 |
+
new columns into `data/chunk-*/file-*.parquet` (matching the writer in
|
| 116 |
+
[lerobot#3471](https://github.com/huggingface/lerobot/pull/3471)) and push the
|
| 117 |
+
result to the Hub, run the bundled FastAPI service:
|
| 118 |
+
|
| 119 |
+
```bash
|
| 120 |
+
# 1. install + start the backend (port 7861 by default)
|
| 121 |
+
cd backend
|
| 122 |
+
python -m venv .venv && source .venv/bin/activate
|
| 123 |
+
pip install -r requirements.txt
|
| 124 |
+
uvicorn app:app --port 7861 --reload
|
| 125 |
+
|
| 126 |
+
# 2. start the visualizer with the backend URL configured
|
| 127 |
+
cd ..
|
| 128 |
+
NEXT_PUBLIC_ANNOTATE_BACKEND_URL=http://127.0.0.1:7861 bun run dev
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
The backend exposes:
|
| 132 |
+
|
| 133 |
+
- `POST /api/dataset/load` — load a dataset by `repo_id` or `local_path`
|
| 134 |
+
- `GET /api/episodes/{ep}/atoms` — list atoms for an episode
|
| 135 |
+
- `POST /api/episodes/{ep}/atoms` — replace atoms (event timestamps are
|
| 136 |
+
snapped to exact source-frame timestamps before persisting)
|
| 137 |
+
- `GET /api/episodes/{ep}/frame_timestamps` — used client-side for snapping
|
| 138 |
+
- `POST /api/export` — rewrite parquet with the new language columns plus
|
| 139 |
+
the dataset-level `tools` column (drops legacy `subtask_index`)
|
| 140 |
+
- `POST /api/push_to_hub` — export and push to a target repo
|
| 141 |
|
| 142 |
## Docker Deployment
|
| 143 |
|
backend/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Annotations backend
|
| 2 |
+
|
| 3 |
+
FastAPI service that lets the visualizer write the LeRobot v3.1 language
|
| 4 |
+
schema (`language_persistent` + `language_events`) directly into
|
| 5 |
+
`data/chunk-*/file-*.parquet`. Mirrors the conventions of the steerable
|
| 6 |
+
annotation pipeline in [lerobot#3471](https://github.com/huggingface/lerobot/pull/3471):
|
| 7 |
+
|
| 8 |
+
- per-episode persistent identity (every frame in the episode sees the same
|
| 9 |
+
`language_persistent` list)
|
| 10 |
+
- exact-frame timestamps for events (`language_events`)
|
| 11 |
+
- column routing per `column_for_style(style)` — `subtask`/`plan`/`memory`
|
| 12 |
+
go to `language_persistent`, `interjection`/`vqa` and speech tool-call
|
| 13 |
+
atoms (`style=null`) go to `language_events`
|
| 14 |
+
- dataset-level `tools` column carrying the JSON schema for the `say` tool
|
| 15 |
+
- legacy `subtask_index` column dropped
|
| 16 |
+
|
| 17 |
+
## Run
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
python -m venv .venv && source .venv/bin/activate
|
| 21 |
+
pip install -r requirements.txt
|
| 22 |
+
uvicorn app:app --port 7861 --reload
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
Then start the Next.js visualizer with the backend URL configured:
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
NEXT_PUBLIC_ANNOTATE_BACKEND_URL=http://127.0.0.1:7861 bun run dev
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## API
|
| 32 |
+
|
| 33 |
+
| Method | Path | Purpose |
|
| 34 |
+
| ------ | ------------------------------------- | -------------------------------------------------------------------- |
|
| 35 |
+
| GET | `/api/health` | Liveness + style catalog |
|
| 36 |
+
| POST | `/api/dataset/load` | Cache + read a dataset's `meta/` |
|
| 37 |
+
| GET | `/api/episodes/{ep}/atoms` | Read saved atoms |
|
| 38 |
+
| POST | `/api/episodes/{ep}/atoms` | Write atoms (event timestamps are snapped to exact frame timestamps) |
|
| 39 |
+
| GET | `/api/episodes/{ep}/frame_timestamps` | Frame timestamps for client-side snapping |
|
| 40 |
+
| POST | `/api/export` | Rewrite parquet shards into a new directory |
|
| 41 |
+
| POST | `/api/push_to_hub` | Export and push to a target repo |
|
| 42 |
+
|
| 43 |
+
## Storage layout
|
| 44 |
+
|
| 45 |
+
Annotations are persisted to `<dataset_root>/meta/lerobot_annotations.json`,
|
| 46 |
+
v2 schema:
|
| 47 |
+
|
| 48 |
+
```json
|
| 49 |
+
{
|
| 50 |
+
"version": 2,
|
| 51 |
+
"schema": {
|
| 52 |
+
"persistent_styles": ["memory", "plan", "subtask"],
|
| 53 |
+
"event_styles": ["interjection", "vqa"]
|
| 54 |
+
},
|
| 55 |
+
"episodes": {
|
| 56 |
+
"0": {
|
| 57 |
+
"atoms": [
|
| 58 |
+
{
|
| 59 |
+
"role": "assistant",
|
| 60 |
+
"content": "grasp the sponge",
|
| 61 |
+
"style": "subtask",
|
| 62 |
+
"timestamp": 0.0,
|
| 63 |
+
"tool_calls": null
|
| 64 |
+
}
|
| 65 |
+
]
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
The legacy v1 layout (`subtasks`/`high_levels` from earlier `lerobot-annotate`)
|
| 72 |
+
is auto-migrated on load.
|
backend/app.py
ADDED
|
@@ -0,0 +1,871 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LeRobot dataset visualizer — annotation backend.
|
| 2 |
+
|
| 3 |
+
A small FastAPI service that lets the Next.js visualizer write the v3.1
|
| 4 |
+
language schema introduced in lerobot#3467 (PR1) and used by the steerable
|
| 5 |
+
annotation pipeline in lerobot#3471 (PR2). Specifically it owns:
|
| 6 |
+
|
| 7 |
+
- per-episode annotation state, persisted to ``meta/lerobot_annotations.json``
|
| 8 |
+
- snapping event-style atom timestamps to exact source-frame timestamps
|
| 9 |
+
(the writer in lerobot#3471 enforces exact match)
|
| 10 |
+
- exporting the annotated dataset by rewriting ``data/chunk-*/file-*.parquet``
|
| 11 |
+
with two new columns:
|
| 12 |
+
* ``language_persistent`` — broadcast per-episode (subtask/plan/memory)
|
| 13 |
+
* ``language_events`` — per-frame (interjection/vqa, plus speech
|
| 14 |
+
tool-call atoms with style=None)
|
| 15 |
+
and a dataset-level ``tools`` column carrying the JSON schema for ``say``.
|
| 16 |
+
- pushing the result back to the Hugging Face Hub.
|
| 17 |
+
|
| 18 |
+
The frontend can run without this backend (read-only browsing). Annotation
|
| 19 |
+
write paths only light up when ``NEXT_PUBLIC_ANNOTATE_BACKEND_URL`` points to
|
| 20 |
+
an instance of this service.
|
| 21 |
+
|
| 22 |
+
Run locally:
|
| 23 |
+
|
| 24 |
+
cd backend && pip install -r requirements.txt
|
| 25 |
+
uvicorn app:app --port 7861 --reload
|
| 26 |
+
|
| 27 |
+
Then in another terminal:
|
| 28 |
+
|
| 29 |
+
NEXT_PUBLIC_ANNOTATE_BACKEND_URL=http://127.0.0.1:7861 bun run dev
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import json
|
| 35 |
+
import logging
|
| 36 |
+
import os
|
| 37 |
+
import shutil
|
| 38 |
+
from dataclasses import dataclass, field
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
from typing import Any
|
| 41 |
+
|
| 42 |
+
import pandas as pd
|
| 43 |
+
import pyarrow as pa
|
| 44 |
+
import pyarrow.parquet as pq
|
| 45 |
+
from fastapi import FastAPI, HTTPException
|
| 46 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 47 |
+
from fastapi.responses import JSONResponse
|
| 48 |
+
from huggingface_hub import HfApi, hf_hub_download, snapshot_download
|
| 49 |
+
from pydantic import BaseModel
|
| 50 |
+
|
| 51 |
+
logger = logging.getLogger("lerobot-annotate")
|
| 52 |
+
logging.basicConfig(level=logging.INFO)
|
| 53 |
+
|
| 54 |
+
CACHE_ROOT = Path(os.environ.get("LEROBOT_ANNOTATE_CACHE", "/tmp/lerobot_visualizer_annotate_cache"))
|
| 55 |
+
EXPORT_ROOT = Path(os.environ.get("LEROBOT_ANNOTATE_EXPORT", "/tmp/lerobot_visualizer_annotate_exports"))
|
| 56 |
+
|
| 57 |
+
# --- Schema mirrors src/lerobot/datasets/language.py --------------------------
|
| 58 |
+
|
| 59 |
+
PERSISTENT_STYLES = {"task_aug", "subtask", "plan", "memory"}
|
| 60 |
+
EVENT_ONLY_STYLES = {"interjection", "vqa"}
|
| 61 |
+
KNOWN_STYLES = PERSISTENT_STYLES | EVENT_ONLY_STYLES
|
| 62 |
+
LANGUAGE_PERSISTENT = "language_persistent"
|
| 63 |
+
LANGUAGE_EVENTS = "language_events"
|
| 64 |
+
|
| 65 |
+
SAY_TOOL_SCHEMA: dict[str, Any] = {
|
| 66 |
+
"type": "function",
|
| 67 |
+
"function": {
|
| 68 |
+
"name": "say",
|
| 69 |
+
"description": "Speak a short utterance to the user via the TTS executor.",
|
| 70 |
+
"parameters": {
|
| 71 |
+
"type": "object",
|
| 72 |
+
"properties": {
|
| 73 |
+
"text": {"type": "string", "description": "The verbatim text to speak."},
|
| 74 |
+
},
|
| 75 |
+
"required": ["text"],
|
| 76 |
+
},
|
| 77 |
+
},
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def column_for_style(style: str | None) -> str:
|
| 82 |
+
if style is None:
|
| 83 |
+
return LANGUAGE_EVENTS
|
| 84 |
+
if style in PERSISTENT_STYLES:
|
| 85 |
+
return LANGUAGE_PERSISTENT
|
| 86 |
+
if style in EVENT_ONLY_STYLES:
|
| 87 |
+
return LANGUAGE_EVENTS
|
| 88 |
+
raise ValueError(f"Unknown language style: {style!r}")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# --- Pydantic models ----------------------------------------------------------
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class DatasetRef(BaseModel):
|
| 95 |
+
repo_id: str | None = None
|
| 96 |
+
revision: str | None = None
|
| 97 |
+
local_path: str | None = None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class LoadRequest(DatasetRef):
|
| 101 |
+
pass
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class LanguageAtom(BaseModel):
|
| 105 |
+
role: str
|
| 106 |
+
content: str | None = None
|
| 107 |
+
style: str | None = None
|
| 108 |
+
timestamp: float
|
| 109 |
+
# ``observation.images.*`` feature key for view-dependent atoms
|
| 110 |
+
# (vqa / trace). ``None`` for camera-agnostic atoms. Mirrors the
|
| 111 |
+
# row-level ``camera`` field added in lerobot PR 3467.
|
| 112 |
+
camera: str | None = None
|
| 113 |
+
tool_calls: list[dict[str, Any]] | None = None
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class EpisodeAtomsPayload(BaseModel):
|
| 117 |
+
repo_id: str | None = None
|
| 118 |
+
local_path: str | None = None
|
| 119 |
+
episode_index: int
|
| 120 |
+
atoms: list[LanguageAtom] = []
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class ExportRequest(DatasetRef):
|
| 124 |
+
output_dir: str | None = None
|
| 125 |
+
copy_videos: bool = False
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class PushToHubRequest(DatasetRef):
|
| 129 |
+
hf_token: str
|
| 130 |
+
push_in_place: bool = True
|
| 131 |
+
new_repo_id: str | None = None
|
| 132 |
+
private: bool = False
|
| 133 |
+
commit_message: str = "Add language annotations"
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@dataclass
|
| 137 |
+
class EpisodeAnnotations:
|
| 138 |
+
atoms: list[dict[str, Any]] = field(default_factory=list)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# --- Per-dataset state cache --------------------------------------------------
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@dataclass
|
| 145 |
+
class DatasetState:
|
| 146 |
+
repo_id: str | None
|
| 147 |
+
local_path: str | None
|
| 148 |
+
revision: str | None
|
| 149 |
+
root: Path
|
| 150 |
+
info: dict[str, Any]
|
| 151 |
+
episodes_df: pd.DataFrame
|
| 152 |
+
annotations: dict[int, EpisodeAnnotations] = field(default_factory=dict)
|
| 153 |
+
frame_ts_cache: dict[int, list[float]] = field(default_factory=dict)
|
| 154 |
+
|
| 155 |
+
@property
|
| 156 |
+
def annotations_path(self) -> Path:
|
| 157 |
+
return self.root / "meta" / "lerobot_annotations.json"
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
_states: dict[str, DatasetState] = {}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _state_key(req: DatasetRef) -> str:
|
| 164 |
+
if req.local_path:
|
| 165 |
+
return f"local::{Path(req.local_path).expanduser().resolve()}"
|
| 166 |
+
if req.repo_id:
|
| 167 |
+
return f"hf::{req.repo_id}@{req.revision or 'main'}"
|
| 168 |
+
raise HTTPException(status_code=400, detail="need repo_id or local_path")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _ensure_state(req: DatasetRef) -> DatasetState:
|
| 172 |
+
key = _state_key(req)
|
| 173 |
+
if key in _states:
|
| 174 |
+
return _states[key]
|
| 175 |
+
return _load_state(req, key)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _load_state(req: DatasetRef, key: str) -> DatasetState:
|
| 179 |
+
if req.local_path:
|
| 180 |
+
root = Path(req.local_path).expanduser().resolve()
|
| 181 |
+
if not root.exists():
|
| 182 |
+
raise HTTPException(status_code=404, detail=f"Dataset path not found: {root}")
|
| 183 |
+
elif req.repo_id:
|
| 184 |
+
CACHE_ROOT.mkdir(parents=True, exist_ok=True)
|
| 185 |
+
slug = req.repo_id.replace("/", "__") + (f"@{req.revision}" if req.revision else "")
|
| 186 |
+
root = CACHE_ROOT / slug
|
| 187 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 188 |
+
snapshot_download(
|
| 189 |
+
req.repo_id,
|
| 190 |
+
repo_type="dataset",
|
| 191 |
+
revision=req.revision,
|
| 192 |
+
local_dir=root,
|
| 193 |
+
allow_patterns=["meta/*"],
|
| 194 |
+
)
|
| 195 |
+
else:
|
| 196 |
+
raise HTTPException(status_code=400, detail="need repo_id or local_path")
|
| 197 |
+
|
| 198 |
+
info_path = root / "meta" / "info.json"
|
| 199 |
+
if not info_path.exists():
|
| 200 |
+
raise HTTPException(status_code=404, detail=f"Missing meta/info.json at {root}")
|
| 201 |
+
info = json.loads(info_path.read_text())
|
| 202 |
+
|
| 203 |
+
episodes_root = root / "meta" / "episodes"
|
| 204 |
+
if not episodes_root.exists():
|
| 205 |
+
raise HTTPException(status_code=404, detail="Missing meta/episodes/ directory")
|
| 206 |
+
files = sorted(episodes_root.rglob("*.parquet"))
|
| 207 |
+
if not files:
|
| 208 |
+
raise HTTPException(status_code=404, detail="No episodes parquet files found")
|
| 209 |
+
episodes_df = (
|
| 210 |
+
pd.concat([pd.read_parquet(p) for p in files], ignore_index=True)
|
| 211 |
+
.sort_values("episode_index")
|
| 212 |
+
.reset_index(drop=True)
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
state = DatasetState(
|
| 216 |
+
repo_id=req.repo_id,
|
| 217 |
+
local_path=str(root) if req.local_path else None,
|
| 218 |
+
revision=req.revision,
|
| 219 |
+
root=root,
|
| 220 |
+
info=info,
|
| 221 |
+
episodes_df=episodes_df,
|
| 222 |
+
)
|
| 223 |
+
_load_existing_annotations(state)
|
| 224 |
+
_states[key] = state
|
| 225 |
+
return state
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _load_existing_annotations(state: DatasetState) -> None:
|
| 229 |
+
path = state.annotations_path
|
| 230 |
+
if not path.exists():
|
| 231 |
+
return
|
| 232 |
+
data = json.loads(path.read_text())
|
| 233 |
+
for ep_str, payload in data.get("episodes", {}).items():
|
| 234 |
+
ep_idx = int(ep_str)
|
| 235 |
+
atoms = payload.get("atoms")
|
| 236 |
+
if atoms is None:
|
| 237 |
+
# v1 format from older lerobot-annotate (legacy)
|
| 238 |
+
atoms = []
|
| 239 |
+
for seg in payload.get("subtasks", []):
|
| 240 |
+
if "label" in seg and "start" in seg:
|
| 241 |
+
atoms.append(
|
| 242 |
+
{
|
| 243 |
+
"role": "assistant",
|
| 244 |
+
"content": str(seg["label"]),
|
| 245 |
+
"style": "subtask",
|
| 246 |
+
"timestamp": float(seg["start"]),
|
| 247 |
+
"tool_calls": None,
|
| 248 |
+
}
|
| 249 |
+
)
|
| 250 |
+
for seg in payload.get("high_levels", []):
|
| 251 |
+
ts = float(seg.get("start", 0.0))
|
| 252 |
+
if seg.get("user_prompt"):
|
| 253 |
+
atoms.append(
|
| 254 |
+
{
|
| 255 |
+
"role": "user",
|
| 256 |
+
"content": str(seg["user_prompt"]),
|
| 257 |
+
"style": "interjection",
|
| 258 |
+
"timestamp": ts,
|
| 259 |
+
"tool_calls": None,
|
| 260 |
+
}
|
| 261 |
+
)
|
| 262 |
+
if seg.get("robot_utterance"):
|
| 263 |
+
atoms.append(
|
| 264 |
+
{
|
| 265 |
+
"role": "assistant",
|
| 266 |
+
"content": None,
|
| 267 |
+
"style": None,
|
| 268 |
+
"timestamp": ts,
|
| 269 |
+
"tool_calls": [
|
| 270 |
+
{
|
| 271 |
+
"type": "function",
|
| 272 |
+
"function": {
|
| 273 |
+
"name": "say",
|
| 274 |
+
"arguments": {"text": str(seg["robot_utterance"])},
|
| 275 |
+
},
|
| 276 |
+
}
|
| 277 |
+
],
|
| 278 |
+
}
|
| 279 |
+
)
|
| 280 |
+
state.annotations[ep_idx] = EpisodeAnnotations(atoms=[dict(a) for a in atoms])
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _save_annotations(state: DatasetState) -> None:
|
| 284 |
+
path = state.annotations_path
|
| 285 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 286 |
+
payload = {
|
| 287 |
+
"version": 2,
|
| 288 |
+
"schema": {
|
| 289 |
+
"persistent_styles": sorted(PERSISTENT_STYLES),
|
| 290 |
+
"event_styles": sorted(EVENT_ONLY_STYLES),
|
| 291 |
+
},
|
| 292 |
+
"episodes": {str(ep): {"atoms": ann.atoms} for ep, ann in state.annotations.items()},
|
| 293 |
+
}
|
| 294 |
+
path.write_text(json.dumps(payload, indent=2))
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
# --- Frame-timestamp helpers --------------------------------------------------
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def _episode_data_path(state: DatasetState, episode_index: int) -> Path | None:
|
| 301 |
+
rows = state.episodes_df[state.episodes_df["episode_index"] == episode_index]
|
| 302 |
+
if rows.empty:
|
| 303 |
+
return None
|
| 304 |
+
row = rows.iloc[0]
|
| 305 |
+
chunk_col = "data/chunk_index"
|
| 306 |
+
file_col = "data/file_index"
|
| 307 |
+
if chunk_col not in row or file_col not in row:
|
| 308 |
+
return None
|
| 309 |
+
chunk_index = int(row[chunk_col])
|
| 310 |
+
file_index = int(row[file_col])
|
| 311 |
+
rel = state.info.get("data_path") or "data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet"
|
| 312 |
+
rel = rel.format(chunk_index=chunk_index, file_index=file_index)
|
| 313 |
+
full = (state.root / rel).resolve()
|
| 314 |
+
if full.exists():
|
| 315 |
+
return full
|
| 316 |
+
if state.repo_id:
|
| 317 |
+
try:
|
| 318 |
+
hf_hub_download(
|
| 319 |
+
repo_id=state.repo_id,
|
| 320 |
+
repo_type="dataset",
|
| 321 |
+
filename=rel,
|
| 322 |
+
revision=state.revision,
|
| 323 |
+
local_dir=state.root,
|
| 324 |
+
)
|
| 325 |
+
except Exception as e: # noqa: BLE001
|
| 326 |
+
logger.warning("frame_ts download failed for ep %s: %s", episode_index, e)
|
| 327 |
+
return None
|
| 328 |
+
return full if full.exists() else None
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def _frame_timestamps(state: DatasetState, episode_index: int) -> list[float]:
|
| 332 |
+
if episode_index in state.frame_ts_cache:
|
| 333 |
+
return state.frame_ts_cache[episode_index]
|
| 334 |
+
path = _episode_data_path(state, episode_index)
|
| 335 |
+
if path is None:
|
| 336 |
+
return []
|
| 337 |
+
try:
|
| 338 |
+
df = pd.read_parquet(path, columns=["episode_index", "timestamp"])
|
| 339 |
+
except Exception as e: # noqa: BLE001
|
| 340 |
+
logger.warning("frame_ts read failed for ep %s: %s", episode_index, e)
|
| 341 |
+
return []
|
| 342 |
+
ts = df.loc[df["episode_index"] == episode_index, "timestamp"].astype(float).tolist()
|
| 343 |
+
ts.sort()
|
| 344 |
+
state.frame_ts_cache[episode_index] = ts
|
| 345 |
+
return ts
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def _coerce_existing_atom(
|
| 349 |
+
raw: Any, fallback_ts: float | None = None
|
| 350 |
+
) -> dict[str, Any] | None:
|
| 351 |
+
if raw is None:
|
| 352 |
+
return None
|
| 353 |
+
if not isinstance(raw, dict):
|
| 354 |
+
try:
|
| 355 |
+
raw = dict(raw)
|
| 356 |
+
except Exception: # noqa: BLE001
|
| 357 |
+
return None
|
| 358 |
+
if not raw.get("role"):
|
| 359 |
+
return None
|
| 360 |
+
tool_calls = raw.get("tool_calls")
|
| 361 |
+
if tool_calls is not None and not isinstance(tool_calls, list):
|
| 362 |
+
tool_calls = [tool_calls]
|
| 363 |
+
camera = raw.get("camera")
|
| 364 |
+
if isinstance(camera, str) and not camera:
|
| 365 |
+
camera = None
|
| 366 |
+
raw_ts = raw.get("timestamp")
|
| 367 |
+
if raw_ts is None:
|
| 368 |
+
# v3.1 event rows don't carry a ``timestamp`` field in the struct —
|
| 369 |
+
# the writer drops it because the parquet row's frame timestamp is
|
| 370 |
+
# already the event's firing time. Use the caller-provided fallback
|
| 371 |
+
# so dedup doesn't collapse every event atom into one (timestamp=0.0)
|
| 372 |
+
# entry.
|
| 373 |
+
timestamp = float(fallback_ts) if fallback_ts is not None else 0.0
|
| 374 |
+
else:
|
| 375 |
+
timestamp = float(raw_ts)
|
| 376 |
+
return {
|
| 377 |
+
"role": str(raw["role"]),
|
| 378 |
+
"content": None if raw.get("content") is None else str(raw.get("content")),
|
| 379 |
+
"style": raw.get("style"),
|
| 380 |
+
"timestamp": timestamp,
|
| 381 |
+
"camera": camera if isinstance(camera, str) else None,
|
| 382 |
+
"tool_calls": tool_calls or None,
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def _extract_existing_atoms_from_table(table: pa.Table, episode_index: int) -> list[dict[str, Any]]:
|
| 387 |
+
if "episode_index" not in table.column_names:
|
| 388 |
+
return []
|
| 389 |
+
|
| 390 |
+
episode_col = table.column("episode_index").to_pylist()
|
| 391 |
+
persistent_col = (
|
| 392 |
+
table.column(LANGUAGE_PERSISTENT).to_pylist()
|
| 393 |
+
if LANGUAGE_PERSISTENT in table.column_names
|
| 394 |
+
else None
|
| 395 |
+
)
|
| 396 |
+
events_col = (
|
| 397 |
+
table.column(LANGUAGE_EVENTS).to_pylist()
|
| 398 |
+
if LANGUAGE_EVENTS in table.column_names
|
| 399 |
+
else None
|
| 400 |
+
)
|
| 401 |
+
# Event rows don't carry their own ``timestamp`` in the v3.1 struct;
|
| 402 |
+
# the parquet row's frame timestamp IS the event's firing time. Read
|
| 403 |
+
# the timestamp column so we can pass it as a fallback to
|
| 404 |
+
# ``_coerce_existing_atom`` — without this, every event row defaults
|
| 405 |
+
# to timestamp=0.0 and dedup collapses them all into one.
|
| 406 |
+
ts_col = (
|
| 407 |
+
table.column("timestamp").to_pylist()
|
| 408 |
+
if "timestamp" in table.column_names
|
| 409 |
+
else None
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
atoms: list[dict[str, Any]] = []
|
| 413 |
+
seen: set[str] = set()
|
| 414 |
+
persistent_loaded = False
|
| 415 |
+
|
| 416 |
+
def add_many(raw_atoms: Any, fallback_ts: float | None = None) -> None:
|
| 417 |
+
if not raw_atoms:
|
| 418 |
+
return
|
| 419 |
+
for raw in raw_atoms:
|
| 420 |
+
atom = _coerce_existing_atom(raw, fallback_ts=fallback_ts)
|
| 421 |
+
if atom is None:
|
| 422 |
+
continue
|
| 423 |
+
key = json.dumps(atom, sort_keys=True, default=str)
|
| 424 |
+
if key in seen:
|
| 425 |
+
continue
|
| 426 |
+
seen.add(key)
|
| 427 |
+
atoms.append(atom)
|
| 428 |
+
|
| 429 |
+
for row_idx, ep_value in enumerate(episode_col):
|
| 430 |
+
if int(ep_value) != int(episode_index):
|
| 431 |
+
continue
|
| 432 |
+
if persistent_col is not None and not persistent_loaded:
|
| 433 |
+
add_many(persistent_col[row_idx])
|
| 434 |
+
persistent_loaded = True
|
| 435 |
+
if events_col is not None:
|
| 436 |
+
row_ts = float(ts_col[row_idx]) if ts_col is not None else None
|
| 437 |
+
add_many(events_col[row_idx], fallback_ts=row_ts)
|
| 438 |
+
|
| 439 |
+
atoms.sort(key=lambda a: (a["timestamp"], a.get("style") or "", a.get("role") or ""))
|
| 440 |
+
return atoms
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def _snap(ts: float, frame_ts: list[float]) -> float:
|
| 444 |
+
if not frame_ts:
|
| 445 |
+
return float(ts)
|
| 446 |
+
return float(min(frame_ts, key=lambda f: abs(f - ts)))
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
VIEW_DEPENDENT_STYLES = {"vqa", "trace"}
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def _validate_atom(atom: dict[str, Any]) -> None:
|
| 453 |
+
style = atom.get("style")
|
| 454 |
+
if style is not None and style not in KNOWN_STYLES:
|
| 455 |
+
raise HTTPException(status_code=400, detail=f"Unknown language style: {style!r}")
|
| 456 |
+
has_content = atom.get("content") is not None
|
| 457 |
+
has_tools = bool(atom.get("tool_calls"))
|
| 458 |
+
if not (has_content or has_tools):
|
| 459 |
+
raise HTTPException(status_code=400, detail="atom must have content or tool_calls")
|
| 460 |
+
if style is None and not has_tools:
|
| 461 |
+
raise HTTPException(status_code=400, detail="style=None requires tool_calls (speech atom)")
|
| 462 |
+
camera = atom.get("camera")
|
| 463 |
+
if camera is not None and not isinstance(camera, str):
|
| 464 |
+
raise HTTPException(status_code=400, detail="camera must be a string or null")
|
| 465 |
+
# Mirror lerobot's row-level invariant: camera is set iff the style is
|
| 466 |
+
# view-dependent. We don't enforce camera-required here because the
|
| 467 |
+
# visualizer accepts in-progress edits where the user hasn't picked a
|
| 468 |
+
# camera yet — the writer (or the next save round-trip) will surface
|
| 469 |
+
# the missing tag. We DO reject camera-on-non-view-dependent so the
|
| 470 |
+
# field can't drift onto task_aug/subtask/plan/memory rows.
|
| 471 |
+
if (
|
| 472 |
+
camera is not None
|
| 473 |
+
and style is not None
|
| 474 |
+
and style not in VIEW_DEPENDENT_STYLES
|
| 475 |
+
):
|
| 476 |
+
raise HTTPException(
|
| 477 |
+
status_code=400,
|
| 478 |
+
detail=f"camera must be null for style={style!r} (only vqa/trace are view-dependent)",
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def _normalize_atom(atom: dict[str, Any], *, with_timestamp: bool) -> dict[str, Any]:
|
| 483 |
+
"""Coerce an atom into a language-column struct row.
|
| 484 |
+
|
| 485 |
+
Field order matches the canonical schema in ``lerobot.datasets.language``
|
| 486 |
+
(``PERSISTENT_ROW_FIELDS`` / ``EVENT_ROW_FIELDS``); pyarrow infers the
|
| 487 |
+
struct schema from insertion order. Persistent rows carry their own
|
| 488 |
+
``timestamp`` (the moment the state became active); event rows do NOT —
|
| 489 |
+
the parquet frame's ``timestamp`` column IS the event's firing time, so a
|
| 490 |
+
per-row ``timestamp`` field would be redundant (matches lerobot#3471's
|
| 491 |
+
``language_event_row_arrow_type``, which omits it).
|
| 492 |
+
"""
|
| 493 |
+
camera = atom.get("camera")
|
| 494 |
+
if isinstance(camera, str) and not camera:
|
| 495 |
+
camera = None
|
| 496 |
+
row: dict[str, Any] = {
|
| 497 |
+
"role": str(atom["role"]),
|
| 498 |
+
"content": None if atom.get("content") is None else str(atom["content"]),
|
| 499 |
+
"style": atom.get("style"),
|
| 500 |
+
}
|
| 501 |
+
if with_timestamp:
|
| 502 |
+
row["timestamp"] = float(atom.get("timestamp", 0.0))
|
| 503 |
+
row["camera"] = camera if isinstance(camera, str) else None
|
| 504 |
+
row["tool_calls"] = list(atom["tool_calls"]) if atom.get("tool_calls") else None
|
| 505 |
+
return row
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
# --- Export -------------------------------------------------------------------
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
def _materialize_table(table: pa.Table, atoms_by_ep: dict[int, list[dict[str, Any]]]) -> tuple[pa.Table, int, int]:
|
| 512 |
+
if "episode_index" not in table.column_names or "timestamp" not in table.column_names:
|
| 513 |
+
raise HTTPException(
|
| 514 |
+
status_code=400,
|
| 515 |
+
detail="data parquet missing 'episode_index' or 'timestamp' columns",
|
| 516 |
+
)
|
| 517 |
+
|
| 518 |
+
episode_col = table.column("episode_index").to_pylist()
|
| 519 |
+
ts_col = [float(x) for x in table.column("timestamp").to_pylist()]
|
| 520 |
+
n_rows = table.num_rows
|
| 521 |
+
|
| 522 |
+
persistent_by_ep: dict[int, list[dict[str, Any]]] = {}
|
| 523 |
+
events_by_ep_ts: dict[int, dict[float, list[dict[str, Any]]]] = {}
|
| 524 |
+
|
| 525 |
+
n_persistent_total = 0
|
| 526 |
+
n_event_total = 0
|
| 527 |
+
|
| 528 |
+
unique_eps = sorted(set(episode_col))
|
| 529 |
+
for ep_idx in unique_eps:
|
| 530 |
+
atoms = atoms_by_ep.get(int(ep_idx))
|
| 531 |
+
if atoms is None:
|
| 532 |
+
atoms = _extract_existing_atoms_from_table(table, int(ep_idx))
|
| 533 |
+
persistent_rows: list[dict[str, Any]] = []
|
| 534 |
+
frame_ts = sorted({ts_col[i] for i in range(n_rows) if episode_col[i] == ep_idx})
|
| 535 |
+
|
| 536 |
+
buckets: dict[float, list[dict[str, Any]]] = {}
|
| 537 |
+
for atom in atoms:
|
| 538 |
+
col = column_for_style(atom.get("style"))
|
| 539 |
+
if col == LANGUAGE_PERSISTENT:
|
| 540 |
+
persistent_rows.append(_normalize_atom(atom, with_timestamp=True))
|
| 541 |
+
else:
|
| 542 |
+
# The event row's firing time lives in the parquet frame's
|
| 543 |
+
# ``timestamp`` column, so we bucket by the snapped timestamp
|
| 544 |
+
# but do NOT store it inside the event struct (matches the
|
| 545 |
+
# lerobot#3471 writer / canonical schema).
|
| 546 |
+
ts = float(atom.get("timestamp", 0.0))
|
| 547 |
+
if frame_ts:
|
| 548 |
+
ts = _snap(ts, frame_ts)
|
| 549 |
+
buckets.setdefault(ts, []).append(_normalize_atom(atom, with_timestamp=False))
|
| 550 |
+
|
| 551 |
+
persistent_rows.sort(
|
| 552 |
+
key=lambda r: (r["timestamp"], r.get("style") or "", r.get("role") or "")
|
| 553 |
+
)
|
| 554 |
+
persistent_by_ep[ep_idx] = persistent_rows
|
| 555 |
+
|
| 556 |
+
for ts in buckets:
|
| 557 |
+
buckets[ts].sort(key=lambda r: (r.get("style") or "", r.get("role") or ""))
|
| 558 |
+
events_by_ep_ts[ep_idx] = buckets
|
| 559 |
+
|
| 560 |
+
n_persistent_total += len(persistent_rows)
|
| 561 |
+
n_event_total += sum(len(v) for v in buckets.values())
|
| 562 |
+
|
| 563 |
+
per_row_persistent = [persistent_by_ep.get(episode_col[i], []) for i in range(n_rows)]
|
| 564 |
+
per_row_events = [
|
| 565 |
+
events_by_ep_ts.get(episode_col[i], {}).get(ts_col[i], []) for i in range(n_rows)
|
| 566 |
+
]
|
| 567 |
+
|
| 568 |
+
keep_names: list[str] = []
|
| 569 |
+
keep_cols: list[Any] = []
|
| 570 |
+
for name in table.column_names:
|
| 571 |
+
if name == "subtask_index":
|
| 572 |
+
continue
|
| 573 |
+
if name in {LANGUAGE_PERSISTENT, LANGUAGE_EVENTS, "tools"}:
|
| 574 |
+
continue
|
| 575 |
+
keep_names.append(name)
|
| 576 |
+
keep_cols.append(table.column(name))
|
| 577 |
+
|
| 578 |
+
persistent_arr = pa.array(per_row_persistent)
|
| 579 |
+
events_arr = pa.array(per_row_events)
|
| 580 |
+
|
| 581 |
+
# NOTE: we deliberately do NOT add a per-row ``tools`` column. The ``say``
|
| 582 |
+
# tool *schema* is dataset-level metadata and lives in
|
| 583 |
+
# ``meta/info.json["tools"]`` (written in ``_do_export``), exactly as the
|
| 584 |
+
# lerobot#3471 pipeline does. Tool *calls* travel per-row inside the
|
| 585 |
+
# ``tool_calls`` field of the language structs. Any pre-existing ``tools``
|
| 586 |
+
# column is stripped in the keep-loop above.
|
| 587 |
+
new_names = keep_names + [LANGUAGE_PERSISTENT, LANGUAGE_EVENTS]
|
| 588 |
+
new_cols = keep_cols + [persistent_arr, events_arr]
|
| 589 |
+
return pa.Table.from_arrays(new_cols, names=new_names), n_persistent_total, n_event_total
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
def _materialize_tree(src: Path, dst: Path, *, force_copy: bool) -> None:
|
| 593 |
+
"""Recreate ``src`` under ``dst`` as real files (no symlinks).
|
| 594 |
+
|
| 595 |
+
Hardlinks each file when ``force_copy`` is False and the source/target sit
|
| 596 |
+
on the same filesystem (cheap, self-contained, uploadable); otherwise
|
| 597 |
+
falls back to a byte copy. The result is always a standalone tree that
|
| 598 |
+
survives being moved and is uploaded verbatim by ``upload_folder``.
|
| 599 |
+
"""
|
| 600 |
+
|
| 601 |
+
def _copy_file(s: str, d: str) -> None:
|
| 602 |
+
if not force_copy:
|
| 603 |
+
try:
|
| 604 |
+
os.link(s, d)
|
| 605 |
+
return
|
| 606 |
+
except OSError:
|
| 607 |
+
pass
|
| 608 |
+
shutil.copy2(s, d)
|
| 609 |
+
|
| 610 |
+
shutil.copytree(src, dst, copy_function=_copy_file)
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def _do_export(state: DatasetState, output_dir: str | None, copy_videos: bool) -> dict[str, Any]:
|
| 614 |
+
if output_dir:
|
| 615 |
+
out_root = Path(output_dir).expanduser().resolve()
|
| 616 |
+
else:
|
| 617 |
+
EXPORT_ROOT.mkdir(parents=True, exist_ok=True)
|
| 618 |
+
name = (state.repo_id or Path(state.root).name or "dataset").replace("/", "__")
|
| 619 |
+
out_root = EXPORT_ROOT / f"{name}_annotated"
|
| 620 |
+
|
| 621 |
+
out_root.mkdir(parents=True, exist_ok=True)
|
| 622 |
+
|
| 623 |
+
# Copy meta/
|
| 624 |
+
src_meta = state.root / "meta"
|
| 625 |
+
dst_meta = out_root / "meta"
|
| 626 |
+
if dst_meta.exists():
|
| 627 |
+
shutil.rmtree(dst_meta)
|
| 628 |
+
shutil.copytree(src_meta, dst_meta)
|
| 629 |
+
|
| 630 |
+
info_path = dst_meta / "info.json"
|
| 631 |
+
info = json.loads(info_path.read_text())
|
| 632 |
+
info.setdefault("features", {})
|
| 633 |
+
info["features"].pop("subtask_index", None)
|
| 634 |
+
info["features"][LANGUAGE_PERSISTENT] = {"dtype": "language", "shape": [1], "names": None}
|
| 635 |
+
info["features"][LANGUAGE_EVENTS] = {"dtype": "language", "shape": [1], "names": None}
|
| 636 |
+
# The ``say`` tool schema is dataset-level metadata, stored at the top of
|
| 637 |
+
# info.json under "tools" (NOT as a per-frame feature). Mirrors the
|
| 638 |
+
# lerobot#3471 pipeline's ``_ensure_annotation_metadata_in_info``: merge
|
| 639 |
+
# additively so any user-declared tools are preserved, and stop emitting
|
| 640 |
+
# the stray ``tools`` feature older exports added.
|
| 641 |
+
info["features"].pop("tools", None)
|
| 642 |
+
existing_tools = info.get("tools") or []
|
| 643 |
+
tool_names = {
|
| 644 |
+
(t.get("function") or {}).get("name") for t in existing_tools if isinstance(t, dict)
|
| 645 |
+
}
|
| 646 |
+
if SAY_TOOL_SCHEMA["function"]["name"] not in tool_names:
|
| 647 |
+
info["tools"] = [*existing_tools, SAY_TOOL_SCHEMA]
|
| 648 |
+
info_path.write_text(json.dumps(info, indent=2))
|
| 649 |
+
|
| 650 |
+
# Drop legacy meta files if present
|
| 651 |
+
for legacy in ("subtasks.parquet", "tasks_high_level.parquet"):
|
| 652 |
+
p = dst_meta / legacy
|
| 653 |
+
if p.exists():
|
| 654 |
+
p.unlink()
|
| 655 |
+
|
| 656 |
+
# Make sure data AND videos are downloaded for HF datasets. The export
|
| 657 |
+
# must be a self-contained, loadable dataset (the writer only rewrites
|
| 658 |
+
# the parquet shards; videos are carried over untouched), so we pull the
|
| 659 |
+
# video shards too — otherwise the exported folder is missing the
|
| 660 |
+
# observation videos and won't load. Mirrors the lerobot#3471 pipeline,
|
| 661 |
+
# which annotates a full local snapshot in place.
|
| 662 |
+
data_dir = state.root / "data"
|
| 663 |
+
data_files = sorted(data_dir.rglob("*.parquet"))
|
| 664 |
+
if not data_files and state.repo_id:
|
| 665 |
+
snapshot_download(
|
| 666 |
+
state.repo_id,
|
| 667 |
+
repo_type="dataset",
|
| 668 |
+
revision=state.revision,
|
| 669 |
+
local_dir=state.root,
|
| 670 |
+
allow_patterns=["data/**/*.parquet", "videos/**"],
|
| 671 |
+
)
|
| 672 |
+
data_files = sorted(data_dir.rglob("*.parquet"))
|
| 673 |
+
if not data_files:
|
| 674 |
+
raise HTTPException(status_code=404, detail="No data parquet files found")
|
| 675 |
+
|
| 676 |
+
atoms_by_ep = {ep: ann.atoms for ep, ann in state.annotations.items()}
|
| 677 |
+
|
| 678 |
+
n_persistent = 0
|
| 679 |
+
n_events = 0
|
| 680 |
+
for src_path in data_files:
|
| 681 |
+
rel_path = src_path.relative_to(state.root)
|
| 682 |
+
dst_path = out_root / rel_path
|
| 683 |
+
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
| 684 |
+
table = pq.read_table(src_path)
|
| 685 |
+
new_table, np_n, ne_n = _materialize_table(table, atoms_by_ep)
|
| 686 |
+
n_persistent += np_n
|
| 687 |
+
n_events += ne_n
|
| 688 |
+
pq.write_table(new_table, dst_path)
|
| 689 |
+
|
| 690 |
+
# Carry over the video shards so the export is self-contained. We
|
| 691 |
+
# materialize *real* files (hardlink where the filesystem allows it, else
|
| 692 |
+
# copy) rather than symlinking the source tree: a symlinked ``videos/``
|
| 693 |
+
# breaks as soon as the folder is moved and is not uploaded by
|
| 694 |
+
# ``HfApi.upload_folder``, which is exactly the "downloaded dataset isn't
|
| 695 |
+
# usable" problem. ``copy_videos=True`` forces a full byte copy (used by
|
| 696 |
+
# the push-to-hub path, where the upload reads the bytes anyway).
|
| 697 |
+
src_videos = state.root / "videos"
|
| 698 |
+
dst_videos = out_root / "videos"
|
| 699 |
+
if src_videos.exists():
|
| 700 |
+
if dst_videos.exists() or dst_videos.is_symlink():
|
| 701 |
+
if dst_videos.is_symlink():
|
| 702 |
+
dst_videos.unlink()
|
| 703 |
+
else:
|
| 704 |
+
shutil.rmtree(dst_videos)
|
| 705 |
+
_materialize_tree(src_videos, dst_videos, force_copy=copy_videos)
|
| 706 |
+
|
| 707 |
+
return {"output_dir": str(out_root), "persistent_rows": n_persistent, "event_rows": n_events}
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
# --- FastAPI app --------------------------------------------------------------
|
| 711 |
+
|
| 712 |
+
app = FastAPI(title="LeRobot dataset visualizer — annotation backend")
|
| 713 |
+
app.add_middleware(
|
| 714 |
+
CORSMiddleware,
|
| 715 |
+
allow_origins=["*"],
|
| 716 |
+
allow_methods=["*"],
|
| 717 |
+
allow_headers=["*"],
|
| 718 |
+
)
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
@app.get("/api/health")
|
| 722 |
+
def health() -> JSONResponse:
|
| 723 |
+
return JSONResponse(
|
| 724 |
+
{
|
| 725 |
+
"ok": True,
|
| 726 |
+
"service": "lerobot-visualizer-annotate",
|
| 727 |
+
"persistent_styles": sorted(PERSISTENT_STYLES),
|
| 728 |
+
"event_styles": sorted(EVENT_ONLY_STYLES),
|
| 729 |
+
}
|
| 730 |
+
)
|
| 731 |
+
|
| 732 |
+
|
| 733 |
+
@app.post("/api/dataset/load")
|
| 734 |
+
def load_dataset(req: LoadRequest) -> JSONResponse:
|
| 735 |
+
state = _ensure_state(req)
|
| 736 |
+
return JSONResponse(
|
| 737 |
+
{
|
| 738 |
+
"repo_id": state.repo_id,
|
| 739 |
+
"local_path": state.local_path,
|
| 740 |
+
"revision": state.revision,
|
| 741 |
+
"root": str(state.root),
|
| 742 |
+
"fps": float(state.info.get("fps", 30)),
|
| 743 |
+
"num_episodes": int(state.episodes_df["episode_index"].nunique()),
|
| 744 |
+
"persistent_styles": sorted(PERSISTENT_STYLES),
|
| 745 |
+
"event_styles": sorted(EVENT_ONLY_STYLES),
|
| 746 |
+
}
|
| 747 |
+
)
|
| 748 |
+
|
| 749 |
+
|
| 750 |
+
@app.get("/api/episodes/{episode_index}/atoms")
|
| 751 |
+
def get_episode_atoms(
|
| 752 |
+
episode_index: int,
|
| 753 |
+
repo_id: str | None = None,
|
| 754 |
+
revision: str | None = None,
|
| 755 |
+
local_path: str | None = None,
|
| 756 |
+
) -> JSONResponse:
|
| 757 |
+
state = _ensure_state(DatasetRef(repo_id=repo_id, revision=revision, local_path=local_path))
|
| 758 |
+
ann = state.annotations.get(episode_index)
|
| 759 |
+
if ann is None:
|
| 760 |
+
path = _episode_data_path(state, episode_index)
|
| 761 |
+
atoms: list[dict[str, Any]] = []
|
| 762 |
+
if path is not None:
|
| 763 |
+
try:
|
| 764 |
+
schema = pq.read_schema(path)
|
| 765 |
+
columns = ["episode_index"]
|
| 766 |
+
# Always pull the row timestamp — needed as a fallback for
|
| 767 |
+
# event rows whose v3.1 struct intentionally omits it.
|
| 768 |
+
if "timestamp" in schema.names:
|
| 769 |
+
columns.append("timestamp")
|
| 770 |
+
if LANGUAGE_PERSISTENT in schema.names:
|
| 771 |
+
columns.append(LANGUAGE_PERSISTENT)
|
| 772 |
+
if LANGUAGE_EVENTS in schema.names:
|
| 773 |
+
columns.append(LANGUAGE_EVENTS)
|
| 774 |
+
if (
|
| 775 |
+
LANGUAGE_PERSISTENT in columns
|
| 776 |
+
or LANGUAGE_EVENTS in columns
|
| 777 |
+
):
|
| 778 |
+
atoms = _extract_existing_atoms_from_table(
|
| 779 |
+
pq.read_table(path, columns=columns),
|
| 780 |
+
episode_index,
|
| 781 |
+
)
|
| 782 |
+
except Exception as e: # noqa: BLE001
|
| 783 |
+
logger.warning("language column read failed for ep %s: %s", episode_index, e)
|
| 784 |
+
ann = EpisodeAnnotations(atoms=atoms)
|
| 785 |
+
if atoms:
|
| 786 |
+
state.annotations[episode_index] = ann
|
| 787 |
+
return JSONResponse({"episode_index": episode_index, "atoms": ann.atoms})
|
| 788 |
+
|
| 789 |
+
|
| 790 |
+
@app.post("/api/episodes/{episode_index}/atoms")
|
| 791 |
+
def set_episode_atoms(episode_index: int, payload: EpisodeAtomsPayload) -> JSONResponse:
|
| 792 |
+
if episode_index != payload.episode_index:
|
| 793 |
+
raise HTTPException(status_code=400, detail="episode index mismatch")
|
| 794 |
+
state = _ensure_state(DatasetRef(repo_id=payload.repo_id, local_path=payload.local_path))
|
| 795 |
+
atoms = [a.dict() for a in payload.atoms]
|
| 796 |
+
for atom in atoms:
|
| 797 |
+
_validate_atom(atom)
|
| 798 |
+
# Snap event timestamps to exact frame timestamps (matches lerobot#3471).
|
| 799 |
+
frame_ts = _frame_timestamps(state, episode_index)
|
| 800 |
+
for atom in atoms:
|
| 801 |
+
if column_for_style(atom.get("style")) == LANGUAGE_EVENTS and frame_ts:
|
| 802 |
+
atom["timestamp"] = _snap(float(atom["timestamp"]), frame_ts)
|
| 803 |
+
state.annotations[episode_index] = EpisodeAnnotations(atoms=atoms)
|
| 804 |
+
_save_annotations(state)
|
| 805 |
+
return JSONResponse(
|
| 806 |
+
{"ok": True, "saved": len(atoms), "path": str(state.annotations_path)}
|
| 807 |
+
)
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
@app.get("/api/episodes/{episode_index}/frame_timestamps")
|
| 811 |
+
def episode_frame_timestamps(
|
| 812 |
+
episode_index: int,
|
| 813 |
+
repo_id: str | None = None,
|
| 814 |
+
revision: str | None = None,
|
| 815 |
+
local_path: str | None = None,
|
| 816 |
+
) -> JSONResponse:
|
| 817 |
+
state = _ensure_state(DatasetRef(repo_id=repo_id, revision=revision, local_path=local_path))
|
| 818 |
+
ts = _frame_timestamps(state, episode_index)
|
| 819 |
+
return JSONResponse({"episode_index": episode_index, "timestamps": ts})
|
| 820 |
+
|
| 821 |
+
|
| 822 |
+
@app.post("/api/export")
|
| 823 |
+
def export_dataset(req: ExportRequest) -> JSONResponse:
|
| 824 |
+
state = _ensure_state(req)
|
| 825 |
+
return JSONResponse(_do_export(state, req.output_dir, req.copy_videos))
|
| 826 |
+
|
| 827 |
+
|
| 828 |
+
@app.post("/api/push_to_hub")
|
| 829 |
+
def push_to_hub(req: PushToHubRequest) -> JSONResponse:
|
| 830 |
+
state = _ensure_state(req)
|
| 831 |
+
if not state.repo_id and not req.new_repo_id:
|
| 832 |
+
raise HTTPException(status_code=400, detail="repo_id or new_repo_id required")
|
| 833 |
+
|
| 834 |
+
# Ensure data + videos are present locally before exporting.
|
| 835 |
+
if state.repo_id:
|
| 836 |
+
snapshot_download(
|
| 837 |
+
state.repo_id,
|
| 838 |
+
repo_type="dataset",
|
| 839 |
+
revision=state.revision,
|
| 840 |
+
local_dir=state.root,
|
| 841 |
+
allow_patterns=["data/**/*.parquet", "videos/**/*.mp4"],
|
| 842 |
+
)
|
| 843 |
+
export_result = _do_export(state, output_dir=None, copy_videos=True)
|
| 844 |
+
export_dir = Path(export_result["output_dir"])
|
| 845 |
+
|
| 846 |
+
target_repo = state.repo_id if req.push_in_place else req.new_repo_id
|
| 847 |
+
if not target_repo:
|
| 848 |
+
raise HTTPException(status_code=400, detail="No target repo")
|
| 849 |
+
|
| 850 |
+
api = HfApi(token=req.hf_token)
|
| 851 |
+
if not req.push_in_place:
|
| 852 |
+
api.create_repo(
|
| 853 |
+
repo_id=target_repo,
|
| 854 |
+
repo_type="dataset",
|
| 855 |
+
private=req.private,
|
| 856 |
+
exist_ok=True,
|
| 857 |
+
)
|
| 858 |
+
api.upload_folder(
|
| 859 |
+
folder_path=str(export_dir),
|
| 860 |
+
repo_id=target_repo,
|
| 861 |
+
repo_type="dataset",
|
| 862 |
+
commit_message=req.commit_message,
|
| 863 |
+
)
|
| 864 |
+
return JSONResponse(
|
| 865 |
+
{
|
| 866 |
+
"ok": True,
|
| 867 |
+
"repo_id": target_repo,
|
| 868 |
+
"url": f"https://huggingface.co/datasets/{target_repo}",
|
| 869 |
+
"message": f"Pushed annotated dataset to {target_repo}",
|
| 870 |
+
}
|
| 871 |
+
)
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.110
|
| 2 |
+
uvicorn[standard]>=0.27
|
| 3 |
+
pydantic>=2.5
|
| 4 |
+
pandas>=2.0
|
| 5 |
+
pyarrow>=15
|
| 6 |
+
huggingface_hub>=0.24
|
src/app/[org]/[dataset]/[episode]/episode-viewer.tsx
CHANGED
|
@@ -7,6 +7,12 @@ import { SimpleVideosPlayer } from "@/components/simple-videos-player";
|
|
| 7 |
import PlaybackBar from "@/components/playback-bar";
|
| 8 |
import { TimeProvider, useTime } from "@/context/time-context";
|
| 9 |
import { FlaggedEpisodesProvider } from "@/context/flagged-episodes-context";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
import Sidebar from "@/components/side-nav";
|
| 11 |
import StatsPanel from "@/components/stats-panel";
|
| 12 |
import OverviewPanel from "@/components/overview-panel";
|
|
@@ -39,8 +45,25 @@ const FilteringPanel = lazy(() => import("@/components/filtering-panel"));
|
|
| 39 |
// videos start downloading in parallel with the chart bundle.
|
| 40 |
const DataRecharts = lazy(() => import("@/components/data-recharts"));
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
type ActiveTab =
|
| 43 |
| "episodes"
|
|
|
|
| 44 |
| "statistics"
|
| 45 |
| "frames"
|
| 46 |
| "insights"
|
|
@@ -180,12 +203,35 @@ export default function EpisodeViewer({
|
|
| 180 |
return (
|
| 181 |
<TimeProvider duration={data!.duration}>
|
| 182 |
<FlaggedEpisodesProvider>
|
| 183 |
-
<
|
|
|
|
|
|
|
|
|
|
| 184 |
</FlaggedEpisodesProvider>
|
| 185 |
</TimeProvider>
|
| 186 |
);
|
| 187 |
}
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
function EpisodeViewerInner({
|
| 190 |
data,
|
| 191 |
org,
|
|
@@ -212,8 +258,29 @@ function EpisodeViewerInner({
|
|
| 212 |
const router = useRouter();
|
| 213 |
const searchParams = useSearchParams();
|
| 214 |
|
| 215 |
-
// Tab state & lazy stats
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
const isLoading = activeTab === "episodes" && (!videosReady || !chartsReady);
|
| 218 |
|
| 219 |
useEffect(() => {
|
|
@@ -232,8 +299,16 @@ function EpisodeViewerInner({
|
|
| 232 |
useState<EpisodeFramesData | null>(null);
|
| 233 |
const [framesLoading, setFramesLoading] = useState(false);
|
| 234 |
const framesLoadedRef = useRef(false);
|
| 235 |
-
const [framesFlaggedOnly, setFramesFlaggedOnly] = useState(
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
const [crossEpData, setCrossEpData] =
|
| 238 |
useState<CrossEpisodeVarianceData | null>(null);
|
| 239 |
const [insightsLoading, setInsightsLoading] = useState(false);
|
|
@@ -267,28 +342,6 @@ function EpisodeViewerInner({
|
|
| 267 |
}
|
| 268 |
}, [datasetInfo.robot_type, datasetInfo.codebase_version]);
|
| 269 |
|
| 270 |
-
// Hydrate UI state from sessionStorage after mount (avoids SSR/client mismatch)
|
| 271 |
-
useEffect(() => {
|
| 272 |
-
const stored = sessionStorage.getItem("activeTab");
|
| 273 |
-
if (
|
| 274 |
-
stored &&
|
| 275 |
-
[
|
| 276 |
-
"episodes",
|
| 277 |
-
"statistics",
|
| 278 |
-
"frames",
|
| 279 |
-
"insights",
|
| 280 |
-
"filtering",
|
| 281 |
-
"urdf",
|
| 282 |
-
].includes(stored)
|
| 283 |
-
) {
|
| 284 |
-
setActiveTab(stored as ActiveTab);
|
| 285 |
-
}
|
| 286 |
-
if (sessionStorage.getItem("framesFlaggedOnly") === "true")
|
| 287 |
-
setFramesFlaggedOnly(true);
|
| 288 |
-
if (sessionStorage.getItem("sidebarFlaggedOnly") === "true")
|
| 289 |
-
setSidebarFlaggedOnly(true);
|
| 290 |
-
}, []);
|
| 291 |
-
|
| 292 |
// Persist UI state across episode navigations. One effect instead of
|
| 293 |
// three near-identical writes — fewer commit hooks per render and the
|
| 294 |
// intent (mirror three primitives to sessionStorage) reads as one unit.
|
|
@@ -483,8 +536,10 @@ function EpisodeViewerInner({
|
|
| 483 |
const onKeyDown = (e: KeyboardEvent) => {
|
| 484 |
const { key } = e;
|
| 485 |
const s = keyStateRef.current;
|
|
|
|
| 486 |
|
| 487 |
if (key === " ") {
|
|
|
|
| 488 |
e.preventDefault();
|
| 489 |
if (s.activeTab === "urdf") {
|
| 490 |
urdfPlayToggleRef.current?.();
|
|
@@ -492,6 +547,7 @@ function EpisodeViewerInner({
|
|
| 492 |
setIsPlaying((prev: boolean) => !prev);
|
| 493 |
}
|
| 494 |
} else if (key === "ArrowDown" || key === "ArrowUp") {
|
|
|
|
| 495 |
e.preventDefault();
|
| 496 |
if (s.activeTab === "urdf") {
|
| 497 |
const nextEp =
|
|
@@ -550,6 +606,11 @@ function EpisodeViewerInner({
|
|
| 550 |
{/* Top tab bar */}
|
| 551 |
<div className="flex items-center border-b border-white/5 bg-[var(--surface-0)] shrink-0">
|
| 552 |
{renderTab("episodes", "Episodes")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
{hasURDFSupport(datasetInfo.robot_type) &&
|
| 554 |
datasetInfo.codebase_version >= "v3.0" &&
|
| 555 |
renderTab("urdf", "3D Replay")}
|
|
@@ -570,7 +631,9 @@ function EpisodeViewerInner({
|
|
| 570 |
{/* Body: sidebar + content */}
|
| 571 |
<div className="flex flex-1 min-h-0">
|
| 572 |
{/* Sidebar — on Episodes and 3D Replay tabs */}
|
| 573 |
-
{(activeTab === "episodes" ||
|
|
|
|
|
|
|
| 574 |
<Sidebar
|
| 575 |
datasetInfo={datasetInfo}
|
| 576 |
paginatedEpisodes={paginatedEpisodes}
|
|
@@ -587,7 +650,9 @@ function EpisodeViewerInner({
|
|
| 587 |
setUrdfEpisode(ep);
|
| 588 |
urdfChangerRef.current?.(ep);
|
| 589 |
}
|
| 590 |
-
:
|
|
|
|
|
|
|
| 591 |
}
|
| 592 |
/>
|
| 593 |
)}
|
|
@@ -668,6 +733,45 @@ function EpisodeViewerInner({
|
|
| 668 |
</>
|
| 669 |
)}
|
| 670 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 671 |
{activeTab === "statistics" && (
|
| 672 |
<StatsPanel
|
| 673 |
datasetInfo={datasetInfo}
|
|
|
|
| 7 |
import PlaybackBar from "@/components/playback-bar";
|
| 8 |
import { TimeProvider, useTime } from "@/context/time-context";
|
| 9 |
import { FlaggedEpisodesProvider } from "@/context/flagged-episodes-context";
|
| 10 |
+
import {
|
| 11 |
+
AnnotationsProvider,
|
| 12 |
+
useAnnotations,
|
| 13 |
+
} from "@/context/annotations-context";
|
| 14 |
+
import { AnnotationsPanel } from "@/components/annotations-panel";
|
| 15 |
+
import { AnnotationsTimeline } from "@/components/annotations-timeline";
|
| 16 |
import Sidebar from "@/components/side-nav";
|
| 17 |
import StatsPanel from "@/components/stats-panel";
|
| 18 |
import OverviewPanel from "@/components/overview-panel";
|
|
|
|
| 45 |
// videos start downloading in parallel with the chart bundle.
|
| 46 |
const DataRecharts = lazy(() => import("@/components/data-recharts"));
|
| 47 |
|
| 48 |
+
/** Skip global playback / navigation shortcuts while typing in a field. */
|
| 49 |
+
function isKeyboardFocusInsideTextEntry(target: EventTarget | null): boolean {
|
| 50 |
+
if (!(target instanceof HTMLElement)) return false;
|
| 51 |
+
if (target.isContentEditable || target.closest('[contenteditable="true"]')) {
|
| 52 |
+
return true;
|
| 53 |
+
}
|
| 54 |
+
const tag = target.tagName;
|
| 55 |
+
return (
|
| 56 |
+
tag === "TEXTAREA" ||
|
| 57 |
+
tag === "SELECT" ||
|
| 58 |
+
tag === "INPUT" ||
|
| 59 |
+
tag === "BUTTON" ||
|
| 60 |
+
(tag === "A" && target.hasAttribute("href"))
|
| 61 |
+
);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
type ActiveTab =
|
| 65 |
| "episodes"
|
| 66 |
+
| "annotations"
|
| 67 |
| "statistics"
|
| 68 |
| "frames"
|
| 69 |
| "insights"
|
|
|
|
| 203 |
return (
|
| 204 |
<TimeProvider duration={data!.duration}>
|
| 205 |
<FlaggedEpisodesProvider>
|
| 206 |
+
<AnnotationsProvider>
|
| 207 |
+
<EpisodeBootstrap data={data!} />
|
| 208 |
+
<EpisodeViewerInner data={data!} org={org} dataset={dataset} />
|
| 209 |
+
</AnnotationsProvider>
|
| 210 |
</FlaggedEpisodesProvider>
|
| 211 |
</TimeProvider>
|
| 212 |
);
|
| 213 |
}
|
| 214 |
|
| 215 |
+
/** Wires the loaded episode into the AnnotationsProvider. */
|
| 216 |
+
function EpisodeBootstrap({ data }: { data: EpisodeData }) {
|
| 217 |
+
const { setEpisode } = useAnnotations();
|
| 218 |
+
useEffect(() => {
|
| 219 |
+
setEpisode(
|
| 220 |
+
data.episodeId,
|
| 221 |
+
{ repoId: data.datasetInfo.repoId },
|
| 222 |
+
data.languageAtoms,
|
| 223 |
+
data.frameTimestamps,
|
| 224 |
+
);
|
| 225 |
+
}, [
|
| 226 |
+
data.episodeId,
|
| 227 |
+
data.datasetInfo.repoId,
|
| 228 |
+
data.languageAtoms,
|
| 229 |
+
data.frameTimestamps,
|
| 230 |
+
setEpisode,
|
| 231 |
+
]);
|
| 232 |
+
return null;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
function EpisodeViewerInner({
|
| 236 |
data,
|
| 237 |
org,
|
|
|
|
| 258 |
const router = useRouter();
|
| 259 |
const searchParams = useSearchParams();
|
| 260 |
|
| 261 |
+
// Tab state & lazy stats — read sessionStorage in the initializer so the
|
| 262 |
+
// correct tab renders on the very first frame (no post-mount flash).
|
| 263 |
+
// Safe because EpisodeViewerInner only mounts client-side (behind a loading gate).
|
| 264 |
+
const [activeTab, setActiveTab] = useState<ActiveTab>(() => {
|
| 265 |
+
if (typeof window !== "undefined") {
|
| 266 |
+
const stored = sessionStorage.getItem("activeTab");
|
| 267 |
+
if (
|
| 268 |
+
stored &&
|
| 269 |
+
[
|
| 270 |
+
"episodes",
|
| 271 |
+
"annotations",
|
| 272 |
+
"statistics",
|
| 273 |
+
"frames",
|
| 274 |
+
"insights",
|
| 275 |
+
"filtering",
|
| 276 |
+
"urdf",
|
| 277 |
+
].includes(stored)
|
| 278 |
+
) {
|
| 279 |
+
return stored as ActiveTab;
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
return "episodes";
|
| 283 |
+
});
|
| 284 |
const isLoading = activeTab === "episodes" && (!videosReady || !chartsReady);
|
| 285 |
|
| 286 |
useEffect(() => {
|
|
|
|
| 299 |
useState<EpisodeFramesData | null>(null);
|
| 300 |
const [framesLoading, setFramesLoading] = useState(false);
|
| 301 |
const framesLoadedRef = useRef(false);
|
| 302 |
+
const [framesFlaggedOnly, setFramesFlaggedOnly] = useState(() =>
|
| 303 |
+
typeof window !== "undefined"
|
| 304 |
+
? sessionStorage.getItem("framesFlaggedOnly") === "true"
|
| 305 |
+
: false,
|
| 306 |
+
);
|
| 307 |
+
const [sidebarFlaggedOnly, setSidebarFlaggedOnly] = useState(() =>
|
| 308 |
+
typeof window !== "undefined"
|
| 309 |
+
? sessionStorage.getItem("sidebarFlaggedOnly") === "true"
|
| 310 |
+
: false,
|
| 311 |
+
);
|
| 312 |
const [crossEpData, setCrossEpData] =
|
| 313 |
useState<CrossEpisodeVarianceData | null>(null);
|
| 314 |
const [insightsLoading, setInsightsLoading] = useState(false);
|
|
|
|
| 342 |
}
|
| 343 |
}, [datasetInfo.robot_type, datasetInfo.codebase_version]);
|
| 344 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
// Persist UI state across episode navigations. One effect instead of
|
| 346 |
// three near-identical writes — fewer commit hooks per render and the
|
| 347 |
// intent (mirror three primitives to sessionStorage) reads as one unit.
|
|
|
|
| 536 |
const onKeyDown = (e: KeyboardEvent) => {
|
| 537 |
const { key } = e;
|
| 538 |
const s = keyStateRef.current;
|
| 539 |
+
const inTextEntry = isKeyboardFocusInsideTextEntry(e.target);
|
| 540 |
|
| 541 |
if (key === " ") {
|
| 542 |
+
if (inTextEntry) return;
|
| 543 |
e.preventDefault();
|
| 544 |
if (s.activeTab === "urdf") {
|
| 545 |
urdfPlayToggleRef.current?.();
|
|
|
|
| 547 |
setIsPlaying((prev: boolean) => !prev);
|
| 548 |
}
|
| 549 |
} else if (key === "ArrowDown" || key === "ArrowUp") {
|
| 550 |
+
if (inTextEntry) return;
|
| 551 |
e.preventDefault();
|
| 552 |
if (s.activeTab === "urdf") {
|
| 553 |
const nextEp =
|
|
|
|
| 606 |
{/* Top tab bar */}
|
| 607 |
<div className="flex items-center border-b border-white/5 bg-[var(--surface-0)] shrink-0">
|
| 608 |
{renderTab("episodes", "Episodes")}
|
| 609 |
+
{renderTab(
|
| 610 |
+
"annotations",
|
| 611 |
+
"Annotations",
|
| 612 |
+
"Edit subtask / plan / memory / interjection / VQA atoms (lerobot v3.1 schema)",
|
| 613 |
+
)}
|
| 614 |
{hasURDFSupport(datasetInfo.robot_type) &&
|
| 615 |
datasetInfo.codebase_version >= "v3.0" &&
|
| 616 |
renderTab("urdf", "3D Replay")}
|
|
|
|
| 631 |
{/* Body: sidebar + content */}
|
| 632 |
<div className="flex flex-1 min-h-0">
|
| 633 |
{/* Sidebar — on Episodes and 3D Replay tabs */}
|
| 634 |
+
{(activeTab === "episodes" ||
|
| 635 |
+
activeTab === "annotations" ||
|
| 636 |
+
activeTab === "urdf") && (
|
| 637 |
<Sidebar
|
| 638 |
datasetInfo={datasetInfo}
|
| 639 |
paginatedEpisodes={paginatedEpisodes}
|
|
|
|
| 650 |
setUrdfEpisode(ep);
|
| 651 |
urdfChangerRef.current?.(ep);
|
| 652 |
}
|
| 653 |
+
: activeTab === "annotations"
|
| 654 |
+
? (ep) => router.push(`./episode_${ep}`)
|
| 655 |
+
: undefined
|
| 656 |
}
|
| 657 |
/>
|
| 658 |
)}
|
|
|
|
| 733 |
</>
|
| 734 |
)}
|
| 735 |
|
| 736 |
+
{activeTab === "annotations" && (
|
| 737 |
+
<div className="annotations-skin flex flex-col gap-4">
|
| 738 |
+
<div className="flex items-center gap-3">
|
| 739 |
+
<p className="text-base font-medium text-slate-200 truncate">
|
| 740 |
+
{datasetInfo.repoId}
|
| 741 |
+
</p>
|
| 742 |
+
<p className="text-[10px] uppercase tracking-wide text-slate-500 tabular">
|
| 743 |
+
Episode · {episodeId}
|
| 744 |
+
</p>
|
| 745 |
+
</div>
|
| 746 |
+
{videosInfo.length > 0 && (
|
| 747 |
+
<SimpleVideosPlayer
|
| 748 |
+
videosInfo={videosInfo}
|
| 749 |
+
onVideosReady={() => setVideosReady(true)}
|
| 750 |
+
/>
|
| 751 |
+
)}
|
| 752 |
+
<div className="grounding-intro">
|
| 753 |
+
<span className="section-kicker">Grounded VQA</span>
|
| 754 |
+
<ul>
|
| 755 |
+
<li>
|
| 756 |
+
Draw directly on the active video to create visual
|
| 757 |
+
questions. Drag for a bounding box, click for a point. The
|
| 758 |
+
camera is detected from the video you draw on.
|
| 759 |
+
</li>
|
| 760 |
+
<li>
|
| 761 |
+
Drag on any video to add a bbox question. Click any video to
|
| 762 |
+
add a keypoint question. Confirm the popup with <kbd>↵</kbd>
|
| 763 |
+
, or cancel with <kbd>Esc</kbd>.
|
| 764 |
+
</li>
|
| 765 |
+
</ul>
|
| 766 |
+
</div>
|
| 767 |
+
<PlaybackBar />
|
| 768 |
+
<AnnotationsTimeline duration={data.duration} />
|
| 769 |
+
<AnnotationsPanel
|
| 770 |
+
cameraKeys={videosInfo.map((v) => v.filename)}
|
| 771 |
+
/>
|
| 772 |
+
</div>
|
| 773 |
+
)}
|
| 774 |
+
|
| 775 |
{activeTab === "statistics" && (
|
| 776 |
<StatsPanel
|
| 777 |
datasetInfo={datasetInfo}
|
src/app/[org]/[dataset]/[episode]/fetch-data.ts
CHANGED
|
@@ -84,6 +84,20 @@ export type EpisodeData = {
|
|
| 84 |
ignoredColumns: string[];
|
| 85 |
duration: number;
|
| 86 |
task?: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
};
|
| 88 |
|
| 89 |
type EpisodeMetadataV3 = {
|
|
@@ -708,8 +722,14 @@ async function getEpisodeDataV3(
|
|
| 708 |
);
|
| 709 |
|
| 710 |
// Load episode data for charts
|
| 711 |
-
const {
|
| 712 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 713 |
|
| 714 |
const duration = episodeMetadata.length
|
| 715 |
? episodeMetadata.length / info.fps
|
|
@@ -725,6 +745,8 @@ async function getEpisodeDataV3(
|
|
| 725 |
ignoredColumns,
|
| 726 |
duration,
|
| 727 |
task,
|
|
|
|
|
|
|
| 728 |
};
|
| 729 |
}
|
| 730 |
|
|
@@ -739,6 +761,8 @@ async function loadEpisodeDataV3(
|
|
| 739 |
flatChartData: Record<string, number>[];
|
| 740 |
ignoredColumns: string[];
|
| 741 |
task?: string;
|
|
|
|
|
|
|
| 742 |
}> {
|
| 743 |
// Build data file path using chunk and file indices
|
| 744 |
const dataChunkIndex = bigIntToNumber(episodeMetadata.data_chunk_index, 0);
|
|
@@ -756,6 +780,10 @@ async function loadEpisodeDataV3(
|
|
| 756 |
"language_instruction",
|
| 757 |
"language_instruction_2",
|
| 758 |
"language_instruction_3",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 759 |
...Object.entries(info.features)
|
| 760 |
.filter(([, feature]) => {
|
| 761 |
const dtype = feature.dtype.toLowerCase();
|
|
@@ -815,6 +843,20 @@ async function loadEpisodeDataV3(
|
|
| 815 |
episodeRows = await readParquetAsObjects(parquetFile, v3DataColumns);
|
| 816 |
}
|
| 817 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 818 |
const episodeData = evenlySampleArray(episodeRows, MAX_EPISODE_POINTS);
|
| 819 |
|
| 820 |
if (episodeData.length === 0) {
|
|
@@ -823,6 +865,8 @@ async function loadEpisodeDataV3(
|
|
| 823 |
flatChartData: [],
|
| 824 |
ignoredColumns: [],
|
| 825 |
task: undefined,
|
|
|
|
|
|
|
| 826 |
};
|
| 827 |
}
|
| 828 |
|
|
@@ -905,7 +949,14 @@ async function loadEpisodeDataV3(
|
|
| 905 |
}
|
| 906 |
}
|
| 907 |
|
| 908 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 909 |
} catch {
|
| 910 |
return {
|
| 911 |
chartDataGroups: [],
|
|
@@ -916,6 +967,87 @@ async function loadEpisodeDataV3(
|
|
| 916 |
}
|
| 917 |
}
|
| 918 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 919 |
// Process episode data for charts (v3.0 compatible)
|
| 920 |
function processEpisodeDataForCharts(
|
| 921 |
episodeData: Record<string, unknown>[],
|
|
|
|
| 84 |
ignoredColumns: string[];
|
| 85 |
duration: number;
|
| 86 |
task?: string;
|
| 87 |
+
/**
|
| 88 |
+
* v3.1 language atoms read from `language_persistent` and `language_events`
|
| 89 |
+
* (lerobot#3467). Empty when the dataset hasn't been annotated yet.
|
| 90 |
+
* The annotations panel uses this to seed its editor state when no
|
| 91 |
+
* annotation backend is running.
|
| 92 |
+
*/
|
| 93 |
+
languageAtoms?: import("@/types/language.types").LanguageAtom[];
|
| 94 |
+
/**
|
| 95 |
+
* Sorted source-frame timestamps from the data parquet, in seconds. Used by
|
| 96 |
+
* the annotations editor to snap atom timestamps to exact frame times —
|
| 97 |
+
* avoiding sub-frame drift from a throttled `currentTime` and keeping
|
| 98 |
+
* events compatible with the writer in lerobot#3471.
|
| 99 |
+
*/
|
| 100 |
+
frameTimestamps?: number[];
|
| 101 |
};
|
| 102 |
|
| 103 |
type EpisodeMetadataV3 = {
|
|
|
|
| 722 |
);
|
| 723 |
|
| 724 |
// Load episode data for charts
|
| 725 |
+
const {
|
| 726 |
+
chartDataGroups,
|
| 727 |
+
flatChartData,
|
| 728 |
+
ignoredColumns,
|
| 729 |
+
task,
|
| 730 |
+
languageAtoms,
|
| 731 |
+
frameTimestamps,
|
| 732 |
+
} = await loadEpisodeDataV3(repoId, version, info, episodeMetadata);
|
| 733 |
|
| 734 |
const duration = episodeMetadata.length
|
| 735 |
? episodeMetadata.length / info.fps
|
|
|
|
| 745 |
ignoredColumns,
|
| 746 |
duration,
|
| 747 |
task,
|
| 748 |
+
languageAtoms,
|
| 749 |
+
frameTimestamps,
|
| 750 |
};
|
| 751 |
}
|
| 752 |
|
|
|
|
| 761 |
flatChartData: Record<string, number>[];
|
| 762 |
ignoredColumns: string[];
|
| 763 |
task?: string;
|
| 764 |
+
languageAtoms?: import("@/types/language.types").LanguageAtom[];
|
| 765 |
+
frameTimestamps?: number[];
|
| 766 |
}> {
|
| 767 |
// Build data file path using chunk and file indices
|
| 768 |
const dataChunkIndex = bigIntToNumber(episodeMetadata.data_chunk_index, 0);
|
|
|
|
| 780 |
"language_instruction",
|
| 781 |
"language_instruction_2",
|
| 782 |
"language_instruction_3",
|
| 783 |
+
// v3.1 language schema (lerobot#3467) — list<struct{...}> columns
|
| 784 |
+
// that the annotations panel renders / edits.
|
| 785 |
+
"language_persistent",
|
| 786 |
+
"language_events",
|
| 787 |
...Object.entries(info.features)
|
| 788 |
.filter(([, feature]) => {
|
| 789 |
const dtype = feature.dtype.toLowerCase();
|
|
|
|
| 843 |
episodeRows = await readParquetAsObjects(parquetFile, v3DataColumns);
|
| 844 |
}
|
| 845 |
|
| 846 |
+
// Extract frame timestamps from the *full* (non-sampled) row set so the
|
| 847 |
+
// annotations editor can snap to the exact frame the user is on.
|
| 848 |
+
const frameTimestamps = episodeRows
|
| 849 |
+
.map((r) => {
|
| 850 |
+
const t = r.timestamp;
|
| 851 |
+
return typeof t === "number"
|
| 852 |
+
? t
|
| 853 |
+
: typeof t === "bigint"
|
| 854 |
+
? Number(t)
|
| 855 |
+
: Number.NaN;
|
| 856 |
+
})
|
| 857 |
+
.filter((t) => Number.isFinite(t))
|
| 858 |
+
.sort((a, b) => a - b);
|
| 859 |
+
|
| 860 |
const episodeData = evenlySampleArray(episodeRows, MAX_EPISODE_POINTS);
|
| 861 |
|
| 862 |
if (episodeData.length === 0) {
|
|
|
|
| 865 |
flatChartData: [],
|
| 866 |
ignoredColumns: [],
|
| 867 |
task: undefined,
|
| 868 |
+
languageAtoms: extractLanguageAtoms(episodeRows),
|
| 869 |
+
frameTimestamps,
|
| 870 |
};
|
| 871 |
}
|
| 872 |
|
|
|
|
| 949 |
}
|
| 950 |
}
|
| 951 |
|
| 952 |
+
return {
|
| 953 |
+
chartDataGroups,
|
| 954 |
+
flatChartData,
|
| 955 |
+
ignoredColumns,
|
| 956 |
+
task,
|
| 957 |
+
languageAtoms: extractLanguageAtoms(episodeRows),
|
| 958 |
+
frameTimestamps,
|
| 959 |
+
};
|
| 960 |
} catch {
|
| 961 |
return {
|
| 962 |
chartDataGroups: [],
|
|
|
|
| 967 |
}
|
| 968 |
}
|
| 969 |
|
| 970 |
+
/**
|
| 971 |
+
* Extract `LanguageAtom`s from a list of v3.1 parquet rows.
|
| 972 |
+
*
|
| 973 |
+
* Each row may carry two arrays:
|
| 974 |
+
* - `language_persistent`: broadcast — same content on every row in the
|
| 975 |
+
* episode. We sample row 0 (skipping nulls/empties).
|
| 976 |
+
* - `language_events`: per-row, mostly empty. We collect every event whose
|
| 977 |
+
* row's `timestamp` falls within the episode.
|
| 978 |
+
*
|
| 979 |
+
* Hyparquet decodes `list<struct<...>>` to `Array<Record<string, unknown>>`.
|
| 980 |
+
* We coerce loosely-typed values to the canonical `LanguageAtom` shape and
|
| 981 |
+
* drop rows that don't validate.
|
| 982 |
+
*/
|
| 983 |
+
function extractLanguageAtoms(
|
| 984 |
+
episodeRows: Record<string, unknown>[],
|
| 985 |
+
): import("@/types/language.types").LanguageAtom[] {
|
| 986 |
+
if (!episodeRows.length) return [];
|
| 987 |
+
type LanguageAtom = import("@/types/language.types").LanguageAtom;
|
| 988 |
+
const atoms: LanguageAtom[] = [];
|
| 989 |
+
const seenPersistent: Set<string> = new Set(); // dedupe broadcast copies
|
| 990 |
+
|
| 991 |
+
const coerce = (raw: unknown, fallbackTs?: number): LanguageAtom | null => {
|
| 992 |
+
if (!raw || typeof raw !== "object") return null;
|
| 993 |
+
const r = raw as Record<string, unknown>;
|
| 994 |
+
const role =
|
| 995 |
+
typeof r.role === "string" ? (r.role as LanguageAtom["role"]) : null;
|
| 996 |
+
if (!role) return null;
|
| 997 |
+
const style =
|
| 998 |
+
typeof r.style === "string" ? (r.style as LanguageAtom["style"]) : null;
|
| 999 |
+
const content = typeof r.content === "string" ? r.content : null;
|
| 1000 |
+
const timestamp =
|
| 1001 |
+
typeof r.timestamp === "number"
|
| 1002 |
+
? r.timestamp
|
| 1003 |
+
: typeof r.timestamp === "bigint"
|
| 1004 |
+
? Number(r.timestamp)
|
| 1005 |
+
: (fallbackTs ?? 0);
|
| 1006 |
+
const tool_calls = Array.isArray(r.tool_calls)
|
| 1007 |
+
? (r.tool_calls as LanguageAtom["tool_calls"])
|
| 1008 |
+
: null;
|
| 1009 |
+
const camera =
|
| 1010 |
+
typeof r.camera === "string" && r.camera.length > 0 ? r.camera : null;
|
| 1011 |
+
return { role, content, style, timestamp, camera, tool_calls };
|
| 1012 |
+
};
|
| 1013 |
+
|
| 1014 |
+
// Persistent slice: read once from the first row that has a non-empty list.
|
| 1015 |
+
for (const row of episodeRows) {
|
| 1016 |
+
const list = row["language_persistent"];
|
| 1017 |
+
if (Array.isArray(list) && list.length > 0) {
|
| 1018 |
+
for (const raw of list) {
|
| 1019 |
+
const atom = coerce(raw);
|
| 1020 |
+
if (!atom) continue;
|
| 1021 |
+
const key = `${atom.style}|${atom.role}|${atom.timestamp}|${atom.camera ?? ""}|${atom.content}`;
|
| 1022 |
+
if (seenPersistent.has(key)) continue;
|
| 1023 |
+
seenPersistent.add(key);
|
| 1024 |
+
atoms.push(atom);
|
| 1025 |
+
}
|
| 1026 |
+
break;
|
| 1027 |
+
}
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
// Event slice: every non-empty per-row list contributes its rows. Each
|
| 1031 |
+
// event's timestamp should already match its frame timestamp; we use the
|
| 1032 |
+
// row timestamp as a fallback if it's missing.
|
| 1033 |
+
for (const row of episodeRows) {
|
| 1034 |
+
const list = row["language_events"];
|
| 1035 |
+
if (!Array.isArray(list) || list.length === 0) continue;
|
| 1036 |
+
const rowTs =
|
| 1037 |
+
typeof row.timestamp === "number"
|
| 1038 |
+
? row.timestamp
|
| 1039 |
+
: typeof row.timestamp === "bigint"
|
| 1040 |
+
? Number(row.timestamp)
|
| 1041 |
+
: 0;
|
| 1042 |
+
for (const raw of list) {
|
| 1043 |
+
const atom = coerce(raw, rowTs);
|
| 1044 |
+
if (atom) atoms.push(atom);
|
| 1045 |
+
}
|
| 1046 |
+
}
|
| 1047 |
+
|
| 1048 |
+
return atoms;
|
| 1049 |
+
}
|
| 1050 |
+
|
| 1051 |
// Process episode data for charts (v3.0 compatible)
|
| 1052 |
function processEpisodeDataForCharts(
|
| 1053 |
episodeData: Record<string, unknown>[],
|
src/components/annotations-panel.tsx
ADDED
|
@@ -0,0 +1,1021 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import "./annotations-skin.css";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Editor UI for v3.1 language atoms.
|
| 7 |
+
*
|
| 8 |
+
* Three vertical sections:
|
| 9 |
+
* 1. Inline quick-add bar above the timeline (style picker + label + Add).
|
| 10 |
+
* 2. Annotations timeline (in `annotations-timeline.tsx`).
|
| 11 |
+
* 3. Workspace below the timeline:
|
| 12 |
+
* - Left rail: full atom list grouped by style; click to select.
|
| 13 |
+
* - Right pane: editor for the selected atom (or empty state).
|
| 14 |
+
*
|
| 15 |
+
* Bbox / keypoint VQA atoms are still added through the canvas overlay's
|
| 16 |
+
* quick-label popup; the inline quick-add covers subtask / plan / memory /
|
| 17 |
+
* interjection / speech / count / attribute / spatial.
|
| 18 |
+
*/
|
| 19 |
+
|
| 20 |
+
import React, { useMemo, useState } from "react";
|
| 21 |
+
import { useTime } from "../context/time-context";
|
| 22 |
+
import { useAnnotations } from "../context/annotations-context";
|
| 23 |
+
import {
|
| 24 |
+
buildSpeechAtom,
|
| 25 |
+
classifyVqa,
|
| 26 |
+
isSpeechAtom,
|
| 27 |
+
parseVqaAnswer,
|
| 28 |
+
speechText,
|
| 29 |
+
type LanguageAtom,
|
| 30 |
+
} from "../types/language.types";
|
| 31 |
+
import {
|
| 32 |
+
exportDataset as apiExport,
|
| 33 |
+
isAnnotateBackendEnabled,
|
| 34 |
+
} from "../utils/annotationsClient";
|
| 35 |
+
|
| 36 |
+
interface Props {
|
| 37 |
+
cameraKeys: string[];
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function fmtTime(s: number): string {
|
| 41 |
+
return s.toFixed(3) + "s";
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function StylePill({ style }: { style: string | null }) {
|
| 45 |
+
const cls = style ?? "speech";
|
| 46 |
+
return <span className={`style-pill ${cls}`}>{style ?? "speech"}</span>;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* Highlight a row when its timestamp is within ~half a frame of currentTime.
|
| 51 |
+
*/
|
| 52 |
+
function isActiveAt(ts: number, currentTime: number, fps = 30): boolean {
|
| 53 |
+
return Math.abs(ts - currentTime) < 0.5 / fps;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
type QuickAddKind =
|
| 57 |
+
| "task_aug"
|
| 58 |
+
| "subtask"
|
| 59 |
+
| "plan"
|
| 60 |
+
| "memory"
|
| 61 |
+
| "interjection"
|
| 62 |
+
| "speech"
|
| 63 |
+
| "count"
|
| 64 |
+
| "attribute"
|
| 65 |
+
| "spatial";
|
| 66 |
+
|
| 67 |
+
interface QuickAddField {
|
| 68 |
+
name: string;
|
| 69 |
+
placeholder: string;
|
| 70 |
+
type?: "text" | "number";
|
| 71 |
+
width?: string;
|
| 72 |
+
grow?: boolean;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
interface QuickAddBuildCtx {
|
| 76 |
+
ts: number;
|
| 77 |
+
vqaCamera: string | null;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
interface QuickAddDef {
|
| 81 |
+
kind: QuickAddKind;
|
| 82 |
+
label: string;
|
| 83 |
+
/** When true, the displayed timestamp is 0 (atom is pinned to episode start). */
|
| 84 |
+
atEpisodeStart?: boolean;
|
| 85 |
+
fields: QuickAddField[];
|
| 86 |
+
build: (
|
| 87 |
+
values: Record<string, string>,
|
| 88 |
+
ctx: QuickAddBuildCtx,
|
| 89 |
+
) => LanguageAtom[] | null;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// Each text-style atom kind (and the simpler VQA shapes) is one entry: how
|
| 93 |
+
// it appears in the dropdown, what fields the user fills, and how those
|
| 94 |
+
// values map to one or two language atoms.
|
| 95 |
+
const QUICK_ADD_DEFS: QuickAddDef[] = [
|
| 96 |
+
{
|
| 97 |
+
kind: "task_aug",
|
| 98 |
+
label: "task augmentation",
|
| 99 |
+
atEpisodeStart: true,
|
| 100 |
+
fields: [
|
| 101 |
+
{
|
| 102 |
+
name: "label",
|
| 103 |
+
placeholder: "pick up the blue cube and place it in the green box",
|
| 104 |
+
grow: true,
|
| 105 |
+
},
|
| 106 |
+
],
|
| 107 |
+
build: ({ label }) => {
|
| 108 |
+
const text = label.trim();
|
| 109 |
+
if (!text) return null;
|
| 110 |
+
return [
|
| 111 |
+
{
|
| 112 |
+
role: "user",
|
| 113 |
+
content: text,
|
| 114 |
+
style: "task_aug",
|
| 115 |
+
timestamp: 0,
|
| 116 |
+
camera: null,
|
| 117 |
+
tool_calls: null,
|
| 118 |
+
},
|
| 119 |
+
];
|
| 120 |
+
},
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
kind: "subtask",
|
| 124 |
+
label: "subtask",
|
| 125 |
+
fields: [
|
| 126 |
+
{
|
| 127 |
+
name: "label",
|
| 128 |
+
placeholder: "grasp the handle of the sponge",
|
| 129 |
+
grow: true,
|
| 130 |
+
},
|
| 131 |
+
],
|
| 132 |
+
build: ({ label }, { ts }) => {
|
| 133 |
+
const text = label.trim();
|
| 134 |
+
if (!text) return null;
|
| 135 |
+
return [
|
| 136 |
+
{
|
| 137 |
+
role: "assistant",
|
| 138 |
+
content: text,
|
| 139 |
+
style: "subtask",
|
| 140 |
+
timestamp: ts,
|
| 141 |
+
camera: null,
|
| 142 |
+
tool_calls: null,
|
| 143 |
+
},
|
| 144 |
+
];
|
| 145 |
+
},
|
| 146 |
+
},
|
| 147 |
+
{
|
| 148 |
+
kind: "plan",
|
| 149 |
+
label: "plan",
|
| 150 |
+
fields: [
|
| 151 |
+
{
|
| 152 |
+
name: "label",
|
| 153 |
+
placeholder: "1. grab sponge / 2. wipe / 3. tidy",
|
| 154 |
+
grow: true,
|
| 155 |
+
},
|
| 156 |
+
],
|
| 157 |
+
build: ({ label }, { ts }) => {
|
| 158 |
+
const text = label.trim();
|
| 159 |
+
if (!text) return null;
|
| 160 |
+
return [
|
| 161 |
+
{
|
| 162 |
+
role: "assistant",
|
| 163 |
+
content: text,
|
| 164 |
+
style: "plan",
|
| 165 |
+
timestamp: ts,
|
| 166 |
+
camera: null,
|
| 167 |
+
tool_calls: null,
|
| 168 |
+
},
|
| 169 |
+
];
|
| 170 |
+
},
|
| 171 |
+
},
|
| 172 |
+
{
|
| 173 |
+
kind: "memory",
|
| 174 |
+
label: "memory",
|
| 175 |
+
fields: [
|
| 176 |
+
{
|
| 177 |
+
name: "label",
|
| 178 |
+
placeholder: "sponge picked up; counter still dirty",
|
| 179 |
+
grow: true,
|
| 180 |
+
},
|
| 181 |
+
],
|
| 182 |
+
build: ({ label }, { ts }) => {
|
| 183 |
+
const text = label.trim();
|
| 184 |
+
if (!text) return null;
|
| 185 |
+
return [
|
| 186 |
+
{
|
| 187 |
+
role: "assistant",
|
| 188 |
+
content: text,
|
| 189 |
+
style: "memory",
|
| 190 |
+
timestamp: ts,
|
| 191 |
+
camera: null,
|
| 192 |
+
tool_calls: null,
|
| 193 |
+
},
|
| 194 |
+
];
|
| 195 |
+
},
|
| 196 |
+
},
|
| 197 |
+
{
|
| 198 |
+
kind: "interjection",
|
| 199 |
+
label: "interjection (user)",
|
| 200 |
+
fields: [
|
| 201 |
+
{
|
| 202 |
+
name: "label",
|
| 203 |
+
placeholder: "user: actually skip the wipe…",
|
| 204 |
+
grow: true,
|
| 205 |
+
},
|
| 206 |
+
],
|
| 207 |
+
build: ({ label }, { ts }) => {
|
| 208 |
+
const text = label.trim();
|
| 209 |
+
if (!text) return null;
|
| 210 |
+
return [
|
| 211 |
+
{
|
| 212 |
+
role: "user",
|
| 213 |
+
content: text,
|
| 214 |
+
style: "interjection",
|
| 215 |
+
timestamp: ts,
|
| 216 |
+
camera: null,
|
| 217 |
+
tool_calls: null,
|
| 218 |
+
},
|
| 219 |
+
];
|
| 220 |
+
},
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
kind: "speech",
|
| 224 |
+
label: "speech (robot say)",
|
| 225 |
+
fields: [
|
| 226 |
+
{
|
| 227 |
+
name: "label",
|
| 228 |
+
placeholder: "robot say: Got it, skipping the wipe.",
|
| 229 |
+
grow: true,
|
| 230 |
+
},
|
| 231 |
+
],
|
| 232 |
+
build: ({ label }, { ts }) => {
|
| 233 |
+
const text = label.trim();
|
| 234 |
+
if (!text) return null;
|
| 235 |
+
return [buildSpeechAtom(ts, text)];
|
| 236 |
+
},
|
| 237 |
+
},
|
| 238 |
+
{
|
| 239 |
+
kind: "count",
|
| 240 |
+
label: "vqa: count",
|
| 241 |
+
fields: [
|
| 242 |
+
{ name: "label", placeholder: "object label (e.g. cup)", grow: true },
|
| 243 |
+
{ name: "count", placeholder: "count", type: "number", width: "80px" },
|
| 244 |
+
],
|
| 245 |
+
build: ({ label, count }, { ts, vqaCamera }) => {
|
| 246 |
+
const text = label.trim();
|
| 247 |
+
if (!text || !count) return null;
|
| 248 |
+
return [
|
| 249 |
+
{
|
| 250 |
+
role: "user",
|
| 251 |
+
content: `How many ${text}?`,
|
| 252 |
+
style: "vqa",
|
| 253 |
+
timestamp: ts,
|
| 254 |
+
camera: vqaCamera,
|
| 255 |
+
tool_calls: null,
|
| 256 |
+
},
|
| 257 |
+
{
|
| 258 |
+
role: "assistant",
|
| 259 |
+
content: JSON.stringify({ label: text, count: Number(count) }),
|
| 260 |
+
style: "vqa",
|
| 261 |
+
timestamp: ts,
|
| 262 |
+
camera: vqaCamera,
|
| 263 |
+
tool_calls: null,
|
| 264 |
+
},
|
| 265 |
+
];
|
| 266 |
+
},
|
| 267 |
+
},
|
| 268 |
+
{
|
| 269 |
+
kind: "attribute",
|
| 270 |
+
label: "vqa: attribute",
|
| 271 |
+
fields: [
|
| 272 |
+
{ name: "label", placeholder: "label", width: "120px" },
|
| 273 |
+
{ name: "attribute", placeholder: "attribute (color)", width: "120px" },
|
| 274 |
+
{ name: "value", placeholder: "value (red)", grow: true },
|
| 275 |
+
],
|
| 276 |
+
build: ({ label, attribute, value }, { ts, vqaCamera }) => {
|
| 277 |
+
const text = label.trim();
|
| 278 |
+
if (!text || !attribute || !value) return null;
|
| 279 |
+
return [
|
| 280 |
+
{
|
| 281 |
+
role: "user",
|
| 282 |
+
content: `What ${attribute} is the ${text}?`,
|
| 283 |
+
style: "vqa",
|
| 284 |
+
timestamp: ts,
|
| 285 |
+
camera: vqaCamera,
|
| 286 |
+
tool_calls: null,
|
| 287 |
+
},
|
| 288 |
+
{
|
| 289 |
+
role: "assistant",
|
| 290 |
+
content: JSON.stringify({ label: text, attribute, value }),
|
| 291 |
+
style: "vqa",
|
| 292 |
+
timestamp: ts,
|
| 293 |
+
camera: vqaCamera,
|
| 294 |
+
tool_calls: null,
|
| 295 |
+
},
|
| 296 |
+
];
|
| 297 |
+
},
|
| 298 |
+
},
|
| 299 |
+
{
|
| 300 |
+
kind: "spatial",
|
| 301 |
+
label: "vqa: spatial relation",
|
| 302 |
+
fields: [
|
| 303 |
+
{ name: "subject", placeholder: "subject", width: "100px" },
|
| 304 |
+
{ name: "relation", placeholder: "relation (right_of)", width: "130px" },
|
| 305 |
+
{ name: "object", placeholder: "object", grow: true },
|
| 306 |
+
],
|
| 307 |
+
build: ({ subject, relation, object }, { ts, vqaCamera }) => {
|
| 308 |
+
if (!subject || !relation || !object) return null;
|
| 309 |
+
return [
|
| 310 |
+
{
|
| 311 |
+
role: "user",
|
| 312 |
+
content: `Where is the ${subject} relative to the ${object}?`,
|
| 313 |
+
style: "vqa",
|
| 314 |
+
timestamp: ts,
|
| 315 |
+
camera: vqaCamera,
|
| 316 |
+
tool_calls: null,
|
| 317 |
+
},
|
| 318 |
+
{
|
| 319 |
+
role: "assistant",
|
| 320 |
+
content: JSON.stringify({ subject, relation, object }),
|
| 321 |
+
style: "vqa",
|
| 322 |
+
timestamp: ts,
|
| 323 |
+
camera: vqaCamera,
|
| 324 |
+
tool_calls: null,
|
| 325 |
+
},
|
| 326 |
+
];
|
| 327 |
+
},
|
| 328 |
+
},
|
| 329 |
+
];
|
| 330 |
+
|
| 331 |
+
const QUICK_ADD_DEFS_BY_KIND: Record<QuickAddKind, QuickAddDef> =
|
| 332 |
+
QUICK_ADD_DEFS.reduce(
|
| 333 |
+
(acc, def) => {
|
| 334 |
+
acc[def.kind] = def;
|
| 335 |
+
return acc;
|
| 336 |
+
},
|
| 337 |
+
{} as Record<QuickAddKind, QuickAddDef>,
|
| 338 |
+
);
|
| 339 |
+
|
| 340 |
+
interface RailGroupDef {
|
| 341 |
+
key: string;
|
| 342 |
+
title: string;
|
| 343 |
+
dotClass: string;
|
| 344 |
+
// Which v3.1 language column this style is written to. Used to group the
|
| 345 |
+
// rail under "Persistent" vs "Events" headers so it's clear at a glance
|
| 346 |
+
// that task_aug / subtask / plan / memory broadcast across the whole
|
| 347 |
+
// episode (language_persistent) while interjection / speech / vqa fire on
|
| 348 |
+
// a single frame (language_events). Mirrors columnForStyle() exactly.
|
| 349 |
+
column: "persistent" | "events";
|
| 350 |
+
match: (
|
| 351 |
+
atom: LanguageAtom,
|
| 352 |
+
otherCamera: (a: LanguageAtom) => boolean,
|
| 353 |
+
) => boolean;
|
| 354 |
+
label: (
|
| 355 |
+
atom: LanguageAtom,
|
| 356 |
+
helpers: {
|
| 357 |
+
activeCamera: string | null;
|
| 358 |
+
firstLine: (s: string | null) => string;
|
| 359 |
+
},
|
| 360 |
+
) => string;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
const RAIL_GROUPS: RailGroupDef[] = [
|
| 364 |
+
{
|
| 365 |
+
key: "task_aug",
|
| 366 |
+
title: "task aug",
|
| 367 |
+
dotClass: "dot-task-aug",
|
| 368 |
+
column: "persistent",
|
| 369 |
+
match: (a) => a.style === "task_aug",
|
| 370 |
+
label: (a) => a.content || "(empty)",
|
| 371 |
+
},
|
| 372 |
+
{
|
| 373 |
+
key: "subtask",
|
| 374 |
+
title: "subtask",
|
| 375 |
+
dotClass: "dot-subtask",
|
| 376 |
+
column: "persistent",
|
| 377 |
+
match: (a) => a.style === "subtask",
|
| 378 |
+
label: (a) => a.content || "(empty)",
|
| 379 |
+
},
|
| 380 |
+
{
|
| 381 |
+
key: "plan",
|
| 382 |
+
title: "plan",
|
| 383 |
+
dotClass: "dot-plan",
|
| 384 |
+
column: "persistent",
|
| 385 |
+
match: (a) => a.style === "plan",
|
| 386 |
+
label: (a, { firstLine }) => firstLine(a.content),
|
| 387 |
+
},
|
| 388 |
+
{
|
| 389 |
+
key: "memory",
|
| 390 |
+
title: "memory",
|
| 391 |
+
dotClass: "dot-memory",
|
| 392 |
+
column: "persistent",
|
| 393 |
+
match: (a) => a.style === "memory",
|
| 394 |
+
label: (a, { firstLine }) => firstLine(a.content),
|
| 395 |
+
},
|
| 396 |
+
{
|
| 397 |
+
key: "interjection",
|
| 398 |
+
title: "interjection",
|
| 399 |
+
dotClass: "dot-interjection",
|
| 400 |
+
column: "events",
|
| 401 |
+
match: (a) => a.style === "interjection",
|
| 402 |
+
label: (a) => a.content || "(empty)",
|
| 403 |
+
},
|
| 404 |
+
{
|
| 405 |
+
key: "speech",
|
| 406 |
+
title: "speech",
|
| 407 |
+
dotClass: "dot-speech",
|
| 408 |
+
column: "events",
|
| 409 |
+
match: (a) => isSpeechAtom(a),
|
| 410 |
+
label: (a) => speechText(a) || "(empty)",
|
| 411 |
+
},
|
| 412 |
+
{
|
| 413 |
+
key: "vqa",
|
| 414 |
+
title: "vqa",
|
| 415 |
+
dotClass: "dot-vqa",
|
| 416 |
+
column: "events",
|
| 417 |
+
match: (a, otherCamera) => a.style === "vqa" && !otherCamera(a),
|
| 418 |
+
label: (a, { activeCamera }) => {
|
| 419 |
+
const role = a.role === "user" ? "Q" : "A";
|
| 420 |
+
const t = a.content || "";
|
| 421 |
+
const cameraSuffix =
|
| 422 |
+
a.camera && a.camera !== activeCamera ? ` [${a.camera}]` : "";
|
| 423 |
+
return `${role}: ${t.slice(0, 60)}${t.length > 60 ? "…" : ""}${cameraSuffix}`;
|
| 424 |
+
},
|
| 425 |
+
},
|
| 426 |
+
];
|
| 427 |
+
|
| 428 |
+
function useJump(): (ts: number) => void {
|
| 429 |
+
const { seek, setIsPlaying } = useTime();
|
| 430 |
+
return React.useCallback(
|
| 431 |
+
(ts: number) => {
|
| 432 |
+
seek(ts, "external");
|
| 433 |
+
setIsPlaying(false);
|
| 434 |
+
},
|
| 435 |
+
[seek, setIsPlaying],
|
| 436 |
+
);
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
export const AnnotationsPanel: React.FC<Props> = ({ cameraKeys }) => {
|
| 440 |
+
const {
|
| 441 |
+
atoms,
|
| 442 |
+
addAtoms,
|
| 443 |
+
updateAtom,
|
| 444 |
+
deleteAtom,
|
| 445 |
+
snap,
|
| 446 |
+
save,
|
| 447 |
+
saving,
|
| 448 |
+
dirty,
|
| 449 |
+
backendEnabled,
|
| 450 |
+
activeCamera,
|
| 451 |
+
setActiveCamera,
|
| 452 |
+
setDrawMode,
|
| 453 |
+
selectedIdx,
|
| 454 |
+
selectAtom,
|
| 455 |
+
ident,
|
| 456 |
+
} = useAnnotations();
|
| 457 |
+
const { currentTime } = useTime();
|
| 458 |
+
|
| 459 |
+
// ============ Inline quick-add state ============
|
| 460 |
+
const [qaKind, setQaKind] = useState<QuickAddKind>("subtask");
|
| 461 |
+
const [qaValues, setQaValues] = useState<Record<string, string>>({});
|
| 462 |
+
const [exportStatus, setExportStatus] = useState<string | null>(null);
|
| 463 |
+
const qaDef = QUICK_ADD_DEFS_BY_KIND[qaKind];
|
| 464 |
+
|
| 465 |
+
// Initialize active camera once cameras arrive.
|
| 466 |
+
React.useEffect(() => {
|
| 467 |
+
if (!activeCamera && cameraKeys.length > 0) setActiveCamera(cameraKeys[0]);
|
| 468 |
+
}, [activeCamera, cameraKeys, setActiveCamera]);
|
| 469 |
+
|
| 470 |
+
// The Annotations tab keeps the canvas overlay in "auto" mode the whole
|
| 471 |
+
// time — drag = bbox, click = keypoint.
|
| 472 |
+
React.useEffect(() => {
|
| 473 |
+
setDrawMode("auto");
|
| 474 |
+
return () => setDrawMode("off");
|
| 475 |
+
}, [setDrawMode]);
|
| 476 |
+
|
| 477 |
+
// ============ Atom grouping for the rail ============
|
| 478 |
+
// The rail shows one section per atom-kind. Each kind is a single config
|
| 479 |
+
// entry: how to detect atoms in this kind, and how to label them in the row.
|
| 480 |
+
// VQA filters out other-camera answers when the dataset has multiple
|
| 481 |
+
// cameras so the rail mirrors the active video.
|
| 482 |
+
const groups = useMemo(() => {
|
| 483 |
+
const firstLine = (s: string | null) =>
|
| 484 |
+
(s || "").split("\n")[0] || "(empty)";
|
| 485 |
+
const otherCamera = (a: LanguageAtom): boolean =>
|
| 486 |
+
!!activeCamera &&
|
| 487 |
+
cameraKeys.length > 1 &&
|
| 488 |
+
a.camera != null &&
|
| 489 |
+
a.camera !== activeCamera;
|
| 490 |
+
return RAIL_GROUPS.map((def) => {
|
| 491 |
+
const entries = atoms
|
| 492 |
+
.map((atom, idx) => ({ atom, idx }))
|
| 493 |
+
.filter(({ atom }) => def.match(atom, otherCamera))
|
| 494 |
+
.map(({ atom, idx }) => ({
|
| 495 |
+
atom,
|
| 496 |
+
idx,
|
| 497 |
+
label: def.label(atom, { activeCamera, firstLine }),
|
| 498 |
+
}))
|
| 499 |
+
.sort((a, b) => a.atom.timestamp - b.atom.timestamp);
|
| 500 |
+
return { def, entries };
|
| 501 |
+
});
|
| 502 |
+
}, [atoms, activeCamera, cameraKeys.length]);
|
| 503 |
+
|
| 504 |
+
// ============ Quick-add handler ============
|
| 505 |
+
// VQA quick-adds inherit the active camera so per-camera filtering shows
|
| 506 |
+
// them in the right rail / overlay. Non-VQA atoms stay camera-agnostic
|
| 507 |
+
// (the def's `build` ignores `vqaCamera` for those).
|
| 508 |
+
const handleQuickAdd = () => {
|
| 509 |
+
const ts = snap(currentTime);
|
| 510 |
+
const vqaCamera = activeCamera ?? cameraKeys[0] ?? null;
|
| 511 |
+
const newAtoms = qaDef.build(qaValues, { ts, vqaCamera });
|
| 512 |
+
if (!newAtoms || !newAtoms.length) return;
|
| 513 |
+
addAtoms(newAtoms);
|
| 514 |
+
// Select the freshly added atom (last one added) so the editor opens for it.
|
| 515 |
+
selectAtom(atoms.length + newAtoms.length - 1);
|
| 516 |
+
setQaValues({});
|
| 517 |
+
};
|
| 518 |
+
|
| 519 |
+
// ============ Save / export ============
|
| 520 |
+
const handleSave = async () => {
|
| 521 |
+
const r = await save();
|
| 522 |
+
if (!r.ok) {
|
| 523 |
+
setExportStatus(`Save failed: ${r.error || "unknown"}`);
|
| 524 |
+
} else {
|
| 525 |
+
setExportStatus(
|
| 526 |
+
r.path
|
| 527 |
+
? `Saved episode to ${r.path}`
|
| 528 |
+
: "Saved episode (backend did not report a path — update/restart backend/app.py).",
|
| 529 |
+
);
|
| 530 |
+
}
|
| 531 |
+
};
|
| 532 |
+
|
| 533 |
+
const handleSaveDataset = async () => {
|
| 534 |
+
if (!isAnnotateBackendEnabled()) {
|
| 535 |
+
setExportStatus(
|
| 536 |
+
"Backend not configured. Set NEXT_PUBLIC_ANNOTATE_BACKEND_URL and run backend/app.py.",
|
| 537 |
+
);
|
| 538 |
+
return;
|
| 539 |
+
}
|
| 540 |
+
setExportStatus("Saving dataset…");
|
| 541 |
+
try {
|
| 542 |
+
const r = await apiExport(ident);
|
| 543 |
+
setExportStatus(
|
| 544 |
+
`Saved dataset to ${r.output_dir} (persistent: ${r.persistent_rows}, events: ${r.event_rows}).`,
|
| 545 |
+
);
|
| 546 |
+
} catch (e) {
|
| 547 |
+
setExportStatus(
|
| 548 |
+
`Save dataset failed: ${e instanceof Error ? e.message : String(e)}`,
|
| 549 |
+
);
|
| 550 |
+
}
|
| 551 |
+
};
|
| 552 |
+
|
| 553 |
+
const selectedAtom =
|
| 554 |
+
selectedIdx != null && selectedIdx >= 0 && selectedIdx < atoms.length
|
| 555 |
+
? atoms[selectedIdx]
|
| 556 |
+
: null;
|
| 557 |
+
|
| 558 |
+
// ============ Render ============
|
| 559 |
+
return (
|
| 560 |
+
<div className="annotation-workbench">
|
| 561 |
+
<div className="annotation-actionbar">
|
| 562 |
+
<div>
|
| 563 |
+
<h3>
|
| 564 |
+
Language annotations
|
| 565 |
+
{dirty && <span className="dirty-pill">unsaved</span>}
|
| 566 |
+
</h3>
|
| 567 |
+
<p>
|
| 568 |
+
Select an atom from the timeline or list, then edit it in the
|
| 569 |
+
inspector.
|
| 570 |
+
</p>
|
| 571 |
+
</div>
|
| 572 |
+
<div className="actionbar-actions">
|
| 573 |
+
{!backendEnabled && (
|
| 574 |
+
<span className="backend-offline">
|
| 575 |
+
backend offline — edits saved to sessionStorage only
|
| 576 |
+
</span>
|
| 577 |
+
)}
|
| 578 |
+
<button
|
| 579 |
+
disabled={saving || !dirty}
|
| 580 |
+
onClick={handleSave}
|
| 581 |
+
className="text-xs h-7 px-3 rounded border border-cyan-500/40 bg-cyan-500/10 text-cyan-200 hover:bg-cyan-500/20 disabled:opacity-40"
|
| 582 |
+
>
|
| 583 |
+
{saving ? "Saving…" : "Save episode"}
|
| 584 |
+
</button>
|
| 585 |
+
<button
|
| 586 |
+
disabled={!backendEnabled}
|
| 587 |
+
onClick={handleSaveDataset}
|
| 588 |
+
className="text-xs h-7 px-3 rounded border border-emerald-500/40 bg-emerald-500/10 text-emerald-200 hover:bg-emerald-500/20 disabled:opacity-40"
|
| 589 |
+
>
|
| 590 |
+
Save dataset
|
| 591 |
+
</button>
|
| 592 |
+
</div>
|
| 593 |
+
</div>
|
| 594 |
+
|
| 595 |
+
{exportStatus && <div className="save-status">{exportStatus}</div>}
|
| 596 |
+
|
| 597 |
+
<section className="annotation-composer">
|
| 598 |
+
<div className="composer-copy">
|
| 599 |
+
<span className="section-kicker">Add text annotation</span>
|
| 600 |
+
<p>
|
| 601 |
+
Adds task phrasing, subtask, plan, memory, speech, or non-spatial
|
| 602 |
+
VQA atoms. Task phrasings are saved at episode start.
|
| 603 |
+
</p>
|
| 604 |
+
</div>
|
| 605 |
+
<div className="quick-add">
|
| 606 |
+
<span className="ts-pill">
|
| 607 |
+
t = {qaDef.atEpisodeStart ? fmtTime(0) : fmtTime(currentTime)}
|
| 608 |
+
</span>
|
| 609 |
+
<select
|
| 610 |
+
value={qaKind}
|
| 611 |
+
onChange={(e) => {
|
| 612 |
+
setQaKind(e.target.value as QuickAddKind);
|
| 613 |
+
setQaValues({});
|
| 614 |
+
}}
|
| 615 |
+
>
|
| 616 |
+
{QUICK_ADD_DEFS.map((d) => (
|
| 617 |
+
<option key={d.kind} value={d.kind}>
|
| 618 |
+
{d.label}
|
| 619 |
+
</option>
|
| 620 |
+
))}
|
| 621 |
+
</select>
|
| 622 |
+
{qaDef.fields.map((f, i) => (
|
| 623 |
+
<input
|
| 624 |
+
key={f.name}
|
| 625 |
+
type={f.type === "number" ? "number" : "text"}
|
| 626 |
+
placeholder={f.placeholder}
|
| 627 |
+
className={f.grow ? "grow" : undefined}
|
| 628 |
+
style={f.width ? { width: f.width } : undefined}
|
| 629 |
+
value={qaValues[f.name] ?? ""}
|
| 630 |
+
onChange={(e) =>
|
| 631 |
+
setQaValues((v) => ({ ...v, [f.name]: e.target.value }))
|
| 632 |
+
}
|
| 633 |
+
onKeyDown={
|
| 634 |
+
i === qaDef.fields.length - 1
|
| 635 |
+
? (e) => e.key === "Enter" && handleQuickAdd()
|
| 636 |
+
: undefined
|
| 637 |
+
}
|
| 638 |
+
/>
|
| 639 |
+
))}
|
| 640 |
+
<button className="add-btn" onClick={handleQuickAdd}>
|
| 641 |
+
+ Add at frame
|
| 642 |
+
</button>
|
| 643 |
+
</div>
|
| 644 |
+
</section>
|
| 645 |
+
|
| 646 |
+
<div className="workspace inspector-workspace">
|
| 647 |
+
<div className="rail annotation-list">
|
| 648 |
+
<div className="list-head">
|
| 649 |
+
<div>
|
| 650 |
+
<span className="section-kicker">Annotations</span>
|
| 651 |
+
<p>{atoms.length} atoms in this episode</p>
|
| 652 |
+
</div>
|
| 653 |
+
<span className="ts-pill">{fmtTime(currentTime)}</span>
|
| 654 |
+
</div>
|
| 655 |
+
{atoms.length === 0 && (
|
| 656 |
+
<div className="rail-empty">
|
| 657 |
+
No annotations yet.
|
| 658 |
+
<br />
|
| 659 |
+
Add text above or draw on the active video.
|
| 660 |
+
</div>
|
| 661 |
+
)}
|
| 662 |
+
{(["persistent", "events"] as const).map((column) => {
|
| 663 |
+
const colGroups = groups.filter(({ def }) => def.column === column);
|
| 664 |
+
const total = colGroups.reduce(
|
| 665 |
+
(n, { entries }) => n + entries.length,
|
| 666 |
+
0,
|
| 667 |
+
);
|
| 668 |
+
if (total === 0) return null;
|
| 669 |
+
return (
|
| 670 |
+
<div className="rail-column" key={column}>
|
| 671 |
+
<div className={`rail-column-head ${column}`}>
|
| 672 |
+
<span className="rail-column-title">
|
| 673 |
+
{column === "persistent" ? "Persistent" : "Events"}
|
| 674 |
+
</span>
|
| 675 |
+
<span className="rail-column-sub">
|
| 676 |
+
{column === "persistent"
|
| 677 |
+
? "language_persistent · broadcast across every frame"
|
| 678 |
+
: "language_events · fire on a single frame"}
|
| 679 |
+
</span>
|
| 680 |
+
</div>
|
| 681 |
+
{colGroups.map(({ def, entries }) => (
|
| 682 |
+
<RailGroup
|
| 683 |
+
key={def.key}
|
| 684 |
+
title={def.title}
|
| 685 |
+
dotClass={def.dotClass}
|
| 686 |
+
entries={entries}
|
| 687 |
+
currentTime={currentTime}
|
| 688 |
+
/>
|
| 689 |
+
))}
|
| 690 |
+
</div>
|
| 691 |
+
);
|
| 692 |
+
})}
|
| 693 |
+
</div>
|
| 694 |
+
|
| 695 |
+
<div className="editor inspector">
|
| 696 |
+
{selectedAtom == null ? (
|
| 697 |
+
<div className="editor-empty">
|
| 698 |
+
<span className="section-kicker">Inspector</span>
|
| 699 |
+
<p>
|
| 700 |
+
Select an annotation from the list or timeline, or draw a new
|
| 701 |
+
bbox/keypoint on the video.
|
| 702 |
+
</p>
|
| 703 |
+
</div>
|
| 704 |
+
) : (
|
| 705 |
+
<AtomEditor
|
| 706 |
+
atom={selectedAtom}
|
| 707 |
+
cameraKeys={cameraKeys}
|
| 708 |
+
onChange={(updates) => updateAtom(selectedIdx as number, updates)}
|
| 709 |
+
onDelete={() => deleteAtom(selectedAtom)}
|
| 710 |
+
/>
|
| 711 |
+
)}
|
| 712 |
+
</div>
|
| 713 |
+
</div>
|
| 714 |
+
</div>
|
| 715 |
+
);
|
| 716 |
+
};
|
| 717 |
+
|
| 718 |
+
// ---------------------------------------------------------------------------
|
| 719 |
+
// Rail group — one row per atom, click selects.
|
| 720 |
+
// ---------------------------------------------------------------------------
|
| 721 |
+
|
| 722 |
+
const RailGroup: React.FC<{
|
| 723 |
+
title: string;
|
| 724 |
+
dotClass: string;
|
| 725 |
+
entries: { atom: LanguageAtom; idx: number; label: string }[];
|
| 726 |
+
currentTime: number;
|
| 727 |
+
}> = ({ title, dotClass, entries, currentTime }) => {
|
| 728 |
+
const { selectedIdx, selectAtom } = useAnnotations();
|
| 729 |
+
const jump = useJump();
|
| 730 |
+
if (entries.length === 0) return null;
|
| 731 |
+
return (
|
| 732 |
+
<div className="rail-group">
|
| 733 |
+
<div className="rail-group-head">
|
| 734 |
+
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
| 735 |
+
<span className={`style-dot ${dotClass}`} />
|
| 736 |
+
{title}
|
| 737 |
+
</span>
|
| 738 |
+
<span className="count">{entries.length}</span>
|
| 739 |
+
</div>
|
| 740 |
+
{entries.map(({ atom, idx, label }) => {
|
| 741 |
+
const sel = idx === selectedIdx;
|
| 742 |
+
const active = isActiveAt(atom.timestamp, currentTime);
|
| 743 |
+
return (
|
| 744 |
+
<div
|
| 745 |
+
key={idx}
|
| 746 |
+
className={`rail-row ${sel ? "selected" : ""} ${active ? "active-now" : ""}`}
|
| 747 |
+
onClick={() => {
|
| 748 |
+
selectAtom(idx);
|
| 749 |
+
jump(atom.timestamp);
|
| 750 |
+
}}
|
| 751 |
+
>
|
| 752 |
+
<span className="ts">{fmtTime(atom.timestamp)}</span>
|
| 753 |
+
<span className="body">{label}</span>
|
| 754 |
+
</div>
|
| 755 |
+
);
|
| 756 |
+
})}
|
| 757 |
+
</div>
|
| 758 |
+
);
|
| 759 |
+
};
|
| 760 |
+
|
| 761 |
+
// ---------------------------------------------------------------------------
|
| 762 |
+
// AtomEditor — form for the currently selected atom.
|
| 763 |
+
// ---------------------------------------------------------------------------
|
| 764 |
+
|
| 765 |
+
const AtomEditor: React.FC<{
|
| 766 |
+
atom: LanguageAtom;
|
| 767 |
+
cameraKeys: string[];
|
| 768 |
+
onChange: (updates: Partial<LanguageAtom>) => void;
|
| 769 |
+
onDelete: () => void;
|
| 770 |
+
}> = ({ atom, cameraKeys, onChange, onDelete }) => {
|
| 771 |
+
const jump = useJump();
|
| 772 |
+
const { snap } = useAnnotations();
|
| 773 |
+
const isSpeech = isSpeechAtom(atom);
|
| 774 |
+
const cameraLabel = atom.camera ?? "all cameras";
|
| 775 |
+
const roleLabel = isSpeech ? "speech" : atom.role;
|
| 776 |
+
const [timestampDraft, setTimestampDraft] = useState(() =>
|
| 777 |
+
String(atom.timestamp),
|
| 778 |
+
);
|
| 779 |
+
|
| 780 |
+
React.useEffect(() => {
|
| 781 |
+
setTimestampDraft(String(atom.timestamp));
|
| 782 |
+
}, [atom.timestamp]);
|
| 783 |
+
|
| 784 |
+
const commitTimestamp = React.useCallback(
|
| 785 |
+
(raw = timestampDraft) => {
|
| 786 |
+
const next = Number(raw);
|
| 787 |
+
if (!Number.isFinite(next) || next < 0) {
|
| 788 |
+
setTimestampDraft(String(atom.timestamp));
|
| 789 |
+
return;
|
| 790 |
+
}
|
| 791 |
+
onChange({ timestamp: next });
|
| 792 |
+
setTimestampDraft(String(next));
|
| 793 |
+
},
|
| 794 |
+
[atom.timestamp, onChange, timestampDraft],
|
| 795 |
+
);
|
| 796 |
+
|
| 797 |
+
const commitSnappedTimestamp = () => {
|
| 798 |
+
const parsed = Number(timestampDraft);
|
| 799 |
+
const next = snap(Number.isFinite(parsed) ? parsed : atom.timestamp);
|
| 800 |
+
onChange({ timestamp: next });
|
| 801 |
+
setTimestampDraft(String(next));
|
| 802 |
+
};
|
| 803 |
+
|
| 804 |
+
return (
|
| 805 |
+
<div className="inspector-body">
|
| 806 |
+
<div className="editor-head inspector-head">
|
| 807 |
+
<div className="inspector-title">
|
| 808 |
+
<StylePill style={atom.style} />
|
| 809 |
+
<div>
|
| 810 |
+
<strong>{fmtTime(atom.timestamp)}</strong>
|
| 811 |
+
<span>
|
| 812 |
+
{roleLabel} · {cameraLabel}
|
| 813 |
+
</span>
|
| 814 |
+
</div>
|
| 815 |
+
</div>
|
| 816 |
+
<div className="right">
|
| 817 |
+
<button
|
| 818 |
+
className="icon-btn"
|
| 819 |
+
title="Jump to this atom's frame"
|
| 820 |
+
onClick={() => jump(atom.timestamp)}
|
| 821 |
+
>
|
| 822 |
+
▶
|
| 823 |
+
</button>
|
| 824 |
+
<button
|
| 825 |
+
className="icon-btn danger"
|
| 826 |
+
title="Delete this atom"
|
| 827 |
+
onClick={onDelete}
|
| 828 |
+
>
|
| 829 |
+
×
|
| 830 |
+
</button>
|
| 831 |
+
</div>
|
| 832 |
+
</div>
|
| 833 |
+
|
| 834 |
+
<div className="field">
|
| 835 |
+
<label className="field-label">Timestamp (s)</label>
|
| 836 |
+
<div className="ts-row">
|
| 837 |
+
<input
|
| 838 |
+
type="text"
|
| 839 |
+
inputMode="decimal"
|
| 840 |
+
value={timestampDraft}
|
| 841 |
+
onChange={(e) => setTimestampDraft(e.target.value)}
|
| 842 |
+
onBlur={() => commitTimestamp()}
|
| 843 |
+
onKeyDown={(e) => {
|
| 844 |
+
if (e.key === "Enter") commitTimestamp();
|
| 845 |
+
if (e.key === "Escape") setTimestampDraft(String(atom.timestamp));
|
| 846 |
+
}}
|
| 847 |
+
/>
|
| 848 |
+
<button
|
| 849 |
+
type="button"
|
| 850 |
+
className="frame-pill"
|
| 851 |
+
onPointerDown={(e) => {
|
| 852 |
+
e.preventDefault();
|
| 853 |
+
commitSnappedTimestamp();
|
| 854 |
+
}}
|
| 855 |
+
onKeyDown={(e) => {
|
| 856 |
+
if (e.key === "Enter" || e.key === " ") {
|
| 857 |
+
e.preventDefault();
|
| 858 |
+
commitSnappedTimestamp();
|
| 859 |
+
}
|
| 860 |
+
}}
|
| 861 |
+
>
|
| 862 |
+
snap to frame
|
| 863 |
+
</button>
|
| 864 |
+
</div>
|
| 865 |
+
</div>
|
| 866 |
+
|
| 867 |
+
{/* Content / role-specific fields */}
|
| 868 |
+
{(atom.style === "task_aug" ||
|
| 869 |
+
atom.style === "subtask" ||
|
| 870 |
+
atom.style === "plan" ||
|
| 871 |
+
atom.style === "memory" ||
|
| 872 |
+
atom.style === "interjection") && (
|
| 873 |
+
<div className="field">
|
| 874 |
+
<label className="field-label">
|
| 875 |
+
{atom.style === "subtask"
|
| 876 |
+
? "Subtask"
|
| 877 |
+
: atom.style === "task_aug"
|
| 878 |
+
? "Task augmentation"
|
| 879 |
+
: atom.style === "plan"
|
| 880 |
+
? "Plan"
|
| 881 |
+
: atom.style === "memory"
|
| 882 |
+
? "Memory"
|
| 883 |
+
: "Interjection"}
|
| 884 |
+
</label>
|
| 885 |
+
{atom.style === "task_aug" ||
|
| 886 |
+
atom.style === "subtask" ||
|
| 887 |
+
atom.style === "interjection" ? (
|
| 888 |
+
<textarea
|
| 889 |
+
rows={3}
|
| 890 |
+
value={atom.content || ""}
|
| 891 |
+
onChange={(e) => onChange({ content: e.target.value })}
|
| 892 |
+
/>
|
| 893 |
+
) : (
|
| 894 |
+
<textarea
|
| 895 |
+
rows={4}
|
| 896 |
+
value={atom.content || ""}
|
| 897 |
+
onChange={(e) => onChange({ content: e.target.value })}
|
| 898 |
+
/>
|
| 899 |
+
)}
|
| 900 |
+
</div>
|
| 901 |
+
)}
|
| 902 |
+
|
| 903 |
+
{isSpeech && atom.tool_calls && (
|
| 904 |
+
<div className="field">
|
| 905 |
+
<label className="field-label">Robot speech (say tool call)</label>
|
| 906 |
+
<input
|
| 907 |
+
type="text"
|
| 908 |
+
value={speechText(atom) || ""}
|
| 909 |
+
onChange={(e) => {
|
| 910 |
+
const next = atom.tool_calls
|
| 911 |
+
? atom.tool_calls.map((tc, i) =>
|
| 912 |
+
i === 0
|
| 913 |
+
? {
|
| 914 |
+
...tc,
|
| 915 |
+
function: {
|
| 916 |
+
...tc.function,
|
| 917 |
+
arguments: { text: e.target.value },
|
| 918 |
+
},
|
| 919 |
+
}
|
| 920 |
+
: tc,
|
| 921 |
+
)
|
| 922 |
+
: null;
|
| 923 |
+
onChange({ tool_calls: next });
|
| 924 |
+
}}
|
| 925 |
+
/>
|
| 926 |
+
</div>
|
| 927 |
+
)}
|
| 928 |
+
|
| 929 |
+
{atom.style === "vqa" && (
|
| 930 |
+
<>
|
| 931 |
+
<CameraField
|
| 932 |
+
atom={atom}
|
| 933 |
+
cameraKeys={cameraKeys}
|
| 934 |
+
onChange={onChange}
|
| 935 |
+
/>
|
| 936 |
+
<VqaEditorFields atom={atom} onChange={onChange} />
|
| 937 |
+
</>
|
| 938 |
+
)}
|
| 939 |
+
</div>
|
| 940 |
+
);
|
| 941 |
+
};
|
| 942 |
+
|
| 943 |
+
// ---------------------------------------------------------------------------
|
| 944 |
+
// CameraField — surface the row-level camera tag for VQA atoms (PR 3467).
|
| 945 |
+
// ---------------------------------------------------------------------------
|
| 946 |
+
|
| 947 |
+
const CameraField: React.FC<{
|
| 948 |
+
atom: LanguageAtom;
|
| 949 |
+
cameraKeys: string[];
|
| 950 |
+
onChange: (updates: Partial<LanguageAtom>) => void;
|
| 951 |
+
}> = ({ atom, cameraKeys, onChange }) => {
|
| 952 |
+
if (atom.style !== "vqa") return null;
|
| 953 |
+
if (cameraKeys.length === 0) return null;
|
| 954 |
+
const value = atom.camera ?? "";
|
| 955 |
+
return (
|
| 956 |
+
<div className="field">
|
| 957 |
+
<label className="field-label">Camera</label>
|
| 958 |
+
<select
|
| 959 |
+
value={value}
|
| 960 |
+
onChange={(e) =>
|
| 961 |
+
onChange({ camera: e.target.value === "" ? null : e.target.value })
|
| 962 |
+
}
|
| 963 |
+
>
|
| 964 |
+
<option value="">(any — renders on every camera)</option>
|
| 965 |
+
{cameraKeys.map((k) => (
|
| 966 |
+
<option key={k} value={k}>
|
| 967 |
+
{k}
|
| 968 |
+
</option>
|
| 969 |
+
))}
|
| 970 |
+
</select>
|
| 971 |
+
</div>
|
| 972 |
+
);
|
| 973 |
+
};
|
| 974 |
+
|
| 975 |
+
const VqaEditorFields: React.FC<{
|
| 976 |
+
atom: LanguageAtom;
|
| 977 |
+
onChange: (updates: Partial<LanguageAtom>) => void;
|
| 978 |
+
}> = ({ atom, onChange }) => {
|
| 979 |
+
const parsed = parseVqaAnswer(atom.content);
|
| 980 |
+
const kind = parsed ? classifyVqa(parsed) : null;
|
| 981 |
+
|
| 982 |
+
if (atom.role === "user") {
|
| 983 |
+
return (
|
| 984 |
+
<div className="field">
|
| 985 |
+
<label className="field-label">Question</label>
|
| 986 |
+
<input
|
| 987 |
+
type="text"
|
| 988 |
+
value={atom.content || ""}
|
| 989 |
+
onChange={(e) => onChange({ content: e.target.value })}
|
| 990 |
+
/>
|
| 991 |
+
</div>
|
| 992 |
+
);
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
// Assistant atom — answer JSON (raw + structured viewer)
|
| 996 |
+
return (
|
| 997 |
+
<div className="field">
|
| 998 |
+
<label className="field-label">Answer ({kind || "unknown"})</label>
|
| 999 |
+
<textarea
|
| 1000 |
+
rows={5}
|
| 1001 |
+
style={{
|
| 1002 |
+
fontFamily:
|
| 1003 |
+
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
| 1004 |
+
}}
|
| 1005 |
+
value={atom.content || ""}
|
| 1006 |
+
onChange={(e) => onChange({ content: e.target.value })}
|
| 1007 |
+
/>
|
| 1008 |
+
{parsed && kind === "bbox" && (
|
| 1009 |
+
<p className="text-[11px] text-slate-400 mt-1">
|
| 1010 |
+
Tip: bbox values are 0..1 image-relative (xyxy). Edit on the video
|
| 1011 |
+
itself by deleting this and re-drawing.
|
| 1012 |
+
</p>
|
| 1013 |
+
)}
|
| 1014 |
+
{parsed && kind === "keypoint" && (
|
| 1015 |
+
<p className="text-[11px] text-slate-400 mt-1">
|
| 1016 |
+
Tip: point values are 0..1 image-relative (xy).
|
| 1017 |
+
</p>
|
| 1018 |
+
)}
|
| 1019 |
+
</div>
|
| 1020 |
+
);
|
| 1021 |
+
};
|
src/components/annotations-skin.css
ADDED
|
@@ -0,0 +1,1044 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ==========================================================================
|
| 2 |
+
Annotations tab — dark-mode skin scoped under .annotations-skin.
|
| 3 |
+
Borrows the *functional* patterns from the Claude Design handoff
|
| 4 |
+
(per-style pill colors, row layout, timeline shapes) but recolored to
|
| 5 |
+
match the visualizer's existing dark theme. The complaint we're fixing
|
| 6 |
+
here is contrast — earlier inputs were near-black on near-black; these
|
| 7 |
+
are still dark but legible.
|
| 8 |
+
========================================================================== */
|
| 9 |
+
|
| 10 |
+
.annotations-skin {
|
| 11 |
+
/* Brand accents (kept bright; visible on dark) */
|
| 12 |
+
--hf-yellow: #ffd21e;
|
| 13 |
+
--hf-orange: #ff9d00;
|
| 14 |
+
--hf-blue: #5b8cff;
|
| 15 |
+
--hf-cyan: #38bdf8;
|
| 16 |
+
--hf-red: #ef5350;
|
| 17 |
+
--hf-green: #34d399;
|
| 18 |
+
--hf-purple: #b78bff;
|
| 19 |
+
|
| 20 |
+
/* Dark surface scale */
|
| 21 |
+
--surface-0: rgba(255, 255, 255, 0.02);
|
| 22 |
+
--surface-1: rgba(255, 255, 255, 0.06);
|
| 23 |
+
--surface-2: rgba(255, 255, 255, 0.1);
|
| 24 |
+
--border-1: rgba(255, 255, 255, 0.12);
|
| 25 |
+
--border-2: rgba(255, 255, 255, 0.2);
|
| 26 |
+
--fg-1: #f1f5f9;
|
| 27 |
+
--fg-2: #cbd5e1;
|
| 28 |
+
--fg-3: #94a3b8;
|
| 29 |
+
|
| 30 |
+
--radius-xs: 4px;
|
| 31 |
+
--radius-sm: 6px;
|
| 32 |
+
--radius-md: 8px;
|
| 33 |
+
--radius-lg: 12px;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* All form controls in the annotations area: brighter than the rest of the
|
| 37 |
+
visualizer so users can see what they're typing. */
|
| 38 |
+
.annotations-skin input[type="text"],
|
| 39 |
+
.annotations-skin input[type="number"],
|
| 40 |
+
.annotations-skin input[type="password"],
|
| 41 |
+
.annotations-skin input[type="search"],
|
| 42 |
+
.annotations-skin textarea,
|
| 43 |
+
.annotations-skin select {
|
| 44 |
+
background: var(--surface-1) !important;
|
| 45 |
+
color: var(--fg-1) !important;
|
| 46 |
+
border: 1px solid var(--border-1) !important;
|
| 47 |
+
border-radius: var(--radius-sm) !important;
|
| 48 |
+
font: inherit;
|
| 49 |
+
font-size: 13px;
|
| 50 |
+
padding: 6px 9px;
|
| 51 |
+
}
|
| 52 |
+
.annotations-skin input::placeholder,
|
| 53 |
+
.annotations-skin textarea::placeholder {
|
| 54 |
+
color: var(--fg-3);
|
| 55 |
+
}
|
| 56 |
+
.annotations-skin input:focus,
|
| 57 |
+
.annotations-skin textarea:focus,
|
| 58 |
+
.annotations-skin select:focus {
|
| 59 |
+
outline: 2px solid var(--hf-blue) !important;
|
| 60 |
+
outline-offset: -1px;
|
| 61 |
+
border-color: var(--hf-blue) !important;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.annotations-skin kbd {
|
| 65 |
+
background: var(--surface-2);
|
| 66 |
+
border: 1px solid var(--border-1);
|
| 67 |
+
border-radius: var(--radius-xs);
|
| 68 |
+
padding: 1px 5px;
|
| 69 |
+
font-size: 10.5px;
|
| 70 |
+
color: var(--fg-1);
|
| 71 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/* ----- Style pills (per atom style) — bright on dark ----- */
|
| 75 |
+
.annotations-skin .style-pill {
|
| 76 |
+
display: inline-flex;
|
| 77 |
+
align-items: center;
|
| 78 |
+
gap: 6px;
|
| 79 |
+
font-size: 10.5px;
|
| 80 |
+
font-weight: 700;
|
| 81 |
+
text-transform: uppercase;
|
| 82 |
+
letter-spacing: 0.04em;
|
| 83 |
+
padding: 2px 8px;
|
| 84 |
+
border-radius: 999px;
|
| 85 |
+
border: 1px solid currentColor;
|
| 86 |
+
}
|
| 87 |
+
.annotations-skin .style-pill.subtask {
|
| 88 |
+
color: var(--hf-yellow);
|
| 89 |
+
background: rgba(255, 210, 30, 0.12);
|
| 90 |
+
}
|
| 91 |
+
.annotations-skin .style-pill.task_aug {
|
| 92 |
+
color: var(--hf-cyan);
|
| 93 |
+
background: rgba(56, 189, 248, 0.12);
|
| 94 |
+
}
|
| 95 |
+
.annotations-skin .style-pill.plan {
|
| 96 |
+
color: var(--hf-blue);
|
| 97 |
+
background: rgba(91, 140, 255, 0.12);
|
| 98 |
+
}
|
| 99 |
+
.annotations-skin .style-pill.memory {
|
| 100 |
+
color: var(--hf-purple);
|
| 101 |
+
background: rgba(183, 139, 255, 0.12);
|
| 102 |
+
}
|
| 103 |
+
.annotations-skin .style-pill.interjection {
|
| 104 |
+
color: var(--hf-red);
|
| 105 |
+
background: rgba(239, 83, 80, 0.12);
|
| 106 |
+
}
|
| 107 |
+
.annotations-skin .style-pill.vqa {
|
| 108 |
+
color: var(--hf-green);
|
| 109 |
+
background: rgba(52, 211, 153, 0.12);
|
| 110 |
+
}
|
| 111 |
+
.annotations-skin .style-pill.speech {
|
| 112 |
+
color: var(--hf-orange);
|
| 113 |
+
background: rgba(255, 157, 0, 0.12);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.annotations-skin .style-dot {
|
| 117 |
+
width: 8px;
|
| 118 |
+
height: 8px;
|
| 119 |
+
border-radius: 999px;
|
| 120 |
+
display: inline-block;
|
| 121 |
+
}
|
| 122 |
+
.annotations-skin .dot-subtask {
|
| 123 |
+
background: var(--hf-yellow);
|
| 124 |
+
}
|
| 125 |
+
.annotations-skin .dot-task-aug {
|
| 126 |
+
background: var(--hf-cyan);
|
| 127 |
+
}
|
| 128 |
+
.annotations-skin .dot-plan {
|
| 129 |
+
background: var(--hf-blue);
|
| 130 |
+
}
|
| 131 |
+
.annotations-skin .dot-memory {
|
| 132 |
+
background: var(--hf-purple);
|
| 133 |
+
}
|
| 134 |
+
.annotations-skin .dot-interjection {
|
| 135 |
+
background: var(--hf-red);
|
| 136 |
+
}
|
| 137 |
+
.annotations-skin .dot-vqa {
|
| 138 |
+
background: var(--hf-green);
|
| 139 |
+
}
|
| 140 |
+
.annotations-skin .dot-speech {
|
| 141 |
+
background: var(--hf-orange);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/* ----- Annotation rows ----- */
|
| 145 |
+
.annotations-skin .row-anno {
|
| 146 |
+
display: flex;
|
| 147 |
+
gap: 8px;
|
| 148 |
+
padding: 6px 8px;
|
| 149 |
+
border-radius: var(--radius-sm);
|
| 150 |
+
cursor: pointer;
|
| 151 |
+
align-items: center;
|
| 152 |
+
position: relative;
|
| 153 |
+
transition: background 80ms ease;
|
| 154 |
+
}
|
| 155 |
+
.annotations-skin .row-anno:hover {
|
| 156 |
+
background: var(--surface-1);
|
| 157 |
+
}
|
| 158 |
+
.annotations-skin .row-anno.active {
|
| 159 |
+
background: rgba(91, 140, 255, 0.12);
|
| 160 |
+
outline: 1px solid rgba(91, 140, 255, 0.4);
|
| 161 |
+
}
|
| 162 |
+
.annotations-skin .row-anno.active.subtask {
|
| 163 |
+
background: rgba(255, 210, 30, 0.1);
|
| 164 |
+
outline-color: rgba(255, 210, 30, 0.4);
|
| 165 |
+
}
|
| 166 |
+
.annotations-skin .row-anno.active.vqa {
|
| 167 |
+
background: rgba(52, 211, 153, 0.1);
|
| 168 |
+
outline-color: rgba(52, 211, 153, 0.4);
|
| 169 |
+
}
|
| 170 |
+
.annotations-skin .row-anno.active.interjection {
|
| 171 |
+
background: rgba(239, 83, 80, 0.1);
|
| 172 |
+
outline-color: rgba(239, 83, 80, 0.4);
|
| 173 |
+
}
|
| 174 |
+
.annotations-skin .row-anno .ts {
|
| 175 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 176 |
+
font-size: 10.5px;
|
| 177 |
+
color: var(--fg-3);
|
| 178 |
+
width: 56px;
|
| 179 |
+
flex: none;
|
| 180 |
+
text-align: right;
|
| 181 |
+
white-space: nowrap;
|
| 182 |
+
}
|
| 183 |
+
.annotations-skin .row-anno .delete {
|
| 184 |
+
width: 22px;
|
| 185 |
+
height: 22px;
|
| 186 |
+
border: 1px solid var(--border-1);
|
| 187 |
+
background: var(--surface-1);
|
| 188 |
+
color: var(--fg-3);
|
| 189 |
+
border-radius: var(--radius-xs);
|
| 190 |
+
cursor: pointer;
|
| 191 |
+
display: inline-flex;
|
| 192 |
+
align-items: center;
|
| 193 |
+
justify-content: center;
|
| 194 |
+
font-size: 11px;
|
| 195 |
+
flex: none;
|
| 196 |
+
}
|
| 197 |
+
.annotations-skin .row-anno .delete:hover {
|
| 198 |
+
color: var(--hf-red);
|
| 199 |
+
border-color: var(--hf-red);
|
| 200 |
+
}
|
| 201 |
+
.annotations-skin .jump {
|
| 202 |
+
width: 22px;
|
| 203 |
+
height: 22px;
|
| 204 |
+
border: 1px solid var(--border-1);
|
| 205 |
+
background: var(--surface-1);
|
| 206 |
+
color: var(--hf-blue);
|
| 207 |
+
border-radius: var(--radius-xs);
|
| 208 |
+
cursor: pointer;
|
| 209 |
+
display: inline-flex;
|
| 210 |
+
align-items: center;
|
| 211 |
+
justify-content: center;
|
| 212 |
+
font-size: 11px;
|
| 213 |
+
flex: none;
|
| 214 |
+
}
|
| 215 |
+
.annotations-skin .jump:hover {
|
| 216 |
+
background: var(--hf-blue);
|
| 217 |
+
color: #0b0e14;
|
| 218 |
+
border-color: var(--hf-blue);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
/* ----- Section heads ----- */
|
| 222 |
+
.annotations-skin .section-head {
|
| 223 |
+
display: flex;
|
| 224 |
+
align-items: center;
|
| 225 |
+
justify-content: space-between;
|
| 226 |
+
margin-bottom: 8px;
|
| 227 |
+
}
|
| 228 |
+
.annotations-skin .section-head .label {
|
| 229 |
+
display: flex;
|
| 230 |
+
align-items: center;
|
| 231 |
+
gap: 8px;
|
| 232 |
+
font-size: 11.5px;
|
| 233 |
+
font-weight: 700;
|
| 234 |
+
text-transform: uppercase;
|
| 235 |
+
letter-spacing: 0.04em;
|
| 236 |
+
color: var(--fg-1);
|
| 237 |
+
}
|
| 238 |
+
.annotations-skin .section-head .hint {
|
| 239 |
+
font-size: 11.5px;
|
| 240 |
+
color: var(--fg-3);
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
/* ----- Field labels ----- */
|
| 244 |
+
.annotations-skin .field-label {
|
| 245 |
+
display: block;
|
| 246 |
+
font-size: 11px;
|
| 247 |
+
font-weight: 600;
|
| 248 |
+
color: var(--fg-3);
|
| 249 |
+
text-transform: uppercase;
|
| 250 |
+
letter-spacing: 0.04em;
|
| 251 |
+
margin-bottom: 4px;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.annotations-skin .annotation-workbench {
|
| 255 |
+
display: flex;
|
| 256 |
+
flex-direction: column;
|
| 257 |
+
gap: 12px;
|
| 258 |
+
}
|
| 259 |
+
.annotations-skin .annotation-actionbar,
|
| 260 |
+
.annotations-skin .annotation-composer {
|
| 261 |
+
background: var(--surface-0);
|
| 262 |
+
border: 1px solid var(--border-1);
|
| 263 |
+
border-radius: var(--radius-lg);
|
| 264 |
+
}
|
| 265 |
+
.annotations-skin .annotation-actionbar {
|
| 266 |
+
display: flex;
|
| 267 |
+
align-items: center;
|
| 268 |
+
justify-content: space-between;
|
| 269 |
+
gap: 16px;
|
| 270 |
+
padding: 12px 14px;
|
| 271 |
+
}
|
| 272 |
+
.annotations-skin .annotation-actionbar h3 {
|
| 273 |
+
display: flex;
|
| 274 |
+
align-items: center;
|
| 275 |
+
gap: 8px;
|
| 276 |
+
margin: 0;
|
| 277 |
+
font-size: 15px;
|
| 278 |
+
font-weight: 700;
|
| 279 |
+
color: var(--fg-1);
|
| 280 |
+
}
|
| 281 |
+
.annotations-skin .annotation-actionbar p,
|
| 282 |
+
.annotations-skin .annotation-composer p,
|
| 283 |
+
.annotations-skin .list-head p {
|
| 284 |
+
margin: 2px 0 0;
|
| 285 |
+
font-size: 12px;
|
| 286 |
+
color: var(--fg-3);
|
| 287 |
+
}
|
| 288 |
+
.annotations-skin .actionbar-actions {
|
| 289 |
+
display: flex;
|
| 290 |
+
align-items: center;
|
| 291 |
+
gap: 8px;
|
| 292 |
+
flex-wrap: wrap;
|
| 293 |
+
justify-content: flex-end;
|
| 294 |
+
}
|
| 295 |
+
.annotations-skin .dirty-pill {
|
| 296 |
+
border: 1px solid rgba(251, 146, 60, 0.35);
|
| 297 |
+
border-radius: 999px;
|
| 298 |
+
background: rgba(251, 146, 60, 0.12);
|
| 299 |
+
color: #fdba74;
|
| 300 |
+
font-size: 10px;
|
| 301 |
+
font-weight: 700;
|
| 302 |
+
letter-spacing: 0.04em;
|
| 303 |
+
padding: 2px 7px;
|
| 304 |
+
text-transform: uppercase;
|
| 305 |
+
}
|
| 306 |
+
.annotations-skin .backend-offline,
|
| 307 |
+
.annotations-skin .save-status {
|
| 308 |
+
font-size: 11px;
|
| 309 |
+
color: var(--fg-3);
|
| 310 |
+
}
|
| 311 |
+
.annotations-skin .section-kicker {
|
| 312 |
+
display: inline-flex;
|
| 313 |
+
color: var(--fg-2);
|
| 314 |
+
font-size: 10.5px;
|
| 315 |
+
font-weight: 800;
|
| 316 |
+
letter-spacing: 0.06em;
|
| 317 |
+
text-transform: uppercase;
|
| 318 |
+
}
|
| 319 |
+
.annotations-skin .annotation-composer {
|
| 320 |
+
display: grid;
|
| 321 |
+
grid-template-columns: minmax(180px, 260px) 1fr;
|
| 322 |
+
gap: 12px;
|
| 323 |
+
padding: 12px;
|
| 324 |
+
align-items: center;
|
| 325 |
+
}
|
| 326 |
+
.annotations-skin .camera-picker {
|
| 327 |
+
display: flex;
|
| 328 |
+
align-items: center;
|
| 329 |
+
gap: 8px;
|
| 330 |
+
color: var(--fg-3);
|
| 331 |
+
font-size: 12px;
|
| 332 |
+
white-space: nowrap;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
/* ----- Timeline ----- */
|
| 336 |
+
.annotations-skin .tl {
|
| 337 |
+
background: var(--surface-0);
|
| 338 |
+
border: 1px solid var(--border-1);
|
| 339 |
+
border-radius: var(--radius-lg);
|
| 340 |
+
padding: 10px 12px;
|
| 341 |
+
}
|
| 342 |
+
.annotations-skin .tl-head {
|
| 343 |
+
display: flex;
|
| 344 |
+
align-items: center;
|
| 345 |
+
justify-content: space-between;
|
| 346 |
+
font-size: 11px;
|
| 347 |
+
color: var(--fg-3);
|
| 348 |
+
text-transform: uppercase;
|
| 349 |
+
letter-spacing: 0.04em;
|
| 350 |
+
margin-bottom: 8px;
|
| 351 |
+
}
|
| 352 |
+
.annotations-skin .tl-head .ts-display {
|
| 353 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 354 |
+
font-size: 12px;
|
| 355 |
+
color: var(--fg-1);
|
| 356 |
+
text-transform: none;
|
| 357 |
+
letter-spacing: 0;
|
| 358 |
+
}
|
| 359 |
+
.annotations-skin .tl-row {
|
| 360 |
+
display: flex;
|
| 361 |
+
align-items: center;
|
| 362 |
+
gap: 10px;
|
| 363 |
+
height: 24px;
|
| 364 |
+
margin-bottom: 4px;
|
| 365 |
+
}
|
| 366 |
+
.annotations-skin .tl-row .label {
|
| 367 |
+
width: 84px;
|
| 368 |
+
flex: none;
|
| 369 |
+
font-size: 10.5px;
|
| 370 |
+
text-transform: uppercase;
|
| 371 |
+
letter-spacing: 0.04em;
|
| 372 |
+
color: var(--fg-2);
|
| 373 |
+
font-weight: 600;
|
| 374 |
+
display: flex;
|
| 375 |
+
align-items: center;
|
| 376 |
+
gap: 6px;
|
| 377 |
+
}
|
| 378 |
+
.annotations-skin .tl-row .track {
|
| 379 |
+
position: relative;
|
| 380 |
+
flex: 1;
|
| 381 |
+
height: 20px;
|
| 382 |
+
background: var(--surface-1);
|
| 383 |
+
border: 1px solid var(--border-1);
|
| 384 |
+
border-radius: 3px;
|
| 385 |
+
}
|
| 386 |
+
.annotations-skin .tl-seg {
|
| 387 |
+
position: absolute;
|
| 388 |
+
top: 1px;
|
| 389 |
+
bottom: 1px;
|
| 390 |
+
border-radius: 2px;
|
| 391 |
+
font-size: 10px;
|
| 392 |
+
line-height: 16px;
|
| 393 |
+
padding: 0 6px;
|
| 394 |
+
cursor: pointer;
|
| 395 |
+
overflow: hidden;
|
| 396 |
+
text-overflow: ellipsis;
|
| 397 |
+
white-space: nowrap;
|
| 398 |
+
display: flex;
|
| 399 |
+
align-items: center;
|
| 400 |
+
gap: 4px;
|
| 401 |
+
user-select: none;
|
| 402 |
+
font-weight: 500;
|
| 403 |
+
}
|
| 404 |
+
.annotations-skin .tl-seg.task_aug {
|
| 405 |
+
background: rgba(56, 189, 248, 0.18);
|
| 406 |
+
border: 1px solid #38bdf8;
|
| 407 |
+
color: #bae6fd;
|
| 408 |
+
}
|
| 409 |
+
.annotations-skin .tl-seg.subtask {
|
| 410 |
+
background: rgba(255, 210, 30, 0.25);
|
| 411 |
+
border: 1px solid var(--hf-yellow);
|
| 412 |
+
color: var(--hf-yellow);
|
| 413 |
+
}
|
| 414 |
+
.annotations-skin .tl-seg.plan {
|
| 415 |
+
background: rgba(91, 140, 255, 0.25);
|
| 416 |
+
border: 1px solid var(--hf-blue);
|
| 417 |
+
color: #c7d6ff;
|
| 418 |
+
}
|
| 419 |
+
.annotations-skin .tl-seg.memory {
|
| 420 |
+
background: rgba(183, 139, 255, 0.25);
|
| 421 |
+
border: 1px solid var(--hf-purple);
|
| 422 |
+
color: #e3d4ff;
|
| 423 |
+
}
|
| 424 |
+
.annotations-skin .tl-tick {
|
| 425 |
+
position: absolute;
|
| 426 |
+
top: 0;
|
| 427 |
+
bottom: 0;
|
| 428 |
+
width: 4px;
|
| 429 |
+
border-radius: 2px;
|
| 430 |
+
cursor: pointer;
|
| 431 |
+
transform: translateX(-2px);
|
| 432 |
+
}
|
| 433 |
+
.annotations-skin .tl-tick.plan {
|
| 434 |
+
background: var(--hf-blue);
|
| 435 |
+
}
|
| 436 |
+
.annotations-skin .tl-tick.task_aug {
|
| 437 |
+
background: var(--hf-cyan);
|
| 438 |
+
}
|
| 439 |
+
.annotations-skin .tl-tick.memory {
|
| 440 |
+
background: var(--hf-purple);
|
| 441 |
+
}
|
| 442 |
+
.annotations-skin .tl-tick.interjection {
|
| 443 |
+
background: var(--hf-red);
|
| 444 |
+
}
|
| 445 |
+
.annotations-skin .tl-tick.speech {
|
| 446 |
+
background: var(--hf-orange);
|
| 447 |
+
}
|
| 448 |
+
.annotations-skin .tl-tick.vqa {
|
| 449 |
+
background: var(--hf-green);
|
| 450 |
+
}
|
| 451 |
+
.annotations-skin .tl-playhead {
|
| 452 |
+
position: absolute;
|
| 453 |
+
top: 0;
|
| 454 |
+
bottom: 0;
|
| 455 |
+
width: 0;
|
| 456 |
+
border-left: 2px solid var(--hf-yellow);
|
| 457 |
+
pointer-events: none;
|
| 458 |
+
z-index: 5;
|
| 459 |
+
box-shadow: 0 0 8px rgba(255, 210, 30, 0.6);
|
| 460 |
+
}
|
| 461 |
+
.annotations-skin .tl-playhead::before {
|
| 462 |
+
content: "";
|
| 463 |
+
position: absolute;
|
| 464 |
+
top: -3px;
|
| 465 |
+
left: -6px;
|
| 466 |
+
width: 10px;
|
| 467 |
+
height: 10px;
|
| 468 |
+
background: var(--hf-yellow);
|
| 469 |
+
transform: rotate(45deg);
|
| 470 |
+
}
|
| 471 |
+
.annotations-skin .tl-ruler {
|
| 472 |
+
position: relative;
|
| 473 |
+
height: 16px;
|
| 474 |
+
margin-left: 94px;
|
| 475 |
+
border-bottom: 1px solid var(--border-1);
|
| 476 |
+
margin-bottom: 6px;
|
| 477 |
+
}
|
| 478 |
+
.annotations-skin .tl-ruler .tick-mark {
|
| 479 |
+
position: absolute;
|
| 480 |
+
top: 0;
|
| 481 |
+
bottom: 0;
|
| 482 |
+
border-left: 1px solid var(--border-1);
|
| 483 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 484 |
+
font-size: 10px;
|
| 485 |
+
color: var(--fg-3);
|
| 486 |
+
padding-left: 3px;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
/* ----- Persistent / Events section banding (timeline + rail) ----- */
|
| 490 |
+
.annotations-skin .tl-section {
|
| 491 |
+
margin-bottom: 6px;
|
| 492 |
+
}
|
| 493 |
+
.annotations-skin .tl-section-head,
|
| 494 |
+
.annotations-skin .rail-column-head {
|
| 495 |
+
display: flex;
|
| 496 |
+
align-items: baseline;
|
| 497 |
+
gap: 8px;
|
| 498 |
+
padding: 4px 0 4px 2px;
|
| 499 |
+
margin-bottom: 4px;
|
| 500 |
+
border-left: 3px solid var(--border-2, #2a3344);
|
| 501 |
+
padding-left: 8px;
|
| 502 |
+
}
|
| 503 |
+
.annotations-skin .tl-section-head.persistent,
|
| 504 |
+
.annotations-skin .rail-column-head.persistent {
|
| 505 |
+
border-left-color: var(--hf-blue, #5b8cff);
|
| 506 |
+
}
|
| 507 |
+
.annotations-skin .tl-section-head.events,
|
| 508 |
+
.annotations-skin .rail-column-head.events {
|
| 509 |
+
border-left-color: var(--hf-green, #34d399);
|
| 510 |
+
}
|
| 511 |
+
.annotations-skin .tl-section-title,
|
| 512 |
+
.annotations-skin .rail-column-title {
|
| 513 |
+
font-size: 11px;
|
| 514 |
+
font-weight: 700;
|
| 515 |
+
text-transform: uppercase;
|
| 516 |
+
letter-spacing: 0.05em;
|
| 517 |
+
color: var(--fg-1);
|
| 518 |
+
}
|
| 519 |
+
.annotations-skin .tl-section-sub,
|
| 520 |
+
.annotations-skin .rail-column-sub {
|
| 521 |
+
font-size: 10px;
|
| 522 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 523 |
+
color: var(--fg-3);
|
| 524 |
+
}
|
| 525 |
+
.annotations-skin .rail-column {
|
| 526 |
+
margin-bottom: 10px;
|
| 527 |
+
}
|
| 528 |
+
.annotations-skin .rail-column-head {
|
| 529 |
+
margin-top: 2px;
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
/* ----- Quick label popup over the video ----- */
|
| 533 |
+
.annotations-skin .quick-popup {
|
| 534 |
+
position: absolute;
|
| 535 |
+
z-index: 30;
|
| 536 |
+
background: rgba(15, 23, 42, 0.96);
|
| 537 |
+
border: 1px solid var(--border-2);
|
| 538 |
+
border-radius: var(--radius-md);
|
| 539 |
+
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
| 540 |
+
padding: 8px;
|
| 541 |
+
min-width: 220px;
|
| 542 |
+
display: flex;
|
| 543 |
+
flex-direction: column;
|
| 544 |
+
gap: 6px;
|
| 545 |
+
pointer-events: auto;
|
| 546 |
+
backdrop-filter: blur(6px);
|
| 547 |
+
}
|
| 548 |
+
.annotations-skin .quick-popup-head {
|
| 549 |
+
display: flex;
|
| 550 |
+
align-items: center;
|
| 551 |
+
gap: 6px;
|
| 552 |
+
font-size: 10.5px;
|
| 553 |
+
text-transform: uppercase;
|
| 554 |
+
letter-spacing: 0.04em;
|
| 555 |
+
color: var(--fg-3);
|
| 556 |
+
}
|
| 557 |
+
.annotations-skin .quick-popup-actions {
|
| 558 |
+
display: flex;
|
| 559 |
+
gap: 6px;
|
| 560 |
+
justify-content: flex-end;
|
| 561 |
+
}
|
| 562 |
+
.annotations-skin .quick-popup .kind-pill {
|
| 563 |
+
padding: 2px 6px;
|
| 564 |
+
border-radius: var(--radius-xs);
|
| 565 |
+
border: 1px solid currentColor;
|
| 566 |
+
font-size: 10px;
|
| 567 |
+
font-weight: 700;
|
| 568 |
+
text-transform: uppercase;
|
| 569 |
+
letter-spacing: 0.04em;
|
| 570 |
+
}
|
| 571 |
+
.annotations-skin .quick-popup .kind-pill.bbox {
|
| 572 |
+
color: var(--hf-cyan);
|
| 573 |
+
background: rgba(56, 189, 248, 0.16);
|
| 574 |
+
}
|
| 575 |
+
.annotations-skin .quick-popup .kind-pill.keypoint {
|
| 576 |
+
color: var(--hf-yellow);
|
| 577 |
+
background: rgba(255, 210, 30, 0.16);
|
| 578 |
+
}
|
| 579 |
+
.annotations-skin .quick-popup .popup-btn {
|
| 580 |
+
font-size: 11px;
|
| 581 |
+
height: 24px;
|
| 582 |
+
padding: 0 10px;
|
| 583 |
+
border-radius: var(--radius-sm);
|
| 584 |
+
border: 1px solid var(--border-1);
|
| 585 |
+
background: transparent;
|
| 586 |
+
color: var(--fg-2);
|
| 587 |
+
cursor: pointer;
|
| 588 |
+
}
|
| 589 |
+
.annotations-skin .quick-popup .popup-btn:hover {
|
| 590 |
+
background: var(--surface-1);
|
| 591 |
+
color: var(--fg-1);
|
| 592 |
+
}
|
| 593 |
+
.annotations-skin .quick-popup .popup-btn.primary {
|
| 594 |
+
border-color: rgba(56, 189, 248, 0.4);
|
| 595 |
+
background: rgba(56, 189, 248, 0.12);
|
| 596 |
+
color: #c5ecff;
|
| 597 |
+
}
|
| 598 |
+
.annotations-skin .quick-popup .popup-btn.primary:hover {
|
| 599 |
+
background: rgba(56, 189, 248, 0.2);
|
| 600 |
+
}
|
| 601 |
+
.annotations-skin .quick-popup .popup-btn:disabled {
|
| 602 |
+
opacity: 0.4;
|
| 603 |
+
cursor: not-allowed;
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
/* ----- Inline quick-add bar (above the timeline) ----- */
|
| 607 |
+
.annotations-skin .quick-add {
|
| 608 |
+
display: flex;
|
| 609 |
+
align-items: center;
|
| 610 |
+
flex-wrap: wrap;
|
| 611 |
+
gap: 8px;
|
| 612 |
+
padding: 0;
|
| 613 |
+
background: transparent;
|
| 614 |
+
border: 0;
|
| 615 |
+
}
|
| 616 |
+
.annotations-skin .annotation-composer .quick-add {
|
| 617 |
+
justify-content: flex-end;
|
| 618 |
+
}
|
| 619 |
+
.annotations-skin .ts-pill {
|
| 620 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 621 |
+
font-size: 11px;
|
| 622 |
+
padding: 3px 8px;
|
| 623 |
+
border-radius: 999px;
|
| 624 |
+
background: var(--surface-2);
|
| 625 |
+
color: var(--fg-2);
|
| 626 |
+
white-space: nowrap;
|
| 627 |
+
}
|
| 628 |
+
.annotations-skin .quick-add .ts-pill {
|
| 629 |
+
flex: none;
|
| 630 |
+
}
|
| 631 |
+
/* ----- Grounded VQA intro card (under video) ----- */
|
| 632 |
+
.annotations-skin .grounding-intro {
|
| 633 |
+
background: var(--surface-0);
|
| 634 |
+
border: 1px solid var(--border-1);
|
| 635 |
+
border-radius: var(--radius-lg);
|
| 636 |
+
padding: 10px 14px;
|
| 637 |
+
display: flex;
|
| 638 |
+
flex-direction: column;
|
| 639 |
+
gap: 6px;
|
| 640 |
+
}
|
| 641 |
+
.annotations-skin .grounding-intro ul {
|
| 642 |
+
margin: 0;
|
| 643 |
+
padding-left: 18px;
|
| 644 |
+
display: flex;
|
| 645 |
+
flex-direction: column;
|
| 646 |
+
gap: 4px;
|
| 647 |
+
font-size: 12px;
|
| 648 |
+
line-height: 1.5;
|
| 649 |
+
color: var(--fg-3);
|
| 650 |
+
}
|
| 651 |
+
.annotations-skin .grounding-intro li::marker {
|
| 652 |
+
color: var(--fg-3);
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
/* ----- Workspace: annotation list + inspector ----- */
|
| 656 |
+
.annotations-skin .workspace.inspector-workspace {
|
| 657 |
+
grid-template-columns: minmax(300px, 34%) minmax(0, 1fr);
|
| 658 |
+
gap: 12px;
|
| 659 |
+
}
|
| 660 |
+
.annotations-skin .rail.annotation-list {
|
| 661 |
+
padding: 0;
|
| 662 |
+
max-height: min(60vh, 560px);
|
| 663 |
+
overflow-y: auto;
|
| 664 |
+
overscroll-behavior: contain;
|
| 665 |
+
}
|
| 666 |
+
.annotations-skin .list-head {
|
| 667 |
+
display: flex;
|
| 668 |
+
align-items: center;
|
| 669 |
+
justify-content: space-between;
|
| 670 |
+
gap: 12px;
|
| 671 |
+
padding: 12px;
|
| 672 |
+
border-bottom: 1px solid var(--border-1);
|
| 673 |
+
position: sticky;
|
| 674 |
+
top: 0;
|
| 675 |
+
z-index: 2;
|
| 676 |
+
background: rgba(15, 23, 42, 0.96);
|
| 677 |
+
backdrop-filter: blur(6px);
|
| 678 |
+
}
|
| 679 |
+
.annotations-skin .rail.annotation-list .rail-group {
|
| 680 |
+
margin: 8px;
|
| 681 |
+
}
|
| 682 |
+
.annotations-skin .rail.annotation-list .rail-row {
|
| 683 |
+
align-items: flex-start;
|
| 684 |
+
padding: 7px 8px;
|
| 685 |
+
}
|
| 686 |
+
.annotations-skin .rail.annotation-list .rail-row .body {
|
| 687 |
+
white-space: normal;
|
| 688 |
+
overflow: visible;
|
| 689 |
+
text-overflow: clip;
|
| 690 |
+
line-height: 1.3;
|
| 691 |
+
}
|
| 692 |
+
.annotations-skin .rail.annotation-list .rail-row.selected {
|
| 693 |
+
background: rgba(91, 140, 255, 0.2);
|
| 694 |
+
box-shadow: inset 3px 0 0 var(--hf-blue);
|
| 695 |
+
}
|
| 696 |
+
.annotations-skin .rail.annotation-list .rail-row.active-now:not(.selected) {
|
| 697 |
+
border-color: rgba(255, 210, 30, 0.45);
|
| 698 |
+
outline: none;
|
| 699 |
+
}
|
| 700 |
+
.annotations-skin .inspector {
|
| 701 |
+
position: sticky;
|
| 702 |
+
top: 8px;
|
| 703 |
+
}
|
| 704 |
+
.annotations-skin .inspector .editor-empty {
|
| 705 |
+
min-height: 220px;
|
| 706 |
+
display: grid;
|
| 707 |
+
place-content: center;
|
| 708 |
+
gap: 8px;
|
| 709 |
+
}
|
| 710 |
+
.annotations-skin .inspector .editor-empty p {
|
| 711 |
+
margin: 0;
|
| 712 |
+
max-width: 360px;
|
| 713 |
+
}
|
| 714 |
+
.annotations-skin .inspector-body {
|
| 715 |
+
display: flex;
|
| 716 |
+
flex-direction: column;
|
| 717 |
+
gap: 10px;
|
| 718 |
+
}
|
| 719 |
+
.annotations-skin .inspector-head {
|
| 720 |
+
align-items: flex-start;
|
| 721 |
+
}
|
| 722 |
+
.annotations-skin .inspector-title {
|
| 723 |
+
display: flex;
|
| 724 |
+
align-items: flex-start;
|
| 725 |
+
gap: 10px;
|
| 726 |
+
min-width: 0;
|
| 727 |
+
}
|
| 728 |
+
.annotations-skin .inspector-title strong {
|
| 729 |
+
display: block;
|
| 730 |
+
color: var(--fg-1);
|
| 731 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 732 |
+
font-size: 12px;
|
| 733 |
+
line-height: 1.4;
|
| 734 |
+
}
|
| 735 |
+
.annotations-skin .inspector-title span:not(.style-pill) {
|
| 736 |
+
display: block;
|
| 737 |
+
color: var(--fg-3);
|
| 738 |
+
font-size: 11.5px;
|
| 739 |
+
line-height: 1.35;
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
@media (max-width: 900px) {
|
| 743 |
+
.annotations-skin .annotation-actionbar {
|
| 744 |
+
align-items: flex-start;
|
| 745 |
+
flex-direction: column;
|
| 746 |
+
}
|
| 747 |
+
.annotations-skin .annotation-composer,
|
| 748 |
+
.annotations-skin .workspace.inspector-workspace {
|
| 749 |
+
grid-template-columns: 1fr;
|
| 750 |
+
}
|
| 751 |
+
.annotations-skin .annotation-composer .quick-add {
|
| 752 |
+
justify-content: flex-start;
|
| 753 |
+
}
|
| 754 |
+
.annotations-skin .inspector {
|
| 755 |
+
position: static;
|
| 756 |
+
}
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
/* Legacy quick-add styles kept for popup/control compatibility. */
|
| 760 |
+
.annotations-skin .quick-add {
|
| 761 |
+
border-radius: var(--radius-md);
|
| 762 |
+
}
|
| 763 |
+
.annotations-skin .quick-add.legacy {
|
| 764 |
+
padding: 8px 10px;
|
| 765 |
+
background: var(--surface-0);
|
| 766 |
+
border: 1px solid var(--border-1);
|
| 767 |
+
}
|
| 768 |
+
.annotations-skin .quick-add .ts-pill {
|
| 769 |
+
flex: none;
|
| 770 |
+
}
|
| 771 |
+
.annotations-skin .quick-add .grow {
|
| 772 |
+
flex: 1;
|
| 773 |
+
min-width: 200px;
|
| 774 |
+
}
|
| 775 |
+
.annotations-skin .quick-add .add-btn {
|
| 776 |
+
background: var(--hf-blue);
|
| 777 |
+
color: #0b0e14;
|
| 778 |
+
border: 1px solid var(--hf-blue);
|
| 779 |
+
border-radius: var(--radius-sm);
|
| 780 |
+
padding: 6px 14px;
|
| 781 |
+
font-size: 13px;
|
| 782 |
+
font-weight: 700;
|
| 783 |
+
cursor: pointer;
|
| 784 |
+
white-space: nowrap;
|
| 785 |
+
}
|
| 786 |
+
.annotations-skin .quick-add .add-btn:hover {
|
| 787 |
+
filter: brightness(1.1);
|
| 788 |
+
}
|
| 789 |
+
.annotations-skin .quick-add .add-btn:disabled {
|
| 790 |
+
opacity: 0.4;
|
| 791 |
+
cursor: not-allowed;
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
/* ----- Workspace: 2-column rail + editor ----- */
|
| 795 |
+
.annotations-skin .workspace {
|
| 796 |
+
display: grid;
|
| 797 |
+
grid-template-columns: 280px 1fr;
|
| 798 |
+
gap: 10px;
|
| 799 |
+
align-items: start;
|
| 800 |
+
}
|
| 801 |
+
@media (max-width: 900px) {
|
| 802 |
+
.annotations-skin .workspace {
|
| 803 |
+
grid-template-columns: 1fr;
|
| 804 |
+
}
|
| 805 |
+
}
|
| 806 |
+
.annotations-skin .rail {
|
| 807 |
+
background: var(--surface-0);
|
| 808 |
+
border: 1px solid var(--border-1);
|
| 809 |
+
border-radius: var(--radius-md);
|
| 810 |
+
padding: 8px;
|
| 811 |
+
max-height: 60vh;
|
| 812 |
+
overflow-y: auto;
|
| 813 |
+
}
|
| 814 |
+
.annotations-skin .rail-empty {
|
| 815 |
+
padding: 24px 8px;
|
| 816 |
+
font-size: 12px;
|
| 817 |
+
color: var(--fg-3);
|
| 818 |
+
text-align: center;
|
| 819 |
+
}
|
| 820 |
+
.annotations-skin .rail-group {
|
| 821 |
+
margin-bottom: 6px;
|
| 822 |
+
}
|
| 823 |
+
.annotations-skin .rail-group-head {
|
| 824 |
+
display: flex;
|
| 825 |
+
align-items: center;
|
| 826 |
+
justify-content: space-between;
|
| 827 |
+
padding: 6px 8px;
|
| 828 |
+
font-size: 10.5px;
|
| 829 |
+
font-weight: 700;
|
| 830 |
+
text-transform: uppercase;
|
| 831 |
+
letter-spacing: 0.04em;
|
| 832 |
+
color: var(--fg-3);
|
| 833 |
+
}
|
| 834 |
+
.annotations-skin .rail-group-head .count {
|
| 835 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 836 |
+
font-size: 10px;
|
| 837 |
+
}
|
| 838 |
+
.annotations-skin .rail-row {
|
| 839 |
+
display: flex;
|
| 840 |
+
align-items: center;
|
| 841 |
+
gap: 6px;
|
| 842 |
+
padding: 5px 8px;
|
| 843 |
+
border-radius: var(--radius-sm);
|
| 844 |
+
cursor: pointer;
|
| 845 |
+
font-size: 12px;
|
| 846 |
+
color: var(--fg-1);
|
| 847 |
+
border: 1px solid transparent;
|
| 848 |
+
}
|
| 849 |
+
.annotations-skin .rail-row:hover {
|
| 850 |
+
background: var(--surface-1);
|
| 851 |
+
}
|
| 852 |
+
.annotations-skin .rail-row.selected {
|
| 853 |
+
background: rgba(91, 140, 255, 0.16);
|
| 854 |
+
border-color: var(--hf-blue);
|
| 855 |
+
}
|
| 856 |
+
.annotations-skin .rail-row .ts {
|
| 857 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 858 |
+
font-size: 10.5px;
|
| 859 |
+
color: var(--fg-3);
|
| 860 |
+
width: 56px;
|
| 861 |
+
flex: none;
|
| 862 |
+
text-align: right;
|
| 863 |
+
}
|
| 864 |
+
.annotations-skin .rail-row .body {
|
| 865 |
+
flex: 1;
|
| 866 |
+
min-width: 0;
|
| 867 |
+
white-space: nowrap;
|
| 868 |
+
overflow: hidden;
|
| 869 |
+
text-overflow: ellipsis;
|
| 870 |
+
}
|
| 871 |
+
.annotations-skin .rail-row.active-now {
|
| 872 |
+
outline: 2px solid var(--hf-yellow);
|
| 873 |
+
outline-offset: -1px;
|
| 874 |
+
}
|
| 875 |
+
|
| 876 |
+
/* ----- Editor pane (right side of workspace) ----- */
|
| 877 |
+
.annotations-skin .editor {
|
| 878 |
+
background: var(--surface-0);
|
| 879 |
+
border: 1px solid var(--border-1);
|
| 880 |
+
border-radius: var(--radius-md);
|
| 881 |
+
padding: 14px 16px;
|
| 882 |
+
}
|
| 883 |
+
.annotations-skin .editor-empty {
|
| 884 |
+
padding: 40px 16px;
|
| 885 |
+
font-size: 13px;
|
| 886 |
+
color: var(--fg-3);
|
| 887 |
+
text-align: center;
|
| 888 |
+
}
|
| 889 |
+
.annotations-skin .editor-head {
|
| 890 |
+
display: flex;
|
| 891 |
+
align-items: center;
|
| 892 |
+
gap: 8px;
|
| 893 |
+
margin-bottom: 12px;
|
| 894 |
+
padding-bottom: 10px;
|
| 895 |
+
border-bottom: 1px solid var(--border-1);
|
| 896 |
+
}
|
| 897 |
+
.annotations-skin .editor-head .right {
|
| 898 |
+
margin-left: auto;
|
| 899 |
+
display: flex;
|
| 900 |
+
gap: 6px;
|
| 901 |
+
}
|
| 902 |
+
.annotations-skin .editor-head .icon-btn {
|
| 903 |
+
width: 28px;
|
| 904 |
+
height: 28px;
|
| 905 |
+
border-radius: var(--radius-sm);
|
| 906 |
+
border: 1px solid var(--border-1);
|
| 907 |
+
background: var(--surface-1);
|
| 908 |
+
color: var(--fg-2);
|
| 909 |
+
cursor: pointer;
|
| 910 |
+
display: inline-flex;
|
| 911 |
+
align-items: center;
|
| 912 |
+
justify-content: center;
|
| 913 |
+
font-size: 12px;
|
| 914 |
+
}
|
| 915 |
+
.annotations-skin .editor-head .icon-btn:hover {
|
| 916 |
+
color: var(--fg-1);
|
| 917 |
+
border-color: var(--border-2);
|
| 918 |
+
}
|
| 919 |
+
.annotations-skin .editor-head .icon-btn.danger:hover {
|
| 920 |
+
color: var(--hf-red);
|
| 921 |
+
border-color: var(--hf-red);
|
| 922 |
+
}
|
| 923 |
+
.annotations-skin .editor .field {
|
| 924 |
+
margin-bottom: 10px;
|
| 925 |
+
}
|
| 926 |
+
.annotations-skin .editor .field > input,
|
| 927 |
+
.annotations-skin .editor .field > select,
|
| 928 |
+
.annotations-skin .editor .field > textarea {
|
| 929 |
+
width: 100%;
|
| 930 |
+
}
|
| 931 |
+
.annotations-skin .editor .field > textarea {
|
| 932 |
+
min-height: 74px;
|
| 933 |
+
resize: vertical;
|
| 934 |
+
line-height: 1.45;
|
| 935 |
+
}
|
| 936 |
+
.annotations-skin .editor .ts-row {
|
| 937 |
+
display: flex;
|
| 938 |
+
align-items: center;
|
| 939 |
+
gap: 6px;
|
| 940 |
+
}
|
| 941 |
+
.annotations-skin .editor .ts-row input {
|
| 942 |
+
width: 100px;
|
| 943 |
+
}
|
| 944 |
+
.annotations-skin .editor .ts-row .frame-pill {
|
| 945 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 946 |
+
font-size: 11px;
|
| 947 |
+
padding: 3px 8px;
|
| 948 |
+
border: 0;
|
| 949 |
+
border-radius: 999px;
|
| 950 |
+
background: var(--surface-2);
|
| 951 |
+
color: var(--fg-3);
|
| 952 |
+
cursor: pointer;
|
| 953 |
+
}
|
| 954 |
+
.annotations-skin .editor .ts-row .frame-pill:hover {
|
| 955 |
+
color: var(--fg-1);
|
| 956 |
+
background: var(--surface-1);
|
| 957 |
+
}
|
| 958 |
+
|
| 959 |
+
/* ----- Timeline drag handles + tooltip + draggable playhead ----- */
|
| 960 |
+
.annotations-skin .tl-seg .resize {
|
| 961 |
+
position: absolute;
|
| 962 |
+
top: 0;
|
| 963 |
+
bottom: 0;
|
| 964 |
+
width: 6px;
|
| 965 |
+
cursor: ew-resize;
|
| 966 |
+
z-index: 2;
|
| 967 |
+
}
|
| 968 |
+
.annotations-skin .tl-seg .resize.l {
|
| 969 |
+
left: 0;
|
| 970 |
+
}
|
| 971 |
+
.annotations-skin .tl-seg .resize.r {
|
| 972 |
+
right: 0;
|
| 973 |
+
}
|
| 974 |
+
.annotations-skin .tl-seg .resize:hover {
|
| 975 |
+
background: rgba(255, 255, 255, 0.18);
|
| 976 |
+
}
|
| 977 |
+
.annotations-skin .tl-seg.dragging {
|
| 978 |
+
opacity: 0.85;
|
| 979 |
+
outline: 2px solid var(--hf-yellow);
|
| 980 |
+
outline-offset: -1px;
|
| 981 |
+
}
|
| 982 |
+
.annotations-skin .tl-row .track.creating {
|
| 983 |
+
outline: 1px dashed var(--hf-blue);
|
| 984 |
+
}
|
| 985 |
+
.annotations-skin .tl-create-preview {
|
| 986 |
+
position: absolute;
|
| 987 |
+
top: 1px;
|
| 988 |
+
bottom: 1px;
|
| 989 |
+
background: rgba(255, 210, 30, 0.3);
|
| 990 |
+
border: 1px dashed var(--hf-yellow);
|
| 991 |
+
border-radius: 2px;
|
| 992 |
+
pointer-events: none;
|
| 993 |
+
z-index: 4;
|
| 994 |
+
}
|
| 995 |
+
.annotations-skin .tl-playhead-handle {
|
| 996 |
+
position: absolute;
|
| 997 |
+
top: -6px;
|
| 998 |
+
width: 14px;
|
| 999 |
+
height: 14px;
|
| 1000 |
+
background: var(--hf-yellow);
|
| 1001 |
+
border: 2px solid #0b0e14;
|
| 1002 |
+
border-radius: 999px;
|
| 1003 |
+
transform: translate(-7px, 0);
|
| 1004 |
+
cursor: ew-resize;
|
| 1005 |
+
pointer-events: auto;
|
| 1006 |
+
z-index: 6;
|
| 1007 |
+
}
|
| 1008 |
+
.annotations-skin .tl-tooltip {
|
| 1009 |
+
position: fixed;
|
| 1010 |
+
z-index: 1000;
|
| 1011 |
+
background: rgba(15, 23, 42, 0.96);
|
| 1012 |
+
color: var(--fg-1);
|
| 1013 |
+
border: 1px solid var(--border-2);
|
| 1014 |
+
border-radius: var(--radius-sm);
|
| 1015 |
+
padding: 6px 10px;
|
| 1016 |
+
font-size: 12px;
|
| 1017 |
+
max-width: 320px;
|
| 1018 |
+
pointer-events: none;
|
| 1019 |
+
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.4);
|
| 1020 |
+
backdrop-filter: blur(6px);
|
| 1021 |
+
line-height: 1.35;
|
| 1022 |
+
white-space: pre-line;
|
| 1023 |
+
}
|
| 1024 |
+
.annotations-skin .tl-tooltip .meta {
|
| 1025 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 1026 |
+
font-size: 10px;
|
| 1027 |
+
color: var(--fg-3);
|
| 1028 |
+
margin-bottom: 2px;
|
| 1029 |
+
}
|
| 1030 |
+
|
| 1031 |
+
/* ----- Collapsed task-augmentation bar ----- */
|
| 1032 |
+
.annotations-skin .tl-seg.task_aug .aug-count {
|
| 1033 |
+
flex: none;
|
| 1034 |
+
margin-left: auto;
|
| 1035 |
+
padding: 0 5px;
|
| 1036 |
+
border-radius: 999px;
|
| 1037 |
+
background: rgba(56, 189, 248, 0.28);
|
| 1038 |
+
border: 1px solid rgba(56, 189, 248, 0.7);
|
| 1039 |
+
color: #e0f2fe;
|
| 1040 |
+
font-size: 9.5px;
|
| 1041 |
+
font-weight: 700;
|
| 1042 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 1043 |
+
line-height: 14px;
|
| 1044 |
+
}
|
src/components/annotations-timeline.tsx
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Multi-track timeline for v3.1 language atoms — like a video-editing
|
| 5 |
+
* scrubber, but stacked vertically by style and split into two banded
|
| 6 |
+
* sections that mirror the two language columns:
|
| 7 |
+
*
|
| 8 |
+
* PERSISTENT (language_persistent — broadcast across every frame):
|
| 9 |
+
* - task_aug: task phrasings shown as point-in-time ticks at episode start.
|
| 10 |
+
* - subtask: filled spans from each emit time until the next subtask emit
|
| 11 |
+
* (or episode end). Numbered. Resizable edges; the empty subtask track
|
| 12 |
+
* also accepts drag-to-create.
|
| 13 |
+
* - plan: filled (read-only) spans from each plan emit until the next plan
|
| 14 |
+
* refresh (or episode end) — a plan is the active state until superseded,
|
| 15 |
+
* so it reads as a span, not an instantaneous event.
|
| 16 |
+
* - memory: tick marks (state snapshots captured at subtask boundaries).
|
| 17 |
+
*
|
| 18 |
+
* EVENTS (language_events — fire on a single frame):
|
| 19 |
+
* - interjections + speech: combined event track.
|
| 20 |
+
* - vqa: event track.
|
| 21 |
+
*
|
| 22 |
+
* Interactions:
|
| 23 |
+
* - Click a marker → seek + select (handled by the panel's listening to
|
| 24 |
+
* `selectAtom` via context).
|
| 25 |
+
* - Drag a subtask span's left edge → retime that subtask's start.
|
| 26 |
+
* - Drag a subtask span's right edge → retime the *next* subtask's start
|
| 27 |
+
* (since the right edge of subtask[i] *is* the start of subtask[i+1]).
|
| 28 |
+
* - Drag from empty area on the subtask track → create a new subtask span;
|
| 29 |
+
* a label popup appears at the draw end so you can name it.
|
| 30 |
+
* - Drag the playhead handle (or click anywhere on the track band) → scrub
|
| 31 |
+
* the video time. Pauses the player while dragging.
|
| 32 |
+
* - Hover over any marker → custom tooltip shows the atom's content.
|
| 33 |
+
*/
|
| 34 |
+
|
| 35 |
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
| 36 |
+
import { useTime } from "../context/time-context";
|
| 37 |
+
import { useAnnotations } from "../context/annotations-context";
|
| 38 |
+
import {
|
| 39 |
+
classifyVqa,
|
| 40 |
+
isSpeechAtom,
|
| 41 |
+
parseVqaAnswer,
|
| 42 |
+
type LanguageAtom,
|
| 43 |
+
} from "../types/language.types";
|
| 44 |
+
|
| 45 |
+
const LABEL_WIDTH = 84;
|
| 46 |
+
const DRAG_THRESHOLD_PX = 4;
|
| 47 |
+
|
| 48 |
+
// `render` controls how a lane draws: "span-edit" = resizable + drag-to-create
|
| 49 |
+
// (subtask), "span-ro" = read-only spans (task_aug / plan), "tick" = point
|
| 50 |
+
// markers.
|
| 51 |
+
const TRACK_GROUPS = [
|
| 52 |
+
{
|
| 53 |
+
column: "persistent",
|
| 54 |
+
title: "Persistent",
|
| 55 |
+
sub: "language_persistent · broadcast across every frame",
|
| 56 |
+
tracks: [
|
| 57 |
+
// task_aug applies to the whole episode (it's a rephrasing of the task,
|
| 58 |
+
// stored at t0 but persistent across every frame), so it reads as a
|
| 59 |
+
// full-episode span — matching how the annotation pipeline treats it.
|
| 60 |
+
// We collapse all rephrasings into a single full-width bar with a ×N badge;
|
| 61 |
+
// clicking opens a popover listing every phrasing.
|
| 62 |
+
{
|
| 63 |
+
key: "task_aug",
|
| 64 |
+
label: "task aug",
|
| 65 |
+
color: "#38bdf8",
|
| 66 |
+
render: "task-aug",
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
key: "subtask",
|
| 70 |
+
label: "subtask",
|
| 71 |
+
color: "#ffd21e",
|
| 72 |
+
render: "span-edit",
|
| 73 |
+
},
|
| 74 |
+
{ key: "plan", label: "plan", color: "#5b8cff", render: "span-ro" },
|
| 75 |
+
{ key: "memory", label: "memory", color: "#b78bff", render: "tick" },
|
| 76 |
+
],
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
column: "events",
|
| 80 |
+
title: "Events",
|
| 81 |
+
sub: "language_events · fire on a single frame",
|
| 82 |
+
tracks: [
|
| 83 |
+
{
|
| 84 |
+
key: "interjection",
|
| 85 |
+
label: "speech",
|
| 86 |
+
color: "#ef5350",
|
| 87 |
+
render: "tick",
|
| 88 |
+
},
|
| 89 |
+
{ key: "vqa", label: "vqa", color: "#34d399", render: "tick" },
|
| 90 |
+
],
|
| 91 |
+
},
|
| 92 |
+
] as const;
|
| 93 |
+
|
| 94 |
+
interface Props {
|
| 95 |
+
/** Episode duration in seconds. */
|
| 96 |
+
duration: number;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
interface Tooltip {
|
| 100 |
+
x: number;
|
| 101 |
+
y: number;
|
| 102 |
+
meta: string;
|
| 103 |
+
text: string;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
interface DragState {
|
| 107 |
+
kind: "edge" | "playhead" | "create";
|
| 108 |
+
/** Atom index whose timestamp is being moved (edge / create). */
|
| 109 |
+
atomIdx?: number;
|
| 110 |
+
/** Episode-second timestamps captured at drag start (for cancel/clamp). */
|
| 111 |
+
origTs?: number;
|
| 112 |
+
prevTs?: number; // previous subtask's timestamp (lower bound)
|
| 113 |
+
nextTs?: number; // next subtask's timestamp (upper bound, exclusive)
|
| 114 |
+
/** For drag-to-create only. */
|
| 115 |
+
startTs?: number;
|
| 116 |
+
endTs?: number;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
interface PendingCreate {
|
| 120 |
+
start: number;
|
| 121 |
+
end: number;
|
| 122 |
+
/** Anchor for the label popup (canvas-relative px). */
|
| 123 |
+
anchorX: number;
|
| 124 |
+
anchorY: number;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
export const AnnotationsTimeline: React.FC<Props> = ({ duration }) => {
|
| 128 |
+
const { atoms, addAtom, updateAtom, snap, selectAtom } = useAnnotations();
|
| 129 |
+
const { currentTime, seek, setIsPlaying } = useTime();
|
| 130 |
+
const trackBandRef = useRef<HTMLDivElement | null>(null);
|
| 131 |
+
|
| 132 |
+
const [tooltip, setTooltip] = useState<Tooltip | null>(null);
|
| 133 |
+
const [drag, setDrag] = useState<DragState | null>(null);
|
| 134 |
+
const [pendingCreate, setPendingCreate] = useState<PendingCreate | null>(
|
| 135 |
+
null,
|
| 136 |
+
);
|
| 137 |
+
const [createLabel, setCreateLabel] = useState("");
|
| 138 |
+
|
| 139 |
+
// Pause + select helper
|
| 140 |
+
const jumpAndSelect = React.useCallback(
|
| 141 |
+
(ts: number, idx: number | null) => {
|
| 142 |
+
seek(ts, "external");
|
| 143 |
+
setIsPlaying(false);
|
| 144 |
+
if (idx != null) selectAtom(idx);
|
| 145 |
+
},
|
| 146 |
+
[seek, setIsPlaying, selectAtom],
|
| 147 |
+
);
|
| 148 |
+
|
| 149 |
+
// ============ Lane derivation ============
|
| 150 |
+
const lanes = useMemo(() => {
|
| 151 |
+
type SpanMarker = {
|
| 152 |
+
kind: "span";
|
| 153 |
+
start: number;
|
| 154 |
+
end: number;
|
| 155 |
+
label: string;
|
| 156 |
+
atom: LanguageAtom;
|
| 157 |
+
atomIdx: number; // index of the *start* atom in atoms[]
|
| 158 |
+
};
|
| 159 |
+
type TickMarker = {
|
| 160 |
+
kind: "tick";
|
| 161 |
+
t: number;
|
| 162 |
+
label: string;
|
| 163 |
+
atom: LanguageAtom;
|
| 164 |
+
atomIdx: number;
|
| 165 |
+
subtype?: string;
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
const subtask: SpanMarker[] = [];
|
| 169 |
+
const task_aug: SpanMarker[] = [];
|
| 170 |
+
const plan: SpanMarker[] = [];
|
| 171 |
+
const memory: TickMarker[] = [];
|
| 172 |
+
const interjection: TickMarker[] = [];
|
| 173 |
+
const vqa: TickMarker[] = [];
|
| 174 |
+
|
| 175 |
+
// Subtasks → spans, sorted by ts. Track the original atom index so drag
|
| 176 |
+
// operations can update via updateAtom(idx, ...).
|
| 177 |
+
const subWithIdx = atoms
|
| 178 |
+
.map((a, i) => ({ a, i }))
|
| 179 |
+
.filter(({ a }) => a.style === "subtask")
|
| 180 |
+
.sort((x, y) => x.a.timestamp - y.a.timestamp);
|
| 181 |
+
subWithIdx.forEach(({ a, i }, k) => {
|
| 182 |
+
const start = a.timestamp;
|
| 183 |
+
const end =
|
| 184 |
+
k + 1 < subWithIdx.length ? subWithIdx[k + 1].a.timestamp : duration;
|
| 185 |
+
subtask.push({
|
| 186 |
+
kind: "span",
|
| 187 |
+
start,
|
| 188 |
+
end,
|
| 189 |
+
label: a.content || "",
|
| 190 |
+
atom: a,
|
| 191 |
+
atomIdx: i,
|
| 192 |
+
});
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
// Plans → read-only spans: a plan is the active state from its emit time
|
| 196 |
+
// until the next plan refresh (or episode end), exactly like a subtask
|
| 197 |
+
// span. Rendering it as a span (not a tick) makes its persistent nature
|
| 198 |
+
// visible — it isn't a point-in-time event.
|
| 199 |
+
const planWithIdx = atoms
|
| 200 |
+
.map((a, i) => ({ a, i }))
|
| 201 |
+
.filter(({ a }) => a.style === "plan")
|
| 202 |
+
.sort((x, y) => x.a.timestamp - y.a.timestamp);
|
| 203 |
+
planWithIdx.forEach(({ a, i }, k) => {
|
| 204 |
+
const start = a.timestamp;
|
| 205 |
+
const end =
|
| 206 |
+
k + 1 < planWithIdx.length ? planWithIdx[k + 1].a.timestamp : duration;
|
| 207 |
+
plan.push({
|
| 208 |
+
kind: "span",
|
| 209 |
+
start,
|
| 210 |
+
end,
|
| 211 |
+
label: a.content || "plan",
|
| 212 |
+
atom: a,
|
| 213 |
+
atomIdx: i,
|
| 214 |
+
});
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
// Task augmentations → full-episode spans: each is a rephrasing of the
|
| 218 |
+
// task and applies to the whole episode (persistent, stored at t0), so it
|
| 219 |
+
// spans [t0, t_last] rather than sitting as a tick at the start.
|
| 220 |
+
atoms.forEach((a, i) => {
|
| 221 |
+
if (a.style === "task_aug") {
|
| 222 |
+
task_aug.push({
|
| 223 |
+
kind: "span",
|
| 224 |
+
start: 0,
|
| 225 |
+
end: duration,
|
| 226 |
+
label: a.content || "task augmentation",
|
| 227 |
+
atom: a,
|
| 228 |
+
atomIdx: i,
|
| 229 |
+
});
|
| 230 |
+
}
|
| 231 |
+
});
|
| 232 |
+
|
| 233 |
+
atoms.forEach((a, i) => {
|
| 234 |
+
if (a.style === "memory") {
|
| 235 |
+
memory.push({
|
| 236 |
+
kind: "tick",
|
| 237 |
+
t: a.timestamp,
|
| 238 |
+
label: a.content || "memory",
|
| 239 |
+
atom: a,
|
| 240 |
+
atomIdx: i,
|
| 241 |
+
});
|
| 242 |
+
} else if (a.style === "interjection" || isSpeechAtom(a)) {
|
| 243 |
+
interjection.push({
|
| 244 |
+
kind: "tick",
|
| 245 |
+
t: a.timestamp,
|
| 246 |
+
label: a.style === "interjection" ? a.content || "" : "say(…)",
|
| 247 |
+
atom: a,
|
| 248 |
+
atomIdx: i,
|
| 249 |
+
subtype: a.style === "interjection" ? "user" : "speech",
|
| 250 |
+
});
|
| 251 |
+
} else if (a.style === "vqa" && a.role === "assistant") {
|
| 252 |
+
const parsed = parseVqaAnswer(a.content);
|
| 253 |
+
const kind = parsed ? classifyVqa(parsed) : null;
|
| 254 |
+
vqa.push({
|
| 255 |
+
kind: "tick",
|
| 256 |
+
t: a.timestamp,
|
| 257 |
+
label: kind || "vqa",
|
| 258 |
+
atom: a,
|
| 259 |
+
atomIdx: i,
|
| 260 |
+
subtype: kind || undefined,
|
| 261 |
+
});
|
| 262 |
+
}
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
return { task_aug, subtask, plan, memory, interjection, vqa, subWithIdx };
|
| 266 |
+
}, [atoms, duration]);
|
| 267 |
+
|
| 268 |
+
// ============ Pixel <-> time mapping ============
|
| 269 |
+
// The full-width track band (no label margin) is `trackBandRef`. Convert
|
| 270 |
+
// mouse client.x to a 0..duration timestamp.
|
| 271 |
+
const trackXToTs = (clientX: number): number => {
|
| 272 |
+
const r = trackBandRef.current?.getBoundingClientRect();
|
| 273 |
+
if (!r || !duration) return 0;
|
| 274 |
+
const frac = Math.max(0, Math.min(1, (clientX - r.left) / r.width));
|
| 275 |
+
return frac * duration;
|
| 276 |
+
};
|
| 277 |
+
|
| 278 |
+
// ============ Event-track click → seek + select ============
|
| 279 |
+
const onTickClick = (e: React.MouseEvent, atomIdx: number, t: number) => {
|
| 280 |
+
e.stopPropagation();
|
| 281 |
+
jumpAndSelect(t, atomIdx);
|
| 282 |
+
};
|
| 283 |
+
|
| 284 |
+
// ============ task_aug collapsed-bar click ============
|
| 285 |
+
// All phrasings share t0, so there is no spatial way to disambiguate them
|
| 286 |
+
// on the track — clicking just selects the first one (the full list is
|
| 287 |
+
// shown on hover). The inspector + rail still expose every rewording.
|
| 288 |
+
const onTaskAugClick = (e: React.MouseEvent) => {
|
| 289 |
+
e.stopPropagation();
|
| 290 |
+
const augs = lanes.task_aug;
|
| 291 |
+
if (augs.length === 0) return;
|
| 292 |
+
jumpAndSelect(0, augs[0].atomIdx);
|
| 293 |
+
};
|
| 294 |
+
|
| 295 |
+
// ============ Subtask span drag ============
|
| 296 |
+
const onSpanBodyClick = (
|
| 297 |
+
e: React.MouseEvent,
|
| 298 |
+
atomIdx: number,
|
| 299 |
+
start: number,
|
| 300 |
+
) => {
|
| 301 |
+
if (drag || pendingCreate) return;
|
| 302 |
+
e.stopPropagation();
|
| 303 |
+
jumpAndSelect(start, atomIdx);
|
| 304 |
+
};
|
| 305 |
+
|
| 306 |
+
const onEdgeDown = (
|
| 307 |
+
e: React.PointerEvent,
|
| 308 |
+
side: "l" | "r",
|
| 309 |
+
spanK: number,
|
| 310 |
+
) => {
|
| 311 |
+
e.stopPropagation();
|
| 312 |
+
e.preventDefault();
|
| 313 |
+
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
| 314 |
+
const sub = lanes.subWithIdx;
|
| 315 |
+
// Left edge of span k → moves sub[k] timestamp.
|
| 316 |
+
// Right edge of span k → moves sub[k+1] timestamp (if exists).
|
| 317 |
+
const idxToMove = side === "l" ? spanK : spanK + 1;
|
| 318 |
+
if (idxToMove < 0 || idxToMove >= sub.length) return;
|
| 319 |
+
const target = sub[idxToMove];
|
| 320 |
+
const lower = idxToMove > 0 ? sub[idxToMove - 1].a.timestamp : 0;
|
| 321 |
+
const upper =
|
| 322 |
+
idxToMove + 1 < sub.length ? sub[idxToMove + 1].a.timestamp : duration;
|
| 323 |
+
setDrag({
|
| 324 |
+
kind: "edge",
|
| 325 |
+
atomIdx: target.i,
|
| 326 |
+
origTs: target.a.timestamp,
|
| 327 |
+
prevTs: lower,
|
| 328 |
+
nextTs: upper,
|
| 329 |
+
});
|
| 330 |
+
};
|
| 331 |
+
|
| 332 |
+
// ============ Drag-to-create new subtask span ============
|
| 333 |
+
const onSubtaskTrackDown = (e: React.PointerEvent) => {
|
| 334 |
+
// Only fire when the mousedown lands on the track itself, not on a
|
| 335 |
+
// child span/edge (those stop propagation in their own handlers).
|
| 336 |
+
if (drag || pendingCreate) return;
|
| 337 |
+
if (e.button !== 0) return;
|
| 338 |
+
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
| 339 |
+
const ts = snap(trackXToTs(e.clientX));
|
| 340 |
+
setDrag({ kind: "create", startTs: ts, endTs: ts });
|
| 341 |
+
};
|
| 342 |
+
|
| 343 |
+
// ============ Playhead drag ============
|
| 344 |
+
const onPlayheadDown = (e: React.PointerEvent) => {
|
| 345 |
+
e.stopPropagation();
|
| 346 |
+
e.preventDefault();
|
| 347 |
+
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
| 348 |
+
setIsPlaying(false);
|
| 349 |
+
setDrag({ kind: "playhead" });
|
| 350 |
+
};
|
| 351 |
+
|
| 352 |
+
const onTrackBandClick = (e: React.MouseEvent) => {
|
| 353 |
+
// Clicks anywhere on the track band that bubbled up: seek to that point.
|
| 354 |
+
if (drag || pendingCreate) return;
|
| 355 |
+
if ((e.target as HTMLElement).dataset.role === "ruler") {
|
| 356 |
+
// Already handled by the dedicated ruler bar
|
| 357 |
+
}
|
| 358 |
+
const ts = trackXToTs(e.clientX);
|
| 359 |
+
seek(ts, "external");
|
| 360 |
+
setIsPlaying(false);
|
| 361 |
+
};
|
| 362 |
+
|
| 363 |
+
// ============ Global pointermove / pointerup for drag commits ============
|
| 364 |
+
useEffect(() => {
|
| 365 |
+
if (!drag) return;
|
| 366 |
+
const move = (e: PointerEvent) => {
|
| 367 |
+
const ts = trackXToTs(e.clientX);
|
| 368 |
+
if (drag.kind === "playhead") {
|
| 369 |
+
seek(Math.max(0, Math.min(duration, ts)), "external");
|
| 370 |
+
return;
|
| 371 |
+
}
|
| 372 |
+
if (drag.kind === "edge" && drag.atomIdx != null) {
|
| 373 |
+
const lower = drag.prevTs ?? 0;
|
| 374 |
+
const upper = drag.nextTs ?? duration;
|
| 375 |
+
const clamped = Math.max(lower + 0.001, Math.min(upper - 0.001, ts));
|
| 376 |
+
const snapped = snap(clamped);
|
| 377 |
+
updateAtom(drag.atomIdx, { timestamp: snapped });
|
| 378 |
+
return;
|
| 379 |
+
}
|
| 380 |
+
if (drag.kind === "create") {
|
| 381 |
+
const snapped = snap(Math.max(0, Math.min(duration, ts)));
|
| 382 |
+
setDrag((d) => (d ? { ...d, endTs: snapped } : d));
|
| 383 |
+
}
|
| 384 |
+
};
|
| 385 |
+
const up = (e: PointerEvent) => {
|
| 386 |
+
if (drag.kind === "create") {
|
| 387 |
+
const a = Math.min(drag.startTs ?? 0, drag.endTs ?? 0);
|
| 388 |
+
const b = Math.max(drag.startTs ?? 0, drag.endTs ?? 0);
|
| 389 |
+
const distFrac = Math.abs(b - a) / Math.max(0.001, duration);
|
| 390 |
+
// Need at least a few px of drag to count, otherwise treat as click.
|
| 391 |
+
const trackWidth =
|
| 392 |
+
trackBandRef.current?.getBoundingClientRect().width ?? 1;
|
| 393 |
+
if (distFrac * trackWidth >= DRAG_THRESHOLD_PX) {
|
| 394 |
+
// Anchor the label popup at the upper-right of the new span.
|
| 395 |
+
const r = trackBandRef.current?.getBoundingClientRect();
|
| 396 |
+
if (r) {
|
| 397 |
+
const xFrac = b / Math.max(0.001, duration);
|
| 398 |
+
setPendingCreate({
|
| 399 |
+
start: a,
|
| 400 |
+
end: b,
|
| 401 |
+
anchorX: r.left + xFrac * r.width + 4,
|
| 402 |
+
anchorY: r.top - 8,
|
| 403 |
+
});
|
| 404 |
+
}
|
| 405 |
+
} else {
|
| 406 |
+
// Tap, not drag — treat as a seek to that point.
|
| 407 |
+
seek(a, "external");
|
| 408 |
+
setIsPlaying(false);
|
| 409 |
+
}
|
| 410 |
+
} else if (drag.kind === "edge" && drag.atomIdx != null) {
|
| 411 |
+
// Already updated in `move`; nothing more to do beyond final snap.
|
| 412 |
+
}
|
| 413 |
+
setDrag(null);
|
| 414 |
+
// We don't release pointerCapture here because the original target is
|
| 415 |
+
// already cleaned up by the browser when we release the pointer.
|
| 416 |
+
void e;
|
| 417 |
+
};
|
| 418 |
+
window.addEventListener("pointermove", move);
|
| 419 |
+
window.addEventListener("pointerup", up);
|
| 420 |
+
return () => {
|
| 421 |
+
window.removeEventListener("pointermove", move);
|
| 422 |
+
window.removeEventListener("pointerup", up);
|
| 423 |
+
};
|
| 424 |
+
}, [drag, duration, seek, setIsPlaying, snap, updateAtom]);
|
| 425 |
+
|
| 426 |
+
// ============ Tooltip helpers ============
|
| 427 |
+
const showTip = (e: React.MouseEvent, meta: string, text: string) => {
|
| 428 |
+
setTooltip({
|
| 429 |
+
x: e.clientX + 12,
|
| 430 |
+
y: e.clientY + 12,
|
| 431 |
+
meta,
|
| 432 |
+
text,
|
| 433 |
+
});
|
| 434 |
+
};
|
| 435 |
+
const moveTip = (e: React.MouseEvent) => {
|
| 436 |
+
setTooltip((t) => (t ? { ...t, x: e.clientX + 12, y: e.clientY + 12 } : t));
|
| 437 |
+
};
|
| 438 |
+
const hideTip = () => setTooltip(null);
|
| 439 |
+
|
| 440 |
+
// ============ Pending-create label popup commit ============
|
| 441 |
+
const commitPendingCreate = () => {
|
| 442 |
+
if (!pendingCreate) return;
|
| 443 |
+
const text = createLabel.trim();
|
| 444 |
+
if (!text) {
|
| 445 |
+
setPendingCreate(null);
|
| 446 |
+
setCreateLabel("");
|
| 447 |
+
return;
|
| 448 |
+
}
|
| 449 |
+
addAtom({
|
| 450 |
+
role: "assistant",
|
| 451 |
+
content: text,
|
| 452 |
+
style: "subtask",
|
| 453 |
+
timestamp: snap(pendingCreate.start),
|
| 454 |
+
camera: null,
|
| 455 |
+
tool_calls: null,
|
| 456 |
+
});
|
| 457 |
+
// The next subtask boundary is implicit (next sibling's timestamp); if
|
| 458 |
+
// the user wants a different end they can drag the right edge afterwards.
|
| 459 |
+
setPendingCreate(null);
|
| 460 |
+
setCreateLabel("");
|
| 461 |
+
};
|
| 462 |
+
const cancelPendingCreate = () => {
|
| 463 |
+
setPendingCreate(null);
|
| 464 |
+
setCreateLabel("");
|
| 465 |
+
};
|
| 466 |
+
|
| 467 |
+
// ============ Render ============
|
| 468 |
+
if (!duration) return null;
|
| 469 |
+
|
| 470 |
+
return (
|
| 471 |
+
<div className="tl">
|
| 472 |
+
<div className="tl-head">
|
| 473 |
+
<span>Annotations timeline</span>
|
| 474 |
+
<span className="ts-display">
|
| 475 |
+
{currentTime.toFixed(2)}s / {duration.toFixed(2)}s
|
| 476 |
+
</span>
|
| 477 |
+
</div>
|
| 478 |
+
|
| 479 |
+
{/* Time-axis ruler — clicking it scrubs */}
|
| 480 |
+
<div
|
| 481 |
+
className="tl-ruler"
|
| 482 |
+
data-role="ruler"
|
| 483 |
+
onClick={(e) => {
|
| 484 |
+
const ts = trackXToTs(e.clientX);
|
| 485 |
+
seek(ts, "external");
|
| 486 |
+
setIsPlaying(false);
|
| 487 |
+
}}
|
| 488 |
+
>
|
| 489 |
+
{Array.from({ length: Math.floor(duration / 5) + 1 }).map((_, i) => {
|
| 490 |
+
const t = i * 5;
|
| 491 |
+
const left = (t / duration) * 100;
|
| 492 |
+
return (
|
| 493 |
+
<div key={i} className="tick-mark" style={{ left: `${left}%` }}>
|
| 494 |
+
{t}s
|
| 495 |
+
</div>
|
| 496 |
+
);
|
| 497 |
+
})}
|
| 498 |
+
</div>
|
| 499 |
+
|
| 500 |
+
{/* Tracks, grouped into Persistent / Events sections that mirror the
|
| 501 |
+
two language columns. The whole region is position:relative so the
|
| 502 |
+
playhead can span its full height via top/bottom (no brittle
|
| 503 |
+
per-track pixel math that section headers would throw off). The
|
| 504 |
+
playhead's x uses calc() to start at the track band's left edge
|
| 505 |
+
(after the LABEL_WIDTH label column + 10px gap). */}
|
| 506 |
+
{(() => {
|
| 507 |
+
const bandLeft = `${LABEL_WIDTH + 10}px`;
|
| 508 |
+
const playheadLeft = `calc(${bandLeft} + ${
|
| 509 |
+
duration ? currentTime / duration : 0
|
| 510 |
+
} * (100% - ${bandLeft}))`;
|
| 511 |
+
return (
|
| 512 |
+
<div className="tl-tracks" style={{ position: "relative" }}>
|
| 513 |
+
{TRACK_GROUPS.map((group) => (
|
| 514 |
+
<div className="tl-section" key={group.column}>
|
| 515 |
+
<div className={`tl-section-head ${group.column}`}>
|
| 516 |
+
<span className="tl-section-title">{group.title}</span>
|
| 517 |
+
<span className="tl-section-sub">{group.sub}</span>
|
| 518 |
+
</div>
|
| 519 |
+
{group.tracks.map((tk) => (
|
| 520 |
+
<div className="tl-row" key={tk.key}>
|
| 521 |
+
<div className="label">
|
| 522 |
+
<span className={`style-dot dot-${tk.key}`} />
|
| 523 |
+
{tk.label}
|
| 524 |
+
</div>
|
| 525 |
+
<div
|
| 526 |
+
className={`track ${
|
| 527 |
+
tk.key === "subtask" && drag?.kind === "create"
|
| 528 |
+
? "creating"
|
| 529 |
+
: ""
|
| 530 |
+
}`}
|
| 531 |
+
ref={tk.key === "subtask" ? trackBandRef : undefined}
|
| 532 |
+
onClick={
|
| 533 |
+
tk.render === "span-edit" ? undefined : onTrackBandClick
|
| 534 |
+
}
|
| 535 |
+
onPointerDown={
|
| 536 |
+
tk.key === "subtask" ? onSubtaskTrackDown : undefined
|
| 537 |
+
}
|
| 538 |
+
>
|
| 539 |
+
{/* Editable subtask spans (resize + drag-to-create) */}
|
| 540 |
+
{tk.render === "span-edit" &&
|
| 541 |
+
lanes.subtask.map((s, k) => {
|
| 542 |
+
const left = (s.start / duration) * 100;
|
| 543 |
+
const width = Math.max(
|
| 544 |
+
0.3,
|
| 545 |
+
((s.end - s.start) / duration) * 100,
|
| 546 |
+
);
|
| 547 |
+
return (
|
| 548 |
+
<div
|
| 549 |
+
key={k}
|
| 550 |
+
className={`tl-seg subtask ${drag?.kind === "edge" && drag.atomIdx === s.atomIdx ? "dragging" : ""}`}
|
| 551 |
+
style={{ left: `${left}%`, width: `${width}%` }}
|
| 552 |
+
onClick={(e) =>
|
| 553 |
+
onSpanBodyClick(e, s.atomIdx, s.start)
|
| 554 |
+
}
|
| 555 |
+
onMouseEnter={(e) =>
|
| 556 |
+
showTip(
|
| 557 |
+
e,
|
| 558 |
+
`subtask · ${s.start.toFixed(2)}s → ${s.end.toFixed(2)}s`,
|
| 559 |
+
s.label,
|
| 560 |
+
)
|
| 561 |
+
}
|
| 562 |
+
onMouseMove={moveTip}
|
| 563 |
+
onMouseLeave={hideTip}
|
| 564 |
+
>
|
| 565 |
+
<span style={{ opacity: 0.7, fontSize: 10 }}>
|
| 566 |
+
{k}
|
| 567 |
+
</span>
|
| 568 |
+
<span
|
| 569 |
+
style={{
|
| 570 |
+
whiteSpace: "nowrap",
|
| 571 |
+
overflow: "hidden",
|
| 572 |
+
textOverflow: "ellipsis",
|
| 573 |
+
}}
|
| 574 |
+
>
|
| 575 |
+
{s.label}
|
| 576 |
+
</span>
|
| 577 |
+
<div
|
| 578 |
+
className="resize l"
|
| 579 |
+
onPointerDown={(e) => onEdgeDown(e, "l", k)}
|
| 580 |
+
/>
|
| 581 |
+
{k + 1 < lanes.subtask.length && (
|
| 582 |
+
<div
|
| 583 |
+
className="resize r"
|
| 584 |
+
onPointerDown={(e) => onEdgeDown(e, "r", k)}
|
| 585 |
+
/>
|
| 586 |
+
)}
|
| 587 |
+
</div>
|
| 588 |
+
);
|
| 589 |
+
})}
|
| 590 |
+
|
| 591 |
+
{/* Drag-to-create preview rectangle (subtask only) */}
|
| 592 |
+
{tk.render === "span-edit" && drag?.kind === "create" && (
|
| 593 |
+
<div
|
| 594 |
+
className="tl-create-preview"
|
| 595 |
+
style={{
|
| 596 |
+
left: `${(Math.min(drag.startTs ?? 0, drag.endTs ?? 0) / duration) * 100}%`,
|
| 597 |
+
width: `${(Math.abs((drag.endTs ?? 0) - (drag.startTs ?? 0)) / duration) * 100}%`,
|
| 598 |
+
}}
|
| 599 |
+
/>
|
| 600 |
+
)}
|
| 601 |
+
|
| 602 |
+
{/* Collapsed task-augmentation bar: one full-width bar
|
| 603 |
+
(rephrasings carry no temporal info), with a ×N badge
|
| 604 |
+
when there is more than one. Click selects the single
|
| 605 |
+
phrasing, or opens the rewordings popover. */}
|
| 606 |
+
{tk.render === "task-aug" &&
|
| 607 |
+
lanes.task_aug.length > 0 &&
|
| 608 |
+
(() => {
|
| 609 |
+
const augs = lanes.task_aug;
|
| 610 |
+
const primary = augs[0];
|
| 611 |
+
const count = augs.length;
|
| 612 |
+
return (
|
| 613 |
+
<div
|
| 614 |
+
className="tl-seg task_aug"
|
| 615 |
+
style={{ left: "0%", width: "100%" }}
|
| 616 |
+
onClick={onTaskAugClick}
|
| 617 |
+
onMouseEnter={(e) =>
|
| 618 |
+
showTip(
|
| 619 |
+
e,
|
| 620 |
+
`task aug · ${count} phrasing${count > 1 ? "s" : ""}`,
|
| 621 |
+
count > 1
|
| 622 |
+
? augs.map((s) => `• ${s.label}`).join("\n")
|
| 623 |
+
: primary.label,
|
| 624 |
+
)
|
| 625 |
+
}
|
| 626 |
+
onMouseMove={moveTip}
|
| 627 |
+
onMouseLeave={hideTip}
|
| 628 |
+
>
|
| 629 |
+
<span
|
| 630 |
+
style={{
|
| 631 |
+
whiteSpace: "nowrap",
|
| 632 |
+
overflow: "hidden",
|
| 633 |
+
textOverflow: "ellipsis",
|
| 634 |
+
}}
|
| 635 |
+
>
|
| 636 |
+
{primary.label}
|
| 637 |
+
</span>
|
| 638 |
+
{count > 1 && (
|
| 639 |
+
<span className="aug-count">×{count}</span>
|
| 640 |
+
)}
|
| 641 |
+
</div>
|
| 642 |
+
);
|
| 643 |
+
})()}
|
| 644 |
+
|
| 645 |
+
{/* Read-only persistent spans (plan is active until its
|
| 646 |
+
next refresh). Click seeks + selects; no resize. */}
|
| 647 |
+
{tk.render === "span-ro" &&
|
| 648 |
+
(
|
| 649 |
+
lanes[tk.key as "plan"] as Array<{
|
| 650 |
+
kind: "span";
|
| 651 |
+
start: number;
|
| 652 |
+
end: number;
|
| 653 |
+
label: string;
|
| 654 |
+
atom: LanguageAtom;
|
| 655 |
+
atomIdx: number;
|
| 656 |
+
}>
|
| 657 |
+
).map((s, k) => {
|
| 658 |
+
const left = (s.start / duration) * 100;
|
| 659 |
+
const width = Math.max(
|
| 660 |
+
0.3,
|
| 661 |
+
((s.end - s.start) / duration) * 100,
|
| 662 |
+
);
|
| 663 |
+
return (
|
| 664 |
+
<div
|
| 665 |
+
key={k}
|
| 666 |
+
className={`tl-seg ${tk.key}`}
|
| 667 |
+
style={{ left: `${left}%`, width: `${width}%` }}
|
| 668 |
+
onClick={(e) =>
|
| 669 |
+
onSpanBodyClick(e, s.atomIdx, s.start)
|
| 670 |
+
}
|
| 671 |
+
onMouseEnter={(e) =>
|
| 672 |
+
showTip(
|
| 673 |
+
e,
|
| 674 |
+
`${tk.label} · ${s.start.toFixed(2)}s → ${s.end.toFixed(2)}s`,
|
| 675 |
+
s.label,
|
| 676 |
+
)
|
| 677 |
+
}
|
| 678 |
+
onMouseMove={moveTip}
|
| 679 |
+
onMouseLeave={hideTip}
|
| 680 |
+
>
|
| 681 |
+
<span
|
| 682 |
+
style={{
|
| 683 |
+
whiteSpace: "nowrap",
|
| 684 |
+
overflow: "hidden",
|
| 685 |
+
textOverflow: "ellipsis",
|
| 686 |
+
}}
|
| 687 |
+
>
|
| 688 |
+
{s.label}
|
| 689 |
+
</span>
|
| 690 |
+
</div>
|
| 691 |
+
);
|
| 692 |
+
})}
|
| 693 |
+
|
| 694 |
+
{/* Point-in-time tick markers (task_aug / memory /
|
| 695 |
+
interjection / vqa) */}
|
| 696 |
+
{tk.render === "tick" &&
|
| 697 |
+
(
|
| 698 |
+
lanes[
|
| 699 |
+
tk.key as "memory" | "interjection" | "vqa"
|
| 700 |
+
] as Array<{
|
| 701 |
+
kind: "tick";
|
| 702 |
+
t: number;
|
| 703 |
+
label: string;
|
| 704 |
+
atom: LanguageAtom;
|
| 705 |
+
atomIdx: number;
|
| 706 |
+
subtype?: string;
|
| 707 |
+
}>
|
| 708 |
+
).map((m, i) => {
|
| 709 |
+
const left = (m.t / duration) * 100;
|
| 710 |
+
return (
|
| 711 |
+
<div
|
| 712 |
+
key={i}
|
| 713 |
+
className={`tl-tick ${tk.key}`}
|
| 714 |
+
style={{ left: `${left}%` }}
|
| 715 |
+
onClick={(e) => onTickClick(e, m.atomIdx, m.t)}
|
| 716 |
+
onMouseEnter={(e) =>
|
| 717 |
+
showTip(
|
| 718 |
+
e,
|
| 719 |
+
`${tk.label}${m.subtype ? ` · ${m.subtype}` : ""} · ${m.t.toFixed(3)}s`,
|
| 720 |
+
m.label,
|
| 721 |
+
)
|
| 722 |
+
}
|
| 723 |
+
onMouseMove={moveTip}
|
| 724 |
+
onMouseLeave={hideTip}
|
| 725 |
+
/>
|
| 726 |
+
);
|
| 727 |
+
})}
|
| 728 |
+
</div>
|
| 729 |
+
</div>
|
| 730 |
+
))}
|
| 731 |
+
</div>
|
| 732 |
+
))}
|
| 733 |
+
|
| 734 |
+
{/* Playhead — spans the full tracks region via top/bottom. */}
|
| 735 |
+
<div className="tl-playhead" style={{ left: playheadLeft }} />
|
| 736 |
+
<div
|
| 737 |
+
className="tl-playhead-handle"
|
| 738 |
+
style={{ left: playheadLeft, top: -6 }}
|
| 739 |
+
onPointerDown={onPlayheadDown}
|
| 740 |
+
title="Drag to scrub"
|
| 741 |
+
/>
|
| 742 |
+
</div>
|
| 743 |
+
);
|
| 744 |
+
})()}
|
| 745 |
+
|
| 746 |
+
{/* Tooltip */}
|
| 747 |
+
{tooltip && (
|
| 748 |
+
<div className="tl-tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
|
| 749 |
+
<div className="meta">{tooltip.meta}</div>
|
| 750 |
+
{tooltip.text}
|
| 751 |
+
</div>
|
| 752 |
+
)}
|
| 753 |
+
|
| 754 |
+
{/* Drag-to-create label popup */}
|
| 755 |
+
{pendingCreate && (
|
| 756 |
+
<div
|
| 757 |
+
className="quick-popup"
|
| 758 |
+
style={{
|
| 759 |
+
left: pendingCreate.anchorX,
|
| 760 |
+
top: pendingCreate.anchorY,
|
| 761 |
+
position: "fixed",
|
| 762 |
+
}}
|
| 763 |
+
>
|
| 764 |
+
<div className="quick-popup-head">
|
| 765 |
+
<span className="style-pill subtask">subtask</span>
|
| 766 |
+
<span style={{ marginLeft: "auto", fontFamily: "monospace" }}>
|
| 767 |
+
{pendingCreate.start.toFixed(2)}s → {pendingCreate.end.toFixed(2)}
|
| 768 |
+
s
|
| 769 |
+
</span>
|
| 770 |
+
</div>
|
| 771 |
+
<input
|
| 772 |
+
type="text"
|
| 773 |
+
placeholder="label (e.g. grasp the sponge)"
|
| 774 |
+
autoFocus
|
| 775 |
+
value={createLabel}
|
| 776 |
+
onChange={(e) => setCreateLabel(e.target.value)}
|
| 777 |
+
onKeyDown={(e) => {
|
| 778 |
+
if (e.key === "Enter") commitPendingCreate();
|
| 779 |
+
if (e.key === "Escape") cancelPendingCreate();
|
| 780 |
+
}}
|
| 781 |
+
/>
|
| 782 |
+
<div className="quick-popup-actions">
|
| 783 |
+
<button
|
| 784 |
+
onClick={cancelPendingCreate}
|
| 785 |
+
style={{
|
| 786 |
+
fontSize: 11,
|
| 787 |
+
padding: "4px 8px",
|
| 788 |
+
borderRadius: 6,
|
| 789 |
+
border: "1px solid rgba(255,255,255,0.12)",
|
| 790 |
+
background: "transparent",
|
| 791 |
+
color: "var(--fg-2, #cbd5e1)",
|
| 792 |
+
cursor: "pointer",
|
| 793 |
+
}}
|
| 794 |
+
>
|
| 795 |
+
cancel
|
| 796 |
+
</button>
|
| 797 |
+
<button
|
| 798 |
+
onClick={commitPendingCreate}
|
| 799 |
+
disabled={!createLabel.trim()}
|
| 800 |
+
style={{
|
| 801 |
+
fontSize: 11,
|
| 802 |
+
padding: "4px 8px",
|
| 803 |
+
borderRadius: 6,
|
| 804 |
+
border: "1px solid #5b8cff",
|
| 805 |
+
background: "rgba(91,140,255,0.15)",
|
| 806 |
+
color: "#c7d6ff",
|
| 807 |
+
cursor: "pointer",
|
| 808 |
+
opacity: createLabel.trim() ? 1 : 0.4,
|
| 809 |
+
}}
|
| 810 |
+
>
|
| 811 |
+
add ↵
|
| 812 |
+
</button>
|
| 813 |
+
</div>
|
| 814 |
+
</div>
|
| 815 |
+
)}
|
| 816 |
+
</div>
|
| 817 |
+
);
|
| 818 |
+
};
|
src/components/playback-bar.tsx
CHANGED
|
@@ -15,17 +15,20 @@ const PlaybackBar: React.FC = () => {
|
|
| 15 |
|
| 16 |
const sliderActiveRef = React.useRef(false);
|
| 17 |
const wasPlayingRef = React.useRef(false);
|
|
|
|
| 18 |
const [sliderValue, setSliderValue] = React.useState(currentTime);
|
| 19 |
|
| 20 |
// Only update sliderValue from context if not dragging
|
| 21 |
React.useEffect(() => {
|
| 22 |
if (!sliderActiveRef.current) {
|
|
|
|
| 23 |
setSliderValue(currentTime);
|
| 24 |
}
|
| 25 |
}, [currentTime]);
|
| 26 |
|
| 27 |
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 28 |
const t = Number(e.target.value);
|
|
|
|
| 29 |
setSliderValue(t);
|
| 30 |
// Seek videos immediately while dragging (no debounce)
|
| 31 |
seek(t);
|
|
@@ -40,7 +43,7 @@ const PlaybackBar: React.FC = () => {
|
|
| 40 |
const handleSliderMouseUp = () => {
|
| 41 |
sliderActiveRef.current = false;
|
| 42 |
// Final seek to exact slider position
|
| 43 |
-
seek(
|
| 44 |
if (wasPlayingRef.current) {
|
| 45 |
setIsPlaying(true);
|
| 46 |
}
|
|
|
|
| 15 |
|
| 16 |
const sliderActiveRef = React.useRef(false);
|
| 17 |
const wasPlayingRef = React.useRef(false);
|
| 18 |
+
const sliderValueRef = React.useRef(currentTime);
|
| 19 |
const [sliderValue, setSliderValue] = React.useState(currentTime);
|
| 20 |
|
| 21 |
// Only update sliderValue from context if not dragging
|
| 22 |
React.useEffect(() => {
|
| 23 |
if (!sliderActiveRef.current) {
|
| 24 |
+
sliderValueRef.current = currentTime;
|
| 25 |
setSliderValue(currentTime);
|
| 26 |
}
|
| 27 |
}, [currentTime]);
|
| 28 |
|
| 29 |
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 30 |
const t = Number(e.target.value);
|
| 31 |
+
sliderValueRef.current = t;
|
| 32 |
setSliderValue(t);
|
| 33 |
// Seek videos immediately while dragging (no debounce)
|
| 34 |
seek(t);
|
|
|
|
| 43 |
const handleSliderMouseUp = () => {
|
| 44 |
sliderActiveRef.current = false;
|
| 45 |
// Final seek to exact slider position
|
| 46 |
+
seek(sliderValueRef.current);
|
| 47 |
if (wasPlayingRef.current) {
|
| 48 |
setIsPlaying(true);
|
| 49 |
}
|
src/components/simple-videos-player.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { useTime } from "../context/time-context";
|
|
| 5 |
import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
|
| 6 |
import type { VideoInfo } from "@/types";
|
| 7 |
import { proxyHfUrl } from "@/utils/auth";
|
|
|
|
| 8 |
|
| 9 |
const THRESHOLDS = {
|
| 10 |
VIDEO_SYNC_TOLERANCE: 0.2,
|
|
@@ -27,6 +28,31 @@ export const SimpleVideosPlayer = ({
|
|
| 27 |
const { currentTime, seek, externalSeekVersion, isPlaying, setIsPlaying } =
|
| 28 |
useTime();
|
| 29 |
const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
const [hiddenVideos, setHiddenVideos] = React.useState<string[]>([]);
|
| 31 |
const [enlargedVideo, setEnlargedVideo] = React.useState<string | null>(null);
|
| 32 |
const [showHiddenMenu, setShowHiddenMenu] = React.useState(false);
|
|
@@ -369,20 +395,27 @@ export const SimpleVideosPlayer = ({
|
|
| 369 |
</button>
|
| 370 |
</span>
|
| 371 |
</p>
|
| 372 |
-
<
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
</div>
|
| 387 |
);
|
| 388 |
})}
|
|
|
|
| 5 |
import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
|
| 6 |
import type { VideoInfo } from "@/types";
|
| 7 |
import { proxyHfUrl } from "@/utils/auth";
|
| 8 |
+
import { VideoOverlayCanvas } from "./video-overlay-canvas";
|
| 9 |
|
| 10 |
const THRESHOLDS = {
|
| 11 |
VIDEO_SYNC_TOLERANCE: 0.2,
|
|
|
|
| 28 |
const { currentTime, seek, externalSeekVersion, isPlaying, setIsPlaying } =
|
| 29 |
useTime();
|
| 30 |
const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
|
| 31 |
+
// Mirror videoRefs into state so the absolutely-positioned VQA overlay can
|
| 32 |
+
// re-render with the actual <video> element once it mounts. Using a ref
|
| 33 |
+
// alone means the overlay's first render still sees `null`.
|
| 34 |
+
const [videoEls, setVideoEls] = React.useState<(HTMLVideoElement | null)[]>(
|
| 35 |
+
() => [],
|
| 36 |
+
);
|
| 37 |
+
// Stable ref callbacks per index. If we used inline `(el) => { ... }` here
|
| 38 |
+
// React would re-invoke the callback on every render (the function identity
|
| 39 |
+
// changes), which combined with `setVideoEls` would toggle state on each
|
| 40 |
+
// tick and produce a "Maximum update depth exceeded" loop.
|
| 41 |
+
const videoRefCallbacksRef = useRef<
|
| 42 |
+
((el: HTMLVideoElement | null) => void)[]
|
| 43 |
+
>([]);
|
| 44 |
+
while (videoRefCallbacksRef.current.length < videosInfo.length) {
|
| 45 |
+
const idx = videoRefCallbacksRef.current.length;
|
| 46 |
+
videoRefCallbacksRef.current.push((el: HTMLVideoElement | null) => {
|
| 47 |
+
videoRefs.current[idx] = el;
|
| 48 |
+
setVideoEls((prev) => {
|
| 49 |
+
if (prev[idx] === el) return prev;
|
| 50 |
+
const next = prev.slice();
|
| 51 |
+
next[idx] = el;
|
| 52 |
+
return next;
|
| 53 |
+
});
|
| 54 |
+
});
|
| 55 |
+
}
|
| 56 |
const [hiddenVideos, setHiddenVideos] = React.useState<string[]>([]);
|
| 57 |
const [enlargedVideo, setEnlargedVideo] = React.useState<string | null>(null);
|
| 58 |
const [showHiddenMenu, setShowHiddenMenu] = React.useState(false);
|
|
|
|
| 395 |
</button>
|
| 396 |
</span>
|
| 397 |
</p>
|
| 398 |
+
<div className="relative w-full">
|
| 399 |
+
<video
|
| 400 |
+
ref={videoRefCallbacksRef.current[idx]}
|
| 401 |
+
className={`w-full object-contain ${
|
| 402 |
+
isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""
|
| 403 |
+
}`}
|
| 404 |
+
muted
|
| 405 |
+
preload="auto"
|
| 406 |
+
crossOrigin="anonymous"
|
| 407 |
+
>
|
| 408 |
+
<source src={proxyHfUrl(info.url)} type="video/mp4" />
|
| 409 |
+
Your browser does not support the video tag.
|
| 410 |
+
</video>
|
| 411 |
+
{/* VQA bbox/keypoint overlay. Reads atoms + drawMode from
|
| 412 |
+
AnnotationsContext; pointer-events fall through when
|
| 413 |
+
not in draw mode so video controls remain usable. */}
|
| 414 |
+
<VideoOverlayCanvas
|
| 415 |
+
videoEl={videoEls[idx] ?? null}
|
| 416 |
+
cameraKey={info.filename}
|
| 417 |
+
/>
|
| 418 |
+
</div>
|
| 419 |
</div>
|
| 420 |
);
|
| 421 |
})}
|
src/components/video-overlay-canvas.tsx
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Canvas overlay rendered on top of a single `<video>` element. Two roles:
|
| 5 |
+
*
|
| 6 |
+
* 1. Display VQA bbox/keypoint atoms whose `timestamp` matches the current
|
| 7 |
+
* video time (within ~one frame) and whose optional `camera` field matches
|
| 8 |
+
* this video's camera key (or has no camera, which we treat as
|
| 9 |
+
* "render on every camera").
|
| 10 |
+
*
|
| 11 |
+
* 2. When the user is in "draw mode" — bbox or keypoint — capture mouse input
|
| 12 |
+
* and stage a `pendingDraw` in the AnnotationsContext so the AnnotationsPanel
|
| 13 |
+
* can pick it up and persist it as a VQA atom.
|
| 14 |
+
*
|
| 15 |
+
* Coordinates are stored in 0..1 image-relative space. Drawing is computed
|
| 16 |
+
* against the actually-rendered video rect (i.e. taking `object-contain`
|
| 17 |
+
* letterboxing into account).
|
| 18 |
+
*/
|
| 19 |
+
|
| 20 |
+
import React, { useEffect, useRef, useState } from "react";
|
| 21 |
+
import {
|
| 22 |
+
useAnnotations,
|
| 23 |
+
type PendingBboxDraw,
|
| 24 |
+
type PendingPointDraw,
|
| 25 |
+
} from "../context/annotations-context";
|
| 26 |
+
import { useTime } from "../context/time-context";
|
| 27 |
+
import {
|
| 28 |
+
classifyVqa,
|
| 29 |
+
parseVqaAnswer,
|
| 30 |
+
type LanguageAtom,
|
| 31 |
+
type VqaAnswer,
|
| 32 |
+
} from "../types/language.types";
|
| 33 |
+
|
| 34 |
+
interface Props {
|
| 35 |
+
videoEl: HTMLVideoElement | null;
|
| 36 |
+
cameraKey: string;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
interface RenderedRect {
|
| 40 |
+
// Position of the video's actual rendered image area inside the canvas
|
| 41 |
+
// (which is positioned to fill the video's bounding box). The video uses
|
| 42 |
+
// `object-contain` so for a video aspect mismatched with its container,
|
| 43 |
+
// there's letterboxing — we need to compute the inner rect to map 0..1
|
| 44 |
+
// image-relative coordinates correctly.
|
| 45 |
+
left: number;
|
| 46 |
+
top: number;
|
| 47 |
+
width: number;
|
| 48 |
+
height: number;
|
| 49 |
+
// Source image dimensions in pixels — needed so we can also map
|
| 50 |
+
// pixel-space VQA answers (the annotation pipeline emits bboxes in
|
| 51 |
+
// ``[x_min, y_min, x_max, y_max]`` source pixels per Module 3's prompt).
|
| 52 |
+
sourceWidth: number;
|
| 53 |
+
sourceHeight: number;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function computeRenderedRect(
|
| 57 |
+
canvas: HTMLCanvasElement,
|
| 58 |
+
video: HTMLVideoElement,
|
| 59 |
+
): RenderedRect {
|
| 60 |
+
// Use CSS dimensions, not bitmap dimensions — the canvas is HiDPI-scaled
|
| 61 |
+
// (canvas.width = cssWidth * dpr) and the 2D context already has a
|
| 62 |
+
// transform applied, so all drawing happens in CSS-pixel space.
|
| 63 |
+
const cssW = canvas.clientWidth || canvas.width;
|
| 64 |
+
const cssH = canvas.clientHeight || canvas.height;
|
| 65 |
+
const vw = video.videoWidth;
|
| 66 |
+
const vh = video.videoHeight;
|
| 67 |
+
if (!vw || !vh) {
|
| 68 |
+
// Metadata not yet loaded — fall back to the full canvas. The
|
| 69 |
+
// `loadedmetadata` listener forces a redraw the moment vw/vh are known
|
| 70 |
+
// so this fallback is short-lived.
|
| 71 |
+
return {
|
| 72 |
+
left: 0,
|
| 73 |
+
top: 0,
|
| 74 |
+
width: cssW,
|
| 75 |
+
height: cssH,
|
| 76 |
+
sourceWidth: 0,
|
| 77 |
+
sourceHeight: 0,
|
| 78 |
+
};
|
| 79 |
+
}
|
| 80 |
+
const videoAspect = vw / vh;
|
| 81 |
+
const containerAspect = cssW / cssH;
|
| 82 |
+
if (containerAspect > videoAspect) {
|
| 83 |
+
// Container is wider than the video → vertical fill, horizontal letterbox.
|
| 84 |
+
const renderedW = cssH * videoAspect;
|
| 85 |
+
return {
|
| 86 |
+
left: (cssW - renderedW) / 2,
|
| 87 |
+
top: 0,
|
| 88 |
+
width: renderedW,
|
| 89 |
+
height: cssH,
|
| 90 |
+
sourceWidth: vw,
|
| 91 |
+
sourceHeight: vh,
|
| 92 |
+
};
|
| 93 |
+
} else {
|
| 94 |
+
const renderedH = cssW / videoAspect;
|
| 95 |
+
return {
|
| 96 |
+
left: 0,
|
| 97 |
+
top: (cssH - renderedH) / 2,
|
| 98 |
+
width: cssW,
|
| 99 |
+
height: renderedH,
|
| 100 |
+
sourceWidth: vw,
|
| 101 |
+
sourceHeight: vh,
|
| 102 |
+
};
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
const GROUNDING_COORDINATE_SCALE = 1000;
|
| 107 |
+
|
| 108 |
+
function isUnitCoord(x: number, y: number): boolean {
|
| 109 |
+
return Math.max(Math.abs(x), Math.abs(y)) <= 1.5;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function isGroundingCoord(x: number, y: number): boolean {
|
| 113 |
+
return (
|
| 114 |
+
Math.max(Math.abs(x), Math.abs(y)) <= GROUNDING_COORDINATE_SCALE &&
|
| 115 |
+
Math.max(Math.abs(x), Math.abs(y)) > 1.5
|
| 116 |
+
);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function mapPointToCanvas(
|
| 120 |
+
rect: RenderedRect,
|
| 121 |
+
x: number,
|
| 122 |
+
y: number,
|
| 123 |
+
): [number, number] {
|
| 124 |
+
if (isUnitCoord(x, y)) {
|
| 125 |
+
return [rect.left + x * rect.width, rect.top + y * rect.height];
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// Model-generated grounding annotations commonly use a 0..1000 image grid.
|
| 129 |
+
// The videos are often lower resolution, so treating those values as source
|
| 130 |
+
// pixels makes boxes/points too large after rendering.
|
| 131 |
+
const scaleX = isGroundingCoord(x, y)
|
| 132 |
+
? GROUNDING_COORDINATE_SCALE
|
| 133 |
+
: rect.sourceWidth || rect.width;
|
| 134 |
+
const scaleY = isGroundingCoord(x, y)
|
| 135 |
+
? GROUNDING_COORDINATE_SCALE
|
| 136 |
+
: rect.sourceHeight || rect.height;
|
| 137 |
+
|
| 138 |
+
return [
|
| 139 |
+
rect.left + (x / scaleX) * rect.width,
|
| 140 |
+
rect.top + (y / scaleY) * rect.height,
|
| 141 |
+
];
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
function drawBbox(
|
| 145 |
+
ctx: CanvasRenderingContext2D,
|
| 146 |
+
rect: RenderedRect,
|
| 147 |
+
bbox: [number, number, number, number],
|
| 148 |
+
bboxFormat: string,
|
| 149 |
+
label: string,
|
| 150 |
+
color: string,
|
| 151 |
+
) {
|
| 152 |
+
const [bx1, by1, bx2, by2] = bbox;
|
| 153 |
+
const x1 = bx1;
|
| 154 |
+
const y1 = by1;
|
| 155 |
+
let x2 = bx2;
|
| 156 |
+
let y2 = by2;
|
| 157 |
+
if (bboxFormat === "xywh") {
|
| 158 |
+
x2 = x1 + bx2;
|
| 159 |
+
y2 = y1 + by2;
|
| 160 |
+
}
|
| 161 |
+
const [px1, py1] = mapPointToCanvas(rect, x1, y1);
|
| 162 |
+
const [px2, py2] = mapPointToCanvas(rect, x2, y2);
|
| 163 |
+
ctx.lineWidth = 2;
|
| 164 |
+
ctx.strokeStyle = color;
|
| 165 |
+
ctx.fillStyle = color + "26"; // ~15% alpha
|
| 166 |
+
ctx.fillRect(px1, py1, px2 - px1, py2 - py1);
|
| 167 |
+
ctx.strokeRect(px1, py1, px2 - px1, py2 - py1);
|
| 168 |
+
if (label) {
|
| 169 |
+
ctx.font = "12px ui-sans-serif, system-ui";
|
| 170 |
+
const m = ctx.measureText(label);
|
| 171 |
+
ctx.fillStyle = "#0b0e14";
|
| 172 |
+
ctx.fillRect(px1, py1 - 16, m.width + 8, 16);
|
| 173 |
+
ctx.fillStyle = color;
|
| 174 |
+
ctx.fillText(label, px1 + 4, py1 - 4);
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
function drawPoint(
|
| 179 |
+
ctx: CanvasRenderingContext2D,
|
| 180 |
+
rect: RenderedRect,
|
| 181 |
+
point: [number, number],
|
| 182 |
+
label: string,
|
| 183 |
+
color: string,
|
| 184 |
+
) {
|
| 185 |
+
const [x, y] = point;
|
| 186 |
+
const [px, py] = mapPointToCanvas(rect, x, y);
|
| 187 |
+
ctx.lineWidth = 2;
|
| 188 |
+
ctx.strokeStyle = color;
|
| 189 |
+
ctx.fillStyle = color;
|
| 190 |
+
ctx.beginPath();
|
| 191 |
+
ctx.arc(px, py, 5, 0, Math.PI * 2);
|
| 192 |
+
ctx.fill();
|
| 193 |
+
ctx.beginPath();
|
| 194 |
+
ctx.arc(px, py, 11, 0, Math.PI * 2);
|
| 195 |
+
ctx.stroke();
|
| 196 |
+
if (label) {
|
| 197 |
+
ctx.font = "12px ui-sans-serif, system-ui";
|
| 198 |
+
const m = ctx.measureText(label);
|
| 199 |
+
ctx.fillStyle = "#0b0e14";
|
| 200 |
+
ctx.fillRect(px + 8, py - 18, m.width + 8, 16);
|
| 201 |
+
ctx.fillStyle = color;
|
| 202 |
+
ctx.fillText(label, px + 12, py - 6);
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
function vqaMatchesCamera(answer: VqaAnswer, cameraKey: string): boolean {
|
| 207 |
+
// If the answer doesn't carry a camera field, render it on every camera.
|
| 208 |
+
// If it does, only render where it matches.
|
| 209 |
+
const kind = classifyVqa(answer);
|
| 210 |
+
if (kind === "bbox") {
|
| 211 |
+
const dets = (answer as { detections: Array<{ camera?: string }> })
|
| 212 |
+
.detections;
|
| 213 |
+
if (!dets.length) return false;
|
| 214 |
+
return dets.some((d) => !d.camera || d.camera === cameraKey);
|
| 215 |
+
}
|
| 216 |
+
if (kind === "keypoint") {
|
| 217 |
+
const c = (answer as { camera?: string }).camera;
|
| 218 |
+
return !c || c === cameraKey;
|
| 219 |
+
}
|
| 220 |
+
return false; // other VQA kinds aren't drawn
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
/** Pixel distance below which a pointer up counts as a click, not a drag. */
|
| 224 |
+
const CLICK_THRESHOLD_PX = 4;
|
| 225 |
+
|
| 226 |
+
interface FinalizingState {
|
| 227 |
+
/** Where the popup anchors itself, in canvas-relative pixels (top-right of bbox / right of point). */
|
| 228 |
+
anchor: { x: number; y: number };
|
| 229 |
+
/** What the user just drew (label/camera filled in on submit). */
|
| 230 |
+
draw:
|
| 231 |
+
| Pick<PendingBboxDraw, "kind" | "bbox">
|
| 232 |
+
| Pick<PendingPointDraw, "kind" | "point">;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
export const VideoOverlayCanvas: React.FC<Props> = ({ videoEl, cameraKey }) => {
|
| 236 |
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
| 237 |
+
const {
|
| 238 |
+
atoms,
|
| 239 |
+
setPendingDraw,
|
| 240 |
+
pendingDraw,
|
| 241 |
+
drawMode: ctxDrawMode,
|
| 242 |
+
drawLabel,
|
| 243 |
+
activeCamera,
|
| 244 |
+
setActiveCamera,
|
| 245 |
+
setActiveVideoEl,
|
| 246 |
+
snap,
|
| 247 |
+
addAtoms,
|
| 248 |
+
clearPendingDraw,
|
| 249 |
+
selectedIdx,
|
| 250 |
+
} = useAnnotations();
|
| 251 |
+
// Register this camera's <video> as the "active" one so the panel can read
|
| 252 |
+
// its authoritative `currentTime` instead of the throttled context value
|
| 253 |
+
// when adding annotations.
|
| 254 |
+
useEffect(() => {
|
| 255 |
+
if (activeCamera === cameraKey) {
|
| 256 |
+
setActiveVideoEl(videoEl);
|
| 257 |
+
return () => setActiveVideoEl(null);
|
| 258 |
+
}
|
| 259 |
+
}, [activeCamera, cameraKey, videoEl, setActiveVideoEl]);
|
| 260 |
+
const drawMode = ctxDrawMode;
|
| 261 |
+
const { currentTime, isPlaying } = useTime();
|
| 262 |
+
// Pointer-down origin in canvas pixels and 0..1 image-relative coords.
|
| 263 |
+
const dragOriginRef = useRef<{
|
| 264 |
+
px: [number, number];
|
| 265 |
+
norm: [number, number];
|
| 266 |
+
} | null>(null);
|
| 267 |
+
const [dragMoved, setDragMoved] = useState(false);
|
| 268 |
+
const [finalizing, setFinalizing] = useState<FinalizingState | null>(null);
|
| 269 |
+
const [labelInput, setLabelInput] = useState("");
|
| 270 |
+
const [questionKind, setQuestionKind] = useState<"detect" | "point">(
|
| 271 |
+
"detect",
|
| 272 |
+
);
|
| 273 |
+
|
| 274 |
+
// Keep the canvas exactly aligned with the video. Two listeners:
|
| 275 |
+
// - ResizeObserver picks up CSS resize.
|
| 276 |
+
// - `loadedmetadata` picks up the moment `video.videoWidth` becomes
|
| 277 |
+
// non-zero so `computeRenderedRect` stops returning the full-canvas
|
| 278 |
+
// fallback (which is what causes drawn bboxes to land in the wrong
|
| 279 |
+
// spot when the user starts annotating before metadata arrives).
|
| 280 |
+
useEffect(() => {
|
| 281 |
+
if (!videoEl || !canvasRef.current) return;
|
| 282 |
+
const canvas = canvasRef.current;
|
| 283 |
+
const sync = () => {
|
| 284 |
+
const r = videoEl.getBoundingClientRect();
|
| 285 |
+
// Use the device-pixel ratio for crisp drawing on HiDPI displays.
|
| 286 |
+
const dpr = window.devicePixelRatio || 1;
|
| 287 |
+
const w = Math.max(1, Math.round(r.width));
|
| 288 |
+
const h = Math.max(1, Math.round(r.height));
|
| 289 |
+
canvas.width = Math.round(w * dpr);
|
| 290 |
+
canvas.height = Math.round(h * dpr);
|
| 291 |
+
canvas.style.width = `${w}px`;
|
| 292 |
+
canvas.style.height = `${h}px`;
|
| 293 |
+
const ctx = canvas.getContext("2d");
|
| 294 |
+
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
| 295 |
+
redraw();
|
| 296 |
+
};
|
| 297 |
+
const ro = new ResizeObserver(sync);
|
| 298 |
+
ro.observe(videoEl);
|
| 299 |
+
videoEl.addEventListener("loadedmetadata", sync);
|
| 300 |
+
videoEl.addEventListener("loadeddata", sync);
|
| 301 |
+
sync();
|
| 302 |
+
return () => {
|
| 303 |
+
ro.disconnect();
|
| 304 |
+
videoEl.removeEventListener("loadedmetadata", sync);
|
| 305 |
+
videoEl.removeEventListener("loadeddata", sync);
|
| 306 |
+
};
|
| 307 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 308 |
+
}, [videoEl]);
|
| 309 |
+
|
| 310 |
+
const redraw = React.useCallback(() => {
|
| 311 |
+
const canvas = canvasRef.current;
|
| 312 |
+
if (!canvas || !videoEl) return;
|
| 313 |
+
const ctx = canvas.getContext("2d");
|
| 314 |
+
if (!ctx) return;
|
| 315 |
+
// clearRect is in CSS-px after the dpr transform is applied; using the
|
| 316 |
+
// bitmap dims here would clear a region too large but still works.
|
| 317 |
+
ctx.clearRect(
|
| 318 |
+
0,
|
| 319 |
+
0,
|
| 320 |
+
canvas.clientWidth || canvas.width,
|
| 321 |
+
canvas.clientHeight || canvas.height,
|
| 322 |
+
);
|
| 323 |
+
const rect = computeRenderedRect(canvas, videoEl);
|
| 324 |
+
|
| 325 |
+
// Saved VQA atoms within ~one frame of currentTime. We compare against
|
| 326 |
+
// the episode-local `currentTime` from useTime(), not the <video>'s
|
| 327 |
+
// `currentTime`, because the latter is in *global* video-file time for
|
| 328 |
+
// segmented (concatenated) videos.
|
| 329 |
+
const selectedAtom =
|
| 330 |
+
selectedIdx != null && selectedIdx >= 0 && selectedIdx < atoms.length
|
| 331 |
+
? atoms[selectedIdx]
|
| 332 |
+
: null;
|
| 333 |
+
const matches: LanguageAtom[] = atoms.filter((a, idx) => {
|
| 334 |
+
if (a.style !== "vqa" || a.role !== "assistant") return false;
|
| 335 |
+
const isSelectedAnswer =
|
| 336 |
+
idx === selectedIdx ||
|
| 337 |
+
(selectedAtom?.style === "vqa" &&
|
| 338 |
+
selectedAtom.role === "user" &&
|
| 339 |
+
selectedAtom.timestamp === a.timestamp &&
|
| 340 |
+
selectedAtom.camera === a.camera);
|
| 341 |
+
const isCurrentFrame = Math.abs(a.timestamp - (currentTime || 0)) < 0.05;
|
| 342 |
+
if (!isCurrentFrame && !(isSelectedAnswer && !isPlaying)) return false;
|
| 343 |
+
// Row-level camera is authoritative (lerobot PR 3467). Camera-agnostic
|
| 344 |
+
// atoms (a.camera == null) draw on every camera.
|
| 345 |
+
return a.camera == null || a.camera === cameraKey;
|
| 346 |
+
});
|
| 347 |
+
for (const atom of matches) {
|
| 348 |
+
const ans = parseVqaAnswer(atom.content);
|
| 349 |
+
if (!ans) continue;
|
| 350 |
+
// For atoms that already carry a row-level camera tag, the filter above
|
| 351 |
+
// is sufficient. The legacy in-payload camera field still matters for
|
| 352 |
+
// pre-PR-3467 annotations the user may have on disk — keep the fallback
|
| 353 |
+
// check so old datasets don't suddenly render on every camera.
|
| 354 |
+
if (atom.camera == null && !vqaMatchesCamera(ans, cameraKey)) continue;
|
| 355 |
+
const kind = classifyVqa(ans);
|
| 356 |
+
if (kind === "bbox") {
|
| 357 |
+
const dets = (ans as { detections: Array<unknown> })
|
| 358 |
+
.detections as Array<{
|
| 359 |
+
label?: string;
|
| 360 |
+
bbox: [number, number, number, number];
|
| 361 |
+
bbox_format?: string;
|
| 362 |
+
camera?: string;
|
| 363 |
+
}>;
|
| 364 |
+
for (const d of dets) {
|
| 365 |
+
if (d.camera && d.camera !== cameraKey) continue;
|
| 366 |
+
drawBbox(
|
| 367 |
+
ctx,
|
| 368 |
+
rect,
|
| 369 |
+
d.bbox,
|
| 370 |
+
d.bbox_format || "xyxy",
|
| 371 |
+
d.label || "",
|
| 372 |
+
"#22d3ee",
|
| 373 |
+
);
|
| 374 |
+
}
|
| 375 |
+
} else if (kind === "keypoint") {
|
| 376 |
+
const k = ans as { point: [number, number]; label?: string };
|
| 377 |
+
drawPoint(ctx, rect, k.point, k.label || "", "#facc15");
|
| 378 |
+
}
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
// Pending (in-progress) draw for this camera.
|
| 382 |
+
if (
|
| 383 |
+
pendingDraw &&
|
| 384 |
+
(!pendingDraw.camera || pendingDraw.camera === cameraKey)
|
| 385 |
+
) {
|
| 386 |
+
if (pendingDraw.kind === "bbox") {
|
| 387 |
+
drawBbox(
|
| 388 |
+
ctx,
|
| 389 |
+
rect,
|
| 390 |
+
pendingDraw.bbox,
|
| 391 |
+
"xyxy",
|
| 392 |
+
pendingDraw.label || "",
|
| 393 |
+
"#f97316",
|
| 394 |
+
);
|
| 395 |
+
} else {
|
| 396 |
+
drawPoint(
|
| 397 |
+
ctx,
|
| 398 |
+
rect,
|
| 399 |
+
pendingDraw.point,
|
| 400 |
+
pendingDraw.label || "",
|
| 401 |
+
"#f97316",
|
| 402 |
+
);
|
| 403 |
+
}
|
| 404 |
+
}
|
| 405 |
+
}, [
|
| 406 |
+
atoms,
|
| 407 |
+
pendingDraw,
|
| 408 |
+
cameraKey,
|
| 409 |
+
videoEl,
|
| 410 |
+
currentTime,
|
| 411 |
+
selectedIdx,
|
| 412 |
+
isPlaying,
|
| 413 |
+
]);
|
| 414 |
+
|
| 415 |
+
// Redraw on time tick / atoms / pendingDraw / videoEl changes.
|
| 416 |
+
useEffect(() => {
|
| 417 |
+
redraw();
|
| 418 |
+
}, [redraw, currentTime]);
|
| 419 |
+
|
| 420 |
+
// Also redraw the moment the video reports a seek completing — the
|
| 421 |
+
// throttled `currentTime` from TimeContext can lag a paused frame by enough
|
| 422 |
+
// that the overlay first paints empty. Listening directly to `seeked`
|
| 423 |
+
// closes that gap so bbox/keypoint atoms appear instantly after jumping.
|
| 424 |
+
useEffect(() => {
|
| 425 |
+
if (!videoEl) return;
|
| 426 |
+
const onSeeked = () => redraw();
|
| 427 |
+
videoEl.addEventListener("seeked", onSeeked);
|
| 428 |
+
return () => videoEl.removeEventListener("seeked", onSeeked);
|
| 429 |
+
}, [videoEl, redraw]);
|
| 430 |
+
|
| 431 |
+
// Pointer handlers. The disambiguation:
|
| 432 |
+
// - drawMode "auto": drag (>4px) → bbox; release without dragging → keypoint
|
| 433 |
+
// - drawMode "bbox": drag → bbox (no implicit keypoint)
|
| 434 |
+
// - drawMode "keypoint": down-then-up at same spot → keypoint
|
| 435 |
+
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
| 436 |
+
if (drawMode === "off") return;
|
| 437 |
+
const canvas = canvasRef.current;
|
| 438 |
+
if (!canvas || !videoEl) return;
|
| 439 |
+
if (finalizing) return; // wait for the user to confirm/cancel current draw
|
| 440 |
+
e.preventDefault();
|
| 441 |
+
setActiveCamera(cameraKey);
|
| 442 |
+
setActiveVideoEl(videoEl);
|
| 443 |
+
canvas.setPointerCapture(e.pointerId);
|
| 444 |
+
const rect = computeRenderedRect(canvas, videoEl);
|
| 445 |
+
const cr = canvas.getBoundingClientRect();
|
| 446 |
+
const px: [number, number] = [e.clientX - cr.left, e.clientY - cr.top];
|
| 447 |
+
const norm: [number, number] = [
|
| 448 |
+
Math.max(0, Math.min(1, (px[0] - rect.left) / rect.width)),
|
| 449 |
+
Math.max(0, Math.min(1, (px[1] - rect.top) / rect.height)),
|
| 450 |
+
];
|
| 451 |
+
dragOriginRef.current = { px, norm };
|
| 452 |
+
setDragMoved(false);
|
| 453 |
+
// Start a tentative bbox-shaped pendingDraw; if the user releases without
|
| 454 |
+
// moving (auto/keypoint mode) we'll flip it to a keypoint on pointerup.
|
| 455 |
+
setPendingDraw({
|
| 456 |
+
kind: "bbox",
|
| 457 |
+
bbox: [norm[0], norm[1], norm[0], norm[1]],
|
| 458 |
+
label: drawLabel || "",
|
| 459 |
+
camera: cameraKey,
|
| 460 |
+
});
|
| 461 |
+
};
|
| 462 |
+
|
| 463 |
+
const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
| 464 |
+
if (drawMode === "off" || !dragOriginRef.current) return;
|
| 465 |
+
const canvas = canvasRef.current;
|
| 466 |
+
if (!canvas || !videoEl) return;
|
| 467 |
+
const rect = computeRenderedRect(canvas, videoEl);
|
| 468 |
+
const cr = canvas.getBoundingClientRect();
|
| 469 |
+
const cx = e.clientX - cr.left;
|
| 470 |
+
const cy = e.clientY - cr.top;
|
| 471 |
+
const dx = cx - dragOriginRef.current.px[0];
|
| 472 |
+
const dy = cy - dragOriginRef.current.px[1];
|
| 473 |
+
if (
|
| 474 |
+
!dragMoved &&
|
| 475 |
+
Math.hypot(dx, dy) > CLICK_THRESHOLD_PX &&
|
| 476 |
+
drawMode !== "keypoint"
|
| 477 |
+
) {
|
| 478 |
+
setDragMoved(true);
|
| 479 |
+
}
|
| 480 |
+
if (drawMode === "keypoint") return;
|
| 481 |
+
const x = Math.max(0, Math.min(1, (cx - rect.left) / rect.width));
|
| 482 |
+
const y = Math.max(0, Math.min(1, (cy - rect.top) / rect.height));
|
| 483 |
+
const start = dragOriginRef.current.norm;
|
| 484 |
+
setPendingDraw({
|
| 485 |
+
kind: "bbox",
|
| 486 |
+
bbox: [
|
| 487 |
+
Math.min(start[0], x),
|
| 488 |
+
Math.min(start[1], y),
|
| 489 |
+
Math.max(start[0], x),
|
| 490 |
+
Math.max(start[1], y),
|
| 491 |
+
],
|
| 492 |
+
label: drawLabel || "",
|
| 493 |
+
camera: cameraKey,
|
| 494 |
+
});
|
| 495 |
+
};
|
| 496 |
+
|
| 497 |
+
const onPointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
| 498 |
+
if (drawMode === "off" || !dragOriginRef.current) {
|
| 499 |
+
dragOriginRef.current = null;
|
| 500 |
+
setDragMoved(false);
|
| 501 |
+
return;
|
| 502 |
+
}
|
| 503 |
+
const canvas = canvasRef.current;
|
| 504 |
+
if (!canvas || !videoEl) {
|
| 505 |
+
dragOriginRef.current = null;
|
| 506 |
+
setDragMoved(false);
|
| 507 |
+
return;
|
| 508 |
+
}
|
| 509 |
+
const rect = computeRenderedRect(canvas, videoEl);
|
| 510 |
+
const cr = canvas.getBoundingClientRect();
|
| 511 |
+
const cx = e.clientX - cr.left;
|
| 512 |
+
const cy = e.clientY - cr.top;
|
| 513 |
+
|
| 514 |
+
// Decide the gesture's kind.
|
| 515 |
+
const treatAsBbox =
|
| 516 |
+
drawMode === "bbox" || (drawMode === "auto" && dragMoved);
|
| 517 |
+
|
| 518 |
+
if (treatAsBbox) {
|
| 519 |
+
const x = Math.max(0, Math.min(1, (cx - rect.left) / rect.width));
|
| 520 |
+
const y = Math.max(0, Math.min(1, (cy - rect.top) / rect.height));
|
| 521 |
+
const start = dragOriginRef.current.norm;
|
| 522 |
+
const bbox: [number, number, number, number] = [
|
| 523 |
+
Math.min(start[0], x),
|
| 524 |
+
Math.min(start[1], y),
|
| 525 |
+
Math.max(start[0], x),
|
| 526 |
+
Math.max(start[1], y),
|
| 527 |
+
];
|
| 528 |
+
// Anchor popup at the bbox top-right in canvas-px space.
|
| 529 |
+
setPendingDraw({
|
| 530 |
+
kind: "bbox",
|
| 531 |
+
bbox,
|
| 532 |
+
label: drawLabel || "",
|
| 533 |
+
camera: cameraKey,
|
| 534 |
+
});
|
| 535 |
+
setFinalizing({
|
| 536 |
+
anchor: {
|
| 537 |
+
x: rect.left + bbox[2] * rect.width,
|
| 538 |
+
y: rect.top + bbox[1] * rect.height,
|
| 539 |
+
},
|
| 540 |
+
draw: { kind: "bbox", bbox },
|
| 541 |
+
});
|
| 542 |
+
setQuestionKind("detect");
|
| 543 |
+
} else {
|
| 544 |
+
// Click → keypoint at the up position.
|
| 545 |
+
const x = Math.max(0, Math.min(1, (cx - rect.left) / rect.width));
|
| 546 |
+
const y = Math.max(0, Math.min(1, (cy - rect.top) / rect.height));
|
| 547 |
+
const point: [number, number] = [x, y];
|
| 548 |
+
setPendingDraw({
|
| 549 |
+
kind: "keypoint",
|
| 550 |
+
point,
|
| 551 |
+
label: drawLabel || "",
|
| 552 |
+
camera: cameraKey,
|
| 553 |
+
});
|
| 554 |
+
setFinalizing({
|
| 555 |
+
anchor: {
|
| 556 |
+
x: rect.left + point[0] * rect.width + 14,
|
| 557 |
+
y: rect.top + point[1] * rect.height,
|
| 558 |
+
},
|
| 559 |
+
draw: { kind: "keypoint", point },
|
| 560 |
+
});
|
| 561 |
+
setQuestionKind("point");
|
| 562 |
+
}
|
| 563 |
+
dragOriginRef.current = null;
|
| 564 |
+
setDragMoved(false);
|
| 565 |
+
};
|
| 566 |
+
|
| 567 |
+
const onPointerCancel = () => {
|
| 568 |
+
dragOriginRef.current = null;
|
| 569 |
+
setDragMoved(false);
|
| 570 |
+
};
|
| 571 |
+
|
| 572 |
+
const closeFinalize = React.useCallback(() => {
|
| 573 |
+
setFinalizing(null);
|
| 574 |
+
setLabelInput("");
|
| 575 |
+
clearPendingDraw();
|
| 576 |
+
}, [clearPendingDraw]);
|
| 577 |
+
|
| 578 |
+
const submitFinalize = () => {
|
| 579 |
+
if (!finalizing) return;
|
| 580 |
+
const label = labelInput.trim();
|
| 581 |
+
if (!label) return;
|
| 582 |
+
// Episode-local time only — `videoEl.currentTime` is the *global* time
|
| 583 |
+
// inside a shared/concatenated video file, which would push every
|
| 584 |
+
// annotation past the parquet's [0..duration] frame range and collapse
|
| 585 |
+
// them to the boundary on snap. `currentTime` from useTime() is already
|
| 586 |
+
// normalized to episode-local space by SimpleVideosPlayer.
|
| 587 |
+
const ts = snap(currentTime);
|
| 588 |
+
const question =
|
| 589 |
+
questionKind === "point"
|
| 590 |
+
? `Point to the ${label}.`
|
| 591 |
+
: `Where is the ${label} in the image?`;
|
| 592 |
+
let answer:
|
| 593 |
+
| {
|
| 594 |
+
detections: Array<{
|
| 595 |
+
label: string;
|
| 596 |
+
bbox_format: "xyxy";
|
| 597 |
+
bbox: [number, number, number, number];
|
| 598 |
+
camera?: string;
|
| 599 |
+
}>;
|
| 600 |
+
}
|
| 601 |
+
| {
|
| 602 |
+
label: string;
|
| 603 |
+
point_format: "xy";
|
| 604 |
+
point: [number, number];
|
| 605 |
+
camera?: string;
|
| 606 |
+
};
|
| 607 |
+
if (finalizing.draw.kind === "bbox") {
|
| 608 |
+
answer = {
|
| 609 |
+
detections: [
|
| 610 |
+
{
|
| 611 |
+
label,
|
| 612 |
+
bbox_format: "xyxy",
|
| 613 |
+
bbox: finalizing.draw.bbox.map((v) => Number(v.toFixed(4))) as [
|
| 614 |
+
number,
|
| 615 |
+
number,
|
| 616 |
+
number,
|
| 617 |
+
number,
|
| 618 |
+
],
|
| 619 |
+
camera: cameraKey,
|
| 620 |
+
},
|
| 621 |
+
],
|
| 622 |
+
};
|
| 623 |
+
} else {
|
| 624 |
+
answer = {
|
| 625 |
+
label,
|
| 626 |
+
point_format: "xy",
|
| 627 |
+
point: finalizing.draw.point.map((v) => Number(v.toFixed(4))) as [
|
| 628 |
+
number,
|
| 629 |
+
number,
|
| 630 |
+
],
|
| 631 |
+
camera: cameraKey,
|
| 632 |
+
};
|
| 633 |
+
}
|
| 634 |
+
addAtoms([
|
| 635 |
+
{
|
| 636 |
+
role: "user",
|
| 637 |
+
content: question,
|
| 638 |
+
style: "vqa",
|
| 639 |
+
timestamp: ts,
|
| 640 |
+
camera: cameraKey,
|
| 641 |
+
tool_calls: null,
|
| 642 |
+
},
|
| 643 |
+
{
|
| 644 |
+
role: "assistant",
|
| 645 |
+
content: JSON.stringify(answer),
|
| 646 |
+
style: "vqa",
|
| 647 |
+
timestamp: ts,
|
| 648 |
+
camera: cameraKey,
|
| 649 |
+
tool_calls: null,
|
| 650 |
+
},
|
| 651 |
+
]);
|
| 652 |
+
closeFinalize();
|
| 653 |
+
};
|
| 654 |
+
|
| 655 |
+
// ESC / click-outside to cancel the popup.
|
| 656 |
+
useEffect(() => {
|
| 657 |
+
if (!finalizing) return;
|
| 658 |
+
const onKey = (e: KeyboardEvent) => {
|
| 659 |
+
if (e.key === "Escape") closeFinalize();
|
| 660 |
+
};
|
| 661 |
+
window.addEventListener("keydown", onKey);
|
| 662 |
+
return () => window.removeEventListener("keydown", onKey);
|
| 663 |
+
}, [finalizing, closeFinalize]);
|
| 664 |
+
|
| 665 |
+
return (
|
| 666 |
+
<>
|
| 667 |
+
<canvas
|
| 668 |
+
ref={canvasRef}
|
| 669 |
+
onPointerDown={onPointerDown}
|
| 670 |
+
onPointerMove={onPointerMove}
|
| 671 |
+
onPointerUp={onPointerUp}
|
| 672 |
+
onPointerCancel={onPointerCancel}
|
| 673 |
+
style={{
|
| 674 |
+
position: "absolute",
|
| 675 |
+
inset: 0,
|
| 676 |
+
pointerEvents: drawMode === "off" ? "none" : "auto",
|
| 677 |
+
cursor:
|
| 678 |
+
drawMode === "off"
|
| 679 |
+
? "default"
|
| 680 |
+
: drawMode === "keypoint"
|
| 681 |
+
? "pointer"
|
| 682 |
+
: "crosshair",
|
| 683 |
+
}}
|
| 684 |
+
/>
|
| 685 |
+
{finalizing && (
|
| 686 |
+
<QuickLabelPopup
|
| 687 |
+
anchor={finalizing.anchor}
|
| 688 |
+
kind={finalizing.draw.kind}
|
| 689 |
+
questionKind={questionKind}
|
| 690 |
+
onQuestionKindChange={setQuestionKind}
|
| 691 |
+
label={labelInput}
|
| 692 |
+
onLabelChange={setLabelInput}
|
| 693 |
+
onSubmit={submitFinalize}
|
| 694 |
+
onCancel={closeFinalize}
|
| 695 |
+
/>
|
| 696 |
+
)}
|
| 697 |
+
</>
|
| 698 |
+
);
|
| 699 |
+
};
|
| 700 |
+
|
| 701 |
+
/**
|
| 702 |
+
* Floating "what is this?" popup that appears next to a freshly drawn bbox or
|
| 703 |
+
* keypoint. The label gets templated into a question — bbox → "Where is the X
|
| 704 |
+
* in the image?", keypoint → "Point to the X." — and the assistant message
|
| 705 |
+
* carries the JSON answer the steerable validator expects.
|
| 706 |
+
*/
|
| 707 |
+
const QuickLabelPopup: React.FC<{
|
| 708 |
+
anchor: { x: number; y: number };
|
| 709 |
+
kind: "bbox" | "keypoint";
|
| 710 |
+
questionKind: "detect" | "point";
|
| 711 |
+
onQuestionKindChange: (k: "detect" | "point") => void;
|
| 712 |
+
label: string;
|
| 713 |
+
onLabelChange: (s: string) => void;
|
| 714 |
+
onSubmit: () => void;
|
| 715 |
+
onCancel: () => void;
|
| 716 |
+
}> = ({
|
| 717 |
+
anchor,
|
| 718 |
+
kind,
|
| 719 |
+
questionKind,
|
| 720 |
+
onQuestionKindChange,
|
| 721 |
+
label,
|
| 722 |
+
onLabelChange,
|
| 723 |
+
onSubmit,
|
| 724 |
+
onCancel,
|
| 725 |
+
}) => {
|
| 726 |
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
| 727 |
+
const popupRef = useRef<HTMLDivElement | null>(null);
|
| 728 |
+
useEffect(() => {
|
| 729 |
+
inputRef.current?.focus();
|
| 730 |
+
}, []);
|
| 731 |
+
// Position the popup just to the right of the bbox/point anchor, but flip
|
| 732 |
+
// to the left when it would overflow the parent's right edge so it stays
|
| 733 |
+
// visible for bboxes drawn near the right side of the video. Uses
|
| 734 |
+
// useLayoutEffect so the measurement + reposition happens before the
|
| 735 |
+
// browser paints — no flicker.
|
| 736 |
+
React.useLayoutEffect(() => {
|
| 737 |
+
const popup = popupRef.current;
|
| 738 |
+
const parent = popup?.parentElement;
|
| 739 |
+
if (!popup || !parent) return;
|
| 740 |
+
const popW = popup.offsetWidth;
|
| 741 |
+
const popH = popup.offsetHeight;
|
| 742 |
+
const parW = parent.clientWidth;
|
| 743 |
+
const parH = parent.clientHeight;
|
| 744 |
+
const desiredLeft = anchor.x + 6;
|
| 745 |
+
const overflowsRight = desiredLeft + popW > parW - 4;
|
| 746 |
+
const finalLeft = overflowsRight
|
| 747 |
+
? Math.max(4, anchor.x - popW - 6)
|
| 748 |
+
: Math.max(4, desiredLeft);
|
| 749 |
+
const finalTop = Math.max(4, Math.min(parH - popH - 4, anchor.y - 4));
|
| 750 |
+
popup.style.left = `${finalLeft}px`;
|
| 751 |
+
popup.style.top = `${finalTop}px`;
|
| 752 |
+
}, [anchor.x, anchor.y]);
|
| 753 |
+
return (
|
| 754 |
+
<div
|
| 755 |
+
ref={popupRef}
|
| 756 |
+
className="quick-popup"
|
| 757 |
+
onPointerDown={(e) => e.stopPropagation()}
|
| 758 |
+
>
|
| 759 |
+
<div className="quick-popup-head">
|
| 760 |
+
<span className={`kind-pill ${kind}`}>{kind}</span>
|
| 761 |
+
<select
|
| 762 |
+
value={questionKind}
|
| 763 |
+
onChange={(e) =>
|
| 764 |
+
onQuestionKindChange(e.target.value as "detect" | "point")
|
| 765 |
+
}
|
| 766 |
+
style={{ marginLeft: "auto" }}
|
| 767 |
+
>
|
| 768 |
+
<option value="detect">where is …?</option>
|
| 769 |
+
<option value="point">point to …</option>
|
| 770 |
+
</select>
|
| 771 |
+
</div>
|
| 772 |
+
<input
|
| 773 |
+
ref={inputRef}
|
| 774 |
+
type="text"
|
| 775 |
+
placeholder={
|
| 776 |
+
kind === "bbox" ? "label (e.g. carrot)" : "label (e.g. handle)"
|
| 777 |
+
}
|
| 778 |
+
value={label}
|
| 779 |
+
onChange={(e) => onLabelChange(e.target.value)}
|
| 780 |
+
onKeyDown={(e) => {
|
| 781 |
+
if (e.key === "Enter") onSubmit();
|
| 782 |
+
if (e.key === "Escape") onCancel();
|
| 783 |
+
}}
|
| 784 |
+
/>
|
| 785 |
+
<div className="quick-popup-actions">
|
| 786 |
+
<button onClick={onCancel} className="popup-btn">
|
| 787 |
+
cancel
|
| 788 |
+
</button>
|
| 789 |
+
<button
|
| 790 |
+
onClick={onSubmit}
|
| 791 |
+
disabled={!label.trim()}
|
| 792 |
+
className="popup-btn primary"
|
| 793 |
+
>
|
| 794 |
+
add ↵
|
| 795 |
+
</button>
|
| 796 |
+
</div>
|
| 797 |
+
</div>
|
| 798 |
+
);
|
| 799 |
+
};
|
src/context/annotations-context.tsx
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Per-episode annotation state for the v3.1 language schema.
|
| 5 |
+
*
|
| 6 |
+
* - Atoms live in memory + sessionStorage so the user can browse without a
|
| 7 |
+
* backend (read/edit, but no parquet rewrite).
|
| 8 |
+
* - When `NEXT_PUBLIC_ANNOTATE_BACKEND_URL` is set, the context syncs with
|
| 9 |
+
* the FastAPI service in `backend/`: GET on episode entry, POST on save,
|
| 10 |
+
* plus frame-timestamp fetches used to snap event-style atoms to exact
|
| 11 |
+
* source-frame timestamps (the writer in lerobot#3471 enforces exact match).
|
| 12 |
+
*
|
| 13 |
+
* - VQA drawings (active `pendingDraw`) live here too so the panel and the
|
| 14 |
+
* video overlay component share a single source of truth.
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
import React, {
|
| 18 |
+
createContext,
|
| 19 |
+
useCallback,
|
| 20 |
+
useContext,
|
| 21 |
+
useEffect,
|
| 22 |
+
useMemo,
|
| 23 |
+
useRef,
|
| 24 |
+
useState,
|
| 25 |
+
} from "react";
|
| 26 |
+
import type { LanguageAtom } from "../types/language.types";
|
| 27 |
+
import { snapToFrame } from "../types/language.types";
|
| 28 |
+
import {
|
| 29 |
+
fetchEpisodeAtoms,
|
| 30 |
+
saveEpisodeAtoms,
|
| 31 |
+
fetchFrameTimestamps,
|
| 32 |
+
isAnnotateBackendEnabled,
|
| 33 |
+
} from "../utils/annotationsClient";
|
| 34 |
+
|
| 35 |
+
const STORAGE_PREFIX = "lerobot-annotations:v2:";
|
| 36 |
+
|
| 37 |
+
function storageKey(repoOrPath: string, episodeId: number): string {
|
| 38 |
+
return `${STORAGE_PREFIX}${repoOrPath}::${episodeId}`;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export interface PendingBboxDraw {
|
| 42 |
+
kind: "bbox";
|
| 43 |
+
bbox: [number, number, number, number]; // 0..1, image-relative
|
| 44 |
+
label: string;
|
| 45 |
+
camera?: string;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export interface PendingPointDraw {
|
| 49 |
+
kind: "keypoint";
|
| 50 |
+
point: [number, number]; // 0..1, image-relative
|
| 51 |
+
label: string;
|
| 52 |
+
camera?: string;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export type PendingDraw = PendingBboxDraw | PendingPointDraw | null;
|
| 56 |
+
/**
|
| 57 |
+
* `"auto"` — drag = bbox, single click = keypoint (the natural mode the
|
| 58 |
+
* Annotations tab boots into). The other values force a single gesture
|
| 59 |
+
* and exist for the legacy panel-driven flow.
|
| 60 |
+
*/
|
| 61 |
+
export type DrawMode = "off" | "auto" | "bbox" | "keypoint";
|
| 62 |
+
|
| 63 |
+
interface DatasetIdent {
|
| 64 |
+
repoId?: string | null;
|
| 65 |
+
localPath?: string | null;
|
| 66 |
+
revision?: string | null;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
interface AnnotationsContextType {
|
| 70 |
+
episodeId: number | null;
|
| 71 |
+
ident: DatasetIdent;
|
| 72 |
+
atoms: LanguageAtom[];
|
| 73 |
+
frameTimestamps: number[];
|
| 74 |
+
/**
|
| 75 |
+
* Index in `atoms` of the currently selected atom (the one the right-rail
|
| 76 |
+
* editor is bound to). `null` means nothing is selected — the editor shows
|
| 77 |
+
* an empty state. Selection survives content edits because we mutate atoms
|
| 78 |
+
* in place at the same index; we clear it on delete or when atoms reset.
|
| 79 |
+
*/
|
| 80 |
+
selectedIdx: number | null;
|
| 81 |
+
selectAtom: (idx: number | null) => void;
|
| 82 |
+
/**
|
| 83 |
+
* Active <video> element for the camera the user is currently drawing on.
|
| 84 |
+
* Registered by `VideoOverlayCanvas`. Used by the panel to read the
|
| 85 |
+
* authoritative `currentTime` (the time-context's value is throttled and
|
| 86 |
+
* can lag the real video by tens of ms — enough to land an annotation on
|
| 87 |
+
* the wrong frame after a snap to the nearest frame timestamp).
|
| 88 |
+
*/
|
| 89 |
+
activeVideoEl: HTMLVideoElement | null;
|
| 90 |
+
setActiveVideoEl: (el: HTMLVideoElement | null) => void;
|
| 91 |
+
pendingDraw: PendingDraw;
|
| 92 |
+
// Selected camera for the drawing overlay (e.g. "observation.images.top").
|
| 93 |
+
// Determines which video the next drawn bbox/point should be associated with.
|
| 94 |
+
activeCamera: string | null;
|
| 95 |
+
drawMode: DrawMode;
|
| 96 |
+
drawLabel: string;
|
| 97 |
+
backendEnabled: boolean;
|
| 98 |
+
dirty: boolean;
|
| 99 |
+
saving: boolean;
|
| 100 |
+
|
| 101 |
+
setEpisode: (
|
| 102 |
+
episodeId: number,
|
| 103 |
+
ident: DatasetIdent,
|
| 104 |
+
initialAtoms?: LanguageAtom[],
|
| 105 |
+
initialFrameTimestamps?: number[],
|
| 106 |
+
) => void;
|
| 107 |
+
setActiveCamera: (camera: string | null) => void;
|
| 108 |
+
setDrawMode: (mode: DrawMode) => void;
|
| 109 |
+
setDrawLabel: (label: string) => void;
|
| 110 |
+
|
| 111 |
+
addAtom: (atom: LanguageAtom) => void;
|
| 112 |
+
addAtoms: (atoms: LanguageAtom[]) => void;
|
| 113 |
+
updateAtom: (index: number, updates: Partial<LanguageAtom>) => void;
|
| 114 |
+
deleteAtom: (atom: LanguageAtom) => void;
|
| 115 |
+
resetAtoms: () => void;
|
| 116 |
+
|
| 117 |
+
setPendingDraw: (draw: PendingDraw) => void;
|
| 118 |
+
clearPendingDraw: () => void;
|
| 119 |
+
|
| 120 |
+
save: () => Promise<{ ok: boolean; error?: string; path?: string | null }>;
|
| 121 |
+
// Snap an arbitrary timestamp to the nearest source frame (when known).
|
| 122 |
+
snap: (ts: number) => number;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
const AnnotationsContext = createContext<AnnotationsContextType | undefined>(
|
| 126 |
+
undefined,
|
| 127 |
+
);
|
| 128 |
+
|
| 129 |
+
export function useAnnotations(): AnnotationsContextType {
|
| 130 |
+
const ctx = useContext(AnnotationsContext);
|
| 131 |
+
if (!ctx) {
|
| 132 |
+
throw new Error("useAnnotations must be used within AnnotationsProvider");
|
| 133 |
+
}
|
| 134 |
+
return ctx;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function identKey(ident: DatasetIdent): string {
|
| 138 |
+
return ident.localPath || ident.repoId || "unknown";
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
export const AnnotationsProvider: React.FC<{ children: React.ReactNode }> = ({
|
| 142 |
+
children,
|
| 143 |
+
}) => {
|
| 144 |
+
const [episodeId, setEpisodeId] = useState<number | null>(null);
|
| 145 |
+
const [ident, setIdent] = useState<DatasetIdent>({});
|
| 146 |
+
const [atoms, setAtoms] = useState<LanguageAtom[]>([]);
|
| 147 |
+
const [frameTimestamps, setFrameTimestamps] = useState<number[]>([]);
|
| 148 |
+
const [pendingDraw, setPendingDrawState] = useState<PendingDraw>(null);
|
| 149 |
+
const [activeCamera, setActiveCameraState] = useState<string | null>(null);
|
| 150 |
+
const [drawMode, setDrawModeState] = useState<DrawMode>("off");
|
| 151 |
+
const [drawLabel, setDrawLabelState] = useState<string>("");
|
| 152 |
+
const [activeVideoEl, setActiveVideoElState] =
|
| 153 |
+
useState<HTMLVideoElement | null>(null);
|
| 154 |
+
const [selectedIdx, setSelectedIdxState] = useState<number | null>(null);
|
| 155 |
+
const [dirty, setDirty] = useState(false);
|
| 156 |
+
const [saving, setSaving] = useState(false);
|
| 157 |
+
const backendEnabled = isAnnotateBackendEnabled();
|
| 158 |
+
|
| 159 |
+
// Track the last saved snapshot to detect dirtiness honestly.
|
| 160 |
+
const savedSnapshotRef = useRef<string>("[]");
|
| 161 |
+
|
| 162 |
+
// Hydrate from sessionStorage when episode/ident changes; if the backend
|
| 163 |
+
// is enabled, also fetch authoritative atoms + frame timestamps.
|
| 164 |
+
const setEpisode = useCallback(
|
| 165 |
+
(
|
| 166 |
+
newEpisodeId: number,
|
| 167 |
+
newIdent: DatasetIdent,
|
| 168 |
+
initialAtoms?: LanguageAtom[],
|
| 169 |
+
initialFrameTimestamps?: number[],
|
| 170 |
+
) => {
|
| 171 |
+
setEpisodeId(newEpisodeId);
|
| 172 |
+
setIdent(newIdent);
|
| 173 |
+
setPendingDrawState(null);
|
| 174 |
+
setSelectedIdxState(null);
|
| 175 |
+
|
| 176 |
+
// Hydrate from session first (so user edits survive episode toggles).
|
| 177 |
+
// If session is empty, fall back to initialAtoms (parquet-extracted).
|
| 178 |
+
let initial: LanguageAtom[] = [];
|
| 179 |
+
try {
|
| 180 |
+
const raw = sessionStorage.getItem(
|
| 181 |
+
storageKey(identKey(newIdent), newEpisodeId),
|
| 182 |
+
);
|
| 183 |
+
if (raw) initial = JSON.parse(raw) as LanguageAtom[];
|
| 184 |
+
} catch {
|
| 185 |
+
/* ignore */
|
| 186 |
+
}
|
| 187 |
+
if (initial.length === 0 && initialAtoms && initialAtoms.length > 0) {
|
| 188 |
+
initial = initialAtoms;
|
| 189 |
+
}
|
| 190 |
+
setAtoms(initial);
|
| 191 |
+
savedSnapshotRef.current = JSON.stringify(initial);
|
| 192 |
+
setDirty(false);
|
| 193 |
+
// Seed frame timestamps from the parquet (no backend dependency); the
|
| 194 |
+
// backend will optionally overwrite this below.
|
| 195 |
+
setFrameTimestamps(initialFrameTimestamps ?? []);
|
| 196 |
+
|
| 197 |
+
// Fetch from backend if available.
|
| 198 |
+
if (isAnnotateBackendEnabled()) {
|
| 199 |
+
fetchEpisodeAtoms(newEpisodeId, newIdent)
|
| 200 |
+
.then((remoteAtoms) => {
|
| 201 |
+
// Prefer backend if it has anything; otherwise keep session-cached
|
| 202 |
+
// edits the user made before the backend came online.
|
| 203 |
+
if (remoteAtoms && remoteAtoms.length > 0) {
|
| 204 |
+
setAtoms(remoteAtoms);
|
| 205 |
+
savedSnapshotRef.current = JSON.stringify(remoteAtoms);
|
| 206 |
+
setDirty(false);
|
| 207 |
+
}
|
| 208 |
+
})
|
| 209 |
+
.catch(() => {
|
| 210 |
+
/* backend offline — silent fallback to sessionStorage */
|
| 211 |
+
});
|
| 212 |
+
|
| 213 |
+
fetchFrameTimestamps(newEpisodeId, newIdent)
|
| 214 |
+
.then(setFrameTimestamps)
|
| 215 |
+
.catch(() => setFrameTimestamps([]));
|
| 216 |
+
}
|
| 217 |
+
},
|
| 218 |
+
[],
|
| 219 |
+
);
|
| 220 |
+
|
| 221 |
+
// Persist to sessionStorage on every change once we have an episode.
|
| 222 |
+
useEffect(() => {
|
| 223 |
+
if (episodeId == null) return;
|
| 224 |
+
try {
|
| 225 |
+
sessionStorage.setItem(
|
| 226 |
+
storageKey(identKey(ident), episodeId),
|
| 227 |
+
JSON.stringify(atoms),
|
| 228 |
+
);
|
| 229 |
+
} catch {
|
| 230 |
+
/* ignore */
|
| 231 |
+
}
|
| 232 |
+
setDirty(JSON.stringify(atoms) !== savedSnapshotRef.current);
|
| 233 |
+
}, [atoms, episodeId, ident]);
|
| 234 |
+
|
| 235 |
+
const snap = useCallback(
|
| 236 |
+
(ts: number) =>
|
| 237 |
+
frameTimestamps.length > 0 ? snapToFrame(frameTimestamps, ts) : ts,
|
| 238 |
+
[frameTimestamps],
|
| 239 |
+
);
|
| 240 |
+
|
| 241 |
+
const addAtom = useCallback((atom: LanguageAtom) => {
|
| 242 |
+
setAtoms((prev) => [...prev, atom]);
|
| 243 |
+
}, []);
|
| 244 |
+
|
| 245 |
+
const addAtoms = useCallback((newAtoms: LanguageAtom[]) => {
|
| 246 |
+
setAtoms((prev) => [...prev, ...newAtoms]);
|
| 247 |
+
}, []);
|
| 248 |
+
|
| 249 |
+
const updateAtom = useCallback(
|
| 250 |
+
(index: number, updates: Partial<LanguageAtom>) => {
|
| 251 |
+
setAtoms((prev) => {
|
| 252 |
+
if (index < 0 || index >= prev.length) return prev;
|
| 253 |
+
const next = prev.slice();
|
| 254 |
+
next[index] = { ...next[index], ...updates };
|
| 255 |
+
return next;
|
| 256 |
+
});
|
| 257 |
+
},
|
| 258 |
+
[],
|
| 259 |
+
);
|
| 260 |
+
|
| 261 |
+
const deleteAtom = useCallback((atom: LanguageAtom) => {
|
| 262 |
+
setAtoms((prev) => {
|
| 263 |
+
const next = prev.filter((a) => a !== atom);
|
| 264 |
+
// If the deleted index was selected (or the selected index was after the
|
| 265 |
+
// deleted one), nudge selection so it remains pointing at a valid atom
|
| 266 |
+
// — or null when the list is empty.
|
| 267 |
+
setSelectedIdxState((cur) => {
|
| 268 |
+
if (cur == null) return null;
|
| 269 |
+
const oldIdx = prev.indexOf(atom);
|
| 270 |
+
if (oldIdx < 0) return cur;
|
| 271 |
+
if (cur === oldIdx) return null;
|
| 272 |
+
if (cur > oldIdx) return cur - 1;
|
| 273 |
+
return cur;
|
| 274 |
+
});
|
| 275 |
+
return next;
|
| 276 |
+
});
|
| 277 |
+
}, []);
|
| 278 |
+
|
| 279 |
+
const resetAtoms = useCallback(() => {
|
| 280 |
+
setAtoms([]);
|
| 281 |
+
setSelectedIdxState(null);
|
| 282 |
+
}, []);
|
| 283 |
+
|
| 284 |
+
const setPendingDraw = useCallback((draw: PendingDraw) => {
|
| 285 |
+
setPendingDrawState(draw);
|
| 286 |
+
}, []);
|
| 287 |
+
|
| 288 |
+
const clearPendingDraw = useCallback(() => setPendingDrawState(null), []);
|
| 289 |
+
|
| 290 |
+
const setActiveCamera = useCallback((c: string | null) => {
|
| 291 |
+
setActiveCameraState(c);
|
| 292 |
+
}, []);
|
| 293 |
+
|
| 294 |
+
const setDrawMode = useCallback((m: DrawMode) => setDrawModeState(m), []);
|
| 295 |
+
const setDrawLabel = useCallback((l: string) => setDrawLabelState(l), []);
|
| 296 |
+
const setActiveVideoEl = useCallback(
|
| 297 |
+
(el: HTMLVideoElement | null) => setActiveVideoElState(el),
|
| 298 |
+
[],
|
| 299 |
+
);
|
| 300 |
+
|
| 301 |
+
const selectAtom = useCallback(
|
| 302 |
+
(idx: number | null) => setSelectedIdxState(idx),
|
| 303 |
+
[],
|
| 304 |
+
);
|
| 305 |
+
|
| 306 |
+
const save = useCallback(async (): Promise<{
|
| 307 |
+
ok: boolean;
|
| 308 |
+
error?: string;
|
| 309 |
+
path?: string | null;
|
| 310 |
+
}> => {
|
| 311 |
+
if (episodeId == null) return { ok: false, error: "no episode" };
|
| 312 |
+
if (!isAnnotateBackendEnabled()) {
|
| 313 |
+
// Persistence is sessionStorage-only — that already happened in the
|
| 314 |
+
// effect above. Report the storage key as the location so the UI can
|
| 315 |
+
// show a concrete "path" instead of a vague offline message.
|
| 316 |
+
savedSnapshotRef.current = JSON.stringify(atoms);
|
| 317 |
+
setDirty(false);
|
| 318 |
+
return {
|
| 319 |
+
ok: true,
|
| 320 |
+
path: `sessionStorage://${storageKey(identKey(ident), episodeId)}`,
|
| 321 |
+
};
|
| 322 |
+
}
|
| 323 |
+
setSaving(true);
|
| 324 |
+
try {
|
| 325 |
+
const { path } = await saveEpisodeAtoms(episodeId, ident, atoms);
|
| 326 |
+
savedSnapshotRef.current = JSON.stringify(atoms);
|
| 327 |
+
setDirty(false);
|
| 328 |
+
return { ok: true, path };
|
| 329 |
+
} catch (e) {
|
| 330 |
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
| 331 |
+
} finally {
|
| 332 |
+
setSaving(false);
|
| 333 |
+
}
|
| 334 |
+
}, [atoms, episodeId, ident]);
|
| 335 |
+
|
| 336 |
+
const value = useMemo<AnnotationsContextType>(
|
| 337 |
+
() => ({
|
| 338 |
+
episodeId,
|
| 339 |
+
ident,
|
| 340 |
+
atoms,
|
| 341 |
+
frameTimestamps,
|
| 342 |
+
pendingDraw,
|
| 343 |
+
activeCamera,
|
| 344 |
+
activeVideoEl,
|
| 345 |
+
setActiveVideoEl,
|
| 346 |
+
drawMode,
|
| 347 |
+
drawLabel,
|
| 348 |
+
selectedIdx,
|
| 349 |
+
selectAtom,
|
| 350 |
+
backendEnabled,
|
| 351 |
+
dirty,
|
| 352 |
+
saving,
|
| 353 |
+
setEpisode,
|
| 354 |
+
setActiveCamera,
|
| 355 |
+
setDrawMode,
|
| 356 |
+
setDrawLabel,
|
| 357 |
+
addAtom,
|
| 358 |
+
addAtoms,
|
| 359 |
+
updateAtom,
|
| 360 |
+
deleteAtom,
|
| 361 |
+
resetAtoms,
|
| 362 |
+
setPendingDraw,
|
| 363 |
+
clearPendingDraw,
|
| 364 |
+
save,
|
| 365 |
+
snap,
|
| 366 |
+
}),
|
| 367 |
+
[
|
| 368 |
+
episodeId,
|
| 369 |
+
ident,
|
| 370 |
+
atoms,
|
| 371 |
+
frameTimestamps,
|
| 372 |
+
pendingDraw,
|
| 373 |
+
activeCamera,
|
| 374 |
+
activeVideoEl,
|
| 375 |
+
setActiveVideoEl,
|
| 376 |
+
drawMode,
|
| 377 |
+
drawLabel,
|
| 378 |
+
selectedIdx,
|
| 379 |
+
selectAtom,
|
| 380 |
+
backendEnabled,
|
| 381 |
+
dirty,
|
| 382 |
+
saving,
|
| 383 |
+
setEpisode,
|
| 384 |
+
setActiveCamera,
|
| 385 |
+
setDrawMode,
|
| 386 |
+
setDrawLabel,
|
| 387 |
+
addAtom,
|
| 388 |
+
addAtoms,
|
| 389 |
+
updateAtom,
|
| 390 |
+
deleteAtom,
|
| 391 |
+
resetAtoms,
|
| 392 |
+
setPendingDraw,
|
| 393 |
+
clearPendingDraw,
|
| 394 |
+
save,
|
| 395 |
+
snap,
|
| 396 |
+
],
|
| 397 |
+
);
|
| 398 |
+
|
| 399 |
+
return (
|
| 400 |
+
<AnnotationsContext.Provider value={value}>
|
| 401 |
+
{children}
|
| 402 |
+
</AnnotationsContext.Provider>
|
| 403 |
+
);
|
| 404 |
+
};
|
src/context/time-context.tsx
CHANGED
|
@@ -64,7 +64,10 @@ export const TimeProvider: React.FC<{
|
|
| 64 |
listeners.current.forEach((fn) => fn(t));
|
| 65 |
|
| 66 |
if (source === "external") {
|
|
|
|
|
|
|
| 67 |
setExternalSeekVersion((v) => v + 1);
|
|
|
|
| 68 |
}
|
| 69 |
|
| 70 |
// Throttle React state updates — during playback, timeupdate fires ~4×/sec
|
|
|
|
| 64 |
listeners.current.forEach((fn) => fn(t));
|
| 65 |
|
| 66 |
if (source === "external") {
|
| 67 |
+
lastRenderTime.current = performance.now();
|
| 68 |
+
setCurrentTimeState(t);
|
| 69 |
setExternalSeekVersion((v) => v + 1);
|
| 70 |
+
return;
|
| 71 |
}
|
| 72 |
|
| 73 |
// Throttle React state updates — during playback, timeupdate fires ~4×/sec
|
src/types/language.types.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* v3.1 language schema (lerobot#3467 + lerobot#3471).
|
| 3 |
+
*
|
| 4 |
+
* Each row in `language_persistent` and `language_events` has the same shape:
|
| 5 |
+
*
|
| 6 |
+
* { role, content, style, timestamp, tool_calls }
|
| 7 |
+
*
|
| 8 |
+
* Persistent styles (task_aug / subtask / plan / memory) live in
|
| 9 |
+
* `language_persistent` and are broadcast across every frame in the episode. Event styles
|
| 10 |
+
* (interjection / vqa) plus speech tool-call atoms (style=null) live in
|
| 11 |
+
* `language_events` and only appear on the exact frames where they were
|
| 12 |
+
* emitted.
|
| 13 |
+
*
|
| 14 |
+
* VQA assistant rows encode their answer as a JSON string in `content`, in one
|
| 15 |
+
* of five shapes (bbox / keypoint / count / attribute / spatial), matching
|
| 16 |
+
* `VQA_ANSWER_SHAPES` in lerobot's steerable-pipeline validator.
|
| 17 |
+
*/
|
| 18 |
+
|
| 19 |
+
export type Role = "user" | "assistant" | "system" | "tool";
|
| 20 |
+
|
| 21 |
+
export type LanguageStyle =
|
| 22 |
+
| "task_aug"
|
| 23 |
+
| "subtask"
|
| 24 |
+
| "plan"
|
| 25 |
+
| "memory"
|
| 26 |
+
| "interjection"
|
| 27 |
+
| "vqa";
|
| 28 |
+
|
| 29 |
+
export interface ToolCallFn {
|
| 30 |
+
name: string;
|
| 31 |
+
arguments: Record<string, unknown>;
|
| 32 |
+
}
|
| 33 |
+
export interface ToolCall {
|
| 34 |
+
type: "function";
|
| 35 |
+
function: ToolCallFn;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export interface LanguageAtom {
|
| 39 |
+
role: Role;
|
| 40 |
+
content: string | null;
|
| 41 |
+
// null is reserved for tool-call-only atoms (speech).
|
| 42 |
+
style: LanguageStyle | null;
|
| 43 |
+
timestamp: number;
|
| 44 |
+
/**
|
| 45 |
+
* `observation.images.*` feature key when this atom is grounded against a
|
| 46 |
+
* specific camera view (`vqa`, `trace`). `null` for camera-agnostic atoms
|
| 47 |
+
* (`task_aug`, `subtask`, `plan`, `memory`, `motion`, `interjection`, speech).
|
| 48 |
+
* Mirrors lerobot's row-level `camera` field (PR 3467).
|
| 49 |
+
*/
|
| 50 |
+
camera: string | null;
|
| 51 |
+
tool_calls: ToolCall[] | null;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export const PERSISTENT_STYLES: ReadonlySet<LanguageStyle> = new Set([
|
| 55 |
+
"task_aug",
|
| 56 |
+
"subtask",
|
| 57 |
+
"plan",
|
| 58 |
+
"memory",
|
| 59 |
+
]);
|
| 60 |
+
export const EVENT_STYLES: ReadonlySet<LanguageStyle> = new Set([
|
| 61 |
+
"interjection",
|
| 62 |
+
"vqa",
|
| 63 |
+
]);
|
| 64 |
+
|
| 65 |
+
export function columnForStyle(
|
| 66 |
+
style: LanguageStyle | null,
|
| 67 |
+
): "language_persistent" | "language_events" {
|
| 68 |
+
if (style === null) return "language_events";
|
| 69 |
+
if (PERSISTENT_STYLES.has(style)) return "language_persistent";
|
| 70 |
+
if (EVENT_STYLES.has(style)) return "language_events";
|
| 71 |
+
throw new Error(`Unknown style: ${String(style)}`);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export function isSpeechAtom(a: LanguageAtom): boolean {
|
| 75 |
+
return (
|
| 76 |
+
a.style === null &&
|
| 77 |
+
a.role === "assistant" &&
|
| 78 |
+
!!a.tool_calls &&
|
| 79 |
+
a.tool_calls.length > 0 &&
|
| 80 |
+
a.tool_calls[0]?.function?.name === "say"
|
| 81 |
+
);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export function speechText(a: LanguageAtom): string | null {
|
| 85 |
+
if (!isSpeechAtom(a)) return null;
|
| 86 |
+
const args = a.tool_calls?.[0]?.function?.arguments as
|
| 87 |
+
| { text?: unknown }
|
| 88 |
+
| undefined;
|
| 89 |
+
return typeof args?.text === "string" ? args.text : null;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
export function buildSpeechAtom(timestamp: number, text: string): LanguageAtom {
|
| 93 |
+
return {
|
| 94 |
+
role: "assistant",
|
| 95 |
+
content: null,
|
| 96 |
+
style: null,
|
| 97 |
+
timestamp,
|
| 98 |
+
camera: null,
|
| 99 |
+
tool_calls: [
|
| 100 |
+
{
|
| 101 |
+
type: "function",
|
| 102 |
+
function: { name: "say", arguments: { text } },
|
| 103 |
+
},
|
| 104 |
+
],
|
| 105 |
+
};
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/**
|
| 109 |
+
* Whether ``atom`` should render on the camera identified by ``cameraKey``.
|
| 110 |
+
*
|
| 111 |
+
* - row-level ``atom.camera`` is the source of truth (lerobot PR 3467);
|
| 112 |
+
* ``null`` means camera-agnostic and renders everywhere;
|
| 113 |
+
* a non-null value matches only its own camera.
|
| 114 |
+
* - For backwards compatibility with annotations that were created before the
|
| 115 |
+
* row-level field existed (visualizer-only payloads with an in-JSON
|
| 116 |
+
* ``camera`` key inside the VQA answer), the caller is responsible for the
|
| 117 |
+
* payload-level fallback — see ``video-overlay-canvas.tsx``.
|
| 118 |
+
*/
|
| 119 |
+
export function atomMatchesCamera(
|
| 120 |
+
atom: LanguageAtom,
|
| 121 |
+
cameraKey: string,
|
| 122 |
+
): boolean {
|
| 123 |
+
return atom.camera == null || atom.camera === cameraKey;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// --- VQA answer shapes -------------------------------------------------------
|
| 127 |
+
|
| 128 |
+
export type VqaAnswer =
|
| 129 |
+
| VqaBboxAnswer
|
| 130 |
+
| VqaKeypointAnswer
|
| 131 |
+
| VqaCountAnswer
|
| 132 |
+
| VqaAttributeAnswer
|
| 133 |
+
| VqaSpatialAnswer;
|
| 134 |
+
|
| 135 |
+
export interface VqaBboxAnswer {
|
| 136 |
+
detections: Array<{
|
| 137 |
+
label: string;
|
| 138 |
+
bbox_format: "xyxy" | "xywh";
|
| 139 |
+
bbox: [number, number, number, number];
|
| 140 |
+
/**
|
| 141 |
+
* Optional camera key for the visualizer (e.g. `observation.images.top`).
|
| 142 |
+
* Not enforced by lerobot's writer but accepted as a passthrough JSON
|
| 143 |
+
* field since the validator checks only required keys.
|
| 144 |
+
*/
|
| 145 |
+
camera?: string;
|
| 146 |
+
}>;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
export interface VqaKeypointAnswer {
|
| 150 |
+
label: string;
|
| 151 |
+
point_format: "xy";
|
| 152 |
+
point: [number, number];
|
| 153 |
+
camera?: string;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
export interface VqaCountAnswer {
|
| 157 |
+
label: string;
|
| 158 |
+
count: number;
|
| 159 |
+
note?: string;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
export interface VqaAttributeAnswer {
|
| 163 |
+
label: string;
|
| 164 |
+
attribute: string;
|
| 165 |
+
value: string;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
export interface VqaSpatialAnswer {
|
| 169 |
+
subject: string;
|
| 170 |
+
relation: string;
|
| 171 |
+
object: string;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
export type VqaKind = "bbox" | "keypoint" | "count" | "attribute" | "spatial";
|
| 175 |
+
|
| 176 |
+
export function classifyVqa(answer: unknown): VqaKind | null {
|
| 177 |
+
if (!answer || typeof answer !== "object") return null;
|
| 178 |
+
const a = answer as Record<string, unknown>;
|
| 179 |
+
if (Array.isArray(a.detections)) return "bbox";
|
| 180 |
+
if (a.point_format && Array.isArray(a.point)) return "keypoint";
|
| 181 |
+
if (typeof a.count === "number" && typeof a.label === "string")
|
| 182 |
+
return "count";
|
| 183 |
+
if (typeof a.attribute === "string" && a.value != null) return "attribute";
|
| 184 |
+
if (typeof a.subject === "string" && typeof a.relation === "string")
|
| 185 |
+
return "spatial";
|
| 186 |
+
return null;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
export function parseVqaAnswer(
|
| 190 |
+
raw: string | null | undefined,
|
| 191 |
+
): VqaAnswer | null {
|
| 192 |
+
if (!raw) return null;
|
| 193 |
+
try {
|
| 194 |
+
const parsed = JSON.parse(raw);
|
| 195 |
+
return classifyVqa(parsed) ? (parsed as VqaAnswer) : null;
|
| 196 |
+
} catch {
|
| 197 |
+
return null;
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
// --- Atom helpers used by the editor UI --------------------------------------
|
| 202 |
+
|
| 203 |
+
export interface EpisodeAtoms {
|
| 204 |
+
persistent: LanguageAtom[];
|
| 205 |
+
events: LanguageAtom[];
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
/** Group all atoms for an episode by their target column. */
|
| 209 |
+
export function partitionAtoms(atoms: LanguageAtom[]): EpisodeAtoms {
|
| 210 |
+
const persistent: LanguageAtom[] = [];
|
| 211 |
+
const events: LanguageAtom[] = [];
|
| 212 |
+
for (const a of atoms) {
|
| 213 |
+
if (columnForStyle(a.style) === "language_persistent") persistent.push(a);
|
| 214 |
+
else events.push(a);
|
| 215 |
+
}
|
| 216 |
+
persistent.sort((a, b) => a.timestamp - b.timestamp);
|
| 217 |
+
events.sort((a, b) => a.timestamp - b.timestamp);
|
| 218 |
+
return { persistent, events };
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
export function atomsByStyle(
|
| 222 |
+
atoms: LanguageAtom[],
|
| 223 |
+
style: LanguageStyle,
|
| 224 |
+
): LanguageAtom[] {
|
| 225 |
+
return atoms.filter((a) => a.style === style);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
export function activeAt(
|
| 229 |
+
persistent: LanguageAtom[],
|
| 230 |
+
style: LanguageStyle,
|
| 231 |
+
t: number,
|
| 232 |
+
): LanguageAtom | null {
|
| 233 |
+
let best: LanguageAtom | null = null;
|
| 234 |
+
for (const a of persistent) {
|
| 235 |
+
if (a.style !== style) continue;
|
| 236 |
+
if (a.timestamp > t) break;
|
| 237 |
+
best = a;
|
| 238 |
+
}
|
| 239 |
+
return best;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
export function eventsAt(
|
| 243 |
+
events: LanguageAtom[],
|
| 244 |
+
t: number,
|
| 245 |
+
windowSec = 1 / 60,
|
| 246 |
+
): LanguageAtom[] {
|
| 247 |
+
return events.filter((a) => Math.abs(a.timestamp - t) <= windowSec);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/**
|
| 251 |
+
* Snap a timestamp to the nearest source-frame timestamp. Linear scan; episodes
|
| 252 |
+
* are typically a few thousand frames so this is fine without a tree.
|
| 253 |
+
*/
|
| 254 |
+
export function snapToFrame(frameTimestamps: number[], ts: number): number {
|
| 255 |
+
if (!frameTimestamps.length) return ts;
|
| 256 |
+
let best = frameTimestamps[0];
|
| 257 |
+
let dist = Math.abs(ts - best);
|
| 258 |
+
for (let i = 1; i < frameTimestamps.length; i++) {
|
| 259 |
+
const d = Math.abs(ts - frameTimestamps[i]);
|
| 260 |
+
if (d < dist) {
|
| 261 |
+
dist = d;
|
| 262 |
+
best = frameTimestamps[i];
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
return best;
|
| 266 |
+
}
|
src/utils/annotationsClient.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Client for the FastAPI annotation backend in `backend/`.
|
| 3 |
+
*
|
| 4 |
+
* The backend URL is configured via the `NEXT_PUBLIC_ANNOTATE_BACKEND_URL`
|
| 5 |
+
* env var so it can be statically substituted by Next.js. When unset, all
|
| 6 |
+
* annotation write paths are disabled and the UI falls back to sessionStorage
|
| 7 |
+
* for read/edit only.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import type { LanguageAtom } from "../types/language.types";
|
| 11 |
+
|
| 12 |
+
const ENV_URL = (() => {
|
| 13 |
+
const v =
|
| 14 |
+
typeof process !== "undefined"
|
| 15 |
+
? process.env.NEXT_PUBLIC_ANNOTATE_BACKEND_URL
|
| 16 |
+
: undefined;
|
| 17 |
+
return (v || "").trim() || null;
|
| 18 |
+
})();
|
| 19 |
+
|
| 20 |
+
export function isAnnotateBackendEnabled(): boolean {
|
| 21 |
+
return !!ENV_URL;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function getAnnotateBackendUrl(): string | null {
|
| 25 |
+
return ENV_URL;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
interface DatasetIdent {
|
| 29 |
+
repoId?: string | null;
|
| 30 |
+
localPath?: string | null;
|
| 31 |
+
revision?: string | null;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function buildUrl(path: string, ident: DatasetIdent): string {
|
| 35 |
+
if (!ENV_URL) throw new Error("Annotate backend not configured");
|
| 36 |
+
const url = new URL(path, ENV_URL);
|
| 37 |
+
if (ident.repoId) url.searchParams.set("repo_id", ident.repoId);
|
| 38 |
+
if (ident.revision) url.searchParams.set("revision", ident.revision);
|
| 39 |
+
if (ident.localPath) url.searchParams.set("local_path", ident.localPath);
|
| 40 |
+
return url.toString();
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export async function pingBackend(): Promise<boolean> {
|
| 44 |
+
if (!ENV_URL) return false;
|
| 45 |
+
try {
|
| 46 |
+
const res = await fetch(new URL("/api/health", ENV_URL).toString());
|
| 47 |
+
return res.ok;
|
| 48 |
+
} catch {
|
| 49 |
+
return false;
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export async function loadDataset(
|
| 54 |
+
ident: DatasetIdent,
|
| 55 |
+
): Promise<{ ok: boolean }> {
|
| 56 |
+
if (!ENV_URL) return { ok: false };
|
| 57 |
+
const res = await fetch(new URL("/api/dataset/load", ENV_URL).toString(), {
|
| 58 |
+
method: "POST",
|
| 59 |
+
headers: { "Content-Type": "application/json" },
|
| 60 |
+
body: JSON.stringify({
|
| 61 |
+
repo_id: ident.repoId || null,
|
| 62 |
+
revision: ident.revision || null,
|
| 63 |
+
local_path: ident.localPath || null,
|
| 64 |
+
}),
|
| 65 |
+
});
|
| 66 |
+
return { ok: res.ok };
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export async function fetchEpisodeAtoms(
|
| 70 |
+
episodeId: number,
|
| 71 |
+
ident: DatasetIdent,
|
| 72 |
+
): Promise<LanguageAtom[]> {
|
| 73 |
+
if (!ENV_URL) return [];
|
| 74 |
+
await loadDataset(ident);
|
| 75 |
+
const res = await fetch(buildUrl(`/api/episodes/${episodeId}/atoms`, ident));
|
| 76 |
+
if (!res.ok) {
|
| 77 |
+
throw new Error(`fetch atoms: ${res.status}`);
|
| 78 |
+
}
|
| 79 |
+
const data = (await res.json()) as { atoms?: LanguageAtom[] };
|
| 80 |
+
return data.atoms || [];
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
export async function saveEpisodeAtoms(
|
| 84 |
+
episodeId: number,
|
| 85 |
+
ident: DatasetIdent,
|
| 86 |
+
atoms: LanguageAtom[],
|
| 87 |
+
): Promise<{ path: string | null }> {
|
| 88 |
+
if (!ENV_URL) return { path: null };
|
| 89 |
+
const res = await fetch(
|
| 90 |
+
new URL(`/api/episodes/${episodeId}/atoms`, ENV_URL).toString(),
|
| 91 |
+
{
|
| 92 |
+
method: "POST",
|
| 93 |
+
headers: { "Content-Type": "application/json" },
|
| 94 |
+
body: JSON.stringify({
|
| 95 |
+
episode_index: episodeId,
|
| 96 |
+
repo_id: ident.repoId || null,
|
| 97 |
+
local_path: ident.localPath || null,
|
| 98 |
+
atoms,
|
| 99 |
+
}),
|
| 100 |
+
},
|
| 101 |
+
);
|
| 102 |
+
if (!res.ok) {
|
| 103 |
+
const text = await res.text().catch(() => `${res.status}`);
|
| 104 |
+
throw new Error(text || `save atoms: ${res.status}`);
|
| 105 |
+
}
|
| 106 |
+
const data = (await res.json().catch(() => ({}))) as { path?: string | null };
|
| 107 |
+
return { path: data.path ?? null };
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
export async function fetchFrameTimestamps(
|
| 111 |
+
episodeId: number,
|
| 112 |
+
ident: DatasetIdent,
|
| 113 |
+
): Promise<number[]> {
|
| 114 |
+
if (!ENV_URL) return [];
|
| 115 |
+
const res = await fetch(
|
| 116 |
+
buildUrl(`/api/episodes/${episodeId}/frame_timestamps`, ident),
|
| 117 |
+
);
|
| 118 |
+
if (!res.ok) return [];
|
| 119 |
+
const data = (await res.json()) as { timestamps?: number[] };
|
| 120 |
+
return data.timestamps || [];
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
export async function exportDataset(
|
| 124 |
+
ident: DatasetIdent,
|
| 125 |
+
outputDir?: string | null,
|
| 126 |
+
copyVideos = false,
|
| 127 |
+
): Promise<{
|
| 128 |
+
output_dir: string;
|
| 129 |
+
persistent_rows: number;
|
| 130 |
+
event_rows: number;
|
| 131 |
+
}> {
|
| 132 |
+
if (!ENV_URL) throw new Error("Annotate backend not configured");
|
| 133 |
+
const res = await fetch(new URL("/api/export", ENV_URL).toString(), {
|
| 134 |
+
method: "POST",
|
| 135 |
+
headers: { "Content-Type": "application/json" },
|
| 136 |
+
body: JSON.stringify({
|
| 137 |
+
repo_id: ident.repoId || null,
|
| 138 |
+
revision: ident.revision || null,
|
| 139 |
+
local_path: ident.localPath || null,
|
| 140 |
+
output_dir: outputDir || null,
|
| 141 |
+
copy_videos: !!copyVideos,
|
| 142 |
+
}),
|
| 143 |
+
});
|
| 144 |
+
if (!res.ok) {
|
| 145 |
+
const text = await res.text().catch(() => `${res.status}`);
|
| 146 |
+
throw new Error(text || `export: ${res.status}`);
|
| 147 |
+
}
|
| 148 |
+
return res.json();
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
export interface PushToHubResult {
|
| 152 |
+
ok: boolean;
|
| 153 |
+
repo_id: string;
|
| 154 |
+
url: string;
|
| 155 |
+
message: string;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
export async function pushToHub(
|
| 159 |
+
ident: DatasetIdent,
|
| 160 |
+
hfToken: string,
|
| 161 |
+
pushInPlace: boolean,
|
| 162 |
+
newRepoId: string | null,
|
| 163 |
+
privateRepo: boolean,
|
| 164 |
+
commitMessage: string,
|
| 165 |
+
): Promise<PushToHubResult> {
|
| 166 |
+
if (!ENV_URL) throw new Error("Annotate backend not configured");
|
| 167 |
+
const res = await fetch(new URL("/api/push_to_hub", ENV_URL).toString(), {
|
| 168 |
+
method: "POST",
|
| 169 |
+
headers: { "Content-Type": "application/json" },
|
| 170 |
+
body: JSON.stringify({
|
| 171 |
+
repo_id: ident.repoId || null,
|
| 172 |
+
revision: ident.revision || null,
|
| 173 |
+
local_path: ident.localPath || null,
|
| 174 |
+
hf_token: hfToken,
|
| 175 |
+
push_in_place: pushInPlace,
|
| 176 |
+
new_repo_id: newRepoId || null,
|
| 177 |
+
private: privateRepo,
|
| 178 |
+
commit_message: commitMessage,
|
| 179 |
+
}),
|
| 180 |
+
});
|
| 181 |
+
if (!res.ok) {
|
| 182 |
+
const text = await res.text().catch(() => `${res.status}`);
|
| 183 |
+
throw new Error(text || `push: ${res.status}`);
|
| 184 |
+
}
|
| 185 |
+
return res.json();
|
| 186 |
+
}
|