csuvla commited on
Commit
9d416e4
·
verified ·
1 Parent(s): 0621abe

Upload app code and config

Browse files
.gitattributes CHANGED
@@ -1,35 +1 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.mp4 filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ .gradio/
3
+ __pycache__/
4
+ *.pyc
5
+ *.pyo
6
+ *.tmp
7
+
8
+ results/
9
+
10
+ .DS_Store
11
+ Thumbs.db
README.md CHANGED
@@ -1,12 +1,92 @@
1
  ---
2
- title: Anyact User Study
3
- emoji: 📊
4
- colorFrom: green
5
- colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.12.0
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: AnyAct User Study
3
+ colorFrom: blue
4
+ colorTo: indigo
 
5
  sdk: gradio
6
+ python_version: "3.10"
7
  app_file: app.py
8
  pinned: false
9
  ---
10
 
11
+ # AnyAct User Study
12
+
13
+ This repository contains a self-contained Gradio questionnaire for pairwise human motion reenactment evaluation.
14
+
15
+ ## Included Data
16
+
17
+ The Space-ready copy already bundles all 30 study cases inside this repository:
18
+
19
+ - `videos/reference`
20
+ - `videos/anyact`
21
+ - `videos/vlm_hy_motion`
22
+ - `videos/echomotion`
23
+
24
+ The app reads those files through `data/study_config.json`, so it no longer depends on directories outside the repository.
25
+
26
+ ## Runtime Storage
27
+
28
+ By default, the app stores runtime files under:
29
+
30
+ - `results/responses.csv`
31
+ - `results/responses.jsonl`
32
+ - `results/participants`
33
+ - `results/plots`
34
+
35
+ For Hugging Face Spaces, you should mount persistent storage and set:
36
+
37
+ ```bash
38
+ USER_STUDY_RESULTS_DIR=/data/user_study_results
39
+ ```
40
+
41
+ If the app is running inside a Space and `/data` exists, it automatically falls back to `/data/user_study_results` even without that variable.
42
+
43
+ ## Local Run
44
+
45
+ ```bash
46
+ python -m venv .venv
47
+ .\.venv\Scripts\python.exe -m pip install -r requirements.txt
48
+ .\.venv\Scripts\python.exe app.py --share
49
+ ```
50
+
51
+ ## Hugging Face Spaces Deployment
52
+
53
+ ### Option 1: Browser Upload
54
+
55
+ 1. Create a new Hugging Face Space.
56
+ 2. Choose `Gradio` as the SDK.
57
+ 3. Upload the contents of this repository.
58
+ 4. In the Space settings, optionally add persistent storage and set `USER_STUDY_RESULTS_DIR=/data/user_study_results`.
59
+
60
+ ### Option 2: CLI
61
+
62
+ ```bash
63
+ hf auth login
64
+ deploy_to_hf_space.bat username/space-name
65
+ ```
66
+
67
+ The helper script creates the Space if needed and uploads this repository while excluding `.venv`, `.gradio`, `__pycache__`, and `results`.
68
+
69
+ ## Files That Should Not Be Uploaded As Runtime Artifacts
70
+
71
+ The repository includes `.gitignore` rules so that local environments and transient results are not pushed by mistake:
72
+
73
+ - `.venv`
74
+ - `.gradio`
75
+ - `__pycache__`
76
+ - `results`
77
+
78
+ ## Analysis
79
+
80
+ To analyze collected responses:
81
+
82
+ ```bash
83
+ .\.venv\Scripts\python.exe analyze_results.py
84
+ ```
85
+
86
+ This writes:
87
+
88
+ - `summary.csv`
89
+ - `participant_overview.csv`
90
+ - `plots/preference_barplot.png`
91
+
92
+ inside the chosen results directory.
analyze_results.bat ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ setlocal
3
+
4
+ cd /d "%~dp0"
5
+
6
+ echo Running result analysis with the project virtual environment...
7
+
8
+ if not exist ".venv\Scripts\python.exe" (
9
+ echo [ERROR] Project virtual environment not found: .venv\Scripts\python.exe
10
+ echo Please create the virtual environment first or run the project setup again.
11
+ pause
12
+ exit /b 1
13
+ )
14
+
15
+ ".venv\Scripts\python.exe" "analyze_results.py"
16
+ if errorlevel 1 (
17
+ echo.
18
+ echo Analysis failed.
19
+ pause
20
+ exit /b 1
21
+ )
22
+
23
+ echo.
24
+ echo Opening generated outputs...
25
+
26
+ if exist "results\summary.csv" start "" "results\summary.csv"
27
+ if exist "results\participant_overview.csv" start "" "results\participant_overview.csv"
28
+ if exist "results\plots\preference_barplot.png" start "" "results\plots\preference_barplot.png"
29
+ if exist "results" start "" explorer "results"
30
+
31
+ echo.
32
+ echo Analysis completed successfully.
33
+ pause
analyze_results.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Dict, List
7
+
8
+ import matplotlib.pyplot as plt
9
+ import pandas as pd
10
+
11
+ from study_utils import CSV_COLUMNS, get_results_dir, load_study_config
12
+
13
+ PROJECT_ROOT = Path(__file__).resolve().parent
14
+
15
+ METRIC_COLUMNS = {
16
+ "similarity": "answer_similarity",
17
+ "quality": "answer_quality",
18
+ "overall_preference": "answer_preference",
19
+ }
20
+
21
+ METRIC_LABELS = {
22
+ "similarity": "Motion Similarity",
23
+ "quality": "Motion Quality",
24
+ "overall_preference": "Overall Preference",
25
+ }
26
+
27
+ METRIC_COLORS = {
28
+ "similarity": "#cfeedd",
29
+ "quality": "#cfe8f5",
30
+ "overall_preference": "#ddd0ee",
31
+ }
32
+
33
+ METHOD_COLORS = {
34
+ "anyact": "#7ea6e0",
35
+ "vlm_hy_motion": "#f2c27b",
36
+ "echomotion": "#9fd0b0",
37
+ }
38
+
39
+
40
+ def parse_args() -> argparse.Namespace:
41
+ parser = argparse.ArgumentParser(description="Analyze pairwise user study results.")
42
+ parser.add_argument(
43
+ "--config",
44
+ type=Path,
45
+ default=PROJECT_ROOT / "data" / "study_config.json",
46
+ help="Path to study_config.json.",
47
+ )
48
+ parser.add_argument(
49
+ "--input",
50
+ type=Path,
51
+ default=None,
52
+ help="Optional path to responses.csv or responses.jsonl. Defaults to results/responses.csv if available.",
53
+ )
54
+ parser.add_argument(
55
+ "--results-dir",
56
+ type=Path,
57
+ default=get_results_dir(PROJECT_ROOT),
58
+ help="Directory that stores responses and analysis outputs.",
59
+ )
60
+ parser.add_argument(
61
+ "--study-id",
62
+ type=str,
63
+ default=None,
64
+ help="Optional study_id to analyze. Defaults to the study_id in study_config.json.",
65
+ )
66
+ parser.add_argument(
67
+ "--include-incomplete",
68
+ action="store_true",
69
+ help="Include participants who have not answered every question in the selected study.",
70
+ )
71
+ return parser.parse_args()
72
+
73
+
74
+ def load_canonical_dataframe(input_path: Path | None, results_dir: Path) -> pd.DataFrame:
75
+ if input_path is None:
76
+ csv_path = results_dir / "responses.csv"
77
+ jsonl_path = results_dir / "responses.jsonl"
78
+ else:
79
+ csv_path = input_path if input_path.suffix.lower() == ".csv" else None
80
+ jsonl_path = input_path if input_path.suffix.lower() == ".jsonl" else None
81
+
82
+ latest_records: Dict[tuple[str, str, str], tuple[tuple[str, str, str], Dict[str, object]]] = {}
83
+
84
+ if jsonl_path and jsonl_path.exists() and jsonl_path.stat().st_size > 0:
85
+ with jsonl_path.open("r", encoding="utf-8") as handle:
86
+ for line in handle:
87
+ if not line.strip():
88
+ continue
89
+ try:
90
+ record = json.loads(line)
91
+ except json.JSONDecodeError:
92
+ continue
93
+ if "participant_id" not in record or "question_id" not in record:
94
+ continue
95
+ key = (
96
+ str(record.get("study_id", "")),
97
+ str(record["participant_id"]),
98
+ str(record["question_id"]),
99
+ )
100
+ sort_key = (
101
+ str(record.get("event_saved_at") or record.get("answered_at") or ""),
102
+ str(record.get("updated_at") or ""),
103
+ str(record.get("answered_at") or ""),
104
+ )
105
+ canonical_row = {column: record.get(column, "") for column in CSV_COLUMNS}
106
+ previous = latest_records.get(key)
107
+ if previous is None or sort_key >= previous[0]:
108
+ latest_records[key] = (sort_key, canonical_row)
109
+
110
+ if csv_path and csv_path.exists() and csv_path.stat().st_size > 0:
111
+ df = pd.read_csv(csv_path)
112
+ if not df.empty:
113
+ for _, row in df.iterrows():
114
+ record = row.to_dict()
115
+ if "participant_id" not in record or "question_id" not in record:
116
+ continue
117
+ key = (
118
+ str(record.get("study_id", "")),
119
+ str(record["participant_id"]),
120
+ str(record["question_id"]),
121
+ )
122
+ sort_key = (
123
+ str(record.get("event_saved_at") or record.get("answered_at") or ""),
124
+ str(record.get("updated_at") or ""),
125
+ str(record.get("answered_at") or ""),
126
+ )
127
+ canonical_row = {column: record.get(column, "") for column in CSV_COLUMNS}
128
+ previous = latest_records.get(key)
129
+ if previous is None or sort_key >= previous[0]:
130
+ latest_records[key] = (sort_key, canonical_row)
131
+
132
+ if latest_records:
133
+ canonical_df = pd.DataFrame(row for _, row in latest_records.values())
134
+ if not canonical_df.empty:
135
+ return canonical_df
136
+
137
+ raise FileNotFoundError(
138
+ "No response file was found. Please ensure results/responses.csv or results/responses.jsonl exists."
139
+ )
140
+
141
+
142
+ def normalize_dataframe(df: pd.DataFrame) -> pd.DataFrame:
143
+ df = df.copy()
144
+ for column in ["study_id", "participant_id", "question_id", "left_method", "right_method", "result_a_method", "result_b_method"]:
145
+ if column in df.columns:
146
+ df[column] = df[column].astype(str)
147
+ for column in METRIC_COLUMNS.values():
148
+ if column in df.columns:
149
+ df[column] = df[column].fillna("").astype(str).str.strip().str.lower()
150
+ if "question_position" in df.columns:
151
+ df["question_position"] = pd.to_numeric(df["question_position"], errors="coerce")
152
+ if "total_questions" in df.columns:
153
+ df["total_questions"] = pd.to_numeric(df["total_questions"], errors="coerce")
154
+ if "answered_at" in df.columns:
155
+ df["answered_at"] = pd.to_datetime(df["answered_at"], errors="coerce")
156
+ return df
157
+
158
+
159
+ def normalize_choice_for_analysis(raw_value: object) -> str:
160
+ if pd.isna(raw_value):
161
+ return ""
162
+ cleaned = str(raw_value or "").strip().lower()
163
+ compact = cleaned.replace(" ", "").replace("_", "").replace("-", "")
164
+ if compact in {"", "nan", "none"}:
165
+ return ""
166
+ if compact in {"left", "resulta", "a"}:
167
+ return "resulta"
168
+ if compact in {"right", "resultb", "b"}:
169
+ return "resultb"
170
+ if compact in {"tie", "equal", "same"}:
171
+ return "tie"
172
+ return cleaned
173
+
174
+
175
+ def filter_to_selected_study(df: pd.DataFrame, study_id: str) -> pd.DataFrame:
176
+ if "study_id" not in df.columns:
177
+ return df
178
+ filtered = df[df["study_id"].astype(str) == study_id].copy()
179
+ if filtered.empty:
180
+ raise ValueError(
181
+ f"No responses were found for study_id='{study_id}'. "
182
+ "Please check your study_config.json or pass --study-id explicitly."
183
+ )
184
+ return filtered
185
+
186
+
187
+ def filter_to_completed_participants(df: pd.DataFrame) -> pd.DataFrame:
188
+ """
189
+ Keep only participants who answered every question in the selected study.
190
+ This avoids mixing partial pilot sessions into the final analysis.
191
+ """
192
+ rows: List[pd.DataFrame] = []
193
+
194
+ for participant_id, participant_df in df.groupby("participant_id", sort=False):
195
+ expected_questions = int(participant_df["total_questions"].dropna().max())
196
+ answered_questions = participant_df["question_id"].nunique()
197
+ if answered_questions >= expected_questions > 0:
198
+ rows.append(participant_df)
199
+
200
+ if not rows:
201
+ raise ValueError(
202
+ "No completed participants were found for the selected study. "
203
+ "Use --include-incomplete if you want to analyze partial responses."
204
+ )
205
+
206
+ return pd.concat(rows, ignore_index=True)
207
+
208
+
209
+ def compute_method_percentages(df: pd.DataFrame, config: dict) -> pd.DataFrame:
210
+ rows: List[dict] = []
211
+ configured_pairs = [tuple(pair) for pair in config.get("pair_order", [])]
212
+
213
+ for pair in configured_pairs:
214
+ method_a, method_b = pair
215
+ pair_id = f"{method_a}_vs_{method_b}"
216
+ pair_df = df[df["pair_id"].astype(str) == pair_id].copy()
217
+ if pair_df.empty:
218
+ continue
219
+
220
+ pair_method_ids = [method_a, method_b]
221
+ pair_label = (
222
+ f"{config['methods'][method_a]['display_name']} vs "
223
+ f"{config['methods'][method_b]['display_name']}"
224
+ )
225
+
226
+ for metric_name, column_name in METRIC_COLUMNS.items():
227
+ selected_credit = {method_id: 0.0 for method_id in pair_method_ids}
228
+ appearances = {method_id: 0 for method_id in pair_method_ids}
229
+
230
+ for _, row in pair_df.iterrows():
231
+ result_a_method = str(row.get("result_a_method") or "").strip()
232
+ if not result_a_method or result_a_method.lower() == "nan":
233
+ result_a_method = str(row.get("left_method") or "").strip()
234
+
235
+ result_b_method = str(row.get("result_b_method") or "").strip()
236
+ if not result_b_method or result_b_method.lower() == "nan":
237
+ result_b_method = str(row.get("right_method") or "").strip()
238
+
239
+ selected_method = str(row.get(f"{column_name}_method", "") or "").strip()
240
+ if selected_method.lower() == "nan":
241
+ selected_method = ""
242
+ answer = normalize_choice_for_analysis(row.get(column_name, ""))
243
+
244
+ if result_a_method not in selected_credit or result_b_method not in selected_credit:
245
+ continue
246
+
247
+ appearances[result_a_method] += 1
248
+ appearances[result_b_method] += 1
249
+
250
+ if selected_method in selected_credit:
251
+ selected_credit[selected_method] += 1.0
252
+ elif answer == "left" or answer == "resulta":
253
+ selected_credit[result_a_method] += 1.0
254
+ elif answer == "right" or answer == "resultb":
255
+ selected_credit[result_b_method] += 1.0
256
+ elif answer == "tie":
257
+ selected_credit[result_a_method] += 0.5
258
+ selected_credit[result_b_method] += 0.5
259
+
260
+ for method_id in pair_method_ids:
261
+ denominator = appearances[method_id]
262
+ percentage = (selected_credit[method_id] / denominator * 100.0) if denominator else 0.0
263
+ rows.append(
264
+ {
265
+ "pair_id": pair_id,
266
+ "pair_label": pair_label,
267
+ "metric": metric_name,
268
+ "metric_label": METRIC_LABELS[metric_name],
269
+ "method_id": method_id,
270
+ "method_name": config["methods"][method_id]["display_name"],
271
+ "selected_credit": round(selected_credit[method_id], 4),
272
+ "appearances": denominator,
273
+ "selected_percentage": round(percentage, 4),
274
+ }
275
+ )
276
+
277
+ return pd.DataFrame(rows)
278
+
279
+
280
+ def build_analysis_overview(df: pd.DataFrame, study_id: str) -> pd.DataFrame:
281
+ participant_stats = (
282
+ df.groupby("participant_id")
283
+ .agg(
284
+ answered_questions=("question_id", "nunique"),
285
+ total_questions=("total_questions", "max"),
286
+ first_answered_at=("answered_at", "min"),
287
+ last_answered_at=("answered_at", "max"),
288
+ )
289
+ .reset_index()
290
+ )
291
+ participant_stats.insert(1, "study_id", study_id)
292
+ return participant_stats
293
+
294
+
295
+ def plot_summary(summary_df: pd.DataFrame, plot_path: Path, config: dict) -> None:
296
+ metric_order = ["similarity", "quality", "overall_preference"]
297
+ metric_positions = list(range(len(metric_order)))
298
+ pair_order = [tuple(pair) for pair in config.get("pair_order", [])]
299
+
300
+ plot_specs: List[dict] = []
301
+ for pair_index, pair in enumerate(pair_order):
302
+ method_a, method_b = pair
303
+ pair_id = f"{method_a}_vs_{method_b}"
304
+ method_a_name = config["methods"][method_a]["display_name"]
305
+ method_b_name = config["methods"][method_b]["display_name"]
306
+ plot_specs.append(
307
+ {
308
+ "pair_id": pair_id,
309
+ "method_id": method_a,
310
+ "legend_label": f"{method_a_name} (vs {method_b_name})",
311
+ "color": METHOD_COLORS.get(method_a, "#cbd5e1"),
312
+ "hatch": None if pair_index == 0 else "//",
313
+ }
314
+ )
315
+ plot_specs.append(
316
+ {
317
+ "pair_id": pair_id,
318
+ "method_id": method_b,
319
+ "legend_label": method_b_name,
320
+ "color": METHOD_COLORS.get(method_b, "#cbd5e1"),
321
+ "hatch": None,
322
+ }
323
+ )
324
+
325
+ bar_width = 0.16
326
+ if plot_specs:
327
+ center = (len(plot_specs) - 1) / 2
328
+ offsets = [(index - center) * 0.18 for index in range(len(plot_specs))]
329
+ else:
330
+ offsets = []
331
+
332
+ fig, ax = plt.subplots(figsize=(5.4, 3.5), dpi=300)
333
+ fig.patch.set_facecolor("white")
334
+ ax.set_facecolor("white")
335
+
336
+ for spec_index, spec in enumerate(plot_specs):
337
+ method_summary = (
338
+ summary_df[
339
+ (summary_df["pair_id"] == spec["pair_id"])
340
+ & (summary_df["method_id"] == spec["method_id"])
341
+ ].set_index("metric")
342
+ .reindex(metric_order)
343
+ )
344
+ positions = [center + offsets[spec_index] for center in metric_positions]
345
+ values = method_summary["selected_percentage"].tolist()
346
+ bars = ax.bar(
347
+ positions,
348
+ values,
349
+ width=bar_width * 0.92,
350
+ label=spec["legend_label"],
351
+ color=spec["color"],
352
+ edgecolor="white",
353
+ linewidth=0.6,
354
+ hatch=spec["hatch"],
355
+ )
356
+
357
+ for bar, value in zip(bars, values):
358
+ ax.text(
359
+ bar.get_x() + bar.get_width() / 2,
360
+ value + 1.4,
361
+ f"{value:.1f}%",
362
+ va="bottom",
363
+ ha="center",
364
+ fontsize=8,
365
+ color="#334155",
366
+ )
367
+
368
+ ax.set_xticks(metric_positions)
369
+ ax.set_xticklabels([METRIC_LABELS[metric_name] for metric_name in metric_order], fontsize=8)
370
+ ax.set_ylabel("Selection Rate When Shown (%)", fontsize=8)
371
+
372
+ x_max = max(100.0, float(summary_df["selected_percentage"].max()) + 8.0)
373
+ ax.set_ylim(0, x_max)
374
+ ax.tick_params(axis="y", labelsize=8)
375
+ ax.tick_params(axis="x", length=0)
376
+ ax.grid(axis="y", color="#edf2f7", linewidth=0.8)
377
+ ax.set_axisbelow(True)
378
+ ax.spines["top"].set_visible(False)
379
+ ax.spines["right"].set_visible(False)
380
+ ax.legend(
381
+ frameon=False,
382
+ fontsize=7.0,
383
+ loc="upper center",
384
+ bbox_to_anchor=(0.5, 1.12),
385
+ ncol=2,
386
+ handlelength=1.2,
387
+ columnspacing=1.2,
388
+ )
389
+ plt.tight_layout(pad=0.7)
390
+
391
+ plot_path.parent.mkdir(parents=True, exist_ok=True)
392
+ fig.savefig(plot_path, bbox_inches="tight", dpi=300)
393
+ pdf_path = plot_path.with_suffix(".pdf")
394
+ fig.savefig(pdf_path, bbox_inches="tight")
395
+ plt.close(fig)
396
+
397
+
398
+ def main() -> None:
399
+ args = parse_args()
400
+ config = load_study_config(args.config)
401
+ study_id = args.study_id or config["study_id"]
402
+ results_dir = args.results_dir.resolve()
403
+ results_dir.mkdir(parents=True, exist_ok=True)
404
+ (results_dir / "plots").mkdir(parents=True, exist_ok=True)
405
+
406
+ df = load_canonical_dataframe(args.input, results_dir)
407
+ df = normalize_dataframe(df)
408
+ if df.empty:
409
+ raise ValueError("The response table is empty. No analysis output was generated.")
410
+
411
+ df = filter_to_selected_study(df, study_id)
412
+ if not args.include_incomplete:
413
+ df = filter_to_completed_participants(df)
414
+
415
+ summary_df = compute_method_percentages(df, config)
416
+ overview_df = build_analysis_overview(df, study_id)
417
+
418
+ summary_path = results_dir / "summary.csv"
419
+ overview_path = results_dir / "participant_overview.csv"
420
+ plot_path = results_dir / "plots" / "preference_barplot.png"
421
+
422
+ summary_df.to_csv(summary_path, index=False)
423
+ overview_df.to_csv(overview_path, index=False)
424
+ plot_summary(summary_df, plot_path, config)
425
+
426
+ participant_count = overview_df["participant_id"].nunique()
427
+ response_count = df["question_id"].nunique()
428
+
429
+ print(f"Study ID: {study_id}")
430
+ print(f"Participants analyzed: {participant_count}")
431
+ print(f"Unique questions covered: {response_count}")
432
+ print(f"Saved summary table to: {summary_path}")
433
+ print(f"Saved participant overview to: {overview_path}")
434
+ print(f"Saved plot to: {plot_path}")
435
+
436
+
437
+ if __name__ == "__main__":
438
+ main()
app.py ADDED
@@ -0,0 +1,2092 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import base64
5
+ import html
6
+ import os
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import warnings
11
+ from pathlib import Path
12
+ from typing import Any, Tuple
13
+
14
+ import gradio as gr
15
+ from gradio import processing_utils as gr_processing_utils
16
+
17
+ try:
18
+ import imageio_ffmpeg
19
+ except ImportError: # pragma: no cover - optional runtime dependency
20
+ imageio_ffmpeg = None
21
+
22
+ from study_utils import (
23
+ CHOICE_OPTIONS,
24
+ build_completion_markdown,
25
+ build_question_payload,
26
+ create_or_resume_participant,
27
+ ensure_runtime_dirs,
28
+ ensure_video_thumbnail,
29
+ generate_participant_id,
30
+ get_instruction_case,
31
+ get_results_dir,
32
+ load_study_config,
33
+ move_question_pointer,
34
+ prepare_reference_videos_for_web,
35
+ sanitize_participant_id,
36
+ save_current_answer,
37
+ ensure_synchronized_study_videos,
38
+ upgrade_existing_results_schema,
39
+ )
40
+
41
+ PROJECT_ROOT = Path(__file__).resolve().parent
42
+
43
+
44
+ def default_server_name() -> str:
45
+ return "0.0.0.0" if os.environ.get("SPACE_ID") else "127.0.0.1"
46
+
47
+
48
+ def ensure_local_ffmpeg() -> None:
49
+ if imageio_ffmpeg is None:
50
+ return
51
+
52
+ ffmpeg_source = Path(imageio_ffmpeg.get_ffmpeg_exe()).resolve()
53
+ runtime_bin = get_results_dir(PROJECT_ROOT) / "runtime_bin"
54
+ runtime_bin.mkdir(parents=True, exist_ok=True)
55
+ ffmpeg_target = runtime_bin / "ffmpeg.exe"
56
+
57
+ if not ffmpeg_target.exists():
58
+ shutil.copy2(ffmpeg_source, ffmpeg_target)
59
+
60
+ os.environ["IMAGEIO_FFMPEG_EXE"] = str(ffmpeg_target)
61
+ current_path = os.environ.get("PATH", "")
62
+ runtime_bin_str = str(runtime_bin)
63
+ if runtime_bin_str.lower() not in current_path.lower():
64
+ os.environ["PATH"] = runtime_bin_str + os.pathsep + current_path
65
+
66
+
67
+ def _probe_video_codec_with_ffmpeg(video_path: str | Path) -> tuple[str, str]:
68
+ if imageio_ffmpeg is None:
69
+ return "", ""
70
+
71
+ path = Path(video_path)
72
+ ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
73
+ result = subprocess.run(
74
+ [ffmpeg_exe, "-i", str(path)],
75
+ capture_output=True,
76
+ text=True,
77
+ encoding="utf-8",
78
+ errors="ignore",
79
+ )
80
+ probe_text = result.stderr or ""
81
+ codec_match = re.search(r"Video:\s*([^\s,(]+)", probe_text)
82
+ codec_name = (codec_match.group(1) if codec_match else "").strip().lower()
83
+ return path.suffix.lower(), codec_name
84
+
85
+
86
+ def patched_video_is_playable(video_filepath: str) -> bool:
87
+ """
88
+ Avoid Gradio's hard dependency on ffprobe by checking playability
89
+ with the bundled imageio-ffmpeg binary instead.
90
+ """
91
+ try:
92
+ container, video_codec = _probe_video_codec_with_ffmpeg(video_filepath)
93
+ return (container, video_codec) in {
94
+ (".mp4", "h264"),
95
+ (".mp4", "av1"),
96
+ (".ogg", "theora"),
97
+ (".webm", "vp9"),
98
+ (".webm", "vp8"),
99
+ (".webm", "av1"),
100
+ }
101
+ except Exception:
102
+ return True
103
+
104
+
105
+ def patch_gradio_video_probe() -> None:
106
+ gr_processing_utils.video_is_playable = patched_video_is_playable
107
+
108
+ warnings.filterwarnings(
109
+ "ignore",
110
+ message=r"The 'css' parameter in the Blocks constructor will be removed in Gradio 6\.0\..*",
111
+ category=DeprecationWarning,
112
+ )
113
+ warnings.filterwarnings(
114
+ "ignore",
115
+ message=r"The 'theme' parameter in the Blocks constructor will be removed in Gradio 6\.0\..*",
116
+ category=DeprecationWarning,
117
+ )
118
+ warnings.filterwarnings(
119
+ "ignore",
120
+ message=r"The 'head' parameter in the Blocks constructor will be removed in Gradio 6\.0\..*",
121
+ category=DeprecationWarning,
122
+ )
123
+
124
+ CUSTOM_HEAD = """
125
+ <script>
126
+ function anyactForceLightTheme() {
127
+ document.documentElement.classList.remove("dark");
128
+ document.body.classList.remove("dark");
129
+ document.documentElement.setAttribute("data-theme", "light");
130
+ document.body.setAttribute("data-theme", "light");
131
+ document.documentElement.style.colorScheme = "light";
132
+ document.body.style.colorScheme = "light";
133
+ try {
134
+ localStorage.setItem("theme", "light");
135
+ localStorage.setItem("gradio-theme", "light");
136
+ localStorage.setItem("gradio_mode", "light");
137
+ } catch (error) {}
138
+ document.querySelectorAll(".dark").forEach((node) => node.classList.remove("dark"));
139
+ }
140
+
141
+ function anyactForcePlayVideos() {
142
+ const videos = document.querySelectorAll("video");
143
+ videos.forEach((video) => {
144
+ if (!video) return;
145
+ const attemptPlay = () => video.play().catch(() => {
146
+ video.muted = true;
147
+ video.play().catch(() => {});
148
+ });
149
+ if (video.readyState >= 2) {
150
+ attemptPlay();
151
+ } else {
152
+ video.addEventListener("loadeddata", attemptPlay, { once: true });
153
+ }
154
+ });
155
+ }
156
+
157
+ function anyactGetStudyVideoElement(elemId) {
158
+ const root = document.getElementById(elemId);
159
+ return root ? root.querySelector("video") : null;
160
+ }
161
+
162
+ function anyactSetupStudyVideoSync() {
163
+ const reference = anyactGetStudyVideoElement("study-reference-video");
164
+ const left = anyactGetStudyVideoElement("study-left-video");
165
+ const right = anyactGetStudyVideoElement("study-right-video");
166
+
167
+ if (!reference || !left || !right) {
168
+ if (window.__anyactStudySync && typeof window.__anyactStudySync.cleanup === "function") {
169
+ window.__anyactStudySync.cleanup();
170
+ }
171
+ window.__anyactStudySync = null;
172
+ return;
173
+ }
174
+
175
+ const trio = [reference, left, right];
176
+ const signature = trio.map((video) => video.currentSrc || video.src || "").join("|");
177
+ if (window.__anyactStudySync && window.__anyactStudySync.signature === signature) {
178
+ return;
179
+ }
180
+
181
+ if (window.__anyactStudySync && typeof window.__anyactStudySync.cleanup === "function") {
182
+ window.__anyactStudySync.cleanup();
183
+ }
184
+
185
+ let suppressEvents = false;
186
+ let lastReferenceTime = 0;
187
+ const cleanupFns = [];
188
+
189
+ const withSuppressedEvents = (fn) => {
190
+ suppressEvents = true;
191
+ try {
192
+ fn();
193
+ } finally {
194
+ clearTimeout(window.__anyactStudySyncSuppressTimer);
195
+ window.__anyactStudySyncSuppressTimer = setTimeout(() => {
196
+ suppressEvents = false;
197
+ }, 120);
198
+ }
199
+ };
200
+
201
+ const syncTimes = (masterVideo, force = false) => {
202
+ const targetTime = Number.isFinite(masterVideo.currentTime) ? masterVideo.currentTime : 0;
203
+ trio.forEach((video) => {
204
+ if (video === masterVideo) return;
205
+ if (force || Math.abs((video.currentTime || 0) - targetTime) > 0.05) {
206
+ try {
207
+ video.currentTime = targetTime;
208
+ } catch (error) {}
209
+ }
210
+ });
211
+ };
212
+
213
+ const syncPlaybackRate = (masterVideo) => {
214
+ trio.forEach((video) => {
215
+ if (video === masterVideo) return;
216
+ if (video.playbackRate !== masterVideo.playbackRate) {
217
+ video.playbackRate = masterVideo.playbackRate;
218
+ }
219
+ });
220
+ };
221
+
222
+ const playAll = (masterVideo = reference) => {
223
+ withSuppressedEvents(() => {
224
+ syncTimes(masterVideo, true);
225
+ syncPlaybackRate(masterVideo);
226
+ });
227
+ trio.forEach((video) => {
228
+ video.loop = true;
229
+ video.muted = true;
230
+ video.playsInline = true;
231
+ const playPromise = video.play();
232
+ if (playPromise && typeof playPromise.catch === "function") {
233
+ playPromise.catch(() => {});
234
+ }
235
+ });
236
+ };
237
+
238
+ const pauseOthers = (sourceVideo) => {
239
+ trio.forEach((video) => {
240
+ if (video === sourceVideo) return;
241
+ if (!video.paused) {
242
+ try {
243
+ video.pause();
244
+ } catch (error) {}
245
+ }
246
+ });
247
+ };
248
+
249
+ const restartAll = () => {
250
+ withSuppressedEvents(() => {
251
+ trio.forEach((video) => {
252
+ try {
253
+ video.pause();
254
+ } catch (error) {}
255
+ try {
256
+ video.currentTime = 0;
257
+ } catch (error) {}
258
+ });
259
+ });
260
+ playAll(reference);
261
+ };
262
+
263
+ const bind = (video, eventName, handler) => {
264
+ video.addEventListener(eventName, handler);
265
+ cleanupFns.push(() => video.removeEventListener(eventName, handler));
266
+ };
267
+
268
+ trio.forEach((video) => {
269
+ video.loop = true;
270
+ video.muted = true;
271
+ video.playsInline = true;
272
+
273
+ bind(video, "play", () => {
274
+ if (suppressEvents) return;
275
+ playAll(video);
276
+ });
277
+
278
+ bind(video, "pause", () => {
279
+ if (suppressEvents) return;
280
+ withSuppressedEvents(() => {
281
+ pauseOthers(video);
282
+ });
283
+ });
284
+
285
+ bind(video, "seeked", () => {
286
+ if (suppressEvents) return;
287
+ withSuppressedEvents(() => {
288
+ syncTimes(video, true);
289
+ });
290
+ });
291
+
292
+ bind(video, "ratechange", () => {
293
+ if (suppressEvents) return;
294
+ withSuppressedEvents(() => {
295
+ syncPlaybackRate(video);
296
+ });
297
+ });
298
+
299
+ bind(video, "loadeddata", () => {
300
+ if (trio.every((item) => item.readyState >= 2)) {
301
+ playAll(reference);
302
+ }
303
+ });
304
+ });
305
+
306
+ bind(reference, "ended", () => {
307
+ if (suppressEvents) return;
308
+ restartAll();
309
+ });
310
+
311
+ const driftTimer = setInterval(() => {
312
+ const currentReferenceTime = Number.isFinite(reference.currentTime) ? reference.currentTime : 0;
313
+ const loopWrapped = lastReferenceTime > 0.35 && currentReferenceTime + 0.2 < lastReferenceTime;
314
+ if (loopWrapped) {
315
+ withSuppressedEvents(() => {
316
+ syncTimes(reference, true);
317
+ syncPlaybackRate(reference);
318
+ });
319
+ if (!reference.paused) {
320
+ trio.forEach((video) => {
321
+ const playPromise = video.play();
322
+ if (playPromise && typeof playPromise.catch === "function") {
323
+ playPromise.catch(() => {});
324
+ }
325
+ });
326
+ }
327
+ }
328
+ lastReferenceTime = currentReferenceTime;
329
+ if (document.hidden || reference.paused) return;
330
+ syncTimes(reference, false);
331
+ }, 200);
332
+ cleanupFns.push(() => clearInterval(driftTimer));
333
+
334
+ window.__anyactStudySync = {
335
+ signature,
336
+ cleanup: () => {
337
+ cleanupFns.forEach((cleanup) => {
338
+ try {
339
+ cleanup();
340
+ } catch (error) {}
341
+ });
342
+ },
343
+ };
344
+
345
+ if (trio.every((video) => video.readyState >= 2)) {
346
+ playAll(reference);
347
+ }
348
+ }
349
+
350
+ window.addEventListener("load", () => {
351
+ anyactForceLightTheme();
352
+ anyactForcePlayVideos();
353
+ anyactSetupStudyVideoSync();
354
+ const observer = new MutationObserver(() => {
355
+ clearTimeout(window.__anyactVideoTimer);
356
+ window.__anyactVideoTimer = setTimeout(() => {
357
+ anyactForceLightTheme();
358
+ anyactForcePlayVideos();
359
+ anyactSetupStudyVideoSync();
360
+ }, 250);
361
+ });
362
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true });
363
+ });
364
+ </script>
365
+ """
366
+
367
+ CUSTOM_CSS = """
368
+ :root,
369
+ html,
370
+ body {
371
+ color-scheme: light !important;
372
+ background: #eef2f7 !important;
373
+ color: #0f172a !important;
374
+ }
375
+
376
+ .gradio-container {
377
+ max-width: 1380px !important;
378
+ margin: 0 auto !important;
379
+ padding-bottom: 32px !important;
380
+ font-family: "Segoe UI", "Helvetica Neue", sans-serif !important;
381
+ background:
382
+ radial-gradient(circle at top left, rgba(203, 213, 225, 0.26), transparent 28%),
383
+ linear-gradient(180deg, #f7f8fb 0%, #eef2f7 100%);
384
+ }
385
+
386
+ .gradio-container,
387
+ .gradio-container *,
388
+ .gradio-container .dark,
389
+ .gradio-container .dark * {
390
+ --body-background-fill: #f7f8fb !important;
391
+ --body-background-fill-subdued: #eef2f7 !important;
392
+ --background-fill-primary: #ffffff !important;
393
+ --background-fill-secondary: #f8fafc !important;
394
+ --block-background-fill: #ffffff !important;
395
+ --block-border-color: #d8dee8 !important;
396
+ --panel-background-fill: #ffffff !important;
397
+ --panel-border-color: #d8dee8 !important;
398
+ --input-background-fill: #ffffff !important;
399
+ --input-border-color: #cbd5e1 !important;
400
+ --checkbox-background-color: #ffffff !important;
401
+ --checkbox-border-color: #94a3b8 !important;
402
+ --checkbox-label-text-color: #0f172a !important;
403
+ --body-text-color: #0f172a !important;
404
+ --block-label-text-color: #0f172a !important;
405
+ --block-title-text-color: #0f172a !important;
406
+ --button-secondary-background-fill: #ffffff !important;
407
+ --button-secondary-text-color: #1d4ed8 !important;
408
+ }
409
+
410
+ .gradio-container div,
411
+ .gradio-container section,
412
+ .gradio-container article,
413
+ .gradio-container form,
414
+ .gradio-container fieldset,
415
+ .gradio-container label,
416
+ .gradio-container [data-testid="block"],
417
+ .gradio-container [data-testid="textbox"],
418
+ .gradio-container [data-testid="checkbox"],
419
+ .gradio-container [data-testid="radio"],
420
+ .gradio-container [data-testid="markdown"],
421
+ .gradio-container [data-testid="group"] {
422
+ color: #0f172a !important;
423
+ }
424
+
425
+ .gradio-container,
426
+ .gradio-container .prose,
427
+ .gradio-container .prose p,
428
+ .gradio-container .prose li,
429
+ .gradio-container .prose strong,
430
+ .gradio-container .prose h1,
431
+ .gradio-container .prose h2,
432
+ .gradio-container .prose h3,
433
+ .gradio-container .prose h4,
434
+ .gradio-container label,
435
+ .gradio-container p,
436
+ .gradio-container li,
437
+ .gradio-container h1,
438
+ .gradio-container h2,
439
+ .gradio-container h3,
440
+ .gradio-container h4 {
441
+ color: #0f172a !important;
442
+ }
443
+
444
+ .block-title h1,
445
+ .block-title h2,
446
+ .section-heading {
447
+ font-family: "Libre Baskerville", Georgia, serif !important;
448
+ }
449
+
450
+ .hero-card,
451
+ .panel-card,
452
+ .form-card {
453
+ background: rgba(255, 255, 255, 0.96);
454
+ border: 1px solid #d8dee8;
455
+ border-radius: 20px;
456
+ box-shadow: 0 14px 40px rgba(15, 23, 42, 0.06);
457
+ }
458
+
459
+ .hero-card {
460
+ padding: 28px 30px;
461
+ }
462
+
463
+ .panel-card {
464
+ padding: 22px 24px;
465
+ }
466
+
467
+ .form-card {
468
+ padding: 18px;
469
+ }
470
+
471
+ .form-card textarea,
472
+ .form-card input {
473
+ background: #ffffff !important;
474
+ color: #0f172a !important;
475
+ }
476
+
477
+ .form-card,
478
+ .question-card,
479
+ .form-card > div,
480
+ .question-card > div,
481
+ .form-card [data-testid],
482
+ .question-card [data-testid],
483
+ .form-card .prose,
484
+ .question-card .prose,
485
+ .hero-card [data-testid],
486
+ .panel-card [data-testid] {
487
+ background: #ffffff !important;
488
+ color: #0f172a !important;
489
+ }
490
+
491
+ .gradio-container input,
492
+ .gradio-container textarea,
493
+ .gradio-container select {
494
+ background: #ffffff !important;
495
+ color: #0f172a !important;
496
+ }
497
+
498
+ .gradio-container [data-testid="checkbox"],
499
+ .gradio-container [data-testid="checkbox"] * {
500
+ background: transparent !important;
501
+ color: #0f172a !important;
502
+ }
503
+
504
+ .gradio-container [data-testid="textbox"],
505
+ .gradio-container [data-testid="textbox"] > *,
506
+ .gradio-container [data-testid="textbox"] textarea,
507
+ .gradio-container [data-testid="textbox"] input {
508
+ background: #ffffff !important;
509
+ color: #0f172a !important;
510
+ }
511
+
512
+ .gradio-container [data-testid="markdown"],
513
+ .gradio-container [data-testid="markdown"] > *,
514
+ .gradio-container [data-testid="group"],
515
+ .gradio-container [data-testid="group"] > * {
516
+ background: transparent !important;
517
+ color: #0f172a !important;
518
+ }
519
+
520
+ .instruction-shell {
521
+ display: flex;
522
+ flex-direction: column;
523
+ gap: 20px;
524
+ }
525
+
526
+ .instruction-top {
527
+ display: grid;
528
+ grid-template-columns: 1.02fr 0.98fr;
529
+ gap: 20px;
530
+ align-items: start;
531
+ }
532
+
533
+ .instruction-copy {
534
+ display: flex;
535
+ flex-direction: column;
536
+ gap: 16px;
537
+ }
538
+
539
+ .instruction-top-right {
540
+ display: flex;
541
+ flex-direction: column;
542
+ gap: 16px;
543
+ }
544
+
545
+ .lead-text {
546
+ color: #334155;
547
+ font-size: 17px;
548
+ line-height: 1.6;
549
+ margin: 0;
550
+ }
551
+
552
+ .instruction-list {
553
+ margin: 0;
554
+ padding-left: 20px;
555
+ color: #334155;
556
+ line-height: 1.6;
557
+ }
558
+
559
+ .example-caption-note {
560
+ margin: 0 0 10px 0;
561
+ padding: 10px 14px;
562
+ border-radius: 14px;
563
+ border: 1px solid #d9e2ec;
564
+ background: #f8fbff;
565
+ color: #334155;
566
+ line-height: 1.6;
567
+ }
568
+
569
+ .instruction-bottom {
570
+ display: grid;
571
+ grid-template-columns: 1.08fr 0.92fr;
572
+ gap: 20px;
573
+ align-items: start;
574
+ }
575
+
576
+ .instruction-bottom-left,
577
+ .instruction-bottom-right {
578
+ display: flex;
579
+ flex-direction: column;
580
+ gap: 14px;
581
+ }
582
+
583
+ .metric-card {
584
+ border: 1px solid #d9e2ec;
585
+ border-radius: 16px;
586
+ padding: 16px;
587
+ background: #fbfcfe;
588
+ }
589
+
590
+ .metric-card h4 {
591
+ margin: 0 0 8px 0;
592
+ font-size: 17px;
593
+ color: #0f172a;
594
+ }
595
+
596
+ .metric-card p {
597
+ margin: 0;
598
+ color: #475569;
599
+ line-height: 1.55;
600
+ }
601
+
602
+ .metric-stack {
603
+ display: flex;
604
+ flex-direction: column;
605
+ gap: 12px;
606
+ margin-top: 14px;
607
+ }
608
+
609
+ .diagram-card {
610
+ border: 1px solid #d9e2ec;
611
+ border-radius: 18px;
612
+ padding: 18px;
613
+ background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
614
+ }
615
+
616
+ .case-walkthrough {
617
+ display: flex;
618
+ flex-direction: column;
619
+ gap: 14px;
620
+ }
621
+
622
+ .case-walkthrough-head h3 {
623
+ margin: 0;
624
+ font-size: 24px;
625
+ color: #0f172a;
626
+ font-family: "Libre Baskerville", Georgia, serif !important;
627
+ }
628
+
629
+ .case-walkthrough-head p {
630
+ margin: 6px 0 0 0;
631
+ color: #475569;
632
+ line-height: 1.55;
633
+ }
634
+
635
+ .walkthrough-grid {
636
+ display: grid;
637
+ grid-template-columns: 1.15fr 1fr 1fr;
638
+ gap: 12px;
639
+ }
640
+
641
+ .thumb-card {
642
+ background: #ffffff;
643
+ border: 1px solid #d8e2ef;
644
+ border-radius: 16px;
645
+ overflow: hidden;
646
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
647
+ }
648
+
649
+ .thumb-card.ref-card {
650
+ border-color: #93c5fd;
651
+ }
652
+
653
+ .thumb-image {
654
+ aspect-ratio: 1.2 / 1;
655
+ background: #eef2f7;
656
+ overflow: hidden;
657
+ }
658
+
659
+ .thumb-image img {
660
+ width: 100%;
661
+ height: 100%;
662
+ object-fit: cover;
663
+ display: block;
664
+ }
665
+
666
+ .thumb-body {
667
+ padding: 12px 13px 14px;
668
+ }
669
+
670
+ .thumb-title {
671
+ display: inline-block;
672
+ font-size: 12px;
673
+ font-weight: 700;
674
+ letter-spacing: 0.06em;
675
+ text-transform: uppercase;
676
+ color: #1d4ed8;
677
+ background: #dbeafe;
678
+ border-radius: 999px;
679
+ padding: 5px 9px;
680
+ margin-bottom: 8px;
681
+ }
682
+
683
+ .thumb-card.candidate-card .thumb-title {
684
+ color: #0f172a;
685
+ background: #eaf1fb;
686
+ }
687
+
688
+ .thumb-body h4 {
689
+ margin: 0 0 6px 0;
690
+ font-size: 17px;
691
+ color: #0f172a;
692
+ }
693
+
694
+ .thumb-body p {
695
+ margin: 0;
696
+ font-size: 14px;
697
+ color: #475569;
698
+ line-height: 1.5;
699
+ }
700
+
701
+ .walkthrough-note {
702
+ border: 1px solid #d9e2ec;
703
+ border-radius: 16px;
704
+ background: #ffffff;
705
+ padding: 14px 15px;
706
+ }
707
+
708
+ .walkthrough-note > strong {
709
+ display: block;
710
+ color: #0f172a;
711
+ margin-bottom: 6px;
712
+ font-size: 15px;
713
+ }
714
+
715
+ .walkthrough-note p {
716
+ margin: 0;
717
+ color: #475569;
718
+ line-height: 1.6;
719
+ }
720
+
721
+ .walkthrough-note p + p {
722
+ margin-top: 10px;
723
+ }
724
+
725
+ .walkthrough-note p strong {
726
+ display: inline;
727
+ margin: 0;
728
+ font-size: inherit;
729
+ color: #0f172a;
730
+ }
731
+
732
+ .walkthrough-note.accent-note {
733
+ background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%);
734
+ border-color: #bfdbfe;
735
+ }
736
+
737
+ .progress-chip {
738
+ display: inline-block;
739
+ padding: 8px 14px;
740
+ border-radius: 999px;
741
+ background: #0f172a;
742
+ color: white;
743
+ font-weight: 700;
744
+ letter-spacing: 0.01em;
745
+ }
746
+
747
+ .meta-line {
748
+ margin-top: 8px;
749
+ color: #475569;
750
+ font-size: 15px;
751
+ }
752
+
753
+ .study-shell .gr-video {
754
+ border-radius: 16px !important;
755
+ overflow: hidden !important;
756
+ border: 1px solid #dbe2ea !important;
757
+ background: #f8fafc !important;
758
+ }
759
+
760
+ .gradio-container video {
761
+ background: #0f172a !important;
762
+ }
763
+
764
+ .video-panel {
765
+ background: #ffffff;
766
+ }
767
+
768
+ .reference-panel {
769
+ border: 1px solid #bfdbfe !important;
770
+ box-shadow: 0 12px 28px rgba(37, 99, 235, 0.08);
771
+ }
772
+
773
+ .candidate-panel {
774
+ border: 1px solid #dbe4ee !important;
775
+ }
776
+
777
+ .video-caption {
778
+ color: #475569;
779
+ font-size: 14px;
780
+ margin-top: -4px;
781
+ margin-bottom: 10px;
782
+ }
783
+
784
+ .question-card {
785
+ padding: 18px 20px;
786
+ border-radius: 18px;
787
+ border: 1px solid #d9e2ec;
788
+ background: rgba(255, 255, 255, 0.9);
789
+ }
790
+
791
+ .choice-input {
792
+ margin-top: 8px;
793
+ }
794
+
795
+ .choice-input fieldset {
796
+ border: 1px solid #d7e3f4 !important;
797
+ border-radius: 16px !important;
798
+ background: #f8fbff !important;
799
+ padding: 12px 14px !important;
800
+ }
801
+
802
+ .choice-input label {
803
+ border: 1px solid #cbdcf5 !important;
804
+ border-radius: 12px !important;
805
+ background: #ffffff !important;
806
+ padding: 10px 12px !important;
807
+ color: #1e293b !important;
808
+ font-weight: 600 !important;
809
+ transition: all 0.2s ease !important;
810
+ }
811
+
812
+ .choice-input label:hover {
813
+ border-color: #60a5fa !important;
814
+ background: #eff6ff !important;
815
+ }
816
+
817
+ .choice-input label:has(input:checked) {
818
+ border-color: #3b82f6 !important;
819
+ background: linear-gradient(180deg, #dbeafe 0%, #eff6ff 100%) !important;
820
+ color: #1d4ed8 !important;
821
+ box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.12) !important;
822
+ }
823
+
824
+ .choice-input input[type="radio"] {
825
+ accent-color: #2563eb !important;
826
+ }
827
+
828
+ .gradio-container button {
829
+ transition: transform 0.15s ease, box-shadow 0.15s ease !important;
830
+ }
831
+
832
+ .gradio-container button.primary {
833
+ background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%) !important;
834
+ color: #ffffff !important;
835
+ border: none !important;
836
+ box-shadow: 0 8px 20px rgba(37, 99, 235, 0.18) !important;
837
+ }
838
+
839
+ .gradio-container button.primary:hover {
840
+ transform: translateY(-1px);
841
+ box-shadow: 0 12px 24px rgba(37, 99, 235, 0.22) !important;
842
+ }
843
+
844
+ .gradio-container button.secondary {
845
+ background: #ffffff !important;
846
+ color: #1d4ed8 !important;
847
+ border: 1px solid #bfdbfe !important;
848
+ }
849
+
850
+ .language-switch-row {
851
+ justify-content: flex-end;
852
+ margin-bottom: 8px;
853
+ }
854
+
855
+ .language-switch {
856
+ min-width: 210px;
857
+ }
858
+
859
+ .language-switch fieldset {
860
+ border: 1px solid #cfe0fb !important;
861
+ border-radius: 999px !important;
862
+ background: rgba(255, 255, 255, 0.92) !important;
863
+ padding: 6px !important;
864
+ box-shadow: 0 8px 20px rgba(37, 99, 235, 0.08) !important;
865
+ }
866
+
867
+ .language-switch label {
868
+ border: none !important;
869
+ border-radius: 999px !important;
870
+ background: transparent !important;
871
+ color: #475569 !important;
872
+ min-height: 38px !important;
873
+ padding: 8px 16px !important;
874
+ font-weight: 700 !important;
875
+ transition: all 0.18s ease !important;
876
+ }
877
+
878
+ .language-switch label:hover {
879
+ background: #eff6ff !important;
880
+ color: #1d4ed8 !important;
881
+ }
882
+
883
+ .language-switch label:has(input:checked) {
884
+ background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%) !important;
885
+ color: #ffffff !important;
886
+ box-shadow: 0 8px 16px rgba(37, 99, 235, 0.18) !important;
887
+ }
888
+
889
+ .language-switch input[type="radio"] {
890
+ display: none !important;
891
+ }
892
+
893
+ .thank-card {
894
+ max-width: 760px;
895
+ margin: 0 auto;
896
+ padding: 28px 30px;
897
+ text-align: left;
898
+ }
899
+
900
+ .muted-caption {
901
+ color: #64748b;
902
+ font-size: 14px;
903
+ }
904
+
905
+ @media (max-width: 900px) {
906
+ .instruction-top,
907
+ .instruction-bottom,
908
+ .walkthrough-grid {
909
+ grid-template-columns: 1fr;
910
+ }
911
+ }
912
+ """
913
+
914
+
915
+ LANGUAGE_CHOICES = [("English", "en"), ("中文", "zh")]
916
+ LANGUAGE_SWITCH_LABEL = "Language / 语言"
917
+
918
+ TEXT = {
919
+ "en": {
920
+ "language_label": "Language",
921
+ "study_title": "Human Motion Reenactment User Study",
922
+ "example_comparison": "Example Comparison",
923
+ "reference_video": "Reference Video",
924
+ "candidate_a": "Result A",
925
+ "candidate_b": "Result B",
926
+ "reference_caption": "Reference",
927
+ "result_a_caption": "Result A",
928
+ "result_b_caption": "Result B",
929
+ "result_a_left": "Result A",
930
+ "result_b_right": "Result B",
931
+ "participant_id_label": "Participant ID (read-only)",
932
+ "participant_setup_title": "Participant Setup",
933
+ "generate_fresh_id": "Generate Fresh Participant ID",
934
+ "start_continue": "Start / Continue Study",
935
+ "previous": "Previous",
936
+ "next": "Next",
937
+ "submit_study": "Submit Study",
938
+ "question_similarity": "Which result better matches the reference motion?",
939
+ "question_quality": "Which result has better motion quality?",
940
+ "question_preference": "Which result do you overall prefer?",
941
+ "left_choice": "Result A",
942
+ "right_choice": "Result B",
943
+ "saved_progress_restored": "Saved progress restored.",
944
+ "answer_all_required": "Please answer all three questions before continuing.",
945
+ "session_empty": "Session state was empty. Please return to the start page and continue this browser session.",
946
+ "browser_saved_id_notice": (
947
+ "This browser already has a saved participant ID. Click **Start / Continue Study** "
948
+ "to resume the same session safely."
949
+ ),
950
+ "fresh_id_notice": (
951
+ "A fresh participant ID has been created for this browser. "
952
+ "Use it only if you are starting a brand-new session."
953
+ ),
954
+ "completed_id_notice": (
955
+ "This participant ID has already completed the study. "
956
+ "Generate a fresh ID if you need a brand-new session on this browser."
957
+ ),
958
+ "study_instruction": "Watch the reference clip and both anonymous results before answering the three questions on Motion Similarity, Motion Quality, and Overall Preference.",
959
+ "question_word": "Question",
960
+ "saved_responses": "Saved responses",
961
+ "participant_id_meta": "Participant ID",
962
+ "thank_you_title": "Thank you for completing the study.",
963
+ "thank_you_saved": "Your responses have been saved successfully.",
964
+ "thank_you_completed_at": "Completed at",
965
+ "thank_you_close": "You may now close this page.",
966
+ },
967
+ "zh": {
968
+ "language_label": "语言",
969
+ "study_title": "人体动作重演用户研究",
970
+ "example_comparison": "示例对比",
971
+ "reference_video": "参考视频",
972
+ "candidate_a": "结果 A",
973
+ "candidate_b": "结果 B",
974
+ "reference_caption": "参考视频",
975
+ "result_a_caption": "结果 A",
976
+ "result_b_caption": "结果 B",
977
+ "result_a_left": "结果 A",
978
+ "result_b_right": "结果 B",
979
+ "participant_id_label": "参与者编号(只读)",
980
+ "participant_setup_title": "参与者设置",
981
+ "generate_fresh_id": "生成新的参与者编号",
982
+ "start_continue": "开始 / 继续问卷",
983
+ "previous": "上一题",
984
+ "next": "下一题",
985
+ "submit_study": "提交问卷",
986
+ "question_similarity": "哪个结果与参考动作更匹配?",
987
+ "question_quality": "哪个结果的动作质量更好?",
988
+ "question_preference": "你整体更偏好哪个结果?",
989
+ "left_choice": "结果 A",
990
+ "right_choice": "结果 B",
991
+ "saved_progress_restored": "已恢复先前保存的进度。",
992
+ "answer_all_required": "请先回答完这三个问题,再继续下一题。",
993
+ "session_empty": "当前会话为空。请返回起始页后继续此浏览器中的问卷会话。",
994
+ "browser_saved_id_notice": (
995
+ "当前浏览器已保存参与者编号。点击 **开始 / 继续问卷** 可安全地恢复同一会话。"
996
+ ),
997
+ "fresh_id_notice": (
998
+ "当前浏览器已生成一个新的参与者编号。仅当你需要开始一个全新的问卷会话时再使用它。"
999
+ ),
1000
+ "completed_id_notice": (
1001
+ "该参与者编号已经完成本次问卷。若你需要在此浏览器中开始全新会话,请生成新的编号。"
1002
+ ),
1003
+ "study_instruction": "请先观看参考视频和两个匿名结果,再回答动作相似性、动作质量和整体偏好这三个问题。",
1004
+ "question_word": "题目",
1005
+ "saved_responses": "已保存回答",
1006
+ "participant_id_meta": "参与者编号",
1007
+ "thank_you_title": "感谢你完成本次问卷。",
1008
+ "thank_you_saved": "你的回答已成功保存。",
1009
+ "thank_you_completed_at": "完成时间",
1010
+ "thank_you_close": "现在可以关闭此页面。",
1011
+ },
1012
+ }
1013
+
1014
+
1015
+ def normalize_language(language: str | None) -> str:
1016
+ return language if language in TEXT else "en"
1017
+
1018
+
1019
+ def tr(language: str | None, key: str, **kwargs: Any) -> str:
1020
+ return TEXT[normalize_language(language)][key].format(**kwargs)
1021
+
1022
+
1023
+ def choice_options_for_language(language: str | None) -> list[tuple[str, str]]:
1024
+ language = normalize_language(language)
1025
+ return [
1026
+ (tr(language, "left_choice"), "ResultA"),
1027
+ (tr(language, "right_choice"), "ResultB"),
1028
+ ]
1029
+
1030
+
1031
+ def video_caption_html(text: str) -> str:
1032
+ return f"<div class='video-caption'>{html.escape(text)}</div>"
1033
+
1034
+
1035
+ def build_participant_setup_markdown(language: str | None) -> str:
1036
+ language = normalize_language(language)
1037
+ if language == "zh":
1038
+ return """
1039
+ ### 参与者设置
1040
+
1041
+ 当前公开问卷链接会为本浏览器自动创建并保存参与者编号。之后如果你仍使用同一浏览器再次访问,就可以自动恢复同一份作答进度,而无需手动输入编号。
1042
+
1043
+ 只有在你希望于当前浏览器中开始一个全新的作答会话时,才需要点击 **生成新的参与者编号**。请不要将下方显示的编号分享给其他参与者。
1044
+ """.strip()
1045
+
1046
+ return """
1047
+ ### Participant Setup
1048
+
1049
+ This public study link now creates and stores a participant ID automatically for the current browser. Returning on the
1050
+ same browser will safely continue the same session without asking you to type an ID manually.
1051
+
1052
+ Use **Generate Fresh Participant ID** only when starting a completely new participation on this browser. Please do not
1053
+ share the ID shown below with other participants.
1054
+ """.strip()
1055
+
1056
+
1057
+ def build_progress_markdown(state: dict[str, Any], language: str | None) -> str:
1058
+ question = state["questions"][state["current_index"]]
1059
+ answered_count = len(state.get("answers", {}))
1060
+ language = normalize_language(language)
1061
+ if language == "zh":
1062
+ return (
1063
+ f"<div class='meta-line'>{tr(language, 'participant_id_meta')}: <code>{state['participant_id']}</code></div>"
1064
+ f"<div class='meta-line'>{tr(language, 'saved_responses')}: {answered_count} / {question['total_questions']}</div>"
1065
+ )
1066
+
1067
+ return (
1068
+ f"<div class='meta-line'>{tr(language, 'participant_id_meta')}: <code>{state['participant_id']}</code></div>"
1069
+ f"<div class='meta-line'>{tr(language, 'saved_responses')}: {answered_count} / {question['total_questions']}</div>"
1070
+ )
1071
+
1072
+
1073
+ def build_completion_markdown_local(state: dict[str, Any], language: str | None) -> str:
1074
+ completed_at = state.get("completed_at") or ""
1075
+ total_questions = len(state.get("questions", []))
1076
+ answered_count = len(state.get("answers", {}))
1077
+ language = normalize_language(language)
1078
+ if language == "zh":
1079
+ return f"""
1080
+ ## {tr(language, "thank_you_title")}
1081
+
1082
+ {tr(language, "thank_you_saved")}
1083
+
1084
+ - {tr(language, "participant_id_meta")}: `{state["participant_id"]}`
1085
+ - {tr(language, "saved_responses")}: `{answered_count} / {total_questions}`
1086
+ - {tr(language, "thank_you_completed_at")}: `{completed_at}`
1087
+
1088
+ {tr(language, "thank_you_close")}
1089
+ """.strip()
1090
+
1091
+ return f"""
1092
+ ## {tr(language, "thank_you_title")}
1093
+
1094
+ {tr(language, "thank_you_saved")}
1095
+
1096
+ - {tr(language, "participant_id_meta")}: `{state["participant_id"]}`
1097
+ - {tr(language, "saved_responses")}: `{answered_count} / {total_questions}`
1098
+ - {tr(language, "thank_you_completed_at")}: `{completed_at}`
1099
+
1100
+ {tr(language, "thank_you_close")}
1101
+ """.strip()
1102
+
1103
+
1104
+ def image_to_data_uri(image_path: str | Path) -> str:
1105
+ path = Path(image_path)
1106
+ if not path.exists():
1107
+ return ""
1108
+ suffix = path.suffix.lower()
1109
+ mime_type = "image/jpeg" if suffix in {".jpg", ".jpeg"} else "image/png"
1110
+ encoded = base64.b64encode(path.read_bytes()).decode("ascii")
1111
+ return f"data:{mime_type};base64,{encoded}"
1112
+
1113
+
1114
+ def build_instruction_case_html(intro_case: dict[str, Any], language: str | None) -> str:
1115
+ reference_thumb = image_to_data_uri(ensure_video_thumbnail(intro_case["reference_video"], PROJECT_ROOT))
1116
+ result_a_thumb = image_to_data_uri(
1117
+ ensure_video_thumbnail(intro_case["method_videos"]["anyact"], PROJECT_ROOT)
1118
+ )
1119
+ result_b_thumb = image_to_data_uri(
1120
+ ensure_video_thumbnail(intro_case["method_videos"]["vlm_hy_motion"], PROJECT_ROOT)
1121
+ )
1122
+ language = normalize_language(language)
1123
+
1124
+ if language == "zh":
1125
+ return f"""
1126
+ <div class="case-walkthrough">
1127
+ <div class="case-walkthrough-head">
1128
+ <h3>示例对比</h3>
1129
+ <p>
1130
+ 下图说明问卷如何进行比较。参与者需要将一个源角色的运动视频与两个人体重演结果进行比较,
1131
+ 页面上它们显示为 <strong>结果 A</strong> 和 <strong>结果 B</strong>。灰色区域表示地板。
1132
+ </p>
1133
+ </div>
1134
+
1135
+ <div class="walkthrough-grid">
1136
+ <div class="thumb-card ref-card">
1137
+ <div class="thumb-image"><img src="{reference_thumb}" alt="参考案例缩略图"></div>
1138
+ <div class="thumb-body">
1139
+ <div class="thumb-title">参考视频</div>
1140
+ <h4>源角色运动视频</h4>
1141
+ <p>该视频提供需要被模仿的角色运动,主要体现姿态和动作动态。</p>
1142
+ </div>
1143
+ </div>
1144
+
1145
+ <div class="thumb-card candidate-card">
1146
+ <div class="thumb-image"><img src="{result_a_thumb}" alt="匿名结果 A 缩略图"></div>
1147
+ <div class="thumb-body">
1148
+ <div class="thumb-title">结果 A</div>
1149
+ <h4>模仿参考角色运动的人体重演</h4>
1150
+ <p>这是结果 A 对参考角色运动进行人体重演后的表现。</p>
1151
+ </div>
1152
+ </div>
1153
+
1154
+ <div class="thumb-card candidate-card">
1155
+ <div class="thumb-image"><img src="{result_b_thumb}" alt="匿名结果 B 缩略图"></div>
1156
+ <div class="thumb-body">
1157
+ <div class="thumb-title">结果 B</div>
1158
+ <h4>模仿参考角色运动的人体重演</h4>
1159
+ <p>这是结果 B 对参考角色运动进行人体重演后的表现。</p>
1160
+ </div>
1161
+ </div>
1162
+ </div>
1163
+ </div>
1164
+ """.strip()
1165
+
1166
+ return f"""
1167
+ <div class="case-walkthrough">
1168
+ <div class="case-walkthrough-head">
1169
+ <h3>Example Comparison</h3>
1170
+ <p>
1171
+ The figure below shows how the questionnaire works. Participants compare one video of the source character's motion against
1172
+ two human reenactment results that imitate that motion, displayed as <strong>Result A</strong> and <strong>Result B</strong>.
1173
+ The gray area indicates the floor.
1174
+ </p>
1175
+ </div>
1176
+
1177
+ <div class="walkthrough-grid">
1178
+ <div class="thumb-card ref-card">
1179
+ <div class="thumb-image"><img src="{reference_thumb}" alt="Reference case thumbnail"></div>
1180
+ <div class="thumb-body">
1181
+ <div class="thumb-title">Reference</div>
1182
+ <h4>Source character motion video</h4>
1183
+ <p>This video provides the character motion to be imitated, mainly in terms of pose and action dynamics.</p>
1184
+ </div>
1185
+ </div>
1186
+
1187
+ <div class="thumb-card candidate-card">
1188
+ <div class="thumb-image"><img src="{result_a_thumb}" alt="Anonymous Result A thumbnail"></div>
1189
+ <div class="thumb-body">
1190
+ <div class="thumb-title">Result A</div>
1191
+ <h4>Human reenactment imitating the reference character motion</h4>
1192
+ <p>This is the human-motion reenactment shown as Result A.</p>
1193
+ </div>
1194
+ </div>
1195
+
1196
+ <div class="thumb-card candidate-card">
1197
+ <div class="thumb-image"><img src="{result_b_thumb}" alt="Anonymous Result B thumbnail"></div>
1198
+ <div class="thumb-body">
1199
+ <div class="thumb-title">Result B</div>
1200
+ <h4>Human reenactment imitating the reference character motion</h4>
1201
+ <p>This is the human-motion reenactment shown as Result B.</p>
1202
+ </div>
1203
+ </div>
1204
+ </div>
1205
+ </div>
1206
+ """.strip()
1207
+
1208
+
1209
+ def build_judging_instruction_html(language: str | None) -> str:
1210
+ language = normalize_language(language)
1211
+ if language == "zh":
1212
+ return """
1213
+ <div class="walkthrough-note">
1214
+ <strong>参与者需要判断什么</strong>
1215
+ <p>
1216
+ 对于每一组对比,你都需要回答三个问题,分别对应 <strong>动作相似性</strong>、<strong>动作质量</strong> 和
1217
+ <strong>整体偏好</strong>。请直接依据页面上显示的结果 A 和结果 B 进行判断。
1218
+ </p>
1219
+ <div class="metric-stack">
1220
+ <div class="metric-card">
1221
+ <h4>动作相似性</h4>
1222
+ <p>哪个结果与参考动作更匹配,尤其是在姿态对齐和时间动态方面?</p>
1223
+ </div>
1224
+ <div class="metric-card">
1225
+ <h4>动作质量</h4>
1226
+ <p>哪个结果看起来更自然、更平滑,并且更符合人体动作的物理合理性?请主要关注全身动作、身体协调、平衡性和时间连续性,并可适当忽略面部表情与手指细节。</p>
1227
+ </div>
1228
+ <div class="metric-card">
1229
+ <h4>整体偏好</h4>
1230
+ <p>综合考虑动作相似性和动作质量后,你整体更偏好哪个结果?</p>
1231
+ </div>
1232
+ </div>
1233
+ </div>
1234
+ """.strip()
1235
+
1236
+ return """
1237
+ <div class="walkthrough-note">
1238
+ <strong>What participants are asked to judge</strong>
1239
+ <p>
1240
+ For each comparison, please answer three questions covering <strong>Motion Similarity</strong>,
1241
+ <strong>Motion Quality</strong>, and <strong>Overall Preference</strong>. Please base your choice directly on the
1242
+ page labels, namely Result A and Result B.
1243
+ </p>
1244
+ <div class="metric-stack">
1245
+ <div class="metric-card">
1246
+ <h4>Motion Similarity</h4>
1247
+ <p>Which result better matches the reference motion, especially in terms of pose alignment and temporal dynamics?</p>
1248
+ </div>
1249
+ <div class="metric-card">
1250
+ <h4>Motion Quality</h4>
1251
+ <p>Which result appears more natural, smooth, and physically plausible as a human motion sequence (mainly focusing on whole-body motion, body coordination, balance, and temporal continuity, while reasonably ignoring facial expressions and fine finger motion)?</p>
1252
+ </div>
1253
+ <div class="metric-card">
1254
+ <h4>Overall Preference</h4>
1255
+ <p>Considering both similarity and quality together, which result do you prefer overall?</p>
1256
+ </div>
1257
+ </div>
1258
+ </div>
1259
+ """.strip()
1260
+
1261
+
1262
+ def build_instruction_html(
1263
+ config: dict[str, Any],
1264
+ case_walkthrough_html: str,
1265
+ judging_instruction_html: str,
1266
+ language: str | None,
1267
+ ) -> str:
1268
+ total_questions = config["participant_question_total"]
1269
+ language = normalize_language(language)
1270
+ if language == "zh":
1271
+ return f"""
1272
+ <div class="hero-card">
1273
+ <div class="instruction-shell">
1274
+ <div class="instruction-top">
1275
+ <div class="instruction-copy">
1276
+ <div class="block-title">
1277
+ <h1>{tr(language, "study_title")}</h1>
1278
+ </div>
1279
+ <p class="lead-text">
1280
+ 本研究用于评估人体动作重演结果的主观感知质量。每一道题中,你将观看
1281
+ 一个<strong>参考视频</strong>和两个<strong>匿名结果</strong>,它们在页面上显示为
1282
+ <strong>结果 A</strong> 和 <strong>结果 B</strong>。你需要围绕
1283
+ <strong>动作相似性</strong>、<strong>动作质量</strong> 和 <strong>整体偏好</strong>
1284
+ 三个指标完成判断。
1285
+ </p>
1286
+ <ul class="instruction-list">
1287
+ <li>每位参与者需要完成 <strong>{total_questions}</strong> 个成对对比样本。</li>
1288
+ <li>每次比较都会同时展示一个参考视频,以及并排显示的结果 A 和结果 B。</li>
1289
+ <li>结果 A 和结果 B 的时长可能不完全相同,但它们都对应对整段参考视频中角色运动的重演;请忽略这种长度差异。</li>
1290
+ <li>请在进入下一页之前回答完当前题目的三个问题。</li>
1291
+ </ul>
1292
+ {judging_instruction_html}
1293
+ </div>
1294
+ <div class="instruction-top-right">
1295
+ <div class="diagram-card">
1296
+ {case_walkthrough_html}
1297
+ </div>
1298
+ <div class="walkthrough-note accent-note">
1299
+ <strong>如何理解本任务</strong>
1300
+ <p>
1301
+ 本研究关注的是<strong>人去模仿参考视频中角色的运动</strong>。
1302
+ 一个优秀的结果应当既忠实保留参考运动,又能呈现自然、平稳、符合人体运动规律的动作。
1303
+ </p>
1304
+ <p>
1305
+ 当原始角色和人体构造不完全一致时,可以接受使用人体其他肢体去模拟缺失的功能部位,
1306
+ 例如用手臂对应翅膀,只要整体动作意图和动态仍然合理并接近参考动作。
1307
+ </p>
1308
+ </div>
1309
+ </div>
1310
+ </div>
1311
+ </div>
1312
+ </div>
1313
+ """.strip()
1314
+
1315
+ return f"""
1316
+ <div class="hero-card">
1317
+ <div class="instruction-shell">
1318
+ <div class="instruction-top">
1319
+ <div class="instruction-copy">
1320
+ <div class="block-title">
1321
+ <h1>Human Motion Reenactment User Study</h1>
1322
+ </div>
1323
+ <p class="lead-text">
1324
+ This study evaluates perceptual quality in human motion reenactment. In each question, you will watch
1325
+ one <strong>reference video</strong> and two <strong>anonymous results</strong>, displayed on the page as
1326
+ <strong>Result A</strong> and <strong>Result B</strong>. You will answer three evaluation questions covering
1327
+ <strong>Motion Similarity</strong>, <strong>Motion Quality</strong>, and <strong>Overall Preference</strong>.
1328
+ </p>
1329
+ <ul class="instruction-list">
1330
+ <li>Each participant completes <strong>{total_questions}</strong> pairwise comparison samples.</li>
1331
+ <li>Each comparison presents one reference clip together with Result A and Result B shown side by side.</li>
1332
+ <li>Result A and Result B may have different durations, but both are reenactments of the character motion over the full reference clip; please ignore this length difference.</li>
1333
+ <li>Please answer all three questions before moving to the next page.</li>
1334
+ </ul>
1335
+ {judging_instruction_html}
1336
+ </div>
1337
+ <div class="instruction-top-right">
1338
+ <div class="diagram-card">
1339
+ {case_walkthrough_html}
1340
+ </div>
1341
+ <div class="walkthrough-note accent-note">
1342
+ <strong>How to interpret the task</strong>
1343
+ <p>
1344
+ The goal is to assess how well a human reenactment imitates the motion of the character in the reference video.
1345
+ A strong result should preserve the reference motion while still looking smooth, stable, and physically natural as human movement.
1346
+ </p>
1347
+ <p>
1348
+ When the source character does not map directly to a human body, it is acceptable to use other human limbs
1349
+ to simulate the missing functional parts, such as using arms to mimic wings, as long as the motion intent
1350
+ and dynamics remain plausible and close to the reference.
1351
+ </p>
1352
+ </div>
1353
+ </div>
1354
+ </div>
1355
+ </div>
1356
+ </div>
1357
+ """.strip()
1358
+
1359
+
1360
+ def build_example_caption(language: str | None) -> str:
1361
+ language = normalize_language(language)
1362
+ if language == "zh":
1363
+ return """
1364
+ <div class="example-caption-note">
1365
+ 下方展示的是同一个示例案例在正式问卷中的实际观看布局。参与者需要将参考视频与结果 A、结果 B 进行比较,并回答三个评价问题。
1366
+ </div>
1367
+ """.strip()
1368
+
1369
+ return """
1370
+ <div class="example-caption-note">
1371
+ Below is the same example case shown in the actual questionnaire layout. Participants compare the reference clip
1372
+ against Result A and Result B and answer the three evaluation questions.
1373
+ </div>
1374
+ """.strip()
1375
+
1376
+
1377
+ def resolve_browser_participant_id(browser_participant_id: str | None) -> str:
1378
+ sanitized = sanitize_participant_id(browser_participant_id)
1379
+ return sanitized or generate_participant_id()
1380
+
1381
+
1382
+ def build_intro_component_updates(
1383
+ config: dict[str, Any],
1384
+ intro_case: dict[str, Any],
1385
+ language: str | None,
1386
+ ) -> tuple[Any, ...]:
1387
+ language = normalize_language(language)
1388
+ case_walkthrough_html = build_instruction_case_html(intro_case, language)
1389
+ judging_instruction_html = build_judging_instruction_html(language)
1390
+ return (
1391
+ gr.update(value=build_instruction_html(config, case_walkthrough_html, judging_instruction_html, language)),
1392
+ gr.update(value=build_example_caption(language), visible=False),
1393
+ gr.update(label=tr(language, "reference_video")),
1394
+ gr.update(value=video_caption_html(tr(language, "reference_caption"))),
1395
+ gr.update(label=tr(language, "candidate_a")),
1396
+ gr.update(value=video_caption_html(tr(language, "result_a_caption"))),
1397
+ gr.update(label=tr(language, "candidate_b")),
1398
+ gr.update(value=video_caption_html(tr(language, "result_b_caption"))),
1399
+ gr.update(value=build_participant_setup_markdown(language)),
1400
+ gr.update(value=tr(language, "generate_fresh_id")),
1401
+ gr.update(value=tr(language, "start_continue")),
1402
+ )
1403
+
1404
+
1405
+ def build_study_component_updates(
1406
+ language: str | None,
1407
+ similarity_value: str | None,
1408
+ quality_value: str | None,
1409
+ preference_value: str | None,
1410
+ show_previous: bool,
1411
+ show_next: bool,
1412
+ show_submit: bool,
1413
+ ) -> tuple[Any, ...]:
1414
+ language = normalize_language(language)
1415
+ return (
1416
+ gr.update(label=tr(language, "reference_video")),
1417
+ gr.update(label=tr(language, "result_a_left")),
1418
+ gr.update(label=tr(language, "result_b_right")),
1419
+ gr.update(
1420
+ choices=choice_options_for_language(language),
1421
+ label=tr(language, "question_similarity"),
1422
+ value=similarity_value,
1423
+ ),
1424
+ gr.update(
1425
+ choices=choice_options_for_language(language),
1426
+ label=tr(language, "question_quality"),
1427
+ value=quality_value,
1428
+ ),
1429
+ gr.update(
1430
+ choices=choice_options_for_language(language),
1431
+ label=tr(language, "question_preference"),
1432
+ value=preference_value,
1433
+ ),
1434
+ gr.update(value=tr(language, "previous"), visible=show_previous),
1435
+ gr.update(value=tr(language, "next"), visible=show_next),
1436
+ gr.update(value=tr(language, "submit_study"), visible=show_submit),
1437
+ gr.update(value=video_caption_html(tr(language, "reference_caption"))),
1438
+ gr.update(value=video_caption_html(tr(language, "result_a_caption"))),
1439
+ gr.update(value=video_caption_html(tr(language, "result_b_caption"))),
1440
+ )
1441
+
1442
+
1443
+ def render_intro_view(
1444
+ config: dict[str, Any],
1445
+ intro_case: dict[str, Any],
1446
+ language: str | None,
1447
+ participant_id: str | None = None,
1448
+ browser_participant_id: str | None = None,
1449
+ start_message: str = "",
1450
+ ) -> Tuple[Any, ...]:
1451
+ language = normalize_language(language)
1452
+ resolved_participant_id = resolve_browser_participant_id(browser_participant_id or participant_id)
1453
+ intro_updates = build_intro_component_updates(config, intro_case, language)
1454
+ study_updates = build_study_component_updates(
1455
+ language,
1456
+ similarity_value=None,
1457
+ quality_value=None,
1458
+ preference_value=None,
1459
+ show_previous=False,
1460
+ show_next=True,
1461
+ show_submit=False,
1462
+ )
1463
+ return (
1464
+ gr.update(visible=True),
1465
+ gr.update(visible=False),
1466
+ gr.update(visible=False),
1467
+ {},
1468
+ "",
1469
+ "",
1470
+ tr(language, "study_instruction"),
1471
+ None,
1472
+ None,
1473
+ None,
1474
+ study_updates[3],
1475
+ study_updates[4],
1476
+ study_updates[5],
1477
+ "",
1478
+ study_updates[6],
1479
+ study_updates[7],
1480
+ study_updates[8],
1481
+ start_message,
1482
+ "",
1483
+ gr.update(value=resolved_participant_id, label=tr(language, "participant_id_label")),
1484
+ resolved_participant_id,
1485
+ language,
1486
+ gr.update(value=language, label=LANGUAGE_SWITCH_LABEL),
1487
+ *intro_updates,
1488
+ study_updates[9],
1489
+ study_updates[10],
1490
+ study_updates[11],
1491
+ )
1492
+
1493
+
1494
+ def render_question_view(
1495
+ config: dict[str, Any],
1496
+ intro_case: dict[str, Any],
1497
+ language: str | None,
1498
+ state: dict[str, Any],
1499
+ study_message: str = "",
1500
+ draft_answers: tuple[str | None, str | None, str | None] | None = None,
1501
+ ) -> Tuple[Any, ...]:
1502
+ language = normalize_language(language)
1503
+ payload = build_question_payload(state)
1504
+ synced_videos = ensure_synchronized_study_videos(
1505
+ reference_video=payload["reference_video"],
1506
+ left_video=payload["left_video"],
1507
+ right_video=payload["right_video"],
1508
+ project_root=PROJECT_ROOT,
1509
+ )
1510
+ similarity_value = draft_answers[0] if draft_answers and draft_answers[0] is not None else payload["answer_similarity"]
1511
+ quality_value = draft_answers[1] if draft_answers and draft_answers[1] is not None else payload["answer_quality"]
1512
+ preference_value = draft_answers[2] if draft_answers and draft_answers[2] is not None else payload["answer_preference"]
1513
+ intro_updates = build_intro_component_updates(config, intro_case, language)
1514
+ study_updates = build_study_component_updates(
1515
+ language,
1516
+ similarity_value=similarity_value,
1517
+ quality_value=quality_value,
1518
+ preference_value=preference_value,
1519
+ show_previous=payload["show_previous"],
1520
+ show_next=payload["show_next"],
1521
+ show_submit=payload["show_submit"],
1522
+ )
1523
+ return (
1524
+ gr.update(visible=False),
1525
+ gr.update(visible=True),
1526
+ gr.update(visible=False),
1527
+ state,
1528
+ payload["question_token"],
1529
+ build_progress_markdown(state, language),
1530
+ tr(language, "study_instruction"),
1531
+ gr.update(value=synced_videos["reference_video"], label=tr(language, "reference_video")),
1532
+ gr.update(value=synced_videos["left_video"], label=tr(language, "result_a_left")),
1533
+ gr.update(value=synced_videos["right_video"], label=tr(language, "result_b_right")),
1534
+ study_updates[3],
1535
+ study_updates[4],
1536
+ study_updates[5],
1537
+ study_message,
1538
+ study_updates[6],
1539
+ study_updates[7],
1540
+ study_updates[8],
1541
+ "",
1542
+ "",
1543
+ gr.update(value=state["participant_id"], label=tr(language, "participant_id_label")),
1544
+ state["participant_id"],
1545
+ language,
1546
+ gr.update(value=language, label=LANGUAGE_SWITCH_LABEL),
1547
+ *intro_updates,
1548
+ study_updates[9],
1549
+ study_updates[10],
1550
+ study_updates[11],
1551
+ )
1552
+
1553
+
1554
+ def render_thank_you_view(
1555
+ config: dict[str, Any],
1556
+ intro_case: dict[str, Any],
1557
+ language: str | None,
1558
+ state: dict[str, Any],
1559
+ ) -> Tuple[Any, ...]:
1560
+ language = normalize_language(language)
1561
+ intro_updates = build_intro_component_updates(config, intro_case, language)
1562
+ study_updates = build_study_component_updates(
1563
+ language,
1564
+ similarity_value=None,
1565
+ quality_value=None,
1566
+ preference_value=None,
1567
+ show_previous=False,
1568
+ show_next=False,
1569
+ show_submit=False,
1570
+ )
1571
+ return (
1572
+ gr.update(visible=False),
1573
+ gr.update(visible=False),
1574
+ gr.update(visible=True),
1575
+ state,
1576
+ "",
1577
+ "",
1578
+ tr(language, "study_instruction"),
1579
+ gr.update(value=None),
1580
+ gr.update(value=None),
1581
+ gr.update(value=None),
1582
+ study_updates[3],
1583
+ study_updates[4],
1584
+ study_updates[5],
1585
+ "",
1586
+ study_updates[6],
1587
+ study_updates[7],
1588
+ study_updates[8],
1589
+ "",
1590
+ build_completion_markdown_local(state, language),
1591
+ gr.update(value=state["participant_id"], label=tr(language, "participant_id_label")),
1592
+ state["participant_id"],
1593
+ language,
1594
+ gr.update(value=language, label=LANGUAGE_SWITCH_LABEL),
1595
+ *intro_updates,
1596
+ study_updates[9],
1597
+ study_updates[10],
1598
+ study_updates[11],
1599
+ )
1600
+
1601
+
1602
+ def _drop_language_selector_update(payload: Tuple[Any, ...]) -> Tuple[Any, ...]:
1603
+ language_selector_index = 22
1604
+ return payload[:language_selector_index] + payload[language_selector_index + 1 :]
1605
+
1606
+
1607
+ def build_demo(config_path: Path) -> gr.Blocks:
1608
+ ensure_local_ffmpeg()
1609
+ patch_gradio_video_probe()
1610
+ config = load_study_config(config_path)
1611
+ ensure_runtime_dirs(PROJECT_ROOT)
1612
+ config = prepare_reference_videos_for_web(config, PROJECT_ROOT)
1613
+ upgrade_existing_results_schema(PROJECT_ROOT, config)
1614
+ default_language = "en"
1615
+ choice_options = choice_options_for_language(default_language)
1616
+
1617
+ intro_case = get_instruction_case(config)
1618
+ example_left_path = intro_case["method_videos"]["anyact"]
1619
+ example_right_path = intro_case["method_videos"]["vlm_hy_motion"]
1620
+
1621
+ with gr.Blocks(
1622
+ title=config["study_title"],
1623
+ css=CUSTOM_CSS,
1624
+ head=CUSTOM_HEAD,
1625
+ theme=gr.themes.Soft(
1626
+ primary_hue="blue",
1627
+ secondary_hue="sky",
1628
+ neutral_hue="slate",
1629
+ ),
1630
+ ) as demo:
1631
+ participant_state = gr.State({})
1632
+ question_token = gr.State("")
1633
+ browser_participant_id = gr.BrowserState(
1634
+ "",
1635
+ storage_key=f"{config['study_id']}_participant_id",
1636
+ )
1637
+ browser_language = gr.BrowserState(
1638
+ default_language,
1639
+ storage_key=f"{config['study_id']}_language",
1640
+ )
1641
+
1642
+ with gr.Row(elem_classes=["language-switch-row"]):
1643
+ language_selector = gr.Radio(
1644
+ choices=LANGUAGE_CHOICES,
1645
+ value=default_language,
1646
+ label=LANGUAGE_SWITCH_LABEL,
1647
+ interactive=True,
1648
+ show_label=False,
1649
+ elem_classes=["language-switch"],
1650
+ )
1651
+
1652
+ with gr.Column(visible=True) as intro_panel:
1653
+ intro_instruction_html = gr.HTML(
1654
+ build_instruction_html(
1655
+ config=config,
1656
+ case_walkthrough_html=build_instruction_case_html(intro_case, default_language),
1657
+ judging_instruction_html=build_judging_instruction_html(default_language),
1658
+ language=default_language,
1659
+ )
1660
+ )
1661
+ example_caption_md = gr.Markdown(build_example_caption(default_language), visible=False)
1662
+ with gr.Row(visible=False):
1663
+ with gr.Column(scale=5):
1664
+ intro_reference_video = gr.Video(
1665
+ value=intro_case["reference_video"],
1666
+ label=tr(default_language, "reference_video"),
1667
+ autoplay=True,
1668
+ loop=True,
1669
+ elem_classes=["panel-card", "video-panel", "reference-panel"],
1670
+ )
1671
+ intro_reference_caption = gr.Markdown(video_caption_html(tr(default_language, "reference_caption")))
1672
+ with gr.Column(scale=4):
1673
+ intro_left_video = gr.Video(
1674
+ value=example_left_path,
1675
+ label=tr(default_language, "candidate_a"),
1676
+ autoplay=True,
1677
+ loop=True,
1678
+ elem_classes=["panel-card", "video-panel", "candidate-panel"],
1679
+ )
1680
+ intro_left_caption = gr.Markdown(video_caption_html(tr(default_language, "result_a_caption")))
1681
+ with gr.Column(scale=4):
1682
+ intro_right_video = gr.Video(
1683
+ value=example_right_path,
1684
+ label=tr(default_language, "candidate_b"),
1685
+ autoplay=True,
1686
+ loop=True,
1687
+ elem_classes=["panel-card", "video-panel", "candidate-panel"],
1688
+ )
1689
+ intro_right_caption = gr.Markdown(video_caption_html(tr(default_language, "result_b_caption")))
1690
+
1691
+ with gr.Group(elem_classes=["form-card"]):
1692
+ participant_setup_md = gr.Markdown(build_participant_setup_markdown(default_language))
1693
+ participant_id_box = gr.Textbox(
1694
+ label=tr(default_language, "participant_id_label"),
1695
+ interactive=False,
1696
+ )
1697
+ regenerate_button = gr.Button(tr(default_language, "generate_fresh_id"))
1698
+ start_message = gr.Markdown()
1699
+ start_button = gr.Button(tr(default_language, "start_continue"), variant="primary")
1700
+
1701
+ with gr.Column(visible=False, elem_classes=["study-shell"]) as study_panel:
1702
+ progress_html = gr.HTML()
1703
+ study_notice = gr.Markdown(tr(default_language, "study_instruction"))
1704
+ with gr.Row():
1705
+ with gr.Column(scale=5):
1706
+ reference_video = gr.Video(
1707
+ label=tr(default_language, "reference_video"),
1708
+ autoplay=True,
1709
+ loop=True,
1710
+ elem_id="study-reference-video",
1711
+ elem_classes=["panel-card", "video-panel", "reference-panel"],
1712
+ )
1713
+ study_reference_caption = gr.Markdown(video_caption_html(tr(default_language, "reference_caption")))
1714
+ with gr.Column(scale=4):
1715
+ left_video = gr.Video(
1716
+ label=tr(default_language, "result_a_left"),
1717
+ autoplay=True,
1718
+ loop=True,
1719
+ elem_id="study-left-video",
1720
+ elem_classes=["panel-card", "video-panel", "candidate-panel"],
1721
+ )
1722
+ study_left_caption = gr.Markdown(video_caption_html(tr(default_language, "result_a_caption")))
1723
+ with gr.Column(scale=4):
1724
+ right_video = gr.Video(
1725
+ label=tr(default_language, "result_b_right"),
1726
+ autoplay=True,
1727
+ loop=True,
1728
+ elem_id="study-right-video",
1729
+ elem_classes=["panel-card", "video-panel", "candidate-panel"],
1730
+ )
1731
+ study_right_caption = gr.Markdown(video_caption_html(tr(default_language, "result_b_caption")))
1732
+
1733
+ with gr.Group(elem_classes=["question-card"]):
1734
+ similarity_radio = gr.Radio(
1735
+ choices=choice_options,
1736
+ label=tr(default_language, "question_similarity"),
1737
+ elem_classes=["choice-input"],
1738
+ )
1739
+ quality_radio = gr.Radio(
1740
+ choices=choice_options,
1741
+ label=tr(default_language, "question_quality"),
1742
+ elem_classes=["choice-input"],
1743
+ )
1744
+ preference_radio = gr.Radio(
1745
+ choices=choice_options,
1746
+ label=tr(default_language, "question_preference"),
1747
+ elem_classes=["choice-input"],
1748
+ )
1749
+ study_message = gr.Markdown()
1750
+
1751
+ with gr.Row():
1752
+ previous_button = gr.Button(tr(default_language, "previous"))
1753
+ next_button = gr.Button(tr(default_language, "next"), variant="primary")
1754
+ submit_button = gr.Button(tr(default_language, "submit_study"), variant="primary", visible=False)
1755
+
1756
+ with gr.Column(visible=False) as thank_panel:
1757
+ with gr.Group(elem_classes=["hero-card", "thank-card"]):
1758
+ thank_you_markdown = gr.Markdown()
1759
+
1760
+ outputs = [
1761
+ intro_panel,
1762
+ study_panel,
1763
+ thank_panel,
1764
+ participant_state,
1765
+ question_token,
1766
+ progress_html,
1767
+ study_notice,
1768
+ reference_video,
1769
+ left_video,
1770
+ right_video,
1771
+ similarity_radio,
1772
+ quality_radio,
1773
+ preference_radio,
1774
+ study_message,
1775
+ previous_button,
1776
+ next_button,
1777
+ submit_button,
1778
+ start_message,
1779
+ thank_you_markdown,
1780
+ participant_id_box,
1781
+ browser_participant_id,
1782
+ browser_language,
1783
+ language_selector,
1784
+ intro_instruction_html,
1785
+ example_caption_md,
1786
+ intro_reference_video,
1787
+ intro_reference_caption,
1788
+ intro_left_video,
1789
+ intro_left_caption,
1790
+ intro_right_video,
1791
+ intro_right_caption,
1792
+ participant_setup_md,
1793
+ regenerate_button,
1794
+ start_button,
1795
+ study_reference_caption,
1796
+ study_left_caption,
1797
+ study_right_caption,
1798
+ ]
1799
+ outputs_without_language_selector = outputs[:22] + outputs[23:]
1800
+
1801
+ def initialize_page(saved_participant_id: str, saved_language: str) -> Tuple[Any, ...]:
1802
+ language = normalize_language(saved_language)
1803
+ resolved_participant_id = resolve_browser_participant_id(saved_participant_id)
1804
+ start_message = ""
1805
+ if sanitize_participant_id(saved_participant_id):
1806
+ start_message = tr(language, "browser_saved_id_notice")
1807
+ return render_intro_view(
1808
+ config=config,
1809
+ intro_case=intro_case,
1810
+ language=language,
1811
+ participant_id=resolved_participant_id,
1812
+ browser_participant_id=resolved_participant_id,
1813
+ start_message=start_message,
1814
+ )
1815
+
1816
+ def handle_generate_new_id(current_language: str) -> Tuple[Any, str, str]:
1817
+ language = normalize_language(current_language)
1818
+ new_participant_id = generate_participant_id()
1819
+ return (
1820
+ gr.update(value=new_participant_id, label=tr(language, "participant_id_label")),
1821
+ new_participant_id,
1822
+ tr(language, "fresh_id_notice"),
1823
+ )
1824
+
1825
+ def handle_start(
1826
+ participant_id: str,
1827
+ current_language: str,
1828
+ request: gr.Request,
1829
+ ) -> Tuple[Any, ...]:
1830
+ language = normalize_language(current_language)
1831
+ state, status = create_or_resume_participant(
1832
+ project_root=PROJECT_ROOT,
1833
+ config=config,
1834
+ participant_id=participant_id,
1835
+ request=request,
1836
+ )
1837
+
1838
+ if status == "completed":
1839
+ return render_intro_view(
1840
+ config=config,
1841
+ intro_case=intro_case,
1842
+ language=language,
1843
+ participant_id=state["participant_id"],
1844
+ browser_participant_id=state["participant_id"],
1845
+ start_message=tr(language, "completed_id_notice"),
1846
+ )
1847
+
1848
+ study_message_text = tr(language, "saved_progress_restored") if status == "resumed" else ""
1849
+ return render_question_view(
1850
+ config=config,
1851
+ intro_case=intro_case,
1852
+ language=language,
1853
+ state=state,
1854
+ study_message=study_message_text,
1855
+ )
1856
+
1857
+ def handle_previous(
1858
+ state: dict[str, Any],
1859
+ current_token: str,
1860
+ current_browser_participant_id: str,
1861
+ current_language: str,
1862
+ ) -> Tuple[Any, ...]:
1863
+ language = normalize_language(current_language)
1864
+ if not state:
1865
+ resolved_participant_id = resolve_browser_participant_id(current_browser_participant_id)
1866
+ return render_intro_view(
1867
+ config=config,
1868
+ intro_case=intro_case,
1869
+ language=language,
1870
+ participant_id=resolved_participant_id,
1871
+ browser_participant_id=resolved_participant_id,
1872
+ start_message=tr(language, "session_empty"),
1873
+ )
1874
+
1875
+ updated_state, message = move_question_pointer(
1876
+ project_root=PROJECT_ROOT,
1877
+ participant_id=state["participant_id"],
1878
+ question_token=current_token,
1879
+ direction="previous",
1880
+ )
1881
+ return render_question_view(
1882
+ config=config,
1883
+ intro_case=intro_case,
1884
+ language=language,
1885
+ state=updated_state,
1886
+ study_message=message,
1887
+ )
1888
+
1889
+ def handle_next_or_submit(
1890
+ state: dict[str, Any],
1891
+ current_token: str,
1892
+ answer_similarity: str,
1893
+ answer_quality: str,
1894
+ answer_preference: str,
1895
+ action: str,
1896
+ current_browser_participant_id: str,
1897
+ current_language: str,
1898
+ ) -> Tuple[Any, ...]:
1899
+ language = normalize_language(current_language)
1900
+ if not state:
1901
+ resolved_participant_id = resolve_browser_participant_id(current_browser_participant_id)
1902
+ return render_intro_view(
1903
+ config=config,
1904
+ intro_case=intro_case,
1905
+ language=language,
1906
+ participant_id=resolved_participant_id,
1907
+ browser_participant_id=resolved_participant_id,
1908
+ start_message=tr(language, "session_empty"),
1909
+ )
1910
+
1911
+ if not answer_similarity or not answer_quality or not answer_preference:
1912
+ return render_question_view(
1913
+ config=config,
1914
+ intro_case=intro_case,
1915
+ language=language,
1916
+ state=state,
1917
+ study_message=tr(language, "answer_all_required"),
1918
+ draft_answers=(answer_similarity, answer_quality, answer_preference),
1919
+ )
1920
+
1921
+ updated_state, message, status = save_current_answer(
1922
+ project_root=PROJECT_ROOT,
1923
+ participant_id=state["participant_id"],
1924
+ question_token=current_token,
1925
+ answer_similarity=answer_similarity,
1926
+ answer_quality=answer_quality,
1927
+ answer_preference=answer_preference,
1928
+ action=action,
1929
+ )
1930
+ if status == "completed":
1931
+ return render_thank_you_view(
1932
+ config=config,
1933
+ intro_case=intro_case,
1934
+ language=language,
1935
+ state=updated_state,
1936
+ )
1937
+ return render_question_view(
1938
+ config=config,
1939
+ intro_case=intro_case,
1940
+ language=language,
1941
+ state=updated_state,
1942
+ study_message=message,
1943
+ )
1944
+
1945
+ def handle_language_change(
1946
+ selected_language: str,
1947
+ state: dict[str, Any],
1948
+ current_browser_participant_id: str,
1949
+ current_token: str,
1950
+ answer_similarity: str | None,
1951
+ answer_quality: str | None,
1952
+ answer_preference: str | None,
1953
+ current_start_message: str,
1954
+ current_study_message: str,
1955
+ ) -> Tuple[Any, ...]:
1956
+ language = normalize_language(selected_language)
1957
+ if state:
1958
+ if state.get("completed_at"):
1959
+ return _drop_language_selector_update(
1960
+ render_thank_you_view(
1961
+ config=config,
1962
+ intro_case=intro_case,
1963
+ language=language,
1964
+ state=state,
1965
+ )
1966
+ )
1967
+ return _drop_language_selector_update(
1968
+ render_question_view(
1969
+ config=config,
1970
+ intro_case=intro_case,
1971
+ language=language,
1972
+ state=state,
1973
+ study_message=current_study_message,
1974
+ draft_answers=(answer_similarity, answer_quality, answer_preference),
1975
+ )
1976
+ )
1977
+
1978
+ resolved_participant_id = resolve_browser_participant_id(current_browser_participant_id)
1979
+ return _drop_language_selector_update(
1980
+ render_intro_view(
1981
+ config=config,
1982
+ intro_case=intro_case,
1983
+ language=language,
1984
+ participant_id=resolved_participant_id,
1985
+ browser_participant_id=resolved_participant_id,
1986
+ start_message=current_start_message,
1987
+ )
1988
+ )
1989
+
1990
+ demo.load(initialize_page, inputs=[browser_participant_id, browser_language], outputs=outputs)
1991
+ regenerate_button.click(
1992
+ handle_generate_new_id,
1993
+ inputs=[browser_language],
1994
+ outputs=[participant_id_box, browser_participant_id, start_message],
1995
+ )
1996
+ start_button.click(handle_start, inputs=[browser_participant_id, browser_language], outputs=outputs)
1997
+ previous_button.click(
1998
+ handle_previous,
1999
+ inputs=[participant_state, question_token, browser_participant_id, browser_language],
2000
+ outputs=outputs,
2001
+ )
2002
+ next_button.click(
2003
+ lambda state, token, similarity, quality, preference, browser_pid, current_language: handle_next_or_submit(
2004
+ state, token, similarity, quality, preference, "next", browser_pid, current_language
2005
+ ),
2006
+ inputs=[
2007
+ participant_state,
2008
+ question_token,
2009
+ similarity_radio,
2010
+ quality_radio,
2011
+ preference_radio,
2012
+ browser_participant_id,
2013
+ browser_language,
2014
+ ],
2015
+ outputs=outputs,
2016
+ )
2017
+ submit_button.click(
2018
+ lambda state, token, similarity, quality, preference, browser_pid, current_language: handle_next_or_submit(
2019
+ state, token, similarity, quality, preference, "submit", browser_pid, current_language
2020
+ ),
2021
+ inputs=[
2022
+ participant_state,
2023
+ question_token,
2024
+ similarity_radio,
2025
+ quality_radio,
2026
+ preference_radio,
2027
+ browser_participant_id,
2028
+ browser_language,
2029
+ ],
2030
+ outputs=outputs,
2031
+ )
2032
+ language_selector.change(
2033
+ handle_language_change,
2034
+ inputs=[
2035
+ language_selector,
2036
+ participant_state,
2037
+ browser_participant_id,
2038
+ question_token,
2039
+ similarity_radio,
2040
+ quality_radio,
2041
+ preference_radio,
2042
+ start_message,
2043
+ study_message,
2044
+ ],
2045
+ outputs=outputs_without_language_selector,
2046
+ )
2047
+
2048
+ return demo
2049
+
2050
+
2051
+ def parse_args() -> argparse.Namespace:
2052
+ parser = argparse.ArgumentParser(description="Launch the Gradio user study application.")
2053
+ parser.add_argument(
2054
+ "--config",
2055
+ type=Path,
2056
+ default=PROJECT_ROOT / "data" / "study_config.json",
2057
+ help="Path to the study configuration JSON file.",
2058
+ )
2059
+ parser.add_argument(
2060
+ "--port",
2061
+ type=int,
2062
+ default=int(os.environ.get("PORT", "7860")),
2063
+ help="Server port for Gradio.",
2064
+ )
2065
+ parser.add_argument(
2066
+ "--server-name",
2067
+ type=str,
2068
+ default=os.environ.get("GRADIO_SERVER_NAME", default_server_name()),
2069
+ help="Server bind address for Gradio.",
2070
+ )
2071
+ parser.add_argument(
2072
+ "--share",
2073
+ action="store_true",
2074
+ help="Enable Gradio's temporary public share link.",
2075
+ )
2076
+ return parser.parse_args()
2077
+
2078
+
2079
+ def main() -> None:
2080
+ args = parse_args()
2081
+ demo = build_demo(args.config)
2082
+ demo.queue()
2083
+ demo.launch(
2084
+ server_name=args.server_name,
2085
+ server_port=args.port,
2086
+ share=args.share,
2087
+ allowed_paths=[str(PROJECT_ROOT)],
2088
+ )
2089
+
2090
+
2091
+ if __name__ == "__main__":
2092
+ main()
assets/instruction_diagram.svg ADDED
data/study_config.json ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "study_id": "anyact_human_motion_user_study_30sample_v3",
3
+ "study_title": "Human Motion Reenactment User Study",
4
+ "question_order": "shuffle_per_participant",
5
+ "allow_tie_option": false,
6
+ "disjoint_case_sampling": true,
7
+ "per_participant_pair_limits": {
8
+ "anyact_vs_vlm_hy_motion": 15,
9
+ "anyact_vs_echomotion": 15
10
+ },
11
+ "instruction_case_id": "case_003",
12
+ "reference": {
13
+ "directory": "../videos/reference",
14
+ "glob": "{source_key}_crop.mp4"
15
+ },
16
+ "methods": {
17
+ "anyact": {
18
+ "display_name": "AnyAct (ours)",
19
+ "directory": "../videos/anyact",
20
+ "glob": "{source_key}#0#*_gen.mp4"
21
+ },
22
+ "vlm_hy_motion": {
23
+ "display_name": "VLM+HY-Motion",
24
+ "directory": "../videos/vlm_hy_motion",
25
+ "glob": "{source_key}#0#*_gen_joints.mp4"
26
+ },
27
+ "echomotion": {
28
+ "display_name": "EchoMotion",
29
+ "directory": "../videos/echomotion",
30
+ "glob": "{source_key}_crop.mp4"
31
+ }
32
+ },
33
+ "pair_order": [
34
+ ["anyact", "vlm_hy_motion"],
35
+ ["anyact", "echomotion"]
36
+ ],
37
+ "cases": [
38
+ {"case_id": "case_001", "source_key": "animal_dance2_776p"},
39
+ {"case_id": "case_002", "source_key": "bear_flap1_720p"},
40
+ {"case_id": "case_003", "source_key": "bear_jump1_540p"},
41
+ {"case_id": "case_004", "source_key": "bird_fly1_720p"},
42
+ {"case_id": "case_005", "source_key": "butterfly_fly3_720p"},
43
+ {"case_id": "case_006", "source_key": "cartoonhuman_dance1_istock"},
44
+ {"case_id": "case_007", "source_key": "chicken_walk1_istock"},
45
+ {"case_id": "case_008", "source_key": "crab_walk2_istock"},
46
+ {"case_id": "case_009", "source_key": "dear_walk1_istock"},
47
+ {"case_id": "case_010", "source_key": "dinosaur_walk2_720p"},
48
+ {"case_id": "case_011", "source_key": "dinosaur_walk7_istock"},
49
+ {"case_id": "case_012", "source_key": "frog_swim3_istock"},
50
+ {"case_id": "case_013", "source_key": "ghost_dance1_istock"},
51
+ {"case_id": "case_014", "source_key": "gorilla_walk1_720p"},
52
+ {"case_id": "case_015", "source_key": "human_jump1_istock"},
53
+ {"case_id": "case_016", "source_key": "kangeroo_jump4_istock"},
54
+ {"case_id": "case_017", "source_key": "kangeroo_jump5_1080p"},
55
+ {"case_id": "case_018", "source_key": "labubu_jump1_1080p"},
56
+ {"case_id": "case_019", "source_key": "luckycat_wave5_istock"},
57
+ {"case_id": "case_020", "source_key": "monkey_swing3_istock"},
58
+ {"case_id": "case_021", "source_key": "monkey_walk3_720p"},
59
+ {"case_id": "case_022", "source_key": "monster_fly2_720p"},
60
+ {"case_id": "case_023", "source_key": "monster_jump1_720p"},
61
+ {"case_id": "case_024", "source_key": "penguin_walk4_istock"},
62
+ {"case_id": "case_025", "source_key": "penguin_walk5_istock"},
63
+ {"case_id": "case_026", "source_key": "plane_fly1_720p"},
64
+ {"case_id": "case_027", "source_key": "rabit_jump1_720p"},
65
+ {"case_id": "case_028", "source_key": "seal_walk1_istock"},
66
+ {"case_id": "case_029", "source_key": "squirrel_eat1_720p"},
67
+ {"case_id": "case_030", "source_key": "turtle_swim3_istock"}
68
+ ]
69
+ }
deploy_to_hf_space.bat ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ setlocal
3
+ cd /d %~dp0
4
+
5
+ if "%~1"=="" (
6
+ echo Usage: deploy_to_hf_space.bat username/space-name
7
+ echo Example: deploy_to_hf_space.bat yourname/anyact-user-study
8
+ exit /b 1
9
+ )
10
+
11
+ set "SPACE_REPO=%~1"
12
+
13
+ echo [1/3] Checking Hugging Face authentication...
14
+ hf auth whoami >nul 2>nul
15
+ if errorlevel 1 (
16
+ echo [ERROR] Hugging Face CLI is not logged in.
17
+ echo Please run: hf auth login
18
+ exit /b 1
19
+ )
20
+
21
+ echo [2/3] Creating the Space if needed...
22
+ hf repos create %SPACE_REPO% --type space --space-sdk gradio --public --exist-ok
23
+ if errorlevel 1 (
24
+ echo [ERROR] Failed to create or access the target Space.
25
+ exit /b 1
26
+ )
27
+
28
+ echo [3/3] Uploading the project...
29
+ hf upload-large-folder %SPACE_REPO% . --repo-type space --exclude ".venv/**" --exclude ".gradio/**" --exclude "__pycache__/**" --exclude "results/**"
30
+ if errorlevel 1 (
31
+ echo [ERROR] Upload failed.
32
+ exit /b 1
33
+ )
34
+
35
+ echo [DONE] Space uploaded successfully: https://huggingface.co/spaces/%SPACE_REPO%
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=5.0.0,<6.0.0
2
+ pandas>=2.2.0
3
+ matplotlib>=3.8.0
4
+ filelock>=3.16.0
5
+ imageio-ffmpeg>=0.6.0
study_utils.py ADDED
@@ -0,0 +1,1353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import copy
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import random
9
+ import re
10
+ import subprocess
11
+ import time
12
+ import uuid
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Tuple
16
+
17
+ from filelock import FileLock
18
+
19
+ try:
20
+ import imageio_ffmpeg
21
+ except ImportError: # pragma: no cover - optional runtime dependency
22
+ imageio_ffmpeg = None
23
+
24
+ METHOD_FALLBACK_LABELS = {
25
+ "anyact": "AnyAct (ours)",
26
+ "vlm_hy_motion": "VLM+HY-Motion",
27
+ "echomotion": "EchoMotion",
28
+ }
29
+
30
+ PAIRWISE_METHOD_PAIRS: List[Tuple[str, str]] = [
31
+ ("anyact", "vlm_hy_motion"),
32
+ ("anyact", "echomotion"),
33
+ ]
34
+
35
+ CHOICE_OPTIONS = ["ResultA", "ResultB"]
36
+
37
+ CHOICE_RESULT_A = "ResultA"
38
+ CHOICE_RESULT_B = "ResultB"
39
+ CHOICE_TIE = "Tie"
40
+
41
+ CSV_COLUMNS = [
42
+ "participant_id",
43
+ "consent",
44
+ "study_id",
45
+ "study_title",
46
+ "question_id",
47
+ "question_position",
48
+ "total_questions",
49
+ "case_id",
50
+ "case_title",
51
+ "source_key",
52
+ "pair_id",
53
+ "result_a_method",
54
+ "result_b_method",
55
+ "left_method",
56
+ "right_method",
57
+ "reference_video",
58
+ "result_a_video",
59
+ "result_b_video",
60
+ "left_video",
61
+ "right_video",
62
+ "answer_similarity",
63
+ "answer_similarity_method",
64
+ "answer_similarity_video",
65
+ "answer_quality",
66
+ "answer_quality_method",
67
+ "answer_quality_video",
68
+ "answer_preference",
69
+ "answer_preference_method",
70
+ "answer_preference_video",
71
+ "answered_at",
72
+ "duration_seconds",
73
+ "session_hash",
74
+ "user_agent",
75
+ "started_at",
76
+ "updated_at",
77
+ ]
78
+
79
+
80
+ def now_iso() -> str:
81
+ return datetime.now().astimezone().isoformat(timespec="seconds")
82
+
83
+
84
+ def get_results_dir(project_root: Path) -> Path:
85
+ explicit_dir = os.environ.get("USER_STUDY_RESULTS_DIR", "").strip()
86
+ if explicit_dir:
87
+ return Path(explicit_dir).expanduser().resolve()
88
+
89
+ if os.environ.get("SPACE_ID"):
90
+ space_data_dir = Path("/data")
91
+ if space_data_dir.exists():
92
+ return (space_data_dir / "user_study_results").resolve()
93
+
94
+ return (project_root / "results").resolve()
95
+
96
+
97
+ def ensure_runtime_dirs(project_root: Path) -> None:
98
+ results_dir = get_results_dir(project_root)
99
+ for path in [
100
+ results_dir,
101
+ results_dir / "participants",
102
+ results_dir / "participants_archive",
103
+ results_dir / "plots",
104
+ results_dir / "locks",
105
+ ]:
106
+ path.mkdir(parents=True, exist_ok=True)
107
+
108
+ responses_csv = results_dir / "responses.csv"
109
+ if not responses_csv.exists():
110
+ with responses_csv.open("w", newline="", encoding="utf-8") as handle:
111
+ writer = csv.DictWriter(handle, fieldnames=CSV_COLUMNS)
112
+ writer.writeheader()
113
+
114
+ responses_jsonl = results_dir / "responses.jsonl"
115
+ responses_jsonl.touch(exist_ok=True)
116
+
117
+
118
+ def generate_participant_id() -> str:
119
+ return str(uuid.uuid4())
120
+
121
+
122
+ def sanitize_participant_id(raw_value: str | None) -> str:
123
+ cleaned = re.sub(r"[^A-Za-z0-9_-]", "_", (raw_value or "").strip())
124
+ return cleaned[:80]
125
+
126
+ def humanize_source_key(source_key: str) -> str:
127
+ return source_key.replace("_", " ").strip().title()
128
+
129
+
130
+ def stable_int_seed(text: str) -> int:
131
+ digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
132
+ return int(digest[:16], 16)
133
+
134
+
135
+ def build_pair_id(method_a: str, method_b: str) -> str:
136
+ return f"{method_a}_vs_{method_b}"
137
+
138
+
139
+ def normalize_choice_value(raw_value: Any) -> str:
140
+ if raw_value is None:
141
+ return ""
142
+
143
+ cleaned = str(raw_value).strip()
144
+ if not cleaned:
145
+ return ""
146
+
147
+ compact = re.sub(r"[\s_-]+", "", cleaned).lower()
148
+ if compact in {"left", "resulta", "a"}:
149
+ return CHOICE_RESULT_A
150
+ if compact in {"right", "resultb", "b"}:
151
+ return CHOICE_RESULT_B
152
+ if compact in {"tie", "equal", "same"}:
153
+ return CHOICE_TIE
154
+ return cleaned
155
+
156
+
157
+ def _sync_result_slot_fields(row: Dict[str, Any], case: Dict[str, Any] | None = None) -> Dict[str, Any]:
158
+ result_a_method = str(row.get("result_a_method") or row.get("left_method") or "").strip()
159
+ result_b_method = str(row.get("result_b_method") or row.get("right_method") or "").strip()
160
+
161
+ result_a_video = str(row.get("result_a_video") or row.get("left_video") or "").strip()
162
+ result_b_video = str(row.get("result_b_video") or row.get("right_video") or "").strip()
163
+
164
+ if case is not None:
165
+ method_videos = case.get("method_videos", {})
166
+ if result_a_method in method_videos:
167
+ result_a_video = str(method_videos[result_a_method])
168
+ if result_b_method in method_videos:
169
+ result_b_video = str(method_videos[result_b_method])
170
+ if case.get("reference_video"):
171
+ row["reference_video"] = case["reference_video"]
172
+
173
+ row["result_a_method"] = result_a_method
174
+ row["result_b_method"] = result_b_method
175
+ row["left_method"] = result_a_method
176
+ row["right_method"] = result_b_method
177
+ row["result_a_video"] = result_a_video
178
+ row["result_b_video"] = result_b_video
179
+ row["left_video"] = result_a_video
180
+ row["right_video"] = result_b_video
181
+ return row
182
+
183
+
184
+ def _resolve_choice_targets(row: Dict[str, Any], raw_choice: Any) -> tuple[str, str]:
185
+ normalized_choice = normalize_choice_value(raw_choice)
186
+ if normalized_choice == CHOICE_RESULT_A:
187
+ return (
188
+ str(row.get("result_a_method") or row.get("left_method") or "").strip(),
189
+ str(row.get("result_a_video") or row.get("left_video") or "").strip(),
190
+ )
191
+ if normalized_choice == CHOICE_RESULT_B:
192
+ return (
193
+ str(row.get("result_b_method") or row.get("right_method") or "").strip(),
194
+ str(row.get("result_b_video") or row.get("right_video") or "").strip(),
195
+ )
196
+ return "", ""
197
+
198
+
199
+ def upgrade_response_row_schema(row: Dict[str, Any], case: Dict[str, Any] | None = None) -> Dict[str, Any]:
200
+ upgraded = row
201
+ _sync_result_slot_fields(upgraded, case=case)
202
+
203
+ for metric_key in ["answer_similarity", "answer_quality", "answer_preference"]:
204
+ normalized_choice = normalize_choice_value(upgraded.get(metric_key))
205
+ if normalized_choice:
206
+ upgraded[metric_key] = normalized_choice
207
+ elif metric_key not in upgraded:
208
+ upgraded[metric_key] = ""
209
+
210
+ selected_method, selected_video = _resolve_choice_targets(upgraded, upgraded.get(metric_key))
211
+ upgraded[f"{metric_key}_method"] = selected_method
212
+ upgraded[f"{metric_key}_video"] = selected_video
213
+
214
+ return upgraded
215
+
216
+
217
+ def _web_video_cache_dir(project_root: Path) -> Path:
218
+ cache_dir = get_results_dir(project_root) / "web_video_cache"
219
+ cache_dir.mkdir(parents=True, exist_ok=True)
220
+ return cache_dir
221
+
222
+
223
+ def _thumbnail_cache_dir(project_root: Path) -> Path:
224
+ cache_dir = get_results_dir(project_root) / "thumbnail_cache"
225
+ cache_dir.mkdir(parents=True, exist_ok=True)
226
+ return cache_dir
227
+
228
+
229
+ def _synced_video_cache_dir(project_root: Path) -> Path:
230
+ cache_dir = get_results_dir(project_root) / "synced_video_cache"
231
+ cache_dir.mkdir(parents=True, exist_ok=True)
232
+ return cache_dir
233
+
234
+
235
+ def _probe_video_stream(video_path: Path) -> Dict[str, str]:
236
+ if imageio_ffmpeg is None:
237
+ return {}
238
+
239
+ ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
240
+ result = subprocess.run(
241
+ [ffmpeg_exe, "-i", str(video_path)],
242
+ capture_output=True,
243
+ text=True,
244
+ encoding="utf-8",
245
+ errors="ignore",
246
+ )
247
+ stderr_text = result.stderr or ""
248
+ match = re.search(r"Video:\s*([^\s,(]+).*?(yuv[a-zA-Z0-9]+)?", stderr_text)
249
+ if not match:
250
+ return {}
251
+
252
+ codec_name = (match.group(1) or "").strip().lower()
253
+ pixel_format = (match.group(2) or "").strip().lower()
254
+ return {
255
+ "codec_name": codec_name,
256
+ "pixel_format": pixel_format,
257
+ }
258
+
259
+
260
+ def _parse_duration_to_seconds(duration_text: str) -> float:
261
+ hours, minutes, seconds = duration_text.split(":")
262
+ return int(hours) * 3600 + int(minutes) * 60 + float(seconds)
263
+
264
+
265
+ def _probe_video_timing(video_path: Path) -> Dict[str, float]:
266
+ if imageio_ffmpeg is None:
267
+ return {}
268
+
269
+ ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
270
+ result = subprocess.run(
271
+ [ffmpeg_exe, "-i", str(video_path)],
272
+ capture_output=True,
273
+ text=True,
274
+ encoding="utf-8",
275
+ errors="ignore",
276
+ )
277
+ stderr_text = result.stderr or ""
278
+
279
+ duration_match = re.search(r"Duration:\s*(\d+:\d+:\d+(?:\.\d+)?)", stderr_text)
280
+ fps_match = re.search(r"(\d+(?:\.\d+)?)\s+fps", stderr_text)
281
+ if fps_match is None:
282
+ fps_match = re.search(r"(\d+(?:\.\d+)?)\s+tbr", stderr_text)
283
+
284
+ metadata: Dict[str, float] = {}
285
+ if duration_match:
286
+ metadata["duration_seconds"] = _parse_duration_to_seconds(duration_match.group(1))
287
+ if fps_match:
288
+ metadata["fps"] = float(fps_match.group(1))
289
+ return metadata
290
+
291
+
292
+ def _format_ffmpeg_fps(value: float) -> str:
293
+ rounded = round(value)
294
+ if abs(value - rounded) < 1e-6:
295
+ return str(int(rounded))
296
+ return f"{value:.3f}".rstrip("0").rstrip(".")
297
+
298
+
299
+ def _sync_single_video_to_duration(
300
+ source_path: Path,
301
+ target_path: Path,
302
+ target_duration: float,
303
+ target_fps: float,
304
+ ) -> None:
305
+ ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
306
+ source_timing = _probe_video_timing(source_path)
307
+ source_duration = float(source_timing.get("duration_seconds", 0.0) or 0.0)
308
+ pad_duration = max(0.0, target_duration - source_duration)
309
+ fps_literal = _format_ffmpeg_fps(target_fps)
310
+ filter_graph = (
311
+ f"fps={fps_literal},"
312
+ f"tpad=stop_mode=clone:stop_duration={pad_duration:.6f},"
313
+ f"trim=duration={target_duration:.6f},"
314
+ "setpts=PTS-STARTPTS"
315
+ )
316
+ command = [
317
+ ffmpeg_exe,
318
+ "-y",
319
+ "-i",
320
+ str(source_path),
321
+ "-an",
322
+ "-vf",
323
+ filter_graph,
324
+ "-c:v",
325
+ "libx264",
326
+ "-preset",
327
+ "veryfast",
328
+ "-pix_fmt",
329
+ "yuv420p",
330
+ "-movflags",
331
+ "+faststart",
332
+ str(target_path),
333
+ ]
334
+ result = subprocess.run(
335
+ command,
336
+ capture_output=True,
337
+ text=True,
338
+ encoding="utf-8",
339
+ errors="ignore",
340
+ )
341
+ if result.returncode != 0 or not target_path.exists():
342
+ raise RuntimeError(
343
+ f"Failed to create synchronized study video: {source_path}\n{result.stderr}"
344
+ )
345
+
346
+
347
+ def ensure_synchronized_study_videos(
348
+ reference_video: str,
349
+ left_video: str,
350
+ right_video: str,
351
+ project_root: Path,
352
+ target_fps: float = 30.0,
353
+ ) -> Dict[str, str]:
354
+ """
355
+ Create browser-playable synchronized copies for the three study videos.
356
+
357
+ The shorter videos are padded by cloning their last frame so that all three
358
+ outputs share the same fps and total duration. If synchronization fails for
359
+ any reason, the original paths are returned to keep the study app usable.
360
+ """
361
+ raw_paths = {
362
+ "reference_video": Path(reference_video).resolve(),
363
+ "left_video": Path(left_video).resolve(),
364
+ "right_video": Path(right_video).resolve(),
365
+ }
366
+ if imageio_ffmpeg is None or not all(path.exists() for path in raw_paths.values()):
367
+ return {key: str(path) for key, path in raw_paths.items()}
368
+
369
+ try:
370
+ durations = []
371
+ for path in raw_paths.values():
372
+ timing = _probe_video_timing(path)
373
+ durations.append(float(timing.get("duration_seconds", 0.0) or 0.0))
374
+
375
+ target_duration = max(durations)
376
+ if target_duration <= 0:
377
+ return {key: str(path) for key, path in raw_paths.items()}
378
+
379
+ cache_dir = _synced_video_cache_dir(project_root)
380
+ signature = hashlib.sha1(
381
+ "::".join(
382
+ [
383
+ "sync_v1",
384
+ f"fps={_format_ffmpeg_fps(target_fps)}",
385
+ *(
386
+ f"{path.as_posix()}::{path.stat().st_mtime_ns}::{path.stat().st_size}"
387
+ for path in raw_paths.values()
388
+ ),
389
+ ]
390
+ ).encode("utf-8")
391
+ ).hexdigest()[:16]
392
+ trio_dir = cache_dir / signature
393
+ lock_path = trio_dir.with_suffix(".lock")
394
+
395
+ with FileLock(str(lock_path)):
396
+ trio_dir.mkdir(parents=True, exist_ok=True)
397
+ output_paths = {
398
+ "reference_video": trio_dir / "reference.mp4",
399
+ "left_video": trio_dir / "left.mp4",
400
+ "right_video": trio_dir / "right.mp4",
401
+ }
402
+ ready = all(path.exists() and path.stat().st_size > 0 for path in output_paths.values())
403
+ if not ready:
404
+ for key, source_path in raw_paths.items():
405
+ _sync_single_video_to_duration(
406
+ source_path=source_path,
407
+ target_path=output_paths[key],
408
+ target_duration=target_duration,
409
+ target_fps=target_fps,
410
+ )
411
+ return {key: str(path) for key, path in output_paths.items()}
412
+ except Exception as exc:
413
+ print(f"[warn] Falling back to original study videos because sync generation failed: {exc}")
414
+ return {key: str(path) for key, path in raw_paths.items()}
415
+
416
+
417
+ def ensure_web_playable_video(video_path: str, project_root: Path) -> str:
418
+ source_path = Path(video_path).resolve()
419
+ if not source_path.exists() or imageio_ffmpeg is None:
420
+ return str(source_path)
421
+
422
+ stream_info = _probe_video_stream(source_path)
423
+ if (
424
+ source_path.suffix.lower() == ".mp4"
425
+ and stream_info.get("codec_name") == "h264"
426
+ and (not stream_info.get("pixel_format") or stream_info.get("pixel_format") == "yuv420p")
427
+ ):
428
+ return str(source_path)
429
+
430
+ cache_dir = _web_video_cache_dir(project_root)
431
+ signature = hashlib.sha1(
432
+ f"{source_path.as_posix()}::{source_path.stat().st_mtime_ns}::{source_path.stat().st_size}".encode("utf-8")
433
+ ).hexdigest()[:12]
434
+ target_path = cache_dir / f"{source_path.stem}_{signature}.mp4"
435
+ lock_path = target_path.with_suffix(".lock")
436
+
437
+ with FileLock(str(lock_path)):
438
+ if target_path.exists() and target_path.stat().st_size > 0:
439
+ return str(target_path)
440
+
441
+ ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
442
+ command = [
443
+ ffmpeg_exe,
444
+ "-y",
445
+ "-i",
446
+ str(source_path),
447
+ "-an",
448
+ "-c:v",
449
+ "libx264",
450
+ "-pix_fmt",
451
+ "yuv420p",
452
+ "-movflags",
453
+ "+faststart",
454
+ str(target_path),
455
+ ]
456
+ result = subprocess.run(
457
+ command,
458
+ capture_output=True,
459
+ text=True,
460
+ encoding="utf-8",
461
+ errors="ignore",
462
+ )
463
+ if result.returncode != 0 or not target_path.exists():
464
+ raise RuntimeError(
465
+ f"Failed to convert video for browser playback: {source_path}\n{result.stderr}"
466
+ )
467
+
468
+ return str(target_path)
469
+
470
+
471
+ def prepare_reference_videos_for_web(config: Dict[str, Any], project_root: Path) -> Dict[str, Any]:
472
+ for case in config.get("cases", []):
473
+ case["reference_video"] = ensure_web_playable_video(case["reference_video"], project_root)
474
+ return config
475
+
476
+
477
+ def ensure_video_thumbnail(
478
+ video_path: str,
479
+ project_root: Path,
480
+ time_seconds: float = 0.8,
481
+ width: int = 480,
482
+ ) -> str:
483
+ source_path = Path(video_path).resolve()
484
+ if not source_path.exists() or imageio_ffmpeg is None:
485
+ return ""
486
+
487
+ cache_dir = _thumbnail_cache_dir(project_root)
488
+ signature = hashlib.sha1(
489
+ f"{source_path.as_posix()}::{source_path.stat().st_mtime_ns}::{source_path.stat().st_size}::{time_seconds}::{width}".encode(
490
+ "utf-8"
491
+ )
492
+ ).hexdigest()[:12]
493
+ target_path = cache_dir / f"{source_path.stem}_{signature}.jpg"
494
+ lock_path = target_path.with_suffix(".lock")
495
+
496
+ with FileLock(str(lock_path)):
497
+ if target_path.exists() and target_path.stat().st_size > 0:
498
+ return str(target_path)
499
+
500
+ ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
501
+ command = [
502
+ ffmpeg_exe,
503
+ "-y",
504
+ "-ss",
505
+ str(time_seconds),
506
+ "-i",
507
+ str(source_path),
508
+ "-frames:v",
509
+ "1",
510
+ "-vf",
511
+ f"scale={width}:-1",
512
+ "-q:v",
513
+ "2",
514
+ str(target_path),
515
+ ]
516
+ result = subprocess.run(
517
+ command,
518
+ capture_output=True,
519
+ text=True,
520
+ encoding="utf-8",
521
+ errors="ignore",
522
+ )
523
+ if result.returncode != 0 or not target_path.exists():
524
+ raise RuntimeError(
525
+ f"Failed to extract thumbnail from video: {source_path}\n{result.stderr}"
526
+ )
527
+
528
+ return str(target_path)
529
+
530
+
531
+ def _resolve_path(config_dir: Path, raw_path: str) -> Path:
532
+ path = Path(raw_path)
533
+ if path.is_absolute():
534
+ return path
535
+ return (config_dir / path).resolve()
536
+
537
+
538
+ def _resolve_single_match(config_dir: Path, directory: str, pattern: str, source_key: str) -> Path:
539
+ base_dir = _resolve_path(config_dir, directory)
540
+ if not base_dir.exists():
541
+ raise FileNotFoundError(f"Configured directory does not exist: {base_dir}")
542
+
543
+ resolved_pattern = pattern.format(source_key=source_key)
544
+ matches = sorted(base_dir.glob(resolved_pattern))
545
+ if not matches:
546
+ raise FileNotFoundError(
547
+ f"No video matched pattern '{resolved_pattern}' inside '{base_dir}' for source_key='{source_key}'."
548
+ )
549
+ if len(matches) > 1:
550
+ match_str = ", ".join(str(match) for match in matches)
551
+ raise ValueError(
552
+ f"Pattern '{resolved_pattern}' for source_key='{source_key}' matched multiple files: {match_str}"
553
+ )
554
+ return matches[0].resolve()
555
+
556
+
557
+ def _normalize_case(
558
+ raw_case: Dict[str, Any],
559
+ raw_config: Dict[str, Any],
560
+ config_dir: Path,
561
+ method_ids: List[str],
562
+ ) -> Dict[str, Any]:
563
+ case_id = raw_case["case_id"]
564
+ source_key = raw_case.get("source_key", case_id)
565
+ case_title = raw_case.get("title") or humanize_source_key(source_key)
566
+
567
+ if raw_case.get("reference_video") and raw_case.get("method_videos"):
568
+ reference_video = _resolve_path(config_dir, raw_case["reference_video"]).resolve()
569
+ method_videos = {
570
+ method_id: _resolve_path(config_dir, raw_case["method_videos"][method_id]).resolve()
571
+ for method_id in method_ids
572
+ }
573
+ else:
574
+ reference_cfg = raw_config["reference"]
575
+ reference_video = _resolve_single_match(
576
+ config_dir=config_dir,
577
+ directory=reference_cfg["directory"],
578
+ pattern=reference_cfg["glob"],
579
+ source_key=source_key,
580
+ )
581
+ method_videos = {}
582
+ for method_id in method_ids:
583
+ method_cfg = raw_config["methods"][method_id]
584
+ method_videos[method_id] = _resolve_single_match(
585
+ config_dir=config_dir,
586
+ directory=method_cfg["directory"],
587
+ pattern=method_cfg["glob"],
588
+ source_key=source_key,
589
+ )
590
+
591
+ missing_files = [reference_video, *method_videos.values()]
592
+ for path in missing_files:
593
+ if not path.exists():
594
+ raise FileNotFoundError(f"Missing video file for case '{case_id}': {path}")
595
+
596
+ return {
597
+ "case_id": case_id,
598
+ "source_key": source_key,
599
+ "case_title": case_title,
600
+ "reference_video": str(reference_video),
601
+ "method_videos": {method_id: str(path) for method_id, path in method_videos.items()},
602
+ }
603
+
604
+
605
+ def load_study_config(config_path: str | Path) -> Dict[str, Any]:
606
+ config_path = Path(config_path).resolve()
607
+ config_dir = config_path.parent
608
+
609
+ with config_path.open("r", encoding="utf-8") as handle:
610
+ raw_config = json.load(handle)
611
+
612
+ if "methods" not in raw_config or "cases" not in raw_config:
613
+ raise ValueError("study_config.json must define both 'methods' and 'cases'.")
614
+
615
+ method_ids = list(raw_config["methods"].keys())
616
+ if set(method_ids) != set(METHOD_FALLBACK_LABELS.keys()):
617
+ raise ValueError(
618
+ "This sample project expects exactly three methods: anyact, vlm_hy_motion, echomotion."
619
+ )
620
+
621
+ pair_order = raw_config.get("pair_order", [list(pair) for pair in PAIRWISE_METHOD_PAIRS])
622
+ normalized_pairs: List[Tuple[str, str]] = []
623
+ for raw_pair in pair_order:
624
+ if len(raw_pair) != 2:
625
+ raise ValueError(f"Each pair_order entry must contain exactly two methods: {raw_pair}")
626
+ left, right = raw_pair
627
+ if left not in method_ids or right not in method_ids:
628
+ raise ValueError(f"Unknown method in pair_order: {raw_pair}")
629
+ normalized_pairs.append((left, right))
630
+
631
+ methods = {}
632
+ for method_id, method_cfg in raw_config["methods"].items():
633
+ methods[method_id] = {
634
+ "display_name": method_cfg.get("display_name", METHOD_FALLBACK_LABELS[method_id]),
635
+ "directory": method_cfg.get("directory", ""),
636
+ "glob": method_cfg.get("glob", ""),
637
+ }
638
+
639
+ cases = [
640
+ _normalize_case(
641
+ raw_case=raw_case,
642
+ raw_config=raw_config,
643
+ config_dir=config_dir,
644
+ method_ids=method_ids,
645
+ )
646
+ for raw_case in raw_config["cases"]
647
+ ]
648
+
649
+ raw_pair_limits = raw_config.get("per_participant_pair_limits", {})
650
+ pair_sample_limits: Dict[str, int] = {}
651
+ for method_a, method_b in normalized_pairs:
652
+ pair_id = build_pair_id(method_a, method_b)
653
+ raw_limit = raw_pair_limits.get(pair_id, len(cases))
654
+ try:
655
+ limit_value = int(raw_limit)
656
+ except (TypeError, ValueError) as exc:
657
+ raise ValueError(f"Invalid per_participant_pair_limits value for '{pair_id}': {raw_limit}") from exc
658
+ if limit_value <= 0 or limit_value > len(cases):
659
+ raise ValueError(
660
+ f"per_participant_pair_limits['{pair_id}'] must be within [1, {len(cases)}], got {limit_value}."
661
+ )
662
+ pair_sample_limits[pair_id] = limit_value
663
+
664
+ disjoint_case_sampling = bool(raw_config.get("disjoint_case_sampling", False))
665
+ if disjoint_case_sampling and sum(pair_sample_limits.values()) > len(cases):
666
+ raise ValueError(
667
+ "disjoint_case_sampling=True requires the sum of per-participant pair limits "
668
+ f"to be <= number of cases ({len(cases)})."
669
+ )
670
+
671
+ case_ids = {case["case_id"] for case in cases}
672
+ instruction_case_id = raw_config.get("instruction_case_id")
673
+ if instruction_case_id and instruction_case_id not in case_ids:
674
+ raise ValueError(f"instruction_case_id='{instruction_case_id}' is not present in cases.")
675
+ if not instruction_case_id:
676
+ instruction_case_id = cases[0]["case_id"]
677
+
678
+ return {
679
+ "study_id": raw_config.get("study_id", "anyact_user_study"),
680
+ "study_title": raw_config.get("study_title", "Human Motion Reenactment User Study"),
681
+ "question_order": raw_config.get("question_order", "shuffle_per_participant"),
682
+ "allow_tie_option": raw_config.get("allow_tie_option", True),
683
+ "pair_order": normalized_pairs,
684
+ "pair_sample_limits": pair_sample_limits,
685
+ "disjoint_case_sampling": disjoint_case_sampling,
686
+ "question_bank_total": len(cases) * len(normalized_pairs),
687
+ "participant_question_total": sum(pair_sample_limits.values()),
688
+ "methods": methods,
689
+ "cases": cases,
690
+ "instruction_case_id": instruction_case_id,
691
+ "config_path": str(config_path),
692
+ }
693
+
694
+
695
+ def get_instruction_case(config: Dict[str, Any]) -> Dict[str, Any]:
696
+ target_case_id = config["instruction_case_id"]
697
+ for case in config["cases"]:
698
+ if case["case_id"] == target_case_id:
699
+ return case
700
+ raise KeyError(f"Instruction case '{target_case_id}' was not found.")
701
+
702
+
703
+ def build_questions(config: Dict[str, Any], participant_id: str) -> List[Dict[str, Any]]:
704
+ questions: List[Dict[str, Any]] = []
705
+ cases = list(config["cases"])
706
+ pair_case_assignments: Dict[str, List[Dict[str, Any]]] = {}
707
+
708
+ if config.get("disjoint_case_sampling"):
709
+ shuffled_cases = list(cases)
710
+ assignment_rng = random.Random(stable_int_seed(f"{config['study_id']}::{participant_id}::case_assignment"))
711
+ assignment_rng.shuffle(shuffled_cases)
712
+
713
+ cursor = 0
714
+ for method_a, method_b in config["pair_order"]:
715
+ pair_id = build_pair_id(method_a, method_b)
716
+ sample_size = config["pair_sample_limits"][pair_id]
717
+ selected_cases = shuffled_cases[cursor : cursor + sample_size]
718
+ if len(selected_cases) != sample_size:
719
+ raise ValueError(
720
+ f"Not enough unique cases to assign pair '{pair_id}'. Requested {sample_size}, got {len(selected_cases)}."
721
+ )
722
+ pair_case_assignments[pair_id] = selected_cases
723
+ cursor += sample_size
724
+ else:
725
+ for method_a, method_b in config["pair_order"]:
726
+ pair_id = build_pair_id(method_a, method_b)
727
+ sample_size = config["pair_sample_limits"][pair_id]
728
+ pair_rng = random.Random(stable_int_seed(f"{config['study_id']}::{participant_id}::{pair_id}::sample"))
729
+ pair_case_assignments[pair_id] = pair_rng.sample(cases, sample_size)
730
+
731
+ for method_a, method_b in config["pair_order"]:
732
+ pair_id = build_pair_id(method_a, method_b)
733
+ for case in pair_case_assignments[pair_id]:
734
+ order_rng = random.Random(
735
+ stable_int_seed(f"{config['study_id']}::{participant_id}::{case['case_id']}::{method_a}::{method_b}")
736
+ )
737
+ result_a_method, result_b_method = (method_a, method_b)
738
+ if order_rng.random() < 0.5:
739
+ result_a_method, result_b_method = result_b_method, result_a_method
740
+
741
+ questions.append(
742
+ {
743
+ "case_id": case["case_id"],
744
+ "case_title": case["case_title"],
745
+ "source_key": case["source_key"],
746
+ "pair_id": pair_id,
747
+ "reference_video": case["reference_video"],
748
+ "result_a_method": result_a_method,
749
+ "result_b_method": result_b_method,
750
+ "left_method": result_a_method,
751
+ "right_method": result_b_method,
752
+ "result_a_video": case["method_videos"][result_a_method],
753
+ "result_b_video": case["method_videos"][result_b_method],
754
+ "left_video": case["method_videos"][result_a_method],
755
+ "right_video": case["method_videos"][result_b_method],
756
+ }
757
+ )
758
+
759
+ if config["question_order"] == "shuffle_per_participant":
760
+ shuffle_rng = random.Random(stable_int_seed(f"{config['study_id']}::{participant_id}::question_order"))
761
+ shuffle_rng.shuffle(questions)
762
+
763
+ total_questions = len(questions)
764
+ for index, question in enumerate(questions, start=1):
765
+ question["question_number"] = index
766
+ question["question_id"] = f"Q{index:03d}_{question['case_id']}_{question['pair_id']}"
767
+ question["total_questions"] = total_questions
768
+
769
+ return questions
770
+
771
+
772
+ def _state_path(project_root: Path, participant_id: str) -> Path:
773
+ return get_results_dir(project_root) / "participants" / f"{participant_id}.json"
774
+
775
+
776
+ def _archive_dir(project_root: Path) -> Path:
777
+ return get_results_dir(project_root) / "participants_archive"
778
+
779
+
780
+ def _lock_path(project_root: Path) -> Path:
781
+ return get_results_dir(project_root) / "locks" / "results.lock"
782
+
783
+
784
+ def _responses_jsonl_path(project_root: Path) -> Path:
785
+ return get_results_dir(project_root) / "responses.jsonl"
786
+
787
+
788
+ def _responses_csv_path(project_root: Path) -> Path:
789
+ return get_results_dir(project_root) / "responses.csv"
790
+
791
+
792
+ def _read_state_unlocked(project_root: Path, participant_id: str) -> Dict[str, Any] | None:
793
+ path = _state_path(project_root, participant_id)
794
+ if not path.exists():
795
+ return None
796
+ with path.open("r", encoding="utf-8") as handle:
797
+ return json.load(handle)
798
+
799
+
800
+ def _atomic_write_json(path: Path, data: Dict[str, Any]) -> None:
801
+ temp_path = path.with_suffix(path.suffix + ".tmp")
802
+ with temp_path.open("w", encoding="utf-8") as handle:
803
+ json.dump(data, handle, ensure_ascii=False, indent=2)
804
+ os.replace(temp_path, path)
805
+
806
+
807
+ def _write_state_unlocked(project_root: Path, state: Dict[str, Any]) -> None:
808
+ _atomic_write_json(_state_path(project_root, state["participant_id"]), state)
809
+
810
+
811
+ def _archive_state_unlocked(project_root: Path, state: Dict[str, Any]) -> None:
812
+ archive_dir = _archive_dir(project_root)
813
+ timestamp = re.sub(r"[^0-9A-Za-z_-]", "-", now_iso())
814
+ filename = f"{state.get('participant_id', 'participant')}__{state.get('study_id', 'study')}__{timestamp}.json"
815
+ _atomic_write_json(archive_dir / filename, state)
816
+
817
+
818
+ def _append_jsonl_unlocked(project_root: Path, payload: Dict[str, Any]) -> None:
819
+ jsonl_path = _responses_jsonl_path(project_root)
820
+ with jsonl_path.open("a", encoding="utf-8") as handle:
821
+ handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
822
+
823
+
824
+ def _normalize_canonical_row(row: Dict[str, Any]) -> Dict[str, Any]:
825
+ upgraded_row = upgrade_response_row_schema(row)
826
+ return {column: upgraded_row.get(column, "") for column in CSV_COLUMNS}
827
+
828
+
829
+ def _canonical_row_key(row: Dict[str, Any]) -> Tuple[str, str, str] | None:
830
+ participant_id = str(row.get("participant_id", "")).strip()
831
+ question_id = str(row.get("question_id", "")).strip()
832
+ if not participant_id or not question_id:
833
+ return None
834
+ study_id = str(row.get("study_id", "")).strip()
835
+ return study_id, participant_id, question_id
836
+
837
+
838
+ def _canonical_row_sort_key(row: Dict[str, Any]) -> Tuple[str, str, str]:
839
+ return (
840
+ str(row.get("event_saved_at") or row.get("answered_at") or ""),
841
+ str(row.get("updated_at") or ""),
842
+ str(row.get("answered_at") or ""),
843
+ )
844
+
845
+
846
+ def _merge_canonical_rows(*row_groups: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
847
+ merged_rows: Dict[Tuple[str, str, str], Tuple[Tuple[str, str, str], Dict[str, Any]]] = {}
848
+ for rows in row_groups:
849
+ for row in rows:
850
+ row_key = _canonical_row_key(row)
851
+ if row_key is None:
852
+ continue
853
+ sort_key = _canonical_row_sort_key(row)
854
+ previous = merged_rows.get(row_key)
855
+ if previous is None or sort_key >= previous[0]:
856
+ merged_rows[row_key] = (sort_key, _normalize_canonical_row(row))
857
+
858
+ canonical_rows = [payload for _, payload in merged_rows.values()]
859
+ canonical_rows.sort(
860
+ key=lambda row: (row.get("answered_at", ""), row.get("participant_id", ""), row.get("question_id", ""))
861
+ )
862
+ return canonical_rows
863
+
864
+
865
+ def _load_canonical_rows_from_jsonl_unlocked(project_root: Path) -> List[Dict[str, Any]]:
866
+ jsonl_path = _responses_jsonl_path(project_root)
867
+ if not jsonl_path.exists() or jsonl_path.stat().st_size <= 0:
868
+ return []
869
+
870
+ latest_rows: Dict[Tuple[str, str, str], Tuple[Tuple[str, str, str], Dict[str, Any]]] = {}
871
+ with jsonl_path.open("r", encoding="utf-8") as handle:
872
+ for line in handle:
873
+ if not line.strip():
874
+ continue
875
+ try:
876
+ record = json.loads(line)
877
+ except json.JSONDecodeError:
878
+ # A truncated trailing line should not make the whole study unreadable.
879
+ continue
880
+
881
+ row_key = _canonical_row_key(record)
882
+ if row_key is None:
883
+ continue
884
+
885
+ sort_key = _canonical_row_sort_key(record)
886
+ previous = latest_rows.get(row_key)
887
+ if previous is None or sort_key >= previous[0]:
888
+ latest_rows[row_key] = (sort_key, _normalize_canonical_row(record))
889
+
890
+ rows = [payload for _, payload in latest_rows.values()]
891
+ rows.sort(key=lambda row: (row.get("answered_at", ""), row.get("participant_id", ""), row.get("question_id", "")))
892
+ return rows
893
+
894
+
895
+ def _load_canonical_rows_from_state_files_unlocked(project_root: Path) -> List[Dict[str, Any]]:
896
+ rows: List[Dict[str, Any]] = []
897
+ state_dirs = [
898
+ get_results_dir(project_root) / "participants",
899
+ _archive_dir(project_root),
900
+ ]
901
+ for state_dir in state_dirs:
902
+ for state_path in sorted(state_dir.glob("*.json")):
903
+ with state_path.open("r", encoding="utf-8") as handle:
904
+ state = json.load(handle)
905
+ for row in state.get("answers", {}).values():
906
+ rows.append(_normalize_canonical_row(row))
907
+ rows.sort(key=lambda row: (row.get("answered_at", ""), row.get("participant_id", ""), row.get("question_id", "")))
908
+ return rows
909
+
910
+
911
+ def _all_canonical_rows_unlocked(project_root: Path) -> List[Dict[str, Any]]:
912
+ state_rows = _load_canonical_rows_from_state_files_unlocked(project_root)
913
+ jsonl_rows = _load_canonical_rows_from_jsonl_unlocked(project_root)
914
+ merged_rows = _merge_canonical_rows(state_rows, jsonl_rows)
915
+ if merged_rows:
916
+ return merged_rows
917
+ return []
918
+
919
+
920
+ def _export_csv_unlocked(project_root: Path) -> None:
921
+ csv_path = _responses_csv_path(project_root)
922
+ temp_path = csv_path.with_suffix(".tmp")
923
+ rows = _all_canonical_rows_unlocked(project_root)
924
+ with temp_path.open("w", newline="", encoding="utf-8") as handle:
925
+ writer = csv.DictWriter(handle, fieldnames=CSV_COLUMNS)
926
+ writer.writeheader()
927
+ for row in rows:
928
+ writer.writerow(row)
929
+ os.replace(temp_path, csv_path)
930
+
931
+
932
+ def get_current_question(state: Dict[str, Any]) -> Dict[str, Any]:
933
+ return state["questions"][state["current_index"]]
934
+
935
+
936
+ def question_stable_key(question: Dict[str, Any]) -> str:
937
+ return f"{question['case_id']}::{question['pair_id']}"
938
+
939
+
940
+ def refresh_state_video_paths(state: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
941
+ case_lookup = {case["case_id"]: case for case in config["cases"]}
942
+
943
+ for question in state.get("questions", []):
944
+ case = case_lookup.get(question.get("case_id"))
945
+ if not case:
946
+ continue
947
+ upgrade_response_row_schema(question, case=case)
948
+
949
+ for answer in state.get("answers", {}).values():
950
+ case = case_lookup.get(answer.get("case_id"))
951
+ if not case:
952
+ continue
953
+ upgrade_response_row_schema(answer, case=case)
954
+
955
+ return state
956
+
957
+
958
+ def sync_state_with_config(state: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
959
+ new_questions = build_questions(config=config, participant_id=state["participant_id"])
960
+ old_questions = state.get("questions", [])
961
+ old_answers = state.get("answers", {})
962
+ case_lookup = {case["case_id"]: case for case in config["cases"]}
963
+
964
+ old_current_key = None
965
+ if old_questions:
966
+ old_index = min(max(int(state.get("current_index", 0)), 0), len(old_questions) - 1)
967
+ old_current_key = question_stable_key(old_questions[old_index])
968
+
969
+ old_questions_by_key = {
970
+ question_stable_key(question): question
971
+ for question in old_questions
972
+ if question.get("case_id") and question.get("pair_id")
973
+ }
974
+
975
+ old_rows_by_key = {
976
+ question_stable_key(answer_row): answer_row
977
+ for answer_row in old_answers.values()
978
+ if answer_row.get("case_id") and answer_row.get("pair_id")
979
+ }
980
+
981
+ synced_answers: Dict[str, Dict[str, Any]] = {}
982
+ for question in new_questions:
983
+ stable_key = question_stable_key(question)
984
+ case = case_lookup.get(question["case_id"])
985
+ previous_question = old_questions_by_key.get(stable_key)
986
+ if previous_question:
987
+ question["result_a_method"] = previous_question.get("result_a_method") or previous_question.get("left_method")
988
+ question["result_b_method"] = previous_question.get("result_b_method") or previous_question.get("right_method")
989
+ _sync_result_slot_fields(question, case=case)
990
+
991
+ previous_row = old_rows_by_key.get(stable_key)
992
+ if not previous_row:
993
+ continue
994
+
995
+ upgraded_row = {
996
+ **previous_row,
997
+ "study_id": config["study_id"],
998
+ "study_title": config["study_title"],
999
+ "question_id": question["question_id"],
1000
+ "question_position": question["question_number"],
1001
+ "total_questions": question["total_questions"],
1002
+ "case_id": question["case_id"],
1003
+ "case_title": question["case_title"],
1004
+ "source_key": question["source_key"],
1005
+ "pair_id": question["pair_id"],
1006
+ "result_a_method": question["result_a_method"],
1007
+ "result_b_method": question["result_b_method"],
1008
+ "left_method": question["left_method"],
1009
+ "right_method": question["right_method"],
1010
+ "reference_video": question["reference_video"],
1011
+ "result_a_video": question["result_a_video"],
1012
+ "result_b_video": question["result_b_video"],
1013
+ "left_video": question["left_video"],
1014
+ "right_video": question["right_video"],
1015
+ }
1016
+ synced_answers[question["question_id"]] = upgrade_response_row_schema(upgraded_row, case=case)
1017
+
1018
+ current_index = 0
1019
+ if new_questions:
1020
+ if old_current_key is not None:
1021
+ matched_index = next(
1022
+ (index for index, question in enumerate(new_questions) if question_stable_key(question) == old_current_key),
1023
+ None,
1024
+ )
1025
+ if matched_index is not None:
1026
+ current_index = matched_index
1027
+
1028
+ first_unanswered_index = next(
1029
+ (
1030
+ index
1031
+ for index, question in enumerate(new_questions)
1032
+ if question["question_id"] not in synced_answers
1033
+ ),
1034
+ None,
1035
+ )
1036
+ if first_unanswered_index is not None:
1037
+ current_index = first_unanswered_index
1038
+ else:
1039
+ current_index = len(new_questions) - 1
1040
+
1041
+ state["study_id"] = config["study_id"]
1042
+ state["study_title"] = config["study_title"]
1043
+ state["questions"] = new_questions
1044
+ state["answers"] = synced_answers
1045
+ state["current_index"] = current_index
1046
+
1047
+ if new_questions and len(synced_answers) == len(new_questions):
1048
+ state["completed_at"] = state.get("completed_at") or now_iso()
1049
+ state["status"] = "completed"
1050
+ state["current_question_started_at"] = None
1051
+ else:
1052
+ state["completed_at"] = None
1053
+ state["status"] = "in_progress"
1054
+ state["current_question_started_at"] = time.time()
1055
+
1056
+ return refresh_state_video_paths(state, config)
1057
+
1058
+
1059
+ def _upgrade_state_schema(state: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
1060
+ upgraded_state = copy.deepcopy(state)
1061
+ upgraded_state["study_title"] = config["study_title"]
1062
+ return refresh_state_video_paths(upgraded_state, config)
1063
+
1064
+
1065
+ def upgrade_existing_results_schema(project_root: Path, config: Dict[str, Any]) -> None:
1066
+ ensure_runtime_dirs(project_root)
1067
+ state_dirs = [
1068
+ get_results_dir(project_root) / "participants",
1069
+ _archive_dir(project_root),
1070
+ ]
1071
+
1072
+ with FileLock(str(_lock_path(project_root))):
1073
+ for state_dir in state_dirs:
1074
+ for state_path in sorted(state_dir.glob("*.json")):
1075
+ try:
1076
+ with state_path.open("r", encoding="utf-8") as handle:
1077
+ state = json.load(handle)
1078
+ except json.JSONDecodeError:
1079
+ continue
1080
+
1081
+ upgraded_state = _upgrade_state_schema(state, config)
1082
+ if upgraded_state != state:
1083
+ _atomic_write_json(state_path, upgraded_state)
1084
+
1085
+ _export_csv_unlocked(project_root)
1086
+
1087
+
1088
+ def create_or_resume_participant(
1089
+ project_root: Path,
1090
+ config: Dict[str, Any],
1091
+ participant_id: str | None,
1092
+ request: Any = None,
1093
+ ) -> Tuple[Dict[str, Any], str]:
1094
+ ensure_runtime_dirs(project_root)
1095
+
1096
+ participant_id = sanitize_participant_id(participant_id)
1097
+ if not participant_id:
1098
+ participant_id = generate_participant_id()
1099
+
1100
+ session_hash = getattr(request, "session_hash", "") if request is not None else ""
1101
+ user_agent = request.headers.get("user-agent", "") if request is not None and getattr(request, "headers", None) else ""
1102
+
1103
+ with FileLock(str(_lock_path(project_root))):
1104
+ existing_state = _read_state_unlocked(project_root, participant_id)
1105
+ if existing_state:
1106
+ if existing_state.get("study_id") != config["study_id"]:
1107
+ if existing_state.get("answers"):
1108
+ _archive_state_unlocked(project_root, existing_state)
1109
+ timestamp = now_iso()
1110
+ fresh_state = {
1111
+ "participant_id": participant_id,
1112
+ "consent": True,
1113
+ "study_id": config["study_id"],
1114
+ "study_title": config["study_title"],
1115
+ "created_at": timestamp,
1116
+ "started_at": timestamp,
1117
+ "updated_at": timestamp,
1118
+ "completed_at": None,
1119
+ "status": "in_progress",
1120
+ "session_hash": session_hash,
1121
+ "user_agent": user_agent,
1122
+ "current_index": 0,
1123
+ "current_question_started_at": time.time(),
1124
+ "questions": build_questions(config=config, participant_id=participant_id),
1125
+ "answers": {},
1126
+ }
1127
+ _write_state_unlocked(project_root, fresh_state)
1128
+ return fresh_state, "started"
1129
+
1130
+ existing_state = sync_state_with_config(existing_state, config)
1131
+ if existing_state.get("completed_at"):
1132
+ existing_state["session_hash"] = session_hash or existing_state.get("session_hash", "")
1133
+ existing_state["user_agent"] = user_agent or existing_state.get("user_agent", "")
1134
+ existing_state["updated_at"] = now_iso()
1135
+ _write_state_unlocked(project_root, existing_state)
1136
+ return existing_state, "completed"
1137
+
1138
+ existing_state["session_hash"] = session_hash or existing_state.get("session_hash", "")
1139
+ existing_state["user_agent"] = user_agent or existing_state.get("user_agent", "")
1140
+ existing_state["study_title"] = config["study_title"]
1141
+ existing_state["updated_at"] = now_iso()
1142
+ existing_state["current_question_started_at"] = time.time()
1143
+ _write_state_unlocked(project_root, existing_state)
1144
+ return existing_state, "resumed"
1145
+
1146
+ timestamp = now_iso()
1147
+ state = {
1148
+ "participant_id": participant_id,
1149
+ "consent": True,
1150
+ "study_id": config["study_id"],
1151
+ "study_title": config["study_title"],
1152
+ "created_at": timestamp,
1153
+ "started_at": timestamp,
1154
+ "updated_at": timestamp,
1155
+ "completed_at": None,
1156
+ "status": "in_progress",
1157
+ "session_hash": session_hash,
1158
+ "user_agent": user_agent,
1159
+ "current_index": 0,
1160
+ "current_question_started_at": time.time(),
1161
+ "questions": build_questions(config=config, participant_id=participant_id),
1162
+ "answers": {},
1163
+ }
1164
+ _write_state_unlocked(project_root, state)
1165
+ return state, "started"
1166
+
1167
+
1168
+ def move_question_pointer(
1169
+ project_root: Path,
1170
+ participant_id: str,
1171
+ question_token: str | None,
1172
+ direction: str,
1173
+ ) -> Tuple[Dict[str, Any], str]:
1174
+ with FileLock(str(_lock_path(project_root))):
1175
+ state = _read_state_unlocked(project_root, participant_id)
1176
+ if state is None:
1177
+ raise ValueError("Participant session could not be found.")
1178
+
1179
+ if state.get("completed_at"):
1180
+ return state, "This study session has already been submitted."
1181
+
1182
+ current_question = get_current_question(state)
1183
+ if question_token and current_question["question_id"] != question_token:
1184
+ return state, "A newer page state was already loaded. Restored the latest progress."
1185
+
1186
+ if direction == "previous" and state["current_index"] > 0:
1187
+ state["current_index"] -= 1
1188
+ state["current_question_started_at"] = time.time()
1189
+ state["updated_at"] = now_iso()
1190
+ _write_state_unlocked(project_root, state)
1191
+
1192
+ return state, ""
1193
+
1194
+
1195
+ def _build_response_row(
1196
+ state: Dict[str, Any],
1197
+ question: Dict[str, Any],
1198
+ answer_similarity: str,
1199
+ answer_quality: str,
1200
+ answer_preference: str,
1201
+ duration_seconds: float,
1202
+ ) -> Dict[str, Any]:
1203
+ timestamp = now_iso()
1204
+ response_row = {
1205
+ "participant_id": state["participant_id"],
1206
+ "consent": state.get("consent", True),
1207
+ "study_id": state["study_id"],
1208
+ "study_title": state["study_title"],
1209
+ "question_id": question["question_id"],
1210
+ "question_position": question["question_number"],
1211
+ "total_questions": question["total_questions"],
1212
+ "case_id": question["case_id"],
1213
+ "case_title": question["case_title"],
1214
+ "source_key": question["source_key"],
1215
+ "pair_id": question["pair_id"],
1216
+ "result_a_method": question.get("result_a_method") or question.get("left_method"),
1217
+ "result_b_method": question.get("result_b_method") or question.get("right_method"),
1218
+ "left_method": question["left_method"],
1219
+ "right_method": question["right_method"],
1220
+ "reference_video": question["reference_video"],
1221
+ "result_a_video": question.get("result_a_video") or question.get("left_video"),
1222
+ "result_b_video": question.get("result_b_video") or question.get("right_video"),
1223
+ "left_video": question["left_video"],
1224
+ "right_video": question["right_video"],
1225
+ "answer_similarity": normalize_choice_value(answer_similarity),
1226
+ "answer_quality": normalize_choice_value(answer_quality),
1227
+ "answer_preference": normalize_choice_value(answer_preference),
1228
+ "answered_at": timestamp,
1229
+ "duration_seconds": round(duration_seconds, 3),
1230
+ "session_hash": state.get("session_hash", ""),
1231
+ "user_agent": state.get("user_agent", ""),
1232
+ "started_at": state.get("started_at", ""),
1233
+ "updated_at": timestamp,
1234
+ }
1235
+ return upgrade_response_row_schema(response_row)
1236
+
1237
+
1238
+ def save_current_answer(
1239
+ project_root: Path,
1240
+ participant_id: str,
1241
+ question_token: str,
1242
+ answer_similarity: str,
1243
+ answer_quality: str,
1244
+ answer_preference: str,
1245
+ action: str,
1246
+ ) -> Tuple[Dict[str, Any], str, str]:
1247
+ if action not in {"next", "submit"}:
1248
+ raise ValueError(f"Unsupported action: {action}")
1249
+
1250
+ with FileLock(str(_lock_path(project_root))):
1251
+ state = _read_state_unlocked(project_root, participant_id)
1252
+ if state is None:
1253
+ raise ValueError("Participant session could not be found.")
1254
+
1255
+ if state.get("completed_at"):
1256
+ return state, "This study session has already been submitted.", "completed"
1257
+
1258
+ current_question = get_current_question(state)
1259
+ if current_question["question_id"] != question_token:
1260
+ return state, "A newer page state was already loaded. Restored the latest progress.", "stale"
1261
+
1262
+ elapsed = max(0.0, time.time() - float(state.get("current_question_started_at") or time.time()))
1263
+ previous_row = state["answers"].get(question_token)
1264
+ response_row = _build_response_row(
1265
+ state=state,
1266
+ question=current_question,
1267
+ answer_similarity=answer_similarity,
1268
+ answer_quality=answer_quality,
1269
+ answer_preference=answer_preference,
1270
+ duration_seconds=elapsed,
1271
+ )
1272
+
1273
+ state["answers"][question_token] = response_row
1274
+ state["updated_at"] = response_row["answered_at"]
1275
+ event_type = "answer_updated" if previous_row else "answer_saved"
1276
+
1277
+ if action == "next":
1278
+ if state["current_index"] < len(state["questions"]) - 1:
1279
+ state["current_index"] += 1
1280
+ state["current_question_started_at"] = time.time()
1281
+ status = "advanced"
1282
+ message = "Response saved."
1283
+ else:
1284
+ state["completed_at"] = response_row["answered_at"]
1285
+ state["status"] = "completed"
1286
+ state["current_question_started_at"] = None
1287
+ status = "completed"
1288
+ message = "All responses have been submitted."
1289
+ else:
1290
+ state["completed_at"] = response_row["answered_at"]
1291
+ state["status"] = "completed"
1292
+ state["current_question_started_at"] = None
1293
+ status = "completed"
1294
+ message = "All responses have been submitted."
1295
+
1296
+ _write_state_unlocked(project_root, state)
1297
+ _append_jsonl_unlocked(
1298
+ project_root,
1299
+ {
1300
+ "event_type": event_type,
1301
+ "event_saved_at": response_row["answered_at"],
1302
+ **response_row,
1303
+ },
1304
+ )
1305
+ _export_csv_unlocked(project_root)
1306
+
1307
+ return state, message, status
1308
+
1309
+
1310
+ def build_question_payload(state: Dict[str, Any]) -> Dict[str, Any]:
1311
+ question = get_current_question(state)
1312
+ saved_answers = state.get("answers", {}).get(question["question_id"], {})
1313
+ answered_count = len(state.get("answers", {}))
1314
+
1315
+ return {
1316
+ "question_token": question["question_id"],
1317
+ "progress_markdown": (
1318
+ f"<div class='progress-chip'>Question {question['question_number']} / {question['total_questions']}</div>"
1319
+ f"<div class='meta-line'>Participant ID: <code>{state['participant_id']}</code></div>"
1320
+ f"<div class='meta-line'>Saved responses: {answered_count} / {question['total_questions']}</div>"
1321
+ ),
1322
+ "instruction_markdown": (
1323
+ "Watch the reference clip and both anonymous candidates before answering all three questions."
1324
+ ),
1325
+ "reference_video": question["reference_video"],
1326
+ "result_a_video": question.get("result_a_video") or question["left_video"],
1327
+ "result_b_video": question.get("result_b_video") or question["right_video"],
1328
+ "left_video": question["left_video"],
1329
+ "right_video": question["right_video"],
1330
+ "answer_similarity": normalize_choice_value(saved_answers.get("answer_similarity")),
1331
+ "answer_quality": normalize_choice_value(saved_answers.get("answer_quality")),
1332
+ "answer_preference": normalize_choice_value(saved_answers.get("answer_preference")),
1333
+ "show_previous": question["question_number"] > 1,
1334
+ "show_next": question["question_number"] < question["total_questions"],
1335
+ "show_submit": question["question_number"] == question["total_questions"],
1336
+ }
1337
+
1338
+
1339
+ def build_completion_markdown(state: Dict[str, Any]) -> str:
1340
+ completed_at = state.get("completed_at") or now_iso()
1341
+ total_questions = len(state.get("questions", []))
1342
+ answered_count = len(state.get("answers", {}))
1343
+ return f"""
1344
+ ## Thank you for completing the study.
1345
+
1346
+ Your responses have been saved successfully.
1347
+
1348
+ - Participant ID: `{state["participant_id"]}`
1349
+ - Saved answers: `{answered_count} / {total_questions}`
1350
+ - Completed at: `{completed_at}`
1351
+
1352
+ You may now close this page.
1353
+ """.strip()