mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
Face tracking: reactor follows face position via camera
- Smooth lerp animation loop (rAF at 60fps) drives .tb-logo translateX/Y toward face center - Face X flipped to match mirror (move right = reactor slides right toward you) - Scan crosshair overlay appears on screen at detected face position with spinning ring - Reactor glows brighter (drop-shadow) while face tracking is active - Graceful: coasts back to center when face lost, snaps off cleanly on camera disable - Video frame is 320x240 mapped to viewport coords for overlay positioning
This commit is contained in:
+125
-5
@@ -226,7 +226,36 @@ body::after{
|
|||||||
.tb-logo{
|
.tb-logo{
|
||||||
font-family:var(--font-display);font-size:1rem;font-weight:900;letter-spacing:4px;
|
font-family:var(--font-display);font-size:1rem;font-weight:900;letter-spacing:4px;
|
||||||
color:var(--cyan);text-shadow:0 0 10px var(--cyan);display:flex;align-items:center;gap:10px;
|
color:var(--cyan);text-shadow:0 0 10px var(--cyan);display:flex;align-items:center;gap:10px;
|
||||||
|
transition:filter 0.4s ease;
|
||||||
|
will-change:transform;
|
||||||
}
|
}
|
||||||
|
.tb-logo.face-tracking{
|
||||||
|
filter:drop-shadow(0 0 12px rgba(0,212,255,0.9)) drop-shadow(0 0 24px rgba(0,212,255,0.4));
|
||||||
|
}
|
||||||
|
/* Face scan crosshair overlay */
|
||||||
|
#faceScanOverlay{
|
||||||
|
position:fixed;pointer-events:none;z-index:9;
|
||||||
|
width:60px;height:60px;
|
||||||
|
display:none;
|
||||||
|
}
|
||||||
|
#faceScanOverlay::before,#faceScanOverlay::after{
|
||||||
|
content:'';position:absolute;
|
||||||
|
border-color:rgba(0,212,255,0.7);border-style:solid;
|
||||||
|
}
|
||||||
|
#faceScanOverlay::before{
|
||||||
|
top:0;left:0;width:16px;height:16px;
|
||||||
|
border-width:2px 0 0 2px;
|
||||||
|
}
|
||||||
|
#faceScanOverlay::after{
|
||||||
|
bottom:0;right:0;width:16px;height:16px;
|
||||||
|
border-width:0 2px 2px 0;
|
||||||
|
}
|
||||||
|
#faceScanOverlay .fso-tr{position:absolute;top:0;right:0;width:16px;height:16px;border:2px solid rgba(0,212,255,0.7);border-left:0;border-bottom:0}
|
||||||
|
#faceScanOverlay .fso-bl{position:absolute;bottom:0;left:0;width:16px;height:16px;border:2px solid rgba(0,212,255,0.7);border-right:0;border-top:0}
|
||||||
|
#faceScanOverlay .fso-dot{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:4px;border-radius:50%;background:rgba(0,212,255,0.6);box-shadow:0 0 6px var(--cyan)}
|
||||||
|
#faceScanOverlay .fso-label{position:absolute;bottom:-18px;left:50%;transform:translateX(-50%);font-family:var(--font-mono);font-size:0.45rem;color:rgba(0,212,255,0.7);letter-spacing:1px;white-space:nowrap}
|
||||||
|
@keyframes fsoSpin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
|
||||||
|
#faceScanOverlay .fso-ring{position:absolute;top:50%;left:50%;width:40px;height:40px;margin:-20px;border-radius:50%;border:1px solid rgba(0,212,255,0.3);border-top-color:rgba(0,212,255,0.7);animation:fsoSpin 1.2s linear infinite}
|
||||||
.tb-logo-dot{width:8px;height:8px;border-radius:50%;background:var(--cyan);box-shadow:0 0 8px var(--cyan);animation:corePulse 1.5s infinite}
|
.tb-logo-dot{width:8px;height:8px;border-radius:50%;background:var(--cyan);box-shadow:0 0 8px var(--cyan);animation:corePulse 1.5s infinite}
|
||||||
.tb-center{
|
.tb-center{
|
||||||
display:flex;gap:24px;align-items:center;
|
display:flex;gap:24px;align-items:center;
|
||||||
@@ -735,6 +764,13 @@ body::after{
|
|||||||
<body>
|
<body>
|
||||||
<canvas id="particleCanvas"></canvas>
|
<canvas id="particleCanvas"></canvas>
|
||||||
<div id="alertOverlay"></div>
|
<div id="alertOverlay"></div>
|
||||||
|
<div id="faceScanOverlay">
|
||||||
|
<div class="fso-ring"></div>
|
||||||
|
<div class="fso-tr"></div>
|
||||||
|
<div class="fso-bl"></div>
|
||||||
|
<div class="fso-dot"></div>
|
||||||
|
<div class="fso-label">TRACKING</div>
|
||||||
|
</div>
|
||||||
<div class="scanlines"></div>
|
<div class="scanlines"></div>
|
||||||
<div class="scanline-sweep"></div>
|
<div class="scanline-sweep"></div>
|
||||||
|
|
||||||
@@ -1186,6 +1222,83 @@ function setAlertState(hasAlerts) {
|
|||||||
ov.style.display = hasAlerts ? 'block' : 'none';
|
ov.style.display = hasAlerts ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── FACE TRACKING — reactor follows face position ─────────────────────
|
||||||
|
let _faceTargetX = 0, _faceTargetY = 0; // normalized -0.5 to 0.5
|
||||||
|
let _faceCurrX = 0, _faceCurrY = 0;
|
||||||
|
let _faceVisible = false;
|
||||||
|
let _faceTrackRaf = null;
|
||||||
|
const FACE_MAX_X = 48; // max px left/right travel
|
||||||
|
const FACE_MAX_Y = 10; // max px up/down travel
|
||||||
|
const FACE_LERP = 0.07; // smoothing (lower = slower/smoother)
|
||||||
|
|
||||||
|
function _faceTrackLoop() {
|
||||||
|
_faceTrackRaf = requestAnimationFrame(_faceTrackLoop);
|
||||||
|
|
||||||
|
// Lerp toward target (or back to 0 when no face)
|
||||||
|
const targetX = _faceVisible ? _faceTargetX : 0;
|
||||||
|
const targetY = _faceVisible ? _faceTargetY : 0;
|
||||||
|
_faceCurrX += (targetX - _faceCurrX) * FACE_LERP;
|
||||||
|
_faceCurrY += (targetY - _faceCurrY) * FACE_LERP;
|
||||||
|
|
||||||
|
const tx = _faceCurrX * FACE_MAX_X;
|
||||||
|
const ty = _faceCurrY * FACE_MAX_Y;
|
||||||
|
|
||||||
|
const logo = document.querySelector('.tb-logo');
|
||||||
|
if (logo) logo.style.transform = `translateX(${tx.toFixed(2)}px) translateY(${ty.toFixed(2)}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFaceTarget(box, videoW, videoH) {
|
||||||
|
// Face center, normalized — flip X because front camera is mirrored
|
||||||
|
const cx = box.x + box.width / 2;
|
||||||
|
const cy = box.y + box.height / 2;
|
||||||
|
_faceTargetX = 0.5 - (cx / videoW); // flipped: move right when face is left
|
||||||
|
_faceTargetY = (cy / videoH) - 0.5; // positive = face low = logo drops slightly
|
||||||
|
_faceVisible = true;
|
||||||
|
|
||||||
|
// Position the scan overlay over the detected face in the viewport.
|
||||||
|
// The video feed is 320×240 but hidden; map to viewport coords.
|
||||||
|
const scaleX = window.innerWidth / videoW;
|
||||||
|
const scaleY = window.innerHeight / videoH;
|
||||||
|
const faceVx = box.x * scaleX;
|
||||||
|
const faceVy = box.y * scaleY;
|
||||||
|
const faceVw = box.width * scaleX;
|
||||||
|
const faceVh = box.height * scaleY;
|
||||||
|
const ov = document.getElementById('faceScanOverlay');
|
||||||
|
if (ov) {
|
||||||
|
ov.style.display = 'block';
|
||||||
|
// Center overlay on face, flipped X to match mirror
|
||||||
|
const ovX = window.innerWidth - faceVx - faceVw / 2 - 30;
|
||||||
|
const ovY = faceVy + faceVh / 2 - 30;
|
||||||
|
ov.style.left = Math.max(0, Math.min(window.innerWidth - 60, ovX)) + 'px';
|
||||||
|
ov.style.top = Math.max(0, Math.min(window.innerHeight - 80, ovY)) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFaceTarget() {
|
||||||
|
_faceVisible = false;
|
||||||
|
const ov = document.getElementById('faceScanOverlay');
|
||||||
|
if (ov) ov.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startFaceTracking() {
|
||||||
|
const logo = document.querySelector('.tb-logo');
|
||||||
|
if (logo) logo.classList.add('face-tracking');
|
||||||
|
if (!_faceTrackRaf) _faceTrackLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopFaceTracking() {
|
||||||
|
clearFaceTarget();
|
||||||
|
const logo = document.querySelector('.tb-logo');
|
||||||
|
if (logo) { logo.classList.remove('face-tracking'); logo.style.transform = ''; }
|
||||||
|
// Let the loop coast to zero naturally rather than snapping — cancel after settling
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!_faceVisible && _faceTrackRaf) {
|
||||||
|
cancelAnimationFrame(_faceTrackRaf);
|
||||||
|
_faceTrackRaf = null;
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
// ── GLITCH EFFECT ─────────────────────────────────────────────────────
|
// ── GLITCH EFFECT ─────────────────────────────────────────────────────
|
||||||
(function initGlitch() {
|
(function initGlitch() {
|
||||||
function triggerGlitch() {
|
function triggerGlitch() {
|
||||||
@@ -1422,7 +1535,8 @@ async function startCamera() {
|
|||||||
cameraActive = true;
|
cameraActive = true;
|
||||||
btn.classList.add('cam-active');
|
btn.classList.add('cam-active');
|
||||||
btn.textContent = '◉ SENSING';
|
btn.textContent = '◉ SENSING';
|
||||||
addMessage('system', 'Face detection active — JARVIS will auto-engage mic when you approach.');
|
startFaceTracking();
|
||||||
|
addMessage('system', 'Face detection active — reactor tracking engaged.');
|
||||||
|
|
||||||
faceLoopId = setInterval(async () => {
|
faceLoopId = setInterval(async () => {
|
||||||
if (!cameraActive || isSpeaking) return;
|
if (!cameraActive || isSpeaking) return;
|
||||||
@@ -1434,16 +1548,21 @@ async function startCamera() {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (detection) {
|
if (detection) {
|
||||||
lastFaceSeen = now;
|
lastFaceSeen = now;
|
||||||
const {width, height} = detection.box;
|
const {x, y, width, height} = detection.box;
|
||||||
const ratio = (width * height) / (320 * 240);
|
const ratio = (width * height) / (320 * 240);
|
||||||
// Trigger if face fills >3% of frame and mic not already on and cooldown passed
|
|
||||||
|
// Drive the reactor to follow the face
|
||||||
|
updateFaceTarget(detection.box, 320, 240);
|
||||||
|
|
||||||
|
// Trigger voice if face fills >3% of frame
|
||||||
if (ratio > 0.03 && !voiceMode && now > autoMicCooldown) {
|
if (ratio > 0.03 && !voiceMode && now > autoMicCooldown) {
|
||||||
autoMicCooldown = now + 9000; // 9s between auto-triggers
|
autoMicCooldown = now + 9000;
|
||||||
document.getElementById('cameraBtn').classList.add('cam-sensing');
|
document.getElementById('cameraBtn').classList.add('cam-sensing');
|
||||||
enterVoiceMode();
|
enterVoiceMode();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No face — exit voice mode if camera-triggered and face gone >3s
|
clearFaceTarget();
|
||||||
|
// No face — exit voice mode if face gone >3s
|
||||||
if (voiceMode && now - lastFaceSeen > 3000) {
|
if (voiceMode && now - lastFaceSeen > 3000) {
|
||||||
exitVoiceMode();
|
exitVoiceMode();
|
||||||
}
|
}
|
||||||
@@ -1476,6 +1595,7 @@ function stopCamera() {
|
|||||||
btn.classList.remove('cam-active', 'cam-sensing');
|
btn.classList.remove('cam-active', 'cam-sensing');
|
||||||
btn.textContent = '◉ CAMERA';
|
btn.textContent = '◉ CAMERA';
|
||||||
}
|
}
|
||||||
|
stopFaceTracking();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCamera() {
|
function toggleCamera() {
|
||||||
|
|||||||
Reference in New Issue
Block a user