feat: refactor vehicle and gesture control console
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user