Se o servidor caia no meio da transmissao, o app tentava reabrir a camera ainda presa pelo sensor de movimento; o getUserMedia ficava esperando para sempre e a fila de pedidos do painel travava (video "desligado" ate reiniciar o app). Agora abrir a camera tem prazo de 8 s, cada pedido do painel tem prazo de 12 s e uma camera encerrada de repente desfaz a sessao para a proxima mensagem reabrir. Validado no S10: servidor reiniciado com video ativo volta sozinho em ~8 s. APK 0.8.2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
9.9 KiB
HTML
193 lines
9.9 KiB
HTML
<!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]); }
|
|
/* Nada pode esperar para sempre: se a câmera estiver presa (ex.: servidor caiu no meio e o
|
|
sensor de movimento ainda a segura), desiste e a próxima mensagem do painel tenta de novo. */
|
|
function comPrazo(promessa, ms, oque) {
|
|
return Promise.race([promessa, new Promise((_, rej) => setTimeout(() => rej(new Error(oque + ' demorou mais de ' + ms / 1000 + ' s')), ms))]);
|
|
}
|
|
async function capturar(c) {
|
|
const media = await comPrazo(navigator.mediaDevices.getUserMedia({audio:false,video:{facingMode:{ideal:c.cameraLado || 'user'},width:{ideal:640},height:{ideal:360},frameRate:{ideal:30}}}), 8000, 'Abrir a câmera');
|
|
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();
|
|
// câmera fechada de repente (outro uso, erro do sistema): desfaz tudo; o próximo pacote reabre
|
|
stream.getVideoTracks()[0].onended = () => {
|
|
if (captureGeneration !== generation) return;
|
|
console.log('RTC câmera encerrada; recomeçando no próximo pedido');
|
|
parar(); sid = null;
|
|
};
|
|
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 ? comPrazo(receber(packet), 12000, 'Pedido de vídeo') : 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(); sid = null;
|
|
});
|
|
};
|
|
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>
|