Spaces:
Build error
Build error
| from __future__ import annotations | |
| import argparse | |
| import base64 | |
| import html | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import warnings | |
| from pathlib import Path | |
| from typing import Any, Tuple | |
| import gradio as gr | |
| from gradio import processing_utils as gr_processing_utils | |
| try: | |
| import imageio_ffmpeg | |
| except ImportError: # pragma: no cover - optional runtime dependency | |
| imageio_ffmpeg = None | |
| from study_utils import ( | |
| CHOICE_OPTIONS, | |
| build_completion_markdown, | |
| build_question_payload, | |
| create_or_resume_participant, | |
| ensure_runtime_dirs, | |
| ensure_video_thumbnail, | |
| generate_participant_id, | |
| get_instruction_case, | |
| get_results_dir, | |
| load_study_config, | |
| move_question_pointer, | |
| prepare_reference_videos_for_web, | |
| sanitize_participant_id, | |
| save_current_answer, | |
| ensure_synchronized_study_videos, | |
| upgrade_existing_results_schema, | |
| ) | |
| PROJECT_ROOT = Path(__file__).resolve().parent | |
| def default_server_name() -> str: | |
| return "0.0.0.0" if os.environ.get("SPACE_ID") else "127.0.0.1" | |
| def ensure_local_ffmpeg() -> None: | |
| if imageio_ffmpeg is None: | |
| return | |
| ffmpeg_source = Path(imageio_ffmpeg.get_ffmpeg_exe()).resolve() | |
| runtime_bin = get_results_dir(PROJECT_ROOT) / "runtime_bin" | |
| runtime_bin.mkdir(parents=True, exist_ok=True) | |
| ffmpeg_target = runtime_bin / "ffmpeg.exe" | |
| if not ffmpeg_target.exists(): | |
| shutil.copy2(ffmpeg_source, ffmpeg_target) | |
| os.environ["IMAGEIO_FFMPEG_EXE"] = str(ffmpeg_target) | |
| current_path = os.environ.get("PATH", "") | |
| runtime_bin_str = str(runtime_bin) | |
| if runtime_bin_str.lower() not in current_path.lower(): | |
| os.environ["PATH"] = runtime_bin_str + os.pathsep + current_path | |
| def _probe_video_codec_with_ffmpeg(video_path: str | Path) -> tuple[str, str]: | |
| if imageio_ffmpeg is None: | |
| return "", "" | |
| path = Path(video_path) | |
| ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() | |
| result = subprocess.run( | |
| [ffmpeg_exe, "-i", str(path)], | |
| capture_output=True, | |
| text=True, | |
| encoding="utf-8", | |
| errors="ignore", | |
| ) | |
| probe_text = result.stderr or "" | |
| codec_match = re.search(r"Video:\s*([^\s,(]+)", probe_text) | |
| codec_name = (codec_match.group(1) if codec_match else "").strip().lower() | |
| return path.suffix.lower(), codec_name | |
| def patched_video_is_playable(video_filepath: str) -> bool: | |
| """ | |
| Avoid Gradio's hard dependency on ffprobe by checking playability | |
| with the bundled imageio-ffmpeg binary instead. | |
| """ | |
| try: | |
| container, video_codec = _probe_video_codec_with_ffmpeg(video_filepath) | |
| return (container, video_codec) in { | |
| (".mp4", "h264"), | |
| (".mp4", "av1"), | |
| (".ogg", "theora"), | |
| (".webm", "vp9"), | |
| (".webm", "vp8"), | |
| (".webm", "av1"), | |
| } | |
| except Exception: | |
| return True | |
| def patch_gradio_video_probe() -> None: | |
| gr_processing_utils.video_is_playable = patched_video_is_playable | |
| warnings.filterwarnings( | |
| "ignore", | |
| message=r"The 'css' parameter in the Blocks constructor will be removed in Gradio 6\.0\..*", | |
| category=DeprecationWarning, | |
| ) | |
| warnings.filterwarnings( | |
| "ignore", | |
| message=r"The 'theme' parameter in the Blocks constructor will be removed in Gradio 6\.0\..*", | |
| category=DeprecationWarning, | |
| ) | |
| warnings.filterwarnings( | |
| "ignore", | |
| message=r"The 'head' parameter in the Blocks constructor will be removed in Gradio 6\.0\..*", | |
| category=DeprecationWarning, | |
| ) | |
| CUSTOM_HEAD = """ | |
| <script> | |
| function anyactForceLightTheme() { | |
| document.documentElement.classList.remove("dark"); | |
| document.body.classList.remove("dark"); | |
| document.documentElement.setAttribute("data-theme", "light"); | |
| document.body.setAttribute("data-theme", "light"); | |
| document.documentElement.style.colorScheme = "light"; | |
| document.body.style.colorScheme = "light"; | |
| try { | |
| localStorage.setItem("theme", "light"); | |
| localStorage.setItem("gradio-theme", "light"); | |
| localStorage.setItem("gradio_mode", "light"); | |
| } catch (error) {} | |
| document.querySelectorAll(".dark").forEach((node) => node.classList.remove("dark")); | |
| } | |
| function anyactForcePlayVideos() { | |
| const videos = document.querySelectorAll("video"); | |
| videos.forEach((video) => { | |
| if (!video) return; | |
| const attemptPlay = () => video.play().catch(() => { | |
| video.muted = true; | |
| video.play().catch(() => {}); | |
| }); | |
| if (video.readyState >= 2) { | |
| attemptPlay(); | |
| } else { | |
| video.addEventListener("loadeddata", attemptPlay, { once: true }); | |
| } | |
| }); | |
| } | |
| function anyactGetStudyVideoElement(elemId) { | |
| const root = document.getElementById(elemId); | |
| return root ? root.querySelector("video") : null; | |
| } | |
| function anyactSetupStudyVideoSync() { | |
| const reference = anyactGetStudyVideoElement("study-reference-video"); | |
| const left = anyactGetStudyVideoElement("study-left-video"); | |
| const right = anyactGetStudyVideoElement("study-right-video"); | |
| if (!reference || !left || !right) { | |
| if (window.__anyactStudySync && typeof window.__anyactStudySync.cleanup === "function") { | |
| window.__anyactStudySync.cleanup(); | |
| } | |
| window.__anyactStudySync = null; | |
| return; | |
| } | |
| const trio = [reference, left, right]; | |
| const signature = trio.map((video) => video.currentSrc || video.src || "").join("|"); | |
| if (window.__anyactStudySync && window.__anyactStudySync.signature === signature) { | |
| return; | |
| } | |
| if (window.__anyactStudySync && typeof window.__anyactStudySync.cleanup === "function") { | |
| window.__anyactStudySync.cleanup(); | |
| } | |
| let suppressEvents = false; | |
| let lastReferenceTime = 0; | |
| const cleanupFns = []; | |
| const withSuppressedEvents = (fn) => { | |
| suppressEvents = true; | |
| try { | |
| fn(); | |
| } finally { | |
| clearTimeout(window.__anyactStudySyncSuppressTimer); | |
| window.__anyactStudySyncSuppressTimer = setTimeout(() => { | |
| suppressEvents = false; | |
| }, 120); | |
| } | |
| }; | |
| const syncTimes = (masterVideo, force = false) => { | |
| const targetTime = Number.isFinite(masterVideo.currentTime) ? masterVideo.currentTime : 0; | |
| trio.forEach((video) => { | |
| if (video === masterVideo) return; | |
| if (force || Math.abs((video.currentTime || 0) - targetTime) > 0.05) { | |
| try { | |
| video.currentTime = targetTime; | |
| } catch (error) {} | |
| } | |
| }); | |
| }; | |
| const syncPlaybackRate = (masterVideo) => { | |
| trio.forEach((video) => { | |
| if (video === masterVideo) return; | |
| if (video.playbackRate !== masterVideo.playbackRate) { | |
| video.playbackRate = masterVideo.playbackRate; | |
| } | |
| }); | |
| }; | |
| const playAll = (masterVideo = reference) => { | |
| withSuppressedEvents(() => { | |
| syncTimes(masterVideo, true); | |
| syncPlaybackRate(masterVideo); | |
| }); | |
| trio.forEach((video) => { | |
| video.loop = true; | |
| video.muted = true; | |
| video.playsInline = true; | |
| const playPromise = video.play(); | |
| if (playPromise && typeof playPromise.catch === "function") { | |
| playPromise.catch(() => {}); | |
| } | |
| }); | |
| }; | |
| const pauseOthers = (sourceVideo) => { | |
| trio.forEach((video) => { | |
| if (video === sourceVideo) return; | |
| if (!video.paused) { | |
| try { | |
| video.pause(); | |
| } catch (error) {} | |
| } | |
| }); | |
| }; | |
| const restartAll = () => { | |
| withSuppressedEvents(() => { | |
| trio.forEach((video) => { | |
| try { | |
| video.pause(); | |
| } catch (error) {} | |
| try { | |
| video.currentTime = 0; | |
| } catch (error) {} | |
| }); | |
| }); | |
| playAll(reference); | |
| }; | |
| const bind = (video, eventName, handler) => { | |
| video.addEventListener(eventName, handler); | |
| cleanupFns.push(() => video.removeEventListener(eventName, handler)); | |
| }; | |
| trio.forEach((video) => { | |
| video.loop = true; | |
| video.muted = true; | |
| video.playsInline = true; | |
| bind(video, "play", () => { | |
| if (suppressEvents) return; | |
| playAll(video); | |
| }); | |
| bind(video, "pause", () => { | |
| if (suppressEvents) return; | |
| withSuppressedEvents(() => { | |
| pauseOthers(video); | |
| }); | |
| }); | |
| bind(video, "seeked", () => { | |
| if (suppressEvents) return; | |
| withSuppressedEvents(() => { | |
| syncTimes(video, true); | |
| }); | |
| }); | |
| bind(video, "ratechange", () => { | |
| if (suppressEvents) return; | |
| withSuppressedEvents(() => { | |
| syncPlaybackRate(video); | |
| }); | |
| }); | |
| bind(video, "loadeddata", () => { | |
| if (trio.every((item) => item.readyState >= 2)) { | |
| playAll(reference); | |
| } | |
| }); | |
| }); | |
| bind(reference, "ended", () => { | |
| if (suppressEvents) return; | |
| restartAll(); | |
| }); | |
| const driftTimer = setInterval(() => { | |
| const currentReferenceTime = Number.isFinite(reference.currentTime) ? reference.currentTime : 0; | |
| const loopWrapped = lastReferenceTime > 0.35 && currentReferenceTime + 0.2 < lastReferenceTime; | |
| if (loopWrapped) { | |
| withSuppressedEvents(() => { | |
| syncTimes(reference, true); | |
| syncPlaybackRate(reference); | |
| }); | |
| if (!reference.paused) { | |
| trio.forEach((video) => { | |
| const playPromise = video.play(); | |
| if (playPromise && typeof playPromise.catch === "function") { | |
| playPromise.catch(() => {}); | |
| } | |
| }); | |
| } | |
| } | |
| lastReferenceTime = currentReferenceTime; | |
| if (document.hidden || reference.paused) return; | |
| syncTimes(reference, false); | |
| }, 200); | |
| cleanupFns.push(() => clearInterval(driftTimer)); | |
| window.__anyactStudySync = { | |
| signature, | |
| cleanup: () => { | |
| cleanupFns.forEach((cleanup) => { | |
| try { | |
| cleanup(); | |
| } catch (error) {} | |
| }); | |
| }, | |
| }; | |
| if (trio.every((video) => video.readyState >= 2)) { | |
| playAll(reference); | |
| } | |
| } | |
| window.addEventListener("load", () => { | |
| anyactForceLightTheme(); | |
| anyactForcePlayVideos(); | |
| anyactSetupStudyVideoSync(); | |
| const observer = new MutationObserver(() => { | |
| clearTimeout(window.__anyactVideoTimer); | |
| window.__anyactVideoTimer = setTimeout(() => { | |
| anyactForceLightTheme(); | |
| anyactForcePlayVideos(); | |
| anyactSetupStudyVideoSync(); | |
| }, 250); | |
| }); | |
| observer.observe(document.body, { childList: true, subtree: true, attributes: true }); | |
| }); | |
| </script> | |
| """ | |
| CUSTOM_CSS = """ | |
| :root, | |
| html, | |
| body { | |
| color-scheme: light !important; | |
| background: #eef2f7 !important; | |
| color: #0f172a !important; | |
| } | |
| .gradio-container { | |
| max-width: 1380px !important; | |
| margin: 0 auto !important; | |
| padding-bottom: 32px !important; | |
| font-family: "Segoe UI", "Helvetica Neue", sans-serif !important; | |
| background: | |
| radial-gradient(circle at top left, rgba(203, 213, 225, 0.26), transparent 28%), | |
| linear-gradient(180deg, #f7f8fb 0%, #eef2f7 100%); | |
| } | |
| .gradio-container, | |
| .gradio-container *, | |
| .gradio-container .dark, | |
| .gradio-container .dark * { | |
| --body-background-fill: #f7f8fb !important; | |
| --body-background-fill-subdued: #eef2f7 !important; | |
| --background-fill-primary: #ffffff !important; | |
| --background-fill-secondary: #f8fafc !important; | |
| --block-background-fill: #ffffff !important; | |
| --block-border-color: #d8dee8 !important; | |
| --panel-background-fill: #ffffff !important; | |
| --panel-border-color: #d8dee8 !important; | |
| --input-background-fill: #ffffff !important; | |
| --input-border-color: #cbd5e1 !important; | |
| --checkbox-background-color: #ffffff !important; | |
| --checkbox-border-color: #94a3b8 !important; | |
| --checkbox-label-text-color: #0f172a !important; | |
| --body-text-color: #0f172a !important; | |
| --block-label-text-color: #0f172a !important; | |
| --block-title-text-color: #0f172a !important; | |
| --button-secondary-background-fill: #ffffff !important; | |
| --button-secondary-text-color: #1d4ed8 !important; | |
| } | |
| .gradio-container div, | |
| .gradio-container section, | |
| .gradio-container article, | |
| .gradio-container form, | |
| .gradio-container fieldset, | |
| .gradio-container label, | |
| .gradio-container [data-testid="block"], | |
| .gradio-container [data-testid="textbox"], | |
| .gradio-container [data-testid="checkbox"], | |
| .gradio-container [data-testid="radio"], | |
| .gradio-container [data-testid="markdown"], | |
| .gradio-container [data-testid="group"] { | |
| color: #0f172a !important; | |
| } | |
| .gradio-container, | |
| .gradio-container .prose, | |
| .gradio-container .prose p, | |
| .gradio-container .prose li, | |
| .gradio-container .prose strong, | |
| .gradio-container .prose h1, | |
| .gradio-container .prose h2, | |
| .gradio-container .prose h3, | |
| .gradio-container .prose h4, | |
| .gradio-container label, | |
| .gradio-container p, | |
| .gradio-container li, | |
| .gradio-container h1, | |
| .gradio-container h2, | |
| .gradio-container h3, | |
| .gradio-container h4 { | |
| color: #0f172a !important; | |
| } | |
| .block-title h1, | |
| .block-title h2, | |
| .section-heading { | |
| font-family: "Libre Baskerville", Georgia, serif !important; | |
| } | |
| .hero-card, | |
| .panel-card, | |
| .form-card { | |
| background: rgba(255, 255, 255, 0.96); | |
| border: 1px solid #d8dee8; | |
| border-radius: 20px; | |
| box-shadow: 0 14px 40px rgba(15, 23, 42, 0.06); | |
| } | |
| .hero-card { | |
| padding: 28px 30px; | |
| } | |
| .panel-card { | |
| padding: 22px 24px; | |
| } | |
| .form-card { | |
| padding: 18px; | |
| } | |
| .form-card textarea, | |
| .form-card input { | |
| background: #ffffff !important; | |
| color: #0f172a !important; | |
| } | |
| .form-card, | |
| .question-card, | |
| .form-card > div, | |
| .question-card > div, | |
| .form-card [data-testid], | |
| .question-card [data-testid], | |
| .form-card .prose, | |
| .question-card .prose, | |
| .hero-card [data-testid], | |
| .panel-card [data-testid] { | |
| background: #ffffff !important; | |
| color: #0f172a !important; | |
| } | |
| .gradio-container input, | |
| .gradio-container textarea, | |
| .gradio-container select { | |
| background: #ffffff !important; | |
| color: #0f172a !important; | |
| } | |
| .gradio-container [data-testid="checkbox"], | |
| .gradio-container [data-testid="checkbox"] * { | |
| background: transparent !important; | |
| color: #0f172a !important; | |
| } | |
| .gradio-container [data-testid="textbox"], | |
| .gradio-container [data-testid="textbox"] > *, | |
| .gradio-container [data-testid="textbox"] textarea, | |
| .gradio-container [data-testid="textbox"] input { | |
| background: #ffffff !important; | |
| color: #0f172a !important; | |
| } | |
| .gradio-container [data-testid="markdown"], | |
| .gradio-container [data-testid="markdown"] > *, | |
| .gradio-container [data-testid="group"], | |
| .gradio-container [data-testid="group"] > * { | |
| background: transparent !important; | |
| color: #0f172a !important; | |
| } | |
| .instruction-shell { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 20px; | |
| } | |
| .instruction-top { | |
| display: grid; | |
| grid-template-columns: 1.02fr 0.98fr; | |
| gap: 20px; | |
| align-items: start; | |
| } | |
| .instruction-copy { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| } | |
| .instruction-top-right { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| } | |
| .lead-text { | |
| color: #334155; | |
| font-size: 17px; | |
| line-height: 1.6; | |
| margin: 0; | |
| } | |
| .instruction-list { | |
| margin: 0; | |
| padding-left: 20px; | |
| color: #334155; | |
| line-height: 1.6; | |
| } | |
| .example-caption-note { | |
| margin: 0 0 10px 0; | |
| padding: 10px 14px; | |
| border-radius: 14px; | |
| border: 1px solid #d9e2ec; | |
| background: #f8fbff; | |
| color: #334155; | |
| line-height: 1.6; | |
| } | |
| .instruction-bottom { | |
| display: grid; | |
| grid-template-columns: 1.08fr 0.92fr; | |
| gap: 20px; | |
| align-items: start; | |
| } | |
| .instruction-bottom-left, | |
| .instruction-bottom-right { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 14px; | |
| } | |
| .metric-card { | |
| border: 1px solid #d9e2ec; | |
| border-radius: 16px; | |
| padding: 16px; | |
| background: #fbfcfe; | |
| } | |
| .metric-card h4 { | |
| margin: 0 0 8px 0; | |
| font-size: 17px; | |
| color: #0f172a; | |
| } | |
| .metric-card p { | |
| margin: 0; | |
| color: #475569; | |
| line-height: 1.55; | |
| } | |
| .metric-stack { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| margin-top: 14px; | |
| } | |
| .diagram-card { | |
| border: 1px solid #d9e2ec; | |
| border-radius: 18px; | |
| padding: 18px; | |
| background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); | |
| } | |
| .case-walkthrough { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 14px; | |
| } | |
| .case-walkthrough-head h3 { | |
| margin: 0; | |
| font-size: 24px; | |
| color: #0f172a; | |
| font-family: "Libre Baskerville", Georgia, serif !important; | |
| } | |
| .case-walkthrough-head p { | |
| margin: 6px 0 0 0; | |
| color: #475569; | |
| line-height: 1.55; | |
| } | |
| .walkthrough-grid { | |
| display: grid; | |
| grid-template-columns: 1.15fr 1fr 1fr; | |
| gap: 12px; | |
| } | |
| .thumb-card { | |
| background: #ffffff; | |
| border: 1px solid #d8e2ef; | |
| border-radius: 16px; | |
| overflow: hidden; | |
| box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05); | |
| } | |
| .thumb-card.ref-card { | |
| border-color: #93c5fd; | |
| } | |
| .thumb-image { | |
| aspect-ratio: 1.2 / 1; | |
| background: #eef2f7; | |
| overflow: hidden; | |
| } | |
| .thumb-image img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| display: block; | |
| } | |
| .thumb-body { | |
| padding: 12px 13px 14px; | |
| } | |
| .thumb-title { | |
| display: inline-block; | |
| font-size: 12px; | |
| font-weight: 700; | |
| letter-spacing: 0.06em; | |
| text-transform: uppercase; | |
| color: #1d4ed8; | |
| background: #dbeafe; | |
| border-radius: 999px; | |
| padding: 5px 9px; | |
| margin-bottom: 8px; | |
| } | |
| .thumb-card.candidate-card .thumb-title { | |
| color: #0f172a; | |
| background: #eaf1fb; | |
| } | |
| .thumb-body h4 { | |
| margin: 0 0 6px 0; | |
| font-size: 17px; | |
| color: #0f172a; | |
| } | |
| .thumb-body p { | |
| margin: 0; | |
| font-size: 14px; | |
| color: #475569; | |
| line-height: 1.5; | |
| } | |
| .walkthrough-note { | |
| border: 1px solid #d9e2ec; | |
| border-radius: 16px; | |
| background: #ffffff; | |
| padding: 14px 15px; | |
| } | |
| .walkthrough-note > strong { | |
| display: block; | |
| color: #0f172a; | |
| margin-bottom: 6px; | |
| font-size: 15px; | |
| } | |
| .walkthrough-note p { | |
| margin: 0; | |
| color: #475569; | |
| line-height: 1.6; | |
| } | |
| .walkthrough-note p + p { | |
| margin-top: 10px; | |
| } | |
| .walkthrough-note p strong { | |
| display: inline; | |
| margin: 0; | |
| font-size: inherit; | |
| color: #0f172a; | |
| } | |
| .walkthrough-note.accent-note { | |
| background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%); | |
| border-color: #bfdbfe; | |
| } | |
| .progress-chip { | |
| display: inline-block; | |
| padding: 8px 14px; | |
| border-radius: 999px; | |
| background: #0f172a; | |
| color: white; | |
| font-weight: 700; | |
| letter-spacing: 0.01em; | |
| } | |
| .meta-line { | |
| margin-top: 8px; | |
| color: #475569; | |
| font-size: 15px; | |
| } | |
| .study-shell .gr-video { | |
| border-radius: 16px !important; | |
| overflow: hidden !important; | |
| border: 1px solid #dbe2ea !important; | |
| background: #f8fafc !important; | |
| } | |
| .gradio-container video { | |
| background: #0f172a !important; | |
| } | |
| .video-panel { | |
| background: #ffffff; | |
| } | |
| .reference-panel { | |
| border: 1px solid #bfdbfe !important; | |
| box-shadow: 0 12px 28px rgba(37, 99, 235, 0.08); | |
| } | |
| .candidate-panel { | |
| border: 1px solid #dbe4ee !important; | |
| } | |
| .video-caption { | |
| color: #475569; | |
| font-size: 14px; | |
| margin-top: -4px; | |
| margin-bottom: 10px; | |
| } | |
| .question-card { | |
| padding: 18px 20px; | |
| border-radius: 18px; | |
| border: 1px solid #d9e2ec; | |
| background: rgba(255, 255, 255, 0.9); | |
| } | |
| .choice-input { | |
| margin-top: 8px; | |
| } | |
| .choice-input fieldset { | |
| border: 1px solid #d7e3f4 !important; | |
| border-radius: 16px !important; | |
| background: #f8fbff !important; | |
| padding: 12px 14px !important; | |
| } | |
| .choice-input label { | |
| border: 1px solid #cbdcf5 !important; | |
| border-radius: 12px !important; | |
| background: #ffffff !important; | |
| padding: 10px 12px !important; | |
| color: #1e293b !important; | |
| font-weight: 600 !important; | |
| transition: all 0.2s ease !important; | |
| } | |
| .choice-input label:hover { | |
| border-color: #60a5fa !important; | |
| background: #eff6ff !important; | |
| } | |
| .choice-input label:has(input:checked) { | |
| border-color: #3b82f6 !important; | |
| background: linear-gradient(180deg, #dbeafe 0%, #eff6ff 100%) !important; | |
| color: #1d4ed8 !important; | |
| box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.12) !important; | |
| } | |
| .choice-input input[type="radio"] { | |
| accent-color: #2563eb !important; | |
| } | |
| .gradio-container button { | |
| transition: transform 0.15s ease, box-shadow 0.15s ease !important; | |
| } | |
| .gradio-container button.primary { | |
| background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%) !important; | |
| color: #ffffff !important; | |
| border: none !important; | |
| box-shadow: 0 8px 20px rgba(37, 99, 235, 0.18) !important; | |
| } | |
| .gradio-container button.primary:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 12px 24px rgba(37, 99, 235, 0.22) !important; | |
| } | |
| .gradio-container button.secondary { | |
| background: #ffffff !important; | |
| color: #1d4ed8 !important; | |
| border: 1px solid #bfdbfe !important; | |
| } | |
| .language-switch-row { | |
| justify-content: flex-end; | |
| margin-bottom: 8px; | |
| } | |
| .language-switch { | |
| min-width: 210px; | |
| } | |
| .language-switch fieldset { | |
| border: 1px solid #cfe0fb !important; | |
| border-radius: 999px !important; | |
| background: rgba(255, 255, 255, 0.92) !important; | |
| padding: 6px !important; | |
| box-shadow: 0 8px 20px rgba(37, 99, 235, 0.08) !important; | |
| } | |
| .language-switch label { | |
| border: none !important; | |
| border-radius: 999px !important; | |
| background: transparent !important; | |
| color: #475569 !important; | |
| min-height: 38px !important; | |
| padding: 8px 16px !important; | |
| font-weight: 700 !important; | |
| transition: all 0.18s ease !important; | |
| } | |
| .language-switch label:hover { | |
| background: #eff6ff !important; | |
| color: #1d4ed8 !important; | |
| } | |
| .language-switch label:has(input:checked) { | |
| background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%) !important; | |
| color: #ffffff !important; | |
| box-shadow: 0 8px 16px rgba(37, 99, 235, 0.18) !important; | |
| } | |
| .language-switch input[type="radio"] { | |
| display: none !important; | |
| } | |
| .thank-card { | |
| max-width: 760px; | |
| margin: 0 auto; | |
| padding: 28px 30px; | |
| text-align: left; | |
| } | |
| .muted-caption { | |
| color: #64748b; | |
| font-size: 14px; | |
| } | |
| @media (max-width: 900px) { | |
| .instruction-top, | |
| .instruction-bottom, | |
| .walkthrough-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| """ | |
| LANGUAGE_CHOICES = [("English", "en"), ("中文", "zh")] | |
| LANGUAGE_SWITCH_LABEL = "Language / 语言" | |
| TEXT = { | |
| "en": { | |
| "language_label": "Language", | |
| "study_title": "Human Motion Reenactment User Study", | |
| "example_comparison": "Example Comparison", | |
| "reference_video": "Reference Video", | |
| "candidate_a": "Result A", | |
| "candidate_b": "Result B", | |
| "reference_caption": "Reference", | |
| "result_a_caption": "Result A", | |
| "result_b_caption": "Result B", | |
| "result_a_left": "Result A", | |
| "result_b_right": "Result B", | |
| "participant_id_label": "Participant ID (read-only)", | |
| "participant_setup_title": "Participant Setup", | |
| "generate_fresh_id": "Generate Fresh Participant ID", | |
| "start_continue": "Start / Continue Study", | |
| "previous": "Previous", | |
| "next": "Next", | |
| "submit_study": "Submit Study", | |
| "question_similarity": "Which result better matches the reference motion?", | |
| "question_quality": "Which result has better motion quality?", | |
| "question_preference": "Which result do you overall prefer?", | |
| "left_choice": "Result A", | |
| "right_choice": "Result B", | |
| "saved_progress_restored": "Saved progress restored.", | |
| "answer_all_required": "Please answer all three questions before continuing.", | |
| "session_empty": "Session state was empty. Please return to the start page and continue this browser session.", | |
| "browser_saved_id_notice": ( | |
| "This browser already has a saved participant ID. Click **Start / Continue Study** " | |
| "to resume the same session safely." | |
| ), | |
| "fresh_id_notice": ( | |
| "A fresh participant ID has been created for this browser. " | |
| "Use it only if you are starting a brand-new session." | |
| ), | |
| "completed_id_notice": ( | |
| "This participant ID has already completed the study. " | |
| "Generate a fresh ID if you need a brand-new session on this browser." | |
| ), | |
| "study_instruction": "Watch the reference clip and both anonymous results before answering the three questions on Motion Similarity, Motion Quality, and Overall Preference.", | |
| "question_word": "Question", | |
| "saved_responses": "Saved responses", | |
| "participant_id_meta": "Participant ID", | |
| "thank_you_title": "Thank you for completing the study.", | |
| "thank_you_saved": "Your responses have been saved successfully.", | |
| "thank_you_completed_at": "Completed at", | |
| "thank_you_close": "You may now close this page.", | |
| }, | |
| "zh": { | |
| "language_label": "语言", | |
| "study_title": "人体动作重演用户研究", | |
| "example_comparison": "示例对比", | |
| "reference_video": "参考视频", | |
| "candidate_a": "结果 A", | |
| "candidate_b": "结果 B", | |
| "reference_caption": "参考视频", | |
| "result_a_caption": "结果 A", | |
| "result_b_caption": "结果 B", | |
| "result_a_left": "结果 A", | |
| "result_b_right": "结果 B", | |
| "participant_id_label": "参与者编号(只读)", | |
| "participant_setup_title": "参与者设置", | |
| "generate_fresh_id": "生成新的参与者编号", | |
| "start_continue": "开始 / 继续问卷", | |
| "previous": "上一题", | |
| "next": "下一题", | |
| "submit_study": "提交问卷", | |
| "question_similarity": "哪个结果与参考动作更匹配?", | |
| "question_quality": "哪个结果的动作质量更好?", | |
| "question_preference": "你整体更偏好哪个结果?", | |
| "left_choice": "结果 A", | |
| "right_choice": "结果 B", | |
| "saved_progress_restored": "已恢复先前保存的进度。", | |
| "answer_all_required": "请先回答完这三个问题,再继续下一题。", | |
| "session_empty": "当前会话为空。请返回起始页后继续此浏览器中的问卷会话。", | |
| "browser_saved_id_notice": ( | |
| "当前浏览器已保存参与者编号。点击 **开始 / 继续问卷** 可安全地恢复同一会话。" | |
| ), | |
| "fresh_id_notice": ( | |
| "当前浏览器已生成一个新的参与者编号。仅当你需要开始一个全新的问卷会话时再使用它。" | |
| ), | |
| "completed_id_notice": ( | |
| "该参与者编号已经完成本次问卷。若你需要在此浏览器中开始全新会话,请生成新的编号。" | |
| ), | |
| "study_instruction": "请先观看参考视频和两个匿名结果,再回答动作相似性、动作质量和整体偏好这三个问题。", | |
| "question_word": "题目", | |
| "saved_responses": "已保存回答", | |
| "participant_id_meta": "参与者编号", | |
| "thank_you_title": "感谢你完成本次问卷。", | |
| "thank_you_saved": "你的回答已成功保存。", | |
| "thank_you_completed_at": "完成时间", | |
| "thank_you_close": "现在可以关闭此页面。", | |
| }, | |
| } | |
| def normalize_language(language: str | None) -> str: | |
| return language if language in TEXT else "en" | |
| def tr(language: str | None, key: str, **kwargs: Any) -> str: | |
| return TEXT[normalize_language(language)][key].format(**kwargs) | |
| def choice_options_for_language(language: str | None) -> list[tuple[str, str]]: | |
| language = normalize_language(language) | |
| return [ | |
| (tr(language, "left_choice"), "ResultA"), | |
| (tr(language, "right_choice"), "ResultB"), | |
| ] | |
| def video_caption_html(text: str) -> str: | |
| return f"<div class='video-caption'>{html.escape(text)}</div>" | |
| def build_participant_setup_markdown(language: str | None) -> str: | |
| language = normalize_language(language) | |
| if language == "zh": | |
| return """ | |
| ### 参与者设置 | |
| 当前公开问卷链接会为本浏览器自动创建并保存参与者编号。之后如果你仍使用同一浏览器再次访问,就可以自动恢复同一份作答进度,而无需手动输入编号。 | |
| 只有在你希望于当前浏览器中开始一个全新的作答会话时,才需要点击 **生成新的参与者编号**。请不要将下方显示的编号分享给其他参与者。 | |
| """.strip() | |
| return """ | |
| ### Participant Setup | |
| This public study link now creates and stores a participant ID automatically for the current browser. Returning on the | |
| same browser will safely continue the same session without asking you to type an ID manually. | |
| Use **Generate Fresh Participant ID** only when starting a completely new participation on this browser. Please do not | |
| share the ID shown below with other participants. | |
| """.strip() | |
| def build_progress_markdown(state: dict[str, Any], language: str | None) -> str: | |
| question = state["questions"][state["current_index"]] | |
| answered_count = len(state.get("answers", {})) | |
| language = normalize_language(language) | |
| if language == "zh": | |
| return ( | |
| f"<div class='meta-line'>{tr(language, 'participant_id_meta')}: <code>{state['participant_id']}</code></div>" | |
| f"<div class='meta-line'>{tr(language, 'saved_responses')}: {answered_count} / {question['total_questions']}</div>" | |
| ) | |
| return ( | |
| f"<div class='meta-line'>{tr(language, 'participant_id_meta')}: <code>{state['participant_id']}</code></div>" | |
| f"<div class='meta-line'>{tr(language, 'saved_responses')}: {answered_count} / {question['total_questions']}</div>" | |
| ) | |
| def build_completion_markdown_local(state: dict[str, Any], language: str | None) -> str: | |
| completed_at = state.get("completed_at") or "" | |
| total_questions = len(state.get("questions", [])) | |
| answered_count = len(state.get("answers", {})) | |
| language = normalize_language(language) | |
| if language == "zh": | |
| return f""" | |
| ## {tr(language, "thank_you_title")} | |
| {tr(language, "thank_you_saved")} | |
| - {tr(language, "participant_id_meta")}: `{state["participant_id"]}` | |
| - {tr(language, "saved_responses")}: `{answered_count} / {total_questions}` | |
| - {tr(language, "thank_you_completed_at")}: `{completed_at}` | |
| {tr(language, "thank_you_close")} | |
| """.strip() | |
| return f""" | |
| ## {tr(language, "thank_you_title")} | |
| {tr(language, "thank_you_saved")} | |
| - {tr(language, "participant_id_meta")}: `{state["participant_id"]}` | |
| - {tr(language, "saved_responses")}: `{answered_count} / {total_questions}` | |
| - {tr(language, "thank_you_completed_at")}: `{completed_at}` | |
| {tr(language, "thank_you_close")} | |
| """.strip() | |
| def image_to_data_uri(image_path: str | Path) -> str: | |
| path = Path(image_path) | |
| if not path.exists(): | |
| return "" | |
| suffix = path.suffix.lower() | |
| mime_type = "image/jpeg" if suffix in {".jpg", ".jpeg"} else "image/png" | |
| encoded = base64.b64encode(path.read_bytes()).decode("ascii") | |
| return f"data:{mime_type};base64,{encoded}" | |
| def build_instruction_case_html(intro_case: dict[str, Any], language: str | None) -> str: | |
| reference_thumb = image_to_data_uri(ensure_video_thumbnail(intro_case["reference_video"], PROJECT_ROOT)) | |
| result_a_thumb = image_to_data_uri( | |
| ensure_video_thumbnail(intro_case["method_videos"]["anyact"], PROJECT_ROOT) | |
| ) | |
| result_b_thumb = image_to_data_uri( | |
| ensure_video_thumbnail(intro_case["method_videos"]["vlm_hy_motion"], PROJECT_ROOT) | |
| ) | |
| language = normalize_language(language) | |
| if language == "zh": | |
| return f""" | |
| <div class="case-walkthrough"> | |
| <div class="case-walkthrough-head"> | |
| <h3>示例对比</h3> | |
| <p> | |
| 下图说明问卷如何进行比较。参与者需要将一个源角色的运动视频与两个人体重演结果进行比较, | |
| 页面上它们显示为 <strong>结果 A</strong> 和 <strong>结果 B</strong>。灰色区域表示地板。 | |
| </p> | |
| </div> | |
| <div class="walkthrough-grid"> | |
| <div class="thumb-card ref-card"> | |
| <div class="thumb-image"><img src="{reference_thumb}" alt="参考案例缩略图"></div> | |
| <div class="thumb-body"> | |
| <div class="thumb-title">参考视频</div> | |
| <h4>源角色运动视频</h4> | |
| <p>该视频提供需要被模仿的角色运动,主要体现姿态和动作动态。</p> | |
| </div> | |
| </div> | |
| <div class="thumb-card candidate-card"> | |
| <div class="thumb-image"><img src="{result_a_thumb}" alt="匿名结果 A 缩略图"></div> | |
| <div class="thumb-body"> | |
| <div class="thumb-title">结果 A</div> | |
| <h4>模仿参考角色运动的人体重演</h4> | |
| <p>这是结果 A 对参考角色运动进行人体重演后的表现。</p> | |
| </div> | |
| </div> | |
| <div class="thumb-card candidate-card"> | |
| <div class="thumb-image"><img src="{result_b_thumb}" alt="匿名结果 B 缩略图"></div> | |
| <div class="thumb-body"> | |
| <div class="thumb-title">结果 B</div> | |
| <h4>模仿参考角色运动的人体重演</h4> | |
| <p>这是结果 B 对参考角色运动进行人体重演后的表现。</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """.strip() | |
| return f""" | |
| <div class="case-walkthrough"> | |
| <div class="case-walkthrough-head"> | |
| <h3>Example Comparison</h3> | |
| <p> | |
| The figure below shows how the questionnaire works. Participants compare one video of the source character's motion against | |
| two human reenactment results that imitate that motion, displayed as <strong>Result A</strong> and <strong>Result B</strong>. | |
| The gray area indicates the floor. | |
| </p> | |
| </div> | |
| <div class="walkthrough-grid"> | |
| <div class="thumb-card ref-card"> | |
| <div class="thumb-image"><img src="{reference_thumb}" alt="Reference case thumbnail"></div> | |
| <div class="thumb-body"> | |
| <div class="thumb-title">Reference</div> | |
| <h4>Source character motion video</h4> | |
| <p>This video provides the character motion to be imitated, mainly in terms of pose and action dynamics.</p> | |
| </div> | |
| </div> | |
| <div class="thumb-card candidate-card"> | |
| <div class="thumb-image"><img src="{result_a_thumb}" alt="Anonymous Result A thumbnail"></div> | |
| <div class="thumb-body"> | |
| <div class="thumb-title">Result A</div> | |
| <h4>Human reenactment imitating the reference character motion</h4> | |
| <p>This is the human-motion reenactment shown as Result A.</p> | |
| </div> | |
| </div> | |
| <div class="thumb-card candidate-card"> | |
| <div class="thumb-image"><img src="{result_b_thumb}" alt="Anonymous Result B thumbnail"></div> | |
| <div class="thumb-body"> | |
| <div class="thumb-title">Result B</div> | |
| <h4>Human reenactment imitating the reference character motion</h4> | |
| <p>This is the human-motion reenactment shown as Result B.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """.strip() | |
| def build_judging_instruction_html(language: str | None) -> str: | |
| language = normalize_language(language) | |
| if language == "zh": | |
| return """ | |
| <div class="walkthrough-note"> | |
| <strong>参与者需要判断什么</strong> | |
| <p> | |
| 对于每一组对比,你都需要回答三个问题,分别对应 <strong>动作相似性</strong>、<strong>动作质量</strong> 和 | |
| <strong>整体偏好</strong>。请直接依据页面上显示的结果 A 和结果 B 进行判断。 | |
| </p> | |
| <div class="metric-stack"> | |
| <div class="metric-card"> | |
| <h4>动作相似性</h4> | |
| <p>哪个结果与参考动作更匹配,尤其是在姿态对齐和时间动态方面?</p> | |
| </div> | |
| <div class="metric-card"> | |
| <h4>动作质量</h4> | |
| <p>哪个结果看起来更自然、更平滑,并且更符合人体动作的物理合理性?请主要关注全身动作、身体协调、平衡性和时间连续性,并可适当忽略面部表情与手指细节。</p> | |
| </div> | |
| <div class="metric-card"> | |
| <h4>整体偏好</h4> | |
| <p>综合考虑动作相似性和动作质量后,你整体更偏好哪个结果?</p> | |
| </div> | |
| </div> | |
| </div> | |
| """.strip() | |
| return """ | |
| <div class="walkthrough-note"> | |
| <strong>What participants are asked to judge</strong> | |
| <p> | |
| For each comparison, please answer three questions covering <strong>Motion Similarity</strong>, | |
| <strong>Motion Quality</strong>, and <strong>Overall Preference</strong>. Please base your choice directly on the | |
| page labels, namely Result A and Result B. | |
| </p> | |
| <div class="metric-stack"> | |
| <div class="metric-card"> | |
| <h4>Motion Similarity</h4> | |
| <p>Which result better matches the reference motion, especially in terms of pose alignment and temporal dynamics?</p> | |
| </div> | |
| <div class="metric-card"> | |
| <h4>Motion Quality</h4> | |
| <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> | |
| </div> | |
| <div class="metric-card"> | |
| <h4>Overall Preference</h4> | |
| <p>Considering both similarity and quality together, which result do you prefer overall?</p> | |
| </div> | |
| </div> | |
| </div> | |
| """.strip() | |
| def build_instruction_html( | |
| config: dict[str, Any], | |
| case_walkthrough_html: str, | |
| judging_instruction_html: str, | |
| language: str | None, | |
| ) -> str: | |
| total_questions = config["participant_question_total"] | |
| language = normalize_language(language) | |
| if language == "zh": | |
| return f""" | |
| <div class="hero-card"> | |
| <div class="instruction-shell"> | |
| <div class="instruction-top"> | |
| <div class="instruction-copy"> | |
| <div class="block-title"> | |
| <h1>{tr(language, "study_title")}</h1> | |
| </div> | |
| <p class="lead-text"> | |
| 本研究用于评估人体动作重演结果的主观感知质量。每一道题中,你将观看 | |
| 一个<strong>参考视频</strong>和两个<strong>匿名结果</strong>,它们在页面上显示为 | |
| <strong>结果 A</strong> 和 <strong>结果 B</strong>。你需要围绕 | |
| <strong>动作相似性</strong>、<strong>动作质量</strong> 和 <strong>整体偏好</strong> | |
| 三个指标完成判断。 | |
| </p> | |
| <ul class="instruction-list"> | |
| <li>每位参与者需要完成 <strong>{total_questions}</strong> 个成对对比样本。</li> | |
| <li>每次比较都会同时展示一个参考视频,以及并排显示的结果 A 和结果 B。</li> | |
| <li>结果 A 和结果 B 的时长可能不完全相同,但它们都对应对整段参考视频中角色运动的重演;请忽略这种长度差异。</li> | |
| <li>请在进入下一页之前回答完当前题目的三个问题。</li> | |
| </ul> | |
| {judging_instruction_html} | |
| </div> | |
| <div class="instruction-top-right"> | |
| <div class="diagram-card"> | |
| {case_walkthrough_html} | |
| </div> | |
| <div class="walkthrough-note accent-note"> | |
| <strong>如何理解本任务</strong> | |
| <p> | |
| 本研究关注的是<strong>人去模仿参考视频中角色的运动</strong>。 | |
| 一个优秀的结果应当既忠实保留参考运动,又能呈现自然、平稳、符合人体运动规律的动作。 | |
| </p> | |
| <p> | |
| 当原始角色和人体构造不完全一致时,可以接受使用人体其他肢体去模拟缺失的功能部位, | |
| 例如用手臂对应翅膀,只要整体动作意图和动态仍然合理并接近参考动作。 | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """.strip() | |
| return f""" | |
| <div class="hero-card"> | |
| <div class="instruction-shell"> | |
| <div class="instruction-top"> | |
| <div class="instruction-copy"> | |
| <div class="block-title"> | |
| <h1>Human Motion Reenactment User Study</h1> | |
| </div> | |
| <p class="lead-text"> | |
| This study evaluates perceptual quality in human motion reenactment. In each question, you will watch | |
| one <strong>reference video</strong> and two <strong>anonymous results</strong>, displayed on the page as | |
| <strong>Result A</strong> and <strong>Result B</strong>. You will answer three evaluation questions covering | |
| <strong>Motion Similarity</strong>, <strong>Motion Quality</strong>, and <strong>Overall Preference</strong>. | |
| </p> | |
| <ul class="instruction-list"> | |
| <li>Each participant completes <strong>{total_questions}</strong> pairwise comparison samples.</li> | |
| <li>Each comparison presents one reference clip together with Result A and Result B shown side by side.</li> | |
| <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> | |
| <li>Please answer all three questions before moving to the next page.</li> | |
| </ul> | |
| {judging_instruction_html} | |
| </div> | |
| <div class="instruction-top-right"> | |
| <div class="diagram-card"> | |
| {case_walkthrough_html} | |
| </div> | |
| <div class="walkthrough-note accent-note"> | |
| <strong>How to interpret the task</strong> | |
| <p> | |
| The goal is to assess how well a human reenactment imitates the motion of the character in the reference video. | |
| A strong result should preserve the reference motion while still looking smooth, stable, and physically natural as human movement. | |
| </p> | |
| <p> | |
| When the source character does not map directly to a human body, it is acceptable to use other human limbs | |
| to simulate the missing functional parts, such as using arms to mimic wings, as long as the motion intent | |
| and dynamics remain plausible and close to the reference. | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """.strip() | |
| def build_example_caption(language: str | None) -> str: | |
| language = normalize_language(language) | |
| if language == "zh": | |
| return """ | |
| <div class="example-caption-note"> | |
| 下方展示的是同一个示例案例在正式问卷中的实际观看布局。参与者需要将参考视频与结果 A、结果 B 进行比较,并回答三个评价问题。 | |
| </div> | |
| """.strip() | |
| return """ | |
| <div class="example-caption-note"> | |
| Below is the same example case shown in the actual questionnaire layout. Participants compare the reference clip | |
| against Result A and Result B and answer the three evaluation questions. | |
| </div> | |
| """.strip() | |
| def resolve_browser_participant_id(browser_participant_id: str | None) -> str: | |
| sanitized = sanitize_participant_id(browser_participant_id) | |
| return sanitized or generate_participant_id() | |
| def build_intro_component_updates( | |
| config: dict[str, Any], | |
| intro_case: dict[str, Any], | |
| language: str | None, | |
| ) -> tuple[Any, ...]: | |
| language = normalize_language(language) | |
| case_walkthrough_html = build_instruction_case_html(intro_case, language) | |
| judging_instruction_html = build_judging_instruction_html(language) | |
| return ( | |
| gr.update(value=build_instruction_html(config, case_walkthrough_html, judging_instruction_html, language)), | |
| gr.update(value=build_example_caption(language), visible=False), | |
| gr.update(label=tr(language, "reference_video")), | |
| gr.update(value=video_caption_html(tr(language, "reference_caption"))), | |
| gr.update(label=tr(language, "candidate_a")), | |
| gr.update(value=video_caption_html(tr(language, "result_a_caption"))), | |
| gr.update(label=tr(language, "candidate_b")), | |
| gr.update(value=video_caption_html(tr(language, "result_b_caption"))), | |
| gr.update(value=build_participant_setup_markdown(language)), | |
| gr.update(value=tr(language, "generate_fresh_id")), | |
| gr.update(value=tr(language, "start_continue")), | |
| ) | |
| def build_study_component_updates( | |
| language: str | None, | |
| similarity_value: str | None, | |
| quality_value: str | None, | |
| preference_value: str | None, | |
| show_previous: bool, | |
| show_next: bool, | |
| show_submit: bool, | |
| ) -> tuple[Any, ...]: | |
| language = normalize_language(language) | |
| return ( | |
| gr.update(label=tr(language, "reference_video")), | |
| gr.update(label=tr(language, "result_a_left")), | |
| gr.update(label=tr(language, "result_b_right")), | |
| gr.update( | |
| choices=choice_options_for_language(language), | |
| label=tr(language, "question_similarity"), | |
| value=similarity_value, | |
| ), | |
| gr.update( | |
| choices=choice_options_for_language(language), | |
| label=tr(language, "question_quality"), | |
| value=quality_value, | |
| ), | |
| gr.update( | |
| choices=choice_options_for_language(language), | |
| label=tr(language, "question_preference"), | |
| value=preference_value, | |
| ), | |
| gr.update(value=tr(language, "previous"), visible=show_previous), | |
| gr.update(value=tr(language, "next"), visible=show_next), | |
| gr.update(value=tr(language, "submit_study"), visible=show_submit), | |
| gr.update(value=video_caption_html(tr(language, "reference_caption"))), | |
| gr.update(value=video_caption_html(tr(language, "result_a_caption"))), | |
| gr.update(value=video_caption_html(tr(language, "result_b_caption"))), | |
| ) | |
| def render_intro_view( | |
| config: dict[str, Any], | |
| intro_case: dict[str, Any], | |
| language: str | None, | |
| participant_id: str | None = None, | |
| browser_participant_id: str | None = None, | |
| start_message: str = "", | |
| ) -> Tuple[Any, ...]: | |
| language = normalize_language(language) | |
| resolved_participant_id = resolve_browser_participant_id(browser_participant_id or participant_id) | |
| intro_updates = build_intro_component_updates(config, intro_case, language) | |
| study_updates = build_study_component_updates( | |
| language, | |
| similarity_value=None, | |
| quality_value=None, | |
| preference_value=None, | |
| show_previous=False, | |
| show_next=True, | |
| show_submit=False, | |
| ) | |
| return ( | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| {}, | |
| "", | |
| "", | |
| tr(language, "study_instruction"), | |
| None, | |
| None, | |
| None, | |
| study_updates[3], | |
| study_updates[4], | |
| study_updates[5], | |
| "", | |
| study_updates[6], | |
| study_updates[7], | |
| study_updates[8], | |
| start_message, | |
| "", | |
| gr.update(value=resolved_participant_id, label=tr(language, "participant_id_label")), | |
| resolved_participant_id, | |
| language, | |
| gr.update(value=language, label=LANGUAGE_SWITCH_LABEL), | |
| *intro_updates, | |
| study_updates[9], | |
| study_updates[10], | |
| study_updates[11], | |
| ) | |
| def render_question_view( | |
| config: dict[str, Any], | |
| intro_case: dict[str, Any], | |
| language: str | None, | |
| state: dict[str, Any], | |
| study_message: str = "", | |
| draft_answers: tuple[str | None, str | None, str | None] | None = None, | |
| ) -> Tuple[Any, ...]: | |
| language = normalize_language(language) | |
| payload = build_question_payload(state) | |
| synced_videos = ensure_synchronized_study_videos( | |
| reference_video=payload["reference_video"], | |
| left_video=payload["left_video"], | |
| right_video=payload["right_video"], | |
| project_root=PROJECT_ROOT, | |
| ) | |
| similarity_value = draft_answers[0] if draft_answers and draft_answers[0] is not None else payload["answer_similarity"] | |
| quality_value = draft_answers[1] if draft_answers and draft_answers[1] is not None else payload["answer_quality"] | |
| preference_value = draft_answers[2] if draft_answers and draft_answers[2] is not None else payload["answer_preference"] | |
| intro_updates = build_intro_component_updates(config, intro_case, language) | |
| study_updates = build_study_component_updates( | |
| language, | |
| similarity_value=similarity_value, | |
| quality_value=quality_value, | |
| preference_value=preference_value, | |
| show_previous=payload["show_previous"], | |
| show_next=payload["show_next"], | |
| show_submit=payload["show_submit"], | |
| ) | |
| return ( | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| state, | |
| payload["question_token"], | |
| build_progress_markdown(state, language), | |
| tr(language, "study_instruction"), | |
| gr.update(value=synced_videos["reference_video"], label=tr(language, "reference_video")), | |
| gr.update(value=synced_videos["left_video"], label=tr(language, "result_a_left")), | |
| gr.update(value=synced_videos["right_video"], label=tr(language, "result_b_right")), | |
| study_updates[3], | |
| study_updates[4], | |
| study_updates[5], | |
| study_message, | |
| study_updates[6], | |
| study_updates[7], | |
| study_updates[8], | |
| "", | |
| "", | |
| gr.update(value=state["participant_id"], label=tr(language, "participant_id_label")), | |
| state["participant_id"], | |
| language, | |
| gr.update(value=language, label=LANGUAGE_SWITCH_LABEL), | |
| *intro_updates, | |
| study_updates[9], | |
| study_updates[10], | |
| study_updates[11], | |
| ) | |
| def render_thank_you_view( | |
| config: dict[str, Any], | |
| intro_case: dict[str, Any], | |
| language: str | None, | |
| state: dict[str, Any], | |
| ) -> Tuple[Any, ...]: | |
| language = normalize_language(language) | |
| intro_updates = build_intro_component_updates(config, intro_case, language) | |
| study_updates = build_study_component_updates( | |
| language, | |
| similarity_value=None, | |
| quality_value=None, | |
| preference_value=None, | |
| show_previous=False, | |
| show_next=False, | |
| show_submit=False, | |
| ) | |
| return ( | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| state, | |
| "", | |
| "", | |
| tr(language, "study_instruction"), | |
| gr.update(value=None), | |
| gr.update(value=None), | |
| gr.update(value=None), | |
| study_updates[3], | |
| study_updates[4], | |
| study_updates[5], | |
| "", | |
| study_updates[6], | |
| study_updates[7], | |
| study_updates[8], | |
| "", | |
| build_completion_markdown_local(state, language), | |
| gr.update(value=state["participant_id"], label=tr(language, "participant_id_label")), | |
| state["participant_id"], | |
| language, | |
| gr.update(value=language, label=LANGUAGE_SWITCH_LABEL), | |
| *intro_updates, | |
| study_updates[9], | |
| study_updates[10], | |
| study_updates[11], | |
| ) | |
| def _drop_language_selector_update(payload: Tuple[Any, ...]) -> Tuple[Any, ...]: | |
| language_selector_index = 22 | |
| return payload[:language_selector_index] + payload[language_selector_index + 1 :] | |
| def build_demo(config_path: Path) -> gr.Blocks: | |
| ensure_local_ffmpeg() | |
| patch_gradio_video_probe() | |
| config = load_study_config(config_path) | |
| ensure_runtime_dirs(PROJECT_ROOT) | |
| config = prepare_reference_videos_for_web(config, PROJECT_ROOT) | |
| upgrade_existing_results_schema(PROJECT_ROOT, config) | |
| default_language = "en" | |
| choice_options = choice_options_for_language(default_language) | |
| intro_case = get_instruction_case(config) | |
| example_left_path = intro_case["method_videos"]["anyact"] | |
| example_right_path = intro_case["method_videos"]["vlm_hy_motion"] | |
| with gr.Blocks( | |
| title=config["study_title"], | |
| css=CUSTOM_CSS, | |
| head=CUSTOM_HEAD, | |
| theme=gr.themes.Soft( | |
| primary_hue="blue", | |
| secondary_hue="sky", | |
| neutral_hue="slate", | |
| ), | |
| ) as demo: | |
| participant_state = gr.State({}) | |
| question_token = gr.State("") | |
| browser_participant_id = gr.BrowserState( | |
| "", | |
| storage_key=f"{config['study_id']}_participant_id", | |
| ) | |
| browser_language = gr.BrowserState( | |
| default_language, | |
| storage_key=f"{config['study_id']}_language", | |
| ) | |
| with gr.Row(elem_classes=["language-switch-row"]): | |
| language_selector = gr.Radio( | |
| choices=LANGUAGE_CHOICES, | |
| value=default_language, | |
| label=LANGUAGE_SWITCH_LABEL, | |
| interactive=True, | |
| show_label=False, | |
| elem_classes=["language-switch"], | |
| ) | |
| with gr.Column(visible=True) as intro_panel: | |
| intro_instruction_html = gr.HTML( | |
| build_instruction_html( | |
| config=config, | |
| case_walkthrough_html=build_instruction_case_html(intro_case, default_language), | |
| judging_instruction_html=build_judging_instruction_html(default_language), | |
| language=default_language, | |
| ) | |
| ) | |
| example_caption_md = gr.Markdown(build_example_caption(default_language), visible=False) | |
| with gr.Row(visible=False): | |
| with gr.Column(scale=5): | |
| intro_reference_video = gr.Video( | |
| value=intro_case["reference_video"], | |
| label=tr(default_language, "reference_video"), | |
| autoplay=True, | |
| loop=True, | |
| elem_classes=["panel-card", "video-panel", "reference-panel"], | |
| ) | |
| intro_reference_caption = gr.Markdown(video_caption_html(tr(default_language, "reference_caption"))) | |
| with gr.Column(scale=4): | |
| intro_left_video = gr.Video( | |
| value=example_left_path, | |
| label=tr(default_language, "candidate_a"), | |
| autoplay=True, | |
| loop=True, | |
| elem_classes=["panel-card", "video-panel", "candidate-panel"], | |
| ) | |
| intro_left_caption = gr.Markdown(video_caption_html(tr(default_language, "result_a_caption"))) | |
| with gr.Column(scale=4): | |
| intro_right_video = gr.Video( | |
| value=example_right_path, | |
| label=tr(default_language, "candidate_b"), | |
| autoplay=True, | |
| loop=True, | |
| elem_classes=["panel-card", "video-panel", "candidate-panel"], | |
| ) | |
| intro_right_caption = gr.Markdown(video_caption_html(tr(default_language, "result_b_caption"))) | |
| with gr.Group(elem_classes=["form-card"]): | |
| participant_setup_md = gr.Markdown(build_participant_setup_markdown(default_language)) | |
| participant_id_box = gr.Textbox( | |
| label=tr(default_language, "participant_id_label"), | |
| interactive=False, | |
| ) | |
| regenerate_button = gr.Button(tr(default_language, "generate_fresh_id")) | |
| start_message = gr.Markdown() | |
| start_button = gr.Button(tr(default_language, "start_continue"), variant="primary") | |
| with gr.Column(visible=False, elem_classes=["study-shell"]) as study_panel: | |
| progress_html = gr.HTML() | |
| study_notice = gr.Markdown(tr(default_language, "study_instruction")) | |
| with gr.Row(): | |
| with gr.Column(scale=5): | |
| reference_video = gr.Video( | |
| label=tr(default_language, "reference_video"), | |
| autoplay=True, | |
| loop=True, | |
| elem_id="study-reference-video", | |
| elem_classes=["panel-card", "video-panel", "reference-panel"], | |
| ) | |
| study_reference_caption = gr.Markdown(video_caption_html(tr(default_language, "reference_caption"))) | |
| with gr.Column(scale=4): | |
| left_video = gr.Video( | |
| label=tr(default_language, "result_a_left"), | |
| autoplay=True, | |
| loop=True, | |
| elem_id="study-left-video", | |
| elem_classes=["panel-card", "video-panel", "candidate-panel"], | |
| ) | |
| study_left_caption = gr.Markdown(video_caption_html(tr(default_language, "result_a_caption"))) | |
| with gr.Column(scale=4): | |
| right_video = gr.Video( | |
| label=tr(default_language, "result_b_right"), | |
| autoplay=True, | |
| loop=True, | |
| elem_id="study-right-video", | |
| elem_classes=["panel-card", "video-panel", "candidate-panel"], | |
| ) | |
| study_right_caption = gr.Markdown(video_caption_html(tr(default_language, "result_b_caption"))) | |
| with gr.Group(elem_classes=["question-card"]): | |
| similarity_radio = gr.Radio( | |
| choices=choice_options, | |
| label=tr(default_language, "question_similarity"), | |
| elem_classes=["choice-input"], | |
| ) | |
| quality_radio = gr.Radio( | |
| choices=choice_options, | |
| label=tr(default_language, "question_quality"), | |
| elem_classes=["choice-input"], | |
| ) | |
| preference_radio = gr.Radio( | |
| choices=choice_options, | |
| label=tr(default_language, "question_preference"), | |
| elem_classes=["choice-input"], | |
| ) | |
| study_message = gr.Markdown() | |
| with gr.Row(): | |
| previous_button = gr.Button(tr(default_language, "previous")) | |
| next_button = gr.Button(tr(default_language, "next"), variant="primary") | |
| submit_button = gr.Button(tr(default_language, "submit_study"), variant="primary", visible=False) | |
| with gr.Column(visible=False) as thank_panel: | |
| with gr.Group(elem_classes=["hero-card", "thank-card"]): | |
| thank_you_markdown = gr.Markdown() | |
| outputs = [ | |
| intro_panel, | |
| study_panel, | |
| thank_panel, | |
| participant_state, | |
| question_token, | |
| progress_html, | |
| study_notice, | |
| reference_video, | |
| left_video, | |
| right_video, | |
| similarity_radio, | |
| quality_radio, | |
| preference_radio, | |
| study_message, | |
| previous_button, | |
| next_button, | |
| submit_button, | |
| start_message, | |
| thank_you_markdown, | |
| participant_id_box, | |
| browser_participant_id, | |
| browser_language, | |
| language_selector, | |
| intro_instruction_html, | |
| example_caption_md, | |
| intro_reference_video, | |
| intro_reference_caption, | |
| intro_left_video, | |
| intro_left_caption, | |
| intro_right_video, | |
| intro_right_caption, | |
| participant_setup_md, | |
| regenerate_button, | |
| start_button, | |
| study_reference_caption, | |
| study_left_caption, | |
| study_right_caption, | |
| ] | |
| outputs_without_language_selector = outputs[:22] + outputs[23:] | |
| def initialize_page(saved_participant_id: str, saved_language: str) -> Tuple[Any, ...]: | |
| language = normalize_language(saved_language) | |
| resolved_participant_id = resolve_browser_participant_id(saved_participant_id) | |
| start_message = "" | |
| if sanitize_participant_id(saved_participant_id): | |
| start_message = tr(language, "browser_saved_id_notice") | |
| return render_intro_view( | |
| config=config, | |
| intro_case=intro_case, | |
| language=language, | |
| participant_id=resolved_participant_id, | |
| browser_participant_id=resolved_participant_id, | |
| start_message=start_message, | |
| ) | |
| def handle_generate_new_id(current_language: str) -> Tuple[Any, str, str]: | |
| language = normalize_language(current_language) | |
| new_participant_id = generate_participant_id() | |
| return ( | |
| gr.update(value=new_participant_id, label=tr(language, "participant_id_label")), | |
| new_participant_id, | |
| tr(language, "fresh_id_notice"), | |
| ) | |
| def handle_start( | |
| participant_id: str, | |
| current_language: str, | |
| request: gr.Request, | |
| ) -> Tuple[Any, ...]: | |
| language = normalize_language(current_language) | |
| state, status = create_or_resume_participant( | |
| project_root=PROJECT_ROOT, | |
| config=config, | |
| participant_id=participant_id, | |
| request=request, | |
| ) | |
| if status == "completed": | |
| return render_intro_view( | |
| config=config, | |
| intro_case=intro_case, | |
| language=language, | |
| participant_id=state["participant_id"], | |
| browser_participant_id=state["participant_id"], | |
| start_message=tr(language, "completed_id_notice"), | |
| ) | |
| study_message_text = tr(language, "saved_progress_restored") if status == "resumed" else "" | |
| return render_question_view( | |
| config=config, | |
| intro_case=intro_case, | |
| language=language, | |
| state=state, | |
| study_message=study_message_text, | |
| ) | |
| def handle_previous( | |
| state: dict[str, Any], | |
| current_token: str, | |
| current_browser_participant_id: str, | |
| current_language: str, | |
| ) -> Tuple[Any, ...]: | |
| language = normalize_language(current_language) | |
| if not state: | |
| resolved_participant_id = resolve_browser_participant_id(current_browser_participant_id) | |
| return render_intro_view( | |
| config=config, | |
| intro_case=intro_case, | |
| language=language, | |
| participant_id=resolved_participant_id, | |
| browser_participant_id=resolved_participant_id, | |
| start_message=tr(language, "session_empty"), | |
| ) | |
| updated_state, message = move_question_pointer( | |
| project_root=PROJECT_ROOT, | |
| participant_id=state["participant_id"], | |
| question_token=current_token, | |
| direction="previous", | |
| ) | |
| return render_question_view( | |
| config=config, | |
| intro_case=intro_case, | |
| language=language, | |
| state=updated_state, | |
| study_message=message, | |
| ) | |
| def handle_next_or_submit( | |
| state: dict[str, Any], | |
| current_token: str, | |
| answer_similarity: str, | |
| answer_quality: str, | |
| answer_preference: str, | |
| action: str, | |
| current_browser_participant_id: str, | |
| current_language: str, | |
| ) -> Tuple[Any, ...]: | |
| language = normalize_language(current_language) | |
| if not state: | |
| resolved_participant_id = resolve_browser_participant_id(current_browser_participant_id) | |
| return render_intro_view( | |
| config=config, | |
| intro_case=intro_case, | |
| language=language, | |
| participant_id=resolved_participant_id, | |
| browser_participant_id=resolved_participant_id, | |
| start_message=tr(language, "session_empty"), | |
| ) | |
| if not answer_similarity or not answer_quality or not answer_preference: | |
| return render_question_view( | |
| config=config, | |
| intro_case=intro_case, | |
| language=language, | |
| state=state, | |
| study_message=tr(language, "answer_all_required"), | |
| draft_answers=(answer_similarity, answer_quality, answer_preference), | |
| ) | |
| updated_state, message, status = save_current_answer( | |
| project_root=PROJECT_ROOT, | |
| participant_id=state["participant_id"], | |
| question_token=current_token, | |
| answer_similarity=answer_similarity, | |
| answer_quality=answer_quality, | |
| answer_preference=answer_preference, | |
| action=action, | |
| ) | |
| if status == "completed": | |
| return render_thank_you_view( | |
| config=config, | |
| intro_case=intro_case, | |
| language=language, | |
| state=updated_state, | |
| ) | |
| return render_question_view( | |
| config=config, | |
| intro_case=intro_case, | |
| language=language, | |
| state=updated_state, | |
| study_message=message, | |
| ) | |
| def handle_language_change( | |
| selected_language: str, | |
| state: dict[str, Any], | |
| current_browser_participant_id: str, | |
| current_token: str, | |
| answer_similarity: str | None, | |
| answer_quality: str | None, | |
| answer_preference: str | None, | |
| current_start_message: str, | |
| current_study_message: str, | |
| ) -> Tuple[Any, ...]: | |
| language = normalize_language(selected_language) | |
| if state: | |
| if state.get("completed_at"): | |
| return _drop_language_selector_update( | |
| render_thank_you_view( | |
| config=config, | |
| intro_case=intro_case, | |
| language=language, | |
| state=state, | |
| ) | |
| ) | |
| return _drop_language_selector_update( | |
| render_question_view( | |
| config=config, | |
| intro_case=intro_case, | |
| language=language, | |
| state=state, | |
| study_message=current_study_message, | |
| draft_answers=(answer_similarity, answer_quality, answer_preference), | |
| ) | |
| ) | |
| resolved_participant_id = resolve_browser_participant_id(current_browser_participant_id) | |
| return _drop_language_selector_update( | |
| render_intro_view( | |
| config=config, | |
| intro_case=intro_case, | |
| language=language, | |
| participant_id=resolved_participant_id, | |
| browser_participant_id=resolved_participant_id, | |
| start_message=current_start_message, | |
| ) | |
| ) | |
| demo.load(initialize_page, inputs=[browser_participant_id, browser_language], outputs=outputs) | |
| regenerate_button.click( | |
| handle_generate_new_id, | |
| inputs=[browser_language], | |
| outputs=[participant_id_box, browser_participant_id, start_message], | |
| ) | |
| start_button.click(handle_start, inputs=[browser_participant_id, browser_language], outputs=outputs) | |
| previous_button.click( | |
| handle_previous, | |
| inputs=[participant_state, question_token, browser_participant_id, browser_language], | |
| outputs=outputs, | |
| ) | |
| next_button.click( | |
| lambda state, token, similarity, quality, preference, browser_pid, current_language: handle_next_or_submit( | |
| state, token, similarity, quality, preference, "next", browser_pid, current_language | |
| ), | |
| inputs=[ | |
| participant_state, | |
| question_token, | |
| similarity_radio, | |
| quality_radio, | |
| preference_radio, | |
| browser_participant_id, | |
| browser_language, | |
| ], | |
| outputs=outputs, | |
| ) | |
| submit_button.click( | |
| lambda state, token, similarity, quality, preference, browser_pid, current_language: handle_next_or_submit( | |
| state, token, similarity, quality, preference, "submit", browser_pid, current_language | |
| ), | |
| inputs=[ | |
| participant_state, | |
| question_token, | |
| similarity_radio, | |
| quality_radio, | |
| preference_radio, | |
| browser_participant_id, | |
| browser_language, | |
| ], | |
| outputs=outputs, | |
| ) | |
| language_selector.change( | |
| handle_language_change, | |
| inputs=[ | |
| language_selector, | |
| participant_state, | |
| browser_participant_id, | |
| question_token, | |
| similarity_radio, | |
| quality_radio, | |
| preference_radio, | |
| start_message, | |
| study_message, | |
| ], | |
| outputs=outputs_without_language_selector, | |
| ) | |
| return demo | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description="Launch the Gradio user study application.") | |
| parser.add_argument( | |
| "--config", | |
| type=Path, | |
| default=PROJECT_ROOT / "data" / "study_config.json", | |
| help="Path to the study configuration JSON file.", | |
| ) | |
| parser.add_argument( | |
| "--port", | |
| type=int, | |
| default=int(os.environ.get("PORT", "7860")), | |
| help="Server port for Gradio.", | |
| ) | |
| parser.add_argument( | |
| "--server-name", | |
| type=str, | |
| default=os.environ.get("GRADIO_SERVER_NAME", default_server_name()), | |
| help="Server bind address for Gradio.", | |
| ) | |
| parser.add_argument( | |
| "--share", | |
| action="store_true", | |
| help="Enable Gradio's temporary public share link.", | |
| ) | |
| return parser.parse_args() | |
| def main() -> None: | |
| args = parse_args() | |
| demo = build_demo(args.config) | |
| demo.queue() | |
| demo.launch( | |
| server_name=args.server_name, | |
| server_port=args.port, | |
| share=args.share, | |
| allowed_paths=[str(PROJECT_ROOT)], | |
| ) | |
| if __name__ == "__main__": | |
| main() | |