import { useEffect, useRef, useState } from 'react';
import { EffectComposer, RenderPass, EffectPass, BloomEffect, ChromaticAberrationEffect } from 'postprocessing';
import * as THREE from 'three';
import * as faceapi from 'face-api.js';
const vert = `
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
const frag = `
precision highp float;
uniform vec3 iResolution;
uniform float iTime;
uniform vec2 uSkew;
uniform float uTilt;
uniform float uYaw;
uniform float uLineThickness;
uniform vec3 uLinesColor;
uniform vec3 uScanColor;
uniform float uGridScale;
uniform float uLineStyle;
uniform float uLineJitter;
uniform float uScanOpacity;
uniform float uScanDirection;
uniform float uNoise;
uniform float uBloomOpacity;
uniform float uScanGlow;
uniform float uScanSoftness;
uniform float uPhaseTaper;
uniform float uScanDuration;
uniform float uScanDelay;
varying vec2 vUv;
uniform float uScanStarts[8];
uniform float uScanCount;
const int MAX_SCANS = 8;
float smoother01(float a, float b, float x){
float t = clamp((x - a) / max(1e-5, (b - a)), 0.0, 1.0);
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}
void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
vec3 ro = vec3(0.0);
vec3 rd = normalize(vec3(p, 2.0));
float cR = cos(uTilt), sR = sin(uTilt);
rd.xy = mat2(cR, -sR, sR, cR) * rd.xy;
float cY = cos(uYaw), sY = sin(uYaw);
rd.xz = mat2(cY, -sY, sY, cY) * rd.xz;
vec2 skew = clamp(uSkew, vec2(-0.7), vec2(0.7));
rd.xy += skew * rd.z;
vec3 color = vec3(0.0);
float minT = 1e20;
float gridScale = max(1e-5, uGridScale);
float fadeStrength = 2.0;
vec2 gridUV = vec2(0.0);
float hitIsY = 1.0;
for (int i = 0; i < 4; i++)
{
float isY = float(i < 2);
float pos = mix(-0.2, 0.2, float(i)) * isY + mix(-0.5, 0.5, float(i - 2)) * (1.0 - isY);
float num = pos - (isY * ro.y + (1.0 - isY) * ro.x);
float den = isY * rd.y + (1.0 - isY) * rd.x;
float t = num / den;
vec3 h = ro + rd * t;
float depthBoost = smoothstep(0.0, 3.0, h.z);
h.xy += skew * 0.15 * depthBoost;
bool use = t > 0.0 && t < minT;
gridUV = use ? mix(h.zy, h.xz, isY) / gridScale : gridUV;
minT = use ? t : minT;
hitIsY = use ? isY : hitIsY;
}
vec3 hit = ro + rd * minT;
float dist = length(hit - ro);
float jitterAmt = clamp(uLineJitter, 0.0, 1.0);
if (jitterAmt > 0.0) {
vec2 j = vec2(
sin(gridUV.y * 2.7 + iTime * 1.8),
cos(gridUV.x * 2.3 - iTime * 1.6)
) * (0.15 * jitterAmt);
gridUV += j;
}
float fx = fract(gridUV.x);
float fy = fract(gridUV.y);
float ax = min(fx, 1.0 - fx);
float ay = min(fy, 1.0 - fy);
float wx = fwidth(gridUV.x);
float wy = fwidth(gridUV.y);
float halfPx = max(0.0, uLineThickness) * 0.5;
float tx = halfPx * wx;
float ty = halfPx * wy;
float aax = wx;
float aay = wy;
float lineX = 1.0 - smoothstep(tx, tx + aax, ax);
float lineY = 1.0 - smoothstep(ty, ty + aay, ay);
if (uLineStyle > 0.5) {
float dashRepeat = 4.0;
float dashDuty = 0.5;
float vy = fract(gridUV.y * dashRepeat);
float vx = fract(gridUV.x * dashRepeat);
float dashMaskY = step(vy, dashDuty);
float dashMaskX = step(vx, dashDuty);
if (uLineStyle < 1.5) {
lineX *= dashMaskY;
lineY *= dashMaskX;
} else {
float dotRepeat = 6.0;
float dotWidth = 0.18;
float cy = abs(fract(gridUV.y * dotRepeat) - 0.5);
float cx = abs(fract(gridUV.x * dotRepeat) - 0.5);
float dotMaskY = 1.0 - smoothstep(dotWidth, dotWidth + fwidth(gridUV.y * dotRepeat), cy);
float dotMaskX = 1.0 - smoothstep(dotWidth, dotWidth + fwidth(gridUV.x * dotRepeat), cx);
lineX *= dotMaskY;
lineY *= dotMaskX;
}
}
float primaryMask = max(lineX, lineY);
vec2 gridUV2 = (hitIsY > 0.5 ? hit.xz : hit.zy) / gridScale;
if (jitterAmt > 0.0) {
vec2 j2 = vec2(
cos(gridUV2.y * 2.1 - iTime * 1.4),
sin(gridUV2.x * 2.5 + iTime * 1.7)
) * (0.15 * jitterAmt);
gridUV2 += j2;
}
float fx2 = fract(gridUV2.x);
float fy2 = fract(gridUV2.y);
float ax2 = min(fx2, 1.0 - fx2);
float ay2 = min(fy2, 1.0 - fy2);
float wx2 = fwidth(gridUV2.x);
float wy2 = fwidth(gridUV2.y);
float tx2 = halfPx * wx2;
float ty2 = halfPx * wy2;
float aax2 = wx2;
float aay2 = wy2;
float lineX2 = 1.0 - smoothstep(tx2, tx2 + aax2, ax2);
float lineY2 = 1.0 - smoothstep(ty2, ty2 + aay2, ay2);
if (uLineStyle > 0.5) {
float dashRepeat2 = 4.0;
float dashDuty2 = 0.5;
float vy2m = fract(gridUV2.y * dashRepeat2);
float vx2m = fract(gridUV2.x * dashRepeat2);
float dashMaskY2 = step(vy2m, dashDuty2);
float dashMaskX2 = step(vx2m, dashDuty2);
if (uLineStyle < 1.5) {
lineX2 *= dashMaskY2;
lineY2 *= dashMaskX2;
} else {
float dotRepeat2 = 6.0;
float dotWidth2 = 0.18;
float cy2 = abs(fract(gridUV2.y * dotRepeat2) - 0.5);
float cx2 = abs(fract(gridUV2.x * dotRepeat2) - 0.5);
float dotMaskY2 = 1.0 - smoothstep(dotWidth2, dotWidth2 + fwidth(gridUV2.y * dotRepeat2), cy2);
float dotMaskX2 = 1.0 - smoothstep(dotWidth2, dotWidth2 + fwidth(gridUV2.x * dotRepeat2), cx2);
lineX2 *= dotMaskY2;
lineY2 *= dotMaskX2;
}
}
float altMask = max(lineX2, lineY2);
float edgeDistX = min(abs(hit.x - (-0.5)), abs(hit.x - 0.5));
float edgeDistY = min(abs(hit.y - (-0.2)), abs(hit.y - 0.2));
float edgeDist = mix(edgeDistY, edgeDistX, hitIsY);
float edgeGate = 1.0 - smoothstep(gridScale * 0.5, gridScale * 2.0, edgeDist);
altMask *= edgeGate;
float lineMask = max(primaryMask, altMask);
float fade = exp(-dist * fadeStrength);
float dur = max(0.05, uScanDuration);
float del = max(0.0, uScanDelay);
float scanZMax = 2.0;
float widthScale = max(0.1, uScanGlow);
float sigma = max(0.001, 0.18 * widthScale * uScanSoftness);
float sigmaA = sigma * 2.0;
float combinedPulse = 0.0;
float combinedAura = 0.0;
float cycle = dur + del;
float tCycle = mod(iTime, cycle);
float scanPhase = clamp((tCycle - del) / dur, 0.0, 1.0);
float phase = scanPhase;
if (uScanDirection > 0.5 && uScanDirection < 1.5) {
phase = 1.0 - phase;
} else if (uScanDirection > 1.5) {
float t2 = mod(max(0.0, iTime - del), 2.0 * dur);
phase = (t2 < dur) ? (t2 / dur) : (1.0 - (t2 - dur) / dur);
}
float scanZ = phase * scanZMax;
float dz = abs(hit.z - scanZ);
float lineBand = exp(-0.5 * (dz * dz) / (sigma * sigma));
float taper = clamp(uPhaseTaper, 0.0, 0.49);
float headW = taper;
float tailW = taper;
float headFade = smoother01(0.0, headW, phase);
float tailFade = 1.0 - smoother01(1.0 - tailW, 1.0, phase);
float phaseWindow = headFade * tailFade;
float pulseBase = lineBand * phaseWindow;
combinedPulse += pulseBase * clamp(uScanOpacity, 0.0, 1.0);
float auraBand = exp(-0.5 * (dz * dz) / (sigmaA * sigmaA));
combinedAura += (auraBand * 0.25) * phaseWindow * clamp(uScanOpacity, 0.0, 1.0);
for (int i = 0; i < MAX_SCANS; i++) {
if (float(i) >= uScanCount) break;
float tActiveI = iTime - uScanStarts[i];
float phaseI = clamp(tActiveI / dur, 0.0, 1.0);
if (uScanDirection > 0.5 && uScanDirection < 1.5) {
phaseI = 1.0 - phaseI;
} else if (uScanDirection > 1.5) {
phaseI = (phaseI < 0.5) ? (phaseI * 2.0) : (1.0 - (phaseI - 0.5) * 2.0);
}
float scanZI = phaseI * scanZMax;
float dzI = abs(hit.z - scanZI);
float lineBandI = exp(-0.5 * (dzI * dzI) / (sigma * sigma));
float headFadeI = smoother01(0.0, headW, phaseI);
float tailFadeI = 1.0 - smoother01(1.0 - tailW, 1.0, phaseI);
float phaseWindowI = headFadeI * tailFadeI;
combinedPulse += lineBandI * phaseWindowI * clamp(uScanOpacity, 0.0, 1.0);
float auraBandI = exp(-0.5 * (dzI * dzI) / (sigmaA * sigmaA));
combinedAura += (auraBandI * 0.25) * phaseWindowI * clamp(uScanOpacity, 0.0, 1.0);
}
float lineVis = lineMask;
vec3 gridCol = uLinesColor * lineVis * fade;
vec3 scanCol = uScanColor * combinedPulse;
vec3 scanAura = uScanColor * combinedAura;
color = gridCol + scanCol + scanAura;
float n = fract(sin(dot(gl_FragCoord.xy + vec2(iTime * 123.4), vec2(12.9898,78.233))) * 43758.5453123);
color += (n - 0.5) * uNoise;
color = clamp(color, 0.0, 1.0);
float alpha = clamp(max(lineVis, combinedPulse), 0.0, 1.0);
float gx = 1.0 - smoothstep(tx * 2.0, tx * 2.0 + aax * 2.0, ax);
float gy = 1.0 - smoothstep(ty * 2.0, ty * 2.0 + aay * 2.0, ay);
float halo = max(gx, gy) * fade;
alpha = max(alpha, halo * clamp(uBloomOpacity, 0.0, 1.0));
fragColor = vec4(color, alpha);
}
void main(){
vec4 c;
mainImage(c, vUv * iResolution.xy);
gl_FragColor = c;
}
`;
export const GridScan = ({
enableWebcam = false,
showPreview = false,
modelsPath = 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights',
sensitivity = 0.55,
lineThickness = 1,
linesColor = '#392e4e',
scanColor = '#FF9FFC',
scanOpacity = 0.4,
gridScale = 0.1,
lineStyle = 'solid',
lineJitter = 0.1,
scanDirection = 'pingpong',
enablePost = true,
bloomIntensity = 0,
bloomThreshold = 0,
bloomSmoothing = 0,
chromaticAberration = 0.002,
noiseIntensity = 0.01,
scanGlow = 0.5,
scanSoftness = 2,
scanPhaseTaper = 0.9,
scanDuration = 2.0,
scanDelay = 2.0,
enableGyro = false,
scanOnClick = false,
snapBackDelay = 250,
className,
style
}) => {
const containerRef = useRef(null);
const videoRef = useRef(null);
const rendererRef = useRef(null);
const materialRef = useRef(null);
const composerRef = useRef(null);
const bloomRef = useRef(null);
const chromaRef = useRef(null);
const rafRef = useRef(null);
const [modelsReady, setModelsReady] = useState(false);
const [uiFaceActive, setUiFaceActive] = useState(false);
const lookTarget = useRef(new THREE.Vector2(0, 0));
const tiltTarget = useRef(0);
const yawTarget = useRef(0);
const lookCurrent = useRef(new THREE.Vector2(0, 0));
const lookVel = useRef(new THREE.Vector2(0, 0));
const tiltCurrent = useRef(0);
const tiltVel = useRef(0);
const yawCurrent = useRef(0);
const yawVel = useRef(0);
const MAX_SCANS = 8;
const scanStartsRef = useRef([]);
const pushScan = t => {
const arr = scanStartsRef.current.slice();
if (arr.length >= MAX_SCANS) arr.shift();
arr.push(t);
scanStartsRef.current = arr;
if (materialRef.current) {
const u = materialRef.current.uniforms;
const buf = new Array(MAX_SCANS).fill(0);
for (let i = 0; i < arr.length && i < MAX_SCANS; i++) buf[i] = arr[i];
u.uScanStarts.value = buf;
u.uScanCount.value = arr.length;
}
};
const bufX = useRef([]);
const bufY = useRef([]);
const bufT = useRef([]);
const bufYaw = useRef([]);
const s = THREE.MathUtils.clamp(sensitivity, 0, 1);
const skewScale = THREE.MathUtils.lerp(0.06, 0.2, s);
const tiltScale = THREE.MathUtils.lerp(0.12, 0.3, s);
const yawScale = THREE.MathUtils.lerp(0.1, 0.28, s);
const depthResponse = THREE.MathUtils.lerp(0.25, 0.45, s);
const smoothTime = THREE.MathUtils.lerp(0.45, 0.12, s);
const maxSpeed = Infinity;
const yBoost = THREE.MathUtils.lerp(1.2, 1.6, s);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
let leaveTimer = null;
const onMove = e => {
if (uiFaceActive) return;
if (leaveTimer) {
clearTimeout(leaveTimer);
leaveTimer = null;
}
const rect = el.getBoundingClientRect();
const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1);
lookTarget.current.set(nx, ny);
};
const onClick = async () => {
const nowSec = performance.now() / 1000;
if (scanOnClick) pushScan(nowSec);
if (
enableGyro &&
typeof window !== 'undefined' &&
window.DeviceOrientationEvent &&
DeviceOrientationEvent.requestPermission
) {
try {
await DeviceOrientationEvent.requestPermission();
} catch {
// noop
}
}
};
const onEnter = () => {
if (leaveTimer) {
clearTimeout(leaveTimer);
leaveTimer = null;
}
};
const onLeave = () => {
if (uiFaceActive) return;
if (leaveTimer) clearTimeout(leaveTimer);
leaveTimer = window.setTimeout(
() => {
lookTarget.current.set(0, 0);
tiltTarget.current = 0;
yawTarget.current = 0;
},
Math.max(0, snapBackDelay || 0)
);
};
el.addEventListener('mousemove', onMove);
el.addEventListener('mouseenter', onEnter);
if (scanOnClick) el.addEventListener('click', onClick);
el.addEventListener('mouseleave', onLeave);
return () => {
el.removeEventListener('mousemove', onMove);
el.removeEventListener('mouseenter', onEnter);
el.removeEventListener('mouseleave', onLeave);
if (scanOnClick) el.removeEventListener('click', onClick);
if (leaveTimer) clearTimeout(leaveTimer);
};
}, [uiFaceActive, snapBackDelay, scanOnClick, enableGyro]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
rendererRef.current = renderer;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.NoToneMapping;
renderer.autoClear = false;
renderer.setClearColor(0x000000, 0);
container.appendChild(renderer.domElement);
const uniforms = {
iResolution: {
value: new THREE.Vector3(container.clientWidth, container.clientHeight, renderer.getPixelRatio())
},
iTime: { value: 0 },
uSkew: { value: new THREE.Vector2(0, 0) },
uTilt: { value: 0 },
uYaw: { value: 0 },
uLineThickness: { value: lineThickness },
uLinesColor: { value: srgbColor(linesColor) },
uScanColor: { value: srgbColor(scanColor) },
uGridScale: { value: gridScale },
uLineStyle: { value: lineStyle === 'dashed' ? 1 : lineStyle === 'dotted' ? 2 : 0 },
uLineJitter: { value: Math.max(0, Math.min(1, lineJitter || 0)) },
uScanOpacity: { value: scanOpacity },
uNoise: { value: noiseIntensity },
uBloomOpacity: { value: bloomIntensity },
uScanGlow: { value: scanGlow },
uScanSoftness: { value: scanSoftness },
uPhaseTaper: { value: scanPhaseTaper },
uScanDuration: { value: scanDuration },
uScanDelay: { value: scanDelay },
uScanDirection: { value: scanDirection === 'backward' ? 1 : scanDirection === 'pingpong' ? 2 : 0 },
uScanStarts: { value: new Array(MAX_SCANS).fill(0) },
uScanCount: { value: 0 }
};
const material = new THREE.ShaderMaterial({
uniforms,
vertexShader: vert,
fragmentShader: frag,
transparent: true,
depthWrite: false,
depthTest: false
});
materialRef.current = material;
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
scene.add(quad);
let composer = null;
if (enablePost) {
composer = new EffectComposer(renderer);
composerRef.current = composer;
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
const bloom = new BloomEffect({
intensity: 1.0,
luminanceThreshold: bloomThreshold,
luminanceSmoothing: bloomSmoothing
});
bloom.blendMode.opacity.value = Math.max(0, bloomIntensity);
bloomRef.current = bloom;
const chroma = new ChromaticAberrationEffect({
offset: new THREE.Vector2(chromaticAberration, chromaticAberration),
radialModulation: true,
modulationOffset: 0.0
});
chromaRef.current = chroma;
const effectPass = new EffectPass(camera, bloom, chroma);
effectPass.renderToScreen = true;
composer.addPass(effectPass);
}
const onResize = () => {
renderer.setSize(container.clientWidth, container.clientHeight);
material.uniforms.iResolution.value.set(container.clientWidth, container.clientHeight, renderer.getPixelRatio());
if (composerRef.current) composerRef.current.setSize(container.clientWidth, container.clientHeight);
};
window.addEventListener('resize', onResize);
let last = performance.now();
const tick = () => {
const now = performance.now();
const dt = Math.max(0, Math.min(0.1, (now - last) / 1000));
last = now;
lookCurrent.current.copy(
smoothDampVec2(lookCurrent.current, lookTarget.current, lookVel.current, smoothTime, maxSpeed, dt)
);
const tiltSm = smoothDampFloat(
tiltCurrent.current,
tiltTarget.current,
{ v: tiltVel.current },
smoothTime,
maxSpeed,
dt
);
tiltCurrent.current = tiltSm.value;
tiltVel.current = tiltSm.v;
const yawSm = smoothDampFloat(
yawCurrent.current,
yawTarget.current,
{ v: yawVel.current },
smoothTime,
maxSpeed,
dt
);
yawCurrent.current = yawSm.value;
yawVel.current = yawSm.v;
const skew = new THREE.Vector2(lookCurrent.current.x * skewScale, -lookCurrent.current.y * yBoost * skewScale);
material.uniforms.uSkew.value.set(skew.x, skew.y);
material.uniforms.uTilt.value = tiltCurrent.current * tiltScale;
material.uniforms.uYaw.value = THREE.MathUtils.clamp(yawCurrent.current * yawScale, -0.6, 0.6);
material.uniforms.iTime.value = now / 1000;
renderer.clear(true, true, true);
if (composerRef.current) {
composerRef.current.render(dt);
} else {
renderer.render(scene, camera);
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
window.removeEventListener('resize', onResize);
material.dispose();
quad.geometry.dispose();
if (composerRef.current) {
composerRef.current.dispose();
composerRef.current = null;
}
renderer.dispose();
container.removeChild(renderer.domElement);
};
}, [
sensitivity,
lineThickness,
linesColor,
scanColor,
scanOpacity,
gridScale,
lineStyle,
lineJitter,
scanDirection,
enablePost,
noiseIntensity,
bloomIntensity,
scanGlow,
scanSoftness,
scanPhaseTaper,
scanDuration,
scanDelay,
bloomThreshold,
bloomSmoothing,
chromaticAberration,
smoothTime,
maxSpeed,
skewScale,
yBoost,
tiltScale,
yawScale
]);
useEffect(() => {
const m = materialRef.current;
if (m) {
const u = m.uniforms;
u.uLineThickness.value = lineThickness;
u.uLinesColor.value.copy(srgbColor(linesColor));
u.uScanColor.value.copy(srgbColor(scanColor));
u.uGridScale.value = gridScale;
u.uLineStyle.value = lineStyle === 'dashed' ? 1 : lineStyle === 'dotted' ? 2 : 0;
u.uLineJitter.value = Math.max(0, Math.min(1, lineJitter || 0));
u.uBloomOpacity.value = Math.max(0, bloomIntensity);
u.uNoise.value = Math.max(0, noiseIntensity);
u.uScanGlow.value = scanGlow;
u.uScanOpacity.value = Math.max(0, Math.min(1, scanOpacity));
u.uScanDirection.value = scanDirection === 'backward' ? 1 : scanDirection === 'pingpong' ? 2 : 0;
u.uScanSoftness.value = scanSoftness;
u.uPhaseTaper.value = scanPhaseTaper;
u.uScanDuration.value = Math.max(0.05, scanDuration);
u.uScanDelay.value = Math.max(0.0, scanDelay);
}
if (bloomRef.current) {
bloomRef.current.blendMode.opacity.value = Math.max(0, bloomIntensity);
bloomRef.current.luminanceMaterial.threshold = bloomThreshold;
bloomRef.current.luminanceMaterial.smoothing = bloomSmoothing;
}
if (chromaRef.current) {
chromaRef.current.offset.set(chromaticAberration, chromaticAberration);
}
}, [
lineThickness,
linesColor,
scanColor,
gridScale,
lineStyle,
lineJitter,
bloomIntensity,
bloomThreshold,
bloomSmoothing,
chromaticAberration,
noiseIntensity,
scanGlow,
scanOpacity,
scanDirection,
scanSoftness,
scanPhaseTaper,
scanDuration,
scanDelay
]);
useEffect(() => {
if (!enableGyro) return;
const handler = e => {
if (uiFaceActive) return;
const gamma = e.gamma ?? 0;
const beta = e.beta ?? 0;
const nx = THREE.MathUtils.clamp(gamma / 45, -1, 1);
const ny = THREE.MathUtils.clamp(-beta / 30, -1, 1);
lookTarget.current.set(nx, ny);
tiltTarget.current = THREE.MathUtils.degToRad(gamma) * 0.4;
};
window.addEventListener('deviceorientation', handler);
return () => {
window.removeEventListener('deviceorientation', handler);
};
}, [enableGyro, uiFaceActive]);
useEffect(() => {
let canceled = false;
const load = async () => {
try {
await Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri(modelsPath),
faceapi.nets.faceLandmark68TinyNet.loadFromUri(modelsPath)
]);
if (!canceled) setModelsReady(true);
} catch {
if (!canceled) setModelsReady(false);
}
};
load();
return () => {
canceled = true;
};
}, [modelsPath]);
useEffect(() => {
let stop = false;
let lastDetect = 0;
const video = videoRef.current;
const start = async () => {
if (!enableWebcam || !modelsReady) return;
if (!video) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false
});
video.srcObject = stream;
await video.play();
} catch {
return;
}
const opts = new faceapi.TinyFaceDetectorOptions({ inputSize: 320, scoreThreshold: 0.5 });
const detect = async ts => {
if (stop) return;
if (ts - lastDetect >= 33) {
lastDetect = ts;
try {
const res = await faceapi.detectSingleFace(video, opts).withFaceLandmarks(true);
if (res && res.detection) {
const det = res.detection;
const box = det.box;
const vw = video.videoWidth || 1;
const vh = video.videoHeight || 1;
const cx = box.x + box.width * 0.5;
const cy = box.y + box.height * 0.5;
const nx = (cx / vw) * 2 - 1;
const ny = (cy / vh) * 2 - 1;
medianPush(bufX.current, nx, 5);
medianPush(bufY.current, ny, 5);
const nxm = median(bufX.current);
const nym = median(bufY.current);
const look = new THREE.Vector2(Math.tanh(nxm), Math.tanh(nym));
const faceSize = Math.min(1, Math.hypot(box.width / vw, box.height / vh));
const depthScale = 1 + depthResponse * (faceSize - 0.25);
lookTarget.current.copy(look.multiplyScalar(depthScale));
const leftEye = res.landmarks.getLeftEye();
const rightEye = res.landmarks.getRightEye();
const lc = centroid(leftEye);
const rc = centroid(rightEye);
const tilt = Math.atan2(rc.y - lc.y, rc.x - lc.x);
medianPush(bufT.current, tilt, 5);
tiltTarget.current = median(bufT.current);
const nose = res.landmarks.getNose();
const tip = nose[nose.length - 1] || nose[Math.floor(nose.length / 2)];
const jaw = res.landmarks.getJawOutline();
const leftCheek = jaw[3] || jaw[2];
const rightCheek = jaw[13] || jaw[14];
const dL = dist2(tip, leftCheek);
const dR = dist2(tip, rightCheek);
const eyeDist = Math.hypot(rc.x - lc.x, rc.y - lc.y) + 1e-6;
let yawSignal = THREE.MathUtils.clamp((dR - dL) / (eyeDist * 1.6), -1, 1);
yawSignal = Math.tanh(yawSignal);
medianPush(bufYaw.current, yawSignal, 5);
yawTarget.current = median(bufYaw.current);
setUiFaceActive(true);
} else {
setUiFaceActive(false);
}
} catch {
setUiFaceActive(false);
}
}
if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
video.requestVideoFrameCallback(() => detect(performance.now()));
} else {
requestAnimationFrame(detect);
}
};
requestAnimationFrame(detect);
};
start();
return () => {
stop = true;
if (video) {
const stream = video.srcObject;
if (stream) stream.getTracks().forEach(t => t.stop());
video.pause();
video.srcObject = null;
}
};
}, [enableWebcam, modelsReady, depthResponse]);
return (
<div ref={containerRef} className={`relative w-full h-full overflow-hidden ${className ?? ''}`} style={style}>
{showPreview && (
<div className="absolute right-3 bottom-3 w-[220px] h-[132px] rounded-lg overflow-hidden border border-white/25 shadow-[0_4px_16px_rgba(0,0,0,0.4)] bg-black text-white text-[12px] leading-[1.2] font-sans pointer-events-none">
<video ref={videoRef} muted playsInline autoPlay className="w-full h-full object-cover -scale-x-100" />
<div className="absolute left-2 top-2 px-[6px] py-[2px] bg-black/50 rounded-[6px] backdrop-blur-[4px]">
{enableWebcam
? modelsReady
? uiFaceActive
? 'Face: tracking'
: 'Face: searching'
: 'Loading models'
: 'Webcam disabled'}
</div>
</div>
)}
</div>
);
};
function srgbColor(hex) {
const c = new THREE.Color(hex);
return c.convertSRGBToLinear();
}
function smoothDampVec2(current, target, currentVelocity, smoothTime, maxSpeed, deltaTime) {
const out = current.clone();
smoothTime = Math.max(0.0001, smoothTime);
const omega = 2 / smoothTime;
const x = omega * deltaTime;
const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
let change = current.clone().sub(target);
const originalTo = target.clone();
const maxChange = maxSpeed * smoothTime;
if (change.length() > maxChange) change.setLength(maxChange);
target = current.clone().sub(change);
const temp = currentVelocity.clone().addScaledVector(change, omega).multiplyScalar(deltaTime);
currentVelocity.sub(temp.clone().multiplyScalar(omega));
currentVelocity.multiplyScalar(exp);
out.copy(target.clone().add(change.add(temp).multiplyScalar(exp)));
const origMinusCurrent = originalTo.clone().sub(current);
const outMinusOrig = out.clone().sub(originalTo);
if (origMinusCurrent.dot(outMinusOrig) > 0) {
out.copy(originalTo);
currentVelocity.set(0, 0);
}
return out;
}
function smoothDampFloat(current, target, velRef, smoothTime, maxSpeed, deltaTime) {
smoothTime = Math.max(0.0001, smoothTime);
const omega = 2 / smoothTime;
const x = omega * deltaTime;
const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
let change = current - target;
const originalTo = target;
const maxChange = maxSpeed * smoothTime;
change = Math.sign(change) * Math.min(Math.abs(change), maxChange);
target = current - change;
const temp = (velRef.v + omega * change) * deltaTime;
velRef.v = (velRef.v - omega * temp) * exp;
let out = target + (change + temp) * exp;
const origMinusCurrent = originalTo - current;
const outMinusOrig = out - originalTo;
if (origMinusCurrent * outMinusOrig > 0) {
out = originalTo;
velRef.v = 0;
}
return { value: out, v: velRef.v };
}
function medianPush(buf, v, maxLen) {
buf.push(v);
if (buf.length > maxLen) buf.shift();
}
function median(buf) {
if (buf.length === 0) return 0;
const a = [...buf].sort((x, y) => x - y);
const mid = Math.floor(a.length / 2);
return a.length % 2 ? a[mid] : (a[mid - 1] + a[mid]) * 0.5;
}
function centroid(points) {
let x = 0,
y = 0;
const n = points.length || 1;
for (const p of points) {
x += p.x;
y += p.y;
}
return { x: x / n, y: y / n };
}
function dist2(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}