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 && (
+
+
+
+ )}
+
+ )
+}