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

This commit is contained in:
julio
2026-10-05 16:21:57 -03:00
commit 8217292893
114 changed files with 13145 additions and 0 deletions
+355
View File
@@ -0,0 +1,355 @@
<!doctype html>
<html lang="pt-BR">
<meta charset="utf-8">
<title>Câmera</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000;
color:#cfe6f5;font:13px/1.4 monospace;-webkit-user-select:none;user-select:none}
#video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000}
#marca{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
.painel{position:absolute;background:rgba(6,8,14,.74);border:1px solid #1d2b3a;
border-radius:8px;padding:8px 10px}
.painel b{color:#5CD6FF}
#info{left:10px;top:10px}
#mov{right:10px;top:10px;min-width:168px}
#barra{height:10px;background:#12202e;border-radius:5px;overflow:hidden;margin-top:4px}
#barra i{display:block;height:100%;width:0;background:#5CD6FF}
#dica{left:50%;bottom:12px;transform:translateX(-50%);text-align:center;opacity:.85}
#erro{left:10px;bottom:64px;right:10px;display:none;color:#ff9b9b}
#escolha{left:50%;bottom:64px;transform:translateX(-50%);display:flex;gap:8px;padding:8px}
#escolha button{font:inherit;color:#9fc0d8;background:#0e1a26;border:1px solid #24405a;
border-radius:7px;padding:8px 14px}
#escolha button.on{color:#04121c;background:#5CD6FF;border-color:#5CD6FF;font-weight:bold}
#escolha button:disabled{opacity:.4}
#video2{position:absolute;right:12px;bottom:70px;width:26%;aspect-ratio:16/9;display:none;
object-fit:cover;background:#000;border:1px solid #24405a;border-radius:8px}
#rotulo2{position:absolute;right:12px;bottom:70px;width:26%;display:none;text-align:center;
font-size:12px;color:#04121c;background:#5CD6FF;border-radius:0 0 8px 8px;padding:2px 0}
</style>
<body>
<video id="video" autoplay playsinline muted></video>
<video id="video2" autoplay playsinline muted></video>
<div id="rotulo2"></div>
<canvas id="marca"></canvas>
<div class="painel" id="info">
<div>câmera: <b id="nome">—</b></div>
<div>lado: <b id="lado">—</b></div>
<div>resolução: <b id="res">—</b></div>
<div>quadros: <b id="fps">—</b> por segundo</div>
</div>
<div class="painel" id="mov">
<div>movimento: <b id="mvel">0%</b></div>
<div id="barra"><i id="mbar"></i></div>
<div>área mexida: <b id="marea">0%</b></div>
<div>centro: <b id="mcentro">—</b></div>
</div>
<div class="painel" id="escolha">
<button id="bfrontal">Frontal</button>
<button id="btraseira">Traseira</button>
<button id="bduas">Ver as duas</button>
<button id="bsensor">Só sensor (sem imagem)</button>
</div>
<div class="painel" id="erro"></div>
<script src="qualidade-auto.js"></script><script>
"use strict";
/* -------------------------------------------------------------------------
Página da câmera — vídeo ao vivo na maior resolução que o aparelho entregar,
começa na câmera FRONTAL e deixa escolher a traseira. Por cima, um detector de
movimento (diferença de luminância entre quadros), que é a base para os olhos
da carinha seguirem o movimento.
Precisa de contexto seguro: o app abre esta página do arquivo embutido
(file://), por isso o getUserMedia funciona mesmo sem HTTPS.
------------------------------------------------------------------------- */
const video = document.getElementById("video");
const canvasMarca = document.getElementById("marca");
const ctxMarca = canvasMarca.getContext("2d");
const elNome = document.getElementById("nome");
const elLado = document.getElementById("lado");
const elRes = document.getElementById("res");
const elFps = document.getElementById("fps");
const elVel = document.getElementById("mvel");
const elBar = document.getElementById("mbar");
const elArea = document.getElementById("marea");
const elCentro = document.getElementById("mcentro");
const elErro = document.getElementById("erro");
const bFrontal = document.getElementById("bfrontal");
const bTraseira = document.getElementById("btraseira");
const bDuas = document.getElementById("bduas");
const bSensor = document.getElementById("bsensor");
const video2 = document.getElementById("video2");
const rotulo2 = document.getElementById("rotulo2");
const LIMIAR = 26; // salto de luminância que conta como mudança
const CW = 160, CH = 120; // resolução do detector (leve de propósito)
const cv = document.createElement("canvas");
cv.width = CW; cv.height = CH;
const ctx = cv.getContext("2d", { willReadFrequently: true });
let lumaAnterior = null;
let fluxo = null;
let fluxo2 = null; // segunda camera, no "ver as duas"
const ajustesCamera = new URLSearchParams(location.search);
let facingAtual = ajustesCamera.get("lado") || "user"; // frontal primeiro
let quadros = 0, marco = performance.now();
let proxLog = 0, proxLogCena = 0, escura = 0;
function avisaErro(texto) {
elErro.style.display = "block";
elErro.textContent = texto;
}
function marcarBotoes() {
bFrontal.classList.toggle("on", facingAtual === "user");
bTraseira.classList.toggle("on", facingAtual === "environment");
}
/* Tenta o lado pedido na maior resolução; se não existir, cai para o outro. */
async function capturar(facing) {
const novo = await navigator.mediaDevices.getUserMedia({audio:false,video:{facingMode:{ideal:facing}}});
const faixa = novo.getVideoTracks()[0];
const lado = faixa.getSettings().facingMode;
if (lado && lado !== facing) { novo.getTracks().forEach(t=>t.stop()); throw new Error('Camera escolhida indisponivel'); }
const c = {cameraLado:facing,videoResolucao:ajustesCamera.get('resolucao') || 'auto',videoFPS:Number(ajustesCamera.get('fps') || 0)};
const caps = faixa.getCapabilities ? faixa.getCapabilities() : {};
const v = QualidadeAuto.constraints(c,caps); delete v.facingMode;
try { await faixa.applyConstraints(v); }
catch(e) {
if(e.name !== 'OverconstrainedError') { novo.getTracks().forEach(t=>t.stop()); throw e; }
avisaErro('Combinacao indisponivel. Usando qualidade automatica.');
const fallback = QualidadeAuto.constraints({},caps); delete fallback.facingMode;
await faixa.applyConstraints(fallback);
}
return {fluxo:novo,facing};
}
async function abrir(facing) {
if (fluxo) { fluxo.getTracks().forEach((t) => t.stop()); fluxo = null; }
const r = await capturar(facing);
fluxo = r.fluxo;
facingAtual = r.facing || facing || "environment";
video.srcObject = fluxo;
try { await video.play(); } catch (e) { console.log("camera: play " + e); }
const faixa = fluxo.getVideoTracks()[0];
const s = faixa.getSettings ? faixa.getSettings() : {};
elNome.textContent = (faixa.label || "câmera").slice(0, 34);
elLado.textContent = facingAtual === "user" ? "frontal" : "traseira";
elRes.textContent = (s.width || video.videoWidth) + "x" + (s.height || video.videoHeight);
marcarBotoes();
console.log("camera: " + elNome.textContent + " | lado=" + elLado.textContent +
" | " + elRes.textContent + " " + (s.frameRate ? Math.round(s.frameRate) + "fps" : ""));
}
/* Lista o que o navegador realmente expõe — o rótulo só aparece após a
permissão, então vale chamar de novo depois de abrir. */
async function listarCameras() {
let lista = [];
try { lista = await navigator.mediaDevices.enumerateDevices(); } catch (e) {}
const cameras = lista.filter((d) => d.kind === "videoinput");
console.log("camera: " + cameras.length + " aparelho(s) de vídeo — " +
cameras.map((c, i) => i + ":" + (c.label || "sem rótulo")).join(" ; "));
return cameras;
}
async function trocarPara(facing) {
if (facing === facingAtual && fluxo) return;
elErro.style.display = "none";
try {
await abrir(facing);
console.log("camera: trocou para " + facing);
} catch (e) {
marcarBotoes();
elErro.style.display = "block";
elErro.textContent = "não consegui abrir a câmera " +
(facing === "user" ? "frontal" : "traseira") + ": " + e.name;
console.log("camera: falhou trocar para " + facing + " — " + e.name);
}
}
bFrontal.addEventListener("click", (ev) => { ev.stopPropagation(); trocarPara("user"); });
bTraseira.addEventListener("click", (ev) => { ev.stopPropagation(); trocarPara("environment"); });
bDuas.addEventListener("click", (ev) => { ev.stopPropagation(); alternarDuas(); });
/*
* "Só sensor": esconde a imagem e continua detectando o movimento. Serve para
* deixar a câmera trabalhando como sensor sem ocupar a tela — no rosto, quem
* faz isso é a própria carinha.html (vídeo escondido, só os olhos aparecem).
*/
let semImagem = false;
function aplicarSemImagem() {
video.style.visibility = semImagem ? "hidden" : "visible";
bSensor.classList.toggle("on", semImagem);
if (semImagem) {
elErro.style.display = "block";
elErro.textContent = "imagem oculta — a câmera segue ligada, só detectando movimento";
} else if (elErro.textContent.indexOf("imagem oculta") === 0) {
elErro.style.display = "none";
}
console.log("camera: " + (semImagem ? "imagem oculta (so sensor)" : "imagem visivel"));
}
bSensor.addEventListener("click", (ev) => {
ev.stopPropagation();
semImagem = !semImagem;
aplicarSemImagem();
});
/*
* "Ver as duas": abre a OUTRA camera num segundo fluxo e mostra num quadro
* menor. Nao e todo aparelho que deixa duas cameras abertas ao mesmo tempo —
* quando nao deixa, avisamos em vez de fingir que funcionou.
*/
async function alternarDuas() {
if (fluxo2) {
fluxo2.getTracks().forEach((t) => t.stop());
fluxo2 = null;
video2.style.display = "none";
rotulo2.style.display = "none";
bDuas.classList.remove("on");
console.log("camera: duas cameras desligadas");
return;
}
const outro = facingAtual === "user" ? "environment" : "user";
const nomeOutro = outro === "user" ? "frontal" : "traseira";
try {
// resolucao moderada: aumenta a chance de o aparelho aceitar as duas juntas
fluxo2 = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: outro }, width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: false,
});
video2.srcObject = fluxo2;
try { await video2.play(); } catch (e) { console.log("camera: video2 play " + e); }
video2.style.display = "block";
rotulo2.textContent = nomeOutro;
rotulo2.style.display = "block";
bDuas.classList.add("on");
const s = fluxo2.getVideoTracks()[0].getSettings();
console.log("camera: duas cameras — a outra e " + nomeOutro + " " + s.width + "x" + s.height);
} catch (e) {
fluxo2 = null;
bDuas.classList.remove("on");
elErro.style.display = "block";
elErro.textContent = "o aparelho não deixa as duas câmeras ao mesmo tempo (" + e.name + ") — use os botões para alternar";
console.log("camera: duas cameras falhou — " + e.name);
}
}
function redimensionar() {
canvasMarca.width = innerWidth;
canvasMarca.height = innerHeight;
}
/* Traduz coordenada do detector (CW x CH) para a tela, respeitando o "contain". */
function paraTela(cx, cy) {
const r = video.getBoundingClientRect();
const vw = video.videoWidth || CW, vh = video.videoHeight || CH;
const esc = Math.min(r.width / vw, r.height / vh);
const dw = vw * esc, dh = vh * esc;
const ox = r.left + (r.width - dw) / 2, oy = r.top + (r.height - dh) / 2;
return [ox + (cx / CW) * dw, oy + (cy / CH) * dh];
}
function detectar() {
if (!video.videoWidth) return;
ctx.drawImage(video, 0, 0, CW, CH);
const d = ctx.getImageData(0, 0, CW, CH).data;
if (!lumaAnterior) lumaAnterior = new Float32Array(CW * CH);
let mexidos = 0, sx = 0, sy = 0, somaLuz = 0;
for (let i = 0; i < CW * CH; i++) {
const l = d[i * 4] * 0.299 + d[i * 4 + 1] * 0.587 + d[i * 4 + 2] * 0.114;
somaLuz += l;
if (Math.abs(l - lumaAnterior[i]) > LIMIAR) {
mexidos++;
sx += i % CW;
sy += (i / CW) | 0;
}
lumaAnterior[i] = l;
}
// cena escura demais: avisa que a lente provavelmente esta tapada
const brilho = somaLuz / (CW * CH);
if (brilho < 3) { escura++; } else { escura = 0; }
if (escura > 20 && elErro.style.display === "none") {
elErro.style.display = "block";
elErro.textContent = "cena escura (brilho " + Math.round(brilho) +
") — a lente pode estar tapada; levante ou vire o aparelho";
} else if (escura === 0 && elErro.textContent.indexOf("cena escura") === 0) {
elErro.style.display = "none";
}
const vel = Math.min(1, mexidos / (CW * CH * 0.25));
elVel.textContent = Math.round(vel * 100) + "%";
elBar.style.width = Math.round(vel * 100) + "%";
elArea.textContent = Math.round((mexidos / (CW * CH)) * 100) + "%";
ctxMarca.clearRect(0, 0, canvasMarca.width, canvasMarca.height);
if (mexidos > CW * CH * 0.004) {
const cx = sx / mexidos, cy = sy / mexidos;
const [px, py] = paraTela(cx, cy);
elCentro.textContent = "x=" + Math.round(cx / CW * 100) + "% y=" + Math.round(cy / CH * 100) + "%";
ctxMarca.beginPath();
ctxMarca.arc(px, py, Math.max(10, canvasMarca.width * 0.02), 0, Math.PI * 2);
ctxMarca.strokeStyle = "#5CD6FF";
ctxMarca.lineWidth = 3;
ctxMarca.stroke();
ctxMarca.beginPath();
ctxMarca.arc(px, py, 3, 0, Math.PI * 2);
ctxMarca.fillStyle = "#5CD6FF";
ctxMarca.fill();
const agora = performance.now();
if (agora > proxLog) {
proxLog = agora + 1000;
console.log("movimento: " + Math.round(vel * 100) + "% centro x=" +
Math.round(cx / CW * 100) + "% y=" + Math.round(cy / CH * 100) + "%");
}
} else {
elCentro.textContent = "—";
}
}
function laco(t) {
quadros++;
if (t - marco >= 1000) {
elFps.textContent = Math.round(quadros * 1000 / (t - marco));
quadros = 0; marco = t;
}
detectar();
requestAnimationFrame(laco);
}
/* Toque no vídeo alterna entre frontal e traseira (os botões têm prioridade). */
video.addEventListener("click", () => trocarPara(facingAtual === "user" ? "environment" : "user"));
addEventListener("resize", redimensionar);
async function iniciar() {
redimensionar();
marcarBotoes();
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
avisaErro("sem acesso à câmera aqui (contexto inseguro?). Abra esta página pelo app (arquivo local).");
return;
}
await listarCameras();
try {
await abrir(facingAtual); // frontal por padrao
} catch (e) {
avisaErro("não abriu a câmera: " + e.name + " — " + e.message);
console.log("camera: erro " + e.name + " " + e.message);
}
await listarCameras(); // agora com os rótulos reais
requestAnimationFrame(laco);
}
iniciar();
</script>
</body>
</html>