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,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>
|
||||
Reference in New Issue
Block a user