feat: refactor vehicle and gesture control console

This commit is contained in:
vee
2026-08-07 09:18:17 +08:00
parent dc7c91800e
commit 156b10fda1
8 changed files with 4372 additions and 97 deletions
+195 -14
View File
@@ -55,6 +55,10 @@ const state = {
customMapping: loadCustomMapping(),
customizeOpen: false,
engine: null,
runtimeReady: false,
cameraStatus: "checking",
cameraMessage: "正在检测摄像头…",
cameraCheckSequence: 0,
};
const $ = (selector) => document.querySelector(selector);
@@ -80,6 +84,91 @@ function notify(message) {
window.setTimeout(() => toast.classList.remove("visible"), 2600);
}
function cameraFailureDetails(error) {
if (!navigator.mediaDevices?.getUserMedia) {
return window.isSecureContext === false
? { status: "unsupported", message: "当前页面不是安全连接,浏览器已阻止摄像头访问" }
: { status: "unsupported", message: "当前浏览器不支持摄像头访问" };
}
switch (error?.name) {
case "NotFoundError":
case "DevicesNotFoundError":
return { status: "unavailable", message: "未检测到可用摄像头" };
case "NotAllowedError":
case "PermissionDeniedError":
case "SecurityError":
return { status: "blocked", message: "摄像头权限被拒绝,请在浏览器设置中允许访问" };
case "NotReadableError":
case "TrackStartError":
return { status: "busy", message: "摄像头正被其他程序占用或暂时不可用" };
case "OverconstrainedError":
case "ConstraintNotSatisfiedError":
return { status: "incompatible", message: "摄像头不支持当前画面参数" };
case "AbortError":
return { status: "unknown", message: "摄像头启动被中断,请重试" };
default:
return { status: "unknown", message: error?.message || "摄像头启动失败" };
}
}
function syncCameraButtonAvailability() {
const button = $("#gestureStartButton");
if (!button || state.stream) return;
button.disabled = !state.runtimeReady
|| ["checking", "unavailable", "unsupported"].includes(state.cameraStatus);
}
function setCameraAvailability(status, message, { announce = true } = {}) {
state.cameraStatus = status;
state.cameraMessage = message;
$("#gestureDrawer")?.setAttribute("data-camera-status", status);
syncCameraButtonAvailability();
if (announce && !state.stream) $("#gestureSafetyText").textContent = message;
}
async function refreshCameraAvailability({ announce = true } = {}) {
if (!navigator.mediaDevices?.enumerateDevices || !navigator.mediaDevices?.getUserMedia) {
const failure = cameraFailureDetails();
setCameraAvailability(failure.status, failure.message, { announce });
return false;
}
const checkSequence = ++state.cameraCheckSequence;
if (!state.stream) setCameraAvailability("checking", "正在检测摄像头…", { announce });
try {
const devices = await navigator.mediaDevices.enumerateDevices();
if (checkSequence !== state.cameraCheckSequence) return state.cameraStatus === "available";
const available = devices.some((device) => device.kind === "videoinput");
setCameraAvailability(
available ? "available" : "unavailable",
available ? "已检测到摄像头,点击开启开始识别" : "未检测到可用摄像头",
{ announce },
);
return available;
} catch (error) {
if (checkSequence !== state.cameraCheckSequence) return state.cameraStatus === "available";
const failure = cameraFailureDetails(error);
const message = failure.status === "blocked"
? "尚未获得摄像头权限,可点击开启并在浏览器中授权"
: "暂时无法检测摄像头,可点击开启时再次确认";
setCameraAvailability("unknown", message, { announce });
return null;
}
}
async function handleCameraDeviceChange() {
const streamWasActive = Boolean(state.stream);
const available = await refreshCameraAvailability({ announce: !streamWasActive });
const streamStillLive = state.stream?.getVideoTracks().some((track) => track.readyState === "live");
if (streamWasActive && (available === false || !streamStillLive)) {
const message = available === false
? "摄像头连接已断开,未检测到其他可用摄像头"
: "当前摄像头连接已断开,请重新开启摄像头";
stopCamera({ message });
notify(message);
}
}
function distance(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
@@ -386,7 +475,6 @@ function updateEngineUI(engine, message) {
pill.className = `gesture-state-pill ${engine.armed ? (engine.currentAction ? "moving" : "armed") : "idle"}`;
pill.querySelector("b").textContent = engine.armed ? (engine.currentAction ? "执行中" : "已激活") : "已锁止";
$("#gestureSafetyText").textContent = message;
$("#gestureArmButton").textContent = engine.armed ? "已激活" : "激活控制";
}
function executeGestureAction(action) {
@@ -469,6 +557,7 @@ async function loadGestureRuntime() {
state.runtime = await response.json();
state.engine = new GestureEngine(state.runtime.safety, executeGestureAction);
renderMappingGrid();
renderGestureDebugOptions();
if (!state.runtime.model_available) throw new Error("内置手势模型不可用,请检查工程资源");
}
@@ -497,6 +586,8 @@ async function startCamera() {
const button = $("#gestureStartButton");
button.disabled = true;
button.querySelector("span").textContent = "正在启动…";
button.setAttribute("aria-label", "正在启动摄像头");
button.setAttribute("title", "正在启动摄像头");
try {
if (!state.runtime) await loadGestureRuntime();
await loadRecognizer();
@@ -504,27 +595,30 @@ async function startCamera() {
video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: "user" },
audio: false,
});
setCameraAvailability("available", "摄像头可用,点击开启开始识别", { announce: false });
const video = $("#gestureVideo");
video.srcObject = state.stream;
await video.play();
$("#gestureCameraEmpty").hidden = true;
$("#gestureLiveBadge").hidden = false;
$("#gestureArmButton").disabled = false;
$("#gestureStopButton").disabled = false;
button.classList.add("active");
button.querySelector("span").textContent = "关闭摄像头";
button.setAttribute("aria-label", "关闭摄像头");
button.setAttribute("title", "关闭摄像头");
$("#gestureSafetyText").textContent = "张开手掌并保持 1 秒以激活";
state.frameRequest = requestAnimationFrame(recognizeLoop);
} catch (error) {
$("#gestureSafetyText").textContent = error.message || "摄像头启动失败";
notify(error.message || "摄像头启动失败");
stopCamera();
const failure = cameraFailureDetails(error);
setCameraAvailability(failure.status, failure.message, { announce: false });
stopCamera({ message: failure.message });
notify(failure.message);
} finally {
button.disabled = false;
syncCameraButtonAvailability();
}
}
function stopCamera() {
function stopCamera({ message = null } = {}) {
if (state.frameRequest) cancelAnimationFrame(state.frameRequest);
state.frameRequest = null;
state.stream?.getTracks().forEach((track) => track.stop());
@@ -536,15 +630,17 @@ function stopCamera() {
canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height);
$("#gestureCameraEmpty").hidden = false;
$("#gestureLiveBadge").hidden = true;
$("#gestureArmButton").disabled = true;
$("#gestureStopButton").disabled = true;
const button = $("#gestureStartButton");
button.classList.remove("active");
button.querySelector("span").textContent = "开启摄像头";
button.setAttribute("aria-label", "开启摄像头");
button.setAttribute("title", "开启摄像头");
state.engine?.setArmed(false);
$("#gestureDetected").textContent = "—";
$("#gestureExpected").textContent = "等待启动";
$("#gestureSafetyText").textContent = "摄像头尚未开启";
$("#gestureSafetyText").textContent = message || state.cameraMessage || "摄像头尚未开启";
syncCameraButtonAvailability();
}
function toggleCamera() {
@@ -562,6 +658,75 @@ function renderMappingGrid() {
select.innerHTML = Object.entries(ACTIONS).map(([value, item]) => `<option value="${value}">${item.label}</option>`).join("");
}
function renderGestureDebugOptions() {
const select = $("#gestureDebugSelect");
if (!select || !state.runtime) return;
const tokens = [...new Set([
"OPEN_PALM",
"CLOSED_FIST",
...Object.keys(state.runtime.actions || {}),
])];
select.innerHTML = tokens.map((token) => {
const action = mappingForToken(token);
const actionLabel = action ? ACTIONS[action]?.label || action : token === "OPEN_PALM" ? "激活控制" : "停止当前动作";
return `<option value="${token}">${TOKEN_NAMES[token] || token} → ${actionLabel}</option>`;
}).join("");
}
function setGestureDebugOpen(open) {
const panel = $("#gestureDebugPanel");
const button = $("#gestureArmButton");
if (!panel || !button) return;
const allowed = document.body.dataset.demoRole === "admin";
const nextOpen = allowed && open;
panel.hidden = !nextOpen;
button.classList.toggle("active", nextOpen);
button.setAttribute("aria-expanded", String(nextOpen));
}
function toggleGestureDebug() {
const panel = $("#gestureDebugPanel");
setGestureDebugOpen(panel?.hidden ?? true);
}
function sendSimulatedGesture() {
if (document.body.dataset.demoRole !== "admin") {
setGestureDebugOpen(false);
notify("仅管理员可发送模拟手势");
return;
}
const token = $("#gestureDebugSelect")?.value;
if (!token || !state.engine) {
notify("手势调试能力尚未就绪");
return;
}
const tokenName = TOKEN_NAMES[token] || token;
$("#gestureDetected").textContent = `模拟 · ${tokenName}`;
if (token === "OPEN_PALM") {
state.engine.setArmed(true);
$("#gestureExpected").textContent = "激活控制";
notify("已发送模拟张掌手势");
return;
}
if (token === "CLOSED_FIST") {
state.engine.stopMotion("管理员模拟握拳 · 已停止当前动作");
$("#gestureExpected").textContent = "停止当前动作";
notify("已发送模拟握拳手势");
return;
}
const action = mappingForToken(token);
if (!action || !ACTIONS[action]) {
$("#gestureExpected").textContent = "未绑定动作";
notify("该模拟手势尚未绑定动作");
return;
}
state.lastRecognizedToken = token;
$("#gestureExpected").textContent = ACTIONS[action].label;
$("#gestureSafetyText").textContent = `管理员模拟手势:${tokenName}`;
executeGestureAction(action);
notify(`已发送模拟手势:${tokenName}`);
}
function updateCustomizeToken() {
const token = state.lastRecognizedToken;
$("#gestureCustomizeToken").textContent = token ? TOKEN_NAMES[token] || token : "请先摆出手势";
@@ -635,26 +800,42 @@ function initModeRail() {
}
document.addEventListener("DOMContentLoaded", async () => {
$("#gestureStartButton").disabled = true;
$("#gestureToggleButton")?.addEventListener("click", openDrawer);
$("#gestureCloseButton")?.addEventListener("click", closeDrawer);
$("#gestureBackdrop")?.addEventListener("click", closeDrawer);
$("#gestureStartButton")?.addEventListener("click", toggleCamera);
$("#gestureArmButton")?.addEventListener("click", () => state.engine?.setArmed(!state.engine.armed));
$("#gestureArmButton")?.addEventListener("click", toggleGestureDebug);
$("#gestureStopButton")?.addEventListener("click", () => state.engine?.setArmed(false));
$("#gestureDebugSend")?.addEventListener("click", sendSimulatedGesture);
$("#gestureCustomizeButton")?.addEventListener("click", toggleCustomize);
$("#gestureBindButton")?.addEventListener("click", bindCurrentGesture);
$("#gestureResetMapping")?.addEventListener("click", resetMapping);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && $("#gestureDrawer")?.classList.contains("open")) closeDrawer();
if (event.key !== "Escape") return;
if (!$("#gestureDebugPanel")?.hidden) {
setGestureDebugOpen(false);
return;
}
if ($("#gestureDrawer")?.classList.contains("open")) closeDrawer();
});
window.addEventListener("cognitive-agent:demo-role-change", (event) => {
if (event.detail?.role !== "admin") setGestureDebugOpen(false);
});
document.addEventListener("visibilitychange", () => { if (document.hidden && state.stream) stopCamera(); });
window.addEventListener("beforeunload", stopCamera);
navigator.mediaDevices?.addEventListener?.("devicechange", handleCameraDeviceChange);
initModeRail();
let runtimeError = null;
try {
await loadGestureRuntime();
$("#gestureSafetyText").textContent = state.runtime.available ? "能力就绪,开启摄像头开始识别" : "内置手势模型不可用";
state.runtimeReady = Boolean(state.runtime.available && state.runtime.model_available);
if (!state.runtimeReady) runtimeError = new Error("内置手势模型不可用");
} catch (error) {
$("#gestureSafetyText").textContent = error.message;
$("#gestureStartButton").disabled = true;
runtimeError = error;
state.runtimeReady = false;
}
await refreshCameraAvailability({ announce: !runtimeError });
if (runtimeError) $("#gestureSafetyText").textContent = runtimeError.message;
syncCameraButtonAvailability();
});