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, };