dehezhang2 commited on
Commit
f9aaea2
·
verified ·
1 Parent(s): 0f71cc3

deploy v2 study (40-runs baselines) at /v2/: dual-study backend + static/v2 rater UI; v1 study untouched

Browse files
Files changed (6) hide show
  1. README.md +12 -4
  2. app.py +430 -391
  3. static/v2/admin.html +550 -0
  4. static/v2/cases.json +462 -0
  5. static/v2/index.html +0 -0
  6. static/v2/mobile.html +1492 -0
README.md CHANGED
@@ -8,8 +8,16 @@ pinned: false
8
  app_port: 7860
9
  ---
10
 
11
- FrameWorker side-by-side video rater. Participants register (name + email), rate
12
- each video on 13 sub-questions across 4 dimensions, and submit once 100% complete.
13
- Submissions are written to dataset `dehezhang2/Frameworker_User_Study`.
14
 
15
- Set the `HF_TOKEN` Space secret to a token with write access to that dataset.
 
 
 
 
 
 
 
 
 
 
8
  app_port: 7860
9
  ---
10
 
11
+ FrameWorker side-by-side video rater. Two independent studies share this
12
+ Space:
 
13
 
14
+ - **v1** at `/` original study (ours / baseline / univa), database under
15
+ `submissions/` in dataset `dehezhang2/Frameworker_User_Study`.
16
+ - **v2** at `/v2/` — 40-runs baseline study (ours / movieagent /
17
+ anim_director) on a unified story-outline input, database under
18
+ `v2/submissions/` in the same dataset.
19
+
20
+ Participants register (name + email), rate each blinded video on the rubric,
21
+ and submit once 100% complete.
22
+
23
+ Set the `HF_TOKEN` Space secret to a token with write access to the dataset.
app.py CHANGED
@@ -1,8 +1,19 @@
1
- """FastAPI backend for the FrameWorker user-study Space.
2
 
3
- Serves the static rater UI from ./static, and exposes POST /api/submit which
4
- validates a completed submission and appends it to the
5
- `dehezhang2/Frameworker_User_Study` dataset under `submissions/`.
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  Secret: HF_TOKEN must be set as a Space secret with write access to the
8
  dataset repo.
@@ -16,19 +27,18 @@ import re
16
  import uuid
17
  from io import BytesIO
18
 
19
- from fastapi import FastAPI, HTTPException, Request
20
  from fastapi.responses import JSONResponse
21
  from fastapi.staticfiles import StaticFiles
22
- from huggingface_hub import HfApi
23
  from huggingface_hub.utils import HfHubHTTPError
24
 
25
  DATASET_REPO = "dehezhang2/Frameworker_User_Study"
26
- ANNOTATIONS_PATH = "annotations.json"
27
 
28
- # Plain shared-secret gate for the admin dashboard (`/admin.html`).
29
- # Override via `ADMIN_PASSWORD` env var / Space secret. The default is
30
- # intentionally weak — this isn't auth-grade, just a screen against random
31
- # visitors stumbling into the submissions list.
32
  ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "12345")
33
 
34
  # Mirror of the client-side ADMIN_NAMES list. Matched case-insensitively
@@ -39,7 +49,7 @@ ADMIN_NAMES = {
39
  "zhendong li",
40
  }
41
 
42
- app = FastAPI(title="FrameWorker user-study backend")
43
 
44
  _TOKEN = os.environ.get("HF_TOKEN")
45
  _api = HfApi(token=_TOKEN) if _TOKEN else None
@@ -59,10 +69,10 @@ def _is_admin(name: str) -> bool:
59
 
60
 
61
  def _normalize_legacy_models(payload: dict) -> int:
62
- """Rewrite any `model: "huobao"` (and matching `_huobao.mp4` paths) to
63
- `univa` on the way in. Older clients with stale localStorage keep
64
- re-introducing the old name on save; this silently fixes it server-side
65
- so the dataset stays clean. Returns the number of fields rewritten."""
66
  n = 0
67
  for c in payload.get("cases") or []:
68
  for v in c.get("videos") or []:
@@ -95,396 +105,425 @@ def _validate_rater(payload) -> tuple[str, str]:
95
  return name, email
96
 
97
 
98
- @app.get("/api/health")
99
- def health():
100
- return {
101
- "ok": True,
102
- "has_token": bool(_TOKEN),
103
- "dataset": DATASET_REPO,
104
- "time_utc": dt.datetime.utcnow().isoformat() + "Z",
105
- }
106
-
107
-
108
- @app.post("/api/submit")
109
- async def submit(req: Request):
110
- if _api is None:
111
- raise HTTPException(500, "server missing HF_TOKEN secret")
112
- try:
113
- payload = await req.json()
114
- except Exception as e:
115
- raise HTTPException(400, f"invalid JSON: {e}")
116
-
117
- name, email = _validate_rater(payload)
118
- _normalize_legacy_models(payload) # rewrite any stale `huobao` → `univa`
119
-
120
- rubric = payload.get("rubric") or []
121
- cases = payload.get("cases") or []
122
- if not rubric or not cases:
123
- raise HTTPException(400, "submission must include rubric + cases")
124
-
125
- qkeys = [q["key"] for d in rubric for q in d.get("questions", [])]
126
- if not qkeys:
127
- raise HTTPException(400, "rubric has no questions")
128
-
129
- missing = []
130
- bad = []
131
- n_videos = 0
132
- n_scores = 0
133
- for c in cases:
134
- cid = c.get("id", "?")
135
- for v in c.get("videos", []):
136
- n_videos += 1
137
- ratings = v.get("ratings") or {}
138
- slot = v.get("slot", "?")
139
- for qk in qkeys:
140
- if qk not in ratings:
141
- missing.append(f"{cid}/{slot}/{qk}")
142
- elif not _is_score(ratings[qk]):
143
- bad.append(f"{cid}/{slot}/{qk}={ratings[qk]!r}")
144
- else:
145
- n_scores += 1
146
- if missing or bad:
147
- raise HTTPException(
148
- 400,
149
- json.dumps(
150
- {
151
- "error": "incomplete_or_invalid",
152
- "missing_count": len(missing),
153
- "invalid_count": len(bad),
154
- "first_missing": missing[:5],
155
- "first_invalid": bad[:5],
156
- }
157
- ),
158
- )
159
 
160
- ts = dt.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
161
- fname = f"submissions/{_slug(email)}_{ts}_{uuid.uuid4().hex[:8]}.json"
162
-
163
- is_admin = _is_admin(name)
164
- payload["is_admin"] = is_admin
165
- payload["_server"] = {
166
- "received_at_utc": dt.datetime.utcnow().isoformat() + "Z",
167
- "client_ip": req.client.host if req.client else None,
168
- "n_videos": n_videos,
169
- "n_scores": n_scores,
170
- "stored_as": fname,
171
- "status": "final",
172
- }
173
- blob = json.dumps(payload, indent=2, ensure_ascii=False).encode("utf-8")
174
-
175
- try:
176
- _api.upload_file(
177
- repo_id=DATASET_REPO,
178
- repo_type="dataset",
179
- path_or_fileobj=BytesIO(blob),
180
- path_in_repo=fname,
181
- commit_message=(
182
- f"submission from {email} ({n_scores} scores"
183
- f"{' · admin' if is_admin else ''})"
184
- ),
185
- )
186
- except HfHubHTTPError as e:
187
- status = e.response.status_code if e.response is not None else 500
188
- raise HTTPException(
189
- status_code=502,
190
- detail=f"failed to write to dataset ({status}): {e}",
191
- )
192
 
193
- return JSONResponse(
194
- {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  "ok": True,
196
- "stored_as": fname,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  "n_videos": n_videos,
198
  "n_scores": n_scores,
199
- "is_admin": is_admin,
 
200
  }
201
- )
202
-
203
-
204
- @app.post("/api/save_progress")
205
- async def save_progress(req: Request):
206
- """Write a per-rater in-progress snapshot to the dataset.
207
-
208
- Used in two situations:
209
- - Right after registration: page POSTs an empty payload (no ratings yet)
210
- so the rater appears in `submissions/in_progress/` from the moment
211
- they sign in. Useful for tracking participation start time and
212
- identifying drop-offs.
213
- - Mid-study auto-save (debounced) + manual "Save progress" button:
214
- the same single per-user file is overwritten with the latest snapshot.
215
-
216
- Unlike /api/submit, this does NOT require completeness — the payload may
217
- have any subset of ratings (including none).
218
- """
219
- if _api is None:
220
- raise HTTPException(500, "server missing HF_TOKEN secret")
221
- try:
222
- payload = await req.json()
223
- except Exception as e:
224
- raise HTTPException(400, f"invalid JSON: {e}")
225
-
226
- name, email = _validate_rater(payload)
227
- _normalize_legacy_models(payload) # rewrite any stale `huobao` → `univa`
228
- cases = payload.get("cases") or []
229
- n_scores = _count_scores(cases)
230
- is_admin = _is_admin(name)
231
-
232
- fname = f"submissions/in_progress/{_slug(email)}.json"
233
- payload["is_admin"] = is_admin
234
- payload["_server"] = {
235
- "received_at_utc": dt.datetime.utcnow().isoformat() + "Z",
236
- "client_ip": req.client.host if req.client else None,
237
- "n_scores": n_scores,
238
- "stored_as": fname,
239
- "status": "in_progress",
240
- }
241
- blob = json.dumps(payload, indent=2, ensure_ascii=False).encode("utf-8")
242
- try:
243
- _api.upload_file(
244
- repo_id=DATASET_REPO,
245
- repo_type="dataset",
246
- path_or_fileobj=BytesIO(blob),
247
- path_in_repo=fname,
248
- commit_message=(
249
- f"in-progress save from {email} ({n_scores} scores"
250
- f"{' · admin' if is_admin else ''})"
251
- ),
252
- )
253
- except HfHubHTTPError as e:
254
- status = e.response.status_code if e.response is not None else 500
255
- raise HTTPException(
256
- status_code=502,
257
- detail=f"failed to write to dataset ({status}): {e}",
258
- )
259
 
260
- return JSONResponse({
261
- "ok": True,
262
- "stored_as": fname,
263
- "n_scores": n_scores,
264
- "is_admin": is_admin,
265
- })
266
-
267
-
268
- def _load_annotations() -> dict:
269
- """Return the dataset's current annotations.json, or an empty shell."""
270
- if _api is None:
271
- return {"annotations": {}}
272
- try:
273
- from huggingface_hub import hf_hub_download
274
- local = hf_hub_download(
275
- repo_id=DATASET_REPO, repo_type="dataset",
276
- filename=ANNOTATIONS_PATH, force_download=True,
277
- )
278
- with open(local, "r", encoding="utf-8") as f:
279
- data = json.load(f)
280
- if not isinstance(data.get("annotations"), dict):
281
- data["annotations"] = {}
282
- return data
283
- except HfHubHTTPError as e:
284
- # 404 means we haven't created the file yet — that's the empty case.
285
- if e.response is not None and e.response.status_code == 404:
286
- return {"annotations": {}}
287
- return {"annotations": {}}
288
- except Exception:
289
- return {"annotations": {}}
290
 
 
 
 
 
 
 
 
 
 
291
 
292
- def _require_admin_password(req: Request) -> None:
293
- pw = req.headers.get("x-admin-password") or req.query_params.get("pw") or ""
294
- if pw != ADMIN_PASSWORD:
295
- raise HTTPException(401, "admin password required")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
 
 
 
 
 
 
 
297
 
298
- @app.post("/api/admin/login")
299
- async def admin_login(req: Request):
300
- try:
301
- payload = await req.json()
302
- except Exception:
303
- raise HTTPException(400, "invalid JSON")
304
- if (payload.get("password") or "").strip() != ADMIN_PASSWORD:
305
- raise HTTPException(401, "wrong password")
306
- return JSONResponse({"ok": True})
307
-
308
-
309
- @app.get("/api/admin/submissions")
310
- async def admin_submissions(req: Request):
311
- """List every submission JSON in the dataset with light metadata.
312
-
313
- Admin-only (X-Admin-Password header). The page fetches each blob's full
314
- body directly from the public dataset resolve URL for visualisation.
315
- """
316
- _require_admin_password(req)
317
- if _api is None:
318
- return JSONResponse({"submissions": [], "reason": "no token"})
319
- from huggingface_hub import hf_hub_download
320
- out = []
321
- for tree in _api.list_repo_tree(
322
- repo_id=DATASET_REPO, repo_type="dataset",
323
- path_in_repo="submissions", recursive=True,
324
- ):
325
- path = getattr(tree, "path", "")
326
- if not path.endswith(".json"):
327
- continue
328
- record: dict = {"path": path}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  try:
330
  local = hf_hub_download(
331
  repo_id=DATASET_REPO, repo_type="dataset",
332
- filename=path, force_download=False,
 
333
  )
334
  with open(local, "r", encoding="utf-8") as f:
335
- data = json.load(f)
336
- rater = data.get("rater") or {}
337
- srv = data.get("_server") or {}
338
- cases = data.get("cases") or []
339
- record.update({
340
- "rater_name": rater.get("name") or "",
341
- "rater_email": rater.get("email") or "",
342
- "is_admin": bool(data.get("is_admin", False)),
343
- "n_cases": len(cases),
344
- "n_scores": srv.get("n_scores"),
345
- "status": srv.get("status"),
346
- "received_at_utc": srv.get("received_at_utc"),
347
- "chosen_case_ids": rater.get("chosen_case_ids") or [],
348
- })
349
  except Exception as e:
350
- record["error"] = str(e)[:120]
351
- out.append(record)
352
- # newest first
353
- out.sort(key=lambda r: r.get("received_at_utc") or "", reverse=True)
354
- return JSONResponse({"submissions": out, "total": len(out)})
355
-
356
-
357
- @app.delete("/api/admin/submission")
358
- async def admin_delete_submission(req: Request):
359
- """Admin-only delete of a submission JSON.
360
-
361
- Body: {"path": "submissions/...json"} — must start with "submissions/" and
362
- end with ".json" to prevent the endpoint from deleting unrelated dataset
363
- files via path traversal.
364
- """
365
- _require_admin_password(req)
366
- if _api is None:
367
- raise HTTPException(500, "server missing HF_TOKEN secret")
368
- try:
369
- payload = await req.json()
370
- except Exception:
371
- raise HTTPException(400, "invalid JSON")
372
- path = (payload.get("path") or "").strip()
373
- if (not path.startswith("submissions/") or not path.endswith(".json")
374
- or ".." in path):
375
- raise HTTPException(400, "path must be a submissions/*.json file")
376
- try:
377
- _api.delete_file(
378
- repo_id=DATASET_REPO, repo_type="dataset",
379
- path_in_repo=path,
380
- commit_message=f"admin: delete submission {os.path.basename(path)}",
381
- )
382
- except HfHubHTTPError as e:
383
- status = e.response.status_code if e.response is not None else 500
384
- raise HTTPException(502, f"delete failed ({status}): {e}")
385
- return JSONResponse({"ok": True, "deleted": path})
386
-
387
-
388
- @app.get("/api/rater_state")
389
- async def get_rater_state(email: str = ""):
390
- """Return the rater's most recent in-progress JSON (if any).
391
-
392
- Used by the page to recover a rater's locked case subset + prior ratings
393
- when they land on a new device. The submissions/in_progress folder is
394
- public on the dataset, so this is just a convenience wrapper.
395
- """
396
- email = (email or "").strip().lower()
397
- if not email:
398
- raise HTTPException(400, "email query param required")
399
- slug = _slug(email)
400
- if _api is None:
401
- return JSONResponse({"found": False, "reason": "no token"})
402
- try:
403
- from huggingface_hub import hf_hub_download
404
- local = hf_hub_download(
405
- repo_id=DATASET_REPO, repo_type="dataset",
406
- filename=f"submissions/in_progress/{slug}.json", force_download=True,
407
- )
408
- with open(local, "r", encoding="utf-8") as f:
409
- return JSONResponse({"found": True, "state": json.load(f)})
410
- except HfHubHTTPError as e:
411
- status = e.response.status_code if e.response is not None else 500
412
- if status == 404:
413
- return JSONResponse({"found": False})
414
- return JSONResponse({"found": False, "reason": f"HTTP {status}"})
415
- except Exception as e:
416
- return JSONResponse({"found": False, "reason": str(e)})
417
-
418
-
419
- @app.get("/api/annotations")
420
- async def get_annotations():
421
- """Public read of admin-curated prompt highlights.
422
-
423
- Shape: {"annotations": {case_id: {lang: [snippet, snippet, ...]}}}
424
- """
425
- data = _load_annotations()
426
- return JSONResponse({"annotations": data.get("annotations", {})})
427
-
428
-
429
- @app.post("/api/annotations")
430
- async def post_annotations(req: Request):
431
- """Admin-only: replace the highlight list for a (case_id, lang) pair.
432
-
433
- Payload: {rater:{name,email}, case_id, lang, ranges: [string, ...]}
434
- Server-enforces ADMIN_NAMES against the supplied rater.name.
435
- """
436
- if _api is None:
437
- raise HTTPException(500, "server missing HF_TOKEN secret")
438
- try:
439
- payload = await req.json()
440
- except Exception as e:
441
- raise HTTPException(400, f"invalid JSON: {e}")
442
- name, email = _validate_rater(payload)
443
- if not _is_admin(name):
444
- raise HTTPException(403, "admin only")
445
- case_id = (payload.get("case_id") or "").strip()
446
- lang = (payload.get("lang") or "en").strip()
447
- ranges = payload.get("ranges") or []
448
- if not case_id:
449
- raise HTTPException(400, "case_id required")
450
- if not isinstance(ranges, list) or not all(isinstance(r, str) for r in ranges):
451
- raise HTTPException(400, "ranges must be a list of strings")
452
- # Sanitise: drop empty / huge entries, dedupe in order.
453
- cleaned = []
454
- seen = set()
455
- for r in ranges:
456
- s = r.strip()
457
- if not s or len(s) > 1024 or s in seen:
458
- continue
459
- seen.add(s); cleaned.append(s)
460
-
461
- data = _load_annotations()
462
- data.setdefault("annotations", {}).setdefault(case_id, {})[lang] = cleaned
463
- data["_server"] = {
464
- "last_edited_by": email,
465
- "last_edited_name": name,
466
- "last_edited_at_utc": dt.datetime.utcnow().isoformat() + "Z",
467
- "case_id": case_id, "lang": lang,
468
- }
469
- blob = json.dumps(data, indent=2, ensure_ascii=False).encode("utf-8")
470
- try:
471
- _api.upload_file(
472
- repo_id=DATASET_REPO, repo_type="dataset",
473
- path_or_fileobj=BytesIO(blob),
474
- path_in_repo=ANNOTATIONS_PATH,
475
- commit_message=(
476
- f"annotations: {email} set {len(cleaned)} highlight(s) "
477
- f"for {case_id} / {lang}"
478
- ),
479
- )
480
- except HfHubHTTPError as e:
481
- status = e.response.status_code if e.response is not None else 500
482
- raise HTTPException(502, f"failed to write annotations ({status}): {e}")
483
- return JSONResponse({
484
- "ok": True, "case_id": case_id, "lang": lang,
485
- "ranges": cleaned,
486
- })
487
-
488
-
489
- # Static rater UI lives in ./static. Mount last so /api/* takes precedence.
490
  app.mount("/", StaticFiles(directory="static", html=True), name="static")
 
1
+ """FastAPI backend for the FrameWorker user-study Space — dual-study edition.
2
 
3
+ Serves TWO independent studies from one Space:
4
+
5
+ v1 (original) pages at / API at /api/* dataset folder submissions/
6
+ v2 (40-runs) pages at /v2/ API at /v2/api/* dataset folder v2/submissions/
7
+
8
+ Both write to the same dataset repo (`dehezhang2/Frameworker_User_Study`) but
9
+ into disjoint folders, so the two studies' databases never mix. The rater
10
+ pages use relative `./api/*` URLs, which resolve to the right prefix
11
+ automatically depending on which page they were loaded from.
12
+
13
+ Endpoint behavior is identical to the original single-study app.py; the
14
+ handlers are built by a factory parameterized on (prefix, submissions folder,
15
+ annotations file). The huobao→univa legacy rename only applies to the v1
16
+ study.
17
 
18
  Secret: HF_TOKEN must be set as a Space secret with write access to the
19
  dataset repo.
 
27
  import uuid
28
  from io import BytesIO
29
 
30
+ from fastapi import APIRouter, FastAPI, HTTPException, Request
31
  from fastapi.responses import JSONResponse
32
  from fastapi.staticfiles import StaticFiles
33
+ from huggingface_hub import HfApi, hf_hub_download
34
  from huggingface_hub.utils import HfHubHTTPError
35
 
36
  DATASET_REPO = "dehezhang2/Frameworker_User_Study"
 
37
 
38
+ # Plain shared-secret gate for the admin dashboards (`/admin.html`,
39
+ # `/v2/admin.html`). Override via `ADMIN_PASSWORD` env var / Space secret.
40
+ # The default is intentionally weak — this isn't auth-grade, just a screen
41
+ # against random visitors stumbling into the submissions list.
42
  ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "12345")
43
 
44
  # Mirror of the client-side ADMIN_NAMES list. Matched case-insensitively
 
49
  "zhendong li",
50
  }
51
 
52
+ app = FastAPI(title="FrameWorker user-study backend (dual-study)")
53
 
54
  _TOKEN = os.environ.get("HF_TOKEN")
55
  _api = HfApi(token=_TOKEN) if _TOKEN else None
 
69
 
70
 
71
  def _normalize_legacy_models(payload: dict) -> int:
72
+ """v1-study only: rewrite any `model: "huobao"` (and matching
73
+ `_huobao.mp4` paths) to `univa` on the way in. Older clients with stale
74
+ localStorage keep re-introducing the old name on save; this silently
75
+ fixes it server-side so the dataset stays clean."""
76
  n = 0
77
  for c in payload.get("cases") or []:
78
  for v in c.get("videos") or []:
 
105
  return name, email
106
 
107
 
108
+ def _require_admin_password(req: Request) -> None:
109
+ pw = req.headers.get("x-admin-password") or req.query_params.get("pw") or ""
110
+ if pw != ADMIN_PASSWORD:
111
+ raise HTTPException(401, "admin password required")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
+ def build_study_router(
115
+ *,
116
+ prefix: str,
117
+ study: str,
118
+ submissions_root: str,
119
+ annotations_path: str,
120
+ normalize_legacy: bool,
121
+ ) -> APIRouter:
122
+ """One study's full API surface, writing under its own dataset folders."""
123
+ router = APIRouter(prefix=prefix)
124
+
125
+ def _load_annotations() -> dict:
126
+ """Return the study's current annotations file, or an empty shell."""
127
+ if _api is None:
128
+ return {"annotations": {}}
129
+ try:
130
+ local = hf_hub_download(
131
+ repo_id=DATASET_REPO, repo_type="dataset",
132
+ filename=annotations_path, force_download=True,
133
+ )
134
+ with open(local, "r", encoding="utf-8") as f:
135
+ data = json.load(f)
136
+ if not isinstance(data.get("annotations"), dict):
137
+ data["annotations"] = {}
138
+ return data
139
+ except HfHubHTTPError as e:
140
+ # 404 means we haven't created the file yet — that's the empty case.
141
+ if e.response is not None and e.response.status_code == 404:
142
+ return {"annotations": {}}
143
+ return {"annotations": {}}
144
+ except Exception:
145
+ return {"annotations": {}}
146
+
147
+ @router.get("/health")
148
+ def health():
149
+ return {
150
  "ok": True,
151
+ "has_token": bool(_TOKEN),
152
+ "dataset": DATASET_REPO,
153
+ "study": study,
154
+ "time_utc": dt.datetime.utcnow().isoformat() + "Z",
155
+ }
156
+
157
+ @router.post("/submit")
158
+ async def submit(req: Request):
159
+ if _api is None:
160
+ raise HTTPException(500, "server missing HF_TOKEN secret")
161
+ try:
162
+ payload = await req.json()
163
+ except Exception as e:
164
+ raise HTTPException(400, f"invalid JSON: {e}")
165
+
166
+ name, email = _validate_rater(payload)
167
+ if normalize_legacy:
168
+ _normalize_legacy_models(payload)
169
+
170
+ rubric = payload.get("rubric") or []
171
+ cases = payload.get("cases") or []
172
+ if not rubric or not cases:
173
+ raise HTTPException(400, "submission must include rubric + cases")
174
+
175
+ qkeys = [q["key"] for d in rubric for q in d.get("questions", [])]
176
+ if not qkeys:
177
+ raise HTTPException(400, "rubric has no questions")
178
+
179
+ missing = []
180
+ bad = []
181
+ n_videos = 0
182
+ n_scores = 0
183
+ for c in cases:
184
+ cid = c.get("id", "?")
185
+ for v in c.get("videos", []):
186
+ n_videos += 1
187
+ ratings = v.get("ratings") or {}
188
+ slot = v.get("slot", "?")
189
+ for qk in qkeys:
190
+ if qk not in ratings:
191
+ missing.append(f"{cid}/{slot}/{qk}")
192
+ elif not _is_score(ratings[qk]):
193
+ bad.append(f"{cid}/{slot}/{qk}={ratings[qk]!r}")
194
+ else:
195
+ n_scores += 1
196
+ if missing or bad:
197
+ raise HTTPException(
198
+ 400,
199
+ json.dumps(
200
+ {
201
+ "error": "incomplete_or_invalid",
202
+ "missing_count": len(missing),
203
+ "invalid_count": len(bad),
204
+ "first_missing": missing[:5],
205
+ "first_invalid": bad[:5],
206
+ }
207
+ ),
208
+ )
209
+
210
+ ts = dt.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
211
+ fname = f"{submissions_root}/{_slug(email)}_{ts}_{uuid.uuid4().hex[:8]}.json"
212
+
213
+ is_admin = _is_admin(name)
214
+ payload["is_admin"] = is_admin
215
+ payload["_server"] = {
216
+ "received_at_utc": dt.datetime.utcnow().isoformat() + "Z",
217
+ "client_ip": req.client.host if req.client else None,
218
  "n_videos": n_videos,
219
  "n_scores": n_scores,
220
+ "stored_as": fname,
221
+ "status": "final",
222
  }
223
+ blob = json.dumps(payload, indent=2, ensure_ascii=False).encode("utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
 
225
+ try:
226
+ _api.upload_file(
227
+ repo_id=DATASET_REPO,
228
+ repo_type="dataset",
229
+ path_or_fileobj=BytesIO(blob),
230
+ path_in_repo=fname,
231
+ commit_message=(
232
+ f"{study}: submission from {email} ({n_scores} scores"
233
+ f"{' · admin' if is_admin else ''})"
234
+ ),
235
+ )
236
+ except HfHubHTTPError as e:
237
+ status = e.response.status_code if e.response is not None else 500
238
+ raise HTTPException(
239
+ status_code=502,
240
+ detail=f"failed to write to dataset ({status}): {e}",
241
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
242
 
243
+ return JSONResponse(
244
+ {
245
+ "ok": True,
246
+ "stored_as": fname,
247
+ "n_videos": n_videos,
248
+ "n_scores": n_scores,
249
+ "is_admin": is_admin,
250
+ }
251
+ )
252
 
253
+ @router.post("/save_progress")
254
+ async def save_progress(req: Request):
255
+ """Write a per-rater in-progress snapshot to the dataset.
256
+
257
+ Used in two situations:
258
+ - Right after registration: page POSTs an empty payload (no ratings
259
+ yet) so the rater appears in the in_progress folder from the
260
+ moment they sign in.
261
+ - Mid-study auto-save (debounced) + manual "Save progress" button:
262
+ the same single per-user file is overwritten with the latest
263
+ snapshot.
264
+
265
+ Unlike /submit, this does NOT require completeness — the payload may
266
+ have any subset of ratings (including none).
267
+ """
268
+ if _api is None:
269
+ raise HTTPException(500, "server missing HF_TOKEN secret")
270
+ try:
271
+ payload = await req.json()
272
+ except Exception as e:
273
+ raise HTTPException(400, f"invalid JSON: {e}")
274
+
275
+ name, email = _validate_rater(payload)
276
+ if normalize_legacy:
277
+ _normalize_legacy_models(payload)
278
+ cases = payload.get("cases") or []
279
+ n_scores = _count_scores(cases)
280
+ is_admin = _is_admin(name)
281
+
282
+ fname = f"{submissions_root}/in_progress/{_slug(email)}.json"
283
+ payload["is_admin"] = is_admin
284
+ payload["_server"] = {
285
+ "received_at_utc": dt.datetime.utcnow().isoformat() + "Z",
286
+ "client_ip": req.client.host if req.client else None,
287
+ "n_scores": n_scores,
288
+ "stored_as": fname,
289
+ "status": "in_progress",
290
+ }
291
+ blob = json.dumps(payload, indent=2, ensure_ascii=False).encode("utf-8")
292
+ try:
293
+ _api.upload_file(
294
+ repo_id=DATASET_REPO,
295
+ repo_type="dataset",
296
+ path_or_fileobj=BytesIO(blob),
297
+ path_in_repo=fname,
298
+ commit_message=(
299
+ f"{study}: in-progress save from {email} ({n_scores} scores"
300
+ f"{' · admin' if is_admin else ''})"
301
+ ),
302
+ )
303
+ except HfHubHTTPError as e:
304
+ status = e.response.status_code if e.response is not None else 500
305
+ raise HTTPException(
306
+ status_code=502,
307
+ detail=f"failed to write to dataset ({status}): {e}",
308
+ )
309
 
310
+ return JSONResponse({
311
+ "ok": True,
312
+ "stored_as": fname,
313
+ "n_scores": n_scores,
314
+ "is_admin": is_admin,
315
+ })
316
 
317
+ @router.post("/admin/login")
318
+ async def admin_login(req: Request):
319
+ try:
320
+ payload = await req.json()
321
+ except Exception:
322
+ raise HTTPException(400, "invalid JSON")
323
+ if (payload.get("password") or "").strip() != ADMIN_PASSWORD:
324
+ raise HTTPException(401, "wrong password")
325
+ return JSONResponse({"ok": True})
326
+
327
+ @router.get("/admin/submissions")
328
+ async def admin_submissions(req: Request):
329
+ """List every submission JSON in this study's folder with light
330
+ metadata. Admin-only (X-Admin-Password header)."""
331
+ _require_admin_password(req)
332
+ if _api is None:
333
+ return JSONResponse({"submissions": [], "reason": "no token"})
334
+ out = []
335
+ try:
336
+ tree_iter = _api.list_repo_tree(
337
+ repo_id=DATASET_REPO, repo_type="dataset",
338
+ path_in_repo=submissions_root, recursive=True,
339
+ )
340
+ tree_list = list(tree_iter)
341
+ except HfHubHTTPError as e:
342
+ # 404 = no submissions yet for this study — empty, not an error.
343
+ status = e.response.status_code if e.response is not None else 500
344
+ if status == 404:
345
+ return JSONResponse({"submissions": [], "total": 0})
346
+ raise HTTPException(502, f"failed to list dataset ({status}): {e}")
347
+ for tree in tree_list:
348
+ path = getattr(tree, "path", "")
349
+ if not path.endswith(".json"):
350
+ continue
351
+ record: dict = {"path": path}
352
+ try:
353
+ local = hf_hub_download(
354
+ repo_id=DATASET_REPO, repo_type="dataset",
355
+ filename=path, force_download=False,
356
+ )
357
+ with open(local, "r", encoding="utf-8") as f:
358
+ data = json.load(f)
359
+ rater = data.get("rater") or {}
360
+ srv = data.get("_server") or {}
361
+ cases = data.get("cases") or []
362
+ record.update({
363
+ "rater_name": rater.get("name") or "",
364
+ "rater_email": rater.get("email") or "",
365
+ "is_admin": bool(data.get("is_admin", False)),
366
+ "n_cases": len(cases),
367
+ "n_scores": srv.get("n_scores"),
368
+ "status": srv.get("status"),
369
+ "received_at_utc": srv.get("received_at_utc"),
370
+ "chosen_case_ids": rater.get("chosen_case_ids") or [],
371
+ })
372
+ except Exception as e:
373
+ record["error"] = str(e)[:120]
374
+ out.append(record)
375
+ # newest first
376
+ out.sort(key=lambda r: r.get("received_at_utc") or "", reverse=True)
377
+ return JSONResponse({"submissions": out, "total": len(out)})
378
+
379
+ @router.delete("/admin/submission")
380
+ async def admin_delete_submission(req: Request):
381
+ """Admin-only delete of one of this study's submission JSONs.
382
+
383
+ Body: {"path": "<submissions_root>/...json"} — must live under this
384
+ study's submissions folder and end with ".json" to prevent the
385
+ endpoint from deleting unrelated dataset files via path traversal.
386
+ """
387
+ _require_admin_password(req)
388
+ if _api is None:
389
+ raise HTTPException(500, "server missing HF_TOKEN secret")
390
+ try:
391
+ payload = await req.json()
392
+ except Exception:
393
+ raise HTTPException(400, "invalid JSON")
394
+ path = (payload.get("path") or "").strip()
395
+ if (not path.startswith(submissions_root + "/")
396
+ or not path.endswith(".json") or ".." in path):
397
+ raise HTTPException(
398
+ 400, f"path must be a {submissions_root}/*.json file")
399
+ try:
400
+ _api.delete_file(
401
+ repo_id=DATASET_REPO, repo_type="dataset",
402
+ path_in_repo=path,
403
+ commit_message=(
404
+ f"{study} admin: delete submission {os.path.basename(path)}"
405
+ ),
406
+ )
407
+ except HfHubHTTPError as e:
408
+ status = e.response.status_code if e.response is not None else 500
409
+ raise HTTPException(502, f"delete failed ({status}): {e}")
410
+ return JSONResponse({"ok": True, "deleted": path})
411
+
412
+ @router.get("/rater_state")
413
+ async def get_rater_state(email: str = ""):
414
+ """Return the rater's most recent in-progress JSON (if any).
415
+
416
+ Used by the page to recover a rater's locked case subset + prior
417
+ ratings when they land on a new device."""
418
+ email = (email or "").strip().lower()
419
+ if not email:
420
+ raise HTTPException(400, "email query param required")
421
+ slug = _slug(email)
422
+ if _api is None:
423
+ return JSONResponse({"found": False, "reason": "no token"})
424
  try:
425
  local = hf_hub_download(
426
  repo_id=DATASET_REPO, repo_type="dataset",
427
+ filename=f"{submissions_root}/in_progress/{slug}.json",
428
+ force_download=True,
429
  )
430
  with open(local, "r", encoding="utf-8") as f:
431
+ return JSONResponse({"found": True, "state": json.load(f)})
432
+ except HfHubHTTPError as e:
433
+ status = e.response.status_code if e.response is not None else 500
434
+ if status == 404:
435
+ return JSONResponse({"found": False})
436
+ return JSONResponse({"found": False, "reason": f"HTTP {status}"})
 
 
 
 
 
 
 
 
437
  except Exception as e:
438
+ return JSONResponse({"found": False, "reason": str(e)})
439
+
440
+ @router.get("/annotations")
441
+ async def get_annotations():
442
+ """Public read of admin-curated prompt highlights.
443
+
444
+ Shape: {"annotations": {case_id: {lang: [snippet, snippet, ...]}}}
445
+ """
446
+ data = _load_annotations()
447
+ return JSONResponse({"annotations": data.get("annotations", {})})
448
+
449
+ @router.post("/annotations")
450
+ async def post_annotations(req: Request):
451
+ """Admin-only: replace the highlight list for a (case_id, lang) pair.
452
+
453
+ Payload: {rater:{name,email}, case_id, lang, ranges: [string, ...]}
454
+ Server-enforces ADMIN_NAMES against the supplied rater.name.
455
+ """
456
+ if _api is None:
457
+ raise HTTPException(500, "server missing HF_TOKEN secret")
458
+ try:
459
+ payload = await req.json()
460
+ except Exception as e:
461
+ raise HTTPException(400, f"invalid JSON: {e}")
462
+ name, email = _validate_rater(payload)
463
+ if not _is_admin(name):
464
+ raise HTTPException(403, "admin only")
465
+ case_id = (payload.get("case_id") or "").strip()
466
+ lang = (payload.get("lang") or "en").strip()
467
+ ranges = payload.get("ranges") or []
468
+ if not case_id:
469
+ raise HTTPException(400, "case_id required")
470
+ if not isinstance(ranges, list) or not all(isinstance(r, str) for r in ranges):
471
+ raise HTTPException(400, "ranges must be a list of strings")
472
+ # Sanitise: drop empty / huge entries, dedupe in order.
473
+ cleaned = []
474
+ seen = set()
475
+ for r in ranges:
476
+ s = r.strip()
477
+ if not s or len(s) > 1024 or s in seen:
478
+ continue
479
+ seen.add(s); cleaned.append(s)
480
+
481
+ data = _load_annotations()
482
+ data.setdefault("annotations", {}).setdefault(case_id, {})[lang] = cleaned
483
+ data["_server"] = {
484
+ "last_edited_by": email,
485
+ "last_edited_name": name,
486
+ "last_edited_at_utc": dt.datetime.utcnow().isoformat() + "Z",
487
+ "case_id": case_id, "lang": lang,
488
+ }
489
+ blob = json.dumps(data, indent=2, ensure_ascii=False).encode("utf-8")
490
+ try:
491
+ _api.upload_file(
492
+ repo_id=DATASET_REPO, repo_type="dataset",
493
+ path_or_fileobj=BytesIO(blob),
494
+ path_in_repo=annotations_path,
495
+ commit_message=(
496
+ f"{study} annotations: {email} set {len(cleaned)} "
497
+ f"highlight(s) for {case_id} / {lang}"
498
+ ),
499
+ )
500
+ except HfHubHTTPError as e:
501
+ status = e.response.status_code if e.response is not None else 500
502
+ raise HTTPException(502, f"failed to write annotations ({status}): {e}")
503
+ return JSONResponse({
504
+ "ok": True, "case_id": case_id, "lang": lang,
505
+ "ranges": cleaned,
506
+ })
507
+
508
+ return router
509
+
510
+
511
+ # v1 the original study; paths and behavior identical to the old app.py.
512
+ app.include_router(build_study_router(
513
+ prefix="/api", study="v1",
514
+ submissions_root="submissions",
515
+ annotations_path="annotations.json",
516
+ normalize_legacy=True,
517
+ ))
518
+
519
+ # v2 the 40-runs baseline study; everything lives under v2/ in the dataset.
520
+ app.include_router(build_study_router(
521
+ prefix="/v2/api", study="v2",
522
+ submissions_root="v2/submissions",
523
+ annotations_path="v2/annotations.json",
524
+ normalize_legacy=False,
525
+ ))
526
+
527
+ # Static rater UIs live in ./static (v1 at /, v2 at /v2/). Mount last so the
528
+ # API routes take precedence.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  app.mount("/", StaticFiles(directory="static", html=True), name="static")
static/v2/admin.html ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Admin · FrameWorker user study v2</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
8
+ <style>
9
+ :root {
10
+ --bg: #0f1115; --panel: #161922; --panel-2: #1c2030; --panel-3: #232839;
11
+ --border: #2a2f42; --border-strong: #3a4060;
12
+ --fg: #e6e8ef; --muted: #8b91a8; --muted-2: #5e6480;
13
+ --accent: #6aa9ff; --good: #28c76f; --warn: #ff9f43; --bad: #ea5455;
14
+ }
15
+ * { box-sizing: border-box; }
16
+ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg);
17
+ font-family: -apple-system, "Inter", "PingFang SC", sans-serif;
18
+ font-size: 14px; line-height: 1.5; }
19
+
20
+ header { padding: 12px 24px; border-bottom: 1px solid var(--border);
21
+ background: var(--panel); display: flex; gap: 14px; align-items: center;
22
+ flex-wrap: wrap; }
23
+ header h1 { font-size: 16px; margin: 0; }
24
+ header .meta { color: var(--muted); font-size: 12px; }
25
+ header .right { margin-left: auto; display: flex; gap: 10px; align-items: center; }
26
+
27
+ main { padding: 18px 24px 80px; max-width: 1280px; margin: 0 auto; }
28
+
29
+ section { background: var(--panel); border: 1px solid var(--border);
30
+ border-radius: 8px; padding: 14px 16px; margin-bottom: 14px; }
31
+ h2 { font-size: 14px; margin: 0 0 10px; color: var(--accent); }
32
+ .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
33
+ .btn { background: var(--panel-3); border: 1px solid var(--border-strong);
34
+ color: var(--fg); padding: 6px 12px; border-radius: 4px; cursor: pointer;
35
+ font-family: inherit; font-size: 12px; }
36
+ .btn:hover { background: #2c3450; }
37
+ .btn.primary { background: var(--accent); color: #0a1a2a;
38
+ border-color: var(--accent); font-weight: 600; }
39
+ .btn.danger { background: var(--bad); color: #fff; border-color: var(--bad); }
40
+ .btn:disabled { background: var(--panel-3); color: var(--muted-2);
41
+ border-color: var(--border); cursor: not-allowed; }
42
+
43
+ /* login screen */
44
+ .login-wrap { display: flex; align-items: center; justify-content: center;
45
+ min-height: 70vh; }
46
+ .login-card { background: var(--panel); border: 1px solid var(--border-strong);
47
+ border-radius: 10px; padding: 22px 24px; max-width: 380px; width: 100%; }
48
+ .login-card h2 { margin: 0 0 6px; color: var(--accent); }
49
+ .login-card p { color: var(--muted); font-size: 13px; margin: 0 0 14px; }
50
+ .login-card label { font-size: 11px; color: var(--muted);
51
+ text-transform: uppercase; letter-spacing: 0.5px; }
52
+ .login-card input { width: 100%; background: var(--panel-3);
53
+ border: 1px solid var(--border); color: var(--fg); padding: 10px 12px;
54
+ border-radius: 5px; font-family: inherit; font-size: 16px; margin-top: 4px; }
55
+ .login-card .err { color: var(--bad); font-size: 12px; margin-top: 6px;
56
+ min-height: 16px; }
57
+ .login-card .login-btn { width: 100%; margin-top: 12px; padding: 10px;
58
+ font-size: 14px; font-weight: 700; }
59
+
60
+ /* table */
61
+ table { width: 100%; border-collapse: collapse; font-size: 12px; }
62
+ th, td { padding: 7px 10px; text-align: left; border-bottom: 1px solid var(--border); }
63
+ th { color: var(--muted); font-weight: 600; font-size: 11px;
64
+ text-transform: uppercase; letter-spacing: 0.5px;
65
+ background: var(--panel-2); position: sticky; top: 0; }
66
+ tr:hover td { background: var(--panel-2); }
67
+ td.path, td.email { font-family: ui-monospace, monospace; font-size: 11px;
68
+ color: var(--muted); word-break: break-all; }
69
+ td .badge { display: inline-block; padding: 2px 6px; border-radius: 8px;
70
+ font-size: 10px; font-weight: 700; }
71
+ td .badge.final { background: #1c3422; color: var(--good); }
72
+ td .badge.in_progress { background: #3a3520; color: var(--warn); }
73
+ td .badge.admin { background: #3a2f60; color: #d9b6ff; margin-left: 4px; }
74
+ .progress-bar { background: var(--panel-3); height: 4px; border-radius: 2px;
75
+ overflow: hidden; min-width: 80px; }
76
+ .progress-bar .fill { background: var(--accent); height: 100%; }
77
+
78
+ .filter-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
79
+ margin-bottom: 10px; }
80
+ .filter-row input { background: var(--panel-3); border: 1px solid var(--border);
81
+ color: var(--fg); padding: 5px 8px; border-radius: 4px; font-size: 12px;
82
+ font-family: inherit; min-width: 200px; }
83
+
84
+ /* charts */
85
+ #viz { background: var(--panel); border: 1px solid var(--border);
86
+ border-radius: 8px; padding: 14px 16px; margin-top: 14px; }
87
+ .q-block { background: var(--panel-2); border: 1px solid var(--border);
88
+ border-radius: 6px; padding: 10px 12px; margin: 8px 0; }
89
+ .q-block .q-title { font-weight: 600; font-size: 13px; }
90
+ .q-block .q-dim { color: var(--muted); font-size: 11px; }
91
+ canvas { max-height: 220px; }
92
+ .legend-dot { display: inline-block; width: 9px; height: 9px;
93
+ border-radius: 50%; margin-right: 4px; vertical-align: middle; }
94
+
95
+ .toast { position: fixed; bottom: 20px; right: 20px;
96
+ background: var(--good); color: #0a1a0a; padding: 10px 16px;
97
+ border-radius: 6px; font-weight: 600; opacity: 0;
98
+ transition: opacity 0.2s; pointer-events: none; z-index: 100; }
99
+ .toast.show { opacity: 1; }
100
+ .toast.error { background: var(--bad); color: #fff; }
101
+ </style>
102
+ </head>
103
+ <body>
104
+ <div id="app"></div>
105
+ <div class="toast" id="toast"></div>
106
+
107
+ <script>
108
+ const ADMIN_PW_KEY = "user_study_admin_pw_v1";
109
+
110
+ // Canonical rubric (must stay in sync with index.html DIMENSIONS).
111
+ const DIMENSIONS = [
112
+ { key: "instruction_following", label: "Instruction following",
113
+ questions: [
114
+ ["controllability", "Controllability"],
115
+ ["prompt_adherence", "Prompt adherence"],
116
+ ["no_irrelevant_frames", "No irrelevant frames"],
117
+ ] },
118
+ { key: "temporal_consistency", label: "Temporal consistency",
119
+ questions: [
120
+ ["person_stability", "Person stability"],
121
+ ["object_stability", "Object stability"],
122
+ ["environment_stability", "Environment stability"],
123
+ ] },
124
+ { key: "motion_realism", label: "Realism",
125
+ questions: [
126
+ ["realism", "Static realism"],
127
+ ["motion_logic", "Motion logic"],
128
+ ["camera", "Camera"],
129
+ ] },
130
+ ];
131
+ const ALL_QKEYS = DIMENSIONS.flatMap(d => d.questions.map(([k]) => k));
132
+
133
+ let SUBMISSIONS = []; // list from /api/admin/submissions
134
+ let SELECTED = new Set(); // paths the admin has ticked
135
+
136
+ function el(tag, attrs={}, ...children) {
137
+ const e = document.createElement(tag);
138
+ for (const [k,v] of Object.entries(attrs)) {
139
+ if (k === "class") e.className = v;
140
+ else if (k === "html") e.innerHTML = v;
141
+ else if (k.startsWith("on") && typeof v === "function")
142
+ e.addEventListener(k.slice(2).toLowerCase(), v);
143
+ else if (v !== false && v != null) e.setAttribute(k, v);
144
+ }
145
+ for (const c of children) {
146
+ if (c == null || c === false) continue;
147
+ e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
148
+ }
149
+ return e;
150
+ }
151
+ function showToast(msg, isErr=false) {
152
+ const t = document.getElementById("toast");
153
+ t.textContent = msg;
154
+ t.classList.toggle("error", !!isErr);
155
+ t.classList.add("show");
156
+ setTimeout(()=>t.classList.remove("show"), 1800);
157
+ }
158
+ function adminPw() { try { return sessionStorage.getItem(ADMIN_PW_KEY) || ""; } catch(e){ return ""; } }
159
+ function setAdminPw(pw) { try { sessionStorage.setItem(ADMIN_PW_KEY, pw); } catch(e){} }
160
+ function clearAdminPw() { try { sessionStorage.removeItem(ADMIN_PW_KEY); } catch(e){} }
161
+
162
+ /* ---------- login ---------- */
163
+ function renderLogin() {
164
+ const app = document.getElementById("app");
165
+ app.innerHTML = "";
166
+ app.appendChild(el("header", {},
167
+ el("h1", {}, "Admin · FrameWorker user study"),
168
+ el("span", { class: "meta" }, "password-gated"),
169
+ el("div", { class: "right" },
170
+ el("a", { href: "./", style: "color:var(--accent);font-size:12px" }, "← back to study"),
171
+ ),
172
+ ));
173
+ const wrap = el("div", { class: "login-wrap" });
174
+ const card = el("div", { class: "login-card" });
175
+ card.appendChild(el("h2", {}, "Admin login"));
176
+ card.appendChild(el("p", {}, "Enter the admin password to view all study submissions."));
177
+ card.appendChild(el("label", { "for": "pw" }, "Password"));
178
+ const input = el("input", {
179
+ id: "pw", type: "password", autocomplete: "current-password",
180
+ placeholder: "·····",
181
+ onkeydown: e => { if (e.key === "Enter") submitLogin(); },
182
+ });
183
+ card.appendChild(input);
184
+ const err = el("div", { class: "err", id: "loginErr" });
185
+ card.appendChild(err);
186
+ card.appendChild(el("button", {
187
+ class: "btn primary login-btn",
188
+ onclick: submitLogin,
189
+ }, "Sign in"));
190
+ wrap.appendChild(card);
191
+ app.appendChild(wrap);
192
+ setTimeout(() => input.focus(), 30);
193
+ }
194
+
195
+ async function submitLogin() {
196
+ const pw = document.getElementById("pw").value;
197
+ const err = document.getElementById("loginErr");
198
+ err.textContent = "";
199
+ if (!pw) { err.textContent = "Password required."; return; }
200
+ try {
201
+ const r = await fetch("./api/admin/login", {
202
+ method: "POST", headers: { "Content-Type": "application/json" },
203
+ body: JSON.stringify({ password: pw }),
204
+ });
205
+ if (!r.ok) { err.textContent = "Wrong password."; return; }
206
+ setAdminPw(pw);
207
+ renderDashboard();
208
+ fetchSubmissions();
209
+ } catch (e) {
210
+ err.textContent = "Login failed: " + e.message;
211
+ }
212
+ }
213
+
214
+ /* ---------- dashboard ---------- */
215
+ function renderDashboard() {
216
+ const app = document.getElementById("app");
217
+ app.innerHTML = "";
218
+ app.appendChild(el("header", {},
219
+ el("h1", {}, "Admin · FrameWorker user study"),
220
+ el("span", { class: "meta", id: "summaryMeta" }, "loading…"),
221
+ el("div", { class: "right" },
222
+ el("a", { href: "./stats.html", target: "_blank",
223
+ style: "color:var(--accent);font-size:12px;text-decoration:none" },
224
+ "📊 aggregate stats →"),
225
+ el("button", { class: "btn",
226
+ onclick: () => { clearAdminPw(); renderLogin(); } },
227
+ "Sign out"),
228
+ ),
229
+ ));
230
+ const main = el("main");
231
+ // submissions list
232
+ const sec1 = el("section");
233
+ sec1.appendChild(el("h2", {}, "Submissions"));
234
+ const filterRow = el("div", { class: "filter-row" });
235
+ filterRow.appendChild(el("input", {
236
+ type: "search", placeholder: "Filter by name / email…",
237
+ oninput: e => filterTable(e.target.value.toLowerCase()),
238
+ }));
239
+ filterRow.appendChild(el("button", {
240
+ class: "btn", onclick: () => toggleSelectAll(true) }, "Select all"));
241
+ filterRow.appendChild(el("button", {
242
+ class: "btn", onclick: () => toggleSelectAll(false) }, "Clear"));
243
+ filterRow.appendChild(el("button", {
244
+ class: "btn primary", id: "vizBtn", onclick: visualizeSelected,
245
+ disabled: "" }, "Visualize selected"));
246
+ filterRow.appendChild(el("button", {
247
+ class: "btn", onclick: () => fetchSubmissions() }, "↻ Refresh"));
248
+ sec1.appendChild(filterRow);
249
+
250
+ sec1.appendChild(el("div", { id: "tableWrap" }));
251
+ main.appendChild(sec1);
252
+
253
+ // visualisation slot
254
+ const sec2 = el("div", { id: "viz", style: "display:none" });
255
+ main.appendChild(sec2);
256
+
257
+ app.appendChild(main);
258
+ }
259
+
260
+ async function fetchSubmissions() {
261
+ try {
262
+ const r = await fetch("./api/admin/submissions", {
263
+ headers: { "X-Admin-Password": adminPw() },
264
+ });
265
+ if (r.status === 401) { clearAdminPw(); renderLogin(); return; }
266
+ if (!r.ok) {
267
+ showToast(`Fetch failed (HTTP ${r.status})`, true);
268
+ return;
269
+ }
270
+ const j = await r.json();
271
+ SUBMISSIONS = j.submissions || [];
272
+ SELECTED = new Set();
273
+ renderTable();
274
+ document.getElementById("summaryMeta").textContent =
275
+ `${SUBMISSIONS.length} submission${SUBMISSIONS.length===1?"":"s"}, ` +
276
+ `${new Set(SUBMISSIONS.map(s=>s.rater_email)).size} unique raters`;
277
+ } catch (e) {
278
+ showToast("Fetch failed: " + e.message, true);
279
+ }
280
+ }
281
+
282
+ function renderTable() {
283
+ const wrap = document.getElementById("tableWrap");
284
+ wrap.innerHTML = "";
285
+ const table = el("table");
286
+ const thead = el("thead");
287
+ thead.appendChild(el("tr", {},
288
+ el("th", { style: "width:32px" }, ""),
289
+ el("th", {}, "Rater"),
290
+ el("th", { class: "email" }, "Email"),
291
+ el("th", {}, "Status"),
292
+ el("th", {}, "Scores"),
293
+ el("th", {}, "Cases"),
294
+ el("th", {}, "Received (UTC)"),
295
+ el("th", {}, "Action"),
296
+ el("th", { class: "path" }, "Path"),
297
+ ));
298
+ table.appendChild(thead);
299
+ const tbody = el("tbody", { id: "tbody" });
300
+ for (const s of SUBMISSIONS) {
301
+ tbody.appendChild(renderRow(s));
302
+ }
303
+ table.appendChild(tbody);
304
+ wrap.appendChild(table);
305
+ updateVizBtn();
306
+ }
307
+
308
+ function renderRow(s) {
309
+ const tr = el("tr", { "data-path": s.path });
310
+ const cb = el("input", { type: "checkbox" });
311
+ cb.addEventListener("change", () => {
312
+ if (cb.checked) SELECTED.add(s.path); else SELECTED.delete(s.path);
313
+ updateVizBtn();
314
+ });
315
+ if (SELECTED.has(s.path)) cb.checked = true;
316
+ tr.appendChild(el("td", {}, cb));
317
+ tr.appendChild(el("td", {},
318
+ s.rater_name || "(unnamed)",
319
+ s.is_admin ? el("span", { class: "badge admin" }, "admin") : null,
320
+ ));
321
+ tr.appendChild(el("td", { class: "email" }, s.rater_email || "—"));
322
+ const status = s.status || (s.path.includes("in_progress") ? "in_progress" : "final");
323
+ tr.appendChild(el("td", {},
324
+ el("span", { class: "badge " + status }, status)));
325
+ tr.appendChild(el("td", {}, String(s.n_scores ?? "—")));
326
+ tr.appendChild(el("td", {}, String(s.n_cases ?? "—")));
327
+ tr.appendChild(el("td", {},
328
+ (s.received_at_utc || "—").replace("T"," ").replace("Z","")));
329
+ // Row actions: view-as + delete
330
+ const actionCell = el("td", { style: "white-space:nowrap" });
331
+ if (s.rater_email) {
332
+ actionCell.appendChild(el("button", {
333
+ class: "btn",
334
+ style: "padding:3px 8px;margin-right:4px",
335
+ title: "open the main study page with this user's data preloaded",
336
+ onclick: () => viewAs(s),
337
+ }, "👁 View"));
338
+ }
339
+ actionCell.appendChild(el("button", {
340
+ class: "btn danger",
341
+ style: "padding:3px 8px;font-size:11px",
342
+ title: "permanently delete this submission JSON from the dataset",
343
+ onclick: () => deleteSubmission(s),
344
+ }, "🗑"));
345
+ tr.appendChild(actionCell);
346
+ // Path → link to dataset blob view
347
+ const pathLink = el("a", {
348
+ href: `https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/blob/main/${s.path}`,
349
+ target: "_blank", rel: "noopener",
350
+ style: "color:var(--muted);text-decoration:none",
351
+ title: "open raw JSON on the dataset",
352
+ }, s.path);
353
+ tr.appendChild(el("td", { class: "path" }, pathLink));
354
+ return tr;
355
+ }
356
+
357
+ function filterTable(needle) {
358
+ for (const tr of document.querySelectorAll("#tbody tr")) {
359
+ const txt = tr.textContent.toLowerCase();
360
+ tr.style.display = txt.includes(needle) ? "" : "none";
361
+ }
362
+ }
363
+ function toggleSelectAll(on) {
364
+ SELECTED = new Set(on ? SUBMISSIONS.map(s=>s.path) : []);
365
+ renderTable();
366
+ }
367
+ function updateVizBtn() {
368
+ const btn = document.getElementById("vizBtn");
369
+ if (!btn) return;
370
+ btn.textContent = `Visualize selected (${SELECTED.size})`;
371
+ btn.disabled = SELECTED.size === 0;
372
+ }
373
+
374
+ /* ---------- delete a submission ---------- */
375
+ async function deleteSubmission(s) {
376
+ const who = s.rater_name || s.rater_email || "this submission";
377
+ if (!confirm(`Permanently delete ${s.path}?\n\nRater: ${who}\nScores: ${s.n_scores ?? "?"}\n\nThis cannot be undone.`)) return;
378
+ try {
379
+ const r = await fetch("./api/admin/submission", {
380
+ method: "DELETE",
381
+ headers: { "Content-Type": "application/json", "X-Admin-Password": adminPw() },
382
+ body: JSON.stringify({ path: s.path }),
383
+ });
384
+ if (r.status === 401) { clearAdminPw(); renderLogin(); return; }
385
+ if (!r.ok) {
386
+ const text = await r.text().catch(() => "");
387
+ showToast(`Delete failed (HTTP ${r.status}): ${text.slice(0, 80)}`, true);
388
+ return;
389
+ }
390
+ showToast(`Deleted ${who}`);
391
+ // Remove from local list + selection, re-render
392
+ SUBMISSIONS = SUBMISSIONS.filter(x => x.path !== s.path);
393
+ SELECTED.delete(s.path);
394
+ renderTable();
395
+ document.getElementById("summaryMeta").textContent =
396
+ `${SUBMISSIONS.length} submission${SUBMISSIONS.length===1?"":"s"}, ` +
397
+ `${new Set(SUBMISSIONS.map(x=>x.rater_email)).size} unique raters`;
398
+ } catch (e) {
399
+ showToast("Delete failed: " + e.message, true);
400
+ }
401
+ }
402
+
403
+ /* ---------- view-as: open main UI populated with this user's submission ---- */
404
+ async function viewAs(submissionMeta) {
405
+ try {
406
+ const url = `https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/${submissionMeta.path}`;
407
+ const r = await fetch(url, { cache: "no-cache" });
408
+ if (!r.ok) { showToast(`Fetch failed: HTTP ${r.status}`, true); return; }
409
+ const payload = await r.json();
410
+ // Persist for the main page to pick up — sessionStorage so it doesn't
411
+ // contaminate localStorage long-term, and sits in this tab/window only.
412
+ sessionStorage.setItem("user_study_view_as", JSON.stringify({
413
+ opened_at: new Date().toISOString(),
414
+ meta: submissionMeta,
415
+ payload,
416
+ }));
417
+ window.open("./?view_as=1", "_blank");
418
+ showToast(`Opening main UI as ${submissionMeta.rater_name || submissionMeta.rater_email}`);
419
+ } catch (e) {
420
+ showToast("View-as failed: " + e.message, true);
421
+ }
422
+ }
423
+
424
+ /* ---------- visualization ---------- */
425
+ async function visualizeSelected() {
426
+ if (SELECTED.size === 0) return;
427
+ const viz = document.getElementById("viz");
428
+ viz.style.display = "block";
429
+ viz.innerHTML = "<h2>Loading selected submissions…</h2>";
430
+
431
+ const submissions = [];
432
+ for (const path of SELECTED) {
433
+ try {
434
+ const url = `https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/${path}`;
435
+ const r = await fetch(url, { cache: "no-cache" });
436
+ if (r.ok) submissions.push(await r.json());
437
+ } catch (e) { /* skip */ }
438
+ }
439
+ renderViz(submissions);
440
+ }
441
+
442
+ function renderViz(payloads) {
443
+ // bag: (model, qkey) -> scores
444
+ const bag = {};
445
+ for (const p of payloads) {
446
+ for (const c of (p.cases || [])) {
447
+ for (const v of (c.videos || [])) {
448
+ const m = v.model || "unknown";
449
+ for (const [qk, score] of Object.entries(v.ratings || {})) {
450
+ if (typeof score === "number" && score >= 1 && score <= 5) {
451
+ (bag[`${m}|${qk}`] ||= []).push(score);
452
+ }
453
+ }
454
+ }
455
+ }
456
+ }
457
+ const models = Array.from(new Set(Object.keys(bag).map(k => k.split("|")[0])));
458
+ models.sort((a,b) => (a === "ours" ? -1 : b === "ours" ? 1 : a.localeCompare(b)));
459
+ const COLORS = ["#6aa9ff","#ff9f43","#28c76f","#ea5455","#b46aff"];
460
+ const mcolor = Object.fromEntries(models.map((m,i) => [m, COLORS[i % COLORS.length]]));
461
+
462
+ const viz = document.getElementById("viz");
463
+ viz.innerHTML = "";
464
+ viz.appendChild(el("h2", {},
465
+ `Aggregate over ${payloads.length} selected submission(s)`));
466
+
467
+ // Per dimension
468
+ const dimDiv = el("section", { style: "background:transparent;border:none;padding:0" });
469
+ dimDiv.appendChild(el("h2", {}, "Per dimension"));
470
+ const dimCanvas = el("canvas");
471
+ dimDiv.appendChild(dimCanvas);
472
+ viz.appendChild(dimDiv);
473
+
474
+ function mean(arr){ return arr.length ? arr.reduce((a,b)=>a+b,0)/arr.length : null; }
475
+ function statsFor(qkeys, m){
476
+ const all = [];
477
+ for (const qk of qkeys) all.push(...(bag[`${m}|${qk}`] || []));
478
+ return all;
479
+ }
480
+ const dimLabels = DIMENSIONS.map(d => d.label);
481
+ const dimData = models.map(m => ({
482
+ label: m,
483
+ data: DIMENSIONS.map(d => mean(statsFor(d.questions.map(q=>q[0]), m))),
484
+ backgroundColor: mcolor[m] + "cc",
485
+ borderColor: mcolor[m], borderWidth: 1,
486
+ }));
487
+ new Chart(dimCanvas, {
488
+ type: "bar",
489
+ data: { labels: dimLabels, datasets: dimData },
490
+ options: {
491
+ scales: {
492
+ y: { min: 0, max: 5, ticks: { color: "#8b91a8" }, grid: { color: "#2a2f42" } },
493
+ x: { ticks: { color: "#e6e8ef" }, grid: { display: false } },
494
+ },
495
+ plugins: { legend: { labels: { color: "#e6e8ef" } } },
496
+ },
497
+ });
498
+
499
+ // Per question
500
+ viz.appendChild(el("h2", { style: "margin-top:14px" }, "Per sub-question"));
501
+ for (const d of DIMENSIONS) {
502
+ for (const [qk, qlabel] of d.questions) {
503
+ const block = el("div", { class: "q-block" });
504
+ const total = models.reduce((n,m) => n + (bag[`${m}|${qk}`]||[]).length, 0);
505
+ block.appendChild(el("div", { class: "q-title" }, qlabel));
506
+ block.appendChild(el("div", { class: "q-dim" }, d.label + (total ? ` · n=${total}` : " · (no data)")));
507
+ if (total) {
508
+ const c = el("canvas");
509
+ block.appendChild(c);
510
+ const datasets = models.map(m => ({
511
+ label: m,
512
+ data: [mean(bag[`${m}|${qk}`] || [])],
513
+ backgroundColor: mcolor[m] + "cc",
514
+ borderColor: mcolor[m], borderWidth: 1,
515
+ }));
516
+ new Chart(c, {
517
+ type: "bar",
518
+ data: { labels: [qlabel], datasets },
519
+ options: {
520
+ indexAxis: "y", scales: {
521
+ x: { min: 0, max: 5, ticks: { color: "#8b91a8" }, grid: { color: "#2a2f42" } },
522
+ y: { ticks: { display: false }, grid: { display: false } },
523
+ },
524
+ plugins: { legend: { labels: { color: "#e6e8ef" } } },
525
+ },
526
+ });
527
+ }
528
+ viz.appendChild(block);
529
+ }
530
+ }
531
+ viz.scrollIntoView({ behavior: "smooth", block: "start" });
532
+ }
533
+
534
+ /* ---------- boot ---------- */
535
+ (function init(){
536
+ if (adminPw()) {
537
+ // Probe a cheap endpoint to validate the cached password.
538
+ fetch("./api/admin/submissions", {
539
+ headers: { "X-Admin-Password": adminPw() },
540
+ }).then(r => {
541
+ if (r.status === 401) { clearAdminPw(); renderLogin(); }
542
+ else { renderDashboard(); fetchSubmissions(); }
543
+ }).catch(() => renderLogin());
544
+ } else {
545
+ renderLogin();
546
+ }
547
+ })();
548
+ </script>
549
+ </body>
550
+ </html>
static/v2/cases.json ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "AU01",
4
+ "category": "au",
5
+ "prompt": "Night rain over a ruined sect courtyard. Wei Anluo, a lame-legged youth branded \"spirit-dead,\" kneels in mud beside a frozen well, dragged there to be expelled. Above him looms Xie Rulong, a golden-robed prodigy who snaps Wei's cracked jade token and, with a contemptuous flick of his palm, unleashes a silent shockwave — Wei is hurled through a stone wall, dust and shattered brick blooming outward, one sleeve torn, a single defiant tear cutting his grimed cheek. He does not scream. As frost creeps up his fingers, an old sealing-mark on his wrist splinters; buried frost- qi he was never told he carried erupts in a spiral of white spirit-light, freezing the falling rain mid-air into glittering needles. Xie Rulong staggers back, golden robe rimed silver. The \"cripple\" rises.",
6
+ "prompt_i18n": {
7
+ "zh": "夜雨落在残破的宗门庭院。魏安洛,一个被烙上“灵脉已死”之名的跛足少年,跪在冰封水井旁的泥泞里——他是被拖来当众逐出宗门的。金袍天才谢如龙居高临下,折断魏安洛开裂的玉牌,轻蔑地一挥掌,掀起一道无声的冲击波——魏安洛被轰穿石墙,尘土与碎砖向外炸开,一只衣袖撕裂,一滴倔强的泪划过他满是污泥的脸颊。他没有喊叫。当寒霜爬上他的手指,他腕上一道古老的封印裂开;他从不知晓自己身负的冰霜之气以白色灵光的螺旋喷薄而出,将坠落的雨水冻结在半空,化作闪亮的冰针。谢如龙踉跄后退,金袍覆上一层银霜。那个“废人”站了起来。",
8
+ "bg": "Нощен дъжд над разрушен двор на секта. Уей Анлуо, куц младеж, заклеймен като „мъртъв по дух“, коленичи в калта до замръзнал кладенец, довлечен там, за да бъде прогонен. Над него се извисява Сие Жулун, гений в златни одежди, който прекършва напукания нефритен знак на Уей и с презрителен замах на дланта отприщва безмълвна ударна вълна — Уей е запратен през каменна стена, прах и натрошени тухли се разлитат, единият му ръкав е разкъсан, а една-единствена непокорна сълза прорязва изцапаната му буза. Той не изкрещява. Докато скрежът пълзи по пръстите му, старият запечатващ знак на китката му се пропуква; скритата ледена ци, за която никога не са му казвали, изригва в спирала от бяла духовна светлина, замразявайки падащия дъжд във въздуха в блестящи игли. Сие Жулун залита назад, златната му роба е посребрена от скреж. „Сакатият“ се изправя."
9
+ },
10
+ "videos": [
11
+ {
12
+ "model": "ours",
13
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU01_ours.mp4"
14
+ },
15
+ {
16
+ "model": "movieagent",
17
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU01_movieagent.mp4"
18
+ },
19
+ {
20
+ "model": "anim_director",
21
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU01_anim_director.mp4"
22
+ }
23
+ ]
24
+ },
25
+ {
26
+ "id": "AU02",
27
+ "category": "au",
28
+ "prompt": "On a snow-locked stone bridge at dawn, aging swordsman Han Muxue kneels, his blade snapped at the hilt — publicly disgraced, stripped of his sect, blamed for a killing he did not commit. Across the bridge strides Yan Zhuo, a masked duelist who has come to finish him, sleeve rippling like a black banner. Behind Han crouches a trembling child, Ah-Lu, the true witness Yan must silence. Han rises with only the broken hilt, bowing once — accepting death to shield the child. Blades meet as flashes of white wind and scattering plum petals; a shockwave cracks the frozen railing. Dust clears: Han stands, hilt raised, Yan's mask fallen — revealing Yan wept, unable to strike a man who chose the child over his own name.",
29
+ "prompt_i18n": {
30
+ "zh": "黎明时分,冰雪封锁的石桥上,年迈剑客韩慕雪跪在地上,长剑齐柄折断——他当众受辱,被逐出师门,背负一桩并非他所犯的命案。桥对面,蒙面决斗者燕灼大步走来,要来了结他,衣袖如黑色旗帜般翻卷。韩慕雪身后蜷缩着一个发抖的孩子,阿禄——燕灼必须灭口的真正目击者。韩慕雪只握着断剑柄站起身,躬身一礼——他接受死亡,只为护住孩子。剑刃相接,白风闪烁、梅瓣纷飞;一道冲击波震裂了冰封的桥栏。尘埃散尽:韩慕雪立着,断柄高举,燕灼的面具落地——原来燕灼已然落泪,无法对一个把孩子看得比自己名誉更重的人挥剑。",
31
+ "bg": "На заключен от снега каменен мост призори застаряващият мечоносец Хан Мусюе е коленичил, острието му е прекършено до дръжката — публично опозорен, изгонен от сектата си, обвинен в убийство, което не е извършил. През моста крачи Йен Джуо, маскиран дуелист, дошъл да го довърши, ръкавът му се вее като черно знаме. Зад Хан се свива треперещо дете, А-Лу — истинският свидетел, когото Йен трябва да накара да замълчи. Хан се изправя само със счупената дръжка и се покланя веднъж — приема смъртта, за да защити детето. Остриетата се срещат сред проблясъци на бял вятър и разпиляни сливови цветчета; ударна вълна пропуква замръзналия парапет. Прахът се разсейва: Хан стои прав с вдигната дръжка, маската на Йен е паднала — разкривайки, че Йен е плакал, неспособен да посече човек, избрал детето пред собственото си име."
32
+ },
33
+ "videos": [
34
+ {
35
+ "model": "ours",
36
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU02_ours.mp4"
37
+ },
38
+ {
39
+ "model": "movieagent",
40
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU02_movieagent.mp4"
41
+ },
42
+ {
43
+ "model": "anim_director",
44
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU02_anim_director.mp4"
45
+ }
46
+ ]
47
+ },
48
+ {
49
+ "id": "AU03",
50
+ "category": "au",
51
+ "prompt": "Rain hammers a glass tower lobby. Lin Tang, a soaked delivery rider, is shoved back by security as icy heiress Fang Rulan tears up his order and flings the box, dismissing him as \"gutter trash\" before a smirking crowd. Head bowed, dripping, he kneels to gather scattered receipts — and one flutters face-up: a faded hospital bill, her mother's name, a signature that stops her cold. Wei slowly lifts his eyes; the same signature is stitched inside his worn jacket. He is the anonymous donor who saved her mother years ago. The lobby freezes. Fang's arrogance collapses into trembling recognition; the crowd's phones lower. Wei simply retrieves his helmet, bows once, and walks into the storm — leaving her small, silent, undone beneath the lights.",
52
+ "prompt_i18n": {
53
+ "zh": "暴雨击打着玻璃大厦的大堂。浑身湿透的外卖骑手林塘被保安推搡出去,冷艳的千金方如兰撕碎他的订单、把餐盒摔在地上,当着一群窃笑的围观者骂他是“阴沟里的垃圾”。他低着头,滴着水,跪下去捡散落的票据——其中一张正面朝上飘落:一张褪色的医院账单,上面是她母亲的名字,和一个让她浑身僵住的签名。魏缓缓抬起眼;同样的签名就缝在他破旧外套的内衬里。他就是多年前救了她母亲的匿名捐助者。大堂瞬间凝固。方如兰的傲慢崩塌成颤抖的错愕;围观者的手机纷纷放下。魏只是拾起头盔,鞠了一躬,走进暴雨——留下她在灯光下显得渺小、沉默、无地自容。",
54
+ "bg": "Дъжд блъска по фоайето на стъклена кула. Лин Тан, прогизнал куриер, е избутан от охраната, докато ледената наследница Фан Жулан къса поръчката му и запраща кутията, наричайки го „боклук от канавката“ пред подхилваща се тълпа. С наведена глава, капейки, той коленичи да събере разпилените касови бележки — и една пада с лицето нагоре: избеляла болнична сметка, името на майка ѝ и подпис, който я вцепенява. Уей бавно вдига очи; същият подпис е пришит от вътрешната страна на износеното му яке. Той е анонимният дарител, спасил майка ѝ преди години. Фоайето замръзва. Арогантността на Фан рухва в треперещо разпознаване; телефоните на тълпата се снижават. Уей прос��о взема каската си, покланя се веднъж и излиза в бурята — оставяйки я малка, безмълвна и съкрушена под светлините."
55
+ },
56
+ "videos": [
57
+ {
58
+ "model": "ours",
59
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU03_ours.mp4"
60
+ },
61
+ {
62
+ "model": "movieagent",
63
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU03_movieagent.mp4"
64
+ },
65
+ {
66
+ "model": "anim_director",
67
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU03_anim_director.mp4"
68
+ }
69
+ ]
70
+ },
71
+ {
72
+ "id": "AU04",
73
+ "category": "au",
74
+ "prompt": "Aboard the crippled colony freighter Meridian, junior engineer Wen Solari is the only crew awake as the reactor overloads. A red countdown burns across the bridge: ninety seconds to a chain detonation that will vaporize four hundred sleeping evacuees. Captain Idris Vale, unconscious in his pod, cannot help. Wen races to the coolant airlock, but the only override is manual — someone must stay outside the shield to hold the release. She hesitates, tears trembling in zero-g, then clamps her tether and steps into the vacuum. As the countdown hits zero, she wrenches the lever; a silent shockwave floods the hull with cold blue light, the reactor stills, the escape pods launch safe. Her cracked visor drifts free — but on it, one final smile, and a message uploading: \"Tell them I wasn't useless.\"",
75
+ "prompt_i18n": {
76
+ "zh": "残破的殖民货船“子午线号”上,反应堆过载时,初级工程师温·索拉里是唯一清醒的船员。舰桥上燃烧着红色倒计时:九十秒后,连锁爆炸将汽化四百名沉睡的撤离者。船长伊德里斯·韦尔在休眠舱中昏迷,无能为力。温冲向冷却剂气闸,但唯一的超控装置是手动的——必须有人留在护盾之外按住释放杆。她犹豫了,泪珠在零重力中颤动,随后扣紧系绳,踏入真空。倒计时归零的一刻,她猛地扳下拉杆;一道无声的冲击波让冷蓝的光淹没船体,反应堆平息,逃生舱安全弹射。她碎裂的面罩飘离——上面是最后一个微笑,和一条正在上传的讯息:“告诉他们,我不是没用的人。”",
77
+ "bg": "На борда на осакатения колонизаторски товарен кораб „Меридиан“ младшият инженер Уен Солари е единственият буден член на екипажа, когато реакторът се претоварва. Червено обратно броене гори на мостика: деветдесет секунди до верижна детонация, която ще изпари четиристотин спящи евакуирани. Капитан Идрис Вейл, в безсъзнание в капсулата си, не може да помогне. Уен се втурва към шлюза за охладителя, но единственото аварийно превключване е ръчно — някой трябва да остане извън щита и да държи лоста. Тя се колебае, сълзите ѝ треперят в безтегловност, после закопчава осигурителното си въже и пристъпва във вакуума. Когато броенето стига нула, тя дръпва лоста; безмълвна ударна вълна залива корпуса със студена синя светлина, реакторът утихва, спасителните капсули се изстрелват в безопасност. Пукнатият ѝ визьор се отнася настрани — но върху него грее последна усмивка и качващо се съобщение: „Кажете им, че не бях безполезна.“"
78
+ },
79
+ "videos": [
80
+ {
81
+ "model": "ours",
82
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU04_ours.mp4"
83
+ },
84
+ {
85
+ "model": "movieagent",
86
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU04_movieagent.mp4"
87
+ },
88
+ {
89
+ "model": "anim_director",
90
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU04_anim_director.mp4"
91
+ }
92
+ ]
93
+ },
94
+ {
95
+ "id": "AU05",
96
+ "category": "au",
97
+ "prompt": "Tiny baker Pip carries a wobbling three-tier birthday cake across a bustling kitchen, tongue out in concentration. A sneeze from cat Marmalade launches a cascade: a rolling lemon trips a stool, the stool flings a whisk, the whisk splats flour into a fan that blows a snowstorm across the room. Pip juggles, spins, slides on butter, and saves the cake mid-flip with a triumphant pose atop a teetering ladder. Then he freezes—the ladder tips. He turns his own body into a cushion, letting the cake land safely on his belly as HE crashes into dough, buried, defeated. Silence. Slowly a small hand pushes the untouched cake up through the mess toward gruff old baker Bruno—whose stern frown melts into astonished, teary pride. Impact stars, warm golden light.",
98
+ "prompt_i18n": {
99
+ "zh": "小小烘焙师皮普端着一个摇摇欲坠的三层生日蛋糕穿过繁忙的厨房,舌尖抵着嘴角全神贯注。猫咪玛玛蕾德一个喷嚏引发连环灾难:滚动的柠檬绊倒凳子,凳子弹飞打蛋器,打蛋器把面粉拍进风扇,风扇把一场“暴风雪”吹遍整个厨房。皮普又抛又转,在黄油上滑行,在蛋糕翻飞的瞬间接住它,还在摇晃的梯子顶端摆出胜利姿势。随后他僵住了——梯子倾倒。他把自己的身体变成软垫,让蛋糕安全落在他的肚子上,而他自己砸进面团里,被埋住,狼狈不堪。一片寂静。慢慢地,一只小手把完好无损的蛋糕从狼藉中托起,递向严厉的老烘焙师布鲁诺——他紧绷的眉头融化成又惊又喜、含着泪的骄傲。冲击星星特效,温暖的金色光芒。",
100
+ "bg": "Мъничкият пекар Пип носи клатеща се триетажна торта за рожден ден през оживена кухня, изплезил език от съсредоточение. Кихавица на котарака Мармалад отприщва каскада: търкалящ се лимон спъва столче, столчето изхвърля телена бъркалка, бъркалката запраща брашно във вентилатор, който издухва снежна буря из стаята. Пип жонглира, върти се, пързаля се по масло и спасява тортата в движение, заемайки триумфална поза върху клатеща се стълба. После замръзва — стълбата се накланя. Той превръща собственото си тяло във възглавница, оставяйки тортата да се приземи безопасно върху корема му, докато САМИЯТ той се стоварва в тестото, затрупан, победен. Тишина. Бавно една малка ръка избутва непокътнатата торта нагоре през хаоса към навъсения стар пекар Бруно — чието строго смръщване се разтапя в изумена, просълзена гордост. Звездички от удара, топла златиста светлина."
101
+ },
102
+ "videos": [
103
+ {
104
+ "model": "ours",
105
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU05_ours.mp4"
106
+ },
107
+ {
108
+ "model": "movieagent",
109
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU05_movieagent.mp4"
110
+ },
111
+ {
112
+ "model": "anim_director",
113
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU05_anim_director.mp4"
114
+ }
115
+ ]
116
+ },
117
+ {
118
+ "id": "AU06",
119
+ "category": "au",
120
+ "prompt": "Every dusk for three winters, old Maren climbs the spiral stairs of a snowbound lighthouse and lights the lamp for her son Eirik, a fisherman lost at sea. Villagers whisper she's mad; the boy won't return. Tonight her hands shake, the oil is nearly gone, and a storm claws the windows. She lights the wick anyway, then collapses on the cold stone, whispering his name one final time as the flame gutters. Snow sifts through a broken pane. Suddenly a distant foghorn answers through the blizzard — a battered boat scrapes the rocks below. The door bursts open: Eirik, gaunt and frost-bitten, alive after years adrift. He kneels, pressing her frail hand to his cheek. The lamp steadies. Wordless, they weep in its trembling amber glow.",
121
+ "prompt_i18n": {
122
+ "zh": "整整三个冬天,每到黄昏,老玛伦都会爬上被雪封住的灯塔的旋转楼梯,为她的儿子埃里克点亮灯——那是一个在海上失踪的渔夫。村民们窃窃私语说她疯了,说孩子不会回来了。今晚她双手颤抖,灯油即将耗尽,风暴抓挠着窗户。她还是点燃了灯芯,随后瘫倒在冰冷的石地上,在火焰摇曳将熄时最后一次轻唤他的名字。雪从破裂的窗格间飘落。突然,一声遥远的雾号穿透暴风雪回应了她——一艘伤痕累累的船擦着下方的礁石靠岸。门被撞开:埃里克,憔悴而带着冻伤,在漂流多年后活着回来了。他跪下,把她枯瘦的手贴在自己脸颊上。灯焰稳定下来。无言中,两人在颤动的琥珀色光芒里落泪���",
123
+ "bg": "Всеки здрач в продължение на три зими старата Марен изкачва витата стълба на затрупан от сняг фар и пали лампата за сина си Ейрик, рибар, изгубен в морето. Селяните шушукат, че е луда; момчето нямало да се върне. Тази вечер ръцете ѝ треперят, маслото е почти свършило, а буря дращи по прозорците. Тя все пак запалва фитила, после се свлича върху студения камък, прошепвайки името му за последен път, докато пламъкът мъждука. Сняг се сипе през счупено стъкло. Внезапно далечна мъглива сирена отговаря през виелицата — очукана лодка застъргва по скалите долу. Вратата се разтваря с трясък: Ейрик, изпит и измръзнал, жив след години в открито море. Той коленичи и притиска крехката ѝ ръка към бузата си. Пламъкът се успокоява. Без думи двамата плачат в трептящото му кехлибарено сияние."
124
+ },
125
+ "videos": [
126
+ {
127
+ "model": "ours",
128
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU06_ours.mp4"
129
+ },
130
+ {
131
+ "model": "movieagent",
132
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU06_movieagent.mp4"
133
+ },
134
+ {
135
+ "model": "anim_director",
136
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU06_anim_director.mp4"
137
+ }
138
+ ]
139
+ },
140
+ {
141
+ "id": "AU07",
142
+ "category": "au",
143
+ "prompt": "On a windswept cliff, a broken stone bridge arcs over a bottomless chasm toward a floating temple where the dying Ashwood Flame — the only cure for a plague — still burns. Kaelis, a young map-thief with a trembling torch, reaches the bridge's end; the far half has crumbled to nothing. A cloaked Ferryman rises from the mist and offers a bargain: the flame for a memory of the one she loves. She refuses, backing away — then the stones beneath her shatter. Falling, she flings her satchel of maps into the void as an offering instead. The Ferryman freezes, astonished by a gift given, not traded, and the mist itself hardens into a glowing bridge. Kaelis lands, sprints, and cups the Flame — as behind her the chasm seals shut forever.",
144
+ "prompt_i18n": {
145
+ "zh": "狂风呼啸的悬崖上,一座断裂的石桥横跨无底深渊,通向一座漂浮的神庙——治愈瘟疫的唯一解药“灰木圣焰”仍在那里燃烧。年轻的地图窃贼凯莉丝举着颤抖的火把走到桥的尽头;桥的另一半已崩塌殆尽。一位披斗篷的摆渡人从雾中升起,开出交易:用她所爱之人的一段记忆换取圣焰。她拒绝了,向后退去——脚下的石块轰然碎裂。坠落之际,她把装满地图的挎包抛入深渊,作为献礼而非交易。摆渡人怔住了,为这份“给予而非交换”的礼物而震动,雾气自行凝结成一座发光的桥。凯莉丝落地、狂奔,双手捧起圣焰——身后深渊永远地闭合了。",
146
+ "bg": "На брулена от вятъра скала счупен каменен мост се извива над бездънна пропаст към плаващ храм, където все още гори гаснещият Пепеляков пламък — единственият лек срещу чума. Каелис, млада крадла на карти с трепереща факла, стига до края на моста; отсрещната половина се е сринала в нищото. Загърнат в плащ Лодкар се надига от мъглата и предлага сделка: пламъкът срещу спомен за човека, когото тя обича. Тя отказва и отстъпва назад — тогава камъните под нея се разтрошават. Падайки, тя запраща чантата си с карти в бездната като дар вместо размяна. Лодкарят застива, изумен от подарък, даден, а не разменен, и самата мъгла се втвърдява в светещ мост. Каелис се приземява, спринтира и обгръща Пламъка с длани — докато зад нея пропастта се затваря завинаги."
147
+ },
148
+ "videos": [
149
+ {
150
+ "model": "ours",
151
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU07_ours.mp4"
152
+ },
153
+ {
154
+ "model": "movieagent",
155
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU07_movieagent.mp4"
156
+ },
157
+ {
158
+ "model": "anim_director",
159
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU07_anim_director.mp4"
160
+ }
161
+ ]
162
+ },
163
+ {
164
+ "id": "AU08",
165
+ "category": "au",
166
+ "prompt": "Rain lashes a shuttered museum at 2 a.m. Mira, the lone night guard, hears footsteps where the halls should be empty. Penlight trembling, she follows wet prints between marble statues to a sealed gallery. Inside, a young hooded thief, Cole, freezes mid-theft, a gilded portrait half-cut from its frame. As Mira's beam rises, the painted woman's eyes slowly turn — wet with real tears — and fix on them both. Cole drops his tools and bolts into the dark. Mira steps closer, breath fogging the cold glass; the weeping portrait lifts a painted hand, pointing to a loose floorboard. Mira pries it up: a small locked box, and a photograph of the same woman holding a child who looks exactly like Mira.",
167
+ "prompt_i18n": {
168
+ "zh": "凌晨两点,暴雨抽打着闭馆的博物馆。独自值夜的警卫米拉听见了本应空无一人的走廊里传来脚步声。她举着颤抖的手电,循着湿脚印穿过大理石雕像,来到一间封闭的展厅。厅内,一个戴兜帽的年轻窃贼科尔僵在行窃途中,一幅镀金画像已被从画框上割下一半。当米拉的光束抬起,画中女子的眼睛缓缓转动——噙着真实的泪水——直直望向他们两人。科尔丢下工具逃进黑暗。米拉走近,呼吸在冰冷的玻璃上凝成雾气;流泪的画像抬起一只画中的手,指向一块松动的地板。米拉撬开它:一个上锁的小盒子,和一张照片——照片上正是画中女子,怀里抱着一个和米拉长得一模一样的孩子。",
169
+ "bg": "Дъжд шиба затворен музей в 2 през нощта. Мира, самотната нощна охранителка, чува стъпки там, където коридорите би трябвало да са празни. С треперещо фенерче тя проследява мокри отпечатъци между мраморни статуи до запечатана галерия. Вътре млад крадец с качулка, Коул, замръзва насред кражбата — позлатен портрет е наполовина изрязан от рамката си. Когато лъчът на Мира се вдига, очите на нарисуваната жена бавно се обръщат — мокри от истински сълзи — и се впиват в двамата. Коул зарязва инструментите си и хуква в мрака. Мира пристъпва по-близо, дъхът ѝ замъглява студеното стъкло; плачещият портрет вдига нарисувана ръка и посочва разхлабена дъска на пода. Мира я повдига: малка заключена кутия и снимка на същата жена, прегърнала дете, което изглежда точно като Мира."
170
+ },
171
+ "videos": [
172
+ {
173
+ "model": "ours",
174
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU08_ours.mp4"
175
+ },
176
+ {
177
+ "model": "movieagent",
178
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU08_movieagent.mp4"
179
+ },
180
+ {
181
+ "model": "anim_director",
182
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU08_anim_director.mp4"
183
+ }
184
+ ]
185
+ },
186
+ {
187
+ "id": "AU09",
188
+ "category": "au",
189
+ "prompt": "A besieged mountain town, deep winter, ink-and-color period animation. Old bell-master YAN LI works his foundry alone; the invaders will come at dawn and the town's only signal to the fleeing villagers below is the great temple bell — but its bronze is cracked, dead, silent. His apprentice MEI sobs that there is no metal left to recast it. Gu Li looks at his own hands, then at the family heirloom hairpin and the war-medals in his palm — a lifetime melted into the crucible. Molten gold pours; sparks storm; he casts the town's last hope from everything he owns. At dawn, wind and dust — Mei strikes the reforged bell. A pure, world-shaking toll rolls down the valley. Below, tiny lantern-figures turn and run to safety. Gu Li smiles, spent, as snow buries the cold empty forge.",
190
+ "prompt_i18n": {
191
+ "zh": "一座被围困的山城,深冬,水墨设色的年代动画。老铸钟师严离独自在铸坊劳作;侵略者将在黎明来袭,而山下逃难的村民唯一的信号是寺庙大钟——但钟身青铜已裂,喑哑无声。学徒小��哭诉再没有金属可以重铸。顾离看着自己的双手,又看向掌心的传家发簪和战功勋章——一生的珍藏都熔进了坩埚。金液倾泻,火花如暴雨;他用自己拥有的一切铸出全城最后的希望。黎明,风沙漫卷——小梅撞响重铸的大钟。一声纯净、撼动天地的钟鸣滚下山谷。山下,细小的灯笼人影纷纷转身奔向安全之地。顾离微笑着,力竭,任大雪掩埋冰冷空寂的铸坊。",
192
+ "bg": "Обсаден планински град, дълбока зима, анимация в стил туш и цвят. Старият майстор на камбани Йен Ли работи сам в леярната си; нашествениците ще дойдат на разсъмване, а единственият сигнал на града към бягащите селяни долу е голямата храмова камбана — но бронзът ѝ е спукан, мъртъв, безмълвен. Чиракът Мей ридае, че не е останал метал за претопяване. Гу Ли поглежда собствените си ръце, после семейната фиба-реликва и бойните медали в дланта си — цял живот, стопен в тигела. Разтопено злато се лее; искри бушуват; той отлива последната надежда на града от всичко, което притежава. На разсъмване — вятър и прах — Мей удря прекованата камбана. Чист, разтърсващ света звън се търкулва надолу по долината. Долу мънички фигурки с фенери се обръщат и хукват към безопасността. Гу Ли се усмихва, изчерпан, докато снегът затрупва студената пуста леярна."
193
+ },
194
+ "videos": [
195
+ {
196
+ "model": "ours",
197
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU09_ours.mp4"
198
+ },
199
+ {
200
+ "model": "movieagent",
201
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU09_movieagent.mp4"
202
+ },
203
+ {
204
+ "model": "anim_director",
205
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU09_anim_director.mp4"
206
+ }
207
+ ]
208
+ },
209
+ {
210
+ "id": "AU10",
211
+ "category": "au",
212
+ "prompt": "After three centuries adrift, the vast seed-ship Auriga finally nears Kepler's Cradle, a luminous blue world its long-dead founders only dreamed of. Nova, a young navigator born in transit who has never touched soil, guides the ship toward the last hazard: a glittering ring of ancient debris closing across their path. Alarms bloom red across the bridge. Nova's hands fly over the helm, threading the mountain-sized ship through spinning wreckage as starlight strobes the windows. A final jagged shard looms dead ahead; she rolls Auriga hard on its axis and it slips past by mere meters. The debris falls away behind. Ahead, the blue world swells to fill the viewport, its oceans catching a new sun. Behind her, thousands of sleep-pods hiss open at last. Nova presses a trembling palm to the glass, tears rising, as the first of her people wake to a sky none of them have ever seen.",
213
+ "prompt_i18n": {
214
+ "zh": "漂泊三个世纪之后,巨大的种子船“御夫座号”终于接近“开普勒摇篮”——一颗它早已逝去的创建者们只在梦中见过的湛蓝星球。诺娃,一位生于航程中、从未踏上过土地的年轻领航员,引导飞船穿越最后的险关:一圈闪烁的远古残骸正合拢在航路上。警报的红光在舰桥上绽开。诺娃的双手在操纵台上翻飞,驾驶山岳般巨大的飞船穿行于旋转的残骸之间,星光在舷窗上频闪。最后一块锋利的碎片迎面逼来;她让“御夫座号”沿轴线猛然翻滚,以数米之差擦身而过。残骸带向后退去。前方,蓝色星球涨满舷窗,海洋映着一轮新的太阳。她身后,数千个休眠舱终于嘶鸣开启。诺娃颤抖的手掌贴上舷窗,热泪涌起——她的同胞正第一次醒来,望见一片他们从未见过的天空。",
215
+ "bg": "След три века лутане огромният кораб-семе „Аурига“ най-сетне наближава Люлката на Кеплер, сияен син свят, за който отдавна мъртвите му основатели само са мечтали. Нова, млада навигаторка, родена по време на полета и никога не докосвала земя, води кораба през последната опасност: блестящ пръстен от древни отломки, затварящ се пред курса им. Аларми разцъфват в червено по мостика. Ръцете на Нова летят по щурвала, промушвайки кораба с размерите на планина през въртящи се останки, докато звездната светлина примигва по прозорците. Последен назъбен къс се изправя точно отпред; тя завърта „Аурига“ рязко около оста ѝ и корабът се разминава с него на броени метри. Отломките остават назад. Отпред синият свят изпълва илюминатора, океаните му улавят ново слънце. Зад нея хиляди капсули за сън най-сетне се отварят със съскане. Нова притиска трепереща длан към стъклото, сълзите напират, докато първите от нейните хора се събуждат под небе, което никой от тях никога не е виждал."
216
+ },
217
+ "videos": [
218
+ {
219
+ "model": "ours",
220
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU10_ours.mp4"
221
+ },
222
+ {
223
+ "model": "movieagent",
224
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU10_movieagent.mp4"
225
+ },
226
+ {
227
+ "model": "anim_director",
228
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/AU10_anim_director.mp4"
229
+ }
230
+ ]
231
+ },
232
+ {
233
+ "id": "MV_DespicableMe4",
234
+ "category": "mv",
235
+ "prompt": "Neon-lit rooftop at night. Borin, a bald secret agent in a striped scarf, corners the escaped rogue Vex, whose cybernetic cockroach-suit hisses and clacks. Vex lunges; Borin dodges and fires a freeze-ray that ices one of Vex's mechanical legs. Below, a squad of small round helper-drones whirs up a drainpipe and yanks a cable that topples Vex's escape pod. Nadia swoops in on a glowing hover-board to snatch a stolen data-chip, but a drone tangles her rotor and sends her spinning. As Vex and Nadia tumble into a deployed net, Borin straightens his scarf, smirks, and gives the beeping drones a proud thumbs-up.",
236
+ "prompt_i18n": {
237
+ "zh": "霓虹闪烁的夜间天台。博林,一个围条纹围巾的光头特工,将逃脱的恶徒维克斯逼入绝境——对方的机械蟑螂战衣嘶嘶作响、咔咔作声。维克斯猛扑过来;博林闪身躲开,发射冷冻射线冻住了维克斯的一条机械腿。下方,一队圆滚滚的小型助手无人机嗡嗡地爬上排水管,拽动缆绳掀翻了维克斯的逃生舱。娜迪亚踩着发光的悬浮滑板俯冲而来,要夺走被盗的数据芯片,却被一架无人机缠住旋翼,打着转跌落。当维克斯和娜迪亚双双跌进展开的大网,博林理了理围巾,得意一笑,对着哔哔叫的无人机们竖起骄傲的大拇指。",
238
+ "bg": "Огрян от неон покрив през нощта. Борин, плешив таен агент с раиран шал, притиска в ъгъла избягалия злодей Векс, чийто кибернетичен костюм-хлебарка съска и трака. Векс се хвърля; Борин отскача и стреля със замразяващ лъч, който сковава в лед един от механичните крака на Векс. Долу отряд малки кръгли дронове-помощници избръмчава нагоре по водосточна тръба и дръпва кабел, който преобръща спасителната капсула на Векс. Надя се спуска на светещ ховърборд, за да грабне откраднат чип с данни, но един дрон заплита ротора ѝ и я запраща във въртене. Докато Векс и Надя се строполяват в разгъната мрежа, Борин оправя шала си, ухилва се и вдига гордо палец към пиукащите дронове."
239
+ },
240
+ "videos": [
241
+ {
242
+ "model": "ours",
243
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/MV_DespicableMe4_ours.mp4"
244
+ },
245
+ {
246
+ "model": "movieagent",
247
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/MV_DespicableMe4_movieagent.mp4"
248
+ },
249
+ {
250
+ "model": "anim_director",
251
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/MV_DespicableMe4_anim_director.mp4"
252
+ }
253
+ ]
254
+ },
255
+ {
256
+ "id": "MV_FrozenII",
257
+ "category": "mv",
258
+ "prompt": "Storm-lashed shore at night, waves like black mountains. Sereth, a young storm-caller in a tattered cloak, drawn by a haunting voice only she can hear, steps to the edge of the churning dark sea — a towering wave slams her back onto the rocks. She rises, blue lightning crackling from her hands, and charges into the surf again. A colossal serpent of living water erupts from the waves, fighting her; Sereth grapples with it, then binds a shimmering bridle of light across its neck and swings onto its back. She rides the serpent across the roaring sea, hair and cloak streaming, toward a distant lighthouse glowing with ancient memory. Reaching it, Sereth presses her trembling palm to the weathered stone — and the voice resolves at last into an old lullaby as forgotten memories bloom to life around her.",
259
+ "prompt_i18n": {
260
+ "zh": "风暴肆虐的夜岸,海浪如黑色群山。塞蕾丝,一位身披破旧斗篷的年轻风暴召唤者,被一个只有她能听见的萦绕之声吸引,走向翻涌的黑暗海边——一道巨浪将她狠狠拍回礁石。她站起身,双手迸发蓝色闪电,再次冲入浪涛。一条巨大的活水巨蟒从浪中喷涌而出与她缠斗;塞蕾丝与它搏斗,随后将一条微光缰辔缚上它的颈项,翻身跃上蛇背。她骑着水蟒横越咆哮的大海,长发与斗篷猎猎飞扬,驶向远方一座闪烁着古老记忆之光的灯塔。抵达后,塞蕾丝将颤抖的手掌贴上风化的石壁——那个声音终于化作一支古老的摇篮曲,被遗忘的记忆在她周围纷纷苏醒绽放。",
261
+ "bg": "Брулен от буря бряг през нощта, вълни като черни планини. Серет, млада призователка на бури в окъсано наметало, привлечена от натрапчив глас, който само тя чува, пристъпва към ръба на кипящото тъмно море — извисяваща се вълна я запраща обратно върху скалите. Тя се изправя, сини мълнии пращят от ръцете ѝ, и отново се хвърля в прибоя. Колосална змия от жива вода изригва от вълните и се бори с нея; Серет се вкопчва в змията, после стяга блещукаща юзда от светлина около шията ѝ и се мята на гърба ѝ. Тя язди змията през ревящото море, косата и наметалото ѝ се веят, към далечен фар, светещ с древна памет. Стигайки го, Серет притиска треперещата си длан към изветрелия камък — и гласът най-сетне се превръща в стара приспивна песен, докато забравени спомени разцъфват за живот около нея."
262
+ },
263
+ "videos": [
264
+ {
265
+ "model": "ours",
266
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/MV_FrozenII_ours.mp4"
267
+ },
268
+ {
269
+ "model": "movieagent",
270
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/MV_FrozenII_movieagent.mp4"
271
+ },
272
+ {
273
+ "model": "anim_director",
274
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/MV_FrozenII_anim_director.mp4"
275
+ }
276
+ ]
277
+ },
278
+ {
279
+ "id": "MV_InsideOut2",
280
+ "category": "mv",
281
+ "prompt": "A packed arena under bright lights. Mira, a young hockey player, sits trembling in the penalty box, panic rising, her heart hammering and breath ragged as the roaring crowd blurs around her. Her team captain skates over, crouches to her level, taps her helmet, and talks her through one slow, steadying breath. Warmth returns to Mira's face. The penalty clock hits zero; she surges back onto the ice, weaving through defenders, and fires the puck into the net. The crowd erupts as Mira raises her stick, finally calm and triumphant.",
282
+ "prompt_i18n": {
283
+ "zh": "灯火通明、座无虚席的冰球馆。年轻冰球选手米拉坐在受罚席上瑟瑟发抖,恐慌不断上涌,心脏狂跳、呼吸急促,轰鸣的人群在她眼前模糊成一片。队长滑过来,蹲到与她平视的高度,敲敲她的头盔,引导她缓缓做了一次深长的呼吸。暖意回到米拉脸上。罚时钟归零;她重新冲上冰面,连续穿过防守队员,将冰球射入球网。全场沸腾,米拉高举球杆,终于平静而胜利。",
284
+ "bg": "Претъпкана арена под ярки светлини. Мира, млада хокеистка, седи трепереща в наказателното поле, паниката се надига, сърцето ѝ блъска, дъхът ѝ е накъсан, а ревящата тълпа се размива около нея. Капитанът на отбора се плъзга към нея, ��нишава се до нейното ниво, почуква каската ѝ и я превежда през едно бавно, успокояващо вдишване. Топлината се връща на лицето на Мира. Наказателният часовник стига нула; тя се втурва обратно на леда, лъкатуши между защитниците и запраща шайбата в мрежата. Тълпата избухва, а Мира вдига стика си — най-сетне спокойна и триумфираща."
285
+ },
286
+ "videos": [
287
+ {
288
+ "model": "ours",
289
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/MV_InsideOut2_ours.mp4"
290
+ },
291
+ {
292
+ "model": "movieagent",
293
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/MV_InsideOut2_movieagent.mp4"
294
+ },
295
+ {
296
+ "model": "anim_director",
297
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/MV_InsideOut2_anim_director.mp4"
298
+ }
299
+ ]
300
+ },
301
+ {
302
+ "id": "MV_NeZha2",
303
+ "category": "mv",
304
+ "prompt": "Chentang Pass under siege at dusk, storm clouds churning. Nezha, a fierce child-warrior with flaming red sashes and the Wind-Fire Wheels roaring beneath his feet, whirls his blazing spear against a tide of sea-demons led by the sinister Shenggongbao. Aobing, the jade-armored dragon prince, fights at his side, conjuring shields of blue ice. A demon's blow hurls Aobing back and his body begins to crack like porcelain. Nezha screams, unleashing a spiral of golden fire that scatters the demons, and lunges to catch Aobing before he shatters. Cradling his crumbling friend amid the burning ramparts, Nezha's eyes harden with resolve — he will find a way to save him.",
305
+ "prompt_i18n": {
306
+ "zh": "黄昏下被围攻的陈塘关,风暴云翻滚。哪吒——束着烈焰红绫、脚踏轰鸣风火轮的凶悍孩童战士——挥舞燃烧的长枪,迎战阴险的申公豹率领的海妖大军。玉甲龙太子敖丙在他身侧作战,召唤出蓝色寒冰之盾。一记妖魔的重击将敖丙击飞,他的身体开始像瓷器般龟裂。哪吒嘶吼着释放出一道金色火焰的螺旋,击散群妖,扑身接住即将碎裂的敖丙。在燃烧的城垛间抱着支离破碎的挚友,哪吒的眼神变得坚毅——他一定会找到救他的办法。",
307
+ "bg": "Проходът Чънтан под обсада по здрач, буреносни облаци се кълбят. Неджа, свиреп воин-дете с пламтящи червени ленти и ревящи Огнени колела под краката, върти пламтящото си копие срещу вълна от морски демони, водени от зловещия Шънгунбао. Аобин, драконовият принц в нефритена броня, се бие до него, призовавайки щитове от син лед. Удар на демон запраща Аобин назад и тялото му започва да се пука като порцелан. Неджа изкрещява, отприщвайки спирала от златен огън, която разпръсква демоните, и се хвърля да улови Аобин, преди да се разпадне. Прегърнал разпадащия се свой приятел сред горящите укрепления, погледът на Неджа се изпълва с решимост — той ще намери начин да го спаси."
308
+ },
309
+ "videos": [
310
+ {
311
+ "model": "ours",
312
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/MV_NeZha2_ours.mp4"
313
+ },
314
+ {
315
+ "model": "movieagent",
316
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/MV_NeZha2_movieagent.mp4"
317
+ },
318
+ {
319
+ "model": "anim_director",
320
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/MV_NeZha2_anim_director.mp4"
321
+ }
322
+ ]
323
+ },
324
+ {
325
+ "id": "MV_NovelStory_2",
326
+ "category": "mv",
327
+ "prompt": "A mist-veiled mountain gorge, ink-wash peaks fading into cloud. Sun Wukong, the Monkey King in golden armor, clutches a glowing celestial artifact as ErLang, the three-eyed celestial warrior, descends on a beam of white light with his three-pointed halberd leveled. They clash mid-air — staff against blade — shockwaves flattening the bamboo grove below. Nearby the pig-demon Bajie cowers behind a boulder, clutching his rake, torn between fleeing and helping. Wukong somersaults over ErLang's strike, plants his staff, and vaults toward a cliffside temple. ErLang's celestial hound lunges to intercept; Bajie, yelping, finally swings his rake and trips it, buying Wukong a heartbeat. Wukong glances back, a wry grin flashing — the chase is far from over.",
328
+ "prompt_i18n": {
329
+ "zh": "云雾缭绕的山峡,水墨般的峰峦隐入云端。身披金甲的美猴王孙悟空紧握一件发光的天界宝物,三眼天将二郎神踏着白光而降,三尖两刃刀直指向他。两人在半空相撞——金箍棒对刀刃——冲击波掀平了下方的竹林。不远处,猪妖八戒抱着钉耙缩在巨石后,在逃跑与相助之间左右为难。悟空一个筋斗翻过二郎神的劈砍,拄棒借力,跃向崖边的一座山寺。二郎神的哮天犬猛扑拦截;八戒尖叫着终于抡起钉耙将它绊倒,为悟空争得一瞬。悟空回头一瞥,闪过一丝狡黠的笑——这场追逐远未结束。",
330
+ "bg": "Забулен в мъгла планински пролом, върхове като туш, чезнещи в облаците. Сун Укун, Маймунският крал в златна броня, стиска светещ небесен артефакт, докато Ерлан, триокият небесен воин, се спуска по лъч бяла светлина с насочена тризъба алебарда. Те се сблъскват във въздуха — тояга срещу острие — ударни вълни сплескват бамбуковата горичка долу. Наблизо прасето-демон Баджие се свива зад канара, стиснал греблото си, разкъсван между бягство и помощ. Укун прави салто над удара на Ерлан, забива тоягата си и се изстрелва към храм на ръба на скалата. Небесната хрътка на Ерлан се хвърля да го пресрещне; Баджие, скимтейки, най-сетне замахва с греблото и я спъва, печелейки на Укун един миг. Укун поглежда назад с проблясваща дяволита усмивка — гонитбата далеч не е приключила."
331
+ },
332
+ "videos": [
333
+ {
334
+ "model": "ours",
335
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/MV_NovelStory_2_ours.mp4"
336
+ },
337
+ {
338
+ "model": "movieagent",
339
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/MV_NovelStory_2_movieagent.mp4"
340
+ },
341
+ {
342
+ "model": "anim_director",
343
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/MV_NovelStory_2_anim_director.mp4"
344
+ }
345
+ ]
346
+ },
347
+ {
348
+ "id": "TS170",
349
+ "category": "ts",
350
+ "prompt": "Once upon a time, there was a little boy named Tim and a little girl named Lily. They were best friends. They liked to play together and share their toys. One day, they wanted to make a big picture for their mommies. They needed scissors to cut the paper. Tim had some scissors, but Lily did not. Tim said, \"Lily, we can share my scissors. It is easy to use them.\" Lily was happy that Tim wanted to share. They took turns using the scissors to cut the paper. They were very careful because scissors can be sharp. When they finished cutting the paper, they made a beautiful picture. They showed their mommies and said, \"Look, we made this together! We shared the scissors and it was easy.\" Their mommies were very proud of them for sharing and working together.",
351
+ "prompt_i18n": {
352
+ "zh": "从前,有一个小男孩叫蒂姆,还有一个小女孩叫莉莉。他们是最好的朋友。他们喜欢一起玩耍、分享玩具。有一天,他们想给各自的妈妈做一幅大大的画。他们需要剪刀来剪纸。蒂姆有剪刀,莉莉却没有。蒂姆说:“莉莉,我们可以共用我的剪刀,用起来很容易。”莉莉很开心蒂姆愿意分享。他们轮流用剪刀剪纸,非常小心,因为剪刀可能很锋利。剪完纸后,他们做出了一幅漂亮的画。他们拿给妈妈们看,说:“看,这是我们一起做的!我们共用了剪刀,很容易哦。”妈妈们为他们的分享与合作感到非常骄傲。",
353
+ "bg": "Имало едно време едно момченце на име Тим и едно момиченце на име Лили. Те били най-добри приятели. Обичали да играят заедно и да си споделят играчките. Един ден поискали да направят голяма картина за своите майки. Трябвали им ножици, за да режат хартията. Тим имал ножици, но Лили нямала. Тим казал: „Лили, можем да си поделим моите ножици. Лесно е да се използват.“ Лили се зарадвала, че Тим иска да споделя. Те се ��едували да режат хартията с ножиците. Били много внимателни, защото ножиците могат да са остри. Когато свършили с рязането, направили красива картина. Показали я на майките си и казали: „Вижте, направихме това заедно! Поделихме си ножиците и беше лесно.“ Майките им много се гордеели с тях, задето споделяли и работили заедно."
354
+ },
355
+ "videos": [
356
+ {
357
+ "model": "ours",
358
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/TS170_ours.mp4"
359
+ },
360
+ {
361
+ "model": "movieagent",
362
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/TS170_movieagent.mp4"
363
+ },
364
+ {
365
+ "model": "anim_director",
366
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/TS170_anim_director.mp4"
367
+ }
368
+ ]
369
+ },
370
+ {
371
+ "id": "TS199",
372
+ "category": "ts",
373
+ "prompt": "One day, a cat and a dog were playing in the park. The cat liked to tease the dog. The cat said, \"I am faster than you!\" The dog had an opinion too. The dog said, \"No, I am faster!\" They decided to have a race to see who was faster. The cat and dog ran as fast as they could. They were both very fast, but the cat was winning. Suddenly, a bitter wind blew and a big ball came rolling down the hill. The cat and the dog stopped running and looked at the ball. The cat and the dog decided to play with the ball instead of racing. They had lots of fun playing together. In the end, they both agreed that it was more fun to play together than to tease each other.",
374
+ "prompt_i18n": {
375
+ "zh": "有一天,一只猫和一只狗在公园里玩。猫喜欢逗狗。猫说:“我跑得比你快!”狗也有自己的看法。狗说:“不,我更快!”他们决定赛跑,看看谁更快。猫和狗拼命地跑。他们都非常快,但猫渐渐领先。突然,一阵凛冽的风吹过,一个大球从山坡上滚了下来。猫和狗停下脚步,看着那个球。他们决定不比赛了,改玩球。他们一起玩得很开心。最后,他们都同意:一起玩耍比互相逗弄有趣多了。",
376
+ "bg": "Един ден котка и куче си играели в парка. Котката обичала да дразни кучето. Котката казала: „Аз съм по-бърза от теб!“ Кучето също имало мнение. Кучето казало: „Не, аз съм по-бърз!“ Решили да се надбягват, за да видят кой е по-бърз. Котката и кучето тичали колкото им държат краката. И двамата били много бързи, но котката печелела. Изведнъж задухал леден вятър и голяма топка се затъркаляла надолу по хълма. Котката и кучето спрели да тичат и погледнали топката. Решили да играят с топката вместо да се състезават. Забавлявали се чудесно, играейки заедно. Накрая и двамата се съгласили, че е по-забавно да играят заедно, отколкото да се дразнят."
377
+ },
378
+ "videos": [
379
+ {
380
+ "model": "ours",
381
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/TS199_ours.mp4"
382
+ },
383
+ {
384
+ "model": "movieagent",
385
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/TS199_movieagent.mp4"
386
+ },
387
+ {
388
+ "model": "anim_director",
389
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/TS199_anim_director.mp4"
390
+ }
391
+ ]
392
+ },
393
+ {
394
+ "id": "TS217",
395
+ "category": "ts",
396
+ "prompt": "Once upon a time, there was a musician who played a big white drum. He lived in a small house near the park. Every day, he would go to the park and play his drum for the kids. One sunny day, the musician was playing his drum in the park when he saw a little girl on a swing. She was swinging very high. The girl said, \"Look at me, Mr. Musician! I can swing so high!\" The musician smiled and kept playing his drum. Suddenly, the girl lost her grip on the swing and began to fall. The musician saw this and quickly stopped playing his drum. He ran to catch the girl before she hit the ground. The girl was safe in the musician's arms. \"Thank you, Mr. Musician,\" she said. The musician smiled and said, \"You're welcome. Be careful next time.\" From that day on, the little girl and the musician became good friends, and they played in the park every day.",
397
+ "prompt_i18n": {
398
+ "zh": "从前,有一位乐手,他敲一面白色的大鼓。他住在公园附近的一座小房子里。每天,他都会去公园为孩子们打鼓。一个晴朗的日子,乐手正在公园里打鼓,看见一个小女孩在荡秋千。她荡得很高。女孩说:“看我呀,乐手先生!我能荡得好高!”乐手笑了笑,继续打鼓。突然,女孩没抓住秋千,开始往下掉。乐手见状立刻停下鼓,跑过去在女孩落地之前接住了她。女孩安全地落在乐手怀里。“谢谢您,乐手先生。”她说。乐手微笑着说:“不客气,下次要小心哦。”从那天起,小女孩和乐手成了好朋友,他们每天都在公园里一起玩。",
399
+ "bg": "Имало едно време един музикант, който свирел на голям бял барабан. Живеел в малка къща близо до парка. Всеки ден ходел в парка и свирел на барабана си за децата. Един слънчев ден музикантът свирел в парка, когато видял момиченце на люлка. То се люлеело много високо. Момиченцето казало: „Вижте ме, господин Музикант! Мога да се люлея толкова високо!“ Музикантът се усмихнал и продължил да свири. Изведнъж момиченцето изпуснало люлката и започнало да пада. Музикантът видял това и бързо спрял да свири. Затичал се да хване момиченцето, преди да се удари в земята. Момиченцето било в безопасност в ръцете на музиканта. „Благодаря ви, господин Музикант“, казало то. Музикантът се усмихнал и казал: „Моля, следващия път бъди по-внимателна.“ От този ден нататък момиченцето и музикантът станали добри приятели и играели в парка всеки ден."
400
+ },
401
+ "videos": [
402
+ {
403
+ "model": "ours",
404
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/TS217_ours.mp4"
405
+ },
406
+ {
407
+ "model": "movieagent",
408
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/TS217_movieagent.mp4"
409
+ },
410
+ {
411
+ "model": "anim_director",
412
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/TS217_anim_director.mp4"
413
+ }
414
+ ]
415
+ },
416
+ {
417
+ "id": "TS32",
418
+ "category": "ts",
419
+ "prompt": "Once upon a time there was a very brilliant girl named Sofia. Sofia was only three years old but she was already very smart! One day, Sofia was sitting in her bedroom looking out of the window when all of a sudden, a man wearing a huge hat appeared. He was holding something in his hand. He knocked on the door and said to Sofia, \"I'm here to deliver your diary\". Sofia was very excited and smiled. She opened the door and the man handed her the diary. Sofia thanked the man and he smiled. \"You're very welcome,\" he said. And then he left with a wave. Sofia ran back to her bedroom with her diary. She was so happy and couldn't wait to learn what was in it! As she opened it, she was sure that it was going to be brilliant. And she was right - the diary was filled with amazing stories and knowledge!",
420
+ "prompt_i18n": {
421
+ "zh": "从前有一个非常聪明的女孩,名叫索菲亚。索菲亚只有三岁,却已经很聪明了!有一天,索菲亚坐在卧室里望着窗外,忽然出现了一个戴着大帽子的男人。他手里拿着什么东西。他敲了敲门,对索菲亚说:“我是来送你的日记本的。”索菲亚非常兴奋,笑了起来。她打开门,男人把日记本递给她。索菲亚向他道谢,男人笑了。“不客气,”他说。然后他挥挥手离开了。索菲亚拿着日记本跑回卧室。她太开心了,迫不及待想知道里面写了什么!翻开的时候,她确信里面一定精彩极了。她猜得没错——日记本里满是奇妙的故事和知识!",
422
+ "bg": "Имало едно време едно много будно момиче на име София. София била само на три години, но вече била много умна! Един ден София седяла в спалнята си и гледала през прозореца, когато изведнъж се появил мъж с огромна шапка. Държал нещо в ръката си. Той почукал на вратата и казал на София: „Тук съм, за да доставя дневника ти.“ София много се развълн��вала и се усмихнала. Отворила вратата и мъжът ѝ подал дневника. София му благодарила, а той се усмихнал. „За нищо“, казал той. И си тръгнал с махване на ръка. София изтичала обратно в спалнята си с дневника. Била толкова щастлива и нямала търпение да научи какво има в него! Отваряйки го, била сигурна, че ще бъде великолепен. И била права — дневникът бил пълен с удивителни истории и знания!"
423
+ },
424
+ "videos": [
425
+ {
426
+ "model": "ours",
427
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/TS32_ours.mp4"
428
+ },
429
+ {
430
+ "model": "movieagent",
431
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/TS32_movieagent.mp4"
432
+ },
433
+ {
434
+ "model": "anim_director",
435
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/TS32_anim_director.mp4"
436
+ }
437
+ ]
438
+ },
439
+ {
440
+ "id": "TS63",
441
+ "category": "ts",
442
+ "prompt": "Once upon a time, a little bird lived in a small tree. The little bird loved to play with his friend, the squirrel. They liked to play with twigs. One day, the little bird was upset. He did not know where his friend was. The bird looked all around for his friend. He saw a big twig on the ground. The bird thought, \"Maybe my friend is hiding behind the twig.\" The bird flew down to look. But his friend was not there. The bird was more upset now. Just then, the squirrel jumped out from a bush. He had a big smile on his face. He said, \"I was hiding to surprise you!\" The bird was happy to see his friend. They played with the big twig together. The little bird learned that sometimes, when things seem bad, a happy surprise might be just around the corner.",
443
+ "prompt_i18n": {
444
+ "zh": "从前,一只小鸟住在一棵小树上。小鸟喜欢和他的朋友松鼠一起玩。他们喜欢玩小树枝。有一天,小鸟很难过。他不知道朋友去哪儿了。小鸟四处寻找他的朋友。他看见地上有一根大树枝。小鸟想:“也许我的朋友藏在树枝后面。”小鸟飞下去看。可是朋友不在那里。小鸟更难过了。就在这时,松鼠从灌木丛里跳了出来,脸上挂着大大的笑容。他说:“我是藏起来给你一个惊喜!”小鸟见到朋友很开心。他们一起玩那根大树枝。小鸟明白了:有时候事情看起来很糟,但快乐的惊喜也许就在转角处。",
445
+ "bg": "Имало едно време една малка птичка, която живеела на малко дърво. Птичката обичала да играе с приятеля си, катеричката. Обичали да си играят с клонки. Един ден птичката била тъжна. Не знаела къде е приятелят ѝ. Птичката търсила приятеля си навсякъде. Видяла голяма клонка на земята. Птичката си помислила: „Може би приятелят ми се крие зад клонката.“ Птичката слетяла да погледне. Но приятелят ѝ не бил там. Птичката станала още по-тъжна. Точно тогава катеричката изскочила от един храст. Имала голяма усмивка на лицето си. Казала: „Криех се, за да те изненадам!“ Птичката се зарадвала да види приятеля си. Играли заедно с голямата клонка. Малката птичка научила, че понякога, когато нещата изглеждат зле, щастлива изненада може да чака точно зад ъгъла."
446
+ },
447
+ "videos": [
448
+ {
449
+ "model": "ours",
450
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/TS63_ours.mp4"
451
+ },
452
+ {
453
+ "model": "movieagent",
454
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/TS63_movieagent.mp4"
455
+ },
456
+ {
457
+ "model": "anim_director",
458
+ "path": "https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/v2/videos/TS63_anim_director.mp4"
459
+ }
460
+ ]
461
+ }
462
+ ]
static/v2/index.html ADDED
The diff for this file is too large to render. See raw diff
 
static/v2/mobile.html ADDED
@@ -0,0 +1,1492 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport"
6
+ content="width=device-width, initial-scale=1.0, viewport-fit=cover">
7
+ <meta name="theme-color" content="#0f1115">
8
+ <title>Video user study v2 — mobile</title>
9
+ <style>
10
+ :root {
11
+ --bg: #0f1115;
12
+ --panel: #161922;
13
+ --panel-2: #1c2030;
14
+ --panel-3: #232839;
15
+ --border: #2a2f42;
16
+ --border-strong: #3a4060;
17
+ --fg: #e6e8ef;
18
+ --muted: #8b91a8;
19
+ --muted-2: #5e6480;
20
+ --accent: #6aa9ff;
21
+ --accent-strong: #8ec0ff;
22
+ --good: #28c76f;
23
+ --warn: #ff9f43;
24
+ --bad: #ea5455;
25
+ --score-1: #ea5455;
26
+ --score-2: #ff7d52;
27
+ --score-3: #ffa84a;
28
+ --score-4: #8acf6d;
29
+ --score-5: #28c76f;
30
+ --safe-bot: env(safe-area-inset-bottom, 0px);
31
+ --bar-h: 56px;
32
+ }
33
+ * { box-sizing: border-box; }
34
+ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg);
35
+ font-family: -apple-system, BlinkMacSystemFont, "Inter", "PingFang SC",
36
+ "Microsoft YaHei", sans-serif;
37
+ font-size: 15px; line-height: 1.4;
38
+ -webkit-tap-highlight-color: transparent;
39
+ overscroll-behavior-y: contain; }
40
+
41
+ /* ---------- top header ---------- */
42
+ header.top { position: sticky; top: 0; z-index: 50;
43
+ background: var(--panel); border-bottom: 1px solid var(--border);
44
+ padding: 8px 12px; padding-top: calc(env(safe-area-inset-top, 0px) + 8px);
45
+ display: flex; align-items: center; gap: 8px; }
46
+ header.top .title { font-weight: 600; font-size: 14px; flex: 1;
47
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
48
+ header.top .pill { font-size: 11px; padding: 3px 8px; border-radius: 10px;
49
+ background: var(--panel-3); color: var(--muted); white-space: nowrap;
50
+ font-family: ui-monospace, monospace; border: 1px solid var(--border); }
51
+ header.top .pill[data-state="idle"] { background:#1f2a44; color: var(--accent); border-color:#3a4a78; }
52
+ header.top .pill[data-state="submitting"] { background:#1f2a44; color: var(--accent); border-color:#3a4a78; }
53
+ header.top .pill[data-state="submitted"] { background:#1c3422; color: var(--good); border-color:#2a5a37; }
54
+ header.top .pill[data-state="local-only"] { background:#3a3520; color: var(--warn); border-color:#5a4a25; }
55
+ header.top .pill[data-state="error"] { background:#3a2020; color: var(--bad); border-color:#5a2828; }
56
+ header.top button.icon { background: transparent; border: none; color: var(--fg);
57
+ font-size: 22px; padding: 4px 8px; cursor: pointer; line-height: 1; }
58
+ header.top select { background: var(--panel-3); border: 1px solid var(--border);
59
+ color: var(--fg); padding: 4px 6px; border-radius: 4px; font-size: 12px;
60
+ font-family: inherit; }
61
+
62
+ /* ---------- progress strip ---------- */
63
+ .case-strip { position: sticky; top: 56px; z-index: 40;
64
+ background: var(--panel); border-bottom: 1px solid var(--border);
65
+ padding: 6px 12px; display: flex; align-items: center; gap: 8px;
66
+ font-size: 12px; color: var(--muted); }
67
+ .case-strip .case-id { color: var(--fg); font-weight: 600;
68
+ font-family: ui-monospace, monospace; }
69
+ .case-strip .bar { flex: 1; height: 4px; background: var(--panel-3); border-radius: 2px;
70
+ overflow: hidden; }
71
+ .case-strip .fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--good));
72
+ transition: width 0.2s; }
73
+ .case-strip button.jumper { background: var(--panel-3); border: 1px solid var(--border);
74
+ color: var(--fg); padding: 4px 10px; border-radius: 4px;
75
+ font-size: 12px; font-family: inherit; cursor: pointer; }
76
+
77
+ /* ---------- main scroll content ---------- */
78
+ main { padding: 8px 12px calc(var(--bar-h) + var(--safe-bot) + 24px); }
79
+
80
+ /* prompt */
81
+ .prompt-card { background: var(--panel); border: 1px solid var(--border);
82
+ border-radius: 8px; padding: 10px 12px; margin-bottom: 10px;
83
+ font-size: 14px; line-height: 1.6; }
84
+ .prompt-card .label { font-size: 10px; color: var(--muted); letter-spacing: 0.5px;
85
+ text-transform: uppercase; margin-bottom: 4px; }
86
+ .prompt-card details summary { cursor: pointer; user-select: none;
87
+ list-style: none; color: var(--muted); font-size: 13px; }
88
+ .prompt-card details summary::before { content: '▸ '; }
89
+ .prompt-card details[open] summary::before { content: '▾ '; }
90
+ .prompt-card details[open] summary { margin-bottom: 6px; }
91
+ .prompt-card .ref-img { max-width: 100%; max-height: 200px; border-radius: 6px;
92
+ border: 1px solid var(--border); display: block; margin-top: 8px; }
93
+ /* Bold-red highlight on controllability cues in the prompt. */
94
+ .ctrl { color: var(--bad); font-weight: 700; }
95
+ /* Admin-curated highlights get a subtle dotted underline so admins know
96
+ which marks are their own (raters see them the same red as auto cues). */
97
+ .ctrl-admin { text-decoration: underline; text-decoration-style: dotted;
98
+ text-decoration-color: var(--bad); text-underline-offset: 2px; }
99
+
100
+ /* videos — tab UI mirrors desktop: one active video at a time, A/B/C
101
+ buttons to switch. All tiles stay in DOM so each keeps its own state. */
102
+ .video-stack { display: flex; flex-direction: column; gap: 10px;
103
+ margin-bottom: 14px; }
104
+ .video-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
105
+ .tab-btn { background: var(--panel-3); border: 1px solid var(--border);
106
+ color: var(--muted); padding: 8px 14px; border-radius: 6px;
107
+ font-family: ui-monospace, monospace; font-size: 13px; cursor: pointer;
108
+ min-height: 38px; min-width: 44px; }
109
+ .tab-btn.active { background: var(--accent); color: #0a1a2a;
110
+ border-color: var(--accent); font-weight: 700; }
111
+ .video-tile { position: relative; }
112
+ .video-tile.hidden { display: none; }
113
+ .video-frame { position: relative; background: #000; border: 1px solid var(--border);
114
+ border-radius: 8px; overflow: hidden; aspect-ratio: 16 / 9; }
115
+ .video-frame video { width: 100%; height: 100%; display: block; background: #000; }
116
+ .video-frame .empty-state { position: absolute; inset: 0; display: flex;
117
+ flex-direction: column; align-items: center; justify-content: center;
118
+ background: repeating-linear-gradient(45deg, #1a1d28 0 10px, #16191f 10px 20px);
119
+ color: var(--muted); font-size: 12px; gap: 4px; text-align: center;
120
+ padding: 12px; }
121
+
122
+ /* video controls */
123
+ .video-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
124
+ background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
125
+ padding: 8px 10px; margin-bottom: 14px; font-size: 12px; }
126
+ .video-controls button { background: var(--panel-3); border: 1px solid var(--border-strong);
127
+ color: var(--fg); padding: 8px 12px; border-radius: 6px; font-size: 13px;
128
+ font-family: inherit; min-height: 36px; cursor: pointer; }
129
+ .video-controls button.on { background: var(--accent); color: #0a1a2a; border-color: var(--accent); }
130
+ .video-controls select { background: var(--panel-3); border: 1px solid var(--border);
131
+ color: var(--fg); padding: 6px 8px; border-radius: 4px; font-size: 13px;
132
+ font-family: inherit; margin-left: auto; }
133
+
134
+ /* dimension + question cards. dim-header sits inline above its cards
135
+ (not sticky — only the prompt + videos at the top of the page float). */
136
+ /* Responsive grid of question cards. ~280 px min → 1 col on phones,
137
+ 2-3 cols on tablets / landscape. Dim headers span all columns. */
138
+ .question-grid { display: grid; gap: 10px;
139
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
140
+ align-content: start; }
141
+ .question-grid > .dim-header { grid-column: 1 / -1; }
142
+ .dim-header { margin: 14px 0 4px; padding: 0 2px;
143
+ font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;
144
+ color: var(--accent-strong); font-weight: 700;
145
+ display: flex; align-items: baseline; gap: 8px; }
146
+ .dim-header:first-child { margin-top: 0; }
147
+ .dim-header .dim-hint { font-size: 11px; color: var(--muted); font-weight: 400;
148
+ text-transform: none; letter-spacing: 0; }
149
+ .q-card { background: var(--panel); border: 1px solid var(--border);
150
+ border-radius: 8px; padding: 12px; margin-bottom: 10px; }
151
+ .q-card .q-name { font-size: 15px; font-weight: 600; color: var(--fg);
152
+ margin-bottom: 2px; }
153
+ .q-card .q-desc { font-size: 12px; color: var(--muted); margin-bottom: 10px;
154
+ line-height: 1.5; }
155
+ .q-card .video-rate { display: flex; align-items: center; gap: 8px;
156
+ padding: 6px 0; }
157
+ .q-card .video-rate + .video-rate { border-top: 1px solid var(--border); }
158
+ .q-card .video-rate .slot-letter { min-width: 28px; height: 28px;
159
+ display: inline-flex; align-items: center; justify-content: center;
160
+ border-radius: 4px; background: var(--panel-3); color: var(--fg);
161
+ font-weight: 700; font-size: 13px; font-family: ui-monospace, monospace;
162
+ border: 1px solid var(--border-strong); flex-shrink: 0; }
163
+ .q-card .likert { display: flex; gap: 4px; flex: 1; justify-content: space-between; }
164
+ .q-card .likert button { flex: 1; min-height: 42px; min-width: 0;
165
+ border: 1px solid var(--border); background: var(--panel-3); color: var(--fg);
166
+ border-radius: 6px; font-family: inherit; font-size: 14px; font-weight: 500;
167
+ cursor: pointer; }
168
+ .q-card .likert button.selected { font-weight: 700; color: #0a1a0a;
169
+ border-color: rgba(255,255,255,0.2); }
170
+ .q-card .likert button.selected[data-score="1"] { background: var(--score-1); }
171
+ .q-card .likert button.selected[data-score="2"] { background: var(--score-2); }
172
+ .q-card .likert button.selected[data-score="3"] { background: var(--score-3); }
173
+ .q-card .likert button.selected[data-score="4"] { background: var(--score-4); }
174
+ .q-card .likert button.selected[data-score="5"] { background: var(--score-5); }
175
+
176
+ /* bottom fixed action bar */
177
+ footer.bottom { position: fixed; left: 0; right: 0; bottom: 0; z-index: 50;
178
+ background: var(--panel); border-top: 1px solid var(--border);
179
+ padding: 8px 10px calc(8px + var(--safe-bot));
180
+ display: grid; grid-template-columns: 1fr 1fr auto 1fr 1fr; gap: 6px;
181
+ height: calc(var(--bar-h) + var(--safe-bot)); align-items: center; }
182
+ footer.bottom button { background: var(--panel-3); border: 1px solid var(--border-strong);
183
+ color: var(--fg); padding: 0; height: 40px; border-radius: 6px;
184
+ font-family: inherit; font-size: 14px; font-weight: 500; cursor: pointer; }
185
+ footer.bottom .submit-btn { background: var(--good); color: #0a1a0a;
186
+ border-color: var(--good); font-weight: 700; }
187
+ footer.bottom .submit-btn:disabled { background: var(--panel-3); color: var(--muted-2);
188
+ border-color: var(--border); font-weight: 500; }
189
+ footer.bottom .top-btn { font-size: 18px; }
190
+
191
+ /* slide-in drawer (case list) */
192
+ .drawer-backdrop { position: fixed; inset: 0; background: rgba(8,10,16,0.7);
193
+ z-index: 60; opacity: 0; pointer-events: none; transition: opacity 0.2s; }
194
+ .drawer-backdrop.open { opacity: 1; pointer-events: auto; }
195
+ .drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 86%;
196
+ max-width: 360px; background: var(--panel); border-left: 1px solid var(--border);
197
+ z-index: 61; transform: translateX(100%); transition: transform 0.22s ease;
198
+ display: flex; flex-direction: column; padding-top: env(safe-area-inset-top, 0px); }
199
+ .drawer.open { transform: translateX(0); }
200
+ .drawer header { padding: 12px 14px; border-bottom: 1px solid var(--border);
201
+ display: flex; align-items: center; justify-content: space-between;
202
+ font-weight: 600; }
203
+ .drawer .list { flex: 1; overflow-y: auto; padding: 4px 0; }
204
+ .drawer .case-row { padding: 12px 14px; border-bottom: 1px solid var(--border);
205
+ display: flex; align-items: center; gap: 10px; }
206
+ .drawer .case-row.active { background: var(--panel-2);
207
+ border-left: 3px solid var(--accent); padding-left: 11px; }
208
+ .drawer .case-row .dot { width: 9px; height: 9px; border-radius: 50%;
209
+ background: var(--muted-2); flex-shrink: 0; }
210
+ .drawer .case-row.partial .dot { background: var(--warn); }
211
+ .drawer .case-row.complete .dot { background: var(--good); }
212
+ .drawer .case-row .id { flex: 1; font-family: ui-monospace, monospace;
213
+ font-size: 13px; }
214
+ .drawer .case-row .pct { font-size: 11px; color: var(--muted); }
215
+
216
+ /* registration overlay (re-used from desktop) */
217
+ .overlay { position: fixed; inset: 0; background: rgba(8,10,16,0.84);
218
+ backdrop-filter: blur(4px); display: flex; align-items: center;
219
+ justify-content: center; z-index: 100; padding: 14px; }
220
+ .overlay.hidden { display: none; }
221
+ .reg-card { background: var(--panel); border: 1px solid var(--border-strong);
222
+ border-radius: 10px; padding: 18px 18px 16px; max-width: 420px; width: 100%; }
223
+ .reg-card h2 { margin: 0 0 4px; font-size: 17px; }
224
+ .reg-card .lead { color: var(--muted); font-size: 13px; line-height: 1.6;
225
+ margin: 0 0 14px; }
226
+ .reg-card label { display: block; font-size: 11px; text-transform: uppercase;
227
+ letter-spacing: 0.5px; color: var(--muted); margin: 10px 0 4px; }
228
+ .reg-card input, .reg-card textarea { width: 100%; background: var(--panel-3);
229
+ border: 1px solid var(--border); color: var(--fg); padding: 10px;
230
+ border-radius: 6px; font-family: inherit; font-size: 16px; /* 16 ≥ iOS no-zoom */ }
231
+ .reg-card textarea { min-height: 50px; resize: vertical; font-size: 14px; }
232
+ .reg-card .err { color: var(--bad); font-size: 12px; min-height: 14px;
233
+ margin-top: 6px; }
234
+ .reg-card .top-row { display: flex; align-items: center;
235
+ justify-content: space-between; margin-bottom: 8px; gap: 12px; }
236
+ .reg-card select { background: var(--panel-3); border: 1px solid var(--border);
237
+ color: var(--fg); padding: 4px 6px; border-radius: 4px; font-size: 12px;
238
+ font-family: inherit; }
239
+ .reg-card .go-btn { width: 100%; background: var(--accent); color: #0a1a2a;
240
+ border: 1px solid var(--accent); padding: 12px; border-radius: 8px;
241
+ font-weight: 700; font-size: 15px; cursor: pointer; margin-top: 14px; }
242
+
243
+ /* submitted overlay */
244
+ .submitted-card { background: var(--panel); border: 1px solid var(--good);
245
+ border-radius: 10px; padding: 22px; max-width: 420px; width: 100%;
246
+ text-align: center; }
247
+ .submitted-card .check { font-size: 44px; color: var(--good); }
248
+ .submitted-card h2 { margin: 8px 0 4px; font-size: 17px; }
249
+ .submitted-card p { color: var(--muted); font-size: 13px; margin: 4px 0;
250
+ line-height: 1.6; }
251
+ .submitted-card code { background: var(--panel-3); padding: 2px 6px;
252
+ border-radius: 3px; font-size: 11px; color: var(--accent-strong);
253
+ word-break: break-all; }
254
+ .submitted-card .btn { background: var(--panel-3); border: 1px solid var(--border-strong);
255
+ color: var(--fg); padding: 10px 14px; border-radius: 6px; font-size: 14px;
256
+ font-family: inherit; margin-top: 12px; cursor: pointer; min-width: 100px; }
257
+
258
+ /* toast */
259
+ .toast { position: fixed; bottom: calc(var(--bar-h) + var(--safe-bot) + 14px);
260
+ left: 12px; right: 12px; background: var(--good); color: #0a1a0a;
261
+ padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 600;
262
+ text-align: center; opacity: 0; transition: opacity 0.2s;
263
+ z-index: 90; pointer-events: none; }
264
+ .toast.show { opacity: 1; }
265
+ .toast.error { background: var(--bad); color: #fff; }
266
+
267
+ /* desktop switch link */
268
+ .desktop-link { font-size: 11px; color: var(--muted); text-decoration: none;
269
+ padding: 4px 8px; border: 1px solid var(--border); border-radius: 12px;
270
+ background: var(--panel-3); }
271
+ .desktop-link:hover { color: var(--fg); }
272
+
273
+ /* admin / submitted badges */
274
+ .badge { display: inline-flex; align-items: center; padding: 2px 8px;
275
+ border-radius: 10px; font-size: 10px; font-weight: 700; }
276
+ .badge.admin { background: #3a2f60; color: #d9b6ff; border: 1px solid #5a4080; }
277
+ .badge.submitted { background: #1c3422; color: var(--good); border: 1px solid #2a5a37; }
278
+
279
+ /* shut down landscape if it gets weird */
280
+ @media (orientation: landscape) and (max-height: 460px) {
281
+ header.top { padding: 4px 12px; }
282
+ .case-strip { top: 44px; padding: 4px 12px; }
283
+ main { padding-top: 6px; }
284
+ }
285
+
286
+ kbd { background: var(--panel-3); border: 1px solid var(--border-strong);
287
+ border-radius: 3px; padding: 1px 4px; font-family: ui-monospace, monospace;
288
+ font-size: 11px; }
289
+ </style>
290
+ </head>
291
+ <body>
292
+
293
+ <!-- ============ Top bar ============ -->
294
+ <header class="top" id="topBar">
295
+ <button class="icon" id="drawerBtn" title="Cases">☰</button>
296
+ <span class="title" id="topTitle">Video user study</span>
297
+ <button class="icon" id="helpBtn" title="How to rate" style="font-size:18px">?</button>
298
+ <span class="pill" id="syncPill" data-state="idle">●</span>
299
+ <select id="langSelect" title="Language"></select>
300
+ </header>
301
+
302
+ <div class="case-strip" id="caseStrip">
303
+ <span class="case-id" id="caseId">—</span>
304
+ <span id="caseCounter">—</span>
305
+ <div class="bar"><div class="fill" id="progressFill" style="width:0%"></div></div>
306
+ <span id="progressTxt">0/0</span>
307
+ </div>
308
+
309
+ <main id="main">
310
+ <!-- prompt + videos + controls + question cards inserted here -->
311
+ </main>
312
+
313
+ <footer class="bottom">
314
+ <button id="prevBtn">← Prev</button>
315
+ <button id="saveBtn">💾</button>
316
+ <button class="top-btn" id="scrollTopBtn" title="Back to videos">↑</button>
317
+ <button id="submitBtn" class="submit-btn" disabled>Submit</button>
318
+ <button id="nextBtn">Next →</button>
319
+ </footer>
320
+
321
+ <div class="toast" id="toast"></div>
322
+
323
+ <!-- Slide-in drawer with case list -->
324
+ <div class="drawer-backdrop" id="drawerBackdrop"></div>
325
+ <aside class="drawer" id="drawer" aria-hidden="true">
326
+ <header>
327
+ <span>Cases</span>
328
+ <button class="icon" id="drawerCloseBtn"
329
+ style="background:transparent;border:none;color:var(--fg);font-size:22px;cursor:pointer">✕</button>
330
+ </header>
331
+ <div class="list" id="drawerList"></div>
332
+ <div style="padding:10px 14px;border-top:1px solid var(--border);display:flex;gap:8px;align-items:center">
333
+ <a href="./?force=desktop" class="desktop-link" style="flex:1;text-align:center">🖥 Desktop view</a>
334
+ </div>
335
+ </aside>
336
+
337
+ <!-- Rating instructions overlay (mobile) — toggled via the "?" header button.
338
+ Auto-opened on first launch; user can close + reopen any time. -->
339
+ <div id="helpOverlay" class="overlay hidden" role="dialog" aria-modal="true">
340
+ <div class="reg-card" style="max-width:520px">
341
+ <div class="top-row">
342
+ <h2 id="helpHeader">How to rate</h2>
343
+ <button class="btn" id="helpCloseBtn"
344
+ style="background:transparent;border:none;color:var(--fg);font-size:22px;cursor:pointer">✕</button>
345
+ </div>
346
+ <div id="helpBody" style="font-size:13px;line-height:1.6;color:var(--fg);max-height:60vh;overflow-y:auto"></div>
347
+ <button class="go-btn" id="helpDismissBtn" style="margin-top:14px"><span id="helpDismissText">Got it</span></button>
348
+ </div>
349
+ </div>
350
+
351
+ <!-- Registration overlay -->
352
+ <div id="loginOverlay" class="overlay" role="dialog" aria-modal="true">
353
+ <div class="reg-card">
354
+ <div class="top-row">
355
+ <h2 id="regHeader">Register to start rating</h2>
356
+ <select id="regLangSelect"></select>
357
+ </div>
358
+ <p class="lead" id="regLead"></p>
359
+ <label for="regName" id="regNameLabel">Your name</label>
360
+ <input id="regName" type="text" autocomplete="name" maxlength="80">
361
+ <label for="regEmail" id="regEmailLabel">Email</label>
362
+ <input id="regEmail" type="email" autocomplete="email" inputmode="email" maxlength="120">
363
+ <label for="regNote" id="regNoteLabel">Affiliation / notes (optional)</label>
364
+ <textarea id="regNote" maxlength="280"></textarea>
365
+ <div class="err" id="regErr"></div>
366
+ <button class="go-btn" id="regGoBtn"><span id="regContinueText">Continue →</span></button>
367
+ <div style="color:var(--muted-2);font-size:11px;margin-top:8px;line-height:1.5"
368
+ id="regFootText"></div>
369
+ </div>
370
+ </div>
371
+
372
+ <!-- Submitted overlay -->
373
+ <div id="submittedOverlay" class="overlay hidden" role="dialog" aria-modal="true">
374
+ <div class="submitted-card">
375
+ <div class="check">✓</div>
376
+ <h2 id="submittedHeading">Submission recorded</h2>
377
+ <p id="submittedDetail"></p>
378
+ <p><code id="submittedRef">—</code></p>
379
+ <div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
380
+ <button class="btn" id="submittedCloseBtn">Close</button>
381
+ <button class="btn" id="submittedDownloadBtn">↓ Backup JSON</button>
382
+ </div>
383
+ </div>
384
+ </div>
385
+
386
+ <script>
387
+ /* =========================================================================
388
+ * Auto-redirect to the desktop page on large / pointer-fine devices.
389
+ * `?force=mobile` overrides (sticky in sessionStorage for the tab).
390
+ * ========================================================================= */
391
+ (function autoRedirectIfDesktop() {
392
+ try {
393
+ const url = new URL(location.href);
394
+ if (url.searchParams.has("force")) {
395
+ sessionStorage.setItem("user_study_view_force", url.searchParams.get("force"));
396
+ }
397
+ const forced = sessionStorage.getItem("user_study_view_force");
398
+ if (forced === "mobile") return;
399
+ const ua = navigator.userAgent || "";
400
+ const isMobileUA = /Mobi|Android|iPhone|iPod|BlackBerry|Windows Phone/i.test(ua);
401
+ const isFine = window.matchMedia && window.matchMedia("(pointer: fine)").matches;
402
+ const isWide = window.innerWidth >= 900;
403
+ if (forced === "desktop" || (!isMobileUA && isFine && isWide)) {
404
+ location.replace("./");
405
+ }
406
+ } catch (e) { /* ignore — fall through to mobile */ }
407
+ })();
408
+
409
+ /* =========================================================================
410
+ * Same rubric + i18n + admin list + API contract as desktop index.html.
411
+ * Kept in sync manually for now (small enough). localStorage key is shared
412
+ * so progress made on desktop carries to mobile and vice-versa.
413
+ * ========================================================================= */
414
+ const DIMENSIONS = [
415
+ { key: "instruction_following", name: "Instruction following",
416
+ hint: "Re-read the prompt — especially the red-highlighted phrases — before scoring.", questions: [
417
+ { key: "controllability", name: "Controllability",
418
+ desc: "Does the video respond to control signals (BGM, subtitle etc)?" },
419
+ { key: "prompt_adherence", name: "Prompt adherence",
420
+ desc: "Does the content match what the prompt described (subjects, actions, setting, time, and story arc)? Focus primarily on the prompt's text content." },
421
+ { key: "no_irrelevant_frames", name: "No irrelevant frames",
422
+ desc: "Does the video stay on-prompt? Higher = fewer / no frames or content that weren't asked for by the prompt." } ] },
423
+ { key: "temporal_consistency", name: "Temporal consistency",
424
+ hint: "Focus on how characters, objects, and the environment change across frames.", questions: [
425
+ { key: "person_stability", name: "Person stability",
426
+ desc: "Faces, identities, and body proportions stay consistent across frames." },
427
+ { key: "object_stability", name: "Object stability",
428
+ desc: "Props, clothing, and objects keep their shape and position over time." },
429
+ { key: "environment_stability", name: "Environment stability",
430
+ desc: "Background, lighting, and scene layout stay coherent across frames (including absence of brightness / color / texture flicker)." } ] },
431
+ // Dim key stays `motion_realism` for back-compat; display + hint broadened
432
+ // to "Realism", and the `realism` sub-question (formerly under Visual
433
+ // quality) lives here now.
434
+ { key: "motion_realism", name: "Realism",
435
+ hint: "Notice obvious out-of-place or unnatural moments in the video.", questions: [
436
+ { key: "realism", name: "Static realism",
437
+ desc: "Photorealism or stylistic fidelity (per-frame appearance). When the prompt specifies a style, the video should match it. No uncanny or broken artifacts." },
438
+ { key: "motion_logic", name: "Motion logic",
439
+ desc: "Does body and object motion flow smoothly and follow physical / logical patterns (gravity, collisions, fluid / cloth dynamics)? No warping, teleporting, nonsensical, or impossible movements. Score low if there is little to no motion." },
440
+ { key: "camera", name: "Camera",
441
+ desc: "Camera movement (pan, dolly, focal change) feels natural and intentional. Score low if there is little to no camera movement." } ] },
442
+ ];
443
+
444
+ const TOTAL_QUESTIONS = DIMENSIONS.reduce((n, d) => n + d.questions.length, 0);
445
+ const STORAGE_KEY = "user_study_v2_ratings_v1"; // shared with desktop
446
+ const ADMIN_NAMES = new Set(["deheng zhang", "zhendong li"]);
447
+
448
+ const LANGUAGES = [
449
+ { code: "en", label: "English" },
450
+ { code: "zh", label: "Chinese" },
451
+ { code: "bg", label: "Bulgarian" },
452
+ ];
453
+
454
+ const STRINGS = {
455
+ en: {
456
+ title:"Video user study",
457
+ prompt_label:"Prompt / instruction",
458
+ no_prompt:"(no prompt provided)",
459
+ case_n_of_m:"Case {0}/{1}",
460
+ play:"▶ Play", pause:"⏸", restart:"⏮", loop:"↻", mute:"🔇", sound:"🔊", speed:"Speed",
461
+ no_video_file:"no video file at this path",
462
+ prev:"← Prev", next:"Next →",
463
+ save_progress:"💾",
464
+ submit:"Submit ({0})",
465
+ submit_resubmit:"✓ Re-submit",
466
+ submit_disabled:"Submit ({0} left)",
467
+ reg_title:"Register to start rating",
468
+ reg_lead:"We record who submitted each set of ratings. No password — just your name and an email that uniquely identifies you.",
469
+ reg_name:"Your name", reg_name_ph:"e.g. Alex Chen",
470
+ reg_email:"Email", reg_email_ph:"e.g. alex@example.com",
471
+ reg_note:"Affiliation / notes (optional)", reg_note_ph:"e.g. INSAIT, internal pilot, etc.",
472
+ reg_continue:"Continue →",
473
+ help_title:"How to rate",
474
+ help_dismiss:"Got it",
475
+ help_body_html:"<p>For each case watch all videos via the <b>A / B / C</b> tabs at the top, then rate each one on every sub-question below. Slot order is shuffled per rater but stable within a case.</p><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>Likert (1 = worst, 5 = best)</h4><p>1 Very poor · 2 Poor · 3 Fair · 4 Good · 5 Excellent</p><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>3 dimensions × 9 sub-questions</h4><ul style='padding-left:18px;margin:6px 0'><li><b>Instruction following</b> — re-read the prompt, especially the red-highlighted phrases.</li><li><b>Temporal consistency</b> — focus on how characters, objects, and environment change across frames.</li><li><b>Realism</b> — notice obvious out-of-place / unnatural moments.</li></ul><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>Tips</h4><ul style='padding-left:18px;margin:6px 0'><li><b>Click 💾 to save progress at any time.</b> Click <b>Submit study</b> once everything is rated — otherwise your scores don't count.</li><li>For BGM / subtitle / sound questions, unmute the video first.</li><li>Score motion logic / camera low if the video has little to no movement.</li></ul>",
476
+ reg_foot:"Submissions are written to the <code>dehezhang2/Frameworker_User_Study</code> dataset on Hugging Face.",
477
+ reg_err_name:"Please enter your name.",
478
+ reg_err_email:"Please enter a valid email address.",
479
+ pill_idle:"●", pill_submitting:"↻", pill_submitted:"✓", pill_local_only:"💾", pill_error:"✕", pill_checking:"●",
480
+ toast_registered:"Registered as {0}",
481
+ toast_pls_signin:"Please sign in before submitting",
482
+ toast_missing:"{0} ratings still missing",
483
+ toast_no_server:"No server reachable — use Export instead",
484
+ toast_submit_failed:"Submit failed: {0}",
485
+ toast_progress_saved:"Progress saved ({0} ratings)",
486
+ toast_progress_save_failed:"Save failed: {0}",
487
+ submitted_title:"Submission recorded",
488
+ submitted_thanks:"Thanks, {0}. We recorded {1} ratings across {2} videos.",
489
+ submitted_close:"Close", submitted_download:"↓ Backup",
490
+ drawer_cases:"Cases",
491
+ scale_1:"1 Very poor", scale_5:"5 Excellent",
492
+ expand_prompt:"Read full prompt",
493
+ case_complete:"✓ complete",
494
+ confirm_reset:"Reset all ratings + registration on this device? This cannot be undone.",
495
+ admin:"⚙ admin", submitted_badge:"✓ submitted",
496
+ },
497
+ zh: {
498
+ title:"视频用户研究",
499
+ prompt_label:"提示词 / 指令",
500
+ no_prompt:"(未提供提示词)",
501
+ case_n_of_m:"案例 {0}/{1}",
502
+ play:"▶ 播放", pause:"⏸", restart:"⏮", loop:"↻", mute:"🔇", sound:"🔊", speed:"速度",
503
+ no_video_file:"该路径下没有视频文件",
504
+ prev:"← 上一个", next:"下一个 →",
505
+ save_progress:"💾",
506
+ submit:"提交 ({0})",
507
+ submit_resubmit:"✓ 重新提交",
508
+ submit_disabled:"提交 (还剩 {0})",
509
+ reg_title:"注册以开始评分",
510
+ reg_lead:"我们会记录每份评分的提交者。无需密码 — 只需姓名和一个唯一识别您的邮箱。",
511
+ reg_name:"您的姓名", reg_name_ph:"例如:陈小明",
512
+ reg_email:"邮箱", reg_email_ph:"例如:xiaoming@example.com",
513
+ reg_note:"所属单位 / 备注(可选)", reg_note_ph:"例如:INSAIT、内部预测试 等",
514
+ reg_continue:"继续 →",
515
+ help_title:"评分说明",
516
+ help_dismiss:"知道了",
517
+ help_body_html:"<p>每个案例,先在顶部的 <b>A / B / C</b> 视频标签切换观看每个视频,然后在下方对每个视频的每个子问题打分。视频顺序按评分人随机分配,但同一案例内保持一致。</p><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>李克特量表(1 = 最差,5 = 最佳)</h4><p>1 很差 · 2 差 · 3 一般 · 4 好 · 5 极佳</p><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>3 个维度 × 9 个子问题</h4><ul style='padding-left:18px;margin:6px 0'><li><b>指令遵循</b> — 请先看清提示词,尤其是标红的部分。</li><li><b>时间一致性</b> — 关注人物、物品和环境在帧间的变化。</li><li><b>真实感</b> — 留意视频中不和谐或有明显问题的地方。</li></ul><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>操作提示</h4><ul style='padding-left:18px;margin:6px 0'><li><b>随时点击 💾 保存进度;</b>全部完成后必须点击「提交研究」,否则评分不会被计入。</li><li>BGM / 字幕 / 配乐 等问题请先打开视频声音聆听。</li><li>运动逻辑 / 相机运动 若几乎没有运动,请打低分。</li></ul>",
518
+ reg_foot:"提交将写入 Hugging Face 上的 <code>dehezhang2/Frameworker_User_Study</code> 数据集。",
519
+ reg_err_name:"请输入您的姓名。",
520
+ reg_err_email:"请输入有效的邮箱地址���",
521
+ pill_idle:"●", pill_submitting:"↻", pill_submitted:"✓", pill_local_only:"💾", pill_error:"✕", pill_checking:"●",
522
+ toast_registered:"已注册为 {0}",
523
+ toast_pls_signin:"请先登录后再提交",
524
+ toast_missing:"尚有 {0} 项未完成",
525
+ toast_no_server:"无法连接服务器 — 请使用导出功能",
526
+ toast_submit_failed:"提交失败: {0}",
527
+ toast_progress_saved:"进度已保存 ({0} 项评分)",
528
+ toast_progress_save_failed:"保存失败: {0}",
529
+ submitted_title:"提交成功",
530
+ submitted_thanks:"{0},感谢您的参与。我们已写入 {2} 个视频 × {1} 项评分。",
531
+ submitted_close:"关闭", submitted_download:"↓ 备份",
532
+ drawer_cases:"案例",
533
+ scale_1:"1 很差", scale_5:"5 极佳",
534
+ expand_prompt:"查看完整提示词",
535
+ case_complete:"✓ 已完成",
536
+ confirm_reset:"重置本设备的所有评分和注册信息吗?该操作不可撤销。",
537
+ admin:"⚙ 管理员", submitted_badge:"✓ 已提交",
538
+ },
539
+ bg: {
540
+ title:"Видео проучване",
541
+ prompt_label:"Подкана / инструкция",
542
+ no_prompt:"(няма подкана)",
543
+ case_n_of_m:"Случай {0}/{1}",
544
+ play:"▶ Пусни", pause:"⏸", restart:"⏮", loop:"↻", mute:"🔇", sound:"🔊", speed:"Скорост",
545
+ no_video_file:"няма видео файл на този път",
546
+ prev:"← Назад", next:"Напред →",
547
+ save_progress:"💾",
548
+ submit:"Изпрати ({0})",
549
+ submit_resubmit:"✓ Повторно",
550
+ submit_disabled:"Изпрати (остават {0})",
551
+ reg_title:"Регистрирайте се, за да започнете",
552
+ reg_lead:"Записваме кой е изпратил всеки набор от оценки. Без парола — само името и имейл, които ви идентифицират уникално.",
553
+ reg_name:"Вашето име", reg_name_ph:"напр. Алекс Иванов",
554
+ reg_email:"Имейл", reg_email_ph:"напр. alex@example.com",
555
+ reg_note:"Принадлежност / бележки (по избор)", reg_note_ph:"напр. INSAIT, вътрешен пилот",
556
+ reg_continue:"Продължи →",
557
+ help_title:"Как се оценява",
558
+ help_dismiss:"Разбрах",
559
+ help_body_html:"<p>За всеки случай гледайте всички видеа чрез табовете <b>A / B / C</b> отгоре, след това оценете всяко по всеки под-въпрос отдолу.</p><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>Ликерт (1 = най-лошо, 5 = най-добро)</h4><p>1 Много слабо · 2 Слабо · 3 Средно · 4 Добро · 5 Отлично</p><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>3 измерения × 9 под-въпроса</h4><ul style='padding-left:18px;margin:6px 0'><li><b>Следване на инструкции</b> — прочетете подканата, особено маркираните в червено фрази.</li><li><b>Времева съгласуваност</b> — съсредоточете се върху промените на хора, обекти и среда между кадрите.</li><li><b>Реализъм</b> — забележете неестествени моменти.</li></ul><h4 style='color:var(--accent);margin-top:14px;font-size:13px'>Съвети</h4><ul style='padding-left:18px;margin:6px 0'><li><b>Щракнете 💾, за да запазите прогрес по всяко време;</b> щракнете <b>Изпрати проучването</b> накрая — иначе оценките не се броят.</li><li>За въпроси относно BGM / субтитри / звук включете звука първо.</li><li>Дайте нисък резултат за логика на движението / камера, ако има малко движение.</li></ul>",
560
+ reg_foot:"Изпращанията се записват в <code>dehezhang2/Frameworker_User_Study</code> на Hugging Face.",
561
+ reg_err_name:"Моля, въведете името си.",
562
+ reg_err_email:"Моля, въведете валиден имейл адрес.",
563
+ pill_idle:"●", pill_submitting:"↻", pill_submitted:"✓", pill_local_only:"💾", pill_error:"✕", pill_checking:"●",
564
+ toast_registered:"Регистриран(а) като {0}",
565
+ toast_pls_signin:"Моля, влезте преди изпращане",
566
+ toast_missing:"Все още липсват {0} оценки",
567
+ toast_no_server:"Сървърът не е достъпен — използвайте Изтегли",
568
+ toast_submit_failed:"Изпращането се провали: {0}",
569
+ toast_progress_saved:"Прогресът е запазен ({0} оценки)",
570
+ toast_progress_save_failed:"Запазването се провали: {0}",
571
+ submitted_title:"Записано",
572
+ submitted_thanks:"Благодарим, {0}. Записахме {1} оценки за {2} видеа.",
573
+ submitted_close:"Затвори", submitted_download:"↓ Копие",
574
+ drawer_cases:"Случаи",
575
+ scale_1:"1 Много слабо", scale_5:"5 Отлично",
576
+ expand_prompt:"Покажи цялата подкана",
577
+ case_complete:"✓ завършен",
578
+ confirm_reset:"Изчистване на всички оценки и регистрация на това устройство? Действието е необратимо.",
579
+ admin:"⚙ администратор", submitted_badge:"✓ изпратено",
580
+ },
581
+ };
582
+
583
+ const RUBRIC_I18N = {
584
+ zh: {
585
+ instruction_following:{name:"指令遵循",hint:"请先看清提示词,尤其是标红的部分,再作答。"},
586
+ temporal_consistency:{name:"时间一致性",hint:"请关注人物、物品和环境在帧间的变化。"},
587
+ motion_realism:{name:"真实感",hint:"请留意视频中不和谐或有明显问题的地方。"},
588
+ controllability:{name:"可控性",desc:"视频是否响应控制信号(BGM、字幕等)?"},
589
+ prompt_adherence:{name:"提示一致性",desc:"内容是否与提示的描述一致(主体、动作、场景、时间、故事走向)?以提示词的文本内容为主要依据。"},
590
+ no_irrelevant_frames:{name:"无无关画面",desc:"视频是否仅呈现提示中要求的内容?分数越高 = 越少出现与提示无关的帧或内容。"},
591
+ realism:{name:"静态真实感",desc:"单帧的照片级真实感或风格忠实度。若提示词指定了风格,视频应符合该风格。无诡异感或失真伪影。"},
592
+ person_stability:{name:"人物稳定性",desc:"面部、身份及身体比例在帧间保持一致。"},
593
+ object_stability:{name:"物体稳定性",desc:"道具、服饰及物体随时间保持形状和位置。"},
594
+ environment_stability:{name:"环境稳定性",desc:"背景、光照及场景布局在帧间保持连贯(包括无亮度、色彩或纹理闪烁)。"},
595
+ motion_logic:{name:"运动逻辑",desc:"身体与物体的运动是否流畅且符合物理与常识规律(重力、碰撞、流体/布料动力学)?无形变、瞬移、不合常理或不可能的动作。若几乎没有运动,请打低分。"},
596
+ camera:{name:"相机运动",desc:"相机运动(平移、推拉、焦距变化)自然且有意图。若几乎没有相机运动,请打低分。"},
597
+ },
598
+ bg: {
599
+ instruction_following:{name:"Следване на инструкции",hint:"Прочетете внимателно подканата — особено маркираните в червено фрази — преди да оцените."},
600
+ temporal_consistency:{name:"Времева съгласуваност",hint:"Съсредоточете се върху промените на хора, предмети и среда между кадрите."},
601
+ motion_realism:{name:"Реализъм",hint:"Забележете очевидни проблеми или неестествени моменти във видеото."},
602
+ controllability:{name:"Контролируемост",desc:"Реагира ли видеото на контролни сигнали (BGM, субтитри и т.н.)?"},
603
+ prompt_adherence:{name:"Съответствие с подканата",desc:"Съответства ли съдържанието на описаното в подканата (обекти, действия, обстановка, време и развитие на историята)? Фокусирайте се основно върху текстовото съдържание на подканата."},
604
+ no_irrelevant_frames:{name:"Без неуместни кадри",desc:"Остава ли видеото в рамките на подканата? По-висок = по-малко / без кадри или съдържание, които не са поискани."},
605
+ realism:{name:"Статичен реализъм",desc:"Фотореалистичност или стилова достоверност на отделен кадър. Ако подканата задава стил, видеото трябва да следва този стил. Без странни или счупени артефакти."},
606
+ person_stability:{name:"Стабилност на хората",desc:"Лица, идентичности и пропорции на тялото остават постоянни между кадрите."},
607
+ object_stability:{name:"Стабилност на обектите",desc:"Реквизити, дрехи и обекти запазват форма и позиция във времето."},
608
+ environment_stability:{name:"Стабилност на средата",desc:"Фон, осветление и подредба на сцената остават последователни между кадрите (включително липса на трептене на яркост, цвят или текстура)."},
609
+ motion_logic:{name:"Логика на движението",desc:"Текат ли движенията на тела и обекти плавно и в съответствие с физически / логически закони (гравитация, сблъсъци, динамика на флуиди / тъкани)? Без деформации, телепортация, нелогични или невъзможни движения. Дайте нисък резултат, ако има малко или никакво движение."},
610
+ camera:{name:"Камера",desc:"Движението на камерата (панорама, дъли, промяна на фокус) изглежда естествено и целенасочено. Дайте нисък резултат, ако има малко или никакво движение на камерата."},
611
+ },
612
+ };
613
+
614
+ /* ----- State (shared with desktop via localStorage) ----- */
615
+ let CASES = []; // cases visible to this rater (subset of ALL)
616
+ let ALL_CASES = []; // full manifest from cases.json
617
+ const CASES_PER_RATER = 10;
618
+ let SHUFFLE = {};
619
+ let RATINGS = {};
620
+ let RATER = { id:"", name:"", email:"", note:"",
621
+ started_at:null, submitted_at:null };
622
+ let LANG = "en";
623
+ let anonymize = true;
624
+ let currentIdx = 0;
625
+ let SYNC_STATUS = "idle";
626
+ let SYNC_LAST_DETAIL = "";
627
+ const API_SUBMIT = "./api/submit";
628
+ const API_HEALTH = "./api/health";
629
+ const API_SAVE_PROGRESS = "./api/save_progress";
630
+ const PROGRESS_DEBOUNCE_MS = 60000;
631
+ let PROGRESS_TIMER = null;
632
+
633
+ /* ----- Helpers ----- */
634
+ function t(key, ...args){
635
+ let s = (STRINGS[LANG]&&STRINGS[LANG][key])??STRINGS.en[key]??key;
636
+ if(args.length) s = s.replace(/\{(\d+)\}/g,(_,i)=>args[Number(i)]??"");
637
+ return s;
638
+ }
639
+ function dimName(d){ return LANG==="en"?d.name:(RUBRIC_I18N[LANG]?.[d.key]?.name??d.name); }
640
+ function dimHint(d){ return LANG==="en"?d.hint:(RUBRIC_I18N[LANG]?.[d.key]?.hint??d.hint); }
641
+ function qName(q){ return LANG==="en"?q.name:(RUBRIC_I18N[LANG]?.[q.key]?.name??q.name); }
642
+ function qDesc(q){ return LANG==="en"?q.desc:(RUBRIC_I18N[LANG]?.[q.key]?.desc??q.desc); }
643
+ function isAdmin(){ return ADMIN_NAMES.has((RATER.name||"").trim().toLowerCase()); }
644
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
645
+ function hashStr(s){ let h=2166136261>>>0; for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=(h*16777619)>>>0;} return h; }
646
+ function escapeHtml(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);}
647
+
648
+ // Controllability highlighter — wraps imperative verbs + directive phrases
649
+ // in <span class="ctrl"> so raters see what the prompt actually controls.
650
+ const CTRL_PATTERNS = {
651
+ en: [
652
+ /\bAdd\b[^.]*\./g,
653
+ /\bUse this[^.]*\./g,
654
+ /Make a[^—–]*?animated short drama/g,
655
+ ],
656
+ zh: [
657
+ /制作一部[^—–]*?短剧/g,
658
+ /请?添加[^。]*。/g,
659
+ /请使用上传的[^。]*。/g,
660
+ /配以[^。]*。/g,
661
+ /环境音[::][^。]*。/g,
662
+ ],
663
+ bg: [
664
+ /Създайте[^—–]*?анимационна кратка драма/g,
665
+ /Добавете[^.]*\./g,
666
+ /Използвайте качената[^.]*\./g,
667
+ ],
668
+ };
669
+ // Admin-curated highlight snippets, fetched at init from /api/annotations.
670
+ // Same shape as desktop: { case_id: { lang: [snippet, ...] } }
671
+ let ADMIN_HIGHLIGHTS = {};
672
+ const API_ANNOTATIONS = "./api/annotations";
673
+
674
+ function highlightControllability(text, lang, caseId) {
675
+ let html = escapeHtml(text);
676
+ const patterns = CTRL_PATTERNS[lang] || CTRL_PATTERNS.en;
677
+ const ranges = [];
678
+ // Auto-detected controllability cues
679
+ for (const re of patterns) {
680
+ re.lastIndex = 0; let m;
681
+ while ((m = re.exec(html)) !== null) {
682
+ ranges.push([m.index, m.index + m[0].length, "auto"]);
683
+ if (m[0].length === 0) re.lastIndex++;
684
+ }
685
+ }
686
+ // Admin-curated snippets for this case + language
687
+ const adminSnippets = (caseId && ADMIN_HIGHLIGHTS[caseId]?.[lang]) || [];
688
+ for (const snippet of adminSnippets) {
689
+ if (!snippet) continue;
690
+ const esc = escapeHtml(snippet);
691
+ let idx = 0;
692
+ while ((idx = html.indexOf(esc, idx)) !== -1) {
693
+ ranges.push([idx, idx + esc.length, "admin"]);
694
+ idx += esc.length || 1;
695
+ }
696
+ }
697
+ if (!ranges.length) return html;
698
+ ranges.sort((a,b)=>a[0]-b[0]||a[1]-b[1]);
699
+ const merged = [ranges[0].slice()];
700
+ for (let i=1;i<ranges.length;i++){
701
+ const top = merged[merged.length-1];
702
+ if (ranges[i][0]<top[1]) {
703
+ top[1] = Math.max(top[1], ranges[i][1]);
704
+ if (ranges[i][2] === "admin") top[2] = "admin";
705
+ } else merged.push(ranges[i].slice());
706
+ }
707
+ let out = html;
708
+ for (let i=merged.length-1;i>=0;i--){
709
+ const [s,e,kind] = merged[i];
710
+ const cls = kind === "admin" ? "ctrl ctrl-admin" : "ctrl";
711
+ out = out.slice(0,s) + `<span class="${cls}">` + out.slice(s,e) + '</span>' + out.slice(e);
712
+ }
713
+ return out;
714
+ }
715
+
716
+ async function loadAnnotations() {
717
+ try {
718
+ const r = await fetch(API_ANNOTATIONS, { cache: "no-cache" });
719
+ if (!r.ok) return;
720
+ const j = await r.json().catch(() => ({}));
721
+ ADMIN_HIGHLIGHTS = j.annotations || {};
722
+ // Re-render the current case so the freshly-loaded admin highlights
723
+ // appear in the prompt.
724
+ if (CASES.length && RATER.name) renderCase();
725
+ } catch (e) { /* offline: auto-detected highlights only */ }
726
+ }
727
+ function showToast(msg, isErr=false){
728
+ const tt = document.getElementById("toast");
729
+ tt.textContent = msg;
730
+ tt.classList.toggle("error", !!isErr);
731
+ tt.classList.add("show");
732
+ setTimeout(()=>tt.classList.remove("show"), 1800);
733
+ }
734
+ function genRaterId(){ return "rater_"+Math.random().toString(36).slice(2,8)+"_"+Date.now().toString(36).slice(-4); }
735
+
736
+ /* ----- LocalStorage (shared key) ----- */
737
+ function loadState(){ try{ return JSON.parse(localStorage.getItem(STORAGE_KEY)||"null"); }catch(e){return null;} }
738
+ function saveState(){
739
+ try{ localStorage.setItem(STORAGE_KEY, JSON.stringify({
740
+ rater: RATER, anonymize, lang: LANG, shuffle: SHUFFLE, ratings: RATINGS,
741
+ saved_at: new Date().toISOString(),
742
+ })); }catch(e){ showToast("Save failed: "+e.message, true); }
743
+ }
744
+
745
+ /* ----- Shuffle ----- */
746
+ // Per-rater 10-case subset of the manifest. Admins see all. The chosen ids
747
+ // are locked into RATER.chosen_case_ids on first pick so the rater always
748
+ // sees the same set across sessions / devices regardless of future
749
+ // manifest changes.
750
+ function pickRandomSubsetIds(allCases){
751
+ const idx = allCases.map((_,i)=>i);
752
+ let s = hashStr((RATER.id||"anon")+":case-subset");
753
+ for (let k=0;k<6;k++) s = (s*1664525+1013904223)>>>0;
754
+ for (let i=idx.length-1;i>0;i--){
755
+ s = (s*1664525+1013904223)>>>0;
756
+ const j = (s>>>16)%(i+1);
757
+ [idx[i],idx[j]] = [idx[j],idx[i]];
758
+ }
759
+ return idx.slice(0,CASES_PER_RATER).sort((a,b)=>a-b).map(i => allCases[i].id);
760
+ }
761
+ function selectCasesForRater(allCases){
762
+ if (isAdmin()) return allCases.slice();
763
+ if (allCases.length <= CASES_PER_RATER) return allCases.slice();
764
+ const locked = Array.isArray(RATER.chosen_case_ids) ? RATER.chosen_case_ids : null;
765
+ if (locked && locked.length){
766
+ const byId = new Map(allCases.map(c => [c.id, c]));
767
+ const out = locked.map(id => byId.get(id)).filter(Boolean);
768
+ if (out.length >= Math.min(CASES_PER_RATER, allCases.length)) return out;
769
+ }
770
+ const ids = pickRandomSubsetIds(allCases);
771
+ RATER.chosen_case_ids = ids;
772
+ const byId = new Map(allCases.map(c => [c.id, c]));
773
+ return ids.map(id => byId.get(id)).filter(Boolean);
774
+ }
775
+ async function recoverRaterStateFromServer(email){
776
+ if (!email) return;
777
+ try {
778
+ const r = await fetch(`./api/rater_state?email=${encodeURIComponent(email)}`,
779
+ { cache: "no-cache" });
780
+ if (!r.ok) return;
781
+ const j = await r.json().catch(()=>({}));
782
+ if (!j.found || !j.state) return;
783
+ const remoteRater = j.state.rater || {};
784
+ if (Array.isArray(remoteRater.chosen_case_ids) && remoteRater.chosen_case_ids.length) {
785
+ RATER.chosen_case_ids = remoteRater.chosen_case_ids;
786
+ }
787
+ for (const c of (j.state.cases || [])) {
788
+ if (!c || !c.id) continue;
789
+ RATINGS[c.id] = RATINGS[c.id] || {};
790
+ for (const v of (c.videos || [])) {
791
+ const m = v.model;
792
+ if (!m) continue;
793
+ RATINGS[c.id][m] = RATINGS[c.id][m] || {};
794
+ for (const [qk, score] of Object.entries(v.ratings || {})) {
795
+ if (typeof score === "number" && score>=1 && score<=5 &&
796
+ RATINGS[c.id][m][qk] === undefined) {
797
+ RATINGS[c.id][m][qk] = score;
798
+ }
799
+ }
800
+ }
801
+ }
802
+ } catch (e) { /* silent */ }
803
+ }
804
+
805
+ function buildShuffle(cases){
806
+ for (const c of cases){
807
+ if (SHUFFLE[c.id] && SHUFFLE[c.id].length===c.videos.length) continue;
808
+ const idx = c.videos.map((_,i)=>i);
809
+ if (anonymize){
810
+ let s = hashStr(RATER.id+":"+c.id);
811
+ for (let i=idx.length-1;i>0;i--){
812
+ s = (s*1664525+1013904223)>>>0;
813
+ const j = s%(i+1);
814
+ [idx[i],idx[j]] = [idx[j],idx[i]];
815
+ }
816
+ }
817
+ SHUFFLE[c.id] = idx;
818
+ }
819
+ }
820
+ // Within a case, every sub-question shows its rating rows in A → B → C
821
+ // order. (Per-rater case-level shuffle still varies which video sits under
822
+ // each letter across cases, but the order inside a case is now stable so
823
+ // the rater always sees a consistent layout.)
824
+ function rowSlotOrder(caseId, qKey, slotCount){
825
+ return Array.from({length:slotCount},(_,i)=>i);
826
+ }
827
+
828
+ /* ----- Ratings ----- */
829
+ function getCaseR(cid){ if(!RATINGS[cid]) RATINGS[cid]={}; return RATINGS[cid]; }
830
+ function getSlotR(cid, s){ const m = getCaseR(cid); if(!m[s]) m[s]={}; return m[s]; }
831
+ function setRating(cid, sIdx, qk, score){
832
+ getSlotR(cid, sIdx)[qk] = score;
833
+ saveState();
834
+ updateProgress();
835
+ if (RATER.submitted_at){
836
+ RATER.submitted_at = null; saveState();
837
+ setSyncStatus(SYNC_STATUS==="local-only"?"local-only":"idle");
838
+ }
839
+ updateSubmitBtn();
840
+ scheduleProgressSave();
841
+ }
842
+ function studyCompleteness(){
843
+ const qkeys = DIMENSIONS.flatMap(d=>d.questions.map(q=>q.key));
844
+ let done=0, expected=0; const missing=[];
845
+ for (const c of CASES){
846
+ const slots = SHUFFLE[c.id] || c.videos.map((_,i)=>i);
847
+ for (let s=0; s<slots.length; s++){
848
+ const r = (RATINGS[c.id]||{})[s] || {};
849
+ for (const qk of qkeys){
850
+ expected++;
851
+ if (typeof r[qk]==="number" && r[qk]>=1 && r[qk]<=5) done++;
852
+ else missing.push({case_id:c.id, slot:String.fromCharCode(65+s), q:qk});
853
+ }
854
+ }
855
+ }
856
+ return { done, expected, missing };
857
+ }
858
+ function caseProgress(c){
859
+ const slots = SHUFFLE[c.id] || [];
860
+ const expected = slots.length * TOTAL_QUESTIONS;
861
+ let done=0;
862
+ for (let s=0;s<slots.length;s++){
863
+ const r = (RATINGS[c.id]||{})[s] || {};
864
+ done += Object.keys(r).filter(k=>typeof r[k]==="number"&&r[k]>=1&&r[k]<=5).length;
865
+ }
866
+ return { done, expected };
867
+ }
868
+
869
+ /* ----- Sync pill + server status ----- */
870
+ function setSyncStatus(state, detail){
871
+ SYNC_STATUS = state;
872
+ if (detail!==undefined) SYNC_LAST_DETAIL = detail;
873
+ const pill = document.getElementById("syncPill");
874
+ if (!pill) return;
875
+ pill.dataset.state = state;
876
+ pill.textContent = t({
877
+ idle:"pill_idle", submitting:"pill_submitting", submitted:"pill_submitted",
878
+ "local-only":"pill_local_only", error:"pill_error",
879
+ }[state] || "pill_checking");
880
+ pill.title = SYNC_LAST_DETAIL || "";
881
+ }
882
+ async function probeSync(){
883
+ try{
884
+ const r = await fetch(API_HEALTH, {cache:"no-cache"});
885
+ if (r.ok){
886
+ const j = await r.json().catch(()=>({}));
887
+ setSyncStatus(j.ok && j.has_token ? "idle" : "local-only");
888
+ } else setSyncStatus("local-only");
889
+ }catch(e){ setSyncStatus("local-only"); }
890
+ }
891
+
892
+ /* ----- Payloads + API calls ----- */
893
+ function buildProgressPayload(){
894
+ const comp = studyCompleteness();
895
+ return {
896
+ rater: RATER, anonymize_used: anonymize, is_admin: isAdmin(),
897
+ rubric: DIMENSIONS, progress_saved_at: new Date().toISOString(),
898
+ completeness: { done: comp.done, expected: comp.expected, missing_count: comp.missing.length },
899
+ cases: CASES.map(c => {
900
+ const slots = SHUFFLE[c.id] || c.videos.map((_,i)=>i);
901
+ return {
902
+ id: c.id, category: c.category||null, prompt: c.prompt,
903
+ videos: slots.map((origIdx, slotIdx) => ({
904
+ slot: String.fromCharCode(65+slotIdx), slot_index: slotIdx,
905
+ original_index: origIdx, model: c.videos[origIdx].model,
906
+ path: c.videos[origIdx].path,
907
+ ratings: (RATINGS[c.id]||{})[slotIdx] || {},
908
+ })),
909
+ };
910
+ }),
911
+ };
912
+ }
913
+ function buildFullPayload(){
914
+ const p = buildProgressPayload();
915
+ p.submitted_at = new Date().toISOString();
916
+ delete p.progress_saved_at; delete p.completeness;
917
+ return p;
918
+ }
919
+ async function saveProgress(opts={}){
920
+ if (!RATER.email || !RATER.name) return;
921
+ if (SYNC_STATUS==="local-only" && opts.source!=="manual") return;
922
+ try{
923
+ const r = await fetch(API_SAVE_PROGRESS, {
924
+ method:"POST", headers:{"Content-Type":"application/json"},
925
+ body: JSON.stringify(buildProgressPayload()),
926
+ });
927
+ if (r.ok){
928
+ const j = await r.json().catch(()=>({}));
929
+ if (!opts.silent) showToast(t("toast_progress_saved", j.n_scores ?? 0));
930
+ } else if (r.status===404||r.status===503){
931
+ setSyncStatus("local-only");
932
+ } else if (!opts.silent){
933
+ showToast(t("toast_progress_save_failed", "HTTP "+r.status), true);
934
+ }
935
+ }catch(e){
936
+ if (!opts.silent) showToast(t("toast_progress_save_failed", e.message), true);
937
+ }
938
+ }
939
+ function scheduleProgressSave(){
940
+ if (!RATER.email) return;
941
+ if (SYNC_STATUS==="local-only") return;
942
+ if (PROGRESS_TIMER) clearTimeout(PROGRESS_TIMER);
943
+ PROGRESS_TIMER = setTimeout(()=>{ PROGRESS_TIMER=null; saveProgress({silent:true,source:"auto"}); }, PROGRESS_DEBOUNCE_MS);
944
+ }
945
+ function beaconProgress(){
946
+ if (!RATER.email || SYNC_STATUS==="local-only") return;
947
+ try{ navigator.sendBeacon(API_SAVE_PROGRESS,
948
+ new Blob([JSON.stringify(buildProgressPayload())], {type:"application/json"})); }catch(e){}
949
+ }
950
+ async function submitWholeStudy(){
951
+ if (!RATER.email||!RATER.name){ showToast(t("toast_pls_signin"), true); openRegister(); return; }
952
+ const comp = studyCompleteness();
953
+ if (comp.missing.length>0){
954
+ showToast(t("toast_missing", comp.missing.length), true);
955
+ const firstId = comp.missing[0].case_id;
956
+ const idx = CASES.findIndex(c=>c.id===firstId);
957
+ if (idx>=0) goTo(idx);
958
+ return;
959
+ }
960
+ if (SYNC_STATUS==="local-only"){ showToast(t("toast_no_server"), true); return; }
961
+ setSyncStatus("submitting");
962
+ try{
963
+ const r = await fetch(API_SUBMIT, {
964
+ method:"POST", headers:{"Content-Type":"application/json"},
965
+ body: JSON.stringify(buildFullPayload()),
966
+ });
967
+ if (r.ok){
968
+ const body = await r.json().catch(()=>({}));
969
+ RATER.submitted_at = new Date().toISOString();
970
+ saveState();
971
+ setSyncStatus("submitted");
972
+ updateSubmitBtn();
973
+ showSubmittedOverlay(body);
974
+ } else if (r.status===404||r.status===503){
975
+ setSyncStatus("local-only"); showToast(t("toast_no_server"), true);
976
+ } else {
977
+ setSyncStatus("error");
978
+ showToast(t("toast_submit_failed", "HTTP "+r.status), true);
979
+ }
980
+ }catch(e){
981
+ setSyncStatus("local-only"); showToast(t("toast_submit_failed", e.message), true);
982
+ }
983
+ }
984
+ function exportJSON(){
985
+ const out = {
986
+ rater: RATER, anonymize_used: anonymize, exported_at: new Date().toISOString(),
987
+ rubric: DIMENSIONS,
988
+ cases: CASES.map(c => {
989
+ const slots = SHUFFLE[c.id] || c.videos.map((_,i)=>i);
990
+ return { id:c.id, prompt:c.prompt,
991
+ videos: slots.map((oi,si)=>({
992
+ slot:String.fromCharCode(65+si), slot_index:si, original_index:oi,
993
+ model:c.videos[oi].model, path:c.videos[oi].path,
994
+ ratings:(RATINGS[c.id]||{})[si]||{},
995
+ })),
996
+ };
997
+ }),
998
+ };
999
+ const blob = new Blob([JSON.stringify(out,null,2)], {type:"application/json"});
1000
+ const url = URL.createObjectURL(blob);
1001
+ const a = document.createElement("a");
1002
+ a.href = url; a.download = `user_study_${RATER.id||"anon"}_${new Date().toISOString().slice(0,10)}.json`;
1003
+ document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
1004
+ }
1005
+
1006
+ /* =========================================================================
1007
+ * Mobile rendering
1008
+ * ========================================================================= */
1009
+ function renderShell(){
1010
+ // language selectors
1011
+ for (const id of ["langSelect","regLangSelect"]){
1012
+ const sel = document.getElementById(id);
1013
+ sel.innerHTML = "";
1014
+ for (const L of LANGUAGES){
1015
+ const o = document.createElement("option");
1016
+ o.value = L.code; o.textContent = L.label;
1017
+ if (L.code===LANG) o.selected = true;
1018
+ sel.appendChild(o);
1019
+ }
1020
+ }
1021
+ document.getElementById("topTitle").textContent = t("title");
1022
+ // bottom-bar labels (kept short on mobile)
1023
+ document.getElementById("prevBtn").textContent = t("prev");
1024
+ document.getElementById("nextBtn").textContent = t("next");
1025
+ document.getElementById("saveBtn").textContent = t("save_progress");
1026
+ document.getElementById("submittedCloseBtn").textContent = t("submitted_close");
1027
+ document.getElementById("submittedDownloadBtn").textContent = t("submitted_download");
1028
+ applyRegistrationI18n();
1029
+ updateSubmitBtn();
1030
+ renderCase();
1031
+ renderDrawer();
1032
+ }
1033
+
1034
+ function renderCase(){
1035
+ const main = document.getElementById("main");
1036
+ main.innerHTML = "";
1037
+ if (!CASES.length) return;
1038
+ const c = CASES[currentIdx];
1039
+ document.getElementById("caseId").textContent = c.id;
1040
+ document.getElementById("caseCounter").textContent = t("case_n_of_m", currentIdx+1, CASES.length);
1041
+
1042
+ // prompt card
1043
+ main.appendChild(renderPromptCard(c));
1044
+ // video stack
1045
+ main.appendChild(renderVideoStack(c));
1046
+ // controls
1047
+ main.appendChild(renderVideoControls());
1048
+ // rubric — laid out as a responsive grid (1 col on phones, 2-3 on tablets).
1049
+ const grid = document.createElement("div");
1050
+ grid.className = "question-grid";
1051
+ for (const dim of DIMENSIONS){
1052
+ grid.appendChild(renderDimHeader(dim));
1053
+ for (const q of dim.questions) grid.appendChild(renderQuestionCard(c, q));
1054
+ }
1055
+ main.appendChild(grid);
1056
+ updateProgress();
1057
+ refreshDrawerActive();
1058
+ }
1059
+
1060
+ function renderPromptCard(c){
1061
+ const wrap = document.createElement("div");
1062
+ wrap.className = "prompt-card";
1063
+ const localized = (c.prompt_i18n && c.prompt_i18n[LANG]) || c.prompt || t("no_prompt");
1064
+ const isLong = (localized||"").length > 160;
1065
+ const highlightedFull = highlightControllability(localized, LANG, c.id);
1066
+ // For the collapsed teaser we still highlight, but on the truncated text.
1067
+ const teaser = highlightControllability(localized.slice(0,150)+"…", LANG, c.id);
1068
+ wrap.innerHTML = isLong
1069
+ ? `<div class="label">${escapeHtml(t("prompt_label"))}</div>
1070
+ <details><summary>${teaser} <span style="color:var(--accent)">(${escapeHtml(t("expand_prompt"))})</span></summary>
1071
+ <div style="margin-top:6px">${highlightedFull}</div></details>`
1072
+ : `<div class="label">${escapeHtml(t("prompt_label"))}</div>
1073
+ <div>${highlightedFull}</div>`;
1074
+ if (c.reference_image){
1075
+ const img = document.createElement("img");
1076
+ img.src = c.reference_image; img.className = "ref-img";
1077
+ img.onerror = function(){ this.style.display="none"; };
1078
+ wrap.appendChild(img);
1079
+ }
1080
+ return wrap;
1081
+ }
1082
+
1083
+ // Mobile keeps the same A/B/C tab UX as desktop, but stacked top-to-bottom.
1084
+ // Only the active video is visible; others remain in the DOM so they keep
1085
+ // their own playback state.
1086
+ function renderVideoStack(c){
1087
+ const slots = SHUFFLE[c.id];
1088
+ const active = getActiveSlot(c.id);
1089
+ const wrap = document.createElement("div");
1090
+ wrap.className = "video-stack";
1091
+ wrap.id = "videoStack";
1092
+
1093
+ // Tab bar
1094
+ const tabs = document.createElement("div");
1095
+ tabs.className = "video-tabs";
1096
+ slots.forEach((origIdx, slotIdx) => {
1097
+ const v = c.videos[origIdx];
1098
+ const letter = String.fromCharCode(65+slotIdx);
1099
+ const label = anonymize ? letter : `${letter} · ${escapeHtml(v.model)}`;
1100
+ const btn = document.createElement("button");
1101
+ btn.className = "tab-btn" + (slotIdx === active ? " active" : "");
1102
+ btn.innerHTML = label;
1103
+ btn.onclick = () => setActiveSlot(c.id, slotIdx);
1104
+ tabs.appendChild(btn);
1105
+ });
1106
+ wrap.appendChild(tabs);
1107
+
1108
+ // Video tiles
1109
+ slots.forEach((origIdx, slotIdx) => {
1110
+ const v = c.videos[origIdx];
1111
+ const tile = document.createElement("div");
1112
+ tile.className = "video-tile" + (slotIdx === active ? "" : " hidden");
1113
+ tile.innerHTML = `
1114
+ <div class="video-frame">
1115
+ <video data-slot="${slotIdx}" preload="metadata" playsinline controls
1116
+ style="width:100%;height:100%">
1117
+ <source src="${escapeHtml(v.path)}" type="video/mp4">
1118
+ </video>
1119
+ <div class="empty-state" style="display:none">
1120
+ <div style="font-size:24px;opacity:0.3">▶</div>
1121
+ <div>${escapeHtml(t("no_video_file"))}</div>
1122
+ </div>
1123
+ </div>`;
1124
+ const video = tile.querySelector("video");
1125
+ const empty = tile.querySelector(".empty-state");
1126
+ const onErr = () => { empty.style.display="flex"; video.style.display="none"; };
1127
+ video.addEventListener("error", onErr);
1128
+ video.querySelector("source").addEventListener("error", onErr);
1129
+ wrap.appendChild(tile);
1130
+ });
1131
+ return wrap;
1132
+ }
1133
+
1134
+ // Active slot bookkeeping (per case) for the tab UI. Stays in memory only —
1135
+ // not persisted across reloads, which is fine since the tabs are just a view
1136
+ // helper and ratings persist per-slot in the global RATINGS map.
1137
+ let ACTIVE_SLOT = {};
1138
+ function getActiveSlot(caseId){ return ACTIVE_SLOT[caseId] ?? 0; }
1139
+ function setActiveSlot(caseId, slotIdx){
1140
+ pauseAll();
1141
+ ACTIVE_SLOT[caseId] = slotIdx;
1142
+ // Re-render only the video stack and tab bar (cheap), leaving question
1143
+ // cards / scroll position intact.
1144
+ const old = document.getElementById("videoStack");
1145
+ if (!old) return;
1146
+ const fresh = renderVideoStack(CASES[currentIdx]);
1147
+ old.parentElement.replaceChild(fresh, old);
1148
+ }
1149
+
1150
+ function renderVideoControls(){
1151
+ const wrap = document.createElement("div");
1152
+ wrap.className = "video-controls";
1153
+ const mkBtn = (label, fn, id) => {
1154
+ const b = document.createElement("button");
1155
+ b.textContent = label; b.onclick = fn; if (id) b.id = id;
1156
+ return b;
1157
+ };
1158
+ // Play button removed — the native <video controls> on the active tab
1159
+ // provides play/pause already.
1160
+ wrap.appendChild(mkBtn(t("pause"), pauseAll));
1161
+ wrap.appendChild(mkBtn(t("restart"), restartAll));
1162
+ const loop = mkBtn(t("loop"), () => toggleLoop(loop));
1163
+ wrap.appendChild(loop);
1164
+ const mute = mkBtn(t("mute"), () => toggleMute(mute));
1165
+ wrap.appendChild(mute);
1166
+ const sel = document.createElement("select");
1167
+ sel.onchange = (e)=>setSpeed(parseFloat(e.target.value));
1168
+ for (const v of ["0.5","0.75","1","1.5","2"]){
1169
+ const o = document.createElement("option"); o.value=v; o.textContent=v+"×";
1170
+ if (v==="1") o.selected = true; sel.appendChild(o);
1171
+ }
1172
+ wrap.appendChild(sel);
1173
+ return wrap;
1174
+ }
1175
+
1176
+ function renderDimHeader(dim){
1177
+ const d = document.createElement("div");
1178
+ d.className = "dim-header";
1179
+ d.innerHTML = `<span>${escapeHtml(dimName(dim))}</span><span class="dim-hint">${escapeHtml(dimHint(dim))}</span>`;
1180
+ return d;
1181
+ }
1182
+
1183
+ function renderQuestionCard(c, q){
1184
+ const card = document.createElement("div");
1185
+ card.className = "q-card";
1186
+ card.innerHTML = `<div class="q-name">${escapeHtml(qName(q))}</div>
1187
+ <div class="q-desc">${escapeHtml(qDesc(q))}</div>`;
1188
+ const slots = SHUFFLE[c.id];
1189
+ const order = rowSlotOrder(c.id, q.key, slots.length);
1190
+ for (const slotIdx of order){
1191
+ const origIdx = slots[slotIdx];
1192
+ const v = c.videos[origIdx];
1193
+ const letter = String.fromCharCode(65+slotIdx);
1194
+ const row = document.createElement("div");
1195
+ row.className = "video-rate";
1196
+ const chip = document.createElement("span");
1197
+ chip.className = "slot-letter";
1198
+ chip.textContent = anonymize ? letter : `${letter}`;
1199
+ chip.title = anonymize ? `Video ${letter}` : `Video ${letter} · ${v.model}`;
1200
+ row.appendChild(chip);
1201
+ const group = document.createElement("div");
1202
+ group.className = "likert";
1203
+ const cur = getSlotR(c.id, slotIdx)[q.key];
1204
+ for (let s=1; s<=5; s++){
1205
+ const b = document.createElement("button");
1206
+ b.dataset.score = String(s);
1207
+ b.textContent = String(s);
1208
+ if (cur===s) b.classList.add("selected");
1209
+ b.onclick = () => {
1210
+ setRating(c.id, slotIdx, q.key, s);
1211
+ Array.from(group.children).forEach((bb,i)=>bb.classList.toggle("selected", (i+1)===s));
1212
+ };
1213
+ group.appendChild(b);
1214
+ }
1215
+ row.appendChild(group);
1216
+ card.appendChild(row);
1217
+ }
1218
+ return card;
1219
+ }
1220
+
1221
+ /* ----- Drawer ----- */
1222
+ function renderDrawer(){
1223
+ const list = document.getElementById("drawerList");
1224
+ list.innerHTML = "";
1225
+ CASES.forEach((c, i) => {
1226
+ const p = caseProgress(c);
1227
+ let cls = "case-row";
1228
+ if (i===currentIdx) cls += " active";
1229
+ if (p.done === p.expected && p.expected>0) cls += " complete";
1230
+ else if (p.done > 0) cls += " partial";
1231
+ const row = document.createElement("div");
1232
+ row.className = cls;
1233
+ row.innerHTML = `<span class="dot"></span>
1234
+ <span class="id">${c.id}</span>
1235
+ <span class="pct">${p.done}/${p.expected}</span>`;
1236
+ row.onclick = () => { closeDrawer(); goTo(i); };
1237
+ list.appendChild(row);
1238
+ });
1239
+ }
1240
+ function refreshDrawerActive(){
1241
+ document.querySelectorAll("#drawerList .case-row").forEach((r,i)=>{
1242
+ r.classList.toggle("active", i===currentIdx);
1243
+ });
1244
+ }
1245
+ function openDrawer(){
1246
+ document.getElementById("drawer").classList.add("open");
1247
+ document.getElementById("drawerBackdrop").classList.add("open");
1248
+ }
1249
+ function closeDrawer(){
1250
+ document.getElementById("drawer").classList.remove("open");
1251
+ document.getElementById("drawerBackdrop").classList.remove("open");
1252
+ }
1253
+
1254
+ /* ----- Video controls ----- */
1255
+ function allVideos(){ return Array.from(document.querySelectorAll("video[data-slot]")); }
1256
+ function pauseAll(){ allVideos().forEach(v=>v.pause()); }
1257
+ function restartAll(){ allVideos().forEach(v=>{ v.currentTime=0; try{ v.play(); }catch(e){} }); }
1258
+ let LOOPING=false;
1259
+ function toggleLoop(btn){ LOOPING=!LOOPING; allVideos().forEach(v=>v.loop=LOOPING); btn.classList.toggle("on",LOOPING); }
1260
+ let MUTED=false;
1261
+ function toggleMute(btn){ MUTED=!MUTED; allVideos().forEach(v=>v.muted=MUTED); btn.classList.toggle("on",MUTED); btn.textContent=MUTED?t("muted"||"sound"):t("mute"); }
1262
+ function setSpeed(rate){ allVideos().forEach(v=>v.playbackRate=rate); }
1263
+
1264
+ /* ----- Navigation ----- */
1265
+ function goTo(idx){
1266
+ if (idx<0 || idx>=CASES.length) return;
1267
+ pauseAll();
1268
+ currentIdx = idx;
1269
+ renderCase();
1270
+ // scroll to top of main on case switch
1271
+ window.scrollTo({top:0, behavior:"instant"});
1272
+ }
1273
+ function step(d){ goTo((currentIdx + d + CASES.length) % CASES.length); }
1274
+
1275
+ /* ----- Submit button + progress ----- */
1276
+ function updateSubmitBtn(){
1277
+ const btn = document.getElementById("submitBtn");
1278
+ if (!btn) return;
1279
+ const comp = studyCompleteness();
1280
+ const remaining = comp.expected - comp.done;
1281
+ if (remaining > 0){
1282
+ btn.disabled = true; btn.textContent = t("submit_disabled", remaining);
1283
+ } else if (SYNC_STATUS === "submitted"){
1284
+ btn.disabled = false; btn.textContent = t("submit_resubmit");
1285
+ } else {
1286
+ btn.disabled = false; btn.textContent = t("submit", comp.expected);
1287
+ }
1288
+ }
1289
+ function updateProgress(){
1290
+ const { done, expected } = studyCompleteness();
1291
+ document.getElementById("progressFill").style.width =
1292
+ expected>0 ? (done/expected*100).toFixed(1)+"%" : "0%";
1293
+ document.getElementById("progressTxt").textContent = `${done}/${expected}`;
1294
+ updateSubmitBtn();
1295
+ // also refresh per-case status in drawer (cheap)
1296
+ refreshDrawerCounts();
1297
+ }
1298
+ function refreshDrawerCounts(){
1299
+ document.querySelectorAll("#drawerList .case-row").forEach((row,i)=>{
1300
+ const c = CASES[i]; if (!c) return;
1301
+ const p = caseProgress(c);
1302
+ row.classList.remove("partial","complete");
1303
+ if (p.done===p.expected && p.expected>0) row.classList.add("complete");
1304
+ else if (p.done>0) row.classList.add("partial");
1305
+ const pct = row.querySelector(".pct");
1306
+ if (pct) pct.textContent = `${p.done}/${p.expected}`;
1307
+ });
1308
+ }
1309
+
1310
+ /* ----- Registration overlay ----- */
1311
+ function openRegister(){
1312
+ document.getElementById("loginOverlay").classList.remove("hidden");
1313
+ document.getElementById("regName").value = RATER.name || "";
1314
+ document.getElementById("regEmail").value = RATER.email || "";
1315
+ document.getElementById("regNote").value = RATER.note || "";
1316
+ applyRegistrationI18n();
1317
+ }
1318
+ function closeRegister(){ document.getElementById("loginOverlay").classList.add("hidden"); }
1319
+
1320
+ /* ----- help / 评分说明 overlay (mobile only) ----- */
1321
+ const HELP_SEEN_KEY = "user_study_help_seen_v1";
1322
+ function openHelp(){
1323
+ applyHelpI18n();
1324
+ document.getElementById("helpOverlay").classList.remove("hidden");
1325
+ }
1326
+ function closeHelp(){
1327
+ document.getElementById("helpOverlay").classList.add("hidden");
1328
+ try { localStorage.setItem(HELP_SEEN_KEY, "1"); } catch(e){}
1329
+ }
1330
+ function applyHelpI18n(){
1331
+ const h = document.getElementById("helpHeader");
1332
+ if (h) h.textContent = t("help_title");
1333
+ const body = document.getElementById("helpBody");
1334
+ if (body) body.innerHTML = t("help_body_html");
1335
+ const btn = document.getElementById("helpDismissText");
1336
+ if (btn) btn.textContent = t("help_dismiss");
1337
+ }
1338
+ function applyRegistrationI18n(){
1339
+ document.getElementById("regHeader").textContent = t("reg_title");
1340
+ document.getElementById("regLead").textContent = t("reg_lead");
1341
+ document.getElementById("regNameLabel").textContent = t("reg_name");
1342
+ document.getElementById("regEmailLabel").textContent = t("reg_email");
1343
+ document.getElementById("regNoteLabel").textContent = t("reg_note");
1344
+ document.getElementById("regContinueText").textContent = t("reg_continue");
1345
+ document.getElementById("regFootText").innerHTML = t("reg_foot");
1346
+ document.getElementById("regName").placeholder = t("reg_name_ph");
1347
+ document.getElementById("regEmail").placeholder = t("reg_email_ph");
1348
+ document.getElementById("regNote").placeholder = t("reg_note_ph");
1349
+ document.getElementById("submittedHeading").textContent = t("submitted_title");
1350
+ }
1351
+ function handleRegister(){
1352
+ const name = document.getElementById("regName").value.trim();
1353
+ const email = document.getElementById("regEmail").value.trim().toLowerCase();
1354
+ const note = document.getElementById("regNote").value.trim();
1355
+ const err = document.getElementById("regErr");
1356
+ err.textContent = "";
1357
+ if (!name){ err.textContent = t("reg_err_name"); return; }
1358
+ if (!EMAIL_RE.test(email)){ err.textContent = t("reg_err_email"); return; }
1359
+ RATER.name = name; RATER.email = email; RATER.note = note;
1360
+ if (!RATER.id || RATER.id.startsWith("rater_")){
1361
+ RATER.id = email.replace(/[^a-z0-9]+/gi,"_").replace(/^_+|_+$/g,"");
1362
+ }
1363
+ if (!RATER.started_at) RATER.started_at = new Date().toISOString();
1364
+ // Non-admins always anonymized
1365
+ if (!isAdmin()) anonymize = true;
1366
+ closeRegister();
1367
+ // Recover any prior locked case subset + ratings from the server first,
1368
+ // then resolve the subset (locked > deterministic pick).
1369
+ (async () => {
1370
+ await recoverRaterStateFromServer(email);
1371
+ CASES = selectCasesForRater(ALL_CASES);
1372
+ SHUFFLE = {}; buildShuffle(CASES); saveState();
1373
+ renderShell();
1374
+ })();
1375
+ (async()=>{
1376
+ await probeSync();
1377
+ if (SYNC_STATUS!=="local-only"){
1378
+ await saveProgress({silent:true, source:"register"});
1379
+ }
1380
+ showToast(t("toast_registered", name));
1381
+ })();
1382
+ }
1383
+
1384
+ /* ----- Submitted overlay ----- */
1385
+ function showSubmittedOverlay(body){
1386
+ const ov = document.getElementById("submittedOverlay");
1387
+ document.getElementById("submittedDetail").textContent =
1388
+ t("submitted_thanks", RATER.name, body.n_scores ?? "?", body.n_videos ?? "?");
1389
+ document.getElementById("submittedRef").textContent = body.stored_as || "—";
1390
+ ov.classList.remove("hidden");
1391
+ }
1392
+ function hideSubmittedOverlay(){ document.getElementById("submittedOverlay").classList.add("hidden"); }
1393
+
1394
+ /* =========================================================================
1395
+ * Init
1396
+ * ========================================================================= */
1397
+ (async function init(){
1398
+ // restore lang first
1399
+ try{
1400
+ const prior0 = JSON.parse(localStorage.getItem(STORAGE_KEY)||"null");
1401
+ if (prior0 && STRINGS[prior0.lang]) LANG = prior0.lang;
1402
+ }catch(e){}
1403
+ if (LANG === "en"){
1404
+ const nav = (navigator.language||"en").toLowerCase();
1405
+ if (nav.startsWith("zh")) LANG = "zh";
1406
+ else if (nav.startsWith("bg")) LANG = "bg";
1407
+ }
1408
+ document.documentElement.lang = LANG;
1409
+
1410
+ // fetch manifest
1411
+ let manifest = null;
1412
+ for (const p of ["./cases.json", "./cases.example.json"]){
1413
+ try{ const r = await fetch(p, {cache:"no-cache"}); if (r.ok){ manifest = await r.json(); break; } }catch(e){}
1414
+ }
1415
+ if (!manifest || !Array.isArray(manifest) || manifest.length===0){
1416
+ document.getElementById("main").innerHTML =
1417
+ '<div style="padding:40px;color:var(--bad);text-align:center">Could not load cases.json</div>';
1418
+ return;
1419
+ }
1420
+ ALL_CASES = manifest;
1421
+
1422
+ // restore state
1423
+ const prior = loadState();
1424
+ const blankRater = () => ({ id:genRaterId(), name:"", email:"", note:"",
1425
+ started_at:new Date().toISOString(), submitted_at:null });
1426
+ if (prior){
1427
+ RATER = Object.assign(blankRater(), prior.rater||{});
1428
+ anonymize = prior.anonymize !== false;
1429
+ RATINGS = prior.ratings || {};
1430
+ SHUFFLE = prior.shuffle || {};
1431
+ // One-shot client-side huobao→univa rename (see desktop for context).
1432
+ for (const cid of Object.keys(RATINGS)) {
1433
+ const m = RATINGS[cid];
1434
+ if (m && m.huobao && !m.univa) { m.univa = m.huobao; delete m.huobao; }
1435
+ }
1436
+ } else { RATER = blankRater(); }
1437
+ if (!isAdmin()) anonymize = true;
1438
+ // Admins see all cases; everyone else gets a deterministic 10-case subset.
1439
+ CASES = selectCasesForRater(ALL_CASES);
1440
+ buildShuffle(CASES); saveState();
1441
+
1442
+ // wire DOM events
1443
+ document.getElementById("drawerBtn").onclick = openDrawer;
1444
+ document.getElementById("helpBtn").onclick = openHelp;
1445
+ document.getElementById("helpCloseBtn").onclick = closeHelp;
1446
+ document.getElementById("helpDismissBtn").onclick = closeHelp;
1447
+ document.getElementById("drawerCloseBtn").onclick = closeDrawer;
1448
+ document.getElementById("drawerBackdrop").onclick = closeDrawer;
1449
+ document.getElementById("prevBtn").onclick = () => step(-1);
1450
+ document.getElementById("nextBtn").onclick = () => step(1);
1451
+ document.getElementById("submitBtn").onclick = submitWholeStudy;
1452
+ document.getElementById("saveBtn").onclick = () => saveProgress({silent:false, source:"manual"});
1453
+ document.getElementById("scrollTopBtn").onclick = () => {
1454
+ document.getElementById("videoStack")?.scrollIntoView({behavior:"smooth", block:"start"});
1455
+ };
1456
+ document.getElementById("regGoBtn").onclick = handleRegister;
1457
+ document.getElementById("submittedCloseBtn").onclick = hideSubmittedOverlay;
1458
+ document.getElementById("submittedDownloadBtn").onclick = exportJSON;
1459
+ document.getElementById("langSelect").onchange = (e) => {
1460
+ LANG = e.target.value; document.documentElement.lang = LANG;
1461
+ saveState(); renderShell();
1462
+ };
1463
+ document.getElementById("regLangSelect").onchange = (e) => {
1464
+ LANG = e.target.value; document.documentElement.lang = LANG;
1465
+ saveState(); applyRegistrationI18n();
1466
+ };
1467
+ // best-effort save on close
1468
+ window.addEventListener("beforeunload", beaconProgress);
1469
+ document.addEventListener("visibilitychange", () => {
1470
+ if (document.visibilityState==="hidden") beaconProgress();
1471
+ });
1472
+
1473
+ // gate on registration
1474
+ if (!RATER.name || !RATER.email || !EMAIL_RE.test(RATER.email)){
1475
+ openRegister();
1476
+ } else {
1477
+ renderShell();
1478
+ closeRegister();
1479
+ probeSync();
1480
+ // Auto-open the rubric popup once per device. The user can manually
1481
+ // reopen it any time via the "?" header button.
1482
+ try {
1483
+ if (localStorage.getItem(HELP_SEEN_KEY) !== "1") openHelp();
1484
+ } catch (e) {}
1485
+ }
1486
+ // Always fetch admin-curated highlights; available even before registration
1487
+ // gate clears so the prompt shows highlights as soon as the rater enters.
1488
+ loadAnnotations();
1489
+ })();
1490
+ </script>
1491
+ </body>
1492
+ </html>