Compare commits

..
Author SHA1 Message Date
julioandClaude Opus 5.5 8d410dbf24 fix(ao-vivo): rosto do painel dorme e acorda junto com o celular
O celular adormece sozinho (silêncio sem movimento) e acorda com som,
movimento ou toque; isso vem na telemetria, não no SSE de ordens, e a
prévia do painel continuava acordada. Ao vivo e mini-rosto agora seguem
o `dormindo` da telemetria; as prévias de edição (Rosto, Reações) não.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:08:25 -03:00
julioandClaude Opus 5.5 fd6a14054d fix(pir): acompanha o nível do pino em vez de só a borda de subida
O HC-SR505 segura a saída em alto ~8 s e rearma a cada movimento; com
alguém na frente o pino ficava alto direto e passar a mão não gerava
borda nova, então o painel parecia não detectar nada. Agora o gpiomon
escuta as duas bordas, o nível inicial vem do gpioget e o status/SSE
levam `ativo`. A bolinha do Ao vivo fica acesa enquanto o pino está
alto, e o modo presença não manda dormir com o sensor ainda segurando.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:04:41 -03:00
julioandClaude Opus 5.5 784042335c fix(deploy): tira o BOM do script enviado à placa
O PowerShell 5.1 manda BOM no stdin do ssh e o bash lia "<BOM>set -e"
como comando inexistente, rodando a instalação sem parar no primeiro erro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:57:53 -03:00
julioandClaude Opus 5.5 cf54a93b53 feat(ao-vivo): pontinho do PIR sobre a imagem do palco
Verde pulsando por 8 s depois de cada disparo, cinza sem presença;
some quando o sensor não está disponível.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:56:48 -03:00
julioandClaude Opus 5.5 499a4ae479 feat(eventos): menu Eventos, modo presenca e 3 dias de historico
- "Eventos" vira item do menu principal: lista por dia (Hoje, Ontem...),
  presenca e movimento em destaque, botao "Ver foto" com miniatura
- modo presenca: o PIR liga a camera do celular e acorda o Carinha; sem
  ninguem por N minutos (5 por padrao) a camera desliga, ele dorme e fica
  em silencio (sons e fala desligados enquanto dorme)
- historico guarda so os ultimos 3 dias (ajustavel de 1 a 30), com faxina
  automatica de eventos e fotos antigos
- testes da retencao e do modo presenca

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:51:49 -03:00
julioandClaude Opus 5.5 6029c79645 feat(ao-vivo): indicador ao vivo do sensor PIR
Cada disparo do PIR vira um evento SSE 'pir' (mesmo dentro do intervalo do
historico) e o painel mostra 'presenca agora' por 8 s em Ao vivo e no
Historico; /estado inclui o estado do PIR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:47:11 -03:00
julioandClaude Opus 5.5 4b9e805801 fix(historico): ignora a borda falsa do PIR ao iniciar a leitura do pino
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:43:09 -03:00
julioandClaude Opus 5.5 1445ffdcde fix(historico): pull-down no pino do PIR evita disparos falsos com o sensor desligado
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:42:10 -03:00
julioandClaude Opus 5.5 788215b49e feat(historico): deteccoes com foto, sensor PIR na placa e tela apagando no sono
- historico.py: eventos de PIR (gpiomon no pino 11, gpiochip1 linha 70),
  movimento e som do celular em SQLite; ate 200 eventos e 100 fotos;
  cada sensor e a foto ligam/desligam; intervalo minimo por tipo
- PIR acorda o Carinha quando ele esta dormindo
- APK 0.8.3: o app consulta o pedido de foto e envia uma foto da camera
  frontal (quadro da camera de movimento ou do video ao vivo)
- tela apaga depois de X minutos dormindo (preto total + brilho minimo no
  AMOLED); acende com som, movimento, toque, comando ou PIR
- painel: tela Historico (sensores, fotos, filtros, apagar), atalho em Ao
  vivo e no Ctrl+K, ajustes "Apagar a tela quando dormir"
- testes do historico (servico e rotas HTTP)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:40:13 -03:00
julioandClaude Opus 5.5 98c9e8803e fix(rosto): expressoes e reacoes mantem a cor escolhida
Surpreso, triste e dormindo usavam azuis fixos; agora usam tons da cor
escolhida (mais clara no susto, mais escura e apagada na tristeza e no
sono). Nas reacoes da biblioteca o ciano padrao vira a cor escolhida e o
azul-claro um tom claro dela, no celular e nas miniaturas do painel.
Bravo (vermelho) e coracao (rosa) continuam, pois fazem parte da emocao.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:26:45 -03:00
julioandClaude Opus 5.5 2dab45604c feat(rosto): dormindo com olhos fechados e Zzz subindo
Dormindo, os rostos novos mostram os olhos como tracos (-) e tres Z de
tamanhos diferentes saem do canto do olho direito, sobem crescendo e somem
em ciclo. Na Matriz a boca vira '_' (- _ -) e os Z sao maiores, em LEDs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:19:58 -03:00
julioandClaude Opus 5.5 982bf56cd0 feat(rosto): sobrancelha perto do olho, boca mais alta e ajustes de posicao
A sobrancelha passa a ser posicionada a partir do topo real do olho de cada
estilo e formato (antes usava a altura do olho classico, alto demais nos
rostos novos). Nos rostos novos a boca sobe um pouco. Novos ajustes
'Subir a boca' e 'Distancia das sobrancelhas' (tela Rosto e Ajustes),
validados no servidor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:18:17 -03:00
julioandClaude Opus 5.5 1029b5fa99 feat(rosto): iris, boca e sobrancelhas na Matriz; emocoes da tela Rosto vao ao celular
- opcao "iris" (app config, Ajustes e tela Rosto): anel mais escuro com
  pupila que acompanha o olhar, dilata na alegria e contrai no susto; na
  Matriz vira LEDs apagados
- Matriz desenha boca e sobrancelhas em LEDs (antes a tela de pontos
  cobria o SVG); Bloco e Lente passam a obedecer o ajuste de boca
- tela Rosto: boca, sobrancelhas e iris ali mesmo; as teclas de emocao
  agora vao ao celular por padrao ("testar so na previa" e opcional)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:15:39 -03:00
julioandClaude Opus 5.5 a67c5ea9af fix(android): transmissao de video nao trava quando o servidor reinicia
Se o servidor caia no meio da transmissao, o app tentava reabrir a camera
ainda presa pelo sensor de movimento; o getUserMedia ficava esperando para
sempre e a fila de pedidos do painel travava (video "desligado" ate
reiniciar o app). Agora abrir a camera tem prazo de 8 s, cada pedido do
painel tem prazo de 12 s e uma camera encerrada de repente desfaz a sessao
para a proxima mensagem reabrir. Validado no S10: servidor reiniciado com
video ativo volta sozinho em ~8 s. APK 0.8.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:10:39 -03:00
julioandClaude Opus 5.5 e0dd03411d fix(voz): tira o timbre de sapo; voz aguda, limpa e com brilho metalico
O coaxar vinha do zumbido de 72 Hz, da ressonancia grave (~230 Hz), da
oitava de baixo e do FM desalinhado. Agora: ressonancia de lata fina
(~850 Hz), FM harmonico leve, realce em 3,2 kHz, oitava de baixo quase
nula e tom 25% acima. Bravo vira bipes zangados e pensar vira 'hmm?' com
bipinhos; nenhum som tem energia relevante abaixo de 250 Hz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:02:14 -03:00
julioandClaude Opus 5.5 74ae2863a7 feat(voz): timbre mais robotico e metalico
Modulacao em anel (72 Hz), ressonancia de lata (atraso de 4,3 ms
realimentado), degraus digitais com saturacao suave e FM com razao 1,41
nas silabas. Volume geral ajustado para compensar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:59:25 -03:00
julioandClaude Opus 5.5 f7c4793dc2 feat(voz): voz de robo sintetizada no estilo dos robozinhos de cinema
- voz-robo.js: silabas com deslize de tom, vibrato e filtro de formante
  ("uou", "ohh"), motorzinho de ruido filtrado e cliques; tudo gerado na
  hora com Web Audio, sem gravacoes nem arquivos de terceiros
- um som proprio para cada gesto, incluindo maos (motor ao subir e descer)
- balbucio de robo na resposta da conversa (mesma frase, mesmo som; sobe
  no fim quando e pergunta) e silencio dos gestos enquanto ele "fala"
- tela Rosto: ouvir a voz no PC e botao "Testar fala"; bipes antigos
  ficam como reserva

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:57:32 -03:00
julioandClaude Opus 5.5 319e63cc42 feat(rosto): esconder e espiar com maos inclinadas e movimento em arco
As maos sobem em arco a partir dos cantos de baixo, girando no caminho,
com um leve passa-e-volta e uma chegando um instante depois da outra.
Ficam inclinadas para dentro (~24 graus) mostrando o dorso (nos dos
dedos, polegar para fora) e sem tapar o olho inteiro. Espiar gira a mao
pelo punho como uma porta; acenar tambem balanca pelo punho.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:52:39 -03:00
julioandClaude Opus 5.5 f550930f56 feat(gestos): gestos com maos nos favoritos e ate 12 favoritos
Acenar, esconder, espiar, pensar e dancar entram nos favoritos padrao
(logo apos feliz e coracao). Quem ja tinha favoritos salvos recebe esses
gestos uma vez, sem perder os seus. Teclas 1 a 9 para os nove primeiros.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:48:21 -03:00
julioandClaude Opus 5.5 230ca991a2 feat(rosto): mao com dedos, polegar e punho no lugar da luva
Quatro dedos separados (medio maior, levemente abertos), polegar para o
lado do rosto, punho de robo e contorno so na borda externa. Ao acenar os
dedos balancam; ao pensar o punho fecha com o indicador no rosto; para
esconder e espiar as maos cobrem o olho inteiro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:45:17 -03:00
julioandClaude Opus 5.5 628db8492f feat(rosto): maos (esconder, espiar, acenar, pensar) e faixa de baixo
- rosto-motor.js desenha luvinhas na cor dos olhos que sobem da borda da
  tela, sincronizadas com as sequencias de rosto de mesmo nome
- faixa de baixo: legenda da resposta da conversa com efeito de digitacao,
  tres pontinhos enquanto a IA pensa, equalizador ao dancar e indicador de
  carga (raio e porcentagem) quando o celular esta no carregador
- gestos novos no servidor, no painel e na tela Rosto; a IA (Qwen/Gemini)
  pode responder acenando, se escondendo ou pensando
- a previa do painel tambem mostra a legenda da conversa

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:39:00 -03:00
julioandClaude Opus 5.5 f45e05ab81 feat(ao-vivo): avisa bateria baixa e mostra o ultimo sinal do celular
- aviso no painel quando a bateria do celular chega a 15% e a 5% sem
  carregador (uma vez cada; volta a valer depois de carregar)
- selo do celular fica vermelho com bateria baixa
- celular offline mostra ha quanto tempo foi o ultimo sinal e com quanta
  bateria; abaixo de 10% sugere que a bateria acabou

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:24:33 -03:00
julioandClaude Opus 5.5 69018c8b36 feat(reacoes): emojis e figurinhas no mesmo padrao do rosto novo
- rosto-motor.js desenha emoji e figurinha dentro dos olhos no estilo e
  formato escolhidos (brilho, piscada; na Lente no lugar da iris; na
  Matriz os LEDs usam as cores do desenho)
- MotorRosto.retrato desenha um retrato parado em qualquer canvas
- miniaturas da biblioteca no painel usam o mesmo motor, no rosto, formato
  e cor atuais do robo (3:2, com o simbolo "ao redor" no canto)
- emoji e figurinha "ao redor" ganham o brilho na cor da reacao
- cartoes da biblioteca com as acoes numa linha so

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:19:19 -03:00
39 changed files with 2753 additions and 152 deletions
+3
View File
@@ -23,3 +23,6 @@ api-privada.tmp
ia-config.json ia-config.json
seguranca.json seguranca.json
historico.db
historico.json
fotos/
+2 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="br.carinha.rosto" package="br.carinha.rosto"
android:versionCode="9" android:versionCode="11"
android:versionName="0.8.1"> android:versionName="0.8.3">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" /> <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
+129 -14
View File
@@ -26,6 +26,7 @@
</svg> </svg>
<div id="fps"></div> <div id="fps"></div>
<script src="rosto-motor.js"></script> <script src="rosto-motor.js"></script>
<script src="voz-robo.js"></script>
<script> <script>
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao /* 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. */ próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
@@ -91,12 +92,33 @@ addEventListener("resize", ajustar);
const PRETO = [6, 8, 14]; const PRETO = [6, 8, 14];
const CIANO = [92, 214, 255]; 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 CIANO_E = [150, 232, 255];
const VERM = [255, 78, 78]; const VERM = [255, 78, 78];
const ROSA = [255, 112, 176]; const ROSA = [255, 112, 176];
const AMARELO = [255, 218, 96]; const AMARELO = [255, 218, 96];
const AZUL = [130, 170, 255]; 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 VERDE = [120, 255, 160];
const BRANCO = [245, 250, 255]; const BRANCO = [245, 250, 255];
@@ -149,8 +171,18 @@ function caminhoLinha(x0, y0, x1, y1) {
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1; return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
} }
/* formas da boca (o humor escolhe qual) */ /* 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) { 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" if (tipo === "aberta") { // surpresa: um "O"
const rx = l * 0.17, ry = l * 0.22; const rx = l * 0.17, ry = l * 0.22;
return caminhoElipse(cx, cy, rx, ry); return caminhoElipse(cx, cy, rx, ry);
@@ -291,7 +323,7 @@ function sequencias() {
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]]; 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], 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]]; [{ 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], [{ dy: -10 }, 0.5],
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]]; [{ 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], const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
@@ -300,13 +332,21 @@ function sequencias() {
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15], 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], [{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
[{ forma: "aberto", cor: CIANO }, 0.3]]; [{ 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]]; [{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5], const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
[{ forma2: "aberto", esc: 1.0 }, 0.18]]; [{ 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], const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]]; [{ 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 }; bravo, coracao, triste, piscadinha, alegre };
} }
@@ -325,6 +365,7 @@ function proximo() {
const [alvo, dur] = r.fila[0]; 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.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
r.cor_ini = r.cor.slice(); 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 ("forma" in alvo) r.forma = alvo.forma;
if ("forma2" in alvo) r.forma2 = alvo.forma2; if ("forma2" in alvo) r.forma2 = alvo.forma2;
if ("boca" in alvo) r.boca = alvo.boca; if ("boca" in alvo) r.boca = alvo.boca;
@@ -359,7 +400,7 @@ function passo(dt) { // avanca a fila; true se ha gesto em
function repouso(t) { // pose calma: olha devagar, "respira" function repouso(t) { // pose calma: olha devagar, "respira"
if (r.dormindo) { if (r.dormindo) {
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono"; 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.ab = 0; r.ab2 = 0;
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4); r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
r.dx = 0; r.dx = 0;
@@ -388,11 +429,16 @@ function desenhar() {
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0); 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 dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]]; const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
const comMotor = ROSTO.motor && window.MotorRosto && !window.carinhaOlhosIcone && !window.carinhaOlhosDesenho; const comMotor = ROSTO.motor && window.MotorRosto;
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; }); OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
if (comMotor) { if (comMotor) {
window.MotorRosto.desenhar({ window.MotorRosto.desenhar({
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo, 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, parado: !r.kf && !seguindo && !r.dormindo,
olhos: olhos.map(([c, abx, fx, lado]) => ({ olhos: olhos.map(([c, abx, fx, lado]) => ({
cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc, cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc,
@@ -405,16 +451,17 @@ function desenhar() {
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc, 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); abx, fx, r.cor, lado, dx, dy * 0.6);
} }
if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none"; const matriz = comMotor && ROSTO.motor === "matriz";
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
else desenharBoca(); else desenharBoca();
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1)); 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; if (!EXTRAS.sobrancelhas || r.dormindo) continue;
const c = i === 0 ? E1 : E2; const c = i === 0 ? E1 : E2;
const lado = i === 0 ? 1 : -1; const lado = i === 0 ? 1 : -1;
const largura = RX * esc * 0.85; 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 inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const elevacao = r.esc > 1.1 ? -15 * 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)); brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
@@ -423,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 // ------------------------------------------------------------------ controle
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */ /* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
function trocarRosto(nome) { function trocarRosto(nome) {
@@ -466,6 +534,10 @@ function trata(ordem) {
} else if (ordem === "aleatorio") { } else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) { } else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
}
animar(ordem); animar(ordem);
} else if (ordem === "calma") { } else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro. // E-Stop do painel: interrompe a animação e volta ao rosto neutro.
@@ -486,7 +558,7 @@ function pose(nome) {
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) { for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k]; 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; congelado = true;
desenhar(); desenhar();
return true; return true;
@@ -541,6 +613,8 @@ if (MODO_PAINEL) {
const m = e.data; const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem); 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 === '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); } else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
@@ -659,6 +733,16 @@ function checarSilencio(agora) {
journal do carinha-web (so quando o microfone esta ativo). */ journal do carinha-web (so quando o microfone esta ativo). */
function relatarSom(evento, n) { function relatarSom(evento, n) {
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} 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) // ------------------------------------------------------ presenca (movimento)
@@ -747,6 +831,7 @@ function movimento(vel, cx, cy) {
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */ /* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
function relatarMov(evento, n) { function relatarMov(evento, n) {
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
registrarEvento("movimento", evento, n);
} }
// ------------------------------------------------- camera oculta (sensor) // ------------------------------------------------- camera oculta (sensor)
@@ -877,7 +962,11 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
let configRecebida = null; let configRecebida = null;
function configurarApp(c) { 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; AUTO = c.auto;
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume; AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir; COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
@@ -907,6 +996,7 @@ async function sincronizarApp() {
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2, movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
centro: [MOV.cx, MOV.cy], config: configRecebida, centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho, brilho: window.carinhaBrilho,
tela_apagada: !!window.carinhaTelaApagada,
bateria: window.carinhaBateria, bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho, aparelho: window.carinhaAparelho,
video: window.carinhaVideo, video: window.carinhaVideo,
@@ -953,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); }, 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) { function tocarSom(gesto) {
if (!AUDIO.ligado) return false; 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]; const f = SONS[gesto];
if (!f) return false; if (!voz && !f) return false;
const c = contextoAudio(); const c = contextoAudio();
if (!c) return false; if (!c) return false;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } } if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try { 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 // aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
console.log("voz: " + gesto + " ctx=" + c.state); console.log("voz: " + gesto + " ctx=" + c.state);
return true; return true;
@@ -1048,7 +1155,9 @@ setTimeout(relatar, 1500);
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return; 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;} 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; 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); 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;} }catch(e){}finally{fetching=false;}
} }
setInterval(poll,1000);poll(); setInterval(poll,1000);poll();
@@ -1089,10 +1198,16 @@ if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchPa
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); }); ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
ouvir('app_config', (c) => configurarApp(c)); ouvir('app_config', (c) => configurarApp(c));
ouvir('conversa', (s) => { ouvir('conversa', (s) => {
if (window.MotorRosto) {
if (s.ocupado) window.MotorRosto.faixa('digitando');
else window.MotorRosto.faixa(null, { so: 'digitando' });
}
const m = s.ultima; const m = s.ultima;
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return; if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
ultimaConversa = m.id; ultimaConversa = m.id;
falar(m.texto);
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto); MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
}); });
ouvir('reacao', (d) => { ouvir('reacao', (d) => {
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return; if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
+322 -12
View File
@@ -82,9 +82,35 @@
c.fill(); 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). // Olho plano (vivo, bloco, matriz): corpo + pálpebras recortadas (transparentes).
function olhoPlano(c, o, p, est, e) { function olhoPlano(c, o, p, est, e) {
const respira = 1 + 0.012 * Math.sin(o.t * 1.4); 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 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 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 amassa = fecha > 0.5 && e.top < 0.5 ? (fecha - 0.5) * 0.2 : 0; // piscada achata e alarga
@@ -92,6 +118,11 @@
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6); const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
const x = p.cx - w / 2, y = p.cy - h / 2; const x = p.cx - w / 2, y = p.cy - h / 2;
c.fillStyle = css(o.cor); 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; } 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); caixa(c, x, y, w, h, est.r * p.ry * respira);
c.fill(); c.fill();
@@ -105,7 +136,22 @@
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.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.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'; c.globalCompositeOperation = 'source-over';
if (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar 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 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)); 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.fillStyle = 'rgba(245,250,255,0.9)';
@@ -117,11 +163,15 @@
// Olho lente: esclera, íris, pupila que dilata e pálpebras de metal. // Olho lente: esclera, íris, pupila que dilata e pálpebras de metal.
function olhoLente(c, o, p, est, e) { 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 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(e.top, 1 - Math.min(1, p.ab))); const fecha = Math.min(1, Math.max(o.icone || o.desenho ? 0 : e.top, 1 - Math.min(1, p.ab)));
c.save(); 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 * 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.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); 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 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 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); const g = c.createRadialGradient(ix, iy, R * 0.05, ix, iy, R * 0.55);
@@ -130,6 +180,7 @@
const pupila = p.ab > 1.05 ? 0.12 : e.bot > 0.3 ? 0.3 : 0.2; // susto contrai, alegria dilata 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, 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(); 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); const metal = c.createLinearGradient(0, p.cy - R, 0, p.cy + R);
metal.addColorStop(0, '#5b6675'); metal.addColorStop(1, '#2b323d'); metal.addColorStop(0, '#5b6675'); metal.addColorStop(1, '#2b323d');
c.fillStyle = metal; c.fillStyle = metal;
@@ -173,6 +224,15 @@
bctx.clearRect(0, 0, colunas, linhas); bctx.clearRect(0, 0, colunas, linhas);
bctx.save(); bctx.scale(f, f); bctx.save(); bctx.scale(f, f);
olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i])); 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(); bctx.restore();
const px = bctx.getImageData(0, 0, colunas, linhas).data; const px = bctx.getImageData(0, 0, colunas, linhas).data;
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height); ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height);
@@ -180,7 +240,7 @@
for (let j = 0; j < linhas; j++) for (let i = 0; i < colunas; i++) { 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; 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.beginPath(); ctx.arc((i + 0.5) * passo, (j + 0.5) * passo, raio, 0, Math.PI * 2);
ctx.fillStyle = a > 0.25 ? css(o.cor, 0.35 + a * 0.65) : 'rgba(255,255,255,0.045)'; 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(); ctx.fill();
} }
} }
@@ -207,17 +267,267 @@
ry: p.ry * d, ry: p.ry * d,
})); }));
ctx.clearRect(0, 0, tela.width, tela.height); ctx.clearRect(0, 0, tela.width, tela.height);
if (estilo.pontos) return desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d); if (estilo.pontos) {
if (estilo.lente) { olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); return; } desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d);
cctx.clearRect(0, 0, camada.width, camada.height); } else if (estilo.lente) {
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i])); olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
ctx.save(); desenharMaos(ctx, o, olhos, tela.height);
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); } desenharZzz(ctx, o, olhos);
ctx.drawImage(camada, 0, 0); } else {
ctx.restore(); 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; } function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
window.MotorRosto = { desenhar, esconder, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) }; 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) };
})(); })();
+23 -3
View File
@@ -12,8 +12,13 @@ let config = {}, signature = '', movementEnabled = true, aviso = '', capacidades
let allowed = true; let allowed = true;
let lastMotionFrame = -1; let lastMotionFrame = -1;
function chave(c) { return JSON.stringify([c.cameraLado || 'user', c.videoResolucao || 'auto', c.videoFPS || 0]); } 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) { 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 track = media.getVideoTracks()[0];
const facing = track.getSettings().facingMode; const facing = track.getSettings().facingMode;
if (facing && facing !== (c.cameraLado || 'user')) { if (facing && facing !== (c.cameraLado || 'user')) {
@@ -74,6 +79,12 @@ async function iniciar(id) {
const media = await capturar(config); signature = chave(config); const media = await capturar(config); signature = chave(config);
if (!wanted || captureGeneration !== generation) { media.getTracks().forEach(t => t.stop()); return; } if (!wanted || captureGeneration !== generation) { media.getTracks().forEach(t => t.stop()); return; }
stream = media; video.srcObject = stream; await video.play(); 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:[]}); pc = new RTCPeerConnection({iceServers:[]});
sender = pc.addTrack(stream.getVideoTracks()[0], stream); sender = pc.addTrack(stream.getVideoTracks()[0], stream);
const mine = pc; const mine = pc;
@@ -133,14 +144,23 @@ async function receber(packet) {
} }
window.rtcReceber = packet => { window.rtcReceber = packet => {
allowed = true; 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); console.log('RTC erro=' + e.name + ': ' + e.message);
sinal({tipo:'erro',erro:e.name + ': ' + e.message}); sinal({tipo:'erro',erro:e.name + ': ' + e.message});
estado({tipo:'webrtc',estado:'erro',erro:e.message}); estado({tipo:'webrtc',estado:'erro',erro:e.message});
parar(); parar(); sid = null;
}); });
}; };
window.rtcParar = () => { allowed = false; 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(() => { setInterval(() => {
if (!movementEnabled || !stream || video.readyState < 2 || video.currentTime===lastMotionFrame) return; if (!movementEnabled || !stream || video.readyState < 2 || video.currentTime===lastMotionFrame) return;
lastMotionFrame=video.currentTime; lastMotionFrame=video.currentTime;
+188
View File
@@ -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 volatile ScheduledExecutorService worker;
private long consultarEm; private long consultarEm;
private long quadroEm; 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 long logEm;
private final Runnable mudanca; private final Runnable mudanca;
public interface RTCListener { boolean receber(JSONObject pacote); } public interface RTCListener { boolean receber(JSONObject pacote); }
@@ -118,6 +127,7 @@ public final class ControleRemoto {
mudanca.run(); mudanca.run();
} }
if (videoRTC()) rtcTick(base); if (videoRTC()) rtcTick(base);
fotoTick(base);
byte[] jpeg = pendente; byte[] jpeg = pendente;
pendente = null; pendente = null;
if (jpeg != null && preview()) { if (jpeg != null && preview()) {
@@ -147,10 +157,13 @@ public final class ControleRemoto {
return c; return c;
} }
public void quadro(Image image, int rotacao) { public void quadro(Image image, int rotacao) {
if (videoRTC()) return; boolean querFoto = fotoPedida > 0 && fotoJpeg == null;
long agora = System.currentTimeMillis(); long agora = System.currentTimeMillis();
if (!preview() || pendente != null || agora < quadroEm) return; if (!querFoto) {
quadroEm = agora + 200; if (videoRTC()) return;
if (!preview() || pendente != null || agora < quadroEm) return;
quadroEm = agora + 200;
}
try { try {
Image.Plane[] p = image.getPlanes(); Image.Plane[] p = image.getPlanes();
ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()}; ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()};
@@ -171,10 +184,44 @@ public final class ControleRemoto {
if (rotated != b) rotated.recycle(); if (rotated != b) rotated.recycle();
b.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); } } 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 { private void rtcTick(String base) throws Exception {
String json; String json;
while ((json = sinais.poll()) != null) { while ((json = sinais.poll()) != null) {
@@ -198,6 +198,8 @@ public class MainActivity extends Activity {
} }
atualizarSensores(); atualizarSensores();
atualizarBrilho(); atualizarBrilho();
if (remoto.fotoPendente() > 0 && rtcAtivo)
rtcWeb.evaluateJavascript("window.rtcFoto&&window.rtcFoto()", null);
} }
} }
}); });
@@ -1079,7 +1081,7 @@ public class MainActivity extends Activity {
web.evaluateJavascript("window.carinhaBateria={nivel:" + percent + ",carregando:" + charging + "}", null); 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>() { new ValueCallback<String>() {
@Override public void onReceiveValue(String valor) { @Override public void onReceiveValue(String valor) {
if (!emPrimeiroPlano) return; if (!emPrimeiroPlano) return;
@@ -1091,6 +1093,7 @@ public class MainActivity extends Activity {
float nivel = BrilhoRepouso.nivel(remoto.economia(), remoto.brilho(), remoto.brilhoRepouso(), float nivel = BrilhoRepouso.nivel(remoto.economia(), remoto.brilho(), remoto.brilhoRepouso(),
System.currentTimeMillis() - ultimoUsoTela, remoto.repousoTela(), System.currentTimeMillis() - ultimoUsoTela, remoto.repousoTela(),
estado.optBoolean("ocupado"), estado.optBoolean("dormindo")); estado.optBoolean("ocupado"), estado.optBoolean("dormindo"));
if (estado.optBoolean("apagada")) nivel = 0f; // tela preta + brilho mínimo
WindowManager.LayoutParams attr = getWindow().getAttributes(); WindowManager.LayoutParams attr = getWindow().getAttributes();
if (Math.abs(attr.screenBrightness - nivel) > 0.005f) { if (Math.abs(attr.screenBrightness - nivel) > 0.005f) {
attr.screenBrightness = nivel; attr.screenBrightness = nivel;
@@ -1104,6 +1107,14 @@ public class MainActivity extends Activity {
} }
private final class PonteRTC { 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) { @JavascriptInterface public void rastrear(final String json) {
if (!emPrimeiroPlano || !cameraPermitida() || !remoto.config().optBoolean("seguirRastreamento", true)) return; if (!emPrimeiroPlano || !cameraPermitida() || !remoto.config().optBoolean("seguirRastreamento", true)) return;
try { try {
+1 -1
View File
@@ -30,7 +30,7 @@ def main():
def run(*command): def run(*command):
subprocess.run([str(x) for x in command], check=True) subprocess.run([str(x) for x in command], check=True)
for name in ('carinha.html', 'camera.html', 'rosto-motor.js'): for name in ('carinha.html', 'camera.html', 'rosto-motor.js', 'voz-robo.js'):
shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name) shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name)
with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder: with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder:
tmp = Path(folder) tmp = Path(folder)
+129 -14
View File
@@ -26,6 +26,7 @@
</svg> </svg>
<div id="fps"></div> <div id="fps"></div>
<script src="rosto-motor.js"></script> <script src="rosto-motor.js"></script>
<script src="voz-robo.js"></script>
<script> <script>
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao /* 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. */ próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
@@ -91,12 +92,33 @@ addEventListener("resize", ajustar);
const PRETO = [6, 8, 14]; const PRETO = [6, 8, 14];
const CIANO = [92, 214, 255]; 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 CIANO_E = [150, 232, 255];
const VERM = [255, 78, 78]; const VERM = [255, 78, 78];
const ROSA = [255, 112, 176]; const ROSA = [255, 112, 176];
const AMARELO = [255, 218, 96]; const AMARELO = [255, 218, 96];
const AZUL = [130, 170, 255]; 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 VERDE = [120, 255, 160];
const BRANCO = [245, 250, 255]; const BRANCO = [245, 250, 255];
@@ -149,8 +171,18 @@ function caminhoLinha(x0, y0, x1, y1) {
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1; return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
} }
/* formas da boca (o humor escolhe qual) */ /* 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) { 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" if (tipo === "aberta") { // surpresa: um "O"
const rx = l * 0.17, ry = l * 0.22; const rx = l * 0.17, ry = l * 0.22;
return caminhoElipse(cx, cy, rx, ry); return caminhoElipse(cx, cy, rx, ry);
@@ -291,7 +323,7 @@ function sequencias() {
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]]; 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], 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]]; [{ 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], [{ dy: -10 }, 0.5],
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]]; [{ 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], const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
@@ -300,13 +332,21 @@ function sequencias() {
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15], 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], [{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
[{ forma: "aberto", cor: CIANO }, 0.3]]; [{ 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]]; [{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5], const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
[{ forma2: "aberto", esc: 1.0 }, 0.18]]; [{ 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], const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]]; [{ 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 }; bravo, coracao, triste, piscadinha, alegre };
} }
@@ -325,6 +365,7 @@ function proximo() {
const [alvo, dur] = r.fila[0]; 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.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
r.cor_ini = r.cor.slice(); 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 ("forma" in alvo) r.forma = alvo.forma;
if ("forma2" in alvo) r.forma2 = alvo.forma2; if ("forma2" in alvo) r.forma2 = alvo.forma2;
if ("boca" in alvo) r.boca = alvo.boca; if ("boca" in alvo) r.boca = alvo.boca;
@@ -359,7 +400,7 @@ function passo(dt) { // avanca a fila; true se ha gesto em
function repouso(t) { // pose calma: olha devagar, "respira" function repouso(t) { // pose calma: olha devagar, "respira"
if (r.dormindo) { if (r.dormindo) {
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono"; 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.ab = 0; r.ab2 = 0;
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4); r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
r.dx = 0; r.dx = 0;
@@ -388,11 +429,16 @@ function desenhar() {
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0); 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 dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]]; const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
const comMotor = ROSTO.motor && window.MotorRosto && !window.carinhaOlhosIcone && !window.carinhaOlhosDesenho; const comMotor = ROSTO.motor && window.MotorRosto;
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; }); OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
if (comMotor) { if (comMotor) {
window.MotorRosto.desenhar({ window.MotorRosto.desenhar({
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo, 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, parado: !r.kf && !seguindo && !r.dormindo,
olhos: olhos.map(([c, abx, fx, lado]) => ({ olhos: olhos.map(([c, abx, fx, lado]) => ({
cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc, cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc,
@@ -405,16 +451,17 @@ function desenhar() {
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc, 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); abx, fx, r.cor, lado, dx, dy * 0.6);
} }
if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none"; const matriz = comMotor && ROSTO.motor === "matriz";
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
else desenharBoca(); else desenharBoca();
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1)); 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; if (!EXTRAS.sobrancelhas || r.dormindo) continue;
const c = i === 0 ? E1 : E2; const c = i === 0 ? E1 : E2;
const lado = i === 0 ? 1 : -1; const lado = i === 0 ? 1 : -1;
const largura = RX * esc * 0.85; 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 inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const elevacao = r.esc > 1.1 ? -15 * 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)); brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
@@ -423,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 // ------------------------------------------------------------------ controle
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */ /* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
function trocarRosto(nome) { function trocarRosto(nome) {
@@ -466,6 +534,10 @@ function trata(ordem) {
} else if (ordem === "aleatorio") { } else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) { } else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
}
animar(ordem); animar(ordem);
} else if (ordem === "calma") { } else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro. // E-Stop do painel: interrompe a animação e volta ao rosto neutro.
@@ -486,7 +558,7 @@ function pose(nome) {
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) { for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k]; 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; congelado = true;
desenhar(); desenhar();
return true; return true;
@@ -541,6 +613,8 @@ if (MODO_PAINEL) {
const m = e.data; const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem); 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 === '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); } else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
@@ -659,6 +733,16 @@ function checarSilencio(agora) {
journal do carinha-web (so quando o microfone esta ativo). */ journal do carinha-web (so quando o microfone esta ativo). */
function relatarSom(evento, n) { function relatarSom(evento, n) {
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} 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) // ------------------------------------------------------ presenca (movimento)
@@ -747,6 +831,7 @@ function movimento(vel, cx, cy) {
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */ /* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
function relatarMov(evento, n) { function relatarMov(evento, n) {
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
registrarEvento("movimento", evento, n);
} }
// ------------------------------------------------- camera oculta (sensor) // ------------------------------------------------- camera oculta (sensor)
@@ -877,7 +962,11 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
let configRecebida = null; let configRecebida = null;
function configurarApp(c) { 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; AUTO = c.auto;
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume; AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir; COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
@@ -907,6 +996,7 @@ async function sincronizarApp() {
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2, movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
centro: [MOV.cx, MOV.cy], config: configRecebida, centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho, brilho: window.carinhaBrilho,
tela_apagada: !!window.carinhaTelaApagada,
bateria: window.carinhaBateria, bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho, aparelho: window.carinhaAparelho,
video: window.carinhaVideo, video: window.carinhaVideo,
@@ -953,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); }, 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) { function tocarSom(gesto) {
if (!AUDIO.ligado) return false; 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]; const f = SONS[gesto];
if (!f) return false; if (!voz && !f) return false;
const c = contextoAudio(); const c = contextoAudio();
if (!c) return false; if (!c) return false;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } } if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try { 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 // aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
console.log("voz: " + gesto + " ctx=" + c.state); console.log("voz: " + gesto + " ctx=" + c.state);
return true; return true;
@@ -1048,7 +1155,9 @@ setTimeout(relatar, 1500);
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return; 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;} 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; 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); 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;} }catch(e){}finally{fetching=false;}
} }
setInterval(poll,1000);poll(); setInterval(poll,1000);poll();
@@ -1089,10 +1198,16 @@ if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchPa
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); }); ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
ouvir('app_config', (c) => configurarApp(c)); ouvir('app_config', (c) => configurarApp(c));
ouvir('conversa', (s) => { ouvir('conversa', (s) => {
if (window.MotorRosto) {
if (s.ocupado) window.MotorRosto.faixa('digitando');
else window.MotorRosto.faixa(null, { so: 'digitando' });
}
const m = s.ultima; const m = s.ultima;
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return; if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
ultimaConversa = m.id; ultimaConversa = m.id;
falar(m.texto);
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto); MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
}); });
ouvir('reacao', (d) => { ouvir('reacao', (d) => {
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return; if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
+1 -1
View File
@@ -7,7 +7,7 @@ import time
from urllib.request import Request,urlopen from urllib.request import Request,urlopen
from comum import gravar_atomico, ler_json 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') EVENTOS=('som','movimento','saudacao','sozinho')
def validar_intencao(data): def validar_intencao(data):
if not isinstance(data,dict) or set(data)!= {'gesto'} or data['gesto'] not in GESTOS: if not isinstance(data,dict) or set(data)!= {'gesto'} or data['gesto'] not in GESTOS:
+4 -2
View File
@@ -20,7 +20,8 @@ PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35,
somaltolim=0.55, sompausa=20, somcooldown=5, somaltolim=0.55, sompausa=20, somcooldown=5,
movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40, movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40,
economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30, 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, sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False,
cameraLado='user', videoResolucao='auto', videoFPS=0, cameraLado='user', videoResolucao='auto', videoFPS=0,
rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True) rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True)
@@ -30,7 +31,8 @@ ESCOLHAS = dict(cameraLado=('user', 'environment'),
LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1), LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1),
somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120), somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120),
movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100), 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): def validar(dados):
+4 -3
View File
@@ -27,8 +27,8 @@ Set-Location $PSScriptRoot -ErrorAction Stop
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai). # chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
$arquivos = @( $arquivos = @(
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py', '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', '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', 'version.json', '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' 'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
) )
if (Test-Path 'android/carinha-rosto.apk') { $arquivos += 'android/carinha-rosto.apk' } if (Test-Path 'android/carinha-rosto.apk') { $arquivos += 'android/carinha-rosto.apk' }
@@ -92,7 +92,8 @@ systemctl restart carinha-pc
echo "backup em `$backup" echo "backup em `$backup"
"@ "@
Write-Host '== Instalando na placa' 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)' } if ($LASTEXITCODE) { throw 'instalação na placa falhou (o backup foi feito antes de qualquer remoção)' }
Write-Host '== Conferindo' Write-Host '== Conferindo'
+368
View File
@@ -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
+34 -5
View File
@@ -40,6 +40,7 @@ from cerebro import Cerebro
from conversa import Conversa from conversa import Conversa
from treinamento import Treinamento from treinamento import Treinamento
from api_config import ConfigAPI from api_config import ConfigAPI
from historico import Historico
TRAVA = threading.Lock() TRAVA = threading.Lock()
@@ -51,7 +52,8 @@ FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"]
# Espelho dos gestos do carinha.html (para o painel montar os botoes) # Espelho dos gestos do carinha.html (para o painel montar os botoes)
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz", GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre", "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 que o rosto entende mas não viram botão.
ORDENS_INTERNAS = {"calma"} ORDENS_INTERNAS = {"calma"}
@@ -61,14 +63,14 @@ APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255], "formato": "padrao"}
ARQUIVO_APARENCIA = "aparencia.json" ARQUIVO_APARENCIA = "aparencia.json"
# Arquivos servidos como estão: o rosto do celular e o rastreamento do painel. # 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", "/rosto-motor.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/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_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"} "/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. # 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"} 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/") "/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
@@ -235,7 +237,7 @@ class Handler(SimpleHTTPRequestHandler):
def modulos(self): def modulos(self):
s = self.server 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)] "braco", "servos", "controle") if hasattr(s, nome)]
def estado(self): def estado(self):
@@ -258,6 +260,8 @@ class Handler(SimpleHTTPRequestHandler):
dados["treinamento"] = dict(ocupado=s.treinamento.busy, erro=s.treinamento.erro) dados["treinamento"] = dict(ocupado=s.treinamento.busy, erro=s.treinamento.erro)
if hasattr(s, "api_config"): if hasattr(s, "api_config"):
dados["api"] = s.api_config.status() dados["api"] = s.api_config.status()
if hasattr(s, "historico"):
dados["pir"] = dict(s.historico.pir, presenca=dict(s.historico.presenca))
return dados return dados
def do_GET(self): def do_GET(self):
@@ -450,7 +454,7 @@ class Servidor(ThreadingHTTPServer):
def ligar_eventos(servidor): 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): if hasattr(servidor, nome):
getattr(servidor, nome).publicar = EVENTOS.publicar getattr(servidor, nome).publicar = EVENTOS.publicar
@@ -476,6 +480,31 @@ def main():
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro) servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db') servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar 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 servidor.conversa.treinamento = servidor.treinamento
ligar_eventos(servidor) ligar_eventos(servidor)
servidor.registrar = args.log servidor.registrar = args.log
+11 -3
View File
@@ -1,4 +1,12 @@
(function(){ (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 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); 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=[]; let timer,last=null,fetching=false,animation;let particles=[];
@@ -15,13 +23,13 @@
function visual(recipe){ function visual(recipe){
if(recipe.emoji){ if(recipe.emoji){
const e=recipe.emoji;if(e.local==='olhos'){window.carinhaOlhosIcone=e.caractere;return;} 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; animation=badge.animate([{transform:'scale(.92)'},{transform:'scale(1.06)'},{transform:'scale(.92)'}],{duration:1400,iterations:Infinity});return;
} }
if(recipe.figurinha){ if(recipe.figurinha){
const svg=window.carinhaConstruirSVG(recipe.figurinha);if(!svg)return; const svg=window.carinhaConstruirSVG(recipe.figurinha);if(!svg)return;
if(recipe.figurinha.local==='olhos'){window.carinhaOlhosDesenho=recipe.figurinha;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; 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;} 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; 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; clearTimeout(timer);clearVisual();window.eilik.soltar();trata('acordar');MIC.ultimoSom=performance.now()/1000;
visual(recipe); 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(); 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)); 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));
} }
+322 -12
View File
@@ -82,9 +82,35 @@
c.fill(); 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). // Olho plano (vivo, bloco, matriz): corpo + pálpebras recortadas (transparentes).
function olhoPlano(c, o, p, est, e) { function olhoPlano(c, o, p, est, e) {
const respira = 1 + 0.012 * Math.sin(o.t * 1.4); 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 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 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 amassa = fecha > 0.5 && e.top < 0.5 ? (fecha - 0.5) * 0.2 : 0; // piscada achata e alarga
@@ -92,6 +118,11 @@
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6); const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
const x = p.cx - w / 2, y = p.cy - h / 2; const x = p.cx - w / 2, y = p.cy - h / 2;
c.fillStyle = css(o.cor); 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; } 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); caixa(c, x, y, w, h, est.r * p.ry * respira);
c.fill(); c.fill();
@@ -105,7 +136,22 @@
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.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.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'; c.globalCompositeOperation = 'source-over';
if (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar 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 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)); 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.fillStyle = 'rgba(245,250,255,0.9)';
@@ -117,11 +163,15 @@
// Olho lente: esclera, íris, pupila que dilata e pálpebras de metal. // Olho lente: esclera, íris, pupila que dilata e pálpebras de metal.
function olhoLente(c, o, p, est, e) { 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 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(e.top, 1 - Math.min(1, p.ab))); const fecha = Math.min(1, Math.max(o.icone || o.desenho ? 0 : e.top, 1 - Math.min(1, p.ab)));
c.save(); 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 * 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.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); 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 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 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); const g = c.createRadialGradient(ix, iy, R * 0.05, ix, iy, R * 0.55);
@@ -130,6 +180,7 @@
const pupila = p.ab > 1.05 ? 0.12 : e.bot > 0.3 ? 0.3 : 0.2; // susto contrai, alegria dilata 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, 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(); 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); const metal = c.createLinearGradient(0, p.cy - R, 0, p.cy + R);
metal.addColorStop(0, '#5b6675'); metal.addColorStop(1, '#2b323d'); metal.addColorStop(0, '#5b6675'); metal.addColorStop(1, '#2b323d');
c.fillStyle = metal; c.fillStyle = metal;
@@ -173,6 +224,15 @@
bctx.clearRect(0, 0, colunas, linhas); bctx.clearRect(0, 0, colunas, linhas);
bctx.save(); bctx.scale(f, f); bctx.save(); bctx.scale(f, f);
olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i])); 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(); bctx.restore();
const px = bctx.getImageData(0, 0, colunas, linhas).data; const px = bctx.getImageData(0, 0, colunas, linhas).data;
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height); ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height);
@@ -180,7 +240,7 @@
for (let j = 0; j < linhas; j++) for (let i = 0; i < colunas; i++) { 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; 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.beginPath(); ctx.arc((i + 0.5) * passo, (j + 0.5) * passo, raio, 0, Math.PI * 2);
ctx.fillStyle = a > 0.25 ? css(o.cor, 0.35 + a * 0.65) : 'rgba(255,255,255,0.045)'; 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(); ctx.fill();
} }
} }
@@ -207,17 +267,267 @@
ry: p.ry * d, ry: p.ry * d,
})); }));
ctx.clearRect(0, 0, tela.width, tela.height); ctx.clearRect(0, 0, tela.width, tela.height);
if (estilo.pontos) return desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d); if (estilo.pontos) {
if (estilo.lente) { olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); return; } desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d);
cctx.clearRect(0, 0, camada.width, camada.height); } else if (estilo.lente) {
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i])); olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
ctx.save(); desenharMaos(ctx, o, olhos, tela.height);
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); } desenharZzz(ctx, o, olhos);
ctx.drawImage(camada, 0, 0); } else {
ctx.restore(); 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; } function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
window.MotorRosto = { desenhar, esconder, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) }; 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) };
})(); })();
+160
View File
@@ -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()
+7
View File
@@ -82,6 +82,13 @@ def testar(base, server, capturas):
expect(page.get_by_text('samsung SM-G973F')).to_be_visible() expect(page.get_by_text('samsung SM-G973F')).to_be_visible()
expect(page.get_by_text('192.168.1.16')).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 # 3. gesto pelo dock
kiosk.FILA.limpar() kiosk.FILA.limpar()
page.get_by_role('button', name='Feliz').first.click() page.get_by_role('button', name='Feliz').first.click()
+4
View File
@@ -106,6 +106,10 @@ class ServidorTest(unittest.TestCase):
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'rm -rf'})[0], 400) 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(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400)
self.assertEqual(kiosk.FILA.ordens(), ['feliz', 'rosto miudo']) 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], ['feliz', 'rosto miudo'])
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], []) self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], [])
+188
View File
@@ -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) };
})();
+3
View File
@@ -9,6 +9,9 @@
</head> </head>
<body> <body>
<div id="app"></div> <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> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+7 -8
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <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 { onMount, type Component } from 'svelte';
import Avisos from './lib/componentes/Avisos.svelte'; import Avisos from './lib/componentes/Avisos.svelte';
import EStop from './lib/componentes/EStop.svelte'; import EStop from './lib/componentes/EStop.svelte';
@@ -17,9 +17,11 @@
import Movimento from './telas/Movimento.svelte'; import Movimento from './telas/Movimento.svelte';
import Reacoes from './telas/Reacoes.svelte'; import Reacoes from './telas/Reacoes.svelte';
import Rosto from './telas/Rosto.svelte'; import Rosto from './telas/Rosto.svelte';
import Eventos from './telas/Eventos.svelte';
const TELAS: Record<Rota, Component> = { const TELAS: Record<Rota, Component> = {
'ao-vivo': AoVivo, 'ao-vivo': AoVivo,
eventos: Eventos,
conversa: Conversa, conversa: Conversa,
reacoes: Reacoes, reacoes: Reacoes,
movimento: Movimento, movimento: Movimento,
@@ -28,6 +30,7 @@
}; };
const ICONES: Record<string, Component<any>> = { const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video, 'ao-vivo': Video,
eventos: Bell,
conversa: MessageCircle, conversa: MessageCircle,
reacoes: Smile, reacoes: Smile,
movimento: Hand, movimento: Hand,
@@ -104,11 +107,7 @@
const conexaoTexto = $derived( const conexaoTexto = $derived(
{ conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao], { conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao],
); );
const bateria = $derived.by(() => { const bateria = $derived(estado.bateria.nivel);
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;
});
</script> </script>
<svelte:window onkeydown={teclado} /> <svelte:window onkeydown={teclado} />
@@ -147,7 +146,7 @@
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}> <Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
{conexaoTexto} {conexaoTexto}
</Selo> </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}%` : ''} Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''}
</Selo> </Selo>
{#if estado.conversa} {#if estado.conversa}
@@ -210,7 +209,7 @@
margin: 0; margin: 0;
padding: var(--e-1); padding: var(--e-1);
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(6, 1fr);
} }
li a { li a {
display: grid; display: grid;
+5
View File
@@ -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: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' },
{ chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' }, { chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' },
{ chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', 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: '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: '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: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
{ chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' }, { 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: '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: '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' },
], ],
}, },
{ {
+21 -3
View File
@@ -20,17 +20,34 @@ export const GESTOS_INFO: Record<string, { nome: string; emoji: string }> = {
dormir: { nome: 'Dormir', emoji: '😴' }, dormir: { nome: 'Dormir', emoji: '😴' },
acordar: { nome: 'Acordar', emoji: '🌅' }, acordar: { nome: 'Acordar', emoji: '🌅' },
aleatorio: { nome: 'Aleatório', 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); export const gestosDisponiveis = () => estado.gestos.filter((g) => g in GESTOS_INFO);
const CHAVE_FAVORITOS = 'carinha_favoritos'; 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[] { function lerFavoritos(): string[] {
try { try {
const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null'); 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 { } catch {
/* sem armazenamento */ /* sem armazenamento */
} }
@@ -40,9 +57,10 @@ function lerFavoritos(): string[] {
class Favoritos { class Favoritos {
lista = $state<string[]>(lerFavoritos()); lista = $state<string[]>(lerFavoritos());
alternar(g: string) { 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 { try {
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista)); localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista));
localStorage.setItem(CHAVE_MAOS, '1');
} catch { } catch {
/* ok */ /* ok */
} }
+28 -7
View File
@@ -5,15 +5,19 @@
// Cartão do aparelho com o que o próprio app informa (IP, modelo, Android, versão, Wi-Fi, bateria). // 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 s = $derived(estado.app?.estado ?? {});
const ap = $derived(s.aparelho ?? null); const ap = $derived(s.aparelho ?? null);
const bateria = $derived.by(() => { const bateria = $derived(estado.bateria.nivel);
const b = s.bateria; const carregando = $derived(estado.bateria.carregando);
const n = typeof b === 'number' ? b : b?.nivel; // Tempo desde o último sinal, em texto curto ("há 3 min").
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null; 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 carregando = $derived(typeof s.bateria === 'object' && !!s.bateria?.carregando);
const IconeBateria = $derived(carregando ? BatteryCharging : bateria === null ? BatteryMedium : bateria < 20 ? BatteryLow : bateria < 70 ? BatteryMedium : BatteryFull); const IconeBateria = $derived(carregando ? BatteryCharging : bateria === null ? BatteryMedium : bateria < 20 ? BatteryLow : bateria < 70 ? BatteryMedium : BatteryFull);
const ip = $derived(ap?.ip || estado.app?.celular_ip || '—'); const ip = $derived(ap?.ip || estado.app?.celular_ip || '—');
const visto = $derived(estado.app?.idade ?? null);
</script> </script>
<div class="celular" class:offline={!estado.celularOnline}> <div class="celular" class:offline={!estado.celularOnline}>
@@ -25,7 +29,7 @@
{#if estado.celularOnline} {#if estado.celularOnline}
{[ap?.android && 'Android ' + ap.android, ap?.app && 'app ' + ap.app].filter(Boolean).join(' · ') || 'online'} {[ap?.android && 'Android ' + ap.android, ap?.app && 'app ' + ap.app].filter(Boolean).join(' · ') || 'online'}
{:else} {:else}
offline{visto !== null ? ` · visto há ${Math.round(visto)} s` : ''} offline{semSinal ? ' · último sinal ' + semSinal : ''}
{/if} {/if}
</span> </span>
</div> </div>
@@ -70,6 +74,12 @@
</div> </div>
</dl> </dl>
{:else} {: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> <p class="ajuda">Abra o app Carinha no celular e confira se ele aponta para este servidor (toque longo › Mudar o servidor).</p>
{/if} {/if}
</div> </div>
@@ -170,6 +180,17 @@
rect.cheia { rect.cheia {
fill: var(--texto); 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 { .ajuda {
font-size: var(--t-sm); font-size: var(--t-sm);
color: var(--texto-3); color: var(--texto-3);
+2 -2
View File
@@ -11,10 +11,10 @@
{#each lista as g, i (g)} {#each lista as g, i (g)}
{@const info = GESTOS_INFO[g]} {@const info = GESTOS_INFO[g]}
<div class="item"> <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="emoji" aria-hidden="true">{info.emoji}</span>
<span class="nome">{info.nome}</span> <span class="nome">{info.nome}</span>
{#if !completo}<kbd>{i + 1}</kbd>{/if} {#if !completo && i < 9}<kbd>{i + 1}</kbd>{/if}
</button> </button>
{#if completo} {#if completo}
<button <button
+68 -33
View File
@@ -1,56 +1,91 @@
<script lang="ts"> <script lang="ts">
import { rgb, ROSTOS_INFO } from '../comandos.svelte';
import { estado } from '../estado.svelte';
import { SIMBOLO_EMOJI } from '../treinamento.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 { receita, tamanho = 64 }: { receita: any; tamanho?: number } = $props();
let tela: HTMLCanvasElement;
const passo = $derived(receita?.passos?.[0]); 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> </script>
<div class="miniatura" style="--t: {tamanho}px" aria-hidden="true"> <div class="miniatura" style="--t: {tamanho}px" aria-hidden="true">
{#if receita?.figurinha?.formas} <canvas bind:this={tela}></canvas>
<svg viewBox="0 0 100 100" width={tamanho} height={tamanho}> {#if aoRedor?.emoji}
{#each receita.figurinha.formas as forma, i (i)} <span class="extra">{aoRedor.emoji}</span>
<path d={forma.caminho} fill={forma.cor} /> {:else if aoRedor?.figurinha}
{/each} <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> </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} {/if}
</div> </div>
<style> <style>
.miniatura { .miniatura {
position: relative; position: relative;
width: var(--t); width: calc(var(--t) * 1.5);
height: var(--t); height: var(--t);
display: grid;
place-items: center;
border-radius: var(--raio); border-radius: var(--raio);
background: #05080c; background: #000;
flex: none; flex: none;
overflow: hidden;
} }
.emoji { canvas {
font-size: calc(var(--t) * 0.55); width: 100%;
line-height: 1; height: 100%;
display: block;
} }
.simbolo { .extra {
position: absolute; position: absolute;
right: 2px; right: 4px;
bottom: 0; top: 3px;
font-size: calc(var(--t) * 0.3); 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> </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>
+15 -1
View File
@@ -7,7 +7,8 @@
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { estado, ouvir } from '../estado.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 quadro: HTMLIFrameElement;
let pronto = $state(false); let pronto = $state(false);
@@ -20,6 +21,13 @@
if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) }); 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; let ultimaConversa = estado.conversa?.ultima?.id;
const cancelar = [ const cancelar = [
ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })), ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })),
@@ -31,6 +39,7 @@
ultimaConversa = c.ultima.id; ultimaConversa = c.ultima.id;
enviar({ tipo: 'ordem', ordem: 'acordar' }); enviar({ tipo: 'ordem', ordem: 'acordar' });
enviar({ tipo: 'ordem', ordem: c.ultima.gesto }); enviar({ tipo: 'ordem', ordem: c.ultima.gesto });
enviar({ tipo: 'legenda', texto: c.ultima.texto });
} }
}), }),
]; ];
@@ -41,6 +50,11 @@
enviar({ tipo: 'ordem', ordem: texto }); 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. */ /** Para a tela de reações: mostra uma receita sem mandar ao celular. */
export function previa(receita: unknown) { export function previa(receita: unknown) {
enviar({ tipo: 'previa-reacao', receita }); enviar({ tipo: 'previa-reacao', receita });
+42 -2
View File
@@ -3,6 +3,7 @@
* Nenhuma tela faz polling; todas leem daqui. * Nenhuma tela faz polling; todas leem daqui.
*/ */
import { ErroApi, pedir, quandoNegado, urlEventos } from './api'; import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
import { avisos } from './avisos.svelte';
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos'; import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos';
export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token'; export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
@@ -33,10 +34,24 @@ class Estado {
treinamento = $state({ ocupado: false, erro: '' }); treinamento = $state({ ocupado: false, erro: '' });
api = $state<EstadoServidor['api'] | null>(null); api = $state<EstadoServidor['api'] | null>(null);
feed = $state<ItemFeed[]>([]); 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. */ /** Relógio de 1 s para derivar "online" sem esperar evento novo. */
agora = $state(Date.now()); agora = $state(Date.now());
celularOnline = $derived(!!this.app?.online && this.agora - this.appRecebidoEm < 6000); 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(); export const estado = new Estado();
@@ -51,6 +66,23 @@ export function ouvir(tipo: string, fn: Ouvinte): () => void {
return () => ouvintes.get(tipo)!.delete(fn); 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; let seqFeed = 0;
function noFeed(tipo: string, texto: string) { function noFeed(tipo: string, texto: string) {
estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40); estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40);
@@ -67,6 +99,7 @@ function aplicarFoto(f: EstadoServidor) {
if (f.app) { if (f.app) {
estado.app = f.app; estado.app = f.app;
estado.appRecebidoEm = Date.now(); 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.app_config) estado.appConfig = f.app_config;
if (f.motores) estado.motores = f.motores; if (f.motores) estado.motores = f.motores;
@@ -75,6 +108,7 @@ function aplicarFoto(f: EstadoServidor) {
estado.reacao = f.reacao ?? null; estado.reacao = f.reacao ?? null;
if (f.treinamento) estado.treinamento = f.treinamento; if (f.treinamento) estado.treinamento = f.treinamento;
if (f.api) estado.api = f.api; 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> = { const tratadores: Record<string, (d: any) => void> = {
@@ -83,7 +117,8 @@ const tratadores: Record<string, (d: any) => void> = {
}, },
telemetria: (d: Telemetria) => { telemetria: (d: Telemetria) => {
estado.app = d; estado.app = d;
estado.appRecebidoEm = Date.now(); estado.appRecebidoEm = estado.ultimoSinalEm = Date.now();
avisarBateria();
}, },
app_config: (d: AppConfig) => (estado.appConfig = d), app_config: (d: AppConfig) => (estado.appConfig = d),
aparencia: (d) => (estado.aparencia = d), aparencia: (d) => (estado.aparencia = d),
@@ -107,6 +142,11 @@ const tratadores: Record<string, (d: any) => void> = {
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome); if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
}, },
treinamento: (d) => (estado.treinamento = d), 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; let fonte: EventSource | null = null;
@@ -129,7 +169,7 @@ function abrirEventos() {
if (estado.conexao !== 'token') estado.conexao = 'caiu'; if (estado.conexao !== 'token') estado.conexao = 'caiu';
}; };
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia', for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
'reacao', 'treinamento']) { 'reacao', 'treinamento', 'historico', 'pir']) {
fonte.addEventListener(tipo, (e) => { fonte.addEventListener(tipo, (e) => {
const dados = JSON.parse((e as MessageEvent).data); const dados = JSON.parse((e as MessageEvent).data);
tratadores[tipo]?.(dados); tratadores[tipo]?.(dados);
+85
View File
@@ -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());
+7 -3
View File
@@ -1,9 +1,10 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */ /** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto'; export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' }, { id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' }, { id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' }, { id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' }, { id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
@@ -11,12 +12,15 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
]; ];
/** Páginas abertas a partir de outra tela (não aparecem na navegação). */ /** 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 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 ?? ''; export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
function lerHash(): Rota { function lerHash(): Rota {
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota; 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'; return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
} }
+1
View File
@@ -81,4 +81,5 @@ export interface EstadoServidor {
reacao?: Reacao | null; reacao?: Reacao | null;
treinamento?: { ocupado: boolean; erro: string }; treinamento?: { ocupado: boolean; erro: string };
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown }; api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
pir?: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
} }
+53 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { import {
Brain, Camera, Download, Eye, Hand, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video, Brain, Camera, Download, Eye, Footprints, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { postar } from '../lib/api'; import { postar } from '../lib/api';
@@ -12,6 +12,7 @@
import Grupo from '../lib/componentes/Grupo.svelte'; import Grupo from '../lib/componentes/Grupo.svelte';
import Interruptor from '../lib/componentes/Interruptor.svelte'; import Interruptor from '../lib/componentes/Interruptor.svelte';
import Linha from '../lib/componentes/Linha.svelte'; import Linha from '../lib/componentes/Linha.svelte';
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte'; import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import { estado } from '../lib/estado.svelte'; import { estado } from '../lib/estado.svelte';
import { Rastreamento } from '../lib/rastreamento.svelte'; import { Rastreamento } from '../lib/rastreamento.svelte';
@@ -47,6 +48,11 @@
}); });
const cfg = $derived(estado.appConfig); 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 transmitindo = $derived(!!cfg?.preview);
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
@@ -69,6 +75,12 @@
<div class="ao-vivo"> <div class="ao-vivo">
<!-- Palco fixo: a câmera continua visível enquanto a lista rola --> <!-- Palco fixo: a câmera continua visível enquanto a lista rola -->
<section class="palco" aria-label="Câmera e rosto"> <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}> <div class="quadro camera" class:mini={rostoGrande}>
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video> <video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
@@ -146,7 +158,7 @@
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" /> <Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" />
</Grupo> </Grupo>
<Grupo titulo="Gestos" rodape="Teclas 1 a 9 no teclado. Ctrl+K busca qualquer gesto."> <Grupo titulo="Gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto.">
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos"> <Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
<Gestos /> <Gestos />
</Linha> </Linha>
@@ -181,6 +193,10 @@
</Grupo> </Grupo>
<Grupo titulo="Acontecimentos"> <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} {#if estado.feed.length}
<ol class="feed"> <ol class="feed">
{#each estado.feed.slice(0, 12) as item (item.id)} {#each estado.feed.slice(0, 12) as item (item.id)}
@@ -295,6 +311,41 @@
.espaco { .espaco {
flex: 1; flex: 1;
} }
.ponto-pir {
position: absolute;
z-index: 3;
left: var(--e-3);
top: var(--e-3);
display: inline-flex;
align-items: center;
gap: 6px;
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);
}
.ponto-pir .luz {
width: 10px;
height: 10px;
border-radius: 50%;
background: #6b7280;
transition: background var(--transicao);
}
.ponto-pir.ativo {
color: #b8f5c9;
}
.ponto-pir.ativo .luz {
background: #34c759;
animation: pulsoPir 1s ease-in-out infinite;
}
@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 */
.lista { .lista {
display: grid; display: grid;
+308
View File
@@ -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>
+10 -2
View File
@@ -204,7 +204,7 @@
</Card> </Card>
</div> </div>
{:else if aba === 'gestos'} {: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 /> <Gestos completo />
</Card> </Card>
{:else} {:else}
@@ -249,7 +249,7 @@
</div> </div>
<aside class="lateral"> <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> <p class="fraco pequeno-texto">Clique numa reação para ver aqui sem mandar ao celular.</p>
</aside> </aside>
</div> </div>
@@ -364,6 +364,14 @@
.espaco { .espaco {
flex: 1; 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 { .marcado {
color: var(--ok); color: var(--ok);
border-color: var(--ok); border-color: var(--ok);
+82 -11
View File
@@ -4,12 +4,30 @@
cor, CORES, FORMATOS_INFO, formato, gesto, GESTOS_INFO, hex, nomeRosto, rgb, rosto, ROSTOS_INFO, cor, CORES, FORMATOS_INFO, formato, gesto, GESTOS_INFO, hex, nomeRosto, rgb, rosto, ROSTOS_INFO,
} from '../lib/comandos.svelte'; } from '../lib/comandos.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte'; import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import Slider from '../lib/componentes/Slider.svelte';
import Toggle from '../lib/componentes/Toggle.svelte'; import Toggle from '../lib/componentes/Toggle.svelte';
import { salvarApp } from '../lib/ajustes.svelte';
import { estado } from '../lib/estado.svelte'; import { estado } from '../lib/estado.svelte';
let previa = $state<{ ordem: (t: string) => void }>(); let previa = $state<{ ordem: (t: string) => void; legenda: (t: string) => void }>();
// Por padrão as emoções só aparecem aqui na prévia; ligado, vão também para o celular. // Ouvir a voz de robô aqui no PC (o clique na tecla libera o áudio do navegador).
let noCelular = $state(false); 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 atual = $derived(estado.aparencia.rosto);
const formatoAtual = $derived(estado.aparencia.formato ?? 'padrao'); const formatoAtual = $derived(estado.aparencia.formato ?? 'padrao');
@@ -18,16 +36,21 @@
const classicos = $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 formatos = $derived(estado.formatos.length ? estado.formatos : Object.keys(FORMATOS_INFO));
const EMOCOES = ['feliz', 'coracao', 'surpreso', 'triste', 'bravo', 'piscadinha', 'olhar_esq', 'olhar_dir', 'dormir', 'acordar']; const EMOCOES = ['feliz', 'coracao', 'surpreso', 'triste', 'bravo', 'piscadinha', 'acenar', 'esconder', 'espiar', 'pensar', 'musica', 'dormir', 'acordar'];
function emocao(g: string) { function emocao(g: string) {
if (noCelular) gesto(g); if (soPrevia) previa?.ordem(g);
else 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 }[] = [ 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: '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: '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: '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: '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: '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: 'Contato visual', texto: 'Sustenta o olhar de quem a câmera vê e desvia quando fica tímido.', feito: false, onde: 'rosto' },
@@ -47,7 +70,7 @@
<section class="bancada" aria-label="Prévia"> <section class="bancada" aria-label="Prévia">
<div class="celular"> <div class="celular">
<div class="tela"><RostoPrevia bind:this={previa} titulo="Prévia do rosto" /></div> <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="camera-frontal" aria-hidden="true"></span>
<span class="etiqueta">{nomeRosto(atual).toUpperCase()}{aceitaFormato ? ' · ' + (FORMATOS_INFO[formatoAtual] ?? formatoAtual).toUpperCase() : ''}</span> <span class="etiqueta">{nomeRosto(atual).toUpperCase()}{aceitaFormato ? ' · ' + (FORMATOS_INFO[formatoAtual] ?? formatoAtual).toUpperCase() : ''}</span>
</div> </div>
@@ -57,11 +80,20 @@
<button class="tecla" onclick={() => emocao(g)}>{GESTOS_INFO[g].emoji} {GESTOS_INFO[g].nome}</button> <button class="tecla" onclick={() => emocao(g)}>{GESTOS_INFO[g].emoji} {GESTOS_INFO[g].nome}</button>
{/each} {/each}
</div> </div>
<div class="linha">
<button class="tecla" onclick={testarFala}>🗣️ Testar fala</button>
</div>
<Toggle <Toggle
rotulo="Mandar as emoções também para o celular" rotulo="Ouvir a voz de robô aqui no PC"
ajuda="Desligado, elas aparecem só na prévia" ajuda="Sons sintetizados na hora, inspirados nos robozinhos de cinema"
checked={noCelular} checked={ouvirAqui}
onchange={(v) => (noCelular = v)} 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>
@@ -100,6 +132,39 @@
{/if} {/if}
</section> </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"> <section class="bloco-escolha" aria-labelledby="t-cor">
<h2 id="t-cor">Cor dos olhos</h2> <h2 id="t-cor">Cor dos olhos</h2>
<div class="cores"> <div class="cores">
@@ -326,6 +391,12 @@
height: 18px; height: 18px;
border-radius: 6px; 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 { .cores {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+1 -1
View File
@@ -6,7 +6,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte';
const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100'; const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100';
const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos', const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos',
'/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face', '/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({ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],