diff --git a/code/engine/skills/boas-praticas-oo.md b/.agents/skills/boas-praticas-oo/SKILL.md
similarity index 100%
rename from code/engine/skills/boas-praticas-oo.md
rename to .agents/skills/boas-praticas-oo/SKILL.md
diff --git a/.jhonny/analises.db b/.jhonny/analises.db
new file mode 100644
index 0000000..6ffc55b
Binary files /dev/null and b/.jhonny/analises.db differ
diff --git a/.jhonny/analises.db-shm b/.jhonny/analises.db-shm
new file mode 100644
index 0000000..fe9ac28
Binary files /dev/null and b/.jhonny/analises.db-shm differ
diff --git a/.jhonny/analises.db-wal b/.jhonny/analises.db-wal
new file mode 100644
index 0000000..e69de29
diff --git a/.retakes/retakes-12.json b/.retakes/retakes-12.json
new file mode 100644
index 0000000..a6d6f1f
--- /dev/null
+++ b/.retakes/retakes-12.json
@@ -0,0 +1,71 @@
+{
+ "video_id": "12",
+ "versao_transcricao": "1",
+ "versao_regras": "regras:1.1",
+ "versao_modelo": "lexico",
+ "_hash": "ad361d6e7dc4",
+ "grupos_de_retakes": [
+ {
+ "id": "retake_421fa7",
+ "video_id": "12",
+ "faixa_id": "1",
+ "tipo": "retake_confirmado",
+ "confianca": 0.95,
+ "tomadas": [
+ {
+ "ordem": 0,
+ "fala_id": "101",
+ "segmento_id": "c1",
+ "inicio": 0.0,
+ "fim": 6.0,
+ "texto": "Hoje nós vamos mostrar como funciona o sistema.",
+ "similaridade_com_anterior": 0.0,
+ "confianca": 0.95
+ },
+ {
+ "ordem": 1,
+ "fala_id": "102",
+ "segmento_id": "c1",
+ "inicio": 9.0,
+ "fim": 15.0,
+ "texto": "Hoje nós vamos mostrar como funciona o sistema.",
+ "similaridade_com_anterior": 1.0,
+ "confianca": 0.95
+ }
+ ],
+ "evidencias": [
+ {
+ "tipo": "similaridade_textual",
+ "descricao": "As duas falas possuem 100% de palavras em comum e 100% de similaridade textual geral.",
+ "valor": 1.0
+ },
+ {
+ "tipo": "repeticao_do_inicio",
+ "descricao": "As duas falas começam com as mesmas palavras.",
+ "valor": 1.0
+ },
+ {
+ "tipo": "proximidade_temporal",
+ "descricao": "A segunda tentativa começa 3.0 segundos após a primeira.",
+ "valor": 0.75
+ },
+ {
+ "tipo": "reinicio",
+ "descricao": "pausa de 3.0 segundos",
+ "valor": 1.0
+ },
+ {
+ "tipo": "reinicio",
+ "descricao": "repetição das primeiras 8 palavras",
+ "valor": 1.0
+ },
+ {
+ "tipo": "classificacao",
+ "descricao": "É um retake provável. Confiança de 95%.",
+ "valor": 0.95
+ }
+ ],
+ "criado_em": "2026-09-08T20:37:58.545824+00:00"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 435323b..abd3859 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,6 +2,21 @@
## Project Skills
+## Convenções obrigatórias de Python / OO
+
+Todo código Python em `code/engine/` deve seguir a skill **`code/engine/skills/boas-praticas-oo.md`** (boas práticas de programação orientada a objetos). Leia-a antes de criar, modificar, corrigir, revisar, refatorar ou ampliar qualquer sistema Python.
+
+Resumo das regras vinculantes:
+
+- **PT-BR obrigatório** em todos os identificadores internos (classes, métodos, variáveis, atributos, parâmetros, constantes, arquivos, pastas, módulos), docstrings, comentários, logs, mensagens de erro e documentação. Identificadores externos (bibliotecas, APIs, contratos) são preservados e isolados em adaptadores/integrações.
+- **Nomenclatura**: classes `PascalCase`; métodos, funções, variáveis e arquivos `snake_case`; constantes `MAIÚSCULAS_COM_SUBLINHADO`. Evitar nomes genéricos (`Util`, `Helper`, `Manager`, `Data`).
+- **Documentação obrigatória**: docstring no topo de todo módulo, classe e método público, mantida coerente com o comportamento real (documentação viva).
+- **Arquitetura**: responsabilidade única, alta coesão, baixo acoplamento, composição antes de herança, herança só com especialização real, encapsulamento, interfaces pequenas, injeção de dependências, métodos pequenos e objetivos.
+- **Domínio**: regras de negócio ficam no domínio/aplicação, nunca misturadas com interface, SQL, HTTP ou apresentação. Integrações externas isoladas em módulos próprios.
+- **Erros e segurança**: tratar erros com exceções específicas (nunca `except Exception: pass`), validar toda entrada externa, não registrar credenciais, não duplicar regras, retornos previsíveis.
+- **Testes**: toda funcionalidade relevante deve ter testes (unitário, integração, regressão); criar/atualizar teste ao corrigir bug.
+- Seguir o **checklist obrigatório** (seção 41 da skill) antes de finalizar qualquer implementação.
+
## Interface do painel
Quando o usuário pedir uma implementação “na tela”, “no painel” ou “na interface do MCP”, use o painel CEP em `code/cep-plugin/`, identificado por **MCP for Adobe Premiere Pro** e pelas abas **Editar vídeo**, **Scanner**, **Conexão**, **Modelos** e **Ajustes**. Essa é a interface de produto; não criar fluxos de interface alternativos em `code/uxp-plugin/`.
diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md
new file mode 100644
index 0000000..a3e9a82
--- /dev/null
+++ b/CODING_STANDARDS.md
@@ -0,0 +1,68 @@
+# Padrões de código
+
+Este documento consolida as convenções obrigatórias de código do projeto. Serve de fonte de verdade para revisões automáticas (eixo *Standards* do `/code-review`) e vale para qualquer agente que escreva ou altere código.
+
+A fonte completa das regras de orientação a objeto é a skill **`code/engine/skills/boas-praticas-oo.md`**. O que está abaixo é o resumo normativo; em caso de dúvida, leia a skill por inteiro.
+
+## Escopo
+
+As regras de PT-BR e OO aplicam-se a todo **Python em `code/engine/`**. Outras linguagens seguem as convenções existentes no repositório, mas os princípios de design (responsabilidade única, baixo acoplamento, injeção de dependência, módulos profundos) são universais.
+
+## Idioma — PT-BR obrigatório
+
+Todos os identificadores internos usam português brasileiro:
+
+- Classes, métodos, funções, variáveis, atributos, parâmetros, constantes, arquivos, pastas e módulos.
+- Docstrings, comentários, logs, mensagens de erro e documentação.
+- Nomes de testes, fixtures e exemplos.
+
+Identificadores **externos** (bibliotecas, APIs, endpoints, campos de contrato, variáveis de ambiente de terceiros, protocolos) são preservados no idioma oficial **somente quando exigidos**, e isolados em adaptadores/conversores/clientes/repositórios. Não espalhar nomes externos pelo sistema; converter na fronteira para o modelo interno em PT-BR e documentar a exceção.
+
+## Nomenclatura
+
+- Classes: `PascalCase` — ex. `GerenciadorDeArquivos`.
+- Métodos, funções, variáveis, atributos, arquivos e pastas: `snake_case` — ex. `carregar_arquivo`, `nome_do_usuario`.
+- Constantes: `MAIÚSCULAS_COM_SUBLINHADO` — ex. `TEMPO_LIMITE_DA_OPERACAO`.
+- Evitar nomes genéricos: `Util`, `Helper`, `Manager`, `Processor`, `Data`, `Object`, `Thing`, `Temp`.
+
+## Documentação
+
+- Todo **módulo** tem docstring no topo: finalidade, problema que resolve, responsabilidade, o que não faz, componentes e dependências principais.
+- Toda **classe** tem docstring: o que representa, responsabilidade, dependências, o que não deve fazer, efeitos colaterais quando aplicável.
+- Todo **método/função público** tem docstring: o que faz, dados que recebe, retorno, erros possíveis, se altera estado, se realiza operações externas.
+- **Documentação viva**: docstring/comentário deve descrever o comportamento atual. Proibido manter documentação de comportamento que não existe mais.
+
+## Design / orientação a objeto
+
+- **Responsabilidade única**: cada classe/função/módulo tem uma responsabilidade principal.
+- **Alta coesão**: métodos da classe relacionam-se à sua responsabilidade.
+- **Baixo acoplamento**: depender do mínimo; receber dependências, não criá-las internamente.
+- **Composição antes de herança**; herança apenas com relação real de "é um".
+- **Encapsulamento**: dados e regras protegidos, alterados por operações apropriadas.
+- **Interfaces pequenas** e **injeção de dependências** explícita.
+- **Métodos pequenos e objetivos**; retornos previsíveis e consistentes.
+- **Módulos profundos**: muita funcionalidade atrás de interface pequena (ver skill `codebase-design`).
+
+## Domínio e integrações
+
+- Regras de negócio ficam no **domínio/aplicação**, nunca na interface, em controladores gigantes, scripts, SQL, HTTP ou camada de apresentação.
+- Integrações externas (APIs, banco, arquivos, serviços) isoladas em módulos próprios; o resto do sistema usa contratos/classes internas.
+- Não duplicar regras: centralizar em classe, função, validador, objeto de valor ou constante.
+- Evitar dependências circulares; resolver com contratos, módulo comum, inversão ou injeção de dependência.
+
+## Erros e segurança
+
+- Tratar erros com **exceções específicas**; nunca `except Exception: pass`.
+- Validar toda entrada externa (tipo, formato, limites, nulos, estrutura) antes de usar.
+- Não registrar senhas, tokens, chaves ou dados sensíveis em logs.
+- Configurações em componentes próprios, não espalhadas no código.
+
+## Testes
+
+- Toda funcionalidade relevante tem testes (unitário, integração, regressão).
+- Cobrir: entradas válidas/inválidas, casos vazios/extremos, erros, retornos, efeitos colaterais, regras de negócio.
+- Ao corrigir bug, criar/atualizar teste que impeça regressão.
+
+## Checklist antes de finalizar
+
+Antes de dar como concluída qualquer implementação Python em `code/engine/`, percorrer a seção 41 da skill `boas-praticas-oo.md`: requisitos, arquitetura, código, documentação e testes.
\ No newline at end of file
diff --git a/code/cep-plugin/index.html b/code/cep-plugin/index.html
index 78a5b9b..7eefdc1 100755
--- a/code/cep-plugin/index.html
+++ b/code/cep-plugin/index.html
@@ -28,6 +28,9 @@
+
@@ -226,9 +229,19 @@
Escolha o que será lido no arquivo original. O Scanner usa os tempos da timeline, mas não exporta frames pelo Premiere.
Amostragem e faixas
-
-
-
Use a amostragem precisa somente no trecho que será reenquadrado.
+
+
+
+ 1 s/quadro
+
+
Quanto menor, mais frames e mais detalhado (e mais lento). Valores maiores aceleram a análise, principalmente em clipes curtos.
+
+
+
Reduzir a resolução acelera a extração e a leitura Vision sem perder precisão no rosto, pose ou objetos.
Abra a sequência desejada e detecte as faixas para selecioná-las.
Faixas de vídeo
Faixas de áudio
@@ -258,6 +271,68 @@
Relatório pronto
+
+
+
Identifica repetições e tentativas alternativas usando a transcrição do Scanner. O painel nunca corta o vídeo: você revisa e decide.
+
+
+
+
Arquivo analisado
—
+
+
+
+
+
+
Selecione um caso já processado pelo Scanner.
+
+
+
+
+
Critérios
+
+
+
+
A comparação textual, os reinícios de fala e as pausas já fazem parte da análise da engine.
+
+
Análise visual auxiliar
+
+
Reforça a confiança, mas nunca decide sozinha. Reutiliza as cenas já analisadas.
+
+
+
+
+
+
+
+
+ Analisando retakes…
+
—
+
+
+
+
+
Resultados
—
+
+
+
+
Nenhum retake identificado com os critérios atuais.
+
+
+
+
+
diff --git a/code/cep-plugin/main.js b/code/cep-plugin/main.js
index deb2799..83cf5a1 100755
--- a/code/cep-plugin/main.js
+++ b/code/cep-plugin/main.js
@@ -149,6 +149,7 @@ function switchTab(name) {
var tabs = [
{ key: "silence", panel: "tabSilence", btn: "tabBtnSilence" },
{ key: "scanner", panel: "tabScanner", btn: "tabBtnScanner" },
+ { key: "retakes", panel: "tabRetakes", btn: "tabBtnRetakes" },
{ key: "bridge", panel: "tabBridge", btn: "tabBtnBridge" },
{ key: "models", panel: "tabModels", btn: "tabBtnModels" },
{ key: "settings", panel: "tabSettings", btn: "tabBtnSettings" },
@@ -166,6 +167,190 @@ function switchTab(name) {
if (name === "silence") { syncTranscribeModelSelect(); syncLanguageInfo(); }
if (name === "settings") renderEditorPersonalities();
if (name === "scanner") { renderScannerTranscriptionOptions(); loadScannerProfile(); renderScannerHuggingFaceStatus(); }
+ if (name === "retakes") retakesAoAbrir();
+}
+
+// ---- Retakes ---------------------------------------------------------------
+// A aba apenas controla o processo e mostra resultados; a análise inteira
+// acontece na engine (scanner/retakes), chamada via executar_retakes.py.
+var RETAKES_ENGINE_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/executar_retakes.py";
+var retakesCasoAtual = null;
+var retakesGrupos = [];
+var retakesProcesso = null;
+
+function retakesRaiz() { return path.join(os.homedir(), ".premiere-mcp", "scanner"); }
+
+function retakesAoAbrir() { retakesListarCasos(); }
+
+function retakesListarCasos() {
+ var seletor = document.getElementById("retakesCaso");
+ var anterior = seletor.value;
+ seletor.innerHTML = "";
+ var raiz = retakesRaiz();
+ var casos = [];
+ try { casos = fs.readdirSync(raiz).filter(function (item) { return fs.statSync(path.join(raiz, item)).isDirectory(); }).sort(); } catch (_) {}
+ if (!casos.length) { seletor.appendChild(new Option("Nenhum caso encontrado", "")); retakesCasoAtual = null; retakesInfo("Execute o Scanner primeiro para gerar transcrições."); return; }
+ casos.forEach(function (caso) { seletor.appendChild(new Option(caso, caso)); });
+ seletor.value = anterior && casos.indexOf(anterior) >= 0 ? anterior : casos[0];
+ retakesCarregarCaso();
+}
+
+function retakesInfo(mensagem) { document.getElementById("retakesInfoCaso").textContent = mensagem; }
+
+function retakesArtefatosDeAudio(caso) {
+ var pasta = path.join(retakesRaiz(), caso);
+ try { return fs.readdirSync(pasta).filter(function (item) { return /-audio-faixa-\d+\.json$/.test(item); }).map(function (item) { return path.join(pasta, item); }).sort(); } catch (_) { return []; }
+}
+
+function retakesCarregarCaso() {
+ var caso = document.getElementById("retakesCaso").value;
+ retakesCasoAtual = caso || null;
+ retakesGrupos = [];
+ retakesRenderResultados();
+ if (!caso) { retakesInfo("Nenhum caso disponível."); return; }
+ var artefatos = retakesArtefatosDeAudio(caso);
+ var jaTem = fs.existsSync(path.join(retakesRaiz(), caso, "retakes"));
+ retakesInfo("Caso: " + caso + " · " + artefatos.length + " faixa(s) de áudio com transcrição" + (jaTem ? " · já analisado" : " · não analisado"));
+ document.getElementById("retakesStatusGeral").textContent = jaTem ? "Analisado" : "Não analisado";
+ if (jaTem) retakesCarregarResultadosSalvos();
+}
+
+function retakesCarregarResultadosSalvos() {
+ var arquivo = path.join(retakesRaiz(), retakesCasoAtual, "retakes", "retakes-" + retakesCasoAtual + ".json");
+ try {
+ var dados = JSON.parse(fs.readFileSync(arquivo, "utf-8"));
+ retakesGrupos = dados.grupos_de_retakes || [];
+ retakesRenderResultados();
+ } catch (erro) { showToast("err", "Não foi possível ler os resultados: " + erro.message); }
+}
+
+function retakesFormatarTempo(segundos) {
+ if (typeof segundos !== "number" || isNaN(segundos)) return "—";
+ var total = Math.max(0, Math.round(segundos));
+ var m = Math.floor(total / 60), s = total % 60;
+ return String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
+}
+
+function retakesRotuloDoGrupo(grupo) {
+ var tipo = { retake_confirmado: "Retake forte", possivel_retake: "Possível retake", repeticao_parcial: "Repetição parcial", repeticao_de_frase: "Repetição de frase", repeticao_de_inicio: "Repetição de início", continuidade_de_fala: "Continuidade de fala", sem_indicio_de_retake: "Sem indício" }[grupo.tipo] || grupo.tipo;
+ var status = grupo.status_de_revisao === "confirmado" ? " · confirmado" : grupo.status_de_revisao === "nao_e_retake" ? " · rejeitado" : grupo.status_de_revisao === "ignorado" ? " · ignorado" : "";
+ return tipo + " · " + Math.round(grupo.confianca * 100) + "% de confiança" + status;
+}
+
+function retakesPassaNoFiltro(grupo) {
+ var filtro = document.getElementById("retakesFiltro").value;
+ if (filtro === "todos") return true;
+ if (filtro === "fortes") return grupo.tipo === "retake_confirmado";
+ if (filtro === "possiveis") return grupo.tipo !== "retake_confirmado" && grupo.tipo !== "sem_indicio_de_retake";
+ return grupo.status_de_revisao === filtro;
+}
+
+function retakesRenderResultados() {
+ var alvo = document.getElementById("retakesResultados");
+ alvo.innerHTML = "";
+ var visiveis = retakesGrupos.filter(retakesPassaNoFiltro);
+ document.getElementById("retakesContagem").textContent = retakesGrupos.length ? retakesGrupos.length + " grupo(s)" : "—";
+ document.getElementById("retakesSemResultados").hidden = visiveis.length > 0;
+ visiveis.forEach(function (grupo) { alvo.appendChild(retakesCartaoDoGrupo(grupo)); });
+}
+
+function retakesCartaoDoGrupo(grupo) {
+ var cartao = document.createElement("div");
+ cartao.className = "card"; cartao.setAttribute("role", "listitem");
+ var titulo = document.createElement("strong"); titulo.textContent = retakesRotuloDoGrupo(grupo);
+ cartao.appendChild(titulo);
+ grupo.tomadas.forEach(function (tomada, indice) {
+ var linha = document.createElement("p");
+ linha.textContent = "Tentativa " + (indice + 1) + ": " + retakesFormatarTempo(tomada.inicio) + " – " + retakesFormatarTempo(tomada.fim) + " · " + (tomada.texto || "");
+ linha.style.margin = "2px 0"; cartao.appendChild(linha);
+ });
+ if (grupo.evidencias && grupo.evidencias.length) {
+ var motivos = document.createElement("p");
+ motivos.textContent = "Evidências: " + grupo.evidencias.map(function (e) { return e.descricao || e.tipo; }).join("; ");
+ motivos.className = "field-help"; cartao.appendChild(motivos);
+ }
+ var acoes = document.createElement("div"); acoes.className = "button-row";
+ [["confirmar", "Confirmar"], ["rejeitar", "Não é retake"], ["manter", "Manter p/ revisão"], ["ignorar", "Ignorar"]].forEach(function (par) {
+ var botao = document.createElement("button");
+ botao.className = "button button-ghost"; botao.type = "button"; botao.textContent = par[1];
+ botao.onclick = function () { retakesRevisar(grupo.id, par[0]); };
+ acoes.appendChild(botao);
+ });
+ cartao.appendChild(acoes);
+ return cartao;
+}
+
+function retakesAnalisar() {
+ if (!retakesCasoAtual) { showToast("err", "Selecione um caso do Scanner."); return; }
+ var artefatos = retakesArtefatosDeAudio(retakesCasoAtual);
+ if (!artefatos.length) { showToast("err", "O caso não possui transcrição. Execute o Scanner com transcrição primeiro."); return; }
+ if (retakesProcesso) { showToast("err", "Já existe uma análise em andamento."); return; }
+ if (!document.getElementById("retakesReaproveitar").checked) {
+ var pastaRetakes = path.join(retakesRaiz(), retakesCasoAtual, "retakes");
+ try { if (fs.existsSync(pastaRetakes)) fs.rmSync(pastaRetakes, { recursive: true }); } catch (_) {}
+ }
+ var pedido = { acao: "analisar", video_id: retakesCasoAtual, artefatos_de_audio: artefatos,
+ configuracao: { criterio: document.getElementById("retakesCriterio").value,
+ usar_analise_visual: document.getElementById("retakesUsarVisual").checked } };
+ var entrada = path.join(os.tmpdir(), "pedido-retakes-" + Date.now() + ".json");
+ fs.writeFileSync(entrada, JSON.stringify(pedido, null, 2), "utf-8");
+ setBusy("btnRetakesAnalisar", true);
+ taskStart("Retakes", "Preparando análise", null);
+ document.getElementById("retakesProgressoCard").hidden = false;
+ document.getElementById("retakesProgressoEtapa").textContent = "Analisando retakes…";
+ var child = childProcess.spawn("python3", [RETAKES_ENGINE_SCRIPT, entrada, path.join(retakesRaiz(), retakesCasoAtual)],
+ { cwd: "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine", stdio: ["ignore", "pipe", "pipe"] });
+ retakesProcesso = child;
+ var resto = "";
+ child.stdout.on("data", function (pedaco) {
+ resto += pedaco.toString(); var linhas = resto.split("\n"); resto = linhas.pop();
+ linhas.forEach(function (linha) { var evento; try { evento = JSON.parse(linha); } catch (_) { return; } retakesProcessarEvento(evento); });
+ });
+ child.stderr.on("data", function (pedaco) { if (pedaco.toString().trim()) retakesLog(pedaco.toString().trim()); });
+ child.on("error", function (erro) { retakesProcesso = null; setBusy("btnRetakesAnalisar", false); taskEnd(false, "Engine indisponível"); showToast("err", erro.message); });
+ child.on("close", function (codigo) {
+ retakesProcesso = null; setBusy("btnRetakesAnalisar", false);
+ document.getElementById("retakesProgressoCard").hidden = true;
+ document.getElementById("retakesStatusGeral").textContent = "Analisado";
+ if (codigo !== 0) taskEnd(false, "Análise falhou");
+ });
+}
+
+function retakesProcessarEvento(evento) {
+ if (evento.evento === "progresso") {
+ taskUpdate({ stage: evento.etapa, pct: evento.percentual });
+ document.getElementById("retakesProgressoEtapa").textContent = evento.etapa;
+ document.getElementById("retakesProgressoDetalhe").textContent = evento.percentual + "% concluído";
+ } else if (evento.evento === "erro") {
+ showToast("err", evento.mensagem); retakesLog("Erro: " + evento.mensagem);
+ } else if (evento.evento === "concluido") {
+ retakesGrupos = (evento.resultado && evento.resultado.grupos) || [];
+ retakesRenderResultados();
+ taskEnd(true, retakesGrupos.length + " grupo(s) de retakes");
+ showToast("ok", "Análise concluída: " + retakesGrupos.length + " grupo(s) encontrado(s).");
+ }
+}
+
+function retakesRevisar(grupoId, acao) {
+ if (!retakesCasoAtual) return;
+ var pedido = { acao: "revisar", video_id: retakesCasoAtual, revisao: { grupo_id: grupoId, acao: acao } };
+ var entrada = path.join(os.tmpdir(), "revisao-retakes-" + Date.now() + ".json");
+ fs.writeFileSync(entrada, JSON.stringify(pedido), "utf-8");
+ var child = childProcess.spawn("python3", [RETAKES_ENGINE_SCRIPT, entrada, path.join(retakesRaiz(), retakesCasoAtual)],
+ { cwd: "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine", stdio: ["ignore", "pipe", "pipe"] });
+ child.on("close", function (codigo) {
+ if (codigo !== 0) { showToast("err", "Não foi possível registrar a revisão."); return; }
+ var novos = { confirmar: "confirmado", rejeitar: "nao_e_retake", manter: "pendente_de_revisao", ignorar: "ignorado" };
+ retakesGrupos = retakesGrupos.map(function (g) { return g.id === grupoId ? Object.assign({}, g, { status_de_revisao: novos[acao] }) : g; });
+ retakesRenderResultados();
+ showToast("ok", "Revisão registrada.");
+ });
+}
+
+function retakesLog(mensagem) {
+ var alvo = document.getElementById("retakesLog");
+ var linha = document.createElement("p"); linha.textContent = mensagem;
+ alvo.appendChild(linha);
}
// ---- Perfil do Scanner -----------------------------------------------------
@@ -191,23 +376,31 @@ function scannerRenderTracks(kind, tracks, selected) {
tracks.forEach(function (track) { var label = document.createElement("label"); label.className = "checkbox-field"; var input = document.createElement("input"); input.type = "checkbox"; input.setAttribute("data-scanner-track", kind); input.value = track.index; input.checked = selected.indexOf(track.index) >= 0; label.appendChild(input); label.appendChild(document.createTextNode(" " + track.name + " · " + track.numClips + " clipe(s)")); target.appendChild(label); });
}
+function scannerUpdateIntervalLabel() {
+ var valor = Number(document.getElementById("scannerInterval").value);
+ var texto = valor === 1 ? "1 s/quadro" : valor.toFixed(1).replace(/\.0$/, "") + " s/quadro";
+ document.getElementById("scannerIntervalValue").textContent = texto;
+}
+
function scannerProfileFromScreen() {
var enabled = Array.prototype.slice.call(document.querySelectorAll("[data-scanner-resource]:checked")).map(function (item) { return item.getAttribute("data-scanner-resource"); });
function include(source, extras) { if (enabled.indexOf(source) >= 0) extras.forEach(function (item) { if (enabled.indexOf(item) < 0) enabled.push(item); }); }
include("faces", ["qualidade_facial"]); include("pose", ["maos"]); include("ocr", ["codigos"]); include("estetica", ["horizonte", "retangulos", "contornos"]);
var modelo = document.getElementById("scannerTranscribeModel").value;
var opcao = document.getElementById("scannerTranscribeModel").selectedOptions[0];
- return { versao: 1, intervalo_em_segundos: Number(document.getElementById("scannerInterval").value), faixas_de_video: scannerSelectedTracks("video"), faixas_de_audio: scannerSelectedTracks("audio"), recursos_vision: SCANNER_ALL_RESOURCES.filter(function (item) { return enabled.indexOf(item) >= 0; }), detectar_cenas: document.getElementById("scannerScenes").checked, analisar_continuidade: document.getElementById("scannerContinuity").checked, preparar_reenquadramento: document.getElementById("scannerReframe").checked, interpretar_cena: document.getElementById("scannerInterpret").checked, transcricao: { modelo: modelo, caminho_modelo: opcao && opcao.dataset.path || "", idioma: document.getElementById("scannerTranscribeLanguage").value, diarizacao: document.getElementById("scannerDiarization").checked, usar_hugging_face: !!loadHfToken() } };
+ return { versao: 1, intervalo_em_segundos: Number(document.getElementById("scannerInterval").value), resolucao_maxima: Number(document.getElementById("scannerResolution").value), faixas_de_video: scannerSelectedTracks("video"), faixas_de_audio: scannerSelectedTracks("audio"), recursos_vision: SCANNER_ALL_RESOURCES.filter(function (item) { return enabled.indexOf(item) >= 0; }), detectar_cenas: document.getElementById("scannerScenes").checked, analisar_continuidade: document.getElementById("scannerContinuity").checked, preparar_reenquadramento: document.getElementById("scannerReframe").checked, interpretar_cena: document.getElementById("scannerInterpret").checked, transcricao: { modelo: modelo, caminho_modelo: opcao && opcao.dataset.path || "", idioma: document.getElementById("scannerTranscribeLanguage").value, diarizacao: document.getElementById("scannerDiarization").checked, usar_hugging_face: !!loadHfToken() } };
}
function saveScannerProfile() {
- try { var profile = scannerProfileFromScreen(); localStorage.setItem(SCANNER_PROFILE_KEY, JSON.stringify(profile)); document.getElementById("scannerProfileStatus").textContent = "Perfil salvo: " + profile.intervalo_em_segundos + " s por frame; " + profile.faixas_de_video.length + " faixa(s) de vídeo."; showToast("ok", "Perfil do Scanner salvo."); } catch (error) { showToast("err", error.message || String(error)); }
+ try { var profile = scannerProfileFromScreen(); localStorage.setItem(SCANNER_PROFILE_KEY, JSON.stringify(profile)); document.getElementById("scannerProfileStatus").textContent = "Perfil salvo: " + profile.intervalo_em_segundos + " s por quadro; " + profile.faixas_de_video.length + " faixa(s) de vídeo."; showToast("ok", "Perfil do Scanner salvo."); } catch (error) { showToast("err", error.message || String(error)); }
}
function loadScannerProfile() {
var saved; try { saved = JSON.parse(localStorage.getItem(SCANNER_PROFILE_KEY) || "null"); } catch (_) { saved = null; }
- if (!saved) return;
+ if (!saved) { scannerUpdateIntervalLabel(); return; }
document.getElementById("scannerInterval").value = String(saved.intervalo_em_segundos || 1);
+ scannerUpdateIntervalLabel();
+ document.getElementById("scannerResolution").value = String(saved.resolucao_maxima != null ? saved.resolucao_maxima : 1080);
if (scannerTimeline) { scannerRenderTracks("video", scannerTimeline.videoTracks, saved.faixas_de_video || []); scannerRenderTracks("audio", scannerTimeline.audioTracks, saved.faixas_de_audio || []); }
var resources = saved.recursos_vision || SCANNER_ALL_RESOURCES;
Array.prototype.slice.call(document.querySelectorAll("[data-scanner-resource]")).forEach(function (item) { item.checked = resources.indexOf(item.getAttribute("data-scanner-resource")) >= 0; });
@@ -368,6 +561,7 @@ var fs = nodeRequire("fs");
var path = nodeRequire("path");
var os = nodeRequire("os");
var https = nodeRequire("https");
+var childProcess = nodeRequire("child_process");
function defaultBridgeDirectory() {
try {
var nodeProcess = nodeRequire("process");
diff --git a/code/cep-plugin/styles.css b/code/cep-plugin/styles.css
index 673cbe6..ba54f75 100755
--- a/code/cep-plugin/styles.css
+++ b/code/cep-plugin/styles.css
@@ -339,6 +339,9 @@ h1, h2, h3 { margin: 0; }
transition: border-color .15s, box-shadow .15s;
}
.select-field { height: 32px; padding: 0 8px; font-size: 10.5px; }
+.range-field-row { display: flex; align-items: center; gap: 10px; }
+.range-field-row input[type="range"] { flex: 1 1 auto; accent-color: var(--violet); height: 20px; }
+.range-field-value { flex: 0 0 auto; min-width: 84px; text-align: right; font: 10.5px var(--font-mono); color: var(--text-secondary); }
.path-field input, .file-field input { height: 34px; padding: 0 10px; font: 10px var(--font-mono); color: var(--text-secondary); user-select: text; }
.path-field > svg + input { padding-left: 32px; }
textarea { padding: 8px 10px; font: 10px var(--font-mono); color: var(--text-secondary); resize: vertical; user-select: text; }
diff --git a/code/engine/executar_retakes.py b/code/engine/executar_retakes.py
new file mode 100644
index 0000000..b4e01da
--- /dev/null
+++ b/code/engine/executar_retakes.py
@@ -0,0 +1,164 @@
+"""Entrada local do painel CEP para a análise de retakes.
+
+Espelha o ``executar_scanner.py``: recebe um pedido JSON montado pela tela,
+executa a análise com as classes existentes em ``scanner/retakes`` e emite
+eventos de progresso em JSON (uma linha por evento) na saída padrão.
+
+Ações suportadas no pedido:
+
+- ``analisar``: executa a análise completa usando as transcrições já
+ geradas pelo scanner (nunca retranscreve).
+- ``revisar``: registra a decisão manual do usuário sobre um grupo.
+
+Formato do pedido (exemplo)::
+
+ {
+ "acao": "analisar",
+ "video_id": "entrevista_01",
+ "artefatos_de_audio": ["...-audio-faixa-1.json"],
+ "configuracao": {"criterio": "equilibrado"}
+ }
+
+Para ``revisar``::
+
+ {
+ "acao": "revisar",
+ "video_id": "entrevista_01",
+ "revisao": {"grupo_id": "retake_0001", "acao": "confirmar"}
+ }
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from dataclasses import asdict
+from pathlib import Path
+
+from engine.scanner.retakes import (
+ STATUS_CONFIRMADO,
+ STATUS_IGNORADO,
+ STATUS_PENDENTE,
+ STATUS_REJEITADO,
+ CarregadorDeArtefatosDeAudio,
+ ClassificadorDeRetakes,
+ CoordenadorDeRetakes,
+ RegrasDeClassificacao,
+ RepositorioDeRetakes,
+)
+
+# Confiança mínima exibida por critério de confirmação da tela (§9 do guia).
+LIMIARES_POR_CRITERIO = {
+ "conservador": 0.80,
+ "equilibrado": 0.65,
+ "sensivel": 0.45,
+ "personalizado": 0.0,
+}
+STATUS_PADRAO_DO_PEDIDO = {
+ "confirmar": STATUS_CONFIRMADO,
+ "rejeitar": STATUS_REJEITADO,
+ "manter": STATUS_PENDENTE,
+ "ignorar": STATUS_IGNORADO,
+}
+
+
+def _emitir(etapa: str, percentual: float, detalhe: str = "") -> None:
+ """Emite um evento de progresso no mesmo protocolo do scanner."""
+ print(json.dumps({"evento": "progresso", "etapa": etapa,
+ "percentual": round(percentual, 1), "clipe": detalhe},
+ ensure_ascii=False), flush=True)
+
+
+def _criterio_do_pedido(configuracao: dict) -> str:
+ """Valida e devolve o critério de confirmação escolhido na tela."""
+ criterio = str(configuracao.get("criterio", "equilibrado"))
+ if criterio not in LIMIARES_POR_CRITERIO:
+ validos = ", ".join(sorted(LIMIARES_POR_CRITERIO))
+ raise ValueError(f"Critério desconhecido: {criterio!r}. Válidos: {validos}.")
+ return criterio
+
+
+def _grupos_do_dict(grupos) -> list[dict]:
+ """Converte os grupos de retakes para o formato consumido pela tela."""
+ return [asdict(grupo) for grupo in grupos]
+
+
+def _analisar(pedido: dict, pasta_do_caso: Path) -> dict:
+ """Executa a análise completa de retakes para o caso informado."""
+ configuracao = pedido.get("configuracao", {})
+ criterio = _criterio_do_pedido(configuracao)
+ video_id = str(pedido.get("video_id") or "video")
+
+ _emitir("Preparando análise", 5)
+ repositorio = RepositorioDeRetakes(diretoria_de_trabalho=pasta_do_caso / "retakes")
+ if repositorio.ja_analisado(video_id):
+ _emitir("Reutilizando análise existente", 100)
+ return {"reutilizado": True,
+ "grupos": _grupos_do_dict(repositorio.carregar(video_id))}
+
+ _emitir("Carregando transcrição", 20)
+ carregador = CarregadorDeArtefatosDeAudio()
+ falas = carregador.carregar(pedido.get("artefatos_de_audio", []), video_id)
+
+ _emitir("Comparando trechos", 45)
+ coordenador = CoordenadorDeRetakes(
+ classificador_de_retakes=ClassificadorDeRetakes(RegrasDeClassificacao()),
+ repositorio_de_retakes=repositorio,
+ )
+ _emitir("Agrupando tentativas", 65)
+ resultado = coordenador.analisar(video_id=video_id, falas=falas)
+
+ _emitir("Calculando confiança", 85)
+ limiar = LIMIARES_POR_CRITERIO[criterio]
+ visiveis = [grupo for grupo in resultado.grupos if grupo.confianca >= limiar]
+ _emitir("Salvando resultados", 95)
+ repositorio.registrar(video_id, resultado.grupos)
+ return {"reutilizado": False, "grupos": _grupos_do_dict(visiveis)}
+
+
+def _revisar(pedido: dict, pasta_do_caso: Path) -> dict:
+ """Registra a decisão manual do usuário sobre um grupo de retakes."""
+ video_id = str(pedido.get("video_id") or "video")
+ revisao = pedido.get("revisao", {})
+ grupo_id = str(revisao.get("grupo_id", ""))
+ acao = str(revisao.get("acao", ""))
+ if acao not in STATUS_PADRAO_DO_PEDIDO:
+ validos = ", ".join(sorted(STATUS_PADRAO_DO_PEDIDO))
+ raise ValueError(f"Ação de revisão desconhecida: {acao!r}. Válidos: {validos}.")
+ repositorio = RepositorioDeRetakes(diretoria_de_trabalho=pasta_do_caso / "retakes")
+ grupo = repositorio.atualizar_status_de_revisao(
+ video_id, grupo_id, STATUS_PADRAO_DO_PEDIDO[acao])
+ return {"grupos": _grupos_do_dict([grupo])}
+
+
+def executar(entrada: Path, pasta_do_caso: Path) -> dict:
+ """Lê o pedido, executa a ação e devolve o resultado para a tela."""
+ pedido = json.loads(entrada.read_text(encoding="utf-8"))
+ acao = str(pedido.get("acao", "analisar"))
+ if acao == "revisar":
+ return _revisar(pedido, pasta_do_caso)
+ return _analisar(pedido, pasta_do_caso)
+
+
+def main() -> None:
+ """Ponto de entrada da linha de comando usada pelo painel."""
+ parser = argparse.ArgumentParser(description="Análise de retakes para o painel.")
+ parser.add_argument("entrada", type=Path, help="Arquivo de pedido JSON.")
+ parser.add_argument("pasta_do_caso", type=Path,
+ help="Pasta do caso gerada pelo scanner.")
+ args = parser.parse_args()
+ try:
+ resultado = executar(args.entrada, args.pasta_do_caso)
+ except Exception as erro: # repassa a causa para a tela decidir como exibir
+ print(json.dumps({"evento": "erro", "mensagem": str(erro)},
+ ensure_ascii=False), flush=True)
+ sys.exit(1)
+ _emitir("Análise concluída", 100)
+ print(json.dumps({"evento": "concluido", "resultado": resultado},
+ ensure_ascii=False), flush=True)
+
+
+if __name__ == "__main__":
+ main()
+
diff --git a/code/engine/integracoes/visual/apple_vision.py b/code/engine/integracoes/visual/apple_vision.py
index 768e0a9..d288afd 100644
--- a/code/engine/integracoes/visual/apple_vision.py
+++ b/code/engine/integracoes/visual/apple_vision.py
@@ -1,10 +1,13 @@
"""Adapter Python para o runner Swift que concentra Vision e Apple Intelligence."""
+import atexit
import json
import os
from pathlib import Path
+import queue
import subprocess
import tempfile
+import threading
from typing import Any, Callable
from ...scanner.modelos import EvidenciaVisual
@@ -18,27 +21,101 @@ RECURSOS_PADRAO = (
)
+class _TrabalhadorPersistente:
+ """Um processo do runner Swift vivo entre chamadas, evitando recarregar Vision/Foundation Models a cada frame."""
+
+ def __init__(self, executavel: Path) -> None:
+ self.processo = subprocess.Popen([str(executavel)], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE, text=True, bufsize=1)
+
+ def executar(self, carga: dict[str, Any], timeout: float) -> dict[str, Any]:
+ if self.processo.poll() is not None or not self.processo.stdin or not self.processo.stdout:
+ raise RuntimeError("Runner Apple Vision não está mais em execução.")
+ try:
+ self.processo.stdin.write(json.dumps(carga) + "\n")
+ self.processo.stdin.flush()
+ except (BrokenPipeError, ValueError) as exc:
+ raise RuntimeError("Runner Apple Vision não aceitou o pedido.") from exc
+
+ resposta: dict[str, str] = {}
+ falha: list[BaseException] = []
+
+ def ler() -> None:
+ try:
+ resposta["linha"] = self.processo.stdout.readline() # type: ignore[union-attr]
+ except BaseException as exc: # noqa: BLE001 - repassado à thread principal
+ falha.append(exc)
+
+ leitor = threading.Thread(target=ler, daemon=True)
+ leitor.start()
+ leitor.join(timeout)
+ if leitor.is_alive():
+ self.encerrar()
+ raise TimeoutError(f"Runner Apple Vision excedeu {timeout}s.")
+ if falha:
+ raise falha[0]
+ linha = resposta.get("linha", "")
+ if not linha:
+ detalhe = (self.processo.stderr.read() if self.processo.stderr else "") or "runner encerrou sem resposta"
+ raise RuntimeError(f"Runner Apple Vision falhou: {detalhe.strip()}")
+ return json.loads(linha)
+
+ def encerrar(self) -> None:
+ if self.processo.poll() is None:
+ self.processo.kill()
+ try:
+ self.processo.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ pass
+
+
class ExecutorDoRunnerApple:
- """Compila o runner Swift quando necessário e o executa em processo isolado."""
+ """Compila o runner Swift quando necessário e mantém um pool de processos persistentes."""
def __init__(self, fonte: str | Path | None = None, compilador: str = "swiftc",
- diretorio_de_build: str | Path | None = None) -> None:
+ diretorio_de_build: str | Path | None = None, trabalhadores: int = 4) -> None:
self.fonte = Path(fonte) if fonte else Path(__file__).with_name("apple_vision_runner.swift")
self.compilador = compilador
self.diretorio_de_build = Path(diretorio_de_build) if diretorio_de_build else (
Path(tempfile.gettempdir()) / "jhonny-apple-vision")
+ self.trabalhadores = max(1, trabalhadores)
+ self._pool: "queue.Queue[_TrabalhadorPersistente]" | None = None
+ self._trava = threading.Lock()
+ self._todos: list[_TrabalhadorPersistente] = []
+ atexit.register(self.encerrar)
def executar(self, carga: dict[str, Any], timeout: float) -> dict[str, Any]:
executavel = self._garantir_compilado()
- processo = subprocess.run([str(executavel)], input=json.dumps(carga), capture_output=True,
- text=True, timeout=timeout)
- if processo.returncode != 0:
- detalhe = processo.stderr.strip() or "runner Apple terminou sem diagnóstico"
- raise RuntimeError(f"Runner Apple Vision falhou: {detalhe}")
+ pool = self._garantir_pool(executavel)
+ trabalhador = pool.get()
try:
- return json.loads(processo.stdout)
- except json.JSONDecodeError as exc:
- raise RuntimeError(f"Runner Apple Vision devolveu JSON inválido: {processo.stdout!r}") from exc
+ return trabalhador.executar(carga, timeout)
+ except Exception:
+ trabalhador.encerrar()
+ with self._trava:
+ self._todos.remove(trabalhador)
+ trabalhador = _TrabalhadorPersistente(executavel)
+ self._todos.append(trabalhador)
+ raise
+ finally:
+ pool.put(trabalhador)
+
+ def encerrar(self) -> None:
+ with self._trava:
+ for trabalhador in self._todos:
+ trabalhador.encerrar()
+ self._todos.clear()
+ self._pool = None
+
+ def _garantir_pool(self, executavel: Path) -> "queue.Queue[_TrabalhadorPersistente]":
+ with self._trava:
+ if self._pool is None:
+ self._pool = queue.Queue()
+ for _ in range(self.trabalhadores):
+ trabalhador = _TrabalhadorPersistente(executavel)
+ self._todos.append(trabalhador)
+ self._pool.put(trabalhador)
+ return self._pool
def _garantir_compilado(self) -> Path:
if not self.fonte.is_file():
diff --git a/code/engine/integracoes/visual/apple_vision_runner.swift b/code/engine/integracoes/visual/apple_vision_runner.swift
index cd94991..4f10dfb 100644
--- a/code/engine/integracoes/visual/apple_vision_runner.swift
+++ b/code/engine/integracoes/visual/apple_vision_runner.swift
@@ -350,19 +350,32 @@ func interpretar(_ saida: Saida) async -> String? {
} catch { return nil }
}
+/// Processa um pedido por linha de stdin e escreve uma resposta por linha em stdout.
+/// Mantém o processo vivo entre quadros para não recarregar Vision/Foundation Models a
+/// cada frame — o custo de inicialização é o que tornava a análise lenta em lote.
@main struct Principal {
static func main() async {
- do {
- let entrada = try JSONDecoder().decode(Entrada.self, from: FileHandle.standardInput.readDataToEndOfFile())
- var saida = entrada.frames?.count ?? 0 > 1 ? analisarSequencia(entrada) : analisarVision(entrada)
- if entrada.resumir, let resumo = await interpretar(saida) {
- saida = Saida(evidencias: saida.evidencias + [Evidencia(tipo: "interpretacao_editorial", valor: ["texto": .texto(resumo)], confianca: nil, modelo: "AppleFoundationModels")], avisos: saida.avisos)
+ while let linha = readLine(strippingNewline: true) {
+ guard !linha.isEmpty, let entradaDados = linha.data(using: .utf8) else { continue }
+ let saida: Saida
+ do {
+ let entrada = try JSONDecoder().decode(Entrada.self, from: entradaDados)
+ var resultado = entrada.frames?.count ?? 0 > 1 ? analisarSequencia(entrada) : analisarVision(entrada)
+ if entrada.resumir, let resumo = await interpretar(resultado) {
+ resultado = Saida(evidencias: resultado.evidencias + [Evidencia(
+ tipo: "interpretacao_editorial", valor: ["texto": .texto(resumo)],
+ confianca: nil, modelo: "AppleFoundationModels")], avisos: resultado.avisos)
+ }
+ saida = resultado
+ } catch {
+ saida = Saida(evidencias: [], avisos: ["erro_fatal: \(error.localizedDescription)"])
}
- let dados = try JSONEncoder().encode(saida)
- FileHandle.standardOutput.write(dados)
- } catch {
- FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8))
- exit(1)
+ guard let dados = try? JSONEncoder().encode(saida), let linhaDeSaida = String(data: dados, encoding: .utf8) else {
+ FileHandle.standardError.write(Data("Falha ao serializar resposta\n".utf8))
+ continue
+ }
+ print(linhaDeSaida)
+ fflush(stdout)
}
}
}
diff --git a/code/engine/integracoes/visual/extrator_ffmpeg.py b/code/engine/integracoes/visual/extrator_ffmpeg.py
index 402c127..62fd973 100644
--- a/code/engine/integracoes/visual/extrator_ffmpeg.py
+++ b/code/engine/integracoes/visual/extrator_ffmpeg.py
@@ -1,6 +1,7 @@
"""Extrator de frames para Vision usando somente ferramentas locais do sistema."""
import json
+from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import subprocess
@@ -16,16 +17,24 @@ class ExtratorDeQuadrosFFmpeg(ExtratorDeQuadros):
def __init__(self, intervalo_em_segundos: float = 1.0,
diretorio_de_cache: str | Path = ".frames",
maximo_de_quadros: int | None = None,
- ffmpeg: str = "ffmpeg", ffprobe: str = "ffprobe") -> None:
+ ffmpeg: str = "ffmpeg", ffprobe: str = "ffprobe",
+ trabalhadores_em_paralelo: int = 4,
+ resolucao_maxima: int = 0) -> None:
if intervalo_em_segundos <= 0:
raise ValueError("intervalo_em_segundos deve ser positivo.")
if maximo_de_quadros is not None and maximo_de_quadros < 1:
raise ValueError("maximo_de_quadros deve ser maior que zero.")
+ if resolucao_maxima < 0:
+ raise ValueError("resolucao_maxima não pode ser negativa.")
self.intervalo_em_segundos = intervalo_em_segundos
self.diretorio_de_cache = Path(diretorio_de_cache)
self.maximo_de_quadros = maximo_de_quadros
self.ffmpeg = ffmpeg
self.ffprobe = ffprobe
+ self.trabalhadores_em_paralelo = max(1, trabalhadores_em_paralelo)
+ # 0 preserva a resolução original; caso contrário, limita a maior dimensão
+ # do frame extraído, sem nunca ampliar (force_original_aspect_ratio=decrease).
+ self.resolucao_maxima = resolucao_maxima
def extrair(self, clipe: Clipe) -> list[QuadroDeVideo]:
if not clipe.arquivo:
@@ -37,28 +46,44 @@ class ExtratorDeQuadrosFFmpeg(ExtratorDeQuadros):
inicio, fim = ExtratorDeQuadrosOpenCV._intervalo_de_origem(clipe, duracao)
timestamps = ExtratorDeQuadrosOpenCV(self.intervalo_em_segundos,
maximo_de_quadros=self.maximo_de_quadros)._timestamps(inicio, fim)
- resultado: list[QuadroDeVideo] = []
- for indice, timestamp in enumerate(timestamps):
+
+ def extrair_um(indice_e_timestamp: tuple[int, float]) -> QuadroDeVideo:
+ indice, timestamp = indice_e_timestamp
# PNG evita a recodificação JPEG de YUV limitado, que falha em parte
# dos vídeos móveis e ainda preserva melhor OCR/segmentação para Vision.
- destino = self.diretorio_de_cache / arquivo.stem / f"frame-{indice:06d}.png"
+ # O nome carrega clipe, timestamp e resolução: dois clipes do mesmo arquivo
+ # de origem não podem colidir no mesmo caminho, e mudar o intervalo ou a
+ # resolução entre execuções não pode reaproveitar um frame de outra config.
+ resolucao_rotulo = str(self.resolucao_maxima) if self.resolucao_maxima else "orig"
+ destino = (self.diretorio_de_cache / arquivo.stem / clipe.identificador /
+ f"frame-{timestamp:09.3f}s-{resolucao_rotulo}.png")
destino.parent.mkdir(parents=True, exist_ok=True)
+ if destino.is_file():
+ return QuadroDeVideo(timestamp, indice, largura, altura, None, destino)
amostrado, processo = self._extrair_frame(arquivo, destino, timestamp, inicio)
if processo.returncode != 0 or not destino.is_file():
detalhe = processo.stderr.strip() or "ffmpeg não produziu o frame"
raise RuntimeError(f"Não foi possível extrair frame em {timestamp}s: {detalhe}")
- resultado.append(QuadroDeVideo(amostrado, indice, largura, altura, None, destino))
- return resultado
+ return QuadroDeVideo(amostrado, indice, largura, altura, None, destino)
+
+ tarefas = list(enumerate(timestamps))
+ if self.trabalhadores_em_paralelo == 1 or len(tarefas) <= 1:
+ return [extrair_um(tarefa) for tarefa in tarefas]
+ with ThreadPoolExecutor(max_workers=self.trabalhadores_em_paralelo) as executor:
+ return list(executor.map(extrair_um, tarefas))
def _extrair_frame(self, arquivo: Path, destino: Path, timestamp: float,
inicio: float) -> tuple[float, subprocess.CompletedProcess[str]]:
"""Recua um pouco no fim do arquivo se o decoder não encontrar frame no timestamp."""
+ comando_base = [self.ffmpeg, "-hide_banner", "-loglevel", "error"]
+ filtro = (["-vf", f"scale=w={self.resolucao_maxima}:h={self.resolucao_maxima}:force_original_aspect_ratio=decrease"]
+ if self.resolucao_maxima else [])
ultimo: subprocess.CompletedProcess[str] | None = None
for candidato in (timestamp, max(inicio, timestamp - 0.1)):
if destino.exists():
destino.unlink()
- processo = subprocess.run([self.ffmpeg, "-hide_banner", "-loglevel", "error", "-ss", str(candidato),
- "-i", str(arquivo), "-frames:v", "1", "-y", str(destino)],
+ processo = subprocess.run([*comando_base, "-ss", str(candidato), "-i", str(arquivo),
+ "-frames:v", "1", *filtro, "-y", str(destino)],
capture_output=True, text=True, timeout=60)
if processo.returncode == 0 and destino.is_file():
return candidato, processo
diff --git a/code/engine/persistencia/__init__.py b/code/engine/persistencia/__init__.py
new file mode 100644
index 0000000..cb61235
--- /dev/null
+++ b/code/engine/persistencia/__init__.py
@@ -0,0 +1,25 @@
+"""Persistência SQLite das análises de vídeo, imagem e áudio do Scanner.
+
+Este pacote implementa a "camada de banco de dados" prevista no docstring
+de ``scanner/retakes/repositorio_de_retakes.py``: um schema SQLite único
+para vídeos, metadados de arquivo, transcrição, evidências visuais/cenas e
+grupos de retake, com repositórios que espelham a interface dos
+repositórios JSON já existentes.
+"""
+
+from .conexao import abrir_banco
+from .consultas import ConsultasDeAnalises, ErroDeConsultaInvalida
+from .esquema import criar_esquema
+from .repositorio_de_analises_sqlite import RepositorioDeAnalisesSQLite
+from .repositorio_de_retakes_sqlite import RepositorioDeRetakesSQLite
+from .repositorio_de_timeline_sqlite import RepositorioDeTimelineSQLite
+
+__all__ = [
+ "abrir_banco",
+ "criar_esquema",
+ "RepositorioDeAnalisesSQLite",
+ "RepositorioDeRetakesSQLite",
+ "RepositorioDeTimelineSQLite",
+ "ConsultasDeAnalises",
+ "ErroDeConsultaInvalida",
+]
diff --git a/code/engine/persistencia/conexao.py b/code/engine/persistencia/conexao.py
new file mode 100644
index 0000000..372c0ee
--- /dev/null
+++ b/code/engine/persistencia/conexao.py
@@ -0,0 +1,20 @@
+"""Abertura de conexão com o banco SQLite de análises."""
+
+from __future__ import annotations
+
+import sqlite3
+from pathlib import Path
+
+from .esquema import criar_esquema
+
+
+def abrir_banco(caminho: str | Path = ".jhonny/analises.db") -> sqlite3.Connection:
+ """Abre (criando se necessário) o banco SQLite de análises já com o esquema aplicado."""
+ caminho = Path(caminho)
+ caminho.parent.mkdir(parents=True, exist_ok=True)
+ conexao = sqlite3.connect(str(caminho))
+ conexao.row_factory = sqlite3.Row
+ conexao.execute("PRAGMA foreign_keys = ON")
+ conexao.execute("PRAGMA journal_mode = WAL")
+ criar_esquema(conexao)
+ return conexao
diff --git a/code/engine/persistencia/consultas.py b/code/engine/persistencia/consultas.py
new file mode 100644
index 0000000..5d55382
--- /dev/null
+++ b/code/engine/persistencia/consultas.py
@@ -0,0 +1,463 @@
+"""
+Módulo de consultas de acesso rápido ao banco de análises.
+
+Este módulo define ``ConsultasDeAnalises``, responsável por ler o banco de
+análises por intenção (visão geral do vídeo, falas num intervalo, retakes,
+evidências visuais, cenas), para que um agente que vai montar ou editar um
+vídeo consulte o banco sem precisar escrever SQL nem carregar tabelas
+inteiras.
+
+Este módulo não persiste dados — apenas lê. A escrita fica a cargo de
+``RepositorioDeTimelineSQLite``, ``RepositorioDeAnalisesSQLite`` e
+``RepositorioDeRetakesSQLite``, no mesmo pacote.
+
+Cada método devolve apenas os campos relevantes para a decisão daquele
+nível (nunca a linha inteira da tabela, nunca sub-recursos que não foram
+pedidos), para manter as respostas compactas em tokens quando consumidas
+por um agente de IA.
+"""
+
+from __future__ import annotations
+
+import json
+import sqlite3
+from pathlib import Path
+
+from .conexao import abrir_banco
+
+
+class ErroDeConsultaInvalida(ValueError):
+ """Representa uma consulta feita com parâmetros inválidos ou incompletos."""
+
+
+class ConsultasDeAnalises:
+ """
+ Consulta o banco de análises por intenção, devolvendo respostas compactas.
+
+ Esta classe não persiste dados e não altera o banco — todos os métodos
+ são operações de leitura. A escrita é responsabilidade dos
+ repositórios do pacote (``RepositorioDeTimelineSQLite``,
+ ``RepositorioDeAnalisesSQLite``, ``RepositorioDeRetakesSQLite``).
+
+ Atributos:
+ conexao: Conexão SQLite já aberta e com o esquema aplicado.
+ """
+
+ def __init__(self, banco: str | Path | sqlite3.Connection = ".jhonny/analises.db") -> None:
+ """
+ Inicializa as consultas a partir de um caminho de banco ou conexão existente.
+
+ Parâmetros:
+ banco: Caminho do arquivo do banco de análises, ou uma conexão
+ SQLite já aberta (reaproveitada sem reabrir).
+ """
+ self.conexao = banco if isinstance(banco, sqlite3.Connection) else abrir_banco(banco)
+
+ def consultar_resumo_do_video(self, video_id: str) -> dict | None:
+ """
+ Consulta a visão geral de um vídeo: o ponto de partida antes de descer a qualquer detalhe.
+
+ Parâmetros:
+ video_id: Identificador do vídeo (mesmo id usado na timeline do Premiere).
+
+ Retorna:
+ Um dicionário com nome, duração, resolução e as contagens de
+ clipes, falas, retakes e cenas do vídeo, ou None quando o
+ vídeo não existe no banco.
+
+ Pode gerar:
+ ErroDeConsultaInvalida: quando ``video_id`` for vazio.
+ """
+ self._validar_texto_obrigatorio("video_id", video_id)
+
+ video = self.conexao.execute(
+ "SELECT nome, duracao, taxa_de_quadros, largura, altura FROM videos WHERE id = ?",
+ (video_id,),
+ ).fetchone()
+ if video is None:
+ return None
+ contagens = self.conexao.execute(
+ """SELECT
+ (SELECT COUNT(*) FROM clipes WHERE video_id = ?) AS clipes,
+ (SELECT COUNT(*) FROM segmentos_de_transcricao WHERE video_id = ?) AS falas,
+ (SELECT COUNT(*) FROM grupos_de_retake WHERE video_id = ?) AS retakes,
+ (SELECT COUNT(*) FROM cenas WHERE video_id = ?) AS cenas""",
+ (video_id, video_id, video_id, video_id),
+ ).fetchone()
+ return {
+ "video_id": video_id,
+ "nome": video["nome"],
+ "duracao": video["duracao"],
+ "taxa_de_quadros": video["taxa_de_quadros"],
+ "resolucao": f"{video['largura']}x{video['altura']}" if video["largura"] else None,
+ "total_clipes": contagens["clipes"],
+ "total_falas": contagens["falas"],
+ "total_retakes": contagens["retakes"],
+ "total_cenas": contagens["cenas"],
+ }
+
+ def listar_falas_no_intervalo(
+ self,
+ video_id: str,
+ inicio: float = 0.0,
+ fim: float | None = None,
+ incluir_palavras: bool = False,
+ ) -> list[dict]:
+ """
+ Lista as falas transcritas num intervalo da timeline, ordenadas por posição.
+
+ Não traz ``caracteristicas_acusticas`` nem palavra a palavra por
+ padrão — só o necessário para decidir o que cortar. Use
+ ``incluir_palavras=True`` apenas quando o corte precisar acontecer
+ no meio de uma frase.
+
+ Parâmetros:
+ video_id: Identificador do vídeo.
+ inicio: Início do intervalo, em segundos na timeline (padrão: 0.0).
+ fim: Fim do intervalo, em segundos na timeline. Quando None,
+ não há limite superior.
+ incluir_palavras: Quando True, inclui a lista de palavras de
+ cada fala (com início e fim próprios). Aumenta bastante o
+ tamanho da resposta — usar somente quando necessário.
+
+ Retorna:
+ Lista de dicionários com clipe, intervalo, texto, falante e
+ emoção de cada fala encontrada. Lista vazia quando não há
+ falas no intervalo.
+
+ Pode gerar:
+ ErroDeConsultaInvalida: quando ``video_id`` for vazio, ``inicio``
+ for negativo, ou ``fim`` for anterior a ``inicio``.
+ """
+ self._validar_texto_obrigatorio("video_id", video_id)
+ self._validar_intervalo(inicio, fim)
+
+ condicoes = ["video_id = ?", "fim >= ?"]
+ parametros: list = [video_id, inicio]
+ self._acrescentar_se_definido(condicoes, parametros, "inicio <= ?", fim)
+
+ linhas = self.conexao.execute(
+ f"""SELECT id, clipe_id, inicio, fim, texto, confianca, falante, emocao
+ FROM segmentos_de_transcricao
+ WHERE {self._clausula_where(condicoes)}
+ ORDER BY inicio""",
+ parametros,
+ ).fetchall()
+
+ resultado = []
+ for linha in linhas:
+ item = {
+ "clipe_id": linha["clipe_id"],
+ "inicio": linha["inicio"],
+ "fim": linha["fim"],
+ "texto": linha["texto"],
+ "falante": linha["falante"],
+ "emocao": linha["emocao"],
+ }
+ if incluir_palavras:
+ item["palavras"] = [
+ {"texto": p["texto"], "inicio": p["inicio"], "fim": p["fim"]}
+ for p in self.conexao.execute(
+ """SELECT texto, inicio, fim FROM palavras_de_transcricao
+ WHERE segmento_id = ? ORDER BY ordem""",
+ (linha["id"],),
+ ).fetchall()
+ ]
+ resultado.append(item)
+ return resultado
+
+ def consultar_clipe_no_instante(self, video_id: str, instante: float) -> dict | None:
+ """
+ Consulta qual clipe da timeline cobre um instante específico.
+
+ Parâmetros:
+ video_id: Identificador do vídeo.
+ instante: Posição na timeline, em segundos.
+
+ Retorna:
+ Um dicionário com o clipe encontrado (id, faixa, nome,
+ intervalo e arquivo de origem), ou None quando nenhum clipe
+ cobre o instante informado.
+
+ Pode gerar:
+ ErroDeConsultaInvalida: quando ``video_id`` for vazio ou
+ ``instante`` for negativo.
+ """
+ self._validar_texto_obrigatorio("video_id", video_id)
+ if instante < 0:
+ raise ErroDeConsultaInvalida("O instante consultado não pode ser negativo.")
+
+ linha = self.conexao.execute(
+ """SELECT id, faixa_id, nome, inicio_na_timeline, fim_na_timeline, arquivo
+ FROM clipes
+ WHERE video_id = ? AND inicio_na_timeline <= ? AND fim_na_timeline >= ?
+ LIMIT 1""",
+ (video_id, instante, instante),
+ ).fetchone()
+ if linha is None:
+ return None
+ return {
+ "clipe_id": linha["id"], "faixa_id": linha["faixa_id"], "nome": linha["nome"],
+ "inicio": linha["inicio_na_timeline"], "fim": linha["fim_na_timeline"],
+ "arquivo": linha["arquivo"],
+ }
+
+ def listar_retakes_do_video(self, video_id: str) -> list[dict]:
+ """
+ Lista o resumo dos grupos de retake de um vídeo.
+
+ Não traz as tomadas nem as evidências completas de cada grupo —
+ use ``consultar_detalhes_do_retake`` para descer a esse nível
+ quando o agente já tiver decidido qual grupo analisar.
+
+ Parâmetros:
+ video_id: Identificador do vídeo.
+
+ Retorna:
+ Lista de dicionários com o resumo de cada grupo (tipo,
+ confiança, total de tomadas, intervalo coberto e a tomada de
+ maior confiança). Lista vazia quando o vídeo não tem retakes.
+
+ Pode gerar:
+ ErroDeConsultaInvalida: quando ``video_id`` for vazio.
+ """
+ self._validar_texto_obrigatorio("video_id", video_id)
+
+ grupos = self.conexao.execute(
+ """SELECT id, tipo, confianca FROM grupos_de_retake
+ WHERE video_id = ? ORDER BY criado_em""",
+ (video_id,),
+ ).fetchall()
+ resultado = []
+ for grupo in grupos:
+ extremos = self.conexao.execute(
+ """SELECT COUNT(*) AS n, MIN(inicio) AS inicio, MAX(fim) AS fim
+ FROM tomadas_de_retake WHERE grupo_id = ?""",
+ (grupo["id"],),
+ ).fetchone()
+ tomada_principal = self.conexao.execute(
+ """SELECT fala_id, inicio, fim FROM tomadas_de_retake
+ WHERE grupo_id = ? ORDER BY confianca DESC LIMIT 1""",
+ (grupo["id"],),
+ ).fetchone()
+ resultado.append({
+ "grupo_id": grupo["id"], "tipo": grupo["tipo"], "confianca": grupo["confianca"],
+ "total_tomadas": extremos["n"], "inicio": extremos["inicio"],
+ "fim": extremos["fim"],
+ "tomada_principal": (
+ {"fala_id": tomada_principal["fala_id"], "inicio": tomada_principal["inicio"],
+ "fim": tomada_principal["fim"]} if tomada_principal else None
+ ),
+ })
+ return resultado
+
+ def consultar_detalhes_do_retake(self, grupo_id: str) -> dict | None:
+ """
+ Consulta as tomadas e evidências completas de um grupo de retake específico.
+
+ Parâmetros:
+ grupo_id: Identificador do grupo de retake (ex.: ``retake_421fa7``).
+
+ Retorna:
+ Um dicionário com tipo, confiança, a lista completa de
+ tomadas (ordem, fala, intervalo, texto) e a lista completa de
+ evidências (tipo, descrição, valor) do grupo, ou None quando
+ o grupo não existe.
+
+ Pode gerar:
+ ErroDeConsultaInvalida: quando ``grupo_id`` for vazio.
+ """
+ self._validar_texto_obrigatorio("grupo_id", grupo_id)
+
+ grupo = self.conexao.execute(
+ "SELECT id, video_id, faixa_id, tipo, confianca FROM grupos_de_retake WHERE id = ?",
+ (grupo_id,),
+ ).fetchone()
+ if grupo is None:
+ return None
+ tomadas = [
+ {"ordem": t["ordem"], "fala_id": t["fala_id"], "inicio": t["inicio"], "fim": t["fim"],
+ "texto": t["texto"], "confianca": t["confianca"]}
+ for t in self.conexao.execute(
+ """SELECT ordem, fala_id, inicio, fim, texto, confianca
+ FROM tomadas_de_retake WHERE grupo_id = ? ORDER BY ordem""",
+ (grupo_id,),
+ ).fetchall()
+ ]
+ evidencias = [
+ {"tipo": e["tipo"], "descricao": e["descricao"], "valor": e["valor"]}
+ for e in self.conexao.execute(
+ "SELECT tipo, descricao, valor FROM evidencias_de_retake WHERE grupo_id = ?",
+ (grupo_id,),
+ ).fetchall()
+ ]
+ return {
+ "grupo_id": grupo["id"], "tipo": grupo["tipo"], "confianca": grupo["confianca"],
+ "tomadas": tomadas, "evidencias": evidencias,
+ }
+
+ def listar_evidencias_visuais_do_clipe(
+ self,
+ video_id: str,
+ clipe_id: str,
+ tipo: str | None = None,
+ confianca_minima: float | None = None,
+ ) -> list[dict]:
+ """
+ Lista as evidências visuais de um clipe (qualidade, rosto, composição, tremor...).
+
+ Parâmetros:
+ video_id: Identificador do vídeo.
+ clipe_id: Identificador do clipe dentro da timeline do vídeo.
+ tipo: Quando informado, filtra só as evidências desse tipo
+ (ex.: ``"qualidade"``, ``"rosto"``). Recomendado sempre
+ que o agente já souber o que procura, para reduzir o
+ tamanho da resposta.
+ confianca_minima: Quando informado, descarta evidências com
+ confiança abaixo desse valor.
+
+ Retorna:
+ Lista de dicionários com tipo, intervalo, valor (payload
+ próprio de cada analisador), confiança e provider de cada
+ evidência. Lista vazia quando não há evidências que atendam
+ ao filtro.
+
+ Pode gerar:
+ ErroDeConsultaInvalida: quando ``video_id``/``clipe_id`` forem
+ vazios ou ``confianca_minima`` estiver fora de [0.0, 1.0].
+ """
+ self._validar_texto_obrigatorio("video_id", video_id)
+ self._validar_texto_obrigatorio("clipe_id", clipe_id)
+ self._validar_confianca(confianca_minima)
+
+ condicoes = ["video_id = ?", "clipe_id = ?"]
+ parametros: list = [video_id, clipe_id]
+ self._acrescentar_se_definido(condicoes, parametros, "tipo = ?", tipo)
+ self._acrescentar_se_definido(
+ condicoes, parametros, "(confianca IS NULL OR confianca >= ?)", confianca_minima)
+
+ linhas = self.conexao.execute(
+ f"""SELECT tipo, inicio, fim, valor, confianca, provider
+ FROM evidencias_visuais
+ WHERE {self._clausula_where(condicoes)}
+ ORDER BY inicio""",
+ parametros,
+ ).fetchall()
+ return [
+ {"tipo": linha["tipo"], "inicio": linha["inicio"], "fim": linha["fim"],
+ "valor": json.loads(linha["valor"]), "confianca": linha["confianca"],
+ "provider": linha["provider"]}
+ for linha in linhas
+ ]
+
+ def listar_cenas_do_video(self, video_id: str, clipe_id: str | None = None) -> list[dict]:
+ """
+ Lista os cortes de cena detectados no vídeo, ou só de um clipe específico.
+
+ Parâmetros:
+ video_id: Identificador do vídeo.
+ clipe_id: Quando informado, restringe o resultado às cenas
+ desse clipe. Quando None, traz as cenas do vídeo inteiro.
+
+ Retorna:
+ Lista de dicionários com clipe, intervalo e confiança de cada
+ cena, ordenados pela posição na timeline. Lista vazia quando
+ não há cenas detectadas.
+
+ Pode gerar:
+ ErroDeConsultaInvalida: quando ``video_id`` for vazio.
+ """
+ self._validar_texto_obrigatorio("video_id", video_id)
+
+ condicoes = ["video_id = ?"]
+ parametros: list = [video_id]
+ self._acrescentar_se_definido(condicoes, parametros, "clipe_id = ?", clipe_id)
+
+ linhas = self.conexao.execute(
+ f"""SELECT clipe_id, inicio, fim, confianca FROM cenas
+ WHERE {self._clausula_where(condicoes)} ORDER BY inicio""",
+ parametros,
+ ).fetchall()
+ return [
+ {"clipe_id": linha["clipe_id"], "inicio": linha["inicio"], "fim": linha["fim"],
+ "confianca": linha["confianca"]}
+ for linha in linhas
+ ]
+
+ @staticmethod
+ def _clausula_where(condicoes: list[str]) -> str:
+ """Monta a cláusula ``WHERE`` a partir das condições já validadas."""
+ return " AND ".join(condicoes)
+
+ @staticmethod
+ def _acrescentar_se_definido(
+ condicoes: list[str], parametros: list, fragmento_sql: str, valor: object,
+ ) -> None:
+ """
+ Acrescenta um filtro opcional à consulta, apenas quando o valor foi informado.
+
+ Centraliza o padrão repetido de "só filtra por este campo quando
+ o chamador passou um valor", usado por várias consultas deste
+ módulo para evitar duplicar a montagem do ``WHERE`` dinâmico.
+
+ Parâmetros:
+ condicoes: Lista de condições SQL já acumuladas (alterada
+ no local).
+ parametros: Lista de parâmetros posicionais já acumulados
+ (alterada no local).
+ fragmento_sql: Condição SQL com um único placeholder ``?``.
+ valor: Valor do filtro. Quando None, nada é acrescentado.
+ """
+ if valor is not None:
+ condicoes.append(fragmento_sql)
+ parametros.append(valor)
+
+ @staticmethod
+ def _validar_texto_obrigatorio(nome_do_campo: str, valor: str) -> None:
+ """
+ Garante que um identificador obrigatório foi informado e não é vazio.
+
+ Parâmetros:
+ nome_do_campo: Nome do parâmetro, usado na mensagem de erro.
+ valor: Valor recebido para o campo.
+
+ Pode gerar:
+ ErroDeConsultaInvalida: quando ``valor`` for None, vazio ou
+ composto só por espaços.
+ """
+ if not valor or not str(valor).strip():
+ raise ErroDeConsultaInvalida(
+ f"O campo '{nome_do_campo}' é obrigatório e não pode ser vazio.")
+
+ @staticmethod
+ def _validar_intervalo(inicio: float, fim: float | None) -> None:
+ """
+ Garante que um intervalo de tempo é consistente.
+
+ Parâmetros:
+ inicio: Início do intervalo, em segundos.
+ fim: Fim do intervalo, em segundos, ou None quando não há limite.
+
+ Pode gerar:
+ ErroDeConsultaInvalida: quando ``inicio`` for negativo ou
+ ``fim`` for anterior a ``inicio``.
+ """
+ if inicio < 0:
+ raise ErroDeConsultaInvalida("O início do intervalo não pode ser negativo.")
+ if fim is not None and fim < inicio:
+ raise ErroDeConsultaInvalida("O fim do intervalo não pode ser anterior ao início.")
+
+ @staticmethod
+ def _validar_confianca(confianca_minima: float | None) -> None:
+ """
+ Garante que um limiar de confiança está dentro da faixa válida.
+
+ Parâmetros:
+ confianca_minima: Valor a validar, ou None quando não informado.
+
+ Pode gerar:
+ ErroDeConsultaInvalida: quando o valor estiver fora de
+ [0.0, 1.0].
+ """
+ if confianca_minima is not None and not (0.0 <= confianca_minima <= 1.0):
+ raise ErroDeConsultaInvalida("confianca_minima deve estar entre 0.0 e 1.0.")
diff --git a/code/engine/persistencia/esquema.py b/code/engine/persistencia/esquema.py
new file mode 100644
index 0000000..b2a75b3
--- /dev/null
+++ b/code/engine/persistencia/esquema.py
@@ -0,0 +1,166 @@
+"""DDL do banco SQLite de análises (vídeo, imagem e áudio)."""
+
+from __future__ import annotations
+
+import sqlite3
+
+_DDL = """
+CREATE TABLE IF NOT EXISTS videos (
+ id TEXT PRIMARY KEY,
+ nome TEXT,
+ duracao REAL,
+ taxa_de_quadros REAL,
+ largura INTEGER,
+ altura INTEGER,
+ criado_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+);
+
+CREATE TABLE IF NOT EXISTS faixas (
+ id TEXT NOT NULL,
+ video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
+ nome TEXT,
+ tipo TEXT,
+ indice INTEGER,
+ PRIMARY KEY (video_id, id)
+);
+
+CREATE TABLE IF NOT EXISTS clipes (
+ id TEXT NOT NULL,
+ video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
+ faixa_id TEXT NOT NULL,
+ nome TEXT,
+ inicio_na_timeline REAL NOT NULL,
+ fim_na_timeline REAL NOT NULL,
+ inicio_na_origem REAL,
+ fim_na_origem REAL,
+ arquivo TEXT,
+ offline INTEGER NOT NULL DEFAULT 0,
+ metadados TEXT,
+ PRIMARY KEY (video_id, id),
+ FOREIGN KEY (video_id, faixa_id) REFERENCES faixas(video_id, id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_clipes_faixa ON clipes(video_id, faixa_id);
+
+CREATE TABLE IF NOT EXISTS arquivos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ video_id TEXT REFERENCES videos(id) ON DELETE CASCADE,
+ caminho TEXT NOT NULL,
+ formato TEXT,
+ duracao REAL,
+ codec_de_video TEXT,
+ codec_de_audio TEXT,
+ largura INTEGER,
+ altura INTEGER,
+ taxa_de_quadros REAL,
+ canais_de_audio INTEGER,
+ taxa_de_amostragem INTEGER,
+ tamanho_em_bytes INTEGER,
+ orientacao TEXT,
+ timecode TEXT,
+ hash_do_conteudo TEXT,
+ UNIQUE (video_id, caminho)
+);
+
+CREATE TABLE IF NOT EXISTS analises_versao (
+ video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
+ etapa TEXT NOT NULL,
+ hash_versao TEXT NOT NULL,
+ concluido_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+ PRIMARY KEY (video_id, etapa)
+);
+
+CREATE TABLE IF NOT EXISTS segmentos_de_transcricao (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
+ clipe_id TEXT NOT NULL,
+ inicio REAL NOT NULL,
+ fim REAL NOT NULL,
+ texto TEXT NOT NULL,
+ confianca REAL,
+ falante TEXT,
+ voz_aparente TEXT,
+ confianca_voz REAL,
+ emocao TEXT,
+ confianca_emocao REAL,
+ caracteristicas_acusticas TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_segmentos_video_clipe
+ ON segmentos_de_transcricao(video_id, clipe_id);
+
+CREATE TABLE IF NOT EXISTS palavras_de_transcricao (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ segmento_id INTEGER NOT NULL REFERENCES segmentos_de_transcricao(id) ON DELETE CASCADE,
+ ordem INTEGER NOT NULL,
+ texto TEXT NOT NULL,
+ inicio REAL NOT NULL,
+ fim REAL NOT NULL,
+ confianca REAL,
+ falante TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_palavras_segmento ON palavras_de_transcricao(segmento_id);
+
+CREATE TABLE IF NOT EXISTS evidencias_visuais (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
+ clipe_id TEXT NOT NULL,
+ tipo TEXT NOT NULL,
+ inicio REAL NOT NULL,
+ fim REAL NOT NULL,
+ valor TEXT NOT NULL,
+ confianca REAL,
+ provider TEXT NOT NULL,
+ modelo TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_evidencias_video_clipe
+ ON evidencias_visuais(video_id, clipe_id);
+
+CREATE TABLE IF NOT EXISTS cenas (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
+ clipe_id TEXT,
+ inicio REAL NOT NULL,
+ fim REAL NOT NULL,
+ confianca REAL,
+ referencias TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_cenas_video ON cenas(video_id);
+
+CREATE TABLE IF NOT EXISTS grupos_de_retake (
+ id TEXT PRIMARY KEY,
+ video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
+ faixa_id TEXT NOT NULL,
+ tipo TEXT NOT NULL,
+ confianca REAL NOT NULL,
+ criado_em TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_grupos_video ON grupos_de_retake(video_id);
+
+CREATE TABLE IF NOT EXISTS tomadas_de_retake (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ grupo_id TEXT NOT NULL REFERENCES grupos_de_retake(id) ON DELETE CASCADE,
+ ordem INTEGER NOT NULL,
+ fala_id TEXT NOT NULL,
+ segmento_id TEXT NOT NULL,
+ inicio REAL NOT NULL,
+ fim REAL NOT NULL,
+ texto TEXT NOT NULL,
+ similaridade_com_anterior REAL NOT NULL,
+ confianca REAL NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_tomadas_grupo ON tomadas_de_retake(grupo_id);
+
+CREATE TABLE IF NOT EXISTS evidencias_de_retake (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ grupo_id TEXT NOT NULL REFERENCES grupos_de_retake(id) ON DELETE CASCADE,
+ tipo TEXT NOT NULL,
+ descricao TEXT NOT NULL,
+ valor REAL
+);
+CREATE INDEX IF NOT EXISTS idx_evidencias_retake_grupo ON evidencias_de_retake(grupo_id);
+"""
+
+
+def criar_esquema(conexao: sqlite3.Connection) -> None:
+ """Cria (de forma idempotente) todas as tabelas do banco de análises."""
+ conexao.executescript(_DDL)
+ conexao.commit()
diff --git a/code/engine/persistencia/repositorio_de_analises_sqlite.py b/code/engine/persistencia/repositorio_de_analises_sqlite.py
new file mode 100644
index 0000000..3462278
--- /dev/null
+++ b/code/engine/persistencia/repositorio_de_analises_sqlite.py
@@ -0,0 +1,145 @@
+"""Persistência SQLite de metadados de arquivo, transcrição e evidências visuais.
+
+Complementa ``RepositorioDeRetakesSQLite`` gravando o restante do que o
+``ContextoDeAnalise`` do pipeline produz e que hoje só vive em memória
+(metadados técnicos) ou em cache JSON por arquivo (transcrição).
+"""
+
+from __future__ import annotations
+
+import json
+import sqlite3
+from pathlib import Path
+from typing import Iterable
+
+from ..integracoes.midia.extracao_de_metadados import MetadadosDoArquivo
+from ..scanner.modelos import Cena, EvidenciaVisual, PalavraDeTranscricao, SegmentoDeTranscricao
+from ..scanner.transcricao_da_timeline import TranscricaoDoClipe
+from .conexao import abrir_banco
+
+
+class RepositorioDeAnalisesSQLite:
+ """Grava metadados de arquivo, transcrição e evidências/cenas visuais."""
+
+ def __init__(self, banco: str | Path | sqlite3.Connection = ".jhonny/analises.db") -> None:
+ self.conexao = banco if isinstance(banco, sqlite3.Connection) else abrir_banco(banco)
+
+ def _garantir_video(self, video_id: str) -> None:
+ self.conexao.execute("INSERT OR IGNORE INTO videos (id) VALUES (?)", (video_id,))
+
+ def registrar_metadados_de_arquivo(
+ self, video_id: str, metadados: MetadadosDoArquivo, hash_do_conteudo: str | None = None,
+ ) -> None:
+ with self.conexao:
+ self._garantir_video(video_id)
+ self.conexao.execute(
+ """INSERT INTO arquivos
+ (video_id, caminho, formato, duracao, codec_de_video, codec_de_audio,
+ largura, altura, taxa_de_quadros, canais_de_audio, taxa_de_amostragem,
+ tamanho_em_bytes, orientacao, timecode, hash_do_conteudo)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (video_id, caminho) DO UPDATE SET
+ formato = excluded.formato, duracao = excluded.duracao,
+ codec_de_video = excluded.codec_de_video,
+ codec_de_audio = excluded.codec_de_audio,
+ largura = excluded.largura, altura = excluded.altura,
+ taxa_de_quadros = excluded.taxa_de_quadros,
+ canais_de_audio = excluded.canais_de_audio,
+ taxa_de_amostragem = excluded.taxa_de_amostragem,
+ tamanho_em_bytes = excluded.tamanho_em_bytes,
+ orientacao = excluded.orientacao, timecode = excluded.timecode,
+ hash_do_conteudo = excluded.hash_do_conteudo""",
+ (video_id, str(metadados.caminho), metadados.formato, metadados.duracao,
+ metadados.codec_de_video, metadados.codec_de_audio, metadados.largura,
+ metadados.altura, metadados.taxa_de_quadros, metadados.canais_de_audio,
+ metadados.taxa_de_amostragem, metadados.tamanho_em_bytes,
+ metadados.orientacao, metadados.timecode, hash_do_conteudo),
+ )
+
+ def registrar_transcricoes(
+ self, video_id: str, transcricoes: Iterable[TranscricaoDoClipe],
+ ) -> None:
+ with self.conexao:
+ self._garantir_video(video_id)
+ for transcricao in transcricoes:
+ for segmento in transcricao.segmentos:
+ self._inserir_segmento(video_id, transcricao.identificador_do_clipe, segmento)
+
+ def _inserir_segmento(
+ self, video_id: str, clipe_id: str, segmento: SegmentoDeTranscricao,
+ ) -> int:
+ cursor = self.conexao.execute(
+ """INSERT INTO segmentos_de_transcricao
+ (video_id, clipe_id, inicio, fim, texto, confianca, falante, voz_aparente,
+ confianca_voz, emocao, confianca_emocao, caracteristicas_acusticas)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (video_id, clipe_id, segmento.inicio, segmento.fim, segmento.texto,
+ segmento.confianca, segmento.falante, segmento.voz_aparente,
+ segmento.confianca_voz, segmento.emocao, segmento.confianca_emocao,
+ json.dumps(segmento.caracteristicas_acusticas, ensure_ascii=False)),
+ )
+ segmento_id = cursor.lastrowid
+ for ordem, palavra in enumerate(segmento.palavras):
+ self.conexao.execute(
+ """INSERT INTO palavras_de_transcricao
+ (segmento_id, ordem, texto, inicio, fim, confianca, falante)
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
+ (segmento_id, ordem, palavra.texto, palavra.inicio, palavra.fim,
+ palavra.confianca, palavra.falante),
+ )
+ return segmento_id
+
+ def carregar_transcricoes(self, video_id: str, clipe_id: str) -> list[SegmentoDeTranscricao]:
+ segmentos: list[SegmentoDeTranscricao] = []
+ for linha in self.conexao.execute(
+ """SELECT * FROM segmentos_de_transcricao
+ WHERE video_id = ? AND clipe_id = ? ORDER BY inicio""",
+ (video_id, clipe_id),
+ ).fetchall():
+ palavras = tuple(
+ PalavraDeTranscricao(
+ texto=item["texto"], inicio=item["inicio"], fim=item["fim"],
+ confianca=item["confianca"], falante=item["falante"],
+ )
+ for item in self.conexao.execute(
+ "SELECT * FROM palavras_de_transcricao WHERE segmento_id = ? ORDER BY ordem",
+ (linha["id"],),
+ ).fetchall()
+ )
+ segmentos.append(SegmentoDeTranscricao(
+ inicio=linha["inicio"], fim=linha["fim"], texto=linha["texto"],
+ confianca=linha["confianca"], palavras=palavras,
+ emocao=linha["emocao"], confianca_emocao=linha["confianca_emocao"],
+ caracteristicas_acusticas=json.loads(linha["caracteristicas_acusticas"] or "{}"),
+ falante=linha["falante"], voz_aparente=linha["voz_aparente"],
+ confianca_voz=linha["confianca_voz"],
+ ))
+ return segmentos
+
+ def registrar_evidencias_visuais(
+ self, video_id: str, clipe_id: str, evidencias: Iterable[EvidenciaVisual],
+ ) -> None:
+ with self.conexao:
+ self._garantir_video(video_id)
+ for evidencia in evidencias:
+ self.conexao.execute(
+ """INSERT INTO evidencias_visuais
+ (video_id, clipe_id, tipo, inicio, fim, valor, confianca, provider, modelo)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (video_id, clipe_id, evidencia.tipo, evidencia.inicio, evidencia.fim,
+ json.dumps(evidencia.valor, ensure_ascii=False), evidencia.confianca,
+ evidencia.provider, evidencia.modelo),
+ )
+
+ def registrar_cenas(
+ self, video_id: str, cenas: Iterable[Cena], clipe_id: str | None = None,
+ ) -> None:
+ with self.conexao:
+ self._garantir_video(video_id)
+ for cena in cenas:
+ self.conexao.execute(
+ """INSERT INTO cenas (video_id, clipe_id, inicio, fim, confianca, referencias)
+ VALUES (?, ?, ?, ?, ?, ?)""",
+ (video_id, clipe_id, cena.inicio, cena.fim, cena.confianca,
+ json.dumps(list(cena.referencias))),
+ )
diff --git a/code/engine/persistencia/repositorio_de_retakes_sqlite.py b/code/engine/persistencia/repositorio_de_retakes_sqlite.py
new file mode 100644
index 0000000..598c7a2
--- /dev/null
+++ b/code/engine/persistencia/repositorio_de_retakes_sqlite.py
@@ -0,0 +1,147 @@
+"""Persistência SQLite dos grupos de retakes.
+
+Implementação alternativa a ``scanner/retakes/repositorio_de_retakes.RepositorioDeRetakes``,
+com a mesma interface pública (``registrar``/``carregar``/``ja_analisado``), gravando nas
+tabelas ``grupos_de_retake``, ``tomadas_de_retake`` e ``evidencias_de_retake`` e usando
+``analises_versao`` (etapa ``"retakes"``) para a idempotência por hash de versão.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import sqlite3
+from pathlib import Path
+from typing import Iterable
+
+from ..scanner.retakes.modelos_de_retakes import EvidenciaDeRetake, GrupoDeRetake, TomadaDeRetake
+from .conexao import abrir_banco
+
+VERSAO_REGRA_FALAS = 1
+VERSAO_REGRA_TEXTO = 1
+VERSAO_MODELO_PADRAO = "lexico"
+ETAPA = "retakes"
+
+
+class RepositorioDeRetakesSQLite:
+ """Persiste e recupera grupos de retakes em um banco SQLite."""
+
+ def __init__(
+ self,
+ banco: str | Path | sqlite3.Connection = ".jhonny/analises.db",
+ versao_transcricao: str = "1",
+ versao_regras: str = f"regras:{VERSAO_REGRA_FALAS}.{VERSAO_REGRA_TEXTO}",
+ versao_modelo: str = VERSAO_MODELO_PADRAO,
+ ) -> None:
+ self.conexao = banco if isinstance(banco, sqlite3.Connection) else abrir_banco(banco)
+ self.versao_transcricao = versao_transcricao
+ self.versao_regras = versao_regras
+ self.versao_modelo = versao_modelo
+
+ def _hash_de_identificacao(self, video_id: str, versao_visual: str = "sem_visual") -> str:
+ origem = "|".join([video_id, self.versao_transcricao, self.versao_regras,
+ self.versao_modelo, versao_visual])
+ return hashlib.sha256(origem.encode("utf-8")).hexdigest()[:12]
+
+ def ja_analisado(self, video_id: str, versao_visual: str = "sem_visual") -> bool:
+ """Verdadeiro se o vídeo já foi analisado com exatamente estas versões."""
+ linha = self.conexao.execute(
+ "SELECT hash_versao FROM analises_versao WHERE video_id = ? AND etapa = ?",
+ (video_id, ETAPA),
+ ).fetchone()
+ if linha is None:
+ return False
+ return linha["hash_versao"] == self._hash_de_identificacao(video_id, versao_visual)
+
+ def registrar(
+ self,
+ video_id: str,
+ grupos: Iterable[GrupoDeRetake],
+ versao_visual: str = "sem_visual",
+ ) -> None:
+ """Grava os grupos de forma idempotente (substitui os grupos anteriores do vídeo)."""
+ grupos = list(grupos)
+ with self.conexao:
+ self.conexao.execute(
+ "INSERT OR IGNORE INTO videos (id) VALUES (?)", (video_id,)
+ )
+ self.conexao.execute(
+ "DELETE FROM grupos_de_retake WHERE video_id = ?", (video_id,)
+ )
+ for grupo in grupos:
+ self.conexao.execute(
+ """INSERT INTO grupos_de_retake
+ (id, video_id, faixa_id, tipo, confianca, criado_em)
+ VALUES (?, ?, ?, ?, ?, ?)""",
+ (grupo.id, grupo.video_id, grupo.faixa_id, grupo.tipo,
+ grupo.confianca, grupo.criado_em),
+ )
+ for tomada in grupo.tomadas:
+ self.conexao.execute(
+ """INSERT INTO tomadas_de_retake
+ (grupo_id, ordem, fala_id, segmento_id, inicio, fim, texto,
+ similaridade_com_anterior, confianca)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (grupo.id, tomada.ordem, tomada.fala_id, tomada.segmento_id,
+ tomada.inicio, tomada.fim, tomada.texto,
+ tomada.similaridade_com_anterior, tomada.confianca),
+ )
+ for evidencia in grupo.evidencias:
+ self.conexao.execute(
+ """INSERT INTO evidencias_de_retake (grupo_id, tipo, descricao, valor)
+ VALUES (?, ?, ?, ?)""",
+ (grupo.id, evidencia.tipo, evidencia.descricao, evidencia.valor),
+ )
+ self.conexao.execute(
+ """INSERT INTO analises_versao (video_id, etapa, hash_versao)
+ VALUES (?, ?, ?)
+ ON CONFLICT (video_id, etapa)
+ DO UPDATE SET hash_versao = excluded.hash_versao,
+ concluido_em = strftime('%Y-%m-%dT%H:%M:%fZ','now')""",
+ (video_id, ETAPA, self._hash_de_identificacao(video_id, versao_visual)),
+ )
+
+ def carregar(self, video_id: str) -> list[GrupoDeRetake]:
+ """Carrega os grupos já persistidos para o vídeo."""
+ linhas_de_grupos = self.conexao.execute(
+ "SELECT * FROM grupos_de_retake WHERE video_id = ? ORDER BY criado_em",
+ (video_id,),
+ ).fetchall()
+
+ grupos: list[GrupoDeRetake] = []
+ for linha in linhas_de_grupos:
+ tomadas = [
+ TomadaDeRetake(
+ ordem=item["ordem"],
+ fala_id=item["fala_id"],
+ segmento_id=item["segmento_id"],
+ inicio=item["inicio"],
+ fim=item["fim"],
+ texto=item["texto"],
+ similaridade_com_anterior=item["similaridade_com_anterior"],
+ confianca=item["confianca"],
+ )
+ for item in self.conexao.execute(
+ "SELECT * FROM tomadas_de_retake WHERE grupo_id = ? ORDER BY ordem",
+ (linha["id"],),
+ ).fetchall()
+ ]
+ evidencias = [
+ EvidenciaDeRetake(
+ tipo=item["tipo"], descricao=item["descricao"], valor=item["valor"],
+ )
+ for item in self.conexao.execute(
+ "SELECT * FROM evidencias_de_retake WHERE grupo_id = ? ORDER BY id",
+ (linha["id"],),
+ ).fetchall()
+ ]
+ grupos.append(GrupoDeRetake(
+ id=linha["id"],
+ video_id=linha["video_id"],
+ faixa_id=linha["faixa_id"],
+ tipo=linha["tipo"],
+ confianca=linha["confianca"],
+ tomadas=tomadas,
+ evidencias=evidencias,
+ criado_em=linha["criado_em"],
+ ))
+ return grupos
diff --git a/code/engine/persistencia/repositorio_de_timeline_sqlite.py b/code/engine/persistencia/repositorio_de_timeline_sqlite.py
new file mode 100644
index 0000000..0492ca5
--- /dev/null
+++ b/code/engine/persistencia/repositorio_de_timeline_sqlite.py
@@ -0,0 +1,102 @@
+"""Persistência SQLite da estrutura da timeline (faixas e clipes).
+
+É a "coluna vertebral" que liga os `clipe_id`/`video_id` usados em
+transcrição, evidências visuais e retakes de volta à timeline real —
+para que um agente de edição possa navegar tudo por consultas SQL, sem
+precisar reabrir os arquivos de origem ou o projeto de edição.
+"""
+
+from __future__ import annotations
+
+import json
+import sqlite3
+from pathlib import Path
+
+from ..dominio.entidades.modelos import Clipe, Faixa, IntervaloDeTempo, Timeline
+from .conexao import abrir_banco
+
+
+class RepositorioDeTimelineSQLite:
+ """Grava e recarrega uma `Timeline` (faixas e clipes) por vídeo."""
+
+ def __init__(self, banco: str | Path | sqlite3.Connection = ".jhonny/analises.db") -> None:
+ self.conexao = banco if isinstance(banco, sqlite3.Connection) else abrir_banco(banco)
+
+ def registrar_timeline(self, timeline: Timeline) -> None:
+ """Grava a timeline de forma idempotente (substitui faixas/clipes anteriores)."""
+ video_id = timeline.identificador
+ with self.conexao:
+ self.conexao.execute(
+ """INSERT INTO videos (id, nome, duracao, taxa_de_quadros, largura, altura)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT (id) DO UPDATE SET
+ nome = excluded.nome, duracao = excluded.duracao,
+ taxa_de_quadros = excluded.taxa_de_quadros,
+ largura = excluded.largura, altura = excluded.altura""",
+ (video_id, timeline.nome, timeline.duracao, timeline.taxa_de_quadros,
+ timeline.largura, timeline.altura),
+ )
+ # CASCADE em clipes/faixas cuida da limpeza ao apagar as faixas do vídeo.
+ self.conexao.execute("DELETE FROM faixas WHERE video_id = ?", (video_id,))
+ for faixa in timeline.faixas:
+ self.conexao.execute(
+ "INSERT INTO faixas (id, video_id, nome, tipo, indice) VALUES (?, ?, ?, ?, ?)",
+ (faixa.identificador, video_id, faixa.nome, faixa.tipo, faixa.indice),
+ )
+ for clipe in faixa.clipes:
+ origem = clipe.intervalo_na_origem
+ self.conexao.execute(
+ """INSERT INTO clipes
+ (id, video_id, faixa_id, nome, inicio_na_timeline, fim_na_timeline,
+ inicio_na_origem, fim_na_origem, arquivo, offline, metadados)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (clipe.identificador, video_id, faixa.identificador, clipe.nome,
+ clipe.intervalo_na_timeline.inicio, clipe.intervalo_na_timeline.fim,
+ origem.inicio if origem else None, origem.fim if origem else None,
+ clipe.arquivo, int(clipe.offline),
+ json.dumps(clipe.metadados, ensure_ascii=False)),
+ )
+
+ def carregar_timeline(self, video_id: str) -> Timeline | None:
+ """Reconstrói a `Timeline` (faixas e clipes) a partir do banco."""
+ video = self.conexao.execute(
+ "SELECT * FROM videos WHERE id = ?", (video_id,),
+ ).fetchone()
+ if video is None:
+ return None
+
+ faixas: list[Faixa] = []
+ for linha_faixa in self.conexao.execute(
+ "SELECT * FROM faixas WHERE video_id = ? ORDER BY indice", (video_id,),
+ ).fetchall():
+ clipes = [
+ Clipe(
+ identificador=item["id"],
+ nome=item["nome"],
+ intervalo_na_timeline=IntervaloDeTempo(
+ item["inicio_na_timeline"], item["fim_na_timeline"]),
+ intervalo_na_origem=(
+ IntervaloDeTempo(item["inicio_na_origem"], item["fim_na_origem"])
+ if item["inicio_na_origem"] is not None else None
+ ),
+ arquivo=item["arquivo"],
+ identificador_da_faixa=item["faixa_id"],
+ offline=bool(item["offline"]),
+ metadados=json.loads(item["metadados"] or "{}"),
+ )
+ for item in self.conexao.execute(
+ "SELECT * FROM clipes WHERE video_id = ? AND faixa_id = ? "
+ "ORDER BY inicio_na_timeline",
+ (video_id, linha_faixa["id"]),
+ ).fetchall()
+ ]
+ faixas.append(Faixa(
+ identificador=linha_faixa["id"], nome=linha_faixa["nome"],
+ tipo=linha_faixa["tipo"], indice=linha_faixa["indice"], clipes=clipes,
+ ))
+
+ return Timeline(
+ identificador=video["id"], nome=video["nome"], duracao=video["duracao"],
+ taxa_de_quadros=video["taxa_de_quadros"], largura=video["largura"],
+ altura=video["altura"], faixas=faixas,
+ )
diff --git a/code/engine/scanner/__init__.py b/code/engine/scanner/__init__.py
index dc2a4bc..c4290df 100644
--- a/code/engine/scanner/__init__.py
+++ b/code/engine/scanner/__init__.py
@@ -9,9 +9,13 @@ from .visual import (AnaliseVisualDaTimeline, DetectorDeCenas, ResultadoVisualDo
criar_detector_apple_vision, criar_detector_visual_local)
from .relatorio_visual import gerar_relatorio_visual, gerar_resumo_visual_markdown
from .configuracao_visual import ConfiguracaoVisualDoScanner
+from .retakes import (AgrupadorDeTakes, CoordenadorDeRetakes, DeteccaoDeRetakes,
+ Fala, GrupoDeRetake)
__all__ = ["AnaliseVisualDaTimeline", "AnalisadorDeTimeline", "ArquivoDescoberto",
- "Cena", "CenaVisual", "ContextoDeAnalise", "criar_detector_apple_vision", "criar_detector_visual_local", "DescobertaDeArquivos", "DetectorDeCenas",
+ "Cena", "CenaVisual", "ContextoDeAnalise", "criar_detector_apple_vision", "criar_detector_visual_local", "DeteccaoDeRetakes",
+ "DescobertaDeArquivos", "DetectorDeCenas",
+ "AgrupadorDeTakes", "CoordenadorDeRetakes", "Fala", "GrupoDeRetake",
"ConfiguracaoVisualDoScanner",
"EvidenciaVisual", "ErroDeAnalise", "Evento", "ExtracaoDeMetadados",
"gerar_relatorio_visual",
diff --git a/code/engine/scanner/configuracao_visual.py b/code/engine/scanner/configuracao_visual.py
index 11e3726..05adcb2 100644
--- a/code/engine/scanner/configuracao_visual.py
+++ b/code/engine/scanner/configuracao_visual.py
@@ -9,12 +9,24 @@ RECURSOS_VISION = frozenset((
"estetica", "codigos", "horizonte", "retangulos", "contornos", "segmentacao_de_pessoas",
))
+# Sensibilidades aceitas para a análise visual auxiliar e o limiar do
+# detector de cortes (PySceneDetect). Valores MENORES de limiar são mais
+# sensíveis — detectam mais cortes. O default segue o PySceneDetect (27).
+SENSIBILIDADES_VISUAIS: dict[str, float] = {
+ "muito_baixa": 45.0,
+ "baixa": 35.0,
+ "media": 27.0,
+ "alta": 20.0,
+ "muito_alta": 14.0,
+}
+
@dataclass(frozen=True)
class ConfiguracaoVisualDoScanner:
"""Interface curta para uma execução de leitura visual da timeline."""
intervalo_em_segundos: float = 1.0
+ resolucao_maxima: int = 1080
faixas_de_video: tuple[int, ...] = ()
faixas_de_audio: tuple[int, ...] = ()
recursos_vision: frozenset[str] = RECURSOS_VISION
@@ -22,21 +34,37 @@ class ConfiguracaoVisualDoScanner:
analisar_continuidade: bool = True
preparar_reenquadramento: bool = False
interpretar_cena: bool = True
+ # Análise visual auxiliar (PySceneDetect) — desligada por padrão.
+ usar_analise_visual: bool = False
+ detectar_cortes: bool = True
+ sensibilidade: str = "media"
def __post_init__(self) -> None:
if not 0.04 <= self.intervalo_em_segundos <= 60:
raise ValueError("intervalo_em_segundos deve estar entre 0,04 e 60.")
+ if self.resolucao_maxima != 0 and not 240 <= self.resolucao_maxima <= 8640:
+ raise ValueError("resolucao_maxima deve ser 0 (original) ou estar entre 240 e 8640.")
desconhecidos = self.recursos_vision - RECURSOS_VISION
if desconhecidos:
raise ValueError(f"Recursos Vision desconhecidos: {', '.join(sorted(desconhecidos))}")
if any(indice < 0 for indice in self.faixas_de_video + self.faixas_de_audio):
raise ValueError("Índices de faixa não podem ser negativos.")
+ if self.sensibilidade not in SENSIBILIDADES_VISUAIS:
+ raise ValueError(
+ f"Sensibilidade inválida: {self.sensibilidade}. "
+ f"Use um de {', '.join(SENSIBILIDADES_VISUAIS)}.")
+
+ @property
+ def limiar_de_corte(self) -> float:
+ """Limiar do ContentDetector para a sensibilidade configurada."""
+ return SENSIBILIDADES_VISUAIS[self.sensibilidade]
@classmethod
def de_dict(cls, dados: dict[str, Any]) -> "ConfiguracaoVisualDoScanner":
"""Lê o mesmo JSON produzido pelo painel, sem vazar detalhes de adapters."""
return cls(
intervalo_em_segundos=float(dados.get("intervalo_em_segundos", 1)),
+ resolucao_maxima=int(dados.get("resolucao_maxima", 1080)),
faixas_de_video=tuple(sorted(set(int(item) for item in dados.get("faixas_de_video", ())))),
faixas_de_audio=tuple(sorted(set(int(item) for item in dados.get("faixas_de_audio", ())))),
recursos_vision=frozenset(dados.get("recursos_vision", RECURSOS_VISION)),
@@ -44,12 +72,16 @@ class ConfiguracaoVisualDoScanner:
analisar_continuidade=bool(dados.get("analisar_continuidade", True)),
preparar_reenquadramento=bool(dados.get("preparar_reenquadramento", False)),
interpretar_cena=bool(dados.get("interpretar_cena", True)),
+ usar_analise_visual=bool(dados.get("usar_analise_visual", False)),
+ detectar_cortes=bool(dados.get("detectar_cortes", True)),
+ sensibilidade=str(dados.get("sensibilidade", "media")),
)
def para_dict(self) -> dict[str, Any]:
return {
"versao": 1,
"intervalo_em_segundos": self.intervalo_em_segundos,
+ "resolucao_maxima": self.resolucao_maxima,
"faixas_de_video": list(self.faixas_de_video),
"faixas_de_audio": list(self.faixas_de_audio),
"recursos_vision": sorted(self.recursos_vision),
@@ -57,4 +89,7 @@ class ConfiguracaoVisualDoScanner:
"analisar_continuidade": self.analisar_continuidade,
"preparar_reenquadramento": self.preparar_reenquadramento,
"interpretar_cena": self.interpretar_cena,
+ "usar_analise_visual": self.usar_analise_visual,
+ "detectar_cortes": self.detectar_cortes,
+ "sensibilidade": self.sensibilidade,
}
diff --git a/code/engine/scanner/coordenacao/__init__.py b/code/engine/scanner/coordenacao/__init__.py
index a59e005..d270885 100644
--- a/code/engine/scanner/coordenacao/__init__.py
+++ b/code/engine/scanner/coordenacao/__init__.py
@@ -18,6 +18,7 @@ class ContextoDeAnalise:
eventos: list[Any] = field(default_factory=list)
arquivos: dict[str, Any] = field(default_factory=dict)
metadados_dos_arquivos: dict[str, Any] = field(default_factory=dict)
+ retakes: Any = None
class Analisador(Protocol):
diff --git a/code/engine/scanner/retakes/__init__.py b/code/engine/scanner/retakes/__init__.py
new file mode 100644
index 0000000..58ba270
--- /dev/null
+++ b/code/engine/scanner/retakes/__init__.py
@@ -0,0 +1,92 @@
+"""Submódulo de detecção de retakes do scanner.
+
+Analisa a transcrição já existente para identificar possíveis retakes —
+repetições, reinícios e tomadas alternativas da mesma fala — gerando dados
+estruturados para a revisão na interface. Não corta, não apaga e não decide
+qual tomada usar.
+"""
+
+from .agrupador_de_takes import AgrupadorDeTakes
+from .analisador_de_intervalos import AnalisadorDeIntervalos
+from .classificador_de_retakes import (
+ CONTINUIDADE_DE_FALA,
+ POSSIVEL_RETAKE,
+ REPETICAO_DE_FRASE,
+ REPETICAO_DE_INICIO,
+ REPETICAO_PARCIAL,
+ RETAKE_CONFIRMADO,
+ SEM_INDICIO,
+ ClassificadorDeRetakes,
+ RegrasDeClassificacao,
+)
+from .carregador_de_artefatos import (
+ ArtefatoDeTranscricaoInvalido,
+ CarregadorDeArtefatosDeAudio,
+)
+from .comparador_de_falas import ComparadorDeFalas
+from .comparador_semantico import ComparadorSemantico
+from .coordenador_de_retakes import CoordenadorDeRetakes, ResultadoDaAnaliseDeRetakes
+from .deteccao_de_retakes import DeteccaoDeRetakes
+from .detector_de_reinicios import DetectorDeReinicios
+from .gerador_de_evidencias import GeradorDeEvidencias
+from .modelos_de_retakes import (
+ AgrupamentoDeFalas,
+ ClassificacaoDeRetake,
+ EvidenciaDeRetake,
+ Fala,
+ GrupoDeRetake,
+ ParAgrupado,
+ ResultadoDaComparacao,
+ ResultadoDoIntervalo,
+ SinalDeReinicio,
+ STATUS_CONFIRMADO,
+ STATUS_DE_REVISAO_VALIDOS,
+ STATUS_IGNORADO,
+ STATUS_PENDENTE,
+ STATUS_REJEITADO,
+ TomadaDeRetake,
+)
+from .repositorio_de_retakes import (
+ GrupoDeRetakeInexistente,
+ RepositorioDeRetakes,
+)
+
+__all__ = [
+ "AgrupadorDeTakes",
+ "AgrupamentoDeFalas",
+ "AnalisadorDeIntervalos",
+ "ArtefatoDeTranscricaoInvalido",
+ "CarregadorDeArtefatosDeAudio",
+ "ClassificacaoDeRetake",
+ "ClassificadorDeRetakes",
+ "ComparadorDeFalas",
+ "ComparadorSemantico",
+ "CONTINUIDADE_DE_FALA",
+ "CoordenadorDeRetakes",
+ "DeteccaoDeRetakes",
+ "DetectorDeReinicios",
+ "EvidenciaDeRetake",
+ "Fala",
+ "GeradorDeEvidencias",
+ "GrupoDeRetake",
+ "GrupoDeRetakeInexistente",
+ "ParAgrupado",
+ "POSSIVEL_RETAKE",
+ "RegrasDeClassificacao",
+ "REPETICAO_DE_FRASE",
+ "REPETICAO_DE_INICIO",
+ "REPETICAO_PARCIAL",
+ "RepositorioDeRetakes",
+ "ResultadoDaAnaliseDeRetakes",
+ "ResultadoDaComparacao",
+ "ResultadoDoIntervalo",
+ "RETAKE_CONFIRMADO",
+ "SEM_INDICIO",
+ "SinalDeReinicio",
+ "STATUS_CONFIRMADO",
+ "STATUS_DE_REVISAO_VALIDOS",
+ "STATUS_IGNORADO",
+ "STATUS_PENDENTE",
+ "STATUS_REJEITADO",
+ "TomadaDeRetake",
+]
\ No newline at end of file
diff --git a/code/engine/scanner/retakes/adaptador_de_falas.py b/code/engine/scanner/retakes/adaptador_de_falas.py
index 1827cce..3aee360 100644
--- a/code/engine/scanner/retakes/adaptador_de_falas.py
+++ b/code/engine/scanner/retakes/adaptador_de_falas.py
@@ -69,6 +69,11 @@ class AdaptadorDeFalas:
self.video_id = video_id
self.faixa_id = faixa_id
+ @staticmethod
+ def normalizar(texto: str) -> str:
+ """Normaliza um texto (minúsculas, sem pontuação, sem espaços duplos)."""
+ return _normalizar(texto)
+
def converter(
self,
transcricoes: Iterable[TranscricaoDoClipe],
@@ -84,20 +89,59 @@ class AdaptadorDeFalas:
for segmento in transcricao.segmentos:
if _e_ruido(segmento.texto):
continue
- falas.append(Fala(
- id=f"{segmento_id}:{segmento.inicio:.3f}",
- video_id=video_id,
- faixa_id=faixa_id,
- segmento_id=segmento_id,
- ordem=-1, # preenchida após a ordenação
- inicio=segmento.inicio,
- fim=segmento.fim,
- texto=segmento.texto,
- texto_normalizado=_normalizar(segmento.texto),
- palavras=segmento.palavras,
- falante=segmento.falante,
- ))
+ falas.append(self._fala_de_segmento(segmento, segmento_id, video_id, faixa_id))
+ return self._ordenar(falas)
+
+ def converter_de_segmentos(
+ self,
+ segmentos_por_clipe: Iterable[tuple[str, Iterable[object]]],
+ video_id: str | None = None,
+ faixa_id: str | None = None,
+ ) -> list[Fala]:
+ """Converte ``(segmento_id, [SegmentoDeTranscricao])`` em falas.
+
+ Usado quando a transcrição chega no ``ContextoDeAnalise`` do scanner
+ já como segmentos individuais (padrão do pipeline), em vez de
+ ``TranscricaoDoClipe``.
+ """
+ video_id = video_id or self.video_id or "video"
+ faixa_id = faixa_id or self.faixa_id or "faixa"
+
+ falas: list[Fala] = []
+ for segmento_id, segmentos in segmentos_por_clipe:
+ for segmento in segmentos:
+ if _e_ruido(getattr(segmento, "texto", "")):
+ continue
+ falas.append(self._fala_de_segmento(segmento, segmento_id, video_id, faixa_id))
+ return self._ordenar(falas)
+
+ @staticmethod
+ def _fala_de_segmento(
+ segmento: object,
+ segmento_id: str,
+ video_id: str,
+ faixa_id: str,
+ ) -> Fala:
+ texto = str(getattr(segmento, "texto", "")).strip()
+ inicio = float(getattr(segmento, "inicio", 0.0))
+ fim = float(getattr(segmento, "fim", inicio))
+ return Fala(
+ id=f"{segmento_id}:{inicio:.3f}",
+ video_id=video_id,
+ faixa_id=faixa_id,
+ segmento_id=segmento_id,
+ ordem=-1, # preenchida na ordenação
+ inicio=inicio,
+ fim=fim,
+ texto=texto,
+ texto_normalizado=_normalizar(texto),
+ palavras=getattr(segmento, "palavras", ()) or (),
+ falante=getattr(segmento, "falante", None),
+ )
+
+ @staticmethod
+ def _ordenar(falas: list[Fala]) -> list[Fala]:
falas.sort(key=lambda item: (item.inicio, item.fim))
for indice, fala in enumerate(falas):
falas[indice] = Fala(
diff --git a/code/engine/scanner/retakes/agrupador_de_takes.py b/code/engine/scanner/retakes/agrupador_de_takes.py
index f37d45f..8cfdf12 100644
--- a/code/engine/scanner/retakes/agrupador_de_takes.py
+++ b/code/engine/scanner/retakes/agrupador_de_takes.py
@@ -18,7 +18,6 @@ from .modelos_de_retakes import (
AgrupamentoDeFalas,
Fala,
ParAgrupado,
- ResultadoDoIntervalo,
SinalDeReinicio,
)
@@ -34,6 +33,10 @@ _JANELA_PADRAO = 5
_ZONA_DE_DUVIDA_INFERIOR = 0.60
_ZONA_DE_DUVIDA_SUPERIOR = 0.85
+# Janela após o início da segunda tentativa em que um corte visual ainda é
+# considerado "próximo" do possível retake (evidência fraca).
+_JANELA_VISUAL_PROXIMA = 3.0
+
class AgrupadorDeTakes:
"""Gera grupos candidatos conectando falas semelhantes e próximas."""
@@ -52,8 +55,13 @@ class AgrupadorDeTakes:
self.detector_de_reinicios = detector_de_reinicios
self.janela = janela
- def agrupar(self, falas: Iterable[Fala]) -> list[AgrupamentoDeFalas]:
+ def agrupar(
+ self,
+ falas: Iterable[Fala],
+ limites_de_cena: Iterable[float] | None = None,
+ ) -> list[AgrupamentoDeFalas]:
falas = sorted(falas, key=lambda item: (item.ordem, item.inicio))
+ limites = sorted(limites_de_cena) if limites_de_cena else []
sinais = {sinal.fala_id: sinal for sinal in self.detector_de_reinicios.detectar(falas)}
arestas: list[tuple[int, int, ParAgrupado]] = []
@@ -63,7 +71,7 @@ class AgrupadorDeTakes:
fala_b = falas[jindice]
if fala_a.faixa_id != fala_b.faixa_id:
continue
- par = self._avaliar_par(fala_a, fala_b, sinais)
+ par = self._avaliar_par(fala_a, fala_b, sinais, limites)
if par is not None:
arestas.append((indice, jindice, par))
@@ -74,6 +82,7 @@ class AgrupadorDeTakes:
fala_a: Fala,
fala_b: Fala,
sinais: dict[str, SinalDeReinicio],
+ limites_de_cena: list[float] | None = None,
) -> ParAgrupado | None:
comparacao = self.comparador.comparar(fala_a, fala_b)
intervalo = self.analisador_de_intervalos.analisar(fala_a, fala_b)
@@ -94,10 +103,25 @@ class AgrupadorDeTakes:
and (tem_reinicio or intervalo.indicio_de_nova_tentativa))
)
if candidato:
+ mudanca_visual = self._tem_mudanca_visual_proxima(
+ fala_a, fala_b, limites_de_cena or [])
return ParAgrupado(fala_a, fala_b, comparacao, intervalo,
- round(semantica, 4))
+ round(semantica, 4), mudanca_visual)
return None
+ @staticmethod
+ def _tem_mudanca_visual_proxima(
+ fala_a: Fala,
+ fala_b: Fala,
+ limites: list[float],
+ ) -> bool:
+ """Verdadeiro quando há um corte visual na transição entre as tentativas."""
+ janela = _JANELA_VISUAL_PROXIMA
+ for limite in limites:
+ if fala_a.fim <= limite <= fala_b.inicio + janela:
+ return True
+ return False
+
@staticmethod
def _agrupar_por_arestas(
falas: list[Fala],
diff --git a/code/engine/scanner/retakes/carregador_de_artefatos.py b/code/engine/scanner/retakes/carregador_de_artefatos.py
new file mode 100644
index 0000000..659a7e6
--- /dev/null
+++ b/code/engine/scanner/retakes/carregador_de_artefatos.py
@@ -0,0 +1,60 @@
+"""Carrega as transcrições produzidas pelo scanner para análise de retakes.
+
+O painel não retranscreve vídeos: os artefatos ``-audio-faixa-N.json``
+gerados pelo scanner já contêm os segmentos de fala por clipe. Esta classe
+converte esses artefatos em falas prontas para o ``CoordenadorDeRetakes``,
+sem duplicar a lógica de transcrição.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+from .adaptador_de_falas import AdaptadorDeFalas
+from .modelos_de_retakes import Fala
+
+
+class ArtefatoDeTranscricaoInvalido(ValueError):
+ """Erro levantado quando um artefato do scanner não tem transcrição útil."""
+
+
+class CarregadorDeArtefatosDeAudio:
+ """Lê os artefatos de transcrição do scanner e devolve falas ordenadas."""
+
+ def __init__(self, adaptador: AdaptadorDeFalas | None = None) -> None:
+ self.adaptador = adaptador or AdaptadorDeFalas()
+
+ def carregar(self, caminhos_dos_artefatos: list[str | Path],
+ video_id: str) -> list[Fala]:
+ """Converte os artefatos informados em falas prontas para análise.
+
+ Artefatos sem transcrição concluída são ignorados. Quando nenhum
+ artefato contiver segmentos, levanta ``ArtefatoDeTranscricaoInvalido``
+ para que a tela possa avisar o usuário sem executar a análise.
+ """
+ falas: list[Fala] = []
+ for caminho in caminhos_dos_artefatos:
+ falas.extend(self._falas_do_artefato(Path(caminho), video_id))
+ if not falas:
+ raise ArtefatoDeTranscricaoInvalido(
+ "Nenhum artefato de transcrição contém segmentos de fala. "
+ "Execute o Scanner com transcrição antes de analisar retakes.")
+ return falas
+
+ def _falas_do_artefato(self, caminho: Path,
+ video_id: str) -> list[Fala]:
+ """Extrai as falas de um único artefato ``-audio-faixa-N.json``."""
+ if not caminho.is_file():
+ return []
+ dados = json.loads(caminho.read_text(encoding="utf-8"))
+ if dados.get("status") != "concluida":
+ return []
+ pares: list[tuple[str, list[object]]] = []
+ for item in dados.get("segmentos_por_clipe", []):
+ segmentos = [SimpleNamespace(**segmento)
+ for segmento in item.get("segmentos", [])]
+ if segmentos:
+ pares.append((str(item.get("clipe", "clipe")), segmentos))
+ return self.adaptador.converter_de_segmentos(pares, video_id=video_id)
diff --git a/code/engine/scanner/retakes/classificador_de_retakes.py b/code/engine/scanner/retakes/classificador_de_retakes.py
new file mode 100644
index 0000000..bc5cc28
--- /dev/null
+++ b/code/engine/scanner/retakes/classificador_de_retakes.py
@@ -0,0 +1,99 @@
+"""Classificação dos grupos candidatos em tipos de retake.
+
+Transforma as métricas (similaridade textual, semântica, proximidade
+temporal, sinal de reinício e repetição do início) em um tipo e um nível de
+confiança, usando regras configuráveis. Nenhum valor isolado decide; a
+classificação combina os sinais.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from .modelos_de_retakes import AgrupamentoDeFalas, ClassificacaoDeRetake
+
+SEM_INDICIO = "sem_indicio_de_retake"
+RETAKE_CONFIRMADO = "retake_confirmado"
+POSSIVEL_RETAKE = "possivel_retake"
+REPETICAO_DE_FRASE = "repeticao_de_frase"
+REPETICAO_DE_INICIO = "repeticao_de_inicio"
+REPETICAO_PARCIAL = "repeticao_parcial"
+CONTINUIDADE_DE_FALA = "continuidade_de_fala"
+
+
+@dataclass(frozen=True)
+class RegrasDeClassificacao:
+ """Limiares e pesos configuráveis para a classificação."""
+
+ similaridade_textual_minima: float = 0.60
+ similaridade_para_confirmar: float = 0.85
+ proximidade_minima: float = 0.50
+ semantica_para_confirmar: float = 0.85
+ peso_textual: float = 0.50
+ peso_semantica: float = 0.25
+ peso_proximidade: float = 0.15
+ peso_reinicio: float = 0.10
+ # Evidência fraca: reforça a confiança, nunca decide o tipo sozinha.
+ peso_visual_fraca: float = 0.03
+
+
+class ClassificadorDeRetakes:
+ """Atribui tipo e confiança a um ``AgrupamentoDeFalas``."""
+
+ def __init__(self, regras: RegrasDeClassificacao | None = None) -> None:
+ self.regras = regras or RegrasDeClassificacao()
+
+ def classificar(self, agrupamento: AgrupamentoDeFalas) -> ClassificacaoDeRetake:
+ if not agrupamento.pares:
+ return ClassificacaoDeRetake(SEM_INDICIO, 0.0)
+
+ par = max(agrupamento.pares,
+ key=lambda item: item.comparacao.similaridade_final)
+ texto = par.comparacao.similaridade_final
+ semantica = par.similaridade_semantica
+ proximidade = par.intervalo.proximidade_temporal
+ reproducao_inicio = par.comparacao.similaridade_do_inicio >= 0.9
+ tem_reinicio = bool(agrupamento.sinais_de_reinicio)
+
+ r = self.regras
+ confianca = (
+ r.peso_textual * texto
+ + r.peso_semantica * semantica
+ + r.peso_proximidade * proximidade
+ + r.peso_reinicio * (1.0 if tem_reinicio else 0.0)
+ )
+ # A análise visual é reforço (evidência fraca): soma um pequeno peso à
+ # confiança sem jamais alterar o tipo de retake.
+ if par.mudanca_visual_proxima:
+ confianca += r.peso_visual_fraca
+
+
+ # Repetições completas e muito semelhantes de frase.
+ if texto >= r.similaridade_para_confirmar:
+ tipo = RETAKE_CONFIRMADO
+ if reproducao_inicio:
+ tipo = RETAKE_CONFIRMADO
+ elif (
+ texto >= r.similaridade_textual_minima
+ and semantica >= r.semantica_para_confirmar
+ and tem_reinicio
+ ):
+ tipo = RETAKE_CONFIRMADO
+ elif (
+ texto >= r.similaridade_textual_minima
+ and proximidade >= r.proximidade_minima
+ ):
+ tipo = POSSIVEL_RETAKE
+ elif reproducao_inicio and tem_reinicio:
+ tipo = REPETICAO_DE_INICIO
+ elif semantica >= r.semantica_para_confirmar:
+ tipo = REPETICAO_PARCIAL
+ else:
+ tipo = POSSIVEL_RETAKE
+
+ if tipo == RETAKE_CONFIRMADO:
+ confianca = min(1.0, max(confianca, texto * 0.9 + 0.05 * (1 if tem_reinicio else 0)))
+ else:
+ confianca = min(1.0, confianca)
+
+ return ClassificacaoDeRetake(tipo, round(confianca, 3))
\ No newline at end of file
diff --git a/code/engine/scanner/retakes/coordenador_de_retakes.py b/code/engine/scanner/retakes/coordenador_de_retakes.py
new file mode 100644
index 0000000..b2e99bd
--- /dev/null
+++ b/code/engine/scanner/retakes/coordenador_de_retakes.py
@@ -0,0 +1,203 @@
+"""Coordenador do módulo de detecção de retakes.
+
+Responsável por organizar o fluxo completo, delegando cada etapa a uma das
+classes do submódulo. Não implementa as regras de detecção — apenas chama
+os colaboradores na ordem correta e monta o resultado.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Iterable
+
+from ..transcricao_da_timeline import TranscricaoDoClipe
+from .adaptador_de_falas import AdaptadorDeFalas
+from .agrupador_de_takes import AgrupadorDeTakes
+from .analisador_de_intervalos import AnalisadorDeIntervalos
+from .classificador_de_retakes import ClassificadorDeRetakes
+from .comparador_de_falas import ComparadorDeFalas
+from .comparador_semantico import ComparadorSemantico
+from .detector_de_reinicios import DetectorDeReinicios
+from .gerador_de_evidencias import GeradorDeEvidencias
+from .modelos_de_retakes import (
+ Fala,
+ GrupoDeRetake,
+ TomadaDeRetake,
+ gerar_id_do_grupo,
+)
+from .repositorio_de_retakes import RepositorioDeRetakes
+
+
+def _limites_de_cena(cenas_visuais: Iterable[object] | None) -> list[float]:
+ """Extrai os pontos de corte visual do contexto do scanner.
+
+ Cada cena visual tem um ``inicio``; o início de uma cena que não é a
+ primeira representa uma mudança visual. Objetos sem ``inicio`` são
+ ignorados de forma segura.
+ """
+ if not cenas_visuais:
+ return []
+ limites: set[float] = set()
+ for cena in cenas_visuais:
+ inicio = getattr(cena, "inicio", None)
+ if inicio is not None and float(inicio) > 0:
+ limites.add(float(inicio))
+ return sorted(limites)
+
+
+@dataclass
+class ResultadoDaAnaliseDeRetakes:
+ """Saída estruturada da análise de retakes de um vídeo."""
+
+ video_id: str
+ grupos: list[GrupoDeRetake]
+
+ def para_dict(self) -> dict[str, Any]:
+ return {
+ "video_id": self.video_id,
+ "grupos_de_retakes": [
+ {
+ "grupo_id": grupo.id,
+ "tipo": grupo.tipo,
+ "confianca": grupo.confianca,
+ "tomada_principal_id": grupo.tomada_principal_id,
+ "tomadas": [
+ {
+ "ordem": tomada.ordem,
+ "fala_id": tomada.fala_id,
+ "segmento_id": tomada.segmento_id,
+ "inicio": tomada.inicio,
+ "fim": tomada.fim,
+ "texto": tomada.texto,
+ "similaridade_com_anterior": tomada.similaridade_com_anterior,
+ "confianca": tomada.confianca,
+ }
+ for tomada in grupo.tomadas
+ ],
+ "evidencias": [
+ {
+ "tipo": evidencia.tipo,
+ "descricao": evidencia.descricao,
+ "valor": evidencia.valor,
+ }
+ for evidencia in grupo.evidencias
+ ],
+ }
+ for grupo in self.grupos
+ ],
+ }
+
+
+class CoordenadorDeRetakes:
+ """Organiza a análise completa de retakes de um vídeo."""
+
+ def __init__(
+ self,
+ detector_de_reinicios: DetectorDeReinicios | None = None,
+ comparador_de_falas: ComparadorDeFalas | None = None,
+ comparador_semantico: ComparadorSemantico | None = None,
+ agrupador_de_takes: AgrupadorDeTakes | None = None,
+ analisador_de_intervalos: AnalisadorDeIntervalos | None = None,
+ classificador_de_retakes: ClassificadorDeRetakes | None = None,
+ gerador_de_evidencias: GeradorDeEvidencias | None = None,
+ repositorio_de_retakes: RepositorioDeRetakes | None = None,
+ adaptador_de_falas: AdaptadorDeFalas | None = None,
+ ) -> None:
+ self.comparador_de_falas = comparador_de_falas or ComparadorDeFalas()
+ self.comparador_semantico = comparador_semantico or ComparadorSemantico()
+ self.detector_de_reinicios = detector_de_reinicios or DetectorDeReinicios(
+ self.comparador_de_falas)
+ self.analisador_de_intervalos = analisador_de_intervalos or AnalisadorDeIntervalos()
+ self.agrupador_de_takes = agrupador_de_takes or AgrupadorDeTakes(
+ self.comparador_de_falas,
+ self.comparador_semantico,
+ self.analisador_de_intervalos,
+ self.detector_de_reinicios,
+ )
+ self.classificador_de_retakes = classificador_de_retakes or ClassificadorDeRetakes()
+ self.gerador_de_evidencias = gerador_de_evidencias or GeradorDeEvidencias()
+ self.repositorio_de_retakes = repositorio_de_retakes or RepositorioDeRetakes()
+ self.adaptador_de_falas = adaptador_de_falas or AdaptadorDeFalas()
+ def analisar(
+ self,
+ video_id: str,
+ transcricoes: Iterable[TranscricaoDoClipe] | None = None,
+ falas: Iterable[Fala] | None = None,
+ faixa_id: str | None = None,
+ cenas_visuais: Iterable[object] | None = None,
+ ) -> ResultadoDaAnaliseDeRetakes:
+ """Executa o pipeline completo e retorna os grupos de retakes.
+
+ Se ``falas`` for fornecido, usa diretamente; caso contrário converte
+ os ``TranscricaoDoClipe`` recebidos via ``adaptador_de_falas``.
+
+ ``cenas_visuais`` é opcional: quando informado, os cortes visuais são
+ usados como evidência fraca (reforço), nunca como regra obrigatória.
+ """
+ if falas is None:
+ transcricoes = list(transcricoes or [])
+ falas = self.adaptador_de_falas.converter(transcricoes, video_id, faixa_id)
+ falas = list(falas)
+
+ limites_de_cena = _limites_de_cena(cenas_visuais)
+ versao_visual = "visual" if limites_de_cena else "sem_visual"
+
+ # Idempotência: se já analisado com as mesmas versões, devolve o cache.
+ # A ``versao_visual`` no hash garante que ativar/desativar a análise
+ # visual força uma nova análise, em vez de reaproveitar a antiga (§10).
+ if self.repositorio_de_retakes.ja_analisado(video_id, versao_visual):
+ grupos = self.repositorio_de_retakes.carregar(video_id)
+ return ResultadoDaAnaliseDeRetakes(video_id, grupos)
+
+ agrupamentos = self.agrupador_de_takes.agrupar(falas, limites_de_cena)
+ grupos: list[GrupoDeRetake] = []
+ for agrupamento in agrupamentos:
+ classificacao = self.classificador_de_retakes.classificar(agrupamento)
+ evidencias = self.gerador_de_evidencias.gerar(agrupamento, classificacao)
+ tomadas = self._montar_tomadas(agrupamento, classificacao.confianca)
+ grupos.append(GrupoDeRetake(
+ id=gerar_id_do_grupo(),
+ video_id=video_id,
+ faixa_id=self._faixa_do_grupo(agrupamento),
+ tipo=classificacao.tipo,
+ confianca=classificacao.confianca,
+ tomadas=tomadas,
+ evidencias=evidencias,
+ ))
+
+ self.repositorio_de_retakes.registrar(video_id, grupos, versao_visual)
+ return ResultadoDaAnaliseDeRetakes(video_id, grupos)
+
+ @staticmethod
+ def _faixa_do_grupo(agrupamento) -> str:
+ if agrupamento.pares:
+ return agrupamento.pares[0].fala_a.faixa_id
+ return ""
+
+ @staticmethod
+ def _montar_tomadas(agrupamento, confianca_do_grupo: float) -> list[TomadaDeRetake]:
+ # Mapa das falas e da similaridade de cada uma com a anterior.
+ falas: dict[str, Fala] = {}
+ similaridade_com_anterior: dict[str, float] = {}
+ for par in agrupamento.pares:
+ falas[par.fala_a.id] = par.fala_a
+ falas[par.fala_b.id] = par.fala_b
+ similaridade_com_anterior[par.fala_b.id] = par.comparacao.similaridade_final
+
+ tomadas: list[TomadaDeRetake] = []
+ for ordem, fala_id in enumerate(agrupamento.fala_ids):
+ if fala_id not in falas:
+ continue
+ fala = falas[fala_id]
+ tomadas.append(TomadaDeRetake(
+ ordem=ordem,
+ fala_id=fala.id,
+ segmento_id=fala.segmento_id,
+ inicio=fala.inicio,
+ fim=fala.fim,
+ texto=fala.texto,
+ similaridade_com_anterior=similaridade_com_anterior.get(fala_id, 0.0),
+ confianca=confianca_do_grupo,
+ ))
+ return tomadas
+ self.adaptador_de_falas = adaptador_de_falas or AdaptadorDeFalas()
\ No newline at end of file
diff --git a/code/engine/scanner/retakes/deteccao_de_retakes.py b/code/engine/scanner/retakes/deteccao_de_retakes.py
new file mode 100644
index 0000000..6c32836
--- /dev/null
+++ b/code/engine/scanner/retakes/deteccao_de_retakes.py
@@ -0,0 +1,59 @@
+"""Adaptador que expõe a detecção de retakes como um ``Analisador`` do scanner.
+
+Permite plugar o ``CoordenadorDeRetakes`` no ``PipelineDoScanner`` como uma
+etapa entre a detecção de cenas/eventos e a persistência, reutilizando o
+``ContextoDeAnalise`` que já carrega as transcrições.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from ...dominio import Timeline
+from .adaptador_de_falas import AdaptadorDeFalas
+from .coordenador_de_retakes import CoordenadorDeRetakes
+
+
+class DeteccaoDeRetakes:
+ nome = "deteccao_de_retakes"
+
+ def __init__(
+ self,
+ coordenador: CoordenadorDeRetakes | None = None,
+ video_id: str | None = None,
+ adaptador: AdaptadorDeFalas | None = None,
+ ) -> None:
+ self.coordenador = coordenador or CoordenadorDeRetakes()
+ self.video_id = video_id
+ self.adaptador = adaptador or AdaptadorDeFalas()
+
+ def executar(self, contexto: Any) -> Any:
+ """Consome as transcrições do contexto e preenche ``contexto.retakes``."""
+ if contexto is None:
+ return contexto
+
+ transcricoes = getattr(contexto, "transcricoes", None) or {}
+ if not transcricoes:
+ return contexto
+
+ # ``contexto.transcricoes`` é um dicionário ``identificador_clipe ->
+ # list[SegmentoDeTranscricao]``. Ordenamos por chave para processar as
+ # falas na ordem da timeline.
+ itens = [
+ (segmento_id, transcricoes[segmento_id])
+ for segmento_id in sorted(transcricoes)
+ ]
+
+ timeline: Timeline | None = contexto.timeline
+ video_id = self.video_id or (timeline.identificador if timeline else "video")
+ falas = self.adaptador.converter_de_segmentos(itens, video_id=video_id)
+
+ # Cenas visuais do contexto entram como reforço (evidência fraca).
+ cenas_visuais = getattr(contexto, "cenas_visuais", None) or []
+ resultado = self.coordenador.analisar(video_id, falas=falas, cenas_visuais=cenas_visuais)
+ contexto.retakes = {
+ "status": "concluida",
+ "resultado": resultado.para_dict(),
+ "grupos": resultado.grupos,
+ }
+ return contexto
\ No newline at end of file
diff --git a/code/engine/scanner/retakes/detector_de_reinicios.py b/code/engine/scanner/retakes/detector_de_reinicios.py
index 2ea54ce..47b518e 100644
--- a/code/engine/scanner/retakes/detector_de_reinicios.py
+++ b/code/engine/scanner/retakes/detector_de_reinicios.py
@@ -78,9 +78,18 @@ class DetectorDeReinicios:
if not evidencias:
return None
+ tem_marcador = any("marcador" in e for e in evidencias)
+ tem_repeticao = any("repetição" in e for e in evidencias)
+ if tem_marcador:
+ tipo = "reinicio_explicito"
+ elif tem_repeticao:
+ tipo = "repeticao_do_inicio"
+ else:
+ tipo = "reinicio_pos_pausa"
+
return SinalDeReinicio(
fala_id=fala.id,
- tipo="repeticao_do_inicio" if any("repetição" in e for e in evidencias) else "reinicio_pos_pausa",
+ tipo=tipo,
intensidade=round(min(1.0, max(intensidades)), 3),
evidencias=evidencias,
)
\ No newline at end of file
diff --git a/code/engine/scanner/retakes/gerador_de_evidencias.py b/code/engine/scanner/retakes/gerador_de_evidencias.py
new file mode 100644
index 0000000..91069c8
--- /dev/null
+++ b/code/engine/scanner/retakes/gerador_de_evidencias.py
@@ -0,0 +1,87 @@
+"""Geração de evidências legíveis para cada grupo de retakes.
+
+Produz uma lista estruturada ``{tipo, descricao, valor}`` que explica por
+que duas falas foram consideradas retake — pronto para a interface do
+painel descrever os motivos ao usuário.
+"""
+
+from __future__ import annotations
+
+from .modelos_de_retakes import AgrupamentoDeFalas, ClassificacaoDeRetake, EvidenciaDeRetake
+
+
+class GeradorDeEvidencias:
+ """Monta as evidências de um grupo a partir do agrupamento e da categoria."""
+
+ def gerar(
+ self,
+ agrupamento: AgrupamentoDeFalas,
+ classificacao: ClassificacaoDeRetake,
+ ) -> list[EvidenciaDeRetake]:
+ evidencias: list[EvidenciaDeRetake] = []
+ if not agrupamento.pares:
+ return evidencias
+
+ par = max(agrupamento.pares,
+ key=lambda item: item.comparacao.similaridade_final)
+ comp = par.comparacao
+ estado = "É um retake provável." if classificacao.tipo == "retake_confirmado" else (
+ "Pode ser um retake." if classificacao.tipo == "possivel_retake" else "Repetição detectada.")
+
+ evidencias.append(EvidenciaDeRetake(
+ tipo="similaridade_textual",
+ descricao=(
+ f"As duas falas possuem {comp.similaridade_jaccard * 100:.0f}% "
+ f"de palavras em comum e {comp.similaridade_final * 100:.0f}% de "
+ f"similaridade textual geral."
+ ),
+ valor=comp.similaridade_final,
+ ))
+
+ if par.similaridade_semantica > 0:
+ evidencias.append(EvidenciaDeRetake(
+ tipo="similaridade_semantica",
+ descricao=f"Semanticamente, as falas têm {par.similaridade_semantica * 100:.0f}% de afinidade.",
+ valor=par.similaridade_semantica,
+ ))
+
+ if comp.similaridade_do_inicio >= 0.8:
+ evidencias.append(EvidenciaDeRetake(
+ tipo="repeticao_do_inicio",
+ descricao="As duas falas começam com as mesmas palavras.",
+ valor=comp.similaridade_do_inicio,
+ ))
+
+ evidencias.append(EvidenciaDeRetake(
+ tipo="proximidade_temporal",
+ descricao=(
+ f"A segunda tentativa começa {par.intervalo.intervalo_em_segundos:.1f} "
+ f"segundos após a primeira."
+ ),
+ valor=par.intervalo.proximidade_temporal,
+ ))
+
+ for sinal in agrupamento.sinais_de_reinicio:
+ for detalhe in sinal.evidencias:
+ evidencias.append(EvidenciaDeRetake(
+ tipo="reinicio",
+ descricao=detalhe,
+ valor=sinal.intensidade,
+ ))
+
+ if par.mudanca_visual_proxima:
+ evidencias.append(EvidenciaDeRetake(
+ tipo="mudanca_visual_proxima",
+ descricao=(
+ "Há um corte visual próximo: as tentativas podem estar em "
+ "tomadas diferentes, reforçando a hipótese de retake."
+ ),
+ valor=1.0,
+ ))
+
+ evidencias.append(EvidenciaDeRetake(
+ tipo="classificacao",
+ descricao=f"{estado} Confiança de {classificacao.confianca * 100:.0f}%.",
+ valor=classificacao.confianca,
+ ))
+ return evidencias
\ No newline at end of file
diff --git a/code/engine/scanner/retakes/modelos_de_retakes.py b/code/engine/scanner/retakes/modelos_de_retakes.py
index b967c25..67e5c3a 100644
--- a/code/engine/scanner/retakes/modelos_de_retakes.py
+++ b/code/engine/scanner/retakes/modelos_de_retakes.py
@@ -115,6 +115,7 @@ class ParAgrupado:
comparacao: ResultadoDaComparacao
intervalo: ResultadoDoIntervalo
similaridade_semantica: float = 0.0
+ mudanca_visual_proxima: bool = False
@dataclass(frozen=True)
@@ -126,6 +127,24 @@ class AgrupamentoDeFalas:
sinais_de_reinicio: tuple[SinalDeReinicio, ...] = ()
+@dataclass(frozen=True)
+class ClassificacaoDeRetake:
+ """Resultado da classificação de um grupo de retakes."""
+
+ tipo: str
+ confianca: float
+
+# Estados possíveis da revisão manual de um grupo (decisão do usuário na
+# tela de retakes; a detecção automática nunca altera o estado de revisão).
+STATUS_PENDENTE = "pendente_de_revisao"
+STATUS_CONFIRMADO = "confirmado"
+STATUS_REJEITADO = "nao_e_retake"
+STATUS_IGNORADO = "ignorado"
+STATUS_DE_REVISAO_VALIDOS = frozenset(
+ {STATUS_PENDENTE, STATUS_CONFIRMADO, STATUS_REJEITADO, STATUS_IGNORADO})
+
+
+
@dataclass(frozen=True)
class GrupoDeRetake:
"""Agrupa as tomadas que representam tentativas da mesma fala."""
@@ -138,6 +157,7 @@ class GrupoDeRetake:
tomadas: list[TomadaDeRetake] = field(default_factory=list)
evidencias: list[EvidenciaDeRetake] = field(default_factory=list)
criado_em: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
+ status_de_revisao: str = STATUS_PENDENTE
def __post_init__(self) -> None:
if not 0.0 <= self.confianca <= 1.0:
diff --git a/code/engine/scanner/retakes/repositorio_de_retakes.py b/code/engine/scanner/retakes/repositorio_de_retakes.py
new file mode 100644
index 0000000..0b0b986
--- /dev/null
+++ b/code/engine/scanner/retakes/repositorio_de_retakes.py
@@ -0,0 +1,179 @@
+"""Persistência dos grupos de retakes.
+
+Na primeira versão, grava os grupos em um arquivo JSON idempotente, no
+mesmo espírito do cache de transcrição da engine. O `RepositorioDeRetakes`
+define o contrato (``registrar``/``carregar``/``ja_analisado``) para que a
+camada de banco de dados (SQLite/Postgres, prevista na Etapa 8 da
+arquitetura) possa ser plugada depois sem alterar o coordenador.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from dataclasses import asdict
+from pathlib import Path
+from typing import Iterable
+
+from .modelos_de_retakes import (
+ STATUS_DE_REVISAO_VALIDOS,
+ GrupoDeRetake,
+)
+
+# Versões da transição e das regras para a idempotência.
+VERSAO_REGRA_FALAS = 1
+VERSAO_REGRA_TEXTO = 1
+VERSAO_MODELO_PADRAO = "lexico"
+
+
+class GrupoDeRetakeInexistente(LookupError):
+ """Erro levantado quando um grupo de retakes não existe no repositório."""
+
+ def __init__(self, video_id: str, grupo_id: str) -> None:
+ super().__init__(
+ f"Grupo {grupo_id} não encontrado para o vídeo {video_id}.")
+ self.video_id = video_id
+ self.grupo_id = grupo_id
+
+
+class RepositorioDeRetakes:
+ """Persiste e recupera grupos de retakes em um arquivo JSON."""
+
+ def __init__(
+ self,
+ diretoria_de_trabalho: str | Path = ".retakes",
+ versao_transcricao: str = "1",
+ versao_regras: str = f"regras:{VERSAO_REGRA_FALAS}.{VERSAO_REGRA_TEXTO}",
+ versao_modelo: str = VERSAO_MODELO_PADRAO,
+ ) -> None:
+ self.diretoria_de_trabalho = Path(diretoria_de_trabalho)
+ self.versao_transcricao = versao_transcricao
+ self.versao_regras = versao_regras
+ self.versao_modelo = versao_modelo
+
+ def _caminho(self, video_id: str) -> Path:
+ return self.diretoria_de_trabalho / f"retakes-{self._seguro(video_id)}.json"
+
+ @staticmethod
+ def _seguro(valor: str) -> str:
+ return "".join(c if c.isalnum() or c in "._-" else "_" for c in valor) or "video"
+
+ def _hash_de_identificacao(self, video_id: str, versao_visual: str = "sem_visual") -> str:
+ origem = "|".join([video_id, self.versao_transcricao, self.versao_regras,
+ self.versao_modelo, versao_visual])
+ return hashlib.sha256(origem.encode("utf-8")).hexdigest()[:12]
+
+ def ja_analisado(self, video_id: str, versao_visual: str = "sem_visual") -> bool:
+ """Verdadeiro se o vídeo já foi analisado com exatamente estas versões.
+
+ A ``versao_visual`` separa análises com e sem cenas visuais, de modo
+ que uma mudança de configuração visual força uma nova análise (§10).
+ """
+ caminho = self._caminho(video_id)
+ if not caminho.is_file():
+ return False
+ try:
+ dados = json.loads(caminho.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return False
+ return dados.get("_hash") == self._hash_de_identificacao(video_id, versao_visual)
+
+ def registrar(
+ self,
+ video_id: str,
+ grupos: Iterable[GrupoDeRetake],
+ versao_visual: str = "sem_visual",
+ ) -> Path:
+ """Grava os grupos de forma idempotente e retorna o arquivo gerado."""
+ self.diretoria_de_trabalho.mkdir(parents=True, exist_ok=True)
+ dados = {
+ "video_id": video_id,
+ "versao_transcricao": self.versao_transcricao,
+ "versao_regras": self.versao_regras,
+ "versao_modelo": self.versao_modelo,
+ "versao_visual": versao_visual,
+ "_hash": self._hash_de_identificacao(video_id, versao_visual),
+ "grupos_de_retakes": [asdict(grupo) for grupo in grupos],
+ }
+ caminho = self._caminho(video_id)
+ caminho.write_text(json.dumps(dados, ensure_ascii=False, indent=2),
+ encoding="utf-8")
+ return caminho
+
+ def carregar(self, video_id: str) -> list[GrupoDeRetake]:
+ """Carrega os grupos já persistidos para o vídeo."""
+ caminho = self._caminho(video_id)
+ if not caminho.is_file():
+ return []
+ dados = json.loads(caminho.read_text(encoding="utf-8"))
+ grupos: list[GrupoDeRetake] = []
+ for item in dados.get("grupos_de_retakes", []):
+ tomadas = item.get("tomadas", [])
+ evidencias = item.get("evidencias", [])
+ grupos.append(GrupoDeRetake(
+ id=item["id"],
+ video_id=item.get("video_id", video_id),
+ faixa_id=item.get("faixa_id", ""),
+ tipo=item["tipo"],
+ confianca=item["confianca"],
+ tomadas=[self._tomada_do_dict(t) for t in tomadas],
+ evidencias=[self._evidencia_do_dict(e) for e in evidencias],
+ criado_em=item.get("criado_em", ""),
+ status_de_revisao=item.get("status_de_revisao", "pendente_de_revisao"),
+ ))
+ return grupos
+
+ def atualizar_status_de_revisao(
+ self,
+ video_id: str,
+ grupo_id: str,
+ novo_status: str,
+ ) -> GrupoDeRetake:
+ """Registra a decisão manual do usuário sobre um grupo de retakes.
+
+ A revisão não altera a detecção automática: apenas grava o estado
+ escolhido (confirmado, não é retake, ignorado ou pendente) no arquivo
+ já existente do vídeo, preservando os demais grupos.
+
+ Levanta ``ValueError`` se o status não for válido e
+ ``GrupoDeRetakeInexistente`` se o grupo não existir.
+ """
+ if novo_status not in STATUS_DE_REVISAO_VALIDOS:
+ validos = ", ".join(sorted(STATUS_DE_REVISAO_VALIDOS))
+ raise ValueError(f"Status de revisão inválido: {novo_status!r}. "
+ f"Válidos: {validos}.")
+ caminho = self._caminho(video_id)
+ if not caminho.is_file():
+ raise GrupoDeRetakeInexistente(video_id, grupo_id)
+ dados = json.loads(caminho.read_text(encoding="utf-8"))
+ grupos = dados.get("grupos_de_retakes", [])
+ alvo = next((g for g in grupos if g.get("id") == grupo_id), None)
+ if alvo is None:
+ raise GrupoDeRetakeInexistente(video_id, grupo_id)
+ alvo["status_de_revisao"] = novo_status
+ caminho.write_text(json.dumps(dados, ensure_ascii=False, indent=2),
+ encoding="utf-8")
+ return self.carregar(video_id)[grupos.index(alvo)]
+
+ @staticmethod
+ def _tomada_do_dict(item: dict) -> object:
+ from .modelos_de_retakes import TomadaDeRetake
+ return TomadaDeRetake(
+ ordem=item["ordem"],
+ fala_id=item["fala_id"],
+ segmento_id=item.get("segmento_id", ""),
+ inicio=item["inicio"],
+ fim=item["fim"],
+ texto=item.get("texto", ""),
+ similaridade_com_anterior=item.get("similaridade_com_anterior", 0.0),
+ confianca=item.get("confianca", 0.0),
+ )
+
+ @staticmethod
+ def _evidencia_do_dict(item: dict) -> object:
+ from .modelos_de_retakes import EvidenciaDeRetake
+ return EvidenciaDeRetake(
+ tipo=item["tipo"],
+ descricao=item.get("descricao", ""),
+ valor=item.get("valor"),
+ )
\ No newline at end of file
diff --git a/code/engine/scanner/visual.py b/code/engine/scanner/visual.py
index 43363c1..2dbd8b1 100644
--- a/code/engine/scanner/visual.py
+++ b/code/engine/scanner/visual.py
@@ -1,4 +1,6 @@
+import threading
from collections.abc import Callable
+from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from ..dominio import Clipe
@@ -27,12 +29,14 @@ class DetectorDeCenas:
analisadores_de_frame: list[AnalisadorDeFrame] | None = None,
detectores_de_intervalo: list[DetectorDeIntervalosDeCena] | None = None,
analisadores_de_sequencia: list[AnalisadorDeSequenciaDeQuadros] | None = None,
- ao_progresso: Callable[[int, int], None] | None = None) -> None:
+ ao_progresso: Callable[[int, int], None] | None = None,
+ trabalhadores_em_paralelo: int = 4) -> None:
self.extrator = extrator
self.analisadores_de_frame = analisadores_de_frame or []
self.analisadores_de_sequencia = analisadores_de_sequencia or []
self.detectores_de_intervalo = detectores_de_intervalo or []
self.ao_progresso = ao_progresso
+ self.trabalhadores_em_paralelo = max(1, trabalhadores_em_paralelo)
def detectar(self, clipe: Clipe) -> ResultadoVisualDoClipe:
quadros = self.extrator.extrair(clipe)
@@ -49,12 +53,24 @@ class DetectorDeCenas:
def _analisar_quadros(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
total: int = 0) -> list[EvidenciaVisual]:
- resultado: list[EvidenciaVisual] = []
- for quadro in quadros:
- for analisador in self.analisadores_de_frame:
- resultado.extend(analisador.analisar(quadro))
+ if not self.analisadores_de_frame or not quadros:
+ return []
+ tarefas = [(quadro, analisador) for quadro in quadros for analisador in self.analisadores_de_frame]
+ travamento = threading.Lock()
+
+ def processar(tarefa: tuple[QuadroDeVideo, AnalisadorDeFrame]) -> list[EvidenciaVisual]:
+ quadro, analisador = tarefa
+ evidencias = analisador.analisar(quadro)
+ with travamento:
self._avancar_progresso(progresso, total)
- return resultado
+ return evidencias
+
+ if self.trabalhadores_em_paralelo == 1 or len(tarefas) == 1:
+ resultados = [processar(tarefa) for tarefa in tarefas]
+ else:
+ with ThreadPoolExecutor(max_workers=self.trabalhadores_em_paralelo) as executor:
+ resultados = list(executor.map(processar, tarefas))
+ return [evidencia for lista in resultados for evidencia in lista]
def _analisar_sequencia(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
total: int = 0) -> list[EvidenciaVisual]:
@@ -134,11 +150,28 @@ class AnaliseVisualDaTimeline:
return contexto
+def _detectores_de_cena_do_perfil(perfil: ConfiguracaoVisualDoScanner) -> list:
+ """Monta os detectores de intervalo de cena ativos para o perfil.
+
+ A análise visual auxiliar (PySceneDetect) é opcional. Só entra como um
+ ``DetectorDeIntervalosDeCena`` quando a configuração a ativa — e nunca
+ decide sozinho se há retake.
+ """
+ from ..integracoes.visual import DetectorDeCenasPySceneDetect
+
+ detectores: list = []
+ if getattr(perfil, "usar_analise_visual", False) and getattr(perfil, "detectar_cortes", True):
+ detectores.append(DetectorDeCenasPySceneDetect(limiar=perfil.limiar_de_corte))
+ return detectores
+
+
def criar_detector_visual_local(
diretorio_de_cache: str | None = ".frames",
intervalo_em_segundos: float = 1.0,
+ configuracao: ConfiguracaoVisualDoScanner | None = None,
) -> DetectorDeCenas:
"""Monta o detector local padrão com uma interface curta para o Scanner."""
+ from .configuracao_visual import ConfiguracaoVisualDoScanner
from ..integracoes.visual import (
AnalisadorDeComposicaoOpenCV,
AnalisadorDeContinuidadeOpenCV,
@@ -146,9 +179,11 @@ def criar_detector_visual_local(
ExtratorDeQuadrosOpenCV,
)
+ perfil = configuracao or ConfiguracaoVisualDoScanner(intervalo_em_segundos=intervalo_em_segundos)
+
return DetectorDeCenas(
ExtratorDeQuadrosOpenCV(
- intervalo_em_segundos=intervalo_em_segundos,
+ intervalo_em_segundos=perfil.intervalo_em_segundos,
diretorio_de_cache=diretorio_de_cache,
),
analisadores_de_frame=[
@@ -156,6 +191,7 @@ def criar_detector_visual_local(
AnalisadorDeComposicaoOpenCV(),
],
analisadores_de_sequencia=[AnalisadorDeContinuidadeOpenCV()],
+ detectores_de_intervalo=_detectores_de_cena_do_perfil(perfil),
)
@@ -174,11 +210,13 @@ def criar_detector_apple_vision(
return DetectorDeCenas(
ExtratorDeQuadrosFFmpeg(intervalo_em_segundos=perfil.intervalo_em_segundos,
- diretorio_de_cache=diretorio_de_cache or ".frames"),
+ diretorio_de_cache=diretorio_de_cache or ".frames",
+ resolucao_maxima=perfil.resolucao_maxima),
analisadores_de_frame=[AnalisadorAppleVisionNativo(
recursos=tuple(sorted(perfil.recursos_vision)),
interpretar_com_apple_intelligence=perfil.interpretar_cena,
)],
analisadores_de_sequencia=[AnalisadorSequenciaAppleVisionNativo()]
if perfil.analisar_continuidade else [],
+ detectores_de_intervalo=_detectores_de_cena_do_perfil(perfil),
)
diff --git a/code/engine/testes/test_consultas_de_persistencia.py b/code/engine/testes/test_consultas_de_persistencia.py
new file mode 100644
index 0000000..7e19aae
--- /dev/null
+++ b/code/engine/testes/test_consultas_de_persistencia.py
@@ -0,0 +1,207 @@
+"""
+Testes de ``engine.persistencia.consultas.ConsultasDeAnalises``.
+
+Verifica que cada método devolve apenas os campos esperados para aquele
+nível de detalhe, a partir de um banco populado pelos repositórios já
+testados em ``test_persistencia_sqlite.py``, e que entradas inválidas são
+rejeitadas com ``ErroDeConsultaInvalida``.
+"""
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
+from engine.persistencia import (
+ ConsultasDeAnalises,
+ ErroDeConsultaInvalida,
+ RepositorioDeAnalisesSQLite,
+ RepositorioDeRetakesSQLite,
+ RepositorioDeTimelineSQLite,
+)
+from engine.scanner.modelos import Cena, EvidenciaVisual, SegmentoDeTranscricao
+from engine.scanner.retakes.modelos_de_retakes import EvidenciaDeRetake, GrupoDeRetake, TomadaDeRetake
+from engine.scanner.transcricao_da_timeline import TranscricaoDoClipe
+
+VIDEO_ID = "v1"
+
+
+class TesteConsultasDeAnalises(unittest.TestCase):
+ """Popula um banco temporário com dados de todas as análises e valida as consultas."""
+
+ def setUp(self) -> None:
+ self._pasta_temporaria = tempfile.TemporaryDirectory()
+ self.banco = Path(self._pasta_temporaria.name) / "analises.db"
+ self.addCleanup(self._pasta_temporaria.cleanup)
+ self._popular_banco()
+ self.consultas = ConsultasDeAnalises(self.banco)
+
+ def _popular_banco(self) -> None:
+ clipe = Clipe(
+ identificador="clip-1", nome="Take 1",
+ intervalo_na_timeline=IntervaloDeTempo(0.0, 20.0),
+ arquivo="/videos/entrada.mp4", identificador_da_faixa="f1",
+ )
+ faixa = Faixa(identificador="f1", nome="V1", tipo="video", indice=0, clipes=[clipe])
+ timeline = Timeline(identificador=VIDEO_ID, nome="Sequência", duracao=20.0, faixas=[faixa])
+ RepositorioDeTimelineSQLite(self.banco).registrar_timeline(timeline)
+
+ segmento = SegmentoDeTranscricao(
+ inicio=0.0, fim=6.0, texto="Hoje nós vamos mostrar como funciona o sistema.",
+ confianca=0.9, falante="locutor", emocao="neutro",
+ )
+ transcricao = TranscricaoDoClipe("clip-1", "/videos/entrada.mp4", 0.0, 20.0, [segmento])
+ RepositorioDeAnalisesSQLite(self.banco).registrar_transcricoes(VIDEO_ID, [transcricao])
+
+ repositorio_analises = RepositorioDeAnalisesSQLite(self.banco)
+ repositorio_analises.registrar_evidencias_visuais(VIDEO_ID, "clip-1", [
+ EvidenciaVisual(tipo="qualidade", inicio=1.0, fim=2.0, valor={"nitidez": 0.9},
+ confianca=0.95, provider="opencv"),
+ EvidenciaVisual(tipo="rosto", inicio=1.0, fim=2.0, valor={"quantidade": 1},
+ confianca=0.4, provider="opencv"),
+ ])
+ repositorio_analises.registrar_cenas(VIDEO_ID, [Cena(inicio=0.0, fim=20.0, confianca=0.8)],
+ clipe_id="clip-1")
+
+ grupo = GrupoDeRetake(
+ id="retake_teste", video_id=VIDEO_ID, faixa_id="f1",
+ tipo="retake_confirmado", confianca=0.92,
+ tomadas=[
+ TomadaDeRetake(0, "fala-1", "clip-1", 0.0, 6.0, "Hoje nós vamos mostrar.",
+ confianca=0.92),
+ TomadaDeRetake(1, "fala-2", "clip-1", 9.0, 15.0, "Hoje nós vamos mostrar.",
+ similaridade_com_anterior=1.0, confianca=0.98),
+ ],
+ evidencias=[EvidenciaDeRetake("similaridade_textual", "Textos quase idênticos.", 0.98)],
+ )
+ RepositorioDeRetakesSQLite(self.banco).registrar(VIDEO_ID, [grupo])
+
+ # --- consultar_resumo_do_video -----------------------------------------------------
+
+ def test_consultar_resumo_do_video_traz_contagens_agregadas(self) -> None:
+ resumo = self.consultas.consultar_resumo_do_video(VIDEO_ID)
+
+ self.assertIsNotNone(resumo)
+ self.assertEqual(resumo["nome"], "Sequência")
+ self.assertEqual(resumo["total_clipes"], 1)
+ self.assertEqual(resumo["total_falas"], 1)
+ self.assertEqual(resumo["total_retakes"], 1)
+ self.assertEqual(resumo["total_cenas"], 1)
+
+ def test_consultar_resumo_do_video_inexistente_retorna_none(self) -> None:
+ self.assertIsNone(self.consultas.consultar_resumo_do_video("video_desconhecido"))
+
+ def test_consultar_resumo_do_video_rejeita_video_id_vazio(self) -> None:
+ with self.assertRaises(ErroDeConsultaInvalida):
+ self.consultas.consultar_resumo_do_video("")
+
+ # --- listar_falas_no_intervalo ------------------------------------------------------
+
+ def test_listar_falas_no_intervalo_filtra_por_tempo_e_omite_palavras_por_padrao(self) -> None:
+ falas = self.consultas.listar_falas_no_intervalo(VIDEO_ID, inicio=0.0, fim=10.0)
+
+ self.assertEqual(len(falas), 1)
+ self.assertEqual(falas[0]["clipe_id"], "clip-1")
+ self.assertEqual(falas[0]["emocao"], "neutro")
+ self.assertNotIn("palavras", falas[0])
+ self.assertNotIn("caracteristicas_acusticas", falas[0])
+
+ def test_listar_falas_no_intervalo_fora_do_periodo_retorna_lista_vazia(self) -> None:
+ falas = self.consultas.listar_falas_no_intervalo(VIDEO_ID, inicio=100.0, fim=110.0)
+
+ self.assertEqual(falas, [])
+
+ def test_listar_falas_no_intervalo_rejeita_inicio_negativo(self) -> None:
+ with self.assertRaises(ErroDeConsultaInvalida):
+ self.consultas.listar_falas_no_intervalo(VIDEO_ID, inicio=-1.0)
+
+ def test_listar_falas_no_intervalo_rejeita_fim_anterior_ao_inicio(self) -> None:
+ with self.assertRaises(ErroDeConsultaInvalida):
+ self.consultas.listar_falas_no_intervalo(VIDEO_ID, inicio=10.0, fim=5.0)
+
+ # --- consultar_clipe_no_instante -----------------------------------------------------
+
+ def test_consultar_clipe_no_instante_localiza_o_clipe_correto(self) -> None:
+ clipe = self.consultas.consultar_clipe_no_instante(VIDEO_ID, 5.0)
+
+ self.assertIsNotNone(clipe)
+ self.assertEqual(clipe["clipe_id"], "clip-1")
+
+ def test_consultar_clipe_no_instante_fora_da_timeline_retorna_none(self) -> None:
+ self.assertIsNone(self.consultas.consultar_clipe_no_instante(VIDEO_ID, 999.0))
+
+ def test_consultar_clipe_no_instante_rejeita_instante_negativo(self) -> None:
+ with self.assertRaises(ErroDeConsultaInvalida):
+ self.consultas.consultar_clipe_no_instante(VIDEO_ID, -1.0)
+
+ # --- listar_retakes_do_video / consultar_detalhes_do_retake -------------------------
+
+ def test_listar_retakes_do_video_traz_resumo_sem_tomadas_completas(self) -> None:
+ retakes = self.consultas.listar_retakes_do_video(VIDEO_ID)
+
+ self.assertEqual(len(retakes), 1)
+ resumo = retakes[0]
+ self.assertEqual(resumo["grupo_id"], "retake_teste")
+ self.assertEqual(resumo["total_tomadas"], 2)
+ self.assertEqual(resumo["tomada_principal"]["fala_id"], "fala-2")
+ self.assertNotIn("tomadas", resumo)
+ self.assertNotIn("evidencias", resumo)
+
+ def test_consultar_detalhes_do_retake_traz_tomadas_e_evidencias_completas(self) -> None:
+ detalhes = self.consultas.consultar_detalhes_do_retake("retake_teste")
+
+ self.assertIsNotNone(detalhes)
+ self.assertEqual(len(detalhes["tomadas"]), 2)
+ self.assertEqual(len(detalhes["evidencias"]), 1)
+ self.assertEqual(detalhes["evidencias"][0]["tipo"], "similaridade_textual")
+
+ def test_consultar_detalhes_do_retake_inexistente_retorna_none(self) -> None:
+ self.assertIsNone(self.consultas.consultar_detalhes_do_retake("retake_inexistente"))
+
+ def test_consultar_detalhes_do_retake_rejeita_grupo_id_vazio(self) -> None:
+ with self.assertRaises(ErroDeConsultaInvalida):
+ self.consultas.consultar_detalhes_do_retake(" ")
+
+ # --- listar_evidencias_visuais_do_clipe ----------------------------------------------
+
+ def test_listar_evidencias_visuais_filtra_por_tipo(self) -> None:
+ evidencias = self.consultas.listar_evidencias_visuais_do_clipe(
+ VIDEO_ID, "clip-1", tipo="qualidade")
+
+ self.assertEqual(len(evidencias), 1)
+ self.assertEqual(evidencias[0]["valor"], {"nitidez": 0.9})
+
+ def test_listar_evidencias_visuais_filtra_por_confianca_minima(self) -> None:
+ evidencias = self.consultas.listar_evidencias_visuais_do_clipe(
+ VIDEO_ID, "clip-1", confianca_minima=0.9)
+
+ self.assertEqual(len(evidencias), 1)
+ self.assertEqual(evidencias[0]["tipo"], "qualidade")
+
+ def test_listar_evidencias_visuais_rejeita_confianca_fora_da_faixa(self) -> None:
+ with self.assertRaises(ErroDeConsultaInvalida):
+ self.consultas.listar_evidencias_visuais_do_clipe(
+ VIDEO_ID, "clip-1", confianca_minima=1.5)
+
+ def test_listar_evidencias_visuais_rejeita_clipe_id_vazio(self) -> None:
+ with self.assertRaises(ErroDeConsultaInvalida):
+ self.consultas.listar_evidencias_visuais_do_clipe(VIDEO_ID, "")
+
+ # --- listar_cenas_do_video -------------------------------------------------------------
+
+ def test_listar_cenas_do_video_filtra_por_clipe(self) -> None:
+ cenas = self.consultas.listar_cenas_do_video(VIDEO_ID, clipe_id="clip-1")
+
+ self.assertEqual(len(cenas), 1)
+ self.assertEqual(cenas[0]["fim"], 20.0)
+
+ def test_listar_cenas_do_video_sem_clipe_traz_todas(self) -> None:
+ self.assertEqual(len(self.consultas.listar_cenas_do_video(VIDEO_ID)), 1)
+
+ def test_listar_cenas_do_video_rejeita_video_id_vazio(self) -> None:
+ with self.assertRaises(ErroDeConsultaInvalida):
+ self.consultas.listar_cenas_do_video(None)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/code/engine/testes/test_deteccao_de_retakes.py b/code/engine/testes/test_deteccao_de_retakes.py
new file mode 100644
index 0000000..f65224e
--- /dev/null
+++ b/code/engine/testes/test_deteccao_de_retakes.py
@@ -0,0 +1,440 @@
+
+"""Testes do módulo de detecção de retakes.
+
+Cobre o fluxo do ``CoordenadorDeRetakes`` (caso prático do requisito, em
+que a fala final com informação nova não deve entrar no grupo), os
+algoritmos de similaridade, o detector de reinícios, o adaptador de
+pipeline e a idempotência do repositório.
+"""
+
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
+from engine.scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
+from engine.scanner.retakes import (
+ ClassificadorDeRetakes,
+ ComparadorDeFalas,
+ CoordenadorDeRetakes,
+ DeteccaoDeRetakes,
+ GrupoDeRetake,
+ RepositorioDeRetakes,
+ AgrupamentoDeFalas,
+ Fala,
+ ParAgrupado,
+ ResultadoDoIntervalo,
+)
+from engine.scanner.retakes.adaptador_de_falas import AdaptadorDeFalas
+from engine.scanner.retakes.modelos_de_retakes import (
+ STATUS_CONFIRMADO,
+ STATUS_REJEITADO,
+ TomadaDeRetake,
+)
+from engine.scanner.retakes.repositorio_de_retakes import GrupoDeRetakeInexistente
+from engine.scanner.transcricao_da_timeline import TranscricaoDoClipe
+
+
+def _fala(fala_id: str, texto: str, inicio: float, fim: float,
+ video_id: str = "v1", faixa_id: str = "f1", segmento_id: str = "clip-1") -> Fala:
+ return Fala(
+ id=fala_id,
+ video_id=video_id,
+ faixa_id=faixa_id,
+ segmento_id=segmento_id,
+ ordem=0,
+ inicio=inicio,
+ fim=fim,
+ texto=texto,
+ texto_normalizado=AdaptadorDeFalas.normalizar(texto),
+ falante="locutor",
+ )
+
+
+class TesteComparadorDeFalas(unittest.TestCase):
+ def setUp(self):
+ self.comparador = ComparadorDeFalas()
+
+ def test_textos_identicos_possuem_similaridade_maxima(self):
+ a = _fala("a", "Hoje nós vamos mostrar como funciona o sistema.", 0.0, 4.8)
+ b = _fala("b", "Hoje nós vamos mostrar como funciona o sistema.", 6.2, 10.9)
+
+ resultado = self.comparador.comparar(a, b)
+
+ self.assertEqual(resultado.similaridade_jaccard, 1.0)
+ self.assertEqual(resultado.similaridade_do_inicio, 1.0)
+ self.assertEqual(resultado.similaridade_final, 1.0)
+
+ def test_ordem_diferente_penaliza_sequencia_mas_nao_jaccard(self):
+ a = _fala("a", "Vamos mostrar o sistema funcionando.", 0.0, 3.0)
+ b = _fala("b", "O sistema será mostrado funcionando.", 4.0, 8.0)
+
+ resultado = self.comparador.comparar(a, b)
+
+ self.assertGreater(resultado.similaridade_jaccard, 0.3)
+ self.assertLess(resultado.similaridade_de_sequencia, 1.0)
+
+ def test_prefixo_igual_eleva_similaridade_do_inicio(self):
+ a = _fala("a", "Hoje nós vamos mostrar o sistema.", 0.0, 3.0)
+ b = _fala("b", "Hoje nós vamos explicar o programa.", 4.0, 7.0)
+
+ resultado = self.comparador.comparar(a, b)
+
+ self.assertGreaterEqual(resultado.similaridade_do_inicio, 0.5)
+ self.assertGreater(resultado.similaridade_do_inicio, resultado.similaridade_do_final)
+
+
+class TesteDetectorDeReinicios(unittest.TestCase):
+ def test_detecta_reinicio_explicito_e_repeticao_do_inicio(self):
+ from engine.scanner.retakes import DetectorDeReinicios
+ falas = [
+ _fala("101", "Hoje nós vamos mostrar como funciona o sistema.", 0.0, 6.0),
+ _fala("102", "Não, peraí.", 6.0, 8.0),
+ _fala("103", "Hoje nós vamos mostrar como funciona o sistema.", 9.0, 15.0),
+ ]
+ detector = DetectorDeReinicios()
+
+ sinais = detector.detectar(falas)
+
+ tipos = {sinal.fala_id: sinal.tipo for sinal in sinais}
+ self.assertIn("102", tipos)
+ self.assertEqual(tipos["102"], "reinicio_explicito")
+ self.assertIn("marcador", sinais[0].evidencias[0])
+
+
+class TesteClassificador(unittest.TestCase):
+ def test_replicacao_completa_gera_retake_confirmado(self):
+ a = _fala("a", "Hoje nós vamos mostrar como funciona o sistema.", 0.0, 6.0)
+ b = _fala("b", "Hoje nós vamos mostrar como funciona o sistema.", 9.0, 15.0)
+ comparacao = ComparadorDeFalas().comparar(a, b)
+ par = ParAgrupado(a, b, comparacao, ResultadoDoIntervalo(3.0, True, 0.9, True), 1.0)
+ agrupamento = AgrupamentoDeFalas((a.id, b.id), (par,))
+
+ classificacao = ClassificadorDeRetakes().classificar(agrupamento)
+
+ self.assertEqual(classificacao.tipo, "retake_confirmado")
+ self.assertGreaterEqual(classificacao.confianca, 0.8)
+
+
+class TesteCoordenadorEExemploDoRequisito(unittest.TestCase):
+ def _montar_transcricoes(self):
+ segmentos = [
+ SegmentoDeTranscricao(0.0, 6.0, "Hoje nós vamos mostrar como funciona o sistema.",
+ palavras=(
+ PalavraDeTranscricao("Hoje", 0.0, 0.5),
+ PalavraDeTranscricao("sistema", 5.0, 6.0),
+ )),
+ SegmentoDeTranscricao(6.0, 8.0, "Não, peraí."),
+ SegmentoDeTranscricao(9.0, 15.0, "Hoje nós vamos mostrar como funciona o sistema."),
+ SegmentoDeTranscricao(16.0, 21.0, "Primeiro, vamos abrir o programa."),
+ ]
+ clipe = Clipe("clip-1", "clip-1", IntervaloDeTempo(0, 21), arquivo="/v.mp4")
+ timeline = Timeline("t1", "Sequência",
+ faixas=[Faixa("f1", "a1", "audio", 0, [clipe])])
+ frase = TranscricaoDoClipe("clip-1", "/v.mp4", 0.0, 21.0, segmentos)
+ return frase, timeline
+
+ def test_exemplo_pratico_agrupa_repetidas_e_exclui_informacao_nova(self):
+ frase, _ = self._montar_transcricoes()
+ with tempfile.TemporaryDirectory() as pasta:
+ repositorio = RepositorioDeRetakes(pasta)
+ coordenador = CoordenadorDeRetakes(repositorio_de_retakes=repositorio)
+
+ resultado = coordenador.analisar("v1", [frase])
+
+ self.assertEqual(len(resultado.grupos), 1)
+ grupo = resultado.grupos[0]
+ self.assertEqual(len(grupo.tomadas), 2)
+ self.assertEqual(grupo.tomadas[0].inicio, 0.0)
+ self.assertEqual(grupo.tomadas[1].inicio, 9.0)
+ textos = " ".join(t.texto for t in grupo.tomadas)
+ self.assertNotIn("Primeiro", textos)
+ tipos = {evidencia.tipo for evidencia in grupo.evidencias}
+ self.assertIn("similaridade_textual", tipos)
+
+ def test_repositorio_e_idempotente(self):
+ frase, _ = self._montar_transcricoes()
+ with tempfile.TemporaryDirectory() as pasta:
+ repositorio = RepositorioDeRetakes(pasta)
+ coordenador = CoordenadorDeRetakes(repositorio_de_retakes=repositorio)
+
+ primeiro = coordenador.analisar("v1", [frase])
+ segundo = coordenador.analisar("v1", [frase])
+ ja_analisado = repositorio.ja_analisado("v1")
+
+ # A segunda execução não gera um grupo novo (mesmo id de cache).
+ self.assertEqual(primeiro.grupos[0].id, segundo.grupos[0].id)
+ self.assertTrue(ja_analisado)
+
+
+class TesteAdaptadorDePipeline(unittest.TestCase):
+ def test_deteccao_de_retakes_popula_contexto(self):
+ from engine.scanner.coordenacao import ContextoDeAnalise
+ segmentos = [
+ SegmentoDeTranscricao(0.0, 6.0, "Hoje nós vamos mostrar como funciona o sistema."),
+ SegmentoDeTranscricao(9.0, 15.0, "Hoje nós vamos mostrar como funciona o sistema."),
+ ]
+ contexto = ContextoDeAnalise(timeline=Timeline("t1", "Seq"))
+ contexto.transcricoes = {"clip-1": segmentos}
+
+ with tempfile.TemporaryDirectory() as pasta:
+ detector = DeteccaoDeRetakes(
+ coordenador=CoordenadorDeRetakes(
+ repositorio_de_retakes=RepositorioDeRetakes(pasta)))
+
+ novo = detector.executar(contexto)
+
+ self.assertIsNotNone(novo.retakes)
+ self.assertEqual(novo.retakes["status"], "concluida")
+ self.assertEqual(len(novo.retakes["grupos"]), 1)
+
+
+class TesteIntegracaoVisualAuxiliar(unittest.TestCase):
+ """Cobre a Etapa 2: PySceneDetect plugado via config e cenas visuais
+ como evidência fraca nos retakes (reforço, nunca regra obrigatória)."""
+
+ def test_config_visual_aceita_novos_campos_e_preserva_defaults(self):
+ from engine.scanner.configuracao_visual import ConfiguracaoVisualDoScanner
+
+ perfil = ConfiguracaoVisualDoScanner.de_dict({
+ "usar_analise_visual": True,
+ "detectar_cortes": True,
+ "sensibilidade": "alta",
+ })
+
+ self.assertTrue(perfil.usar_analise_visual)
+ self.assertTrue(perfil.detectar_cortes)
+ self.assertEqual(perfil.sensibilidade, "alta")
+ self.assertLess(perfil.limiar_de_corte, 27) # alta = mais sensível
+ # Round-trip pelo JSON do painel.
+ self.assertTrue(perfil.para_dict()["usar_analise_visual"])
+ self.assertEqual(perfil.para_dict()["sensibilidade"], "alta")
+
+ # Ausência dos campos não quebra o comportamento anterior.
+ padrao = ConfiguracaoVisualDoScanner.de_dict({})
+ self.assertFalse(padrao.usar_analise_visual)
+ self.assertTrue(padrao.detectar_cortes)
+ self.assertEqual(padrao.limiar_de_corte, 27.0)
+
+ def test_config_rejeita_sensibilidade_invalida(self):
+ from engine.scanner.configuracao_visual import ConfiguracaoVisualDoScanner
+ with self.assertRaises(ValueError):
+ ConfiguracaoVisualDoScanner(sensibilidade="impossivel")
+
+ def test_detector_local_pluga_pyscenedetect_quando_ativado(self):
+ from engine.scanner.configuracao_visual import ConfiguracaoVisualDoScanner
+ from engine.scanner.visual import criar_detector_visual_local
+
+ ativo = criar_detector_visual_local("cache",
+ configuracao=ConfiguracaoVisualDoScanner(
+ usar_analise_visual=True,
+ sensibilidade="media"))
+ self.assertEqual(len(ativo.detectores_de_intervalo), 1)
+
+ desligado = criar_detector_visual_local("cache")
+ self.assertEqual(desligado.detectores_de_intervalo, [])
+
+ def test_cena_visual_gera_evidencia_fraca_no_retake(self):
+ from engine.scanner.retakes import CoordenadorDeRetakes, RepositorioDeRetakes
+ from engine.scanner.modelos import CenaVisual
+
+ falas = [
+ _fala("101", "Hoje nós vamos mostrar como funciona o sistema.", 0.0, 6.0),
+ _fala("102", "Hoje nós vamos mostrar como funciona o sistema.", 9.0, 15.0),
+ ]
+ # Um corte visual em 8.0s (na pausa entre as tentativas).
+ cenas_visuais = [CenaVisual("clip-1", 8.0, 9.0)]
+
+ with tempfile.TemporaryDirectory() as pasta:
+ coordenador = CoordenadorDeRetakes(
+ repositorio_de_retakes=RepositorioDeRetakes(pasta))
+ resultado = coordenador.analisar("v1", falas=falas, cenas_visuais=cenas_visuais)
+
+ self.assertEqual(len(resultado.grupos), 1)
+ grupo = resultado.grupos[0]
+ tipos = {evidencia.tipo for evidencia in grupo.evidencias}
+ self.assertIn("mudanca_visual_proxima", tipos)
+
+ def test_sem_corte_visual_nao_gera_evidencia_visual(self):
+ from engine.scanner.retakes import CoordenadorDeRetakes, RepositorioDeRetakes
+
+ falas = [
+ _fala("101", "Hoje nós vamos mostrar como funciona o sistema.", 0.0, 6.0),
+ _fala("102", "Hoje nós vamos mostrar como funciona o sistema.", 9.0, 15.0),
+ ]
+ # Sem cenas visuais informadas (comportamento anterior).
+ with tempfile.TemporaryDirectory() as pasta:
+ coordenador = CoordenadorDeRetakes(
+ repositorio_de_retakes=RepositorioDeRetakes(pasta))
+ resultado = coordenador.analisar("v1", falas=falas)
+
+ grupo = resultado.grupos[0]
+ tipos = {evidencia.tipo for evidencia in grupo.evidencias}
+ self.assertNotIn("mudanca_visual_proxima", tipos)
+
+ def test_mudanca_de_uso_visual_forca_reanalise_por_causa_do_hash(self):
+ from engine.scanner.retakes import CoordenadorDeRetakes, RepositorioDeRetakes
+ from engine.scanner.modelos import CenaVisual
+
+ falas = [
+ _fala("101", "Hoje nós vamos mostrar como funciona o sistema.", 0.0, 6.0),
+ _fala("102", "Hoje nós vamos mostrar como funciona o sistema.", 9.0, 15.0),
+ ]
+ with tempfile.TemporaryDirectory() as pasta:
+ repositorio = RepositorioDeRetakes(pasta)
+ coordenador = CoordenadorDeRetakes(repositorio_de_retakes=repositorio)
+
+ # 1ª análise sem visual.
+ sem_visual = coordenador.analisar("v1", falas=falas)
+ tipos_sem = {e.tipo for e in sem_visual.grupos[0].evidencias}
+ self.assertNotIn("mudanca_visual_proxima", tipos_sem)
+
+ # 2ª análise com visual: o hash muda → não reaproveita a antiga.
+ com_visual = coordenador.analisar(
+ "v1", falas=falas, cenas_visuais=[CenaVisual("clip-1", 8.0, 9.0)])
+ tipos_com = {e.tipo for e in com_visual.grupos[0].evidencias}
+ self.assertIn("mudanca_visual_proxima", tipos_com)
+
+
+class TesteRevisaoManualEArtefatos(unittest.TestCase):
+ """Cobre a tela de retakes: revisão manual via repositório e leitura dos
+ artefatos de transcrição do scanner (sem retranscrever)."""
+
+ def _grupo(self, grupo_id: str = "retake_001"):
+ from engine.scanner.retakes import GrupoDeRetake
+ return GrupoDeRetake(
+ id=grupo_id, video_id="v1", faixa_id="f1",
+ tipo="retake_de_fala", confianca=0.9,
+ tomadas=[TomadaDeRetake(ordem=1, fala_id="a", segmento_id="s",
+ inicio=0.0, fim=4.0, texto="olá mundo")])
+
+ def test_revisao_manual_grava_status_e_preserva_demais_grupos(self):
+ from engine.scanner.retakes import RepositorioDeRetakes
+ with tempfile.TemporaryDirectory() as pasta:
+ repositorio = RepositorioDeRetakes(diretoria_de_trabalho=pasta)
+ repositorio.registrar("v1", [self._grupo(), self._grupo("retake_002")])
+ atualizado = repositorio.atualizar_status_de_revisao(
+ "v1", "retake_001", STATUS_CONFIRMADO)
+ self.assertEqual(atualizado.status_de_revisao, STATUS_CONFIRMADO)
+ grupos = repositorio.carregar("v1")
+ self.assertEqual(grupos[0].status_de_revisao, STATUS_CONFIRMADO)
+ self.assertEqual(grupos[1].status_de_revisao, "pendente_de_revisao")
+
+ def test_revisao_manual_rejeita_status_invalido_e_grupo_inexistente(self):
+ from engine.scanner.retakes import RepositorioDeRetakes
+ with tempfile.TemporaryDirectory() as pasta:
+ repositorio = RepositorioDeRetakes(diretoria_de_trabalho=pasta)
+ repositorio.registrar("v1", [self._grupo()])
+ with self.assertRaises(ValueError):
+ repositorio.atualizar_status_de_revisao("v1", "retake_001", "talvez")
+ with self.assertRaises(GrupoDeRetakeInexistente):
+ repositorio.atualizar_status_de_revisao(
+ "v1", "outro", STATUS_REJEITADO)
+
+ def test_carregador_le_artefato_do_scanner_e_ignora_sem_transcricao(self):
+ import json
+ from pathlib import Path
+ from engine.scanner.retakes import (
+ ArtefatoDeTranscricaoInvalido,
+ CarregadorDeArtefatosDeAudio,
+ )
+ artefato = {
+ "status": "concluida",
+ "segmentos_por_clipe": [
+ {"clipe": "clip-1", "segmentos": [
+ {"inicio": 0.0, "fim": 4.0,
+ "texto": "Hoje nós vamos mostrar como funciona o sistema."},
+ {"inicio": 6.0, "fim": 10.0,
+ "texto": "Hoje nós vamos mostrar como funciona o sistema."},
+ ]},
+ ],
+ }
+ with tempfile.TemporaryDirectory() as pasta:
+ caminho = Path(pasta) / "v1-audio-faixa-1.json"
+ caminho.write_text(json.dumps(artefato, ensure_ascii=False),
+ encoding="utf-8")
+ falas = CarregadorDeArtefatosDeAudio().carregar([caminho], "v1")
+ self.assertEqual(len(falas), 2)
+ self.assertTrue(falas[0].texto.startswith("Hoje"))
+
+ caminho_vazio = Path(pasta) / "v1-audio-faixa-2.json"
+ caminho_vazio.write_text(json.dumps({"status": "pendente"}),
+ encoding="utf-8")
+ with self.assertRaises(ArtefatoDeTranscricaoInvalido):
+ CarregadorDeArtefatosDeAudio().carregar([caminho_vazio], "v1")
+
+ def test_carregador_ignora_artefato_inexistente(self):
+ from engine.scanner.retakes import (
+ ArtefatoDeTranscricaoInvalido,
+ CarregadorDeArtefatosDeAudio,
+ )
+ with self.assertRaises(ArtefatoDeTranscricaoInvalido):
+ CarregadorDeArtefatosDeAudio().carregar(
+ ["/caminho/inexistente.json"], "v1")
+
+
+def _grupo_exemplo(diretoria: str):
+ """Registra um grupo mínimo no repositório e o devolve."""
+ repositorio = RepositorioDeRetakes(diretoria_de_trabalho=diretoria)
+ grupo = GrupoDeRetake(id="g1", video_id="v1", faixa_id="f1",
+ tipo="retake_confirmado", confianca=0.9)
+ repositorio.registrar("v1", [grupo])
+ return grupo
+
+
+class TesteRevisaoManualECarregador(unittest.TestCase):
+ """Cobre a revisão manual do repositório e o carregador de artefatos."""
+
+ def setUp(self) -> None:
+ self.pasta = tempfile.TemporaryDirectory()
+ self.addCleanup(self.pasta.cleanup)
+ self.repositorio = RepositorioDeRetakes(
+ diretoria_de_trabalho=self.pasta.name)
+
+ def test_status_padrao_e_pendente(self) -> None:
+ grupo = _grupo_exemplo(self.pasta.name)
+ self.assertEqual(grupo.status_de_revisao, "pendente_de_revisao")
+
+ def test_revisao_atualiza_e_persiste(self) -> None:
+ _grupo_exemplo(self.pasta.name)
+ atualizado = self.repositorio.atualizar_status_de_revisao(
+ "v1", "g1", "confirmado")
+ self.assertEqual(atualizado.status_de_revisao, "confirmado")
+ recarregado = self.repositorio.carregar("v1")[0]
+ self.assertEqual(recarregado.status_de_revisao, "confirmado")
+
+ def test_revisao_rejeita_status_invalido(self) -> None:
+ _grupo_exemplo(self.pasta.name)
+ with self.assertRaises(ValueError):
+ self.repositorio.atualizar_status_de_revisao("v1", "g1", "talvez")
+
+ def test_revisao_de_grupo_inexistente_levanta_erro(self) -> None:
+ with self.assertRaises(LookupError):
+ self.repositorio.atualizar_status_de_revisao("v1", "g9", "confirmado")
+
+ def test_carregador_converte_artefato_do_scanner(self) -> None:
+ from engine.scanner.retakes import CarregadorDeArtefatosDeAudio
+ artefato = Path(self.pasta.name) / "v1-audio-faixa-1.json"
+ artefato.write_text(json.dumps({
+ "status": "concluida",
+ "segmentos_por_clipe": [{"clipe": "clipe-1", "segmentos": [
+ {"inicio": 0.0, "fim": 4.0, "texto": "Hoje vamos testar."},
+ {"inicio": 5.0, "fim": 9.0, "texto": "Hoje vamos testar."},
+ ]}],
+ }), encoding="utf-8")
+ falas = CarregadorDeArtefatosDeAudio().carregar([artefato], "v1")
+ self.assertEqual(len(falas), 2)
+ self.assertEqual(falas[0].video_id, "v1")
+
+ def test_carregador_levanta_erro_sem_transcricao(self) -> None:
+ from engine.scanner.retakes import (
+ ArtefatoDeTranscricaoInvalido, CarregadorDeArtefatosDeAudio)
+ vazio = Path(self.pasta.name) / "vazio-audio-faixa-1.json"
+ vazio.write_text(json.dumps({"status": "pendente"}), encoding="utf-8")
+ with self.assertRaises(ArtefatoDeTranscricaoInvalido):
+ CarregadorDeArtefatosDeAudio().carregar([vazio], "vazio")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/code/engine/testes/test_persistencia_sqlite.py b/code/engine/testes/test_persistencia_sqlite.py
new file mode 100644
index 0000000..445a8b1
--- /dev/null
+++ b/code/engine/testes/test_persistencia_sqlite.py
@@ -0,0 +1,239 @@
+"""Testes da persistência SQLite das análises (timeline, transcrição,
+evidências visuais, metadados de arquivo e retakes)."""
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
+from engine.integracoes.midia.extracao_de_metadados import MetadadosDoArquivo
+from engine.persistencia import (
+ RepositorioDeAnalisesSQLite,
+ RepositorioDeRetakesSQLite,
+ RepositorioDeTimelineSQLite,
+ abrir_banco,
+ criar_esquema,
+)
+from engine.scanner.modelos import Cena, EvidenciaVisual, PalavraDeTranscricao, SegmentoDeTranscricao
+from engine.scanner.retakes.modelos_de_retakes import EvidenciaDeRetake, GrupoDeRetake, TomadaDeRetake
+from engine.scanner.transcricao_da_timeline import TranscricaoDoClipe
+
+TABELAS_ESPERADAS = {
+ "videos", "faixas", "clipes", "arquivos", "analises_versao",
+ "segmentos_de_transcricao", "palavras_de_transcricao",
+ "evidencias_visuais", "cenas", "grupos_de_retake",
+ "tomadas_de_retake", "evidencias_de_retake",
+}
+
+
+class TesteEsquema(unittest.TestCase):
+ def test_criar_esquema_cria_todas_as_tabelas(self):
+ with tempfile.TemporaryDirectory() as pasta:
+ conexao = abrir_banco(Path(pasta) / "analises.db")
+ tabelas = {
+ linha[0] for linha in conexao.execute(
+ "SELECT name FROM sqlite_master WHERE type = 'table'"
+ ).fetchall()
+ }
+ self.assertTrue(TABELAS_ESPERADAS.issubset(tabelas))
+
+ def test_criar_esquema_e_idempotente(self):
+ with tempfile.TemporaryDirectory() as pasta:
+ conexao = abrir_banco(Path(pasta) / "analises.db")
+ criar_esquema(conexao) # não deve levantar exceção ao rodar de novo
+ criar_esquema(conexao)
+
+
+class TesteRepositorioDeRetakesSQLite(unittest.TestCase):
+ def _grupo_de_exemplo(self) -> GrupoDeRetake:
+ return GrupoDeRetake(
+ id="retake_abc123",
+ video_id="v1",
+ faixa_id="f1",
+ tipo="retake_confirmado",
+ confianca=0.92,
+ tomadas=[
+ TomadaDeRetake(0, "fala-1", "clip-1", 0.0, 6.0, "Hoje nós vamos mostrar.",
+ similaridade_com_anterior=0.0, confianca=0.92),
+ TomadaDeRetake(1, "fala-2", "clip-1", 9.0, 15.0, "Hoje nós vamos mostrar.",
+ similaridade_com_anterior=1.0, confianca=0.92),
+ ],
+ evidencias=[
+ EvidenciaDeRetake("similaridade_textual", "Textos quase idênticos.", 0.98),
+ EvidenciaDeRetake("mudanca_visual_proxima", "Corte visual próximo.", None),
+ ],
+ )
+
+ def test_registrar_e_carregar_preserva_campos(self):
+ with tempfile.TemporaryDirectory() as pasta:
+ repositorio = RepositorioDeRetakesSQLite(Path(pasta) / "analises.db")
+ grupo = self._grupo_de_exemplo()
+
+ repositorio.registrar("v1", [grupo])
+ carregados = repositorio.carregar("v1")
+
+ self.assertEqual(len(carregados), 1)
+ recarregado = carregados[0]
+ self.assertEqual(recarregado.id, grupo.id)
+ self.assertEqual(recarregado.video_id, grupo.video_id)
+ self.assertEqual(recarregado.faixa_id, grupo.faixa_id)
+ self.assertEqual(recarregado.tipo, grupo.tipo)
+ self.assertEqual(recarregado.confianca, grupo.confianca)
+ self.assertEqual(len(recarregado.tomadas), 2)
+ self.assertEqual(recarregado.tomadas[0].fala_id, "fala-1")
+ self.assertEqual(recarregado.tomadas[1].texto, "Hoje nós vamos mostrar.")
+ tipos_de_evidencia = {e.tipo for e in recarregado.evidencias}
+ self.assertEqual(tipos_de_evidencia, {"similaridade_textual", "mudanca_visual_proxima"})
+
+ def test_ja_analisado_reflete_hash_de_versao(self):
+ with tempfile.TemporaryDirectory() as pasta:
+ banco = Path(pasta) / "analises.db"
+ repositorio = RepositorioDeRetakesSQLite(banco)
+ grupo = self._grupo_de_exemplo()
+
+ self.assertFalse(repositorio.ja_analisado("v1"))
+ repositorio.registrar("v1", [grupo])
+ self.assertTrue(repositorio.ja_analisado("v1"))
+ # Uma versão visual diferente não deve reaproveitar o cache.
+ self.assertFalse(repositorio.ja_analisado("v1", versao_visual="visual"))
+
+ def test_registrar_de_novo_substitui_grupos_anteriores(self):
+ with tempfile.TemporaryDirectory() as pasta:
+ repositorio = RepositorioDeRetakesSQLite(Path(pasta) / "analises.db")
+ grupo = self._grupo_de_exemplo()
+
+ repositorio.registrar("v1", [grupo])
+ repositorio.registrar("v1", [])
+
+ self.assertEqual(repositorio.carregar("v1"), [])
+
+
+class TesteRepositorioDeAnalisesSQLite(unittest.TestCase):
+ def test_registrar_metadados_de_arquivo_round_trip(self):
+ with tempfile.TemporaryDirectory() as pasta:
+ repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
+ metadados = MetadadosDoArquivo(
+ caminho=Path("/videos/entrada.mp4"), formato="mov,mp4",
+ duracao=21.4, codec_de_video="h264", codec_de_audio="aac",
+ largura=1920, altura=1080, taxa_de_quadros=29.97,
+ canais_de_audio=2, taxa_de_amostragem=48000,
+ tamanho_em_bytes=123456, orientacao=None, timecode="00:00:00:00",
+ )
+
+ repositorio.registrar_metadados_de_arquivo("v1", metadados, hash_do_conteudo="hash123")
+
+ linha = repositorio.conexao.execute(
+ "SELECT * FROM arquivos WHERE video_id = ?", ("v1",),
+ ).fetchone()
+ self.assertEqual(linha["caminho"], "/videos/entrada.mp4")
+ self.assertEqual(linha["largura"], 1920)
+ self.assertEqual(linha["hash_do_conteudo"], "hash123")
+
+ def test_registrar_e_carregar_transcricoes_com_palavras(self):
+ with tempfile.TemporaryDirectory() as pasta:
+ repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
+ segmento = SegmentoDeTranscricao(
+ inicio=0.0, fim=4.8, texto="Hoje nós vamos mostrar o sistema.",
+ confianca=0.95,
+ palavras=(
+ PalavraDeTranscricao("Hoje", 0.0, 0.4, confianca=0.9),
+ PalavraDeTranscricao("nós", 0.4, 0.7, confianca=0.9),
+ ),
+ emocao="neutro", confianca_emocao=0.6,
+ caracteristicas_acusticas={"pitch_medio": 120.5},
+ falante="locutor", voz_aparente="masculina", confianca_voz=0.8,
+ )
+ transcricao = TranscricaoDoClipe("clip-1", "/v.mp4", 0.0, 4.8, [segmento])
+
+ repositorio.registrar_transcricoes("v1", [transcricao])
+ carregados = repositorio.carregar_transcricoes("v1", "clip-1")
+
+ self.assertEqual(len(carregados), 1)
+ recarregado = carregados[0]
+ self.assertEqual(recarregado.texto, segmento.texto)
+ self.assertEqual(len(recarregado.palavras), 2)
+ self.assertEqual(recarregado.palavras[0].texto, "Hoje")
+ self.assertEqual(recarregado.caracteristicas_acusticas, {"pitch_medio": 120.5})
+ self.assertEqual(recarregado.voz_aparente, "masculina")
+
+ def test_registrar_evidencias_visuais_e_cenas(self):
+ with tempfile.TemporaryDirectory() as pasta:
+ repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
+ evidencia = EvidenciaVisual(
+ tipo="qualidade", inicio=1.0, fim=2.0,
+ valor={"nitidez": 0.87}, confianca=0.9, provider="opencv", modelo=None,
+ )
+ cena = Cena(inicio=0.0, fim=5.0, confianca=0.8, referencias=(1.2, 3.4))
+
+ repositorio.registrar_evidencias_visuais("v1", "clip-1", [evidencia])
+ repositorio.registrar_cenas("v1", [cena], clipe_id="clip-1")
+
+ linha_evidencia = repositorio.conexao.execute(
+ "SELECT * FROM evidencias_visuais WHERE video_id = ?", ("v1",),
+ ).fetchone()
+ linha_cena = repositorio.conexao.execute(
+ "SELECT * FROM cenas WHERE video_id = ?", ("v1",),
+ ).fetchone()
+
+ self.assertEqual(linha_evidencia["tipo"], "qualidade")
+ self.assertIn("nitidez", linha_evidencia["valor"])
+ self.assertEqual(linha_cena["clipe_id"], "clip-1")
+
+
+class TesteRepositorioDeTimelineSQLite(unittest.TestCase):
+ def test_registrar_e_carregar_timeline_com_faixas_e_clipes(self):
+ clipe = Clipe(
+ identificador="clip-1", nome="Take 1",
+ intervalo_na_timeline=IntervaloDeTempo(0.0, 6.0),
+ intervalo_na_origem=IntervaloDeTempo(10.0, 16.0),
+ arquivo="/videos/entrada.mp4", identificador_da_faixa="f1",
+ metadados={"origem": "camera-a"},
+ )
+ faixa = Faixa(identificador="f1", nome="V1", tipo="video", indice=0, clipes=[clipe])
+ timeline = Timeline(
+ identificador="v1", nome="Sequência", duracao=21.4,
+ taxa_de_quadros=29.97, largura=1920, altura=1080, faixas=[faixa],
+ )
+
+ with tempfile.TemporaryDirectory() as pasta:
+ repositorio = RepositorioDeTimelineSQLite(Path(pasta) / "analises.db")
+
+ repositorio.registrar_timeline(timeline)
+ recarregada = repositorio.carregar_timeline("v1")
+
+ self.assertIsNotNone(recarregada)
+ self.assertEqual(recarregada.identificador, "v1")
+ self.assertEqual(recarregada.largura, 1920)
+ self.assertEqual(len(recarregada.faixas), 1)
+ faixa_recarregada = recarregada.faixas[0]
+ self.assertEqual(faixa_recarregada.identificador, "f1")
+ self.assertEqual(len(faixa_recarregada.clipes), 1)
+ clipe_recarregado = faixa_recarregada.clipes[0]
+ self.assertEqual(clipe_recarregado.arquivo, "/videos/entrada.mp4")
+ self.assertEqual(clipe_recarregado.intervalo_na_origem.inicio, 10.0)
+ self.assertEqual(clipe_recarregado.metadados, {"origem": "camera-a"})
+
+ def test_carregar_timeline_inexistente_retorna_none(self):
+ with tempfile.TemporaryDirectory() as pasta:
+ repositorio = RepositorioDeTimelineSQLite(Path(pasta) / "analises.db")
+ self.assertIsNone(repositorio.carregar_timeline("desconhecido"))
+
+ def test_registrar_de_novo_substitui_faixas_e_clipes(self):
+ faixa_inicial = Faixa(identificador="f1", nome="V1", tipo="video", indice=0, clipes=[
+ Clipe("clip-1", "Take 1", IntervaloDeTempo(0.0, 6.0)),
+ ])
+ timeline = Timeline(identificador="v1", nome="Sequência", faixas=[faixa_inicial])
+
+ with tempfile.TemporaryDirectory() as pasta:
+ repositorio = RepositorioDeTimelineSQLite(Path(pasta) / "analises.db")
+ repositorio.registrar_timeline(timeline)
+
+ timeline_atualizada = Timeline(identificador="v1", nome="Sequência", faixas=[])
+ repositorio.registrar_timeline(timeline_atualizada)
+ recarregada = repositorio.carregar_timeline("v1")
+
+ self.assertEqual(recarregada.faixas, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/code/relatorios/audio/arquivos/92f97877259a86a8095b1eecafdefe357212a12247d04b78df60e0c0ae38b2ea/audio/parte-0000.wav b/code/relatorios/audio/arquivos/92f97877259a86a8095b1eecafdefe357212a12247d04b78df60e0c0ae38b2ea/audio/parte-0000.wav
new file mode 100644
index 0000000..54dfb87
Binary files /dev/null and b/code/relatorios/audio/arquivos/92f97877259a86a8095b1eecafdefe357212a12247d04b78df60e0c0ae38b2ea/audio/parte-0000.wav differ