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:
@@ -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.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user