From eb98af56d33a90287e72e7038cb380bf4151754b Mon Sep 17 00:00:00 2001 From: name Date: Sun, 12 Apr 2026 09:17:19 -0700 Subject: [PATCH] add RecordingPlayer component and /recording-test page RecordingPlayer draws 2D skeleton from recorded frames with play/pause and scrub controls. Test page at /recording-test loads JSON via file picker. Also added download button to /record results. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/AppRoutes.tsx | 2 + src/components/RecordingPlayer.module.css | 64 +++++++++ src/components/RecordingPlayer.tsx | 168 ++++++++++++++++++++++ src/pages/RecordPage.tsx | 14 ++ src/pages/RecordingTestPage.module.css | 51 +++++++ src/pages/RecordingTestPage.tsx | 62 ++++++++ 6 files changed, 361 insertions(+) create mode 100644 src/components/RecordingPlayer.module.css create mode 100644 src/components/RecordingPlayer.tsx create mode 100644 src/pages/RecordingTestPage.module.css create mode 100644 src/pages/RecordingTestPage.tsx diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index ba8932b..5ed0069 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -12,6 +12,7 @@ const LeaderboardPage = lazy(() => import('../pages/LeaderboardPage').then(m => const CreditsPage = lazy(() => import('../pages/CreditsPage').then(m => ({ default: m.CreditsPage }))) const TutorialTestPage = lazy(() => import('../pages/TutorialTestPage').then(m => ({ default: m.TutorialTestPage }))) const RecordPage = lazy(() => import('../pages/RecordPage').then(m => ({ default: m.RecordPage }))) +const RecordingTestPage = lazy(() => import('../pages/RecordingTestPage').then(m => ({ default: m.RecordingTestPage }))) export function AppRoutes() { return ( @@ -26,6 +27,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/src/components/RecordingPlayer.module.css b/src/components/RecordingPlayer.module.css new file mode 100644 index 0000000..20e850e --- /dev/null +++ b/src/components/RecordingPlayer.module.css @@ -0,0 +1,64 @@ +.player { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; +} + +.canvas { + border-radius: 8px; + max-width: 100%; + height: auto; +} + +.controls { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + max-width: 640px; +} + +.playBtn { + padding: 8px 20px; + border: 2px solid #d946ef; + border-radius: 6px; + background: rgba(217, 70, 239, 0.2); + color: #fff; + font-size: 14px; + font-weight: 700; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; +} + +.playBtn:hover { + background: rgba(217, 70, 239, 0.4); +} + +.scrubber { + flex: 1; + accent-color: #d946ef; + cursor: pointer; +} + +.time { + font-size: 13px; + color: rgba(255, 255, 255, 0.6); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.frameInfo { + font-size: 13px; + color: rgba(255, 255, 255, 0.4); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.empty { + padding: 40px; + text-align: center; + color: rgba(255, 255, 255, 0.4); + font-size: 18px; +} diff --git a/src/components/RecordingPlayer.tsx b/src/components/RecordingPlayer.tsx new file mode 100644 index 0000000..34d63e0 --- /dev/null +++ b/src/components/RecordingPlayer.tsx @@ -0,0 +1,168 @@ +import { useEffect, useRef, useState, useCallback } from 'react' +import type { Landmark2D, Landmark3D } from '../gesture/types' +import styles from './RecordingPlayer.module.css' + +interface RecordedFrame { + t: number + landmarks: Landmark2D[] + worldLandmarks: Landmark3D[] +} + +// MediaPipe pose connections (subset: major body skeleton) +const POSE_CONNECTIONS: [number, number][] = [ + [0, 1], [1, 2], [2, 3], [3, 7], + [0, 4], [4, 5], [5, 6], [6, 8], + [9, 10], + [11, 12], + [11, 13], [13, 15], [15, 17], [15, 19], [15, 21], [17, 19], + [12, 14], [14, 16], [16, 18], [16, 20], [16, 22], [18, 20], + [11, 23], [12, 24], [23, 24], + [23, 25], [24, 26], [25, 27], [26, 28], + [27, 29], [28, 30], [29, 31], [30, 32], + [27, 31], [28, 32], +] + +interface RecordingPlayerProps { + frames: RecordedFrame[] + width?: number + height?: number +} + +export function RecordingPlayer({ frames, width = 640, height = 480 }: RecordingPlayerProps) { + const canvasRef = useRef(null) + const [playing, setPlaying] = useState(false) + const [frameIndex, setFrameIndex] = useState(0) + const playingRef = useRef(false) + const frameIndexRef = useRef(0) + + const totalDuration = frames.length > 0 ? frames[frames.length - 1].t : 0 + + const drawFrame = useCallback((frame: RecordedFrame) => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d')! + ctx.clearRect(0, 0, width, height) + + // Background + ctx.fillStyle = '#111' + ctx.fillRect(0, 0, width, height) + + const lm = frame.landmarks + if (!lm.length) return + + // Draw connections + ctx.strokeStyle = '#00FFFF' + ctx.lineWidth = 2 + for (const [a, b] of POSE_CONNECTIONS) { + if (a >= lm.length || b >= lm.length) continue + const la = lm[a], lb = lm[b] + if ((la.visibility ?? 1) < 0.3 || (lb.visibility ?? 1) < 0.3) continue + ctx.beginPath() + ctx.moveTo(la.x * width, la.y * height) + ctx.lineTo(lb.x * width, lb.y * height) + ctx.stroke() + } + + // Draw landmarks + for (let i = 0; i < lm.length; i++) { + const l = lm[i] + if ((l.visibility ?? 1) < 0.3) continue + ctx.beginPath() + ctx.arc(l.x * width, l.y * height, 4, 0, Math.PI * 2) + ctx.fillStyle = '#00FF00' + ctx.fill() + } + }, [width, height]) + + // Draw current frame when index changes + useEffect(() => { + if (frames.length === 0) return + drawFrame(frames[frameIndex]) + }, [frameIndex, frames, drawFrame]) + + // Playback loop + useEffect(() => { + if (!playing || frames.length === 0) return + playingRef.current = true + frameIndexRef.current = frameIndex + + const startTime = performance.now() + const startT = frames[frameIndexRef.current].t + + let rafId: number + function tick() { + if (!playingRef.current) return + const elapsed = performance.now() - startTime + const targetT = startT + elapsed + + // Advance frame index + let idx = frameIndexRef.current + while (idx < frames.length - 1 && frames[idx + 1].t <= targetT) { + idx++ + } + frameIndexRef.current = idx + setFrameIndex(idx) + + if (idx >= frames.length - 1) { + setPlaying(false) + return + } + rafId = requestAnimationFrame(tick) + } + + rafId = requestAnimationFrame(tick) + return () => { + playingRef.current = false + cancelAnimationFrame(rafId) + } + }, [playing, frames]) // eslint-disable-line react-hooks/exhaustive-deps + + function togglePlay() { + if (playing) { + setPlaying(false) + } else { + if (frameIndex >= frames.length - 1) setFrameIndex(0) + setPlaying(true) + } + } + + function handleScrub(e: React.ChangeEvent) { + const idx = Number(e.target.value) + setFrameIndex(idx) + setPlaying(false) + } + + if (frames.length === 0) { + return
No frames loaded
+ } + + return ( +
+ +
+ + + + {(frames[frameIndex].t / 1000).toFixed(1)}s / {(totalDuration / 1000).toFixed(1)}s + + + {frameIndex + 1}/{frames.length} + +
+
+ ) +} diff --git a/src/pages/RecordPage.tsx b/src/pages/RecordPage.tsx index 35e55fb..9a37609 100644 --- a/src/pages/RecordPage.tsx +++ b/src/pages/RecordPage.tsx @@ -87,6 +87,17 @@ export function RecordPage() { }) } + function downloadJson() { + const json = JSON.stringify(framesRef.current) + const blob = new Blob([json], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `recording-${Date.now()}.json` + a.click() + URL.revokeObjectURL(url) + } + function recordAgain() { framesRef.current = [] setFrameCount(0) @@ -163,6 +174,9 @@ export function RecordPage() { )}

+ diff --git a/src/pages/RecordingTestPage.module.css b/src/pages/RecordingTestPage.module.css new file mode 100644 index 0000000..f75b422 --- /dev/null +++ b/src/pages/RecordingTestPage.module.css @@ -0,0 +1,51 @@ +.root { + min-height: 100vh; + background: #0a0a0a; + display: flex; + flex-direction: column; + align-items: center; + padding: 40px 20px; + gap: 24px; +} + +.title { + font-size: 32px; + color: #d946ef; + margin: 0; + text-shadow: 0 0 30px rgba(217, 70, 239, 0.5); +} + +.fileLabel { + display: inline-block; + padding: 14px 32px; + border: 2px dashed rgba(217, 70, 239, 0.5); + border-radius: 8px; + background: rgba(217, 70, 239, 0.1); + color: #fff; + font-size: 16px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.fileLabel:hover { + background: rgba(217, 70, 239, 0.2); + border-color: #d946ef; +} + +.fileLabelText { + pointer-events: none; +} + +.fileInput { + display: none; +} + +.error { + color: #ef4444; + font-size: 14px; + margin: 0; +} + +.playerWrap { + margin-top: 8px; +} diff --git a/src/pages/RecordingTestPage.tsx b/src/pages/RecordingTestPage.tsx new file mode 100644 index 0000000..6932362 --- /dev/null +++ b/src/pages/RecordingTestPage.tsx @@ -0,0 +1,62 @@ +import { useCallback, useState } from 'react' +import { RecordingPlayer } from '../components/RecordingPlayer' +import styles from './RecordingTestPage.module.css' + +interface RecordedFrame { + t: number + landmarks: { x: number; y: number; visibility?: number }[] + worldLandmarks: { x: number; y: number; z: number; visibility?: number }[] +} + +export function RecordingTestPage() { + const [frames, setFrames] = useState([]) + const [error, setError] = useState(null) + const [fileName, setFileName] = useState(null) + + const handleFile = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + setError(null) + setFileName(file.name) + const reader = new FileReader() + reader.onload = () => { + try { + const data = JSON.parse(reader.result as string) + if (!Array.isArray(data) || data.length === 0) { + setError('JSON must be an array of frames') + return + } + setFrames(data) + } catch { + setError('Invalid JSON') + } + } + reader.readAsText(file) + }, []) + + return ( +
+

Recording Playback

+ + + + {error &&

{error}

} + + {frames.length > 0 && ( +
+ +
+ )} +
+ ) +}