Files
La-Fabrik/src/hooks/handTracking/useBrowserHandTracking.ts
T
Tom Boullay 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
fix(handtracking): absorb React StrictMode double-mount
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>
2026-06-02 16:54:28 +02:00

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