549 lines
18 KiB
JavaScript
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,
|
|
};
|