Files
planet/frontend/public/earth/js/motion-browser-provider.js
2026-05-10 22:06:01 +08:00

549 lines
18 KiB
JavaScript

const MEDIAPIPE_TASKS_VERSION = "0.10.35";
const MEDIAPIPE_TASKS_URLS = [
`https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_TASKS_VERSION}/vision_bundle.mjs`,
`https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_TASKS_VERSION}`,
`https://unpkg.com/@mediapipe/tasks-vision@${MEDIAPIPE_TASKS_VERSION}/vision_bundle.mjs`,
];
const MEDIAPIPE_WASM_URL = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_TASKS_VERSION}/wasm`;
const POSE_MODEL_URL =
"https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/latest/pose_landmarker_lite.task";
const FRAME_INTERVAL_MS = 66;
const GESTURE_COOLDOWN_MS = 420;
const VIDEO_METADATA_TIMEOUT_MS = 900;
const LEFT_WRIST_LAYER_DELTA_Y = 0.05;
const HEAD_TILT_DELTA_Y = 0.035;
const ARM_PATTERN_TERMINAL_TOLERANCE_DEG = 32;
const ARM_PATTERN_UPPER_TOLERANCE_DEG = 34;
const ARM_PATTERN_MIN_SEGMENT = 0.045;
const ARM_PATTERN_MIN_SIDE_REACH = 0.06;
const ARM_PATTERN_MIN_VERTICAL_REACH = 0.055;
const MIN_GESTURE_INTENSITY = 0.45;
const ARM_PATTERN_INTENSITY_SCALE = 5;
const WRIST_LAYER_INTENSITY_SCALE = 9;
const HEAD_TILT_INTENSITY_SCALE = 12;
const ZOOM_OPEN_WRIST_SPREAD_FACTOR = 1.42;
const ZOOM_OPEN_WRIST_HEIGHT_TOLERANCE = 0.16;
const ZOOM_CLOSE_WRIST_SPREAD_FACTOR = 1.28;
const ZOOM_SUPPRESS_WRIST_SPREAD_FACTOR = 1.18;
const CAMERA_CONSTRAINTS = {
video: {
facingMode: "user",
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
};
const POSE_JOINTS = [
[0, "nose"],
[7, "left_ear"],
[8, "right_ear"],
[11, "left_shoulder"],
[12, "right_shoulder"],
[13, "left_elbow"],
[14, "right_elbow"],
[15, "left_wrist"],
[16, "right_wrist"],
];
const POSE_BONES = [
["left_shoulder", "left_elbow"],
["left_elbow", "left_wrist"],
["right_shoulder", "right_elbow"],
["right_elbow", "right_wrist"],
["left_shoulder", "right_shoulder"],
];
function nowMs() {
return Math.round(performance?.now?.() || Date.now());
}
function wallClockMs() {
return Date.now();
}
function waitForVideoMetadata(video) {
if (video.videoWidth > 0 && video.videoHeight > 0) {
return Promise.resolve();
}
return new Promise((resolve) => {
const done = () => resolve();
video.addEventListener?.("loadedmetadata", done, { once: true });
video.addEventListener?.("canplay", done, { once: true });
setTimeout(done, VIDEO_METADATA_TIMEOUT_MS);
});
}
function getUserMediaErrorMessage(error) {
if (error?.name === "NotAllowedError" || error?.name === "PermissionDeniedError") {
return "浏览器摄像头权限被拒绝";
}
if (error?.name === "NotFoundError" || error?.name === "DevicesNotFoundError") {
return "没有找到可用摄像头";
}
if (error?.name === "NotReadableError") {
return "摄像头正被其他程序占用";
}
return `浏览器摄像头启动失败: ${error?.message || String(error)}`;
}
function canUseBrowserCamera(mediaDevices) {
return Boolean(
mediaDevices &&
typeof mediaDevices.getUserMedia === "function",
);
}
function isSecureCameraContext() {
if (typeof window === "undefined") return false;
const hostname = window.location?.hostname || "";
return Boolean(window.isSecureContext || hostname === "localhost" || hostname === "127.0.0.1");
}
function normalizePoseLandmarks(landmarks = []) {
return POSE_JOINTS.map(([index, id]) => {
const point = landmarks[index];
if (!point) return null;
const x = Number(point.x);
const y = Number(point.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return {
id,
x: Math.max(0, Math.min(1, x)),
y: Math.max(0, Math.min(1, y)),
confidence: Math.max(0, Math.min(1, Number(point.visibility ?? point.presence ?? 1))),
};
}).filter(Boolean);
}
function getJoint(joints, id) {
return joints.find((joint) => joint.id === id) || null;
}
function vectorBetween(start, end) {
if (!start || !end) return null;
const dx = end.x - start.x;
const dy = end.y - start.y;
return {
dx,
dy,
length: Math.hypot(dx, dy),
};
}
function vectorAngleDeg(vector) {
return Math.atan2(vector.dy, vector.dx) * 180 / Math.PI;
}
function normalizeAngleDelta(angle, target) {
let delta = angle - target;
while (delta > 180) delta -= 360;
while (delta < -180) delta += 360;
return Math.abs(delta);
}
function isAngleNear(angle, target, toleranceDeg) {
return normalizeAngleDelta(angle, target) <= toleranceDeg;
}
function isHorizontalArm(upperVector) {
if (!upperVector || upperVector.length < ARM_PATTERN_MIN_SEGMENT) return false;
const angle = vectorAngleDeg(upperVector);
return (
isAngleNear(angle, 0, ARM_PATTERN_UPPER_TOLERANCE_DEG) ||
isAngleNear(angle, 180, ARM_PATTERN_UPPER_TOLERANCE_DEG)
);
}
function isTerminalToward(vector, targetAngle) {
if (!vector || vector.length < ARM_PATTERN_MIN_SEGMENT) return false;
return isAngleNear(vectorAngleDeg(vector), targetAngle, ARM_PATTERN_TERMINAL_TOLERANCE_DEG);
}
function getRightArmPattern(rightShoulder, rightElbow, rightWrist) {
const upper = vectorBetween(rightShoulder, rightElbow);
const terminal = vectorBetween(rightElbow, rightWrist);
if (!upper || !terminal) return null;
const intensity = Math.min(1, Math.max(MIN_GESTURE_INTENSITY, terminal.length * ARM_PATTERN_INTENSITY_SCALE));
if (isTerminalToward(terminal, 180) && rightWrist.x < rightShoulder.x - ARM_PATTERN_MIN_SIDE_REACH) {
return { gesture: "rotate_right", confidence: 0.82, intensity };
}
if (isTerminalToward(terminal, 0) && rightWrist.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH) {
return { gesture: "rotate_left", confidence: 0.82, intensity };
}
if (isHorizontalArm(upper) && isTerminalToward(terminal, -90) && rightWrist.y < rightElbow.y - ARM_PATTERN_MIN_VERTICAL_REACH) {
return { gesture: "rotate_up", confidence: 0.8, intensity };
}
if (isHorizontalArm(upper) && isTerminalToward(terminal, 90) && rightWrist.y > rightElbow.y + ARM_PATTERN_MIN_VERTICAL_REACH) {
return { gesture: "rotate_down", confidence: 0.8, intensity };
}
return null;
}
function getZoomPattern(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth) {
const leftUpper = vectorBetween(leftShoulder, leftElbow);
const leftTerminal = vectorBetween(leftElbow, leftWrist);
const rightUpper = vectorBetween(rightShoulder, rightElbow);
const rightTerminal = vectorBetween(rightElbow, rightWrist);
if (!leftUpper || !leftTerminal || !rightUpper || !rightTerminal) return null;
const leftWristOutside = leftWrist.x < leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH;
const rightWristOutside = rightWrist.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH;
const leftArmOut =
leftWristOutside &&
leftElbow.x <= leftShoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.25;
const rightArmOut =
rightWristOutside &&
rightElbow.x >= rightShoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.25;
const leftForearmIn = leftWrist.x > leftElbow.x + ARM_PATTERN_MIN_SIDE_REACH * 0.5;
const rightForearmIn = rightWrist.x < rightElbow.x - ARM_PATTERN_MIN_SIDE_REACH * 0.5;
const wristsApart = Math.abs(rightWrist.x - leftWrist.x);
const wristsCloseToCenter = wristsApart < shoulderWidth * ZOOM_CLOSE_WRIST_SPREAD_FACTOR;
const wristsHeightAligned = Math.abs(rightWrist.y - leftWrist.y) <= ZOOM_OPEN_WRIST_HEIGHT_TOLERANCE;
const elbowsOut =
leftElbow.x < leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.5 &&
rightElbow.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.5;
if (leftArmOut && rightArmOut && wristsHeightAligned && wristsApart > shoulderWidth * ZOOM_OPEN_WRIST_SPREAD_FACTOR) {
return { gesture: "zoom_in", confidence: 0.82, intensity: 0.82 };
}
if (elbowsOut && leftForearmIn && rightForearmIn && wristsCloseToCenter) {
return { gesture: "zoom_out", confidence: 0.78, intensity: 0.72 };
}
return null;
}
function isZoomCandidatePose(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth) {
const wristsApart = Math.abs(rightWrist.x - leftWrist.x);
const bothHandsOutside =
leftWrist.x < leftElbow.x - ARM_PATTERN_MIN_SIDE_REACH * 0.25 &&
leftWrist.x < leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.55 &&
rightWrist.x > rightElbow.x + ARM_PATTERN_MIN_SIDE_REACH * 0.25 &&
rightWrist.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.55;
const bothElbowsParticipating =
leftElbow.x <= leftShoulder.x + ARM_PATTERN_MIN_SIDE_REACH &&
rightElbow.x >= rightShoulder.x - ARM_PATTERN_MIN_SIDE_REACH;
const handsNearCenter =
leftElbow.x < leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.5 &&
rightElbow.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.5 &&
leftWrist.x > leftElbow.x &&
rightWrist.x < rightElbow.x &&
wristsApart < shoulderWidth * ZOOM_CLOSE_WRIST_SPREAD_FACTOR;
return (
(bothHandsOutside && bothElbowsParticipating && wristsApart > shoulderWidth * ZOOM_SUPPRESS_WRIST_SPREAD_FACTOR) ||
handsNearCenter
);
}
function applyPoseLatch(observation, state) {
if (!state || !observation) return observation;
if (state.activePatternGesture === observation.gesture) return null;
state.activePatternGesture = observation.gesture;
return observation;
}
function recognizeGesture(joints, previousJoints, options = {}) {
const state = options.state || null;
const leftEar = getJoint(joints, "left_ear");
const rightEar = getJoint(joints, "right_ear");
const leftWrist = getJoint(joints, "left_wrist");
const rightWrist = getJoint(joints, "right_wrist");
const leftElbow = getJoint(joints, "left_elbow");
const rightElbow = getJoint(joints, "right_elbow");
const leftShoulder = getJoint(joints, "left_shoulder");
const rightShoulder = getJoint(joints, "right_shoulder");
const previousLeftWrist = getJoint(previousJoints, "left_wrist");
if (!leftWrist || !rightWrist || !leftElbow || !rightElbow || !leftShoulder || !rightShoulder) return null;
const shoulderWidth = Math.max(0.08, Math.abs(rightShoulder.x - leftShoulder.x));
const leftRaised = leftWrist.y < leftShoulder.y - 0.05;
const rightRaised = rightWrist.y < rightShoulder.y - 0.05;
const leftDeltaX = previousLeftWrist ? leftWrist.x - previousLeftWrist.x : 0;
const leftDeltaY = previousLeftWrist ? leftWrist.y - previousLeftWrist.y : 0;
const headTiltY = leftEar && rightEar ? rightEar.y - leftEar.y : 0;
if (!rightRaised && leftRaised && leftDeltaY < -LEFT_WRIST_LAYER_DELTA_Y) {
return { gesture: "layer_prev", confidence: 0.78, intensity: Math.min(1, Math.abs(leftDeltaY) * WRIST_LAYER_INTENSITY_SCALE) };
}
if (!rightRaised && leftRaised && leftDeltaY > LEFT_WRIST_LAYER_DELTA_Y) {
return { gesture: "layer_next", confidence: 0.78, intensity: Math.min(1, Math.abs(leftDeltaY) * WRIST_LAYER_INTENSITY_SCALE) };
}
if (headTiltY < -HEAD_TILT_DELTA_Y) {
return { gesture: "focus_prev", confidence: 0.78, intensity: Math.min(1, Math.abs(headTiltY) * HEAD_TILT_INTENSITY_SCALE) };
}
if (headTiltY > HEAD_TILT_DELTA_Y) {
return { gesture: "focus_next", confidence: 0.78, intensity: Math.min(1, Math.abs(headTiltY) * HEAD_TILT_INTENSITY_SCALE) };
}
const pattern =
getZoomPattern(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth) ||
(
isZoomCandidatePose(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth)
? null
: getRightArmPattern(rightShoulder, rightElbow, rightWrist)
);
if (pattern) return applyPoseLatch(pattern, state);
if (state) state.activePatternGesture = null;
return null;
}
async function createDefaultRecognizer() {
const { FilesetResolver, PoseLandmarker } = await importMediaPipeTasksVision();
const vision = await FilesetResolver.forVisionTasks(MEDIAPIPE_WASM_URL);
const pose = await PoseLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath: POSE_MODEL_URL,
delegate: "GPU",
},
runningMode: "VIDEO",
numPoses: 1,
});
return {
recognize(video, timestampMs) {
const result = pose.detectForVideo(video, timestampMs);
const landmarks = result?.landmarks?.[0] || [];
return normalizePoseLandmarks(landmarks);
},
close() {
pose.close?.();
},
};
}
async function importMediaPipeTasksVision() {
const failures = [];
for (const moduleUrl of MEDIAPIPE_TASKS_URLS) {
try {
const module = await import(moduleUrl);
if (module?.FilesetResolver && module?.PoseLandmarker) {
return module;
}
failures.push(`${moduleUrl}: missing MediaPipe exports`);
} catch (error) {
failures.push(`${moduleUrl}: ${error?.message || String(error)}`);
}
}
const error = new Error("无法加载 MediaPipe Tasks Vision 模块,请检查网络或切换 Motion Agent");
error.details = failures;
throw error;
}
export function createBrowserCameraProvider(options = {}) {
const {
mediaDevices = typeof navigator !== "undefined" ? navigator.mediaDevices : null,
recognizerFactory = createDefaultRecognizer,
requestAnimationFrameFn =
typeof requestAnimationFrame !== "undefined"
? requestAnimationFrame.bind(globalThis)
: (callback) => setTimeout(() => callback(nowMs()), 16),
cancelAnimationFrameFn =
typeof cancelAnimationFrame !== "undefined"
? cancelAnimationFrame.bind(globalThis)
: clearTimeout,
onMessage = () => {},
onState = () => {},
onStatus = () => {},
onVideoSource = () => {},
} = options;
let disposed = false;
let connected = false;
let stream = null;
let video = null;
let recognizer = null;
let rafId = null;
let lastFrameAt = 0;
let lastGestureAt = 0;
let seq = 0;
let previousJoints = [];
const gestureState = {};
function emitState(detail = {}) {
onState({
provider: "browser_camera",
connected,
...detail,
});
}
function emitStatus(message, type = "info", extra = {}) {
onStatus(message, type);
emitState({ message, ...extra });
}
function stopStream() {
onVideoSource({
provider: "browser_camera",
source: null,
active: false,
});
if (stream) {
stream.getTracks?.().forEach((track) => track.stop?.());
stream = null;
}
if (video) {
video.pause?.();
video.srcObject = null;
video.remove?.();
video = null;
}
}
function emitSkeleton(joints, matchedGesture = null, confidence = 0) {
onMessage({
type: "skeleton",
timestamp_ms: wallClockMs(),
source: "browser-camera",
mode: "single",
camera_id: "browser:getUserMedia",
matched_gesture: matchedGesture,
confidence,
joints,
bones: POSE_BONES,
});
}
function emitGesture(observation) {
seq += 1;
onMessage({
type: "gesture",
gesture: observation.gesture,
phase: "discrete",
confidence: observation.confidence,
intensity: observation.intensity,
timestamp_ms: wallClockMs(),
seq,
source: "browser-camera",
mode: "single",
payload: {},
});
}
function scheduleFrame() {
if (disposed) return;
rafId = requestAnimationFrameFn(processFrame);
}
function processFrame(timestamp) {
if (disposed || !video || !recognizer) return;
if (timestamp - lastFrameAt < FRAME_INTERVAL_MS) {
scheduleFrame();
return;
}
lastFrameAt = timestamp;
try {
const joints = recognizer.recognize(video, timestamp) || [];
const currentWallMs = wallClockMs();
const observation = recognizeGesture(joints, previousJoints, {
state: gestureState,
timestampMs: currentWallMs,
});
const canEmitGesture = observation && currentWallMs - lastGestureAt >= GESTURE_COOLDOWN_MS;
if (canEmitGesture) {
lastGestureAt = currentWallMs;
emitGesture(observation);
}
emitSkeleton(
joints,
observation?.gesture || null,
observation?.confidence || 0,
);
previousJoints = joints;
} catch (error) {
emitStatus(`浏览器动捕识别失败: ${error?.message || String(error)}`, "error", {
error: "recognition_failed",
});
}
scheduleFrame();
}
async function startCamera() {
if (!canUseBrowserCamera(mediaDevices)) {
emitStatus("当前浏览器不支持 getUserMedia 摄像头接口", "error", {
error: "get_user_media_unavailable",
});
return false;
}
if (!isSecureCameraContext()) {
emitStatus("浏览器摄像头需要 HTTPS 或 localhost 环境", "error", {
error: "insecure_context",
});
return false;
}
try {
recognizer = await recognizerFactory();
} catch (error) {
connected = false;
emitStatus(`浏览器动捕模型加载失败: ${error?.message || String(error)}`, "error", {
error: "model_load_failed",
});
return false;
}
try {
stream = await mediaDevices.getUserMedia(CAMERA_CONSTRAINTS);
video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.autoplay = true;
video.style.display = "none";
video.srcObject = stream;
document.body.appendChild(video);
await video.play();
await waitForVideoMetadata(video);
onVideoSource({
provider: "browser_camera",
source: video,
active: true,
});
} catch (error) {
connected = false;
recognizer?.close?.();
recognizer = null;
stopStream();
emitStatus(getUserMediaErrorMessage(error), "error", {
error: "browser_camera_failed",
});
return false;
}
connected = true;
emitStatus("浏览器摄像头动捕已连接", "info");
scheduleFrame();
return true;
}
return {
provider: "browser_camera",
async start() {
if (disposed) return false;
return startCamera();
},
stop() {
disposed = true;
if (rafId) {
cancelAnimationFrameFn(rafId);
rafId = null;
}
recognizer?.close?.();
recognizer = null;
stopStream();
connected = false;
emitState({ connected: false });
},
isConnected() {
return connected;
},
};
}
export {
CAMERA_CONSTRAINTS,
importMediaPipeTasksVision,
POSE_BONES,
recognizeGesture,
};