A browser-based framework for running human-subject studies on AI-generated images and videos. Participants rate media through several formats (individual sliders, pairwise comparison, ranking, selection, and multi-image grids) while the app optionally records webcam-based eye tracking (WebGazer.js) and voice think-aloud recordings. All results are persisted to Supabase and can be explored in a built-in researcher analytics dashboard.
Built as part of a UROP with the Oliva group.
- Quick Start
- Environment Variables
- Supabase Setup
- How a Study Session Works
- Rating Modes
- Gaze Tracking
- Voice Recording
- Roles: Participant / Researcher / Admin
- Repository Layout
- Data Model
- Study Config Files (
config/) - Media Assets
- Offline Analysis (
src/analysis/) - Testing & Linting
- Deployment Notes
- Troubleshooting
Prerequisites: Node.js (LTS). Check with node -v.
npm install # install all dependencies (React 19, MUI, Supabase, etc.)
cp .env.example .env # then fill in your Supabase credentials (see below)
npm run dev # start Vite dev server → http://localhost:5173Other scripts:
npm run build # production build → dist/
npm run preview # serve the production build locally
npm test # run vitest unit tests once
npm run test:watch # run tests in watch mode
npm run lint # eslintThe app runs without Supabase configured (the client is null and most write calls no-op or log errors), which is fine for poking at the UI — but nothing is persisted and auth/dashboards won't work. For real use, set up Supabase first.
Create .env at the project root (same level as src/, see .env.example):
VITE_SUPABASE_URL=<your Supabase project URL>
VITE_SUPABASE_ANON_KEY=<your Supabase anon public key>
.env is git-ignored. Both values come from the Supabase dashboard → Project Settings → API.
The app uses Supabase Postgres (results), Auth (researcher/admin logins), Storage (images, videos, audio recordings), and one Edge Function (user invites).
- Create a Supabase project and copy the URL + anon key into
.env. - Run the schema SQL in the Supabase SQL editor. The most complete/most recent schema lives in
scripts/supabase-schema.sql; thescripts/*-migration.sqlfiles add later features (gaze sessions table, audio storage bucket, RLS policy updates).docs/supabase_schema.sqlis an earlier version kept for reference. - Create storage buckets (private unless noted):
experiment-images,mem-images,videos(public), and the audio recordings bucket (seescripts/migrate-audio-bucket.sql). - Enable Email auth. Roles are assigned by inserting rows into the
user_rolestable (email→adminorresearcher). - (Optional) Deploy
supabase/functions/invite-userso admins can invite new researchers from the Admin Control Panel. - Upload the memorability image set to the
mem-imagesbucket withnode scripts/upload-images.mjs(reads localpublic/mem_images/).
Step-by-step details: docs/SUPABASE_SETUP.md and docs/SUPABASE_DOCS.md.
The participant-facing flow is the guided session:
- Participant opens the site →
Home→ clicks Start Rating →/start(GuidedSessionWelcome): enters a username, consents, and calibrates WebGazer. - The app loads the active study configuration from the
mode_configtable (which modes run, in what order, how many items each, image vs. video media). Falls back to defaults insrc/services/modeConfig.jsif unreachable. src/utils/guidedFlow.jschains the enabled rating pages together, passing auploadConfigobject throughlocation.state. Each rating page callsnextGuidedNavigation()when done instead of routing to its own results page. Steps can require a quick gaze-accuracy re-check (CalibrationCheck) before starting.- Throughout, gaze data and per-page voice transcripts/audio are captured; a speed check (
checkEngagementinsrc/Results.jsx) warns participants who click through too fast. - After the last step the participant lands on
/thank-you, and everything has been written to Supabase.
Researchers configure step order/counts/media-mode live from the Admin Control Panel (/admin/control-panel), which writes mode_config.
Individual rating pages can also be launched standalone (see routes in src/App.jsx), usually seeded by uploading a JSON config on /rate/upload (see Study Config Files).
| Mode | Image route | Video route | Participant task |
|---|---|---|---|
| Individual | /individual-rate |
/video-individual-rate |
Slide a 0–100 score for one item at a time |
| Pairwise | /pairwise-rate |
/video-pairwise-rate |
Pick the better of two items (reaction time recorded) |
| Ranked | /ranked-rate |
/video-ranked-rate |
Order a set of items best→worst (swap or select style) |
| Selection | /selection-rate |
— | Select all items matching a prompt |
| Group grid | /group-grid-rate |
/video-group-grid-rate |
Rate a whole page of images laid out in a grid (2x2, 3x3, 4x4, …) |
| Combo | /combo-rate |
— | Combined multi-step flow with its own results page |
| Layout flow | /rate, /rate/grid |
— | Grid rating driven by uploaded config (LayoutRatingFlow) |
Grid layout definitions (dimensions, tile counts) live in src/data/gridConstants.js. Video modes share the image logic but load media via src/utils/VideoLoader.js from public/videos/ + manifest.json (or the Supabase videos bucket).
Webcam eye tracking uses WebGazer.js — a vendored copy is served from public/webgazer/webgazer.js.
src/utils/WebGazerContext.jsx— global provider: loads the script, starts/stops the tracker, throttles gaze updates to ~30 FPS, persists calibration to localStorage./webgazer-calibration— click-based 9-point calibration;/webgazer-gaze-test— accuracy test;/calibration-check— quick mid-session validation used between guided steps.src/hooks/useGazeTracker.js+src/components/GazeTrackingProvider.jsx/GazeTrackedImage.jsx— hit-test the current gaze point against registered image elements and accumulate per-image dwell time, entry/exit counts, and in-image coordinates (for heatmaps).src/components/CalibrationGate.jsx— blocks a rating page until calibration exists and the tracker is running.- On submit, each rating page calls
saveGazeSession()(src/utils/gazeStorage.js) which writes to thegaze_sessionstable. - Researchers explore the data in the Gaze Analytics dashboard (
src/Webpages/GazeAnalyticsSection.jsx+src/components/analytics/Gaze*): heatmaps, dwell charts, timelines, data-quality panels, CSV export.
Design/context docs: docs/gaze-tracking-session-context.md, docs/plans/, docs/superpowers/specs/.
A floating microphone widget (src/components/VoiceRecorder.jsx, mounted in App.jsx) appears on rating routes. It uses the browser SpeechRecognition API for live transcription and MediaRecorder for audio capture. VoiceRecorderContext tracks which rating page the participant is on so transcripts/audio are attributed per page; audio blobs upload to Supabase Storage (src/services/supabaseAudioStorage.js) and transcripts to the transcripts table. /transcripts shows recording history; src/analysis/transcribe_recordings.py can re-transcribe audio offline with Whisper-style tooling.
- Participants don't log in — they just enter a username at the start of a guided session. A consent modal (
ConsentModal) gates all data collection;/privacylets them revoke consent and delete their data. - Researchers / Admins sign in via Supabase email auth (
LoginButton); their role comes from theuser_rolestable (src/utils/AuthContext.jsx)./researcher—ResearcherView: session browser and results tables./researcher/simulate— generate simulated sessions for testing dashboards./admin/control-panel— configure the guided flow (mode_config), invite users (via theinvite-useredge function), manage data.- Results dashboards:
/grid-results,/mode-results,/combo-results.
/set-password— landing page for invited users completing signup.
├── index.html # Vite entry HTML
├── vite.config.js # Vite + React plugin config
├── vitest.config.js # Test runner config (jsdom)
├── eslint.config.js # Lint rules
├── .env / .env.example # Supabase credentials (git-ignored / template)
├── config/ # Ready-made study config JSONs to upload on /rate/upload
│ ├── Individual|Pairwise|Ranked|Selection/ # per-mode examples (5/10/15 items)
│ └── group/<layout>/ # grid-mode configs per layout & memorability range
├── docs/ # Setup guides, schema reference, design docs, roadmap
├── scripts/ # Supabase schema + migrations, image upload script
├── supabase/functions/ # Edge functions (invite-user)
├── public/
│ ├── webgazer/webgazer.js # Vendored WebGazer build (served as-is)
│ ├── mem_images/ # Memorability dataset images (LaMem-style targets)
│ ├── images/<model>/ # AI-generated study images (flux_2_pro, etc.)
│ └── videos/ + manifest.json# Video stimuli, organized by model folder
├── src/
│ ├── main.jsx # React entry point
│ ├── App.jsx # Providers, theme, app bar, ALL route definitions
│ ├── Results.jsx # ResultsProvider — global session store, hydrates from Supabase
│ ├── supabaseClient.js # Shared Supabase client (null if env vars missing)
│ ├── services/ # Supabase persistence (results, audio, mode_config)
│ ├── utils/ # Contexts (WebGazer, Auth), guided flow, loaders, transforms
│ ├── hooks/ # useGazeTracker, useVideoFleet, useAutoVoiceRecording
│ ├── components/ # Shared UI + rating-flow building blocks
│ │ └── analytics/ # Researcher dashboard charts/panels (recharts)
│ ├── Webpages/ # One component per route (rating pages, results, admin)
│ ├── data/ # gridConstants, memorability CSV + hook, prompt CSVs
│ └── analysis/ # Offline Python/Jupyter analysis of exported results
└── dist/ # Build output (committed for convenience; regenerate with npm run build)
All persistence goes through src/services/supabaseResults.js and the ResultsProvider in src/Results.jsx (React state is hydrated from Supabase on load and kept in sync on every write). Main tables:
| Table | Contents |
|---|---|
sessions |
One row per completed rating session: id (Date.now), type (individual, group, layout_group, fixed, pairwise, video_pairwise, ranked, selection), username, timestamp, meta (JSON: per-page transcripts/audio URLs, layoutId, prompt, …) |
rating_scores |
Per-image scores for slider/grid/selection sessions (score, time spent, interaction count, click order, memorability scores, image src) |
pairwise_choices |
Winner/loser per pair with reaction time and timeout flag |
ranked_results |
Final rank per image for ranked sessions |
transcripts |
Voice transcript entries (text, duration, timestamp) |
gaze_sessions |
Per-session gaze data: per-image dwell/entries/coordinates, per-page aggregates |
mode_config |
Single active row defining the guided flow (steps + media mode) |
user_roles |
email → admin/researcher |
images |
Metadata for images uploaded via Dataset Manager |
/rate/upload (UnifiedUploadPage) accepts a JSON file describing a standalone session, e.g.:
{
"type": "individual", // individual | pairwise | ranked | selection |
// video_pairwise | a grid layout id (2x2, 3x3, …)
"count": 5, // number of items (or pages for grids)
"prompt": "Rate how accurately this image depicts the described scene",
"minScore": 0.4, // grid modes: memorability-score range to sample from
"maxScore": 0.8,
"showRating": true,
"rankMode": "select" // ranked mode UI variant: "select" | "swap"
}The config/ tree contains ready-made examples: per-mode files named <count>_<mode>.json and grid configs under config/group/<layout>/<imageCount>_<minScore>-<maxScore>.json.
- Memorability images (
public/mem_images/, ~1k JPGs) are sampled by grid modes according to the score ranges insrc/data/memorability_scores.csv(loaded bysrc/data/useMemImages.js). In production they're served from themem-imagesSupabase bucket (src/utils/supabaseImageUrl.jsdecides local vs. remote). - Generated images (
public/images/<model>/) with matching prompt CSVs insrc/data/compare text-to-image models (flux_2_pro, gpt-image-1.5, nano_banana_pro). - Videos (
public/videos/<model>/*.mp4+manifest.json) — same filename across model folders = same prompt rendered by different models; the pairwise builder relies on this. Prompt spreadsheet lives alongside the videos.
Python/Jupyter material for analyzing exported results (not part of the web build):
analysis.py— stats/plots over exported session CSVs.combo_analysis-2.ipynb— notebook for combo-session results (combo_results_all-2.csv).transcribe_recordings.py— batch speech-to-text for downloaded audio recordings.
The dashboards also export CSVs directly in the browser (ExportCSVButton, GazeExport, DashboardExport).
npm test— Vitest + Testing Library (jsdom). Existing suites:src/utils/guidedFlow.test.js,src/hooks/useGazeTracker.test.js.npm run lint— ESLint 9 flat config with React hooks/refresh plugins.
npm run buildoutputs a static site todist/— deployable to any static host (Netlify, Vercel, GitHub Pages with SPA fallback). All backend interaction happens client-side via the Supabase anon key + RLS.- The site must be served over HTTPS (or localhost) for webcam (WebGazer) and microphone (voice recorder) permissions to work.
public/is copied verbatim into the build, so the full image/video set ships with the site; consider moving large media to Supabase Storage only.
- Blank data / nothing saves — check
.envvalues and restartnpm run dev(Vite only reads env at startup).src/supabaseClient.jssilently exportsnullwhen unset. - WebGazer never initializes — camera permission denied, or the page isn't HTTPS/localhost. Check the console for "WebGazer script failed to load" (the script is served from
public/webgazer/). - Calibration constantly invalid — calibration is stored in localStorage (
webgazerCalibrationData); clearing site data forces recalibration. - Grid config rejects an upload — the
typemust match a key insrc/data/gridConstants.jsor one of the rating modes listed inUnifiedUploadPage. - Guided flow skips a mode — check the
mode_configrow in Supabase (or the Admin Control Panel); disabled steps are carried but skipped.