537 lines
17 KiB
JavaScript
537 lines
17 KiB
JavaScript
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 SKELETON_ONLY_SELECTOR = "[data-motion-skeleton-only-toggle]";
|
|
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))";
|
|
const MOBILE_TAP_MAX_DISTANCE_PX = 8;
|
|
const MOBILE_DOUBLE_TAP_MS = 280;
|
|
const MOBILE_CONTROL_FLASH_MS = 500;
|
|
|
|
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;
|
|
let mobilePointer = null;
|
|
let mobileSingleTapTimer = null;
|
|
let mobilePauseFlashTimer = null;
|
|
let mobilePlayFlashTimer = null;
|
|
let mobileLastTapAt = 0;
|
|
|
|
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();
|
|
});
|
|
|
|
panel.addEventListener("pointerdown", handleMobilePointerDown);
|
|
panel.addEventListener("pointermove", handleMobilePointerMove);
|
|
panel.addEventListener("pointerup", handleMobilePointerUp);
|
|
panel.addEventListener("pointercancel", handleMobilePointerCancel);
|
|
}
|
|
|
|
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;
|
|
if (desktopParent && panel.parentElement !== desktopParent) {
|
|
desktopParent.insertBefore(panel, desktopNextSibling);
|
|
}
|
|
}
|
|
|
|
function isMobileLayout() {
|
|
return shouldUseMobileMount();
|
|
}
|
|
|
|
function clearMobileTapTimer() {
|
|
if (mobileSingleTapTimer !== null) {
|
|
window.clearTimeout(mobileSingleTapTimer);
|
|
mobileSingleTapTimer = null;
|
|
}
|
|
}
|
|
|
|
function clearMobilePauseFlash() {
|
|
if (mobilePauseFlashTimer !== null) {
|
|
window.clearTimeout(mobilePauseFlashTimer);
|
|
mobilePauseFlashTimer = null;
|
|
}
|
|
panel?.classList.remove("is-mobile-pause-flash");
|
|
}
|
|
|
|
function clearMobilePlayFlash() {
|
|
if (mobilePlayFlashTimer !== null) {
|
|
window.clearTimeout(mobilePlayFlashTimer);
|
|
mobilePlayFlashTimer = null;
|
|
}
|
|
panel?.classList.remove("is-mobile-play-flash");
|
|
}
|
|
|
|
function flashMobilePauseIcon() {
|
|
if (!isMobileLayout() || !(panel instanceof HTMLElement)) return;
|
|
clearMobilePauseFlash();
|
|
clearMobilePlayFlash();
|
|
panel.classList.add("is-mobile-pause-flash");
|
|
void panel.offsetWidth;
|
|
mobilePauseFlashTimer = window.setTimeout(() => {
|
|
mobilePauseFlashTimer = null;
|
|
panel?.classList.remove("is-mobile-pause-flash");
|
|
}, MOBILE_CONTROL_FLASH_MS);
|
|
}
|
|
|
|
function flashMobilePlayIcon() {
|
|
if (!isMobileLayout() || !(panel instanceof HTMLElement)) return;
|
|
clearMobilePlayFlash();
|
|
clearMobilePauseFlash();
|
|
panel.classList.add("is-mobile-play-flash");
|
|
void panel.offsetWidth;
|
|
mobilePlayFlashTimer = window.setTimeout(() => {
|
|
mobilePlayFlashTimer = null;
|
|
panel?.classList.remove("is-mobile-play-flash");
|
|
}, MOBILE_CONTROL_FLASH_MS);
|
|
}
|
|
|
|
function clampMobilePanelPosition(left, top) {
|
|
if (!(panel instanceof HTMLElement)) return { left, top };
|
|
const rect = panel.getBoundingClientRect();
|
|
const safeGap = 10;
|
|
const minLeft = safeGap;
|
|
const minTop = safeGap;
|
|
const maxLeft = Math.max(minLeft, window.innerWidth - rect.width - safeGap);
|
|
const maxTop = Math.max(minTop, window.innerHeight - rect.height - safeGap);
|
|
return {
|
|
left: Math.min(Math.max(left, minLeft), maxLeft),
|
|
top: Math.min(Math.max(top, minTop), maxTop),
|
|
};
|
|
}
|
|
|
|
function setMobilePanelPosition(left, top) {
|
|
if (!(panel instanceof HTMLElement)) return;
|
|
const clamped = clampMobilePanelPosition(left, top);
|
|
panel.style.left = `${Math.round(clamped.left)}px`;
|
|
panel.style.top = `${Math.round(clamped.top)}px`;
|
|
panel.style.right = "auto";
|
|
panel.style.bottom = "auto";
|
|
}
|
|
|
|
function togglePauseFromPanel() {
|
|
const input = panel?.querySelector(DEBUG_PAUSE_SELECTOR);
|
|
if (input instanceof HTMLInputElement) {
|
|
const nextPaused = !recognitionPaused;
|
|
input.checked = !recognitionPaused;
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
if (nextPaused) {
|
|
flashMobilePauseIcon();
|
|
} else {
|
|
flashMobilePlayIcon();
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleSkeletonOnlyFromPanel() {
|
|
const input = panel?.querySelector(SKELETON_ONLY_SELECTOR);
|
|
if (input instanceof HTMLInputElement) {
|
|
input.checked = !skeletonOnly;
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
}
|
|
|
|
function handleMobilePointerDown(event) {
|
|
if (!isMobileLayout() || !(panel instanceof HTMLElement)) return;
|
|
if (event.target instanceof Element && event.target.closest(DEBUG_CLOSE_SELECTOR)) return;
|
|
const rect = panel.getBoundingClientRect();
|
|
mobilePointer = {
|
|
id: event.pointerId,
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
left: rect.left,
|
|
top: rect.top,
|
|
moved: false,
|
|
};
|
|
panel.setPointerCapture?.(event.pointerId);
|
|
panel.classList.add("is-mobile-pressing");
|
|
panel.classList.add("is-mobile-dragging");
|
|
}
|
|
|
|
function handleMobilePointerMove(event) {
|
|
if (!mobilePointer || event.pointerId !== mobilePointer.id || !isMobileLayout()) return;
|
|
const dx = event.clientX - mobilePointer.startX;
|
|
const dy = event.clientY - mobilePointer.startY;
|
|
if (!mobilePointer.moved && Math.hypot(dx, dy) > MOBILE_TAP_MAX_DISTANCE_PX) {
|
|
mobilePointer.moved = true;
|
|
panel?.classList.remove("is-mobile-pressing");
|
|
clearMobileTapTimer();
|
|
}
|
|
if (!mobilePointer.moved) return;
|
|
event.preventDefault();
|
|
setMobilePanelPosition(mobilePointer.left + dx, mobilePointer.top + dy);
|
|
}
|
|
|
|
function handleMobilePointerUp(event) {
|
|
if (!mobilePointer || event.pointerId !== mobilePointer.id) return;
|
|
const wasMoved = mobilePointer.moved;
|
|
mobilePointer = null;
|
|
panel?.releasePointerCapture?.(event.pointerId);
|
|
panel?.classList.remove("is-mobile-pressing");
|
|
panel?.classList.remove("is-mobile-dragging");
|
|
if (wasMoved || !isMobileLayout()) return;
|
|
|
|
const now = window.performance?.now?.() || Date.now();
|
|
if (now - mobileLastTapAt <= MOBILE_DOUBLE_TAP_MS) {
|
|
clearMobileTapTimer();
|
|
mobileLastTapAt = 0;
|
|
toggleSkeletonOnlyFromPanel();
|
|
return;
|
|
}
|
|
mobileLastTapAt = now;
|
|
clearMobileTapTimer();
|
|
mobileSingleTapTimer = window.setTimeout(() => {
|
|
mobileSingleTapTimer = null;
|
|
togglePauseFromPanel();
|
|
}, MOBILE_DOUBLE_TAP_MS);
|
|
}
|
|
|
|
function handleMobilePointerCancel(event) {
|
|
if (mobilePointer?.id === event.pointerId) {
|
|
mobilePointer = null;
|
|
}
|
|
panel?.classList.remove("is-mobile-pressing");
|
|
panel?.classList.remove("is-mobile-dragging");
|
|
}
|
|
|
|
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", () => {
|
|
if (isMobileLayout() && panel instanceof HTMLElement && panel.style.left && panel.style.top) {
|
|
const rect = panel.getBoundingClientRect();
|
|
setMobilePanelPosition(rect.left, rect.top);
|
|
}
|
|
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();
|
|
}
|