From c4b53a8976b3634be01cc414d688ffd67b34e423 Mon Sep 17 00:00:00 2001 From: julio Date: Mon, 5 Oct 2026 19:47:18 -0300 Subject: [PATCH] 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 --- carinha.html | 13 +- controle_pc.py | 6 +- kiosk-server.py | 12 + tests/test_painel_web_browser.py | 136 +++ web/.gitignore | 2 + web/index.html | 14 + web/package-lock.json | 1250 ++++++++++++++++++++ web/package.json | 26 + web/src/App.svelte | 364 ++++++ web/src/lib/ajustes.svelte.ts | 107 ++ web/src/lib/api.ts | 70 ++ web/src/lib/avisos.svelte.ts | 41 + web/src/lib/comandos.svelte.ts | 89 ++ web/src/lib/componentes/Avisos.svelte | 62 + web/src/lib/componentes/Card.svelte | 51 + web/src/lib/componentes/EStop.svelte | 42 + web/src/lib/componentes/Gestos.svelte | 101 ++ web/src/lib/componentes/MiniRosto.svelte | 87 ++ web/src/lib/componentes/Paleta.svelte | 174 +++ web/src/lib/componentes/RostoPrevia.svelte | 62 + web/src/lib/componentes/Selo.svelte | 50 + web/src/lib/componentes/Slider.svelte | 97 ++ web/src/lib/componentes/Toggle.svelte | 80 ++ web/src/lib/componentes/Token.svelte | 56 + web/src/lib/estado.svelte.ts | 161 +++ web/src/lib/paleta.svelte.ts | 48 + web/src/lib/rastreamento.svelte.ts | 251 ++++ web/src/lib/rotas.svelte.ts | 26 + web/src/lib/tema.css | 128 ++ web/src/lib/tipos.ts | 81 ++ web/src/lib/video.svelte.ts | 236 ++++ web/src/main.ts | 7 + web/src/telas/Ajustes.svelte | 341 ++++++ web/src/telas/AoVivo.svelte | 388 ++++++ web/src/telas/Conversa.svelte | 7 + web/src/telas/Movimento.svelte | 7 + web/src/telas/Reacoes.svelte | 7 + web/svelte.config.js | 2 + web/tsconfig.json | 15 + web/vite.config.ts | 16 + 40 files changed, 4710 insertions(+), 3 deletions(-) create mode 100644 tests/test_painel_web_browser.py create mode 100644 web/.gitignore create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.svelte create mode 100644 web/src/lib/ajustes.svelte.ts create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/avisos.svelte.ts create mode 100644 web/src/lib/comandos.svelte.ts create mode 100644 web/src/lib/componentes/Avisos.svelte create mode 100644 web/src/lib/componentes/Card.svelte create mode 100644 web/src/lib/componentes/EStop.svelte create mode 100644 web/src/lib/componentes/Gestos.svelte create mode 100644 web/src/lib/componentes/MiniRosto.svelte create mode 100644 web/src/lib/componentes/Paleta.svelte create mode 100644 web/src/lib/componentes/RostoPrevia.svelte create mode 100644 web/src/lib/componentes/Selo.svelte create mode 100644 web/src/lib/componentes/Slider.svelte create mode 100644 web/src/lib/componentes/Toggle.svelte create mode 100644 web/src/lib/componentes/Token.svelte create mode 100644 web/src/lib/estado.svelte.ts create mode 100644 web/src/lib/paleta.svelte.ts create mode 100644 web/src/lib/rastreamento.svelte.ts create mode 100644 web/src/lib/rotas.svelte.ts create mode 100644 web/src/lib/tema.css create mode 100644 web/src/lib/tipos.ts create mode 100644 web/src/lib/video.svelte.ts create mode 100644 web/src/main.ts create mode 100644 web/src/telas/Ajustes.svelte create mode 100644 web/src/telas/AoVivo.svelte create mode 100644 web/src/telas/Conversa.svelte create mode 100644 web/src/telas/Movimento.svelte create mode 100644 web/src/telas/Reacoes.svelte create mode 100644 web/svelte.config.js create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/carinha.html b/carinha.html index b3e9220..c145616 100644 --- a/carinha.html +++ b/carinha.html @@ -487,7 +487,18 @@ async function aplicarAparencia() { } catch (e) { /* sem servidor — segue com o padrao */ } } setTimeout(aplicarAparencia, 200); -if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000); +// ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos +// do servidor (SSE) por postMessage. +const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1'; +if (MODO_PAINEL) { + window.carinhaAtiva = false; + addEventListener('message', (e) => { + if (e.origin !== location.origin || e.source !== parent || !e.data) return; + const m = e.data; + if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem); + else if (m.tipo === 'config' && m.config) configurarApp(m.config); + }); +} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000); // teclado (util para testar no PC) const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo", diff --git a/controle_pc.py b/controle_pc.py index 66a3a24..07a8881 100644 --- a/controle_pc.py +++ b/controle_pc.py @@ -104,7 +104,7 @@ class ControlePC: if self.rtc.atender(h, metodo, self.config['preview']): return True rota = h.path.split('?', 1)[0].rstrip('/') - if rota not in ('/app/config', '/app/status', '/camera/frame', '/app/connect', '/app/health'): + if rota not in ('/app/config', '/app/padrao', '/app/status', '/camera/frame', '/app/connect', '/app/health'): return False def enviar(dados, codigo=200): @@ -128,7 +128,9 @@ class ControlePC: else: dados = None - if rota == '/app/health': + if rota == '/app/padrao': + enviar(PADRAO) + elif rota == '/app/health': enviar({'projeto': 'carinha-pc', 'versao': versao()}) elif rota == '/app/config': if metodo == 'POST': diff --git a/kiosk-server.py b/kiosk-server.py index c9888c7..bcca828 100644 --- a/kiosk-server.py +++ b/kiosk-server.py @@ -290,6 +290,18 @@ class Handler(SimpleHTTPRequestHandler): enfileirar(ordem) return self.json({"ok": True, partes[0]: ordem}) + # Painel novo (Vite/Svelte em web/dist); as telas antigas seguem nos caminhos próprios. + painel = Path("web/dist") + if caminho in ("/", "") and (painel / "index.html").is_file(): + self.path = "/web/dist/index.html" + return super().do_GET() + if caminho.startswith("/assets/"): + nome = caminho[len("/assets/"):] + if "/" in nome or "\\" in nome or nome.startswith(".") or not (painel / "assets" / nome).is_file(): + return self.json({"erro": "Arquivo não disponível"}, 404) + self.path = "/web/dist/assets/" + nome + return super().do_GET() + if caminho in ("/dashboard", "/emojis", "/api"): self.path = caminho + ".html" elif caminho == "/painel": diff --git a/tests/test_painel_web_browser.py b/tests/test_painel_web_browser.py new file mode 100644 index 0000000..0e4520d --- /dev/null +++ b/tests/test_painel_web_browser.py @@ -0,0 +1,136 @@ +"""Painel novo (web/dist) contra o servidor real: token, SSE, gestos, paleta, E-Stop, ajustes e celular. + +Requer `npm run build` em web/. Uso: python tests/test_painel_web_browser.py [pasta-para-capturas] +""" +from playwright.sync_api import sync_playwright, expect +from pathlib import Path +import importlib.util +import os +import sys +import tempfile +import threading + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +os.chdir(ROOT) +spec = importlib.util.spec_from_file_location('kiosk_web', ROOT / 'kiosk-server.py') +kiosk = importlib.util.module_from_spec(spec) +spec.loader.exec_module(kiosk) +from braco import Braco +from servos import ControleServos + +TOKEN = 'token-de-teste-123' + + +class HW: + def __init__(self): self.calls = [] + def pulse(self, ch, us): self.calls.append((ch, us)) + def off(self, ch): self.calls.append((ch, None)) + + +def main(capturas=None): + assert (ROOT / 'web/dist/index.html').is_file(), 'Rode npm run build em web/ primeiro' + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + kiosk.ARQUIVO_APARENCIA = str(tmp / 'aparencia.json') + server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler) + server.token = TOKEN + server.controle = kiosk.ControlePC(tmp / 'app.json') + server.servos = ControleServos(tmp / 'servos.json', HW()) + server.braco = Braco(tmp / 'braco.json', server.servos) + kiosk.ligar_eventos(server) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + testar('http://127.0.0.1:%d' % server.server_port, server, capturas) + finally: + kiosk.EVENTOS.fechar() + server.shutdown() + server.server_close() + server.servos.close() + + +def testar(base, server, capturas): + auth = {'Authorization': 'Bearer ' + TOKEN} + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page(viewport={'width': 1440, 'height': 960}) + erros = [] + page.on('pageerror', lambda e: erros.append(str(e))) + page.goto(base + '/') + + # 1. token + expect(page.get_by_role('heading', name='Token do Carinha')).to_be_visible() + page.get_by_label('Token').fill(TOKEN) + page.get_by_role('button', name='Entrar').click() + expect(page.get_by_text('Servidor', exact=True)).to_be_visible(timeout=5000) + + # 2. telemetria do celular chega por SSE + page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8, + 'microfone_ativo': True, 'microfone': 0.3}) + expect(page.get_by_text('Celular online · 80%')).to_be_visible(timeout=5000) + + # 3. gesto pelo dock + kiosk.FILA.limpar() + page.get_by_role('button', name='Feliz').first.click() + expect(page.get_by_text('Expressão: feliz')).to_be_visible(timeout=5000) + assert kiosk.FILA.ordens() == ['feliz'], kiosk.FILA.ordens() + + # 4. atalho numérico e paleta + page.locator('body').click(position={'x': 5, 'y': 900}) + page.keyboard.press('2') + page.wait_for_timeout(300) + assert kiosk.FILA.ordens()[-1] == 'coracao', kiosk.FILA.ordens() + page.keyboard.press('Control+k') + page.get_by_label('Buscar comando').fill('surpreso') + page.keyboard.press('Enter') + page.wait_for_timeout(300) + assert kiosk.FILA.ordens()[-1] == 'surpreso', kiosk.FILA.ordens() + if capturas: + page.screenshot(path=str(Path(capturas) / 'ao-vivo.png'), full_page=True) + + # 5. E-Stop real + page.get_by_role('button', name='Parar tudo').click() + expect(page.get_by_role('button', name='PARADO · Rearmar')).to_be_visible(timeout=5000) + assert kiosk.ESTOP['ativo'] and server.servos.travado + page.get_by_role('button', name='PARADO · Rearmar').click() + expect(page.get_by_role('button', name='Parar tudo')).to_be_visible(timeout=5000) + assert not server.servos.travado + + # 6. ajustes salvam sem travar + page.get_by_role('link', name='Ajustes').first.click() + page.get_by_label('Buscar ajuste').fill('boca') + boca = page.get_by_role('switch', name='Mostrar boca') + assert boca.is_checked() + boca.locator('xpath=..').click() + page.wait_for_timeout(500) + assert server.controle.config['boca'] is False + if capturas: + page.get_by_label('Buscar ajuste').fill('') + page.screenshot(path=str(Path(capturas) / 'ajustes.png'), full_page=True) + + # 7. celular: barra inferior e nada de rolagem horizontal + page.set_viewport_size({'width': 400, 'height': 860}) + page.get_by_role('link', name='Ao vivo').click() + page.wait_for_timeout(300) + largura = page.evaluate('document.scrollingElement.scrollWidth') + assert largura <= 400, 'rolagem horizontal: %s' % largura + if capturas: + page.screenshot(path=str(Path(capturas) / 'celular.png'), full_page=True) + + # 8. tema claro + page.set_viewport_size({'width': 1280, 'height': 900}) + inicial = page.evaluate("document.documentElement.dataset.theme") + outro = 'light' if inicial == 'dark' else 'dark' + page.get_by_role('button', name='Usar tema ' + ('claro' if outro == 'light' else 'escuro')).click() + assert page.evaluate("document.documentElement.dataset.theme") == outro + if capturas: + page.screenshot(path=str(Path(capturas) / ('tema-' + outro + '.png'))) + + assert not erros, erros + browser.close() + print('OK: token, SSE, dock 1-9, paleta, E-Stop, ajustes otimistas, celular 400px, tema claro') + + +if __name__ == '__main__': + main(sys.argv[1] if len(sys.argv) > 1 else None) diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..155f7d3 --- /dev/null +++ b/web/index.html @@ -0,0 +1,14 @@ + + + + + + + Carinha + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..017eb65 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1250 @@ +{ + "name": "carinha-painel", + "version": "0.8.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "carinha-painel", + "version": "0.8.0", + "dependencies": { + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource-variable/outfit": "^5.3.0", + "@lucide/svelte": "^1.52.0" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.3.1", + "@tsconfig/svelte": "^5.0.8", + "@types/node": "^26.6.4", + "svelte": "^5.57.1", + "svelte-check": "^4.7.6", + "typescript": "^6.0.3", + "vite": "^8.3.2" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/outfit": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.3.0.tgz", + "integrity": "sha512-fPb5XnYK0k7SbR++HEmm4+ZqDszmLKQ+sPRUr8oQ/KNC0vy6r6hazJFe8iYozv4cqlHbvJ/JECkJWrfDodUMVQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lucide/svelte": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.52.0.tgz", + "integrity": "sha512-bQ8t3IcZxWqAdmdMZPyZEv8Qf+MDgBAf7n43eRQoXyBeLWr8HWToiMIO6vKlHxC/lnMiCDdTzm09hjy92ch46A==", + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", + "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.57.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", + "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..46a2b08 --- /dev/null +++ b/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "carinha-painel", + "private": true, + "version": "0.8.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.3.1", + "@tsconfig/svelte": "^5.0.8", + "@types/node": "^26.6.4", + "svelte": "^5.57.1", + "svelte-check": "^4.7.6", + "typescript": "^6.0.3", + "vite": "^8.3.2" + }, + "dependencies": { + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource-variable/outfit": "^5.3.0", + "@lucide/svelte": "^1.52.0" + } +} diff --git a/web/src/App.svelte b/web/src/App.svelte new file mode 100644 index 0000000..25c5563 --- /dev/null +++ b/web/src/App.svelte @@ -0,0 +1,364 @@ + + + + +{#if estado.conexao === 'token'} + +{/if} + +
+ + +
+

{ROTAS.find((r) => r.id === nav.atual)?.titulo}

+
+ + {conexaoTexto} + + + Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''} + + {#if estado.conversa} + IA: {estado.conversa.provedor === 'gemini' ? 'Gemini' : 'Qwen local'} + {/if} +
+
+ + + +
+
+ +
+ +
+ + {#if nav.atual !== 'ao-vivo'} + + {/if} +
+ + + + + diff --git a/web/src/lib/ajustes.svelte.ts b/web/src/lib/ajustes.svelte.ts new file mode 100644 index 0000000..58c36e0 --- /dev/null +++ b/web/src/lib/ajustes.svelte.ts @@ -0,0 +1,107 @@ +/** Ajustes do app do celular (/app/config): salvamento otimista e campos agrupados. */ +import { pedir, postar } from './api'; +import { avisos } from './avisos.svelte'; +import { estado } from './estado.svelte'; +import type { AppConfig } from './tipos'; + +/** Atualiza na hora; se o servidor recusar, volta ao valor anterior e avisa. Não trava o formulário. */ +export async function salvarApp(parcial: AppConfig) { + const antes = estado.appConfig ? { ...estado.appConfig } : null; + if (estado.appConfig) estado.appConfig = { ...estado.appConfig, ...parcial }; + try { + const r = await postar<{ config: AppConfig }>('/app/config', parcial); + estado.appConfig = r.config; + } catch (e) { + estado.appConfig = antes; + avisos.erro(e); + } +} + +export async function restaurarPadrao() { + const padrao = await pedir('/app/padrao'); + // A transmissão não é "ajuste": continua como está. + delete padrao.preview; + await salvarApp(padrao); + avisos.ok('Ajustes do celular restaurados para o padrão.'); +} + +type Opcoes = [string | number, string][]; +export interface Campo { + chave: string; + rotulo: string; + ajuda?: string; + tipo: 'toggle' | 'slider' | 'escolha'; + min?: number; + max?: number; + passo?: number; + unidade?: string; + opcoes?: Opcoes; +} + +export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] = [ + { + titulo: 'Tela e personalidade', + descricao: 'Como o rosto aparece e se comporta sozinho.', + campos: [ + { chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' }, + { chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' }, + { chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', tipo: 'toggle' }, + { chave: 'audio', rotulo: 'Sons no celular', ajuda: 'Bipes do próprio robô', tipo: 'toggle' }, + { chave: 'volume', rotulo: 'Volume dos sons', tipo: 'slider', min: 0, max: 1, passo: 0.05 }, + { chave: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 }, + { chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' }, + { chave: 'brilhoRepouso', rotulo: 'Brilho em repouso', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 }, + { chave: 'repousoTela', rotulo: 'Espera para reduzir brilho', tipo: 'slider', min: 5, max: 300, passo: 5, unidade: ' s' }, + ], + }, + { + titulo: 'Sensores e reações', + descricao: 'O que o celular percebe e como reage.', + campos: [ + { chave: 'sensorMic', rotulo: 'Microfone', ajuda: 'Só o nível de som é usado, nunca o áudio', tipo: 'toggle' }, + { chave: 'ouvir', rotulo: 'Reagir a sons', tipo: 'toggle' }, + { chave: 'micSono', rotulo: 'Microfone durante o sono', tipo: 'toggle' }, + { chave: 'sompausa', rotulo: 'Silêncio para dormir', tipo: 'slider', min: 1, max: 120, passo: 1, unidade: ' s' }, + { chave: 'sensorCamera', rotulo: 'Câmera como sensor', ajuda: 'Detecta movimento e permite o vídeo', tipo: 'toggle' }, + { chave: 'reagir', rotulo: 'Reagir a movimento', tipo: 'toggle' }, + { chave: 'cameraSono', rotulo: 'Câmera durante o sono', tipo: 'toggle' }, + { chave: 'movolhar', rotulo: 'Quanto os olhos acompanham', tipo: 'slider', min: 0, max: 100, passo: 1, unidade: '%' }, + ], + }, + { + titulo: 'Vídeo e rastreamento', + descricao: 'Qualidade da transmissão e rastreamento feito neste PC.', + campos: [ + { chave: 'cameraLado', rotulo: 'Câmera', tipo: 'escolha', opcoes: [['user', 'Frontal'], ['environment', 'Traseira']] }, + { + chave: 'videoResolucao', + rotulo: 'Resolução', + tipo: 'escolha', + opcoes: [['auto', 'Automática'], ['max', 'Máxima'], ['480', '480p'], ['720', '720p'], ['1080', '1080p'], ['2160', '4K'], ['4320', '8K']], + }, + { + chave: 'videoFPS', + rotulo: 'Quadros por segundo', + tipo: 'escolha', + opcoes: [[0, 'Automático'], [-1, 'Máximo'], [15, '15'], [24, '24'], [30, '30'], [60, '60'], [120, '120'], [240, '240']], + }, + { chave: 'rastrearRosto', rotulo: 'Rastrear rosto', tipo: 'toggle' }, + { chave: 'rastrearMaos', rotulo: 'Rastrear mãos', tipo: 'toggle' }, + { chave: 'seguirRastreamento', rotulo: 'Olhos seguem o alvo', tipo: 'toggle' }, + { chave: 'mostrarRastreamento', rotulo: 'Desenhar rastreamento no vídeo', tipo: 'toggle' }, + ], + }, + { + titulo: 'Sensibilidade (avançado)', + descricao: 'Limiares dos sustos e intervalos entre reações.', + campos: [ + { chave: 'somlim', rotulo: 'Nível para perceber som', tipo: 'slider', min: 0, max: 1, passo: 0.01 }, + { chave: 'somaltolim', rotulo: 'Som mínimo para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 }, + { chave: 'somganho', rotulo: 'Aumento súbito para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 }, + { chave: 'somcooldown', rotulo: 'Intervalo entre sustos por som', tipo: 'slider', min: 0, max: 120, passo: 1, unidade: ' s' }, + { chave: 'movlim', rotulo: 'Movimento mínimo', tipo: 'slider', min: 0, max: 1, passo: 0.01 }, + { chave: 'movforte', rotulo: 'Movimento para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 }, + { chave: 'movcooldown', rotulo: 'Intervalo entre sustos por movimento', tipo: 'slider', min: 0, max: 120, passo: 1, unidade: ' s' }, + ], + }, +]; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..240af49 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,70 @@ +/** Cliente HTTP único do painel: token, JSON, tempo limite e erro legível. */ + +const CHAVE_TOKEN = 'carinha_token'; + +export class ErroApi extends Error { + constructor(public status: number, mensagem: string) { + super(mensagem); + } +} + +export function lerToken(): string { + try { + return localStorage.getItem(CHAVE_TOKEN) || ''; + } catch { + return ''; + } +} + +export function gravarToken(token: string) { + try { + if (token) localStorage.setItem(CHAVE_TOKEN, token); + else localStorage.removeItem(CHAVE_TOKEN); + } catch { + /* navegação privada: vale só nesta aba */ + } +} + +/** Avisado quando o servidor recusa o token (abre o diálogo de token). */ +let aoNegar: () => void = () => {}; +export function quandoNegado(fn: () => void) { + aoNegar = fn; +} + +export async function pedir(caminho: string, dados?: unknown, tempo = 8000): Promise { + const cabecalhos: Record = {}; + const token = lerToken(); + if (token) cabecalhos.Authorization = 'Bearer ' + token; + const opcoes: RequestInit = { cache: 'no-store', headers: cabecalhos, signal: AbortSignal.timeout(tempo) }; + if (dados !== undefined) { + opcoes.method = 'POST'; + cabecalhos['Content-Type'] = 'application/json'; + opcoes.body = JSON.stringify(dados); + } + let resposta: Response; + try { + resposta = await fetch(caminho, opcoes); + } catch (e) { + const nome = (e as Error).name; + throw new ErroApi(0, nome === 'TimeoutError' ? 'O servidor demorou a responder' : 'Sem conexão com o servidor'); + } + const texto = await resposta.text(); + let corpo: any = null; + try { + corpo = texto ? JSON.parse(texto) : null; + } catch { + corpo = null; + } + if (!resposta.ok) { + if (resposta.status === 401) aoNegar(); + throw new ErroApi(resposta.status, corpo?.erro || `Erro ${resposta.status}`); + } + return corpo as T; +} + +export const postar = (caminho: string, dados: unknown = {}) => pedir(caminho, dados); + +export function urlEventos(): string { + const token = lerToken(); + return token ? '/eventos?t=' + encodeURIComponent(token) : '/eventos'; +} diff --git a/web/src/lib/avisos.svelte.ts b/web/src/lib/avisos.svelte.ts new file mode 100644 index 0000000..802b7ae --- /dev/null +++ b/web/src/lib/avisos.svelte.ts @@ -0,0 +1,41 @@ +/** Toasts no lugar de alert/confirm: mensagem curta, com ação opcional (ex.: Desfazer). */ + +export type TipoAviso = 'info' | 'ok' | 'erro'; + +export interface Aviso { + id: number; + texto: string; + tipo: TipoAviso; + acao?: { rotulo: string; fazer: () => void }; +} + +class Avisos { + lista = $state([]); + private seq = 0; + + mostrar(texto: string, tipo: TipoAviso = 'info', acao?: Aviso['acao'], ms = tipo === 'erro' ? 6000 : 3500) { + const id = ++this.seq; + this.lista = [...this.lista, { id, texto, tipo, acao }].slice(-4); + setTimeout(() => this.fechar(id), acao ? Math.max(ms, 6000) : ms); + return id; + } + fechar(id: number) { + this.lista = this.lista.filter((a) => a.id !== id); + } + ok = (t: string, acao?: Aviso['acao']) => this.mostrar(t, 'ok', acao); + erro = (e: unknown) => this.mostrar(e instanceof Error ? e.message : String(e), 'erro'); +} + +export const avisos = new Avisos(); + +/** Executa uma ação do painel mostrando o erro num toast em vez de engolir. */ +export async function tentar(fn: () => Promise, sucesso?: string): Promise { + try { + const r = await fn(); + if (sucesso) avisos.ok(sucesso); + return r; + } catch (e) { + avisos.erro(e); + return undefined; + } +} diff --git a/web/src/lib/comandos.svelte.ts b/web/src/lib/comandos.svelte.ts new file mode 100644 index 0000000..94cfe93 --- /dev/null +++ b/web/src/lib/comandos.svelte.ts @@ -0,0 +1,89 @@ +/** Ações que qualquer tela pode disparar: gestos, rosto, cor, E-Stop. */ +import { postar } from './api'; +import { avisos, tentar } from './avisos.svelte'; +import { estado } from './estado.svelte'; + +/** Nome amigável e emoji de cada gesto do rosto ('parar' fecha o kiosk: não vira botão). */ +export const GESTOS_INFO: Record = { + feliz: { nome: 'Feliz', emoji: '😊' }, + alegre: { nome: 'Alegre', emoji: '😄' }, + coracao: { nome: 'Coração', emoji: '❤️' }, + piscar: { nome: 'Piscar', emoji: '😌' }, + piscar2: { nome: 'Piscar duplo', emoji: '😑' }, + piscadinha: { nome: 'Piscadinha', emoji: '😉' }, + surpreso: { nome: 'Surpreso', emoji: '😮' }, + triste: { nome: 'Triste', emoji: '😢' }, + bravo: { nome: 'Bravo', emoji: '😠' }, + olhar_esq: { nome: 'Olhar à esquerda', emoji: '👈' }, + olhar_dir: { nome: 'Olhar à direita', emoji: '👉' }, + olhar_baixo: { nome: 'Olhar para baixo', emoji: '👇' }, + dormir: { nome: 'Dormir', emoji: '😴' }, + acordar: { nome: 'Acordar', emoji: '🌅' }, + aleatorio: { nome: 'Aleatório', emoji: '🎲' }, +}; + +export const gestosDisponiveis = () => estado.gestos.filter((g) => g in GESTOS_INFO); + +const CHAVE_FAVORITOS = 'carinha_favoritos'; +const PADRAO_FAVORITOS = ['feliz', 'coracao', 'piscadinha', 'surpreso', 'triste', 'olhar_esq', 'olhar_dir', 'dormir', 'acordar']; + +function lerFavoritos(): string[] { + try { + const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null'); + if (Array.isArray(v) && v.every((x) => typeof x === 'string')) return v.slice(0, 9); + } catch { + /* sem armazenamento */ + } + return PADRAO_FAVORITOS; +} + +class Favoritos { + lista = $state(lerFavoritos()); + alternar(g: string) { + this.lista = this.lista.includes(g) ? this.lista.filter((x) => x !== g) : [...this.lista, g].slice(0, 9); + try { + localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista)); + } catch { + /* ok */ + } + } +} +export const favoritos = new Favoritos(); + +export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome })); +export const rosto = (nome: string) => tentar(() => postar('/comando', { tipo: 'rosto', valor: nome })); +export const cor = (rgb: [number, number, number]) => tentar(() => postar('/comando', { tipo: 'cor', valor: rgb })); +export const recarregarCelular = () => + tentar(async () => { + const r = await postar<{ mensagem: string }>('/comando', { tipo: 'recarregar' }); + avisos.ok(r.mensagem); + }); + +export async function pararTudo() { + try { + await postar('/parar-tudo'); + avisos.mostrar('E-Stop acionado: motores desligados e travados.', 'erro'); + } catch (e) { + avisos.erro(e); + } +} + +export const rearmar = () => tentar(() => postar('/rearmar'), 'Motores rearmados.'); + +export function hex(rgb: number[]) { + return '#' + rgb.map((v) => v.toString(16).padStart(2, '0')).join(''); +} +export function rgb(hexCor: string): [number, number, number] { + const n = parseInt(hexCor.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} +export const CORES: { nome: string; rgb: [number, number, number] }[] = [ + { nome: 'Ciano', rgb: [92, 214, 255] }, + { nome: 'Verde', rgb: [74, 222, 128] }, + { nome: 'Amarelo', rgb: [251, 191, 36] }, + { nome: 'Laranja', rgb: [255, 140, 50] }, + { nome: 'Rosa', rgb: [255, 110, 190] }, + { nome: 'Roxo', rgb: [167, 139, 250] }, + { nome: 'Vermelho', rgb: [248, 90, 90] }, + { nome: 'Branco', rgb: [235, 240, 245] }, +]; diff --git a/web/src/lib/componentes/Avisos.svelte b/web/src/lib/componentes/Avisos.svelte new file mode 100644 index 0000000..5315028 --- /dev/null +++ b/web/src/lib/componentes/Avisos.svelte @@ -0,0 +1,62 @@ + + +
+ {#each avisos.lista as a (a.id)} +
+ {a.texto} + {#if a.acao} + + {/if} + +
+ {/each} +
+ + diff --git a/web/src/lib/componentes/Card.svelte b/web/src/lib/componentes/Card.svelte new file mode 100644 index 0000000..62f9ce0 --- /dev/null +++ b/web/src/lib/componentes/Card.svelte @@ -0,0 +1,51 @@ + + +
+ {#if titulo || acoes} +
+
+ {#if titulo}

{titulo}

{/if} + {#if descricao}

{descricao}

{/if} +
+ {#if acoes}
{@render acoes()}
{/if} +
+ {/if} + {@render children()} +
+ + diff --git a/web/src/lib/componentes/EStop.svelte b/web/src/lib/componentes/EStop.svelte new file mode 100644 index 0000000..5a7984f --- /dev/null +++ b/web/src/lib/componentes/EStop.svelte @@ -0,0 +1,42 @@ + + +{#if estado.estop.ativo} + +{:else} + +{/if} + + diff --git a/web/src/lib/componentes/Gestos.svelte b/web/src/lib/componentes/Gestos.svelte new file mode 100644 index 0000000..515c0fe --- /dev/null +++ b/web/src/lib/componentes/Gestos.svelte @@ -0,0 +1,101 @@ + + +
+ {#each lista as g, i (g)} + {@const info = GESTOS_INFO[g]} +
+ + {#if completo} + + {/if} +
+ {/each} +
+ + diff --git a/web/src/lib/componentes/MiniRosto.svelte b/web/src/lib/componentes/MiniRosto.svelte new file mode 100644 index 0000000..b0e81b5 --- /dev/null +++ b/web/src/lib/componentes/MiniRosto.svelte @@ -0,0 +1,87 @@ + + + + + diff --git a/web/src/lib/componentes/Paleta.svelte b/web/src/lib/componentes/Paleta.svelte new file mode 100644 index 0000000..8e51dc9 --- /dev/null +++ b/web/src/lib/componentes/Paleta.svelte @@ -0,0 +1,174 @@ + + +{#if paleta.aberta} + +
+ +
+{/if} + + diff --git a/web/src/lib/componentes/RostoPrevia.svelte b/web/src/lib/componentes/RostoPrevia.svelte new file mode 100644 index 0000000..0f9fbf8 --- /dev/null +++ b/web/src/lib/componentes/RostoPrevia.svelte @@ -0,0 +1,62 @@ + + + + + diff --git a/web/src/lib/componentes/Selo.svelte b/web/src/lib/componentes/Selo.svelte new file mode 100644 index 0000000..220dc9a --- /dev/null +++ b/web/src/lib/componentes/Selo.svelte @@ -0,0 +1,50 @@ + + +{@render children()} + + diff --git a/web/src/lib/componentes/Slider.svelte b/web/src/lib/componentes/Slider.svelte new file mode 100644 index 0000000..1c5d7c2 --- /dev/null +++ b/web/src/lib/componentes/Slider.svelte @@ -0,0 +1,97 @@ + + +
+ + { + local = Number(e.currentTarget.value); + oninput?.(local); + }} + onchange={(e) => { + const v = Number(e.currentTarget.value); + local = null; + onchange?.(v); + }} + /> +
+ + diff --git a/web/src/lib/componentes/Toggle.svelte b/web/src/lib/componentes/Toggle.svelte new file mode 100644 index 0000000..be0894c --- /dev/null +++ b/web/src/lib/componentes/Toggle.svelte @@ -0,0 +1,80 @@ + + + + + diff --git a/web/src/lib/componentes/Token.svelte b/web/src/lib/componentes/Token.svelte new file mode 100644 index 0000000..a2255d9 --- /dev/null +++ b/web/src/lib/componentes/Token.svelte @@ -0,0 +1,56 @@ + + +
+
+ +

Token do Carinha

+

+ Este servidor exige um token para controlar o robô. Ele fica em CARINHA_TOKEN ou + seguranca.json na placa e é guardado só neste navegador. +

+ + + +
+ + diff --git a/web/src/lib/estado.svelte.ts b/web/src/lib/estado.svelte.ts new file mode 100644 index 0000000..1f87e77 --- /dev/null +++ b/web/src/lib/estado.svelte.ts @@ -0,0 +1,161 @@ +/** + * Estado do painel: uma foto inicial (/estado) + um único fluxo SSE (/eventos). + * Nenhuma tela faz polling; todas leem daqui. + */ +import { ErroApi, pedir, quandoNegado, urlEventos } from './api'; +import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos'; + +export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token'; + +export interface ItemFeed { + id: number; + quando: number; + tipo: string; + texto: string; +} + +class Estado { + conexao = $state('conectando'); + versao = $state(''); + autenticacao = $state(false); + estop = $state({ ativo: false, desde: null as number | null }); + aparencia = $state({ rosto: 'carinha', cor: [92, 214, 255] as [number, number, number] }); + gestos = $state([]); + rostos = $state([]); + app = $state(null); + appRecebidoEm = $state(0); + appConfig = $state(null); + motores = $state(null); + conversa = $state(null); + ia = $state(null); + reacao = $state(null); + treinamento = $state({ ocupado: false, erro: '' }); + api = $state(null); + feed = $state([]); + /** Relógio de 1 s para derivar "online" sem esperar evento novo. */ + agora = $state(Date.now()); + + celularOnline = $derived(!!this.app?.online && this.agora - this.appRecebidoEm < 6000); +} + +export const estado = new Estado(); + +type Ouvinte = (dados: any) => void; +const ouvintes = new Map>(); + +/** Assina um tipo de evento do servidor; devolve a função para cancelar. */ +export function ouvir(tipo: string, fn: Ouvinte): () => void { + if (!ouvintes.has(tipo)) ouvintes.set(tipo, new Set()); + ouvintes.get(tipo)!.add(fn); + return () => ouvintes.get(tipo)!.delete(fn); +} + +let seqFeed = 0; +function noFeed(tipo: string, texto: string) { + estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40); +} + +function aplicarFoto(f: EstadoServidor) { + estado.versao = f.versao; + estado.autenticacao = f.autenticacao; + estado.estop = f.estop; + estado.aparencia = f.aparencia; + estado.gestos = f.gestos; + estado.rostos = f.rostos; + if (f.app) { + estado.app = f.app; + estado.appRecebidoEm = Date.now(); + } + if (f.app_config) estado.appConfig = f.app_config; + if (f.motores) estado.motores = f.motores; + if (f.conversa) estado.conversa = f.conversa; + if (f.ia) estado.ia = f.ia; + estado.reacao = f.reacao ?? null; + if (f.treinamento) estado.treinamento = f.treinamento; + if (f.api) estado.api = f.api; +} + +const tratadores: Record void> = { + expressao: (d: { ordem: string }) => { + if (d.ordem !== 'calma') noFeed('expressao', 'Expressão: ' + d.ordem); + }, + telemetria: (d: Telemetria) => { + estado.app = d; + estado.appRecebidoEm = Date.now(); + }, + app_config: (d: AppConfig) => (estado.appConfig = d), + aparencia: (d) => (estado.aparencia = d), + motores: (d: Motores) => (estado.motores = d), + estop: (d) => { + estado.estop = d; + noFeed('estop', d.ativo ? 'E-Stop acionado' : 'Motores rearmados'); + }, + conversa: (d: Conversa) => { + const nova = d.ultima && d.ultima.id !== estado.conversa?.ultima?.id; + estado.conversa = d; + if (nova) noFeed('conversa', 'Carinha: ' + d.ultima!.texto); + }, + ia: (d: IA) => { + const nova = d.ultima && d.ultima.horario !== estado.ia?.ultima?.horario; + estado.ia = d; + if (nova) noFeed('ia', `IA local: ${d.ultima!.evento} → ${d.ultima!.gesto}`); + }, + reacao: (d: Reacao | null) => { + estado.reacao = d; + if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome); + }, + treinamento: (d) => (estado.treinamento = d), +}; + +let fonte: EventSource | null = null; +let primeiraAbertura = true; + +export async function carregarFoto() { + aplicarFoto(await pedir('/estado')); +} + +function abrirEventos() { + fonte?.close(); + fonte = new EventSource(urlEventos()); + fonte.addEventListener('pronto', async () => { + estado.conexao = 'aberta'; + // Ao reconectar, ressincroniza: pode ter perdido eventos além do buffer do servidor. + if (!primeiraAbertura) carregarFoto().catch(() => {}); + primeiraAbertura = false; + }); + fonte.onerror = () => { + if (estado.conexao !== 'token') estado.conexao = 'caiu'; + }; + for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia', + 'reacao', 'treinamento']) { + fonte.addEventListener(tipo, (e) => { + const dados = JSON.parse((e as MessageEvent).data); + tratadores[tipo]?.(dados); + ouvintes.get(tipo)?.forEach((fn) => fn(dados)); + }); + } +} + +export async function conectar() { + estado.conexao = 'conectando'; + try { + await carregarFoto(); + } catch (e) { + if (e instanceof ErroApi && e.status === 401) { + estado.conexao = 'token'; + return; + } + estado.conexao = 'caiu'; + setTimeout(conectar, 3000); + return; + } + abrirEventos(); +} + +quandoNegado(() => { + estado.conexao = 'token'; + fonte?.close(); + fonte = null; +}); + +setInterval(() => (estado.agora = Date.now()), 1000); diff --git a/web/src/lib/paleta.svelte.ts b/web/src/lib/paleta.svelte.ts new file mode 100644 index 0000000..8dc1244 --- /dev/null +++ b/web/src/lib/paleta.svelte.ts @@ -0,0 +1,48 @@ +/** Registro da command palette (Ctrl+K): cada parte do painel contribui com itens. */ +import { cor, CORES, gesto, GESTOS_INFO, gestosDisponiveis, pararTudo, rearmar, recarregarCelular, rosto } from './comandos.svelte'; +import { estado } from './estado.svelte'; +import { nav, ROTAS } from './rotas.svelte'; + +export interface ItemPaleta { + id: string; + grupo: string; + titulo: string; + dica?: string; + fazer: () => unknown; +} + +type Fonte = () => ItemPaleta[]; +const fontes = new Set(); + +export function registrarNaPaleta(fonte: Fonte): () => void { + fontes.add(fonte); + return () => fontes.delete(fonte); +} + +class Paleta { + aberta = $state(false); + abrir = () => (this.aberta = true); + fechar = () => (this.aberta = false); +} +export const paleta = new Paleta(); + +export function itensDaPaleta(): ItemPaleta[] { + return [...fontes].flatMap((f) => f()); +} + +// Itens base: navegação, ações gerais, gestos, rostos e cores. +registrarNaPaleta(() => [ + ...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })), + estado.estop.ativo + ? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar } + : { id: 'estop', grupo: 'Ações', titulo: 'Parar tudo (E-Stop)', dica: 'Shift+Espaço', fazer: pararTudo }, + { id: 'recarregar', grupo: 'Ações', titulo: 'Recarregar tela do celular', fazer: recarregarCelular }, + ...gestosDisponiveis().map((g) => ({ + id: 'gesto-' + g, + grupo: 'Gestos', + titulo: `${GESTOS_INFO[g].emoji} ${GESTOS_INFO[g].nome}`, + fazer: () => gesto(g), + })), + ...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + r, fazer: () => rosto(r) })), + ...CORES.map((c) => ({ id: 'cor-' + c.nome, grupo: 'Cor dos olhos', titulo: c.nome, fazer: () => cor(c.rgb) })), +]); diff --git a/web/src/lib/rastreamento.svelte.ts b/web/src/lib/rastreamento.svelte.ts new file mode 100644 index 0000000..5e2699f --- /dev/null +++ b/web/src/lib/rastreamento.svelte.ts @@ -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.'); + } +} diff --git a/web/src/lib/rotas.svelte.ts b/web/src/lib/rotas.svelte.ts new file mode 100644 index 0000000..531c6a5 --- /dev/null +++ b/web/src/lib/rotas.svelte.ts @@ -0,0 +1,26 @@ +/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */ + +export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes'; + +export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [ + { id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' }, + { id: 'conversa', titulo: 'Conversa', atalho: 'g c' }, + { id: 'reacoes', titulo: 'Reações', atalho: 'g r' }, + { id: 'movimento', titulo: 'Movimento', atalho: 'g m' }, + { id: 'ajustes', titulo: 'Ajustes', atalho: 'g a' }, +]; + +function lerHash(): Rota { + const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota; + return ROTAS.some((r) => r.id === id) ? id : 'ao-vivo'; +} + +class Navegacao { + atual = $state(lerHash()); + ir(rota: Rota) { + location.hash = '/' + rota; + } +} + +export const nav = new Navegacao(); +addEventListener('hashchange', () => (nav.atual = lerHash())); diff --git a/web/src/lib/tema.css b/web/src/lib/tema.css new file mode 100644 index 0000000..0a0eac6 --- /dev/null +++ b/web/src/lib/tema.css @@ -0,0 +1,128 @@ +/* Design system do painel Carinha: tokens + poucos utilitários. Escuro por padrão, claro opcional. */ +:root { + color-scheme: dark; + --fundo: #0b1017; + --fundo-2: #0f1620; + --superficie: #141c27; + --superficie-2: #1a2431; + --superficie-3: #223041; + --borda: #263447; + --borda-forte: #34475f; + --texto: #e8eef6; + --texto-2: #a9b8c9; + --texto-3: #8396ab; /* AA sobre --superficie */ + --destaque: #5cd6ff; + --destaque-forte: #2fbff0; + --destaque-texto: #04202b; + --destaque-suave: rgb(92 214 255 / 0.12); + --ok: #4ade80; + --ok-suave: rgb(74 222 128 / 0.12); + --aviso: #fbbf24; + --aviso-suave: rgb(251 191 36 / 0.12); + --perigo: #f87171; + --perigo-forte: #ef4444; + --perigo-suave: rgb(248 113 113 / 0.14); + --vidro: rgb(15 22 32 / 0.72); + + --fonte: 'Outfit Variable', 'Segoe UI', system-ui, sans-serif; + --fonte-mono: 'JetBrains Mono Variable', ui-monospace, Consolas, monospace; + --t-xs: 0.75rem; --t-sm: 0.875rem; --t-md: 1rem; --t-lg: 1.25rem; --t-xl: 1.6rem; + + --e-1: 4px; --e-2: 8px; --e-3: 12px; --e-4: 16px; --e-5: 24px; --e-6: 32px; + --raio-sm: 8px; --raio: 12px; --raio-lg: 18px; --raio-pill: 999px; + --sombra: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 24px rgb(0 0 0 / 0.25); + --sombra-flutuante: 0 12px 40px rgb(0 0 0 / 0.45); + --transicao: 160ms cubic-bezier(0.2, 0.8, 0.2, 1); + + --nav-largura: 232px; + --topo-altura: 60px; +} + +:root[data-theme='light'] { + color-scheme: light; + --fundo: #f4f6f9; + --fundo-2: #eef2f6; + --superficie: #ffffff; + --superficie-2: #f3f6fa; + --superficie-3: #e7edf4; + --borda: #dbe3ec; + --borda-forte: #c3cfdc; + --texto: #0f1722; + --texto-2: #3d4d60; + --texto-3: #5a6b7f; + --destaque: #0284c7; + --destaque-forte: #0369a1; + --destaque-texto: #ffffff; + --destaque-suave: rgb(2 132 199 / 0.1); + --ok: #15803d; --ok-suave: rgb(21 128 61 / 0.1); + --aviso: #b45309; --aviso-suave: rgb(180 83 9 / 0.1); + --perigo: #dc2626; --perigo-forte: #b91c1c; --perigo-suave: rgb(220 38 38 / 0.1); + --vidro: rgb(255 255 255 / 0.78); + --sombra: 0 1px 2px rgb(15 23 34 / 0.06), 0 6px 20px rgb(15 23 34 / 0.08); + --sombra-flutuante: 0 12px 40px rgb(15 23 34 / 0.18); +} + +*, *::before, *::after { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + background: var(--fundo); + color: var(--texto); + font: 400 var(--t-md)/1.5 var(--fonte); + -webkit-font-smoothing: antialiased; +} +h1, h2, h3, h4 { margin: 0; line-height: 1.2; font-weight: 600; } +h1 { font-size: var(--t-xl); } +h2 { font-size: var(--t-lg); } +h3 { font-size: var(--t-md); } +p { margin: 0; } +a { color: var(--destaque); } +img, video, canvas, iframe { max-width: 100%; } +[hidden] { display: none !important; } +:focus-visible { outline: 2px solid var(--destaque); outline-offset: 2px; } + +/* ---------- botões ---------- */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: var(--e-2); + min-height: 40px; padding: 0 var(--e-4); + border: 1px solid var(--borda-forte); border-radius: var(--raio-sm); + background: var(--superficie-2); color: var(--texto); + font: 500 var(--t-sm) var(--fonte); cursor: pointer; white-space: nowrap; + transition: background var(--transicao), border-color var(--transicao), transform var(--transicao); + touch-action: manipulation; user-select: none; +} +.btn:hover:not(:disabled) { background: var(--superficie-3); } +.btn:active:not(:disabled) { transform: translateY(1px); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn.primario { background: var(--destaque); border-color: var(--destaque); color: var(--destaque-texto); } +.btn.primario:hover:not(:disabled) { background: var(--destaque-forte); } +.btn.perigo { background: var(--perigo-forte); border-color: var(--perigo-forte); color: #fff; } +.btn.perigo:hover:not(:disabled) { background: var(--perigo); } +.btn.fantasma { background: transparent; border-color: transparent; } +.btn.fantasma:hover:not(:disabled) { background: var(--superficie-2); } +.btn.icone { padding: 0; width: 40px; } +.btn.pequeno { min-height: 32px; padding: 0 var(--e-3); font-size: var(--t-xs); } +.btn.pequeno.icone { width: 32px; } + +/* ---------- formulários ---------- */ +.campo { display: grid; gap: var(--e-1); } +.campo > span, .rotulo { font-size: var(--t-sm); color: var(--texto-2); } +input[type='text'], input[type='password'], input[type='search'], input[type='number'], select, textarea { + width: 100%; min-height: 40px; padding: var(--e-2) var(--e-3); + border: 1px solid var(--borda-forte); border-radius: var(--raio-sm); + background: var(--fundo-2); color: var(--texto); font: inherit; font-size: var(--t-sm); +} +textarea { resize: vertical; min-height: 80px; } +input::placeholder, textarea::placeholder { color: var(--texto-3); } + +/* ---------- utilitários ---------- */ +.grade { display: grid; gap: var(--e-4); } +.linha { display: flex; gap: var(--e-2); align-items: center; flex-wrap: wrap; } +.entre { justify-content: space-between; } +.mono { font-family: var(--fonte-mono); font-size: 0.92em; } +.fraco { color: var(--texto-3); } +.pequeno-texto { font-size: var(--t-sm); } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition-duration: 0ms !important; animation-duration: 0ms !important; } +} diff --git a/web/src/lib/tipos.ts b/web/src/lib/tipos.ts new file mode 100644 index 0000000..ec46684 --- /dev/null +++ b/web/src/lib/tipos.ts @@ -0,0 +1,81 @@ +/** Formato do que o servidor manda em /estado e nos eventos SSE. */ + +export type AppConfig = Record; + +export interface Telemetria { + online: boolean; + idade: number | null; + celular_ip: string | null; + servidor_ip: string; + recarga: { id: string | null; confirmada: boolean }; + estado: { + rosto?: string; + dormindo?: boolean; + microfone?: number; + microfone_ativo?: boolean; + movimento?: number; + camera_ativa?: boolean; + bateria?: { nivel?: number; carregando?: boolean } | number | null; + brilho?: number; + video?: { qualidade?: string } | null; + [k: string]: unknown; + }; + preview: boolean; +} + +export interface Motores { + disponivel: boolean; + erro: string; + ativos: number[]; + dono: 'braco' | 'diagnostico' | null; + travado: boolean; +} + +export interface Mensagem { + role: 'user' | 'assistant'; + content: string; +} + +export interface Conversa { + provedor: 'local' | 'gemini'; + modelo: string; + gemini_configurado: boolean; + ocupado: boolean; + ultima: { id: number; texto: string; gesto: string; horario: number; segundos: number; fonte?: string } | null; + erro: string; + historico: Mensagem[]; + lembrancas: { id: number; texto: string }[]; +} + +export interface IA { + ativo: boolean; + ocupado: boolean; + modelo: string; + executor: string; + ultima: { evento: string; gesto: string; segundos: number; horario: number } | null; + erro: string; +} + +export interface Reacao { + id: number; + horario: number; + receita: any; +} + +export interface EstadoServidor { + versao: string; + seq: number; + autenticacao: boolean; + estop: { ativo: boolean; desde: number | null }; + aparencia: { rosto: string; cor: [number, number, number] }; + gestos: string[]; + rostos: string[]; + app?: Telemetria; + app_config?: AppConfig; + motores?: Motores; + conversa?: Conversa; + ia?: IA; + reacao?: Reacao | null; + treinamento?: { ocupado: boolean; erro: string }; + api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown }; +} diff --git a/web/src/lib/video.svelte.ts b/web/src/lib/video.svelte.ts new file mode 100644 index 0000000..53c920a --- /dev/null +++ b/web/src/lib/video.svelte.ts @@ -0,0 +1,236 @@ +/** + * Vídeo ao vivo do celular por WebRTC P2P. A sinalização passa pelo servidor (/rtc/*); + * o vídeo vai direto do celular para este navegador. Porta de painel-video.js. + * Só a tela Ao vivo cria uma instância: assim não há disputa pela única sessão. + */ +import { lerToken, pedir } from './api'; +import type { AppConfig, Telemetria } from './tipos'; + +export interface AlvoRastreio { + tipo: 'mao' | 'rosto'; + x: number; + y: number; + atividade?: boolean; +} + +export class VideoAoVivo { + mensagem = $state('Transmissão desligada.'); + exibindo = $state(false); + qualidade = $state(''); + + private pc: RTCPeerConnection | null = null; + private id: string | null = null; + private desde = 0; + private ice: RTCIceCandidateInit[] = []; + private querido = false; + private abrindo = false; + private consultando = false; + private geracao = 0; + private tentarEm = 0; + private ultimoStats: any = null; + private outroPainel = false; + private canal: RTCDataChannel | null = null; + private seqRastreio = 0; + private qualidadeEfetiva = ''; + private config: AppConfig = {}; + private relogios: number[] = []; + + constructor(private video: HTMLVideoElement) { + video.onloadeddata = video.onplaying = () => this.mostrar(); + this.relogios.push( + window.setInterval(() => { + this.iniciar(); + this.consultar(); + }, 400), + window.setInterval(() => this.estatisticas(), 1000), + ); + addEventListener('pagehide', this.aoSair); + } + + private mostrar() { + this.exibindo = true; + } + + private texto(t: string) { + this.mensagem = t; + this.exibindo = false; + } + + private parar(fechar = true) { + this.geracao++; + const antiga = this.id; + this.id = null; + this.pc?.close(); + this.pc = null; + this.canal = null; + this.ice = []; + this.desde = 0; + this.ultimoStats = null; + this.video.pause(); + this.video.srcObject = null; + this.exibindo = false; + if (antiga && fechar) pedir('/rtc/session', { acao: 'fechar', id: antiga }).catch(() => {}); + } + + private enviar(mensagem: unknown, sessao?: string) { + return pedir('/rtc/send', { id: sessao || this.id, de: 'pc', mensagem }, 3500); + } + + private async iniciar() { + if (!this.querido || this.pc || this.abrindo || this.outroPainel || Date.now() < this.tentarEm) return; + this.abrindo = true; + const geracao = this.geracao; + this.texto('Conectando vídeo ao vivo… O rosto continua no celular.'); + try { + const sessao = await pedir<{ id: string }>('/rtc/session', { acao: 'abrir' }, 3500); + if (geracao !== this.geracao || !this.querido) { + pedir('/rtc/session', { acao: 'fechar', id: sessao.id }).catch(() => {}); + return; + } + this.id = sessao.id; + const id = sessao.id; + const pc = new RTCPeerConnection({ iceServers: [] }); + this.pc = pc; + this.canal = pc.createDataChannel('rastreamento', { ordered: false, maxRetransmits: 0 }); + pc.addTransceiver('video', { direction: 'recvonly' }); + pc.onicecandidate = (e) => { + if (this.pc === pc && e.candidate) this.enviar({ tipo: 'candidate', candidate: e.candidate.toJSON() }, id).catch(() => {}); + }; + pc.ontrack = (e) => { + if (this.pc !== pc) return; + this.video.srcObject = e.streams[0] || new MediaStream([e.track]); + this.video.play().catch((err) => this.texto('Clique no vídeo para permitir a reprodução: ' + err.message)); + }; + pc.onconnectionstatechange = () => { + if (this.pc === pc && pc.connectionState === 'failed') { + this.parar(); + this.tentarEm = Date.now() + 2000; + this.texto('Reconectando o vídeo…'); + } + }; + await pc.setLocalDescription(await pc.createOffer()); + if (this.pc !== pc) return; + await this.enviar({ tipo: 'offer', sdp: pc.localDescription!.sdp }, id); + } catch (e) { + this.parar(); + this.tentarEm = Date.now() + 2500; + this.texto('Falha ao iniciar vídeo: ' + (e as Error).message); + } finally { + this.abrindo = false; + } + } + + private async consultar() { + if (!this.id || !this.pc || this.consultando) return; + this.consultando = true; + const pc = this.pc; + const sid = this.id; + try { + const pacote = await pedir<{ id: string | null; mensagens: { seq: number; mensagem: any }[] }>( + `/rtc/poll?lado=pc&desde=${this.desde}&id=${sid}`, + undefined, + 3500, + ); + if (this.pc !== pc) return; + if (pacote.id !== sid) { + this.outroPainel = !!pacote.id; + this.parar(false); + this.texto(this.outroPainel ? 'Vídeo aberto em outro painel. Recarregue para assumir.' : 'Aguardando reconexão do vídeo…'); + return; + } + for (const { seq, mensagem: m } of pacote.mensagens) { + if (m.tipo === 'answer') { + await pc.setRemoteDescription({ type: 'answer', sdp: m.sdp }); + for (const c of this.ice) await pc.addIceCandidate(c); + this.ice = []; + } else if (m.tipo === 'candidate') { + if (pc.remoteDescription) await pc.addIceCandidate(m.candidate); + else this.ice.push(m.candidate); + } else if (m.tipo === 'erro') { + this.parar(); + this.tentarEm = Date.now() + 3500; + this.texto('Câmera no celular: ' + m.erro); + return; + } + this.desde = Math.max(this.desde, seq); + } + } catch { + if (this.pc === pc) { + this.parar(); + this.tentarEm = Date.now() + 2500; + this.texto('Sinalização interrompida. Reconectando…'); + } + } finally { + this.consultando = false; + } + } + + private async estatisticas() { + const pc = this.pc; + if (!pc || pc.connectionState !== 'connected') return; + try { + const relatorio = await pc.getStats(); + relatorio.forEach((s: any) => { + if (s.type !== 'inbound-rtp' || (s.kind !== 'video' && s.mediaType !== 'video')) return; + const u = this.ultimoStats; + const fps = u ? ((s.framesDecoded - u.framesDecoded) * 1000) / (s.timestamp - u.timestamp) : s.framesPerSecond || 0; + const kbps = u ? ((s.bytesReceived - u.bytesReceived) * 8) / (s.timestamp - u.timestamp) : 0; + this.ultimoStats = s; + const auto = + this.qualidadeEfetiva === 'auto' || (!this.qualidadeEfetiva && this.config.videoResolucao === 'auto' && !this.config.videoFPS); + this.qualidade = `${auto ? 'Automática' : 'Manual'} · ${s.frameWidth || this.video.videoWidth}×${s.frameHeight || this.video.videoHeight} · ${Math.round(fps)} fps · ${Math.round(kbps)} kb/s`; + if (fps > 0) this.mostrar(); + }); + } catch { + /* conexão em transição */ + } + } + + rastrear(alvo: AlvoRastreio | null) { + if (this.canal?.readyState === 'open' && this.canal.bufferedAmount < 4096) + this.canal.send(JSON.stringify({ tipo: 'alvo', seq: ++this.seqRastreio, alvo })); + } + + atualizar(status: Telemetria | null, config: AppConfig | null, online: boolean) { + if (!config) return; + this.config = config; + this.qualidadeEfetiva = (status?.estado?.video as any)?.qualidade || ''; + const dormindo = !!status?.estado?.dormindo; + const liberada = config.sensorCamera !== false && (!dormindo || config.cameraSono === true); + const quer = !!config.preview && online && liberada; + if (!quer) { + if (this.querido || this.pc) this.parar(); + this.querido = false; + this.texto( + !liberada + ? 'Câmera desativada pelos ajustes de sensores.' + : config.preview + ? 'Celular sem conexão. Abra o app Carinha.' + : 'Transmissão desligada. O rosto e os sensores continuam funcionando.', + ); + } else { + this.querido = true; + this.iniciar(); + } + } + + private aoSair = () => { + if (this.id) { + const t = lerToken(); + navigator.sendBeacon( + '/rtc/session' + (t ? '?t=' + encodeURIComponent(t) : ''), + new Blob([JSON.stringify({ acao: 'fechar', id: this.id })], { type: 'application/json' }), + ); + } + this.querido = false; + this.parar(false); + }; + + destruir() { + this.relogios.forEach(clearInterval); + removeEventListener('pagehide', this.aoSair); + this.querido = false; + this.outroPainel = false; + this.parar(); + } +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..55d761c --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,7 @@ +import '@fontsource-variable/outfit'; +import '@fontsource-variable/jetbrains-mono'; +import './lib/tema.css'; +import { mount } from 'svelte'; +import App from './App.svelte'; + +mount(App, { target: document.getElementById('app')! }); diff --git a/web/src/telas/Ajustes.svelte b/web/src/telas/Ajustes.svelte new file mode 100644 index 0000000..e391ab3 --- /dev/null +++ b/web/src/telas/Ajustes.svelte @@ -0,0 +1,341 @@ + + +
+ + {#snippet acoes()} + + {#if confirmarPadrao} + Restaurar todos? + + + {:else} + + {/if} + {/snippet} + + {#if !estado.appConfig} +

Carregando ajustes…

+ {:else} +
+ {#each grupos as g (g.titulo)} +
+ + {g.titulo} + {g.descricao} + + {#each g.campos as c (c.chave)} + {#if c.tipo === 'toggle'} + salvarApp({ [c.chave]: v })} /> + {:else if c.tipo === 'slider'} + salvarApp({ [c.chave]: v })} + /> + {:else} + + {/if} + {/each} +
+ {:else} +

Nenhum ajuste com “{busca}”.

+ {/each} +
+ {/if} +
+ +
+ +
+ Motor da conversa +
+ + +
+ Local roda no celular/placa, sem custo. Gemini responde melhor e cria reações. +
+ +
+
+ Gemini + {estado.api?.configurada ? 'Chave configurada' : 'Sem chave'} +
+ + +
+ + +
+ {#if resultadoTeste} +

+ {resultadoTeste.mensagem}{resultadoTeste.segundos ? ` (${resultadoTeste.segundos}s)` : ''} +

+ {/if} +
+
+ + +
+ {#if estado.autenticacao} + Servidor exige token + {:else} + Servidor aberto na rede + {/if} +
+

+ {#if estado.autenticacao} + Só quem tem o token controla o robô. Ele fica guardado apenas neste navegador. + {:else} + Qualquer aparelho da rede local pode controlar o robô. Defina CARINHA_TOKEN na placa + (veja docs/SEGURANCA.md) depois de atualizar o app do celular. + {/if} +

+ {#if tokenLocal} + + {/if} +
+ + +
+
Versão do servidor
{estado.versao || '—'}
+
Celular
{estado.app?.celular_ip ?? 'offline'}
+
Servidor
{location.host}
+
+
+ + Baixar app Android +
+
+
+
+ + diff --git a/web/src/telas/AoVivo.svelte b/web/src/telas/AoVivo.svelte new file mode 100644 index 0000000..c6f615c --- /dev/null +++ b/web/src/telas/AoVivo.svelte @@ -0,0 +1,388 @@ + + +
+
+
+ + + + {#if !video?.exibindo} +
+ +

{video?.mensagem ?? 'Carregando…'}

+
+ {/if} +
+ Câmera + {#if video?.qualidade && video.exibindo}{video.qualidade}{/if} + + {#if video?.exibindo} + + {/if} +
+
+
+ +
+ Rosto {estado.celularOnline ? 'no celular' : '· prévia'} +
+
+
+ +
+
+ salvarApp({ preview: v })} + /> + {#if rastreio && estado.appConfig?.preview} +

{rastreio.texto}

+ {/if} +
+
+ +
+ + + + + +
+ Rosto +
+ {#each estado.rostos as r (r)} + + {/each} +
+
+
+ Cor dos olhos +
+ {#each CORES as c (c.nome)} + + {/each} + +
+
+
+ + + {#snippet acoes()} + + {/snippet} + {#if estado.celularOnline} +
+
+
Bateria
+
{bateria !== null ? bateria + '%' : '—'}
+
+
+
Estado
+
{sensores.dormindo ? 'Dormindo' : 'Acordado'}
+
+
+
Som
+
+ {#if sensores.microfone_ativo} + + {:else}desligado{/if} +
+
+
+
Movimento
+
+ {#if sensores.camera_ativa} + + {:else}desligado{/if} +
+
+
+

{estado.app?.celular_ip ?? ''}

+ {:else} +

+ O celular não está enviando sinais. Abra o app Carinha no aparelho e confira se ele aponta para este servidor. +

+ {/if} +
+ + + + {#if estado.ia?.ultima} +

+ Último: {estado.ia.ultima.evento} → {estado.ia.ultima.gesto} + ({estado.ia.ultima.segundos}s) +

+ {/if} + {#if estado.ia?.erro}

{estado.ia.erro}

{/if} +
+ + + {#if estado.feed.length} +
    + {#each estado.feed as item (item.id)} +
  1. {item.texto}
  2. + {/each} +
+ {:else} +

Gestos, respostas e reações aparecem aqui assim que acontecerem.

+ {/if} +
+
+
+ + diff --git a/web/src/telas/Conversa.svelte b/web/src/telas/Conversa.svelte new file mode 100644 index 0000000..b3fecfb --- /dev/null +++ b/web/src/telas/Conversa.svelte @@ -0,0 +1,7 @@ + + + +

Enquanto isso, use a tela antiga: abrir Conversa (versão anterior).

+
diff --git a/web/src/telas/Movimento.svelte b/web/src/telas/Movimento.svelte new file mode 100644 index 0000000..0c5902e --- /dev/null +++ b/web/src/telas/Movimento.svelte @@ -0,0 +1,7 @@ + + + +

Enquanto isso, use a tela antiga: abrir Movimento (versão anterior).

+
diff --git a/web/src/telas/Reacoes.svelte b/web/src/telas/Reacoes.svelte new file mode 100644 index 0000000..8dcd849 --- /dev/null +++ b/web/src/telas/Reacoes.svelte @@ -0,0 +1,7 @@ + + + +

Enquanto isso, use a tela antiga: abrir Reações (versão anterior).

+
diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..0378127 --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,2 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; +export default { preprocess: vitePreprocess() }; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..d37e848 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "types": ["vite/client", "node"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..e268fdf --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +// Em desenvolvimento, a API vem do servidor Python (local ou a placa): +// CARINHA_SERVIDOR=http://192.168.1.35:8100 npm run dev +const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100'; +const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos', + '/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face', + '/carinha.html', '/reacoes-render.js', '/rastreador-worker.js', '/vendor', '/audio-robot.js', '/qualidade-auto.js']; + +export default defineConfig({ + plugins: [svelte()], + base: '/', + build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' }, + server: { proxy: Object.fromEntries(api.map((p) => [p, { target: alvo, changeOrigin: false }])) }, +});