Files
carinha-control-panel/web/src/telas/AoVivo.svelte
T
julioandClaude Opus 5.5 92701e8a16 feat(pessoas): cartão "Quem é?" com o rosto, no Ao vivo e em Pessoas
Um cartão por pessoa desconhecida (grupo de rostos parecidos), com o rosto
grande: toca no nome de quem já conhece ou digita um novo, e a resposta vale
para todas as fotos do grupo. Não é ninguém / Pular. No Ao vivo só aparece
de quem ele já viu 3 vezes ou mais.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:13:26 -03:00

482 lines
17 KiB
Svelte

<script lang="ts">
import {
Brain, Cable, Camera, Download, Eye, Footprints, Gamepad2, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video, Wand2,
} from '@lucide/svelte';
import { onDestroy, onMount } from 'svelte';
import { postar } from '../lib/api';
import { salvarApp } from '../lib/ajustes.svelte';
import { tentar } from '../lib/avisos.svelte';
import { atividade, ATIVIDADES_INFO, FORMATOS_INFO, hex, nomeRosto, recarregarCelular, ROSTOS_INFO } from '../lib/comandos.svelte';
import Celular from '../lib/componentes/Celular.svelte';
import Gestos from '../lib/componentes/Gestos.svelte';
import Grupo from '../lib/componentes/Grupo.svelte';
import Interruptor from '../lib/componentes/Interruptor.svelte';
import Linha from '../lib/componentes/Linha.svelte';
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import VidaCartao from '../lib/componentes/VidaCartao.svelte';
import QuemE from '../lib/componentes/QuemE.svelte';
import AprendizadoCartao from '../lib/componentes/AprendizadoCartao.svelte';
import { estado } from '../lib/estado.svelte';
import { Rastreamento } from '../lib/rastreamento.svelte';
import { VideoAoVivo } from '../lib/video.svelte';
let elVideo: HTMLVideoElement;
let elTela: HTMLCanvasElement;
let video = $state<VideoAoVivo>();
let rastreio = $state<Rastreamento>();
// Palco: câmera grande e rosto no quadrinho do canto; tocar no quadrinho troca os dois.
let rostoGrande = $state(false);
// Atividade que o celular está fazendo agora (vem na telemetria).
const atividadeAgora = $derived((estado.app?.online && (estado.app.estado?.atividade as string | null)) || null);
onMount(() => {
video = new VideoAoVivo(elVideo);
rastreio = new Rastreamento(elVideo, elTela, video);
(window as any).carinhaVideo = video; // depuração e testes de ponta a ponta
});
onDestroy(() => {
rastreio?.destruir();
video?.destruir();
delete (window as any).carinhaVideo;
});
// Vídeo e rastreamento seguem o estado do servidor (SSE), sem polling próprio.
$effect(() => {
const cfg = estado.appConfig ? { ...estado.appConfig } : null;
const online = estado.celularOnline;
video?.atualizar(estado.app, cfg, online);
const dormindo = !!estado.app?.estado?.dormindo;
const permitido = !!cfg?.preview && online && cfg.sensorCamera !== false && (!dormindo || !!cfg.cameraSono);
rastreio?.atualizar(cfg, permitido);
});
const cfg = $derived(estado.appConfig);
// Pontinho do PIR sobre a imagem: verde pulsando nos 8 s depois de cada disparo.
// aceso enquanto o pino do sensor está alto; servidor antigo (sem `ativo`) cai nos 8 s após o disparo
const pirAtivo = $derived(
estado.pir?.ativo ?? (!!estado.pir?.ultimo && estado.agora - estado.pir.ultimo * 1000 < 8000),
);
const transmitindo = $derived(!!cfg?.preview);
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
function foto() {
if (!elVideo.videoWidth) return;
const c = document.createElement('canvas');
c.width = elVideo.videoWidth;
c.height = elVideo.videoHeight;
c.getContext('2d')!.drawImage(elVideo, 0, 0);
const a = document.createElement('a');
a.href = c.toDataURL('image/png');
a.download = `carinha-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
a.click();
}
const iaLocal = (ativo: boolean) =>
tentar(() => postar('/ia/config', { ativo }), ativo ? 'Personalidade local ligada.' : 'Personalidade local desligada.');
</script>
<div class="ao-vivo">
<!-- Palco fixo: a câmera continua visível enquanto a lista rola -->
<section class="palco" aria-label="Câmera e rosto">
{#if estado.pir?.disponivel}
<span class="ponto-pir" class:ativo={pirAtivo} title="Sensor de presença (PIR)" aria-live="polite">
<span class="luz" aria-hidden="true"></span>
PIR{pirAtivo ? ' · presença' : ''}
</span>
{/if}
<div class="quadro camera" class:mini={rostoGrande}>
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
<canvas bind:this={elTela} class="sobreposicao" aria-hidden="true"></canvas>
{#if !video?.exibindo}
<div class="vazio">
<Camera size={rostoGrande ? 18 : 32} />
{#if !rostoGrande}
<p>{video?.mensagem ?? 'Carregando…'}</p>
{#if !transmitindo && cfg}
<button class="btn primario" onclick={() => salvarApp({ preview: true })}><Video size={16} /> Ligar câmera</button>
{/if}
{/if}
</div>
{/if}
{#if rostoGrande}
<button class="trocar" onclick={() => (rostoGrande = false)} aria-label="Mostrar a câmera em destaque"></button>
{:else}
<div class="rodape">
<span class="ponto" class:ao-vivo-on={video?.exibindo}></span>
<span>{video?.exibindo ? 'Ao vivo' : 'Câmera'}</span>
{#if video?.qualidade && video.exibindo}<span class="mono">{video.qualidade}</span>{/if}
<span class="espaco"></span>
{#if video?.exibindo}
<button class="btn pequeno" onclick={foto}><Download size={14} /> Foto</button>
{/if}
</div>
{/if}
</div>
<div class="quadro rosto" class:mini={!rostoGrande}>
<RostoPrevia titulo="Rosto ao vivo" />
{#if !rostoGrande}
<button class="trocar" onclick={() => (rostoGrande = true)} aria-label="Mostrar o rosto em destaque">
<Repeat size={14} />
</button>
{/if}
</div>
</section>
<!-- No PC: à direita Vida e o que ele aprendeu; embaixo da câmera, o resto.
No celular as duas colunas se desfazem (display: contents) e a ordem vem do `order`. -->
<div class="coluna direita">
<div class="c-quem"><QuemE minimo={3} /></div>
<div class="c-vida"><VidaCartao /></div>
{#if estado.vida?.config.ligado}<div class="c-aprendizado"><AprendizadoCartao /></div>{/if}
</div>
<div class="coluna esquerda">
<div class="c-camera"><Grupo titulo="Câmera" ajuda="camera" rodape={transmitindo && rastreio ? rastreio.texto : 'O vídeo vai direto do celular para este navegador (WebRTC); nada é gravado.'}>
<Linha icone={Video} cor="#34c759" rotulo="Transmitir câmera do celular" detalhe={video?.exibindo ? video.qualidade : ''}>
{#snippet acessorio()}
<Interruptor rotulo="Transmitir câmera do celular" checked={transmitindo} disabled={!cfg} onchange={(v) => salvarApp({ preview: v })} />
{/snippet}
</Linha>
{#if transmitindo}
<Linha icone={ScanFace} cor="#af52de" rotulo="Rastrear rosto">
{#snippet acessorio()}
<Interruptor rotulo="Rastrear rosto" checked={!!cfg?.rastrearRosto} onchange={(v) => salvarApp({ rastrearRosto: v })} />
{/snippet}
</Linha>
<Linha icone={Hand} cor="#ff9500" rotulo="Rastrear mãos">
{#snippet acessorio()}
<Interruptor rotulo="Rastrear mãos" checked={!!cfg?.rastrearMaos} onchange={(v) => salvarApp({ rastrearMaos: v })} />
{/snippet}
</Linha>
<Linha icone={Eye} cor="#007aff" rotulo="Olhos seguem o alvo">
{#snippet acessorio()}
<Interruptor rotulo="Olhos seguem o alvo" checked={!!cfg?.seguirRastreamento} onchange={(v) => salvarApp({ seguirRastreamento: v })} />
{/snippet}
</Linha>
<Linha icone={PenLine} cor="#8e8e93" rotulo="Desenhar rastreamento no vídeo">
{#snippet acessorio()}
<Interruptor rotulo="Desenhar rastreamento no vídeo" checked={!!cfg?.mostrarRastreamento} onchange={(v) => salvarApp({ mostrarRastreamento: v })} />
{/snippet}
</Linha>
{/if}
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes/camera" />
</Grupo></div>
<div class="c-gestos"><Grupo titulo="Gestos" ajuda="gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto.">
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
<Gestos />
</Linha>
<Linha
icone={Gamepad2}
cor="#30b0c7"
rotulo="Atividades"
detalhe={atividadeAgora ? `Agora: ${ATIVIDADES_INFO[atividadeAgora]?.emoji ?? ''} ${ATIVIDADES_INFO[atividadeAgora]?.nome ?? atividadeAgora}` : 'Um bonequinho dele faz algo embaixo dos olhos'}
>
<div class="atividades">
{#each Object.entries(ATIVIDADES_INFO) as [id, a] (id)}
<button class="btn pequeno" class:ativo={atividadeAgora === id} onclick={() => atividade(id)}>
<span aria-hidden="true">{a.emoji}</span> {a.nome}
</button>
{/each}
{#if atividadeAgora}
<button class="btn pequeno" onclick={() => atividade('parar')}>⏹ Parar</button>
{/if}
</div>
</Linha>
<Linha icone={Wand2} cor="#af52de" rotulo="Criar com IA" detalhe="Expressões, atividades e figurinhas novas" href="#/criar" />
<Linha icone={Sparkles} cor="#ff2d55" rotulo="Todos os gestos e reações" href="#/reacoes" />
</Grupo></div>
<div class="c-acontecimentos"><Grupo titulo="Acontecimentos" ajuda="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" />
<Linha icone={Cable} cor="#8e8e93" rotulo="Ligações da placa" detalhe="Em que pino vai cada fio do PIR e dos servos" href="#/ajustes/ligacoes" />
{#if estado.feed.length}
<ol class="feed">
{#each estado.feed.slice(0, 12) as item (item.id)}
<li class={item.tipo}><time class="mono">{hora(item.quando)}</time> <span>{item.texto}</span></li>
{/each}
</ol>
{:else}
<p class="vazio-feed">Gestos, respostas e reações aparecem aqui na hora.</p>
{/if}
</Grupo></div>
<div class="c-celular"><Grupo titulo="Celular" ajuda="celular">
<Celular />
<Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} />
</Grupo></div>
<div class="c-aparencia"><Grupo titulo="Aparência" ajuda="aparencia">
<Linha
icone={Palette}
cor="#ff9500"
rotulo="Rosto, formato e cor"
detalhe={nomeRosto(estado.aparencia.rosto) + (ROSTOS_INFO[estado.aparencia.rosto]?.formato ? ' · ' + (FORMATOS_INFO[estado.aparencia.formato ?? 'padrao'] ?? '') : '')}
href="#/rosto"
>
{#snippet acessorio()}
<span class="amostra-cor" style="--c: {hex(estado.aparencia.cor)}" aria-hidden="true"></span>
{/snippet}
</Linha>
</Grupo></div>
<div class="c-personalidade"><Grupo titulo="Personalidade" ajuda="personalidade" rodape={estado.ia?.erro || 'Qwen escolhe expressões a partir de sons e movimentos do celular.'}>
<Linha
icone={Brain}
cor="#5e5ce6"
rotulo="Reagir sozinho com IA local"
detalhe={estado.ia?.ultima ? `Último: ${estado.ia.ultima.evento} → ${estado.ia.ultima.gesto} (${estado.ia.ultima.segundos}s)` : estado.ia ? `${estado.ia.modelo} · ${estado.ia.executor}` : ''}
>
{#snippet acessorio()}
<Interruptor rotulo="Reagir sozinho com IA local" checked={!!estado.ia?.ativo} disabled={!estado.ia} onchange={iaLocal} />
{/snippet}
</Linha>
</Grupo></div>
</div>
</div>
<style>
.ao-vivo {
display: grid;
gap: var(--e-5);
align-items: start;
}
.atividades {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.atividades .btn.ativo {
border-color: var(--destaque);
background: var(--destaque-suave);
}
/* ---------- palco: sempre visível */
.palco {
position: sticky;
top: calc(var(--topo-altura) + env(safe-area-inset-top, 0px));
z-index: 20;
aspect-ratio: 16 / 9;
margin: calc(-1 * var(--e-4)) calc(-1 * var(--e-4)) 0;
background: #000;
}
.quadro {
position: absolute;
overflow: hidden;
background: #000;
transition: all 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.quadro:not(.mini) {
inset: 0;
}
.quadro.mini {
right: var(--e-3);
top: var(--e-3);
width: 30%;
aspect-ratio: 16 / 9;
border-radius: var(--raio);
border: 2px solid rgb(255 255 255 / 0.85);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.5);
z-index: 2;
}
.camera video,
.sobreposicao {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.sobreposicao {
pointer-events: none;
}
.vazio {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
gap: var(--e-3);
padding: var(--e-5);
text-align: center;
color: #a9b8c9;
font-size: var(--t-sm);
}
.trocar {
position: absolute;
inset: 0;
display: grid;
place-items: start end;
padding: 6px;
border: 0;
background: transparent;
color: #fff;
cursor: pointer;
}
.rodape {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-3);
background: linear-gradient(transparent, rgb(0 0 0 / 0.75));
color: #e8eef6;
font-size: var(--t-xs);
}
.rodape .mono {
opacity: 0.8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ponto {
width: 8px;
height: 8px;
border-radius: 50%;
background: #8e8e93;
flex: none;
}
.ponto.ao-vivo-on {
background: #ff3b30;
box-shadow: 0 0 0 3px rgb(255 59 48 / 0.3);
}
.espaco {
flex: 1;
}
.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); }
}
/* ---------- cartões: uma coluna no celular (na ordem abaixo), duas no PC */
.coluna {
display: contents;
}
.coluna > div {
min-width: 0;
}
.palco { order: 0; }
.c-quem { order: 1; }
.c-quem:empty { display: none; }
.c-vida { order: 1; }
.c-aprendizado { order: 2; }
.c-celular { order: 3; }
.c-camera { order: 4; }
.c-gestos { order: 5; }
.c-aparencia { order: 6; }
.c-personalidade { order: 7; }
.c-acontecimentos { order: 8; }
.amostra-cor {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--c);
border: 2px solid var(--borda-forte);
}
.feed {
list-style: none;
margin: 0;
padding: var(--e-2) 0;
max-height: 280px;
overflow: auto;
font-size: var(--t-sm);
}
.feed li {
display: flex;
gap: var(--e-3);
padding: 6px var(--e-4);
}
.feed time {
color: var(--texto-3);
flex: none;
}
.feed li.estop {
color: var(--aviso);
}
.vazio-feed {
padding: var(--e-4);
font-size: var(--t-sm);
color: var(--texto-3);
}
@media (min-width: 640px) {
.palco {
margin: 0;
border-radius: var(--raio-lg);
overflow: hidden;
border: 1px solid var(--borda);
}
}
@media (min-width: 1024px) {
.ao-vivo {
grid-template-columns: minmax(0, 1.5fr) minmax(340px, 1fr);
grid-template-areas: 'palco direita' 'esquerda direita';
grid-template-rows: auto 1fr;
gap: var(--e-5);
}
/* com cartões embaixo, a câmera deixa de ficar presa no topo (rolaria por cima deles) */
.palco {
grid-area: palco;
position: relative;
top: auto;
}
.coluna {
display: grid;
align-content: start;
gap: var(--e-5);
min-width: 0;
}
.esquerda {
grid-area: esquerda;
}
.direita {
grid-area: direita;
}
.esquerda > .c-gestos { order: 1; }
.esquerda > .c-celular { order: 2; }
.esquerda > .c-camera { order: 3; }
.esquerda > .c-acontecimentos { order: 4; }
.esquerda > .c-aparencia { order: 5; }
.esquerda > .c-personalidade { order: 6; }
}
</style>