Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d410dbf24 | ||
|
|
fd6a14054d | ||
|
|
784042335c | ||
|
|
cf54a93b53 | ||
|
|
499a4ae479 | ||
|
|
6029c79645 | ||
|
|
4b9e805801 | ||
|
|
1445ffdcde | ||
|
|
788215b49e | ||
|
|
98c9e8803e | ||
|
|
2dab45604c | ||
|
|
982bf56cd0 | ||
|
|
1029b5fa99 | ||
|
|
a67c5ea9af | ||
|
|
e0dd03411d | ||
|
|
74ae2863a7 | ||
|
|
f7c4793dc2 | ||
|
|
319e63cc42 | ||
|
|
f550930f56 | ||
|
|
230ca991a2 | ||
|
|
628db8492f | ||
|
|
f45e05ab81 | ||
|
|
69018c8b36 | ||
|
|
972dd8dd66 | ||
|
|
b6b8e2efd5 | ||
|
|
69d04d4972 | ||
|
|
a058c6f453 |
@@ -23,3 +23,6 @@ api-privada.tmp
|
||||
|
||||
ia-config.json
|
||||
seguranca.json
|
||||
historico.db
|
||||
historico.json
|
||||
fotos/
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="br.carinha.rosto"
|
||||
android:versionCode="8"
|
||||
android:versionName="0.8-token">
|
||||
android:versionCode="11"
|
||||
android:versionName="0.8.3">
|
||||
|
||||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
|
||||
|
||||
<!-- precisa de rede para falar com o servidor da carinha (troca de rosto) -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- só o nível do sinal do Wi-Fi, para o painel (sem localização nem nome da rede) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<!-- escutar o ambiente: reage a barulho (surpreso) e a silencio (dormir) -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- camera: pagina de visualizacao e, depois, seguir o movimento -->
|
||||
|
||||
+173
-14
@@ -25,6 +25,8 @@
|
||||
<path id="sobrancelha2" class="traco" style="display:none"/>
|
||||
</svg>
|
||||
<div id="fps"></div>
|
||||
<script src="rosto-motor.js"></script>
|
||||
<script src="voz-robo.js"></script>
|
||||
<script>
|
||||
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
|
||||
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
|
||||
@@ -90,12 +92,33 @@ addEventListener("resize", ajustar);
|
||||
|
||||
const PRETO = [6, 8, 14];
|
||||
const CIANO = [92, 214, 255];
|
||||
const EXTRAS = { boca: true, sobrancelhas: false };
|
||||
const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 };
|
||||
const SONO = { apagar: true, minutos: 5, desde: 0, apagada: false };
|
||||
/* Dormindo há tempo demais: apaga a tela (preto total) e o app baixa o brilho ao mínimo.
|
||||
Qualquer coisa que acorde o rosto (som, movimento, toque, comando, PIR) acende de novo. */
|
||||
function cuidarDaTela() {
|
||||
const agora = performance.now();
|
||||
if (!r.dormindo) SONO.desde = 0;
|
||||
else if (!SONO.desde) SONO.desde = agora;
|
||||
const apagar = SONO.apagar && r.dormindo && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
|
||||
if (apagar !== SONO.apagada) {
|
||||
SONO.apagada = apagar;
|
||||
window.carinhaTelaApagada = apagar;
|
||||
document.body.style.visibility = apagar ? "hidden" : "";
|
||||
console.log(apagar ? "tela apagada (dormindo ha " + SONO.minutos + " min)" : "tela acesa");
|
||||
}
|
||||
}
|
||||
setInterval(cuidarDaTela, 1000);
|
||||
const CIANO_E = [150, 232, 255];
|
||||
const VERM = [255, 78, 78];
|
||||
const ROSA = [255, 112, 176];
|
||||
const AMARELO = [255, 218, 96];
|
||||
const AZUL = [130, 170, 255];
|
||||
// Tons derivados da cor escolhida no painel (CIANO é trocada pela cor escolhida em tempo real):
|
||||
// "claro" para susto/surpresa, "escuro" para tristeza e sono. Nada de azul fixo.
|
||||
const corClara = (k) => CIANO.map((v) => v + (255 - v) * (k === undefined ? 0.4 : k));
|
||||
const corEscura = (k) => CIANO.map((v) => v * (k === undefined ? 0.62 : k));
|
||||
function resolverCor(c) { return c === "claro" ? corClara() : c === "escuro" ? corEscura() : c === "escolhida" ? CIANO.slice() : c; }
|
||||
const VERDE = [120, 255, 160];
|
||||
const BRANCO = [245, 250, 255];
|
||||
|
||||
@@ -103,10 +126,17 @@ const BRANCO = [245, 250, 255];
|
||||
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
|
||||
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
|
||||
const ROSTOS = {
|
||||
vivo: { rotulo: "Carinha Viva", boca: true, escala: 1.00, motor: "vivo" },
|
||||
bloco: { rotulo: "Bloco", boca: false, escala: 1.00, motor: "bloco" },
|
||||
matriz: { rotulo: "Matriz", boca: false, escala: 1.00, motor: "matriz" },
|
||||
lente: { rotulo: "Lente", boca: false, escala: 1.00, motor: "lente" },
|
||||
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
|
||||
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
|
||||
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
|
||||
};
|
||||
// Formato do olho nos rostos novos (rosto-motor.js): padrao, redondo, pilula, quadrado, largo.
|
||||
const FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"];
|
||||
let FORMATO = "padrao";
|
||||
let ROSTO_NOME = "carinha";
|
||||
let ROSTO = ROSTOS[ROSTO_NOME];
|
||||
|
||||
@@ -141,8 +171,18 @@ function caminhoLinha(x0, y0, x1, y1) {
|
||||
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
|
||||
}
|
||||
/* formas da boca (o humor escolhe qual) */
|
||||
function deslocamentoBoca() {
|
||||
const auto = ROSTO && ROSTO.motor ? 22 : 0; // rostos novos têm olhos mais baixos
|
||||
return -(auto + (EXTRAS.subirBoca || 0)) * ESC;
|
||||
}
|
||||
/* Topo do olho atual (para a sobrancelha ficar perto dele, em qualquer estilo/formato). */
|
||||
function metadeOlho(esc) {
|
||||
const m = ROSTO && ROSTO.motor && window.MotorRosto && window.MotorRosto.metadeAltura
|
||||
? window.MotorRosto.metadeAltura(ROSTO.motor, FORMATO) : 1;
|
||||
return m * RY * esc;
|
||||
}
|
||||
function caminhoBoca(tipo) {
|
||||
const cx = BOCA[0], cy = BOCA[1], l = BOCA_L, meia = l / 2;
|
||||
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca(), l = BOCA_L, meia = l / 2;
|
||||
if (tipo === "aberta") { // surpresa: um "O"
|
||||
const rx = l * 0.17, ry = l * 0.22;
|
||||
return caminhoElipse(cx, cy, rx, ry);
|
||||
@@ -283,7 +323,7 @@ function sequencias() {
|
||||
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
|
||||
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
|
||||
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
|
||||
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: CIANO_E, boca: "aberta" }, 0.09],
|
||||
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: "claro", boca: "aberta" }, 0.09],
|
||||
[{ dy: -10 }, 0.5],
|
||||
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
|
||||
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
|
||||
@@ -292,13 +332,21 @@ function sequencias() {
|
||||
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
|
||||
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
|
||||
[{ forma: "aberto", cor: CIANO }, 0.3]];
|
||||
const triste = [[{ forma: "triste", cor: AZUL, dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
|
||||
const triste = [[{ forma: "triste", cor: "escuro", dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
|
||||
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
|
||||
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
|
||||
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
|
||||
// Gestos com mãos: o motor desenha as mãos com a mesma duração (rosto-motor.js, DUR_MAOS).
|
||||
const esconder = [[{ forma: "aberto", esc: 0.95, dy: 4 }, 0.3], [{}, 1.6], [{ forma: "feliz", boca: "sorriso", esc: 1.05, dy: 0 }, 0.3], [{ forma: "aberto", esc: 1.0 }, 0.4]];
|
||||
const espiar = [[{ esc: 0.96 }, 0.4], [{}, 0.6], [{ dx: -14, forma: "aberto", ab: 1.08 }, 0.25], [{}, 0.9], [{ dx: 0, ab: 1.0 }, 0.35], [{ forma: "feliz", boca: "sorriso" }, 0.4], [{ forma: "aberto" }, 0.3]];
|
||||
const acenar = [[{ forma: "feliz", boca: "sorriso", esc: 1.05 }, 0.3], [{}, 1.7], [{ forma: "aberto", esc: 1.0 }, 0.4]];
|
||||
const pensar = [[{ dy: -10, dx: 14, boca: "neutra" }, 0.4], [{}, 2.0], [{ dy: 0, dx: 0 }, 0.4]];
|
||||
const musica = [[{ forma: "feliz", boca: "sorriso", dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25],
|
||||
[{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ forma: "aberto", dy: 0 }, 0.3]];
|
||||
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
|
||||
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
|
||||
return { piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
|
||||
return { esconder, espiar, acenar, pensar, musica,
|
||||
piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
|
||||
bravo, coracao, triste, piscadinha, alegre };
|
||||
}
|
||||
|
||||
@@ -317,6 +365,7 @@ function proximo() {
|
||||
const [alvo, dur] = r.fila[0];
|
||||
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
|
||||
r.cor_ini = r.cor.slice();
|
||||
if ("cor" in alvo) alvo.cor = resolverCor(alvo.cor); // "claro"/"escuro" viram tons da cor escolhida
|
||||
if ("forma" in alvo) r.forma = alvo.forma;
|
||||
if ("forma2" in alvo) r.forma2 = alvo.forma2;
|
||||
if ("boca" in alvo) r.boca = alvo.boca;
|
||||
@@ -351,7 +400,7 @@ function passo(dt) { // avanca a fila; true se ha gesto em
|
||||
function repouso(t) { // pose calma: olha devagar, "respira"
|
||||
if (r.dormindo) {
|
||||
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
|
||||
r.cor = lerpCor(AZUL, [40, 60, 120], 0.5 + 0.5 * Math.sin(t * 1.2));
|
||||
r.cor = lerpCor(corEscura(0.75), corEscura(0.35), 0.5 + 0.5 * Math.sin(t * 1.2)); // respira na cor escolhida
|
||||
r.ab = 0; r.ab2 = 0;
|
||||
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
|
||||
r.dx = 0;
|
||||
@@ -380,20 +429,39 @@ function desenhar() {
|
||||
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
|
||||
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
|
||||
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const comMotor = ROSTO.motor && window.MotorRosto;
|
||||
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
|
||||
if (comMotor) {
|
||||
window.MotorRosto.desenhar({
|
||||
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo,
|
||||
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
|
||||
carga: window.carinhaBateria || null,
|
||||
iris: EXTRAS.iris,
|
||||
tracos: ROSTO.motor === "matriz" ? tracosDoRosto(esc) : null,
|
||||
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
|
||||
parado: !r.kf && !seguindo && !r.dormindo,
|
||||
olhos: olhos.map(([c, abx, fx, lado]) => ({
|
||||
cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc,
|
||||
ab: abx, forma: fx, lado, lx: dx, ly: dy * 0.6,
|
||||
})),
|
||||
});
|
||||
} else if (window.MotorRosto) window.MotorRosto.esconder();
|
||||
for (let i = 0; i < 2 && !comMotor; i++) {
|
||||
const [c, abx, fx, lado] = olhos[i];
|
||||
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
|
||||
abx, fx, r.cor, lado, dx, dy * 0.6);
|
||||
}
|
||||
desenharBoca();
|
||||
const matriz = comMotor && ROSTO.motor === "matriz";
|
||||
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
|
||||
else desenharBoca();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const brow = document.getElementById('sobrancelha' + (i + 1));
|
||||
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
|
||||
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
|
||||
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
|
||||
const c = i === 0 ? E1 : E2;
|
||||
const lado = i === 0 ? 1 : -1;
|
||||
const largura = RX * esc * 0.85;
|
||||
const y = c[1] - RY * esc - 20 * ESC;
|
||||
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
|
||||
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
|
||||
const elevacao = r.esc > 1.1 ? -15 * ESC : 0;
|
||||
brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
|
||||
@@ -402,6 +470,27 @@ function desenhar() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Boca e sobrancelhas como caminhos SVG, para a Matriz acender em LEDs (mesmas formas do SVG). */
|
||||
function tracosDoRosto(esc) {
|
||||
const tracos = [];
|
||||
if (EXTRAS.boca) {
|
||||
const forma = r.boca;
|
||||
if (r.dormindo) { // dormindo na Matriz: "– _ –"
|
||||
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca() + BOCA_L * 0.08, l = BOCA_L * 0.32;
|
||||
tracos.push({ d: 'M ' + (cx - l) + ',' + cy + ' L ' + (cx + l) + ',' + cy, largura: BOCA_L * 0.16 });
|
||||
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
|
||||
}
|
||||
if (EXTRAS.sobrancelhas && !r.dormindo) {
|
||||
[E1, E2].forEach((c, i) => {
|
||||
const lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
|
||||
const inc = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
|
||||
const ele = r.esc > 1.1 ? -15 * ESC : 0;
|
||||
tracos.push({ d: 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele), largura: 9 * ESC });
|
||||
});
|
||||
}
|
||||
return tracos;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ controle
|
||||
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
|
||||
function trocarRosto(nome) {
|
||||
@@ -430,6 +519,9 @@ function trata(ordem) {
|
||||
trocarRosto(ordem.slice(6).trim());
|
||||
} else if (ROSTOS[ordem]) {
|
||||
trocarRosto(ordem); // aceita so o nome: "miudo"
|
||||
} else if (ordem.indexOf("formato ") === 0) {
|
||||
const f = ordem.slice(8).trim();
|
||||
if (FORMATOS.includes(f)) FORMATO = f;
|
||||
} else if (ordem.indexOf("cor ") === 0) {
|
||||
const v = ordem.slice(4).split(",").map(Number);
|
||||
if (v.length === 3 && v.every((n) => !isNaN(n))) {
|
||||
@@ -442,6 +534,10 @@ function trata(ordem) {
|
||||
} else if (ordem === "aleatorio") {
|
||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
||||
} else if (SEQS[ordem]) {
|
||||
if (window.MotorRosto) {
|
||||
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
|
||||
else window.MotorRosto.maos(ordem);
|
||||
}
|
||||
animar(ordem);
|
||||
} else if (ordem === "calma") {
|
||||
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
|
||||
@@ -462,7 +558,7 @@ function pose(nome) {
|
||||
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
|
||||
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
|
||||
}
|
||||
if ("cor" in alvo) r.cor = alvo.cor.slice();
|
||||
if ("cor" in alvo) r.cor = resolverCor(alvo.cor).slice();
|
||||
congelado = true;
|
||||
desenhar();
|
||||
return true;
|
||||
@@ -501,6 +597,7 @@ async function aplicarAparencia() {
|
||||
if (!resp.ok) return;
|
||||
const c = await resp.json();
|
||||
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
|
||||
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
|
||||
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
|
||||
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
|
||||
} catch (e) { /* sem servidor — segue com o padrao */ }
|
||||
@@ -516,9 +613,29 @@ if (MODO_PAINEL) {
|
||||
const m = e.data;
|
||||
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
|
||||
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
|
||||
else if (m.tipo === 'sono' && !!m.dormindo !== r.dormindo) trata(m.dormindo ? 'dormir' : 'acordar');
|
||||
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
|
||||
});
|
||||
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
|
||||
|
||||
// toque na tela: cutucar dá susto e depois risada; deslizar o dedo é carinho.
|
||||
// O toque longo (menu do app) passa direto: só reagimos a toques curtos.
|
||||
(function () {
|
||||
let inicio = null;
|
||||
addEventListener('pointerdown', (e) => { inicio = { x: e.clientX, y: e.clientY, t: performance.now() }; });
|
||||
addEventListener('pointerup', (e) => {
|
||||
if (!inicio) return;
|
||||
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
|
||||
inicio = null;
|
||||
if (r.dormindo) { trata('acordar'); return; }
|
||||
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); return; }
|
||||
if (dur < 350 && Math.hypot(dx, dy) < 15) {
|
||||
animar('surpreso');
|
||||
setTimeout(() => animar('feliz'), 700);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// teclado (util para testar no PC)
|
||||
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
|
||||
t: "triste", a: "alegre", o: "olhar_esq", i: "olhar_dir",
|
||||
@@ -616,6 +733,16 @@ function checarSilencio(agora) {
|
||||
journal do carinha-web (so quando o microfone esta ativo). */
|
||||
function relatarSom(evento, n) {
|
||||
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
||||
if (evento !== "adormeceu") registrarEvento("som", evento, n);
|
||||
}
|
||||
|
||||
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
|
||||
function registrarEvento(tipo, evento, nivel) {
|
||||
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
|
||||
try {
|
||||
fetch("/historico/evento", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tipo, evento, nivel }) }).catch(() => {});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ presenca (movimento)
|
||||
@@ -704,6 +831,7 @@ function movimento(vel, cx, cy) {
|
||||
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
|
||||
function relatarMov(evento, n) {
|
||||
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
||||
registrarEvento("movimento", evento, n);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- camera oculta (sensor)
|
||||
@@ -834,7 +962,11 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
|
||||
|
||||
let configRecebida = null;
|
||||
function configurarApp(c) {
|
||||
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true;
|
||||
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
|
||||
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca;
|
||||
SONO.apagar = c.apagarTela !== false;
|
||||
if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin;
|
||||
if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
|
||||
AUTO = c.auto;
|
||||
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
|
||||
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
|
||||
@@ -864,7 +996,9 @@ async function sincronizarApp() {
|
||||
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
|
||||
centro: [MOV.cx, MOV.cy], config: configRecebida,
|
||||
brilho: window.carinhaBrilho,
|
||||
tela_apagada: !!window.carinhaTelaApagada,
|
||||
bateria: window.carinhaBateria,
|
||||
aparelho: window.carinhaAparelho,
|
||||
video: window.carinhaVideo,
|
||||
}) });
|
||||
}
|
||||
@@ -909,15 +1043,32 @@ const SONS = {
|
||||
dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); },
|
||||
};
|
||||
|
||||
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
|
||||
os sons dos gestos ficam quietos para não embolar. */
|
||||
function falar(texto) {
|
||||
if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
|
||||
const c = contextoAudio();
|
||||
if (!c) return;
|
||||
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||
try {
|
||||
const dur = window.VozRobo.balbuciar(c, texto, AUDIO.volume * 0.85);
|
||||
AUDIO.mudoAte = performance.now() + dur * 1000;
|
||||
} catch (e) { /* segue sem voz */ }
|
||||
}
|
||||
|
||||
function tocarSom(gesto) {
|
||||
if (!AUDIO.ligado) return false;
|
||||
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
|
||||
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
|
||||
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
|
||||
const f = SONS[gesto];
|
||||
if (!f) return false;
|
||||
if (!voz && !f) return false;
|
||||
const c = contextoAudio();
|
||||
if (!c) return false;
|
||||
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||
try {
|
||||
f(c, c.currentTime + 0.02, AUDIO.volume);
|
||||
if (voz) window.VozRobo.tocar(c, gesto, AUDIO.volume); // voz de robô sintetizada
|
||||
else f(c, c.currentTime + 0.02, AUDIO.volume); // bipes antigos (reserva)
|
||||
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
|
||||
console.log("voz: " + gesto + " ctx=" + c.state);
|
||||
return true;
|
||||
@@ -1004,7 +1155,9 @@ setTimeout(relatar, 1500);
|
||||
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
|
||||
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
|
||||
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
|
||||
falar(m.texto);
|
||||
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
|
||||
if(window.MotorRosto)window.MotorRosto.faixa('legenda',{texto:m.texto});
|
||||
}catch(e){}finally{fetching=false;}
|
||||
}
|
||||
setInterval(poll,1000);poll();
|
||||
@@ -1045,10 +1198,16 @@ if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchPa
|
||||
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
|
||||
ouvir('app_config', (c) => configurarApp(c));
|
||||
ouvir('conversa', (s) => {
|
||||
if (window.MotorRosto) {
|
||||
if (s.ocupado) window.MotorRosto.faixa('digitando');
|
||||
else window.MotorRosto.faixa(null, { so: 'digitando' });
|
||||
}
|
||||
const m = s.ultima;
|
||||
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
|
||||
ultimaConversa = m.id;
|
||||
falar(m.texto);
|
||||
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
|
||||
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
|
||||
});
|
||||
ouvir('reacao', (d) => {
|
||||
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
/* rosto-motor.js — motor de pálpebras dos rostos novos (Carinha Viva, Bloco, Matriz, Lente).
|
||||
*
|
||||
* O carinha.html continua decidindo O QUE o rosto faz (gestos, reações, sensores, rastreamento);
|
||||
* aqui só se desenha. Cada "forma" vira posição de pálpebra (cima, inclinação, baixo) e o motor
|
||||
* suaviza a passagem entre elas, então feliz → triste não salta de um desenho para outro.
|
||||
* Também acrescenta vida: respiração, sacadas e achatamento na piscada.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// forma → pálpebras. top: pálpebra de cima (0 aberto, 1 fechado); slant: + = canto interno sobe;
|
||||
// bot: pálpebra de baixo sobe ("olho sorrindo").
|
||||
const PALPEBRAS = {
|
||||
aberto: { top: 0, slant: 0, bot: 0 },
|
||||
surpreso: { top: 0, slant: 0, bot: 0 },
|
||||
feliz: { top: 0, slant: 0, bot: 0.46 },
|
||||
coracao: { top: 0, slant: 0, bot: 0.42 },
|
||||
triste: { top: 0.3, slant: 0.32, bot: 0 },
|
||||
bravo: { top: 0.34, slant: -0.42, bot: 0.06 },
|
||||
dormir: { top: 0.9, slant: 0.04, bot: 0.08 },
|
||||
};
|
||||
|
||||
// Medidas em frações do raio vertical do olho clássico (RY), para casar com o layout atual.
|
||||
const ESTILOS = {
|
||||
vivo: { w: 1.0, h: 1.45, r: 0.5, brilho: true },
|
||||
bloco: { w: 1.35, h: 1.25, r: 0.22, brilho: true },
|
||||
matriz: { w: 1.0, h: 1.45, r: 0.5, brilho: false, pontos: true },
|
||||
lente: { w: 1.15, h: 1.15, r: 0.58, lente: true },
|
||||
};
|
||||
|
||||
// Formato do olho, escolhido no painel e combinável com Viva, Bloco e Matriz.
|
||||
const FORMATOS = {
|
||||
padrao: null,
|
||||
redondo: { w: 1.15, h: 1.15, r: 0.58 },
|
||||
pilula: { w: 0.8, h: 1.55, r: 0.4 },
|
||||
quadrado: { w: 1.3, h: 1.3, r: 0.12 },
|
||||
largo: { w: 1.65, h: 0.9, r: 0.3 },
|
||||
};
|
||||
|
||||
let tela = null, ctx = null, camada = null, cctx = null, baixa = null, bctx = null;
|
||||
const estado = [novo(), novo()];
|
||||
const sacada = { x: 0, y: 0, alvoX: 0, alvoY: 0, proxima: 0 };
|
||||
let antes = 0;
|
||||
|
||||
function novo() { return { top: 0, slant: 0, bot: 0, coracao: 0 }; }
|
||||
const css = (c, a) => 'rgba(' + c.map((v) => Math.round(v)).join(',') + ',' + (a === undefined ? 1 : a) + ')';
|
||||
|
||||
function preparar() {
|
||||
if (tela) return;
|
||||
tela = document.createElement('canvas');
|
||||
tela.id = 'rostoMotor';
|
||||
tela.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:2;display:none';
|
||||
document.body.append(tela);
|
||||
ctx = tela.getContext('2d');
|
||||
camada = document.createElement('canvas'); cctx = camada.getContext('2d');
|
||||
baixa = document.createElement('canvas'); bctx = baixa.getContext('2d', { willReadFrequently: true });
|
||||
}
|
||||
|
||||
function medir() {
|
||||
// Até 1,5x a densidade: a tela do S10 é enorme e o brilho custa caro.
|
||||
const d = Math.min(1.5, window.devicePixelRatio || 1);
|
||||
const w = Math.round(innerWidth * d), h = Math.round(innerHeight * d);
|
||||
if (tela.width !== w || tela.height !== h) { tela.width = camada.width = w; tela.height = camada.height = h; }
|
||||
return d;
|
||||
}
|
||||
|
||||
function caixa(c, x, y, w, h, r) {
|
||||
r = Math.max(0, Math.min(r, w / 2, h / 2));
|
||||
c.beginPath();
|
||||
if (c.roundRect) c.roundRect(x, y, w, h, r);
|
||||
else { // WebViews antigos
|
||||
c.moveTo(x + r, y); c.arcTo(x + w, y, x + w, y + h, r); c.arcTo(x + w, y + h, x, y + h, r);
|
||||
c.arcTo(x, y + h, x, y, r); c.arcTo(x, y, x + w, y, r); c.closePath();
|
||||
}
|
||||
}
|
||||
|
||||
function coracao(c, cx, cy, s) {
|
||||
c.beginPath();
|
||||
c.moveTo(cx, cy + s * 0.72);
|
||||
c.bezierCurveTo(cx - s * 1.3, cy - s * 0.3, cx - s * 0.8, cy - s * 1.18, cx, cy - s * 0.3);
|
||||
c.bezierCurveTo(cx + s * 0.8, cy - s * 1.18, cx + s * 1.3, cy - s * 0.3, cx, cy + s * 0.72);
|
||||
c.fill();
|
||||
}
|
||||
|
||||
const FONTE_EMOJI = '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif';
|
||||
|
||||
/** Figurinha (viewBox 100x100, só M L C Q Z) ou emoji, centrado no olho. */
|
||||
function conteudo(c, o, cx, cy, tam) {
|
||||
if (o.desenho && Array.isArray(o.desenho.formas)) {
|
||||
c.save();
|
||||
c.translate(cx - tam / 2, cy - tam / 2);
|
||||
c.scale(tam / 100, tam / 100);
|
||||
for (const f of o.desenho.formas) {
|
||||
if (typeof f.caminho !== 'string' || !/^[MLCQZmlcqz0-9.,\s+-]+$/.test(f.caminho) || !/^#[0-9a-f]{6}$/i.test(f.cor)) continue;
|
||||
c.fillStyle = f.cor;
|
||||
c.fill(new Path2D(f.caminho));
|
||||
}
|
||||
c.restore();
|
||||
} else if (o.icone) {
|
||||
c.save();
|
||||
c.font = Math.round(tam * 0.8) + 'px ' + FONTE_EMOJI;
|
||||
c.textAlign = 'center';
|
||||
c.textBaseline = 'middle';
|
||||
c.fillText(o.icone, cx, cy + tam * 0.04);
|
||||
c.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// Olho plano (vivo, bloco, matriz): corpo + pálpebras recortadas (transparentes).
|
||||
function olhoPlano(c, o, p, est, e) {
|
||||
const respira = 1 + 0.012 * Math.sin(o.t * 1.4);
|
||||
const temConteudo = !!(o.icone || o.desenho);
|
||||
if (temConteudo) e = { top: 0, slant: 0, bot: 0, coracao: 0 };
|
||||
const fecha = Math.min(1, Math.max(e.top, 1 - Math.min(1, p.ab)));
|
||||
const abre = Math.max(1, p.ab); // surpreso abre além de 1
|
||||
const amassa = fecha > 0.5 && e.top < 0.5 ? (fecha - 0.5) * 0.2 : 0; // piscada achata e alarga
|
||||
const w = est.w * p.ry * respira * (1 + amassa);
|
||||
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
|
||||
const x = p.cx - w / 2, y = p.cy - h / 2;
|
||||
c.fillStyle = css(o.cor);
|
||||
if (o.dormindo && !temConteudo) { // dormindo: "–" (olho fechado)
|
||||
const esp = Math.max(2, p.ry * 0.2);
|
||||
caixa(c, p.cx - w * 0.5, p.cy + h * 0.08 - esp / 2, w, esp, esp / 2); c.fill();
|
||||
return;
|
||||
}
|
||||
if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; }
|
||||
caixa(c, x, y, w, h, est.r * p.ry * respira);
|
||||
c.fill();
|
||||
c.save();
|
||||
caixa(c, x - 2, y - 2, w + 4, h + 4, est.r * p.ry);
|
||||
c.clip();
|
||||
c.globalCompositeOperation = 'destination-out';
|
||||
c.fillStyle = '#000';
|
||||
const sl = p.lado > 0 ? e.slant : -e.slant; // lado +1 = olho esquerdo; canto interno = lado do nariz
|
||||
const yEsq = y + h * (fecha + sl * 0.5), yDir = y + h * (fecha - sl * 0.5);
|
||||
c.beginPath(); c.moveTo(x - 4, y - 4); c.lineTo(x + w + 4, y - 4); c.lineTo(x + w + 4, yDir); c.lineTo(x - 4, yEsq); c.closePath(); c.fill();
|
||||
c.beginPath(); c.ellipse(p.cx, y + h * (1.62 - e.bot), w * 0.75, h * 0.62, 0, 0, Math.PI * 2); c.fill();
|
||||
c.globalCompositeOperation = 'source-over';
|
||||
if (temConteudo && fecha < 0.6) conteudo(c, o, p.cx, p.cy, Math.min(w, h) * 0.74);
|
||||
else if (o.iris && fecha < 0.6 && e.bot < 0.35) {
|
||||
const R = Math.min(w, h) * 0.3;
|
||||
const ix = Math.max(x + R * 1.1, Math.min(x + w - R * 1.1, p.cx + (p.lx + sacada.x) * w * 0.016));
|
||||
const iy = Math.max(y + R * 1.1, Math.min(y + h - R * 1.1, p.cy + h * 0.04 + (p.ly + sacada.y) * h * 0.016));
|
||||
c.fillStyle = css(o.cor.map((v) => v * 0.42));
|
||||
c.beginPath(); c.arc(ix, iy, R, 0, Math.PI * 2); c.fill();
|
||||
c.globalCompositeOperation = 'destination-out';
|
||||
const pupila = p.ab > 1.05 ? 0.38 : e.bot > 0.2 ? 0.62 : 0.5; // susto contrai, alegria dilata
|
||||
c.beginPath(); c.arc(ix, iy, R * pupila, 0, Math.PI * 2); c.fill();
|
||||
c.globalCompositeOperation = 'source-over';
|
||||
if (est.brilho) {
|
||||
c.fillStyle = 'rgba(245,250,255,0.9)';
|
||||
c.beginPath(); c.arc(ix - R * 0.42, iy - R * 0.42, R * 0.2, 0, Math.PI * 2); c.fill();
|
||||
}
|
||||
} else if (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar
|
||||
const hx = Math.max(x + w * 0.18, Math.min(x + w * 0.62, x + w * 0.3 + (p.lx + sacada.x) * w * 0.012));
|
||||
const hy = Math.max(y + h * 0.18, Math.min(y + h * 0.5, y + h * 0.26 + (p.ly + sacada.y) * h * 0.012));
|
||||
c.fillStyle = 'rgba(245,250,255,0.9)';
|
||||
c.beginPath(); c.ellipse(hx, hy, w * 0.1, h * 0.08, 0, 0, Math.PI * 2); c.fill();
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
|
||||
// Olho lente: esclera, íris, pupila que dilata e pálpebras de metal.
|
||||
function olhoLente(c, o, p, est, e) {
|
||||
const R = est.w * p.ry * 0.5 * Math.max(1, p.ab) * (1 + 0.01 * Math.sin(o.t * 1.4));
|
||||
const fecha = Math.min(1, Math.max(o.icone || o.desenho ? 0 : e.top, 1 - Math.min(1, p.ab)));
|
||||
c.save();
|
||||
c.beginPath(); c.arc(p.cx, p.cy, R * 1.12, 0, Math.PI * 2); c.fillStyle = '#1b2029'; c.fill();
|
||||
c.beginPath(); c.arc(p.cx, p.cy, R, 0, Math.PI * 2); c.clip();
|
||||
c.fillStyle = '#e9eef3'; c.fillRect(p.cx - R, p.cy - R, 2 * R, 2 * R);
|
||||
if (o.icone || o.desenho) {
|
||||
conteudo(c, o, p.cx, p.cy, R * 1.3);
|
||||
e = { top: 0, slant: 0, bot: 0 };
|
||||
} else {
|
||||
const ix = p.cx + Math.max(-R * 0.4, Math.min(R * 0.4, (p.lx + sacada.x) * R * 0.02));
|
||||
const iy = p.cy + Math.max(-R * 0.35, Math.min(R * 0.35, (p.ly + sacada.y) * R * 0.02));
|
||||
const g = c.createRadialGradient(ix, iy, R * 0.05, ix, iy, R * 0.55);
|
||||
g.addColorStop(0, css(o.cor.map((v) => v * 0.5))); g.addColorStop(1, css(o.cor));
|
||||
c.beginPath(); c.arc(ix, iy, R * 0.55, 0, Math.PI * 2); c.fillStyle = g; c.fill();
|
||||
const pupila = p.ab > 1.05 ? 0.12 : e.bot > 0.3 ? 0.3 : 0.2; // susto contrai, alegria dilata
|
||||
c.beginPath(); c.arc(ix, iy, R * pupila, 0, Math.PI * 2); c.fillStyle = '#05070a'; c.fill();
|
||||
c.beginPath(); c.arc(ix - R * 0.2, iy - R * 0.22, R * 0.09, 0, Math.PI * 2); c.fillStyle = 'rgba(255,255,255,0.9)'; c.fill();
|
||||
}
|
||||
const metal = c.createLinearGradient(0, p.cy - R, 0, p.cy + R);
|
||||
metal.addColorStop(0, '#5b6675'); metal.addColorStop(1, '#2b323d');
|
||||
c.fillStyle = metal;
|
||||
const sl = p.lado > 0 ? e.slant : -e.slant;
|
||||
const yEsq = p.cy - R + 2 * R * (fecha + sl * 0.5), yDir = p.cy - R + 2 * R * (fecha - sl * 0.5);
|
||||
c.beginPath(); c.moveTo(p.cx - R, p.cy - R); c.lineTo(p.cx + R, p.cy - R); c.lineTo(p.cx + R, yDir); c.lineTo(p.cx - R, yEsq); c.closePath(); c.fill();
|
||||
c.strokeStyle = '#8994a3'; c.lineWidth = Math.max(1, R * 0.03);
|
||||
c.beginPath(); c.moveTo(p.cx - R, yEsq); c.lineTo(p.cx + R, yDir); c.stroke();
|
||||
c.beginPath(); c.ellipse(p.cx, p.cy + R * (2.3 - 2 * e.bot), R * 1.3, R * 1.3, 0, 0, Math.PI * 2); c.fill();
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function atualizarPalpebras(dt, olhos, lento) {
|
||||
const k = 1 - Math.exp(-dt * (lento ? 4 : 11));
|
||||
olhos.forEach((p, i) => {
|
||||
const alvo = PALPEBRAS[p.forma] || PALPEBRAS.aberto, e = estado[i];
|
||||
e.top += (alvo.top - e.top) * k;
|
||||
e.slant += (alvo.slant - e.slant) * k;
|
||||
e.bot += (alvo.bot - e.bot) * k;
|
||||
e.coracao += ((p.forma === 'coracao' ? 1 : 0) - e.coracao) * Math.min(1, k * 2);
|
||||
});
|
||||
}
|
||||
|
||||
function atualizarSacada(t, dt, parado) {
|
||||
// Pequenos saltos do olhar quando nada está acontecendo; olhos de verdade nunca ficam parados.
|
||||
if (parado && t > sacada.proxima) {
|
||||
sacada.alvoX = (Math.random() - 0.5) * 14;
|
||||
sacada.alvoY = (Math.random() - 0.5) * 6;
|
||||
sacada.proxima = t + 0.8 + Math.random() * 2.4;
|
||||
} else if (!parado) { sacada.alvoX = sacada.alvoY = 0; }
|
||||
const k = 1 - Math.exp(-dt * 22);
|
||||
sacada.x += (sacada.alvoX - sacada.x) * k;
|
||||
sacada.y += (sacada.alvoY - sacada.y) * k;
|
||||
}
|
||||
|
||||
function desenharPontos(estilo, o, olhos, d) {
|
||||
// Matriz: desenha os olhos numa tela pequena e acende um LED por pixel.
|
||||
const linhas = 38, colunas = Math.round(linhas * tela.width / tela.height);
|
||||
if (baixa.width !== colunas || baixa.height !== linhas) { baixa.width = colunas; baixa.height = linhas; }
|
||||
const f = linhas / tela.height * d;
|
||||
bctx.clearRect(0, 0, colunas, linhas);
|
||||
bctx.save(); bctx.scale(f, f);
|
||||
olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i]));
|
||||
for (const traco of o.tracos || []) {
|
||||
if (typeof traco.d !== 'string') continue;
|
||||
const caminho = new Path2D(traco.d);
|
||||
bctx.strokeStyle = bctx.fillStyle = css(o.cor);
|
||||
bctx.lineWidth = traco.largura; bctx.lineCap = 'round'; bctx.lineJoin = 'round';
|
||||
if (traco.cheio) bctx.fill(caminho); else bctx.stroke(caminho);
|
||||
}
|
||||
desenharMaos(bctx, o, olhos, tela.height / d);
|
||||
desenharZzz(bctx, o, olhos, 1.6); // em LEDs o Z precisa ser maior para ler
|
||||
bctx.restore();
|
||||
const px = bctx.getImageData(0, 0, colunas, linhas).data;
|
||||
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height);
|
||||
const passo = tela.width / colunas, raio = passo * 0.36;
|
||||
for (let j = 0; j < linhas; j++) for (let i = 0; i < colunas; i++) {
|
||||
const k = (j * colunas + i) * 4, a = px[k + 3] / 255;
|
||||
ctx.beginPath(); ctx.arc((i + 0.5) * passo, (j + 0.5) * passo, raio, 0, Math.PI * 2);
|
||||
ctx.fillStyle = a > 0.25 ? css([px[k], px[k + 1], px[k + 2]], 0.35 + a * 0.65) : 'rgba(255,255,255,0.045)';
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Desenha um quadro. o = { estilo, formato, t, cor:[r,g,b], dormindo, parado,
|
||||
* olhos: [{ cx, cy, ry, ab, forma, lado, lx, ly }, ...] } em pixels CSS.
|
||||
*/
|
||||
function desenhar(o) {
|
||||
preparar();
|
||||
const base = ESTILOS[o.estilo];
|
||||
if (!base) return esconder();
|
||||
const formato = !base.lente && FORMATOS[o.formato];
|
||||
const estilo = formato ? Object.assign({}, base, formato) : base;
|
||||
tela.style.display = '';
|
||||
const d = medir();
|
||||
const dt = antes ? Math.min(0.1, o.t - antes) : 0.033;
|
||||
antes = o.t;
|
||||
atualizarPalpebras(dt, o.olhos, o.dormindo);
|
||||
atualizarSacada(o.t, dt, o.parado);
|
||||
const olhos = o.olhos.map((p) => Object.assign({}, p, {
|
||||
cx: (p.cx + (o.parado ? sacada.x * 0.35 : 0)) * d,
|
||||
cy: (p.cy + (o.parado ? sacada.y * 0.35 : 0)) * d,
|
||||
ry: p.ry * d,
|
||||
}));
|
||||
ctx.clearRect(0, 0, tela.width, tela.height);
|
||||
if (estilo.pontos) {
|
||||
desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d);
|
||||
} else if (estilo.lente) {
|
||||
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
|
||||
desenharMaos(ctx, o, olhos, tela.height);
|
||||
desenharZzz(ctx, o, olhos);
|
||||
} else {
|
||||
cctx.clearRect(0, 0, camada.width, camada.height);
|
||||
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
|
||||
desenharMaos(cctx, o, olhos, tela.height);
|
||||
desenharZzz(cctx, o, olhos);
|
||||
ctx.save();
|
||||
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
|
||||
ctx.drawImage(camada, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
desenharFaixa(ctx, o, tela.width, tela.height);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ mãos
|
||||
// Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências
|
||||
// de rosto de mesmo nome no carinha.html.
|
||||
const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8 };
|
||||
const maos = { modo: null, t0: null };
|
||||
const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); };
|
||||
const faixaDe = (k, a, b) => suave((k - a) / (b - a)); // 0→1 entre a e b
|
||||
|
||||
// Mão de desenho animado: palma, quatro dedos (o do meio maior), polegar para o lado do rosto
|
||||
// e um punho de robô. dobra (0–1) fecha os dedos; mexe (0–1) agita os dedos ao acenar.
|
||||
// O contorno é traçado antes do preenchimento: assim só a borda externa aparece.
|
||||
function capsula(c, x0, y0, x1, y1, r) {
|
||||
const a = Math.atan2(y1 - y0, x1 - x0) + Math.PI / 2;
|
||||
c.moveTo(x0 + Math.cos(a) * r, y0 + Math.sin(a) * r);
|
||||
c.arc(x0, y0, r, a, a + Math.PI);
|
||||
c.arc(x1, y1, r, a + Math.PI, a + 2 * Math.PI);
|
||||
c.closePath();
|
||||
}
|
||||
|
||||
function formaMao(c, s, dobra, mexe, t, indicador) {
|
||||
c.beginPath();
|
||||
// punho (manga do robô)
|
||||
caixa(c, -0.42 * s, 0.5 * s, 0.84 * s, 0.38 * s, 0.12 * s);
|
||||
// palma
|
||||
caixa(c, -0.5 * s, -0.36 * s, 1.0 * s, 0.95 * s, 0.34 * s);
|
||||
// dedos: x da base, comprimento e abertura
|
||||
// mindinho, anelar, médio e indicador (o indicador fica do lado do polegar)
|
||||
const dedos = [[-0.355, 0.5, -0.16], [-0.12, 0.68, -0.05], [0.12, 0.74, 0.05], [0.355, 0.66, 0.14]];
|
||||
dedos.forEach(([x, comp, abre], i) => {
|
||||
const onda = mexe ? Math.sin(t * 14 + i * 0.9) * 0.06 * mexe : 0;
|
||||
const fecha = indicador && i === 3 ? 0 : dobra; // "pensando": só o indicador esticado
|
||||
const ang = abre + onda, l = comp * (1 - 0.7 * fecha) * s;
|
||||
const bx = x * s, by = -0.26 * s;
|
||||
capsula(c, bx, by, bx + Math.sin(ang) * l, by - Math.cos(ang) * l, 0.115 * s);
|
||||
});
|
||||
// polegar, saindo para o lado
|
||||
capsula(c, 0.38 * s, 0.2 * s, (0.38 + 0.42 * (1 - 0.4 * dobra)) * s, (0.2 - 0.34) * s, 0.13 * s);
|
||||
}
|
||||
|
||||
function mao(c, o, x, y, s, ang, espelho, dobra, mexe, indicador, pulso, costas) {
|
||||
dobra = dobra || 0; mexe = mexe || 0;
|
||||
s *= 0.82;
|
||||
const escura = o.cor.map((v) => v * 0.34);
|
||||
c.save();
|
||||
c.translate(x, y); c.rotate(ang); c.scale(espelho, 1);
|
||||
if (pulso) c.translate(0, -0.7 * s); // (x, y) é o punho: gira como uma mão de verdade
|
||||
c.lineJoin = 'round'; c.lineCap = 'round';
|
||||
formaMao(c, s, dobra, mexe, o.t || 0, indicador);
|
||||
c.strokeStyle = css(o.cor); c.lineWidth = s * 0.17; c.stroke(); // contorno (metade some sob o preenchimento)
|
||||
c.fillStyle = css(escura); c.fill();
|
||||
// detalhes por dentro, finos: linha da palma, vincos dos dedos e a costura do punho
|
||||
c.strokeStyle = css(o.cor, 0.55); c.lineWidth = s * 0.045;
|
||||
if (costas) { // dorso da mão: nós dos dedos em vez da linha da palma
|
||||
for (const nx of [-0.355, -0.12, 0.12, 0.355]) { c.beginPath(); c.arc(nx * s, -0.2 * s, 0.075 * s, Math.PI * 1.15, Math.PI * 1.85); c.stroke(); }
|
||||
} else {
|
||||
c.beginPath(); c.moveTo(-0.3 * s, 0.12 * s); c.quadraticCurveTo(0.02 * s, 0.26 * s, 0.3 * s, 0.06 * s); c.stroke();
|
||||
}
|
||||
c.beginPath(); c.moveTo(-0.36 * s, 0.6 * s); c.lineTo(0.36 * s, 0.6 * s); c.stroke();
|
||||
c.restore();
|
||||
}
|
||||
|
||||
// Sobe com um leve "passa e volta" no fim (como um braço que freia).
|
||||
const voltinha = (x) => { x = Math.max(0, Math.min(1, x)); const k = 1.6; return 1 + (k + 1) * Math.pow(x - 1, 3) + k * Math.pow(x - 1, 2); };
|
||||
|
||||
function desenharMaos(c, o, olhos, H) {
|
||||
if (!maos.modo) return;
|
||||
if (maos.t0 === null) maos.t0 = o.t;
|
||||
const k = (o.t - maos.t0) / DUR_MAOS[maos.modo];
|
||||
if (k >= 1) { maos.modo = null; return; }
|
||||
const [e, d] = olhos.slice().sort((a, b) => a.cx - b.cx); // olho da esquerda e da direita na tela
|
||||
const s = e.ry * 0.95, fora = H + s * 1.8;
|
||||
if (maos.modo === 'esconder' || maos.modo === 'espiar') {
|
||||
// Cada mão vem do canto de baixo, por fora, num arco, girando até ficar inclinada para dentro.
|
||||
// A da direita chega um pouco depois: duas mãos nunca se mexem igualzinho.
|
||||
const cobrir = (olho, lado, atraso) => {
|
||||
const ida = voltinha((k - atraso) / 0.24), volta = faixaDe(k, 0.8 + atraso * 0.5, 1);
|
||||
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta); // 0 = fora da tela, 1 = cobrindo
|
||||
const alvoX = olho.cx - lado * olho.ry * 0.05, alvoY = olho.cy + olho.ry * 0.38;
|
||||
const x = alvoX - lado * olho.ry * 1.6 * (1 - Math.min(1, v));
|
||||
const y = fora + (alvoY - fora) * v;
|
||||
const respira = Math.sin(o.t * 3 + lado) * s * 0.03 * Math.min(1, v);
|
||||
const ang = lado * (0.42 + 0.55 * (1 - Math.min(1, v))); // gira enquanto sobe, termina a ~24°
|
||||
return { x, y: y + respira, ang };
|
||||
};
|
||||
const pe = cobrir(e, 1, 0), pd = cobrir(d, -1, 0.05);
|
||||
if (maos.modo === 'espiar') {
|
||||
// a mão da esquerda gira pelo punho para fora, como uma porta, e o olho aparece
|
||||
const abre = faixaDe(k, 0.3, 0.44) - faixaDe(k, 0.64, 0.76);
|
||||
pe.ang -= abre * 1.05; pe.x -= abre * e.ry * 0.35; pe.y += abre * e.ry * 0.2;
|
||||
}
|
||||
const treme = Math.sin(o.t * 22) * s * 0.012; // tímido: tremidinha
|
||||
// dorso virado para quem olha: polegar para fora, perto da bochecha
|
||||
mao(c, o, pe.x + treme, pe.y, s * 1.1, pe.ang, -1, 0, 0, false, true, true);
|
||||
mao(c, o, pd.x - treme, pd.y, s * 1.1, pd.ang, 1, 0, 0, false, true, true);
|
||||
} else if (maos.modo === 'acenar') {
|
||||
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
|
||||
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
|
||||
const x = d.cx + d.ry * 2.2 + d.ry * 0.6 * (1 - Math.min(1, v)), y0 = d.cy + d.ry * 1.55;
|
||||
const y = fora + (y0 - fora) * v;
|
||||
const balanco = k > 0.22 && k < 0.82 ? Math.sin((k - 0.22) * Math.PI * 7) * 0.32 : 0;
|
||||
mao(c, o, x, y, s * 0.95, -0.18 + balanco, -1, 0, 1, false, true);
|
||||
} else if (maos.modo === 'pensar') {
|
||||
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
|
||||
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
|
||||
const x = (e.cx + d.cx) / 2 + e.ry * 1.05, y0 = e.cy + e.ry * 1.4;
|
||||
const y = fora + (y0 - fora) * v + Math.sin(o.t * 9) * s * 0.03;
|
||||
mao(c, o, x, y, s * 0.85, -0.5, -1, 0.95, 0, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Zzz
|
||||
function desenharZzz(c, o, olhos, fator) {
|
||||
fator = fator || 1;
|
||||
if (!o.dormindo) return;
|
||||
const d = olhos.slice().sort((a, b) => a.cx - b.cx)[1]; // olho da direita na tela
|
||||
const x0 = d.cx + d.ry * 0.7, y0 = d.cy - d.ry * 0.55;
|
||||
c.save();
|
||||
c.strokeStyle = css(o.cor); c.lineCap = 'round'; c.lineJoin = 'round';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const k = ((o.t || 0) * 0.32 + i / 3) % 1; // cada Z num ponto do ciclo
|
||||
const tam = d.ry * (0.28 + 0.3 * k) * fator;
|
||||
const x = x0 + d.ry * 0.7 * k + Math.sin(k * Math.PI * 2) * d.ry * 0.08, y = y0 - d.ry * 1.7 * k;
|
||||
c.globalAlpha = Math.min(1, k / 0.15) * (1 - k);
|
||||
c.lineWidth = Math.max(1.5, tam * 0.2);
|
||||
c.beginPath();
|
||||
c.moveTo(x - tam / 2, y - tam / 2); c.lineTo(x + tam / 2, y - tam / 2);
|
||||
c.lineTo(x - tam / 2, y + tam / 2); c.lineTo(x + tam / 2, y + tam / 2);
|
||||
c.stroke();
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- faixa de baixo
|
||||
// A parte de baixo da tela vira palco: legenda da conversa, pensando, equalizador e carga.
|
||||
const faixa = { modo: null, texto: '', t0: null, dur: 0 };
|
||||
|
||||
function definirFaixa(modo, opcoes) {
|
||||
opcoes = opcoes || {};
|
||||
if (!modo) {
|
||||
if (!opcoes.so || faixa.modo === opcoes.so) faixa.modo = null; // so: só apaga esse modo
|
||||
return;
|
||||
}
|
||||
faixa.modo = modo; faixa.t0 = performance.now() / 1000; faixa.texto = String(opcoes.texto || '').replace(/\s+/g, ' ').trim().slice(0, 400);
|
||||
faixa.dur = opcoes.segundos || (modo === 'legenda' ? Math.max(3, Math.min(14, faixa.texto.length / 14 + 2.5)) : modo === 'digitando' ? 20 : 2.6);
|
||||
}
|
||||
|
||||
function desenharFaixa(c, o, W, H) {
|
||||
const y = H * 0.955, fonte = Math.round(H * 0.042);
|
||||
c.save();
|
||||
c.shadowColor = css(o.cor, 0.6); c.shadowBlur = H * 0.02;
|
||||
if (o.carga && o.carga.carregando && typeof o.carga.nivel === 'number') { // carregando: canto direito
|
||||
const n = Math.max(0, Math.min(100, o.carga.nivel)), bw = H * 0.075, bh = H * 0.036, bx = W - H * 0.13, by = y - bh / 2;
|
||||
c.globalAlpha = 0.55 + 0.25 * Math.sin(o.t * 2.5);
|
||||
c.strokeStyle = css(o.cor); c.lineWidth = Math.max(1, H * 0.005);
|
||||
caixa(c, bx, by, bw, bh, bh * 0.25); c.stroke();
|
||||
c.fillStyle = css(o.cor); c.fillRect(bx + bw, y - bh * 0.2, bh * 0.15, bh * 0.4);
|
||||
c.fillRect(bx + bh * 0.12, by + bh * 0.15, (bw - bh * 0.24) * n / 100, bh * 0.7);
|
||||
c.font = '600 ' + Math.round(fonte * 0.8) + 'px system-ui,sans-serif'; c.textAlign = 'right'; c.textBaseline = 'middle';
|
||||
const tx = bx - H * 0.012;
|
||||
c.fillText(Math.round(n) + '%', tx, y);
|
||||
const rx = tx - c.measureText(Math.round(n) + '%').width - H * 0.022, r = H * 0.016; // raio desenhado na cor dos olhos
|
||||
c.beginPath(); c.moveTo(rx + r * 0.3, y - r); c.lineTo(rx - r * 0.5, y + r * 0.15); c.lineTo(rx, y + r * 0.15);
|
||||
c.lineTo(rx - r * 0.3, y + r); c.lineTo(rx + r * 0.5, y - r * 0.15); c.lineTo(rx, y - r * 0.15); c.closePath(); c.fill();
|
||||
c.globalAlpha = 1;
|
||||
}
|
||||
if (faixa.modo) {
|
||||
if (faixa.t0 === null) faixa.t0 = o.t;
|
||||
const k = (o.t - faixa.t0) / faixa.dur;
|
||||
if (k >= 1) faixa.modo = null;
|
||||
else {
|
||||
const some = Math.min(1, (faixa.dur - (o.t - faixa.t0)) / 0.4);
|
||||
c.globalAlpha = Math.max(0, some);
|
||||
c.fillStyle = css(o.cor);
|
||||
if (faixa.modo === 'legenda') {
|
||||
// efeito de digitação; se não couber, mostra o final do que já foi "dito"
|
||||
let txt = faixa.texto.slice(0, Math.floor((o.t - faixa.t0) * 30));
|
||||
c.font = '500 ' + fonte + 'px system-ui,sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle';
|
||||
const max = W * 0.8;
|
||||
while (txt.length > 1 && c.measureText(txt).width > max) txt = txt.slice(1);
|
||||
c.fillText(txt, W / 2, y);
|
||||
} else if (faixa.modo === 'digitando') {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const pulo = Math.max(0, Math.sin(o.t * 7 - i * 0.7)) * H * 0.012;
|
||||
c.beginPath(); c.arc(W / 2 + (i - 1) * H * 0.035, y - pulo, H * 0.011, 0, Math.PI * 2); c.fill();
|
||||
}
|
||||
} else if (faixa.modo === 'equalizador') {
|
||||
const n = 15, larg = H * 0.016, passo = H * 0.026;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = 0.25 + 0.75 * Math.abs(Math.sin(o.t * (5 + i % 4) + i * 1.3)) * Math.sin(Math.PI * (i + 0.5) / n);
|
||||
const h = H * 0.04 * a;
|
||||
caixa(c, W / 2 + (i - (n - 1) / 2) * passo - larg / 2, H * 0.988 - h, larg, h, larg / 2); c.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrato parado num canvas qualquer (miniaturas da biblioteca no painel).
|
||||
* o = { estilo, formato, cor, forma, icone, desenho }.
|
||||
*/
|
||||
function retrato(alvo, o) {
|
||||
const base = ESTILOS[o.estilo] || ESTILOS.vivo;
|
||||
const formato = !base.lente && FORMATOS[o.formato];
|
||||
const est = formato ? Object.assign({}, base, formato) : base;
|
||||
const W = alvo.width, H = alvo.height, c = alvo.getContext('2d');
|
||||
const pal = Object.assign({ coracao: o.forma === 'coracao' ? 1 : 0 }, PALPEBRAS[o.forma] || PALPEBRAS.aberto);
|
||||
const op = { t: 0, cor: o.cor || [92, 214, 255], icone: o.icone || null, desenho: o.desenho || null };
|
||||
const ry = H * 0.36, sep = Math.min(W * 0.23, H * 0.62);
|
||||
const olhos = [-1, 1].map((s) => ({ cx: W / 2 + s * sep, cy: H / 2, ry, ab: o.forma === 'surpreso' ? 1.12 : 1, forma: o.forma, lado: -s, lx: 0, ly: 0 }));
|
||||
c.clearRect(0, 0, W, H);
|
||||
if (est.lente) { olhos.forEach((p) => olhoLente(c, op, p, est, pal)); return; }
|
||||
const tmp = document.createElement('canvas'); tmp.width = W; tmp.height = H;
|
||||
const t = tmp.getContext('2d');
|
||||
olhos.forEach((p) => olhoPlano(t, op, p, est, Object.assign({}, pal)));
|
||||
if (est.pontos) {
|
||||
const passo = Math.max(3, Math.round(H / 16)), d = t.getImageData(0, 0, W, H).data;
|
||||
for (let y = passo / 2; y < H; y += passo) for (let x = passo / 2; x < W; x += passo) {
|
||||
const k = (Math.floor(y) * W + Math.floor(x)) * 4, a = d[k + 3] / 255;
|
||||
c.beginPath(); c.arc(x, y, passo * 0.38, 0, Math.PI * 2);
|
||||
c.fillStyle = a > 0.25 ? css([d[k], d[k + 1], d[k + 2]], 0.4 + a * 0.6) : 'rgba(255,255,255,0.05)';
|
||||
c.fill();
|
||||
}
|
||||
return;
|
||||
}
|
||||
c.save();
|
||||
if (est.brilho) { c.shadowColor = css(op.cor, 0.7); c.shadowBlur = H * 0.08; }
|
||||
c.drawImage(tmp, 0, 0);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
|
||||
|
||||
window.MotorRosto = {
|
||||
desenhar, retrato, esconder,
|
||||
/** Metade da altura do olho (em frações de RY) para o estilo/formato: posiciona as sobrancelhas. */
|
||||
metadeAltura: (estilo, formato) => {
|
||||
const base = ESTILOS[estilo]; if (!base) return 1;
|
||||
if (base.lente) return base.w * 0.5 * 1.12;
|
||||
return ((!base.lente && FORMATOS[formato]) || base).h / 2;
|
||||
},
|
||||
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
|
||||
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) };
|
||||
})();
|
||||
@@ -12,8 +12,13 @@ let config = {}, signature = '', movementEnabled = true, aviso = '', capacidades
|
||||
let allowed = true;
|
||||
let lastMotionFrame = -1;
|
||||
function chave(c) { return JSON.stringify([c.cameraLado || 'user', c.videoResolucao || 'auto', c.videoFPS || 0]); }
|
||||
/* Nada pode esperar para sempre: se a câmera estiver presa (ex.: servidor caiu no meio e o
|
||||
sensor de movimento ainda a segura), desiste e a próxima mensagem do painel tenta de novo. */
|
||||
function comPrazo(promessa, ms, oque) {
|
||||
return Promise.race([promessa, new Promise((_, rej) => setTimeout(() => rej(new Error(oque + ' demorou mais de ' + ms / 1000 + ' s')), ms))]);
|
||||
}
|
||||
async function capturar(c) {
|
||||
const media = await navigator.mediaDevices.getUserMedia({audio:false,video:{facingMode:{ideal:c.cameraLado || 'user'},width:{ideal:640},height:{ideal:360},frameRate:{ideal:30}}});
|
||||
const media = await comPrazo(navigator.mediaDevices.getUserMedia({audio:false,video:{facingMode:{ideal:c.cameraLado || 'user'},width:{ideal:640},height:{ideal:360},frameRate:{ideal:30}}}), 8000, 'Abrir a câmera');
|
||||
const track = media.getVideoTracks()[0];
|
||||
const facing = track.getSettings().facingMode;
|
||||
if (facing && facing !== (c.cameraLado || 'user')) {
|
||||
@@ -74,6 +79,12 @@ async function iniciar(id) {
|
||||
const media = await capturar(config); signature = chave(config);
|
||||
if (!wanted || captureGeneration !== generation) { media.getTracks().forEach(t => t.stop()); return; }
|
||||
stream = media; video.srcObject = stream; await video.play();
|
||||
// câmera fechada de repente (outro uso, erro do sistema): desfaz tudo; o próximo pacote reabre
|
||||
stream.getVideoTracks()[0].onended = () => {
|
||||
if (captureGeneration !== generation) return;
|
||||
console.log('RTC câmera encerrada; recomeçando no próximo pedido');
|
||||
parar(); sid = null;
|
||||
};
|
||||
pc = new RTCPeerConnection({iceServers:[]});
|
||||
sender = pc.addTrack(stream.getVideoTracks()[0], stream);
|
||||
const mine = pc;
|
||||
@@ -133,14 +144,23 @@ async function receber(packet) {
|
||||
}
|
||||
window.rtcReceber = packet => {
|
||||
allowed = true;
|
||||
tasks = tasks.then(() => allowed ? receber(packet) : undefined).catch(e => {
|
||||
tasks = tasks.then(() => allowed ? comPrazo(receber(packet), 12000, 'Pedido de vídeo') : undefined).catch(e => {
|
||||
console.log('RTC erro=' + e.name + ': ' + e.message);
|
||||
sinal({tipo:'erro',erro:e.name + ': ' + e.message});
|
||||
estado({tipo:'webrtc',estado:'erro',erro:e.message});
|
||||
parar();
|
||||
parar(); sid = null;
|
||||
});
|
||||
};
|
||||
window.rtcParar = () => { allowed = false; parar(); sid = null; };
|
||||
/* Foto do evento para o histórico, tirada do vídeo ao vivo (câmera de movimento parada). */
|
||||
window.rtcFoto = () => {
|
||||
if (!stream || video.readyState < 2 || !video.videoWidth) return;
|
||||
const escala = Math.min(1, 1280 / video.videoWidth);
|
||||
const tela = document.createElement('canvas');
|
||||
tela.width = Math.round(video.videoWidth * escala); tela.height = Math.round(video.videoHeight * escala);
|
||||
tela.getContext('2d').drawImage(video, 0, 0, tela.width, tela.height);
|
||||
CarinhaRTC.foto(tela.toDataURL('image/jpeg', 0.82));
|
||||
};
|
||||
setInterval(() => {
|
||||
if (!movementEnabled || !stream || video.readyState < 2 || video.currentTime===lastMotionFrame) return;
|
||||
lastMotionFrame=video.currentTime;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/* voz-robo.js — voz sintetizada do Carinha, no estilo dos robozinhos de cinema.
|
||||
*
|
||||
* Tudo é gerado na hora com Web Audio: nenhuma gravação, nenhum arquivo de terceiros.
|
||||
* Cada som é uma sequência de "sílabas": um oscilador que desliza de um tom a outro, com vibrato,
|
||||
* passando por um filtro de formante (a "vogal": o→i faz o "uou"). Motores das mãos viram ruído
|
||||
* filtrado, e a resposta da conversa vira um balbucio que sobe no fim quando é pergunta.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let ruido = null; // buffer de ruído reaproveitado
|
||||
const TOM = 1.25; // todas as sílabas 25% acima do tom escrito
|
||||
const saidas = new WeakMap(); // AudioContext → nó de saída (compressor)
|
||||
|
||||
/*
|
||||
* Cadeia de "brilho metálico" (criada uma vez por AudioContext):
|
||||
* voz → ressonância de lata fina (agudo, ~850 Hz) → saturação leve → compressor → saída
|
||||
* Nada grave: zumbido e ressonância graves deixavam a voz com cara de sapo.
|
||||
*/
|
||||
function saida(c) {
|
||||
if (saidas.has(c)) return saidas.get(c);
|
||||
const entrada = c.createGain();
|
||||
const seca = c.createGain(); seca.gain.value = 0.8;
|
||||
const atraso = c.createDelay(0.05); atraso.delayTime.value = 0.00118;
|
||||
const volta = c.createGain(); volta.gain.value = 0.42;
|
||||
const molhada = c.createGain(); molhada.gain.value = 0.35;
|
||||
entrada.connect(seca);
|
||||
entrada.connect(atraso); atraso.connect(volta); volta.connect(atraso); atraso.connect(molhada);
|
||||
const satura = c.createWaveShaper();
|
||||
const n = 2048, curva = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) { const x = (i / (n - 1)) * 2 - 1; curva[i] = Math.tanh(x * 1.3); }
|
||||
satura.curve = curva;
|
||||
seca.connect(satura); molhada.connect(satura);
|
||||
const brilho = c.createBiquadFilter(); // realce leve nos agudos: "metal" limpo
|
||||
brilho.type = 'peaking'; brilho.frequency.value = 3200; brilho.Q.value = 1.2; brilho.gain.value = 4;
|
||||
const comp = c.createDynamicsCompressor();
|
||||
comp.threshold.value = -16; comp.ratio.value = 4; comp.attack.value = 0.003; comp.release.value = 0.15;
|
||||
const passaBaixa = c.createBiquadFilter();
|
||||
passaBaixa.type = 'lowpass'; passaBaixa.frequency.value = 8000;
|
||||
const volume = c.createGain(); volume.gain.value = 0.8;
|
||||
satura.connect(brilho); brilho.connect(comp); comp.connect(passaBaixa); passaBaixa.connect(volume); volume.connect(c.destination);
|
||||
saidas.set(c, entrada);
|
||||
return entrada;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uma sílaba. de/para: tom em Hz; vogal: [formante inicial, final] em Hz;
|
||||
* vib: [Hz, profundidade em Hz]; forma: tipo de onda.
|
||||
*/
|
||||
function silaba(c, t0, dur, de, para, vol, op) {
|
||||
op = op || {};
|
||||
vol *= 2.4; // compensa a energia que o filtro de formante tira
|
||||
de *= TOM; para *= TOM; // voz de robozinho: aguda
|
||||
const osc = c.createOscillator();
|
||||
osc.type = op.forma || 'sawtooth';
|
||||
osc.frequency.setValueAtTime(de, t0);
|
||||
osc.frequency.exponentialRampToValueAtTime(Math.max(20, para), t0 + dur);
|
||||
if (op.vib) {
|
||||
const lfo = c.createOscillator(), prof = c.createGain();
|
||||
lfo.frequency.value = op.vib[0]; prof.gain.value = op.vib[1];
|
||||
lfo.connect(prof); prof.connect(osc.frequency);
|
||||
lfo.start(t0); lfo.stop(t0 + dur + 0.05);
|
||||
}
|
||||
// FM harmônico e leve: brilho de metal sem aspereza
|
||||
const mod = c.createOscillator(), modProf = c.createGain();
|
||||
mod.type = 'sine';
|
||||
mod.frequency.setValueAtTime(de * 2, t0);
|
||||
mod.frequency.exponentialRampToValueAtTime(Math.max(20, para * 2), t0 + dur);
|
||||
modProf.gain.setValueAtTime(de * (op.metal ?? 0.22), t0);
|
||||
modProf.gain.exponentialRampToValueAtTime(Math.max(1, para * (op.metal ?? 0.22)), t0 + dur);
|
||||
mod.connect(modProf); modProf.connect(osc.frequency);
|
||||
mod.start(t0); mod.stop(t0 + dur + 0.05);
|
||||
// corpo: uma senoide uma oitava abaixo deixa a voz menos "apito"
|
||||
const sub = c.createOscillator();
|
||||
sub.type = 'sine';
|
||||
sub.frequency.setValueAtTime(de / 2, t0);
|
||||
sub.frequency.exponentialRampToValueAtTime(Math.max(20, para / 2), t0 + dur);
|
||||
const vogal = op.vogal || [900, 1500];
|
||||
const filtro = c.createBiquadFilter();
|
||||
filtro.type = 'bandpass'; filtro.Q.value = op.q || 5;
|
||||
filtro.frequency.setValueAtTime(vogal[0], t0);
|
||||
filtro.frequency.exponentialRampToValueAtTime(vogal[1], t0 + dur);
|
||||
const g = c.createGain(), gs = c.createGain();
|
||||
const ataque = Math.min(0.02, dur * 0.25);
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.exponentialRampToValueAtTime(vol, t0 + ataque);
|
||||
g.gain.setValueAtTime(vol, t0 + dur * 0.7);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
gs.gain.value = 0.12; // pouca oitava de baixo: grave demais vira coaxar
|
||||
osc.connect(filtro); sub.connect(gs); gs.connect(filtro); filtro.connect(g); g.connect(saida(c));
|
||||
osc.start(t0); sub.start(t0);
|
||||
osc.stop(t0 + dur + 0.05); sub.stop(t0 + dur + 0.05);
|
||||
}
|
||||
|
||||
function bufferRuido(c) {
|
||||
if (ruido && ruido.sampleRate === c.sampleRate) return ruido;
|
||||
ruido = c.createBuffer(1, c.sampleRate, c.sampleRate);
|
||||
const d = ruido.getChannelData(0);
|
||||
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
||||
return ruido;
|
||||
}
|
||||
|
||||
/** Motorzinho: ruído filtrado que sobe de tom, com um tremor mecânico. */
|
||||
function servo(c, t0, dur, vol, sobe) {
|
||||
const fonte = c.createBufferSource();
|
||||
fonte.buffer = bufferRuido(c); fonte.loop = true;
|
||||
const f = c.createBiquadFilter();
|
||||
f.type = 'bandpass'; f.Q.value = 9;
|
||||
f.frequency.setValueAtTime(sobe === false ? 1500 : 500, t0);
|
||||
f.frequency.exponentialRampToValueAtTime(sobe === false ? 500 : 1500, t0 + dur);
|
||||
const g = c.createGain(), trem = c.createOscillator(), tg = c.createGain();
|
||||
trem.frequency.value = 38; tg.gain.value = vol * 0.35;
|
||||
trem.connect(tg); tg.connect(g.gain);
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.exponentialRampToValueAtTime(vol * 0.6, t0 + 0.05);
|
||||
g.gain.setValueAtTime(vol * 0.6, t0 + dur - 0.06);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
fonte.connect(f); f.connect(g); g.connect(saida(c));
|
||||
fonte.start(t0); trem.start(t0);
|
||||
fonte.stop(t0 + dur + 0.05); trem.stop(t0 + dur + 0.05);
|
||||
}
|
||||
|
||||
function clique(c, t0, vol) {
|
||||
const fonte = c.createBufferSource();
|
||||
fonte.buffer = bufferRuido(c);
|
||||
const f = c.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 3000;
|
||||
const g = c.createGain();
|
||||
g.gain.setValueAtTime(vol * 0.5, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.03);
|
||||
fonte.connect(f); f.connect(g); g.connect(saida(c));
|
||||
fonte.start(t0); fonte.stop(t0 + 0.05);
|
||||
}
|
||||
|
||||
const O = [500, 900], I = [1800, 2400], U = [350, 700], A = [900, 1300]; // vogais (formantes)
|
||||
|
||||
// Cada som: (c, t, v) → agenda as sílabas. Volumes relativos ao volume do app.
|
||||
const SONS = {
|
||||
feliz: (c, t, v) => { silaba(c, t, 0.12, 620, 900, v, { vogal: [1200, 2000] }); silaba(c, t + 0.13, 0.18, 900, 1250, v, { vogal: I, vib: [9, 18] }); },
|
||||
alegre: (c, t, v) => [560, 700, 860, 1100].forEach((f, i) => silaba(c, t + i * 0.085, 0.09, f, f * 1.25, v * 0.85, { vogal: I, forma: 'square' })),
|
||||
coracao: (c, t, v) => { silaba(c, t, 0.55, 760, 560, v, { vogal: [800, 1300], vib: [7, 28] }); },
|
||||
piscadinha: (c, t, v) => { clique(c, t, v); silaba(c, t + 0.04, 0.08, 1000, 1300, v * 0.6, { forma: 'square', vogal: I }); },
|
||||
surpreso: (c, t, v) => { silaba(c, t, 0.22, 260, 1100, v, { vogal: [450, 2000] }); silaba(c, t + 0.22, 0.12, 1100, 820, v * 0.8, { vogal: I }); },
|
||||
bravo: (c, t, v) => [0, 0.13, 0.26].forEach((d, i) => silaba(c, t + d, 0.1, 480 - i * 30, 380 - i * 30, v * 0.85, { forma: 'square', vogal: [1300, 900] })), // bipes zangados
|
||||
triste: (c, t, v) => { silaba(c, t, 0.65, 640, 380, v * 0.85, { vogal: [1200, 750], vib: [5, 14] }); },
|
||||
dormir: (c, t, v) => { silaba(c, t, 0.35, 460, 760, v * 0.7, { vogal: [900, 1500] }); silaba(c, t + 0.36, 0.75, 760, 340, v * 0.6, { vogal: [1500, 700], vib: [4, 8] }); },
|
||||
acordar: (c, t, v) => { servo(c, t, 0.25, v * 0.5); [300, 450, 700].forEach((f, i) => silaba(c, t + 0.22 + i * 0.1, 0.1, f, f * 1.2, v * 0.8, { vogal: I, forma: 'square' })); silaba(c, t + 0.56, 0.2, 700, 1050, v, { vogal: [1200, 2200], vib: [8, 15] }); },
|
||||
acenar: (c, t, v) => { servo(c, t, 0.3, v * 0.45); silaba(c, t + 0.28, 0.13, 520, 820, v, { vogal: [1000, 1900] }); silaba(c, t + 0.44, 0.2, 760, 1080, v, { vogal: I, vib: [9, 20] }); },
|
||||
esconder: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 0.1, 0.08, 950, 1350, v * 0.8, { vogal: I }); for (let i = 0; i < 5; i++) silaba(c, t + 0.6 + i * 0.09, 0.05, 1200 + (i % 2) * 180, 1300, v * 0.35, { forma: 'square', vogal: I }); servo(c, t + 2.05, 0.4, v * 0.45, false); },
|
||||
espiar: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 1.0, 0.3, 480, 760, v * 0.9, { vogal: [600, 1700] }); silaba(c, t + 2.0, 0.16, 800, 1150, v, { vogal: I }); },
|
||||
pensar: (c, t, v) => { servo(c, t, 0.35, v * 0.4); silaba(c, t + 0.35, 0.5, 520, 640, v * 0.7, { vogal: [900, 1600], vib: [5, 10] }); [0.95, 1.12].forEach((d) => silaba(c, t + d, 0.05, 1500, 1600, v * 0.35, { forma: 'square', vogal: I })); }, // "hmm?" e bipinhos
|
||||
musica: (c, t, v) => [523, 659, 784, 659, 880, 784, 659, 1047].forEach((f, i) => silaba(c, t + i * 0.26, 0.2, f, f * 1.02, v * 0.6, { forma: 'triangle', vogal: A, vib: [6, 5] })),
|
||||
};
|
||||
|
||||
// Gerador previsível: a mesma frase sempre "soa" igual.
|
||||
function semente(texto) {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < texto.length; i++) h = Math.imul(h ^ texto.charCodeAt(i), 16777619);
|
||||
return () => { h = Math.imul(h ^ (h >>> 15), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); return ((h ^= h >>> 16) >>> 0) / 4294967296; };
|
||||
}
|
||||
|
||||
/** Balbucio de robô para uma resposta: ~1 sílaba a cada 6 letras, no máximo 16. */
|
||||
function balbuciar(c, texto, v) {
|
||||
texto = String(texto || '').trim();
|
||||
if (!texto) return 0;
|
||||
const rnd = semente(texto), n = Math.max(2, Math.min(16, Math.ceil(texto.length / 6)));
|
||||
const pergunta = /\?\s*$/.test(texto), exclama = /!\s*$/.test(texto);
|
||||
const vogais = [O, I, A, U];
|
||||
let t = c.currentTime + 0.03, tom = 520 + rnd() * 180;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const ultima = i === n - 1;
|
||||
const dur = 0.07 + rnd() * 0.1;
|
||||
let para = tom * (0.8 + rnd() * 0.5);
|
||||
if (ultima) para = pergunta ? tom * 1.6 : exclama ? tom * 1.35 : tom * 0.7;
|
||||
silaba(c, t, ultima ? dur + 0.08 : dur, tom, para, v * (0.65 + rnd() * 0.3), { vogal: vogais[Math.floor(rnd() * 4)], vib: rnd() < 0.3 ? [8, 14] : null, forma: rnd() < 0.25 ? 'square' : 'sawtooth' });
|
||||
t += dur + 0.025 + rnd() * 0.05;
|
||||
tom = Math.max(330, Math.min(1000, para * (0.9 + rnd() * 0.25)));
|
||||
}
|
||||
return t - c.currentTime;
|
||||
}
|
||||
|
||||
function tocar(c, nome, v) {
|
||||
const f = SONS[nome];
|
||||
if (!f || !c) return false;
|
||||
f(c, c.currentTime + 0.02, v);
|
||||
return true;
|
||||
}
|
||||
|
||||
window.VozRobo = { tocar, balbuciar, servo: (c, dur, v) => servo(c, c.currentTime + 0.01, dur, v), sons: Object.keys(SONS) };
|
||||
})();
|
||||
@@ -28,6 +28,15 @@ public final class ControleRemoto {
|
||||
private volatile ScheduledExecutorService worker;
|
||||
private long consultarEm;
|
||||
private long quadroEm;
|
||||
// Foto do evento (histórico): o servidor pede, o app tira e envia.
|
||||
private volatile long fotoPedida = 0, fotoFeita = 0, fotoId = 0, pedidoEm = 0;
|
||||
private volatile byte[] fotoJpeg = null;
|
||||
/** Id do evento que está esperando foto (0 = nenhum). */
|
||||
public long fotoPendente() { return fotoJpeg == null ? fotoPedida : 0; }
|
||||
/** Foto vinda do vídeo ao vivo (quando a câmera de movimento está desligada). */
|
||||
public void fotoRTC(byte[] jpeg) {
|
||||
if (fotoPedida > 0 && fotoJpeg == null && jpeg != null && jpeg.length > 0) { fotoId = fotoPedida; fotoJpeg = jpeg; fotoPedida = 0; }
|
||||
}
|
||||
private long logEm;
|
||||
private final Runnable mudanca;
|
||||
public interface RTCListener { boolean receber(JSONObject pacote); }
|
||||
@@ -118,6 +127,7 @@ public final class ControleRemoto {
|
||||
mudanca.run();
|
||||
}
|
||||
if (videoRTC()) rtcTick(base);
|
||||
fotoTick(base);
|
||||
byte[] jpeg = pendente;
|
||||
pendente = null;
|
||||
if (jpeg != null && preview()) {
|
||||
@@ -147,10 +157,13 @@ public final class ControleRemoto {
|
||||
return c;
|
||||
}
|
||||
public void quadro(Image image, int rotacao) {
|
||||
if (videoRTC()) return;
|
||||
boolean querFoto = fotoPedida > 0 && fotoJpeg == null;
|
||||
long agora = System.currentTimeMillis();
|
||||
if (!querFoto) {
|
||||
if (videoRTC()) return;
|
||||
if (!preview() || pendente != null || agora < quadroEm) return;
|
||||
quadroEm = agora + 200;
|
||||
}
|
||||
try {
|
||||
Image.Plane[] p = image.getPlanes();
|
||||
ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()};
|
||||
@@ -171,10 +184,44 @@ public final class ControleRemoto {
|
||||
if (rotated != b) rotated.recycle();
|
||||
b.recycle();
|
||||
}
|
||||
if (preview()) pendente = jpeg;
|
||||
if (querFoto) { fotoId = fotoPedida; fotoJpeg = jpeg; fotoPedida = 0; }
|
||||
else if (preview()) pendente = jpeg;
|
||||
} catch (Exception e) { Log.w("CarinhaPC", "quadro descartado: " + e); }
|
||||
}
|
||||
|
||||
/** Consulta o pedido de foto (1x/s) e envia a foto pronta para o histórico. */
|
||||
private void fotoTick(String base) throws Exception {
|
||||
byte[] jpeg = fotoJpeg;
|
||||
if (jpeg != null) {
|
||||
long id = fotoId;
|
||||
fotoJpeg = null;
|
||||
HttpURLConnection c = abrir(base + "/historico/foto?id=" + id);
|
||||
try {
|
||||
c.setRequestMethod("POST"); c.setDoOutput(true);
|
||||
c.setRequestProperty("Content-Type", "image/jpeg");
|
||||
c.setFixedLengthStreamingMode(jpeg.length);
|
||||
try (java.io.OutputStream out = c.getOutputStream()) { out.write(jpeg); }
|
||||
int code = c.getResponseCode();
|
||||
Log.i("CarinhaPC", "foto do evento " + id + ": HTTP " + code);
|
||||
fotoFeita = id;
|
||||
} finally { c.disconnect(); }
|
||||
}
|
||||
long agora = System.currentTimeMillis();
|
||||
if (agora < pedidoEm) return;
|
||||
pedidoEm = agora + 1000;
|
||||
HttpURLConnection c = abrir(base + "/historico/pedido");
|
||||
try {
|
||||
if (c.getResponseCode() != 200) return;
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (InputStream in = c.getInputStream()) {
|
||||
byte[] buffer = new byte[512]; int n;
|
||||
while ((n = in.read(buffer)) != -1 && bytes.size() < 4096) bytes.write(buffer, 0, n);
|
||||
}
|
||||
long id = new JSONObject(bytes.toString("UTF-8")).optLong("id", 0);
|
||||
if (id > 0 && id != fotoFeita && id != fotoId && fotoJpeg == null) fotoPedida = id;
|
||||
} finally { c.disconnect(); }
|
||||
}
|
||||
|
||||
private void rtcTick(String base) throws Exception {
|
||||
String json;
|
||||
while ((json = sinais.poll()) != null) {
|
||||
|
||||
@@ -198,6 +198,8 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
atualizarSensores();
|
||||
atualizarBrilho();
|
||||
if (remoto.fotoPendente() > 0 && rtcAtivo)
|
||||
rtcWeb.evaluateJavascript("window.rtcFoto&&window.rtcFoto()", null);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1021,8 +1023,53 @@ public class MainActivity extends Activity {
|
||||
return String.format(java.util.Locale.US, "%.3f", v);
|
||||
}
|
||||
|
||||
private String aparelhoJson = "null";
|
||||
private long aparelhoEm = 0;
|
||||
|
||||
/** IP, modelo, Android, versão do app e sinal do Wi-Fi; recalculado a cada 10 s. */
|
||||
private String infoAparelho() {
|
||||
long agora = System.currentTimeMillis();
|
||||
if (agora - aparelhoEm < 10000) return aparelhoJson;
|
||||
aparelhoEm = agora;
|
||||
try {
|
||||
JSONObject a = new JSONObject();
|
||||
a.put("ip", ipLocal());
|
||||
a.put("modelo", (Build.MANUFACTURER + " " + Build.MODEL).trim());
|
||||
a.put("android", Build.VERSION.RELEASE);
|
||||
try { a.put("app", getPackageManager().getPackageInfo(getPackageName(), 0).versionName); }
|
||||
catch (Exception e) { a.put("app", JSONObject.NULL); }
|
||||
try {
|
||||
android.net.wifi.WifiManager wifi = (android.net.wifi.WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE);
|
||||
android.net.wifi.WifiInfo info = wifi == null ? null : wifi.getConnectionInfo();
|
||||
// Sem localização o Android 10+ esconde o ID da rede, mas o RSSI continua valendo.
|
||||
if (info != null && info.getRssi() > -127 && info.getRssi() < 0) {
|
||||
a.put("wifi", android.net.wifi.WifiManager.calculateSignalLevel(info.getRssi(), 5));
|
||||
a.put("rssi", info.getRssi());
|
||||
}
|
||||
} catch (Exception e) { /* sem permissão ou sem Wi-Fi */ }
|
||||
aparelhoJson = a.toString();
|
||||
} catch (Exception e) {
|
||||
aparelhoJson = "null";
|
||||
}
|
||||
return aparelhoJson;
|
||||
}
|
||||
|
||||
/** Primeiro IPv4 privado da rede (Wi-Fi), sem precisar de permissão extra. */
|
||||
private static String ipLocal() {
|
||||
try {
|
||||
for (java.net.NetworkInterface n : java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) {
|
||||
if (!n.isUp() || n.isLoopback()) continue;
|
||||
for (java.net.InetAddress e : java.util.Collections.list(n.getInetAddresses())) {
|
||||
if (e instanceof java.net.Inet4Address && e.isSiteLocalAddress()) return e.getHostAddress();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) { /* sem rede */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private void atualizarBrilho() {
|
||||
if (naCamera) return;
|
||||
web.evaluateJavascript("window.carinhaAparelho=" + infoAparelho(), null);
|
||||
Intent bateria = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
|
||||
if (bateria != null) {
|
||||
int level = bateria.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
|
||||
@@ -1034,7 +1081,7 @@ public class MainActivity extends Activity {
|
||||
web.evaluateJavascript("window.carinhaBateria={nivel:" + percent + ",carregando:" + charging + "}", null);
|
||||
}
|
||||
}
|
||||
web.evaluateJavascript("window.eilik?({dormindo:window.eilik.face.dormindo,comando:window.carinhaUltimoComando||0,ocupado:(Date.now()-(window.carinhaUltimoComando||0)<3000)&&!!(window.eilik.face.kf||window.eilik.face.fila.length)}):null",
|
||||
web.evaluateJavascript("window.eilik?({dormindo:window.eilik.face.dormindo,comando:window.carinhaUltimoComando||0,ocupado:(Date.now()-(window.carinhaUltimoComando||0)<3000)&&!!(window.eilik.face.kf||window.eilik.face.fila.length),apagada:!!window.carinhaTelaApagada}):null",
|
||||
new ValueCallback<String>() {
|
||||
@Override public void onReceiveValue(String valor) {
|
||||
if (!emPrimeiroPlano) return;
|
||||
@@ -1046,6 +1093,7 @@ public class MainActivity extends Activity {
|
||||
float nivel = BrilhoRepouso.nivel(remoto.economia(), remoto.brilho(), remoto.brilhoRepouso(),
|
||||
System.currentTimeMillis() - ultimoUsoTela, remoto.repousoTela(),
|
||||
estado.optBoolean("ocupado"), estado.optBoolean("dormindo"));
|
||||
if (estado.optBoolean("apagada")) nivel = 0f; // tela preta + brilho mínimo
|
||||
WindowManager.LayoutParams attr = getWindow().getAttributes();
|
||||
if (Math.abs(attr.screenBrightness - nivel) > 0.005f) {
|
||||
attr.screenBrightness = nivel;
|
||||
@@ -1059,6 +1107,14 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
|
||||
private final class PonteRTC {
|
||||
/** Foto do vídeo ao vivo (data:image/jpeg;base64,...) para o histórico. */
|
||||
@JavascriptInterface public void foto(final String dataUrl) {
|
||||
try {
|
||||
int virgula = dataUrl == null ? -1 : dataUrl.indexOf(',');
|
||||
if (virgula < 0 || dataUrl.length() > 4_000_000) return;
|
||||
remoto.fotoRTC(android.util.Base64.decode(dataUrl.substring(virgula + 1), android.util.Base64.DEFAULT));
|
||||
} catch (Exception e) { Log.w("CarinhaPC", "foto do video falhou: " + e); }
|
||||
}
|
||||
@JavascriptInterface public void rastrear(final String json) {
|
||||
if (!emPrimeiroPlano || !cameraPermitida() || !remoto.config().optBoolean("seguirRastreamento", true)) return;
|
||||
try {
|
||||
|
||||
@@ -30,7 +30,7 @@ def main():
|
||||
def run(*command):
|
||||
subprocess.run([str(x) for x in command], check=True)
|
||||
|
||||
for name in ('carinha.html', 'camera.html'):
|
||||
for name in ('carinha.html', 'camera.html', 'rosto-motor.js', 'voz-robo.js'):
|
||||
shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name)
|
||||
with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder:
|
||||
tmp = Path(folder)
|
||||
|
||||
+173
-14
@@ -25,6 +25,8 @@
|
||||
<path id="sobrancelha2" class="traco" style="display:none"/>
|
||||
</svg>
|
||||
<div id="fps"></div>
|
||||
<script src="rosto-motor.js"></script>
|
||||
<script src="voz-robo.js"></script>
|
||||
<script>
|
||||
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
|
||||
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
|
||||
@@ -90,12 +92,33 @@ addEventListener("resize", ajustar);
|
||||
|
||||
const PRETO = [6, 8, 14];
|
||||
const CIANO = [92, 214, 255];
|
||||
const EXTRAS = { boca: true, sobrancelhas: false };
|
||||
const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 };
|
||||
const SONO = { apagar: true, minutos: 5, desde: 0, apagada: false };
|
||||
/* Dormindo há tempo demais: apaga a tela (preto total) e o app baixa o brilho ao mínimo.
|
||||
Qualquer coisa que acorde o rosto (som, movimento, toque, comando, PIR) acende de novo. */
|
||||
function cuidarDaTela() {
|
||||
const agora = performance.now();
|
||||
if (!r.dormindo) SONO.desde = 0;
|
||||
else if (!SONO.desde) SONO.desde = agora;
|
||||
const apagar = SONO.apagar && r.dormindo && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
|
||||
if (apagar !== SONO.apagada) {
|
||||
SONO.apagada = apagar;
|
||||
window.carinhaTelaApagada = apagar;
|
||||
document.body.style.visibility = apagar ? "hidden" : "";
|
||||
console.log(apagar ? "tela apagada (dormindo ha " + SONO.minutos + " min)" : "tela acesa");
|
||||
}
|
||||
}
|
||||
setInterval(cuidarDaTela, 1000);
|
||||
const CIANO_E = [150, 232, 255];
|
||||
const VERM = [255, 78, 78];
|
||||
const ROSA = [255, 112, 176];
|
||||
const AMARELO = [255, 218, 96];
|
||||
const AZUL = [130, 170, 255];
|
||||
// Tons derivados da cor escolhida no painel (CIANO é trocada pela cor escolhida em tempo real):
|
||||
// "claro" para susto/surpresa, "escuro" para tristeza e sono. Nada de azul fixo.
|
||||
const corClara = (k) => CIANO.map((v) => v + (255 - v) * (k === undefined ? 0.4 : k));
|
||||
const corEscura = (k) => CIANO.map((v) => v * (k === undefined ? 0.62 : k));
|
||||
function resolverCor(c) { return c === "claro" ? corClara() : c === "escuro" ? corEscura() : c === "escolhida" ? CIANO.slice() : c; }
|
||||
const VERDE = [120, 255, 160];
|
||||
const BRANCO = [245, 250, 255];
|
||||
|
||||
@@ -103,10 +126,17 @@ const BRANCO = [245, 250, 255];
|
||||
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
|
||||
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
|
||||
const ROSTOS = {
|
||||
vivo: { rotulo: "Carinha Viva", boca: true, escala: 1.00, motor: "vivo" },
|
||||
bloco: { rotulo: "Bloco", boca: false, escala: 1.00, motor: "bloco" },
|
||||
matriz: { rotulo: "Matriz", boca: false, escala: 1.00, motor: "matriz" },
|
||||
lente: { rotulo: "Lente", boca: false, escala: 1.00, motor: "lente" },
|
||||
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
|
||||
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
|
||||
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
|
||||
};
|
||||
// Formato do olho nos rostos novos (rosto-motor.js): padrao, redondo, pilula, quadrado, largo.
|
||||
const FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"];
|
||||
let FORMATO = "padrao";
|
||||
let ROSTO_NOME = "carinha";
|
||||
let ROSTO = ROSTOS[ROSTO_NOME];
|
||||
|
||||
@@ -141,8 +171,18 @@ function caminhoLinha(x0, y0, x1, y1) {
|
||||
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
|
||||
}
|
||||
/* formas da boca (o humor escolhe qual) */
|
||||
function deslocamentoBoca() {
|
||||
const auto = ROSTO && ROSTO.motor ? 22 : 0; // rostos novos têm olhos mais baixos
|
||||
return -(auto + (EXTRAS.subirBoca || 0)) * ESC;
|
||||
}
|
||||
/* Topo do olho atual (para a sobrancelha ficar perto dele, em qualquer estilo/formato). */
|
||||
function metadeOlho(esc) {
|
||||
const m = ROSTO && ROSTO.motor && window.MotorRosto && window.MotorRosto.metadeAltura
|
||||
? window.MotorRosto.metadeAltura(ROSTO.motor, FORMATO) : 1;
|
||||
return m * RY * esc;
|
||||
}
|
||||
function caminhoBoca(tipo) {
|
||||
const cx = BOCA[0], cy = BOCA[1], l = BOCA_L, meia = l / 2;
|
||||
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca(), l = BOCA_L, meia = l / 2;
|
||||
if (tipo === "aberta") { // surpresa: um "O"
|
||||
const rx = l * 0.17, ry = l * 0.22;
|
||||
return caminhoElipse(cx, cy, rx, ry);
|
||||
@@ -283,7 +323,7 @@ function sequencias() {
|
||||
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
|
||||
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
|
||||
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
|
||||
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: CIANO_E, boca: "aberta" }, 0.09],
|
||||
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: "claro", boca: "aberta" }, 0.09],
|
||||
[{ dy: -10 }, 0.5],
|
||||
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
|
||||
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
|
||||
@@ -292,13 +332,21 @@ function sequencias() {
|
||||
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
|
||||
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
|
||||
[{ forma: "aberto", cor: CIANO }, 0.3]];
|
||||
const triste = [[{ forma: "triste", cor: AZUL, dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
|
||||
const triste = [[{ forma: "triste", cor: "escuro", dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
|
||||
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
|
||||
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
|
||||
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
|
||||
// Gestos com mãos: o motor desenha as mãos com a mesma duração (rosto-motor.js, DUR_MAOS).
|
||||
const esconder = [[{ forma: "aberto", esc: 0.95, dy: 4 }, 0.3], [{}, 1.6], [{ forma: "feliz", boca: "sorriso", esc: 1.05, dy: 0 }, 0.3], [{ forma: "aberto", esc: 1.0 }, 0.4]];
|
||||
const espiar = [[{ esc: 0.96 }, 0.4], [{}, 0.6], [{ dx: -14, forma: "aberto", ab: 1.08 }, 0.25], [{}, 0.9], [{ dx: 0, ab: 1.0 }, 0.35], [{ forma: "feliz", boca: "sorriso" }, 0.4], [{ forma: "aberto" }, 0.3]];
|
||||
const acenar = [[{ forma: "feliz", boca: "sorriso", esc: 1.05 }, 0.3], [{}, 1.7], [{ forma: "aberto", esc: 1.0 }, 0.4]];
|
||||
const pensar = [[{ dy: -10, dx: 14, boca: "neutra" }, 0.4], [{}, 2.0], [{ dy: 0, dx: 0 }, 0.4]];
|
||||
const musica = [[{ forma: "feliz", boca: "sorriso", dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25],
|
||||
[{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ forma: "aberto", dy: 0 }, 0.3]];
|
||||
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
|
||||
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
|
||||
return { piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
|
||||
return { esconder, espiar, acenar, pensar, musica,
|
||||
piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
|
||||
bravo, coracao, triste, piscadinha, alegre };
|
||||
}
|
||||
|
||||
@@ -317,6 +365,7 @@ function proximo() {
|
||||
const [alvo, dur] = r.fila[0];
|
||||
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
|
||||
r.cor_ini = r.cor.slice();
|
||||
if ("cor" in alvo) alvo.cor = resolverCor(alvo.cor); // "claro"/"escuro" viram tons da cor escolhida
|
||||
if ("forma" in alvo) r.forma = alvo.forma;
|
||||
if ("forma2" in alvo) r.forma2 = alvo.forma2;
|
||||
if ("boca" in alvo) r.boca = alvo.boca;
|
||||
@@ -351,7 +400,7 @@ function passo(dt) { // avanca a fila; true se ha gesto em
|
||||
function repouso(t) { // pose calma: olha devagar, "respira"
|
||||
if (r.dormindo) {
|
||||
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
|
||||
r.cor = lerpCor(AZUL, [40, 60, 120], 0.5 + 0.5 * Math.sin(t * 1.2));
|
||||
r.cor = lerpCor(corEscura(0.75), corEscura(0.35), 0.5 + 0.5 * Math.sin(t * 1.2)); // respira na cor escolhida
|
||||
r.ab = 0; r.ab2 = 0;
|
||||
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
|
||||
r.dx = 0;
|
||||
@@ -380,20 +429,39 @@ function desenhar() {
|
||||
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
|
||||
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
|
||||
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const comMotor = ROSTO.motor && window.MotorRosto;
|
||||
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
|
||||
if (comMotor) {
|
||||
window.MotorRosto.desenhar({
|
||||
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo,
|
||||
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
|
||||
carga: window.carinhaBateria || null,
|
||||
iris: EXTRAS.iris,
|
||||
tracos: ROSTO.motor === "matriz" ? tracosDoRosto(esc) : null,
|
||||
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
|
||||
parado: !r.kf && !seguindo && !r.dormindo,
|
||||
olhos: olhos.map(([c, abx, fx, lado]) => ({
|
||||
cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc,
|
||||
ab: abx, forma: fx, lado, lx: dx, ly: dy * 0.6,
|
||||
})),
|
||||
});
|
||||
} else if (window.MotorRosto) window.MotorRosto.esconder();
|
||||
for (let i = 0; i < 2 && !comMotor; i++) {
|
||||
const [c, abx, fx, lado] = olhos[i];
|
||||
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
|
||||
abx, fx, r.cor, lado, dx, dy * 0.6);
|
||||
}
|
||||
desenharBoca();
|
||||
const matriz = comMotor && ROSTO.motor === "matriz";
|
||||
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
|
||||
else desenharBoca();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const brow = document.getElementById('sobrancelha' + (i + 1));
|
||||
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
|
||||
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
|
||||
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
|
||||
const c = i === 0 ? E1 : E2;
|
||||
const lado = i === 0 ? 1 : -1;
|
||||
const largura = RX * esc * 0.85;
|
||||
const y = c[1] - RY * esc - 20 * ESC;
|
||||
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
|
||||
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
|
||||
const elevacao = r.esc > 1.1 ? -15 * ESC : 0;
|
||||
brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
|
||||
@@ -402,6 +470,27 @@ function desenhar() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Boca e sobrancelhas como caminhos SVG, para a Matriz acender em LEDs (mesmas formas do SVG). */
|
||||
function tracosDoRosto(esc) {
|
||||
const tracos = [];
|
||||
if (EXTRAS.boca) {
|
||||
const forma = r.boca;
|
||||
if (r.dormindo) { // dormindo na Matriz: "– _ –"
|
||||
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca() + BOCA_L * 0.08, l = BOCA_L * 0.32;
|
||||
tracos.push({ d: 'M ' + (cx - l) + ',' + cy + ' L ' + (cx + l) + ',' + cy, largura: BOCA_L * 0.16 });
|
||||
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
|
||||
}
|
||||
if (EXTRAS.sobrancelhas && !r.dormindo) {
|
||||
[E1, E2].forEach((c, i) => {
|
||||
const lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
|
||||
const inc = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
|
||||
const ele = r.esc > 1.1 ? -15 * ESC : 0;
|
||||
tracos.push({ d: 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele), largura: 9 * ESC });
|
||||
});
|
||||
}
|
||||
return tracos;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ controle
|
||||
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
|
||||
function trocarRosto(nome) {
|
||||
@@ -430,6 +519,9 @@ function trata(ordem) {
|
||||
trocarRosto(ordem.slice(6).trim());
|
||||
} else if (ROSTOS[ordem]) {
|
||||
trocarRosto(ordem); // aceita so o nome: "miudo"
|
||||
} else if (ordem.indexOf("formato ") === 0) {
|
||||
const f = ordem.slice(8).trim();
|
||||
if (FORMATOS.includes(f)) FORMATO = f;
|
||||
} else if (ordem.indexOf("cor ") === 0) {
|
||||
const v = ordem.slice(4).split(",").map(Number);
|
||||
if (v.length === 3 && v.every((n) => !isNaN(n))) {
|
||||
@@ -442,6 +534,10 @@ function trata(ordem) {
|
||||
} else if (ordem === "aleatorio") {
|
||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
||||
} else if (SEQS[ordem]) {
|
||||
if (window.MotorRosto) {
|
||||
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
|
||||
else window.MotorRosto.maos(ordem);
|
||||
}
|
||||
animar(ordem);
|
||||
} else if (ordem === "calma") {
|
||||
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
|
||||
@@ -462,7 +558,7 @@ function pose(nome) {
|
||||
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
|
||||
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
|
||||
}
|
||||
if ("cor" in alvo) r.cor = alvo.cor.slice();
|
||||
if ("cor" in alvo) r.cor = resolverCor(alvo.cor).slice();
|
||||
congelado = true;
|
||||
desenhar();
|
||||
return true;
|
||||
@@ -501,6 +597,7 @@ async function aplicarAparencia() {
|
||||
if (!resp.ok) return;
|
||||
const c = await resp.json();
|
||||
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
|
||||
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
|
||||
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
|
||||
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
|
||||
} catch (e) { /* sem servidor — segue com o padrao */ }
|
||||
@@ -516,9 +613,29 @@ if (MODO_PAINEL) {
|
||||
const m = e.data;
|
||||
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
|
||||
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
|
||||
else if (m.tipo === 'sono' && !!m.dormindo !== r.dormindo) trata(m.dormindo ? 'dormir' : 'acordar');
|
||||
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
|
||||
});
|
||||
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
|
||||
|
||||
// toque na tela: cutucar dá susto e depois risada; deslizar o dedo é carinho.
|
||||
// O toque longo (menu do app) passa direto: só reagimos a toques curtos.
|
||||
(function () {
|
||||
let inicio = null;
|
||||
addEventListener('pointerdown', (e) => { inicio = { x: e.clientX, y: e.clientY, t: performance.now() }; });
|
||||
addEventListener('pointerup', (e) => {
|
||||
if (!inicio) return;
|
||||
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
|
||||
inicio = null;
|
||||
if (r.dormindo) { trata('acordar'); return; }
|
||||
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); return; }
|
||||
if (dur < 350 && Math.hypot(dx, dy) < 15) {
|
||||
animar('surpreso');
|
||||
setTimeout(() => animar('feliz'), 700);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// teclado (util para testar no PC)
|
||||
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
|
||||
t: "triste", a: "alegre", o: "olhar_esq", i: "olhar_dir",
|
||||
@@ -616,6 +733,16 @@ function checarSilencio(agora) {
|
||||
journal do carinha-web (so quando o microfone esta ativo). */
|
||||
function relatarSom(evento, n) {
|
||||
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
||||
if (evento !== "adormeceu") registrarEvento("som", evento, n);
|
||||
}
|
||||
|
||||
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
|
||||
function registrarEvento(tipo, evento, nivel) {
|
||||
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
|
||||
try {
|
||||
fetch("/historico/evento", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tipo, evento, nivel }) }).catch(() => {});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ presenca (movimento)
|
||||
@@ -704,6 +831,7 @@ function movimento(vel, cx, cy) {
|
||||
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
|
||||
function relatarMov(evento, n) {
|
||||
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
||||
registrarEvento("movimento", evento, n);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- camera oculta (sensor)
|
||||
@@ -834,7 +962,11 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
|
||||
|
||||
let configRecebida = null;
|
||||
function configurarApp(c) {
|
||||
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true;
|
||||
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
|
||||
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca;
|
||||
SONO.apagar = c.apagarTela !== false;
|
||||
if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin;
|
||||
if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
|
||||
AUTO = c.auto;
|
||||
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
|
||||
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
|
||||
@@ -864,7 +996,9 @@ async function sincronizarApp() {
|
||||
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
|
||||
centro: [MOV.cx, MOV.cy], config: configRecebida,
|
||||
brilho: window.carinhaBrilho,
|
||||
tela_apagada: !!window.carinhaTelaApagada,
|
||||
bateria: window.carinhaBateria,
|
||||
aparelho: window.carinhaAparelho,
|
||||
video: window.carinhaVideo,
|
||||
}) });
|
||||
}
|
||||
@@ -909,15 +1043,32 @@ const SONS = {
|
||||
dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); },
|
||||
};
|
||||
|
||||
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
|
||||
os sons dos gestos ficam quietos para não embolar. */
|
||||
function falar(texto) {
|
||||
if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
|
||||
const c = contextoAudio();
|
||||
if (!c) return;
|
||||
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||
try {
|
||||
const dur = window.VozRobo.balbuciar(c, texto, AUDIO.volume * 0.85);
|
||||
AUDIO.mudoAte = performance.now() + dur * 1000;
|
||||
} catch (e) { /* segue sem voz */ }
|
||||
}
|
||||
|
||||
function tocarSom(gesto) {
|
||||
if (!AUDIO.ligado) return false;
|
||||
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
|
||||
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
|
||||
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
|
||||
const f = SONS[gesto];
|
||||
if (!f) return false;
|
||||
if (!voz && !f) return false;
|
||||
const c = contextoAudio();
|
||||
if (!c) return false;
|
||||
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||
try {
|
||||
f(c, c.currentTime + 0.02, AUDIO.volume);
|
||||
if (voz) window.VozRobo.tocar(c, gesto, AUDIO.volume); // voz de robô sintetizada
|
||||
else f(c, c.currentTime + 0.02, AUDIO.volume); // bipes antigos (reserva)
|
||||
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
|
||||
console.log("voz: " + gesto + " ctx=" + c.state);
|
||||
return true;
|
||||
@@ -1004,7 +1155,9 @@ setTimeout(relatar, 1500);
|
||||
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
|
||||
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
|
||||
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
|
||||
falar(m.texto);
|
||||
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
|
||||
if(window.MotorRosto)window.MotorRosto.faixa('legenda',{texto:m.texto});
|
||||
}catch(e){}finally{fetching=false;}
|
||||
}
|
||||
setInterval(poll,1000);poll();
|
||||
@@ -1045,10 +1198,16 @@ if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchPa
|
||||
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
|
||||
ouvir('app_config', (c) => configurarApp(c));
|
||||
ouvir('conversa', (s) => {
|
||||
if (window.MotorRosto) {
|
||||
if (s.ocupado) window.MotorRosto.faixa('digitando');
|
||||
else window.MotorRosto.faixa(null, { so: 'digitando' });
|
||||
}
|
||||
const m = s.ultima;
|
||||
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
|
||||
ultimaConversa = m.id;
|
||||
falar(m.texto);
|
||||
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
|
||||
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
|
||||
});
|
||||
ouvir('reacao', (d) => {
|
||||
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import time
|
||||
from urllib.request import Request,urlopen
|
||||
from comum import gravar_atomico, ler_json
|
||||
|
||||
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo')
|
||||
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo','acenar','esconder','pensar')
|
||||
EVENTOS=('som','movimento','saudacao','sozinho')
|
||||
def validar_intencao(data):
|
||||
if not isinstance(data,dict) or set(data)!= {'gesto'} or data['gesto'] not in GESTOS:
|
||||
|
||||
+16
-3
@@ -1,4 +1,5 @@
|
||||
"""Configuração persistente e telemetria do celular; sinalização do vídeo WebRTC."""
|
||||
import ipaddress
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
@@ -19,7 +20,8 @@ PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35,
|
||||
somaltolim=0.55, sompausa=20, somcooldown=5,
|
||||
movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40,
|
||||
economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30,
|
||||
boca=True, sobrancelhas=False, videoRTC=True,
|
||||
boca=True, sobrancelhas=False, iris=False, subirBoca=0, distSobrancelha=16, videoRTC=True,
|
||||
apagarTela=True, apagarTelaMin=5,
|
||||
sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False,
|
||||
cameraLado='user', videoResolucao='auto', videoFPS=0,
|
||||
rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True)
|
||||
@@ -29,7 +31,8 @@ ESCOLHAS = dict(cameraLado=('user', 'environment'),
|
||||
LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1),
|
||||
somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120),
|
||||
movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100),
|
||||
brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600))
|
||||
brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600),
|
||||
subirBoca=(-20, 60), distSobrancelha=(0, 60), apagarTelaMin=(1, 120))
|
||||
|
||||
|
||||
def validar(dados):
|
||||
@@ -51,6 +54,16 @@ def validar(dados):
|
||||
return dados
|
||||
|
||||
|
||||
def ip_informado(dados):
|
||||
"""IP que o próprio app informa (aparelho.ip); o endereço da conexão pode ser de um proxy."""
|
||||
aparelho = dados.get('aparelho')
|
||||
ip = aparelho.get('ip') if isinstance(aparelho, dict) else None
|
||||
try:
|
||||
return str(ipaddress.IPv4Address(ip)) if isinstance(ip, str) else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class ControlePC:
|
||||
def __init__(self, arquivo):
|
||||
self.arquivo = Path(arquivo)
|
||||
@@ -134,7 +147,7 @@ class ControlePC:
|
||||
raise ValueError('Estado deve ser objeto JSON')
|
||||
with self.lock:
|
||||
self.estado = dados
|
||||
self.celular_ip = h.client_address[0]
|
||||
self.celular_ip = ip_informado(dados) or h.client_address[0]
|
||||
if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
|
||||
self.recarga["confirmada"] = True
|
||||
self.status_at = time.monotonic()
|
||||
|
||||
+7
-4
@@ -7,12 +7,14 @@
|
||||
.\deploy.ps1 # compila, publica em root@192.168.1.35:/opt/carinha-pc e reinicia
|
||||
.\deploy.ps1 -SoPacote # só gera o pacote local (nada vai para a placa)
|
||||
.\deploy.ps1 -Placa 192.168.1.40 -Usuario carinha
|
||||
.\deploy.ps1 -Ssh orangepi # usa um Host do ~/.ssh/config (chave própria)
|
||||
#>
|
||||
param(
|
||||
[string]$Placa = '192.168.1.35',
|
||||
[string]$Usuario = 'root',
|
||||
[string]$Destino = '/opt/carinha-pc',
|
||||
[int]$Porta = 8100,
|
||||
[string]$Ssh = '',
|
||||
[switch]$SemCompilar,
|
||||
[switch]$SoPacote
|
||||
)
|
||||
@@ -25,8 +27,8 @@ Set-Location $PSScriptRoot -ErrorAction Stop
|
||||
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
|
||||
$arquivos = @(
|
||||
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
|
||||
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py',
|
||||
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'version.json',
|
||||
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py',
|
||||
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json',
|
||||
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
|
||||
)
|
||||
if (Test-Path 'android/carinha-rosto.apk') { $arquivos += 'android/carinha-rosto.apk' }
|
||||
@@ -67,7 +69,7 @@ if ($SoPacote) {
|
||||
return
|
||||
}
|
||||
|
||||
$alvo = "$Usuario@$Placa"
|
||||
$alvo = if ($Ssh) { $Ssh } else { "$Usuario@$Placa" }
|
||||
$remoto = "/tmp/carinha-$versao-$carimbo.tar.gz"
|
||||
Write-Host "== Enviando para $alvo"
|
||||
& scp -q $pacote "${alvo}:$remoto"
|
||||
@@ -90,7 +92,8 @@ systemctl restart carinha-pc
|
||||
echo "backup em `$backup"
|
||||
"@
|
||||
Write-Host '== Instalando na placa'
|
||||
$script -replace "`r", '' | & ssh $alvo 'bash -s'
|
||||
# O PowerShell 5.1 põe BOM no stdin do ssh; sem tirar, o bash lê "<BOM>set -e" e não para no primeiro erro.
|
||||
$script -replace "`r", '' | & ssh $alvo "sed '1s/^\xEF\xBB\xBF//' | bash -s"
|
||||
if ($LASTEXITCODE) { throw 'instalação na placa falhou (o backup foi feito antes de qualquer remoção)' }
|
||||
|
||||
Write-Host '== Conferindo'
|
||||
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
"""Histórico de detecções (PIR da placa, movimento e som do celular) com uma foto por evento.
|
||||
|
||||
- PIR: lê o pino com `gpiomon` (libgpiod). Padrão: gpiochip1 linha 70 = pino 11 (PC6) do
|
||||
Orange Pi Zero 3; outro pino com CARINHA_PIR=gpiochip1:79.
|
||||
- Foto: ao registrar um evento, o servidor deixa um "pedido de foto"; o app do S10 consulta
|
||||
/historico/pedido, tira a foto com a câmera frontal e envia em /historico/foto?id=N.
|
||||
- Guarda só os últimos 3 dias (eventos e fotos mais antigos são apagados sozinhos), com teto de
|
||||
segurança de 2000 eventos e 500 fotos. Cada sensor e a foto podem ser desligados.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
from comum import gravar_atomico, ler_json
|
||||
|
||||
TIPOS = ('pir', 'movimento', 'som')
|
||||
PADRAO = dict(pir=True, movimento=True, som=True, foto=True, intervalo=15, dias=3,
|
||||
presenca=True, ausencia=5) # modo presença: o PIR liga a câmera; sem ninguém, ela desliga
|
||||
MAX_EVENTOS, MAX_FOTOS = 2000, 500 # teto de segurança; quem manda é a retenção em dias
|
||||
VALIDADE_PEDIDO = 12 # s que o app tem para mandar a foto
|
||||
|
||||
|
||||
class Historico:
|
||||
def __init__(self, pasta, iniciar_pir=True):
|
||||
self.pasta = Path(pasta)
|
||||
self.fotos = self.pasta / 'fotos'
|
||||
self.fotos.mkdir(exist_ok=True)
|
||||
self.caminho_db = str(self.pasta / 'historico.db')
|
||||
self.arquivo_config = self.pasta / 'historico.json'
|
||||
self.lock = threading.RLock()
|
||||
self.publicar = lambda tipo, dados: None
|
||||
self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto
|
||||
# Modo presença: o servidor liga câmera/acorda (ao_presenca) e desliga/dorme (ao_ausencia).
|
||||
self.ao_presenca = lambda: None
|
||||
self.ao_ausencia = lambda: None
|
||||
self.presenca = dict(estado='presente', desde=time.time(), ultimo=time.time())
|
||||
self.config = dict(PADRAO)
|
||||
try:
|
||||
salvo = json.loads(self.arquivo_config.read_text(encoding='utf-8'))
|
||||
self.config.update(self.validar(salvo))
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
self.ultimo = {} # tipo -> horário do último evento (intervalo mínimo)
|
||||
self.pedido = None # (id do evento, monotonic) aguardando foto do app
|
||||
self.pir = dict(disponivel=False, erro='', linha=os.environ.get('CARINHA_PIR', 'gpiochip1:70'), ultimo=None,
|
||||
ativo=False) # ativo = pino em nível alto agora (o HC-SR505 segura ~8 s e rearma)
|
||||
self.processo_pir = None
|
||||
with self.db() as db:
|
||||
db.execute('CREATE TABLE IF NOT EXISTS eventos(id INTEGER PRIMARY KEY, horario REAL, tipo TEXT, evento TEXT, nivel REAL, foto TEXT)')
|
||||
self.limpar_antigos()
|
||||
if iniciar_pir:
|
||||
threading.Thread(target=self.vigiar_pir, daemon=True).start()
|
||||
threading.Thread(target=self.faxina, daemon=True).start()
|
||||
threading.Thread(target=self.vigiar_ausencia, daemon=True).start()
|
||||
|
||||
@contextmanager
|
||||
def db(self):
|
||||
con = sqlite3.connect(self.caminho_db)
|
||||
try:
|
||||
with con:
|
||||
yield con
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
@staticmethod
|
||||
def validar(dados):
|
||||
if not isinstance(dados, dict):
|
||||
raise ValueError('Esperava um objeto')
|
||||
limpo = {}
|
||||
for chave, valor in dados.items():
|
||||
if chave not in PADRAO:
|
||||
raise ValueError('Ajuste desconhecido: ' + chave)
|
||||
if chave == 'ausencia':
|
||||
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 120:
|
||||
raise ValueError('Ausência de 1 a 120 minutos')
|
||||
elif chave == 'dias':
|
||||
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 30:
|
||||
raise ValueError('Guardar de 1 a 30 dias')
|
||||
elif chave == 'intervalo':
|
||||
if isinstance(valor, bool) or not isinstance(valor, (int, float)) or not 0 <= valor <= 600:
|
||||
raise ValueError('Intervalo entre 0 e 600 s')
|
||||
elif not isinstance(valor, bool):
|
||||
raise ValueError(chave + ' precisa ser verdadeiro ou falso')
|
||||
limpo[chave] = valor
|
||||
return limpo
|
||||
|
||||
# ------------------------------------------------------------------ retenção
|
||||
def limpar_antigos(self, agora=None):
|
||||
"""Apaga eventos (e fotos) mais velhos que a retenção em dias."""
|
||||
limite = (agora if agora is not None else time.time()) - self.config['dias'] * 86400
|
||||
with self.lock, self.db() as db:
|
||||
velhos = db.execute('SELECT id, foto FROM eventos WHERE horario < ?', (limite,)).fetchall()
|
||||
for vid, foto in velhos:
|
||||
self.apagar_arquivo(foto)
|
||||
db.execute('DELETE FROM eventos WHERE horario < ?', (limite,))
|
||||
if velhos:
|
||||
self.publicar('historico', dict(expirados=len(velhos)))
|
||||
return len(velhos)
|
||||
|
||||
# ------------------------------------------------------------------ modo presença
|
||||
def marcar_presenca(self, agora=None):
|
||||
agora = agora if agora is not None else time.time()
|
||||
with self.lock:
|
||||
self.presenca['ultimo'] = agora
|
||||
voltou = self.presenca['estado'] != 'presente'
|
||||
if voltou:
|
||||
self.presenca.update(estado='presente', desde=agora)
|
||||
if voltou and self.config['presenca']:
|
||||
self.ao_presenca()
|
||||
|
||||
def checar_ausencia(self, agora=None):
|
||||
"""Sem presença no PIR por `ausencia` minutos: desliga câmera, dorme e fica quieto."""
|
||||
agora = agora if agora is not None else time.time()
|
||||
if not (self.config['presenca'] and self.config['pir'] and self.pir['disponivel']):
|
||||
return False
|
||||
with self.lock:
|
||||
if self.pir['ativo']: # sensor ainda segurando: a pessoa continua ali
|
||||
self.presenca['ultimo'] = agora
|
||||
return False
|
||||
if self.presenca['estado'] != 'presente' or agora - self.presenca['ultimo'] < self.config['ausencia'] * 60:
|
||||
return False
|
||||
self.presenca.update(estado='ausente', desde=agora)
|
||||
self.ao_ausencia()
|
||||
self.avisar_pir()
|
||||
return True
|
||||
|
||||
def avisar_pir(self):
|
||||
self.publicar('pir', dict(ultimo=self.pir['ultimo'], ativo=self.pir['ativo'], presenca=dict(self.presenca)))
|
||||
|
||||
def nivel_pir(self, alto, agora=None, registrar=True):
|
||||
"""Mudança no pino do PIR. Subida = presença (evento + foto); enquanto alto, a bolinha fica acesa."""
|
||||
agora = agora if agora is not None else time.time()
|
||||
self.pir['ativo'] = alto
|
||||
if alto:
|
||||
self.pir['ultimo'] = agora
|
||||
self.marcar_presenca(agora)
|
||||
self.avisar_pir()
|
||||
if alto and registrar:
|
||||
self.registrar('pir', 'presença')
|
||||
|
||||
def vigiar_ausencia(self):
|
||||
while True:
|
||||
time.sleep(10)
|
||||
try:
|
||||
self.checar_ausencia()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def faxina(self):
|
||||
while True:
|
||||
time.sleep(600)
|
||||
try:
|
||||
self.limpar_antigos()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------ eventos
|
||||
def registrar(self, tipo, evento='', nivel=None, agora=None):
|
||||
"""Grava o evento se o sensor estiver ligado e fora do intervalo mínimo; devolve o id ou None."""
|
||||
if tipo not in TIPOS:
|
||||
raise ValueError('Tipo de evento inválido')
|
||||
agora = agora if agora is not None else time.time()
|
||||
with self.lock:
|
||||
if not self.config.get(tipo, False):
|
||||
return None
|
||||
if tipo in self.ultimo and agora - self.ultimo[tipo] < self.config['intervalo']:
|
||||
return None
|
||||
self.ultimo[tipo] = agora
|
||||
evento = str(evento or '')[:40]
|
||||
nivel = float(nivel) if isinstance(nivel, (int, float)) and not isinstance(nivel, bool) else None
|
||||
with self.db() as db:
|
||||
ident = db.execute('INSERT INTO eventos(horario, tipo, evento, nivel) VALUES (?,?,?,?)',
|
||||
(agora, tipo, evento, nivel)).lastrowid
|
||||
velhos = db.execute('SELECT id, foto FROM eventos ORDER BY id DESC LIMIT -1 OFFSET ?', (MAX_EVENTOS,)).fetchall()
|
||||
for vid, foto in velhos:
|
||||
self.apagar_arquivo(foto)
|
||||
db.execute('DELETE FROM eventos WHERE id=?', (vid,))
|
||||
if self.config['foto']:
|
||||
self.pedido = (ident, time.monotonic())
|
||||
self.limpar_antigos(agora)
|
||||
self.publicar('historico', dict(novo=self.evento(ident)))
|
||||
try:
|
||||
self.ao_detectar(tipo)
|
||||
except Exception:
|
||||
pass
|
||||
return ident
|
||||
|
||||
def evento(self, ident):
|
||||
with self.db() as db:
|
||||
linha = db.execute('SELECT id, horario, tipo, evento, nivel, foto FROM eventos WHERE id=?', (ident,)).fetchone()
|
||||
return self.como_dict(linha) if linha else None
|
||||
|
||||
@staticmethod
|
||||
def como_dict(linha):
|
||||
i, h, t, e, n, f = linha
|
||||
return dict(id=i, horario=h, tipo=t, evento=e, nivel=n, foto=bool(f))
|
||||
|
||||
def listar(self, limite=500):
|
||||
with self.db() as db:
|
||||
linhas = db.execute('SELECT id, horario, tipo, evento, nivel, foto FROM eventos ORDER BY id DESC LIMIT ?', (limite,)).fetchall()
|
||||
total = db.execute('SELECT count(*) FROM eventos').fetchone()[0]
|
||||
return [self.como_dict(l) for l in linhas], total
|
||||
|
||||
def apagar(self, ident):
|
||||
with self.lock, self.db() as db:
|
||||
linha = db.execute('SELECT foto FROM eventos WHERE id=?', (ident,)).fetchone()
|
||||
if linha:
|
||||
self.apagar_arquivo(linha[0])
|
||||
db.execute('DELETE FROM eventos WHERE id=?', (ident,))
|
||||
self.publicar('historico', dict(apagado=ident))
|
||||
|
||||
def limpar(self):
|
||||
with self.lock, self.db() as db:
|
||||
db.execute('DELETE FROM eventos')
|
||||
for f in self.fotos.glob('evento-*.jpg'):
|
||||
f.unlink(missing_ok=True)
|
||||
self.pedido = None
|
||||
self.publicar('historico', dict(limpo=True))
|
||||
|
||||
def apagar_arquivo(self, nome):
|
||||
if nome:
|
||||
(self.fotos / nome).unlink(missing_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------ foto
|
||||
def pedido_atual(self):
|
||||
with self.lock:
|
||||
if self.pedido and time.monotonic() - self.pedido[1] < VALIDADE_PEDIDO:
|
||||
return self.pedido[0]
|
||||
self.pedido = None
|
||||
return None
|
||||
|
||||
def guardar_foto(self, ident, jpeg):
|
||||
if not (jpeg.startswith(b'\xff\xd8') and jpeg.endswith(b'\xff\xd9')) or len(jpeg) > 3_000_000:
|
||||
raise ValueError('Esperava uma foto JPEG de até 3 MB')
|
||||
with self.lock:
|
||||
if not self.config['foto']:
|
||||
raise ValueError('Fotos desligadas')
|
||||
if self.pedido and self.pedido[0] == ident:
|
||||
self.pedido = None
|
||||
nome = f'evento-{ident}.jpg'
|
||||
with self.db() as db:
|
||||
if not db.execute('SELECT 1 FROM eventos WHERE id=?', (ident,)).fetchone():
|
||||
raise ValueError('Evento não existe mais')
|
||||
tmp = self.fotos / (nome + '.tmp')
|
||||
tmp.write_bytes(jpeg); tmp.replace(self.fotos / nome)
|
||||
db.execute('UPDATE eventos SET foto=? WHERE id=?', (nome, ident))
|
||||
antigas = db.execute('SELECT id, foto FROM eventos WHERE foto IS NOT NULL ORDER BY id DESC LIMIT -1 OFFSET ?', (MAX_FOTOS,)).fetchall()
|
||||
for vid, foto in antigas:
|
||||
self.apagar_arquivo(foto)
|
||||
db.execute('UPDATE eventos SET foto=NULL WHERE id=?', (vid,))
|
||||
self.publicar('historico', dict(foto=ident))
|
||||
|
||||
# ------------------------------------------------------------------ PIR
|
||||
def vigiar_pir(self):
|
||||
"""Mantém um `gpiomon` rodando enquanto o PIR estiver ligado e acompanha o nível do pino."""
|
||||
while True:
|
||||
if not self.config['pir'] or os.name != 'posix' or not shutil.which('gpiomon'):
|
||||
self.pir.update(disponivel=False, erro='' if self.config['pir'] else 'desligado')
|
||||
if os.name == 'posix' and not shutil.which('gpiomon'):
|
||||
self.pir['erro'] = 'gpiomon não instalado (apt install gpiod)'
|
||||
time.sleep(2)
|
||||
continue
|
||||
chip, _, linha = self.pir['linha'].partition(':')
|
||||
try:
|
||||
# pull-down: com o sensor desligado o pino não fica "flutuando" (disparos falsos)
|
||||
lido = subprocess.run(['gpioget', '-c', chip, '-b', 'pull-down', '--numeric', linha],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
self.processo_pir = subprocess.Popen(['gpiomon', '-c', chip, '-b', 'pull-down', '-e', 'both', '-F', '%E', linha],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
self.pir.update(disponivel=True, erro='')
|
||||
# nível de partida: alguém já na frente não gera borda de subida, mas acende a bolinha
|
||||
self.nivel_pir(lido.stdout.strip() == '1', registrar=False)
|
||||
inicio = time.monotonic()
|
||||
for linha_evento in self.processo_pir.stdout:
|
||||
if not self.config['pir']:
|
||||
break
|
||||
alto = linha_evento.strip() == 'rising'
|
||||
# o pino "assenta" ao ligar o pull-down: acompanha o nível, mas não registra evento
|
||||
self.nivel_pir(alto, registrar=time.monotonic() - inicio >= 2)
|
||||
erro = (self.processo_pir.stderr.read() or '').strip()[:200]
|
||||
except OSError as e:
|
||||
erro = str(e)
|
||||
except subprocess.TimeoutExpired:
|
||||
erro = 'gpioget não respondeu'
|
||||
finally:
|
||||
self.parar_pir()
|
||||
self.pir.update(disponivel=False, ativo=False, erro=erro if self.config['pir'] else 'desligado')
|
||||
time.sleep(3)
|
||||
|
||||
def parar_pir(self):
|
||||
p, self.processo_pir = self.processo_pir, None
|
||||
if p and p.poll() is None:
|
||||
p.terminate()
|
||||
try:
|
||||
p.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
p.kill()
|
||||
|
||||
def status(self):
|
||||
eventos, total = self.listar()
|
||||
with self.lock:
|
||||
return dict(config=dict(self.config), pir=dict(self.pir), presenca=dict(self.presenca), eventos=eventos, total=total,
|
||||
max_eventos=MAX_EVENTOS, max_fotos=MAX_FOTOS)
|
||||
|
||||
# ------------------------------------------------------------------ HTTP
|
||||
def rotas(self, h, metodo):
|
||||
url = urlsplit(h.path)
|
||||
rota = url.path
|
||||
if not rota.startswith('/historico/'):
|
||||
return False
|
||||
try:
|
||||
if metodo == 'GET' and rota == '/historico/status':
|
||||
return h.json(self.status()) or True
|
||||
if metodo == 'GET' and rota == '/historico/pedido':
|
||||
return h.json(dict(id=self.pedido_atual())) or True
|
||||
if metodo == 'GET' and rota.startswith('/historico/foto/'):
|
||||
nome = rota.rsplit('/', 1)[-1]
|
||||
if not nome.startswith('evento-') or not nome.endswith('.jpg') or '/' in nome or '..' in nome:
|
||||
raise ValueError('Foto inválida')
|
||||
arquivo = self.fotos / nome
|
||||
if not arquivo.is_file():
|
||||
h.json(dict(erro='Foto não encontrada'), 404)
|
||||
return True
|
||||
h.enviar(arquivo.read_bytes(), 'image/jpeg')
|
||||
return True
|
||||
if metodo == 'POST' and rota == '/historico/foto':
|
||||
ident = int(parse_qs(url.query).get('id', ['0'])[0])
|
||||
if h.headers.get('Content-Type', '').split(';')[0] != 'image/jpeg':
|
||||
raise ValueError('Use image/jpeg')
|
||||
tamanho = int(h.headers.get('Content-Length', '0'))
|
||||
if not 0 < tamanho <= 3_000_000:
|
||||
raise ValueError('Foto vazia ou grande demais')
|
||||
self.guardar_foto(ident, h.rfile.read(tamanho))
|
||||
return h.json(dict(ok=True)) or True
|
||||
if metodo == 'POST':
|
||||
dados = ler_json(h, 4096)
|
||||
if rota == '/historico/evento':
|
||||
ident = self.registrar(dados.get('tipo'), dados.get('evento'), dados.get('nivel'))
|
||||
return h.json(dict(ok=True, id=ident)) or True
|
||||
if rota == '/historico/config':
|
||||
limpo = self.validar(dados)
|
||||
with self.lock:
|
||||
self.config.update(limpo)
|
||||
gravar_atomico(self.arquivo_config, self.config)
|
||||
if 'pir' in limpo and not limpo['pir']:
|
||||
self.parar_pir()
|
||||
if 'dias' in limpo:
|
||||
self.limpar_antigos()
|
||||
self.publicar('historico', dict(config=dict(self.config)))
|
||||
return h.json(self.status()) or True
|
||||
if rota == '/historico/apagar':
|
||||
if type(dados.get('id')) is not int:
|
||||
raise ValueError('ID inválido')
|
||||
self.apagar(dados['id'])
|
||||
return h.json(dict(ok=True)) or True
|
||||
if rota == '/historico/limpar':
|
||||
self.limpar()
|
||||
return h.json(dict(ok=True)) or True
|
||||
h.json(dict(erro='Rota do histórico desconhecida'), 404)
|
||||
except (ValueError, TypeError) as e:
|
||||
h.json(dict(erro=str(e)), 400)
|
||||
return True
|
||||
+53
-11
@@ -40,33 +40,37 @@ from cerebro import Cerebro
|
||||
from conversa import Conversa
|
||||
from treinamento import Treinamento
|
||||
from api_config import ConfigAPI
|
||||
from historico import Historico
|
||||
|
||||
TRAVA = threading.Lock()
|
||||
|
||||
# Espelho da biblioteca de rostos do carinha.html (para /faces funcionar)
|
||||
ROSTOS = ["eilik", "carinha", "miudo"]
|
||||
ROSTOS = ["vivo", "bloco", "matriz", "lente", "eilik", "carinha", "miudo"]
|
||||
# Formato do olho nos rostos novos (rosto-motor.js)
|
||||
FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"]
|
||||
|
||||
# Espelho dos gestos do carinha.html (para o painel montar os botoes)
|
||||
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
|
||||
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre",
|
||||
"dormir", "acordar", "aleatorio", "parar"]
|
||||
"dormir", "acordar", "aleatorio", "parar",
|
||||
"esconder", "espiar", "acenar", "pensar", "musica"]
|
||||
# Ordens internas que o rosto entende mas não viram botão.
|
||||
ORDENS_INTERNAS = {"calma"}
|
||||
|
||||
# Aparência atual da carinha (personalizacao pelo painel). Fica gravada num
|
||||
# arquivo ao lado do servidor, para sobreviver a reinicios.
|
||||
APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255]}
|
||||
APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255], "formato": "padrao"}
|
||||
ARQUIVO_APARENCIA = "aparencia.json"
|
||||
|
||||
# Arquivos servidos como estão: o rosto do celular e o rastreamento do painel.
|
||||
ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador-worker.js",
|
||||
ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador-worker.js", "/rosto-motor.js", "/voz-robo.js",
|
||||
"/vendor/mediapipe/vision_bundle.mjs", "/vendor/mediapipe/face.tflite", "/vendor/mediapipe/hands.task",
|
||||
"/vendor/mediapipe/wasm/vision_wasm_internal.js", "/vendor/mediapipe/wasm/vision_wasm_internal.wasm",
|
||||
"/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js", "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm"}
|
||||
|
||||
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar.
|
||||
PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
|
||||
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/",
|
||||
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/",
|
||||
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
|
||||
|
||||
|
||||
@@ -123,6 +127,8 @@ def carregar_aparencia():
|
||||
if isinstance(dados, dict):
|
||||
if dados.get("rosto") in ROSTOS:
|
||||
APARENCIA["rosto"] = dados["rosto"]
|
||||
if dados.get("formato") in FORMATOS:
|
||||
APARENCIA["formato"] = dados["formato"]
|
||||
cor = dados.get("cor")
|
||||
if isinstance(cor, list) and len(cor) == 3:
|
||||
APARENCIA["cor"] = [max(0, min(255, int(v))) for v in cor]
|
||||
@@ -153,6 +159,11 @@ def aplicar_aparencia(dados):
|
||||
if nome in ROSTOS:
|
||||
APARENCIA["rosto"] = nome
|
||||
ordens.append("rosto " + nome)
|
||||
if "formato" in dados:
|
||||
nome = str(dados["formato"]).strip().lower()
|
||||
if nome in FORMATOS:
|
||||
APARENCIA["formato"] = nome
|
||||
ordens.append("formato " + nome)
|
||||
if "cor" in dados:
|
||||
try:
|
||||
APARENCIA["cor"] = validar_cor(dados["cor"])
|
||||
@@ -226,7 +237,7 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
|
||||
def modulos(self):
|
||||
s = self.server
|
||||
return [getattr(s, nome) for nome in ("api_config", "treinamento", "conversa", "cerebro",
|
||||
return [getattr(s, nome) for nome in ("api_config", "historico", "treinamento", "conversa", "cerebro",
|
||||
"braco", "servos", "controle") if hasattr(s, nome)]
|
||||
|
||||
def estado(self):
|
||||
@@ -235,7 +246,7 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
with TRAVA:
|
||||
aparencia = dict(APARENCIA)
|
||||
dados = dict(versao=versao(), seq=EVENTOS.seq, autenticacao=bool(getattr(s, "token", None)),
|
||||
estop=dict(ESTOP), aparencia=aparencia, gestos=GESTOS, rostos=ROSTOS)
|
||||
estop=dict(ESTOP), aparencia=aparencia, gestos=GESTOS, rostos=ROSTOS, formatos=FORMATOS)
|
||||
if hasattr(s, "controle"):
|
||||
dados.update(app=s.controle.resumo(host), app_config=s.controle.config.copy())
|
||||
if hasattr(s, "servos"):
|
||||
@@ -249,6 +260,8 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
dados["treinamento"] = dict(ocupado=s.treinamento.busy, erro=s.treinamento.erro)
|
||||
if hasattr(s, "api_config"):
|
||||
dados["api"] = s.api_config.status()
|
||||
if hasattr(s, "historico"):
|
||||
dados["pir"] = dict(s.historico.pir, presenca=dict(s.historico.presenca))
|
||||
return dados
|
||||
|
||||
def do_GET(self):
|
||||
@@ -360,9 +373,13 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
def comando(self, dados):
|
||||
tipo, valor = dados.get("tipo"), dados.get("valor")
|
||||
s = self.server
|
||||
if tipo == "cor":
|
||||
if not aplicar_aparencia({"cor": valor}):
|
||||
raise ValueError("Cor inválida")
|
||||
if tipo == "rosto":
|
||||
ordem_de_comando("rosto", valor) # valida e dá erro legível
|
||||
aplicar_aparencia({"rosto": valor}) # salva, avisa os painéis e manda ao celular
|
||||
return self.json({"ok": True})
|
||||
if tipo in ("cor", "formato"):
|
||||
if not aplicar_aparencia({tipo: valor}):
|
||||
raise ValueError("Cor inválida" if tipo == "cor" else "Formato inválido")
|
||||
return self.json({"ok": True})
|
||||
if tipo == "recarregar":
|
||||
return self.recarregar()
|
||||
@@ -437,7 +454,7 @@ class Servidor(ThreadingHTTPServer):
|
||||
|
||||
|
||||
def ligar_eventos(servidor):
|
||||
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento"):
|
||||
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico"):
|
||||
if hasattr(servidor, nome):
|
||||
getattr(servidor, nome).publicar = EVENTOS.publicar
|
||||
|
||||
@@ -463,6 +480,31 @@ def main():
|
||||
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
|
||||
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
|
||||
servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar
|
||||
servidor.historico = Historico(Path.cwd())
|
||||
|
||||
def presenca(tipo):
|
||||
# PIR viu alguém: se o rosto estiver dormindo (e talvez com a tela apagada), acorda
|
||||
if tipo == "pir":
|
||||
with servidor.controle.lock:
|
||||
dormindo = bool(servidor.controle.estado.get("dormindo"))
|
||||
if dormindo:
|
||||
enfileirar("acordar")
|
||||
|
||||
def camera(ligada):
|
||||
if servidor.controle.config.get("sensorCamera") != ligada:
|
||||
servidor.controle.publicar("app_config", servidor.controle.atualizar({"sensorCamera": ligada}))
|
||||
|
||||
def alguem_chegou():
|
||||
camera(True) # modo presença: o PIR liga a câmera do celular
|
||||
enfileirar("acordar")
|
||||
|
||||
def ninguem_por_perto():
|
||||
camera(False) # sem ninguém: câmera desligada, dorme e fica em silêncio
|
||||
enfileirar("dormir")
|
||||
|
||||
servidor.historico.ao_detectar = presenca
|
||||
servidor.historico.ao_presenca = alguem_chegou
|
||||
servidor.historico.ao_ausencia = ninguem_por_perto
|
||||
servidor.conversa.treinamento = servidor.treinamento
|
||||
ligar_eventos(servidor)
|
||||
servidor.registrar = args.log
|
||||
|
||||
+11
-3
@@ -1,4 +1,12 @@
|
||||
(function(){
|
||||
// Cores das receitas: o ciano padrão (#5cd6ff) vira a cor escolhida no painel e o azul-claro
|
||||
// (#aaccff) um tom mais claro dela; rosa, amarelo e verde continuam (fazem parte da emoção).
|
||||
function corDaReacao(hex){
|
||||
const h=String(hex||'').toLowerCase(), base=(typeof CIANO!=='undefined'?CIANO:[92,214,255]);
|
||||
if(h==='#5cd6ff')return base.slice();
|
||||
if(h==='#aaccff')return base.map(v=>v+(255-v)*.45);
|
||||
return [1,3,5].map(i=>parseInt(h.slice(i,i+2),16));
|
||||
}
|
||||
const icons={nenhum:'',estrela:'✦',coracao:'♥',musica:'♫',cafe:'☕',ideia:'💡'};
|
||||
const badge=document.createElement('div');badge.id='reacaoSimbolo';badge.style.cssText='position:fixed;left:5%;top:6%;z-index:15;color:#5cd6ff;font:clamp(30px,7vw,64px) sans-serif;pointer-events:none;white-space:pre-wrap';document.body.append(badge);
|
||||
let timer,last=null,fetching=false,animation;let particles=[];
|
||||
@@ -15,13 +23,13 @@
|
||||
function visual(recipe){
|
||||
if(recipe.emoji){
|
||||
const e=recipe.emoji;if(e.local==='olhos'){window.carinhaOlhosIcone=e.caractere;return;}
|
||||
badge.style.cssText='position:fixed;right:5%;top:8%;z-index:15;font:clamp(42px,10vw,90px) sans-serif;pointer-events:none';badge.textContent=e.caractere;
|
||||
badge.style.cssText='position:fixed;right:5%;top:8%;z-index:15;font:clamp(42px,10vw,90px) sans-serif;pointer-events:none;filter:drop-shadow(0 0 14px '+(recipe.passos[0].cor||'#5cd6ff')+')';badge.textContent=e.caractere;
|
||||
animation=badge.animate([{transform:'scale(.92)'},{transform:'scale(1.06)'},{transform:'scale(.92)'}],{duration:1400,iterations:Infinity});return;
|
||||
}
|
||||
if(recipe.figurinha){
|
||||
const svg=window.carinhaConstruirSVG(recipe.figurinha);if(!svg)return;
|
||||
if(recipe.figurinha.local==='olhos'){window.carinhaOlhosDesenho=recipe.figurinha;return;}
|
||||
badge.style.cssText='position:fixed;right:5%;top:8%;width:24%;height:30%;z-index:15;pointer-events:none';svg.style.cssText='width:100%;height:100%';badge.append(svg);
|
||||
badge.style.cssText='position:fixed;right:5%;top:8%;width:24%;height:30%;z-index:15;pointer-events:none;filter:drop-shadow(0 0 14px '+(recipe.passos[0].cor||'#5cd6ff')+')';svg.style.cssText='width:100%;height:100%';badge.append(svg);
|
||||
animation=svg.animate([{transform:'scale(.92) rotate(-3deg)'},{transform:'scale(1.05) rotate(3deg)'},{transform:'scale(.92) rotate(-3deg)'}],{duration:1500,iterations:Infinity});return;
|
||||
}
|
||||
if(['ideia','cafe'].includes(recipe.simbolo)){window.carinhaOlhosIcone=icons[recipe.simbolo];return;}
|
||||
@@ -42,7 +50,7 @@
|
||||
for(const s of recipe.passos)if(!['aberto','feliz','surpreso','coracao','triste'].includes(s.olhos)||!['neutra','sorriso','aberta','triste'].includes(s.boca)||!/^#[0-9a-f]{6}$/i.test(s.cor)||!Number.isFinite(s.duracao)||s.duracao<.15||s.duracao>2||!Number.isFinite(s.dx)||Math.abs(s.dx)>25||!Number.isFinite(s.dy)||Math.abs(s.dy)>12||!Number.isFinite(s.escala)||s.escala<.8||s.escala>1.3)return;
|
||||
clearTimeout(timer);clearVisual();window.eilik.soltar();trata('acordar');MIC.ultimoSom=performance.now()/1000;
|
||||
visual(recipe);
|
||||
r.fila=recipe.passos.map(s=>[{forma:s.olhos,forma2:null,boca:s.boca,cor:[1,3,5].map(i=>parseInt(s.cor.slice(i,i+2),16)),dx:s.dx,dy:s.dy,esc:s.escala,ab:1,ab2:1},s.duracao]);
|
||||
r.fila=recipe.passos.map(s=>[{forma:s.olhos,forma2:null,boca:s.boca,cor:corDaReacao(s.cor),dx:s.dx,dy:s.dy,esc:s.escala,ab:1,ab2:1},s.duracao]);
|
||||
r.fila.push([{forma:'aberto',forma2:null,boca:'neutra',cor:CIANO.slice(),dx:0,dy:0,esc:1,ab:1,ab2:1},.4]);proximo();
|
||||
timer=setTimeout(clearVisual,Math.max(recipe.figurinha?6000:0,['musica','estrela','coracao'].includes(recipe.simbolo)?4500:['ideia','cafe'].includes(recipe.simbolo)?2500:0,recipe.passos.reduce((n,s)=>n+s.duracao,0)*1000+400));
|
||||
}
|
||||
|
||||
+533
@@ -0,0 +1,533 @@
|
||||
/* rosto-motor.js — motor de pálpebras dos rostos novos (Carinha Viva, Bloco, Matriz, Lente).
|
||||
*
|
||||
* O carinha.html continua decidindo O QUE o rosto faz (gestos, reações, sensores, rastreamento);
|
||||
* aqui só se desenha. Cada "forma" vira posição de pálpebra (cima, inclinação, baixo) e o motor
|
||||
* suaviza a passagem entre elas, então feliz → triste não salta de um desenho para outro.
|
||||
* Também acrescenta vida: respiração, sacadas e achatamento na piscada.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// forma → pálpebras. top: pálpebra de cima (0 aberto, 1 fechado); slant: + = canto interno sobe;
|
||||
// bot: pálpebra de baixo sobe ("olho sorrindo").
|
||||
const PALPEBRAS = {
|
||||
aberto: { top: 0, slant: 0, bot: 0 },
|
||||
surpreso: { top: 0, slant: 0, bot: 0 },
|
||||
feliz: { top: 0, slant: 0, bot: 0.46 },
|
||||
coracao: { top: 0, slant: 0, bot: 0.42 },
|
||||
triste: { top: 0.3, slant: 0.32, bot: 0 },
|
||||
bravo: { top: 0.34, slant: -0.42, bot: 0.06 },
|
||||
dormir: { top: 0.9, slant: 0.04, bot: 0.08 },
|
||||
};
|
||||
|
||||
// Medidas em frações do raio vertical do olho clássico (RY), para casar com o layout atual.
|
||||
const ESTILOS = {
|
||||
vivo: { w: 1.0, h: 1.45, r: 0.5, brilho: true },
|
||||
bloco: { w: 1.35, h: 1.25, r: 0.22, brilho: true },
|
||||
matriz: { w: 1.0, h: 1.45, r: 0.5, brilho: false, pontos: true },
|
||||
lente: { w: 1.15, h: 1.15, r: 0.58, lente: true },
|
||||
};
|
||||
|
||||
// Formato do olho, escolhido no painel e combinável com Viva, Bloco e Matriz.
|
||||
const FORMATOS = {
|
||||
padrao: null,
|
||||
redondo: { w: 1.15, h: 1.15, r: 0.58 },
|
||||
pilula: { w: 0.8, h: 1.55, r: 0.4 },
|
||||
quadrado: { w: 1.3, h: 1.3, r: 0.12 },
|
||||
largo: { w: 1.65, h: 0.9, r: 0.3 },
|
||||
};
|
||||
|
||||
let tela = null, ctx = null, camada = null, cctx = null, baixa = null, bctx = null;
|
||||
const estado = [novo(), novo()];
|
||||
const sacada = { x: 0, y: 0, alvoX: 0, alvoY: 0, proxima: 0 };
|
||||
let antes = 0;
|
||||
|
||||
function novo() { return { top: 0, slant: 0, bot: 0, coracao: 0 }; }
|
||||
const css = (c, a) => 'rgba(' + c.map((v) => Math.round(v)).join(',') + ',' + (a === undefined ? 1 : a) + ')';
|
||||
|
||||
function preparar() {
|
||||
if (tela) return;
|
||||
tela = document.createElement('canvas');
|
||||
tela.id = 'rostoMotor';
|
||||
tela.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:2;display:none';
|
||||
document.body.append(tela);
|
||||
ctx = tela.getContext('2d');
|
||||
camada = document.createElement('canvas'); cctx = camada.getContext('2d');
|
||||
baixa = document.createElement('canvas'); bctx = baixa.getContext('2d', { willReadFrequently: true });
|
||||
}
|
||||
|
||||
function medir() {
|
||||
// Até 1,5x a densidade: a tela do S10 é enorme e o brilho custa caro.
|
||||
const d = Math.min(1.5, window.devicePixelRatio || 1);
|
||||
const w = Math.round(innerWidth * d), h = Math.round(innerHeight * d);
|
||||
if (tela.width !== w || tela.height !== h) { tela.width = camada.width = w; tela.height = camada.height = h; }
|
||||
return d;
|
||||
}
|
||||
|
||||
function caixa(c, x, y, w, h, r) {
|
||||
r = Math.max(0, Math.min(r, w / 2, h / 2));
|
||||
c.beginPath();
|
||||
if (c.roundRect) c.roundRect(x, y, w, h, r);
|
||||
else { // WebViews antigos
|
||||
c.moveTo(x + r, y); c.arcTo(x + w, y, x + w, y + h, r); c.arcTo(x + w, y + h, x, y + h, r);
|
||||
c.arcTo(x, y + h, x, y, r); c.arcTo(x, y, x + w, y, r); c.closePath();
|
||||
}
|
||||
}
|
||||
|
||||
function coracao(c, cx, cy, s) {
|
||||
c.beginPath();
|
||||
c.moveTo(cx, cy + s * 0.72);
|
||||
c.bezierCurveTo(cx - s * 1.3, cy - s * 0.3, cx - s * 0.8, cy - s * 1.18, cx, cy - s * 0.3);
|
||||
c.bezierCurveTo(cx + s * 0.8, cy - s * 1.18, cx + s * 1.3, cy - s * 0.3, cx, cy + s * 0.72);
|
||||
c.fill();
|
||||
}
|
||||
|
||||
const FONTE_EMOJI = '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif';
|
||||
|
||||
/** Figurinha (viewBox 100x100, só M L C Q Z) ou emoji, centrado no olho. */
|
||||
function conteudo(c, o, cx, cy, tam) {
|
||||
if (o.desenho && Array.isArray(o.desenho.formas)) {
|
||||
c.save();
|
||||
c.translate(cx - tam / 2, cy - tam / 2);
|
||||
c.scale(tam / 100, tam / 100);
|
||||
for (const f of o.desenho.formas) {
|
||||
if (typeof f.caminho !== 'string' || !/^[MLCQZmlcqz0-9.,\s+-]+$/.test(f.caminho) || !/^#[0-9a-f]{6}$/i.test(f.cor)) continue;
|
||||
c.fillStyle = f.cor;
|
||||
c.fill(new Path2D(f.caminho));
|
||||
}
|
||||
c.restore();
|
||||
} else if (o.icone) {
|
||||
c.save();
|
||||
c.font = Math.round(tam * 0.8) + 'px ' + FONTE_EMOJI;
|
||||
c.textAlign = 'center';
|
||||
c.textBaseline = 'middle';
|
||||
c.fillText(o.icone, cx, cy + tam * 0.04);
|
||||
c.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// Olho plano (vivo, bloco, matriz): corpo + pálpebras recortadas (transparentes).
|
||||
function olhoPlano(c, o, p, est, e) {
|
||||
const respira = 1 + 0.012 * Math.sin(o.t * 1.4);
|
||||
const temConteudo = !!(o.icone || o.desenho);
|
||||
if (temConteudo) e = { top: 0, slant: 0, bot: 0, coracao: 0 };
|
||||
const fecha = Math.min(1, Math.max(e.top, 1 - Math.min(1, p.ab)));
|
||||
const abre = Math.max(1, p.ab); // surpreso abre além de 1
|
||||
const amassa = fecha > 0.5 && e.top < 0.5 ? (fecha - 0.5) * 0.2 : 0; // piscada achata e alarga
|
||||
const w = est.w * p.ry * respira * (1 + amassa);
|
||||
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
|
||||
const x = p.cx - w / 2, y = p.cy - h / 2;
|
||||
c.fillStyle = css(o.cor);
|
||||
if (o.dormindo && !temConteudo) { // dormindo: "–" (olho fechado)
|
||||
const esp = Math.max(2, p.ry * 0.2);
|
||||
caixa(c, p.cx - w * 0.5, p.cy + h * 0.08 - esp / 2, w, esp, esp / 2); c.fill();
|
||||
return;
|
||||
}
|
||||
if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; }
|
||||
caixa(c, x, y, w, h, est.r * p.ry * respira);
|
||||
c.fill();
|
||||
c.save();
|
||||
caixa(c, x - 2, y - 2, w + 4, h + 4, est.r * p.ry);
|
||||
c.clip();
|
||||
c.globalCompositeOperation = 'destination-out';
|
||||
c.fillStyle = '#000';
|
||||
const sl = p.lado > 0 ? e.slant : -e.slant; // lado +1 = olho esquerdo; canto interno = lado do nariz
|
||||
const yEsq = y + h * (fecha + sl * 0.5), yDir = y + h * (fecha - sl * 0.5);
|
||||
c.beginPath(); c.moveTo(x - 4, y - 4); c.lineTo(x + w + 4, y - 4); c.lineTo(x + w + 4, yDir); c.lineTo(x - 4, yEsq); c.closePath(); c.fill();
|
||||
c.beginPath(); c.ellipse(p.cx, y + h * (1.62 - e.bot), w * 0.75, h * 0.62, 0, 0, Math.PI * 2); c.fill();
|
||||
c.globalCompositeOperation = 'source-over';
|
||||
if (temConteudo && fecha < 0.6) conteudo(c, o, p.cx, p.cy, Math.min(w, h) * 0.74);
|
||||
else if (o.iris && fecha < 0.6 && e.bot < 0.35) {
|
||||
const R = Math.min(w, h) * 0.3;
|
||||
const ix = Math.max(x + R * 1.1, Math.min(x + w - R * 1.1, p.cx + (p.lx + sacada.x) * w * 0.016));
|
||||
const iy = Math.max(y + R * 1.1, Math.min(y + h - R * 1.1, p.cy + h * 0.04 + (p.ly + sacada.y) * h * 0.016));
|
||||
c.fillStyle = css(o.cor.map((v) => v * 0.42));
|
||||
c.beginPath(); c.arc(ix, iy, R, 0, Math.PI * 2); c.fill();
|
||||
c.globalCompositeOperation = 'destination-out';
|
||||
const pupila = p.ab > 1.05 ? 0.38 : e.bot > 0.2 ? 0.62 : 0.5; // susto contrai, alegria dilata
|
||||
c.beginPath(); c.arc(ix, iy, R * pupila, 0, Math.PI * 2); c.fill();
|
||||
c.globalCompositeOperation = 'source-over';
|
||||
if (est.brilho) {
|
||||
c.fillStyle = 'rgba(245,250,255,0.9)';
|
||||
c.beginPath(); c.arc(ix - R * 0.42, iy - R * 0.42, R * 0.2, 0, Math.PI * 2); c.fill();
|
||||
}
|
||||
} else if (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar
|
||||
const hx = Math.max(x + w * 0.18, Math.min(x + w * 0.62, x + w * 0.3 + (p.lx + sacada.x) * w * 0.012));
|
||||
const hy = Math.max(y + h * 0.18, Math.min(y + h * 0.5, y + h * 0.26 + (p.ly + sacada.y) * h * 0.012));
|
||||
c.fillStyle = 'rgba(245,250,255,0.9)';
|
||||
c.beginPath(); c.ellipse(hx, hy, w * 0.1, h * 0.08, 0, 0, Math.PI * 2); c.fill();
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
|
||||
// Olho lente: esclera, íris, pupila que dilata e pálpebras de metal.
|
||||
function olhoLente(c, o, p, est, e) {
|
||||
const R = est.w * p.ry * 0.5 * Math.max(1, p.ab) * (1 + 0.01 * Math.sin(o.t * 1.4));
|
||||
const fecha = Math.min(1, Math.max(o.icone || o.desenho ? 0 : e.top, 1 - Math.min(1, p.ab)));
|
||||
c.save();
|
||||
c.beginPath(); c.arc(p.cx, p.cy, R * 1.12, 0, Math.PI * 2); c.fillStyle = '#1b2029'; c.fill();
|
||||
c.beginPath(); c.arc(p.cx, p.cy, R, 0, Math.PI * 2); c.clip();
|
||||
c.fillStyle = '#e9eef3'; c.fillRect(p.cx - R, p.cy - R, 2 * R, 2 * R);
|
||||
if (o.icone || o.desenho) {
|
||||
conteudo(c, o, p.cx, p.cy, R * 1.3);
|
||||
e = { top: 0, slant: 0, bot: 0 };
|
||||
} else {
|
||||
const ix = p.cx + Math.max(-R * 0.4, Math.min(R * 0.4, (p.lx + sacada.x) * R * 0.02));
|
||||
const iy = p.cy + Math.max(-R * 0.35, Math.min(R * 0.35, (p.ly + sacada.y) * R * 0.02));
|
||||
const g = c.createRadialGradient(ix, iy, R * 0.05, ix, iy, R * 0.55);
|
||||
g.addColorStop(0, css(o.cor.map((v) => v * 0.5))); g.addColorStop(1, css(o.cor));
|
||||
c.beginPath(); c.arc(ix, iy, R * 0.55, 0, Math.PI * 2); c.fillStyle = g; c.fill();
|
||||
const pupila = p.ab > 1.05 ? 0.12 : e.bot > 0.3 ? 0.3 : 0.2; // susto contrai, alegria dilata
|
||||
c.beginPath(); c.arc(ix, iy, R * pupila, 0, Math.PI * 2); c.fillStyle = '#05070a'; c.fill();
|
||||
c.beginPath(); c.arc(ix - R * 0.2, iy - R * 0.22, R * 0.09, 0, Math.PI * 2); c.fillStyle = 'rgba(255,255,255,0.9)'; c.fill();
|
||||
}
|
||||
const metal = c.createLinearGradient(0, p.cy - R, 0, p.cy + R);
|
||||
metal.addColorStop(0, '#5b6675'); metal.addColorStop(1, '#2b323d');
|
||||
c.fillStyle = metal;
|
||||
const sl = p.lado > 0 ? e.slant : -e.slant;
|
||||
const yEsq = p.cy - R + 2 * R * (fecha + sl * 0.5), yDir = p.cy - R + 2 * R * (fecha - sl * 0.5);
|
||||
c.beginPath(); c.moveTo(p.cx - R, p.cy - R); c.lineTo(p.cx + R, p.cy - R); c.lineTo(p.cx + R, yDir); c.lineTo(p.cx - R, yEsq); c.closePath(); c.fill();
|
||||
c.strokeStyle = '#8994a3'; c.lineWidth = Math.max(1, R * 0.03);
|
||||
c.beginPath(); c.moveTo(p.cx - R, yEsq); c.lineTo(p.cx + R, yDir); c.stroke();
|
||||
c.beginPath(); c.ellipse(p.cx, p.cy + R * (2.3 - 2 * e.bot), R * 1.3, R * 1.3, 0, 0, Math.PI * 2); c.fill();
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function atualizarPalpebras(dt, olhos, lento) {
|
||||
const k = 1 - Math.exp(-dt * (lento ? 4 : 11));
|
||||
olhos.forEach((p, i) => {
|
||||
const alvo = PALPEBRAS[p.forma] || PALPEBRAS.aberto, e = estado[i];
|
||||
e.top += (alvo.top - e.top) * k;
|
||||
e.slant += (alvo.slant - e.slant) * k;
|
||||
e.bot += (alvo.bot - e.bot) * k;
|
||||
e.coracao += ((p.forma === 'coracao' ? 1 : 0) - e.coracao) * Math.min(1, k * 2);
|
||||
});
|
||||
}
|
||||
|
||||
function atualizarSacada(t, dt, parado) {
|
||||
// Pequenos saltos do olhar quando nada está acontecendo; olhos de verdade nunca ficam parados.
|
||||
if (parado && t > sacada.proxima) {
|
||||
sacada.alvoX = (Math.random() - 0.5) * 14;
|
||||
sacada.alvoY = (Math.random() - 0.5) * 6;
|
||||
sacada.proxima = t + 0.8 + Math.random() * 2.4;
|
||||
} else if (!parado) { sacada.alvoX = sacada.alvoY = 0; }
|
||||
const k = 1 - Math.exp(-dt * 22);
|
||||
sacada.x += (sacada.alvoX - sacada.x) * k;
|
||||
sacada.y += (sacada.alvoY - sacada.y) * k;
|
||||
}
|
||||
|
||||
function desenharPontos(estilo, o, olhos, d) {
|
||||
// Matriz: desenha os olhos numa tela pequena e acende um LED por pixel.
|
||||
const linhas = 38, colunas = Math.round(linhas * tela.width / tela.height);
|
||||
if (baixa.width !== colunas || baixa.height !== linhas) { baixa.width = colunas; baixa.height = linhas; }
|
||||
const f = linhas / tela.height * d;
|
||||
bctx.clearRect(0, 0, colunas, linhas);
|
||||
bctx.save(); bctx.scale(f, f);
|
||||
olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i]));
|
||||
for (const traco of o.tracos || []) {
|
||||
if (typeof traco.d !== 'string') continue;
|
||||
const caminho = new Path2D(traco.d);
|
||||
bctx.strokeStyle = bctx.fillStyle = css(o.cor);
|
||||
bctx.lineWidth = traco.largura; bctx.lineCap = 'round'; bctx.lineJoin = 'round';
|
||||
if (traco.cheio) bctx.fill(caminho); else bctx.stroke(caminho);
|
||||
}
|
||||
desenharMaos(bctx, o, olhos, tela.height / d);
|
||||
desenharZzz(bctx, o, olhos, 1.6); // em LEDs o Z precisa ser maior para ler
|
||||
bctx.restore();
|
||||
const px = bctx.getImageData(0, 0, colunas, linhas).data;
|
||||
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height);
|
||||
const passo = tela.width / colunas, raio = passo * 0.36;
|
||||
for (let j = 0; j < linhas; j++) for (let i = 0; i < colunas; i++) {
|
||||
const k = (j * colunas + i) * 4, a = px[k + 3] / 255;
|
||||
ctx.beginPath(); ctx.arc((i + 0.5) * passo, (j + 0.5) * passo, raio, 0, Math.PI * 2);
|
||||
ctx.fillStyle = a > 0.25 ? css([px[k], px[k + 1], px[k + 2]], 0.35 + a * 0.65) : 'rgba(255,255,255,0.045)';
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Desenha um quadro. o = { estilo, formato, t, cor:[r,g,b], dormindo, parado,
|
||||
* olhos: [{ cx, cy, ry, ab, forma, lado, lx, ly }, ...] } em pixels CSS.
|
||||
*/
|
||||
function desenhar(o) {
|
||||
preparar();
|
||||
const base = ESTILOS[o.estilo];
|
||||
if (!base) return esconder();
|
||||
const formato = !base.lente && FORMATOS[o.formato];
|
||||
const estilo = formato ? Object.assign({}, base, formato) : base;
|
||||
tela.style.display = '';
|
||||
const d = medir();
|
||||
const dt = antes ? Math.min(0.1, o.t - antes) : 0.033;
|
||||
antes = o.t;
|
||||
atualizarPalpebras(dt, o.olhos, o.dormindo);
|
||||
atualizarSacada(o.t, dt, o.parado);
|
||||
const olhos = o.olhos.map((p) => Object.assign({}, p, {
|
||||
cx: (p.cx + (o.parado ? sacada.x * 0.35 : 0)) * d,
|
||||
cy: (p.cy + (o.parado ? sacada.y * 0.35 : 0)) * d,
|
||||
ry: p.ry * d,
|
||||
}));
|
||||
ctx.clearRect(0, 0, tela.width, tela.height);
|
||||
if (estilo.pontos) {
|
||||
desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d);
|
||||
} else if (estilo.lente) {
|
||||
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
|
||||
desenharMaos(ctx, o, olhos, tela.height);
|
||||
desenharZzz(ctx, o, olhos);
|
||||
} else {
|
||||
cctx.clearRect(0, 0, camada.width, camada.height);
|
||||
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
|
||||
desenharMaos(cctx, o, olhos, tela.height);
|
||||
desenharZzz(cctx, o, olhos);
|
||||
ctx.save();
|
||||
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
|
||||
ctx.drawImage(camada, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
desenharFaixa(ctx, o, tela.width, tela.height);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ mãos
|
||||
// Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências
|
||||
// de rosto de mesmo nome no carinha.html.
|
||||
const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8 };
|
||||
const maos = { modo: null, t0: null };
|
||||
const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); };
|
||||
const faixaDe = (k, a, b) => suave((k - a) / (b - a)); // 0→1 entre a e b
|
||||
|
||||
// Mão de desenho animado: palma, quatro dedos (o do meio maior), polegar para o lado do rosto
|
||||
// e um punho de robô. dobra (0–1) fecha os dedos; mexe (0–1) agita os dedos ao acenar.
|
||||
// O contorno é traçado antes do preenchimento: assim só a borda externa aparece.
|
||||
function capsula(c, x0, y0, x1, y1, r) {
|
||||
const a = Math.atan2(y1 - y0, x1 - x0) + Math.PI / 2;
|
||||
c.moveTo(x0 + Math.cos(a) * r, y0 + Math.sin(a) * r);
|
||||
c.arc(x0, y0, r, a, a + Math.PI);
|
||||
c.arc(x1, y1, r, a + Math.PI, a + 2 * Math.PI);
|
||||
c.closePath();
|
||||
}
|
||||
|
||||
function formaMao(c, s, dobra, mexe, t, indicador) {
|
||||
c.beginPath();
|
||||
// punho (manga do robô)
|
||||
caixa(c, -0.42 * s, 0.5 * s, 0.84 * s, 0.38 * s, 0.12 * s);
|
||||
// palma
|
||||
caixa(c, -0.5 * s, -0.36 * s, 1.0 * s, 0.95 * s, 0.34 * s);
|
||||
// dedos: x da base, comprimento e abertura
|
||||
// mindinho, anelar, médio e indicador (o indicador fica do lado do polegar)
|
||||
const dedos = [[-0.355, 0.5, -0.16], [-0.12, 0.68, -0.05], [0.12, 0.74, 0.05], [0.355, 0.66, 0.14]];
|
||||
dedos.forEach(([x, comp, abre], i) => {
|
||||
const onda = mexe ? Math.sin(t * 14 + i * 0.9) * 0.06 * mexe : 0;
|
||||
const fecha = indicador && i === 3 ? 0 : dobra; // "pensando": só o indicador esticado
|
||||
const ang = abre + onda, l = comp * (1 - 0.7 * fecha) * s;
|
||||
const bx = x * s, by = -0.26 * s;
|
||||
capsula(c, bx, by, bx + Math.sin(ang) * l, by - Math.cos(ang) * l, 0.115 * s);
|
||||
});
|
||||
// polegar, saindo para o lado
|
||||
capsula(c, 0.38 * s, 0.2 * s, (0.38 + 0.42 * (1 - 0.4 * dobra)) * s, (0.2 - 0.34) * s, 0.13 * s);
|
||||
}
|
||||
|
||||
function mao(c, o, x, y, s, ang, espelho, dobra, mexe, indicador, pulso, costas) {
|
||||
dobra = dobra || 0; mexe = mexe || 0;
|
||||
s *= 0.82;
|
||||
const escura = o.cor.map((v) => v * 0.34);
|
||||
c.save();
|
||||
c.translate(x, y); c.rotate(ang); c.scale(espelho, 1);
|
||||
if (pulso) c.translate(0, -0.7 * s); // (x, y) é o punho: gira como uma mão de verdade
|
||||
c.lineJoin = 'round'; c.lineCap = 'round';
|
||||
formaMao(c, s, dobra, mexe, o.t || 0, indicador);
|
||||
c.strokeStyle = css(o.cor); c.lineWidth = s * 0.17; c.stroke(); // contorno (metade some sob o preenchimento)
|
||||
c.fillStyle = css(escura); c.fill();
|
||||
// detalhes por dentro, finos: linha da palma, vincos dos dedos e a costura do punho
|
||||
c.strokeStyle = css(o.cor, 0.55); c.lineWidth = s * 0.045;
|
||||
if (costas) { // dorso da mão: nós dos dedos em vez da linha da palma
|
||||
for (const nx of [-0.355, -0.12, 0.12, 0.355]) { c.beginPath(); c.arc(nx * s, -0.2 * s, 0.075 * s, Math.PI * 1.15, Math.PI * 1.85); c.stroke(); }
|
||||
} else {
|
||||
c.beginPath(); c.moveTo(-0.3 * s, 0.12 * s); c.quadraticCurveTo(0.02 * s, 0.26 * s, 0.3 * s, 0.06 * s); c.stroke();
|
||||
}
|
||||
c.beginPath(); c.moveTo(-0.36 * s, 0.6 * s); c.lineTo(0.36 * s, 0.6 * s); c.stroke();
|
||||
c.restore();
|
||||
}
|
||||
|
||||
// Sobe com um leve "passa e volta" no fim (como um braço que freia).
|
||||
const voltinha = (x) => { x = Math.max(0, Math.min(1, x)); const k = 1.6; return 1 + (k + 1) * Math.pow(x - 1, 3) + k * Math.pow(x - 1, 2); };
|
||||
|
||||
function desenharMaos(c, o, olhos, H) {
|
||||
if (!maos.modo) return;
|
||||
if (maos.t0 === null) maos.t0 = o.t;
|
||||
const k = (o.t - maos.t0) / DUR_MAOS[maos.modo];
|
||||
if (k >= 1) { maos.modo = null; return; }
|
||||
const [e, d] = olhos.slice().sort((a, b) => a.cx - b.cx); // olho da esquerda e da direita na tela
|
||||
const s = e.ry * 0.95, fora = H + s * 1.8;
|
||||
if (maos.modo === 'esconder' || maos.modo === 'espiar') {
|
||||
// Cada mão vem do canto de baixo, por fora, num arco, girando até ficar inclinada para dentro.
|
||||
// A da direita chega um pouco depois: duas mãos nunca se mexem igualzinho.
|
||||
const cobrir = (olho, lado, atraso) => {
|
||||
const ida = voltinha((k - atraso) / 0.24), volta = faixaDe(k, 0.8 + atraso * 0.5, 1);
|
||||
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta); // 0 = fora da tela, 1 = cobrindo
|
||||
const alvoX = olho.cx - lado * olho.ry * 0.05, alvoY = olho.cy + olho.ry * 0.38;
|
||||
const x = alvoX - lado * olho.ry * 1.6 * (1 - Math.min(1, v));
|
||||
const y = fora + (alvoY - fora) * v;
|
||||
const respira = Math.sin(o.t * 3 + lado) * s * 0.03 * Math.min(1, v);
|
||||
const ang = lado * (0.42 + 0.55 * (1 - Math.min(1, v))); // gira enquanto sobe, termina a ~24°
|
||||
return { x, y: y + respira, ang };
|
||||
};
|
||||
const pe = cobrir(e, 1, 0), pd = cobrir(d, -1, 0.05);
|
||||
if (maos.modo === 'espiar') {
|
||||
// a mão da esquerda gira pelo punho para fora, como uma porta, e o olho aparece
|
||||
const abre = faixaDe(k, 0.3, 0.44) - faixaDe(k, 0.64, 0.76);
|
||||
pe.ang -= abre * 1.05; pe.x -= abre * e.ry * 0.35; pe.y += abre * e.ry * 0.2;
|
||||
}
|
||||
const treme = Math.sin(o.t * 22) * s * 0.012; // tímido: tremidinha
|
||||
// dorso virado para quem olha: polegar para fora, perto da bochecha
|
||||
mao(c, o, pe.x + treme, pe.y, s * 1.1, pe.ang, -1, 0, 0, false, true, true);
|
||||
mao(c, o, pd.x - treme, pd.y, s * 1.1, pd.ang, 1, 0, 0, false, true, true);
|
||||
} else if (maos.modo === 'acenar') {
|
||||
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
|
||||
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
|
||||
const x = d.cx + d.ry * 2.2 + d.ry * 0.6 * (1 - Math.min(1, v)), y0 = d.cy + d.ry * 1.55;
|
||||
const y = fora + (y0 - fora) * v;
|
||||
const balanco = k > 0.22 && k < 0.82 ? Math.sin((k - 0.22) * Math.PI * 7) * 0.32 : 0;
|
||||
mao(c, o, x, y, s * 0.95, -0.18 + balanco, -1, 0, 1, false, true);
|
||||
} else if (maos.modo === 'pensar') {
|
||||
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
|
||||
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
|
||||
const x = (e.cx + d.cx) / 2 + e.ry * 1.05, y0 = e.cy + e.ry * 1.4;
|
||||
const y = fora + (y0 - fora) * v + Math.sin(o.t * 9) * s * 0.03;
|
||||
mao(c, o, x, y, s * 0.85, -0.5, -1, 0.95, 0, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Zzz
|
||||
function desenharZzz(c, o, olhos, fator) {
|
||||
fator = fator || 1;
|
||||
if (!o.dormindo) return;
|
||||
const d = olhos.slice().sort((a, b) => a.cx - b.cx)[1]; // olho da direita na tela
|
||||
const x0 = d.cx + d.ry * 0.7, y0 = d.cy - d.ry * 0.55;
|
||||
c.save();
|
||||
c.strokeStyle = css(o.cor); c.lineCap = 'round'; c.lineJoin = 'round';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const k = ((o.t || 0) * 0.32 + i / 3) % 1; // cada Z num ponto do ciclo
|
||||
const tam = d.ry * (0.28 + 0.3 * k) * fator;
|
||||
const x = x0 + d.ry * 0.7 * k + Math.sin(k * Math.PI * 2) * d.ry * 0.08, y = y0 - d.ry * 1.7 * k;
|
||||
c.globalAlpha = Math.min(1, k / 0.15) * (1 - k);
|
||||
c.lineWidth = Math.max(1.5, tam * 0.2);
|
||||
c.beginPath();
|
||||
c.moveTo(x - tam / 2, y - tam / 2); c.lineTo(x + tam / 2, y - tam / 2);
|
||||
c.lineTo(x - tam / 2, y + tam / 2); c.lineTo(x + tam / 2, y + tam / 2);
|
||||
c.stroke();
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- faixa de baixo
|
||||
// A parte de baixo da tela vira palco: legenda da conversa, pensando, equalizador e carga.
|
||||
const faixa = { modo: null, texto: '', t0: null, dur: 0 };
|
||||
|
||||
function definirFaixa(modo, opcoes) {
|
||||
opcoes = opcoes || {};
|
||||
if (!modo) {
|
||||
if (!opcoes.so || faixa.modo === opcoes.so) faixa.modo = null; // so: só apaga esse modo
|
||||
return;
|
||||
}
|
||||
faixa.modo = modo; faixa.t0 = performance.now() / 1000; faixa.texto = String(opcoes.texto || '').replace(/\s+/g, ' ').trim().slice(0, 400);
|
||||
faixa.dur = opcoes.segundos || (modo === 'legenda' ? Math.max(3, Math.min(14, faixa.texto.length / 14 + 2.5)) : modo === 'digitando' ? 20 : 2.6);
|
||||
}
|
||||
|
||||
function desenharFaixa(c, o, W, H) {
|
||||
const y = H * 0.955, fonte = Math.round(H * 0.042);
|
||||
c.save();
|
||||
c.shadowColor = css(o.cor, 0.6); c.shadowBlur = H * 0.02;
|
||||
if (o.carga && o.carga.carregando && typeof o.carga.nivel === 'number') { // carregando: canto direito
|
||||
const n = Math.max(0, Math.min(100, o.carga.nivel)), bw = H * 0.075, bh = H * 0.036, bx = W - H * 0.13, by = y - bh / 2;
|
||||
c.globalAlpha = 0.55 + 0.25 * Math.sin(o.t * 2.5);
|
||||
c.strokeStyle = css(o.cor); c.lineWidth = Math.max(1, H * 0.005);
|
||||
caixa(c, bx, by, bw, bh, bh * 0.25); c.stroke();
|
||||
c.fillStyle = css(o.cor); c.fillRect(bx + bw, y - bh * 0.2, bh * 0.15, bh * 0.4);
|
||||
c.fillRect(bx + bh * 0.12, by + bh * 0.15, (bw - bh * 0.24) * n / 100, bh * 0.7);
|
||||
c.font = '600 ' + Math.round(fonte * 0.8) + 'px system-ui,sans-serif'; c.textAlign = 'right'; c.textBaseline = 'middle';
|
||||
const tx = bx - H * 0.012;
|
||||
c.fillText(Math.round(n) + '%', tx, y);
|
||||
const rx = tx - c.measureText(Math.round(n) + '%').width - H * 0.022, r = H * 0.016; // raio desenhado na cor dos olhos
|
||||
c.beginPath(); c.moveTo(rx + r * 0.3, y - r); c.lineTo(rx - r * 0.5, y + r * 0.15); c.lineTo(rx, y + r * 0.15);
|
||||
c.lineTo(rx - r * 0.3, y + r); c.lineTo(rx + r * 0.5, y - r * 0.15); c.lineTo(rx, y - r * 0.15); c.closePath(); c.fill();
|
||||
c.globalAlpha = 1;
|
||||
}
|
||||
if (faixa.modo) {
|
||||
if (faixa.t0 === null) faixa.t0 = o.t;
|
||||
const k = (o.t - faixa.t0) / faixa.dur;
|
||||
if (k >= 1) faixa.modo = null;
|
||||
else {
|
||||
const some = Math.min(1, (faixa.dur - (o.t - faixa.t0)) / 0.4);
|
||||
c.globalAlpha = Math.max(0, some);
|
||||
c.fillStyle = css(o.cor);
|
||||
if (faixa.modo === 'legenda') {
|
||||
// efeito de digitação; se não couber, mostra o final do que já foi "dito"
|
||||
let txt = faixa.texto.slice(0, Math.floor((o.t - faixa.t0) * 30));
|
||||
c.font = '500 ' + fonte + 'px system-ui,sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle';
|
||||
const max = W * 0.8;
|
||||
while (txt.length > 1 && c.measureText(txt).width > max) txt = txt.slice(1);
|
||||
c.fillText(txt, W / 2, y);
|
||||
} else if (faixa.modo === 'digitando') {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const pulo = Math.max(0, Math.sin(o.t * 7 - i * 0.7)) * H * 0.012;
|
||||
c.beginPath(); c.arc(W / 2 + (i - 1) * H * 0.035, y - pulo, H * 0.011, 0, Math.PI * 2); c.fill();
|
||||
}
|
||||
} else if (faixa.modo === 'equalizador') {
|
||||
const n = 15, larg = H * 0.016, passo = H * 0.026;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = 0.25 + 0.75 * Math.abs(Math.sin(o.t * (5 + i % 4) + i * 1.3)) * Math.sin(Math.PI * (i + 0.5) / n);
|
||||
const h = H * 0.04 * a;
|
||||
caixa(c, W / 2 + (i - (n - 1) / 2) * passo - larg / 2, H * 0.988 - h, larg, h, larg / 2); c.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrato parado num canvas qualquer (miniaturas da biblioteca no painel).
|
||||
* o = { estilo, formato, cor, forma, icone, desenho }.
|
||||
*/
|
||||
function retrato(alvo, o) {
|
||||
const base = ESTILOS[o.estilo] || ESTILOS.vivo;
|
||||
const formato = !base.lente && FORMATOS[o.formato];
|
||||
const est = formato ? Object.assign({}, base, formato) : base;
|
||||
const W = alvo.width, H = alvo.height, c = alvo.getContext('2d');
|
||||
const pal = Object.assign({ coracao: o.forma === 'coracao' ? 1 : 0 }, PALPEBRAS[o.forma] || PALPEBRAS.aberto);
|
||||
const op = { t: 0, cor: o.cor || [92, 214, 255], icone: o.icone || null, desenho: o.desenho || null };
|
||||
const ry = H * 0.36, sep = Math.min(W * 0.23, H * 0.62);
|
||||
const olhos = [-1, 1].map((s) => ({ cx: W / 2 + s * sep, cy: H / 2, ry, ab: o.forma === 'surpreso' ? 1.12 : 1, forma: o.forma, lado: -s, lx: 0, ly: 0 }));
|
||||
c.clearRect(0, 0, W, H);
|
||||
if (est.lente) { olhos.forEach((p) => olhoLente(c, op, p, est, pal)); return; }
|
||||
const tmp = document.createElement('canvas'); tmp.width = W; tmp.height = H;
|
||||
const t = tmp.getContext('2d');
|
||||
olhos.forEach((p) => olhoPlano(t, op, p, est, Object.assign({}, pal)));
|
||||
if (est.pontos) {
|
||||
const passo = Math.max(3, Math.round(H / 16)), d = t.getImageData(0, 0, W, H).data;
|
||||
for (let y = passo / 2; y < H; y += passo) for (let x = passo / 2; x < W; x += passo) {
|
||||
const k = (Math.floor(y) * W + Math.floor(x)) * 4, a = d[k + 3] / 255;
|
||||
c.beginPath(); c.arc(x, y, passo * 0.38, 0, Math.PI * 2);
|
||||
c.fillStyle = a > 0.25 ? css([d[k], d[k + 1], d[k + 2]], 0.4 + a * 0.6) : 'rgba(255,255,255,0.05)';
|
||||
c.fill();
|
||||
}
|
||||
return;
|
||||
}
|
||||
c.save();
|
||||
if (est.brilho) { c.shadowColor = css(op.cor, 0.7); c.shadowBlur = H * 0.08; }
|
||||
c.drawImage(tmp, 0, 0);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
|
||||
|
||||
window.MotorRosto = {
|
||||
desenhar, retrato, esconder,
|
||||
/** Metade da altura do olho (em frações de RY) para o estilo/formato: posiciona as sobrancelhas. */
|
||||
metadeAltura: (estilo, formato) => {
|
||||
const base = ESTILOS[estilo]; if (!base) return 1;
|
||||
if (base.lente) return base.w * 0.5 * 1.12;
|
||||
return ((!base.lente && FORMATOS[formato]) || base).h / 2;
|
||||
},
|
||||
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
|
||||
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) };
|
||||
})();
|
||||
@@ -86,6 +86,12 @@ class ControleTest(unittest.TestCase):
|
||||
self.assertTrue(json.loads(body)['online'])
|
||||
self.assertEqual(json.loads(body)['estado']['rosto'], 'miudo')
|
||||
|
||||
def test_phone_reports_its_own_ip(self):
|
||||
self.request('/app/status', {'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F'}})
|
||||
self.assertEqual(json.loads(self.request('/app/status')[1])['celular_ip'], '192.168.1.16')
|
||||
self.request('/app/status', {'aparelho': {'ip': 'não é ip'}})
|
||||
self.assertEqual(json.loads(self.request('/app/status')[1])['celular_ip'], '127.0.0.1')
|
||||
|
||||
def test_legacy_camera_and_adb_routes_are_gone(self):
|
||||
self.request('/app/config', {'preview': True})
|
||||
self.assertEqual(self.request('/camera/frame', b'\xff\xd8x\xff\xd9', 'image/jpeg')[0], 404)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Histórico de detecções: sensores liga/desliga, intervalo, pedido de foto e rotas HTTP."""
|
||||
import http.client
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from historico import Historico, MAX_FOTOS
|
||||
|
||||
spec = importlib.util.spec_from_file_location('kiosk_hist', ROOT / 'kiosk-server.py')
|
||||
kiosk = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(kiosk)
|
||||
|
||||
JPEG = b'\xff\xd8' + b'foto' * 10 + b'\xff\xd9'
|
||||
|
||||
|
||||
class HistoricoTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.h = Historico(self.tmp.name, iniciar_pir=False)
|
||||
self.publicados = []
|
||||
self.h.publicar = lambda t, d: self.publicados.append((t, d))
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_intervalo_e_sensor_desligado(self):
|
||||
self.assertIsNotNone(self.h.registrar('movimento', 'surpreso', 0.4, agora=100))
|
||||
self.assertIsNone(self.h.registrar('movimento', 'surpreso', 0.4, agora=105)) # dentro dos 15 s
|
||||
self.assertIsNotNone(self.h.registrar('som', 'surpreso', 0.9, agora=105)) # outro tipo vale
|
||||
self.h.config['pir'] = False
|
||||
self.assertIsNone(self.h.registrar('pir', agora=200))
|
||||
with self.assertRaises(ValueError):
|
||||
self.h.registrar('fumaça')
|
||||
self.assertEqual(self.h.listar()[1], 2)
|
||||
self.assertEqual(self.publicados[0][0], 'historico')
|
||||
|
||||
def test_pedido_e_foto(self):
|
||||
ident = self.h.registrar('pir', 'presença', agora=10)
|
||||
self.assertEqual(self.h.pedido_atual(), ident)
|
||||
with self.assertRaises(ValueError):
|
||||
self.h.guardar_foto(ident, b'nao e jpeg')
|
||||
self.h.guardar_foto(ident, JPEG)
|
||||
self.assertIsNone(self.h.pedido_atual())
|
||||
self.assertTrue(self.h.evento(ident)['foto'])
|
||||
self.assertTrue((Path(self.tmp.name) / 'fotos' / f'evento-{ident}.jpg').is_file())
|
||||
|
||||
def test_sem_foto_quando_desligado(self):
|
||||
self.h.config['foto'] = False
|
||||
self.h.registrar('pir', agora=10)
|
||||
self.assertIsNone(self.h.pedido_atual())
|
||||
|
||||
def test_guarda_no_maximo_as_fotos_recentes(self):
|
||||
self.h.config['intervalo'] = 0
|
||||
ids = []
|
||||
for i in range(MAX_FOTOS + 3):
|
||||
ids.append(self.h.registrar('pir', agora=i))
|
||||
self.h.guardar_foto(ids[-1], JPEG)
|
||||
com_foto = [e for e in self.h.listar(500)[0] if e['foto']]
|
||||
self.assertEqual(len(com_foto), MAX_FOTOS)
|
||||
self.assertFalse(self.h.evento(ids[0])['foto'])
|
||||
self.assertEqual(len(list((Path(self.tmp.name) / 'fotos').glob('*.jpg'))), MAX_FOTOS)
|
||||
|
||||
def test_guarda_so_os_ultimos_dias(self):
|
||||
agora = 10 * 86400
|
||||
velho = self.h.registrar('pir', agora=agora - 4 * 86400)
|
||||
self.h.guardar_foto(velho, JPEG)
|
||||
novo = self.h.registrar('pir', agora=agora - 86400)
|
||||
self.assertEqual(self.h.limpar_antigos(agora), 1) # 3 dias por padrão
|
||||
self.assertIsNone(self.h.evento(velho))
|
||||
self.assertIsNotNone(self.h.evento(novo))
|
||||
self.assertFalse((Path(self.tmp.name) / 'fotos' / f'evento-{velho}.jpg').exists())
|
||||
with self.assertRaises(ValueError):
|
||||
self.h.validar({'dias': 0})
|
||||
|
||||
def test_modo_presenca(self):
|
||||
chamadas = []
|
||||
self.h.ao_presenca = lambda: chamadas.append('chegou')
|
||||
self.h.ao_ausencia = lambda: chamadas.append('saiu')
|
||||
self.h.pir['disponivel'] = True
|
||||
self.h.marcar_presenca(1000)
|
||||
self.assertFalse(self.h.checar_ausencia(1000 + 4 * 60)) # 4 min: ainda presente
|
||||
self.assertTrue(self.h.checar_ausencia(1000 + 5 * 60 + 1)) # 5 min: ausente
|
||||
self.assertFalse(self.h.checar_ausencia(1000 + 10 * 60)) # não repete
|
||||
self.h.marcar_presenca(2000) # voltou alguém
|
||||
self.assertEqual(chamadas, ['saiu', 'chegou'])
|
||||
self.h.config['presenca'] = False
|
||||
self.h.marcar_presenca(3000)
|
||||
self.assertFalse(self.h.checar_ausencia(9999))
|
||||
|
||||
def test_pir_segurando_conta_como_presenca(self):
|
||||
avisos = []
|
||||
self.h.publicar = lambda tipo, dados: avisos.append((tipo, dados))
|
||||
self.h.pir['disponivel'] = True
|
||||
self.h.nivel_pir(True, agora=1000) # subida: evento + bolinha acesa
|
||||
self.assertTrue(self.h.pir['ativo'])
|
||||
self.assertEqual(self.h.status()['eventos'][0]['tipo'], 'pir')
|
||||
self.assertFalse(self.h.checar_ausencia(1000 + 10 * 60)) # pino ainda alto: não dorme
|
||||
self.h.nivel_pir(False, agora=1000 + 10 * 60) # descida: só apaga a bolinha
|
||||
self.assertFalse(self.h.pir['ativo'])
|
||||
self.assertEqual(self.h.status()['total'], 1)
|
||||
self.assertTrue(self.h.checar_ausencia(1000 + 15 * 60 + 1))
|
||||
self.assertIn(('pir', True), [(t, d.get('ativo')) for t, d in avisos])
|
||||
|
||||
def test_apagar_e_limpar(self):
|
||||
a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG)
|
||||
b = self.h.registrar('som', agora=1)
|
||||
self.h.apagar(a)
|
||||
self.assertIsNone(self.h.evento(a))
|
||||
self.h.limpar()
|
||||
self.assertEqual(self.h.listar()[1], 0)
|
||||
self.assertEqual(list((Path(self.tmp.name) / 'fotos').glob('*.jpg')), [])
|
||||
|
||||
|
||||
class HistoricoHttpTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
|
||||
self.server.token = 'token-historico'
|
||||
self.server.historico = Historico(self.tmp.name, iniciar_pir=False)
|
||||
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown(); self.server.server_close(); self.tmp.cleanup()
|
||||
|
||||
def pedir(self, metodo, rota, corpo=None, tipo='application/json', token='token-historico'):
|
||||
c = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=5)
|
||||
h = {'Content-Type': tipo}
|
||||
if token:
|
||||
h['Authorization'] = 'Bearer ' + token
|
||||
if isinstance(corpo, dict):
|
||||
corpo = json.dumps(corpo)
|
||||
c.request(metodo, rota, body=corpo, headers=h)
|
||||
r = c.getresponse(); dados = r.read(); c.close()
|
||||
return r.status, dados
|
||||
|
||||
def test_fluxo_completo(self):
|
||||
self.assertEqual(self.pedir('POST', '/historico/evento', {'tipo': 'pir'}, token=None)[0], 401)
|
||||
codigo, corpo = self.pedir('POST', '/historico/evento', {'tipo': 'movimento', 'evento': 'surpreso', 'nivel': 0.3})
|
||||
ident = json.loads(corpo)['id']
|
||||
self.assertEqual(json.loads(self.pedir('GET', '/historico/pedido')[1])['id'], ident)
|
||||
self.assertEqual(self.pedir('POST', f'/historico/foto?id={ident}', JPEG, 'image/jpeg')[0], 200)
|
||||
codigo, foto = self.pedir('GET', f'/historico/foto/evento-{ident}.jpg?t=token-historico', token=None)
|
||||
self.assertEqual((codigo, foto), (200, JPEG))
|
||||
self.assertEqual(self.pedir('GET', '/historico/foto/../historico.db')[0], 400)
|
||||
status = json.loads(self.pedir('GET', '/historico/status')[1])
|
||||
self.assertEqual(status['eventos'][0]['id'], ident)
|
||||
self.assertTrue(status['eventos'][0]['foto'])
|
||||
self.assertEqual(self.pedir('POST', '/historico/config', {'foto': False, 'intervalo': 30})[0], 200)
|
||||
self.assertEqual(self.pedir('POST', '/historico/config', {'intervalo': 9999})[0], 400)
|
||||
self.assertFalse(self.server.historico.config['foto'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -74,8 +74,20 @@ def testar(base, server, capturas):
|
||||
|
||||
# 2. telemetria do celular chega por SSE
|
||||
page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8,
|
||||
'microfone_ativo': True, 'microfone': 0.3})
|
||||
'microfone_ativo': True, 'microfone': 0.3,
|
||||
'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F', 'android': '12',
|
||||
'app': '0.8.1', 'wifi': 3, 'rssi': -58}})
|
||||
expect(page.get_by_text('Celular online · 80%')).to_be_visible(timeout=5000)
|
||||
# Dados que o próprio APK informa aparecem no cartão do celular.
|
||||
expect(page.get_by_text('samsung SM-G973F')).to_be_visible()
|
||||
expect(page.get_by_text('192.168.1.16')).to_be_visible()
|
||||
|
||||
# 2b. bateria baixa sem carregador vira aviso na hora
|
||||
page.request.post(base + '/app/status', headers=auth, data={'bateria': {'nivel': 12, 'carregando': False}})
|
||||
expect(page.get_by_text('Bateria do celular em 12%. Ligue o carregador')).to_be_visible(timeout=5000)
|
||||
page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8,
|
||||
'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F', 'android': '12',
|
||||
'app': '0.8.1', 'wifi': 3, 'rssi': -58}})
|
||||
|
||||
# 3. gesto pelo dock
|
||||
kiosk.FILA.limpar()
|
||||
@@ -116,6 +128,16 @@ def testar(base, server, capturas):
|
||||
page.get_by_label('Buscar ajuste').fill('')
|
||||
page.screenshot(path=str(Path(capturas) / 'ajustes.png'), full_page=True)
|
||||
|
||||
# 6a. rosto: escolher estilo e formato do olho
|
||||
page.goto(base + '/#/rosto')
|
||||
page.get_by_role('radio', name='Bloco').click()
|
||||
page.get_by_role('radio', name='Quadrado', exact=True).click()
|
||||
page.wait_for_timeout(500)
|
||||
assert kiosk.APARENCIA['rosto'] == 'bloco' and kiosk.APARENCIA['formato'] == 'quadrado', kiosk.APARENCIA
|
||||
expect(page.get_by_text('BLOCO · QUADRADO')).to_be_visible()
|
||||
if capturas:
|
||||
page.screenshot(path=str(Path(capturas) / 'rosto.png'), full_page=True)
|
||||
|
||||
# 6b. conversa: pergunta de hora é respondida pelo relógio (sem IA) e chega por SSE
|
||||
page.get_by_role('link', name='Conversa').first.click()
|
||||
page.get_by_label('Mensagem').fill('Que horas são?')
|
||||
|
||||
@@ -106,9 +106,24 @@ class ServidorTest(unittest.TestCase):
|
||||
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'rm -rf'})[0], 400)
|
||||
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400)
|
||||
self.assertEqual(kiosk.FILA.ordens(), ['feliz', 'rosto miudo'])
|
||||
for novo in ('acenar', 'esconder', 'espiar', 'pensar', 'musica'): # gestos com mãos e faixa
|
||||
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': novo})[0], 200)
|
||||
kiosk.FILA.limpar()
|
||||
kiosk.FILA.adicionar('feliz'); kiosk.FILA.adicionar('rosto miudo')
|
||||
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], ['feliz', 'rosto miudo'])
|
||||
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], [])
|
||||
|
||||
def test_rostos_novos_e_formato_do_olho(self):
|
||||
kiosk.ARQUIVO_APARENCIA = str(Path(self.tmp.name) / 'aparencia.json')
|
||||
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'bloco'})[0], 200)
|
||||
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'formato', 'valor': 'quadrado'})[0], 200)
|
||||
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'formato', 'valor': 'triangulo'})[0], 400)
|
||||
self.assertEqual(kiosk.FILA.ordens(), ['rosto bloco', 'formato quadrado'])
|
||||
estado = self.pedir('GET', '/estado')[1]
|
||||
self.assertEqual(estado['aparencia']['formato'], 'quadrado')
|
||||
self.assertEqual(estado['aparencia']['rosto'], 'bloco') # a escolha fica salva
|
||||
self.assertIn('pilula', estado['formatos'])
|
||||
|
||||
def test_post_face_usa_prefixo_rosto(self):
|
||||
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
|
||||
conn.request('POST', '/face', body='miudo', headers={'Authorization': 'Bearer ' + TOKEN})
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
/* voz-robo.js — voz sintetizada do Carinha, no estilo dos robozinhos de cinema.
|
||||
*
|
||||
* Tudo é gerado na hora com Web Audio: nenhuma gravação, nenhum arquivo de terceiros.
|
||||
* Cada som é uma sequência de "sílabas": um oscilador que desliza de um tom a outro, com vibrato,
|
||||
* passando por um filtro de formante (a "vogal": o→i faz o "uou"). Motores das mãos viram ruído
|
||||
* filtrado, e a resposta da conversa vira um balbucio que sobe no fim quando é pergunta.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let ruido = null; // buffer de ruído reaproveitado
|
||||
const TOM = 1.25; // todas as sílabas 25% acima do tom escrito
|
||||
const saidas = new WeakMap(); // AudioContext → nó de saída (compressor)
|
||||
|
||||
/*
|
||||
* Cadeia de "brilho metálico" (criada uma vez por AudioContext):
|
||||
* voz → ressonância de lata fina (agudo, ~850 Hz) → saturação leve → compressor → saída
|
||||
* Nada grave: zumbido e ressonância graves deixavam a voz com cara de sapo.
|
||||
*/
|
||||
function saida(c) {
|
||||
if (saidas.has(c)) return saidas.get(c);
|
||||
const entrada = c.createGain();
|
||||
const seca = c.createGain(); seca.gain.value = 0.8;
|
||||
const atraso = c.createDelay(0.05); atraso.delayTime.value = 0.00118;
|
||||
const volta = c.createGain(); volta.gain.value = 0.42;
|
||||
const molhada = c.createGain(); molhada.gain.value = 0.35;
|
||||
entrada.connect(seca);
|
||||
entrada.connect(atraso); atraso.connect(volta); volta.connect(atraso); atraso.connect(molhada);
|
||||
const satura = c.createWaveShaper();
|
||||
const n = 2048, curva = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) { const x = (i / (n - 1)) * 2 - 1; curva[i] = Math.tanh(x * 1.3); }
|
||||
satura.curve = curva;
|
||||
seca.connect(satura); molhada.connect(satura);
|
||||
const brilho = c.createBiquadFilter(); // realce leve nos agudos: "metal" limpo
|
||||
brilho.type = 'peaking'; brilho.frequency.value = 3200; brilho.Q.value = 1.2; brilho.gain.value = 4;
|
||||
const comp = c.createDynamicsCompressor();
|
||||
comp.threshold.value = -16; comp.ratio.value = 4; comp.attack.value = 0.003; comp.release.value = 0.15;
|
||||
const passaBaixa = c.createBiquadFilter();
|
||||
passaBaixa.type = 'lowpass'; passaBaixa.frequency.value = 8000;
|
||||
const volume = c.createGain(); volume.gain.value = 0.8;
|
||||
satura.connect(brilho); brilho.connect(comp); comp.connect(passaBaixa); passaBaixa.connect(volume); volume.connect(c.destination);
|
||||
saidas.set(c, entrada);
|
||||
return entrada;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uma sílaba. de/para: tom em Hz; vogal: [formante inicial, final] em Hz;
|
||||
* vib: [Hz, profundidade em Hz]; forma: tipo de onda.
|
||||
*/
|
||||
function silaba(c, t0, dur, de, para, vol, op) {
|
||||
op = op || {};
|
||||
vol *= 2.4; // compensa a energia que o filtro de formante tira
|
||||
de *= TOM; para *= TOM; // voz de robozinho: aguda
|
||||
const osc = c.createOscillator();
|
||||
osc.type = op.forma || 'sawtooth';
|
||||
osc.frequency.setValueAtTime(de, t0);
|
||||
osc.frequency.exponentialRampToValueAtTime(Math.max(20, para), t0 + dur);
|
||||
if (op.vib) {
|
||||
const lfo = c.createOscillator(), prof = c.createGain();
|
||||
lfo.frequency.value = op.vib[0]; prof.gain.value = op.vib[1];
|
||||
lfo.connect(prof); prof.connect(osc.frequency);
|
||||
lfo.start(t0); lfo.stop(t0 + dur + 0.05);
|
||||
}
|
||||
// FM harmônico e leve: brilho de metal sem aspereza
|
||||
const mod = c.createOscillator(), modProf = c.createGain();
|
||||
mod.type = 'sine';
|
||||
mod.frequency.setValueAtTime(de * 2, t0);
|
||||
mod.frequency.exponentialRampToValueAtTime(Math.max(20, para * 2), t0 + dur);
|
||||
modProf.gain.setValueAtTime(de * (op.metal ?? 0.22), t0);
|
||||
modProf.gain.exponentialRampToValueAtTime(Math.max(1, para * (op.metal ?? 0.22)), t0 + dur);
|
||||
mod.connect(modProf); modProf.connect(osc.frequency);
|
||||
mod.start(t0); mod.stop(t0 + dur + 0.05);
|
||||
// corpo: uma senoide uma oitava abaixo deixa a voz menos "apito"
|
||||
const sub = c.createOscillator();
|
||||
sub.type = 'sine';
|
||||
sub.frequency.setValueAtTime(de / 2, t0);
|
||||
sub.frequency.exponentialRampToValueAtTime(Math.max(20, para / 2), t0 + dur);
|
||||
const vogal = op.vogal || [900, 1500];
|
||||
const filtro = c.createBiquadFilter();
|
||||
filtro.type = 'bandpass'; filtro.Q.value = op.q || 5;
|
||||
filtro.frequency.setValueAtTime(vogal[0], t0);
|
||||
filtro.frequency.exponentialRampToValueAtTime(vogal[1], t0 + dur);
|
||||
const g = c.createGain(), gs = c.createGain();
|
||||
const ataque = Math.min(0.02, dur * 0.25);
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.exponentialRampToValueAtTime(vol, t0 + ataque);
|
||||
g.gain.setValueAtTime(vol, t0 + dur * 0.7);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
gs.gain.value = 0.12; // pouca oitava de baixo: grave demais vira coaxar
|
||||
osc.connect(filtro); sub.connect(gs); gs.connect(filtro); filtro.connect(g); g.connect(saida(c));
|
||||
osc.start(t0); sub.start(t0);
|
||||
osc.stop(t0 + dur + 0.05); sub.stop(t0 + dur + 0.05);
|
||||
}
|
||||
|
||||
function bufferRuido(c) {
|
||||
if (ruido && ruido.sampleRate === c.sampleRate) return ruido;
|
||||
ruido = c.createBuffer(1, c.sampleRate, c.sampleRate);
|
||||
const d = ruido.getChannelData(0);
|
||||
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
||||
return ruido;
|
||||
}
|
||||
|
||||
/** Motorzinho: ruído filtrado que sobe de tom, com um tremor mecânico. */
|
||||
function servo(c, t0, dur, vol, sobe) {
|
||||
const fonte = c.createBufferSource();
|
||||
fonte.buffer = bufferRuido(c); fonte.loop = true;
|
||||
const f = c.createBiquadFilter();
|
||||
f.type = 'bandpass'; f.Q.value = 9;
|
||||
f.frequency.setValueAtTime(sobe === false ? 1500 : 500, t0);
|
||||
f.frequency.exponentialRampToValueAtTime(sobe === false ? 500 : 1500, t0 + dur);
|
||||
const g = c.createGain(), trem = c.createOscillator(), tg = c.createGain();
|
||||
trem.frequency.value = 38; tg.gain.value = vol * 0.35;
|
||||
trem.connect(tg); tg.connect(g.gain);
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.exponentialRampToValueAtTime(vol * 0.6, t0 + 0.05);
|
||||
g.gain.setValueAtTime(vol * 0.6, t0 + dur - 0.06);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
fonte.connect(f); f.connect(g); g.connect(saida(c));
|
||||
fonte.start(t0); trem.start(t0);
|
||||
fonte.stop(t0 + dur + 0.05); trem.stop(t0 + dur + 0.05);
|
||||
}
|
||||
|
||||
function clique(c, t0, vol) {
|
||||
const fonte = c.createBufferSource();
|
||||
fonte.buffer = bufferRuido(c);
|
||||
const f = c.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 3000;
|
||||
const g = c.createGain();
|
||||
g.gain.setValueAtTime(vol * 0.5, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.03);
|
||||
fonte.connect(f); f.connect(g); g.connect(saida(c));
|
||||
fonte.start(t0); fonte.stop(t0 + 0.05);
|
||||
}
|
||||
|
||||
const O = [500, 900], I = [1800, 2400], U = [350, 700], A = [900, 1300]; // vogais (formantes)
|
||||
|
||||
// Cada som: (c, t, v) → agenda as sílabas. Volumes relativos ao volume do app.
|
||||
const SONS = {
|
||||
feliz: (c, t, v) => { silaba(c, t, 0.12, 620, 900, v, { vogal: [1200, 2000] }); silaba(c, t + 0.13, 0.18, 900, 1250, v, { vogal: I, vib: [9, 18] }); },
|
||||
alegre: (c, t, v) => [560, 700, 860, 1100].forEach((f, i) => silaba(c, t + i * 0.085, 0.09, f, f * 1.25, v * 0.85, { vogal: I, forma: 'square' })),
|
||||
coracao: (c, t, v) => { silaba(c, t, 0.55, 760, 560, v, { vogal: [800, 1300], vib: [7, 28] }); },
|
||||
piscadinha: (c, t, v) => { clique(c, t, v); silaba(c, t + 0.04, 0.08, 1000, 1300, v * 0.6, { forma: 'square', vogal: I }); },
|
||||
surpreso: (c, t, v) => { silaba(c, t, 0.22, 260, 1100, v, { vogal: [450, 2000] }); silaba(c, t + 0.22, 0.12, 1100, 820, v * 0.8, { vogal: I }); },
|
||||
bravo: (c, t, v) => [0, 0.13, 0.26].forEach((d, i) => silaba(c, t + d, 0.1, 480 - i * 30, 380 - i * 30, v * 0.85, { forma: 'square', vogal: [1300, 900] })), // bipes zangados
|
||||
triste: (c, t, v) => { silaba(c, t, 0.65, 640, 380, v * 0.85, { vogal: [1200, 750], vib: [5, 14] }); },
|
||||
dormir: (c, t, v) => { silaba(c, t, 0.35, 460, 760, v * 0.7, { vogal: [900, 1500] }); silaba(c, t + 0.36, 0.75, 760, 340, v * 0.6, { vogal: [1500, 700], vib: [4, 8] }); },
|
||||
acordar: (c, t, v) => { servo(c, t, 0.25, v * 0.5); [300, 450, 700].forEach((f, i) => silaba(c, t + 0.22 + i * 0.1, 0.1, f, f * 1.2, v * 0.8, { vogal: I, forma: 'square' })); silaba(c, t + 0.56, 0.2, 700, 1050, v, { vogal: [1200, 2200], vib: [8, 15] }); },
|
||||
acenar: (c, t, v) => { servo(c, t, 0.3, v * 0.45); silaba(c, t + 0.28, 0.13, 520, 820, v, { vogal: [1000, 1900] }); silaba(c, t + 0.44, 0.2, 760, 1080, v, { vogal: I, vib: [9, 20] }); },
|
||||
esconder: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 0.1, 0.08, 950, 1350, v * 0.8, { vogal: I }); for (let i = 0; i < 5; i++) silaba(c, t + 0.6 + i * 0.09, 0.05, 1200 + (i % 2) * 180, 1300, v * 0.35, { forma: 'square', vogal: I }); servo(c, t + 2.05, 0.4, v * 0.45, false); },
|
||||
espiar: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 1.0, 0.3, 480, 760, v * 0.9, { vogal: [600, 1700] }); silaba(c, t + 2.0, 0.16, 800, 1150, v, { vogal: I }); },
|
||||
pensar: (c, t, v) => { servo(c, t, 0.35, v * 0.4); silaba(c, t + 0.35, 0.5, 520, 640, v * 0.7, { vogal: [900, 1600], vib: [5, 10] }); [0.95, 1.12].forEach((d) => silaba(c, t + d, 0.05, 1500, 1600, v * 0.35, { forma: 'square', vogal: I })); }, // "hmm?" e bipinhos
|
||||
musica: (c, t, v) => [523, 659, 784, 659, 880, 784, 659, 1047].forEach((f, i) => silaba(c, t + i * 0.26, 0.2, f, f * 1.02, v * 0.6, { forma: 'triangle', vogal: A, vib: [6, 5] })),
|
||||
};
|
||||
|
||||
// Gerador previsível: a mesma frase sempre "soa" igual.
|
||||
function semente(texto) {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < texto.length; i++) h = Math.imul(h ^ texto.charCodeAt(i), 16777619);
|
||||
return () => { h = Math.imul(h ^ (h >>> 15), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); return ((h ^= h >>> 16) >>> 0) / 4294967296; };
|
||||
}
|
||||
|
||||
/** Balbucio de robô para uma resposta: ~1 sílaba a cada 6 letras, no máximo 16. */
|
||||
function balbuciar(c, texto, v) {
|
||||
texto = String(texto || '').trim();
|
||||
if (!texto) return 0;
|
||||
const rnd = semente(texto), n = Math.max(2, Math.min(16, Math.ceil(texto.length / 6)));
|
||||
const pergunta = /\?\s*$/.test(texto), exclama = /!\s*$/.test(texto);
|
||||
const vogais = [O, I, A, U];
|
||||
let t = c.currentTime + 0.03, tom = 520 + rnd() * 180;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const ultima = i === n - 1;
|
||||
const dur = 0.07 + rnd() * 0.1;
|
||||
let para = tom * (0.8 + rnd() * 0.5);
|
||||
if (ultima) para = pergunta ? tom * 1.6 : exclama ? tom * 1.35 : tom * 0.7;
|
||||
silaba(c, t, ultima ? dur + 0.08 : dur, tom, para, v * (0.65 + rnd() * 0.3), { vogal: vogais[Math.floor(rnd() * 4)], vib: rnd() < 0.3 ? [8, 14] : null, forma: rnd() < 0.25 ? 'square' : 'sawtooth' });
|
||||
t += dur + 0.025 + rnd() * 0.05;
|
||||
tom = Math.max(330, Math.min(1000, para * (0.9 + rnd() * 0.25)));
|
||||
}
|
||||
return t - c.currentTime;
|
||||
}
|
||||
|
||||
function tocar(c, nome, v) {
|
||||
const f = SONS[nome];
|
||||
if (!f || !c) return false;
|
||||
f(c, c.currentTime + 0.02, v);
|
||||
return true;
|
||||
}
|
||||
|
||||
window.VozRobo = { tocar, balbuciar, servo: (c, dur, v) => servo(c, c.currentTime + 0.01, dur, v), sons: Object.keys(SONS) };
|
||||
})();
|
||||
@@ -9,6 +9,9 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- mesmo motor de rosto do celular, usado nas miniaturas das reações -->
|
||||
<script src="/rosto-motor.js"></script>
|
||||
<script src="/voz-robo.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+16
-13
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
|
||||
import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
|
||||
import { onMount, type Component } from 'svelte';
|
||||
import Avisos from './lib/componentes/Avisos.svelte';
|
||||
import EStop from './lib/componentes/EStop.svelte';
|
||||
@@ -10,28 +10,35 @@
|
||||
import { favoritos, gesto, pararTudo } from './lib/comandos.svelte';
|
||||
import { conectar, estado } from './lib/estado.svelte';
|
||||
import { paleta } from './lib/paleta.svelte';
|
||||
import { nav, ROTAS, type Rota } from './lib/rotas.svelte';
|
||||
import { nav, ROTAS, SECUNDARIAS, tituloDa, type Rota } from './lib/rotas.svelte';
|
||||
import AoVivo from './telas/AoVivo.svelte';
|
||||
import Ajustes from './telas/Ajustes.svelte';
|
||||
import Conversa from './telas/Conversa.svelte';
|
||||
import Movimento from './telas/Movimento.svelte';
|
||||
import Reacoes from './telas/Reacoes.svelte';
|
||||
import Rosto from './telas/Rosto.svelte';
|
||||
import Eventos from './telas/Eventos.svelte';
|
||||
|
||||
const TELAS: Record<Rota, Component> = {
|
||||
'ao-vivo': AoVivo,
|
||||
eventos: Eventos,
|
||||
conversa: Conversa,
|
||||
reacoes: Reacoes,
|
||||
movimento: Movimento,
|
||||
ajustes: Ajustes,
|
||||
rosto: Rosto,
|
||||
};
|
||||
const ICONES: Record<Rota, Component<any>> = {
|
||||
const ICONES: Record<string, Component<any>> = {
|
||||
'ao-vivo': Video,
|
||||
eventos: Bell,
|
||||
conversa: MessageCircle,
|
||||
reacoes: Smile,
|
||||
movimento: Hand,
|
||||
ajustes: Settings,
|
||||
};
|
||||
const Tela = $derived(TELAS[nav.atual]);
|
||||
// Páginas secundárias acendem a seção de onde vieram.
|
||||
const secao = $derived(SECUNDARIAS.find((r) => r.id === nav.atual)?.volta ?? nav.atual);
|
||||
|
||||
// ---------- tema
|
||||
let tema = $state<'dark' | 'light'>('dark');
|
||||
@@ -100,11 +107,7 @@
|
||||
const conexaoTexto = $derived(
|
||||
{ conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao],
|
||||
);
|
||||
const bateria = $derived.by(() => {
|
||||
const b = estado.app?.estado?.bateria;
|
||||
const n = typeof b === 'number' ? b : b?.nivel;
|
||||
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
|
||||
});
|
||||
const bateria = $derived(estado.bateria.nivel);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={teclado} />
|
||||
@@ -126,7 +129,7 @@
|
||||
{#each ROTAS as r (r.id)}
|
||||
{@const Icone = ICONES[r.id]}
|
||||
<li>
|
||||
<a href={'#/' + r.id} aria-current={nav.atual === r.id ? 'page' : undefined}>
|
||||
<a href={'#/' + r.id} aria-current={secao === r.id ? 'page' : undefined}>
|
||||
<Icone size={20} /> <span>{r.titulo}</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -138,12 +141,12 @@
|
||||
</nav>
|
||||
|
||||
<header class="topo">
|
||||
<h1 class="titulo">{ROTAS.find((r) => r.id === nav.atual)?.titulo}</h1>
|
||||
<h1 class="titulo">{tituloDa(nav.atual)}</h1>
|
||||
<div class="status linha">
|
||||
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
|
||||
{conexaoTexto}
|
||||
</Selo>
|
||||
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>
|
||||
<Selo tom={!estado.celularOnline ? 'neutro' : estado.bateriaBaixa ? 'perigo' : 'ok'}>
|
||||
Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''}
|
||||
</Selo>
|
||||
{#if estado.conversa}
|
||||
@@ -169,7 +172,7 @@
|
||||
<Tela />
|
||||
</main>
|
||||
|
||||
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes'}
|
||||
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes' && nav.atual !== 'rosto'}
|
||||
<MiniRosto />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -206,7 +209,7 @@
|
||||
margin: 0;
|
||||
padding: var(--e-1);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
li a {
|
||||
display: grid;
|
||||
|
||||
@@ -46,12 +46,17 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] =
|
||||
{ chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' },
|
||||
{ chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' },
|
||||
{ chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', tipo: 'toggle' },
|
||||
{ chave: 'iris', rotulo: 'Mostrar íris', ajuda: 'Pupila que acompanha o olhar (Viva, Bloco e Matriz)', tipo: 'toggle' },
|
||||
{ chave: 'subirBoca', rotulo: 'Subir a boca', tipo: 'slider', min: -20, max: 60, passo: 2 },
|
||||
{ chave: 'distSobrancelha', rotulo: 'Distância das sobrancelhas', tipo: 'slider', min: 0, max: 60, passo: 2 },
|
||||
{ chave: 'audio', rotulo: 'Sons no celular', ajuda: 'Bipes do próprio robô', tipo: 'toggle' },
|
||||
{ chave: 'volume', rotulo: 'Volume dos sons', tipo: 'slider', min: 0, max: 1, passo: 0.05 },
|
||||
{ chave: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
|
||||
{ chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' },
|
||||
{ chave: 'brilhoRepouso', rotulo: 'Brilho em repouso', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
|
||||
{ chave: 'repousoTela', rotulo: 'Espera para reduzir brilho', tipo: 'slider', min: 5, max: 300, passo: 5, unidade: ' s' },
|
||||
{ chave: 'apagarTela', rotulo: 'Apagar a tela quando dormir', ajuda: 'Tela preta e brilho mínimo; acorda com som, movimento, toque ou PIR', tipo: 'toggle' },
|
||||
{ chave: 'apagarTelaMin', rotulo: 'Dormindo há quanto tempo para apagar', tipo: 'slider', min: 1, max: 120, passo: 1, unidade: ' min' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -20,17 +20,34 @@ export const GESTOS_INFO: Record<string, { nome: string; emoji: string }> = {
|
||||
dormir: { nome: 'Dormir', emoji: '😴' },
|
||||
acordar: { nome: 'Acordar', emoji: '🌅' },
|
||||
aleatorio: { nome: 'Aleatório', emoji: '🎲' },
|
||||
acenar: { nome: 'Acenar', emoji: '👋' },
|
||||
esconder: { nome: 'Esconder o rosto', emoji: '🙈' },
|
||||
espiar: { nome: 'Espiar', emoji: '🫣' },
|
||||
pensar: { nome: 'Pensar', emoji: '🤔' },
|
||||
musica: { nome: 'Dançar', emoji: '🎵' },
|
||||
};
|
||||
|
||||
export const gestosDisponiveis = () => estado.gestos.filter((g) => g in GESTOS_INFO);
|
||||
|
||||
const CHAVE_FAVORITOS = 'carinha_favoritos';
|
||||
const PADRAO_FAVORITOS = ['feliz', 'coracao', 'piscadinha', 'surpreso', 'triste', 'olhar_esq', 'olhar_dir', 'dormir', 'acordar'];
|
||||
const CHAVE_MAOS = 'carinha_favoritos_maos'; // marca que os gestos com mãos já entraram
|
||||
/** Até 12 favoritos; as teclas 1 a 9 valem para os nove primeiros. */
|
||||
export const MAX_FAVORITOS = 12;
|
||||
const GESTOS_MAOS = ['acenar', 'esconder', 'espiar', 'pensar', 'musica'];
|
||||
const PADRAO_FAVORITOS = ['feliz', 'coracao', 'acenar', 'esconder', 'espiar', 'pensar', 'musica', 'piscadinha', 'surpreso', 'triste', 'dormir', 'acordar'];
|
||||
|
||||
function lerFavoritos(): string[] {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null');
|
||||
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) return v.slice(0, 9);
|
||||
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) {
|
||||
if (localStorage.getItem(CHAVE_MAOS)) return v.slice(0, MAX_FAVORITOS);
|
||||
// Quem já tinha favoritos salvos ganha os gestos com mãos logo depois dos dois primeiros.
|
||||
const novos = GESTOS_MAOS.filter((g) => !v.includes(g));
|
||||
const lista = [...v.slice(0, 2), ...novos, ...v.slice(2)].slice(0, MAX_FAVORITOS);
|
||||
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(lista));
|
||||
localStorage.setItem(CHAVE_MAOS, '1');
|
||||
return lista;
|
||||
}
|
||||
} catch {
|
||||
/* sem armazenamento */
|
||||
}
|
||||
@@ -40,9 +57,10 @@ function lerFavoritos(): string[] {
|
||||
class Favoritos {
|
||||
lista = $state<string[]>(lerFavoritos());
|
||||
alternar(g: string) {
|
||||
this.lista = this.lista.includes(g) ? this.lista.filter((x) => x !== g) : [...this.lista, g].slice(0, 9);
|
||||
this.lista = this.lista.includes(g) ? this.lista.filter((x) => x !== g) : [...this.lista, g].slice(0, MAX_FAVORITOS);
|
||||
try {
|
||||
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista));
|
||||
localStorage.setItem(CHAVE_MAOS, '1');
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
@@ -53,6 +71,22 @@ export const favoritos = new Favoritos();
|
||||
export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome }));
|
||||
export const rosto = (nome: string) => tentar(() => postar('/comando', { tipo: 'rosto', valor: nome }));
|
||||
export const cor = (rgb: [number, number, number]) => tentar(() => postar('/comando', { tipo: 'cor', valor: rgb }));
|
||||
export const formato = (nome: string) => tentar(() => postar('/comando', { tipo: 'formato', valor: nome }));
|
||||
|
||||
/** Rostos: os novos usam o motor de pálpebras (rosto-motor.js) e aceitam formato de olho. */
|
||||
export const ROSTOS_INFO: Record<string, { nome: string; descricao: string; novo: boolean; formato: boolean }> = {
|
||||
vivo: { nome: 'Carinha Viva', descricao: 'O rosto de sempre com pálpebras, brilho e micro-movimentos.', novo: true, formato: true },
|
||||
bloco: { nome: 'Bloco', descricao: 'Olhos quadrados que se recortam, estilo robô de mesa.', novo: true, formato: true },
|
||||
matriz: { nome: 'Matriz', descricao: 'Painel de LEDs retrô; cada olho é feito de pontos.', novo: true, formato: true },
|
||||
lente: { nome: 'Lente', descricao: 'Íris e pupila que dilata, pálpebras de metal.', novo: true, formato: false },
|
||||
carinha: { nome: 'Clássico', descricao: 'O desenho original, com boca.', novo: false, formato: false },
|
||||
miudo: { nome: 'Miúdo', descricao: 'Clássico com olhos menores.', novo: false, formato: false },
|
||||
eilik: { nome: 'Eilik', descricao: 'Clássico sem boca.', novo: false, formato: false },
|
||||
};
|
||||
export const FORMATOS_INFO: Record<string, string> = {
|
||||
padrao: 'Padrão', redondo: 'Redondo', pilula: 'Pílula', quadrado: 'Quadrado', largo: 'Largo',
|
||||
};
|
||||
export const nomeRosto = (id: string) => ROSTOS_INFO[id]?.nome ?? id;
|
||||
export const recarregarCelular = () =>
|
||||
tentar(async () => {
|
||||
const r = await postar<{ mensagem: string }>('/comando', { tipo: 'recarregar' });
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import { BatteryCharging, BatteryFull, BatteryLow, BatteryMedium, Moon, Smartphone, Sun, WifiOff } from '@lucide/svelte';
|
||||
import { estado } from '../estado.svelte';
|
||||
|
||||
// Cartão do aparelho com o que o próprio app informa (IP, modelo, Android, versão, Wi-Fi, bateria).
|
||||
const s = $derived(estado.app?.estado ?? {});
|
||||
const ap = $derived(s.aparelho ?? null);
|
||||
const bateria = $derived(estado.bateria.nivel);
|
||||
const carregando = $derived(estado.bateria.carregando);
|
||||
// Tempo desde o último sinal, em texto curto ("há 3 min").
|
||||
const semSinal = $derived.by(() => {
|
||||
if (!estado.ultimoSinalEm) return '';
|
||||
const seg = Math.max(0, Math.round((estado.agora - estado.ultimoSinalEm) / 1000));
|
||||
if (seg < 60) return `há ${seg} s`;
|
||||
if (seg < 3600) return `há ${Math.round(seg / 60)} min`;
|
||||
if (seg < 86400) return `há ${Math.round(seg / 3600)} h`;
|
||||
return `há ${Math.round(seg / 86400)} dia(s)`;
|
||||
});
|
||||
const IconeBateria = $derived(carregando ? BatteryCharging : bateria === null ? BatteryMedium : bateria < 20 ? BatteryLow : bateria < 70 ? BatteryMedium : BatteryFull);
|
||||
const ip = $derived(ap?.ip || estado.app?.celular_ip || '—');
|
||||
</script>
|
||||
|
||||
<div class="celular" class:offline={!estado.celularOnline}>
|
||||
<div class="cabeca">
|
||||
<span class="aparelho" aria-hidden="true"><Smartphone size={22} /></span>
|
||||
<div class="nome">
|
||||
<strong>{ap?.modelo || 'Celular do rosto'}</strong>
|
||||
<span>
|
||||
{#if estado.celularOnline}
|
||||
{[ap?.android && 'Android ' + ap.android, ap?.app && 'app ' + ap.app].filter(Boolean).join(' · ') || 'online'}
|
||||
{:else}
|
||||
offline{semSinal ? ' · último sinal ' + semSinal : ''}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<span class="estado" class:on={estado.celularOnline}>{estado.celularOnline ? 'Online' : 'Offline'}</span>
|
||||
</div>
|
||||
|
||||
{#if estado.celularOnline}
|
||||
<dl class="dados">
|
||||
<div>
|
||||
<dt>Bateria</dt>
|
||||
<dd class:baixa={bateria !== null && bateria < 20 && !carregando}>
|
||||
<IconeBateria size={18} />
|
||||
{bateria !== null ? bateria + '%' : '—'}
|
||||
{#if carregando}<small>carregando</small>{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Wi-Fi</dt>
|
||||
<dd>
|
||||
{#if typeof ap?.wifi === 'number'}
|
||||
<svg viewBox="0 0 20 16" width="20" height="16" aria-hidden="true">
|
||||
{#each [0, 1, 2, 3] as i (i)}
|
||||
<rect x={i * 5} y={12 - i * 4} width="3.5" height={4 + i * 4} rx="1" class:cheia={ap.wifi > i} />
|
||||
{/each}
|
||||
</svg>
|
||||
<span class="sr-only">{ap.wifi} de 4</span>
|
||||
{#if ap.rssi}<small>{ap.rssi} dBm</small>{/if}
|
||||
{:else}
|
||||
<WifiOff size={16} /> <small>sem dado</small>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>IP</dt>
|
||||
<dd class="mono">{ip}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Rosto</dt>
|
||||
<dd>
|
||||
{#if s.dormindo}<Moon size={16} /> Dormindo{:else}<Sun size={16} /> Acordado{/if}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{:else}
|
||||
{#if bateria !== null && estado.ultimoSinalEm}
|
||||
<p class="ultimo" class:baixa={bateria <= 15 && !carregando}>
|
||||
<IconeBateria size={18} /> Último sinal com {bateria}%{carregando ? ', carregando' : ''}.
|
||||
{#if bateria <= 10 && !carregando}Provavelmente a bateria acabou: ligue o carregador e abra o app.{/if}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="ajuda">Abra o app Carinha no celular e confira se ele aponta para este servidor (toque longo › Mudar o servidor).</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.celular {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
padding: var(--e-4);
|
||||
}
|
||||
.cabeca {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-3);
|
||||
}
|
||||
.aparelho {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #5e5ce6, #0a84ff);
|
||||
color: #fff;
|
||||
flex: none;
|
||||
}
|
||||
.offline .aparelho {
|
||||
background: var(--superficie-3);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.nome {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.nome strong {
|
||||
font-size: var(--t-md);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nome span {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.estado {
|
||||
font-size: var(--t-xs);
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--raio-pill);
|
||||
background: var(--superficie-3);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.estado.on {
|
||||
background: var(--ok-suave);
|
||||
color: var(--ok);
|
||||
}
|
||||
.dados {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--e-2);
|
||||
margin: 0;
|
||||
}
|
||||
.dados div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: var(--e-2) var(--e-3);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie-2);
|
||||
min-width: 0;
|
||||
}
|
||||
dt {
|
||||
font-size: 11px;
|
||||
color: var(--texto-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: var(--t-sm);
|
||||
flex-wrap: wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
dd small {
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.baixa {
|
||||
color: var(--perigo);
|
||||
}
|
||||
rect {
|
||||
fill: var(--superficie-3);
|
||||
}
|
||||
rect.cheia {
|
||||
fill: var(--texto);
|
||||
}
|
||||
.ultimo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-2);
|
||||
flex-wrap: wrap;
|
||||
font-size: var(--t-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ultimo.baixa {
|
||||
color: var(--perigo);
|
||||
}
|
||||
.ajuda {
|
||||
font-size: var(--t-sm);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
dd.mono {
|
||||
white-space: nowrap;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -11,10 +11,10 @@
|
||||
{#each lista as g, i (g)}
|
||||
{@const info = GESTOS_INFO[g]}
|
||||
<div class="item">
|
||||
<button class="gesto" onclick={() => gesto(g)} title={completo ? info.nome : `${info.nome} (tecla ${i + 1})`}>
|
||||
<button class="gesto" onclick={() => gesto(g)} title={completo || i > 8 ? info.nome : `${info.nome} (tecla ${i + 1})`}>
|
||||
<span class="emoji" aria-hidden="true">{info.emoji}</span>
|
||||
<span class="nome">{info.nome}</span>
|
||||
{#if !completo}<kbd>{i + 1}</kbd>{/if}
|
||||
{#if !completo && i < 9}<kbd>{i + 1}</kbd>{/if}
|
||||
</button>
|
||||
{#if completo}
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
// Lista agrupada no estilo dos Ajustes do iPhone: título pequeno, cartão arredondado, rodapé de ajuda.
|
||||
let { titulo = '', rodape = '', children }: { titulo?: string; rodape?: string; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<section class="grupo">
|
||||
{#if titulo}<h2>{titulo}</h2>{/if}
|
||||
<div class="itens">{@render children()}</div>
|
||||
{#if rodape}<p class="rodape">{rodape}</p>{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.grupo {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
h2 {
|
||||
padding: 0 var(--e-4);
|
||||
font-size: var(--t-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.itens {
|
||||
background: var(--superficie);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.rodape {
|
||||
padding: 0 var(--e-4);
|
||||
font-size: var(--t-xs);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
// Interruptor no estilo iOS (verde quando ligado). O rótulo vira o nome acessível.
|
||||
let { rotulo, checked = false, disabled = false, onchange }: {
|
||||
rotulo: string;
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
onchange: (v: boolean) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<label class="interruptor">
|
||||
<input type="checkbox" role="switch" aria-label={rotulo} {checked} {disabled} onchange={(e) => onchange(e.currentTarget.checked)} />
|
||||
<span class="trilho" aria-hidden="true"></span>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
.interruptor {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.trilho {
|
||||
width: 51px;
|
||||
height: 31px;
|
||||
border-radius: var(--raio-pill);
|
||||
background: var(--superficie-3);
|
||||
position: relative;
|
||||
transition: background var(--transicao);
|
||||
}
|
||||
.trilho::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 0.3);
|
||||
transition: transform var(--transicao);
|
||||
}
|
||||
input:checked + .trilho {
|
||||
background: #34c759;
|
||||
}
|
||||
input:checked + .trilho::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
input:focus-visible + .trilho {
|
||||
outline: 2px solid var(--destaque);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
input:disabled + .trilho {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { ChevronRight } from '@lucide/svelte';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
|
||||
// Uma linha da lista: ícone colorido, rótulo, detalhe e um acessório (interruptor, valor ou seta).
|
||||
// Com href ou onclick a linha inteira é clicável e mostra a seta.
|
||||
let {
|
||||
icone,
|
||||
cor = 'var(--destaque)',
|
||||
rotulo,
|
||||
detalhe = '',
|
||||
href,
|
||||
onclick,
|
||||
disabled = false,
|
||||
acessorio,
|
||||
children,
|
||||
}: {
|
||||
icone?: Component<any>;
|
||||
cor?: string;
|
||||
rotulo: string;
|
||||
detalhe?: string;
|
||||
href?: string;
|
||||
onclick?: () => void;
|
||||
disabled?: boolean;
|
||||
acessorio?: Snippet;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
const clicavel = $derived(!!href || !!onclick);
|
||||
</script>
|
||||
|
||||
{#snippet corpo()}
|
||||
{#if icone}
|
||||
{@const Icone = icone}
|
||||
<span class="icone" style="--cor: {cor}" aria-hidden="true"><Icone size={16} strokeWidth={2.4} /></span>
|
||||
{/if}
|
||||
<span class="texto">
|
||||
<span class="rotulo">{rotulo}</span>
|
||||
{#if detalhe}<span class="detalhe">{detalhe}</span>{/if}
|
||||
</span>
|
||||
{#if acessorio}<span class="acessorio">{@render acessorio()}</span>{/if}
|
||||
{#if clicavel}<ChevronRight size={18} class="seta" aria-hidden="true" />{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="linha-ios" class:com-icone={!!icone} class:desativada={disabled}>
|
||||
{#if href}
|
||||
<a class="conteudo" {href}>{@render corpo()}</a>
|
||||
{:else if onclick}
|
||||
<button class="conteudo" {onclick} {disabled}>{@render corpo()}</button>
|
||||
{:else}
|
||||
<div class="conteudo">{@render corpo()}</div>
|
||||
{/if}
|
||||
{#if children}<div class="extra">{@render children()}</div>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.linha-ios {
|
||||
position: relative;
|
||||
}
|
||||
/* separador fino que começa depois do ícone, como no iOS */
|
||||
.linha-ios:not(:first-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: var(--e-4);
|
||||
border-top: 1px solid var(--borda);
|
||||
}
|
||||
.linha-ios.com-icone:not(:first-child)::before {
|
||||
left: calc(var(--e-4) + 30px + var(--e-3));
|
||||
}
|
||||
.conteudo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-3);
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: var(--e-2) var(--e-4);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--texto);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.conteudo,
|
||||
button.conteudo {
|
||||
cursor: pointer;
|
||||
}
|
||||
a.conteudo:hover,
|
||||
button.conteudo:hover:not(:disabled) {
|
||||
background: var(--superficie-2);
|
||||
}
|
||||
a.conteudo:active,
|
||||
button.conteudo:active:not(:disabled) {
|
||||
background: var(--superficie-3);
|
||||
}
|
||||
.icone {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: var(--cor);
|
||||
color: #fff;
|
||||
}
|
||||
.texto {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.rotulo {
|
||||
font-size: var(--t-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.detalhe {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--texto-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.acessorio {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-2);
|
||||
color: var(--texto-3);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
.conteudo :global(.seta) {
|
||||
flex: none;
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.extra {
|
||||
padding: 0 var(--e-4) var(--e-4);
|
||||
}
|
||||
.desativada {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { rgb, ROSTOS_INFO } from '../comandos.svelte';
|
||||
import { estado } from '../estado.svelte';
|
||||
import { SIMBOLO_EMOJI } from '../treinamento.svelte';
|
||||
|
||||
// Prévia estática de uma reação (sem iframe): figurinha SVG, emoji ou olhos do 1º passo.
|
||||
// Retrato parado da reação, desenhado pelo mesmo motor do celular (rosto-motor.js),
|
||||
// no rosto, formato e cor que o robô está usando agora.
|
||||
let { receita, tamanho = 64 }: { receita: any; tamanho?: number } = $props();
|
||||
let tela: HTMLCanvasElement;
|
||||
|
||||
const passo = $derived(receita?.passos?.[0]);
|
||||
const simbolo = $derived(receita?.emoji?.caractere || SIMBOLO_EMOJI[receita?.simbolo] || '');
|
||||
const nosOlhos = $derived({
|
||||
icone:
|
||||
receita?.emoji?.local === 'olhos'
|
||||
? receita.emoji.caractere
|
||||
: ['ideia', 'cafe'].includes(receita?.simbolo) && !receita?.emoji && !receita?.figurinha
|
||||
? SIMBOLO_EMOJI[receita.simbolo]
|
||||
: null,
|
||||
desenho: receita?.figurinha?.local === 'olhos' ? receita.figurinha : null,
|
||||
});
|
||||
// O que aparece fora dos olhos (emoji/figurinha "ao redor", notas, estrelas, corações).
|
||||
const aoRedor = $derived.by(() => {
|
||||
if (receita?.emoji && receita.emoji.local !== 'olhos') return { emoji: receita.emoji.caractere };
|
||||
if (receita?.figurinha && receita.figurinha.local !== 'olhos') return { figurinha: receita.figurinha };
|
||||
if (['musica', 'estrela', 'coracao'].includes(receita?.simbolo)) return { emoji: SIMBOLO_EMOJI[receita.simbolo] };
|
||||
return null;
|
||||
});
|
||||
// Mesma regra do celular (reacoes-render.js): ciano padrão = cor escolhida; azul-claro = tom claro dela.
|
||||
function corDaReacao(hex: string | undefined): number[] {
|
||||
const h = (hex ?? '').toLowerCase(), base = estado.aparencia.cor;
|
||||
if (!/^#[0-9a-f]{6}$/.test(h) || h === '#5cd6ff') return [...base];
|
||||
if (h === '#aaccff') return base.map((v) => v + (255 - v) * 0.45);
|
||||
return rgb(h);
|
||||
}
|
||||
const estilo = $derived(ROSTOS_INFO[estado.aparencia.rosto]?.novo ? estado.aparencia.rosto : 'vivo');
|
||||
|
||||
$effect(() => {
|
||||
const motor = (window as any).MotorRosto;
|
||||
if (!motor || !tela) return;
|
||||
const d = Math.min(2, devicePixelRatio || 1);
|
||||
tela.width = Math.round(tamanho * 1.5 * d); // 3:2, cabe os dois olhos com folga
|
||||
tela.height = Math.round(tamanho * d);
|
||||
motor.retrato(tela, {
|
||||
estilo,
|
||||
formato: estado.aparencia.formato ?? 'padrao',
|
||||
cor: corDaReacao(passo?.cor),
|
||||
forma: passo?.olhos ?? 'aberto',
|
||||
icone: nosOlhos.icone,
|
||||
desenho: nosOlhos.desenho ? $state.snapshot(nosOlhos.desenho) : null,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="miniatura" style="--t: {tamanho}px" aria-hidden="true">
|
||||
{#if receita?.figurinha?.formas}
|
||||
<svg viewBox="0 0 100 100" width={tamanho} height={tamanho}>
|
||||
{#each receita.figurinha.formas as forma, i (i)}
|
||||
<path d={forma.caminho} fill={forma.cor} />
|
||||
{/each}
|
||||
<canvas bind:this={tela}></canvas>
|
||||
{#if aoRedor?.emoji}
|
||||
<span class="extra">{aoRedor.emoji}</span>
|
||||
{:else if aoRedor?.figurinha}
|
||||
<svg class="extra" viewBox="0 0 100 100">
|
||||
{#each aoRedor.figurinha.formas as forma, i (i)}<path d={forma.caminho} fill={forma.cor} />{/each}
|
||||
</svg>
|
||||
{:else if receita?.emoji}
|
||||
<span class="emoji">{receita.emoji.caractere}</span>
|
||||
{:else}
|
||||
<svg viewBox="0 0 100 60" width={tamanho} height={tamanho * 0.6}>
|
||||
{#if passo?.olhos === 'feliz'}
|
||||
<path d="M18 38 Q30 18 42 38 M58 38 Q70 18 82 38" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
|
||||
{:else if passo?.olhos === 'triste'}
|
||||
<path d="M18 26 Q30 44 42 26 M58 26 Q70 44 82 26" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
|
||||
{:else}
|
||||
{@const ry = passo?.olhos === 'surpreso' ? 20 : 16}
|
||||
<ellipse cx="30" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
|
||||
<ellipse cx="70" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
|
||||
{/if}
|
||||
</svg>
|
||||
{#if simbolo}<span class="simbolo">{simbolo}</span>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.miniatura {
|
||||
position: relative;
|
||||
width: var(--t);
|
||||
width: calc(var(--t) * 1.5);
|
||||
height: var(--t);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--raio);
|
||||
background: #05080c;
|
||||
background: #000;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.emoji {
|
||||
font-size: calc(var(--t) * 0.55);
|
||||
line-height: 1;
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.simbolo {
|
||||
.extra {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 0;
|
||||
font-size: calc(var(--t) * 0.3);
|
||||
right: 4px;
|
||||
top: 3px;
|
||||
width: calc(var(--t) * 0.34);
|
||||
height: calc(var(--t) * 0.34);
|
||||
font-size: calc(var(--t) * 0.26);
|
||||
line-height: calc(var(--t) * 0.34);
|
||||
text-align: center;
|
||||
filter: drop-shadow(0 0 4px rgb(92 214 255 / 0.6));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { estado } from '../estado.svelte';
|
||||
// Selo ao vivo do PIR: "Presença agora" por 8 s depois de cada disparo (o HC-SR505 segura ~8 s).
|
||||
const seg = $derived(estado.pir?.ultimo ? Math.max(0, Math.round((estado.agora - estado.pir.ultimo * 1000) / 1000)) : null);
|
||||
const agora = $derived(seg !== null && seg < 8);
|
||||
const texto = $derived(
|
||||
!estado.pir?.disponivel ? 'sensor indisponível'
|
||||
: agora ? 'presença agora'
|
||||
: seg === null ? 'aguardando presença'
|
||||
: seg < 60 ? `último há ${seg} s` : seg < 3600 ? `último há ${Math.round(seg / 60)} min` : `último há ${Math.round(seg / 3600)} h`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class="pir" class:agora class:off={!estado.pir?.disponivel} aria-live="polite">
|
||||
<span class="ponto" aria-hidden="true"></span>{texto}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.pir {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--raio-pill);
|
||||
font-size: var(--t-xs);
|
||||
font-weight: 600;
|
||||
background: var(--superficie-2);
|
||||
color: var(--texto-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ponto {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.agora {
|
||||
background: var(--ok-suave);
|
||||
color: var(--ok);
|
||||
}
|
||||
.agora .ponto {
|
||||
animation: pulso 1s ease-in-out infinite;
|
||||
}
|
||||
.off {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@keyframes pulso {
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px rgb(52 199 89 / 0.25);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,8 @@
|
||||
import { onDestroy } from 'svelte';
|
||||
import { estado, ouvir } from '../estado.svelte';
|
||||
|
||||
let { titulo = 'Prévia do rosto' }: { titulo?: string } = $props();
|
||||
/** espelharSono: dorme/acorda junto com o celular (ele adormece sozinho no silêncio). */
|
||||
let { titulo = 'Prévia do rosto', espelharSono = true }: { titulo?: string; espelharSono?: boolean } = $props();
|
||||
let quadro: HTMLIFrameElement;
|
||||
let pronto = $state(false);
|
||||
|
||||
@@ -20,6 +21,13 @@
|
||||
if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) });
|
||||
}
|
||||
|
||||
// Sono que o celular decidiu sozinho (silêncio, toque, barulho) não passa pelo SSE de ordens:
|
||||
// vem na telemetria. Sem celular online, a prévia fica como está.
|
||||
const dormindoNoCelular = $derived(estado.celularOnline ? !!estado.app?.estado?.dormindo : null);
|
||||
$effect(() => {
|
||||
if (espelharSono && pronto && dormindoNoCelular !== null) enviar({ tipo: 'sono', dormindo: dormindoNoCelular });
|
||||
});
|
||||
|
||||
let ultimaConversa = estado.conversa?.ultima?.id;
|
||||
const cancelar = [
|
||||
ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })),
|
||||
@@ -31,11 +39,22 @@
|
||||
ultimaConversa = c.ultima.id;
|
||||
enviar({ tipo: 'ordem', ordem: 'acordar' });
|
||||
enviar({ tipo: 'ordem', ordem: c.ultima.gesto });
|
||||
enviar({ tipo: 'legenda', texto: c.ultima.texto });
|
||||
}
|
||||
}),
|
||||
];
|
||||
onDestroy(() => cancelar.forEach((c) => c()));
|
||||
|
||||
/** Mostra uma ordem (gesto, rosto, formato) só na prévia, sem mandar ao celular. */
|
||||
export function ordem(texto: string) {
|
||||
enviar({ tipo: 'ordem', ordem: texto });
|
||||
}
|
||||
|
||||
/** Mostra uma legenda na faixa de baixo da prévia. */
|
||||
export function legenda(texto: string) {
|
||||
enviar({ tipo: 'legenda', texto });
|
||||
}
|
||||
|
||||
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */
|
||||
export function previa(receita: unknown) {
|
||||
enviar({ tipo: 'previa-reacao', receita });
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Nenhuma tela faz polling; todas leem daqui.
|
||||
*/
|
||||
import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
|
||||
import { avisos } from './avisos.svelte';
|
||||
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos';
|
||||
|
||||
export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
|
||||
@@ -19,7 +20,8 @@ class Estado {
|
||||
versao = $state('');
|
||||
autenticacao = $state(false);
|
||||
estop = $state({ ativo: false, desde: null as number | null });
|
||||
aparencia = $state({ rosto: 'carinha', cor: [92, 214, 255] as [number, number, number] });
|
||||
aparencia = $state<{ rosto: string; cor: [number, number, number]; formato?: string }>({ rosto: 'carinha', cor: [92, 214, 255], formato: 'padrao' });
|
||||
formatos = $state<string[]>([]);
|
||||
gestos = $state<string[]>([]);
|
||||
rostos = $state<string[]>([]);
|
||||
app = $state<Telemetria | null>(null);
|
||||
@@ -32,10 +34,24 @@ class Estado {
|
||||
treinamento = $state({ ocupado: false, erro: '' });
|
||||
api = $state<EstadoServidor['api'] | null>(null);
|
||||
feed = $state<ItemFeed[]>([]);
|
||||
/** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */
|
||||
pir = $state<{ disponivel: boolean; ultimo: number | null; ativo?: boolean; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null);
|
||||
/** Relógio de 1 s para derivar "online" sem esperar evento novo. */
|
||||
agora = $state(Date.now());
|
||||
|
||||
celularOnline = $derived(!!this.app?.online && this.agora - this.appRecebidoEm < 6000);
|
||||
/** Quando o celular mandou o último sinal (0 = nunca, nesta sessão nem no servidor). */
|
||||
ultimoSinalEm = $state(0);
|
||||
/** Bateria do último sinal recebido, mesmo com o celular offline. */
|
||||
bateria = $derived.by(() => {
|
||||
const b = this.app?.estado?.bateria;
|
||||
const n = typeof b === 'number' ? b : b?.nivel;
|
||||
return {
|
||||
nivel: typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null,
|
||||
carregando: typeof b === 'object' && !!b?.carregando,
|
||||
};
|
||||
});
|
||||
bateriaBaixa = $derived(this.bateria.nivel !== null && this.bateria.nivel <= 15 && !this.bateria.carregando);
|
||||
}
|
||||
|
||||
export const estado = new Estado();
|
||||
@@ -50,6 +66,23 @@ export function ouvir(tipo: string, fn: Ouvinte): () => void {
|
||||
return () => ouvintes.get(tipo)!.delete(fn);
|
||||
}
|
||||
|
||||
// Avisa uma vez em 15% e outra em 5% (sem carregador); zera quando volta a carregar.
|
||||
let avisouEm: number | null = null;
|
||||
function avisarBateria() {
|
||||
const { nivel, carregando } = estado.bateria;
|
||||
if (nivel === null) return;
|
||||
if (carregando || nivel > 20) {
|
||||
avisouEm = null;
|
||||
return;
|
||||
}
|
||||
const limite = nivel <= 5 ? 5 : nivel <= 15 ? 15 : null;
|
||||
if (limite !== null && avisouEm !== limite) {
|
||||
avisouEm = limite;
|
||||
avisos.mostrar(`Bateria do celular em ${nivel}%. Ligue o carregador ou o Carinha vai desligar.`, 'erro');
|
||||
noFeed('estop', `Bateria do celular em ${nivel}%`);
|
||||
}
|
||||
}
|
||||
|
||||
let seqFeed = 0;
|
||||
function noFeed(tipo: string, texto: string) {
|
||||
estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40);
|
||||
@@ -62,9 +95,11 @@ function aplicarFoto(f: EstadoServidor) {
|
||||
estado.aparencia = f.aparencia;
|
||||
estado.gestos = f.gestos;
|
||||
estado.rostos = f.rostos;
|
||||
estado.formatos = f.formatos ?? [];
|
||||
if (f.app) {
|
||||
estado.app = f.app;
|
||||
estado.appRecebidoEm = Date.now();
|
||||
estado.ultimoSinalEm = typeof f.app.idade === 'number' ? Date.now() - f.app.idade * 1000 : 0;
|
||||
}
|
||||
if (f.app_config) estado.appConfig = f.app_config;
|
||||
if (f.motores) estado.motores = f.motores;
|
||||
@@ -73,6 +108,7 @@ function aplicarFoto(f: EstadoServidor) {
|
||||
estado.reacao = f.reacao ?? null;
|
||||
if (f.treinamento) estado.treinamento = f.treinamento;
|
||||
if (f.api) estado.api = f.api;
|
||||
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, ativo: (f.pir as any).ativo, presenca: (f.pir as any).presenca };
|
||||
}
|
||||
|
||||
const tratadores: Record<string, (d: any) => void> = {
|
||||
@@ -81,7 +117,8 @@ const tratadores: Record<string, (d: any) => void> = {
|
||||
},
|
||||
telemetria: (d: Telemetria) => {
|
||||
estado.app = d;
|
||||
estado.appRecebidoEm = Date.now();
|
||||
estado.appRecebidoEm = estado.ultimoSinalEm = Date.now();
|
||||
avisarBateria();
|
||||
},
|
||||
app_config: (d: AppConfig) => (estado.appConfig = d),
|
||||
aparencia: (d) => (estado.aparencia = d),
|
||||
@@ -105,6 +142,11 @@ const tratadores: Record<string, (d: any) => void> = {
|
||||
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
|
||||
},
|
||||
treinamento: (d) => (estado.treinamento = d),
|
||||
pir: (d: { ultimo: number; ativo?: boolean; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, ativo: d.ativo, presenca: d.presenca }),
|
||||
historico: (d) => {
|
||||
const n = d?.novo;
|
||||
if (n) noFeed('historico', { pir: '🚶 Presença no PIR', movimento: '👀 Movimento na câmera', som: '🔊 Som alto' }[n.tipo as string] ?? 'Detecção');
|
||||
},
|
||||
};
|
||||
|
||||
let fonte: EventSource | null = null;
|
||||
@@ -127,7 +169,7 @@ function abrirEventos() {
|
||||
if (estado.conexao !== 'token') estado.conexao = 'caiu';
|
||||
};
|
||||
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
|
||||
'reacao', 'treinamento']) {
|
||||
'reacao', 'treinamento', 'historico', 'pir']) {
|
||||
fonte.addEventListener(tipo, (e) => {
|
||||
const dados = JSON.parse((e as MessageEvent).data);
|
||||
tratadores[tipo]?.(dados);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/** Histórico de detecções (/historico/*): recarrega ao abrir e quando o servidor avisa por SSE. */
|
||||
import { lerToken, pedir, postar } from './api';
|
||||
import { avisos } from './avisos.svelte';
|
||||
import { ouvir } from './estado.svelte';
|
||||
|
||||
export interface EventoHistorico {
|
||||
id: number;
|
||||
horario: number;
|
||||
tipo: 'pir' | 'movimento' | 'som';
|
||||
evento: string;
|
||||
nivel: number | null;
|
||||
foto: boolean;
|
||||
}
|
||||
|
||||
export interface StatusHistorico {
|
||||
config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number; dias: number; presenca: boolean; ausencia: number };
|
||||
presenca: { estado: 'presente' | 'ausente'; desde: number; ultimo: number };
|
||||
pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
|
||||
eventos: EventoHistorico[];
|
||||
total: number;
|
||||
max_eventos: number;
|
||||
max_fotos: number;
|
||||
}
|
||||
|
||||
export const TIPOS_EVENTO: Record<string, { nome: string; emoji: string; cor: string }> = {
|
||||
pir: { nome: 'Presença (PIR)', emoji: '🚶', cor: '#34c759' },
|
||||
movimento: { nome: 'Movimento na câmera', emoji: '👀', cor: '#0a84ff' },
|
||||
som: { nome: 'Som alto', emoji: '🔊', cor: '#ff9500' },
|
||||
};
|
||||
|
||||
/** URL da foto com o token na query (a tag <img> não manda cabeçalho). */
|
||||
export function urlFoto(id: number) {
|
||||
const t = lerToken();
|
||||
return `/historico/foto/evento-${id}.jpg` + (t ? '?t=' + encodeURIComponent(t) : '');
|
||||
}
|
||||
|
||||
class Historico {
|
||||
status = $state<StatusHistorico | null>(null);
|
||||
private espera = 0;
|
||||
|
||||
async carregar() {
|
||||
try {
|
||||
this.status = await pedir<StatusHistorico>('/historico/status');
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
async configurar(parcial: Partial<StatusHistorico['config']>) {
|
||||
const antes = this.status ? { ...this.status.config } : null;
|
||||
if (this.status) this.status = { ...this.status, config: { ...this.status.config, ...parcial } };
|
||||
try {
|
||||
this.status = await postar<StatusHistorico>('/historico/config', parcial);
|
||||
} catch (e) {
|
||||
if (this.status && antes) this.status = { ...this.status, config: antes };
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
async apagar(id: number) {
|
||||
try {
|
||||
await postar('/historico/apagar', { id });
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
async limpar() {
|
||||
try {
|
||||
await postar('/historico/limpar');
|
||||
avisos.ok('Histórico apagado.');
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Vários avisos seguidos (evento + foto) viram uma recarga só. */
|
||||
agendar() {
|
||||
clearTimeout(this.espera);
|
||||
this.espera = window.setTimeout(() => this.carregar(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
export const historico = new Historico();
|
||||
ouvir('historico', () => historico.status && historico.agendar());
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Registro da command palette (Ctrl+K): cada parte do painel contribui com itens. */
|
||||
import { cor, CORES, gesto, GESTOS_INFO, gestosDisponiveis, pararTudo, rearmar, recarregarCelular, rosto } from './comandos.svelte';
|
||||
import {
|
||||
cor, CORES, formato, FORMATOS_INFO, gesto, GESTOS_INFO, gestosDisponiveis, nomeRosto, pararTudo, rearmar, recarregarCelular, rosto,
|
||||
} from './comandos.svelte';
|
||||
import { estado } from './estado.svelte';
|
||||
import { nav, ROTAS } from './rotas.svelte';
|
||||
|
||||
@@ -33,6 +35,8 @@ export function itensDaPaleta(): ItemPaleta[] {
|
||||
// Itens base: navegação, ações gerais, gestos, rostos e cores.
|
||||
registrarNaPaleta(() => [
|
||||
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
|
||||
{ id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') },
|
||||
...estado.formatos.map((f) => ({ id: 'formato-' + f, grupo: 'Formato dos olhos', titulo: FORMATOS_INFO[f] ?? f, fazer: () => formato(f) })),
|
||||
estado.estop.ativo
|
||||
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
|
||||
: { id: 'estop', grupo: 'Ações', titulo: 'Parar tudo (E-Stop)', dica: 'Shift+Espaço', fazer: pararTudo },
|
||||
@@ -43,6 +47,6 @@ registrarNaPaleta(() => [
|
||||
titulo: `${GESTOS_INFO[g].emoji} ${GESTOS_INFO[g].nome}`,
|
||||
fazer: () => gesto(g),
|
||||
})),
|
||||
...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + r, fazer: () => rosto(r) })),
|
||||
...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + nomeRosto(r), fazer: () => rosto(r) })),
|
||||
...CORES.map((c) => ({ id: 'cor-' + c.nome, grupo: 'Cor dos olhos', titulo: c.nome, fazer: () => cor(c.rgb) })),
|
||||
]);
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
|
||||
|
||||
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes';
|
||||
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto';
|
||||
|
||||
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
|
||||
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
|
||||
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
|
||||
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
|
||||
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
|
||||
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
|
||||
{ id: 'ajustes', titulo: 'Ajustes', atalho: 'g a' },
|
||||
];
|
||||
|
||||
/** Páginas abertas a partir de outra tela (não aparecem na navegação). */
|
||||
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
|
||||
{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' },
|
||||
];
|
||||
|
||||
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
|
||||
|
||||
function lerHash(): Rota {
|
||||
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
|
||||
return ROTAS.some((r) => r.id === id) ? id : 'ao-vivo';
|
||||
let id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
|
||||
if ((id as string) === 'historico') id = 'eventos'; // endereço antigo
|
||||
return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
|
||||
}
|
||||
|
||||
class Navegacao {
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface Telemetria {
|
||||
bateria?: { nivel?: number; carregando?: boolean } | number | null;
|
||||
brilho?: number;
|
||||
video?: { qualidade?: string } | null;
|
||||
/** Informado pelo próprio app (APK 0.8.1+). */
|
||||
aparelho?: { ip?: string | null; modelo?: string; android?: string; app?: string | null; wifi?: number; rssi?: number } | null;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
preview: boolean;
|
||||
@@ -67,7 +69,8 @@ export interface EstadoServidor {
|
||||
seq: number;
|
||||
autenticacao: boolean;
|
||||
estop: { ativo: boolean; desde: number | null };
|
||||
aparencia: { rosto: string; cor: [number, number, number] };
|
||||
aparencia: { rosto: string; cor: [number, number, number]; formato?: string };
|
||||
formatos?: string[];
|
||||
gestos: string[];
|
||||
rostos: string[];
|
||||
app?: Telemetria;
|
||||
@@ -78,4 +81,5 @@ export interface EstadoServidor {
|
||||
reacao?: Reacao | null;
|
||||
treinamento?: { ocupado: boolean; erro: string };
|
||||
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
|
||||
pir?: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
|
||||
}
|
||||
|
||||
+252
-238
@@ -1,15 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Battery, Brain, Camera, Download, Ear, Moon, RefreshCw, ScanFace } from '@lucide/svelte';
|
||||
import {
|
||||
Brain, Camera, Download, Eye, Footprints, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
|
||||
} from '@lucide/svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { postar } from '../lib/api';
|
||||
import { salvarApp } from '../lib/ajustes.svelte';
|
||||
import { tentar } from '../lib/avisos.svelte';
|
||||
import { cor, CORES, hex, recarregarCelular, rgb, rosto } from '../lib/comandos.svelte';
|
||||
import Card from '../lib/componentes/Card.svelte';
|
||||
import { FORMATOS_INFO, hex, nomeRosto, recarregarCelular, ROSTOS_INFO } from '../lib/comandos.svelte';
|
||||
import Celular from '../lib/componentes/Celular.svelte';
|
||||
import Gestos from '../lib/componentes/Gestos.svelte';
|
||||
import Grupo from '../lib/componentes/Grupo.svelte';
|
||||
import Interruptor from '../lib/componentes/Interruptor.svelte';
|
||||
import Linha from '../lib/componentes/Linha.svelte';
|
||||
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
|
||||
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
||||
import Selo from '../lib/componentes/Selo.svelte';
|
||||
import Toggle from '../lib/componentes/Toggle.svelte';
|
||||
import { estado } from '../lib/estado.svelte';
|
||||
import { Rastreamento } from '../lib/rastreamento.svelte';
|
||||
import { VideoAoVivo } from '../lib/video.svelte';
|
||||
@@ -19,6 +23,9 @@
|
||||
let video = $state<VideoAoVivo>();
|
||||
let rastreio = $state<Rastreamento>();
|
||||
|
||||
// Palco: câmera grande e rosto no quadrinho do canto; tocar no quadrinho troca os dois.
|
||||
let rostoGrande = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
video = new VideoAoVivo(elVideo);
|
||||
rastreio = new Rastreamento(elVideo, elTela, video);
|
||||
@@ -40,16 +47,16 @@
|
||||
rastreio?.atualizar(cfg, permitido);
|
||||
});
|
||||
|
||||
const sensores = $derived(estado.app?.estado ?? {});
|
||||
const bateria = $derived.by(() => {
|
||||
const b = sensores.bateria;
|
||||
const n = typeof b === 'number' ? b : b?.nivel;
|
||||
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
|
||||
});
|
||||
const pct = (v: unknown) => (typeof v === 'number' ? Math.round(Math.min(1, v) * 100) : 0);
|
||||
const cfg = $derived(estado.appConfig);
|
||||
// Pontinho do PIR sobre a imagem: verde pulsando nos 8 s depois de cada disparo.
|
||||
// aceso enquanto o pino do sensor está alto; servidor antigo (sem `ativo`) cai nos 8 s após o disparo
|
||||
const pirAtivo = $derived(
|
||||
estado.pir?.ativo ?? (!!estado.pir?.ultimo && estado.agora - estado.pir.ultimo * 1000 < 8000),
|
||||
);
|
||||
const transmitindo = $derived(!!cfg?.preview);
|
||||
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
async function foto() {
|
||||
function foto() {
|
||||
if (!elVideo.videoWidth) return;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = elVideo.videoWidth;
|
||||
@@ -61,156 +68,145 @@
|
||||
a.click();
|
||||
}
|
||||
|
||||
const iaLocal = (ativo: boolean) => tentar(() => postar('/ia/config', { ativo }), ativo ? 'Personalidade local ligada.' : 'Personalidade local desligada.');
|
||||
const iaLocal = (ativo: boolean) =>
|
||||
tentar(() => postar('/ia/config', { ativo }), ativo ? 'Personalidade local ligada.' : 'Personalidade local desligada.');
|
||||
</script>
|
||||
|
||||
<div class="ao-vivo">
|
||||
<section class="palco">
|
||||
<div class="quadro camera">
|
||||
<!-- Palco fixo: a câmera continua visível enquanto a lista rola -->
|
||||
<section class="palco" aria-label="Câmera e rosto">
|
||||
{#if estado.pir?.disponivel}
|
||||
<span class="ponto-pir" class:ativo={pirAtivo} title="Sensor de presença (PIR)" aria-live="polite">
|
||||
<span class="luz" aria-hidden="true"></span>
|
||||
PIR{pirAtivo ? ' · presença' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="quadro camera" class:mini={rostoGrande}>
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
|
||||
<canvas bind:this={elTela} class="sobreposicao" aria-hidden="true"></canvas>
|
||||
{#if !video?.exibindo}
|
||||
<div class="vazio">
|
||||
<Camera size={32} />
|
||||
<Camera size={rostoGrande ? 18 : 32} />
|
||||
{#if !rostoGrande}
|
||||
<p>{video?.mensagem ?? 'Carregando…'}</p>
|
||||
{#if !transmitindo && cfg}
|
||||
<button class="btn primario" onclick={() => salvarApp({ preview: true })}><Video size={16} /> Ligar câmera</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if rostoGrande}
|
||||
<button class="trocar" onclick={() => (rostoGrande = false)} aria-label="Mostrar a câmera em destaque"></button>
|
||||
{:else}
|
||||
<div class="rodape">
|
||||
<Selo tom={video?.exibindo ? 'ok' : 'neutro'}>Câmera</Selo>
|
||||
{#if video?.qualidade && video.exibindo}<span class="mono fraco">{video.qualidade}</span>{/if}
|
||||
<span class="ponto" class:ao-vivo-on={video?.exibindo}></span>
|
||||
<span>{video?.exibindo ? 'Ao vivo' : 'Câmera'}</span>
|
||||
{#if video?.qualidade && video.exibindo}<span class="mono">{video.qualidade}</span>{/if}
|
||||
<span class="espaco"></span>
|
||||
{#if video?.exibindo}
|
||||
<button class="btn pequeno" onclick={foto}><Download size={14} /> Foto</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="quadro rosto">
|
||||
|
||||
<div class="quadro rosto" class:mini={!rostoGrande}>
|
||||
<RostoPrevia titulo="Rosto ao vivo" />
|
||||
<div class="rodape">
|
||||
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>Rosto {estado.celularOnline ? 'no celular' : '· prévia'}</Selo>
|
||||
</div>
|
||||
{#if !rostoGrande}
|
||||
<button class="trocar" onclick={() => (rostoGrande = true)} aria-label="Mostrar o rosto em destaque">
|
||||
<Repeat size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="controles">
|
||||
<div class="transmissao">
|
||||
<Toggle
|
||||
rotulo="Transmitir câmera do celular"
|
||||
ajuda="Vídeo direto do celular para este PC (WebRTC)"
|
||||
checked={!!estado.appConfig?.preview}
|
||||
disabled={!estado.appConfig}
|
||||
onchange={(v) => salvarApp({ preview: v })}
|
||||
/>
|
||||
{#if rastreio && estado.appConfig?.preview}
|
||||
<p class="fraco pequeno-texto linha"><ScanFace size={14} /> {rastreio.texto}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="lista">
|
||||
<Grupo titulo="Celular">
|
||||
<Celular />
|
||||
<Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} />
|
||||
</Grupo>
|
||||
|
||||
<div class="grade-cards">
|
||||
<Card titulo="Gestos" descricao="Favoritos com atalho de 1 a 9. Fixe outros na tela de Reações ou pelo Ctrl+K.">
|
||||
<Gestos />
|
||||
</Card>
|
||||
|
||||
<Card titulo="Aparência">
|
||||
<div class="campo">
|
||||
<span>Rosto</span>
|
||||
<div class="segmentado" role="radiogroup" aria-label="Rosto">
|
||||
{#each estado.rostos as r (r)}
|
||||
<button role="radio" aria-checked={estado.aparencia.rosto === r} onclick={() => rosto(r)}>{r}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="campo">
|
||||
<span>Cor dos olhos</span>
|
||||
<div class="cores">
|
||||
{#each CORES as c (c.nome)}
|
||||
<button
|
||||
class="cor"
|
||||
style="--c: {hex(c.rgb)}"
|
||||
aria-label={c.nome}
|
||||
aria-pressed={hex(estado.aparencia.cor) === hex(c.rgb)}
|
||||
onclick={() => cor(c.rgb)}
|
||||
></button>
|
||||
{/each}
|
||||
<label class="cor livre" style="--c: {hex(estado.aparencia.cor)}" title="Outra cor">
|
||||
<input type="color" value={hex(estado.aparencia.cor)} onchange={(e) => cor(rgb(e.currentTarget.value))} />
|
||||
<span class="sr-only">Escolher outra cor</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card titulo="Celular">
|
||||
{#snippet acoes()}
|
||||
<button class="btn pequeno" onclick={recarregarCelular} disabled={!estado.celularOnline}>
|
||||
<RefreshCw size={14} /> Recarregar tela
|
||||
</button>
|
||||
<Grupo titulo="Câmera" rodape={transmitindo && rastreio ? rastreio.texto : 'O vídeo vai direto do celular para este navegador (WebRTC); nada é gravado.'}>
|
||||
<Linha icone={Video} cor="#34c759" rotulo="Transmitir câmera do celular" detalhe={video?.exibindo ? video.qualidade : ''}>
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Transmitir câmera do celular" checked={transmitindo} disabled={!cfg} onchange={(v) => salvarApp({ preview: v })} />
|
||||
{/snippet}
|
||||
{#if estado.celularOnline}
|
||||
<dl class="sensores">
|
||||
<div>
|
||||
<dt><Battery size={16} /> Bateria</dt>
|
||||
<dd>{bateria !== null ? bateria + '%' : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><Moon size={16} /> Estado</dt>
|
||||
<dd>{sensores.dormindo ? 'Dormindo' : 'Acordado'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><Ear size={16} /> Som</dt>
|
||||
<dd>
|
||||
{#if sensores.microfone_ativo}
|
||||
<meter min="0" max="100" value={pct(sensores.microfone)} aria-label="Nível de som"></meter>
|
||||
{:else}<span class="fraco">desligado</span>{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><Camera size={16} /> Movimento</dt>
|
||||
<dd>
|
||||
{#if sensores.camera_ativa}
|
||||
<meter min="0" max="100" value={pct((sensores.movimento as number) * 5)} aria-label="Movimento"></meter>
|
||||
{:else}<span class="fraco">desligado</span>{/if}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="fraco pequeno-texto mono">{estado.app?.celular_ip ?? ''}</p>
|
||||
{:else}
|
||||
<p class="fraco pequeno-texto">
|
||||
O celular não está enviando sinais. Abra o app Carinha no aparelho e confira se ele aponta para este servidor.
|
||||
</p>
|
||||
</Linha>
|
||||
{#if transmitindo}
|
||||
<Linha icone={ScanFace} cor="#af52de" rotulo="Rastrear rosto">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Rastrear rosto" checked={!!cfg?.rastrearRosto} onchange={(v) => salvarApp({ rastrearRosto: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Hand} cor="#ff9500" rotulo="Rastrear mãos">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Rastrear mãos" checked={!!cfg?.rastrearMaos} onchange={(v) => salvarApp({ rastrearMaos: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Eye} cor="#007aff" rotulo="Olhos seguem o alvo">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Olhos seguem o alvo" checked={!!cfg?.seguirRastreamento} onchange={(v) => salvarApp({ seguirRastreamento: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={PenLine} cor="#8e8e93" rotulo="Desenhar rastreamento no vídeo">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Desenhar rastreamento no vídeo" checked={!!cfg?.mostrarRastreamento} onchange={(v) => salvarApp({ mostrarRastreamento: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
{/if}
|
||||
</Card>
|
||||
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" />
|
||||
</Grupo>
|
||||
|
||||
<Card titulo="Personalidade local" descricao="Qwen escolhe expressões a partir de sons e movimentos.">
|
||||
<Toggle
|
||||
<Grupo titulo="Gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto.">
|
||||
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
|
||||
<Gestos />
|
||||
</Linha>
|
||||
<Linha icone={Sparkles} cor="#ff2d55" rotulo="Todos os gestos e reações" href="#/reacoes" />
|
||||
</Grupo>
|
||||
|
||||
<Grupo titulo="Aparência">
|
||||
<Linha
|
||||
icone={Palette}
|
||||
cor="#ff9500"
|
||||
rotulo="Rosto, formato e cor"
|
||||
detalhe={nomeRosto(estado.aparencia.rosto) + (ROSTOS_INFO[estado.aparencia.rosto]?.formato ? ' · ' + (FORMATOS_INFO[estado.aparencia.formato ?? 'padrao'] ?? '') : '')}
|
||||
href="#/rosto"
|
||||
>
|
||||
{#snippet acessorio()}
|
||||
<span class="amostra-cor" style="--c: {hex(estado.aparencia.cor)}" aria-hidden="true"></span>
|
||||
{/snippet}
|
||||
</Linha>
|
||||
</Grupo>
|
||||
|
||||
<Grupo titulo="Personalidade" rodape={estado.ia?.erro || 'Qwen escolhe expressões a partir de sons e movimentos do celular.'}>
|
||||
<Linha
|
||||
icone={Brain}
|
||||
cor="#5e5ce6"
|
||||
rotulo="Reagir sozinho com IA local"
|
||||
ajuda={estado.ia ? `${estado.ia.modelo} · ${estado.ia.executor}` : ''}
|
||||
checked={!!estado.ia?.ativo}
|
||||
disabled={!estado.ia}
|
||||
onchange={iaLocal}
|
||||
/>
|
||||
{#if estado.ia?.ultima}
|
||||
<p class="pequeno-texto linha">
|
||||
<Brain size={14} /> Último: {estado.ia.ultima.evento} → <strong>{estado.ia.ultima.gesto}</strong>
|
||||
<span class="fraco">({estado.ia.ultima.segundos}s)</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if estado.ia?.erro}<p class="erro pequeno-texto">{estado.ia.erro}</p>{/if}
|
||||
</Card>
|
||||
detalhe={estado.ia?.ultima ? `Último: ${estado.ia.ultima.evento} → ${estado.ia.ultima.gesto} (${estado.ia.ultima.segundos}s)` : estado.ia ? `${estado.ia.modelo} · ${estado.ia.executor}` : ''}
|
||||
>
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Reagir sozinho com IA local" checked={!!estado.ia?.ativo} disabled={!estado.ia} onchange={iaLocal} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
</Grupo>
|
||||
|
||||
<Card titulo="Acontecimentos" class="feed">
|
||||
<Grupo titulo="Acontecimentos">
|
||||
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)">
|
||||
{#snippet acessorio()}<PresencaPir />{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={History} cor="#30b0c7" rotulo="Eventos" detalhe="Presença, movimento e som, com foto" href="#/eventos" />
|
||||
{#if estado.feed.length}
|
||||
<ol>
|
||||
{#each estado.feed as item (item.id)}
|
||||
<li class={item.tipo}><time class="mono fraco">{hora(item.quando)}</time> {item.texto}</li>
|
||||
<ol class="feed">
|
||||
{#each estado.feed.slice(0, 12) as item (item.id)}
|
||||
<li class={item.tipo}><time class="mono">{hora(item.quando)}</time> <span>{item.texto}</span></li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else}
|
||||
<p class="fraco pequeno-texto">Gestos, respostas e reações aparecem aqui assim que acontecerem.</p>
|
||||
<p class="vazio-feed">Gestos, respostas e reações aparecem aqui na hora.</p>
|
||||
{/if}
|
||||
</Card>
|
||||
</Grupo>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -218,19 +214,35 @@
|
||||
.ao-vivo {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
align-items: start;
|
||||
}
|
||||
/* ---------- palco: sempre visível */
|
||||
.palco {
|
||||
display: grid;
|
||||
gap: var(--e-3);
|
||||
grid-template-columns: 1fr;
|
||||
position: sticky;
|
||||
top: calc(var(--topo-altura) + env(safe-area-inset-top, 0px));
|
||||
z-index: 20;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: calc(-1 * var(--e-4)) calc(-1 * var(--e-4)) 0;
|
||||
background: #000;
|
||||
}
|
||||
.quadro {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--raio-lg);
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
border: 1px solid var(--borda);
|
||||
transition: all 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.quadro:not(.mini) {
|
||||
inset: 0;
|
||||
}
|
||||
.quadro.mini {
|
||||
right: var(--e-3);
|
||||
top: var(--e-3);
|
||||
width: 30%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--raio);
|
||||
border: 2px solid rgb(255 255 255 / 0.85);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.5);
|
||||
z-index: 2;
|
||||
}
|
||||
.camera video,
|
||||
.sobreposicao {
|
||||
@@ -249,12 +261,23 @@
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: var(--e-2);
|
||||
gap: var(--e-3);
|
||||
padding: var(--e-5);
|
||||
text-align: center;
|
||||
color: #8396ab;
|
||||
color: #a9b8c9;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
.trocar {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: start end;
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rodape {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -264,127 +287,118 @@
|
||||
align-items: center;
|
||||
gap: var(--e-2);
|
||||
padding: var(--e-2) var(--e-3);
|
||||
background: linear-gradient(transparent, rgb(0 0 0 / 0.7));
|
||||
background: linear-gradient(transparent, rgb(0 0 0 / 0.75));
|
||||
color: #e8eef6;
|
||||
font-size: var(--t-xs);
|
||||
pointer-events: none;
|
||||
}
|
||||
.rodape button {
|
||||
pointer-events: auto;
|
||||
.rodape .mono {
|
||||
opacity: 0.8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ponto {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #8e8e93;
|
||||
flex: none;
|
||||
}
|
||||
.ponto.ao-vivo-on {
|
||||
background: #ff3b30;
|
||||
box-shadow: 0 0 0 3px rgb(255 59 48 / 0.3);
|
||||
}
|
||||
.espaco {
|
||||
flex: 1;
|
||||
}
|
||||
.controles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--e-4);
|
||||
align-items: center;
|
||||
}
|
||||
.transmissao {
|
||||
display: grid;
|
||||
gap: var(--e-1);
|
||||
min-width: min(100%, 420px);
|
||||
}
|
||||
.grade-cards {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
|
||||
}
|
||||
.segmentado {
|
||||
display: flex;
|
||||
background: var(--fundo-2);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio-sm);
|
||||
padding: 3px;
|
||||
gap: 3px;
|
||||
}
|
||||
.segmentado button {
|
||||
flex: 1;
|
||||
min-height: 34px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--texto-2);
|
||||
font: 500 var(--t-sm) var(--fonte);
|
||||
text-transform: capitalize;
|
||||
cursor: pointer;
|
||||
}
|
||||
.segmentado button[aria-checked='true'] {
|
||||
background: var(--superficie-3);
|
||||
color: var(--texto);
|
||||
}
|
||||
.cores {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.cor {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--borda-forte);
|
||||
background: var(--c);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.cor[aria-pressed='true'] {
|
||||
outline: 2px solid var(--texto);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.livre {
|
||||
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
|
||||
}
|
||||
.livre input {
|
||||
.ponto-pir {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sensores {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--e-3);
|
||||
margin: 0;
|
||||
}
|
||||
.sensores div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
dt {
|
||||
display: flex;
|
||||
z-index: 3;
|
||||
left: var(--e-3);
|
||||
top: var(--e-3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--t-xs);
|
||||
color: var(--texto-3);
|
||||
padding: 4px 10px 4px 7px;
|
||||
border-radius: var(--raio-pill);
|
||||
background: rgb(0 0 0 / 0.55);
|
||||
color: #c7cdd4;
|
||||
font: 600 11px var(--fonte);
|
||||
letter-spacing: 0.04em;
|
||||
pointer-events: none;
|
||||
transition: color var(--transicao);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
.ponto-pir .luz {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #6b7280;
|
||||
transition: background var(--transicao);
|
||||
}
|
||||
meter {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
.ponto-pir.ativo {
|
||||
color: #b8f5c9;
|
||||
}
|
||||
.erro {
|
||||
color: var(--perigo);
|
||||
.ponto-pir.ativo .luz {
|
||||
background: #34c759;
|
||||
animation: pulsoPir 1s ease-in-out infinite;
|
||||
}
|
||||
:global(.feed) ol {
|
||||
@keyframes pulsoPir {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgb(52 199 89 / 0.7); }
|
||||
50% { box-shadow: 0 0 0 7px rgb(52 199 89 / 0); }
|
||||
}
|
||||
/* ---------- lista */
|
||||
.lista {
|
||||
display: grid;
|
||||
gap: var(--e-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.amostra-cor {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--c);
|
||||
border: 2px solid var(--borda-forte);
|
||||
}
|
||||
.feed {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
max-height: 260px;
|
||||
padding: var(--e-2) 0;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
:global(.feed) li.estop {
|
||||
.feed li {
|
||||
display: flex;
|
||||
gap: var(--e-3);
|
||||
padding: 6px var(--e-4);
|
||||
}
|
||||
.feed time {
|
||||
color: var(--texto-3);
|
||||
flex: none;
|
||||
}
|
||||
.feed li.estop {
|
||||
color: var(--aviso);
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
.vazio-feed {
|
||||
padding: var(--e-4);
|
||||
font-size: var(--t-sm);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.palco {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin: 0;
|
||||
border-radius: var(--raio-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--borda);
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.ao-vivo {
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(340px, 1fr);
|
||||
gap: var(--e-5);
|
||||
}
|
||||
.palco {
|
||||
top: calc(var(--topo-altura) + var(--e-5));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
<script lang="ts">
|
||||
import { Camera, Ear, Footprints, Image as ImageIcon, Moon, Timer, Trash2, Video } from '@lucide/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import Grupo from '../lib/componentes/Grupo.svelte';
|
||||
import Interruptor from '../lib/componentes/Interruptor.svelte';
|
||||
import Linha from '../lib/componentes/Linha.svelte';
|
||||
import Modal from '../lib/componentes/Modal.svelte';
|
||||
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
|
||||
import Slider from '../lib/componentes/Slider.svelte';
|
||||
import { estado } from '../lib/estado.svelte';
|
||||
import { historico, TIPOS_EVENTO, urlFoto, type EventoHistorico } from '../lib/historico.svelte';
|
||||
|
||||
onMount(() => {
|
||||
historico.carregar();
|
||||
});
|
||||
|
||||
const st = $derived(historico.status);
|
||||
// Presença e movimento em destaque; som é opcional no filtro.
|
||||
type Filtro = 'principais' | 'pir' | 'movimento' | 'som' | 'todos';
|
||||
let filtro = $state<Filtro>('principais');
|
||||
const FILTROS: [Filtro, string][] = [
|
||||
['principais', 'Presença e movimento'], ['pir', '🚶 Presença'], ['movimento', '👀 Movimento'], ['som', '🔊 Som'], ['todos', 'Todos'],
|
||||
];
|
||||
const eventos = $derived(
|
||||
(st?.eventos ?? []).filter((e) => (filtro === 'todos' ? true : filtro === 'principais' ? e.tipo !== 'som' : e.tipo === filtro)),
|
||||
);
|
||||
|
||||
// Agrupa por dia: Hoje, Ontem, depois a data.
|
||||
const dias = $derived.by(() => {
|
||||
const grupos: { titulo: string; itens: EventoHistorico[] }[] = [];
|
||||
const hoje = new Date(estado.agora); hoje.setHours(0, 0, 0, 0);
|
||||
for (const ev of eventos) {
|
||||
const d = new Date(ev.horario * 1000); d.setHours(0, 0, 0, 0);
|
||||
const diff = Math.round((hoje.getTime() - d.getTime()) / 86400000);
|
||||
const titulo = diff === 0 ? 'Hoje' : diff === 1 ? 'Ontem' : d.toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' });
|
||||
if (!grupos.length || grupos[grupos.length - 1].titulo !== titulo) grupos.push({ titulo, itens: [] });
|
||||
grupos[grupos.length - 1].itens.push(ev);
|
||||
}
|
||||
return grupos;
|
||||
});
|
||||
|
||||
const TITULO: Record<string, string> = { pir: 'Presença detectada', movimento: 'Movimento na câmera', som: 'Som alto' };
|
||||
const DETALHE: Record<string, string> = { surpreso: 'deu um susto', acordou: 'acordou o Carinha', presença: 'sensor PIR' };
|
||||
const horaCurta = (t: number) => new Date(t * 1000).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const hora = (t: number) => new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'medium' });
|
||||
const desde = (t: number) => {
|
||||
const min = Math.max(0, Math.round((estado.agora - t * 1000) / 60000));
|
||||
return min < 1 ? 'agora' : min < 60 ? `há ${min} min` : `há ${Math.round(min / 60)} h`;
|
||||
};
|
||||
|
||||
const presenca = $derived(st?.presenca);
|
||||
let aberta = $state<EventoHistorico | null>(null);
|
||||
let modal = $state(false);
|
||||
let confirmar = $state(false);
|
||||
function verFoto(ev: EventoHistorico) {
|
||||
aberta = ev;
|
||||
modal = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="eventos">
|
||||
<header>
|
||||
<div class="linha entre">
|
||||
<h1>Eventos</h1>
|
||||
{#if st?.config.pir}<PresencaPir />{/if}
|
||||
</div>
|
||||
<p class="fraco">
|
||||
O que o sensor de presença e a câmera do celular perceberam, com uma foto de cada momento. Guardamos {st?.config.dias ?? 3} dias.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="colunas">
|
||||
<section class="lista" aria-label="Lista de eventos">
|
||||
<div class="filtros" role="radiogroup" aria-label="Filtrar eventos">
|
||||
{#each FILTROS as [id, nome] (id)}
|
||||
<button role="radio" aria-checked={filtro === id} onclick={() => (filtro = id)}>{nome}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !st}
|
||||
<p class="fraco">Carregando eventos…</p>
|
||||
{:else if !eventos.length}
|
||||
<div class="vazio">
|
||||
<p>Nenhum evento {filtro === 'todos' ? 'nos últimos dias' : 'com esse filtro'}.</p>
|
||||
<p class="fraco pequeno-texto">Quando alguém passar na frente do sensor ou a câmera perceber movimento, aparece aqui na hora.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each dias as dia (dia.titulo)}
|
||||
<Grupo titulo={`${dia.titulo} · ${dia.itens.length}`}>
|
||||
{#each dia.itens as ev (ev.id)}
|
||||
{@const info = TIPOS_EVENTO[ev.tipo]}
|
||||
<div class="evento">
|
||||
<span class="icone" style="--c: {info.cor}" aria-hidden="true">{info.emoji}</span>
|
||||
<div class="texto">
|
||||
<strong>{TITULO[ev.tipo] ?? ev.tipo}</strong>
|
||||
<span class="fraco pequeno-texto">
|
||||
{horaCurta(ev.horario)} · {DETALHE[ev.evento] ?? ev.evento}{ev.nivel !== null && ev.tipo !== 'pir' ? ` · nível ${Math.round(ev.nivel * 100)}%` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{#if ev.foto}
|
||||
<button class="ver-foto" onclick={() => verFoto(ev)} aria-label="Ver foto de {horaCurta(ev.horario)}">
|
||||
<img src={urlFoto(ev.id)} alt="" loading="lazy" />
|
||||
<span>Ver foto</span>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="sem-foto fraco pequeno-texto">sem foto</span>
|
||||
{/if}
|
||||
<button class="btn pequeno icone fantasma" aria-label="Apagar evento" onclick={() => historico.apagar(ev.id)}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
</Grupo>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<aside class="lado">
|
||||
<Grupo
|
||||
titulo="Modo presença"
|
||||
rodape="Alguém aparece: o PIR liga a câmera do celular e acorda o Carinha. Ninguém pelo tempo escolhido: a câmera desliga, ele dorme e fica em silêncio."
|
||||
>
|
||||
<Linha
|
||||
icone={Footprints}
|
||||
cor="#34c759"
|
||||
rotulo="Câmera ligada pela presença"
|
||||
detalhe={presenca ? (presenca.estado === 'presente' ? `alguém por perto · último movimento ${desde(presenca.ultimo)}` : `ninguém por perto ${desde(presenca.desde)}`) : ''}
|
||||
>
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Câmera ligada pela presença" checked={!!st?.config.presenca} disabled={!st} onchange={(v) => historico.configurar({ presenca: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Moon} cor="#5e5ce6" rotulo="Sem presença por">
|
||||
<Slider rotulo="Desligar câmera e dormir depois de" valor={st?.config.ausencia ?? 5} min={1} max={60} passo={1} unidade=" min" onchange={(v) => historico.configurar({ ausencia: v })} />
|
||||
</Linha>
|
||||
</Grupo>
|
||||
|
||||
<Grupo titulo="O que registrar">
|
||||
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)" detalhe={st?.pir.disponivel ? 'Pino 11 · HC-SR505' : st?.pir.erro || 'indisponível'}>
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Sensor de presença (PIR)" checked={!!st?.config.pir} disabled={!st} onchange={(v) => historico.configurar({ pir: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Video} cor="#0a84ff" rotulo="Movimento na câmera">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Movimento na câmera" checked={!!st?.config.movimento} disabled={!st} onchange={(v) => historico.configurar({ movimento: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Ear} cor="#ff9500" rotulo="Som alto">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Som alto" checked={!!st?.config.som} disabled={!st} onchange={(v) => historico.configurar({ som: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Camera} cor="#af52de" rotulo="Tirar uma foto por evento" detalhe="Câmera frontal do S10">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Tirar uma foto por evento" checked={!!st?.config.foto} disabled={!st} onchange={(v) => historico.configurar({ foto: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Timer} cor="#8e8e93" rotulo="Intervalo entre eventos do mesmo tipo">
|
||||
<Slider rotulo="Intervalo" valor={st?.config.intervalo ?? 15} min={0} max={120} passo={5} unidade=" s" onchange={(v) => historico.configurar({ intervalo: v })} />
|
||||
</Linha>
|
||||
<Linha icone={ImageIcon} cor="#30b0c7" rotulo="Guardar">
|
||||
<Slider rotulo="Dias de histórico" valor={st?.config.dias ?? 3} min={1} max={30} passo={1} unidade=" dias" onchange={(v) => historico.configurar({ dias: v })} />
|
||||
</Linha>
|
||||
</Grupo>
|
||||
|
||||
<div class="limpar">
|
||||
{#if confirmar}
|
||||
<p class="pequeno-texto">Apagar todos os eventos e fotos?</p>
|
||||
<div class="linha">
|
||||
<button class="btn perigo pequeno" onclick={() => { confirmar = false; historico.limpar(); }}>Apagar tudo</button>
|
||||
<button class="btn pequeno" onclick={() => (confirmar = false)}>Cancelar</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="btn fantasma pequeno" onclick={() => (confirmar = true)} disabled={!st?.total}><Trash2 size={14} /> Limpar eventos</button>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:aberto={modal} titulo={aberta ? `${TITULO[aberta.tipo]} · ${hora(aberta.horario)}` : 'Foto'}>
|
||||
{#if aberta}<img class="grande" src={urlFoto(aberta.id)} alt="Foto do evento" />{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.eventos {
|
||||
display: grid;
|
||||
gap: var(--e-5);
|
||||
}
|
||||
header {
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
header p {
|
||||
max-width: 70ch;
|
||||
}
|
||||
.colunas {
|
||||
display: grid;
|
||||
gap: var(--e-5);
|
||||
align-items: start;
|
||||
}
|
||||
.lista,
|
||||
.lado {
|
||||
display: grid;
|
||||
gap: var(--e-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.filtros {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.filtros button {
|
||||
min-height: 34px;
|
||||
padding: 0 var(--e-3);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio-pill);
|
||||
background: var(--superficie);
|
||||
color: var(--texto-2);
|
||||
font: 500 var(--t-sm) var(--fonte);
|
||||
cursor: pointer;
|
||||
}
|
||||
.filtros button[aria-checked='true'] {
|
||||
background: var(--destaque);
|
||||
border-color: var(--destaque);
|
||||
color: var(--destaque-texto);
|
||||
}
|
||||
.vazio {
|
||||
display: grid;
|
||||
gap: var(--e-1);
|
||||
padding: var(--e-6);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--borda-forte);
|
||||
border-radius: var(--raio-lg);
|
||||
}
|
||||
.evento {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-3);
|
||||
padding: var(--e-2) var(--e-3) var(--e-2) var(--e-4);
|
||||
min-height: 64px;
|
||||
}
|
||||
.evento + .evento {
|
||||
border-top: 1px solid var(--borda);
|
||||
}
|
||||
.icone {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--c) 18%, transparent);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.texto {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.ver-foto {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-2);
|
||||
padding: 3px var(--e-3) 3px 3px;
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie-2);
|
||||
color: var(--destaque);
|
||||
font: 600 var(--t-xs) var(--fonte);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.ver-foto img {
|
||||
width: 56px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.sem-foto {
|
||||
flex: none;
|
||||
}
|
||||
.limpar {
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
padding: 0 var(--e-2);
|
||||
}
|
||||
.grande {
|
||||
width: 100%;
|
||||
border-radius: var(--raio);
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.ver-foto span {
|
||||
display: none;
|
||||
}
|
||||
.ver-foto {
|
||||
padding: 3px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1100px) {
|
||||
.colunas {
|
||||
grid-template-columns: 1fr 380px;
|
||||
}
|
||||
.lado {
|
||||
position: sticky;
|
||||
top: calc(var(--topo-altura) + var(--e-5));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -204,7 +204,7 @@
|
||||
</Card>
|
||||
</div>
|
||||
{:else if aba === 'gestos'}
|
||||
<Card titulo="Gestos do rosto" descricao="Toque para mandar ao celular. A estrela fixa o gesto nos favoritos (atalhos 1 a 9).">
|
||||
<Card titulo="Gestos do rosto" descricao="Toque para mandar ao celular. A estrela fixa o gesto nos favoritos (até 12; as teclas 1 a 9 valem para os nove primeiros).">
|
||||
<Gestos completo />
|
||||
</Card>
|
||||
{:else}
|
||||
@@ -249,7 +249,7 @@
|
||||
</div>
|
||||
|
||||
<aside class="lateral">
|
||||
<div class="quadro"><RostoPrevia bind:this={previa} titulo="Prévia das reações" /></div>
|
||||
<div class="quadro"><RostoPrevia bind:this={previa} titulo="Prévia das reações" espelharSono={false} /></div>
|
||||
<p class="fraco pequeno-texto">Clique numa reação para ver aqui sem mandar ao celular.</p>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -364,6 +364,14 @@
|
||||
.espaco {
|
||||
flex: 1;
|
||||
}
|
||||
/* seis ações pequenas + Mostrar cabem numa linha num cartão de 300 px */
|
||||
.acoes :global(.btn.pequeno.icone) {
|
||||
width: 28px;
|
||||
}
|
||||
.acoes {
|
||||
flex-wrap: nowrap;
|
||||
gap: 2px;
|
||||
}
|
||||
.marcado {
|
||||
color: var(--ok);
|
||||
border-color: var(--ok);
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, Check } from '@lucide/svelte';
|
||||
import {
|
||||
cor, CORES, FORMATOS_INFO, formato, gesto, GESTOS_INFO, hex, nomeRosto, rgb, rosto, ROSTOS_INFO,
|
||||
} from '../lib/comandos.svelte';
|
||||
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
||||
import Slider from '../lib/componentes/Slider.svelte';
|
||||
import Toggle from '../lib/componentes/Toggle.svelte';
|
||||
import { salvarApp } from '../lib/ajustes.svelte';
|
||||
import { estado } from '../lib/estado.svelte';
|
||||
|
||||
let previa = $state<{ ordem: (t: string) => void; legenda: (t: string) => void }>();
|
||||
// Ouvir a voz de robô aqui no PC (o clique na tecla libera o áudio do navegador).
|
||||
let ouvirAqui = $state(true);
|
||||
let audio: AudioContext | null = null;
|
||||
function voz(): AudioContext | null {
|
||||
const AC = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (!ouvirAqui || !AC || !(window as any).VozRobo) return null;
|
||||
audio ??= new AC();
|
||||
if (audio.state === 'suspended') audio.resume();
|
||||
return audio;
|
||||
}
|
||||
const FRASE = 'Oi! Eu sou o Carinha. Quer brincar comigo?';
|
||||
function testarFala() {
|
||||
previa?.legenda(FRASE);
|
||||
const c = voz();
|
||||
if (c) (window as any).VozRobo.balbuciar(c, FRASE, 0.5);
|
||||
}
|
||||
// As emoções vão para o celular (e a prévia mostra junto); "só na prévia" serve para testar quieto.
|
||||
let soPrevia = $state(false);
|
||||
|
||||
const atual = $derived(estado.aparencia.rosto);
|
||||
const formatoAtual = $derived(estado.aparencia.formato ?? 'padrao');
|
||||
const aceitaFormato = $derived(!!ROSTOS_INFO[atual]?.formato);
|
||||
const novos = $derived(estado.rostos.filter((r) => ROSTOS_INFO[r]?.novo));
|
||||
const classicos = $derived(estado.rostos.filter((r) => !ROSTOS_INFO[r]?.novo));
|
||||
const formatos = $derived(estado.formatos.length ? estado.formatos : Object.keys(FORMATOS_INFO));
|
||||
|
||||
const EMOCOES = ['feliz', 'coracao', 'surpreso', 'triste', 'bravo', 'piscadinha', 'acenar', 'esconder', 'espiar', 'pensar', 'musica', 'dormir', 'acordar'];
|
||||
function emocao(g: string) {
|
||||
if (soPrevia) previa?.ordem(g);
|
||||
else gesto(g);
|
||||
const c = voz();
|
||||
if (c) (window as any).VozRobo.tocar(c, g, 0.5);
|
||||
}
|
||||
|
||||
const IDEIAS: { titulo: string; texto: string; feito: boolean; onde: string }[] = [
|
||||
{ titulo: 'Pálpebras no lugar de formas prontas', texto: 'Cada emoção é posição de pálpebra; as trocas ficam suaves em vez de saltar.', feito: true, onde: 'rosto' },
|
||||
{ titulo: 'Nunca parado', texto: 'Respiração, sacadas e piscada que achata o olho. Também protege a tela AMOLED.', feito: true, onde: 'rosto' },
|
||||
{ titulo: 'Toque na tela', texto: 'Cutucar dá susto e risada; deslizar o dedo é carinho; tocar acorda.', feito: true, onde: 'rosto' },
|
||||
{ titulo: 'Mãos', texto: 'Mãozinhas na cor dos olhos: esconde o rosto, espia, acena e pensa com a mão no queixo.', feito: true, onde: 'rosto' },
|
||||
{ titulo: 'Faixa de baixo', texto: 'Legenda da conversa sendo digitada, pontinhos enquanto pensa, equalizador ao dançar e carga quando está no carregador.', feito: true, onde: 'rosto' },
|
||||
{ titulo: 'Voz de robô', texto: 'Chilreios, “uou!”, “ohh” e um balbucio quando responde, sintetizados na hora; motorzinho quando as mãos se mexem.', feito: true, onde: 'rosto' },
|
||||
{ titulo: 'Fala pelo próprio celular', texto: 'A resposta da conversa sai no alto-falante do S10, com os olhos pulsando na voz.', feito: false, onde: 'app' },
|
||||
{ titulo: 'Ritmo do dia', texto: 'Boceja à noite, fica sonolento com bateria baixa, mostra um raio quando carrega.', feito: false, onde: 'rosto e servidor' },
|
||||
{ titulo: 'Contato visual', texto: 'Sustenta o olhar de quem a câmera vê e desvia quando fica tímido.', feito: false, onde: 'rosto' },
|
||||
{ titulo: 'Humor que dura', texto: 'Energia e alegria sobem com conversa e carinho e caem devagar.', feito: false, onde: 'servidor e IA' },
|
||||
{ titulo: 'Hora nos olhos', texto: 'Ao perguntar a hora, ela aparece dentro dos olhos por alguns segundos.', feito: false, onde: 'rosto' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="rosto-pagina">
|
||||
<a class="voltar" href="#/ao-vivo"><ArrowLeft size={16} /> Ao vivo</a>
|
||||
|
||||
<header>
|
||||
<span class="rotulo">Galaxy S10 · 19:9 · AMOLED</span>
|
||||
<h1>Rosto do Carinha</h1>
|
||||
<p>Escolha o rosto, o formato dos olhos e a cor. A prévia abaixo é o mesmo rosto que roda no celular; clique nela para cutucar.</p>
|
||||
</header>
|
||||
|
||||
<section class="bancada" aria-label="Prévia">
|
||||
<div class="celular">
|
||||
<div class="tela"><RostoPrevia bind:this={previa} titulo="Prévia do rosto" espelharSono={false} /></div>
|
||||
<span class="camera-frontal" aria-hidden="true"></span>
|
||||
<span class="etiqueta">{nomeRosto(atual).toUpperCase()}{aceitaFormato ? ' · ' + (FORMATOS_INFO[formatoAtual] ?? formatoAtual).toUpperCase() : ''}</span>
|
||||
</div>
|
||||
|
||||
<div class="emocoes" role="group" aria-label="Testar emoção">
|
||||
{#each EMOCOES.filter((g) => GESTOS_INFO[g]) as g (g)}
|
||||
<button class="tecla" onclick={() => emocao(g)}>{GESTOS_INFO[g].emoji} {GESTOS_INFO[g].nome}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="linha">
|
||||
<button class="tecla" onclick={testarFala}>🗣️ Testar fala</button>
|
||||
</div>
|
||||
<Toggle
|
||||
rotulo="Ouvir a voz de robô aqui no PC"
|
||||
ajuda="Sons sintetizados na hora, inspirados nos robozinhos de cinema"
|
||||
checked={ouvirAqui}
|
||||
onchange={(v) => (ouvirAqui = v)}
|
||||
/>
|
||||
<Toggle
|
||||
rotulo="Testar só na prévia, sem mandar ao celular"
|
||||
ajuda="Desligado, cada emoção aparece no celular na hora"
|
||||
checked={soPrevia}
|
||||
onchange={(v) => (soPrevia = v)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="bloco-escolha" aria-labelledby="t-rostos">
|
||||
<h2 id="t-rostos">Rosto</h2>
|
||||
<div class="cartoes" role="radiogroup" aria-label="Rosto">
|
||||
{#each novos as r (r)}
|
||||
<button class="cartao" role="radio" aria-checked={atual === r} onclick={() => rosto(r)}>
|
||||
<span class="nome">{ROSTOS_INFO[r].nome} {#if atual === r}<Check size={16} />{/if}</span>
|
||||
<span class="desc">{ROSTOS_INFO[r].descricao}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if classicos.length}
|
||||
<div class="classicos" role="radiogroup" aria-label="Rostos clássicos">
|
||||
<span class="rotulo">Clássicos</span>
|
||||
{#each classicos as r (r)}
|
||||
<button class="tecla" role="radio" aria-checked={atual === r} onclick={() => rosto(r)}>{nomeRosto(r)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="bloco-escolha" aria-labelledby="t-formato">
|
||||
<h2 id="t-formato">Formato dos olhos</h2>
|
||||
<div class="formatos" role="radiogroup" aria-label="Formato dos olhos">
|
||||
{#each formatos as f (f)}
|
||||
<button class="formato" role="radio" aria-checked={formatoAtual === f} disabled={!aceitaFormato} onclick={() => formato(f)}>
|
||||
<span class="amostra {f}" aria-hidden="true"></span>
|
||||
{FORMATOS_INFO[f] ?? f}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !aceitaFormato}
|
||||
<p class="fraco pequeno-texto">O formato vale para Carinha Viva, Bloco e Matriz.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="bloco-escolha" aria-labelledby="t-detalhes">
|
||||
<h2 id="t-detalhes">Detalhes do rosto</h2>
|
||||
<div class="detalhes">
|
||||
<Toggle rotulo="Boca" checked={estado.appConfig?.boca !== false} disabled={!estado.appConfig} onchange={(v) => salvarApp({ boca: v })} />
|
||||
<Toggle rotulo="Sobrancelhas" checked={!!estado.appConfig?.sobrancelhas} disabled={!estado.appConfig} onchange={(v) => salvarApp({ sobrancelhas: v })} />
|
||||
<Toggle
|
||||
rotulo="Íris"
|
||||
ajuda={aceitaFormato ? 'Pupila que acompanha o olhar' : 'Vale para Viva, Bloco e Matriz'}
|
||||
checked={!!estado.appConfig?.iris}
|
||||
disabled={!estado.appConfig}
|
||||
onchange={(v) => salvarApp({ iris: v })}
|
||||
/>
|
||||
</div>
|
||||
<div class="detalhes">
|
||||
<Slider
|
||||
rotulo="Subir a boca"
|
||||
valor={Number(estado.appConfig?.subirBoca ?? 0)}
|
||||
min={-20}
|
||||
max={60}
|
||||
passo={2}
|
||||
onchange={(v) => salvarApp({ subirBoca: v })}
|
||||
/>
|
||||
<Slider
|
||||
rotulo="Distância das sobrancelhas"
|
||||
valor={Number(estado.appConfig?.distSobrancelha ?? 16)}
|
||||
min={0}
|
||||
max={60}
|
||||
passo={2}
|
||||
onchange={(v) => salvarApp({ distSobrancelha: v })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bloco-escolha" aria-labelledby="t-cor">
|
||||
<h2 id="t-cor">Cor dos olhos</h2>
|
||||
<div class="cores">
|
||||
{#each CORES as c (c.nome)}
|
||||
<button class="cor" style="--c: {hex(c.rgb)}" aria-label={c.nome} aria-pressed={hex(estado.aparencia.cor) === hex(c.rgb)} onclick={() => cor(c.rgb)}></button>
|
||||
{/each}
|
||||
<label class="cor livre" title="Outra cor">
|
||||
<input type="color" value={hex(estado.aparencia.cor)} onchange={(e) => cor(rgb(e.currentTarget.value))} />
|
||||
<span class="sr-only">Escolher outra cor</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bloco-escolha" aria-labelledby="t-vivo">
|
||||
<h2 id="t-vivo">O que deixa o Carinha vivo</h2>
|
||||
<ul class="ideias">
|
||||
{#each IDEIAS as i (i.titulo)}
|
||||
<li>
|
||||
<div class="linha entre">
|
||||
<h3>{i.titulo}</h3>
|
||||
<span class="status" class:feito={i.feito}>{i.feito ? 'Pronto' : 'Próximo'}</span>
|
||||
</div>
|
||||
<p>{i.texto}</p>
|
||||
<span class="onde">{i.onde}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rosto-pagina {
|
||||
display: grid;
|
||||
gap: var(--e-6);
|
||||
max-width: 1120px;
|
||||
}
|
||||
.voltar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--e-1);
|
||||
width: fit-content;
|
||||
color: var(--texto-2);
|
||||
text-decoration: none;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
header {
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
header h1 {
|
||||
font-size: clamp(1.6rem, 3.5vw, 2.3rem);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
header p {
|
||||
color: var(--texto-2);
|
||||
max-width: 65ch;
|
||||
}
|
||||
.rotulo {
|
||||
font: 500 11px var(--fonte-mono);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.bancada {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
}
|
||||
.celular {
|
||||
position: relative;
|
||||
aspect-ratio: 19 / 9;
|
||||
max-width: 100%;
|
||||
border-radius: clamp(18px, 3.4vw, 40px);
|
||||
padding: clamp(6px, 0.9vw, 12px);
|
||||
background: linear-gradient(145deg, #2a313b, #11161d 45%, #222932);
|
||||
box-shadow: 0 30px 80px rgb(0 0 0 / 0.45), inset 0 1px 0 rgb(255 255 255 / 0.08);
|
||||
}
|
||||
.tela {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: clamp(13px, 2.6vw, 30px);
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
.camera-frontal {
|
||||
position: absolute;
|
||||
left: clamp(14px, 2.2vw, 26px);
|
||||
top: 50%;
|
||||
translate: 0 -50%;
|
||||
width: clamp(6px, 0.8vw, 10px);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 35%, #2b3a55, #05070a 70%);
|
||||
}
|
||||
.etiqueta {
|
||||
position: absolute;
|
||||
right: clamp(20px, 3vw, 36px);
|
||||
top: clamp(14px, 2vw, 24px);
|
||||
font: 500 11px var(--fonte-mono);
|
||||
color: rgb(255 255 255 / 0.4);
|
||||
letter-spacing: 0.08em;
|
||||
pointer-events: none;
|
||||
}
|
||||
.emocoes,
|
||||
.classicos {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--e-2);
|
||||
align-items: center;
|
||||
}
|
||||
.tecla {
|
||||
min-height: 40px;
|
||||
padding: 0 var(--e-4);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio-pill);
|
||||
background: var(--superficie);
|
||||
color: var(--texto);
|
||||
font: 500 var(--t-sm) var(--fonte);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tecla:hover {
|
||||
border-color: var(--destaque);
|
||||
}
|
||||
.tecla[aria-checked='true'] {
|
||||
background: var(--destaque);
|
||||
border-color: var(--destaque);
|
||||
color: var(--destaque-texto);
|
||||
}
|
||||
.bloco-escolha {
|
||||
display: grid;
|
||||
gap: var(--e-3);
|
||||
}
|
||||
.bloco-escolha h2 {
|
||||
font-size: var(--t-lg);
|
||||
}
|
||||
.cartoes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--e-3);
|
||||
}
|
||||
.cartao {
|
||||
display: grid;
|
||||
gap: var(--e-1);
|
||||
align-content: start;
|
||||
padding: var(--e-4);
|
||||
text-align: left;
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie);
|
||||
color: var(--texto);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cartao:hover {
|
||||
border-color: var(--borda-forte);
|
||||
}
|
||||
.cartao[aria-checked='true'] {
|
||||
border-color: var(--destaque);
|
||||
background: var(--destaque-suave);
|
||||
}
|
||||
.nome {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nome :global(svg) {
|
||||
color: var(--destaque);
|
||||
}
|
||||
.desc {
|
||||
font-size: var(--t-sm);
|
||||
color: var(--texto-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.formatos {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.formato {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--e-2);
|
||||
padding: var(--e-3) var(--e-2);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie);
|
||||
color: var(--texto-2);
|
||||
font: 500 var(--t-sm) var(--fonte);
|
||||
cursor: pointer;
|
||||
}
|
||||
.formato[aria-checked='true'] {
|
||||
border-color: var(--destaque);
|
||||
color: var(--texto);
|
||||
}
|
||||
.formato:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* amostras com as mesmas proporções do rosto-motor.js */
|
||||
.amostra {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 29px;
|
||||
border-radius: 10px;
|
||||
background: var(--destaque);
|
||||
}
|
||||
.amostra.redondo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.amostra.pilula {
|
||||
width: 16px;
|
||||
height: 31px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.amostra.quadrado {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.amostra.largo {
|
||||
width: 33px;
|
||||
height: 18px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.detalhes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
|
||||
gap: var(--e-2) var(--e-6);
|
||||
max-width: 780px;
|
||||
}
|
||||
.cores {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.cor {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--borda-forte);
|
||||
background: var(--c);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.cor[aria-pressed='true'] {
|
||||
outline: 2px solid var(--texto);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.livre {
|
||||
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
|
||||
}
|
||||
.livre input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ideias {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--borda);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ideias li {
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
align-content: start;
|
||||
padding: var(--e-4) var(--e-5);
|
||||
background: var(--superficie);
|
||||
}
|
||||
.ideias h3 {
|
||||
font-size: var(--t-md);
|
||||
}
|
||||
.ideias p {
|
||||
font-size: var(--t-sm);
|
||||
color: var(--texto-2);
|
||||
}
|
||||
.status {
|
||||
flex: none;
|
||||
font: 500 11px var(--fonte-mono);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--raio-pill);
|
||||
border: 1px solid var(--borda-forte);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.status.feito {
|
||||
color: var(--ok);
|
||||
border-color: transparent;
|
||||
background: var(--ok-suave);
|
||||
}
|
||||
.onde {
|
||||
font: 500 11px var(--fonte-mono);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.cartoes {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.ideias {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.formatos {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100';
|
||||
const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos',
|
||||
'/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face',
|
||||
'/carinha.html', '/reacoes-render.js', '/rastreador-worker.js', '/vendor', '/qualidade-auto.js', '/app.apk'];
|
||||
'/carinha.html', '/reacoes-render.js', '/rosto-motor.js', '/voz-robo.js', '/rastreador-worker.js', '/vendor', '/qualidade-auto.js', '/app.apk'];
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
|
||||
Reference in New Issue
Block a user