Googly Eyes – Ultimate Edition
https://cdn.jsdelivr.net/npm/@vladmandic/face-api/dist/face-api.min.js
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: #000;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
overflow: hidden;
font-family: Arial, sans-serif;
}
.eyes-container {
display: flex;
gap: 60px;
position: relative;
z-index: 10;
}
.eye {
width: 200px;
height: 200px;
background-color: #fff;
border-radius: 50%;
position: relative;
overflow: hidden;
transition: transform 0.15s ease-out;
}
.pupil {
width: 80px;
height: 80px;
background-color: #000;
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transition: all 0.1s ease-out;
}
.eye.blinking {
animation: blink 0.15s ease-in-out;
}
@keyframes blink {
0%, 100% {
transform: scaleY(1);
}
50% {
transform: scaleY(0.05);
}
}
/* Kamera Preview */
#videoContainer {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 100;
}
#video {
width: 320px;
height: 240px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 10px;
transform: scaleX(-1);
}
#canvas {
position: absolute;
top: 0;
left: 0;
transform: scaleX(-1);
}
/* Kontrollpanel */
.control-panel {
position: fixed;
top: 20px;
left: 20px;
background-color: rgba(255, 255, 255, 0.1);
padding: 20px;
border-radius: 10px;
color: #fff;
backdrop-filter: blur(10px);
max-width: 320px;
z-index: 100;
}
.control-panel.collapsed {
padding: 10px 15px;
}
.control-panel.collapsed .panel-content {
display: none;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
cursor: pointer;
}
.control-panel.collapsed .panel-header {
margin-bottom: 0;
}
.panel-header h3 {
font-size: 16px;
}
.toggle-icon {
font-size: 20px;
transition: transform 0.3s;
}
.control-panel.collapsed .toggle-icon {
transform: rotate(180deg);
}
.mode-selector {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.mode-option {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 5px;
cursor: pointer;
transition: background-color 0.2s;
}
.mode-option:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.mode-option.active {
background-color: rgba(0, 255, 100, 0.2);
border: 2px solid rgba(0, 255, 100, 0.5);
}
.mode-option input[type=“checkbox“] {
width: 18px;
height: 18px;
cursor: pointer;
}
.mode-option label {
cursor: pointer;
flex: 1;
font-size: 14px;
}
.control-group {
margin-bottom: 15px;
}
.control-group label {
display: block;
margin-bottom: 5px;
font-size: 12px;
color: #ccc;
}
.control-group input[type=“range“] {
width: 100%;
cursor: pointer;
}
.control-group .value-display {
display: inline-block;
margin-left: 10px;
font-weight: bold;
color: #fff;
}
.status-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
}
.status-indicator.active {
background-color: #0f0;
box-shadow: 0 0 10px #0f0;
}
.status-indicator.inactive {
background-color: #555;
}
.button-group {
display: flex;
gap: 10px;
margin-top: 15px;
}
.control-btn {
flex: 1;
background-color: rgba(255, 255, 255, 0.2);
color: #fff;
border: none;
padding: 10px;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
transition: background-color 0.2s;
}
.control-btn:hover {
background-color: rgba(255, 255, 255, 0.3);
}
.control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.control-btn.active {
background-color: rgba(0, 255, 100, 0.3);
}
/* Status Meldung */
.status-message {
position: fixed;
top: 20px;
right: 20px;
background-color: rgba(255, 255, 255, 0.1);
color: #fff;
padding: 15px 20px;
border-radius: 10px;
backdrop-filter: blur(10px);
z-index: 150;
font-size: 14px;
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s;
pointer-events: none;
}
.status-message.show {
opacity: 1;
transform: translateY(0);
}
.status-message.error {
background-color: rgba(255, 50, 50, 0.3);
}
.status-message.success {
background-color: rgba(0, 255, 100, 0.2);
}
.hidden {
display: none !important;
}
/* Loading Spinner */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
Automatisch (Zufall)
Maus folgen
Kamera folgen
Bewegungsgeschwindigkeit (ms)
1500
Bewegungsintervall (ms)
2000
Bewegungsradius (px)
40
Maus-Inaktivität Timeout (ms)
2000
// DOM Elemente
const video = document.getElementById(‚video‘);
const canvas = document.getElementById(‚canvas‘);
const videoContainer = document.getElementById(‚videoContainer‘);
const leftPupil = document.getElementById(‚leftPupil‘);
const rightPupil = document.getElementById(‚rightPupil‘);
const leftEye = document.getElementById(‚leftEye‘);
const rightEye = document.getElementById(‚rightEye‘);
// Konfiguration
let config = {
movementSpeed: 1500,
movementInterval: 2000,
movementRadius: 40,
mouseTimeout: 2000,
blinkFrequency: 3000,
blinkDuration: 150,
smoothing: 0.15
};
// Status
let modes = {
auto: true,
mouse: false,
camera: false
};
let state = {
modelsLoaded: false,
cameraActive: false,
showPreview: false,
mouseInactivityTimer: null,
movementIntervalId: null,
detectionIntervalId: null,
currentLeftX: 0,
currentLeftY: 0,
currentRightX: 0,
currentRightY: 0,
lastFacePosition: null,
noFaceDetectedCount: 0,
noMovementDetectedCount: 0,
faceDetectionThreshold: 5, // Anzahl der Frames ohne Gesicht bevor Rückkehr zur Mitte
movementDetectionThreshold: 8, // Anzahl der Frames ohne Bewegung bevor Rückkehr zur Mitte
isReturningToCenter: false
};
// Panel Toggle
function togglePanel() {
document.getElementById(‚controlPanel‘).classList.toggle(‚collapsed‘);
}
// Status-Nachricht anzeigen
function showMessage(message, type = ’success‘) {
const msg = document.getElementById(’statusMessage‘);
msg.textContent = message;
msg.className = `status-message ${type} show`;
setTimeout(() => msg.classList.remove(’show‘), 3000);
}
// Status-Indikatoren aktualisieren
function updateStatusIndicators() {
document.getElementById(’statusAuto‘).className =
`status-indicator ${modes.auto ? ‚active‘ : ‚inactive‘}`;
document.getElementById(’statusMouse‘).className =
`status-indicator ${modes.mouse ? ‚active‘ : ‚inactive‘}`;
document.getElementById(’statusCamera‘).className =
`status-indicator ${modes.camera ? ‚active‘ : ‚inactive‘}`;
}
// Modus-Auswahl
document.getElementById(‚checkAuto‘).addEventListener(‚change‘, function(e) {
modes.auto = e.target.checked;
document.getElementById(‚modeAuto‘).classList.toggle(‚active‘, modes.auto);
updateStatusIndicators();
if (modes.auto) {
restartAutoMovement();
} else {
stopAutoMovement();
}
});
document.getElementById(‚checkMouse‘).addEventListener(‚change‘, function(e) {
modes.mouse = e.target.checked;
document.getElementById(‚modeMouse‘).classList.toggle(‚active‘, modes.mouse);
updateStatusIndicators();
if (modes.mouse) {
showMessage(‚Maus-Verfolgung aktiviert‘);
}
});
document.getElementById(‚checkCamera‘).addEventListener(‚change‘, async function(e) {
modes.camera = e.target.checked;
document.getElementById(‚modeCamera‘).classList.toggle(‚active‘, modes.camera);
updateStatusIndicators();
if (modes.camera) {
if (!state.modelsLoaded) {
document.getElementById(‚cameraLoading‘).classList.remove(‚hidden‘);
await loadModels();
document.getElementById(‚cameraLoading‘).classList.add(‚hidden‘);
}
await startCamera();
} else {
stopCamera();
}
});
// Slider Event Listeners
document.getElementById(‚movementSpeed‘).addEventListener(‚input‘, function(e) {
config.movementSpeed = parseInt(e.target.value);
document.getElementById(’speedValue‘).textContent = e.target.value;
updatePupilTransition();
});
document.getElementById(‚movementInterval‘).addEventListener(‚input‘, function(e) {
config.movementInterval = parseInt(e.target.value);
document.getElementById(‚intervalValue‘).textContent = e.target.value;
if (modes.auto) restartAutoMovement();
});
document.getElementById(‚movementRadius‘).addEventListener(‚input‘, function(e) {
config.movementRadius = parseInt(e.target.value);
document.getElementById(‚radiusValue‘).textContent = e.target.value;
});
document.getElementById(‚mouseTimeout‘).addEventListener(‚input‘, function(e) {
config.mouseTimeout = parseInt(e.target.value);
document.getElementById(‚mouseTimeoutValue‘).textContent = e.target.value;
});
// Buttons
document.getElementById(‚togglePreview‘).addEventListener(‚click‘, function() {
state.showPreview = !state.showPreview;
videoContainer.classList.toggle(‚hidden‘, !state.showPreview);
this.classList.toggle(‚active‘, state.showPreview);
this.textContent = state.showPreview ? ‚👁️ Preview ✓‘ : ‚👁️ Preview‘;
});
document.getElementById(‚resetBtn‘).addEventListener(‚click‘, function() {
resetPupils();
showMessage(‚Position zurückgesetzt‘);
});
// Pupillen-Transition aktualisieren
function updatePupilTransition() {
const speed = config.movementSpeed / 1000;
leftPupil.style.transition = `all ${speed}s ease-in-out`;
rightPupil.style.transition = `all ${speed}s ease-in-out`;
}
// Auto-Bewegung
function moveAutomatically() {
const angle = Math.random() * Math.PI * 2;
const distance = Math.random() * config.movementRadius;
const x = Math.cos(angle) * distance;
const y = Math.sin(angle) * distance;
leftPupil.style.transition = `all ${config.movementSpeed / 1000}s ease-in-out`;
rightPupil.style.transition = `all ${config.movementSpeed / 1000}s ease-in-out`;
leftPupil.style.transform = `translate(calc(-50% + ${x}px), calc(-50% + ${y}px))`;
rightPupil.style.transform = `translate(calc(-50% + ${x}px), calc(-50% + ${y}px))`;
}
function restartAutoMovement() {
stopAutoMovement();
if (modes.auto) {
moveAutomatically();
state.movementIntervalId = setInterval(moveAutomatically, config.movementInterval);
}
}
function stopAutoMovement() {
if (state.movementIntervalId) {
clearInterval(state.movementIntervalId);
state.movementIntervalId = null;
}
}
// Maus-Verfolgung
document.addEventListener(‚mousemove‘, function(e) {
if (!modes.mouse && !modes.camera) return;
// Bei Kamera-Modus: Maus hat niedrigere Priorität
if (modes.camera && state.cameraActive) return;
if (!modes.mouse) return;
// Reset Rückkehr-Flag wenn Maus bewegt wird
state.isReturningToCenter = false;
trackPosition(e.clientX, e.clientY, ‚mouse‘);
// Auto-Bewegung pausieren
if (modes.auto) {
stopAutoMovement();
if (state.mouseInactivityTimer) {
clearTimeout(state.mouseInactivityTimer);
}
state.mouseInactivityTimer = setTimeout(() => {
if (modes.auto && !state.isReturningToCenter) {
returnToCenter(() => {
if (modes.auto) {
restartAutoMovement();
}
});
}
}, config.mouseTimeout);
}
});
// Position tracken (für Maus und Kamera)
function trackPosition(targetX, targetY, source = ‚mouse‘) {
const leftEyeRect = leftEye.getBoundingClientRect();
const rightEyeRect = rightEye.getBoundingClientRect();
const leftEyeCenterX = leftEyeRect.left + leftEyeRect.width / 2;
const leftEyeCenterY = leftEyeRect.top + leftEyeRect.height / 2;
const rightEyeCenterX = rightEyeRect.left + rightEyeRect.width / 2;
const rightEyeCenterY = rightEyeRect.top + rightEyeRect.height / 2;
const leftAngle = Math.atan2(targetY – leftEyeCenterY, targetX – leftEyeCenterX);
const leftDistance = Math.sqrt(
Math.pow(targetX – leftEyeCenterX, 2) + Math.pow(targetY – leftEyeCenterY, 2)
);
const rightAngle = Math.atan2(targetY – rightEyeCenterY, targetX – rightEyeCenterX);
const rightDistance = Math.sqrt(
Math.pow(targetX – rightEyeCenterX, 2) + Math.pow(targetY – rightEyeCenterY, 2)
);
const factor = source === ‚mouse‘ ? 0.8 : 0.5;
const maxPupilMovement = config.movementRadius;
const targetLeftX = Math.cos(leftAngle) * Math.min(leftDistance * factor, maxPupilMovement);
const targetLeftY = Math.sin(leftAngle) * Math.min(leftDistance * factor, maxPupilMovement);
const targetRightX = Math.cos(rightAngle) * Math.min(rightDistance * factor, maxPupilMovement);
const targetRightY = Math.sin(rightAngle) * Math.min(rightDistance * factor, maxPupilMovement);
if (source === ‚camera‘) {
// Smoothing für Kamera
state.currentLeftX += (targetLeftX – state.currentLeftX) * config.smoothing;
state.currentLeftY += (targetLeftY – state.currentLeftY) * config.smoothing;
state.currentRightX += (targetRightX – state.currentRightX) * config.smoothing;
state.currentRightY += (targetRightY – state.currentRightY) * config.smoothing;
leftPupil.style.transition = ‚all 0.1s ease-out‘;
rightPupil.style.transition = ‚all 0.1s ease-out‘;
leftPupil.style.transform = `translate(calc(-50% + ${state.currentLeftX}px), calc(-50% + ${state.currentLeftY}px))`;
rightPupil.style.transform = `translate(calc(-50% + ${state.currentRightX}px), calc(-50% + ${state.currentRightY}px))`;
} else {
// Direkt für Maus
leftPupil.style.transition = ‚all 0.1s ease-out‘;
rightPupil.style.transition = ‚all 0.1s ease-out‘;
leftPupil.style.transform = `translate(calc(-50% + ${targetLeftX}px), calc(-50% + ${targetLeftY}px))`;
rightPupil.style.transform = `translate(calc(-50% + ${targetRightX}px), calc(-50% + ${targetRightY}px))`;
}
}
// Pupillen zurücksetzen
function resetPupils() {
leftPupil.style.transform = ‚translate(-50%, -50%)‘;
rightPupil.style.transform = ‚translate(-50%, -50%)‘;
state.currentLeftX = state.currentLeftY = state.currentRightX = state.currentRightY = 0;
}
// Sanfte Rückkehr zur Mitte
function returnToCenter(callback) {
state.isReturningToCenter = true;
// Setze sanfte Transition für Rückkehr
leftPupil.style.transition = ‚all 0.8s ease-in-out‘;
rightPupil.style.transition = ‚all 0.8s ease-in-out‘;
// Bewege zur Mitte
leftPupil.style.transform = ‚translate(-50%, -50%)‘;
rightPupil.style.transform = ‚translate(-50%, -50%)‘;
state.currentLeftX = state.currentLeftY = state.currentRightX = state.currentRightY = 0;
// Warte bis Transition fertig ist, dann callback
setTimeout(() => {
state.isReturningToCenter = false;
if (callback) callback();
}, 800);
}
// Face-API Modelle laden
async function loadModels() {
if (state.modelsLoaded) return;
try {
const MODEL_URL = ‚
https://cdn.jsdelivr.net/npm/@vladmandic/face-api@1.7.12/model/‘;
await Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL)
]);
state.modelsLoaded = true;
showMessage(‚Face-API Modelle geladen‘);
} catch (error) {
console.error(‚Fehler beim Laden der Modelle:‘, error);
showMessage(‚Fehler beim Laden der Face-API‘, ‚error‘);
modes.camera = false;
document.getElementById(‚checkCamera‘).checked = false;
document.getElementById(‚modeCamera‘).classList.remove(‚active‘);
updateStatusIndicators();
}
}
// Kamera starten
async function startCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: 640,
height: 480,
facingMode: ‚user‘
}
});
video.srcObject = stream;
video.addEventListener(‚loadeddata‘, () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
state.cameraActive = true;
showMessage(‚Kamera-Tracking aktiviert‘);
startDetection();
});
} catch (error) {
console.error(‚Kamera-Fehler:‘, error);
showMessage(‚Kamera-Zugriff verweigert‘, ‚error‘);
modes.camera = false;
document.getElementById(‚checkCamera‘).checked = false;
document.getElementById(‚modeCamera‘).classList.remove(‚active‘);
updateStatusIndicators();
}
}
// Kamera stoppen
function stopCamera() {
if (video.srcObject) {
video.srcObject.getTracks().forEach(track => track.stop());
video.srcObject = null;
}
if (state.detectionIntervalId) {
clearInterval(state.detectionIntervalId);
state.detectionIntervalId = null;
}
state.cameraActive = false;
showMessage(‚Kamera-Tracking deaktiviert‘);
}
// Gesichtserkennung
function startDetection() {
state.detectionIntervalId = setInterval(async () => {
if (!state.cameraActive || !modes.camera) return;
const detections = await faceapi
.detectSingleFace(video, new faceapi.TinyFaceDetectorOptions())
.withFaceLandmarks();
if (detections) {
// Gesicht erkannt – reset counter
state.noFaceDetectedCount = 0;
if (state.showPreview) {
const ctx = canvas.getContext(‚2d‘);
ctx.clearRect(0, 0, canvas.width, canvas.height);
faceapi.draw.drawFaceLandmarks(canvas, detections);
}
// Mehrere Gesichtspunkte kombinieren für stabileres Tracking
const landmarks = detections.landmarks;
// Hole verschiedene Referenzpunkte
const nose = landmarks.getNose()[3]; // Nasenspitze
const leftEyePoints = landmarks.getLeftEye();
const rightEyePoints = landmarks.getRightEye();
const jawline = landmarks.getJawOutline();
// Berechne Augenmittelpunkte
const leftEyeCenter = {
x: leftEyePoints.reduce((sum, p) => sum + p.x, 0) / leftEyePoints.length,
y: leftEyePoints.reduce((sum, p) => sum + p.y, 0) / leftEyePoints.length
};
const rightEyeCenter = {
x: rightEyePoints.reduce((sum, p) => sum + p.x, 0) / rightEyePoints.length,
y: rightEyePoints.reduce((sum, p) => sum + p.y, 0) / rightEyePoints.length
};
// Berechne Punkt zwischen den Augen
const eyesMidpoint = {
x: (leftEyeCenter.x + rightEyeCenter.x) / 2,
y: (leftEyeCenter.y + rightEyeCenter.y) / 2
};
// Berechne Gesichtszentrum (Kinn-Mitte)
const chinCenter = jawline[8]; // Mittlerer Punkt der Kinnlinie
// Gewichteter Durchschnitt mehrerer Punkte für stabileres Tracking
// 40% Augenmitte, 30% Nase, 20% Kinn, 10% Gesamtgesicht
const allPoints = landmarks.positions;
const faceCenter = {
x: allPoints.reduce((sum, p) => sum + p.x, 0) / allPoints.length,
y: allPoints.reduce((sum, p) => sum + p.y, 0) / allPoints.length
};
const combinedX = (
eyesMidpoint.x * 0.4 +
nose.x * 0.3 +
chinCenter.x * 0.2 +
faceCenter.x * 0.1
);
const combinedY = (
eyesMidpoint.y * 0.4 +
nose.y * 0.3 +
chinCenter.y * 0.2 +
faceCenter.y * 0.1
);
// Speichere aktuelle Position
const currentPosition = { x: combinedX, y: combinedY };
// Prüfe ob signifikante Bewegung erkannt wurde
let hasMovement = true;
if (state.lastFacePosition) {
const movementThreshold = 8; // Erhöhter Schwellwert für deutlichere Bewegungen
const deltaX = Math.abs(currentPosition.x – state.lastFacePosition.x);
const deltaY = Math.abs(currentPosition.y – state.lastFacePosition.y);
hasMovement = (deltaX > movementThreshold || deltaY > movementThreshold);
}
state.lastFacePosition = currentPosition;
// Wenn Bewegung erkannt
if (hasMovement) {
// Reset Bewegungs-Counter
state.noMovementDetectedCount = 0;
// Stoppe Auto-Bewegung wenn sie läuft
if (state.movementIntervalId) {
stopAutoMovement();
}
// Reset Rückkehr-Flag
state.isReturningToCenter = false;
// Konvertiere zu Bildschirmkoordinaten
const videoRect = video.getBoundingClientRect();
const relX = 1 – (combinedX / video.videoWidth); // Gespiegelt
const relY = combinedY / video.videoHeight;
const screenX = relX * window.innerWidth;
const screenY = relY * window.innerHeight;
trackPosition(screenX, screenY, ‚camera‘);
} else {
// Keine signifikante Bewegung mehr
state.noMovementDetectedCount++;
// Nach mehreren Frames ohne Bewegung – zurück zur Mitte
if (state.noMovementDetectedCount >= state.movementDetectionThreshold) {
if (!state.isReturningToCenter && !state.movementIntervalId) {
returnToCenter(() => {
if (modes.auto) {
restartAutoMovement();
}
});
}
}
}
} else {
// Kein Gesicht erkannt
state.noFaceDetectedCount++;
state.noMovementDetectedCount = 0; // Reset, da kein Gesicht = automatisch keine Bewegung
// Schnellere Rückkehr wenn Gesicht komplett weg ist
if (state.noFaceDetectedCount >= state.faceDetectionThreshold) {
if (!state.isReturningToCenter && !state.movementIntervalId) {
returnToCenter(() => {
if (modes.auto) {
restartAutoMovement();
}
});
}
state.lastFacePosition = null; // Reset Position
}
}
}, 100);
}
// Zwinkern
function blink() {
const blinkBoth = Math.random() > 0.4;
if (blinkBoth) {
leftEye.classList.add(‚blinking‘);
rightEye.classList.add(‚blinking‘);
setTimeout(() => {
leftEye.classList.remove(‚blinking‘);
rightEye.classList.remove(‚blinking‘);
}, config.blinkDuration);
} else {
const eyeToBlink = Math.random() > 0.5 ? leftEye : rightEye;
eyeToBlink.classList.add(‚blinking‘);
setTimeout(() => eyeToBlink.classList.remove(‚blinking‘), config.blinkDuration);
}
}
// Zwinkern-Intervall
setInterval(() => {
const variation = (Math.random() – 0.5) * config.blinkFrequency * 0.4;
setTimeout(blink, variation);
}, config.blinkFrequency);
// Initialisierung
function initializeWhenReady() {
if (typeof faceapi !== ‚undefined‘) {
// Face-API geladen, aber noch nicht die Modelle laden
// (werden nur geladen wenn Kamera-Modus aktiviert wird)
updatePupilTransition();
restartAutoMovement();
setTimeout(blink, 1000);
} else {
setTimeout(initializeWhenReady, 100);
}
}
if (document.readyState === ‚loading‘) {
document.addEventListener(‚DOMContentLoaded‘, initializeWhenReady);
} else {
initializeWhenReady();
}
Gefällt mir Wird geladen …