feat: initial commit - Carinha Control Panel v1.0 com UI glassmorphism, watch-bar, emojis, braco robotico e conversa IA
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Carinha</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user