a30a9a2d29
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
In dev, <StrictMode> intentionally mounts → unmounts → remounts each effect to surface non-idempotent code. The hand tracking hooks were calling getUserMedia and creating MediaPipe / WebSocket runtimes on every mount, which in practice ran the full start/stop/start cycle inside a few milliseconds and pushed WebGL over its limit on top of the loaded scene → context lost. Add HAND_TRACKING_RUNTIME_START_DELAY_MS (80ms) and delay the actual start() call behind a setTimeout in both useBrowserHandTracking and useRemoteHandTracking. The cleanup clears the timer, so a fast mount/unmount never reaches start(). 80ms is invisible to the user (<5 frames at 60fps) and also absorbs rapid `nearby` toggles at trigger borders. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
190 lines
5.2 KiB
TypeScript
190 lines
5.2 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
HAND_TRACKING_FRAME_HEIGHT,
|
|
HAND_TRACKING_FRAME_WIDTH,
|
|
HAND_TRACKING_RUNTIME_START_DELAY_MS,
|
|
HAND_TRACKING_TARGET_FPS,
|
|
} from "@/data/handTrackingConfig";
|
|
import {
|
|
convertBrowserHandResult,
|
|
getBrowserHandLandmarker,
|
|
releaseBrowserHandLandmarker,
|
|
} from "@/lib/handTracking/browserHandTracking";
|
|
import {
|
|
INITIAL_HAND_TRACKING_SNAPSHOT,
|
|
getCameraStreamWithTimeout,
|
|
} from "@/lib/handTracking/handTrackingSession";
|
|
import type { HandTrackingSnapshot } from "@/types/handTracking/handTracking";
|
|
import { logger } from "@/utils/core/Logger";
|
|
|
|
interface UseBrowserHandTrackingOptions {
|
|
enabled: boolean;
|
|
}
|
|
|
|
export function useBrowserHandTracking({
|
|
enabled,
|
|
}: UseBrowserHandTrackingOptions): HandTrackingSnapshot {
|
|
const [snapshot, setSnapshot] = useState<HandTrackingSnapshot>(
|
|
INITIAL_HAND_TRACKING_SNAPSHOT,
|
|
);
|
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
const streamRef = useRef<MediaStream | null>(null);
|
|
const intervalRef = useRef<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) {
|
|
return undefined;
|
|
}
|
|
|
|
let cancelled = false;
|
|
let cleanedUp = false;
|
|
|
|
const cleanup = (): void => {
|
|
if (cleanedUp) return;
|
|
cleanedUp = true;
|
|
|
|
if (intervalRef.current !== null) {
|
|
window.clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
|
|
streamRef.current?.getTracks().forEach((track) => track.stop());
|
|
streamRef.current = null;
|
|
videoRef.current = null;
|
|
releaseBrowserHandLandmarker();
|
|
};
|
|
|
|
const start = async (): Promise<void> => {
|
|
setSnapshot({
|
|
hands: [],
|
|
status: "requesting_camera",
|
|
usageStatus: "available",
|
|
serverStatus: "Browser JS",
|
|
error: null,
|
|
});
|
|
|
|
try {
|
|
const stream = await getCameraStreamWithTimeout({
|
|
video: {
|
|
width: HAND_TRACKING_FRAME_WIDTH,
|
|
height: HAND_TRACKING_FRAME_HEIGHT,
|
|
facingMode: "user",
|
|
},
|
|
audio: false,
|
|
});
|
|
|
|
if (cancelled) {
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
return;
|
|
}
|
|
|
|
setSnapshot((current) => ({
|
|
...current,
|
|
status: "starting_camera",
|
|
}));
|
|
|
|
const video = document.createElement("video");
|
|
video.muted = true;
|
|
video.playsInline = true;
|
|
video.srcObject = stream;
|
|
await video.play();
|
|
|
|
if (cancelled) {
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
return;
|
|
}
|
|
|
|
setSnapshot((current) => ({
|
|
...current,
|
|
status: "connecting",
|
|
serverStatus: "Loading Browser JS model",
|
|
}));
|
|
|
|
const handLandmarker = await getBrowserHandLandmarker();
|
|
|
|
if (cancelled) {
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
return;
|
|
}
|
|
|
|
streamRef.current = stream;
|
|
videoRef.current = video;
|
|
|
|
setSnapshot((current) => ({
|
|
...current,
|
|
status: "connected",
|
|
serverStatus: "Browser JS",
|
|
}));
|
|
|
|
intervalRef.current = window.setInterval(() => {
|
|
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
|
|
|
|
try {
|
|
const result = handLandmarker.detectForVideo(
|
|
video,
|
|
performance.now(),
|
|
);
|
|
const hands = convertBrowserHandResult(result);
|
|
|
|
setSnapshot((current) => ({
|
|
...current,
|
|
hands,
|
|
usageStatus: hands.some((hand) => hand.isFist)
|
|
? "active"
|
|
: "available",
|
|
error: null,
|
|
}));
|
|
} catch (error) {
|
|
logger.error("HandTracking", "Browser JS runtime error", {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
cleanup();
|
|
setSnapshot({
|
|
hands: [],
|
|
status: "error",
|
|
usageStatus: "inactive",
|
|
serverStatus: "Browser JS",
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Browser hand tracking failed",
|
|
});
|
|
}
|
|
}, 1_000 / HAND_TRACKING_TARGET_FPS);
|
|
} catch (error) {
|
|
if (cancelled) return;
|
|
|
|
logger.error("HandTracking", "Browser JS runtime failed", {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
setSnapshot({
|
|
hands: [],
|
|
status: "error",
|
|
usageStatus: "inactive",
|
|
serverStatus: "Browser JS",
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Browser hand tracking failed",
|
|
});
|
|
}
|
|
};
|
|
|
|
// Delay the actual start so that a StrictMode mount/unmount/mount
|
|
// cycle, or a rapid `enabled` toggle at a trigger border, does not
|
|
// spin up the camera + MediaPipe twice in a few milliseconds.
|
|
const startTimer = window.setTimeout(() => {
|
|
if (cancelled) return;
|
|
void start();
|
|
}, HAND_TRACKING_RUNTIME_START_DELAY_MS);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearTimeout(startTimer);
|
|
cleanup();
|
|
};
|
|
}, [enabled]);
|
|
|
|
return snapshot;
|
|
}
|