import React, { useCallback, useEffect, useMemo, useState } from "react"; import GuidelinePage from "./pages/GuidelinePage"; import TrainingPage from "./pages/TrainingPage"; import EvaluationPage from "./pages/EvaluationPage"; import RoundCompletePage from "./pages/RoundCompletePage"; import rawPrompts from "./data/prompts.json"; import { normalizePromptList } from "./lib/promptSchema"; import "./styles/guideline.css"; const API_BASE_URL = ( import.meta.env.VITE_API_BASE_URL || (import.meta.env.DEV ? "http://localhost:8000" : "") ).replace(/\/$/, ""); const STORAGE_KEYS = { annotatorName: "t2av_annotator_name", trainingPassed: "t2av_training_passed" }; function readSession(key, fallback) { try { const value = sessionStorage.getItem(key); return value === null ? fallback : JSON.parse(value); } catch { return fallback; } } function writeSession(key, value) { try { sessionStorage.setItem(key, JSON.stringify(value)); } catch { // sessionStorage unavailable (e.g. private browsing) - state just won't survive a refresh. } } const normalizedPrompts = normalizePromptList(rawPrompts); export default function App() { // Gating note: there is no router and no URL for 'training'/'evaluation' - // the only way to reach them is through the state transitions below, so // there is nothing to type into an address bar to skip ahead. // // Stage itself is intentionally NOT restored from sessionStorage: every // fresh load (including a same-tab refresh) should land back on the // guideline page rather than silently resuming mid-training or // mid-evaluation. annotatorName and trainingPassed are still remembered, // so a returning annotator gets recognized and can skip the worked // example again (see the collision handling in GuidelinePage) - only the // "which page am I on" state resets. const [stage, setStage] = useState("guideline"); const [annotatorName, setAnnotatorName] = useState(() => readSession(STORAGE_KEYS.annotatorName, "")); const [trainingPassed, setTrainingPassed] = useState(() => readSession(STORAGE_KEYS.trainingPassed, false) ); const [videoManifest, setVideoManifest] = useState(null); const [videoManifestError, setVideoManifestError] = useState(null); const [assignmentData, setAssignmentData] = useState(null); const [assignmentError, setAssignmentError] = useState(null); useEffect(() => { let cancelled = false; fetch(`${API_BASE_URL}/videos`) .then((res) => { if (!res.ok) throw new Error(`Video catalog request failed (status ${res.status})`); return res.json(); }) .then((data) => { if (!cancelled) setVideoManifest(data); }) .catch((err) => { if (!cancelled) setVideoManifestError(err.message || "Failed to load videos"); }); return () => { cancelled = true; }; }, []); useEffect(() => writeSession(STORAGE_KEYS.annotatorName, annotatorName), [annotatorName]); useEffect(() => writeSession(STORAGE_KEYS.trainingPassed, trainingPassed), [trainingPassed]); // Defensive fallback: the UI never lets you reach 'evaluation' without // trainingPassed, but if sessionStorage were hand-edited, fall back to // training rather than trusting the stored stage. useEffect(() => { if (stage === "evaluation" && !trainingPassed) { setStage("training"); } }, [stage, trainingPassed]); const effectiveStage = stage === "evaluation" && !trainingPassed ? "training" : stage; // Each annotator gets a balanced 20-video round, not the full catalog. // The backend decides everything about round state (in progress / just // completed / time to auto-advance to the next round) - this call is // always the same "give me my current status" request, made automatically // on every evaluation-stage visit and again once EvaluationPage reports // the last video was saved, so the completed/code state is picked up // without any manual "start new round" action. const fetchAssignment = useCallback(() => { if (!annotatorName.trim()) return; fetch(`${API_BASE_URL}/assignment`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ annotator: annotatorName }) }) .then((res) => { if (!res.ok) throw new Error(`Assignment request failed (status ${res.status})`); return res.json(); }) .then((data) => setAssignmentData(data)) .catch((err) => setAssignmentError(err.message || "Failed to load your assignment")); }, [annotatorName]); useEffect(() => { if (effectiveStage !== "evaluation") return; fetchAssignment(); }, [effectiveStage, fetchAssignment]); // All resolved (itemId, model) videos, keyed by taskId - the full 200, // before per-annotator filtering. const videoEntriesByTaskId = useMemo(() => { if (!videoManifest) return null; return new Map(videoManifest.items.map((entry) => [`${entry.itemId}__${entry.model}`, entry])); }, [videoManifest]); const itemsById = useMemo( () => new Map(normalizedPrompts.map((item) => [item.itemId, item])), [] ); // The annotator's actual queue: their current round's video_ids (already // excludes anything they've completed before, in any round), resolved // against the live video catalog. An assigned id that no longer resolves // (e.g. a video went missing after assignment) is skipped gracefully, // matching the existing missing-video handling elsewhere. const tasks = useMemo(() => { if (!videoEntriesByTaskId || !assignmentData) return []; const built = []; assignmentData.video_ids.forEach((taskId) => { const entry = videoEntriesByTaskId.get(taskId); if (!entry) return; const item = itemsById.get(entry.itemId); if (!item) return; built.push({ taskId, itemId: entry.itemId, model: entry.model, videoUrl: entry.videoUrl, variant: entry.variant, item }); }); return built; }, [videoEntriesByTaskId, itemsById, assignmentData]); const handleContinueToTraining = () => { if (!annotatorName.trim()) return; setStage("training"); }; // Offered on GuidelinePage only once handleGuidelineLookupResult has // already confirmed (via /assignment/lookup) that this name passed // training before - lets a returning annotator skip straight back into // their in-progress round instead of re-doing the worked example. const handleSkipTraining = () => { if (!annotatorName.trim() || !trainingPassed) return; setStage("evaluation"); }; // A round can only ever exist for a name that has already passed training // (POST /assignment is only ever called after trainingPassed is set, see // the evaluation-stage effect above) - so GET /assignment/lookup finding // an existing round is a reliable, read-only signal that this annotator // already passed training in an earlier session. GuidelinePage already // makes this exact call for its name-collision warning; this just listens // in on that same result instead of adding a second request. const handleGuidelineLookupResult = useCallback((data) => { if (data?.exists) setTrainingPassed(true); }, []); const handlePassTraining = () => { setTrainingPassed(true); setStage("evaluation"); }; if (effectiveStage === "training") { return ; } if (effectiveStage === "evaluation") { if (videoManifestError) { return (
Could not load videos {videoManifestError}. Check that the backend is running and reachable at{" "} {API_BASE_URL || "(same origin)"}.
); } if (!videoManifest) { return (

Loading videos…

); } if (assignmentError) { return (
Could not load your assignment {assignmentError}. Check that the backend is running and reachable at{" "} {API_BASE_URL || "(same origin)"}.
); } if (!assignmentData) { return (

Loading your assignment…

); } if (assignmentData.status === "completed") { return ( ); } if (tasks.length === 0) { return (
No videos available The video catalog loaded, but none of your {assignmentData.video_ids.length} assigned videos matched a video in {normalizedPrompts.length} prompt items. Check HF_VIDEO_DATASET_REPO and the dataset's folder structure.
); } return ( ); } return ( ); }