Files
planet/frontend/public/earth/js/motion-control.js
2026-05-11 09:49:08 +08:00

263 lines
8.6 KiB
JavaScript

import {
createMotionAgentProvider,
DEFAULT_AGENT_URL,
} from "./motion-agent-provider.js";
import { createBrowserCameraProvider } from "./motion-browser-provider.js";
import {
DEFAULT_MOTION_PROVIDER,
MOTION_PROVIDER_AGENT,
normalizeGestureMessage,
normalizeMotionProvider,
normalizeSkeletonMessage,
} from "./motion-protocol.js";
import {
MOTION_CONTROL_STATE_EVENT,
MOTION_DEBUG_FRAME_EVENT,
MOTION_DEBUG_VIDEO_SOURCE_EVENT,
MOTION_RECOGNITION_PAUSE_EVENT,
} from "./motion-events.js";
const DEFAULT_MIN_CONFIDENCE = 0.72;
const DEFAULT_COOLDOWN_MS = 120;
const DEFAULT_FOCUS_COOLDOWN_MS = 900;
const DEFAULT_LAYER_COOLDOWN_MS = 1400;
const DEFAULT_CONFIRM_COOLDOWN_MS = 1200;
const ENABLED_STORAGE_KEY = "planet-earth-motion-control-enabled";
const URL_STORAGE_KEY = "planet-earth-motion-control-url";
const GESTURE_POLICIES = {
rotate_left: { group: "rotate_left", cooldownMs: DEFAULT_COOLDOWN_MS },
rotate_right: { group: "rotate_right", cooldownMs: DEFAULT_COOLDOWN_MS },
rotate_up: { group: "rotate_up", cooldownMs: DEFAULT_COOLDOWN_MS },
rotate_down: { group: "rotate_down", cooldownMs: DEFAULT_COOLDOWN_MS },
zoom_in: { group: "zoom_in", cooldownMs: DEFAULT_COOLDOWN_MS },
zoom_out: { group: "zoom_out", cooldownMs: DEFAULT_COOLDOWN_MS },
focus_prev: { group: "focus", cooldownMs: DEFAULT_FOCUS_COOLDOWN_MS },
focus_next: { group: "focus", cooldownMs: DEFAULT_FOCUS_COOLDOWN_MS },
layer_prev: { group: "layer", cooldownMs: DEFAULT_LAYER_COOLDOWN_MS },
layer_next: { group: "layer", cooldownMs: DEFAULT_LAYER_COOLDOWN_MS },
confirm: { group: "confirm", cooldownMs: DEFAULT_CONFIRM_COOLDOWN_MS },
};
function getSearchParams() {
if (typeof window === "undefined") return new URLSearchParams();
return new URLSearchParams(window.location.search || "");
}
function dispatchWindowEvent(name, detail) {
if (typeof window === "undefined") return;
if (name === MOTION_DEBUG_VIDEO_SOURCE_EVENT) {
window.__earthMotionDebugVideoSource = detail;
}
window.dispatchEvent(new CustomEvent(name, { detail }));
}
function getConfiguredAgentUrl() {
if (typeof window === "undefined") return DEFAULT_AGENT_URL;
const search = getSearchParams();
const queryUrl = search.get("motionAgent");
if (queryUrl) return queryUrl;
const storedUrl = window.localStorage?.getItem(URL_STORAGE_KEY);
return storedUrl || DEFAULT_AGENT_URL;
}
export function getConfiguredMotionProvider(fallback = DEFAULT_MOTION_PROVIDER) {
const search = getSearchParams();
if (search.get("motionAgent")) return MOTION_PROVIDER_AGENT;
const queryProvider = search.get("motionProvider");
if (queryProvider) return normalizeMotionProvider(queryProvider, fallback);
return normalizeMotionProvider(fallback, DEFAULT_MOTION_PROVIDER);
}
export function shouldEnableMotionControl() {
if (typeof window === "undefined") return false;
const search = getSearchParams();
if (search.get("motion") === "1") return true;
if (search.get("motion") === "0") return false;
return window.localStorage?.getItem(ENABLED_STORAGE_KEY) === "true";
}
export function createMotionControlAdapter(options = {}) {
const {
enabled = false,
provider = DEFAULT_MOTION_PROVIDER,
url = getConfiguredAgentUrl(),
minConfidence = DEFAULT_MIN_CONFIDENCE,
cooldownMs = DEFAULT_COOLDOWN_MS,
providerFactories = {},
WebSocketCtor = typeof WebSocket !== "undefined" ? WebSocket : null,
onRotate = () => false,
onZoom = () => false,
onConfirm = () => false,
onFocus = () => false,
onLayer = () => false,
onSkeleton = () => {},
onStatus = () => {},
onVideoSource = (detail) => dispatchWindowEvent(MOTION_DEBUG_VIDEO_SOURCE_EVENT, detail),
nowFn = () => Date.now(),
} = options;
const selectedProvider = getConfiguredMotionProvider(provider);
let disposed = false;
let activeProvider = null;
let connected = false;
let recognitionPaused = false;
const lastHandledByGestureGroup = new Map();
function emitState(detail) {
connected = Boolean(detail?.connected);
dispatchWindowEvent(MOTION_CONTROL_STATE_EVENT, {
provider: selectedProvider,
connected,
recognitionPaused,
...detail,
});
}
function setRecognitionPaused(nextPaused) {
recognitionPaused = Boolean(nextPaused);
emitState({ provider: selectedProvider, connected });
}
function shouldHandleGesture(event) {
if (!event || event.confidence < minConfidence) return false;
const policy = GESTURE_POLICIES[event.gesture] || {
group: event.gesture,
cooldownMs,
};
const effectiveCooldownMs =
policy.cooldownMs === DEFAULT_COOLDOWN_MS ? cooldownMs : policy.cooldownMs;
const now = nowFn();
const last = lastHandledByGestureGroup.get(policy.group);
if (last !== undefined && now - last < effectiveCooldownMs) return false;
lastHandledByGestureGroup.set(policy.group, now);
return true;
}
function handleGesture(event) {
if (recognitionPaused) return;
if (!shouldHandleGesture(event)) return;
if (event.gesture === "rotate_left" || event.gesture === "rotate_right") {
onRotate("horizontal", event.gesture === "rotate_left" ? "left" : "right", event.intensity, event);
} else if (event.gesture === "rotate_up" || event.gesture === "rotate_down") {
onRotate("vertical", event.gesture === "rotate_up" ? "up" : "down", event.intensity, event);
} else if (event.gesture === "zoom_in") {
onZoom("in", event.intensity, event);
} else if (event.gesture === "zoom_out") {
onZoom("out", event.intensity, event);
} else if (event.gesture === "focus_prev") {
onFocus("prev", event);
} else if (event.gesture === "focus_next") {
onFocus("next", event);
} else if (event.gesture === "layer_prev") {
onLayer("prev", event);
} else if (event.gesture === "layer_next") {
onLayer("next", event);
} else if (event.gesture === "confirm") {
onConfirm(event);
}
}
function handleProviderMessage(rawMessage) {
let data = rawMessage;
if (typeof rawMessage === "string") {
try {
data = JSON.parse(rawMessage);
} catch (_error) {
return;
}
}
const gesture = normalizeGestureMessage(data, selectedProvider);
if (gesture) {
handleGesture(gesture);
return;
}
const skeleton = normalizeSkeletonMessage(data, selectedProvider);
if (skeleton) {
const nextSkeleton = recognitionPaused
? { ...skeleton, matchedGesture: null, confidence: 0 }
: skeleton;
onSkeleton(nextSkeleton);
dispatchWindowEvent(MOTION_DEBUG_FRAME_EVENT, nextSkeleton);
return;
}
if (data?.type === "status" || data?.type === "heartbeat") {
emitState({ provider: selectedProvider, connected, message: data });
}
}
function createProvider() {
const sharedOptions = {
onMessage: handleProviderMessage,
onState: emitState,
onStatus,
onVideoSource,
};
if (providerFactories[selectedProvider]) {
return providerFactories[selectedProvider]({
...sharedOptions,
url,
WebSocketCtor,
});
}
if (selectedProvider === MOTION_PROVIDER_AGENT) {
return createMotionAgentProvider({
...sharedOptions,
url,
WebSocketCtor,
});
}
return createBrowserCameraProvider(sharedOptions);
}
return {
start() {
if (!enabled || disposed) return false;
if (typeof window !== "undefined") {
window.addEventListener(MOTION_RECOGNITION_PAUSE_EVENT, handleRecognitionPause);
}
activeProvider = createProvider();
const result = activeProvider.start();
emitState({ provider: selectedProvider, connected: activeProvider.isConnected?.() || false });
return result;
},
stop() {
disposed = true;
if (typeof window !== "undefined") {
window.removeEventListener(MOTION_RECOGNITION_PAUSE_EVENT, handleRecognitionPause);
}
activeProvider?.stop?.();
activeProvider = null;
connected = false;
dispatchWindowEvent(MOTION_DEBUG_VIDEO_SOURCE_EVENT, {
provider: selectedProvider,
source: null,
active: false,
});
emitState({ provider: selectedProvider, connected: false });
},
isConnected() {
return Boolean(activeProvider?.isConnected?.());
},
getProvider() {
return selectedProvider;
},
handleMessage: handleProviderMessage,
};
function handleRecognitionPause(event) {
setRecognitionPaused(event?.detail?.paused === true);
}
}
export {
DEFAULT_AGENT_URL,
DEFAULT_MOTION_PROVIDER,
normalizeGestureMessage,
normalizeSkeletonMessage,
normalizeMotionProvider,
};