mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
- 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>
246 lines
8.3 KiB
React
246 lines
8.3 KiB
React
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; // 0–120 ms: closing
|
||
if (e < 220) return 0; // 120–220 ms: closed
|
||
if (e < 340) return (e - 220) / 120; // 220–340 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.5–5 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',
|
||
}}
|
||
/>
|
||
);
|
||
}
|