// avatar-qa.jsx — in-app runtime QA for MediaPipe → VRM avatar motion const { useEffect: useEffectAvatarQa, useRef: useRefAvatarQa, useState: useStateAvatarQa } = React; const AVATAR_QA_PATHS = { runtime: 'vendor/avatar-runtime/dist/index.js', gltfLoader: 'vendor/three/examples/jsm/loaders/GLTFLoader.js', threeVrm: 'vendor/@pixiv/three-vrm/lib/three-vrm.module.js', three: 'vendor/three/build/three.module.js', wasm: 'vendor/@mediapipe/tasks-vision/wasm', model: 'wasm/pose_landmarker_lite.task', sample: 'assets/avatar-qa/sample-pose.jpg', }; function avatarQaUrl(path) { if (/^https?:\/\//.test(path) || path.startsWith('/')) return path; return new URL(path, window.location.href).href; } const AVATAR_CAMERA_FACING_YAW = Math.PI; function avatarQaInitialChecks() { return [ { id: 'modules', label: 'Runtime modules', status: 'pending', detail: '대기 중' }, { id: 'catalog', label: 'Avatar catalog', status: 'pending', detail: '대기 중' }, { id: 'assets', label: 'Local VRM assets', status: 'pending', detail: '대기 중' }, { id: 'vrm', label: 'three-vrm load', status: 'pending', detail: '대기 중' }, { id: 'pose', label: 'MediaPipe pose', status: 'pending', detail: '대기 중' }, { id: 'retarget', label: 'Retarget → bones', status: 'pending', detail: '대기 중' }, { id: 'camera', label: 'Live camera loop', status: 'idle', detail: '선택 실행' }, ]; } function avatarQaBadgeColor(status) { if (status === 'pass') return { bg: T.goodSoft, fg: T.good, text: 'PASS' }; if (status === 'fail') return { bg: T.pinkSoft, fg: T.pink, text: 'FAIL' }; if (status === 'running') return { bg: T.calmSoft, fg: T.calm, text: 'RUN' }; if (status === 'idle') return { bg: T.surface2, fg: T.ink2, text: 'IDLE' }; return { bg: T.amberSoft, fg: T.amberDark, text: 'WAIT' }; } function AvatarQaStatusRow({ row }) { const badge = avatarQaBadgeColor(row.status); return (
{badge.text}
{row.label}
{row.detail}
); } function AvatarQaScreen({ back }) { const canvasRef = useRefAvatarQa(null); const videoRef = useRefAvatarQa(null); const imageRef = useRefAvatarQa(null); const qaRef = useRefAvatarQa({ modules: null, renderer: null, scene: null, camera: null, renderRaf: 0, vrm: null, modelRoot: null, runtime: null, cameraRuntime: null, cameraDetach: null, disposed: false }); const [checks, setChecks] = useStateAvatarQa(avatarQaInitialChecks); const [avatars, setAvatars] = useStateAvatarQa([]); const [selectedId, setSelectedId] = useStateAvatarQa(''); const [mode, setMode] = useStateAvatarQa('sample'); const [busy, setBusy] = useStateAvatarQa(false); const [stats, setStats] = useStateAvatarQa({ landmarks: 0, worldLandmarks: 0, rigBones: 0, hipsChanged: false, catalogVerified: 0, frameCount: 0, poseScore: 0, cue: '대기 중' }); const [error, setError] = useStateAvatarQa(''); const patchCheck = (id, patch) => { setChecks(rows => rows.map(row => row.id === id ? { ...row, ...patch } : row)); }; const selectedAvatar = avatars.find(a => a.id === selectedId) || avatars[0]; async function loadModules() { if (qaRef.current.modules) return qaRef.current.modules; patchCheck('modules', { status: 'running', detail: 'ESM 로딩 중' }); const modules = window.FitbodyAvatarQaModules || await (window.FitbodyAvatarQaModulesReady || new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('Avatar QA ESM modules did not load.')), 8000); window.addEventListener('fitbody-avatar-qa-modules-ready', () => { clearTimeout(timer); resolve(window.FitbodyAvatarQaModules); }, { once: true }); })); qaRef.current.modules = modules; patchCheck('modules', { status: 'pass', detail: 'avatar-runtime / MediaPipe / three-vrm import OK' }); return modules; } function ensureScene(modules) { const qa = qaRef.current; if (qa.renderer) return qa; const { THREE } = modules; const canvas = canvasRef.current; if (!canvas) throw new Error('QA canvas is not mounted.'); const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 100); camera.position.set(0, 1.25, 3.0); scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 2.4)); const key = new THREE.DirectionalLight(0xffffff, 2.0); key.position.set(2.5, 3.2, 3.5); scene.add(key); qa.renderer = renderer; qa.scene = scene; qa.camera = camera; const render = () => { if (qa.disposed) return; const w = Math.max(1, canvas.clientWidth || 320); const h = Math.max(1, canvas.clientHeight || 300); if (canvas.width !== Math.round(w * renderer.getPixelRatio()) || canvas.height !== Math.round(h * renderer.getPixelRatio())) { renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); } renderer.render(scene, camera); qa.renderRaf = requestAnimationFrame(render); }; render(); return qa; } function frameObject(root, modules) { const { THREE } = modules; const box = new THREE.Box3().setFromObject(root); const size = new THREE.Vector3(); const center = new THREE.Vector3(); box.getSize(size); box.getCenter(center); root.position.x -= center.x; root.position.z -= center.z; root.position.y -= box.min.y; const span = Math.max(size.x, size.y, size.z, 0.001); const scale = Math.min(1.4, 1.9 / span); root.scale.multiplyScalar(scale); root.rotation.y = AVATAR_CAMERA_FACING_YAW; root.updateMatrixWorld?.(true); } async function verifyCatalogAssets(catalog) { patchCheck('assets', { status: 'running', detail: '5개 VRM HTTP/GLB 검증 중' }); const results = []; for (const avatar of catalog) { const path = avatar.metadata && avatar.metadata.localModelPath; const expectedBytes = avatar.metadata && avatar.metadata.localModelBytes; if (!path || !expectedBytes) throw new Error(`${avatar.name}: local VRM metadata missing.`); const response = await fetch(path); const data = await response.arrayBuffer(); const magic = new TextDecoder().decode(new Uint8Array(data.slice(0, 4))); const version = new DataView(data).getUint32(4, true); results.push({ name: avatar.name, ok: response.ok && data.byteLength === expectedBytes && magic === 'glTF' && version === 2, bytes: data.byteLength }); } const passed = results.filter(r => r.ok).length; patchCheck('assets', { status: passed === catalog.length ? 'pass' : 'fail', detail: `${passed}/${catalog.length} VRM served with GLB v2 header` }); setStats(s => ({ ...s, catalogVerified: passed })); return results; } async function loadVrm(avatar, modules) { const qa = ensureScene(modules); const { GLTFLoader, VRMLoaderPlugin } = modules; patchCheck('vrm', { status: 'running', detail: `${avatar.name} 로딩 중` }); if (qa.modelRoot) qa.scene.remove(qa.modelRoot); const loader = new GLTFLoader(); loader.register(parser => new VRMLoaderPlugin(parser)); const url = avatar.metadata && avatar.metadata.localModelPath ? avatar.metadata.localModelPath : avatar.modelUrl; const gltf = await loader.loadAsync(url); const vrm = gltf.userData && gltf.userData.vrm; if (!vrm || !vrm.humanoid) throw new Error(`${avatar.name}: VRM humanoid not found.`); const root = vrm.scene || gltf.scene; frameObject(root, modules); qa.scene.add(root); qa.vrm = vrm; qa.modelRoot = root; patchCheck('vrm', { status: 'pass', detail: `${avatar.name} humanoid bones loaded` }); return vrm; } function loadSampleImage() { if (imageRef.current) return Promise.resolve(imageRef.current); return new Promise((resolve, reject) => { const image = new Image(); image.crossOrigin = 'anonymous'; image.onload = () => { const canvas = document.createElement('canvas'); canvas.width = image.naturalWidth || image.width; canvas.height = image.naturalHeight || image.height; const ctx = canvas.getContext('2d'); if (!ctx) return reject(new Error('Sample pose canvas is unavailable.')); ctx.drawImage(image, 0, 0); imageRef.current = canvas; resolve(canvas); }; image.onerror = () => reject(new Error('Sample pose image failed to load.')); image.src = avatarQaUrl(AVATAR_QA_PATHS.sample); }); } function publishResult(result) { window.__fitbodyAvatarQaResult = result; if (result && result.error) { document.title = `Fitbody Avatar QA: FAIL ${result.error}`; } else if (result && result.mode === 'catalog') { document.title = `Fitbody Avatar QA: ${result.passed === result.total ? 'PASS' : 'FAIL'} ${result.passed}/${result.total}`; } else if (result && result.mode === 'sample') { document.title = `Fitbody Avatar QA: SAMPLE ${result.landmarks}/${result.worldLandmarks}/${result.rigBones}`; } else if (result && result.mode === 'camera') { document.title = `Fitbody Avatar QA: CAMERA ${result.landmarks}/${result.worldLandmarks} score ${result.poseScore || 0}`; } else if (result && result.mode === 'injected') { document.title = `Fitbody Avatar QA: INJECTED ${result.passed ? 'PASS' : 'FAIL'} ${result.frameCount}/${result.rigBones}`; } if (window.__fitbodyQaReport) { window.__fitbodyQaReport(result); } window.dispatchEvent(new CustomEvent('fitbody-avatar-qa-result', { detail: result })); } function syntheticLandmark(x, y, z, visibility = 0.99) { return { x, y, z, visibility, presence: visibility }; } function buildSyntheticPoseFrame(phase) { const sway = phase * 0.035; const armLift = phase * 0.11; const headTurn = phase * 0.07; const points = Array.from({ length: 33 }, () => syntheticLandmark(0.5, 0.5, 0, 0.96)); const set = (index, x, y, z, visibility) => { points[index] = syntheticLandmark(1 - x, y, z, visibility); }; set(0, 0.5 + headTurn, 0.17, -0.18); set(2, 0.46 + headTurn * 0.7, 0.15, -0.12); set(5, 0.54 + headTurn * 0.7, 0.15, -0.12); set(7, 0.43 + headTurn * 0.45, 0.18, -0.1); set(8, 0.57 + headTurn * 0.45, 0.18, -0.1); set(11, 0.36 + sway, 0.34, -0.04); set(12, 0.64 + sway, 0.35, 0.04); set(13, 0.25 + sway, 0.48 - armLift, -0.12); set(14, 0.75 + sway, 0.48 + armLift * 0.2, 0.1); set(15, 0.2 + sway, 0.64 - armLift * 1.3, -0.2); set(16, 0.8 + sway, 0.62 + armLift * 0.35, 0.16); set(17, 0.18 + sway, 0.66 - armLift * 1.35, -0.2); set(18, 0.82 + sway, 0.64 + armLift * 0.35, 0.16); set(19, 0.2 + sway, 0.66 - armLift * 1.38, -0.22); set(20, 0.8 + sway, 0.64 + armLift * 0.35, 0.18); set(23, 0.42 - sway * 0.45, 0.68, 0.03); set(24, 0.58 - sway * 0.45, 0.69, -0.03); set(25, 0.39 - sway, 0.88, 0.07); set(26, 0.61 - sway, 0.87, -0.07); set(27, 0.38 - sway, 1.06, 0.12); set(28, 0.62 - sway, 1.05, -0.12); set(29, 0.37 - sway, 1.09, 0.17); set(30, 0.63 - sway, 1.08, -0.17); set(31, 0.36 - sway, 1.08, 0.23); set(32, 0.64 - sway, 1.07, -0.23); const worldLandmarks = points.map(point => ({ x: (point.x - 0.5) * 1.8, y: (point.y - 0.68) * -1.8, z: point.z, visibility: point.visibility, presence: point.presence, })); return { landmarks: [points], worldLandmarks: [worldLandmarks] }; } function scorePoseLandmarks(landmarks, worldLandmarks) { const points = Array.isArray(landmarks) ? landmarks : []; const world = Array.isArray(worldLandmarks) ? worldLandmarks : []; const visible = points.filter(point => (point.visibility ?? point.presence ?? 1) >= 0.55).length; const hasCore = [11, 12, 23, 24].every(index => points[index] && (points[index].visibility ?? points[index].presence ?? 1) >= 0.55); const leftShoulder = points[11]; const rightShoulder = points[12]; const leftHip = points[23]; const rightHip = points[24]; const shoulderSpan = leftShoulder && rightShoulder ? Math.abs(leftShoulder.x - rightShoulder.x) : 0; const hipSpan = leftHip && rightHip ? Math.abs(leftHip.x - rightHip.x) : 0; const centeredCore = leftShoulder && rightShoulder && leftHip && rightHip ? Math.abs(((leftShoulder.x + rightShoulder.x + leftHip.x + rightHip.x) / 4) - 0.5) : 1; const visibilityScore = Math.min(1, visible / 33); const coreScore = hasCore ? 0.28 : 0; const framingScore = shoulderSpan > 0.12 && hipSpan > 0.10 && centeredCore < 0.22 ? 0.22 : 0; const worldScore = world.length === 33 ? 0.12 : 0; const poseScore = Math.round(Math.max(0, Math.min(1, visibilityScore * 0.38 + coreScore + framingScore + worldScore)) * 100); let cue = '전신이 카메라에 보이도록 한 걸음 물러서세요.'; if (points.length === 33 && visible >= 30 && hasCore && centeredCore < 0.22) cue = '좋아요. 전신 관절점이 안정적으로 잡혔습니다.'; else if (!hasCore) cue = '어깨와 골반이 모두 보이게 정면을 맞춰주세요.'; else if (centeredCore >= 0.22) cue = '몸을 화면 중앙으로 옮겨주세요.'; else if (visible >= 24) cue = '거의 좋아요. 손과 발끝까지 프레임에 넣어주세요.'; return { poseScore, cue, visibleLandmarks: visible }; } function rotationSnapshot(vrm) { const readBone = (name) => { const node = vrm.humanoid.getNormalizedBoneNode(name); if (!node || !node.rotation) return null; return { x: node.rotation.x || 0, y: node.rotation.y || 0, z: node.rotation.z || 0 }; }; return { hips: readBone('hips'), head: readBone('head'), leftUpperArm: readBone('leftUpperArm'), rightUpperArm: readBone('rightUpperArm'), }; } function rotationDelta(before, after) { const delta = {}; Object.keys(after).forEach(name => { if (!before[name] || !after[name]) { delta[name] = null; return; } delta[name] = { x: Math.abs(after[name].x - before[name].x), y: Math.abs(after[name].y - before[name].y), z: Math.abs(after[name].z - before[name].z), }; delta[name].max = Math.max(delta[name].x, delta[name].y, delta[name].z); }); return delta; } function maxRotationDelta(deltas, names) { return names.reduce((max, name) => Math.max(max, deltas[name] ? deltas[name].max : 0), 0); } async function runSampleQa(avatarOverride) { setBusy(true); setError(''); try { const modules = await loadModules(); const catalog = modules.runtime.FITBODY_AVATARS || []; if (!catalog.length) throw new Error('FITBODY_AVATARS is empty.'); setAvatars(catalog); const nextAvatar = avatarOverride || selectedAvatar || catalog[0]; if (!selectedId) setSelectedId(nextAvatar.id); patchCheck('catalog', { status: catalog.length === 5 ? 'pass' : 'fail', detail: `${catalog.length} avatars · human ${catalog.filter(a => a.category === 'human').length} / special ${catalog.filter(a => a.category === 'special').length}` }); await verifyCatalogAssets(catalog); const vrm = await loadVrm(nextAvatar, modules); const image = await loadSampleImage(); patchCheck('pose', { status: 'running', detail: 'sample image inference 중' }); const runtime = await modules.runtime.createBrowserVrmAvatarRuntime({ source: image, vrm, pose: { wasmPath: avatarQaUrl(AVATAR_QA_PATHS.wasm), modelAssetPath: avatarQaUrl(AVATAR_QA_PATHS.model), delegate: 'CPU', numPoses: 1, }, adapter: { updateDeltaSeconds: 0.016 }, session: { minFrameIntervalMs: 0 }, }); const before = vrm.humanoid.getNormalizedBoneNode('hips').rotation.x; const frame = runtime.step(performance.now()); runtime.close(); const after = vrm.humanoid.getNormalizedBoneNode('hips').rotation.x; const result = { mode: 'sample', avatar: nextAvatar.name, landmarks: frame.pose.landmarks[0] ? frame.pose.landmarks[0].length : 0, worldLandmarks: frame.pose.worldLandmarks && frame.pose.worldLandmarks[0] ? frame.pose.worldLandmarks[0].length : 0, rigBones: Object.keys(frame.rig).length, hipsChanged: Math.abs(after - before) > 1e-8, catalogCount: catalog.length, }; patchCheck('pose', { status: result.landmarks === 33 ? 'pass' : 'fail', detail: `${result.landmarks} landmarks / ${result.worldLandmarks} world` }); patchCheck('retarget', { status: result.rigBones >= 18 && result.hipsChanged ? 'pass' : 'fail', detail: `${result.rigBones} rig bones · hips rotation ${result.hipsChanged ? 'changed' : 'unchanged'}` }); setStats(s => ({ ...s, ...result })); publishResult(result); return result; } catch (e) { const message = e && e.message ? e.message : String(e); setError(message); publishResult({ mode: 'sample', error: message }); throw e; } finally { setBusy(false); } } async function runFullCatalogQa() { setBusy(true); setError(''); try { const modules = await loadModules(); const catalog = modules.runtime.FITBODY_AVATARS || []; const image = await loadSampleImage(); const detector = await modules.runtime.createMediaPipePoseDetector({ wasmPath: avatarQaUrl(AVATAR_QA_PATHS.wasm), modelAssetPath: avatarQaUrl(AVATAR_QA_PATHS.model), delegate: 'CPU', numPoses: 1 }); const results = []; for (const avatar of catalog) { setSelectedId(avatar.id); const vrm = await loadVrm(avatar, modules); const adapter = modules.runtime.createVrmAvatarPoseAdapter(vrm, { updateDeltaSeconds: 0.016 }); const motion = modules.runtime.createAvatarMotionRuntime({ detector, adapter, minFrameIntervalMs: 0 }); const before = vrm.humanoid.getNormalizedBoneNode('hips').rotation.x; const frame = motion.step(image, performance.now()); const after = vrm.humanoid.getNormalizedBoneNode('hips').rotation.x; motion.stop(); results.push({ name: avatar.name, landmarks: frame.pose.landmarks[0] ? frame.pose.landmarks[0].length : 0, worldLandmarks: frame.pose.worldLandmarks && frame.pose.worldLandmarks[0] ? frame.pose.worldLandmarks[0].length : 0, rigBones: Object.keys(frame.rig).length, hipsChanged: Math.abs(after - before) > 1e-8 }); } detector.close(); const passed = results.filter(r => r.landmarks === 33 && r.worldLandmarks === 33 && r.rigBones >= 18 && r.hipsChanged).length; const result = { mode: 'catalog', passed, total: results.length, results }; patchCheck('retarget', { status: passed === results.length ? 'pass' : 'fail', detail: `${passed}/${results.length} avatars passed full chain` }); setStats(s => ({ ...s, catalogVerified: passed })); publishResult(result); return result; } catch (e) { const message = e && e.message ? e.message : String(e); setError(message); publishResult({ mode: 'catalog', error: message }); throw e; } finally { setBusy(false); } } async function runInjectedPoseQa(avatarOverride) { setBusy(true); setError(''); try { const modules = await loadModules(); const catalog = modules.runtime.FITBODY_AVATARS || []; if (catalog.length) setAvatars(catalog); const avatar = avatarOverride || selectedAvatar || catalog[0]; if (!avatar) throw new Error('No avatar selected.'); if (!selectedId) setSelectedId(avatar.id); if (qaRef.current.runtime) qaRef.current.runtime.close(); if (qaRef.current.cameraRuntime) qaRef.current.cameraRuntime.close(); if (qaRef.current.cameraDetach) qaRef.current.cameraDetach(); qaRef.current.runtime = null; qaRef.current.cameraRuntime = null; qaRef.current.cameraDetach = null; patchCheck('pose', { status: 'running', detail: '가상 33-landmark 프레임 주입 중' }); const vrm = await loadVrm(avatar, modules); const syntheticFrames = [buildSyntheticPoseFrame(0), buildSyntheticPoseFrame(1), buildSyntheticPoseFrame(2)]; let detectIndex = 0; const detector = { detectForVideo: (_input, timestampMs) => { const sourceFrame = syntheticFrames[Math.min(detectIndex, syntheticFrames.length - 1)]; detectIndex += 1; return { ...sourceFrame, timestampMs }; }, close: () => {}, }; const adapter = modules.runtime.createVrmAvatarPoseAdapter(vrm, { updateDeltaSeconds: 0.016 }); const motion = modules.runtime.createAvatarMotionRuntime({ detector, adapter, retarget: { cameraFacing: true }, minFrameIntervalMs: 0, stabilization: false, }); const input = canvasRef.current || document.createElement('canvas'); const before = rotationSnapshot(vrm); const frames = syntheticFrames.map((_, index) => motion.step(input, 1000 + index * 33)); motion.close(); const after = rotationSnapshot(vrm); const deltas = rotationDelta(before, after); const frame = frames[frames.length - 1]; const rigBones = Object.keys(frame.rig).length; const hipsRotationDelta = deltas.hips ? deltas.hips.max : 0; const headRotationDelta = deltas.head ? deltas.head.max : 0; const leftArmRotationDelta = deltas.leftUpperArm ? deltas.leftUpperArm.max : 0; const rightArmRotationDelta = deltas.rightUpperArm ? deltas.rightUpperArm.max : 0; const modelRootYaw = qaRef.current.modelRoot ? qaRef.current.modelRoot.rotation.y : null; const evidence = { frameCount: frames.length, rigBones, landmarks: frame.pose.landmarks[0] ? frame.pose.landmarks[0].length : 0, worldLandmarks: frame.pose.worldLandmarks && frame.pose.worldLandmarks[0] ? frame.pose.worldLandmarks[0].length : 0, modelRootYaw, facingCamera: Math.abs(Math.abs(modelRootYaw || 0) - Math.PI) < 1e-6, hipsRotationDelta, headRotationDelta, leftArmRotationDelta, rightArmRotationDelta, maxCoreDelta: Math.max(hipsRotationDelta, headRotationDelta), maxArmDelta: Math.max(leftArmRotationDelta, rightArmRotationDelta), rotationDeltas: deltas, }; const passed = evidence.frameCount === syntheticFrames.length && evidence.landmarks === 33 && evidence.worldLandmarks === 33 && evidence.rigBones >= 18 && evidence.facingCamera && evidence.maxCoreDelta > 1e-5 && evidence.maxArmDelta > 1e-5; const result = { mode: 'injected', avatar: avatar.name, passed, ...evidence }; patchCheck('pose', { status: evidence.landmarks === 33 && evidence.worldLandmarks === 33 ? 'pass' : 'fail', detail: `가상 ${evidence.frameCount}프레임 · ${evidence.landmarks}/${evidence.worldLandmarks} landmarks` }); patchCheck('retarget', { status: passed ? 'pass' : 'fail', detail: `${evidence.rigBones} rig bones · facing ${evidence.facingCamera ? 'OK' : 'NO'} · core Δ ${evidence.maxCoreDelta.toFixed(6)} · arm Δ ${evidence.maxArmDelta.toFixed(6)}` }); setStats(s => ({ ...s, ...evidence, hipsChanged: hipsRotationDelta > 1e-5 })); setMode('injected'); publishResult(result); return result; } catch (e) { const message = e && e.message ? e.message : String(e); setError(message); patchCheck('retarget', { status: 'fail', detail: message }); publishResult({ mode: 'injected', error: message }); throw e; } finally { setBusy(false); } } async function startCameraQa() { setBusy(true); setError(''); try { const modules = await loadModules(); const video = videoRef.current; if (!video) throw new Error('Camera video element is not mounted.'); if (!window.FitbodyCamera) throw new Error('FitbodyCamera is unavailable.'); if (qaRef.current.cameraRuntime) qaRef.current.cameraRuntime.close(); if (qaRef.current.cameraDetach) qaRef.current.cameraDetach(); qaRef.current.cameraRuntime = null; qaRef.current.cameraDetach = null; patchCheck('camera', { status: 'running', detail: '카메라 권한 대기 중 · 아바타는 안내용입니다' }); qaRef.current.cameraDetach = window.FitbodyCamera.attach(video, { facingMode: 'user' }); await new Promise((resolve, reject) => { const startedAt = performance.now(); const tick = () => { if (video.readyState >= 2 && video.videoWidth) return resolve(); if (performance.now() - startedAt > 8000) return reject(new Error('Camera video did not become ready.')); requestAnimationFrame(tick); }; tick(); }); const detector = await modules.runtime.createMediaPipePoseDetector({ wasmPath: avatarQaUrl(AVATAR_QA_PATHS.wasm), modelAssetPath: avatarQaUrl(AVATAR_QA_PATHS.model), delegate: 'CPU', numPoses: 1, }); let raf = 0; let stopped = false; let lastInferenceAt = 0; const closeCameraRuntime = () => { stopped = true; if (raf) cancelAnimationFrame(raf); detector.close?.(); }; const inferFrame = () => { if (stopped) return; const now = performance.now(); if (video.readyState >= 2 && now - lastInferenceAt >= 66) { lastInferenceAt = now; const pose = (detector.detectForVideo ? detector.detectForVideo(video, now) : detector.detect(video)) || {}; const landmarks = pose.landmarks && pose.landmarks[0] ? pose.landmarks[0] : []; const worldLandmarks = pose.worldLandmarks && pose.worldLandmarks[0] ? pose.worldLandmarks[0] : []; const coaching = scorePoseLandmarks(landmarks, worldLandmarks); const result = { mode: 'camera', landmarks: landmarks.length, worldLandmarks: worldLandmarks.length, poseScore: coaching.poseScore, cue: coaching.cue, visibleLandmarks: coaching.visibleLandmarks }; setStats(s => ({ ...s, ...result, rigBones: 0, frameCount: s.frameCount + 1 })); patchCheck('camera', { status: result.landmarks ? 'pass' : 'running', detail: `${result.landmarks} dots · score ${result.poseScore} · ${result.cue}` }); patchCheck('retarget', { status: 'idle', detail: 'camera QA는 VRM 매핑 없이 모션 캡처 점수만 확인' }); publishResult(result); } raf = requestAnimationFrame(inferFrame); }; qaRef.current.cameraRuntime = { close: closeCameraRuntime }; setMode('camera'); patchCheck('camera', { status: 'running', detail: '모션 캡처 점/코칭 루프 실행 중' }); inferFrame(); } catch (e) { const message = e && e.message ? e.message : String(e); if (qaRef.current.cameraRuntime) qaRef.current.cameraRuntime.close(); qaRef.current.cameraRuntime = null; if (qaRef.current.cameraDetach) qaRef.current.cameraDetach(); qaRef.current.cameraDetach = null; setError(message); patchCheck('camera', { status: 'fail', detail: message }); publishResult({ mode: 'camera', error: message }); throw e; } finally { setBusy(false); } } function stopCameraQa() { if (qaRef.current.cameraRuntime) qaRef.current.cameraRuntime.close(); qaRef.current.cameraRuntime = null; if (qaRef.current.cameraDetach) qaRef.current.cameraDetach(); qaRef.current.cameraDetach = null; setMode('sample'); patchCheck('camera', { status: 'idle', detail: 'stopped' }); } useEffectAvatarQa(() => { qaRef.current.disposed = false; runSampleQa().then(() => { const autorun = new URLSearchParams(window.location.search).get('autorun'); if (autorun === 'catalog') return runFullCatalogQa(); if (autorun === 'injected') return runInjectedPoseQa(); return undefined; }).catch(() => {}); window.__fitbodyRunAvatarQa = runFullCatalogQa; window.__fitbodyRunAvatarSampleQa = runSampleQa; window.__fitbodyRunAvatarInjectedQa = runInjectedPoseQa; return () => { qaRef.current.disposed = true; if (qaRef.current.runtime) qaRef.current.runtime.close(); if (qaRef.current.cameraRuntime) qaRef.current.cameraRuntime.close(); if (qaRef.current.cameraDetach) qaRef.current.cameraDetach(); if (qaRef.current.renderRaf) cancelAnimationFrame(qaRef.current.renderRaf); if (qaRef.current.renderer) qaRef.current.renderer.dispose(); delete window.__fitbodyRunAvatarQa; delete window.__fitbodyRunAvatarSampleQa; delete window.__fitbodyRunAvatarInjectedQa; }; }, []); const selectAvatar = (avatar) => { setSelectedId(avatar.id); runSampleQa(avatar).catch(() => {}); }; return (
TEST}/> 아바타는 “이렇게 움직이세요”를 보여주는 안내/demo asset입니다. 실제 카메라는 VRM에 매핑하지 않고 관절점 모션 캡처와 자세 코칭 점수만 확인합니다.
{[['랜드마크', stats.landmarks], ['월드', stats.worldLandmarks], [mode === 'camera' ? '점수' : '본', mode === 'camera' ? stats.poseScore : stats.rigBones], ['아바타', stats.catalogVerified || 0]].map((item) => (
{item[1]}
{item[0]}
))}
{mode === 'camera' &&
카메라 QA: VRM 매핑 없음 · 관절점 {stats.visibleLandmarks || 0}/33 · {stats.cue}
} {!!avatars.length && (
{avatars.map(avatar => ( ))}
)} {checks.map(row => ) } {error &&
{error}
}
runSampleQa().catch(() => {})}>샘플 포즈 QA 다시 실행 runFullCatalogQa().catch(() => {})}>5개 아바타 전체 체인 검증 {mode === 'camera' ? 카메라 모션 캡처 QA 정지 : startCameraQa().catch(() => {})}>실제 카메라 모션 캡처 QA 시작}
); } Object.assign(window, { AvatarQaScreen });