feat(web): cria painel Svelte com shell, Ao vivo e Ajustes

- web/ com Vite + Svelte 5 + TypeScript; servidor entrega web/dist em "/"
  (telas antigas seguem em /dashboard, /painel, /braco...)
- design system em tokens CSS, tema escuro e claro, fontes locais
- estado unico: foto de /estado + um EventSource em /eventos, sem polling
- shell com navegacao lateral/inferior, selos de status reais e E-Stop fixo
- command palette (Ctrl+K), gestos favoritos com atalhos 1-9, g+letra navega
- Ao vivo: camera WebRTC e rastreamento portados para TS, rosto, aparencia,
  sensores do celular, IA local e feed de acontecimentos
- Ajustes: campos do celular agrupados com busca, salvamento otimista e
  restaurar padrao (GET /app/padrao); motor da conversa, Gemini e token
- carinha.html ganha modo ?painel=1 dirigido por postMessage (sem polling)
- teste Playwright de ponta a ponta do painel novo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-05 19:47:18 -03:00
co-authored by Claude Opus 5.5
parent 35d443a358
commit c4b53a8976
40 changed files with 4710 additions and 3 deletions
+251
View File
@@ -0,0 +1,251 @@
/**
* Rastreamento de rosto/mãos no navegador do PC (MediaPipe num Worker servido pelo
* servidor em /rastreador-worker.js). Porta de painel-rastreamento.js + rastreamento-core.js.
*/
import type { AppConfig } from './tipos';
import type { AlvoRastreio, VideoAoVivo } from './video.svelte';
interface Ponto {
x: number;
y: number;
}
interface Face extends Ponto {
w: number;
h: number;
score: number;
pontos?: Ponto[];
}
interface Resultado {
faces: Face[];
hands: Ponto[][];
ms: number;
timestamp: number;
}
const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y);
const limitar = (v: number) => Math.max(0, Math.min(1, v));
const LIGACOES = [[0, 1], [1, 2], [2, 3], [3, 4], [0, 5], [5, 6], [6, 7], [7, 8], [5, 9], [9, 10], [10, 11], [11, 12],
[9, 13], [13, 14], [14, 15], [15, 16], [13, 17], [0, 17], [17, 18], [18, 19], [19, 20]];
export function escolherAlvo(faces: Face[], maos: Ponto[][], anterior: AlvoRastreio | null): AlvoRastreio | null {
let candidatos: AlvoRastreio[] = maos
.filter((h) => h.length === 21 && h.every(valido))
.map((h) => {
const palma = [0, 5, 9, 13, 17].map((i) => h[i]);
return { tipo: 'mao', x: palma.reduce((s, p) => s + p.x, 0) / 5, y: palma.reduce((s, p) => s + p.y, 0) / 5 };
});
if (!candidatos.length)
candidatos = faces
.filter((f) => valido(f) && f.w > 0 && f.h > 0)
.sort((a, b) => b.w * b.h - a.w * a.h)
.map((f) => ({ tipo: 'rosto', x: f.x + f.w / 2, y: f.y + f.h / 2 }));
if (!candidatos.length) return null;
if (anterior && anterior.tipo === candidatos[0].tipo)
candidatos.sort((a, b) => Math.hypot(a.x - anterior.x, a.y - anterior.y) - Math.hypot(b.x - anterior.x, b.y - anterior.y));
const t = candidatos[0];
return { ...t, x: limitar(t.x), y: limitar(t.y) };
}
function encaixe(w: number, h: number, vw: number, vh: number) {
const escala = Math.min(w / vw, h / vh);
return { x: Math.max(0, (w - vw * escala) / 2), y: Math.max(0, (h - vh * escala) / 2), w: vw * escala, h: vh * escala };
}
export class Rastreamento {
texto = $state('Rastreamento desligado.');
private worker: Worker | null = null;
private pronto = false;
private pendente = false;
private geracao = 0;
private ativo = false;
private config: AppConfig = {};
private resultado: Resultado | null = null;
private alvo: AlvoRastreio | null = null;
private alvoEm = 0;
private em = 0;
private intervalo = 50;
private proximo = 0;
private ultimoVideo = -1;
private tentarEm = 0;
private amostraAnterior = 0;
private rascunho = document.createElement('canvas');
private relogio: number;
private ctx: CanvasRenderingContext2D;
constructor(
private video: HTMLVideoElement,
private tela: HTMLCanvasElement,
private saida: VideoAoVivo,
) {
this.ctx = tela.getContext('2d')!;
this.relogio = window.setInterval(() => this.passo(), 20);
addEventListener('resize', this.desenhar);
}
private limpar() {
this.ctx.clearRect(0, 0, this.tela.width, this.tela.height);
}
private parar(texto: string) {
if (this.ativo) this.saida.rastrear(null);
this.ativo = false;
this.geracao++;
this.worker?.terminate();
this.worker = null;
this.pendente = this.pronto = false;
this.resultado = this.alvo = null;
this.ultimoVideo = -1;
this.amostraAnterior = 0;
this.limpar();
this.texto = texto;
}
private desenhar = () => {
const caixa = this.tela.parentElement!.getBoundingClientRect();
if (this.tela.width !== Math.round(caixa.width) || this.tela.height !== Math.round(caixa.height)) {
this.tela.width = Math.round(caixa.width);
this.tela.height = Math.round(caixa.height);
}
this.limpar();
this.tela.hidden = !this.config.mostrarRastreamento;
const r = this.resultado;
if (this.tela.hidden || !r || performance.now() - this.em > 350 || !this.video.videoWidth) return;
const f = encaixe(this.tela.width, this.tela.height, this.video.videoWidth, this.video.videoHeight);
const pt = (p: Ponto) => ({ x: f.x + p.x * f.w, y: f.y + p.y * f.h });
const c = this.ctx;
c.lineWidth = 2;
c.font = '13px system-ui';
const bola = (p: Ponto, raio = 3) => {
c.beginPath();
c.arc(p.x, p.y, raio, 0, Math.PI * 2);
c.fill();
};
for (const rosto of r.faces) {
const p = pt(rosto);
c.strokeStyle = c.fillStyle = '#5cd6ff';
c.strokeRect(p.x, p.y, rosto.w * f.w, rosto.h * f.h);
c.fillText('Rosto ' + Math.round(rosto.score * 100) + '%', p.x, Math.max(15, p.y - 6));
for (const q of rosto.pontos || []) bola(pt(q));
}
for (const mao of r.hands) {
c.strokeStyle = c.fillStyle = '#4ade80';
for (const [a, b] of LIGACOES) {
const p = pt(mao[a]);
const q = pt(mao[b]);
c.beginPath();
c.moveTo(p.x, p.y);
c.lineTo(q.x, q.y);
c.stroke();
}
for (const q of mao) bola(pt(q));
}
if (this.alvo) {
const p = pt(this.alvo);
c.strokeStyle = '#fbbf24';
c.beginPath();
c.arc(p.x, p.y, 12, 0, Math.PI * 2);
c.moveTo(p.x - 18, p.y);
c.lineTo(p.x + 18, p.y);
c.moveTo(p.x, p.y - 18);
c.lineTo(p.x, p.y + 18);
c.stroke();
}
};
private iniciar() {
this.ativo = true;
const geracao = ++this.geracao;
this.worker = new Worker('/rastreador-worker.js');
this.texto = 'Carregando rastreadores locais…';
this.worker.onerror = (e) => {
this.tentarEm = Date.now() + 10000;
this.parar('Falha no rastreador: ' + e.message);
};
this.worker.onmessage = ({ data }) => {
if (geracao !== this.geracao) return;
this.pendente = false;
if (data.tipo === 'erro') {
this.tentarEm = Date.now() + 10000;
this.parar('Rastreamento indisponível: ' + data.erro);
return;
}
this.pronto = true;
if (data.tipo === 'pronto') {
this.texto = 'Rastreadores prontos. Aguardando imagem…';
return;
}
const r = data as Resultado;
this.resultado = r;
this.em = r.timestamp;
const anterior = this.alvo;
const alvo = escolherAlvo(r.faces, r.hands, anterior);
if (alvo) alvo.atividade = !!anterior && alvo.tipo === anterior.tipo && Math.hypot(alvo.x - anterior.x, alvo.y - anterior.y) > 0.008;
this.alvo = alvo;
this.alvoEm = r.timestamp;
this.intervalo = Math.max(50, Math.min(200, r.ms * 1.2));
const hz = this.amostraAnterior ? Math.round(1000 / (r.timestamp - this.amostraAnterior)) : 0;
this.amostraAnterior = r.timestamp;
this.texto = `${r.faces.length} rosto(s) · ${r.hands.length} mão(s) · ${hz} análises/s · ${Math.round(r.ms)} ms${
alvo ? ' · alvo: ' + (alvo.tipo === 'mao' ? 'mão' : 'rosto') : ' · procurando…'
}`;
if (this.config.seguirRastreamento) this.saida.rastrear(alvo);
this.desenhar();
};
this.pendente = true;
this.worker.postMessage({ config: { rosto: this.config.rastrearRosto, maos: this.config.rastrearMaos } });
}
private async passo() {
const agora = performance.now();
if (this.alvo && agora - this.alvoEm > 350) {
this.alvo = null;
if (this.config.seguirRastreamento) this.saida.rastrear(null);
}
if (this.resultado && agora - this.em > 350) {
this.resultado = null;
this.limpar();
}
const v = this.video;
if (!this.ativo || !this.pronto || this.pendente || agora < this.proximo || v.readyState < 2 || v.currentTime === this.ultimoVideo)
return;
this.pendente = true;
this.proximo = agora + this.intervalo;
this.ultimoVideo = v.currentTime;
const geracao = this.geracao;
try {
const escala = Math.min(1, 640 / v.videoWidth);
this.rascunho.width = Math.round(v.videoWidth * escala);
this.rascunho.height = Math.round(v.videoHeight * escala);
this.rascunho.getContext('2d')!.drawImage(v, 0, 0, this.rascunho.width, this.rascunho.height);
const bitmap = await createImageBitmap(this.rascunho);
if (geracao !== this.geracao) return bitmap.close();
this.worker!.postMessage(
{ bitmap, timestamp: agora, config: { rosto: this.config.rastrearRosto, maos: this.config.rastrearMaos } },
[bitmap],
);
} catch (e) {
if (geracao === this.geracao) this.parar('Falha ao analisar vídeo: ' + (e as Error).message);
}
}
atualizar(config: AppConfig | null, permitido: boolean) {
if (!config) return;
const mudou = this.config.rastrearRosto !== config.rastrearRosto || this.config.rastrearMaos !== config.rastrearMaos;
if (this.config.seguirRastreamento && !config.seguirRastreamento) this.saida.rastrear(null);
this.config = config;
if (!permitido || (!config.rastrearRosto && !config.rastrearMaos)) {
this.parar(!permitido ? 'Ligue a transmissão para rastrear no PC.' : 'Rastreamento desligado.');
return;
}
if (mudou && this.ativo) this.parar('Atualizando rastreadores…');
if (!this.ativo && Date.now() >= this.tentarEm) this.iniciar();
this.desenhar();
}
destruir() {
clearInterval(this.relogio);
removeEventListener('resize', this.desenhar);
this.parar('Painel fechado.');
}
}