# Project export: jiggle wiggle

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: any move, any coach, anytime.
- Devpost: https://devpost.com/software/jiggle-wiggle
- GitHub: https://github.com/cindyzli/jigglewiggle
- Demo: https://jiggle-wiggle.onrender.com/
- Video: https://www.youtube.com/embed/kL3udtWY1EE?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Result: winner ([Modal] Inference Track (Inference Track Grand Prize: $5K in Modal Credits per person, paid visit (flight & hotel) to SF or NY Office with lunch with Modal team,. Inference Track Runner Up Prize: $1K in Modal credits, airpods for each team member.); [Zoom x Render] Best use of Zoom APIs + Render: (Bose Headphones x 4 Zoom Ocean Bottle x 4$2000 Render Credits))
- Team: 5 GitHub contributor(s) — Aryan Keluskar (11 commits), Aryan Kumar (9 commits), Soham Daga (6 commits), Cindy Li (1 commits), Cursor (1 commits)

## Devpost submission (written by the team)

### Inspiration

We’ve all tried learning a dance from TikTok or YouTube, rewinding, pausing, squinting at our reflection, and still not knowing if we’re doing it right. Your timing feels off, your arms look strange, but you can’t tell why. And this problem is much bigger than dance. It’s golf swings, tennis serves, yoga poses, lifts at the gym, martial arts, physical therapy, millions of physical skills where the internet gives us infinite demonstrations but zero correction. You can watch experts in slow motion or replay them frame by frame, but information isn’t coaching. Without someone telling you what to change, progress is slow, frustrating, and can even lead to bad habits or injury. We wanted to build the thing we wished existed. We personally want it to finally nail the choreographies I learn online. Someone else might use it to stay engaged and improve during a Zoom class, rehearse a presentation, or practice rehab exercises correctly. An AI coach that can watch any reference, from YouTube, a live instructor, or even an AI-generated demo, can watch you at the same time and give real-time, precise, actionable feedback. Not just “good job,” but “your left elbow is dropping 15 degrees,” “your hips are opening too early,” or “hold the pose a little longer.” Instead of passive watching, learning becomes interactive. Instant feedback, infinite patience, always available. We believe movement education should be as accessible as opening a browser tab.

### What it does

Jiggle Wiggle is a real-time AI movement coach that works with three input modes: YouTube mode: Paste any dance or fitness video URL. The app downloads it, extracts reference poses frame-by-frame, and compares your webcam feed against the dancer in real-time with a split-screen view, skeleton overlays, and per-body-part scoring (arms, legs, torso). Zoom mode: Join a live Zoom call with a dance instructor or friend. The app captures their video feed, runs pose detection on both of you simultaneously, and coaches you through matching their moves — like having an AI mirror in a private lesson. Generation mode: Describe what you want to learn and AI generates the reference for you. Across all modes, an LLM-powered voice coach gives you adaptive spoken feedback in real-time ("Raise your left arm higher!", "Great match — keep it locked in!"). It adapts its personality based on whether you're doing dance (hype, rhythmic) or gym/fitness (form-focused, technical). You can also control playback with hand gestures — wave to pause, hands above your head to restart. Why Now The demand is massive. Online fitness and sports instruction already reaches hundreds of millions of people worldwide. The global fitness industry is estimated at $250B+, with digital fitness alone projected to surpass $60B in the next few years. Meanwhile, more than 500 hours of video are uploaded to YouTube every minute, and platforms like TikTok have made short-form skill tutorials one of the most consumed categories on the internet. But despite unlimited access to demonstrations, the missing layer has always been personalized feedback at scale. Watching is passive. Improvement is active. We’re building the bridge between the two — turning any video into an interactive coach.

### How we built it

Frontend: Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS 4 Pose detection: MediaPipe Pose running entirely in-browser via WASM — no server-side GPU needed for real-time tracking. We load the model from CDN and process frames through offscreen canvases for reliability. We also use a segmentation model—specifically SAM2 by Meta, hosted on serverless GPUs—to help the user match the motion better. Scoring engine: Custom geometric pose comparison that normalizes for different aspect ratios and camera angles, with per-limb scoring (arms, legs, torso). We blend this with Groq vision-based scoring for an "anchor" score and apply EMA smoothing for stable display. AI coaching: OpenAI GPT-4o-mini with mode-specific system prompts, conversation history, and adaptive throttling. Audio feedback via OpenAI TTS. The coach sees the full geometric comparison data so it can give specific limb-level corrections. Zoom integration: Zoom Meeting SDK embedded in an isolated iframe (React 18) for joining calls, plus getDisplayMedia screen capture with a shared singleton MediaPipe Pose instance and a mutex to serialize WASM calls across video sources. Gesture control: Hand gesture recognition using MediaPipe Pose landmarks — wave to play/pause, swipe to skip, hands above head to restart. Chrome extension: One-click to open any YouTube video in the coaching app. AI Video Generation Pipeline: Perplexity Sonar Pro researches the movement across the web to gather accurate descriptions and context. Bright Data lets us fetch reference material, tutorials, and blog posts. Finally, HeyGen's Avatar API generates a high-fidelity AI avatar of the movement, and we use Grok Imagine to animate it.

### Challenges we ran into

The biggest challenge was running MediaPipe Pose on two live video sources simultaneously. MediaPipe's WASM backend only supports a single Pose instance per page. We tried multiple approaches — shared round-robin managers, independent instances, separate onResults callbacks — and discovered that creating two new Pose() objects causes the second to silently stomp the first. The solution was making loadPose() return a singleton and having each panel set its onResults handler atomically inside a mutex lock right before each send() call. This took many iterations to get right. Screen capture via getDisplayMedia also had quirks — MediaPipe couldn't reliably process raw HTMLVideoElement frames from screen capture streams, so we had to draw each frame to an offscreen canvas first, then send the canvas to MediaPipe. And preferCurrentTab for tab capture was a dead end because MediaPipe would detect poses from the entire page (including the webcam panel and its own skeleton overlay), creating a feedback loop of wrong detections. We ran into platform-specific binary issues while deploying on Render. Native Node.js addons (lightningcss, @tailwindcss/oxide) ship platform-specific binaries, and npm ci from a macOS-generated lockfile silently skips Linux binaries. We fixed it by explicitly installing Linux native bindings in the Dockerfile build stage.

### Accomplishments we're proud of

Real-time dual-source pose comparison running entirely in the browser — no server-side GPU needed for the core experience. MediaPipe Pose, skeleton overlays, geometric comparison, and scoring all run client-side at interactive frame rates. Three input modes (YouTube, Zoom, generation) all feeding into the same comparison and coaching engine — the architecture is genuinely flexible. The AI coach actually gives useful, specific feedback — it's not generic encouragement, it knows which limb is off and by how much because we pipe the full geometric comparison data into the LLM context. Hand gesture controls that let you interact with the app while dancing without touching the keyboard. Aspect ratio normalization in pose comparison — comparing a 16:9 Zoom feed against a 4:3 webcam "just works" because we correct the coordinate space before comparison.

### What we learned

LLM coaching is only as good as the context you give it. Generic pose summaries produced generic advice. Once we started feeding per-limb geometric comparison data with specific scores, the coaching quality jumped dramatically. Context is everything. MediaPipe's WASM backend is powerful but has hard constraints around concurrency that aren't well documented. We learned to treat it as a shared resource with careful serialization. The gap between "pose detection works" and "pose detection works well enough to be useful" is enormous. Raw landmarks are noisy, and without smoothing, visibility thresholds, and garbage frame rejection, the skeleton overlay is unusable. LLM coaching is only as good as the context you give it. Generic pose summaries produced generic advice. Once we started feeding per-limb geometric comparison data with specific scores, the coaching quality jumped dramatically. Browser APIs like getDisplayMedia have subtle differences across capture sources (tab vs window vs screen) that significantly affect what pixels you actually get.

### What's next

for Jiggle Wiggle Progress tracking over time — save sessions, track improvement across days and weeks, and surface trends ("Your arm placement improved 15% this week"). AI-generated reference videos — describe a move in text and get a generated video reference to learn from, closing the loop on the generation mode. Progress tracking over time — save sessions, track improvement across days/weeks, and surface trends ("Your arm placement improved 15% this week"). Mobile support — the core MediaPipe pipeline works on mobile browsers, but the UI needs adaptation for single-screen use. Multi-person support — detect and compare against multiple people in a Zoom call or video (e.g., follow the instructor, not the other students). Community library — share and discover pose sequences, routines, and challenges created by other users. Expanded movement domains — physical therapy rehabilitation tracking, martial arts kata scoring, sign language learning.

## README (from the GitHub repository)

# Jiggle Wiggle 💃

devpost: https://devpost.com/software/jiggle-wiggle

**Real-time AI dance & fitness coaching from any YouTube video.**

<img width="1568" height="890" alt="image" src="https://github.com/user-attachments/assets/ee59ebe5-8bf5-477a-b759-f3ea83ae9024" />

Built at TreeHacks 2026.

## What it does

Paste any YouTube dance or workout video. Your webcam tracks your body in real-time, scores every move against the reference, and an AI coach gives live audio feedback. When the video ends, you get a full Spotify Wrapped-style performance report.

### Core features

- **Auto mode detection** — classifies videos as dance or gym and re-themes the entire UI
- **Real-time pose scoring** — geometric comparison against reference frames blended with Groq vision scoring, EMA-smoothed at 30fps
- **Score popups** — PERFECT (+25), GREAT (+20), OK (+15), ALMOST (+10), MISS (0) flash on screen with particle effects, accumulating to a total points counter
- **Combo streaks** — every 5th consecutive non-miss hit triggers a gold streak celebration
- **AI coach** — OpenAI LLM watches your pose summary every few seconds and gives personality-driven audio feedback via TTS
- **Gesture controls** — raise both hands to pause/play, no keyboard needed
- **Person segmentation** — isolates the dancer from the background using Modal (SAM2)
- **AI video generation** — describe a workout or dance and it generates a video (Perplexity research → GPT-4o synthesis → Grok video gen)
- **Performance report card** — 4-slide Spotify Wrapped-style overlay with letter grade, per-limb breakdown, AI persona, and improvement tips
- **Move queue** — scrolling timeline of key poses extracted from the video
- **Chrome extension** — sends the current YouTube tab URL to the app

## Quick Start

### Prerequisites

- Node.js 18+
- `yt-dlp` and `ffmpeg` installed and on PATH
- OpenAI API key (for coaching + reports)

### Environment variables

Create `.env.local`:

```
OPENAI_API_KEY=sk-...
GROQ_API_KEY=gsk_...          # Groq vision scoring
XAI_API_KEY=xai-...           # Grok video generation
PERPLEXITY_API_KEY=...        # AI generate research (optional)
```

### Run

```bash
npm install
npm run dev
```

Open [http://localhost:3000](http://localhost:3000).

### Usage

1. Paste a YouTube video URL (short clips work best) or use AI Generate
2. Allow camera access when prompted
3. Wait for pose extraction + segmentation to complete
4. Hit play and start moving
5. Watch score popups, points accumulating, and listen to the AI coach
6. When the video ends, view your performance report card

## How Scoring Works

Three scoring signals blended per frame:

| Signal | Weight | Source |
|--------|--------|--------|
| Geometric pose comparison | 50-80% | Compares your landmark positions against the closest reference frame using angle and distance matching per limb |
| Groq vision scoring | 40% | Periodic screenshot comparison (reference vs webcam) scored by Groq's vision model |
| Heuristic body metrics | 10-20% | Arm height, symmetry, motion energy, torso angle |

Final score is EMA-smoothed with alpha 0.15 and a dead zone of 2 to suppress jitter. Frame hits are detected when the video passes each key pose timestamp, converting the smoothed score to a tier (PERFECT 90+, GREAT 80+, OK 60+, ALMOST 40+, MISS <40).

### Report card grades

| Grade | Avg Score |
|-------|-----------|
| S | 78+ |
| A | 62+ |
| B | 45+ |
| C | 30+ |
| D | <30 |

The grade is computed from data. The headline, persona, roasts, and tips are generated by GPT-4o-mini with tone that scales to performance — S/A gets hype, C/D gets roasted.

## Architecture

```
YouTube URL → /api/download (yt-dlp, SSE progress) → /tmp/jigglewiggle/{id}.mp4
  → auto-classification (dance/gym) → mode overlay
  → /api/video/[id] (serves MP4 with range requests)
  → pose extraction (hidden video + MediaPipe, key frames)
  → segmentation (Modal SAM2, person mask overlay)

Webcam → MediaPipe Pose (CDN, client-side WASM) → skeleton overlay + scoring
  → pose summary → /api/coach (OpenAI) → text + TTS audio
  → frame hits → score popups + points + combo streaks
  → video end → /api/report (GPT-4o-mini) → report card
```

## Tech Stack

- **Framework:** Next.js 16 (App Router), React 19, TypeScript
- **Styling:** Tailwind CSS 4
- **Pose detection:** MediaPipe Pose 0.5 (client-side WASM via CDN)
- **AI coaching:** OpenAI GPT-4o-mini + TTS
- **Vision scoring:** Groq (periodic screenshot comparison)
- **Video segmentation:** Modal (SAM2)
- **Video generation:** Grok (xAI) + Perplexity Sonar + GPT-4o
- **Video download:** yt-dlp + ffmpeg


## License

MIT


## Detected evidence (automated analysis)

Indexed codebase: 102 recognized source files, 601 KB.
- CSS (language) — detected in the code
- Express (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository
- AI coding agent: Cursor — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (120 of 120)

```
.claude/settings.json
.dockerignore
.gitignore
app/__tests__/classify.test.ts
app/api/coach/route.ts
app/api/download/route.ts
app/api/generate/route.ts
app/api/recording/upload/route.ts
app/api/report/route.ts
app/api/score/route.ts
app/api/segment/[predictionId]/route.ts
app/api/segment/proxy/route.ts
app/api/segment/route.ts
app/api/segment/save/route.ts
app/api/segment/video/[videoId]/route.ts
app/api/video/[id]/route.ts
app/api/zoom-signature/route.ts
app/components/CameraPanel.tsx
app/components/CoachPanel.tsx
app/components/GenerateInput.tsx
app/components/GestureGuide.tsx
app/components/GestureToast.tsx
app/components/ModeOverlay.tsx
app/components/MoveQueue.tsx
app/components/RecordReplayPanel.tsx
app/components/report/ReportCard.tsx
app/components/report/SlideBody.tsx
app/components/report/SlideGrade.tsx
app/components/report/SlideLevelUp.tsx
app/components/report/SlideVibe.tsx
app/components/report/types.ts
app/components/ScorePopup.tsx
app/components/SummaryReadyNotification.tsx
app/components/UrlInput.tsx
app/components/YoutubePanel.tsx
app/globals.css
app/layout.tsx
app/lib/brightdata.ts
app/lib/classifyVideo.ts
app/lib/coach.ts
app/lib/frameCapture.ts
app/lib/gestureControl.ts
app/lib/grok.ts
app/lib/groqScoring.ts
app/lib/gymScoring.ts
app/lib/outlineExtractor.ts
app/lib/pose.ts
app/lib/poseComparison.ts
app/lib/poseRecorder.ts
app/lib/poseReplay.ts
app/lib/scoring.ts
app/lib/segmentation.ts
app/lib/sessionStats.ts
app/lib/speech.ts
app/lib/videoPoseExtractor.ts
app/lib/webcamRecorder.ts
app/lib/youtube.ts
app/page.tsx
app/shared/CameraPanel.tsx
app/shared/coach.ts
app/shared/CoachPanel.tsx
app/shared/compare.ts
app/shared/ComparisonPanel.tsx
app/shared/gymScoring.ts
app/shared/landmarkSmoother.ts
app/shared/mode.ts
app/shared/pose.ts
app/shared/poseManager.ts
app/shared/poseMutex.ts
app/shared/scoring.ts
app/shared/ScreenCapturePanel.tsx
app/shared/speech.ts
app/zoom/page.tsx
bun.lockb
CLAUDE.md
Dockerfile
eslint.config.mjs
extension/background.js
extension/background.ts
extension/manifest.json
next.config.ts
package.json
pnpm-workspace.yaml
postcss.config.mjs
PRD.md
public/zoom-embed.html
README.md
render.yaml
segmentation/.env.example
segmentation/.gitignore
segmentation/bun.lock
segmentation/eslint.config.js
segmentation/index.html
segmentation/modal_sam2.py
segmentation/package.json
segmentation/postcss.config.mjs
segmentation/README.md
segmentation/runpod_sam2.py
segmentation/server.js
segmentation/src/App.css
segmentation/src/App.tsx
segmentation/src/components/DeviationScore.tsx
segmentation/src/components/VideoUploader.tsx
segmentation/src/components/WebcamFeed.tsx
segmentation/src/index.css
segmentation/src/main.tsx
segmentation/src/types/pose.types.ts
segmentation/src/utils/deviationCalculator.ts
segmentation/src/utils/poseDetection.ts
segmentation/src/utils/poseNormalization.ts
segmentation/src/utils/replicateClient.ts
segmentation/tsconfig.app.json
segmentation/tsconfig.json
segmentation/tsconfig.node.json
segmentation/vite.config.ts
styles/globals.css
tsconfig.json
vitest.config.ts
zoom/README.md
zoom/sample-config.json
```

### Dependencies

- package.json: @mediapipe/camera_utils@^0.3.1675466862, @mediapipe/drawing_utils@^0.3.1675466124, @mediapipe/pose@^0.5.1675469404, @tailwindcss/postcss@^4, @types/jsonwebtoken@^9.0.10, @types/node@^20, @types/react@^19, @types/react-dom@^19, @vercel/blob@^2.2.0, @zoom/appssdk@^0.16.36, @zoom/meetingsdk@^5.1.2, eslint@^9, eslint-config-next@16.1.6, jsonwebtoken@^9.0.3, next@16.1.6, openai@^6.22.0, qrcode.react@^4.2.0, react@19.2.3, react-dom@19.2.3, replicate@^1.4.0, tailwindcss@^4, typescript@^5, vitest@^4.0.18
- segmentation/package.json: @eslint/js@^9.39.1, @mediapipe/camera_utils@^0.3.1675466862, @mediapipe/pose@^0.5.1675469404, @types/node@^24.10.1, @types/react@^19.2.7, @types/react-dom@^19.2.3, @vitejs/plugin-react@^5.1.1, cors@^2.8.6, dotenv@^17.3.1, eslint@^9.39.1, eslint-plugin-react-hooks@^7.0.1, eslint-plugin-react-refresh@^0.4.24, express@^4.22.1, globals@^16.5.0, react@^19.2.0, react-dom@^19.2.0, typescript@~5.9.3, typescript-eslint@^8.48.0, vite@^7.3.1

### Recent commits (newest first)

- readme
- Update README to simplify quick start instructions
- Add Devpost link to README
- added QR code recording
- docs: add banner
- hmm2
- hmm
- more trial
- dude, render
- try again
- fix ci
- docker harness for render blueprint
- jehdbcheb
- Merge remote-tracking branch 'origin/main'
- gym report
- Revert "remove overlay"
- flashier popups
- remove overlay
- Merge remote-tracking branch 'origin/main'
- wrapped

## Key source files (fetched from GitHub, selected and truncated for size)

### CLAUDE.md

```markdown
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

"Jiggle Wiggle" — a real-time AI dance coaching web app (TreeHacks 2026 hackathon). User pastes a YouTube URL, the video downloads server-side, plays in a custom player alongside a webcam feed with live pose detection, scoring, and AI coaching feedback.

## Commands

- **Dev server:** `npm run dev` (runs on localhost:3000)
- **Build:** `npm run build`
- **Lint:** `npm run lint` (ESLint with Next.js config)
- **No test suite currently exists**

## System Dependencies

- **yt-dlp** — required for YouTube video downloads (spawned via child_process)
- **ffmpeg** — used by yt-dlp for muxing
- **OPENAI_API_KEY** — env var required for AI coach (set in `.env.local`)

## Architecture

### Data Flow

```
YouTube URL → /api/download (SSE: progress → done → classified) → /tmp/jigglewiggle/{id}.mp4
  → classified event sets mode (gym/dance) → ModeOverlay flash (3s)
  → /api/video/[id] (serves MP4 with range requests)
  → Client: pose extraction (hidden video+canvas, MediaPipe, 10fps)
  → PoseTimeline displayed as MoveQueue strip

Webcam → MediaPipe Pose (CDN-loaded, on-device) → skeleton overlay + scoring
  → buildPoseSummary() → /api/coach (OpenAI) → text + OpenAI TTS audio
```

### Key Files

- **`app/page.tsx`** — Main orchestrator. Owns all top-level state (download, extraction, scoring, coaching). Coordinates the full pipeline.
- **`app/lib/pose.ts`** — MediaPipe Pose setup via CDN (`@mediapipe/pose@0.5.1675469404`). Exports `loadPose()`, `drawSkeleton()` (parameterized with `SkeletonStyle`), and pose connection/landmark types.
- **`app/lib/videoPoseExtractor.ts`** — Client-side pipeline: creates hidden video+canvas, seeks through frames at 0.1s intervals, runs MediaPipe on each, returns `PoseTimeline`.
- **`app/lib/scoring.ts`** — Choreography-agnostic scoring: movement energy (keypoint velocity), form heuristics (arm height, torso angle, symmetry). Exports `computeScore()` and `buildPoseSummary()`.
- **`app/lib/coach.ts`** — Maintains conversation history (last 6 exchanges), throttles to one call per 3s. Sends pose summaries to `/api/coach`.
- **`app/components/ModeOverlay.tsx`** — Full-screen flashy overlay announcing the detected mode ("BEAST MODE" / "LET'S GROOVE"). Triggered by a `seq` counter prop. Uses layered CSS animations: expanding ring bursts, horizontal streaks, diagonal flashes, slam-in text, and expanding-letter-spacing subtitle. Auto-hides after 3s, `pointer-events: none`.
- **`app/api/download/route.ts`** — POST endpoint. Spawns yt-dlp, parses stdout for progress, streams SSE events (`progress`, `done`, `classified`, `error`). Caches to `/tmp/jigglewiggle/`.
- **`app/api/video/[id]/route.ts`** — GET endpoint. Serves MP4 with HTTP range request support. Uses `cancelled` flag pattern to prevent ERR_INVALID_STATE on stream cancellation.
- **`app/api/coach/route.ts`** — POST endpoint. OpenAI chat complet
[truncated — 1719 more characters]
```

### PRD.md

```markdown
# PRD — AI Just Dance Trainer

**Mode B: Direct Download YouTube Link → File → Training UI**

> A Chrome extension where the user pastes a YouTube link, the product downloads the video, loads it into our own player UI (left), and runs an AI dance coach using the webcam (right) with pose overlay, stars/progress, and audio feedback.

**Non-functional constraint:** Downloading YouTube videos programmatically can raise ToS/legal issues. For this hackathon build, treat this as a demo pipeline with ephemeral storage (short TTL), no public redistribution, and clear user-facing disclosure.

---

## 1. Goals

### Product goals

- User action is only: paste YouTube URL.
- Video is playable in our own `<video>` player (not YouTube embed).
- Real-time coaching that is fun, responsive, and actionable.
- End summary + replay hardest part.

### Hackathon goals

- End-to-end stable demo in 36–48 hours.
- One "hero" video works reliably.
- Clear UI that looks like a real product.

---

## 2. Non-goals (MVP)

- Supporting every YouTube edge case (age-gated, region-locked, private, live streams).
- High-precision choreo matching against the dancer in the video (stretch).
- Permanent hosting or sharing of downloaded content.

---

## 3. Target users & top use cases

- **Learners** practicing choreography from dance tutorials/choreo videos.
- **Creators** rehearsing and wanting structured feedback + replay loops.

---

## 4. User experience & flows

### 4.1 Entry flow

1. User opens extension popup.
2. Paste YouTube URL → click Import.
3. Show progress states:
   - "Fetching video info…"
   - "Downloading… (xx%)"
   - "Processing for playback…"
   - "Ready"
4. Trainer UI opens (new tab or extension page) with imported video loaded.

### 4.2 Training flow

- **Left:** custom video player with timeline, speed control, loop markers.
- **Right:** webcam with pose overlay, score/stars, coach callouts (text + audio).
- **Controls:** Start / Pause / Resume / Stop.
- **A/B loop:** set loop markers to repeat segments.

### 4.3 End-of-session summary

- Total stars, longest streak.
- 2 strengths, 2 improvement tips.
- "Replay hardest part" button sets A/B loop and seeks.

---

## 5. Functional requirements

### FR1 — YouTube direct download import (core)

**Input:** YouTube URL (watch / youtu.be)

**Output:** A session playback URL pointing to a downloaded/transcoded MP4 file. Video is playable via HTML5 `<video>` element.

**Requirements — backend must:**

- Resolve video metadata (title, duration, thumbnail optional)
- Fetch a playable stream
- Download to ephemeral storage
- Transcode/remux to a browser-friendly format (H.264/AAC in MP4 preferred)
- Serve the resulting file via HTTPS with range requests

**Acceptance criteria:**

- Paste URL → playable video in trainer within <= 60s (hackathon acceptable).
- Seek works (range requests supported).
- Playback speed control works.
- At least 1 "hero" video imports reliably from start to finish.

**Error states (must implement)
[truncated — 7423 more characters]
```

### package.json

```
{
  "name": "jigglewiggle",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint",
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "dependencies": {
    "@mediapipe/camera_utils": "^0.3.1675466862",
    "@mediapipe/drawing_utils": "^0.3.1675466124",
    "@mediapipe/pose": "^0.5.1675469404",
    "@vercel/blob": "^2.2.0",
    "@zoom/appssdk": "^0.16.36",
    "@zoom/meetingsdk": "^5.1.2",
    "jsonwebtoken": "^9.0.3",
    "next": "16.1.6",
    "openai": "^6.22.0",
    "qrcode.react": "^4.2.0",
    "react": "19.2.3",
    "react-dom": "19.2.3",
    "replicate": "^1.4.0"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/jsonwebtoken": "^9.0.10",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.1.6",
    "tailwindcss": "^4",
    "typescript": "^5",
    "vitest": "^4.0.18"
  }
}

```

### Dockerfile

```
FROM node:20-slim AS base

# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    ffmpeg \
    python3 \
    python3-pip \
    pipx \
    ca-certificates \
    && pipx install yt-dlp \
    && apt-get purge -y python3-pip pipx \
    && apt-get autoremove -y \
    && rm -rf /var/lib/apt/lists/*

ENV PATH="/root/.local/bin:$PATH"

# --- Build stage ---
FROM base AS builder

WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci
RUN npm install --no-save lightningcss-linux-x64-gnu@1.30.2 @tailwindcss/oxide-linux-x64-gnu@4.1.18

COPY . .
RUN npm run build

# --- Production stage ---
FROM base AS runner

WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

# Copy standalone build output
COPY --from=builder /app/.next/standalone ./
# Copy static assets and public files (not included in standalone)
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

# Create video storage directory
RUN mkdir -p /tmp/jigglewiggle

EXPOSE 3000

CMD ["node", "server.js"]

```

### segmentation/package.json

```
{
  "name": "segmentation",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "server": "node server.js",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "@mediapipe/camera_utils": "^0.3.1675466862",
    "@mediapipe/pose": "^0.5.1675469404",
    "cors": "^2.8.6",
    "dotenv": "^17.3.1",
    "express": "^4.22.1",
    "react": "^19.2.0",
    "react-dom": "^19.2.0"
  },
  "devDependencies": {
    "@eslint/js": "^9.39.1",
    "@types/node": "^24.10.1",
    "@types/react": "^19.2.7",
    "@types/react-dom": "^19.2.3",
    "@vitejs/plugin-react": "^5.1.1",
    "eslint": "^9.39.1",
    "eslint-plugin-react-hooks": "^7.0.1",
    "eslint-plugin-react-refresh": "^0.4.24",
    "globals": "^16.5.0",
    "typescript": "~5.9.3",
    "typescript-eslint": "^8.48.0",
    "vite": "^7.3.1"
  }
}

```

### app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Audiowide, Chakra_Petch } from "next/font/google";
import "./globals.css";

const audiowide = Audiowide({
  weight: "400",
  variable: "--font-audiowide",
  subsets: ["latin"],
});

const chakraPetch = Chakra_Petch({
  weight: ["300", "400", "500", "600", "700"],
  variable: "--font-chakra-petch",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Jiggle Wiggle",
  description: "Learn dance moves from YouTube videos with real-time AI coaching",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${audiowide.variable} ${chakraPetch.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
}

```

### segmentation/server.js

```javascript
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';

dotenv.config();

const app = express();
const port = 3001;

// Modal endpoint URL - set after deploying with `modal deploy modal_sam2.py`
const MODAL_ENDPOINT_URL = process.env.MODAL_ENDPOINT_URL;

app.use(cors({
  origin: '*',
  credentials: true,
  methods: ['GET', 'POST', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json({ limit: '50mb' }));

app.get('/health', (_req, res) => {
  res.json({ status: 'ok', modal: !!MODAL_ENDPOINT_URL });
});

// Single synchronous endpoint — no more polling
app.post('/api/segment-video', async (req, res) => {
  try {
    const { videoDataUrl } = req.body;

    if (!videoDataUrl) {
      return res.status(400).json({ error: 'No video data provided' });
    }

    if (!MODAL_ENDPOINT_URL) {
      return res.status(500).json({ error: 'MODAL_ENDPOINT_URL not configured' });
    }

    console.log('Starting video segmentation via Modal...');
    const startTime = Date.now();

    const response = await fetch(MODAL_ENDPOINT_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ video_base64: videoDataUrl }),
    });

    if (!response.ok) {
      const errorText = await response.text();
      throw new Error(`Modal returned ${response.status}: ${errorText}`);
    }

    const result = await response.json();
    const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
    console.log(`Segmentation complete in ${elapsed}s (${result.num_frames} frames)`);

    if (result.error) {
      throw new Error(result.error);
    }

    // Return the mask video as a data URL so frontend can use it directly
    const maskDataUrl = `data:video/mp4;base64,${result.mask_video_base64}`;
    res.json({ maskVideoUrl: maskDataUrl });
  } catch (error) {
    console.error('Error in segmentation:', error);
    res.status(500).json({ error: error.message });
  }
});

app.listen(port, () => {
  console.log(`Backend server running at http://localhost:${port}`);
  console.log(`Modal endpoint configured: ${!!MODAL_ENDPOINT_URL}`);
});

```

### segmentation/src/main.tsx

```typescript
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

```

### segmentation/src/App.tsx

```typescript
import { useState } from 'react';
import { VideoUploader } from './components/VideoUploader';
import { WebcamFeed } from './components/WebcamFeed';
import './App.css';

function App() {
  const [, setOriginalVideoUrl] = useState<string | null>(null);
  const [segmentedVideoUrl, setSegmentedVideoUrl] = useState<string | null>(null);

  const handleVideoProcessed = (
    originalUrl: string,
    segmentedUrl: string
  ) => {
    setOriginalVideoUrl(originalUrl);
    setSegmentedVideoUrl(segmentedUrl);
  };

  return (
    <div style={styles.app}>
      <header style={styles.header}>
        <h1 style={styles.appTitle}>JiggleWiggle</h1>
      </header>

      <main style={styles.main}>
        <section style={styles.section}>
          <VideoUploader onVideoProcessed={handleVideoProcessed} />
        </section>

        {segmentedVideoUrl && (
          <section style={styles.section}>
            <WebcamFeed segmentedVideoUrl={segmentedVideoUrl} />
          </section>
        )}
      </main>
    </div>
  );
}

const styles: Record<string, React.CSSProperties> = {
  app: {
    minHeight: '100vh',
    backgroundColor: '#0a0a0a',
    color: '#ffffff',
  },
  header: {
    padding: '30px 20px',
    textAlign: 'center',
    backgroundColor: '#141414',
    borderBottom: '2px solid #333333',
  },
  appTitle: {
    fontSize: '48px',
    fontWeight: 'bold',
    margin: 0,
    background: 'linear-gradient(135deg, #00ff88 0%, #0088ff 100%)',
    WebkitBackgroundClip: 'text',
    WebkitTextFillColor: 'transparent',
    backgroundClip: 'text',
  },
  main: {
    maxWidth: '1400px',
    margin: '0 auto',
    padding: '20px',
  },
  section: {
    marginBottom: '30px',
  },
};

export default App;

```

### app/zoom/page.tsx

```typescript
"use client";

import { useState, useCallback, useRef, useEffect } from "react";
import CameraPanel from "../shared/CameraPanel";
import ComparisonPanel from "../shared/ComparisonPanel";
import ScreenCapturePanel from "../shared/ScreenCapturePanel";
import { comparePosesDetailed } from "../lib/poseComparison";
import { computeScore, buildPoseSummary } from "../shared/scoring";
import { getCoachMessage } from "../shared/coach";
import { speak } from "../shared/speech";
import type { NormalizedLandmark } from "../shared/pose";
import type { ComparisonResult } from "../shared/compare";

type Mode = "choose" | "zoom-sdk" | "screen-capture";
type ZoomState = "idle" | "ready" | "joining" | "joined" | "error";

// Scoring constants (same as YouTube page)
const SCORE_EMA_ALPHA = 0.15;
const SCORE_DEAD_ZONE = 2;

const LIMB_LABELS: Record<string, string> = {
  rightArm: "Right arm",
  leftArm: "Left arm",
  rightLeg: "Right leg",
  leftLeg: "Left leg",
  torso: "Torso",
};

export default function ZoomApp() {
  const [mode, setMode] = useState<Mode>("choose");
  const [comparison, setComparison] = useState<ComparisonResult | null>(null);
  const [coachMsg, setCoachMsg] = useState("");
  const [score, setScore] = useState(0);

  // Zoom SDK state
  const [meetingNumber, setMeetingNumber] = useState("");
  const [passcode, setPasscode] = useState("");
  const [userName, setUserName] = useState("JiggleWiggle");
  const [zoomState, setZoomState] = useState<ZoomState>("idle");
  const [zoomError, setZoomError] = useState("");
  const iframeRef = useRef<HTMLIFrameElement>(null);

  // Track both poses and their source video aspect ratios
  const remotePoseRef = useRef<NormalizedLandmark[] | null>(null);
  const selfPoseRef = useRef<NormalizedLandmark[] | null>(null);
  const remoteAspectRef = useRef(16 / 9); // Zoom is typically 16:9
  const selfAspectRef = useRef(4 / 3);    // Webcam is typically 4:3
  const smoothedScoreRef = useRef(0);

  // Core comparison logic — runs on every frame from either source
  const runComparison = useCallback(() => {
    const remoteLm = remotePoseRef.current;
    const selfLm = selfPoseRef.current;

    // Need both poses with enough landmarks
    if (!remoteLm || !selfLm || remoteLm.length < 33 || selfLm.length < 33) {
      // If we have self pose only, show basic heuristic score
      if (selfLm && selfLm.length >= 33) {
        const frame = computeScore(selfLm);
        setScore(frame.score);
      }
      setComparison({
        similarity: 0,
        parts: { arms: 0, legs: 0, torso: 0 },
        feedback: remoteLm ? ["Step into frame!"] : ["Waiting for Zoom feed…"],
      });
      return;
    }

    // 1. Detailed geometric comparison (per-limb, normalized with aspect ratio correction)
    const detailed = comparePosesDetailed(
      remoteLm,
      selfLm,
      remoteAspectRef.current,
      selfAspectRef.current
    );
    if (!detailed) {
      setComparison({
        similarity: 0,
        parts: { arms: 0, legs: 0, torso: 0 },
        feedback: ["Can't compare poses clearly"],
      });
      return;
    }

    // 2. Heuristic score from self pose (for body metrics)
    const frame = computeScore(selfLm);

    // 3. Blend: mostly geometric match, with some heuristic
    const geoScore = detailed.matchScore;
    const blended = Math.round(0.8 * geoScore + 0.2 * frame.score);
    const finalScore = Math.max(0, Math.min(100, blended));

    // 4. EMA smoothing
    const smoothed =
      smoothedScoreRef.current * (1 - SCORE_EMA_ALPHA) +
      finalScore * SCORE_EMA_ALPHA;
    smoothedScoreRef.current = smoothed;
    const rounded = Math.round(smoothed);
    if (Math.abs(rounded - score) >= SCORE_DEAD_ZONE) {
      setScore(rounded);
    }

    // 5. Build per-part breakdown for ComparisonPanel
    const arms = Math.round(
      ((detailed.limbScores.leftArm ?? 50) + (detailed.limbScores.rightArm ?? 50)) / 2
    );
    const legs = Math.round(
      ((detailed.limbScores.leftLeg ?? 50) + (detailed.limbScores.rightLeg ?? 50)) / 2
    );
    const torso = detailed.limbScores.torso ?? 50;

    // 6. Generate feedback
    const feedback: string[] = [];
    const worstLabel = LIMB_LABELS[detailed.worstLimb] ?? detailed.worstLimb;
    if (detailed.limbScores[detailed.worstLimb] < 60) {
      feedback.push(`Fix your ${worstLabel.toLowerCase()}!`);
    }
    if (rounded >= 80) {
      feedback.push("Great match — keep it locked in!");
    } else if (rounded >= 60) {
      feedback.push("Getting close — tighten it up!");
    } else if (rounded >= 40) {
      feedback.push("Mirror their moves!");
    }
    if (detailed.refPoseLabel && detailed.refPoseLabel !== "Neutral") {
      feedback.push(`They're doing: ${detailed.refPoseLabel}`);
    }
    if (feedback.length === 0) feedback.push("Try to match the dancer!");

    setComparison({
      similarity: rounded,
      parts: { arms, legs, torso },
      feedback,
    });

    // 7. Feed into LLM coach with full context
    const issues = [...frame.issues];
    if (detailed.limbScores[detailed.worstLimb] < 60) {
      issues.unshift(`${worstLabel} off from reference`);
    }

    const summary = buildPoseSummary(selfLm, {
      ...frame,
      score: rounded,
      issues,
    });
    // Attach reference comparison data for richer LLM coaching
    (summary as Record<string, unknown>).reference = detailed;

    getCoachMessage(summary).then((result) => {
      if (result) {
        setCoachMsg(result.message);
        if (result.audio) speak(result.audio);
      }
    });
  }, [score]);

  // Remote pose handler (dancer in Zoom)
  const handleRemotePose = useCallback(
    (landmarks: NormalizedLandmark[] | null) => {
      remotePoseRef.current = landmarks;
      runComparison();
    },
    [runComparison]
  );

  // Self pose handler (your webcam)
  const handleSelfPose = useCallback(
    (landmarks: NormalizedLandmark[] | null) => {
      selfPoseRef.current = landmarks;
      runComparison();
    },
    [runComparison
[truncated — 10193 more characters]
```

[91 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]