oopsie-data-tools 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oopsie_data_tools/__init__.py +7 -0
- oopsie_data_tools/annotation_tool/__init__.py +5 -0
- oopsie_data_tools/annotation_tool/annotation_schema.py +266 -0
- oopsie_data_tools/annotation_tool/annotator_server.py +850 -0
- oopsie_data_tools/annotation_tool/episode_recorder.py +648 -0
- oopsie_data_tools/annotation_tool/rollout_annotator.py +333 -0
- oopsie_data_tools/annotation_tool/ui/annotator.html +2240 -0
- oopsie_data_tools/cli.py +894 -0
- oopsie_data_tools/init_wizard.py +329 -0
- oopsie_data_tools/skill/SKILL.md +98 -0
- oopsie_data_tools/skill/reference/conversion.md +257 -0
- oopsie_data_tools/skill/reference/format.md +112 -0
- oopsie_data_tools/skill/reference/robot-profile.md +104 -0
- oopsie_data_tools/skill/reference/setup.md +216 -0
- oopsie_data_tools/skill/reference/troubleshooting.md +97 -0
- oopsie_data_tools/test/__init__.py +1 -0
- oopsie_data_tools/test/conftest.py +174 -0
- oopsie_data_tools/test/fixtures/__init__.py +0 -0
- oopsie_data_tools/test/fixtures/make_invalid.py +633 -0
- oopsie_data_tools/test/fixtures/make_valid.py +406 -0
- oopsie_data_tools/test/test_annotation_completeness.py +154 -0
- oopsie_data_tools/test/test_annotation_schema.py +190 -0
- oopsie_data_tools/test/test_annotator_server.py +240 -0
- oopsie_data_tools/test/test_annotator_server_guards.py +146 -0
- oopsie_data_tools/test/test_bulk_inference_end_to_end.py +112 -0
- oopsie_data_tools/test/test_cli_config.py +111 -0
- oopsie_data_tools/test/test_cli_tools.py +438 -0
- oopsie_data_tools/test/test_contributor_config.py +38 -0
- oopsie_data_tools/test/test_conversion_utils_annotations.py +75 -0
- oopsie_data_tools/test/test_credentials_location.py +106 -0
- oopsie_data_tools/test/test_diversity.py +33 -0
- oopsie_data_tools/test/test_episode_recorder.py +335 -0
- oopsie_data_tools/test/test_episode_video_paths.py +173 -0
- oopsie_data_tools/test/test_hf_upload.py +234 -0
- oopsie_data_tools/test/test_init_wizard.py +193 -0
- oopsie_data_tools/test/test_install_skill.py +120 -0
- oopsie_data_tools/test/test_migrate_taxonomy_v2.py +255 -0
- oopsie_data_tools/test/test_new_profile.py +84 -0
- oopsie_data_tools/test/test_paths.py +137 -0
- oopsie_data_tools/test/test_python38_compat.py +79 -0
- oopsie_data_tools/test/test_record_step_purity.py +127 -0
- oopsie_data_tools/test/test_robot_setup.py +244 -0
- oopsie_data_tools/test/test_rollout_annotator.py +134 -0
- oopsie_data_tools/test/test_rotation_utils.py +126 -0
- oopsie_data_tools/test/test_validate.py +494 -0
- oopsie_data_tools/utils/__init__.py +1 -0
- oopsie_data_tools/utils/claude_skill.py +96 -0
- oopsie_data_tools/utils/contributor_config.py +91 -0
- oopsie_data_tools/utils/conversion_utils.py +220 -0
- oopsie_data_tools/utils/h5.py +60 -0
- oopsie_data_tools/utils/h5_inspect.py +167 -0
- oopsie_data_tools/utils/hf_limits.py +22 -0
- oopsie_data_tools/utils/hf_upload.py +235 -0
- oopsie_data_tools/utils/log.py +35 -0
- oopsie_data_tools/utils/migrate_taxonomy_v2.py +215 -0
- oopsie_data_tools/utils/paths.py +162 -0
- oopsie_data_tools/utils/restructure.py +402 -0
- oopsie_data_tools/utils/robot_profile/__init__.py +1 -0
- oopsie_data_tools/utils/robot_profile/robot_profile.py +240 -0
- oopsie_data_tools/utils/robot_profile/rotation_utils.py +119 -0
- oopsie_data_tools/utils/robot_profile/template.py +108 -0
- oopsie_data_tools/utils/validation/__init__.py +5 -0
- oopsie_data_tools/utils/validation/annotation_completeness.py +67 -0
- oopsie_data_tools/utils/validation/diversity.py +93 -0
- oopsie_data_tools/utils/validation/episode_data.py +54 -0
- oopsie_data_tools/utils/validation/episode_loader.py +201 -0
- oopsie_data_tools/utils/validation/episode_validator.py +315 -0
- oopsie_data_tools/utils/validation/errors.py +17 -0
- oopsie_data_tools/utils/validation/validation_utils.py +88 -0
- oopsie_data_tools-0.2.0.dist-info/METADATA +131 -0
- oopsie_data_tools-0.2.0.dist-info/RECORD +74 -0
- oopsie_data_tools-0.2.0.dist-info/WHEEL +4 -0
- oopsie_data_tools-0.2.0.dist-info/entry_points.txt +2 -0
- oopsie_data_tools-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,2240 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>OopsieData</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
[hidden] { display: none !important; }
|
|
10
|
+
:root {
|
|
11
|
+
color-scheme: light dark;
|
|
12
|
+
/* Type scale — four role-named tiers. Every font-size in the stylesheet
|
|
13
|
+
should reference one of these; if you feel the urge to add a fifth,
|
|
14
|
+
it's a signal that a rule is doing something the role model doesn't
|
|
15
|
+
yet describe, not that we need finer granularity. */
|
|
16
|
+
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
17
|
+
--font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
18
|
+
--fs-label: 0.72rem; /* uppercase eyebrows, hints, chip counters */
|
|
19
|
+
--fs-ui: 0.9rem; /* every interface control + secondary body text */
|
|
20
|
+
--fs-heading: 1.1rem; /* section headings inside a panel */
|
|
21
|
+
--fs-title: 1.5rem; /* page title only */
|
|
22
|
+
--tree-indent: 14px;
|
|
23
|
+
--video-h: min(60vh, 420px);
|
|
24
|
+
--tooltip-w: min(280px, 90vw);
|
|
25
|
+
/* focus ring */
|
|
26
|
+
--focus-ring: 0 0 0 2px var(--bg-app), 0 0 0 4px var(--accent);
|
|
27
|
+
/* Dark theme (default). Overridden below by prefers-color-scheme + [data-theme]. */
|
|
28
|
+
--bg-app: #1a1a2e;
|
|
29
|
+
--bg-panel: #16213e;
|
|
30
|
+
--bg-inset: #0f172a;
|
|
31
|
+
--bg-hover: #1e3a5f;
|
|
32
|
+
--bg-tooltip: #1e293b;
|
|
33
|
+
--tooltip-text: #cbd5e1;
|
|
34
|
+
--text: #eee;
|
|
35
|
+
--text-strong: #f1f5f9;
|
|
36
|
+
--text-input: #fff;
|
|
37
|
+
--text-value: #e2e8f0;
|
|
38
|
+
--text-muted: #cbd5e1;
|
|
39
|
+
--text-muted-2: #94a3b8;
|
|
40
|
+
--text-muted-3: #64748b;
|
|
41
|
+
--border-input: #333;
|
|
42
|
+
--border-panel: #2a3f5f;
|
|
43
|
+
--border-divider: #334155;
|
|
44
|
+
--border-inner: #1e293b;
|
|
45
|
+
--accent: #60a5fa;
|
|
46
|
+
--primary-bg: #4ade80;
|
|
47
|
+
--primary-hover: #22c55e;
|
|
48
|
+
--primary-text: #000;
|
|
49
|
+
--scrollbar-thumb: #334155;
|
|
50
|
+
--scrollbar-thumb-hover: #475569;
|
|
51
|
+
--other-mark: #a78bfa;
|
|
52
|
+
--warn: #fbbf24;
|
|
53
|
+
--danger: #f87171;
|
|
54
|
+
--video-bg: #000;
|
|
55
|
+
}
|
|
56
|
+
/* Standard OS-preference resolution: follow prefers-color-scheme first,
|
|
57
|
+
then let [data-theme] on <html> override for the toggle button. */
|
|
58
|
+
@media (prefers-color-scheme: light) {
|
|
59
|
+
:root {
|
|
60
|
+
--bg-app: #f1f5f9;
|
|
61
|
+
--bg-panel: #ffffff;
|
|
62
|
+
--bg-inset: #f8fafc;
|
|
63
|
+
--bg-hover: #dbeafe;
|
|
64
|
+
--bg-tooltip: #1e293b;
|
|
65
|
+
--tooltip-text: #f1f5f9;
|
|
66
|
+
--text: #0f172a;
|
|
67
|
+
--text-strong: #0f172a;
|
|
68
|
+
--text-input: #0f172a;
|
|
69
|
+
--text-value: #1e293b;
|
|
70
|
+
--text-muted: #475569;
|
|
71
|
+
--text-muted-2: #64748b;
|
|
72
|
+
--text-muted-3: #94a3b8;
|
|
73
|
+
--border-input: #cbd5e1;
|
|
74
|
+
--border-panel: #e2e8f0;
|
|
75
|
+
--border-divider: #e2e8f0;
|
|
76
|
+
--border-inner: #e2e8f0;
|
|
77
|
+
--accent: #2563eb;
|
|
78
|
+
--primary-bg: #22c55e;
|
|
79
|
+
--primary-hover: #16a34a;
|
|
80
|
+
--primary-text: #ffffff;
|
|
81
|
+
--scrollbar-thumb: #cbd5e1;
|
|
82
|
+
--scrollbar-thumb-hover: #94a3b8;
|
|
83
|
+
--other-mark: #7c3aed;
|
|
84
|
+
--warn: #b45309;
|
|
85
|
+
--danger: #dc2626;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
:root[data-theme="dark"] {
|
|
89
|
+
color-scheme: dark;
|
|
90
|
+
--bg-app: #1a1a2e;
|
|
91
|
+
--bg-panel: #16213e;
|
|
92
|
+
--bg-inset: #0f172a;
|
|
93
|
+
--bg-hover: #1e3a5f;
|
|
94
|
+
--bg-tooltip: #1e293b;
|
|
95
|
+
--tooltip-text: #cbd5e1;
|
|
96
|
+
--text: #eee;
|
|
97
|
+
--text-strong: #f1f5f9;
|
|
98
|
+
--text-input: #fff;
|
|
99
|
+
--text-value: #e2e8f0;
|
|
100
|
+
--text-muted: #cbd5e1;
|
|
101
|
+
--text-muted-2: #94a3b8;
|
|
102
|
+
--text-muted-3: #64748b;
|
|
103
|
+
--border-input: #333;
|
|
104
|
+
--border-panel: #2a3f5f;
|
|
105
|
+
--border-divider: #334155;
|
|
106
|
+
--border-inner: #1e293b;
|
|
107
|
+
--accent: #60a5fa;
|
|
108
|
+
--primary-bg: #4ade80;
|
|
109
|
+
--primary-hover: #22c55e;
|
|
110
|
+
--primary-text: #000;
|
|
111
|
+
--scrollbar-thumb: #334155;
|
|
112
|
+
--scrollbar-thumb-hover: #475569;
|
|
113
|
+
--other-mark: #a78bfa;
|
|
114
|
+
--warn: #fbbf24;
|
|
115
|
+
--danger: #f87171;
|
|
116
|
+
}
|
|
117
|
+
:root[data-theme="light"] {
|
|
118
|
+
color-scheme: light;
|
|
119
|
+
--bg-app: #f1f5f9;
|
|
120
|
+
--bg-panel: #ffffff;
|
|
121
|
+
--bg-inset: #f8fafc;
|
|
122
|
+
--bg-hover: #dbeafe;
|
|
123
|
+
--bg-tooltip: #1e293b;
|
|
124
|
+
--tooltip-text: #f1f5f9;
|
|
125
|
+
--text: #0f172a;
|
|
126
|
+
--text-strong: #0f172a;
|
|
127
|
+
--text-input: #0f172a;
|
|
128
|
+
--text-value: #1e293b;
|
|
129
|
+
--text-muted: #475569;
|
|
130
|
+
--text-muted-2: #64748b;
|
|
131
|
+
--text-muted-3: #94a3b8;
|
|
132
|
+
--border-input: #cbd5e1;
|
|
133
|
+
--border-panel: #e2e8f0;
|
|
134
|
+
--border-divider: #e2e8f0;
|
|
135
|
+
--border-inner: #e2e8f0;
|
|
136
|
+
--accent: #2563eb;
|
|
137
|
+
--primary-bg: #22c55e;
|
|
138
|
+
--primary-hover: #16a34a;
|
|
139
|
+
--primary-text: #ffffff;
|
|
140
|
+
--scrollbar-thumb: #cbd5e1;
|
|
141
|
+
--scrollbar-thumb-hover: #94a3b8;
|
|
142
|
+
--other-mark: #7c3aed;
|
|
143
|
+
--warn: #b45309;
|
|
144
|
+
--danger: #dc2626;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
html, body { height: 100%; }
|
|
148
|
+
body {
|
|
149
|
+
font-family: var(--font-body);
|
|
150
|
+
background: var(--bg-app);
|
|
151
|
+
color: var(--text);
|
|
152
|
+
overflow: hidden;
|
|
153
|
+
}
|
|
154
|
+
input, textarea, select, button, h1, h2, h3, h4, h5, h6 { font-family: inherit; }
|
|
155
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
|
156
|
+
/* Shared internal-scroll affordance. Apply to any element that owns its own scroll region. */
|
|
157
|
+
.scroll-y {
|
|
158
|
+
overflow-y: auto;
|
|
159
|
+
min-height: 0;
|
|
160
|
+
scrollbar-width: thin;
|
|
161
|
+
scrollbar-color: var(--scrollbar-thumb) transparent;
|
|
162
|
+
}
|
|
163
|
+
.scroll-y::-webkit-scrollbar { width: 10px; }
|
|
164
|
+
.scroll-y::-webkit-scrollbar-track { background: transparent; }
|
|
165
|
+
.scroll-y::-webkit-scrollbar-thumb {
|
|
166
|
+
background: var(--scrollbar-thumb);
|
|
167
|
+
border-radius: 6px;
|
|
168
|
+
border: 2px solid transparent;
|
|
169
|
+
background-clip: padding-box;
|
|
170
|
+
}
|
|
171
|
+
.scroll-y::-webkit-scrollbar-thumb:hover {
|
|
172
|
+
background: var(--scrollbar-thumb-hover);
|
|
173
|
+
background-clip: padding-box;
|
|
174
|
+
border: 2px solid transparent;
|
|
175
|
+
}
|
|
176
|
+
.container {
|
|
177
|
+
max-width: none;
|
|
178
|
+
margin: 0 auto;
|
|
179
|
+
padding: 16px 20px 0;
|
|
180
|
+
height: 100%;
|
|
181
|
+
display: flex;
|
|
182
|
+
flex-direction: column;
|
|
183
|
+
min-height: 0;
|
|
184
|
+
}
|
|
185
|
+
header {
|
|
186
|
+
flex: 0 0 auto;
|
|
187
|
+
display: flex;
|
|
188
|
+
justify-content: space-between;
|
|
189
|
+
align-items: center;
|
|
190
|
+
flex-wrap: wrap;
|
|
191
|
+
gap: 12px 20px;
|
|
192
|
+
margin-bottom: 14px;
|
|
193
|
+
padding-bottom: 12px;
|
|
194
|
+
border-bottom: 1px solid var(--border-divider);
|
|
195
|
+
}
|
|
196
|
+
header h1 {
|
|
197
|
+
font-size: var(--fs-title);
|
|
198
|
+
margin: 0;
|
|
199
|
+
min-width: 0;
|
|
200
|
+
flex-shrink: 0;
|
|
201
|
+
}
|
|
202
|
+
.session-meta {
|
|
203
|
+
flex: 1 1 auto;
|
|
204
|
+
min-width: 0;
|
|
205
|
+
display: flex;
|
|
206
|
+
align-items: baseline;
|
|
207
|
+
flex-wrap: wrap;
|
|
208
|
+
gap: 4px 8px;
|
|
209
|
+
font-size: var(--fs-ui);
|
|
210
|
+
color: var(--text-value);
|
|
211
|
+
}
|
|
212
|
+
.session-meta .ctx-label {
|
|
213
|
+
color: var(--text-muted-2);
|
|
214
|
+
font-weight: 600;
|
|
215
|
+
text-transform: uppercase;
|
|
216
|
+
font-size: var(--fs-label);
|
|
217
|
+
letter-spacing: 0.04em;
|
|
218
|
+
}
|
|
219
|
+
.session-meta .ctx-sep { color: var(--text-muted-3); }
|
|
220
|
+
.header-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
|
221
|
+
.header-back { flex-shrink: 0; }
|
|
222
|
+
.theme-toggle { padding: 8px 12px; font-size: var(--fs-ui); color: var(--text); }
|
|
223
|
+
.statusline { font-family: var(--font-mono); font-size: var(--fs-ui); color: var(--text-muted); }
|
|
224
|
+
|
|
225
|
+
.main-content {
|
|
226
|
+
flex: 1 1 auto;
|
|
227
|
+
min-height: 0;
|
|
228
|
+
display: grid;
|
|
229
|
+
grid-template-columns: minmax(220px, 1.2fr) minmax(0, 3fr) minmax(320px, 1.8fr);
|
|
230
|
+
gap: 16px;
|
|
231
|
+
padding-bottom: 16px;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.sidebar {
|
|
235
|
+
background: var(--bg-panel);
|
|
236
|
+
border-radius: 8px;
|
|
237
|
+
padding: 10px;
|
|
238
|
+
display: flex;
|
|
239
|
+
flex-direction: column;
|
|
240
|
+
min-height: 0;
|
|
241
|
+
height: 100%;
|
|
242
|
+
}
|
|
243
|
+
.sidebar #h5List { flex: 1 1 auto; margin-top: 10px; }
|
|
244
|
+
.h5-header { display:flex; align-items:center; margin-bottom: 10px; padding: 0 5px; }
|
|
245
|
+
.h5-title { font-size: var(--fs-ui); color: var(--text-muted-2); font-weight: 600; }
|
|
246
|
+
.sidebar-stats {
|
|
247
|
+
padding: 0 5px 10px;
|
|
248
|
+
border-bottom: 1px solid var(--border-panel);
|
|
249
|
+
margin-bottom: 10px;
|
|
250
|
+
display: flex;
|
|
251
|
+
flex-direction: column;
|
|
252
|
+
gap: 3px;
|
|
253
|
+
font-size: var(--fs-label);
|
|
254
|
+
}
|
|
255
|
+
.sidebar-stats .stats-row {
|
|
256
|
+
display: flex;
|
|
257
|
+
justify-content: space-between;
|
|
258
|
+
align-items: baseline;
|
|
259
|
+
gap: 8px;
|
|
260
|
+
color: var(--text-muted-2);
|
|
261
|
+
}
|
|
262
|
+
.sidebar-stats .stats-row span:last-child { color: var(--text-value); font-weight: 600; }
|
|
263
|
+
.sidebar-stats .stats-row.muted span:last-child { color: var(--text-muted-3); font-weight: 500; }
|
|
264
|
+
.sidebar-filter {
|
|
265
|
+
padding: 0 5px 10px;
|
|
266
|
+
border-bottom: 1px solid var(--border-panel);
|
|
267
|
+
margin-bottom: 10px;
|
|
268
|
+
}
|
|
269
|
+
.sidebar-filter-row {
|
|
270
|
+
display: flex;
|
|
271
|
+
align-items: center;
|
|
272
|
+
justify-content: space-between;
|
|
273
|
+
gap: 8px;
|
|
274
|
+
font-size: var(--fs-label);
|
|
275
|
+
color: var(--text-muted-2);
|
|
276
|
+
font-weight: 500;
|
|
277
|
+
cursor: pointer;
|
|
278
|
+
}
|
|
279
|
+
.sidebar-filter-row input { width: 14px; height: 14px; }
|
|
280
|
+
.sidebar-filter-text {
|
|
281
|
+
display: inline-flex;
|
|
282
|
+
align-items: baseline;
|
|
283
|
+
gap: 6px;
|
|
284
|
+
min-width: 0;
|
|
285
|
+
}
|
|
286
|
+
.filter-shown {
|
|
287
|
+
font-family: var(--font-mono);
|
|
288
|
+
color: var(--text-muted-3);
|
|
289
|
+
font-weight: 500;
|
|
290
|
+
white-space: nowrap;
|
|
291
|
+
}
|
|
292
|
+
.filter-shown:empty::before { content: "\00a0"; } /* reserve a line so toggling never reflows */
|
|
293
|
+
|
|
294
|
+
.episode-toolbar {
|
|
295
|
+
flex: 0 0 auto;
|
|
296
|
+
display: grid;
|
|
297
|
+
grid-template-columns: minmax(220px, 1.2fr) minmax(0, 3fr) minmax(320px, 1.8fr);
|
|
298
|
+
gap: 16px;
|
|
299
|
+
align-items: center;
|
|
300
|
+
margin-bottom: 14px;
|
|
301
|
+
background: var(--bg-panel);
|
|
302
|
+
border: 1px solid var(--border-panel);
|
|
303
|
+
border-radius: 8px;
|
|
304
|
+
padding: 8px 12px;
|
|
305
|
+
}
|
|
306
|
+
.episode-toolbar > .episode-nav { grid-column: 2; justify-self: stretch; width: 100%; justify-content: center; }
|
|
307
|
+
.episode-toolbar > .h5-search { grid-column: 3; }
|
|
308
|
+
.episode-position {
|
|
309
|
+
flex: 0 0 auto;
|
|
310
|
+
font-size: var(--fs-label);
|
|
311
|
+
color: var(--text-muted-2);
|
|
312
|
+
font-family: var(--font-mono);
|
|
313
|
+
padding: 0 4px;
|
|
314
|
+
white-space: nowrap;
|
|
315
|
+
}
|
|
316
|
+
.episode-nav {
|
|
317
|
+
display: flex;
|
|
318
|
+
align-items: center;
|
|
319
|
+
gap: 10px;
|
|
320
|
+
min-width: 0;
|
|
321
|
+
}
|
|
322
|
+
.episode-nav button {
|
|
323
|
+
flex: 0 0 auto;
|
|
324
|
+
padding: 6px 10px;
|
|
325
|
+
font-size: var(--fs-ui);
|
|
326
|
+
}
|
|
327
|
+
.episode-nav .episode-name {
|
|
328
|
+
flex: 0 1 auto;
|
|
329
|
+
min-width: 0;
|
|
330
|
+
max-width: 100%;
|
|
331
|
+
text-align: center;
|
|
332
|
+
font-family: var(--font-mono);
|
|
333
|
+
font-size: var(--fs-ui);
|
|
334
|
+
color: var(--text-value);
|
|
335
|
+
white-space: nowrap;
|
|
336
|
+
overflow: hidden;
|
|
337
|
+
text-overflow: ellipsis;
|
|
338
|
+
}
|
|
339
|
+
.episode-nav .episode-name.placeholder { color: var(--text-muted-3); }
|
|
340
|
+
.h5-search { padding: 8px 10px; font-size: var(--fs-ui); }
|
|
341
|
+
.h5-search::placeholder { color: var(--text-muted-3); }
|
|
342
|
+
.h5-legend {
|
|
343
|
+
font-size: var(--fs-label);
|
|
344
|
+
line-height: 1.45;
|
|
345
|
+
color: var(--text-muted-2);
|
|
346
|
+
padding: 0 5px 10px;
|
|
347
|
+
border-bottom: 1px solid var(--border-panel);
|
|
348
|
+
margin-bottom: 10px;
|
|
349
|
+
}
|
|
350
|
+
.h5-legend-row { margin-top: 4px; }
|
|
351
|
+
.h5-legend-row:first-child { margin-top: 0; }
|
|
352
|
+
.muted { opacity: 0.85; color: var(--text-muted-2); }
|
|
353
|
+
|
|
354
|
+
button { padding: 10px 14px; border-radius: 6px; border: 1px solid var(--border-input); cursor: pointer; font-weight: 600; background: transparent; color: var(--accent); }
|
|
355
|
+
button:hover { background: var(--bg-hover); }
|
|
356
|
+
button.primary { background: var(--primary-bg); color: var(--primary-text); border: none; }
|
|
357
|
+
button.primary:hover { background: var(--primary-hover); }
|
|
358
|
+
button:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
359
|
+
|
|
360
|
+
.video-section {
|
|
361
|
+
background: var(--bg-panel);
|
|
362
|
+
border-radius: 8px;
|
|
363
|
+
padding: 16px 20px;
|
|
364
|
+
min-height: 0;
|
|
365
|
+
height: 100%;
|
|
366
|
+
display: flex;
|
|
367
|
+
flex-direction: column;
|
|
368
|
+
}
|
|
369
|
+
.video-section > * { flex: 0 0 auto; }
|
|
370
|
+
.video-scroll {
|
|
371
|
+
flex: 1 1 auto;
|
|
372
|
+
margin: 0 -8px;
|
|
373
|
+
padding: 0 8px;
|
|
374
|
+
}
|
|
375
|
+
.video-label {
|
|
376
|
+
font-size: var(--fs-ui);
|
|
377
|
+
font-weight: 600;
|
|
378
|
+
color: var(--text-muted-2);
|
|
379
|
+
margin-bottom: 4px;
|
|
380
|
+
text-transform: uppercase;
|
|
381
|
+
letter-spacing: 0.5px;
|
|
382
|
+
}
|
|
383
|
+
.video-controls {
|
|
384
|
+
display: flex;
|
|
385
|
+
align-items: center;
|
|
386
|
+
justify-content: flex-end;
|
|
387
|
+
gap: 16px;
|
|
388
|
+
margin-bottom: 12px;
|
|
389
|
+
flex-wrap: wrap;
|
|
390
|
+
}
|
|
391
|
+
.video-controls label {
|
|
392
|
+
display: flex;
|
|
393
|
+
align-items: center;
|
|
394
|
+
gap: 6px;
|
|
395
|
+
margin: 0;
|
|
396
|
+
font-weight: 500;
|
|
397
|
+
font-size: var(--fs-ui);
|
|
398
|
+
color: var(--text-muted);
|
|
399
|
+
}
|
|
400
|
+
.video-controls select { width: auto; padding: 5px 8px; }
|
|
401
|
+
.video-controls input[type="checkbox"] { width: 16px; height: 16px; }
|
|
402
|
+
|
|
403
|
+
.form-section {
|
|
404
|
+
background: var(--bg-panel);
|
|
405
|
+
border-radius: 8px;
|
|
406
|
+
padding: 16px 20px;
|
|
407
|
+
display: flex;
|
|
408
|
+
flex-direction: column;
|
|
409
|
+
min-height: 0;
|
|
410
|
+
height: 100%;
|
|
411
|
+
}
|
|
412
|
+
.form-scroll {
|
|
413
|
+
flex: 1 1 auto;
|
|
414
|
+
margin: 0 -8px;
|
|
415
|
+
padding: 0 8px;
|
|
416
|
+
}
|
|
417
|
+
.save-bar {
|
|
418
|
+
flex: 0 0 auto;
|
|
419
|
+
margin-top: 12px;
|
|
420
|
+
padding-top: 12px;
|
|
421
|
+
border-top: 1px solid var(--border-divider);
|
|
422
|
+
display: flex;
|
|
423
|
+
align-items: center;
|
|
424
|
+
gap: 12px;
|
|
425
|
+
flex-wrap: wrap;
|
|
426
|
+
}
|
|
427
|
+
.save-bar .spacer { flex: 1 1 auto; }
|
|
428
|
+
.dirty-indicator { font-size: var(--fs-ui); font-weight: 600; color: var(--warn); }
|
|
429
|
+
.dirty-indicator.saved { color: var(--primary-bg); }
|
|
430
|
+
.autoadvance-label { font-size: var(--fs-label); color: var(--text-muted-2); display: flex; align-items: center; gap: 5px; cursor: pointer; user-select: none; }
|
|
431
|
+
.autoadvance-label input { width: 15px; height: 15px; }
|
|
432
|
+
.form-group { margin-bottom: 20px; }
|
|
433
|
+
.form-divider {
|
|
434
|
+
margin: 8px 0 14px;
|
|
435
|
+
padding-top: 8px;
|
|
436
|
+
border-top: 1px solid var(--border-divider);
|
|
437
|
+
font-size: var(--fs-ui);
|
|
438
|
+
color: var(--text-muted-2);
|
|
439
|
+
}
|
|
440
|
+
label { display: block; margin-bottom: 8px; font-weight: 500; }
|
|
441
|
+
.required::after { content: " *"; color: var(--danger); }
|
|
442
|
+
input[type="text"], input[type="search"], textarea, select {
|
|
443
|
+
width: 100%;
|
|
444
|
+
padding: 10px;
|
|
445
|
+
border: 1px solid var(--border-input);
|
|
446
|
+
border-radius: 6px;
|
|
447
|
+
background: var(--bg-app);
|
|
448
|
+
color: var(--text-input);
|
|
449
|
+
font-size: var(--fs-ui);
|
|
450
|
+
}
|
|
451
|
+
input[type="search"] { appearance: none; -webkit-appearance: none; }
|
|
452
|
+
textarea { min-height: 100px; resize: vertical; }
|
|
453
|
+
select { cursor: pointer; }
|
|
454
|
+
input[type="radio"], input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; }
|
|
455
|
+
.opt-row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-weight: 500; cursor: pointer; }
|
|
456
|
+
.opt-row input { margin: 0; width: 18px; }
|
|
457
|
+
|
|
458
|
+
.info-icon {
|
|
459
|
+
display: inline-flex;
|
|
460
|
+
align-items: center;
|
|
461
|
+
justify-content: center;
|
|
462
|
+
width: 15px;
|
|
463
|
+
height: 15px;
|
|
464
|
+
border-radius: 50%;
|
|
465
|
+
background: var(--bg-hover);
|
|
466
|
+
color: var(--accent);
|
|
467
|
+
font-size: var(--fs-label);
|
|
468
|
+
font-style: normal;
|
|
469
|
+
cursor: help;
|
|
470
|
+
flex-shrink: 0;
|
|
471
|
+
position: relative;
|
|
472
|
+
}
|
|
473
|
+
/* Shared floating tooltip element. Lives at the end of <body> so it can
|
|
474
|
+
never be clipped by ancestor overflow (e.g. .form-scroll). Positioned
|
|
475
|
+
at hover-time by JS via getBoundingClientRect. */
|
|
476
|
+
.floating-tooltip {
|
|
477
|
+
position: fixed;
|
|
478
|
+
top: 0;
|
|
479
|
+
left: 0;
|
|
480
|
+
display: none;
|
|
481
|
+
background: var(--bg-tooltip);
|
|
482
|
+
color: var(--tooltip-text);
|
|
483
|
+
border: 1px solid var(--border-divider);
|
|
484
|
+
border-radius: 6px;
|
|
485
|
+
padding: 8px 12px;
|
|
486
|
+
font-size: var(--fs-ui);
|
|
487
|
+
line-height: 1.4;
|
|
488
|
+
width: max-content;
|
|
489
|
+
max-width: var(--tooltip-w);
|
|
490
|
+
z-index: 200;
|
|
491
|
+
pointer-events: none;
|
|
492
|
+
white-space: normal;
|
|
493
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
|
494
|
+
}
|
|
495
|
+
.floating-tooltip.visible { display: block; }
|
|
496
|
+
|
|
497
|
+
.tree-row { display:flex; align-items:center; gap: 8px; padding: 8px 10px; border-radius: 4px; cursor: pointer; font-size: var(--fs-ui); font-family: var(--font-mono); transition: background 0.15s; }
|
|
498
|
+
.tree-row:hover { background: var(--bg-app); }
|
|
499
|
+
.tree-row.active { background: var(--bg-hover); color: var(--accent); }
|
|
500
|
+
.tree-arrow { width: var(--tree-indent); display:inline-block; opacity: 0.75; color: var(--text-muted-2); }
|
|
501
|
+
.tree-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
502
|
+
|
|
503
|
+
.tick { color: var(--primary-bg); font-weight: 800; }
|
|
504
|
+
.other-mark { color: var(--other-mark); font-weight: 800; }
|
|
505
|
+
|
|
506
|
+
.videos-grid {
|
|
507
|
+
display: grid;
|
|
508
|
+
grid-template-columns: repeat(var(--video-cols, 1), minmax(0, 1fr));
|
|
509
|
+
gap: 12px;
|
|
510
|
+
}
|
|
511
|
+
.video-card { margin-bottom: 10px; }
|
|
512
|
+
.video-card video {
|
|
513
|
+
width: 100%;
|
|
514
|
+
height: auto;
|
|
515
|
+
max-height: var(--video-h);
|
|
516
|
+
object-fit: contain;
|
|
517
|
+
border-radius: 4px;
|
|
518
|
+
background: var(--video-bg);
|
|
519
|
+
display: block;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.task-instruction-box {
|
|
523
|
+
background: var(--bg-inset);
|
|
524
|
+
border: 1px solid var(--border-panel);
|
|
525
|
+
border-radius: 6px;
|
|
526
|
+
padding: 12px 14px;
|
|
527
|
+
margin-bottom: 14px;
|
|
528
|
+
}
|
|
529
|
+
.task-instruction-box-label {
|
|
530
|
+
font-size: var(--fs-label);
|
|
531
|
+
font-weight: 700;
|
|
532
|
+
text-transform: uppercase;
|
|
533
|
+
letter-spacing: 0.6px;
|
|
534
|
+
color: var(--accent);
|
|
535
|
+
margin-bottom: 4px;
|
|
536
|
+
}
|
|
537
|
+
.task-instruction-box-value {
|
|
538
|
+
font-size: var(--fs-ui);
|
|
539
|
+
font-weight: 600;
|
|
540
|
+
color: var(--text-strong);
|
|
541
|
+
line-height: 1.4;
|
|
542
|
+
word-break: break-word;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.meta-panel {
|
|
546
|
+
background: var(--bg-inset);
|
|
547
|
+
border: 1px solid var(--border-divider);
|
|
548
|
+
border-radius: 6px;
|
|
549
|
+
padding: 12px 14px;
|
|
550
|
+
margin-bottom: 16px;
|
|
551
|
+
font-size: var(--fs-ui);
|
|
552
|
+
}
|
|
553
|
+
.meta-instruction {
|
|
554
|
+
font-size: var(--fs-ui);
|
|
555
|
+
font-weight: 600;
|
|
556
|
+
color: var(--text-strong);
|
|
557
|
+
margin-bottom: 8px;
|
|
558
|
+
line-height: 1.4;
|
|
559
|
+
}
|
|
560
|
+
.meta-instruction-label {
|
|
561
|
+
font-size: var(--fs-label);
|
|
562
|
+
font-weight: 700;
|
|
563
|
+
text-transform: uppercase;
|
|
564
|
+
letter-spacing: 0.6px;
|
|
565
|
+
color: var(--accent);
|
|
566
|
+
margin-bottom: 3px;
|
|
567
|
+
}
|
|
568
|
+
.meta-row {
|
|
569
|
+
display: flex;
|
|
570
|
+
gap: 16px;
|
|
571
|
+
flex-wrap: wrap;
|
|
572
|
+
margin-top: 8px;
|
|
573
|
+
padding-top: 8px;
|
|
574
|
+
border-top: 1px solid var(--border-inner);
|
|
575
|
+
}
|
|
576
|
+
.meta-row.meta-row-flat { margin-top: 0; padding-top: 0; border-top: none; }
|
|
577
|
+
.meta-item { display: flex; flex-direction: column; gap: 1px; }
|
|
578
|
+
.meta-key { font-size: var(--fs-label); text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted-3); }
|
|
579
|
+
.meta-val { font-size: var(--fs-ui); color: var(--text-muted); font-family: var(--font-mono); word-break: break-all; }
|
|
580
|
+
|
|
581
|
+
#episodeFields { margin-bottom: 16px; }
|
|
582
|
+
.episode-data { background: var(--bg-inset); border: 1px solid var(--border-divider); border-radius: 6px; padding: 8px 12px; font-size: var(--fs-ui); }
|
|
583
|
+
.episode-data summary { cursor: pointer; color: var(--text-muted-2); font-weight: 600; }
|
|
584
|
+
.ef-section { margin-top: 10px; }
|
|
585
|
+
.ef-section-title { font-size: var(--fs-label); text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted-3); margin-bottom: 3px; }
|
|
586
|
+
.ef-row { display: flex; gap: 10px; padding: 1px 0; }
|
|
587
|
+
.ef-key { flex: 0 0 42%; color: var(--text-muted-2); font-family: var(--font-mono); word-break: break-word; }
|
|
588
|
+
.ef-val { flex: 1; color: var(--text-muted); font-family: var(--font-mono); word-break: break-word; }
|
|
589
|
+
|
|
590
|
+
#autofillRow { margin-bottom: 16px; }
|
|
591
|
+
.autofill-label { display: block; font-size: var(--fs-ui); color: var(--text-muted-2); font-weight: 500; margin-bottom: 4px; }
|
|
592
|
+
#autofillRow select { margin-top: 4px; }
|
|
593
|
+
|
|
594
|
+
.link-btn { background: none; border: none; color: var(--accent); font-size: var(--fs-label); padding: 0 0 0 6px; font-weight: 600; cursor: pointer; }
|
|
595
|
+
.link-btn:hover { background: none; text-decoration: underline; }
|
|
596
|
+
#instrEditRow input { margin-bottom: 6px; }
|
|
597
|
+
#instrEditRow .instr-btns { display: flex; gap: 6px; }
|
|
598
|
+
#instrEditRow .instr-btns button { padding: 6px 10px; font-size: var(--fs-ui); }
|
|
599
|
+
|
|
600
|
+
.card-title { font-size: var(--fs-heading); margin-bottom: 10px; font-weight: 600; }
|
|
601
|
+
.button-row { margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
|
|
602
|
+
.instr-edit { margin-top: 8px; }
|
|
603
|
+
.instr-edit input { margin-bottom: 6px; }
|
|
604
|
+
|
|
605
|
+
/* Non-blocking toast for save/network errors (replaces alert()). */
|
|
606
|
+
.toast-region {
|
|
607
|
+
position: fixed;
|
|
608
|
+
bottom: 20px;
|
|
609
|
+
right: 20px;
|
|
610
|
+
display: flex;
|
|
611
|
+
flex-direction: column;
|
|
612
|
+
gap: 8px;
|
|
613
|
+
z-index: 200;
|
|
614
|
+
pointer-events: none;
|
|
615
|
+
}
|
|
616
|
+
.toast {
|
|
617
|
+
pointer-events: auto;
|
|
618
|
+
background: var(--bg-tooltip);
|
|
619
|
+
color: var(--tooltip-text);
|
|
620
|
+
border: 1px solid var(--danger);
|
|
621
|
+
border-left-width: 4px;
|
|
622
|
+
border-radius: 6px;
|
|
623
|
+
padding: 10px 14px;
|
|
624
|
+
font-size: var(--fs-ui);
|
|
625
|
+
max-width: 360px;
|
|
626
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
|
627
|
+
}
|
|
628
|
+
.toast.info { border-color: var(--accent); }
|
|
629
|
+
|
|
630
|
+
@media (max-width: 1200px) {
|
|
631
|
+
html, body { height: auto; }
|
|
632
|
+
body { overflow: auto; }
|
|
633
|
+
.container { height: auto; padding-bottom: 16px; }
|
|
634
|
+
.main-content {
|
|
635
|
+
grid-template-columns: 1fr;
|
|
636
|
+
flex: 0 0 auto;
|
|
637
|
+
min-height: 0;
|
|
638
|
+
}
|
|
639
|
+
.episode-toolbar { grid-template-columns: 1fr; }
|
|
640
|
+
.episode-toolbar > .episode-nav,
|
|
641
|
+
.episode-toolbar > .h5-search { grid-column: auto; justify-self: stretch; }
|
|
642
|
+
.sidebar, .video-section, .form-section { height: auto; }
|
|
643
|
+
.sidebar #h5List {
|
|
644
|
+
flex: 0 1 auto;
|
|
645
|
+
max-height: min(420px, 50vh);
|
|
646
|
+
}
|
|
647
|
+
.video-scroll, .form-scroll { overflow-y: visible; }
|
|
648
|
+
.videos-grid { grid-template-columns: 1fr; }
|
|
649
|
+
}
|
|
650
|
+
</style>
|
|
651
|
+
<script>
|
|
652
|
+
// Apply the persisted theme (if any) before the body paints, to avoid a flash
|
|
653
|
+
// of the wrong palette. When no explicit choice is saved, the CSS falls back to
|
|
654
|
+
// the OS-level prefers-color-scheme media query.
|
|
655
|
+
(function () {
|
|
656
|
+
try {
|
|
657
|
+
const saved = localStorage.getItem("oopsie_theme");
|
|
658
|
+
if (saved === "light" || saved === "dark") {
|
|
659
|
+
document.documentElement.setAttribute("data-theme", saved);
|
|
660
|
+
}
|
|
661
|
+
// Pre-set the button label so the "action verb" matches the current
|
|
662
|
+
// theme immediately — the main script re-syncs later, but this avoids
|
|
663
|
+
// an initial-paint flash of the wrong text.
|
|
664
|
+
const explicit = (saved === "light" || saved === "dark") ? saved : null;
|
|
665
|
+
const osDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
666
|
+
const resolved = explicit ?? (osDark ? "dark" : "light");
|
|
667
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
668
|
+
const btn = document.getElementById("themeToggleBtn");
|
|
669
|
+
if (btn) btn.textContent = resolved === "dark" ? "Light mode" : "Dark mode";
|
|
670
|
+
});
|
|
671
|
+
} catch (e) { /* localStorage unavailable — fall back to prefers-color-scheme */ }
|
|
672
|
+
})();
|
|
673
|
+
</script>
|
|
674
|
+
</head>
|
|
675
|
+
<body>
|
|
676
|
+
<div class="container">
|
|
677
|
+
<header>
|
|
678
|
+
<h1>OopsieData</h1>
|
|
679
|
+
<div class="session-meta" aria-label="Current session">
|
|
680
|
+
<span class="ctx-label">Status</span>
|
|
681
|
+
<span class="statusline" id="statusLine">Loading…</span>
|
|
682
|
+
<span class="ctx-sep">·</span>
|
|
683
|
+
<span class="ctx-label">Annotator</span>
|
|
684
|
+
<span id="sessionAnnotatorName">—</span>
|
|
685
|
+
</div>
|
|
686
|
+
<div class="header-actions">
|
|
687
|
+
<!-- Hidden by default. Only the in-the-loop mode reveals it (browse-only launches, i.e. standalone annotation of pre-collected data, never show this control). -->
|
|
688
|
+
<button class="header-back" id="backToTaskBtn" type="button" hidden title="Return to instruction / next rollout">Start next rollout</button>
|
|
689
|
+
<button class="theme-toggle" id="themeToggleBtn" type="button" aria-label="Toggle light/dark theme" title="Toggle theme">Light mode</button>
|
|
690
|
+
</div>
|
|
691
|
+
</header>
|
|
692
|
+
|
|
693
|
+
<nav class="episode-toolbar" aria-label="Episode navigation">
|
|
694
|
+
<div class="episode-nav">
|
|
695
|
+
<button id="prevBtn" type="button" title="Previous episode (←)" aria-label="Previous episode">◀ Prev</button>
|
|
696
|
+
<div class="episode-name placeholder" id="sessionH5Name" aria-live="polite">No episode selected</div>
|
|
697
|
+
<span class="episode-position" id="episodePosition" aria-hidden="true"></span>
|
|
698
|
+
<button id="nextBtn" type="button" title="Next episode (→)" aria-label="Next episode">Next ▶</button>
|
|
699
|
+
<button id="nextUnannotatedBtn" type="button" title="Jump to next episode that isn't fully annotated" aria-label="Next unannotated episode">Next unannotated ▶▶</button>
|
|
700
|
+
</div>
|
|
701
|
+
<input id="h5Search" class="h5-search" type="search" placeholder="Search files…" aria-label="Search episode files" />
|
|
702
|
+
</nav>
|
|
703
|
+
|
|
704
|
+
<main class="main-content">
|
|
705
|
+
<aside class="sidebar" aria-label="Episode list">
|
|
706
|
+
<div class="h5-header">
|
|
707
|
+
<h2 class="h5-title">HDF5 files</h2>
|
|
708
|
+
</div>
|
|
709
|
+
<div class="h5-legend" aria-label="Annotation status icons">
|
|
710
|
+
<div class="h5-legend-row"><span class="tick">✓</span> Outcome recorded (details left blank)</div>
|
|
711
|
+
<div class="h5-legend-row"><span class="tick">✓✓</span> Complete for its outcome</div>
|
|
712
|
+
<div class="h5-legend-row"><span class="other-mark">◆</span> Also annotated by another annotator</div>
|
|
713
|
+
</div>
|
|
714
|
+
<div class="sidebar-stats" id="sidebarStats">
|
|
715
|
+
<div class="stats-row"><span>Episodes</span><span id="statsTotal">—</span></div>
|
|
716
|
+
<div class="stats-row"><span>Fully detailed</span><span id="statsComplete">—</span></div>
|
|
717
|
+
</div>
|
|
718
|
+
<div class="sidebar-filter">
|
|
719
|
+
<label class="sidebar-filter-row" title="Hide episodes whose outcome and all its details are filled in">
|
|
720
|
+
<span class="sidebar-filter-text">
|
|
721
|
+
Hide fully detailed
|
|
722
|
+
<!-- Always reserved so toggling the checkbox does not shift the tree. -->
|
|
723
|
+
<span class="filter-shown" id="h5Count" aria-hidden="true"></span>
|
|
724
|
+
</span>
|
|
725
|
+
<input type="checkbox" id="hideAnnotatedChk" />
|
|
726
|
+
</label>
|
|
727
|
+
</div>
|
|
728
|
+
<div id="h5List" class="scroll-y" role="tree" aria-label="Episode tree"></div>
|
|
729
|
+
</aside>
|
|
730
|
+
|
|
731
|
+
<section class="video-section" aria-label="Episode preview">
|
|
732
|
+
<div id="annotateControls" hidden>
|
|
733
|
+
<div class="video-controls">
|
|
734
|
+
<label title="Seek/play/pause one video and the others follow">Sync videos <input type="checkbox" id="syncVideosChk" /></label>
|
|
735
|
+
<label>Video speed
|
|
736
|
+
<select id="playbackRateSel" aria-label="Video speed">
|
|
737
|
+
<option value="0.5">0.5×</option>
|
|
738
|
+
<option value="0.75">0.75×</option>
|
|
739
|
+
<option value="1">1×</option>
|
|
740
|
+
<option value="1.5">1.5×</option>
|
|
741
|
+
<option value="2">2×</option>
|
|
742
|
+
<option value="3">3×</option>
|
|
743
|
+
<option value="4">4×</option>
|
|
744
|
+
</select>
|
|
745
|
+
</label>
|
|
746
|
+
<label>Video grid layout
|
|
747
|
+
<select id="videoLayoutSel" aria-label="Video grid layout">
|
|
748
|
+
<option value="1">1 wide</option>
|
|
749
|
+
<option value="2">2 side-by-side</option>
|
|
750
|
+
<option value="3">3 side-by-side</option>
|
|
751
|
+
</select>
|
|
752
|
+
</label>
|
|
753
|
+
</div>
|
|
754
|
+
</div>
|
|
755
|
+
<div class="video-scroll scroll-y">
|
|
756
|
+
<div id="instructionCard" hidden>
|
|
757
|
+
<h2 class="card-title">Provide language instruction</h2>
|
|
758
|
+
<label for="instruction">Instruction</label>
|
|
759
|
+
<input id="instruction" type="text" placeholder="e.g., pick up the red block and place it in the bin" />
|
|
760
|
+
<div class="button-row">
|
|
761
|
+
<button class="primary" id="submitInstructionBtn">Submit instruction</button>
|
|
762
|
+
<button id="repeatInstructionBtn" hidden type="button">Repeat last</button>
|
|
763
|
+
</div>
|
|
764
|
+
</div>
|
|
765
|
+
|
|
766
|
+
<div id="runningCard" hidden>
|
|
767
|
+
<h2 class="card-title">Policy is running</h2>
|
|
768
|
+
<div class="muted">Waiting for rollout to finish…</div>
|
|
769
|
+
</div>
|
|
770
|
+
|
|
771
|
+
<div id="annotateCard" hidden>
|
|
772
|
+
<div class="videos-grid" id="videosRow"></div>
|
|
773
|
+
</div>
|
|
774
|
+
</div>
|
|
775
|
+
</section>
|
|
776
|
+
|
|
777
|
+
<section class="form-section" aria-label="Annotation form">
|
|
778
|
+
<div class="form-scroll scroll-y">
|
|
779
|
+
<div class="task-instruction-box">
|
|
780
|
+
<div class="task-instruction-box-label">Task instruction<button id="editInstrBtn" class="link-btn" type="button" hidden title="Edit this episode's task instruction">edit</button></div>
|
|
781
|
+
<div class="task-instruction-box-value" id="sessionTaskInstruction">—</div>
|
|
782
|
+
<div id="instrEditRow" class="instr-edit" hidden>
|
|
783
|
+
<input id="instrEditInput" type="text" placeholder="Task instruction" />
|
|
784
|
+
<div class="instr-btns">
|
|
785
|
+
<button id="instrSaveBtn" class="primary" type="button">Save instruction</button>
|
|
786
|
+
<button id="instrCancelBtn" type="button">Cancel</button>
|
|
787
|
+
</div>
|
|
788
|
+
</div>
|
|
789
|
+
</div>
|
|
790
|
+
<div id="metadataPanel" hidden></div>
|
|
791
|
+
<div id="episodeFields" hidden></div>
|
|
792
|
+
<div id="autofillRow" hidden>
|
|
793
|
+
<label class="autofill-label" for="autofillSelect">Copy from a previous annotation</label>
|
|
794
|
+
<select id="autofillSelect"><option value="">— select —</option></select>
|
|
795
|
+
</div>
|
|
796
|
+
<!--
|
|
797
|
+
The annotation form is hardcoded rather than rendered from a spec. The
|
|
798
|
+
`value` on each input is what gets stored — a stable slug — while the
|
|
799
|
+
visible text is free to be reworded without touching data. Slugs must stay
|
|
800
|
+
in step with annotation_schema.py; test_ui_vocabulary_matches_python
|
|
801
|
+
enforces that.
|
|
802
|
+
-->
|
|
803
|
+
<div id="questionnaire">
|
|
804
|
+
<h2 class="card-title">Robot Rollout Annotation</h2>
|
|
805
|
+
<div class="card">
|
|
806
|
+
<div class="form-group" data-question-id="outcome" data-qtype="radio" data-required="1">
|
|
807
|
+
<label class="required">How did the episode end?</label>
|
|
808
|
+
<div>
|
|
809
|
+
<label class="opt-row">
|
|
810
|
+
<input type="radio" name="outcome" value="success" data-qname="outcome" />
|
|
811
|
+
<span>Success</span>
|
|
812
|
+
<span class="info-icon" tabindex="0" role="note" aria-label="Definition" data-tooltip="Task completed correctly, efficiently, and with no unwanted side-effects.">ⓘ</span>
|
|
813
|
+
</label>
|
|
814
|
+
<label class="opt-row">
|
|
815
|
+
<input type="radio" name="outcome" value="success_suboptimal" data-qname="outcome" />
|
|
816
|
+
<span>Success, suboptimal execution</span>
|
|
817
|
+
<span class="info-icon" tabindex="0" role="note" aria-label="Definition" data-tooltip="Task completed, but inefficiently or awkwardly (e.g. many retries, a very indirect path, near-misses). Nothing in the scene was disturbed and nothing was at risk.">ⓘ</span>
|
|
818
|
+
</label>
|
|
819
|
+
<label class="opt-row">
|
|
820
|
+
<input type="radio" name="outcome" value="success_side_effect" data-qname="outcome" />
|
|
821
|
+
<span>Success, unwanted side-effect</span>
|
|
822
|
+
<span class="info-icon" tabindex="0" role="note" aria-label="Definition" data-tooltip="Task completed, but something unintended happened along the way (e.g. knocked over a nearby object, minor collision) that did not prevent completion.">ⓘ</span>
|
|
823
|
+
</label>
|
|
824
|
+
<label class="opt-row">
|
|
825
|
+
<input type="radio" name="outcome" value="failure" data-qname="outcome" />
|
|
826
|
+
<span>Failure</span>
|
|
827
|
+
<span class="info-icon" tabindex="0" role="note" aria-label="Definition" data-tooltip="The task was not completed.">ⓘ</span>
|
|
828
|
+
</label>
|
|
829
|
+
</div>
|
|
830
|
+
</div>
|
|
831
|
+
|
|
832
|
+
<div class="form-group" data-question-id="episode_description" data-qtype="textarea" hidden>
|
|
833
|
+
<div class="form-divider"></div>
|
|
834
|
+
<label>Describe what happened</label>
|
|
835
|
+
<textarea name="episode_description" data-qname="episode_description"></textarea>
|
|
836
|
+
</div>
|
|
837
|
+
|
|
838
|
+
<div class="form-group" data-question-id="side_effect_category" data-qtype="checkbox" hidden>
|
|
839
|
+
<label>Side-effect categories (select all that apply)</label>
|
|
840
|
+
<div>
|
|
841
|
+
<label class="opt-row">
|
|
842
|
+
<input type="checkbox" name="side_effect_category" value="reaching" data-qname="side_effect_category" />
|
|
843
|
+
<span>Reaching (pre contact)</span>
|
|
844
|
+
<span class="info-icon" tabindex="0" role="note" aria-label="Definition" data-tooltip="The robot did not reach the target object or location, so no contact was made. If a grasp just barely misses the object, count this as grasp rather than reaching.">ⓘ</span>
|
|
845
|
+
</label>
|
|
846
|
+
<label class="opt-row">
|
|
847
|
+
<input type="checkbox" name="side_effect_category" value="grasp" data-qname="side_effect_category" />
|
|
848
|
+
<span>Grasp (at contact)</span>
|
|
849
|
+
<span class="info-icon" tabindex="0" role="note" aria-label="Definition" data-tooltip="The target object was not grasped properly. Includes slipping, dropping, or an incorrect grasp.">ⓘ</span>
|
|
850
|
+
</label>
|
|
851
|
+
<label class="opt-row">
|
|
852
|
+
<input type="checkbox" name="side_effect_category" value="manipulation" data-qname="side_effect_category" />
|
|
853
|
+
<span>Manipulation (post contact)</span>
|
|
854
|
+
<span class="info-icon" tabindex="0" role="note" aria-label="Definition" data-tooltip="The object was grasped but not manipulated as intended. For example, successfully grasping a door handle but failing to open the door.">ⓘ</span>
|
|
855
|
+
</label>
|
|
856
|
+
<label class="opt-row">
|
|
857
|
+
<input type="checkbox" name="side_effect_category" value="sequencing_semantic" data-qname="side_effect_category" />
|
|
858
|
+
<span>Sequencing or semantic</span>
|
|
859
|
+
<span class="info-icon" tabindex="0" role="note" aria-label="Definition" data-tooltip="Error in planning or sequencing of actions. This includes executing a related but wrong action, such as pick-and-place of the wrong object, or executing a pick motion when a push motion was specified.">ⓘ</span>
|
|
860
|
+
</label>
|
|
861
|
+
<label class="opt-row">
|
|
862
|
+
<input type="checkbox" name="side_effect_category" value="collision" data-qname="side_effect_category" />
|
|
863
|
+
<span>Collision</span>
|
|
864
|
+
<span class="info-icon" tabindex="0" role="note" aria-label="Definition" data-tooltip="Collision with an obstacle or the environment. This can co-occur with other categories; please select all that apply.">ⓘ</span>
|
|
865
|
+
</label>
|
|
866
|
+
<label class="opt-row">
|
|
867
|
+
<input type="checkbox" name="side_effect_category" value="hardware" data-qname="side_effect_category" />
|
|
868
|
+
<span>Hardware/mechanical issue</span>
|
|
869
|
+
<span class="info-icon" tabindex="0" role="note" aria-label="Definition" data-tooltip="Hardware or mechanical problem with the robot.">ⓘ</span>
|
|
870
|
+
</label>
|
|
871
|
+
<label class="opt-row">
|
|
872
|
+
<input type="checkbox" name="side_effect_category" value="not_attempted" data-qname="side_effect_category" />
|
|
873
|
+
<span>Task not attempted</span>
|
|
874
|
+
<span class="info-icon" tabindex="0" role="note" aria-label="Definition" data-tooltip="The robot made no discernible attempt at the task, e.g. by stalling.">ⓘ</span>
|
|
875
|
+
</label>
|
|
876
|
+
<label class="opt-row">
|
|
877
|
+
<input type="checkbox" name="side_effect_category" value="other" data-qname="side_effect_category" />
|
|
878
|
+
<span>Other</span>
|
|
879
|
+
<span class="info-icon" tabindex="0" role="note" aria-label="Definition" data-tooltip="A side-effect or failure mode not covered by the categories above.">ⓘ</span>
|
|
880
|
+
</label>
|
|
881
|
+
</div>
|
|
882
|
+
</div>
|
|
883
|
+
|
|
884
|
+
<div class="form-group" data-question-id="severity" data-qtype="radio" hidden>
|
|
885
|
+
<label>Severity</label>
|
|
886
|
+
<div>
|
|
887
|
+
<label class="opt-row">
|
|
888
|
+
<input type="radio" name="severity" value="low" data-qname="severity" />
|
|
889
|
+
<span>Low</span>
|
|
890
|
+
<span class="info-icon" tabindex="0" role="note" aria-label="Definition" data-tooltip="No damage; the scene can be reset and the task reattempted.">ⓘ</span>
|
|
891
|
+
</label>
|
|
892
|
+
<label class="opt-row">
|
|
893
|
+
<input type="radio" name="severity" value="medium" data-qname="severity" />
|
|
894
|
+
<span>Medium</span>
|
|
895
|
+
<span class="info-icon" tabindex="0" role="note" aria-label="Definition" data-tooltip="Some damage or risk of damage, or a significant reset is required, but the task can be reattempted.">ⓘ</span>
|
|
896
|
+
</label>
|
|
897
|
+
<label class="opt-row">
|
|
898
|
+
<input type="radio" name="severity" value="catastrophic" data-qname="severity" />
|
|
899
|
+
<span>Catastrophic</span>
|
|
900
|
+
<span class="info-icon" tabindex="0" role="note" aria-label="Definition" data-tooltip="Significant damage or risk of damage; the task cannot be reattempted without repair.">ⓘ</span>
|
|
901
|
+
</label>
|
|
902
|
+
</div>
|
|
903
|
+
</div>
|
|
904
|
+
|
|
905
|
+
<div class="form-group" data-question-id="additional_notes" data-qtype="text">
|
|
906
|
+
<div class="form-divider"></div>
|
|
907
|
+
<label>Additional notes (optional)</label>
|
|
908
|
+
<input type="text" name="additional_notes" data-qname="additional_notes" />
|
|
909
|
+
</div>
|
|
910
|
+
</div>
|
|
911
|
+
</div>
|
|
912
|
+
</div>
|
|
913
|
+
<div class="save-bar" id="saveBar">
|
|
914
|
+
<button class="primary" id="saveBtn">Save annotation</button>
|
|
915
|
+
<span class="dirty-indicator" id="dirtyIndicator" role="status" aria-live="polite"></span>
|
|
916
|
+
<span class="muted" id="saveMsg" role="status" aria-live="polite"></span>
|
|
917
|
+
<span class="spacer"></span>
|
|
918
|
+
<label class="autoadvance-label" title="After saving, jump to the next episode">
|
|
919
|
+
<input type="checkbox" id="autoAdvanceChk" /> Auto-advance
|
|
920
|
+
</label>
|
|
921
|
+
</div>
|
|
922
|
+
</section>
|
|
923
|
+
</main>
|
|
924
|
+
</div>
|
|
925
|
+
|
|
926
|
+
<div id="toastRegion" class="toast-region" role="alert" aria-live="assertive"></div>
|
|
927
|
+
<div id="floatingTooltip" class="floating-tooltip" role="tooltip"></div>
|
|
928
|
+
|
|
929
|
+
<script>
|
|
930
|
+
const el = (id) => document.getElementById(id);
|
|
931
|
+
|
|
932
|
+
// Encapsulated app state (replaces ad-hoc window.__* globals).
|
|
933
|
+
const state = {
|
|
934
|
+
h5Items: [],
|
|
935
|
+
flatFileOrder: [],
|
|
936
|
+
recentAnnotations: [],
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
function escHtml(s) {
|
|
940
|
+
return String(s ?? "")
|
|
941
|
+
.replaceAll("&", "&")
|
|
942
|
+
.replaceAll("<", "<")
|
|
943
|
+
.replaceAll(">", ">")
|
|
944
|
+
.replaceAll('"', """)
|
|
945
|
+
.replaceAll("'", "'");
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Poll cadence + prefetch behaviour are the only "magic numbers" that affect
|
|
949
|
+
// UX; keep them here so they're greppable and tunable in one place.
|
|
950
|
+
const POLL_TASK_MS = 800;
|
|
951
|
+
const POLL_LIST_MS = 1500;
|
|
952
|
+
const PREFETCH_COUNT = 2;
|
|
953
|
+
const PREFETCH_LINK_CAP = 24; // rolling cap on <link rel=prefetch> nodes
|
|
954
|
+
const TREE_INDENT_PX = 14; // must match --tree-indent in CSS
|
|
955
|
+
|
|
956
|
+
/** Human-readable task state for the status line (API still uses short codes). */
|
|
957
|
+
function taskStatusLabel(status, browseOnly) {
|
|
958
|
+
const labels = {
|
|
959
|
+
idle: "Awaiting Instruction",
|
|
960
|
+
pending: "Awaiting Rollout Start",
|
|
961
|
+
running: "Robot Executing",
|
|
962
|
+
annotating: "Annotation in Progress",
|
|
963
|
+
};
|
|
964
|
+
if (status == null || status === "") return "—";
|
|
965
|
+
const key = String(status);
|
|
966
|
+
if (browseOnly && key === "idle") {
|
|
967
|
+
return "Annotation in Progress";
|
|
968
|
+
}
|
|
969
|
+
return labels[key] ?? key;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function syncHeaderPanel(st) {
|
|
973
|
+
const annEl = el("sessionAnnotatorName");
|
|
974
|
+
const fileEl = el("sessionH5Name");
|
|
975
|
+
const taskEl = el("sessionTaskInstruction");
|
|
976
|
+
if (annEl) {
|
|
977
|
+
annEl.textContent =
|
|
978
|
+
typeof ANNOTATOR_NAME !== "undefined" && ANNOTATOR_NAME != null && String(ANNOTATOR_NAME).trim() !== ""
|
|
979
|
+
? String(ANNOTATOR_NAME)
|
|
980
|
+
: "—";
|
|
981
|
+
}
|
|
982
|
+
let file = "—";
|
|
983
|
+
let fileTitle = "";
|
|
984
|
+
let task = "—";
|
|
985
|
+
if (browseRelPath) {
|
|
986
|
+
const parts = browseRelPath.split("/").filter(Boolean);
|
|
987
|
+
file = parts.length ? parts[parts.length - 1] : browseRelPath;
|
|
988
|
+
fileTitle = browseRelPath;
|
|
989
|
+
const md = currentBrowseSample?.metadata || {};
|
|
990
|
+
const li = String(md.language_instruction || "").trim();
|
|
991
|
+
if (li) task = li;
|
|
992
|
+
} else if (st && st.status === "annotating" && st.current_sample) {
|
|
993
|
+
const cs = st.current_sample;
|
|
994
|
+
const li = String(cs.language_instruction || st.last_instruction || "").trim();
|
|
995
|
+
if (li) task = li;
|
|
996
|
+
const sid = cs.sample_id;
|
|
997
|
+
if (sid) {
|
|
998
|
+
const s = String(sid);
|
|
999
|
+
file = s.endsWith(".h5") ? s : `${s}.h5`;
|
|
1000
|
+
fileTitle = file;
|
|
1001
|
+
}
|
|
1002
|
+
} else if (st && st.status === "running") {
|
|
1003
|
+
const li = String(st.last_instruction || "").trim();
|
|
1004
|
+
if (li) task = li;
|
|
1005
|
+
}
|
|
1006
|
+
if (fileEl) {
|
|
1007
|
+
const isPlaceholder = file === "—";
|
|
1008
|
+
fileEl.textContent = isPlaceholder ? "No episode selected" : file;
|
|
1009
|
+
fileEl.title = fileTitle;
|
|
1010
|
+
fileEl.classList.toggle("placeholder", isPlaceholder);
|
|
1011
|
+
}
|
|
1012
|
+
if (taskEl) taskEl.textContent = task;
|
|
1013
|
+
// The task instruction is only editable for a selected HDF5 episode (#31).
|
|
1014
|
+
const editBtn = el("editInstrBtn");
|
|
1015
|
+
if (editBtn) editBtn.hidden = !browseRelPath;
|
|
1016
|
+
if (!browseRelPath) el("instrEditRow").hidden = true;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async function jget(url) {
|
|
1020
|
+
const r = await fetch(url);
|
|
1021
|
+
return await r.json();
|
|
1022
|
+
}
|
|
1023
|
+
async function jpost(url, body) {
|
|
1024
|
+
const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body ?? {}) });
|
|
1025
|
+
const text = await r.text();
|
|
1026
|
+
let data = null;
|
|
1027
|
+
try {
|
|
1028
|
+
data = text ? JSON.parse(text) : null;
|
|
1029
|
+
} catch (e) {
|
|
1030
|
+
throw new Error(text.trim().slice(0, 300) || `HTTP ${r.status}`);
|
|
1031
|
+
}
|
|
1032
|
+
if (!r.ok) {
|
|
1033
|
+
throw new Error((data && data.error) || text.trim().slice(0, 300) || `HTTP ${r.status}`);
|
|
1034
|
+
}
|
|
1035
|
+
return data;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function setHidden(id, hidden) {
|
|
1039
|
+
const node = el(id);
|
|
1040
|
+
if (node) node.hidden = !!hidden;
|
|
1041
|
+
}
|
|
1042
|
+
function showOnly(which) {
|
|
1043
|
+
for (const id of ["instructionCard", "runningCard", "annotateCard"]) {
|
|
1044
|
+
setHidden(id, id !== which);
|
|
1045
|
+
}
|
|
1046
|
+
setHidden("annotateControls", which !== "annotateCard");
|
|
1047
|
+
if (which === "annotateCard") {
|
|
1048
|
+
setHidden("questionnaire", false);
|
|
1049
|
+
setHidden("saveBar", false);
|
|
1050
|
+
setHidden("autofillRow", !(state.recentAnnotations && state.recentAnnotations.length));
|
|
1051
|
+
} else {
|
|
1052
|
+
setHidden("metadataPanel", true);
|
|
1053
|
+
setHidden("episodeFields", true);
|
|
1054
|
+
setHidden("autofillRow", true);
|
|
1055
|
+
setHidden("questionnaire", true);
|
|
1056
|
+
setHidden("saveBar", true);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Non-blocking notification (replaces alert() for save/network errors).
|
|
1061
|
+
function showToast(message, kind = "error", ttl = 5000) {
|
|
1062
|
+
const region = el("toastRegion");
|
|
1063
|
+
if (!region) return;
|
|
1064
|
+
const t = document.createElement("div");
|
|
1065
|
+
t.className = `toast ${kind === "info" ? "info" : ""}`;
|
|
1066
|
+
t.textContent = String(message);
|
|
1067
|
+
region.appendChild(t);
|
|
1068
|
+
setTimeout(() => t.remove(), ttl);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// ── Video controls: playback speed (#32), sync scrubbing (#25), prefetch (#28) ──
|
|
1072
|
+
const RATE_KEY = "oopsie_playback_rate";
|
|
1073
|
+
const SYNC_KEY = "oopsie_sync_videos";
|
|
1074
|
+
const COLS_KEY = "oopsie_video_cols";
|
|
1075
|
+
function getVideoCols() {
|
|
1076
|
+
const n = parseInt(localStorage.getItem(COLS_KEY) || "1", 10);
|
|
1077
|
+
return (n === 2 || n === 3) ? n : 1;
|
|
1078
|
+
}
|
|
1079
|
+
function applyVideoCols(n) {
|
|
1080
|
+
const row = el("videosRow");
|
|
1081
|
+
if (row) row.style.setProperty("--video-cols", String(n));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function getVideos() {
|
|
1085
|
+
return Array.from(el("videosRow").querySelectorAll("video"));
|
|
1086
|
+
}
|
|
1087
|
+
function getPlaybackRate() {
|
|
1088
|
+
const v = parseFloat(localStorage.getItem(RATE_KEY) || "1");
|
|
1089
|
+
return Number.isFinite(v) && v > 0 ? v : 1;
|
|
1090
|
+
}
|
|
1091
|
+
function applyPlaybackRate(rate) {
|
|
1092
|
+
getVideos().forEach((v) => { v.playbackRate = rate; });
|
|
1093
|
+
}
|
|
1094
|
+
function syncEnabled() {
|
|
1095
|
+
const chk = el("syncVideosChk");
|
|
1096
|
+
return chk ? chk.checked : false;
|
|
1097
|
+
}
|
|
1098
|
+
function attachSyncHandlers(v) {
|
|
1099
|
+
// Mirror play/pause STATE only (no currentTime write) so staggered muted-autoplay
|
|
1100
|
+
// 'play' events cannot yank each other back to 0. play()/pause() on an element
|
|
1101
|
+
// already in that state fires no event, so these self-terminate. Only an explicit
|
|
1102
|
+
// seek mirrors currentTime, and each mirrored write is tagged (__mirroredSeek) so
|
|
1103
|
+
// the target's own async 'seeked' does not echo back — which also stops an
|
|
1104
|
+
// unequal-length clip's clamped seek from dragging the others.
|
|
1105
|
+
v.addEventListener("play", () => {
|
|
1106
|
+
if (!syncEnabled()) return;
|
|
1107
|
+
getVideos().forEach((o) => { if (o !== v) o.play().catch(() => {}); });
|
|
1108
|
+
});
|
|
1109
|
+
v.addEventListener("pause", () => {
|
|
1110
|
+
if (!syncEnabled()) return;
|
|
1111
|
+
getVideos().forEach((o) => { if (o !== v) o.pause(); });
|
|
1112
|
+
});
|
|
1113
|
+
v.addEventListener("seeked", () => {
|
|
1114
|
+
if (!syncEnabled()) return;
|
|
1115
|
+
if (v.__mirroredSeek) { v.__mirroredSeek = false; return; }
|
|
1116
|
+
getVideos().forEach((o) => {
|
|
1117
|
+
if (o === v) return;
|
|
1118
|
+
if (Math.abs(o.currentTime - v.currentTime) <= 0.05) return; // already aligned
|
|
1119
|
+
o.__mirroredSeek = true;
|
|
1120
|
+
o.currentTime = v.currentTime;
|
|
1121
|
+
});
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const sampleCache = new Map(); // relPath -> /api/h5/sample JSON (#28)
|
|
1126
|
+
const prefetchedVideos = new Set(); // video URLs already <link rel=prefetch>'d
|
|
1127
|
+
const prefetchLinks = []; // rolling FIFO of the <link> elements we added
|
|
1128
|
+
async function fetchSampleCached(relPath) {
|
|
1129
|
+
if (sampleCache.has(relPath)) return sampleCache.get(relPath);
|
|
1130
|
+
const s = await jget(`/api/h5/sample?path=${encodeURIComponent(relPath)}`);
|
|
1131
|
+
if (s && !s.error) sampleCache.set(relPath, s); // never cache an error response
|
|
1132
|
+
return s;
|
|
1133
|
+
}
|
|
1134
|
+
function prefetchVideoUrl(url) {
|
|
1135
|
+
if (!url || prefetchedVideos.has(url)) return;
|
|
1136
|
+
prefetchedVideos.add(url);
|
|
1137
|
+
const link = document.createElement("link");
|
|
1138
|
+
link.rel = "prefetch";
|
|
1139
|
+
link.as = "video";
|
|
1140
|
+
link.href = url;
|
|
1141
|
+
document.head.appendChild(link);
|
|
1142
|
+
prefetchLinks.push({ url, link });
|
|
1143
|
+
// Cap the rolling set so a long session doesn't pile up hint elements.
|
|
1144
|
+
while (prefetchLinks.length > PREFETCH_LINK_CAP) {
|
|
1145
|
+
const oldest = prefetchLinks.shift();
|
|
1146
|
+
prefetchedVideos.delete(oldest.url);
|
|
1147
|
+
oldest.link.remove();
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
async function prefetchNeighbors(relPath) {
|
|
1151
|
+
const order = state.flatFileOrder || [];
|
|
1152
|
+
const idx = order.indexOf(relPath);
|
|
1153
|
+
if (idx < 0) return;
|
|
1154
|
+
for (let i = 1; i <= PREFETCH_COUNT; i++) {
|
|
1155
|
+
const nrel = order[idx + i];
|
|
1156
|
+
if (!nrel) break;
|
|
1157
|
+
try {
|
|
1158
|
+
const s = await fetchSampleCached(nrel);
|
|
1159
|
+
Object.values(s.video_urls || {}).forEach(prefetchVideoUrl);
|
|
1160
|
+
} catch (e) { /* ignore prefetch failures */ }
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function renderVideos(videoUrls) {
|
|
1165
|
+
const row = el("videosRow");
|
|
1166
|
+
row.innerHTML = "";
|
|
1167
|
+
applyVideoCols(getVideoCols());
|
|
1168
|
+
for (const [cam, url] of Object.entries(videoUrls || {})) {
|
|
1169
|
+
row.insertAdjacentHTML("beforeend", `
|
|
1170
|
+
<div class="video-card">
|
|
1171
|
+
<div class="video-label">${escHtml(cam)} view</div>
|
|
1172
|
+
<video controls muted autoplay playsinline preload="metadata" src="${escHtml(url)}"></video>
|
|
1173
|
+
</div>
|
|
1174
|
+
`);
|
|
1175
|
+
}
|
|
1176
|
+
const rate = getPlaybackRate();
|
|
1177
|
+
getVideos().forEach((v) => {
|
|
1178
|
+
v.playbackRate = rate;
|
|
1179
|
+
v.addEventListener("loadedmetadata", () => { v.playbackRate = getPlaybackRate(); });
|
|
1180
|
+
attachSyncHandlers(v);
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function renderMetadata(meta) {
|
|
1185
|
+
const panel = el("metadataPanel");
|
|
1186
|
+
const episodeId = String(meta?.episode_id || "").trim();
|
|
1187
|
+
const operator = String(meta?.operator_name || "").trim();
|
|
1188
|
+
const relPath = String(meta?.rel_path || "").trim();
|
|
1189
|
+
const sampleId = String(meta?.sample_id || "").trim();
|
|
1190
|
+
|
|
1191
|
+
const extraRows = [];
|
|
1192
|
+
if (episodeId) extraRows.push({ k: "Episode", v: episodeId });
|
|
1193
|
+
if (operator) extraRows.push({ k: "Operator", v: operator });
|
|
1194
|
+
if (relPath && relPath !== episodeId) extraRows.push({ k: "File", v: relPath });
|
|
1195
|
+
else if (sampleId && !relPath) extraRows.push({ k: "Sample", v: sampleId });
|
|
1196
|
+
|
|
1197
|
+
if (!extraRows.length) {
|
|
1198
|
+
panel.hidden = true;
|
|
1199
|
+
panel.innerHTML = "";
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
panel.innerHTML = `
|
|
1204
|
+
<div class="meta-panel">
|
|
1205
|
+
<div class="meta-row meta-row-flat">
|
|
1206
|
+
${extraRows.map(r => `<div class="meta-item"><span class="meta-key">${escHtml(r.k)}</span><span class="meta-val">${escHtml(r.v)}</span></div>`).join("")}
|
|
1207
|
+
</div>
|
|
1208
|
+
</div>`;
|
|
1209
|
+
panel.hidden = false;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// ── Episode data visualizer (#30) ──────────────────────────────────────
|
|
1213
|
+
function fmtFieldVal(v) {
|
|
1214
|
+
if (v == null) return "";
|
|
1215
|
+
if (Array.isArray(v)) return v.map(fmtFieldVal).join(", ");
|
|
1216
|
+
if (typeof v === "object") return JSON.stringify(v);
|
|
1217
|
+
return String(v);
|
|
1218
|
+
}
|
|
1219
|
+
function renderEpisodeFields(fields) {
|
|
1220
|
+
const panel = el("episodeFields");
|
|
1221
|
+
if (!panel) return;
|
|
1222
|
+
if (!fields || typeof fields !== "object" || !Object.keys(fields).length) {
|
|
1223
|
+
panel.hidden = true;
|
|
1224
|
+
panel.innerHTML = "";
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
const kvRows = (obj) => Object.entries(obj || {})
|
|
1228
|
+
.map(([k, v]) => `<div class="ef-row"><span class="ef-key">${escHtml(k)}</span><span class="ef-val">${escHtml(fmtFieldVal(v))}</span></div>`)
|
|
1229
|
+
.join("");
|
|
1230
|
+
const dsRows = (obj) => Object.entries(obj || {})
|
|
1231
|
+
.map(([k, v]) => {
|
|
1232
|
+
const shape = Array.isArray(v && v.shape) ? `[${v.shape.join(", ")}]` : "scalar";
|
|
1233
|
+
const empty = v && v.empty ? " (empty)" : "";
|
|
1234
|
+
return `<div class="ef-row"><span class="ef-key">${escHtml(k)}</span><span class="ef-val">${escHtml(shape)} · ${escHtml(String((v && v.dtype) || ""))}${escHtml(empty)}</span></div>`;
|
|
1235
|
+
}).join("");
|
|
1236
|
+
const section = (title, body) => body ? `<div class="ef-section"><div class="ef-section-title">${escHtml(title)}</div>${body}</div>` : "";
|
|
1237
|
+
const nStates = Object.keys(fields.robot_states || {}).length;
|
|
1238
|
+
const nActions = Object.values(fields.actions || {}).filter((a) => a && !a.empty).length;
|
|
1239
|
+
panel.innerHTML = `
|
|
1240
|
+
<details class="episode-data">
|
|
1241
|
+
<summary>Episode data — ${nStates} state · ${nActions} action key(s)</summary>
|
|
1242
|
+
${section("Attributes", kvRows(fields.attributes))}
|
|
1243
|
+
${section("Robot profile", kvRows(fields.robot_profile))}
|
|
1244
|
+
${section("observations/robot_states (shape · dtype)", dsRows(fields.robot_states))}
|
|
1245
|
+
${section("actions (shape · dtype)", dsRows(fields.actions))}
|
|
1246
|
+
${section("observations/video_paths", kvRows(fields.video_paths))}
|
|
1247
|
+
${section("Other", kvRows({ trajectory_length: fields.trajectory_length, annotators: (fields.annotators || []).join(", ") }))}
|
|
1248
|
+
</details>`;
|
|
1249
|
+
panel.hidden = false;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// ── Autofill from a previous annotation (#27) ──────────────────────────
|
|
1253
|
+
const RECENT_LIMIT = 15;
|
|
1254
|
+
async function loadRecentAnnotations() {
|
|
1255
|
+
try {
|
|
1256
|
+
const items = await jget(`/api/annotations/recent?limit=${RECENT_LIMIT}`);
|
|
1257
|
+
state.recentAnnotations = Array.isArray(items) ? items : [];
|
|
1258
|
+
} catch (e) {
|
|
1259
|
+
state.recentAnnotations = [];
|
|
1260
|
+
}
|
|
1261
|
+
renderAutofillOptions();
|
|
1262
|
+
}
|
|
1263
|
+
function annotationSummaryLabel(a) {
|
|
1264
|
+
const desc = String(a?.episode_description || "").trim() || "(no description)";
|
|
1265
|
+
const trimmed = desc.length > 80 ? desc.slice(0, 80) + "…" : desc;
|
|
1266
|
+
const ep = String(a?.__source_name || "").trim();
|
|
1267
|
+
return ep ? `[${ep}] ${trimmed}` : trimmed;
|
|
1268
|
+
}
|
|
1269
|
+
function renderAutofillOptions() {
|
|
1270
|
+
const sel = el("autofillSelect");
|
|
1271
|
+
const row = el("autofillRow");
|
|
1272
|
+
if (!sel || !row) return;
|
|
1273
|
+
const items = state.recentAnnotations || [];
|
|
1274
|
+
sel.innerHTML = items.length
|
|
1275
|
+
? '<option value="">— copy from a previous annotation —</option>' +
|
|
1276
|
+
items.map((a, i) => `<option value="${i}">${escHtml(annotationSummaryLabel(a))}</option>`).join("")
|
|
1277
|
+
: '<option value="">— select —</option>';
|
|
1278
|
+
// Only surface the picker while annotating; showOnly also manages this.
|
|
1279
|
+
const annotating = !el("annotateCard").hidden;
|
|
1280
|
+
row.hidden = !(items.length && annotating);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// The form itself is static markup (see #questionnaire above). This is only the
|
|
1284
|
+
// field list the generic save/populate/validate helpers iterate — ids and input
|
|
1285
|
+
// types, no vocabulary.
|
|
1286
|
+
const FIELDS = [
|
|
1287
|
+
{ id: "outcome", type: "radio" },
|
|
1288
|
+
{ id: "episode_description", type: "textarea" },
|
|
1289
|
+
{ id: "side_effect_category", type: "checkbox" },
|
|
1290
|
+
{ id: "severity", type: "radio" },
|
|
1291
|
+
{ id: "additional_notes", type: "text" },
|
|
1292
|
+
];
|
|
1293
|
+
|
|
1294
|
+
// Which fields each outcome asks about. Mirrors OUTCOME_EXPECTED_FIELDS in
|
|
1295
|
+
// annotation_completeness.py; a clean success asks for nothing further.
|
|
1296
|
+
const OUTCOME_FIELDS = {
|
|
1297
|
+
success: [],
|
|
1298
|
+
success_suboptimal: ["episode_description"],
|
|
1299
|
+
success_side_effect: ["episode_description", "side_effect_category", "severity"],
|
|
1300
|
+
failure: ["episode_description", "side_effect_category", "severity"],
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* Optional radio: click the already-selected row again to clear.
|
|
1305
|
+
* Listeners must live on the <label> (or capture), because clicks on the text
|
|
1306
|
+
* do not target the <input>, so input-only mousedown never ran.
|
|
1307
|
+
*/
|
|
1308
|
+
function attachOptionalRadioDeselect() {
|
|
1309
|
+
// `outcome` is required, so it is deliberately not in this list.
|
|
1310
|
+
for (const id of ["severity"]) {
|
|
1311
|
+
const group = document.querySelector(`[data-question-id="${CSS.escape(id)}"]`);
|
|
1312
|
+
if (!group) continue;
|
|
1313
|
+
group.querySelectorAll("label.opt-row").forEach((label) => {
|
|
1314
|
+
const radio = label.querySelector('input[type="radio"]');
|
|
1315
|
+
if (!radio) return;
|
|
1316
|
+
label.addEventListener("pointerdown", () => {
|
|
1317
|
+
if (radio.checked) {
|
|
1318
|
+
radio.dataset.__deselectTap = "1";
|
|
1319
|
+
} else {
|
|
1320
|
+
delete radio.dataset.__deselectTap;
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
label.addEventListener("pointerleave", () => {
|
|
1324
|
+
delete radio.dataset.__deselectTap;
|
|
1325
|
+
});
|
|
1326
|
+
label.addEventListener(
|
|
1327
|
+
"click",
|
|
1328
|
+
(e) => {
|
|
1329
|
+
if (radio.dataset.__deselectTap === "1") {
|
|
1330
|
+
delete radio.dataset.__deselectTap;
|
|
1331
|
+
e.preventDefault();
|
|
1332
|
+
e.stopPropagation();
|
|
1333
|
+
radio.checked = false;
|
|
1334
|
+
}
|
|
1335
|
+
},
|
|
1336
|
+
true,
|
|
1337
|
+
);
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function initAnnotationForm() {
|
|
1343
|
+
document.querySelectorAll('input[type="radio"][name="outcome"]').forEach((r) => {
|
|
1344
|
+
r.addEventListener("change", applyOutcomeVisibility);
|
|
1345
|
+
});
|
|
1346
|
+
attachOptionalRadioDeselect();
|
|
1347
|
+
applyOutcomeVisibility();
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// Shared tooltip driver. One <div id="floatingTooltip"> at body-level is
|
|
1351
|
+
// positioned at hover-time from the icon's bounding rect, so it can never
|
|
1352
|
+
// be clipped by a scrolling ancestor (e.g. .form-scroll's implicit
|
|
1353
|
+
// overflow-x). Clicks on the icon are also swallowed to stop the
|
|
1354
|
+
// enclosing <label class="opt-row"> from toggling its radio/checkbox.
|
|
1355
|
+
(function initFloatingTooltip() {
|
|
1356
|
+
const tip = document.getElementById("floatingTooltip");
|
|
1357
|
+
if (!tip) return;
|
|
1358
|
+
const MARGIN = 8;
|
|
1359
|
+
function place(icon) {
|
|
1360
|
+
const text = icon.getAttribute("data-tooltip") || "";
|
|
1361
|
+
if (!text) return hide();
|
|
1362
|
+
tip.textContent = text;
|
|
1363
|
+
tip.classList.add("visible");
|
|
1364
|
+
const iconRect = icon.getBoundingClientRect();
|
|
1365
|
+
// Measure after making visible so max-width has applied.
|
|
1366
|
+
const tw = tip.offsetWidth;
|
|
1367
|
+
const th = tip.offsetHeight;
|
|
1368
|
+
const vw = document.documentElement.clientWidth;
|
|
1369
|
+
const vh = document.documentElement.clientHeight;
|
|
1370
|
+
// Prefer left of the icon (matches the previous visual affordance);
|
|
1371
|
+
// fall back to right if left would clip, then clamp vertically.
|
|
1372
|
+
let left = iconRect.left - tw - MARGIN;
|
|
1373
|
+
if (left < MARGIN) left = iconRect.right + MARGIN;
|
|
1374
|
+
if (left + tw > vw - MARGIN) left = Math.max(MARGIN, vw - tw - MARGIN);
|
|
1375
|
+
let top = iconRect.top + (iconRect.height - th) / 2;
|
|
1376
|
+
if (top < MARGIN) top = MARGIN;
|
|
1377
|
+
if (top + th > vh - MARGIN) top = vh - th - MARGIN;
|
|
1378
|
+
tip.style.left = `${Math.round(left)}px`;
|
|
1379
|
+
tip.style.top = `${Math.round(top)}px`;
|
|
1380
|
+
}
|
|
1381
|
+
function hide() {
|
|
1382
|
+
tip.classList.remove("visible");
|
|
1383
|
+
}
|
|
1384
|
+
function targetIcon(e) {
|
|
1385
|
+
const t = e.target;
|
|
1386
|
+
return (t && t.closest) ? t.closest(".info-icon[data-tooltip]") : null;
|
|
1387
|
+
}
|
|
1388
|
+
document.addEventListener("mouseover", (e) => {
|
|
1389
|
+
const icon = targetIcon(e);
|
|
1390
|
+
if (icon) place(icon);
|
|
1391
|
+
});
|
|
1392
|
+
document.addEventListener("mouseout", (e) => {
|
|
1393
|
+
const icon = targetIcon(e);
|
|
1394
|
+
const to = e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest(".info-icon[data-tooltip]");
|
|
1395
|
+
if (icon && icon !== to) hide();
|
|
1396
|
+
});
|
|
1397
|
+
document.addEventListener("focusin", (e) => {
|
|
1398
|
+
const icon = targetIcon(e);
|
|
1399
|
+
if (icon) place(icon);
|
|
1400
|
+
});
|
|
1401
|
+
document.addEventListener("focusout", (e) => {
|
|
1402
|
+
const icon = targetIcon(e);
|
|
1403
|
+
if (icon) hide();
|
|
1404
|
+
});
|
|
1405
|
+
// Reposition on scroll/resize while visible so the anchor stays glued.
|
|
1406
|
+
window.addEventListener("scroll", () => hide(), true);
|
|
1407
|
+
window.addEventListener("resize", () => hide());
|
|
1408
|
+
// Swallow clicks so the wrapping <label class="opt-row"> doesn't
|
|
1409
|
+
// toggle the option, and the optional-radio deselect-tap logic
|
|
1410
|
+
// never sees a false positive.
|
|
1411
|
+
document.addEventListener("mousedown", (e) => {
|
|
1412
|
+
if (targetIcon(e)) e.stopPropagation();
|
|
1413
|
+
}, true);
|
|
1414
|
+
document.addEventListener("click", (e) => {
|
|
1415
|
+
if (targetIcon(e)) { e.preventDefault(); e.stopPropagation(); }
|
|
1416
|
+
}, true);
|
|
1417
|
+
})();
|
|
1418
|
+
|
|
1419
|
+
function gatherAnswers() {
|
|
1420
|
+
const out = {};
|
|
1421
|
+
for (const q of FIELDS) {
|
|
1422
|
+
const id = q.id;
|
|
1423
|
+
const type = q.type;
|
|
1424
|
+
// Conditionally-hidden fields (e.g. severity when "Success" is selected)
|
|
1425
|
+
// count as empty: they must neither block save (#35) nor be persisted with
|
|
1426
|
+
// stale values the annotator can no longer see.
|
|
1427
|
+
const group = document.querySelector(`[data-question-id="${CSS.escape(id)}"]`);
|
|
1428
|
+
if (group && group.hidden) {
|
|
1429
|
+
out[id] = type === "checkbox" ? [] : "";
|
|
1430
|
+
continue;
|
|
1431
|
+
}
|
|
1432
|
+
if (type === "checkbox") {
|
|
1433
|
+
const els = Array.from(document.querySelectorAll(`input[type="checkbox"][name="${CSS.escape(id)}"]`));
|
|
1434
|
+
out[id] = els.filter(e => e.checked).map(e => e.value);
|
|
1435
|
+
} else if (type === "radio") {
|
|
1436
|
+
const checked = document.querySelector(`input[type="radio"][name="${CSS.escape(id)}"]:checked`);
|
|
1437
|
+
out[id] = checked ? checked.value : "";
|
|
1438
|
+
} else {
|
|
1439
|
+
const el0 = document.querySelector(`[name="${CSS.escape(id)}"]`);
|
|
1440
|
+
out[id] = el0 ? String(el0.value ?? "") : "";
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
return out;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function validateRequired() {
|
|
1447
|
+
const groups = document.querySelectorAll("[data-question-id][data-required='1']");
|
|
1448
|
+
for (const g of groups) {
|
|
1449
|
+
if (g.hidden) continue;
|
|
1450
|
+
const qtype = (g.getAttribute("data-qtype") || "").toLowerCase();
|
|
1451
|
+
if (qtype === "radio") {
|
|
1452
|
+
if (!g.querySelector('input[type="radio"]:checked')) return false;
|
|
1453
|
+
} else if (qtype === "checkbox") {
|
|
1454
|
+
if (!g.querySelector('input[type="checkbox"]:checked')) return false;
|
|
1455
|
+
} else {
|
|
1456
|
+
const field = g.querySelector('input, textarea, select');
|
|
1457
|
+
if (!field || !String(field.value ?? "").trim()) return false;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
return true;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Only the outcome is required; every taxonomy field is optional in every branch,
|
|
1464
|
+
// so a partial annotation saves cleanly and the tick prompts to finish it later.
|
|
1465
|
+
function validateSaveAnswers(answers) {
|
|
1466
|
+
return String(answers?.outcome ?? "").trim() ? "" : "Please select an outcome.";
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function questionCurrentValue(id) {
|
|
1470
|
+
const checked = document.querySelector(`input[type="radio"][name="${CSS.escape(id)}"]:checked`);
|
|
1471
|
+
if (checked) return checked.value;
|
|
1472
|
+
const other = document.querySelector(`[name="${CSS.escape(id)}"]`);
|
|
1473
|
+
return other ? String(other.value ?? "") : "";
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Show only the fields the selected outcome asks about (#29). With no outcome
|
|
1477
|
+
// chosen, all three conditional fields stay hidden.
|
|
1478
|
+
function applyOutcomeVisibility() {
|
|
1479
|
+
const outcome = questionCurrentValue("outcome");
|
|
1480
|
+
const shown = OUTCOME_FIELDS[outcome] || [];
|
|
1481
|
+
for (const id of ["episode_description", "side_effect_category", "severity"]) {
|
|
1482
|
+
const group = document.querySelector(`[data-question-id="${CSS.escape(id)}"]`);
|
|
1483
|
+
if (group) group.hidden = !shown.includes(id);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
let currentSampleId = null; // rollout sample_id
|
|
1488
|
+
let browseRelPath = null; // selected h5 rel path (string)
|
|
1489
|
+
|
|
1490
|
+
// Central place to reflect "is there a target to save into" — the Save
|
|
1491
|
+
// button and its native hover tooltip both derive from this, so any code
|
|
1492
|
+
// that flips browseRelPath / currentSampleId just needs to call this once.
|
|
1493
|
+
function updateSaveButtonState() {
|
|
1494
|
+
const btn = el("saveBtn");
|
|
1495
|
+
if (!btn) return;
|
|
1496
|
+
const hasTarget = Boolean(browseRelPath || currentSampleId);
|
|
1497
|
+
btn.disabled = !hasTarget;
|
|
1498
|
+
btn.title = hasTarget
|
|
1499
|
+
? ""
|
|
1500
|
+
: "Select an episode from the file tree before saving.";
|
|
1501
|
+
}
|
|
1502
|
+
let currentBrowseSample = null;
|
|
1503
|
+
let lastStatus = null;
|
|
1504
|
+
let formPopulatedFor = null; // guards populateForm against clobbering live edits
|
|
1505
|
+
let videosRenderedFor = null;
|
|
1506
|
+
let lastH5ListSig = "";
|
|
1507
|
+
let isDirty = false; // unsaved edits in the current form (#34)
|
|
1508
|
+
const expandedDirs = new Set([""]); // "" = root
|
|
1509
|
+
|
|
1510
|
+
// ── Episode navigation (#24, #33) ──────────────────────────────────────
|
|
1511
|
+
function currentFlatIndex() {
|
|
1512
|
+
const order = state.flatFileOrder || [];
|
|
1513
|
+
return browseRelPath ? order.indexOf(browseRelPath) : -1;
|
|
1514
|
+
}
|
|
1515
|
+
function updateNavButtons() {
|
|
1516
|
+
const order = state.flatFileOrder || [];
|
|
1517
|
+
const idx = currentFlatIndex();
|
|
1518
|
+
const prev = el("prevBtn"), next = el("nextBtn");
|
|
1519
|
+
if (prev) prev.disabled = !(idx > 0);
|
|
1520
|
+
if (next) next.disabled = !(order.length && idx >= 0 && idx < order.length - 1);
|
|
1521
|
+
const pos = el("episodePosition");
|
|
1522
|
+
if (pos) {
|
|
1523
|
+
pos.textContent = (idx >= 0 && order.length) ? `${idx + 1} / ${order.length}` : "";
|
|
1524
|
+
}
|
|
1525
|
+
const unBtn = el("nextUnannotatedBtn");
|
|
1526
|
+
if (unBtn) unBtn.disabled = findNextUnannotatedRel(idx) === null;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// Find the next episode in the current flat order whose tick level is
|
|
1530
|
+
// below "complete" (i.e. still needs work). Returns null if none exists.
|
|
1531
|
+
function findNextUnannotatedRel(fromIdx) {
|
|
1532
|
+
const order = state.flatFileOrder || [];
|
|
1533
|
+
const byRel = new Map((state.h5Items || []).map((it) => [String(it.rel_path || ""), it]));
|
|
1534
|
+
const start = Math.max(-1, Number.isFinite(fromIdx) ? fromIdx : -1);
|
|
1535
|
+
for (let i = start + 1; i < order.length; i++) {
|
|
1536
|
+
const it = byRel.get(order[i]);
|
|
1537
|
+
if (!it || Number(it.annotation_tick_level ?? 0) < 2) return order[i];
|
|
1538
|
+
}
|
|
1539
|
+
return null;
|
|
1540
|
+
}
|
|
1541
|
+
function expandParents(rel) {
|
|
1542
|
+
const parts = String(rel || "").split("/").filter(Boolean);
|
|
1543
|
+
let prefix = "";
|
|
1544
|
+
for (const p of parts.slice(0, -1)) {
|
|
1545
|
+
prefix = prefix ? `${prefix}/${p}` : p;
|
|
1546
|
+
expandedDirs.add(prefix);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
function scrollActiveIntoView() {
|
|
1550
|
+
const node = document.querySelector('#h5List .tree-row.active');
|
|
1551
|
+
if (node) node.scrollIntoView({ block: "nearest" });
|
|
1552
|
+
}
|
|
1553
|
+
async function navigateToRel(rel) {
|
|
1554
|
+
if (!rel || rel === browseRelPath) return;
|
|
1555
|
+
if (!(await confirmDiscardIfDirty())) return;
|
|
1556
|
+
expandParents(rel);
|
|
1557
|
+
await selectBrowseH5(rel);
|
|
1558
|
+
scrollActiveIntoView();
|
|
1559
|
+
}
|
|
1560
|
+
async function navigateBy(delta) {
|
|
1561
|
+
const order = state.flatFileOrder || [];
|
|
1562
|
+
if (!order.length) return;
|
|
1563
|
+
let idx = currentFlatIndex();
|
|
1564
|
+
let target = idx < 0 ? (delta > 0 ? 0 : order.length - 1) : idx + delta;
|
|
1565
|
+
target = Math.max(0, Math.min(order.length - 1, target));
|
|
1566
|
+
await navigateToRel(order[target]);
|
|
1567
|
+
}
|
|
1568
|
+
async function navigateToNextUnannotated() {
|
|
1569
|
+
const rel = findNextUnannotatedRel(currentFlatIndex());
|
|
1570
|
+
if (!rel) return;
|
|
1571
|
+
await navigateToRel(rel);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// ── Dirty / saved indicator (#34) ──────────────────────────────────────
|
|
1575
|
+
function setDirty(v) {
|
|
1576
|
+
isDirty = !!v;
|
|
1577
|
+
const ind = el("dirtyIndicator");
|
|
1578
|
+
if (!ind) return;
|
|
1579
|
+
ind.classList.remove("saved");
|
|
1580
|
+
ind.textContent = isDirty ? "● Unsaved changes" : "";
|
|
1581
|
+
}
|
|
1582
|
+
function markSaved() {
|
|
1583
|
+
isDirty = false;
|
|
1584
|
+
const ind = el("dirtyIndicator");
|
|
1585
|
+
if (ind) { ind.textContent = "✓ Saved"; ind.classList.add("saved"); }
|
|
1586
|
+
}
|
|
1587
|
+
async function confirmDiscardIfDirty() {
|
|
1588
|
+
// Only warn about "unsaved changes" when there's actually somewhere to
|
|
1589
|
+
// save them to. Editing the questionnaire without a selected episode
|
|
1590
|
+
// has no persistent side-effect, so silently drop those edits.
|
|
1591
|
+
if (!isDirty || !(browseRelPath || currentSampleId)) return true;
|
|
1592
|
+
const ok = window.confirm("You have unsaved changes. Discard them and continue?");
|
|
1593
|
+
if (ok) setDirty(false);
|
|
1594
|
+
return ok;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// ── Outcome hotkeys (#33) ─────────────────────────────────────────────
|
|
1598
|
+
function setOutcome(val) {
|
|
1599
|
+
const radios = document.querySelectorAll('input[type="radio"][name="outcome"]');
|
|
1600
|
+
if (!radios.length) return;
|
|
1601
|
+
let changed = false;
|
|
1602
|
+
radios.forEach((r) => {
|
|
1603
|
+
const match = r.value === val;
|
|
1604
|
+
if (r.checked !== match) changed = true;
|
|
1605
|
+
r.checked = match;
|
|
1606
|
+
});
|
|
1607
|
+
applyOutcomeVisibility();
|
|
1608
|
+
if (changed) { setDirty(true); el("saveMsg").textContent = ""; }
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function autoAdvanceEnabled() {
|
|
1612
|
+
const chk = el("autoAdvanceChk");
|
|
1613
|
+
return chk ? chk.checked : false;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
async function loadH5List() {
|
|
1617
|
+
const items = await jget("/api/h5/list");
|
|
1618
|
+
state.h5Items = Array.isArray(items) ? items : [];
|
|
1619
|
+
renderH5List();
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
async function pollH5List() {
|
|
1623
|
+
try {
|
|
1624
|
+
const items = await jget("/api/h5/list");
|
|
1625
|
+
const arr = Array.isArray(items) ? items : [];
|
|
1626
|
+
// Lightweight signature: rel_path + annotated + mtime.
|
|
1627
|
+
const sig = arr.map(it => `${it.rel_path}|${Number(it.annotation_tick_level ?? 0)}|${it.annotated_by_others ? 1 : 0}|${it.mtime}`).join("\n");
|
|
1628
|
+
if (sig !== lastH5ListSig) {
|
|
1629
|
+
lastH5ListSig = sig;
|
|
1630
|
+
state.h5Items = arr;
|
|
1631
|
+
renderH5List();
|
|
1632
|
+
}
|
|
1633
|
+
} catch (e) {
|
|
1634
|
+
// ignore transient errors
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function hideAnnotatedEnabled() {
|
|
1639
|
+
const chk = el("hideAnnotatedChk");
|
|
1640
|
+
return chk ? chk.checked : false;
|
|
1641
|
+
}
|
|
1642
|
+
function renderH5List() {
|
|
1643
|
+
const q = String(el("h5Search").value || "").toLowerCase();
|
|
1644
|
+
const hideAnnotated = hideAnnotatedEnabled();
|
|
1645
|
+
// The currently-selected episode always stays visible even when the
|
|
1646
|
+
// filter would otherwise hide it — the user is looking at it, and
|
|
1647
|
+
// silently vanishing it from the tree while they're working on it is
|
|
1648
|
+
// disorienting.
|
|
1649
|
+
const items = (state.h5Items || []).filter(it => {
|
|
1650
|
+
const rp = String(it.rel_path || "");
|
|
1651
|
+
if (q && !rp.toLowerCase().includes(q)) return false;
|
|
1652
|
+
if (hideAnnotated
|
|
1653
|
+
&& Number(it.annotation_tick_level ?? 0) >= 2
|
|
1654
|
+
&& rp !== browseRelPath) return false;
|
|
1655
|
+
return true;
|
|
1656
|
+
});
|
|
1657
|
+
const allItems = state.h5Items || [];
|
|
1658
|
+
const total = allItems.length;
|
|
1659
|
+
const complete = allItems.filter(
|
|
1660
|
+
(it) => Number(it.annotation_tick_level ?? 0) >= 2,
|
|
1661
|
+
).length;
|
|
1662
|
+
const totalEl = el("statsTotal");
|
|
1663
|
+
const completeEl = el("statsComplete");
|
|
1664
|
+
if (totalEl) totalEl.textContent = String(total);
|
|
1665
|
+
if (completeEl) completeEl.textContent = `${complete} / ${total}`;
|
|
1666
|
+
// Show the filtered count inline with the filter toggle when any
|
|
1667
|
+
// filter narrows the list. The .filter-shown span always occupies at
|
|
1668
|
+
// least one line (via ::before content), so toggling doesn't reflow.
|
|
1669
|
+
const filtering = Boolean(q) || hideAnnotated;
|
|
1670
|
+
el("h5Count").textContent = filtering ? `${items.length} shown` : "";
|
|
1671
|
+
|
|
1672
|
+
// Build a directory tree from rel_path strings.
|
|
1673
|
+
function makeNode() { return { files: [], dirs: new Map() }; }
|
|
1674
|
+
const root = makeNode();
|
|
1675
|
+
for (const it of items) {
|
|
1676
|
+
const rp = String(it.rel_path || "");
|
|
1677
|
+
const parts = rp.split("/").filter(Boolean);
|
|
1678
|
+
if (parts.length === 0) continue;
|
|
1679
|
+
const fileName = parts[parts.length - 1];
|
|
1680
|
+
const dirParts = parts.slice(0, -1);
|
|
1681
|
+
let node = root;
|
|
1682
|
+
let prefix = "";
|
|
1683
|
+
for (const d of dirParts) {
|
|
1684
|
+
prefix = prefix ? `${prefix}/${d}` : d;
|
|
1685
|
+
if (!node.dirs.has(d)) node.dirs.set(d, makeNode());
|
|
1686
|
+
node = node.dirs.get(d);
|
|
1687
|
+
}
|
|
1688
|
+
node.files.push({ ...it, _fileName: fileName });
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// If searching, auto-expand all matching parent directories so results are visible.
|
|
1692
|
+
if (q) {
|
|
1693
|
+
for (const it of items) {
|
|
1694
|
+
const rp = String(it.rel_path || "");
|
|
1695
|
+
const parts = rp.split("/").filter(Boolean);
|
|
1696
|
+
let prefix = "";
|
|
1697
|
+
for (const p of parts.slice(0, -1)) {
|
|
1698
|
+
prefix = prefix ? `${prefix}/${p}` : p;
|
|
1699
|
+
expandedDirs.add(prefix);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
const esc = escHtml;
|
|
1705
|
+
const lines = [];
|
|
1706
|
+
function renderDir(node, dirPath, depth) {
|
|
1707
|
+
const dirEntries = Array.from(node.dirs.entries()).sort((a,b) => a[0].localeCompare(b[0]));
|
|
1708
|
+
const fileEntries = node.files.slice().sort((a,b) => String(a._fileName).localeCompare(String(b._fileName)));
|
|
1709
|
+
|
|
1710
|
+
for (const [dirName, child] of dirEntries) {
|
|
1711
|
+
const childPath = dirPath ? `${dirPath}/${dirName}` : dirName;
|
|
1712
|
+
const isOpen = expandedDirs.has(childPath);
|
|
1713
|
+
const indent = depth * TREE_INDENT_PX;
|
|
1714
|
+
lines.push(
|
|
1715
|
+
`<div class="tree-row" role="treeitem" tabindex="-1" aria-expanded="${isOpen ? "true" : "false"}" data-kind="dir" data-dir="${esc(childPath)}" style="padding-left:${indent + 10}px;">` +
|
|
1716
|
+
`<span class="tree-arrow" aria-hidden="true">${isOpen ? "▾" : "▸"}</span>` +
|
|
1717
|
+
`<span class="tree-label">${esc(dirName)}/</span>` +
|
|
1718
|
+
`</div>`
|
|
1719
|
+
);
|
|
1720
|
+
if (isOpen) renderDir(child, childPath, depth + 1);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
for (const it of fileEntries) {
|
|
1724
|
+
const rp = String(it.rel_path || "");
|
|
1725
|
+
const isActive = browseRelPath === rp;
|
|
1726
|
+
const active = isActive ? "active" : "";
|
|
1727
|
+
const tl = Number(it.annotation_tick_level ?? 0);
|
|
1728
|
+
const tickMark = tl >= 2 ? "✓✓" : tl >= 1 ? "✓" : "";
|
|
1729
|
+
const tick = tickMark ? `<span class="tick" aria-label="${tl >= 2 ? "Complete" : "Partial"}">${tickMark}</span> ` : "";
|
|
1730
|
+
const other = it.annotated_by_others
|
|
1731
|
+
? '<span class="other-mark" title="Also annotated by another annotator" aria-label="Also annotated by another annotator">◆</span> '
|
|
1732
|
+
: "";
|
|
1733
|
+
const indent = depth * TREE_INDENT_PX;
|
|
1734
|
+
lines.push(
|
|
1735
|
+
`<div class="tree-row ${active}" role="treeitem" tabindex="${isActive ? 0 : -1}" aria-selected="${isActive ? "true" : "false"}" data-kind="file" data-rel="${esc(rp)}" style="padding-left:${indent + 10}px;">` +
|
|
1736
|
+
`<span class="tree-arrow" aria-hidden="true"></span>` +
|
|
1737
|
+
`<span class="tree-label">${other}${tick}${esc(it._fileName)}</span>` +
|
|
1738
|
+
`</div>`
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
renderDir(root, "", 0);
|
|
1744
|
+
el("h5List").innerHTML = lines.join("");
|
|
1745
|
+
|
|
1746
|
+
// Flattened file order (dirs-first, name-sorted) matching the tree's visual
|
|
1747
|
+
// order, but independent of collapse state so Prev/Next can traverse every
|
|
1748
|
+
// filtered file. #24 / #33 (auto-advance) navigate through this array.
|
|
1749
|
+
const flatOrder = [];
|
|
1750
|
+
(function collect(node) {
|
|
1751
|
+
const dirs = Array.from(node.dirs.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
|
1752
|
+
const files = node.files.slice().sort((a, b) => String(a._fileName).localeCompare(String(b._fileName)));
|
|
1753
|
+
for (const [, child] of dirs) collect(child);
|
|
1754
|
+
for (const it of files) flatOrder.push(String(it.rel_path || ""));
|
|
1755
|
+
})(root);
|
|
1756
|
+
state.flatFileOrder = flatOrder;
|
|
1757
|
+
updateNavButtons();
|
|
1758
|
+
|
|
1759
|
+
el("h5List").querySelectorAll('[data-kind="dir"]').forEach(node => {
|
|
1760
|
+
node.addEventListener("click", () => {
|
|
1761
|
+
const dir = node.getAttribute("data-dir") || "";
|
|
1762
|
+
if (expandedDirs.has(dir)) expandedDirs.delete(dir);
|
|
1763
|
+
else expandedDirs.add(dir);
|
|
1764
|
+
renderH5List();
|
|
1765
|
+
});
|
|
1766
|
+
});
|
|
1767
|
+
el("h5List").querySelectorAll('[data-kind="file"]').forEach(node => {
|
|
1768
|
+
node.addEventListener("click", async () => {
|
|
1769
|
+
const rp = node.getAttribute("data-rel");
|
|
1770
|
+
if (!rp || rp === browseRelPath) return;
|
|
1771
|
+
if (!(await confirmDiscardIfDirty())) return;
|
|
1772
|
+
await selectBrowseH5(rp);
|
|
1773
|
+
});
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function populateForm(existing) {
|
|
1778
|
+
const ann = existing && typeof existing === "object" ? existing : {};
|
|
1779
|
+
const skip = new Set(["annotated_at", "annotator", "source"]);
|
|
1780
|
+
// Reset every managed field first so applying a (possibly partial) annotation —
|
|
1781
|
+
// e.g. copying a previous annotation onto an already-filled form (#27) — never
|
|
1782
|
+
// leaves stale values (like a severity) from a prior selection.
|
|
1783
|
+
for (const q of FIELDS) {
|
|
1784
|
+
const id = q.id;
|
|
1785
|
+
const type = q.type;
|
|
1786
|
+
if (skip.has(id)) continue;
|
|
1787
|
+
if (type === "checkbox" || type === "radio") {
|
|
1788
|
+
document.querySelectorAll(`input[name="${CSS.escape(id)}"]`).forEach((e) => { e.checked = false; });
|
|
1789
|
+
} else {
|
|
1790
|
+
const el0 = document.querySelector(`[name="${CSS.escape(id)}"]`);
|
|
1791
|
+
if (el0) el0.value = "";
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
for (const q of FIELDS) {
|
|
1795
|
+
const id = q.id;
|
|
1796
|
+
const type = q.type;
|
|
1797
|
+
if (skip.has(id) || !(id in ann)) continue;
|
|
1798
|
+
const value = ann[id];
|
|
1799
|
+
if (type === "checkbox") {
|
|
1800
|
+
const values = Array.isArray(value) ? value.map(String) : [String(value)];
|
|
1801
|
+
const els = Array.from(document.querySelectorAll(`input[type="checkbox"][name="${CSS.escape(id)}"]`));
|
|
1802
|
+
for (const elc of els) elc.checked = values.includes(String(elc.value));
|
|
1803
|
+
} else if (type === "radio") {
|
|
1804
|
+
const els = Array.from(document.querySelectorAll(`input[type="radio"][name="${CSS.escape(id)}"]`));
|
|
1805
|
+
for (const elr of els) elr.checked = String(elr.value) === String(value);
|
|
1806
|
+
} else {
|
|
1807
|
+
const el0 = document.querySelector(`[name="${CSS.escape(id)}"]`);
|
|
1808
|
+
if (el0) el0.value = (value == null) ? "" : String(value);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
applyOutcomeVisibility();
|
|
1812
|
+
// Programmatic population does not fire input/change events, so the form
|
|
1813
|
+
// starts clean after (re)loading an episode.
|
|
1814
|
+
setDirty(false);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
async function selectBrowseH5(relPath) {
|
|
1818
|
+
browseRelPath = relPath;
|
|
1819
|
+
currentBrowseSample = null;
|
|
1820
|
+
formPopulatedFor = null;
|
|
1821
|
+
videosRenderedFor = null;
|
|
1822
|
+
el("instrEditRow").hidden = true; // never carry an open edit onto another episode (#31)
|
|
1823
|
+
renderH5List();
|
|
1824
|
+
updateSaveButtonState();
|
|
1825
|
+
await refresh();
|
|
1826
|
+
prefetchNeighbors(relPath).catch(() => {});
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
function clearBrowse() {
|
|
1830
|
+
browseRelPath = null;
|
|
1831
|
+
currentBrowseSample = null;
|
|
1832
|
+
el("saveMsg").textContent = "";
|
|
1833
|
+
el("instrEditRow").hidden = true;
|
|
1834
|
+
formPopulatedFor = null;
|
|
1835
|
+
videosRenderedFor = null;
|
|
1836
|
+
renderH5List();
|
|
1837
|
+
updateSaveButtonState();
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
async function refresh() {
|
|
1841
|
+
const st = await jget("/api/task/state");
|
|
1842
|
+
const browseOnly = Boolean(
|
|
1843
|
+
st?.browse_only ??
|
|
1844
|
+
(typeof ANNOTATOR_BROWSE_ONLY !== "undefined" && ANNOTATOR_BROWSE_ONLY),
|
|
1845
|
+
);
|
|
1846
|
+
el("statusLine").textContent = taskStatusLabel(st?.status, browseOnly);
|
|
1847
|
+
|
|
1848
|
+
// Server started with --browse-only: skip instruction / rollout cards entirely.
|
|
1849
|
+
if (typeof ANNOTATOR_BROWSE_ONLY !== "undefined" && ANNOTATOR_BROWSE_ONLY) {
|
|
1850
|
+
if (browseRelPath) {
|
|
1851
|
+
showOnly("annotateCard");
|
|
1852
|
+
|
|
1853
|
+
if (!currentBrowseSample || currentBrowseSample.rel_path !== browseRelPath) {
|
|
1854
|
+
const sample = await fetchSampleCached(browseRelPath);
|
|
1855
|
+
currentBrowseSample = sample;
|
|
1856
|
+
formPopulatedFor = null;
|
|
1857
|
+
videosRenderedFor = null;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
if (videosRenderedFor !== browseRelPath) {
|
|
1861
|
+
renderVideos(currentBrowseSample?.video_urls || {});
|
|
1862
|
+
renderEpisodeFields(currentBrowseSample?.episode_fields || null);
|
|
1863
|
+
videosRenderedFor = browseRelPath;
|
|
1864
|
+
}
|
|
1865
|
+
if (formPopulatedFor !== browseRelPath) {
|
|
1866
|
+
populateForm(currentBrowseSample?.existing_annotation || {});
|
|
1867
|
+
formPopulatedFor = browseRelPath;
|
|
1868
|
+
}
|
|
1869
|
+
syncHeaderPanel(st);
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
showOnly("annotateCard");
|
|
1874
|
+
if (videosRenderedFor !== "__browse_landing__") {
|
|
1875
|
+
renderVideos({});
|
|
1876
|
+
renderEpisodeFields(null);
|
|
1877
|
+
videosRenderedFor = "__browse_landing__";
|
|
1878
|
+
}
|
|
1879
|
+
if (formPopulatedFor !== "__browse_landing__") {
|
|
1880
|
+
populateForm({});
|
|
1881
|
+
formPopulatedFor = "__browse_landing__";
|
|
1882
|
+
}
|
|
1883
|
+
syncHeaderPanel(st);
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// Browse mode overrides the main pane regardless of rollout status.
|
|
1888
|
+
if (browseRelPath) {
|
|
1889
|
+
showOnly("annotateCard");
|
|
1890
|
+
|
|
1891
|
+
if (!currentBrowseSample || currentBrowseSample.rel_path !== browseRelPath) {
|
|
1892
|
+
const sample = await fetchSampleCached(browseRelPath);
|
|
1893
|
+
currentBrowseSample = sample;
|
|
1894
|
+
formPopulatedFor = null;
|
|
1895
|
+
videosRenderedFor = null;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
if (videosRenderedFor !== browseRelPath) {
|
|
1899
|
+
renderVideos(currentBrowseSample?.video_urls || {});
|
|
1900
|
+
renderEpisodeFields(currentBrowseSample?.episode_fields || null);
|
|
1901
|
+
videosRenderedFor = browseRelPath;
|
|
1902
|
+
}
|
|
1903
|
+
if (formPopulatedFor !== browseRelPath) {
|
|
1904
|
+
populateForm(currentBrowseSample?.existing_annotation || {});
|
|
1905
|
+
formPopulatedFor = browseRelPath;
|
|
1906
|
+
}
|
|
1907
|
+
syncHeaderPanel(st);
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
const cs = st.current_sample || {};
|
|
1912
|
+
const nextSampleId = cs.sample_id || null;
|
|
1913
|
+
|
|
1914
|
+
// Only switch cards when status actually changes to avoid UI flicker.
|
|
1915
|
+
if (st.status !== lastStatus) {
|
|
1916
|
+
if (st.status === "idle" || st.status === "pending") {
|
|
1917
|
+
showOnly("instructionCard");
|
|
1918
|
+
const last = String(st.last_instruction || "").trim();
|
|
1919
|
+
if (last) {
|
|
1920
|
+
el("instruction").value = last;
|
|
1921
|
+
el("repeatInstructionBtn").hidden = false;
|
|
1922
|
+
} else {
|
|
1923
|
+
el("repeatInstructionBtn").hidden = true;
|
|
1924
|
+
}
|
|
1925
|
+
} else if (st.status === "running") {
|
|
1926
|
+
showOnly("runningCard");
|
|
1927
|
+
} else if (st.status === "annotating") {
|
|
1928
|
+
showOnly("annotateCard");
|
|
1929
|
+
}
|
|
1930
|
+
lastStatus = st.status;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
if (st.status === "annotating") {
|
|
1934
|
+
currentSampleId = nextSampleId;
|
|
1935
|
+
updateSaveButtonState();
|
|
1936
|
+
|
|
1937
|
+
// Render videos only when the sample changes.
|
|
1938
|
+
if (nextSampleId && videosRenderedFor !== nextSampleId) {
|
|
1939
|
+
renderVideos(cs.video_urls || {});
|
|
1940
|
+
renderEpisodeFields(null);
|
|
1941
|
+
videosRenderedFor = nextSampleId;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// Reset the form once per sample; re-running it on every poll would wipe
|
|
1945
|
+
// edits the annotator is in the middle of making.
|
|
1946
|
+
if (nextSampleId && formPopulatedFor !== nextSampleId) {
|
|
1947
|
+
populateForm({});
|
|
1948
|
+
formPopulatedFor = nextSampleId;
|
|
1949
|
+
}
|
|
1950
|
+
} else {
|
|
1951
|
+
currentSampleId = null;
|
|
1952
|
+
updateSaveButtonState();
|
|
1953
|
+
formPopulatedFor = null;
|
|
1954
|
+
videosRenderedFor = null;
|
|
1955
|
+
renderMetadata({});
|
|
1956
|
+
}
|
|
1957
|
+
syncHeaderPanel(st);
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
async function submitInstruction(v) {
|
|
1961
|
+
if (!v) return;
|
|
1962
|
+
el("submitInstructionBtn").disabled = true;
|
|
1963
|
+
el("repeatInstructionBtn").disabled = true;
|
|
1964
|
+
try {
|
|
1965
|
+
await jpost("/api/task/submit", { instruction: v });
|
|
1966
|
+
el("instruction").value = "";
|
|
1967
|
+
} catch (e) {
|
|
1968
|
+
showToast(`Could not submit instruction: ${e}`);
|
|
1969
|
+
} finally {
|
|
1970
|
+
el("submitInstructionBtn").disabled = false;
|
|
1971
|
+
el("repeatInstructionBtn").disabled = false;
|
|
1972
|
+
await refresh();
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
el("submitInstructionBtn").addEventListener("click", () =>
|
|
1977
|
+
submitInstruction(el("instruction").value.trim())
|
|
1978
|
+
);
|
|
1979
|
+
el("repeatInstructionBtn").addEventListener("click", () =>
|
|
1980
|
+
submitInstruction(el("instruction").value.trim())
|
|
1981
|
+
);
|
|
1982
|
+
|
|
1983
|
+
el("saveBtn").addEventListener("click", async () => {
|
|
1984
|
+
if (!currentSampleId && !browseRelPath) return;
|
|
1985
|
+
const answers = gatherAnswers();
|
|
1986
|
+
if (!validateRequired()) {
|
|
1987
|
+
el("saveMsg").textContent = "Please fill required fields.";
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
const saveValidationError = validateSaveAnswers(answers);
|
|
1991
|
+
if (saveValidationError) {
|
|
1992
|
+
el("saveMsg").textContent = saveValidationError;
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
el("saveMsg").textContent = "";
|
|
1996
|
+
el("saveBtn").disabled = true;
|
|
1997
|
+
try {
|
|
1998
|
+
if (browseRelPath) {
|
|
1999
|
+
await jpost(`/api/h5/annotations?path=${encodeURIComponent(browseRelPath)}`, answers);
|
|
2000
|
+
sampleCache.delete(browseRelPath); // cached sample now has a stale annotation
|
|
2001
|
+
} else {
|
|
2002
|
+
await jpost("/api/annotations", { sample_id: currentSampleId, ...answers });
|
|
2003
|
+
}
|
|
2004
|
+
el("saveMsg").textContent = "";
|
|
2005
|
+
markSaved();
|
|
2006
|
+
await pollH5List();
|
|
2007
|
+
loadRecentAnnotations().catch(() => {}); // the just-saved annotation becomes copyable
|
|
2008
|
+
if (browseRelPath && autoAdvanceEnabled()) {
|
|
2009
|
+
await navigateBy(1);
|
|
2010
|
+
}
|
|
2011
|
+
} catch (e) {
|
|
2012
|
+
el("saveMsg").textContent = "Error saving.";
|
|
2013
|
+
showToast(`Could not save annotation: ${e}`);
|
|
2014
|
+
} finally {
|
|
2015
|
+
el("saveBtn").disabled = false;
|
|
2016
|
+
}
|
|
2017
|
+
});
|
|
2018
|
+
|
|
2019
|
+
el("h5Search").addEventListener("input", () => renderH5List());
|
|
2020
|
+
|
|
2021
|
+
// Prev/Next episode navigation (#24)
|
|
2022
|
+
el("prevBtn").addEventListener("click", () => navigateBy(-1));
|
|
2023
|
+
el("nextBtn").addEventListener("click", () => navigateBy(1));
|
|
2024
|
+
el("nextUnannotatedBtn").addEventListener("click", () => navigateToNextUnannotated());
|
|
2025
|
+
|
|
2026
|
+
// Persisted "hide fully annotated" filter.
|
|
2027
|
+
(function initHideAnnotated() {
|
|
2028
|
+
const KEY = "oopsie_hide_annotated";
|
|
2029
|
+
const chk = el("hideAnnotatedChk");
|
|
2030
|
+
if (!chk) return;
|
|
2031
|
+
chk.checked = localStorage.getItem(KEY) === "1";
|
|
2032
|
+
chk.addEventListener("change", () => {
|
|
2033
|
+
localStorage.setItem(KEY, chk.checked ? "1" : "0");
|
|
2034
|
+
renderH5List();
|
|
2035
|
+
});
|
|
2036
|
+
})();
|
|
2037
|
+
|
|
2038
|
+
// Editable task instruction (#31)
|
|
2039
|
+
el("editInstrBtn").addEventListener("click", () => {
|
|
2040
|
+
if (!browseRelPath) return;
|
|
2041
|
+
const cur = String(currentBrowseSample?.metadata?.language_instruction || "").trim();
|
|
2042
|
+
el("instrEditInput").value = cur;
|
|
2043
|
+
el("instrEditRow").hidden = false;
|
|
2044
|
+
el("instrEditInput").focus();
|
|
2045
|
+
});
|
|
2046
|
+
el("instrCancelBtn").addEventListener("click", () => { el("instrEditRow").hidden = true; });
|
|
2047
|
+
|
|
2048
|
+
// Autofill: copy a chosen previous annotation into the form (#27)
|
|
2049
|
+
el("autofillSelect").addEventListener("change", () => {
|
|
2050
|
+
const items = state.recentAnnotations || [];
|
|
2051
|
+
const idx = parseInt(el("autofillSelect").value, 10);
|
|
2052
|
+
if (Number.isInteger(idx) && items[idx]) {
|
|
2053
|
+
populateForm(items[idx]);
|
|
2054
|
+
setDirty(true);
|
|
2055
|
+
el("saveMsg").textContent = "Copied from a previous annotation — review and Save.";
|
|
2056
|
+
}
|
|
2057
|
+
el("autofillSelect").value = "";
|
|
2058
|
+
});
|
|
2059
|
+
el("instrSaveBtn").addEventListener("click", async () => {
|
|
2060
|
+
if (!browseRelPath) return;
|
|
2061
|
+
const val = el("instrEditInput").value.trim();
|
|
2062
|
+
if (!val) { showToast("Instruction cannot be empty."); return; }
|
|
2063
|
+
el("instrSaveBtn").disabled = true;
|
|
2064
|
+
try {
|
|
2065
|
+
await jpost(`/api/h5/instruction?path=${encodeURIComponent(browseRelPath)}`, { instruction: val });
|
|
2066
|
+
if (currentBrowseSample && currentBrowseSample.metadata) {
|
|
2067
|
+
currentBrowseSample.metadata.language_instruction = val;
|
|
2068
|
+
}
|
|
2069
|
+
if (currentBrowseSample && currentBrowseSample.episode_fields && currentBrowseSample.episode_fields.attributes) {
|
|
2070
|
+
currentBrowseSample.episode_fields.attributes.language_instruction = val;
|
|
2071
|
+
renderEpisodeFields(currentBrowseSample.episode_fields); // keep the data panel in sync
|
|
2072
|
+
}
|
|
2073
|
+
el("sessionTaskInstruction").textContent = val;
|
|
2074
|
+
sampleCache.delete(browseRelPath); // cached sample now has a stale instruction
|
|
2075
|
+
el("instrEditRow").hidden = true;
|
|
2076
|
+
} catch (e) {
|
|
2077
|
+
showToast(`Could not save instruction: ${e}`);
|
|
2078
|
+
} finally {
|
|
2079
|
+
el("instrSaveBtn").disabled = false;
|
|
2080
|
+
}
|
|
2081
|
+
});
|
|
2082
|
+
|
|
2083
|
+
// Dirty tracking: any real edit flips the indicator and clears stale messages (#34).
|
|
2084
|
+
el("questionnaire").addEventListener("input", () => { setDirty(true); el("saveMsg").textContent = ""; });
|
|
2085
|
+
el("questionnaire").addEventListener("change", () => { setDirty(true); el("saveMsg").textContent = ""; });
|
|
2086
|
+
|
|
2087
|
+
// Theme toggle: dark/light. Reads OS preference by default; explicit choice is
|
|
2088
|
+
// persisted in localStorage and applied via data-theme on <html>.
|
|
2089
|
+
(function initThemeToggle() {
|
|
2090
|
+
const THEME_KEY = "oopsie_theme";
|
|
2091
|
+
const btn = el("themeToggleBtn");
|
|
2092
|
+
if (!btn) return;
|
|
2093
|
+
const mql = window.matchMedia("(prefers-color-scheme: dark)");
|
|
2094
|
+
function currentTheme() {
|
|
2095
|
+
const attr = document.documentElement.getAttribute("data-theme");
|
|
2096
|
+
if (attr === "light" || attr === "dark") return attr;
|
|
2097
|
+
return mql.matches ? "dark" : "light";
|
|
2098
|
+
}
|
|
2099
|
+
function syncButton() {
|
|
2100
|
+
const t = currentTheme();
|
|
2101
|
+
btn.textContent = t === "dark" ? "Light mode" : "Dark mode";
|
|
2102
|
+
btn.title = t === "dark" ? "Switch to light mode" : "Switch to dark mode";
|
|
2103
|
+
}
|
|
2104
|
+
syncButton();
|
|
2105
|
+
btn.addEventListener("click", () => {
|
|
2106
|
+
const next = currentTheme() === "dark" ? "light" : "dark";
|
|
2107
|
+
document.documentElement.setAttribute("data-theme", next);
|
|
2108
|
+
try { localStorage.setItem(THEME_KEY, next); } catch (e) { /* ignore */ }
|
|
2109
|
+
syncButton();
|
|
2110
|
+
});
|
|
2111
|
+
// Follow OS changes only while the user hasn't made an explicit choice.
|
|
2112
|
+
mql.addEventListener?.("change", () => {
|
|
2113
|
+
try {
|
|
2114
|
+
if (!localStorage.getItem(THEME_KEY)) syncButton();
|
|
2115
|
+
} catch (e) { syncButton(); }
|
|
2116
|
+
});
|
|
2117
|
+
})();
|
|
2118
|
+
|
|
2119
|
+
// Persisted auto-advance-on-save preference (#33), default on.
|
|
2120
|
+
(function initAutoAdvance() {
|
|
2121
|
+
const KEY = "oopsie_auto_advance";
|
|
2122
|
+
const chk = el("autoAdvanceChk");
|
|
2123
|
+
if (!chk) return;
|
|
2124
|
+
const saved = localStorage.getItem(KEY);
|
|
2125
|
+
chk.checked = saved === null ? true : saved === "1";
|
|
2126
|
+
chk.addEventListener("change", () => localStorage.setItem(KEY, chk.checked ? "1" : "0"));
|
|
2127
|
+
})();
|
|
2128
|
+
|
|
2129
|
+
// Persisted video controls: playback speed (#32) + sync toggle (#25).
|
|
2130
|
+
(function initVideoControls() {
|
|
2131
|
+
const rateSel = el("playbackRateSel");
|
|
2132
|
+
if (rateSel) {
|
|
2133
|
+
const stored = String(getPlaybackRate());
|
|
2134
|
+
if ([...rateSel.options].some((o) => o.value === stored)) rateSel.value = stored;
|
|
2135
|
+
rateSel.addEventListener("change", () => {
|
|
2136
|
+
localStorage.setItem(RATE_KEY, rateSel.value);
|
|
2137
|
+
applyPlaybackRate(parseFloat(rateSel.value) || 1);
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
const syncChk = el("syncVideosChk");
|
|
2141
|
+
if (syncChk) {
|
|
2142
|
+
syncChk.checked = localStorage.getItem(SYNC_KEY) === "1";
|
|
2143
|
+
syncChk.addEventListener("change", () => localStorage.setItem(SYNC_KEY, syncChk.checked ? "1" : "0"));
|
|
2144
|
+
}
|
|
2145
|
+
const colsSel = el("videoLayoutSel");
|
|
2146
|
+
if (colsSel) {
|
|
2147
|
+
colsSel.value = String(getVideoCols());
|
|
2148
|
+
applyVideoCols(getVideoCols());
|
|
2149
|
+
colsSel.addEventListener("change", () => {
|
|
2150
|
+
const n = parseInt(colsSel.value, 10);
|
|
2151
|
+
const clean = (n === 2 || n === 3) ? n : 1;
|
|
2152
|
+
localStorage.setItem(COLS_KEY, String(clean));
|
|
2153
|
+
applyVideoCols(clean);
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
})();
|
|
2157
|
+
|
|
2158
|
+
// Keyboard shortcuts (#33). Ctrl/Cmd+S / Ctrl/Cmd+Enter always save; the rest
|
|
2159
|
+
// are ignored while typing in a field.
|
|
2160
|
+
document.addEventListener("keydown", (e) => {
|
|
2161
|
+
const t = e.target;
|
|
2162
|
+
// The instruction editor owns Enter=save / Esc=cancel / Ctrl+S=save (#31), so its
|
|
2163
|
+
// keys never fall through to the questionnaire save hotkey (which would save the
|
|
2164
|
+
// annotation and auto-advance, silently discarding the typed instruction).
|
|
2165
|
+
if (t === el("instrEditInput")) {
|
|
2166
|
+
if (e.key === "Enter" || ((e.ctrlKey || e.metaKey) && (e.key === "s" || e.key === "S"))) {
|
|
2167
|
+
e.preventDefault();
|
|
2168
|
+
el("instrSaveBtn").click();
|
|
2169
|
+
} else if (e.key === "Escape") {
|
|
2170
|
+
e.preventDefault();
|
|
2171
|
+
el("instrCancelBtn").click();
|
|
2172
|
+
}
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
const typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable);
|
|
2176
|
+
if ((e.ctrlKey || e.metaKey) && (e.key === "s" || e.key === "S" || e.key === "Enter")) {
|
|
2177
|
+
e.preventDefault();
|
|
2178
|
+
el("saveBtn").click();
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
if (typing) return;
|
|
2182
|
+
if (e.key === "ArrowRight" || e.key === "n" || e.key === "k") { e.preventDefault(); navigateBy(1); return; }
|
|
2183
|
+
if (e.key === "ArrowLeft" || e.key === "p" || e.key === "j") { e.preventDefault(); navigateBy(-1); return; }
|
|
2184
|
+
if (!browseRelPath && !currentSampleId) return;
|
|
2185
|
+
if (e.key === "s" || e.key === "S") { e.preventDefault(); setOutcome("success"); }
|
|
2186
|
+
else if (e.key === "f" || e.key === "F") { e.preventDefault(); setOutcome("failure"); }
|
|
2187
|
+
// 1-4 reach the two qualified successes, which s/f alone cannot.
|
|
2188
|
+
else if (["1", "2", "3", "4"].includes(e.key)) {
|
|
2189
|
+
e.preventDefault();
|
|
2190
|
+
setOutcome(["success", "success_suboptimal", "success_side_effect", "failure"][Number(e.key) - 1]);
|
|
2191
|
+
}
|
|
2192
|
+
});
|
|
2193
|
+
|
|
2194
|
+
// Guard against losing unsaved edits on tab close / reload (#34).
|
|
2195
|
+
window.addEventListener("beforeunload", (e) => {
|
|
2196
|
+
// Same rationale as confirmDiscardIfDirty: nothing to lose if there's
|
|
2197
|
+
// no target episode selected.
|
|
2198
|
+
if (isDirty && (browseRelPath || currentSampleId)) {
|
|
2199
|
+
e.preventDefault();
|
|
2200
|
+
e.returnValue = "";
|
|
2201
|
+
}
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
el("backToTaskBtn").addEventListener("click", async () => {
|
|
2205
|
+
try {
|
|
2206
|
+
await jpost("/api/task/done", {});
|
|
2207
|
+
} catch (e) {
|
|
2208
|
+
/* already idle or network glitch; still return to instruction UI */
|
|
2209
|
+
}
|
|
2210
|
+
clearBrowse();
|
|
2211
|
+
showOnly("instructionCard");
|
|
2212
|
+
await refresh();
|
|
2213
|
+
});
|
|
2214
|
+
|
|
2215
|
+
// Standalone (browse-only) launches only annotate pre-collected data — no
|
|
2216
|
+
// rollout to start, so hide the "Start next rollout" affordance entirely.
|
|
2217
|
+
// In-the-loop launches show it as the primary way to advance to the next
|
|
2218
|
+
// policy execution.
|
|
2219
|
+
const IS_IN_THE_LOOP = !(typeof ANNOTATOR_BROWSE_ONLY !== "undefined" && ANNOTATOR_BROWSE_ONLY);
|
|
2220
|
+
if (IS_IN_THE_LOOP) {
|
|
2221
|
+
el("backToTaskBtn").hidden = false;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
updateSaveButtonState(); // start disabled with the "select an episode" tooltip
|
|
2225
|
+
initAnnotationForm(); // conditional visibility + optional-radio deselect
|
|
2226
|
+
loadH5List().catch(() => {});
|
|
2227
|
+
loadRecentAnnotations().catch(() => {});
|
|
2228
|
+
refresh().catch(() => {}); // initial paint
|
|
2229
|
+
if (IS_IN_THE_LOOP) {
|
|
2230
|
+
setInterval(() => refresh().catch(() => {}), POLL_TASK_MS);
|
|
2231
|
+
setInterval(() => pollH5List().catch(() => {}), POLL_LIST_MS);
|
|
2232
|
+
} else {
|
|
2233
|
+
// Browse mode: no rollout task-state to watch, but keep the list fresh so
|
|
2234
|
+
// other annotators' new episodes appear without a hard refresh.
|
|
2235
|
+
setInterval(() => pollH5List().catch(() => {}), POLL_LIST_MS);
|
|
2236
|
+
}
|
|
2237
|
+
</script>
|
|
2238
|
+
</body>
|
|
2239
|
+
</html>
|
|
2240
|
+
|