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) <noreply@anthropic.com>
main
name 2026-04-12 09:17:19 -07:00
parent b8134b4fbe
commit eb98af56d3
6 changed files with 361 additions and 0 deletions

View File

@ -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() {
<Route path={ROUTE_PATHS.leaderboard} element={<LeaderboardPage />} />
<Route path={ROUTE_PATHS.credits} element={<CreditsPage />} />
<Route path={ROUTE_PATHS.record} element={<RecordPage />} />
<Route path="/recording-test" element={<RecordingTestPage />} />
<Route path="/test/tutorial" element={<TutorialTestPage />} />
<Route path="*" element={<Navigate to={ROUTE_PATHS.intro} replace />} />
</Route>

View File

@ -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;
}

View File

@ -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<HTMLCanvasElement>(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<HTMLInputElement>) {
const idx = Number(e.target.value)
setFrameIndex(idx)
setPlaying(false)
}
if (frames.length === 0) {
return <div className={styles.empty}>No frames loaded</div>
}
return (
<div className={styles.player}>
<canvas
ref={canvasRef}
width={width}
height={height}
className={styles.canvas}
/>
<div className={styles.controls}>
<button className={styles.playBtn} onClick={togglePlay}>
{playing ? 'Pause' : 'Play'}
</button>
<input
type="range"
className={styles.scrubber}
min={0}
max={frames.length - 1}
value={frameIndex}
onChange={handleScrub}
/>
<span className={styles.time}>
{(frames[frameIndex].t / 1000).toFixed(1)}s / {(totalDuration / 1000).toFixed(1)}s
</span>
<span className={styles.frameInfo}>
{frameIndex + 1}/{frames.length}
</span>
</div>
</div>
)
}

View File

@ -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() {
)}
</p>
<div className={styles.resultActions}>
<button className={styles.actionBtn} onClick={downloadJson}>
Download JSON
</button>
<button className={styles.actionBtn} onClick={copyJson}>
{copied ? 'Copied!' : 'Copy JSON'}
</button>

View File

@ -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;
}

View File

@ -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<RecordedFrame[]>([])
const [error, setError] = useState<string | null>(null)
const [fileName, setFileName] = useState<string | null>(null)
const handleFile = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className={styles.root}>
<h1 className={styles.title}>Recording Playback</h1>
<label className={styles.fileLabel}>
<span className={styles.fileLabelText}>
{fileName ?? 'Load recording JSON'}
</span>
<input
type="file"
accept=".json"
onChange={handleFile}
className={styles.fileInput}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
{frames.length > 0 && (
<div className={styles.playerWrap}>
<RecordingPlayer frames={frames} width={640} height={480} />
</div>
)}
</div>
)
}