release: bump version to 0.50.0
This commit is contained in:
355
frontend/public/earth/js/motion-debug-panel.js
Normal file
355
frontend/public/earth/js/motion-debug-panel.js
Normal file
@@ -0,0 +1,355 @@
|
||||
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_PAUSE_ID = "motion-debug-pause";
|
||||
const DEBUG_CLOSE_SELECTOR = "[data-motion-debug-close]";
|
||||
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;
|
||||
|
||||
const closeButton = panel.querySelector(DEBUG_CLOSE_SELECTOR);
|
||||
closeButton?.addEventListener?.("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dispatchWindowEvent(MOTION_DEBUG_CLOSE_EVENT);
|
||||
});
|
||||
|
||||
const pauseInput = document.getElementById(DEBUG_PAUSE_ID);
|
||||
pauseInput?.addEventListener?.("change", () => {
|
||||
recognitionPaused = pauseInput.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);
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user