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
parent
b8134b4fbe
commit
eb98af56d3
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue