// 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 (
);
}
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에 매핑하지 않고 관절점 모션 캡처와 자세 코칭 점수만 확인합니다.
{mode === 'camera' ? 'MOCAP DOTS · AVATAR GUIDE ONLY' : mode === 'injected' ? 'VIRTUAL INJECTION' : 'SAMPLE POSE'}
{[['랜드마크', stats.landmarks], ['월드', stats.worldLandmarks], [mode === 'camera' ? '점수' : '본', mode === 'camera' ? stats.poseScore : stats.rigBones], ['아바타', stats.catalogVerified || 0]].map((item) => (
))}
{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 });