AulaMeet foi construido do zero por um unico programador brasileiro. Aqui voce ve quem fez, como cada modulo funciona por dentro, as decisoes tecnicas e os codigos reais que fazem tudo isso rodar.
Cada modulo foi construido do zero, sem frameworks pesados. PHP puro, JavaScript vanilla, MySQL e muita logica bem pensada.
// Iniciar sala de aula ao vivo
if($acao === 'iniciar_sala'){
$curso_id = intval($_POST['curso_id']);
$codigo = bin2hex(random_bytes(8)); // codigo unico da sala
// Criar sala no banco com status 'ao_vivo'
$s = $conn->prepare("INSERT INTO am_salas
(tenant_slug, curso_id, codigo, status, professor_id, iniciada_em)
VALUES (?, ?, ?, 'ao_vivo', ?, NOW())");
$s->bind_param("ssis", $ue, $curso_id, $codigo, $am_uid);
$s->execute();
$sala_id = $conn->insert_id;
// Registrar presenca do professor
$sessao = session_id();
$conn->prepare("INSERT INTO am_sala_presenca
(sala_id, tenant_slug, usuario_id, usuario_nome,
usuario_tipo, sessao_id, ultimo_ping)
VALUES (?,?,?,?,'professor',?,NOW())")
->execute([$sala_id,$ue,$am_uid,$am_nome,$sessao]);
resp(['ok'=>true, 'sala_id'=>$sala_id, 'codigo'=>$codigo]);
}
// Ping de presenca — chamado a cada 5 segundos pelo cliente
if($acao === 'ping'){
$sessao = session_id();
$conn->query("UPDATE am_sala_presenca
SET ultimo_ping=NOW()
WHERE sessao_id='$sessao'");
// Detectar participantes que saíram (sem ping ha mais de 15s)
$conn->query("DELETE FROM am_sala_presenca
WHERE sala_id=$sala_id
AND ultimo_ping < DATE_SUB(NOW(), INTERVAL 15 SECOND)");
// Retornar lista atualizada de participantes
$participantes = $conn->query("SELECT usuario_nome, usuario_tipo,
mic_ativo, cam_ativo FROM am_sala_presenca
WHERE sala_id=$sala_id")->fetch_all(MYSQLI_ASSOC);
resp(['ok'=>true, 'participantes'=>$participantes]);
}
// Enviar sinal WebRTC (offer, answer ou ICE candidate)
if($acao === 'sinal_webrtc'){
$de_sessao = session_id();
$para_sessao = $_POST['para_sessao'];
$tipo = $_POST['tipo']; // 'offer' | 'answer' | 'candidate'
$payload = $_POST['payload']; // JSON com SDP ou ICE candidate
$s = $conn->prepare("INSERT INTO am_sala_webrtc
(sala_id, tenant_slug, de_sessao, para_sessao, tipo, payload)
VALUES (?,?,?,?,?,?)");
$s->bind_param("isssss",
$sala_id, $ue, $de_sessao, $para_sessao, $tipo, $payload);
$s->execute();
resp(['ok'=>true]);
}
// Buscar sinais pendentes (chamado a cada 2 segundos pelo JS)
if($acao === 'buscar_sinais'){
$sessao = session_id();
$s = $conn->prepare("SELECT id, de_sessao, tipo, payload
FROM am_sala_webrtc
WHERE para_sessao=? AND lido=0
ORDER BY id ASC LIMIT 50");
$s->bind_param("s", $sessao);
$s->execute();
$sinais = $s->get_result()->fetch_all(MYSQLI_ASSOC);
// Marcar como lidos para nao repetir
if(!empty($sinais)){
$ids = implode(',', array_column($sinais, 'id'));
$conn->query("UPDATE am_sala_webrtc SET lido=1 WHERE id IN ($ids)");
}
resp(['ok'=>true, 'sinais'=>$sinais]);
}
// Configuracao dos servidores STUN (para atravessar NAT/firewall)
var ICE_SERVERS = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
};
// Criar conexao com um participante especifico
function criarConexao(sessao_remota) {
var pc = new RTCPeerConnection(ICE_SERVERS);
conexoes[sessao_remota] = pc;
// Adicionar stream local (camera + microfone)
streamLocal.getTracks().forEach(function(track){
pc.addTrack(track, streamLocal);
});
// Quando receber stream remoto, exibir no video tile
pc.ontrack = function(event){
exibirVideoParticipante(sessao_remota, event.streams[0]);
};
// Quando gerar ICE candidate, enviar para o outro lado via banco
pc.onicecandidate = function(event){
if(event.candidate){
enviarSinal(sessao_remota, 'candidate',
JSON.stringify(event.candidate));
}
};
return pc;
}
// Processar sinais recebidos do banco (polling a cada 2s)
async function processarSinais(sinais){
for(var sinal of sinais){
var pc = conexoes[sinal.de_sessao]
|| criarConexao(sinal.de_sessao);
var payload = JSON.parse(sinal.payload);
if(sinal.tipo === 'offer'){
await pc.setRemoteDescription(payload);
var answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
enviarSinal(sinal.de_sessao, 'answer',
JSON.stringify(answer));
}
else if(sinal.tipo === 'answer'){
await pc.setRemoteDescription(payload);
}
else if(sinal.tipo === 'candidate'){
await pc.addIceCandidate(payload);
}
}
}
var canvas = document.getElementById('canvas-lousa');
var ctx = canvas.getContext('2d');
var desenhando = false;
// Pointer Events captura mouse, dedo, stylus e mesa digitalizadora
canvas.addEventListener('pointerdown', function(e){
e.preventDefault();
desenhando = true;
ctx.beginPath();
var pos = getPos(e);
ctx.moveTo(pos.x, pos.y);
// Suporte a pressao do stylus (0.0 a 1.0)
// Mesa Wacom, Apple Pencil, Surface Pen
var pressao = e.pressure || 0.5;
ctx.lineWidth = pressao * espessura_atual * 2;
ctx.strokeStyle = cor_atual;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
});
canvas.addEventListener('pointermove', function(e){
if(!desenhando) return;
e.preventDefault();
var pos = getPos(e);
// Ajustar espessura dinamicamente pela pressao
if(e.pressure > 0){
ctx.lineWidth = e.pressure * espessura_atual * 2;
}
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
// Transmitir traco para todos os participantes
transmitirTraco({
x: pos.x / canvas.width, // normalizar para 0-1
y: pos.y / canvas.height, // independente de resolucao
pressao: e.pressure,
cor: cor_atual,
espessura: espessura_atual,
tipo: 'move'
});
});
// Receber tracos de outros participantes
function receberTraco(dados){
var x = dados.x * canvas.width;
var y = dados.y * canvas.height;
ctx.strokeStyle = dados.cor;
ctx.lineWidth = dados.pressao * dados.espessura * 2;
if(dados.tipo === 'start') ctx.beginPath(), ctx.moveTo(x,y);
else { ctx.lineTo(x,y); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x,y); }
}
var mediaRecorder, chunks = [];
// Iniciar gravacao ao comecar a aula
function iniciarGravacao(stream){
var opcoes = { mimeType: 'video/webm;codecs=vp9,opus' };
if(!MediaRecorder.isTypeSupported(opcoes.mimeType)){
opcoes = { mimeType: 'video/webm' }; // fallback
}
mediaRecorder = new MediaRecorder(stream, opcoes);
// Coletar chunks a cada 5 segundos
mediaRecorder.ondataavailable = function(e){
if(e.data.size > 0) chunks.push(e.data);
};
// Ao parar, montar o blob e enviar
mediaRecorder.onstop = function(){
var blob = new Blob(chunks, { type: 'video/webm' });
enviarGravacao(blob);
chunks = [];
};
mediaRecorder.start(5000); // chunk a cada 5s
}
// Enviar gravacao para o servidor (que salva no R2)
async function enviarGravacao(blob){
var fd = new FormData();
fd.append('gravacao', blob, 'aula_' + Date.now() + '.webm');
fd.append('sala_id', SALA_ID);
fd.append('curso_id', CURSO_ID);
var r = await fetch('/aula-ao-vivo/ajax-aula.php?acao=salvar_gravacao',
{ method: 'POST', body: fd });
var d = await r.json();
if(d.ok) console.log('Gravacao salva:', d.url);
}
// Extrair texto limpo dos resumos HTML
foreach($resumos as $res){
$html = $res['conteudo_html'];
// Converter headings em texto estruturado
$html = preg_replace('/]*>(.*?)<\/h2>/i', "\n\n## $1\n", $html);
$html = preg_replace('/]*>(.*?)<\/h3>/i', "\n\n### $1\n", $html);
$html = preg_replace('/
]*>/i', "\n- ", $html);
$html = preg_replace('/
/i', "\n", $html);
$t = strip_tags($html);
$t = html_entity_decode($t, ENT_QUOTES|ENT_HTML5, 'UTF-8');
$t = preg_replace('/[ \t]+/', ' ', $t);
$texto .= "\n\n=== " . $res['titulo'] . " ===\n" . trim($t);
}
$texto = mb_substr(trim($texto), 0, 9000); // limite de tokens
// Prompt com regras absolutas contra alucinacao
$prompt = "Voce e um organizador de conteudo didatico.
Sua unica funcao e transformar o texto abaixo em questoes.
REGRAS ABSOLUTAS — NUNCA VIOLE:
1. Use EXCLUSIVAMENTE as informacoes presentes no texto.
ZERO conhecimento externo.
2. Nao invente, nao complete, nao infira nada que nao
esteja explicitamente escrito no texto.
3. Para V/F: so afirme como Verdadeiro o que o texto
confirma explicitamente.
4. Gere exatamente $qtd questoes.
5. Retorne SOMENTE o array JSON, sem texto antes ou depois.
FORMATO JSON:
[
{\"tipo\":\"multipla\",\"questao\":\"...\",
\"resposta_correta\":\"texto da opcao correta\",
\"opcao_b\":\"...\",\"opcao_c\":\"...\",\"opcao_d\":\"...\"},
{\"tipo\":\"vf\",\"questao\":\"...\",
\"resposta_correta\":\"Verdadeiro\",
\"opcao_b\":\"Falso\",\"opcao_c\":null,\"opcao_d\":null}
]
TEXTO DO RESUMO (use SOMENTE este conteudo):
$texto";
// Chamar Groq com temperatura baixa
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'model' => 'llama-3.3-70b-versatile',
'messages' => [['role'=>'user','content'=>$prompt]],
'temperature' => 0.3, // baixo = menos criativo = menos erro
'max_tokens' => 5000
], JSON_UNESCAPED_UNICODE),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $groq_key
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 90,
]);
$resp = curl_exec($ch);
$dados = json_decode($resp, true);
$conteudo = $dados['choices'][0]['message']['content'];
// Limpar markdown e extrair JSON
$conteudo = preg_replace('/^```(?:json)?\s*/i', '', trim($conteudo));
$conteudo = preg_replace('/\s*```$/', '', trim($conteudo));
if(preg_match('/(\[[\s\S]*\])/s', $conteudo, $m)) $conteudo = $m[1];
// Validar cada questao antes de retornar
$questoes_raw = json_decode($conteudo, true);
foreach($questoes_raw as $q){
// Descartar questoes incompletas
if(empty($q['questao']) || empty($q['resposta_correta'])) continue;
$questoes[] = $q;
}
/contrato-view.php?modo=validar.
// Preencher variaveis dinamicas do contrato
$contrato = str_replace([
'{{nome_aluno}}', '{{cpf_aluno}}',
'{{endereco_aluno}}','{{telefone_aluno}}',
'{{nome_turma}}', '{{nome_curso}}',
'{{valor_parcela}}', '{{total_parcelas}}',
'{{data_matricula}}','{{data_fim_contrato}}'
], [
htmlspecialchars($nome), htmlspecialchars($cpf_fmt),
htmlspecialchars($end), htmlspecialchars($tel),
htmlspecialchars($turma['nome']),
htmlspecialchars($turma['curso_nome']),
$valor_fmt, $total_parc,
date('d/m/Y'), $data_fim
], $turma['contrato_template']);
// Hash SHA-256 do conteudo + dados do aluno + timestamp
// Garante que o contrato nao pode ser alterado apos assinatura
$hash = hash('sha256',
$contrato . $nome . $cpf . $end . $tel . date('Y-m-d H:i:s')
);
// Token unico para o aluno acessar seu painel
$token_aluno = bin2hex(random_bytes(32));
// Salvar tudo — selfie e assinatura em base64 no banco
// (para contratos pequenos; grandes vao para storage)
$sql = "INSERT INTO am_matriculas_ext
(tenant_slug, turma_id, token_aluno, nome, cpf,
endereco, telefone, email, ip_matricula, user_agent,
selfie_base64, assinatura_base64,
contrato_html, contrato_hash, status_aprovacao)
VALUES (...)";
$conn->query($sql);
// Gerar parcelas automaticamente
gerarParcelas($conn, $tenant, $matricula_id, $turma);
// Notificar professor por email
mail($prof['email'],
"Nova matricula aguardando aprovacao — $nome",
"Aluno: $nome\nCPF: $cpf_fmt\nTurma: {$turma['nome']}"
);
// Acessar camera frontal do dispositivo
async function iniciarCamera(){
try {
_stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user' }, // camera frontal
audio: false
});
video.srcObject = _stream;
} catch(e){
// Fallback: mostrar botao de upload de arquivo
mostrarUpload();
}
}
// Capturar frame do video como imagem base64
function tirarSelfie(){
var canvas = document.getElementById('canvas-selfie');
var ctx = canvas.getContext('2d');
canvas.width = video.videoWidth || 400;
canvas.height = video.videoHeight || 300;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Converter para JPEG base64 (qualidade 80%)
_selfieB64 = canvas.toDataURL('image/jpeg', 0.8);
}
// Assinatura digital com Canvas
canvas.addEventListener('touchmove', function(e){
e.preventDefault(); // evitar scroll durante assinatura
var touch = e.touches[0];
var rect = canvas.getBoundingClientRect();
var x = (touch.clientX - rect.left) * (canvas.width / rect.width);
var y = (touch.clientY - rect.top) * (canvas.height / rect.height);
ctx.lineTo(x, y);
ctx.stroke();
_assinHasData = true;
}, { passive: false });
function gerarParcelas($conn, $tenant, $matricula_id, $turma){
$modalidade = $turma['fin_modalidade']; // mensal|anual|diaria
$duracao = intval($turma['fin_duracao'] ?? 1);
$valor = floatval($turma['fin_valor'] ?? 0);
$vencimento = $turma['fin_vencimento']; // dia_matricula|dia_fixo
$dia_fixo = intval($turma['fin_dia_fixo'] ?? 1);
$inicio = $turma['fin_inicio']; // imediato|proximo_mes
// Anual = 1 parcela; outros = duracao parcelas
$n = ($modalidade === 'anual') ? 1 : $duracao;
$data_mat = new DateTime();
for($i = 1; $i <= $n; $i++){
$dt = clone $data_mat;
if($vencimento === 'dia_matricula'){
// Vencimento no mesmo dia, mes seguinte
if($modalidade === 'diaria')
$dt->modify('+' . ($i-1) . ' days');
else
$dt->modify('+' . ($i-1) . ' months');
} else {
// Vencimento em dia fixo do mes
if($inicio === 'proximo_mes') $dt->modify('+1 month');
$dt->modify('+' . ($i-1) . ' months');
// Limitar a 28 para evitar problemas em fevereiro
$dt->setDate(
(int)$dt->format('Y'),
(int)$dt->format('m'),
min($dia_fixo, 28)
);
}
$dv = $dt->format('Y-m-d');
$ins = $conn->prepare("INSERT INTO am_parcelas
(tenant_slug, matricula_id, turma_id,
numero_parcela, valor, data_vencimento)
VALUES (?,?,?,?,?,?)");
$ins->bind_param("siiids",
$tenant, $matricula_id, $turma['id'], $i, $valor, $dv);
$ins->execute();
}
}
// Detectar inadimplencia (chamado ao listar parcelas)
$hoje = date('Y-m-d');
$conn->query("UPDATE am_parcelas
SET status = 'atrasado'
WHERE status = 'pendente'
AND data_vencimento < '$hoje'
AND matricula_id = $matricula_id");
tenant_slug que identifica a qual escola/professor aquele registro pertence. Toda query filtra pelo slug da sessao logada.
tenant_slug e sempre validado na sessao PHP antes de qualquer operacao. Um professor nao consegue ver dados de outro professor — o sistema filtra automaticamente em todas as queries.
am_sala_webrtc funciona como fila de mensagens para a sinalizacao WebRTC. Cada participante faz polling a cada 2 segundos buscando sinais pendentes para sua sessao.
-- Tenants (cada escola/professor e um tenant)
CREATE TABLE am_tenants (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(100) NOT NULL UNIQUE, -- ex: "fontelis"
nome VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
plano ENUM('inicial','turma','escola','academia','pro'),
template VARCHAR(50) DEFAULT 'netflix',
cor VARCHAR(20) DEFAULT '#CC0000',
ativo TINYINT(1) DEFAULT 1
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Cursos — cada um tem um slug publico (aulameet.com/{slug})
CREATE TABLE am_cursos (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_slug VARCHAR(100) NOT NULL, -- isolamento por escola
nome VARCHAR(500) NOT NULL,
slug VARCHAR(100) NULL UNIQUE, -- URL publica
tipo ENUM('curso','mentoria') DEFAULT 'curso',
status ENUM('rascunho','ativo','arquivado'),
INDEX idx_tenant (tenant_slug),
INDEX idx_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Sinalizacao WebRTC (fila de mensagens para peer-to-peer)
CREATE TABLE am_sala_webrtc (
id INT AUTO_INCREMENT PRIMARY KEY,
sala_id INT NOT NULL,
de_sessao VARCHAR(64) NOT NULL,
para_sessao VARCHAR(64) NOT NULL,
tipo VARCHAR(30) NOT NULL, -- offer|answer|candidate
payload MEDIUMTEXT NOT NULL, -- JSON com SDP ou ICE
lido TINYINT(1) DEFAULT 0,
criado_em DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_para (para_sessao, lido), -- index para polling rapido
INDEX idx_criado (criado_em) -- para limpeza automatica
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Matriculas com contrato digital completo
CREATE TABLE am_matriculas_ext (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_slug VARCHAR(100) NOT NULL,
turma_id INT NOT NULL,
token_aluno VARCHAR(64) NOT NULL UNIQUE,
nome VARCHAR(255) NOT NULL,
cpf VARCHAR(14) NOT NULL,
selfie_base64 LONGTEXT DEFAULT NULL, -- foto do aluno
assinatura_base64 LONGTEXT DEFAULT NULL, -- assinatura canvas
contrato_html LONGTEXT DEFAULT NULL, -- contrato preenchido
contrato_hash VARCHAR(64) DEFAULT NULL, -- SHA-256 do contrato
ip_matricula VARCHAR(45) DEFAULT NULL,
status_aprovacao ENUM('pendente','aprovado','rejeitado'),
INDEX idx_tenant (tenant_slug),
INDEX idx_turma (turma_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
.htaccess captura qualquer requisicao de subdominio e redireciona para um unico arquivo PHP que identifica o tenant pelo HOST da requisicao e renderiza o template correto (Netflix, Moderno, Elegante, Bold ou Glassmorphism).
$tenant, $cursos e $professor e renderiza a pagina completa. O professor pode personalizar cores e conteudo pelo editor visual.
# Subdominios dinamicos: meupreparatorio.aulameet.com
# Captura qualquer subdominio e envia para subdominio.php
RewriteCond %{HTTP_HOST} ^(?!www\.)([a-z0-9\-]+)\.aulameet\.com$ [NC]
RewriteCond %{REQUEST_URI} !^/acesso/subdominio\.php
RewriteCond %{REQUEST_URI} !^/uploads/
RewriteRule ^(.*)$ /acesso/subdominio.php [QSA,L]
# Paginas publicas de cursos: aulameet.com/{slug}
# So roteia se nao for arquivo ou pasta real
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^([a-z0-9][a-z0-9\-]{1,60})/?$ /curso-publico.php?slug=$1 [QSA,L]
// Extrair o slug do subdominio a partir do HOST
// Ex: "fontelis.aulameet.com" -> slug = "fontelis"
preg_match('/^([a-z0-9\-]+)\.aulameet\.com$/i',
$_SERVER['HTTP_HOST'], $m);
$slug = $m[1] ?? '';
// Buscar tenant no banco pelo slug
$s = $conn->prepare("SELECT * FROM am_tenants
WHERE slug=? AND ativo=1 LIMIT 1");
$s->bind_param("s", $slug);
$s->execute();
$tenant = $s->get_result()->fetch_assoc();
if(!$tenant){ http_response_code(404); die('Nao encontrado'); }
// Buscar cursos ativos do tenant
$cursos = buscarCursosAtivos($tenant['slug'], $conn);
// Carregar template escolhido pelo professor
$template = $tenant['template'] ?? 'netflix';
$tpl = __DIR__ . '/templates/template-' . $template . '.php';
include $tpl; // renderiza a pagina completa
// Verificar se etapa esta desbloqueada para o aluno
function etapa_desbloqueada($etapa_id, $usuario_id, $conn){
$s = $conn->prepare("SELECT ordem, turma_id FROM am_etapas
WHERE id=? LIMIT 1");
$s->bind_param("i", $etapa_id);
$s->execute();
$etapa = $s->get_result()->fetch_assoc();
// Primeira etapa sempre disponivel
if($etapa['ordem'] <= 1) return true;
// Verificar aprovacao na etapa anterior
$s = $conn->prepare("SELECT id FROM am_etapa_resultados
WHERE usuario_id=? AND turma_id=? AND etapa_ordem=?
AND aprovado=1 LIMIT 1");
$s->bind_param("iii",
$usuario_id, $etapa['turma_id'], $etapa['ordem'] - 1);
$s->execute();
return $s->get_result()->num_rows > 0;
}
// Calcular nota e registrar resultado
if($acao === 'finalizar_etapa'){
$respostas = $_POST['respostas']; // array [questao_id => resposta]
$total = count($questoes);
$acertos = 0;
foreach($questoes as $q){
$resp_aluno = $respostas[$q['id']] ?? '';
if($resp_aluno === $q['resposta_correta']) $acertos++;
}
$nota = ($total > 0) ? round(($acertos / $total) * 10, 1) : 0;
$aprovado = ($nota >= $etapa['nota_minima']) ? 1 : 0;
// Salvar resultado (pode ter multiplas tentativas)
$conn->prepare("INSERT INTO am_etapa_resultados
(usuario_id, etapa_id, turma_id, etapa_ordem,
nota, acertos, total, aprovado)
VALUES (?,?,?,?,?,?,?,?)")
->execute([$uid,$etapa_id,$turma_id,
$etapa['ordem'],$nota,$acertos,$total,$aprovado]);
resp(['ok'=>true, 'nota'=>$nota, 'aprovado'=>$aprovado,
'acertos'=>$acertos, 'total'=>$total]);
}
Por que o sistema funciona sem depender de servicos caros e como cada problema foi resolvido de forma simples.
A videoconferencia usa conexao peer-to-peer direta. A sinalizacao e feita via banco MySQL com polling a cada 2 segundos. Sem TURN server proprio — usa servidores STUN publicos do Google. Funciona para a maioria das redes sem custo adicional.
O segredo e o prompt: a IA recebe o texto do resumo e instrucoes explicitas para nao usar conhecimento externo. Temperatura 0.3 reduz criatividade e aumenta fidelidade. O JSON retornado e validado campo a campo antes de exibir.
O Canvas HTML5 com Pointer Events API captura pressao, inclinacao e posicao de stylus nativamente. Funciona com qualquer tablet Wacom, iPad com Apple Pencil ou mesa digitalizadora sem instalar drivers ou plugins.
O contrato captura: dados pessoais, IP de origem, user-agent, selfie biometrica, assinatura manuscrita digitalizada e hash SHA-256 do conteudo. Tudo armazenado e verificavel publicamente via URL de validacao.
Ao criar um curso, a API da Hostinger e chamada para criar o subdominio. O .htaccess captura qualquer requisicao de subdominio e redireciona para um unico PHP que identifica o tenant pelo HOST e renderiza o template.
Em vez de criar um banco por cliente, todos os dados ficam no mesmo banco MySQL separados por tenant_slug. Cada query filtra pelo slug da sessao. Simples, eficiente e escalavel sem custo adicional de infraestrutura.
O AulaMeet e desenvolvido e mantido por uma unica pessoa, no tempo livre, sem financiamento externo. Se o sistema te ajudou, considere fazer uma doacao para manter tudo funcionando e gratuito para todos.