mishig HF Staff commited on
Commit
461b74e
·
verified ·
1 Parent(s): c399e5f

Sync from GitHub via hub-sync

Browse files
.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
- <EpisodeViewerInner data={data!} org={org} dataset={dataset} />
 
 
 
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
- const [activeTab, setActiveTab] = useState<ActiveTab>("episodes");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(false);
236
- const [sidebarFlaggedOnly, setSidebarFlaggedOnly] = useState(false);
 
 
 
 
 
 
 
 
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" || activeTab === "urdf") && (
 
 
574
  <Sidebar
575
  datasetInfo={datasetInfo}
576
  paginatedEpisodes={paginatedEpisodes}
@@ -587,7 +650,9 @@ function EpisodeViewerInner({
587
  setUrdfEpisode(ep);
588
  urdfChangerRef.current?.(ep);
589
  }
590
- : undefined
 
 
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 { chartDataGroups, flatChartData, ignoredColumns, task } =
712
- await loadEpisodeDataV3(repoId, version, info, episodeMetadata);
 
 
 
 
 
 
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 { chartDataGroups, flatChartData, ignoredColumns, task };
 
 
 
 
 
 
 
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(sliderValue);
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
- <video
373
- ref={(el: HTMLVideoElement | null) => {
374
- videoRefs.current[idx] = el;
375
- }}
376
- className={`w-full object-contain ${
377
- isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""
378
- }`}
379
- muted
380
- preload="auto"
381
- crossOrigin="anonymous"
382
- >
383
- <source src={proxyHfUrl(info.url)} type="video/mp4" />
384
- Your browser does not support the video tag.
385
- </video>
 
 
 
 
 
 
 
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
+ }