import { MOTION_CONTROL_STATE_EVENT, MOTION_DEBUG_CLOSE_EVENT, MOTION_DEBUG_FRAME_EVENT, MOTION_DEBUG_VIDEO_SOURCE_EVENT, MOTION_RECOGNITION_PAUSE_EVENT, } from "./motion-events.js"; const DEBUG_PANEL_ID = "motion-debug-panel"; const DEBUG_CANVAS_ID = "motion-debug-canvas"; const DEBUG_STATUS_ID = "motion-debug-status"; const DEBUG_MATCH_ID = "motion-debug-match"; const DEBUG_CLOSE_SELECTOR = "[data-motion-debug-close]"; const DEBUG_PAUSE_SELECTOR = "[data-motion-recognition-pause-toggle]"; const MOBILE_MOUNT_ID = "mobile-motion-debug-mount"; const FALLBACK_CANVAS_WIDTH = 320; const FALLBACK_CANVAS_HEIGHT = 220; const MIN_MEASURED_CANVAS_SIZE = 20; const CANVAS_ASPECT_HEIGHT = 11; const CANVAS_ASPECT_WIDTH = 16; const CANVAS_CSS_HEIGHT = "calc(194px * var(--hud-scale))"; const CANVAS_CSS_MIN_HEIGHT = "calc(170px * var(--hud-scale))"; let panel = null; let canvas = null; let ctx = null; let statusEl = null; let matchEl = null; let desktopParent = null; let desktopNextSibling = null; let visible = false; let lastFrame = null; let connected = false; let provider = "browser_camera"; let videoSource = null; let previewRafId = null; let skeletonOnly = false; let recognitionPaused = false; let controlsBound = false; function getProviderLabel(value) { return value === "motion_agent" ? "Motion Agent" : "浏览器摄像头"; } function createCanvasElement() { const nextCanvas = document.createElement("canvas"); nextCanvas.id = DEBUG_CANVAS_ID; nextCanvas.className = "motion-debug-canvas"; nextCanvas.width = FALLBACK_CANVAS_WIDTH; nextCanvas.height = FALLBACK_CANVAS_HEIGHT; return nextCanvas; } function getPanelElements() { panel = panel || document.getElementById(DEBUG_PANEL_ID); if (panel && !desktopParent) { desktopParent = panel.parentElement; desktopNextSibling = panel.nextSibling; } if (panel && !document.getElementById(DEBUG_CANVAS_ID)) { const body = panel.querySelector(".motion-debug-body"); const footer = panel.querySelector(".motion-debug-footer"); if (body instanceof HTMLElement) { body.insertBefore(createCanvasElement(), footer || null); } } canvas = canvas || document.getElementById(DEBUG_CANVAS_ID); ctx = ctx || canvas?.getContext?.("2d") || null; statusEl = statusEl || document.getElementById(DEBUG_STATUS_ID); matchEl = matchEl || document.getElementById(DEBUG_MATCH_ID); } function dispatchWindowEvent(name, detail = {}) { if (typeof window === "undefined") return; window.dispatchEvent(new CustomEvent(name, { detail })); } function bindPanelControls() { if (controlsBound || !(panel instanceof HTMLElement)) return; controlsBound = true; panel.addEventListener("click", (event) => { const target = event.target instanceof Element ? event.target : null; if (!target?.closest(DEBUG_CLOSE_SELECTOR)) return; event.preventDefault(); event.stopPropagation(); dispatchWindowEvent(MOTION_DEBUG_CLOSE_EVENT); }); panel.addEventListener("change", (event) => { const target = event.target; if (!(target instanceof HTMLInputElement) || !target.matches(DEBUG_PAUSE_SELECTOR)) { return; } event.stopPropagation(); recognitionPaused = target.checked === true; dispatchWindowEvent(MOTION_RECOGNITION_PAUSE_EVENT, { paused: recognitionPaused }); render(); }); } function setText(element, value) { if (element instanceof HTMLElement) { element.textContent = value; } } function ensurePanelLayout() { if (canvas instanceof HTMLCanvasElement) { canvas.style.display = "block"; canvas.style.width = "100%"; canvas.style.height = CANVAS_CSS_HEIGHT; canvas.style.minHeight = CANVAS_CSS_MIN_HEIGHT; } const body = canvas?.closest?.(".motion-debug-body"); if (body instanceof HTMLElement) { body.style.display = "flex"; body.style.flexDirection = "column"; } } function shouldUseMobileMount() { return Boolean(document.querySelector(".layout-mode-mobile")); } function syncPanelMount() { if (!(panel instanceof HTMLElement)) return; const mobileMount = document.getElementById(MOBILE_MOUNT_ID); if (shouldUseMobileMount() && mobileMount instanceof HTMLElement) { if (panel.parentElement !== mobileMount) { mobileMount.appendChild(panel); } return; } if (desktopParent && panel.parentElement !== desktopParent) { desktopParent.insertBefore(panel, desktopNextSibling); } } function resizeCanvasToDisplaySize() { if (!(canvas instanceof HTMLCanvasElement)) return; ensurePanelLayout(); const rect = canvas.getBoundingClientRect(); const scale = window.devicePixelRatio || 1; const fallbackWidth = canvas.parentElement?.clientWidth || FALLBACK_CANVAS_WIDTH; const cssWidth = rect.width >= MIN_MEASURED_CANVAS_SIZE ? rect.width : fallbackWidth; const cssHeight = rect.height >= MIN_MEASURED_CANVAS_SIZE ? rect.height : Math.max( FALLBACK_CANVAS_HEIGHT, Math.round(cssWidth * CANVAS_ASPECT_HEIGHT / CANVAS_ASPECT_WIDTH), ); const width = Math.max(1, Math.round(cssWidth * scale)); const height = Math.max(1, Math.round(cssHeight * scale)); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } } function clearCanvas(message = "等待动捕数据") { if (!ctx || !(canvas instanceof HTMLCanvasElement)) return; resizeCanvasToDisplaySize(); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "rgba(8, 12, 20, 0.82)"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "rgba(226, 232, 240, 0.74)"; ctx.font = `${Math.max(13, Math.round(canvas.width * 0.038))}px sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(message, canvas.width / 2, canvas.height / 2); } function hasDrawableVideoSource() { return Boolean( videoSource && typeof videoSource.videoWidth === "number" && typeof videoSource.videoHeight === "number" && videoSource.videoWidth > 0 && videoSource.videoHeight > 0 && videoSource.readyState >= 2, ); } function hasVideoSource() { return Boolean(videoSource); } function drawVideoSource() { if (!ctx || !(canvas instanceof HTMLCanvasElement) || !hasDrawableVideoSource()) return false; const sourceRatio = videoSource.videoWidth / videoSource.videoHeight; const canvasRatio = canvas.width / canvas.height; let sourceWidth = videoSource.videoWidth; let sourceHeight = videoSource.videoHeight; let sourceX = 0; let sourceY = 0; if (sourceRatio > canvasRatio) { sourceWidth = videoSource.videoHeight * canvasRatio; sourceX = (videoSource.videoWidth - sourceWidth) / 2; } else { sourceHeight = videoSource.videoWidth / canvasRatio; sourceY = (videoSource.videoHeight - sourceHeight) / 2; } ctx.drawImage( videoSource, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, canvas.width, canvas.height, ); ctx.fillStyle = "rgba(4, 8, 14, 0.24)"; ctx.fillRect(0, 0, canvas.width, canvas.height); return true; } function drawFrame(frame = {}) { if (!ctx || !(canvas instanceof HTMLCanvasElement)) return; resizeCanvasToDisplaySize(); const matched = Boolean(frame?.matchedGesture); const color = matched ? "#39e58c" : "#ff4d5f"; const jointMap = new Map((frame?.joints || []).map((joint) => [joint.id, joint])); ctx.clearRect(0, 0, canvas.width, canvas.height); if (skeletonOnly || !drawVideoSource()) { ctx.fillStyle = "rgba(8, 12, 20, 0.82)"; ctx.fillRect(0, 0, canvas.width, canvas.height); } ctx.lineWidth = Math.max(2, canvas.width * 0.008); ctx.lineCap = "round"; ctx.strokeStyle = color; ctx.shadowColor = color; ctx.shadowBlur = 10; (frame?.bones || []).forEach(([fromId, toId]) => { const from = jointMap.get(fromId); const to = jointMap.get(toId); if (!from || !to) return; ctx.beginPath(); ctx.moveTo(from.x * canvas.width, from.y * canvas.height); ctx.lineTo(to.x * canvas.width, to.y * canvas.height); ctx.stroke(); }); ctx.shadowBlur = 6; ctx.fillStyle = color; (frame?.joints || []).forEach((joint) => { ctx.beginPath(); ctx.arc(joint.x * canvas.width, joint.y * canvas.height, Math.max(4, canvas.width * 0.012), 0, Math.PI * 2); ctx.fill(); }); ctx.shadowBlur = 0; } function stopPreviewLoop() { if (previewRafId !== null) { cancelAnimationFrame(previewRafId); previewRafId = null; } } function shouldAnimatePreview() { return visible && connected && hasVideoSource(); } function startPreviewLoop() { if (previewRafId !== null || !shouldAnimatePreview()) return; const tick = () => { previewRafId = null; if (!shouldAnimatePreview()) return; drawFrame(lastFrame || {}); previewRafId = requestAnimationFrame(tick); }; previewRafId = requestAnimationFrame(tick); } function render() { getPanelElements(); if (!panel) return; syncPanelMount(); ensurePanelLayout(); panel.classList.toggle("hud-panel-hidden", !visible); panel.classList.toggle("is-motion-matched", Boolean(lastFrame?.matchedGesture)); panel.classList.toggle("is-motion-recognition-paused", recognitionPaused); panel.querySelectorAll(DEBUG_PAUSE_SELECTOR).forEach((input) => { if (input instanceof HTMLInputElement) { input.checked = recognitionPaused; } }); const providerLabel = getProviderLabel(provider); const pauseSuffix = recognitionPaused ? " · 匹配已暂停" : ""; setText(statusEl, connected ? `${providerLabel}已连接${pauseSuffix}` : `${providerLabel}未连接${pauseSuffix}`); if (!visible) { stopPreviewLoop(); return; } if (lastFrame) { const action = recognitionPaused ? "匹配已暂停" : (lastFrame.matchedGesture || "未匹配动作"); const confidence = !recognitionPaused && lastFrame.matchedGesture ? ` · ${Math.round((lastFrame.confidence || 0) * 100)}%` : ""; setText(matchEl, `${action}${confidence}`); drawFrame(lastFrame); } else if (hasVideoSource()) { setText(matchEl, "等待骨架数据"); drawFrame({}); } else { setText(matchEl, "等待骨架数据"); clearCanvas(connected ? "等待动捕数据" : "动捕未连接"); } startPreviewLoop(); } export function initMotionDebugPanel() { getPanelElements(); bindPanelControls(); const cachedVideoSource = window.__earthMotionDebugVideoSource; if (cachedVideoSource?.active !== false && cachedVideoSource?.source) { videoSource = cachedVideoSource.source; provider = cachedVideoSource.provider || provider; } render(); window.addEventListener("resize", render); window.addEventListener(MOTION_DEBUG_FRAME_EVENT, (event) => { lastFrame = event.detail || null; render(); }); window.addEventListener(MOTION_CONTROL_STATE_EVENT, (event) => { connected = Boolean(event?.detail?.connected); provider = event?.detail?.provider || provider; if (!connected) { videoSource = null; stopPreviewLoop(); } render(); }); window.addEventListener(MOTION_DEBUG_VIDEO_SOURCE_EVENT, (event) => { if (event?.detail?.active === false) { videoSource = null; stopPreviewLoop(); } else { videoSource = event?.detail?.source || null; provider = event?.detail?.provider || provider; videoSource?.addEventListener?.("loadedmetadata", render, { once: true }); videoSource?.addEventListener?.("canplay", render, { once: true }); } render(); }); } export function setMotionDebugPanelVisible(nextVisible) { visible = Boolean(nextVisible); render(); } export function setMotionDebugPanelSkeletonOnly(nextSkeletonOnly) { skeletonOnly = Boolean(nextSkeletonOnly); render(); }