294 lines
9.9 KiB
JavaScript
294 lines
9.9 KiB
JavaScript
import {
|
|
createMotionAgentProvider,
|
|
DEFAULT_AGENT_URL,
|
|
} from "./motion-agent-provider.js";
|
|
import { createBrowserCameraProvider } from "./motion-browser-provider.js";
|
|
import {
|
|
DEFAULT_MOTION_PROVIDER,
|
|
MOTION_GESTURES,
|
|
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 DEFAULT_ENABLED_GESTURES = Array.from(MOTION_GESTURES);
|
|
|
|
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 = {},
|
|
debugSkeleton = false,
|
|
enabledGestures = DEFAULT_ENABLED_GESTURES,
|
|
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;
|
|
let enabledGestureSet = normalizeEnabledGestureSet(enabledGestures);
|
|
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 (!enabledGestureSet.has(event?.gesture)) return false;
|
|
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,
|
|
debugSkeleton,
|
|
enabledGestures: Array.from(enabledGestureSet),
|
|
});
|
|
}
|
|
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?.());
|
|
},
|
|
sendCommand(command, payload = {}, requestId = null) {
|
|
return activeProvider?.sendCommand?.(command, payload, requestId) || {
|
|
ok: false,
|
|
error: "motion_provider_commands_unavailable",
|
|
requestId,
|
|
};
|
|
},
|
|
setEnabledGestures(nextGestures) {
|
|
enabledGestureSet = normalizeEnabledGestureSet(nextGestures);
|
|
lastHandledByGestureGroup.clear();
|
|
activeProvider?.sendCommand?.("set_enabled_gestures", {
|
|
gestures: Array.from(enabledGestureSet),
|
|
}, "earth-motion-enabled-gestures");
|
|
return Array.from(enabledGestureSet);
|
|
},
|
|
getProvider() {
|
|
return selectedProvider;
|
|
},
|
|
handleMessage: handleProviderMessage,
|
|
};
|
|
|
|
function handleRecognitionPause(event) {
|
|
setRecognitionPaused(event?.detail?.paused === true);
|
|
}
|
|
}
|
|
|
|
function normalizeEnabledGestureSet(gestures) {
|
|
const values = Array.isArray(gestures) ? gestures : DEFAULT_ENABLED_GESTURES;
|
|
const normalized = values
|
|
.map((gesture) => String(gesture || "").trim())
|
|
.filter((gesture) => MOTION_GESTURES.has(gesture));
|
|
return new Set(normalized.length > 0 ? normalized : DEFAULT_ENABLED_GESTURES);
|
|
}
|
|
|
|
export {
|
|
DEFAULT_AGENT_URL,
|
|
DEFAULT_MOTION_PROVIDER,
|
|
normalizeGestureMessage,
|
|
normalizeSkeletonMessage,
|
|
normalizeMotionProvider,
|
|
};
|