Files
links/static_src/jbot/components/RobotFace.jsx
T
junvandCopilot a87b4643fd feat: port JBOT AI robot face into links as Django app
- Add jbot Django app (jbot/__init__.py, apps.py, urls.py, api_urls.py)
- Python backend (jbot/views.py): TTS 2.0, ASR 1.0 BigASR, LLM/memory via OpenRouter
- React SPA frontend at /jbot/ (static_src/jbot/): RobotFace, ChatPanel, expressions,
  voices, push-to-talk, history, memory, settings pages
- Vite entry jbot: static_src/jbot/main.jsx → dist/jbot.js + dist/jbot.css
- react-router-dom added; BrowserRouter basename=/jbot for SPA routing
- core/settings.py: added jbot to INSTALLED_APPS
- core/urls.py: /jbot/ + /api/jbot/ URL includes
- pyproject.toml: websocket-client>=1.9.0 for BigASR binary WS protocol
- Dockerfile: ffmpeg added to production apt-get for WebM→PCM audio conversion
- k8s/manifest.yaml: VOLC_APP_ID, VOLC_ACCESS_TOKEN, OPENROUTER_API_KEY env vars
  via jbot-credentials Secret; LLM_MODEL, VOLC_TTS_VOICE, VOLC_ASR_RESOURCE defaults

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-17 14:13:37 +10:00

246 lines
8.3 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef } from 'react';
import { G, DOT_R, CANVAS, FACE, byId, getSpeakMouth } from '../data/expressions';
// ── Drawing primitives ────────────────────────────────────────────
/**
* drawDots with optional twinkling.
* When t is provided, dots are split into bright/dim groups each frame
* using a per-dot phase hash → asynchronous LED shimmer effect.
* When t is null (transition/fade), uses single-batch draw for performance.
*/
function drawDots(ctx, pts, color, alpha, t = null) {
if (!pts || pts.length === 0) return;
ctx.save();
if (t == null) {
// ── Static: one batched fill (fast)
ctx.globalAlpha = Math.max(0, Math.min(1, alpha));
ctx.shadowBlur = 14;
ctx.shadowColor = color;
ctx.fillStyle = color;
ctx.beginPath();
for (const [gx, gy] of pts) {
const x = gx * G + G / 2, y = gy * G + G / 2;
ctx.moveTo(x + DOT_R, y);
ctx.arc(x, y, DOT_R, 0, Math.PI * 2);
}
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.beginPath();
for (const [gx, gy] of pts) {
const x = gx * G + G / 2, y = gy * G + G / 2;
ctx.moveTo(x + DOT_R * 0.38, y);
ctx.arc(x, y, DOT_R * 0.38, 0, Math.PI * 2);
}
ctx.fill();
} else {
// ── Live: split by per-dot sine phase → bright/dim groups
const bright = [], dim = [];
for (const [gx, gy] of pts) {
const phase = (gx * 7.3 + gy * 13.1) * 0.31;
(Math.sin(t * 1.1 + phase) > 0 ? bright : dim).push([gx, gy]);
}
const drawGroup = (group, a, blur) => {
if (!group.length) return;
ctx.globalAlpha = Math.max(0, Math.min(1, a));
ctx.shadowBlur = blur;
ctx.shadowColor = color;
ctx.fillStyle = color;
ctx.beginPath();
for (const [gx, gy] of group) {
const x = gx * G + G / 2, y = gy * G + G / 2;
ctx.moveTo(x + DOT_R, y);
ctx.arc(x, y, DOT_R, 0, Math.PI * 2);
}
ctx.fill();
};
drawGroup(dim, alpha * 0.68, 7); // dim group: subdued glow
drawGroup(bright, alpha, 20); // bright group: extra glow
// White cores (always batched)
ctx.globalAlpha = Math.max(0, Math.min(1, alpha * 0.55));
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.beginPath();
for (const [gx, gy] of pts) {
const x = gx * G + G / 2, y = gy * G + G / 2;
ctx.moveTo(x + DOT_R * 0.38, y);
ctx.arc(x, y, DOT_R * 0.38, 0, Math.PI * 2);
}
ctx.fill();
}
ctx.restore();
}
// Squeeze dot Y-coords toward the eye centre row (blink squash)
function applyBlink(pts, eyeCY, blinkScale) {
if (blinkScale >= 1) return pts;
return pts.map(([gx, gy]) => [gx, eyeCY + (gy - eyeCY) * blinkScale]);
}
function getBlinkScale(blinkStart, now) {
if (blinkStart === null) return 1;
const e = now - blinkStart;
if (e < 120) return 1 - e / 120; // 0120 ms: closing
if (e < 220) return 0; // 120220 ms: closed
if (e < 340) return (e - 220) / 120; // 220340 ms: opening
return 1;
}
function drawEye(ctx, eyeData, cx, cy, color, pupilColor, alpha, blinkScale, t) {
if (!eyeData) return;
const pc = pupilColor || '#001020';
if (eyeData.iris) {
drawDots(ctx, applyBlink(eyeData.iris, cy, blinkScale), color, alpha, t);
drawDots(ctx, applyBlink(eyeData.pupil, cy, blinkScale), pc, alpha, null);
if (eyeData.hl)
drawDots(ctx, applyBlink(eyeData.hl, cy, blinkScale), '#ffffff', alpha * 0.9, null);
if (eyeData.lash)
drawDots(ctx, applyBlink(eyeData.lash,cy, blinkScale), color, alpha, null);
} else if (eyeData.closed !== undefined) {
drawDots(ctx, eyeData.closed, color, alpha, t);
if (eyeData.lash) drawDots(ctx, eyeData.lash, color, alpha, null);
} else if (eyeData.wink) { drawDots(ctx, eyeData.wink, color, alpha, t); }
else if (eyeData.heart) { drawDots(ctx, eyeData.heart, color, alpha, t); }
else if (eyeData.spiral) { drawDots(ctx, eyeData.spiral, color, alpha, t); }
else if (eyeData.star) { drawDots(ctx, eyeData.star, color, alpha, t); }
else if (eyeData.pts) {
// pts-based micro-expression eyes: support blink + sparkle
drawDots(ctx, applyBlink(eyeData.pts, cy, blinkScale), color, alpha, t);
}
}
// ── RobotFace component ───────────────────────────────────────────
export default function RobotFace({ expressionId, scale = 2 }) {
const canvasRef = useRef(null);
const animRef = useRef({
currentId: expressionId,
previousId: null,
transitionStart: null,
blinkStart: null,
nextBlink: performance.now() + 2000 + Math.random() * 2000,
t: 0,
raf: null,
});
// Trigger cross-fade whenever the prop changes
useEffect(() => {
const s = animRef.current;
if (expressionId !== s.currentId) {
s.previousId = s.currentId;
s.currentId = expressionId;
s.transitionStart = performance.now();
s.blinkStart = null;
}
}, [expressionId]);
// One-time RAF loop
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const s = animRef.current;
function drawExpression(expr, alpha, blinkScale, t) {
if (!expr) return;
drawEye(ctx, expr.leftEye, FACE.LE[0], FACE.LE[1],
expr.color, expr.pupilColor, alpha, blinkScale, t);
drawEye(ctx, expr.rightEye, FACE.RE[0], FACE.RE[1],
expr.color, expr.pupilColor, alpha, blinkScale, t);
const mouth = expr.speakingMouth
? getSpeakMouth(FACE.MO[0], FACE.MO[1], s.t)
: expr.mouth;
if (mouth) drawDots(ctx, mouth, expr.color, alpha, t);
if (expr.extras) {
for (const extra of expr.extras) {
let ea = alpha * extra.alpha;
if (extra.zzzIndex !== undefined) {
const phase = s.t - extra.zzzIndex * 1.2;
ea *= 0.35 + 0.65 * (Math.sin(phase) * 0.5 + 0.5);
}
drawDots(ctx, extra.dots, extra.color, ea, null); // extras: no sparkle
}
}
}
function render() {
const now = performance.now();
s.t += 0.04;
ctx.clearRect(0, 0, CANVAS, CANVAS);
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, CANVAS, CANVAS);
// Transition alphas (ease-in-out, 380 ms)
let curAlpha = 1, prevAlpha = 0;
if (s.transitionStart !== null) {
const elapsed = now - s.transitionStart;
if (elapsed >= 380) {
s.transitionStart = null;
s.previousId = null;
} else {
const p = elapsed / 380;
const eased = p < 0.5 ? 2 * p * p : 1 - (-2 * p + 2) ** 2 / 2;
curAlpha = eased;
prevAlpha = 1 - eased;
}
}
// Blink scheduling
const curExpr = byId[s.currentId];
if (!s.blinkStart && s.transitionStart === null &&
curExpr?.canBlink && now >= s.nextBlink) {
s.blinkStart = now;
s.nextBlink = now + 2500 + Math.random() * 2500; // 2.55 s between blinks
}
if (s.blinkStart !== null && now - s.blinkStart >= 340) s.blinkStart = null;
const blinkScale = getBlinkScale(s.blinkStart, now);
// Previous: fading out, no sparkle
if (s.previousId) drawExpression(byId[s.previousId], prevAlpha, 1, null);
// Current: sparkle only when fully settled (not mid-transition)
const liveT = s.transitionStart === null ? s.t : null;
drawExpression(curExpr, curAlpha, blinkScale, liveT);
// CRT scanlines overlay
ctx.save();
ctx.globalAlpha = 0.04;
ctx.fillStyle = '#000';
for (let y = 0; y < CANVAS; y += 2) ctx.fillRect(0, y + 1, CANVAS, 1);
ctx.restore();
s.raf = requestAnimationFrame(render);
}
s.raf = requestAnimationFrame(render);
return () => cancelAnimationFrame(s.raf);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<canvas
ref={canvasRef}
width={CANVAS}
height={CANVAS}
style={{
width: CANVAS * scale,
height: CANVAS * scale,
imageRendering: 'pixelated',
borderRadius: 14,
boxShadow: '0 0 40px rgba(0,255,200,0.2)',
display: 'block',
}}
/>
);
}