feat: initial commit - Carinha Control Panel v1.0 com UI glassmorphism, watch-bar, emojis, braco robotico e conversa IA

This commit is contained in:
julio
2026-10-05 16:21:57 -03:00
commit 8217292893
114 changed files with 13145 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
# App Carinha conectado ao PC
## Endereços atuais
- Painel no PC: http://localhost:8100/painel
- Servidor salvo no celular: http://192.168.1.15:8100
- Galaxy S10: 192.168.1.16, ADB Wi-Fi 5555.
- Pacote: `br.carinha.rosto`, versão `0.3-video-auto`. APK com a mesma assinatura do app anterior.
## Abrir e configurar
Execute `../iniciar.bat`. No painel, clique **Conectar e abrir o app**. O botão usa ADB local para acordar o aparelho e abrir a atividade com o endereço do PC. A depuração Wi-Fi precisa estar previamente habilitada; depois de reiniciar o celular pode ser necessário reativar via USB.
Alternativa: abra Carinha no celular, segure a tela e escolha **Mudar o servidor**, digitando `http://192.168.1.15:8100`.
No PowerShell, com ADB disponível (substitua o caminho se necessário):
```powershell
$adb = 'C:/Users/JullioPC/Documents/Open/orange-pi-zero3/android/tools/android-sdk/platform-tools/adb.exe'
& $adb connect 192.168.1.16:5555
& $adb -s 192.168.1.16:5555 shell am start -n br.carinha.rosto/.MainActivity -e servidor http://192.168.1.15:8100
```
## Câmera junto com o rosto
Clique **Ligar câmera** no painel. O app transmite vídeo comprimido WebRTC, buscando 30 fps em qualidade automática. O painel exibe resolução, fps e bitrate efetivos. Solicita captura de até 1280×720; resolução/compressão se adaptam à conexão.
Durante a transmissão, uma página embutida numa WebView auxiliar usa a frontal e calcula movimento numa amostra pequena, alimentando a carinha. O detector Camera2 é suspenso para liberar a lente e volta quando o vídeo é desligado. O rosto permanece em primeiro plano, microfone e brilho continuam ativos. Detalhes e testes em [VIDEO-AUTO.md](../docs/VIDEO-AUTO.md).
Não abra **Ver a câmera** no celular nesse fluxo: essa página de diagnóstico substitui o rosto e toma a lente. A transmissão pelo PC mantém o rosto. **Desligar câmera** no painel encerra somente a prévia; o detector continua conforme os ajustes.
Vídeo e telemetria são ocultados quando o celular perde conexão. Vídeo vai diretamente ao navegador, sem gravação; o microfone transmite somente nível, sem áudio. O modo JPEG antigo permanece disponível apenas como compatibilidade técnica (`videoRTC=false`), sem ser o padrão do painel.
## Boca e sobrancelhas
Em **Comportamento**, use **Mostrar boca** e **Mostrar sobrancelhas**. São controles independentes e valem para os três rostos. A boca acompanha o humor; sobrancelhas inclinam conforme bravo/triste e sobem na surpresa. Sobrancelhas desaparecem ao dormir.
## Economia de tela
O painel configura **Reduzir brilho em repouso**, **Brilho normal**, **Brilho em repouso** e **Espera para reduzir brilho**. Padrões: ligado, 70%, 8%, 30 segundos.
O app reduz o brilho quando dorme ou quando não há som/movimento/comando pelo tempo definido. Som e movimento acima dos limiares, ou um comando recebido, restauram o brilho. Gestos espontâneos não impedem o repouso. O ajuste afeta somente a janela do Carinha; ao sair do app, volta ao brilho do sistema.
**Reagir aos sons** e **Reagir ao movimento** controlam o rosto; os sensores podem continuar medindo para a telemetria e economia da tela. Brilho representa o nível solicitado ao Android, não uma medição física da luminância.
## Compilar e instalar
Na pasta do projeto:
```powershell
python android/build-apk.py
# Ferramentas em outro caminho:
python android/build-apk.py --tools C:/caminho/android/tools
```
Por padrão reutiliza JDK e SDK em `C:/Users/JullioPC/Documents/Open/orange-pi-zero3/android/tools`. Pode definir `CARINHA_TOOLS` para outro caminho. Não usa servidor na placa.
Resultado: `android/carinha-rosto.apk`. Download na rede: `http://192.168.1.15:8100/app.apk`. Instale por cima do anterior; não precisa apagar dados.
Aceite as permissões de câmera e microfone no Android. Captura e envio param ao sair do primeiro plano. Logs: `CarinhaRTC`, `CarinhaPC`, `CarinhaMov`, `CarinhaMic`, `CarinhaJS`.
# Atualização 0.4
Segure a tela para abrir **Sensores acordado / dormindo** ou **Camera e qualidade do video**. Padrão automático; opções de resolução e FPS máximos e manuais dependem do suporte real do WebView. No sono, só o microfone permanece ativo por padrão. Os ajustes são salvos no PC. Consulte `../docs/SENSORES-CAMERAS.md`.
# Atualização 0.5 — olhos seguindo rosto/mãos
Reabra o app após instalar e atualize o painel com Ctrl+F5. Os rastreadores rodam no PC e enviam o alvo ao Android por WebRTC. Ative vídeo e mantenha o painel aberto. Os controles de rosto, mãos, acompanhamento e marcações estão sob o vídeo. Detector simples ficou mais rápido; regras de sensores/sono foram mantidas. Detalhes: `../docs/RASTREAMENTO.md`.
+5
View File
@@ -0,0 +1,5 @@
# Android do projeto Carinha PC
Consulte [APP.md](APP.md) para conexão, instalação, câmera, economia de tela e compilação. [README.md](../README.md) explica como iniciar o painel.
Uma Activity com WebView em paisagem, tela ligada e rosto SVG. Microfone e Camera2 rodam no nativo. `ControleRemoto.java` consulta configuração e envia JPEG em uma única thread de rede, com no máximo um quadro pendente. `YuvFrame.java` respeita row/pixel stride ao converter YUV para NV21. `BrilhoRepouso.java` decide o brilho; MainActivity aplica esse nível somente à própria janela.
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="br.carinha.rosto"
android:versionCode="6"
android:versionName="0.6-ia-local">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
<!-- precisa de rede para falar com o servidor da carinha (troca de rosto) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- escutar o ambiente: reage a barulho (surpreso) e a silencio (dormir) -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- camera: pagina de visualizacao e, depois, seguir o movimento -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:label="@string/app_name"
android:icon="@drawable/icone"
android:allowBackup="false"
android:extractNativeLibs="true"
android:hardwareAccelerated="true"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|keyboardHidden|uiMode|smallestScreenSize"
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
+355
View File
@@ -0,0 +1,355 @@
<!doctype html>
<html lang="pt-BR">
<meta charset="utf-8">
<title>Câmera</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000;
color:#cfe6f5;font:13px/1.4 monospace;-webkit-user-select:none;user-select:none}
#video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000}
#marca{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
.painel{position:absolute;background:rgba(6,8,14,.74);border:1px solid #1d2b3a;
border-radius:8px;padding:8px 10px}
.painel b{color:#5CD6FF}
#info{left:10px;top:10px}
#mov{right:10px;top:10px;min-width:168px}
#barra{height:10px;background:#12202e;border-radius:5px;overflow:hidden;margin-top:4px}
#barra i{display:block;height:100%;width:0;background:#5CD6FF}
#dica{left:50%;bottom:12px;transform:translateX(-50%);text-align:center;opacity:.85}
#erro{left:10px;bottom:64px;right:10px;display:none;color:#ff9b9b}
#escolha{left:50%;bottom:64px;transform:translateX(-50%);display:flex;gap:8px;padding:8px}
#escolha button{font:inherit;color:#9fc0d8;background:#0e1a26;border:1px solid #24405a;
border-radius:7px;padding:8px 14px}
#escolha button.on{color:#04121c;background:#5CD6FF;border-color:#5CD6FF;font-weight:bold}
#escolha button:disabled{opacity:.4}
#video2{position:absolute;right:12px;bottom:70px;width:26%;aspect-ratio:16/9;display:none;
object-fit:cover;background:#000;border:1px solid #24405a;border-radius:8px}
#rotulo2{position:absolute;right:12px;bottom:70px;width:26%;display:none;text-align:center;
font-size:12px;color:#04121c;background:#5CD6FF;border-radius:0 0 8px 8px;padding:2px 0}
</style>
<body>
<video id="video" autoplay playsinline muted></video>
<video id="video2" autoplay playsinline muted></video>
<div id="rotulo2"></div>
<canvas id="marca"></canvas>
<div class="painel" id="info">
<div>câmera: <b id="nome">—</b></div>
<div>lado: <b id="lado">—</b></div>
<div>resolução: <b id="res">—</b></div>
<div>quadros: <b id="fps">—</b> por segundo</div>
</div>
<div class="painel" id="mov">
<div>movimento: <b id="mvel">0%</b></div>
<div id="barra"><i id="mbar"></i></div>
<div>área mexida: <b id="marea">0%</b></div>
<div>centro: <b id="mcentro">—</b></div>
</div>
<div class="painel" id="escolha">
<button id="bfrontal">Frontal</button>
<button id="btraseira">Traseira</button>
<button id="bduas">Ver as duas</button>
<button id="bsensor">Só sensor (sem imagem)</button>
</div>
<div class="painel" id="erro"></div>
<script src="qualidade-auto.js"></script><script>
"use strict";
/* -------------------------------------------------------------------------
Página da câmera — vídeo ao vivo na maior resolução que o aparelho entregar,
começa na câmera FRONTAL e deixa escolher a traseira. Por cima, um detector de
movimento (diferença de luminância entre quadros), que é a base para os olhos
da carinha seguirem o movimento.
Precisa de contexto seguro: o app abre esta página do arquivo embutido
(file://), por isso o getUserMedia funciona mesmo sem HTTPS.
------------------------------------------------------------------------- */
const video = document.getElementById("video");
const canvasMarca = document.getElementById("marca");
const ctxMarca = canvasMarca.getContext("2d");
const elNome = document.getElementById("nome");
const elLado = document.getElementById("lado");
const elRes = document.getElementById("res");
const elFps = document.getElementById("fps");
const elVel = document.getElementById("mvel");
const elBar = document.getElementById("mbar");
const elArea = document.getElementById("marea");
const elCentro = document.getElementById("mcentro");
const elErro = document.getElementById("erro");
const bFrontal = document.getElementById("bfrontal");
const bTraseira = document.getElementById("btraseira");
const bDuas = document.getElementById("bduas");
const bSensor = document.getElementById("bsensor");
const video2 = document.getElementById("video2");
const rotulo2 = document.getElementById("rotulo2");
const LIMIAR = 26; // salto de luminância que conta como mudança
const CW = 160, CH = 120; // resolução do detector (leve de propósito)
const cv = document.createElement("canvas");
cv.width = CW; cv.height = CH;
const ctx = cv.getContext("2d", { willReadFrequently: true });
let lumaAnterior = null;
let fluxo = null;
let fluxo2 = null; // segunda camera, no "ver as duas"
const ajustesCamera = new URLSearchParams(location.search);
let facingAtual = ajustesCamera.get("lado") || "user"; // frontal primeiro
let quadros = 0, marco = performance.now();
let proxLog = 0, proxLogCena = 0, escura = 0;
function avisaErro(texto) {
elErro.style.display = "block";
elErro.textContent = texto;
}
function marcarBotoes() {
bFrontal.classList.toggle("on", facingAtual === "user");
bTraseira.classList.toggle("on", facingAtual === "environment");
}
/* Tenta o lado pedido na maior resolução; se não existir, cai para o outro. */
async function capturar(facing) {
const novo = await navigator.mediaDevices.getUserMedia({audio:false,video:{facingMode:{ideal:facing}}});
const faixa = novo.getVideoTracks()[0];
const lado = faixa.getSettings().facingMode;
if (lado && lado !== facing) { novo.getTracks().forEach(t=>t.stop()); throw new Error('Camera escolhida indisponivel'); }
const c = {cameraLado:facing,videoResolucao:ajustesCamera.get('resolucao') || 'auto',videoFPS:Number(ajustesCamera.get('fps') || 0)};
const caps = faixa.getCapabilities ? faixa.getCapabilities() : {};
const v = QualidadeAuto.constraints(c,caps); delete v.facingMode;
try { await faixa.applyConstraints(v); }
catch(e) {
if(e.name !== 'OverconstrainedError') { novo.getTracks().forEach(t=>t.stop()); throw e; }
avisaErro('Combinacao indisponivel. Usando qualidade automatica.');
const fallback = QualidadeAuto.constraints({},caps); delete fallback.facingMode;
await faixa.applyConstraints(fallback);
}
return {fluxo:novo,facing};
}
async function abrir(facing) {
if (fluxo) { fluxo.getTracks().forEach((t) => t.stop()); fluxo = null; }
const r = await capturar(facing);
fluxo = r.fluxo;
facingAtual = r.facing || facing || "environment";
video.srcObject = fluxo;
try { await video.play(); } catch (e) { console.log("camera: play " + e); }
const faixa = fluxo.getVideoTracks()[0];
const s = faixa.getSettings ? faixa.getSettings() : {};
elNome.textContent = (faixa.label || "câmera").slice(0, 34);
elLado.textContent = facingAtual === "user" ? "frontal" : "traseira";
elRes.textContent = (s.width || video.videoWidth) + "x" + (s.height || video.videoHeight);
marcarBotoes();
console.log("camera: " + elNome.textContent + " | lado=" + elLado.textContent +
" | " + elRes.textContent + " " + (s.frameRate ? Math.round(s.frameRate) + "fps" : ""));
}
/* Lista o que o navegador realmente expõe — o rótulo só aparece após a
permissão, então vale chamar de novo depois de abrir. */
async function listarCameras() {
let lista = [];
try { lista = await navigator.mediaDevices.enumerateDevices(); } catch (e) {}
const cameras = lista.filter((d) => d.kind === "videoinput");
console.log("camera: " + cameras.length + " aparelho(s) de vídeo — " +
cameras.map((c, i) => i + ":" + (c.label || "sem rótulo")).join(" ; "));
return cameras;
}
async function trocarPara(facing) {
if (facing === facingAtual && fluxo) return;
elErro.style.display = "none";
try {
await abrir(facing);
console.log("camera: trocou para " + facing);
} catch (e) {
marcarBotoes();
elErro.style.display = "block";
elErro.textContent = "não consegui abrir a câmera " +
(facing === "user" ? "frontal" : "traseira") + ": " + e.name;
console.log("camera: falhou trocar para " + facing + " — " + e.name);
}
}
bFrontal.addEventListener("click", (ev) => { ev.stopPropagation(); trocarPara("user"); });
bTraseira.addEventListener("click", (ev) => { ev.stopPropagation(); trocarPara("environment"); });
bDuas.addEventListener("click", (ev) => { ev.stopPropagation(); alternarDuas(); });
/*
* "Só sensor": esconde a imagem e continua detectando o movimento. Serve para
* deixar a câmera trabalhando como sensor sem ocupar a tela — no rosto, quem
* faz isso é a própria carinha.html (vídeo escondido, só os olhos aparecem).
*/
let semImagem = false;
function aplicarSemImagem() {
video.style.visibility = semImagem ? "hidden" : "visible";
bSensor.classList.toggle("on", semImagem);
if (semImagem) {
elErro.style.display = "block";
elErro.textContent = "imagem oculta — a câmera segue ligada, só detectando movimento";
} else if (elErro.textContent.indexOf("imagem oculta") === 0) {
elErro.style.display = "none";
}
console.log("camera: " + (semImagem ? "imagem oculta (so sensor)" : "imagem visivel"));
}
bSensor.addEventListener("click", (ev) => {
ev.stopPropagation();
semImagem = !semImagem;
aplicarSemImagem();
});
/*
* "Ver as duas": abre a OUTRA camera num segundo fluxo e mostra num quadro
* menor. Nao e todo aparelho que deixa duas cameras abertas ao mesmo tempo —
* quando nao deixa, avisamos em vez de fingir que funcionou.
*/
async function alternarDuas() {
if (fluxo2) {
fluxo2.getTracks().forEach((t) => t.stop());
fluxo2 = null;
video2.style.display = "none";
rotulo2.style.display = "none";
bDuas.classList.remove("on");
console.log("camera: duas cameras desligadas");
return;
}
const outro = facingAtual === "user" ? "environment" : "user";
const nomeOutro = outro === "user" ? "frontal" : "traseira";
try {
// resolucao moderada: aumenta a chance de o aparelho aceitar as duas juntas
fluxo2 = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: outro }, width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: false,
});
video2.srcObject = fluxo2;
try { await video2.play(); } catch (e) { console.log("camera: video2 play " + e); }
video2.style.display = "block";
rotulo2.textContent = nomeOutro;
rotulo2.style.display = "block";
bDuas.classList.add("on");
const s = fluxo2.getVideoTracks()[0].getSettings();
console.log("camera: duas cameras — a outra e " + nomeOutro + " " + s.width + "x" + s.height);
} catch (e) {
fluxo2 = null;
bDuas.classList.remove("on");
elErro.style.display = "block";
elErro.textContent = "o aparelho não deixa as duas câmeras ao mesmo tempo (" + e.name + ") — use os botões para alternar";
console.log("camera: duas cameras falhou — " + e.name);
}
}
function redimensionar() {
canvasMarca.width = innerWidth;
canvasMarca.height = innerHeight;
}
/* Traduz coordenada do detector (CW x CH) para a tela, respeitando o "contain". */
function paraTela(cx, cy) {
const r = video.getBoundingClientRect();
const vw = video.videoWidth || CW, vh = video.videoHeight || CH;
const esc = Math.min(r.width / vw, r.height / vh);
const dw = vw * esc, dh = vh * esc;
const ox = r.left + (r.width - dw) / 2, oy = r.top + (r.height - dh) / 2;
return [ox + (cx / CW) * dw, oy + (cy / CH) * dh];
}
function detectar() {
if (!video.videoWidth) return;
ctx.drawImage(video, 0, 0, CW, CH);
const d = ctx.getImageData(0, 0, CW, CH).data;
if (!lumaAnterior) lumaAnterior = new Float32Array(CW * CH);
let mexidos = 0, sx = 0, sy = 0, somaLuz = 0;
for (let i = 0; i < CW * CH; i++) {
const l = d[i * 4] * 0.299 + d[i * 4 + 1] * 0.587 + d[i * 4 + 2] * 0.114;
somaLuz += l;
if (Math.abs(l - lumaAnterior[i]) > LIMIAR) {
mexidos++;
sx += i % CW;
sy += (i / CW) | 0;
}
lumaAnterior[i] = l;
}
// cena escura demais: avisa que a lente provavelmente esta tapada
const brilho = somaLuz / (CW * CH);
if (brilho < 3) { escura++; } else { escura = 0; }
if (escura > 20 && elErro.style.display === "none") {
elErro.style.display = "block";
elErro.textContent = "cena escura (brilho " + Math.round(brilho) +
") — a lente pode estar tapada; levante ou vire o aparelho";
} else if (escura === 0 && elErro.textContent.indexOf("cena escura") === 0) {
elErro.style.display = "none";
}
const vel = Math.min(1, mexidos / (CW * CH * 0.25));
elVel.textContent = Math.round(vel * 100) + "%";
elBar.style.width = Math.round(vel * 100) + "%";
elArea.textContent = Math.round((mexidos / (CW * CH)) * 100) + "%";
ctxMarca.clearRect(0, 0, canvasMarca.width, canvasMarca.height);
if (mexidos > CW * CH * 0.004) {
const cx = sx / mexidos, cy = sy / mexidos;
const [px, py] = paraTela(cx, cy);
elCentro.textContent = "x=" + Math.round(cx / CW * 100) + "% y=" + Math.round(cy / CH * 100) + "%";
ctxMarca.beginPath();
ctxMarca.arc(px, py, Math.max(10, canvasMarca.width * 0.02), 0, Math.PI * 2);
ctxMarca.strokeStyle = "#5CD6FF";
ctxMarca.lineWidth = 3;
ctxMarca.stroke();
ctxMarca.beginPath();
ctxMarca.arc(px, py, 3, 0, Math.PI * 2);
ctxMarca.fillStyle = "#5CD6FF";
ctxMarca.fill();
const agora = performance.now();
if (agora > proxLog) {
proxLog = agora + 1000;
console.log("movimento: " + Math.round(vel * 100) + "% centro x=" +
Math.round(cx / CW * 100) + "% y=" + Math.round(cy / CH * 100) + "%");
}
} else {
elCentro.textContent = "—";
}
}
function laco(t) {
quadros++;
if (t - marco >= 1000) {
elFps.textContent = Math.round(quadros * 1000 / (t - marco));
quadros = 0; marco = t;
}
detectar();
requestAnimationFrame(laco);
}
/* Toque no vídeo alterna entre frontal e traseira (os botões têm prioridade). */
video.addEventListener("click", () => trocarPara(facingAtual === "user" ? "environment" : "user"));
addEventListener("resize", redimensionar);
async function iniciar() {
redimensionar();
marcarBotoes();
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
avisaErro("sem acesso à câmera aqui (contexto inseguro?). Abra esta página pelo app (arquivo local).");
return;
}
await listarCameras();
try {
await abrir(facingAtual); // frontal por padrao
} catch (e) {
avisaErro("não abriu a câmera: " + e.name + " — " + e.message);
console.log("camera: erro " + e.name + " " + e.message);
}
await listarCameras(); // agora com os rótulos reais
requestAnimationFrame(laco);
}
iniciar();
</script>
</body>
</html>
+987
View File
@@ -0,0 +1,987 @@
<!doctype html>
<html lang="pt-BR">
<meta charset="utf-8">
<title>Eilik</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000}
svg{display:block;width:100%;height:100%}
.corpo{fill-rule:evenodd}
.traco{fill:none;stroke-linecap:round}
.corpo,.traco{filter:drop-shadow(0 0 2px currentColor)}
#o1,#o2,#boca,#sobrancelha1,#sobrancelha2{color:#70cadd}
.iconeOlho,.figurinhaDinamica{animation:aparecerFigura .28s ease-out}
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
</style>
<body>
<svg id="tela" viewBox="0 0 480 320" preserveAspectRatio="xMidYMid meet">
<rect x="0" y="0" width="480" height="320" fill="rgb(0,0,0)"/>
<g id="o1"><path class="corpo"/><path class="traco"/><ellipse class="brilho" fill="rgb(245,250,255)"/></g>
<g id="o2"><path class="corpo"/><path class="traco"/><ellipse class="brilho" fill="rgb(245,250,255)"/></g>
<path id="boca" class="traco"/>
<path id="sobrancelha1" class="traco" style="display:none"/>
<path id="sobrancelha2" class="traco" style="display:none"/>
</svg>
<div id="fps"></div>
<script>
"use strict";
/* -------------------------------------------------------------------------
Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer).
Mesmas medidas, mesmas cores, mesmos gestos e o mesmo comportamento ocioso.
Roda em qualquer navegador, inclusive num kiosk (cage + Epiphany) na telinha.
------------------------------------------------------------------------- */
// Medidas de projeto (as mesmas do eilik.py) — a tela de verdade e 480x320.
const LW = 480, LH = 320;
const E1P = [150, 166], E2P = [330, 166]; // centros dos olhos
const RXP = 64, RYP = 76; // raio base
const BOCAP = [240, 284], BOCALP = 96; // centro e largura da boca
// A area util nem sempre e 480x320 (o navegador pode reservar uma barra de
// titulo), entao tudo e escalado junto: preenche a ALTURA mantendo a
// proporcao dos olhos, e as posicoes acompanham a largura e a altura.
let W = LW, H = LH, ESC = 1;
let E1 = E1P.slice(), E2 = E2P.slice(), RX = RXP, RY = RYP;
let BOCA = BOCAP.slice(), BOCA_L = BOCALP;
function ajustar() {
W = Math.max(80, window.innerWidth);
H = Math.max(60, window.innerHeight);
ESC = H / LH;
// O rosto fica CENTRADO e o afastamento dos olhos vem da altura (ESC), nao
// da largura: a cara mantem a mesma proporcao em qualquer tela — inclusive
// nas compridas, como a do Galaxy S10 (19:9). A sobra vira margem.
const meio = W / 2;
E1 = [meio - (LW / 2 - E1P[0]) * ESC, H * (E1P[1] / LH)];
E2 = [meio + (E2P[0] - LW / 2) * ESC, H * (E2P[1] / LH)];
RX = RXP * ESC;
RY = RYP * ESC;
BOCA = [W * (BOCAP[0] / LW), H * (BOCAP[1] / LH)];
BOCA_L = BOCALP * ESC;
const svg = document.getElementById("tela");
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
const fundo = svg.querySelector("rect");
fundo.setAttribute("width", W);
fundo.setAttribute("height", H);
}
ajustar();
addEventListener("resize", ajustar);
const PRETO = [6, 8, 14];
const CIANO = [92, 214, 255];
const EXTRAS = { boca: true, sobrancelhas: false };
const CIANO_E = [150, 232, 255];
const VERM = [255, 78, 78];
const ROSA = [255, 112, 176];
const AMARELO = [255, 218, 96];
const AZUL = [130, 170, 255];
const VERDE = [120, 255, 160];
const BRANCO = [245, 250, 255];
// -------------------------------------------------------------------- rostos
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
const ROSTOS = {
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
};
let ROSTO_NOME = "carinha";
let ROSTO = ROSTOS[ROSTO_NOME];
// ------------------------------------------------------------------ utilidades
const suave = (x) => x * x * x * (x * (x * 6 - 15) + 10); // smoothstep
const lerp = (a, b, e) => a + (b - a) * e;
const lerpCor = (a, b, e) => [0, 1, 2].map((i) => a[i] + (b[i] - a[i]) * e);
const rgb = (c) => "rgb(" + c.map((v) => Math.round(v)).join(",") + ")";
// ------------------------------------------------------------------ geometria
function caminhoElipse(cx, cy, rx, ry) {
return "M " + (cx - rx) + "," + cy +
" a " + rx + "," + ry + " 0 1 0 " + (2 * rx) + ",0" +
" a " + rx + "," + ry + " 0 1 0 " + (-2 * rx) + ",0 Z";
}
function caminhoArco(cx, cy, rx, ry) { // 196 -> 344 graus ( "^" )
const a0 = 196 * Math.PI / 180, a1 = 344 * Math.PI / 180;
const x0 = cx + rx * Math.cos(a0), y0 = cy + ry * Math.sin(a0);
const x1 = cx + rx * Math.cos(a1), y1 = cy + ry * Math.sin(a1);
return "M " + x0 + "," + y0 + " A " + rx + "," + ry + " 0 0 1 " + x1 + "," + y1;
}
function caminhoCoracao(cx, cy, s) {
return "M " + cx + "," + (cy + s * 0.72) +
" C " + (cx - s * 1.30) + "," + (cy - s * 0.30) + " " +
(cx - s * 0.80) + "," + (cy - s * 1.18) + " " +
cx + "," + (cy - s * 0.30) +
" C " + (cx + s * 0.80) + "," + (cy - s * 1.18) + " " +
(cx + s * 1.30) + "," + (cy - s * 0.30) + " " +
cx + "," + (cy + s * 0.72) + " Z";
}
function caminhoLinha(x0, y0, x1, y1) {
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
}
/* formas da boca (o humor escolhe qual) */
function caminhoBoca(tipo) {
const cx = BOCA[0], cy = BOCA[1], l = BOCA_L, meia = l / 2;
if (tipo === "aberta") { // surpresa: um "O"
const rx = l * 0.17, ry = l * 0.22;
return caminhoElipse(cx, cy, rx, ry);
}
if (tipo === "brava") { // canto para baixo
return "M " + (cx - meia) + "," + (cy + l * 0.16) +
" Q " + cx + "," + (cy - l * 0.26) + " " + (cx + meia) + "," + (cy + l * 0.16);
}
if (tipo === "triste") {
return "M " + (cx - l * 0.40) + "," + (cy + l * 0.16) +
" Q " + cx + "," + (cy - l * 0.20) + " " + (cx + l * 0.40) + "," + (cy + l * 0.16);
}
if (tipo === "sono") { // dormindo: um tracinho torto
return "M " + (cx - l * 0.24) + "," + cy +
" q " + (l * 0.24) + "," + (l * 0.16) + " " + (l * 0.48) + ",0";
}
if (tipo === "sorriso") { // feliz: sorriso grande
return "M " + (cx - meia) + "," + (cy - l * 0.10) +
" Q " + cx + "," + (cy + l * 0.42) + " " + (cx + meia) + "," + (cy - l * 0.10);
}
return "M " + (cx - l * 0.30) + "," + cy + // repouso: leve
" q " + (l * 0.30) + "," + (l * 0.18) + " " + (l * 0.60) + ",0";
}
// ------------------------------------------------------------------ desenho
const OLHOS = [document.getElementById("o1"), document.getElementById("o2")];
const PE = OLHOS.map((g) => ({
corpo: g.querySelector(".corpo"),
traco: g.querySelector(".traco"),
brilho: g.querySelector(".brilho"),
}));
function esconderTudo(p) {
p.corpo.style.display = "none";
p.traco.style.display = "none";
p.brilho.style.display = "none";
}
const elBoca = document.getElementById("boca");
/* A boca so aparece nos rostos que tem boca; a forma segue o humor. */
function desenharBoca(tipo) {
if (!EXTRAS.boca) {
elBoca.style.display = "none";
return;
}
const forma = tipo || r.boca;
elBoca.setAttribute("d", caminhoBoca(forma));
elBoca.setAttribute("stroke-width", BOCA_L * 0.15);
elBoca.setAttribute("stroke", rgb(r.cor));
elBoca.style.color=rgb(r.cor);
elBoca.setAttribute("fill", forma === "aberta" ? rgb(r.cor) : "none");
elBoca.style.display = "";
}
/* Desenha um olho igual ao _olho() do eilik.py. */
function olho(indice, cx, cy, rx, ry, ab, forma, cor, lado, lx, ly) {
const p = PE[indice];
document.getElementById('o'+(indice+1)).style.color=rgb(cor);
esconderTudo(p);
if (!p.icone) {
p.icone = document.createElementNS('http://www.w3.org/2000/svg','text');
p.icone.setAttribute('class','iconeOlho');p.icone.setAttribute('text-anchor','middle');
p.icone.setAttribute('dominant-baseline','central');document.getElementById('o'+(indice+1)).append(p.icone);
}
const desenho = !r.dormindo && window.carinhaOlhosDesenho;
const icone = !r.dormindo && (window.carinhaOlhosIcone || desenho);
p.icone.style.display = icone && !desenho ? '' : 'none';
if (p.figurinha) p.figurinha.style.display=desenho?'':'none';
if(desenho && window.carinhaConstruirSVG){
if(p.desenho!==desenho){if(p.figurinha)p.figurinha.remove();p.figurinha=window.carinhaConstruirSVG(desenho);p.desenho=desenho;if(p.figurinha)document.getElementById('o'+(indice+1)).append(p.figurinha);}
if(p.figurinha){p.figurinha.style.display='';p.figurinha.setAttribute('x',cx-rx*.65);p.figurinha.setAttribute('y',cy-ry*.65);p.figurinha.setAttribute('width',rx*1.3);p.figurinha.setAttribute('height',ry*1.3);}
}
if (icone) {
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy);
p.icone.setAttribute('font-size',Math.min(rx*1.1,ry*1.1));
}
if (forma === "aberto" || forma === "surpreso") {
const rr = Math.max(2, ry * ab);
p.corpo.setAttribute("d", caminhoElipse(cx, cy, rx, rr));
p.corpo.setAttribute("fill", rgb(cor));
p.corpo.style.display = "";
if (ab > 0.62 && !icone) {
// o brilho ("pupila") anda dentro do olho: e o que da a sensacao de olhar
let hx = cx - rx * 0.30 + lx * 1.2 * ESC;
let hy = cy - rr * 0.36 + ly * 1.2 * ESC;
hx = Math.max(cx - rx * 0.46, Math.min(cx + rx * 0.46, hx));
hy = Math.max(cy - rr * 0.46, Math.min(cy + rr * 0.46, hy));
p.brilho.setAttribute("cx", hx);
p.brilho.setAttribute("cy", hy);
p.brilho.setAttribute("rx", rx * 0.20);
p.brilho.setAttribute("ry", rr * 0.18);
p.brilho.style.display = "";
}
} else if (forma === "feliz") {
p.traco.setAttribute("d", caminhoArco(cx, cy + ry * 0.22, rx * 1.0, ry * 0.80));
p.traco.setAttribute("stroke-width", rx * 0.34);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "dormir") {
p.traco.setAttribute("d", caminhoLinha(cx - rx * 0.86, cy + ry * 0.14,
cx + rx * 0.86, cy + ry * 0.14));
p.traco.setAttribute("stroke-width", rx * 0.22);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "bravo") {
p.traco.setAttribute("d", caminhoLinha(cx - lado * rx * 0.9, cy - ry * 0.42,
cx + lado * rx * 0.9, cy + ry * 0.26));
p.traco.setAttribute("stroke-width", rx * 0.26);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "triste") {
p.traco.setAttribute("d", caminhoLinha(cx - lado * rx * 0.9, cy + ry * 0.22,
cx + lado * rx * 0.9, cy - ry * 0.46));
p.traco.setAttribute("stroke-width", rx * 0.26);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "coracao") {
p.corpo.setAttribute("d", caminhoCoracao(cx, cy, Math.min(rx, ry) * 0.98));
p.corpo.setAttribute("fill", rgb(cor));
p.corpo.style.display = "";
} else {
p.corpo.setAttribute("d", caminhoElipse(cx, cy, rx, ry));
p.corpo.setAttribute("fill", rgb(cor));
p.corpo.style.display = "";
}
}
// ------------------------------------------------------------------ gestos
function sequencias() {
const piscar = [[{ ab: 0.0 }, 0.06], [{ ab: 0.0 }, 0.04], [{ ab: 1.0 }, 0.11]];
const piscar2 = piscar.concat([[{}, 0.09]], piscar);
const olhar_esq = [[{ dx: -24, dy: 3 }, 0.16], [{}, 0.55], [{ dx: 0, dy: 0 }, 0.2]];
const olhar_dir = [[{ dx: 24, dy: 3 }, 0.16], [{}, 0.55], [{ dx: 0, dy: 0 }, 0.2]];
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: CIANO_E, boca: "aberta" }, 0.09],
[{ dy: -10 }, 0.5],
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
[{ dx: -7 }, 0.06], [{ dx: 7 }, 0.06], [{ dx: -7 }, 0.06], [{ dx: 0 }, 0.06],
[{ forma: "aberto", cor: CIANO }, 0.32]];
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
[{ forma: "aberto", cor: CIANO }, 0.3]];
const triste = [[{ forma: "triste", cor: AZUL, dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
return { piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
bravo, coracao, triste, piscadinha, alegre };
}
const r = {
ab: 1.0, ab2: null, esc: 1.0, dx: 0.0, dy: 0.0,
forma: "aberto", forma2: null, cor: CIANO.slice(), dormindo: false,
boca: "neutra",
fila: [], kf: null, kf_ini: null, kf_t: 0, cor_ini: CIANO.slice(),
};
const SEQS = sequencias();
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"];
function proximo() {
if (!r.fila.length) { r.kf = null; return; }
const [alvo, dur] = r.fila[0];
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
r.cor_ini = r.cor.slice();
if ("forma" in alvo) r.forma = alvo.forma;
if ("forma2" in alvo) r.forma2 = alvo.forma2;
if ("boca" in alvo) r.boca = alvo.boca;
r.kf = [alvo, dur];
r.kf_t = 0;
}
function animar(nome) {
if (SEQS[nome]) {
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
proximo();
tocarSom(nome); // trilha sonora do gesto (se houver)
return true;
}
return false;
}
function passo(dt) { // avanca a fila; true se ha gesto em curso
if (!r.fila.length || !r.kf) return false;
const [alvo, dur] = r.kf;
r.kf_t += dt;
const e = suave(dur <= 0 ? 1 : Math.min(1, r.kf_t / dur));
for (const k of ["ab", "ab2", "esc", "dx", "dy"]) {
if (k in alvo && alvo[k] !== null && r.kf_ini[k] !== null) r[k] = lerp(r.kf_ini[k], alvo[k], e);
else if (k in alvo && alvo[k] === null) r[k] = null;
}
if ("cor" in alvo) r.cor = lerpCor(r.cor_ini, alvo.cor, e);
if (e >= 1) { r.fila.shift(); proximo(); }
return true;
}
function repouso(t) { // pose calma: olha devagar, "respira"
if (r.dormindo) {
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
r.cor = lerpCor(AZUL, [40, 60, 120], 0.5 + 0.5 * Math.sin(t * 1.2));
r.ab = 0; r.ab2 = 0;
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
r.dx = 0;
r.dy = 3.0 * Math.sin(t * 1.4);
return;
}
r.forma = "aberto"; r.forma2 = null;
r.boca = "neutra";
r.cor = CIANO.slice();
r.ab = 1.0; r.ab2 = null; r.esc = 1.0;
// o vaivem de sempre + a direcao de quem se mexeu na frente (se houver)
r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX;
r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY;
}
function desenhar() {
const esc = r.esc * ROSTO.escala, ab = r.ab;
const ab2 = r.ab2 === null ? r.ab : r.ab2;
const f2 = r.forma2 === null ? r.forma : r.forma2;
// durante o repouso o corpo do olho fica parado e so o brilho se move
// mirando: algo se mexeu ha pouco, entao o olho inteiro (nao so o brilho)
// desliza na direcao do movimento; parado, volta ao repouso discreto
const tAgora = performance.now() / 1000;
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
const mirando = MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2;
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
for (let i = 0; i < 2; i++) {
const [c, abx, fx, lado] = olhos[i];
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
abx, fx, r.cor, lado, dx, dy * 0.6);
}
desenharBoca();
for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
const c = i === 0 ? E1 : E2;
const lado = i === 0 ? 1 : -1;
const largura = RX * esc * 0.85;
const y = c[1] - RY * esc - 20 * ESC;
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const elevacao = r.esc > 1.1 ? -15 * ESC : 0;
brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
brow.setAttribute('stroke', rgb(r.cor));
brow.setAttribute('stroke-width', 7 * ESC);
}
}
// ------------------------------------------------------------------ controle
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
function trocarRosto(nome) {
const alvo = ROSTOS[(nome || "").trim().toLowerCase()];
if (!alvo) return false;
ROSTO_NOME = (nome || "").trim().toLowerCase();
ROSTO = alvo;
r.fila = []; r.kf = null;
r.boca = "neutra";
desenhar();
relatar(); // conta ao servidor qual rosto ficou
return true;
}
function trata(ordem) {
ordem = (ordem || "").trim().toLowerCase();
if (!ordem) return;
if(ordem==='recarregar') {
if(new URLSearchParams(location.search).get('aparelho')==='1') {
fetch('/app/status',{cache:'no-store'}).then(r=>r.json()).then(s=>{const url=new URL(location.href);url.searchParams.set('atualizacao',s.recarga?.id||Date.now());location.replace(url.href);}).catch(()=>{});
}
return;
}
window.carinhaUltimoComando = Date.now();
if (ordem.indexOf("rosto ") === 0) {
trocarRosto(ordem.slice(6).trim());
} else if (ROSTOS[ordem]) {
trocarRosto(ordem); // aceita so o nome: "miudo"
} else if (ordem.indexOf("cor ") === 0) {
const v = ordem.slice(4).split(",").map(Number);
if (v.length === 3 && v.every((n) => !isNaN(n))) {
CIANO.splice(0, 3, ...v); r.cor = v;
}
} else if (ordem === "dormir" || ordem === "sono") {
r.dormindo = true;
} else if (ordem === "acordar") {
r.dormindo = false; animar("surpreso");
} else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) {
animar(ordem);
} else if (ordem === "parar") {
try { window.close(); } catch (e) { r.fila = []; }
}
}
let congelado = false; // util para conferir poses (ver eilik.pose)
/* Mostra a pose caracteristica de um gesto e congela — serve para tirar
"foto" de cada gesto e conferir o desenho. eilik.soltar() volta ao normal. */
function pose(nome) {
const seq = SEQS[nome];
if (!seq) return false;
const alvo = seq[0][0];
r.fila = []; r.kf = null;
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
}
if ("cor" in alvo) r.cor = alvo.cor.slice();
congelado = true;
desenhar();
return true;
}
window.eilik = {
animar, trata, pose, trocarRosto, som, tocarSom, movimento, rastrear, face: r,
gestos: Object.keys(SEQS), rostos: Object.keys(ROSTOS),
rostoAtual: () => ROSTO_NOME,
soltar: () => { congelado = false; r.dormindo = false; },
presenca: () => MOV, // estado do detector de movimento (para conferir)
ouvido: () => MIC, // estado do microfone (para conferir)
ligarCamera, desligarCamera, decidirCameraOculta, // camera oculta (sensor)
cameraTick: detectarCamera, // um passo do detector (conferencia/sem laco)
};
// busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto
// do PC com um curl, igual ao FIFO de antes
async function buscarOrdens() {
if (new URLSearchParams(location.search).get('espelho') === '1') return;
try {
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
if (!resp.ok) return;
const lista = await resp.json();
for (const nome of lista) trata(nome);
} catch (e) { /* sem servidor (file://) — ignora */ }
}
setInterval(buscarOrdens, 500);
// A aparencia guardada no servidor (rosto + cor) e aplicada ao carregar: o
// painel de controle personaliza e a carinha ja abre do jeito escolhido. Sem
// servidor (file://) isso falha calado e vale o padrao.
async function aplicarAparencia() {
try {
const resp = await fetch("config", { cache: "no-store" });
if (!resp.ok) return;
const c = await resp.json();
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
} catch (e) { /* sem servidor — segue com o padrao */ }
}
setTimeout(aplicarAparencia, 200);
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",
t: "triste", a: "alegre", o: "olhar_esq", i: "olhar_dir",
k: "olhar_baixo", w: "piscadinha" };
addEventListener("keydown", (ev) => {
const k = ev.key.toLowerCase();
if (TECLAS[k]) animar(TECLAS[k]);
else if (k === "d") { r.dormindo = !r.dormindo; if (!r.dormindo) animar("surpreso"); }
else if (k === " ") { animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); }
});
// ------------------------------------------------------------------ laço
const par = new URLSearchParams(location.search);
const FPS = parseFloat(par.get("fps") || "30"); // 0 = sem limite
let AUTO = par.get("auto") !== "0"; // gestos sozinhos
const COMPORTAMENTO = { ouvir: true, reagir: true };
const passoFixo = FPS > 0 ? 1 / FPS : 0;
// ----------------------------------------------------------- ouvido (microfone)
/* A ponte nativa do APK chama window.eilik.som(nivel) ~20x/s, com 0=silencio e
1=bem alto. Quem reage e a propria pagina, entao a telinha do Pi (sem
microfone) continua igual: sem chamadas, nada acontece. Ajustes por URL:
?somlim= ?somaltolim= ?sompausa= ?somcooldown= */
const MIC = {
ativo: false, // virou true na primeira leitura recebida
visto: 0, // t da ultima leitura recebida (s)
nivel: 0,
ultimoSom: 0, // t do ultimo som audivel (s)
estavaAlto: false,
proxSurpresa: 0,
ultimoLog: 0,
piso: null, // piso de ruido do ambiente (segue devagar)
ganho: parseFloat(par.get("somganho") || "0.40"), // quanto acima do piso = susto
limiarSom: parseFloat(par.get("somlim") || "0.35"), // acima: "tem som"
limiarAlto: parseFloat(par.get("somaltolim") || "0.55"), // minimo absoluto p/ susto
segSilencio: parseFloat(par.get("sompausa") || "20"), // s calado -> dormir
cooldown: parseFloat(par.get("somcooldown") || "5"), // s entre sustos
};
function som(nivel) {
const n = Math.max(0, Math.min(1, Number(nivel) || 0));
const agora = performance.now() / 1000;
MIC.ativo = true;
MIC.visto = agora;
MIC.nivel = n;
if (!COMPORTAMENTO.ouvir) return;
// piso de ruido: desce rapido e sobe devagar, acompanhando o ambiente. Assim
// o "susto" e sempre relativo ao barulho de fundo e nao dispara sem parar
// num lugar barulhento. Ajuste por URL: ?somganho=
if (MIC.piso === null) MIC.piso = n;
else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015);
const temSom = n >= MIC.limiarSom; // ruido audivel (absoluto)
const alto = n >= MIC.limiarAlto && (n - MIC.piso) >= MIC.ganho;
if (temSom) MIC.ultimoSom = agora;
// registro periodico (aparece no logcat do app: prova que o ouvido chegou)
if (agora - MIC.ultimoLog >= 2) {
MIC.ultimoLog = agora;
console.log("ouvido nivel=" + n.toFixed(2) + " piso=" + (MIC.piso || 0).toFixed(2));
}
if (r.dormindo && temSom) { // acordou: tinha barulho
r.dormindo = false;
animar("surpreso");
MIC.proxSurpresa = agora + MIC.cooldown;
console.log("ouvido: acordou nivel=" + n.toFixed(2));
relatarSom("acordou", n);
} else if (alto && !MIC.estavaAlto && agora >= MIC.proxSurpresa) {
MIC.proxSurpresa = agora + MIC.cooldown; // susto: barulho alto e repentino
animar("surpreso");
console.log("ouvido: surpreso nivel=" + n.toFixed(2));
relatarSom("surpreso", n);
}
MIC.estavaAlto = alto;
}
/* Sem barulho por tempo demais, adormece (so quando o microfone esta ativo). */
function checarSilencio(agora) {
if (!COMPORTAMENTO.ouvir) return;
if (!MIC.ativo) return; // telinha do Pi: sem ouvido
if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado
if (r.dormindo) return;
// movimento tambem conta como "tem alguem aqui": nao adormece com gente passando
const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0);
if (agora - atividade >= MIC.segSilencio) {
r.dormindo = true;
console.log("ouvido: adormeceu (" + MIC.segSilencio + "s sem som nem movimento)");
relatarSom("adormeceu", 0);
}
}
/* Conta ao servidor o que o ouvido percebeu — da para conferir de longe no
journal do carinha-web (so quando o microfone esta ativo). */
function relatarSom(evento, n) {
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
}
// ------------------------------------------------------ presenca (movimento)
/* A ponte nativa do APK abre a camera FRONTAL, mede o movimento (diferenca de
luminancia entre quadros) e chama window.eilik.movimento(vel, cx, cy) umas
12x/s: vel vai de 0 (parado) a 1 (muito movimento) e cx,cy e o centro do que
se mexeu, de 0 a 1 na imagem. A carinha acorda quando algo se mexe na frente
dela e, acordada, os olhos acompanham o movimento. Sem a ponte nada acontece
(a telinha do Pi continua igual, sem camera). Ajustes por URL:
?movlim= ?movforte= ?movcooldown= ?movolhar= */
const MOV = {
ativo: false, // virou true na primeira leitura recebida
vel: 0, // ultimo movimento (0 a 1)
cx: 0.5, cy: 0.5, // ultimo centro do movimento (0 a 1)
visto: 0, // t da ultima leitura (s)
ultimoMov: 0, // t do ultimo movimento percebido (s)
proxSurpresa: 0, // t a partir do qual um novo susto pode disparar
estavaForte: false, // ja vinha em movimento forte (so o INICIO assusta)
ultimoLog: 0,
olharX: 0, olharY: 0, // deslocamento dos olhos na direcao do movimento
limiar: parseFloat(par.get("movlim") || "0.05"), // acima: houve movimento
limiarForte: parseFloat(par.get("movforte") || "0.12"), // acima: susto (medido: mao ~0,1)
cooldown: parseFloat(par.get("movcooldown") || "6"), // s entre sustos
olharMax: parseFloat(par.get("movolhar") || "40"), // quanto os olhos seguem
};
const TRACK = {alvo:null,visto:-Infinity};
function rastrear(t) {
if (!t) { TRACK.alvo=null; return; }
if (!Number.isFinite(t.x) || !Number.isFinite(t.y) || t.x<0 || t.x>1 || t.y<0 || t.y>1 || !['mao','rosto'].includes(t.tipo)) return;
if (!COMPORTAMENTO.reagir || configRecebida?.seguirRastreamento===false || configRecebida?.sensorCamera===false ||
(t.tipo==='mao' && configRecebida?.rastrearMaos===false) || (t.tipo==='rosto' && configRecebida?.rastrearRosto===false) ||
(r.dormindo && !configRecebida?.cameraSono)) return;
const agora=performance.now()/1000;
TRACK.alvo=t; TRACK.visto=agora; MOV.ativo=true;MOV.visto=agora;MOV.cx=t.x;MOV.cy=t.y;
MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65;
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
if(t.atividade===true) {
MOV.ultimoMov=agora;
if(r.dormindo) { r.dormindo=false;animar('surpreso'); }
}
}
function movimento(vel, cx, cy) {
const v = Math.max(0, Math.min(1, Number(vel) || 0));
const agora = performance.now() / 1000;
MOV.ativo = true;
MOV.vel = v;
MOV.visto = agora;
if (typeof cx === "number" && !isNaN(cx)) MOV.cx = Math.max(0, Math.min(1, cx));
if (typeof cy === "number" && !isNaN(cy)) MOV.cy = Math.max(0, Math.min(1, cy));
if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; return; }
if (TRACK.alvo && agora-TRACK.visto<.35) return;
const mexeu = v >= MOV.limiar;
if (mexeu) MOV.ultimoMov = agora;
// olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro
const alvoX = mexeu ? (MOV.cx - 0.5) * 2 * MOV.olharMax : agora-MOV.ultimoMov<.2 ? MOV.olharX : 0;
const alvoY = mexeu ? (MOV.cy - 0.5) * 2 * MOV.olharMax * 0.6 : agora-MOV.ultimoMov<.2 ? MOV.olharY : 0;
MOV.olharX += (alvoX - MOV.olharX) * 0.65;
MOV.olharY += (alvoY - MOV.olharY) * 0.65;
if (agora - MOV.ultimoLog >= 2) {
MOV.ultimoLog = agora;
console.log("presenca nivel=" + v.toFixed(2) + " centro=" +
MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2));
}
const forte = v >= MOV.limiarForte;
if (r.dormindo && mexeu) { // acordou: algo se mexeu na frente
r.dormindo = false;
animar("surpreso");
MOV.proxSurpresa = agora + MOV.cooldown;
console.log("presenca: acordou nivel=" + v.toFixed(2));
relatarMov("acordou", v);
} else if (forte && !MOV.estavaForte && agora >= MOV.proxSurpresa) {
// susto so no INICIO do movimento forte: se continuar se mexendo, nao repete
MOV.proxSurpresa = agora + MOV.cooldown;
animar("surpreso");
console.log("presenca: surpreso nivel=" + v.toFixed(2));
relatarMov("surpreso", v);
}
MOV.estavaForte = forte;
}
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
function relatarMov(evento, n) {
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
}
// ------------------------------------------------- camera oculta (sensor)
/* A propria pagina abre a camera para SENTIR o movimento sem mostrar a imagem:
o video fica escondido e a tela continua so com os olhos do robo. Serve para
o kiosk do Pi com uma webcam (a pagina e servida por 127.0.0.1, que conta
como contexto seguro). No celular quem faz isso e a ponte nativa, que tambem
nao mostra imagem — entao, se ela ja estiver mandando movimento, nao abrimos
outra camera (dois acessos juntos costumam falhar).
Para ver a imagem em um cantinho (so para conferir): ?campreview=1
Para desligar de vez: ?camera=0 */
const CAM = {
querer: par.get("camera") !== "0"
&& typeof window.isSecureContext !== "undefined" && window.isSecureContext,
ativo: false,
erro: null,
fluxo: null,
video: null,
cv: null,
ctx: null,
lumaAnt: null,
quadros: 0, // aquecimento: a exposicao demora a assentar
CW: 160, CH: 120,
limite: 26, // salto de luminancia (igual a camera.html)
mostrar: par.get("campreview") === "1",
atraso: parseInt(par.get("camatraso") || "2000", 10), // espera antes de abrir
};
async function ligarCamera() {
if (CAM.ativo) return true;
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
CAM.erro = "sem getUserMedia (contexto inseguro)";
return false;
}
try {
const fluxo = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "user" }, width: { ideal: 640 }, height: { ideal: 480 } },
audio: false,
});
CAM.fluxo = fluxo;
const v = document.createElement("video");
v.autoplay = true; v.muted = true; v.setAttribute("playsinline", "");
// escondido de verdade (fora da tela); so aparece com ?campreview=1
v.style.cssText = CAM.mostrar
? "position:fixed;right:8px;bottom:8px;width:160px;z-index:9;border:1px solid #24405a;border-radius:6px"
: "position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0;pointer-events:none";
v.srcObject = fluxo;
document.body.appendChild(v);
try { await v.play(); } catch (e) { /* segue */ }
CAM.video = v;
CAM.cv = document.createElement("canvas");
CAM.cv.width = CAM.CW; CAM.cv.height = CAM.CH;
CAM.ctx = CAM.cv.getContext("2d", { willReadFrequently: true });
CAM.quadros = 0;
CAM.ativo = true;
const f = fluxo.getVideoTracks()[0];
console.log("camera: sensor oculto ligado (" + ((f && f.label) || "camera") + ")");
requestAnimationFrame(lacoCamera);
return true;
} catch (e) {
CAM.erro = e.name;
console.log("camera: sensor oculto falhou — " + e.name);
return false;
}
}
function desligarCamera() {
CAM.ativo = false;
if (CAM.fluxo) { try { CAM.fluxo.getTracks().forEach((t) => t.stop()); } catch (e) {} }
if (CAM.video && CAM.video.parentNode) CAM.video.parentNode.removeChild(CAM.video);
CAM.fluxo = null; CAM.video = null; CAM.lumaAnt = null;
}
function detectarCamera() {
const v = CAM.video;
if (!v || !v.videoWidth) return;
CAM.ctx.drawImage(v, 0, 0, CAM.CW, CAM.CH);
const d = CAM.ctx.getImageData(0, 0, CAM.CW, CAM.CH).data;
if (!CAM.lumaAnt) CAM.lumaAnt = new Float32Array(CAM.CW * CAM.CH);
let mexidos = 0, sx = 0, sy = 0;
for (let i = 0; i < CAM.CW * CAM.CH; i++) {
const l = d[i * 4] * 0.299 + d[i * 4 + 1] * 0.587 + d[i * 4 + 2] * 0.114;
if (Math.abs(l - CAM.lumaAnt[i]) > CAM.limite) { mexidos++; sx += i % CAM.CW; sy += (i / CAM.CW) | 0; }
CAM.lumaAnt[i] = l;
}
CAM.quadros++;
if (CAM.quadros <= 6) return; // ignora o pico de entrada
const vel = Math.min(1, mexidos / (CAM.CW * CAM.CH * 0.25));
const cx = mexidos > 0 ? (sx / mexidos) / CAM.CW : 0.5;
const cy = mexidos > 0 ? (sy / mexidos) / CAM.CH : 0.5;
movimento(vel, cx, cy);
}
function lacoCamera() {
if (!CAM.ativo) return;
try { detectarCamera(); } catch (e) { CAM.erro = e.name; }
requestAnimationFrame(lacoCamera);
}
/* Decisao de abrir (ou nao) a camera escondida. Fica numa funcao a parte para
dar para conferir sem depender do relogio. */
function decidirCameraOculta() {
if (!CAM.querer) { if (!CAM.erro) CAM.erro = "desligada"; return; }
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
CAM.erro = "sem getUserMedia (contexto inseguro)";
console.log("camera: sem sensor na pagina — " + CAM.erro);
return;
}
// se a ponte nativa ja esta mandando movimento, deixa a camera com ela
// (dois acessos a camera ao mesmo tempo costumam falhar)
if (MOV.ativo) { CAM.erro = "ponte nativa no comando"; return; }
ligarCamera();
}
setTimeout(decidirCameraOculta, CAM.atraso);
// -------------------------------------------------------------- voz (saida)
/* Sons curtos sintetizados com Web Audio — sem arquivos, sem permissao e sem
rede. Cada gesto tem o seu (piscar e olhar ficam mudos de proposito). Para
silenciar: ?audio=0 ; volume: ?volume=0.5 */
const AUDIO = {
ligado: par.get("audio") !== "0",
volume: parseFloat(par.get("volume") || "0.35"),
ctx: null,
};
if (window.eilik) window.eilik.audio = AUDIO; // so depois do const: sem TDZ
if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
let configRecebida = null;
function configurarApp(c) {
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true;
AUTO = c.auto;
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
MIC.limiarSom = c.somlim; MIC.ganho = c.somganho;
MIC.limiarAlto = c.somaltolim; MIC.segSilencio = c.sompausa; MIC.cooldown = c.somcooldown;
MOV.limiar = c.movlim; MOV.limiarForte = c.movforte;
MOV.cooldown = c.movcooldown; MOV.olharMax = c.movolhar;
if (!c.reagir) { MOV.olharX = MOV.olharY = 0; MOV.ultimoMov = 0; }
configRecebida = c;
if(!c.seguirRastreamento || !c.sensorCamera || !c.reagir) TRACK.alvo=null;
}
let sincronizando = false;
async function sincronizarApp() {
if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return;
sincronizando = true;
try {
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
if (resp.ok) configurarApp(await resp.json());
if (par.get('aparelho') === '1') {
const agora = performance.now() / 1000;
await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'},
signal: AbortSignal.timeout(2500), body: JSON.stringify({
tela_atualizada: par.get('atualizacao'), rosto: ROSTO_NOME, cor: r.cor, dormindo: r.dormindo,
microfone: MIC.nivel, microfone_ativo: window.carinhaSensores ? window.carinhaSensores.microfone : MIC.ativo && agora - MIC.visto < 2,
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho,
bateria: window.carinhaBateria,
video: window.carinhaVideo,
}) });
}
} catch (e) { /* servidor ausente: animação continua */ }
finally { sincronizando = false; }
}
setTimeout(sincronizarApp, 300);
setInterval(sincronizarApp, 1000);
function contextoAudio() {
if (AUDIO.ctx) return AUDIO.ctx;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
try { AUDIO.ctx = new AC(); } catch (e) { return null; }
return AUDIO.ctx;
}
/* Uma nota simples: freq -> freqFim (se houver), com subida e queda suaves. */
function nota(c, freq, t0, dur, tipo, vol, freqFim) {
const osc = c.createOscillator();
const ganho = c.createGain();
osc.type = tipo || "sine";
osc.frequency.setValueAtTime(freq, t0);
if (freqFim) osc.frequency.exponentialRampToValueAtTime(freqFim, t0 + dur);
ganho.gain.setValueAtTime(0.0001, t0);
ganho.gain.exponentialRampToValueAtTime(vol, t0 + 0.012);
ganho.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(ganho);
ganho.connect(c.destination);
osc.start(t0);
osc.stop(t0 + dur + 0.03);
}
const SONS = {
feliz: (c, t, v) => { nota(c, 660, t, 0.12, "sine", v); nota(c, 990, t + 0.10, 0.16, "sine", v); },
alegre: (c, t, v) => [523, 659, 784, 1047].forEach((f, i) => nota(c, f, t + i * 0.07, 0.12, "triangle", v)),
coracao: (c, t, v) => { nota(c, 784, t, 0.5, "sine", v * 0.9); nota(c, 988, t + 0.04, 0.5, "sine", v * 0.7); },
piscadinha: (c, t, v) => { nota(c, 880, t, 0.09, "square", v * 0.6); nota(c, 1175, t + 0.09, 0.12, "square", v * 0.5); },
surpreso: (c, t, v) => nota(c, 300, t, 0.28, "sawtooth", v * 0.6, 1400), // "uou!"
bravo: (c, t, v) => nota(c, 220, t, 0.35, "sawtooth", v * 0.7, 90), // resmungo
triste: (c, t, v) => { nota(c, 500, t, 0.22, "sine", v * 0.8); nota(c, 330, t + 0.20, 0.34, "sine", v * 0.8); },
dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); },
};
function tocarSom(gesto) {
if (!AUDIO.ligado) return false;
const f = SONS[gesto];
if (!f) return false;
const c = contextoAudio();
if (!c) return false;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try {
f(c, c.currentTime + 0.02, AUDIO.volume);
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
console.log("voz: " + gesto + " ctx=" + c.state);
return true;
} catch (e) { return false; }
}
let anterior = performance.now();
let descanso = 0;
let agora = performance.now();
let proxPiscar = agora + 1500 + Math.random() * 2500;
let proxGesto = agora + 6000 + Math.random() * 6000;
const elFps = document.getElementById("fps");
if (par.get("fpsmostrar") === "1") elFps.style.display = "block";
let contador = 0, marco = performance.now();
function laco(t) {
const dt = Math.min(0.25, (t - anterior) / 1000);
anterior = t;
// se a janela mudou de tamanho (ou so ficou pronta depois do primeiro
// quadro), refaz a geometria — assim a carinha nunca fica enquadrada errado
if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
checarSilencio(t / 1000);
const ocupado = congelado ? true : passo(dt);
if (!ocupado) {
descanso += dt;
repouso(descanso);
if (r.dormindo) {
proxPiscar = proxGesto = t + 3600000;
} else if (t >= proxPiscar) {
animar("piscar");
proxPiscar = t + 1600 + Math.random() * 3500;
} else if (AUTO && t >= proxGesto) {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
proxGesto = t + 7000 + Math.random() * 8000;
}
} else if (t >= proxGesto) {
proxGesto = t + 7000 + Math.random() * 8000;
}
desenhar();
contador++;
if (t - marco >= 1000) {
if (elFps.style.display !== "none") elFps.textContent = contador + " fps";
contador = 0; marco = t;
}
if (passoFixo > 0) {
const folga = passoFixo * 1000 - (performance.now() - t);
if (folga > 1) { setTimeout(() => requestAnimationFrame(laco), folga); return; }
}
requestAnimationFrame(laco);
}
requestAnimationFrame(laco);
// gesto inicial por URL: carinha.html?gesto=feliz
if (par.get("gesto")) setTimeout(() => trata(par.get("gesto")), 300);
// Relatorio de diagnostico: conta ao servidor o tamanho da janela e qual rosto
// esta em uso. Aparece no journal do servico carinha-web. Tambem e chamado
// quando alguem troca de rosto, para dar para conferir de longe.
function relatar() {
try {
const g = document.getElementById("tela").getBoundingClientRect();
const info = "rosto=" + ROSTO_NOME +
" janela=" + innerWidth + "x" + innerHeight +
" tela=" + screen.width + "x" + screen.height +
" svg=" + Math.round(g.width) + "x" + Math.round(g.height);
fetch("diag/" + encodeURIComponent(info)).catch(() => {});
} catch (e) {}
}
setTimeout(relatar, 1500);
/* Respostas escritas ficam na aba Conversar. No aparelho, só expressões. */
(function(){
if(location.protocol==='file:')return;
let seen=null,fetching=false;
async function poll(){
if(fetching||window.carinhaAtiva===false)return;fetching=true;
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
}catch(e){}finally{fetching=false;}
}
setInterval(poll,1000);poll();
})();
</script>
<script src="/reacoes-render.js"></script>
</body>
</html>
<script>
if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchParams(location.search).has('atualizacao')) {
const aviso=document.createElement('div');aviso.id='avisoRecarga';aviso.textContent='Tela atualizada';aviso.style.cssText='position:fixed;bottom:8%;left:0;right:0;text-align:center;color:#fff;font:24px sans-serif;z-index:100;pointer-events:none';document.body.append(aviso);setTimeout(()=>aviso.remove(),4000);
}
</script>
+30
View File
@@ -0,0 +1,30 @@
(function(root) {
class QualidadeAuto {
constructor(fps = 30, bitrate = 2500000) { this.level = 0; this.good = 0; this.fps = fps; this.bitrate = bitrate; }
static constraints(c, caps = {}) {
const v = {facingMode:{exact:c.cameraLado || 'user'}};
const res = c.videoResolucao || 'auto', fps = Number(c.videoFPS || 0);
if (res === 'auto') { v.width = {ideal:1920}; v.height = {ideal:1080}; }
else if (res === 'max') {
v.width = {ideal:caps.width?.max || 7680}; v.height = {ideal:caps.height?.max || 4320};
} else {
const h = Number(res); v.height = {exact:h}; v.width = {exact: h === 480 ? 640 : Math.round(h*16/9)};
}
v.frameRate = fps === -1 ? {ideal:caps.frameRate?.max || 240} : fps > 0 ? {exact:fps} : {ideal:30};
return v;
}
update(s) {
const bad = (Number.isFinite(s.fps) && s.fps > 0 && s.fps < this.fps*.73) || s.rtt > .3 || s.bitrate < [this.bitrate*.48, this.bitrate*.26, 0][this.level];
if (bad) { this.level = Math.min(2, this.level + 1); this.good = 0; }
else if (s.fps >= this.fps*.9 && s.rtt < .15 && s.bitrate > this.bitrate*.72) {
if (++this.good >= 4) { this.level = Math.max(0, this.level - 1); this.good = 0; }
} else this.good = 0;
return this.parameters();
}
parameters() {
return {maxFramerate:this.fps, maxBitrate:Math.round(this.bitrate*[1,.52,.24][this.level]), scaleResolutionDownBy:[1,1.5,2][this.level]};
}
}
root.QualidadeAuto = QualidadeAuto;
if (typeof module !== 'undefined') module.exports = QualidadeAuto;
})(typeof globalThis !== 'undefined' ? globalThis : window);
+181
View File
@@ -0,0 +1,181 @@
<!doctype html><html><meta charset="utf-8"><body style="margin:0;background:black">
<video id="fonte" autoplay muted playsinline style="width:1px;height:1px"></video>
<script src="qualidade-auto.js"></script><script>
'use strict';
const video = document.getElementById('fonte');
const sensor = document.createElement('canvas'); sensor.width = 176; sensor.height = 132;
const ctx = sensor.getContext('2d', {willReadFrequently:true});
let stream = null, pc = null, sender = null, sid = null, wanted = false;
let previous = null, warmup = 0, icePending = [], tasks = Promise.resolve();
let automatico = new QualidadeAuto(), lastStats = null, captureGeneration = 0;
let config = {}, signature = '', movementEnabled = true, aviso = '', capacidades = {};
let allowed = true;
let lastMotionFrame = -1;
function chave(c) { return JSON.stringify([c.cameraLado || 'user', c.videoResolucao || 'auto', c.videoFPS || 0]); }
async function capturar(c) {
const media = await navigator.mediaDevices.getUserMedia({audio:false,video:{facingMode:{ideal:c.cameraLado || 'user'},width:{ideal:640},height:{ideal:360},frameRate:{ideal:30}}});
const track = media.getVideoTracks()[0];
const facing = track.getSettings().facingMode;
if (facing && facing !== (c.cameraLado || 'user')) {
media.getTracks().forEach(t=>t.stop()); throw new Error('Câmera escolhida indisponível');
}
try { await ajustarTrack(track, c); } catch (e) { media.getTracks().forEach(t=>t.stop()); throw e; }
return media;
}
async function ajustarTrack(track, c) {
capacidades = track.getCapabilities ? track.getCapabilities() : {};
aviso = '';
const constraints = QualidadeAuto.constraints(c, capacidades); delete constraints.facingMode;
try { await track.applyConstraints(constraints); }
catch (e) {
if (e.name !== 'OverconstrainedError') throw e;
aviso = 'Combinação solicitada indisponível. Usando qualidade automática.';
const fallback = QualidadeAuto.constraints({cameraLado:c.cameraLado}, capacidades); delete fallback.facingMode;
await track.applyConstraints(fallback);
}
const s = track.getSettings();
const bitrate = Math.max(2500000, Math.round((s.width || 640)*(s.height || 360)*(s.frameRate || 30)*.12));
automatico = new QualidadeAuto(s.frameRate || 30, bitrate); lastStats = null;
}
async function configurar(c) {
if (signature === chave(c) || !stream) { config = c; return; }
const old = stream, oldCamera = config.cameraLado || 'user'; config = c;
if (oldCamera !== (c.cameraLado || 'user')) {
old.getTracks().forEach(t=>t.stop());
const media = await capturar(c);
if (!wanted) { media.getTracks().forEach(t=>t.stop()); return; }
stream = media; await sender.replaceTrack(media.getVideoTracks()[0]); video.srcObject = stream; await video.play();
} else await ajustarTrack(stream.getVideoTracks()[0], c);
signature = chave(c); previous = null; warmup = 0;
await aplicar(parametros());
}
function parametros() {
if (aviso || ((config.videoResolucao || 'auto') === 'auto' && !config.videoFPS)) return automatico.parameters();
return {maxFramerate:automatico.fps,maxBitrate:automatico.bitrate,scaleResolutionDownBy:1};
}
function sinal(msg) {
if (sid) CarinhaRTC.enviar(JSON.stringify({id:sid, de:'app', mensagem:msg}));
}
function estado(msg) { CarinhaRTC.estado(JSON.stringify(msg)); }
function parar() {
wanted = false; captureGeneration++;
if (pc) pc.close(); pc = null; sender = null;
if (stream) stream.getTracks().forEach(t => t.stop());
stream = null; video.srcObject = null; previous = null; icePending = [];
estado({tipo:'webrtc', estado:'desligado'});
}
async function iniciar(id) {
if (sid === id && wanted && stream) return;
parar(); sid = id; wanted = true;
const generation = captureGeneration;
automatico = new QualidadeAuto(); lastStats = null; warmup = 0;
await new Promise(r => setTimeout(r, 400));
if (!wanted || captureGeneration !== generation) return;
const media = await capturar(config); signature = chave(config);
if (!wanted || captureGeneration !== generation) { media.getTracks().forEach(t => t.stop()); return; }
stream = media; video.srcObject = stream; await video.play();
pc = new RTCPeerConnection({iceServers:[]});
sender = pc.addTrack(stream.getVideoTracks()[0], stream);
const mine = pc;
pc.ondatachannel = ({channel}) => {
let lastSequence=-1;
if (channel.label !== 'rastreamento') { channel.close(); return; }
channel.onmessage = ({data}) => {
if (pc!==mine || !wanted || typeof data!=='string' || data.length>2048 || !config.seguirRastreamento) return;
try {
const msg=JSON.parse(data), t=msg.alvo;
if(msg.tipo!=='alvo' || !Number.isSafeInteger(msg.seq) || msg.seq<=lastSequence) return;
if(t && (!['mao','rosto'].includes(t.tipo) || !Number.isFinite(t.x) || !Number.isFinite(t.y) || t.x<0 || t.x>1 || t.y<0 || t.y>1 ||
(t.tipo==='mao' && !config.rastrearMaos) || (t.tipo==='rosto' && !config.rastrearRosto))) return;
lastSequence=msg.seq; CarinhaRTC.rastrear(JSON.stringify(t || null));
} catch(e) { console.log('Alvo inválido'); }
};
};
pc.onicecandidate = e => { if (pc === mine && e.candidate) sinal({tipo:'candidate', candidate:e.candidate.toJSON()}); };
pc.onconnectionstatechange = () => {
if (pc !== mine) return;
console.log('RTC estado=' + pc.connectionState);
estado({tipo:'webrtc', estado:pc.connectionState, qualidade:'auto'});
};
console.log('RTC captura=' + JSON.stringify(stream.getVideoTracks()[0].getSettings()));
}
async function aplicar(p) {
if (!sender) return;
const params = sender.getParameters();
if (!params.encodings || !params.encodings.length) return;
Object.assign(params.encodings[0], p); params.degradationPreference = 'maintain-framerate';
try { await sender.setParameters(params); }
catch (e) {
delete params.degradationPreference;
try { await sender.setParameters(params); } catch (ignored) { console.log('RTC adaptação nativa: ' + ignored.name); }
}
}
async function receber(packet) {
if (!packet.id) { parar(); sid = null; return; }
movementEnabled = packet.movimento !== false;
if (packet.config) await configurar(packet.config);
if (!wanted || sid !== packet.id) await iniciar(packet.id);
if (!pc) return;
for (const envelope of packet.mensagens || []) {
const msg = envelope.mensagem;
if (msg.tipo === 'offer') {
await pc.setRemoteDescription({type:'offer',sdp:msg.sdp});
for (const c of icePending) await pc.addIceCandidate(c);
icePending = [];
await pc.setLocalDescription(await pc.createAnswer());
sinal({tipo:'answer',sdp:pc.localDescription.sdp});
await aplicar(parametros());
} else if (msg.tipo === 'candidate') {
if (pc.remoteDescription) await pc.addIceCandidate(msg.candidate);
else icePending.push(msg.candidate);
}
}
}
window.rtcReceber = packet => {
allowed = true;
tasks = tasks.then(() => allowed ? receber(packet) : undefined).catch(e => {
console.log('RTC erro=' + e.name + ': ' + e.message);
sinal({tipo:'erro',erro:e.name + ': ' + e.message});
estado({tipo:'webrtc',estado:'erro',erro:e.message});
parar();
});
};
window.rtcParar = () => { allowed = false; parar(); sid = null; };
setInterval(() => {
if (!movementEnabled || !stream || video.readyState < 2 || video.currentTime===lastMotionFrame) return;
lastMotionFrame=video.currentTime;
ctx.drawImage(video,0,0,176,132);
const d = ctx.getImageData(0,0,176,132).data;
const current = new Uint8Array(176*132);
let changed = 0, sx = 0, sy = 0;
for (let i = 0; i < current.length; i++) {
current[i] = (d[i*4]*77 + d[i*4+1]*150 + d[i*4+2]*29) >> 8;
if (previous && Math.abs(current[i]-previous[i]) > 26) {changed++; sx += i%176; sy += Math.floor(i/176);}
}
previous = current;
if (++warmup > 3) CarinhaRTC.movimento(Math.min(1,changed/(current.length*.25)), changed?sx/changed/176:.5, changed?sy/changed/132:.5);
},33);
let statsBusy = false;
setInterval(async () => {
if (!pc || pc.connectionState !== 'connected' || statsBusy) return;
statsBusy = true;
const mine = pc;
try {
const report = await pc.getStats();
if (pc !== mine) return;
let outbound, pair;
report.forEach(s => {
if (s.type === 'outbound-rtp' && (s.kind === 'video' || s.mediaType === 'video')) outbound = s;
if (s.type === 'candidate-pair' && s.state === 'succeeded' && (s.nominated || s.selected)) pair = s;
});
if (!outbound) return;
const fps = lastStats ? (outbound.framesEncoded-lastStats.framesEncoded)*1000/(outbound.timestamp-lastStats.timestamp) : outbound.framesPerSecond || 30;
lastStats = outbound;
const auto = !!aviso || ((config.videoResolucao || 'auto') === 'auto' && !config.videoFPS);
if (auto) await aplicar(automatico.update({fps,bitrate:pair && pair.availableOutgoingBitrate || Infinity,rtt:pair && pair.currentRoundTripTime || 0}));
const capture = stream.getVideoTracks()[0].getSettings();
estado({tipo:'webrtc',estado:'connected',qualidade:auto?'auto':'manual',fps:Math.round(fps),largura:outbound.frameWidth,altura:outbound.frameHeight,nivel:automatico.level,aviso,captura:capture,capacidades});
} catch (e) { console.log('RTC stats: ' + e.message); }
finally { statsBusy = false; }
},2000);
</script></body></html>
@@ -0,0 +1,9 @@
package br.carinha.rosto;
public final class BrilhoRepouso {
public static float nivel(boolean economia, float normal, float repouso,
long inativo, long espera, boolean ocupado, boolean dormindo) {
if (economia && !ocupado && (dormindo || inativo >= espera)) return Math.min(normal, repouso);
return normal;
}
}
@@ -0,0 +1,57 @@
package br.carinha.rosto;
import android.content.Context;
import android.util.Log;
import java.io.*;
import java.net.*;
import java.security.MessageDigest;
/** Executor nativo no telefone. Preparação fora da thread da interface. */
public final class CerebroLocal {
private volatile boolean fechado;
private Process processo;
public void iniciar(final Context contexto, final String servidor) {
new Thread(() -> {
try {
File libs = new File(contexto.getApplicationInfo().nativeLibraryDir);
File executavel = new File(libs, "libcarinha_ai.so");
if (!executavel.exists()) { Log.w("CarinhaIA", "Runtime ARM64 indisponivel"); return; }
File modelo = new File(contexto.getFilesDir(), "qwen3-q4.gguf");
if (!modelo.exists()) {
Log.i("CarinhaIA", "Baixando modelo do servidor local");
HttpURLConnection con = (HttpURLConnection)new URL(servidor.replaceAll("/+$", "") + "/ia/modelo").openConnection();
con.setConnectTimeout(10000); con.setReadTimeout(60000);
File temporario = new File(contexto.getFilesDir(), "qwen3-q4.partial");
MessageDigest hash = MessageDigest.getInstance("SHA-256");
try (InputStream input = con.getInputStream(); FileOutputStream output = new FileOutputStream(temporario)) {
byte[] buffer = new byte[65536]; int n;
while ((n = input.read(buffer)) != -1) {
if (fechado) return;
output.write(buffer, 0, n); hash.update(buffer, 0, n);
}
} finally { con.disconnect(); }
StringBuilder digest = new StringBuilder();
for (byte b : hash.digest()) digest.append(String.format("%02x", b & 255));
if (!digest.toString().equals("ac2d97712095a558e31573f62f466a3f9d93990898b0ec79d7c974c1780d524a")) throw new IOException("Checksum do modelo invalido");
if (!temporario.renameTo(modelo)) throw new IOException("Falha ao guardar modelo");
}
if (fechado) return;
// Um executor já ativo (teste ADB) pode ser reutilizado durante a transição.
try {
HttpURLConnection health = (HttpURLConnection)new URL("http://127.0.0.1:8101/health").openConnection();
health.setConnectTimeout(500); health.setReadTimeout(500);
try { if (health.getResponseCode() == 200) { Log.i("CarinhaIA", "Executor existente pronto"); return; } }
finally { health.disconnect(); }
} catch (IOException ignored) { }
ProcessBuilder builder = new ProcessBuilder(executavel.getAbsolutePath(), "-m", modelo.getAbsolutePath(), "--host", "0.0.0.0", "--port", "8101", "-c", "1024", "-t", "2", "-np", "1", "--jinja");
builder.environment().put("LD_LIBRARY_PATH", libs.getAbsolutePath());
builder.environment().put("GGML_BACKEND_DL_PATH", libs.getAbsolutePath());
builder.redirectErrorStream(true);
builder.redirectOutput(new File(contexto.getFilesDir(), "ia-runtime.log"));
synchronized (this) { if (!fechado) processo = builder.start(); }
Log.i("CarinhaIA", "Executor nativo iniciado");
} catch (Exception e) { Log.e("CarinhaIA", "IA local: " + e); }
}, "CarinhaIA").start();
}
public synchronized void fechar() { fechado = true; if (processo != null) processo.destroy(); }
}
@@ -0,0 +1,209 @@
package br.carinha.rosto;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.ImageFormat;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.graphics.YuvImage;
import android.media.Image;
import android.util.Log;
import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.ByteBuffer;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ConcurrentLinkedQueue;
/** One network worker and one pending JPEG; camera never waits on the network. */
public final class ControleRemoto {
private volatile String servidor;
private volatile boolean preview;
private volatile JSONObject config = new JSONObject();
private volatile byte[] pendente;
private volatile ScheduledExecutorService worker;
private long consultarEm;
private long quadroEm;
private long logEm;
private final Runnable mudanca;
public interface RTCListener { boolean receber(JSONObject pacote); }
private RTCListener rtcListener;
private final ConcurrentLinkedQueue<String> sinais = new ConcurrentLinkedQueue<>();
private String rtcId = "";
private int rtcDesde;
private long rtcConsultarEm;
private final ConcurrentLinkedQueue<JSONObject> ajustes = new ConcurrentLinkedQueue<>();
public JSONObject config() { return config; }
public void configurar(JSONObject parte) { ajustes.offer(parte); }
public ControleRemoto(Runnable mudanca) { this.mudanca = mudanca; }
public void rtcListener(RTCListener listener) { rtcListener = listener; }
public boolean videoRTC() { return config.optBoolean("videoRTC", true); }
public void sinalRTC(String json) {
if (sinais.size() < 64) sinais.offer(json);
}
public void servidor(String valor) {
servidor = valor;
pendente = null;
consultarEm = 0;
rtcId = ""; rtcDesde = 0; rtcConsultarEm = 0;
}
public boolean preview() { return preview && worker != null; }
public boolean economia() { return config.optBoolean("economia", true); }
public float brilho() { return (float) config.optDouble("brilho", 0.7); }
public float brilhoRepouso() { return (float) config.optDouble("brilhoRepouso", 0.08); }
public long repousoTela() { return (long) (config.optDouble("repousoTela", 30) * 1000); }
public double somlim() { return config.optDouble("somlim", 0.35); }
public double movlim() { return config.optDouble("movlim", 0.05); }
public synchronized void iniciar() {
if (worker != null) return;
consultarEm = 0;
worker = Executors.newSingleThreadScheduledExecutor();
worker.scheduleWithFixedDelay(new Runnable() {
@Override public void run() { tick(); }
}, 0, 200, TimeUnit.MILLISECONDS);
}
public synchronized void parar() {
if (worker != null) worker.shutdownNow();
worker = null;
preview = false;
pendente = null;
sinais.clear();
}
private void tick() {
String base = servidor;
if (base == null || worker == null) return;
HttpURLConnection conn = null;
try {
long agora = System.currentTimeMillis();
JSONObject ajuste = ajustes.peek();
if (ajuste != null) {
conn = abrir(base + "/app/config");
conn.setRequestMethod("POST"); conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
byte[] dados = ajuste.toString().getBytes("UTF-8");
try (java.io.OutputStream out = conn.getOutputStream()) { out.write(dados); }
if (conn.getResponseCode() != 200) throw new java.io.IOException("Ajuste não salvo");
ajustes.poll(); consultarEm = 0;
conn.disconnect(); conn = null;
}
if (agora >= consultarEm) {
consultarEm = agora + 1000;
conn = abrir(base + "/app/config");
String texto;
try (InputStream input = conn.getInputStream()) {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int n;
while ((n = input.read(buffer)) != -1 && bytes.size() < 16384) bytes.write(buffer, 0, n);
texto = bytes.toString("UTF-8");
}
conn.disconnect(); conn = null;
JSONObject recebido = new JSONObject(texto);
boolean novo = recebido.optBoolean("preview", false);
if (worker == null || !base.equals(servidor)) return;
config = recebido;
if (novo != preview) {
preview = novo;
if (!novo) pendente = null;
Log.i("CarinhaPC", "transmissao=" + novo);
}
mudanca.run();
}
if (videoRTC()) rtcTick(base);
byte[] jpeg = pendente;
pendente = null;
if (jpeg != null && preview()) {
conn = abrir(base + "/camera/frame");
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "image/jpeg");
conn.setFixedLengthStreamingMode(jpeg.length);
try (java.io.OutputStream out = conn.getOutputStream()) { out.write(jpeg); }
int code = conn.getResponseCode();
if (code == 409) { preview = false; pendente = null; }
else if (code != 200) throw new java.io.IOException("HTTP " + code);
}
} catch (Exception e) {
pendente = null;
if (System.currentTimeMillis() >= logEm) {
logEm = System.currentTimeMillis() + 5000;
Log.w("CarinhaPC", "servidor indisponivel: " + e);
}
} finally { if (conn != null) conn.disconnect(); }
}
private HttpURLConnection abrir(String url) throws Exception {
HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
c.setConnectTimeout(1500); c.setReadTimeout(1500);
c.setUseCaches(false);
return c;
}
public void quadro(Image image, int rotacao) {
if (videoRTC()) return;
long agora = System.currentTimeMillis();
if (!preview() || pendente != null || agora < quadroEm) return;
quadroEm = agora + 200;
try {
Image.Plane[] p = image.getPlanes();
ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()};
int[] rows = {p[0].getRowStride(), p[1].getRowStride(), p[2].getRowStride()};
int[] pixels = {p[0].getPixelStride(), p[1].getPixelStride(), p[2].getPixelStride()};
int w = image.getWidth(), h = image.getHeight();
byte[] nv21 = YuvFrame.nv21(w, h, buffers, rows, pixels);
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
new YuvImage(nv21, ImageFormat.NV21, w, h, null)
.compressToJpeg(new Rect(0, 0, w, h), 70, bytes);
byte[] jpeg = bytes.toByteArray();
if (rotacao != 0) {
Bitmap b = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length);
Matrix m = new Matrix(); m.postRotate(rotacao);
Bitmap rotated = Bitmap.createBitmap(b, 0, 0, b.getWidth(), b.getHeight(), m, true);
bytes.reset(); rotated.compress(Bitmap.CompressFormat.JPEG, 70, bytes);
jpeg = bytes.toByteArray();
if (rotated != b) rotated.recycle();
b.recycle();
}
if (preview()) pendente = jpeg;
} catch (Exception e) { Log.w("CarinhaPC", "quadro descartado: " + e); }
}
private void rtcTick(String base) throws Exception {
String json;
while ((json = sinais.poll()) != null) {
HttpURLConnection c = abrir(base + "/rtc/send");
try {
byte[] body = json.getBytes("UTF-8");
c.setRequestMethod("POST"); c.setDoOutput(true);
c.setRequestProperty("Content-Type", "application/json");
c.setFixedLengthStreamingMode(body.length);
try (java.io.OutputStream out = c.getOutputStream()) { out.write(body); }
int code = c.getResponseCode();
if (code != 200 && code != 409) throw new java.io.IOException("RTC HTTP " + code);
} finally { c.disconnect(); }
}
long agora = System.currentTimeMillis();
if (agora < rtcConsultarEm || rtcListener == null) return;
rtcConsultarEm = agora + 400;
HttpURLConnection c = abrir(base + "/rtc/poll?lado=app&desde=" + rtcDesde + "&id=" + rtcId);
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (InputStream in = c.getInputStream()) {
byte[] buffer = new byte[4096]; int n;
while ((n = in.read(buffer)) != -1 && bytes.size() < 262144) bytes.write(buffer, 0, n);
}
JSONObject packet = new JSONObject(bytes.toString("UTF-8"));
if (worker == null || !base.equals(servidor)) return;
if (rtcListener.receber(packet)) {
String id = packet.optString("id", "");
if (!id.equals(rtcId)) { rtcDesde = 0; rtcId = id; }
org.json.JSONArray messages = packet.optJSONArray("mensagens");
if (messages != null) for (int i = 0; i < messages.length(); i++)
rtcDesde = Math.max(rtcDesde, messages.getJSONObject(i).getInt("seq"));
}
} finally { c.disconnect(); }
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
package br.carinha.rosto;
public final class Sensores {
private Sensores() {}
public static boolean ativo(boolean ligado, boolean duranteSono, boolean dormindo) {
return ligado && (!dormindo || duranteSono);
}
}
@@ -0,0 +1,22 @@
package br.carinha.rosto;
import java.nio.ByteBuffer;
/** Converts Camera2 planes to NV21, respecting padding and interleaved chroma. */
public final class YuvFrame {
public static byte[] nv21(int w, int h, ByteBuffer[] planes, int[] rows, int[] pixels) {
if (w <= 0 || h <= 0 || (w & 1) != 0 || (h & 1) != 0)
throw new IllegalArgumentException("YUV requires positive even dimensions");
byte[] out = new byte[w * h * 3 / 2];
int at = 0;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
out[at++] = planes[0].get(planes[0].position() + y * rows[0] + x * pixels[0]);
for (int y = 0; y < h / 2; y++)
for (int x = 0; x < w / 2; x++) {
out[at++] = planes[2].get(planes[2].position() + y * rows[2] + x * pixels[2]);
out[at++] = planes[1].get(planes[1].position() + y * rows[1] + x * pixels[1]);
}
return out;
}
}
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- icone: um olho ciano no fundo escuro, igual ao da carinha -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#06080E"
android:pathData="M0,0h48v48h-48z" />
<path
android:fillColor="#5CD6FF"
android:pathData="M24,12c7,0 12,6 12,14c0,8 -5,12 -12,12c-7,0 -12,-4 -12,-12c0,-8 5,-14 12,-14z" />
<path
android:fillColor="#F5FAFF"
android:pathData="M19,21m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" />
</vector>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Carinha</string>
</resources>
+67
View File
@@ -0,0 +1,67 @@
"""Build Windows/Linux usando JDK e Android SDK existentes; sem Gradle."""
import argparse
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import zipfile
HERE = Path(__file__).resolve().parent
DEFAULT_TOOLS = os.environ.get('CARINHA_TOOLS', 'C:/Users/JullioPC/Documents/Open/orange-pi-zero3/android/tools')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--tools', default=DEFAULT_TOOLS)
args = parser.parse_args()
tools = Path(args.tools)
suffix = '.exe' if os.name == 'nt' else ''
java = tools / 'jdk/bin' / ('java' + suffix)
javac = tools / 'jdk/bin' / ('javac' + suffix)
bt = tools / 'android-sdk/build-tools/34.0.0'
android = tools / 'android-sdk/platforms/android-34/android.jar'
for path in (java, javac, android, bt / ('aapt2' + suffix), HERE / 'keystore.jks'):
if not path.exists():
raise SystemExit('Arquivo necessário não encontrado: ' + str(path))
work = HERE.parents[2] / 'work'
work.mkdir(parents=True, exist_ok=True)
def run(*command):
subprocess.run([str(x) for x in command], check=True)
for name in ('carinha.html', 'camera.html'):
shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name)
with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder:
tmp = Path(folder)
for name in ('compiled', 'classes', 'gen'):
(tmp / name).mkdir()
print('Recursos e manifesto', flush=True)
run(bt / ('aapt2' + suffix), 'compile', '--dir', HERE / 'app/res', '-o', tmp / 'compiled/res.zip')
run(bt / ('aapt2' + suffix), 'link', '-o', tmp / 'base.apk', '-I', android,
'--manifest', HERE / 'app/AndroidManifest.xml', '-A', HERE / 'app/assets',
'--java', tmp / 'gen', '--min-sdk-version', '21', '--target-sdk-version', '34', tmp / 'compiled/res.zip')
sources = list((HERE / 'app/java').rglob('*.java')) + list((tmp / 'gen').rglob('*.java'))
print('Java e DEX', flush=True)
run(javac, '--release', '8', '-nowarn', '-classpath', android, '-d', tmp / 'classes', *sources)
classes = list((tmp / 'classes').rglob('*.class'))
run(java, '-cp', bt / 'lib/d8.jar', 'com.android.tools.r8.D8', '--lib', android,
'--min-api', '21', '--output', tmp, *classes)
with zipfile.ZipFile(tmp / 'base.apk', 'a', zipfile.ZIP_DEFLATED) as z:
z.write(tmp / 'classes.dex', 'classes.dex')
for native in (HERE / 'app/native/arm64-v8a').glob('*.so'):
z.write(native, 'lib/arm64-v8a/' + native.name)
run(bt / ('zipalign' + suffix), '-f', '-p', '4', tmp / 'base.apk', tmp / 'aligned.apk')
print('Assinatura persistente', flush=True)
run(java, '-jar', bt / 'lib/apksigner.jar', 'sign', '--ks', HERE / 'keystore.jks',
'--ks-pass', 'pass:android', '--key-pass', 'pass:android', '--ks-key-alias', 'carinha',
'--out', HERE / 'carinha-rosto.apk', tmp / 'aligned.apk')
run(java, '-jar', bt / 'lib/apksigner.jar', 'verify', HERE / 'carinha-rosto.apk')
with zipfile.ZipFile(HERE / 'carinha-rosto.apk') as z:
required = {'AndroidManifest.xml', 'classes.dex', 'resources.arsc', 'assets/carinha.html', 'assets/camera.html', 'assets/transmissao.html', 'assets/qualidade-auto.js'}
assert required.issubset(z.namelist()), 'APK incompleto'
print('APK pronto: ' + str(HERE / 'carinha-rosto.apk'))
if __name__ == '__main__':
main()
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -e
AQUI="$(cd "$(dirname "$0")" && pwd)"
python "$AQUI/build-apk.py" "$@"