// academy.jsx — Motion Academy Lite (multi-step flow + edge states) const { useState: useStateA, useEffect: useEffectA, useRef: useRefA } = React; function Academy({ exit, initial, data, spendEnergy, onReward }) { const [step, setStep] = useStateA(initial || 'home'); const [wk, setWk] = useStateA(null); const [lensReport, setLensReport] = useStateA(null); const go = (s, payload) => { if (payload !== undefined) setLensReport(payload); setStep(s); }; const openWorkout = (w) => { setWk(w); go('wkIntro'); }; const map = { home: , tutorial: , permission: , denied: , setup: , lens: , failed: , feedback: , unlock: , wkIntro: , wkPlay: , wkDone: , }; return map[step]; } // learned exercises you can train as sets (with camera form-check) const WORKOUTS = [ { id: 'squat', name: '스쿼트', cat: 'strength', icon: 'dumbbell', reps: 10, sets: 3, sec: 4, xp: 30, cue: '무릎은 발끝 방향으로', steps: ['발은 어깨너비', '천천히 앉기', '일어나며 숨 내쉬기'] }, { id: 'shoulder', name: '어깨 리셋', cat: 'mobility', icon: 'wind', reps: 8, sets: 2, sec: 3, xp: 20, cue: '어깨를 크게 돌려요', steps: ['어깨를 귀 쪽으로', '뒤로 크게 돌리기', '숨 길게 내쉬기'] }, { id: 'hinge', name: '힙 힌지', cat: 'strength', icon: 'dumbbell', reps: 10, sets: 3, sec: 4, xp: 30, cue: '엉덩이를 뒤로', steps: ['무릎 살짝 굽히기', '엉덩이 뒤로 밀기', '등은 곧게'] }, { id: 'breath', name: '회복 호흡', cat: 'recovery', icon: 'leaf', reps: 6, sets: 1, sec: 6, xp: 14, cue: '4초 들이마시고 6초 내쉬기', steps: ['편하게 앉기', '4초 들이마시기', '6초 내쉬기'] }, ]; // Real browser camera preview + lightweight on-device frame metrics. // No image leaves the browser; only brightness/contrast/motion numbers are exposed. function ensureCameraRuntime() { if (window.FitbodyCamera) return window.FitbodyCamera; const runtime = { current: null, last: null, tracking: { start: null, peakMotion: 0, frames: 0, bestBox: null }, attach(session) { this.current = session; }, detach(session) { if (this.current === session) this.current = null; }, latest() { return this.last; }, captureFrame(kind) { if (!this.current || !this.current.capture) return { ok: false, kind, status: 'no_camera', capturedAt: new Date().toISOString() }; return this.current.capture(kind); }, startTracking() { this.tracking = { start: Date.now(), peakMotion: 0, frames: 0, bestBox: null }; }, updateTracking(frame) { if (!frame || !frame.ok || !this.tracking.start) return; this.tracking.frames += 1; this.tracking.peakMotion = Math.max(this.tracking.peakMotion, frame.motion || 0); if (frame.movementBox && (!this.tracking.bestBox || frame.movementBox.active > this.tracking.bestBox.active)) this.tracking.bestBox = frame.movementBox; }, finishTracking() { const t = this.tracking; const report = { durationMs: t.start ? Date.now() - t.start : 0, peakMotion: t.peakMotion, frames: t.frames, movementBox: t.bestBox, ok: t.frames > 0 }; this.tracking = { start: null, peakMotion: 0, frames: 0, bestBox: null }; return report; }, motionScore() { return this.last && typeof this.last.motion === 'number' ? this.last.motion : 0; }, }; window.FitbodyCamera = runtime; return runtime; } function hasMeaningfulCameraMovement(report) { if (!report || !report.ok) return false; const box = report.movementBox; return report.peakMotion > 2 || !!(box && box.active > 8); } function cameraBrightnessLabel(frame) { if (!frame || !frame.ok) return '확인 필요'; if (frame.brightness < 45) return '조금 어두움'; if (frame.brightness > 215) return '너무 밝음'; return '좋음'; } function cameraStabilityLabel(frame) { if (!frame || !frame.ok) return '확인 필요'; if (frame.motion > 18) return '움직임 많음'; if (frame.motion > 6) return '움직임 감지'; return '안정적'; } function cameraContrastLabel(frame) { if (!frame || !frame.ok) return '확인 필요'; if (frame.contrast < 18) return '배경과 구분 낮음'; return '구분 가능'; } function CameraView({ label = 'CAMERA PREVIEW', guide = true, tint, mirror = true, onFrame }) { const videoRef = React.useRef(null); const canvasRef = React.useRef(null); const prevGray = React.useRef(null); const sessionRef = React.useRef(null); const [status, setStatus] = React.useState('starting'); const [metrics, setMetrics] = React.useState(null); const [error, setError] = React.useState(''); React.useEffect(() => { const runtime = ensureCameraRuntime(); let alive = true; let stream = null; let timer = null; function stopStream(s) { if (!s) return; s.getTracks().forEach(track => track.stop()); } function analyze(kind) { const video = videoRef.current; const canvas = canvasRef.current; if (!video || !canvas || video.readyState < 2 || !video.videoWidth || !video.videoHeight) { return { ok: false, kind, status, capturedAt: new Date().toISOString() }; } const w = 64, h = 64; canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d', { willReadFrequently: true }); if (!ctx) return { ok: false, kind, status: 'no_canvas', capturedAt: new Date().toISOString() }; ctx.drawImage(video, 0, 0, w, h); const data = ctx.getImageData(0, 0, w, h).data; let sum = 0, sumSq = 0, motion = 0; let minX = w, minY = h, maxX = 0, maxY = 0, active = 0; const gray = new Uint8Array(w * h); for (let i = 0, p = 0; i < data.length; i += 4, p++) { const y = Math.round(data[i] * 0.2126 + data[i + 1] * 0.7152 + data[i + 2] * 0.0722); gray[p] = y; sum += y; sumSq += y * y; if (prevGray.current) { const diff = Math.abs(y - prevGray.current[p]); motion += diff; if (diff > 22) { const x = p % w, yy = Math.floor(p / w); minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, yy); maxY = Math.max(maxY, yy); active++; } } } const count = gray.length; const brightness = sum / count; const variance = Math.max(0, (sumSq / count) - brightness * brightness); const contrast = Math.sqrt(variance); const motionScore = prevGray.current ? motion / count : 0; prevGray.current = gray; const movementBox = active > 8 ? { x: Math.round((minX / w) * 100), y: Math.round((minY / h) * 100), width: Math.round(((maxX - minX + 1) / w) * 100), height: Math.round(((maxY - minY + 1) / h) * 100), active, } : null; const frame = { ok: true, kind, status: 'live', brightness: Math.round(brightness), contrast: Math.round(contrast), motion: Math.round(motionScore), movementBox, width: video.videoWidth, height: video.videoHeight, capturedAt: new Date().toISOString(), }; runtime.last = frame; return frame; } const session = { capture: (kind = 'capture') => analyze(kind) }; sessionRef.current = session; runtime.attach(session); async function startCamera() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus('unsupported'); setError('camera API unsupported'); return; } try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 720 }, height: { ideal: 1280 } }, audio: false, }); if (!alive) { stopStream(stream); return; } const video = videoRef.current; if (video) { video.srcObject = stream; await video.play().catch(() => {}); } setStatus('live'); timer = setInterval(() => { const frame = analyze('preview'); if (frame.ok) { setMetrics(frame); runtime.updateTracking(frame); if (onFrame) onFrame(frame); } }, 500); } catch (err) { setStatus('blocked'); setError(err && (err.name || err.message) ? (err.name || err.message) : 'camera unavailable'); } } startCamera(); return () => { alive = false; if (timer) clearInterval(timer); runtime.detach(session); stopStream(stream); }; }, []); const live = status === 'live'; const dot = live ? T.good : status === 'starting' ? T.amber : '#FF4D4D'; const statusText = live ? (metrics ? `${cameraBrightnessLabel(metrics)} · ${cameraStabilityLabel(metrics)}` : 'LIVE') : status === 'starting' ? '카메라 시작 중' : status === 'unsupported' ? '카메라 미지원' : '카메라 권한 필요'; return (