From 3d5b5704f52e8761ce448be676573df83528f7de Mon Sep 17 00:00:00 2001 From: max Date: Sun, 8 Feb 2026 21:22:52 +0100 Subject: [PATCH] additional new face stuff --- face/var/www/html/face.css | 77 +++++++ face/var/www/html/face.js | 398 +++++++++++++++++++++++++++++++++-- face/var/www/html/index.html | 170 +++++++-------- face/var/www/html/style.css | 139 ------------ 4 files changed, 535 insertions(+), 249 deletions(-) create mode 100644 face/var/www/html/face.css delete mode 100755 face/var/www/html/style.css diff --git a/face/var/www/html/face.css b/face/var/www/html/face.css new file mode 100644 index 0000000..7818a42 --- /dev/null +++ b/face/var/www/html/face.css @@ -0,0 +1,77 @@ +:root{ + --panel:#0b1020cc; + --text:#e8f0ff; + --shadow: 0 12px 40px rgba(0,0,0,.55); + --r: 18px; +} + +html, body { + height: 100%; + margin: 0; + background: #000; +} + +.stage { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background: #000; + touch-action: manipulation; + user-select: none; +} + +.face { + width: 100vw; + height: 100vh; + display: block; +} + +/* Touch-only overlay with two links */ +.overlay{ + position: fixed; + left: max(12px, env(safe-area-inset-left)); + right: max(12px, env(safe-area-inset-right)); + bottom: max(12px, env(safe-area-inset-bottom)); + display:flex; + gap:10px; + justify-content:flex-start; + align-items:center; + + background: var(--panel); + border: 1px solid rgba(255,255,255,.10); + border-radius: var(--r); + padding: 10px 12px; + box-shadow: var(--shadow); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + + opacity:0; + transform: translateY(10px); + pointer-events:none; + transition: opacity .18s ease, transform .18s ease; +} + +.overlay.show{ + opacity:1; + transform: translateY(0); + pointer-events:auto; +} + +a.btn{ + appearance:none; + border: 1px solid rgba(255,255,255,.14); + background: rgba(255,255,255,.06); + color: var(--text); + border-radius: 14px; + padding: 10px 12px; + font-size: 14px; + text-decoration:none; + line-height:1; + display:inline-flex; + align-items:center; + gap:8px; +} + +a.btn:active{ transform: translateY(1px); } + diff --git a/face/var/www/html/face.js b/face/var/www/html/face.js index 87b1ea5..050ad55 100644 --- a/face/var/www/html/face.js +++ b/face/var/www/html/face.js @@ -1,19 +1,389 @@ -(function connectSSE(){ - let es; - function open() { - es = new EventSource("/events"); - es.onopen = () => window.setConnected(true); +// Black fullscreen + cyan sticker-style face, plus touch-only 2 links. +// State via SSE /events from face_server (nginx proxies /events -> :8001/events) +// +// Payload example: +// {"emotion":"angry","intensity":0.9,"look":{"x":0.9,"y":0.0},"speaking":true} - es.addEventListener("state", (ev) => { - try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {} - }); +(() => { + const OVERLAY_TIMEOUT_MS = 3500; - es.onerror = () => { - window.setConnected(false); - try { es.close(); } catch (_) {} - setTimeout(open, 1200); - }; + const clamp = (n,a,b) => Math.max(a, Math.min(b, n)); + const clamp01 = (v) => { + const n = Number(v); + if (Number.isNaN(n)) return 0; + return clamp(n, 0, 1); + }; + + const EMOTIONS = new Set(["neutral","happy","sad","angry","sleepy","surprised","excited"]); + + const state = { + emotion: "neutral", + intensity: 0.85, + look: { x: 0, y: 0 }, + lookLock: false, + speaking: false, + eyesMoving: true, + }; + + // DOM + const stage = document.getElementById("stage"); + const overlay = document.getElementById("overlay"); + const controlLink = document.getElementById("controlLink"); + const emotionLabel = document.getElementById("emotionLabel"); + + const eyeL = document.getElementById("eyeL"); + const eyeR = document.getElementById("eyeR"); + const eyesGroup = document.getElementById("eyesGroup"); + const mouth = document.getElementById("mouth"); + + const topbarLit = document.getElementById("topbarLit"); + const faceGroup = document.getElementById("faceGroup"); + + // Touch overlay show + let overlayTimer = null; + function showOverlay() { + overlay.classList.add("show"); + clearTimeout(overlayTimer); + overlayTimer = setTimeout(() => overlay.classList.remove("show"), OVERLAY_TIMEOUT_MS); } - open(); + stage.addEventListener("pointerdown", () => showOverlay(), { passive: true }); + + // Layout tuned to your reference composition + const BASE = { + L: { x: 360, y: 300 }, + R: { x: 640, y: 300 }, + eyeW: 170, + eyeH: 180, + lookMaxPx: 28, + mouthCx: 500, + mouthY: 415, + }; + + function updateControlLink() { + emotionLabel.textContent = state.emotion; + controlLink.href = `/control?current=${encodeURIComponent(state.emotion)}`; + } + + function applyIntensity(intensity) { + state.intensity = clamp01(intensity); + + // bar brightness + topbarLit.setAttribute("opacity", String(0.35 + state.intensity * 0.65)); + + // stronger glow at high intensity + faceGroup.setAttribute("filter", state.intensity > 0.8 ? "url(#glowStrong)" : "url(#glow)"); + } + + function setStroke(el, width) { + el.setAttribute("fill", "none"); + el.setAttribute("stroke", `rgba(120,255,235,${0.86 + 0.14 * state.intensity})`); + el.setAttribute("stroke-width", String(width)); + el.setAttribute("stroke-linecap", "round"); + el.setAttribute("stroke-linejoin", "round"); + } + + // ---- Eye shapes (icon-like) ---- + function eyeOval(cx, cy, w, h) { + const rx = w * 0.30; + const ry = h * 0.22; + return `M ${cx - rx},${cy} + C ${cx - rx},${cy - ry} ${cx - rx*0.35},${cy - ry} ${cx},${cy - ry} + C ${cx + rx*0.35},${cy - ry} ${cx + rx},${cy - ry} ${cx + rx},${cy} + C ${cx + rx},${cy + ry} ${cx + rx*0.35},${cy + ry} ${cx},${cy + ry} + C ${cx - rx*0.35},${cy + ry} ${cx - rx},${cy + ry} ${cx - rx},${cy}`; + } + + function eyeRound(cx, cy, r) { + return `M ${cx - r},${cy} + C ${cx - r},${cy - r} ${cx - r*0.2},${cy - r} ${cx},${cy - r} + C ${cx + r*0.2},${cy - r} ${cx + r},${cy - r} ${cx + r},${cy} + C ${cx + r},${cy + r} ${cx + r*0.2},${cy + r} ${cx},${cy + r} + C ${cx - r*0.2},${cy + r} ${cx - r},${cy + r} ${cx - r},${cy}`; + } + + function eyeSleepLine(cx, cy, w) { + const half = w * 0.20; + return `M ${cx - half},${cy} L ${cx + half},${cy}`; + } + + function eyeHappyArc(cx, cy, w) { + const half = w * 0.20; + const lift = 22; + return `M ${cx - half},${cy} Q ${cx},${cy + lift} ${cx + half},${cy}`; + } + + function eyeAngrySlashL(cx, cy) { return `M ${cx - 60},${cy - 18} L ${cx + 26},${cy + 26}`; } + function eyeAngrySlashR(cx, cy) { return `M ${cx + 60},${cy - 18} L ${cx - 26},${cy + 26}`; } + + function eyeSadSmall(cx, cy, w) { + const rx = w * 0.16; + const ry = 18; + const y = cy + 8; + return `M ${cx - rx},${y} + C ${cx - rx},${y - ry} ${cx - rx*0.35},${y - ry} ${cx},${y - ry} + C ${cx + rx*0.35},${y - ry} ${cx + rx},${y - ry} ${cx + rx},${y} + C ${cx + rx},${y + ry} ${cx + rx*0.35},${y + ry} ${cx},${y + ry} + C ${cx - rx*0.35},${y + ry} ${cx - rx},${y + ry} ${cx - rx},${y}`; + } + + // ---- Mouth shapes (sticker-like) ---- + function mouthDot(cx, y) { + setStroke(mouth, 24); // thick capsule-dot + const half = 14; + return `M ${cx - half},${y} L ${cx + half},${y}`; + } + + function mouthLine(cx, y, w=170) { + setStroke(mouth, 18); + const half = w/2; + return `M ${cx - half},${y} L ${cx + half},${y}`; + } + + function mouthSmile(cx, y, w=190) { + setStroke(mouth, 18); + const half = w/2; + return `M ${cx - half},${y} Q ${cx},${y + 70} ${cx + half},${y}`; + } + + function mouthGrin(cx, y) { + setStroke(mouth, 18); + const w = 220; + const half = w/2; + return `M ${cx - half},${y} + Q ${cx - half*0.15},${y + 70} ${cx},${y + 70} + Q ${cx + half*0.15},${y + 70} ${cx + half},${y}`; + } + + function mouthFrown(cx, y, w=190) { + setStroke(mouth, 18); + const half = w/2; + return `M ${cx - half},${y + 50} Q ${cx},${y - 25} ${cx + half},${y + 50}`; + } + + function mouthO(cx, y) { + setStroke(mouth, 18); + const r = 38; + return eyeRound(cx, y + 10, r); + } + + function mouthShout(cx, y) { + // filled open mouth with neon outline + mouth.setAttribute("stroke", `rgba(120,255,235,${0.90 + 0.10 * state.intensity})`); + mouth.setAttribute("stroke-width", "18"); + mouth.setAttribute("stroke-linecap", "round"); + mouth.setAttribute("stroke-linejoin", "round"); + mouth.setAttribute("fill", "rgba(0,0,0,0.38)"); + + const wTop = 90, wBot = 150, h = 78; + const x1 = cx - wTop/2, x2 = cx + wTop/2; + const x3 = cx + wBot/2, x4 = cx - wBot/2; + const y1 = y - 10, y2 = y + h; + return `M ${x1},${y1} L ${x2},${y1} L ${x3},${y2} L ${x4},${y2} Z`; + } + + // speaking animation overlays ANY mouth shape + let speakTimer = null; + function startSpeaking() { + stopSpeaking(); + let open = false; + const base = state.emotion === "excited" ? 85 : (state.emotion === "sleepy" ? 170 : 115); + + speakTimer = setInterval(() => { + if (!state.speaking) return; + open = !open; + const amp = 0.10 + 0.18 * state.intensity; + const sy = 1 + amp * (open ? 1 : -0.35); + const ty = open ? 5 : 0; + mouth.setAttribute("transform", `translate(0,${ty}) scale(1,${sy})`); + }, base); + } + + function stopSpeaking() { + if (speakTimer) clearInterval(speakTimer); + speakTimer = null; + mouth.setAttribute("transform", ""); + } + + // blink by temporarily switching to sleepy lines + let blinkTimer = null; + let blinking = false; + + function render() { + // look -> subtle move of the whole eyesGroup (keeps sticker look) + const lookX = clamp(state.look.x, -1, 1); + const lookY = clamp(state.look.y, -1, 1); + + const iconLocked = (state.emotion === "angry" || state.emotion === "happy" || state.emotion === "sleepy"); + const lookScale = iconLocked ? 0.20 : 1.0; + + const dx = lookX * BASE.lookMaxPx * lookScale; + const dy = lookY * BASE.lookMaxPx * lookScale; + eyesGroup.setAttribute("transform", `translate(${dx},${dy})`); + + // eyes styling + setStroke(eyeL, state.emotion === "excited" ? 22 : 20); + setStroke(eyeR, state.emotion === "excited" ? 22 : 20); + + const emo = state.emotion; + const useBlink = blinking && emo !== "sleepy"; + + if (useBlink) { + eyeL.setAttribute("d", eyeSleepLine(BASE.L.x, BASE.L.y + 10, BASE.eyeW)); + eyeR.setAttribute("d", eyeSleepLine(BASE.R.x, BASE.R.y + 10, BASE.eyeW)); + } else { + switch (emo) { + case "happy": + eyeL.setAttribute("d", eyeHappyArc(BASE.L.x, BASE.L.y, BASE.eyeW)); + eyeR.setAttribute("d", eyeHappyArc(BASE.R.x, BASE.R.y, BASE.eyeW)); + break; + case "angry": + eyeL.setAttribute("d", eyeAngrySlashL(BASE.L.x, BASE.L.y)); + eyeR.setAttribute("d", eyeAngrySlashR(BASE.R.x, BASE.R.y)); + break; + case "sleepy": + eyeL.setAttribute("d", eyeSleepLine(BASE.L.x, BASE.L.y + 10, BASE.eyeW)); + eyeR.setAttribute("d", eyeSleepLine(BASE.R.x, BASE.R.y + 10, BASE.eyeW)); + break; + case "surprised": + eyeL.setAttribute("d", eyeRound(BASE.L.x, BASE.L.y, 40)); + eyeR.setAttribute("d", eyeRound(BASE.R.x, BASE.R.y, 40)); + break; + case "excited": + eyeL.setAttribute("d", eyeRound(BASE.L.x, BASE.L.y - 6, 46)); + eyeR.setAttribute("d", eyeRound(BASE.R.x, BASE.R.y - 6, 46)); + break; + case "sad": + eyeL.setAttribute("d", eyeSadSmall(BASE.L.x, BASE.L.y, BASE.eyeW)); + eyeR.setAttribute("d", eyeSadSmall(BASE.R.x, BASE.R.y, BASE.eyeW)); + break; + default: + eyeL.setAttribute("d", eyeOval(BASE.L.x, BASE.L.y, BASE.eyeW, BASE.eyeH)); + eyeR.setAttribute("d", eyeOval(BASE.R.x, BASE.R.y, BASE.eyeW, BASE.eyeH)); + } + } + + // mouth mapping + switch (emo) { + case "neutral": + mouth.setAttribute("d", mouthDot(BASE.mouthCx, BASE.mouthY + 20)); + break; + case "happy": + mouth.setAttribute("d", state.intensity > 0.75 ? mouthGrin(BASE.mouthCx, BASE.mouthY) : mouthSmile(BASE.mouthCx, BASE.mouthY, 190)); + break; + case "excited": + mouth.setAttribute("d", mouthGrin(BASE.mouthCx, BASE.mouthY - 6)); + break; + case "sad": + mouth.setAttribute("d", mouthFrown(BASE.mouthCx, BASE.mouthY - 6, 190)); + break; + case "angry": + mouth.setAttribute("d", mouthShout(BASE.mouthCx, BASE.mouthY - 12)); + break; + case "sleepy": + mouth.setAttribute("d", mouthLine(BASE.mouthCx, BASE.mouthY + 26, 170)); + break; + case "surprised": + mouth.setAttribute("d", mouthO(BASE.mouthCx, BASE.mouthY - 6)); + break; + default: + mouth.setAttribute("d", mouthLine(BASE.mouthCx, BASE.mouthY + 20, 170)); + } + + // top bar color hint + if (emo === "angry") topbarLit.setAttribute("stroke", "rgba(255,107,107,0.95)"); + else if (emo === "sad" || emo === "sleepy") topbarLit.setAttribute("stroke", "rgba(120,200,255,0.95)"); + else topbarLit.setAttribute("stroke", "rgba(120,255,235,0.95)"); + + updateControlLink(); + } + + function scheduleBlink() { + clearTimeout(blinkTimer); + const base = state.emotion === "sleepy" ? 5200 : (state.emotion === "excited" ? 2000 : 2800); + blinkTimer = setTimeout(() => { + blinking = true; + render(); + setTimeout(() => { + blinking = false; + render(); + scheduleBlink(); + }, 140); + }, base + Math.random() * 2200); + } + + let driftTimer = null; + function scheduleDrift() { + clearTimeout(driftTimer); + if (state.lookLock) return; + if (!state.eyesMoving) return; + + const iconLocked = (state.emotion === "angry" || state.emotion === "happy" || state.emotion === "sleepy"); + if (iconLocked) return; + + state.look = { + x: (Math.random() * 2 - 1) * 0.5, + y: (Math.random() * 2 - 1) * 0.35, + }; + render(); + driftTimer = setTimeout(scheduleDrift, 900 + Math.random() * 900); + } + + // Public API + window.applyFaceState = (payload) => { + if (!payload || typeof payload !== "object") return; + + if (payload.intensity !== undefined) applyIntensity(payload.intensity); + if (typeof payload.emotion === "string") state.emotion = EMOTIONS.has(payload.emotion) ? payload.emotion : "neutral"; + + if (payload.look && typeof payload.look === "object") { + state.look = { + x: clamp(Number(payload.look.x ?? 0), -1, 1), + y: clamp(Number(payload.look.y ?? 0), -1, 1), + }; + state.lookLock = true; + } else { + state.lookLock = false; + } + + if (typeof payload.speaking === "boolean") state.speaking = payload.speaking; + if (typeof payload.eyesMoving === "boolean") state.eyesMoving = payload.eyesMoving; + + render(); + scheduleBlink(); + if (!state.lookLock && state.eyesMoving) scheduleDrift(); + + if (state.speaking) startSpeaking(); else stopSpeaking(); + }; + + // SSE /events + (function connectSSE(){ + let es; + function open() { + es = new EventSource("/events"); + + // server recommended: event: state + es.addEventListener("state", (ev) => { + try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {} + }); + + // fallback for plain data: + es.onmessage = (ev) => { + try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {} + }; + + es.onerror = () => { + try { es.close(); } catch (_) {} + setTimeout(open, 1200); + }; + } + open(); + })(); + + // Init default + applyIntensity(state.intensity); + updateControlLink(); + render(); + scheduleBlink(); + scheduleDrift(); })(); diff --git a/face/var/www/html/index.html b/face/var/www/html/index.html index 06c8046..6e28269 100755 --- a/face/var/www/html/index.html +++ b/face/var/www/html/index.html @@ -3,112 +3,90 @@ - Helva Face Display - + Face + -
-
Tippe, um Drive & Control zu sehen
+
+ + + + + + + + + + + + + + + + + + + + + -
- - - - - - - - + + - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - -
-
- 🚗 Drive - 🎛️ Control: neutral - - - ready - -
-
- - -
-
+ +
- + diff --git a/face/var/www/html/style.css b/face/var/www/html/style.css deleted file mode 100755 index 9594e45..0000000 --- a/face/var/www/html/style.css +++ /dev/null @@ -1,139 +0,0 @@ -:root{ - --bg:#000; - --panel:#0b1020cc; - --text:#e8f0ff; - --muted:#a8b6d8; - --accent:#7cffc9; - --accent2:#7cb7ff; - --danger:#ff6b6b; - --shadow: 0 12px 40px rgba(0,0,0,.55); - --r: 18px; -} - -html,body{ - height:100%; - margin:0; - background:var(--bg); - color:var(--text); - font-family:system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; -} - -.stage{ - position:fixed; inset:0; - display:grid; place-items:center; - overflow:hidden; - touch-action:manipulation; - user-select:none; - background:#000; -} - -.hint{ - position:fixed; - top: max(10px, env(safe-area-inset-top)); - left: 0; - right: 0; - text-align:center; - opacity:.55; - font-size:12px; - color:var(--muted); - pointer-events:none; -} - -.displayWrap{ - width:min(96vw, 720px); - aspect-ratio: 520 / 360; - position:relative; - display:grid; place-items:center; -} - -.display{ - width:100%; - height:100%; - display:block; - filter: drop-shadow(0 18px 40px rgba(0,0,0,.70)); -} - -/* Touch overlay */ -.overlay{ - position:absolute; - left: max(12px, env(safe-area-inset-left)); - right: max(12px, env(safe-area-inset-right)); - bottom: max(12px, env(safe-area-inset-bottom)); - display:flex; - gap:10px; - align-items:center; - justify-content:space-between; - background:var(--panel); - border:1px solid rgba(255,255,255,.10); - border-radius: var(--r); - padding:10px 12px; - box-shadow: var(--shadow); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - opacity:0; - transform: translateY(10px); - pointer-events:none; - transition: opacity .18s ease, transform .18s ease; -} - -.overlay.show{ - opacity:1; - transform: translateY(0); - pointer-events:auto; -} - -.overlay .left, -.overlay .right{ - display:flex; - gap:10px; - align-items:center; - flex-wrap:wrap; -} - -a.btn, button.btn{ - appearance:none; - border:1px solid rgba(255,255,255,.14); - background: rgba(255,255,255,.06); - color:var(--text); - border-radius: 14px; - padding:10px 12px; - font-size:14px; - text-decoration:none; - line-height:1; - display:inline-flex; - align-items:center; - gap:8px; - cursor:pointer; -} - -a.btn:active, button.btn:active{ transform: translateY(1px); } - -.pill{ - font-size:12px; - color:var(--muted); - padding:8px 10px; - border-radius:999px; - border:1px solid rgba(255,255,255,.12); - background: rgba(255,255,255,.04); - display:inline-flex; - align-items:center; - gap:8px; -} - -.dot{ - width:9px; - height:9px; - border-radius:99px; - background: var(--accent2); - box-shadow: 0 0 0 3px rgba(124,183,255,.15); -} -.dot.on{ - background: var(--accent); - box-shadow: 0 0 0 3px rgba(124,255,201,.15); -} -.dot.off{ - background: var(--danger); - box-shadow: 0 0 0 3px rgba(255,107,107,.12); -} - -