feat: criado repositório jhonny-editor no Gitea

criado repositório jhonny-editor no Gitea
adicionado script admin/deploy.command com commit automático
atualizado admin/DEV-NOTES.md com template limpo

Resumo:
- 32 arquivos alterados
- 16 novos
- 15 modificados
- 0 removidos

 16 files changed, 638 insertions(+), 61 deletions(-)

Arquivos:
  - code/engine/skills/boas-praticas-oo.md -> .agents/skills/boas-praticas-oo/SKILL.md
  - AGENTS.md
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
  - code/engine/integracoes/visual/apple_vision.py
  - code/engine/integracoes/visual/apple_vision_runner.swift
  - code/engine/integracoes/visual/extrator_ffmpeg.py
  - code/engine/scanner/__init__.py
  - code/engine/scanner/configuracao_visual.py
  - code/engine/scanner/coordenacao/__init__.py
  - code/engine/scanner/retakes/adaptador_de_falas.py
  - code/engine/scanner/retakes/agrupador_de_takes.py
  - code/engine/scanner/retakes/detector_de_reinicios.py
  - code/engine/scanner/retakes/modelos_de_retakes.py
  - code/engine/scanner/visual.py
  - .jhonny/
  - .retakes/
  - CODING_STANDARDS.md
  - code/engine/executar_retakes.py
  - code/engine/persistencia/
  - code/engine/scanner/retakes/__init__.py
  - code/engine/scanner/retakes/carregador_de_artefatos.py
  - code/engine/scanner/retakes/classificador_de_retakes.py
  - code/engine/scanner/retakes/coordenador_de_retakes.py
  - code/engine/scanner/retakes/deteccao_de_retakes.py
  - code/engine/scanner/retakes/gerador_de_evidencias.py
  - code/engine/scanner/retakes/repositorio_de_retakes.py
  - code/engine/testes/test_consultas_de_persistencia.py
  - code/engine/testes/test_deteccao_de_retakes.py
  - code/engine/testes/test_persistencia_sqlite.py
  - code/relatorios/audio/arquivos/92f97877259a86a8095b1eecafdefe357212a12247d04b78df60e0c0ae38b2ea/
This commit is contained in:
João Henrique
2026-09-08 17:13:19 -04:00
parent b9bf3b2863
commit ee181360c8
40 changed files with 3674 additions and 61 deletions
Binary file not shown.
Binary file not shown.
View File
+71
View File
@@ -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"
}
]
}
+15
View File
@@ -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/`.
+68
View File
@@ -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.
+78 -3
View File
@@ -28,6 +28,9 @@
<button class="tab-button" id="tabBtnScanner" role="tab" aria-selected="false" aria-controls="tabScanner" onclick="switchTab('scanner')" type="button">
<span class="tab-icon" aria-hidden="true">◉</span>Scanner
</button>
<button class="tab-button" id="tabBtnRetakes" role="tab" aria-selected="false" aria-controls="tabRetakes" onclick="switchTab('retakes')" type="button">
<span class="tab-icon" aria-hidden="true">↻</span>Retakes
</button>
<button class="tab-button" id="tabBtnBridge" role="tab" aria-selected="false" aria-controls="tabBridge" onclick="switchTab('bridge')" type="button">
<span class="tab-icon" aria-hidden="true">⇄</span>Conexão
</button>
@@ -226,9 +229,19 @@
<p class="tab-intro">Escolha o que será lido no arquivo original. O Scanner usa os tempos da timeline, mas não exporta frames pelo Premiere.</p>
<section class="card">
<div class="card-head"><h2>Amostragem e faixas</h2></div>
<label class="field-label" for="scannerInterval">Frames de vídeo</label>
<select id="scannerInterval" class="select-field"><option value="0.2">Precisa — 5 frames por segundo</option><option value="1" selected>Equilibrada — 1 frame por segundo</option><option value="5">Econômica — 1 frame a cada 5 segundos</option></select>
<p class="field-help">Use a amostragem precisa somente no trecho que será reenquadrado.</p>
<label class="field-label" for="scannerInterval">Segundos por quadro</label>
<div class="range-field-row">
<input id="scannerInterval" type="range" min="0.2" max="10" step="0.1" value="1" oninput="scannerUpdateIntervalLabel()">
<span class="range-field-value" id="scannerIntervalValue">1 s/quadro</span>
</div>
<p class="field-help">Quanto menor, mais frames e mais detalhado (e mais lento). Valores maiores aceleram a análise, principalmente em clipes curtos.</p>
<label class="field-label" for="scannerResolution">Resolução da análise</label>
<select id="scannerResolution" class="select-field">
<option value="0">Original — sem redimensionar</option>
<option value="1080" selected>1080p — recomendado</option>
<option value="720">720p — mais rápido</option>
</select>
<p class="field-help">Reduzir a resolução acelera a extração e a leitura Vision sem perder precisão no rosto, pose ou objetos.</p>
<button class="button button-ghost" id="btnScannerDetectTracks" onclick="scannerDetectTracks()" type="button">Detectar faixas da sequência</button>
<p class="field-help" id="scannerTracksHelp">Abra a sequência desejada e detecte as faixas para selecioná-las.</p>
<div class="scanner-track-grid"><div><span class="field-label">Faixas de vídeo</span><div class="scanner-track-list" id="scannerVideoTracks"></div></div><div><span class="field-label">Faixas de áudio</span><div class="scanner-track-list" id="scannerAudioTracks"></div></div></div>
@@ -258,6 +271,68 @@
<div class="callout callout-info" id="scannerReportCard" hidden><strong>Relatório pronto</strong><p id="scannerReportPath"></p><div class="button-row"><button class="button button-ghost" onclick="scannerOpenReport()" type="button">Abrir JSON</button><button class="button button-ghost" onclick="scannerCopyReport()" type="button">Copiar conteúdo</button></div></div>
</section></div>
</div>
<!-- ============================== RETAKES ============================== -->
<div class="tab-panel" id="tabRetakes" role="tabpanel" aria-labelledby="tabBtnRetakes" hidden>
<p class="tab-intro">Identifica repetições e tentativas alternativas usando a transcrição do Scanner. O painel nunca corta o vídeo: você revisa e decide.</p>
<!-- 1. Arquivo ou conjunto analisado -->
<section class="card">
<div class="card-head"><h2>Arquivo analisado</h2><span class="card-kicker" id="retakesStatusGeral">—</span></div>
<div class="field-grid">
<div><label class="field-label" for="retakesCaso">Caso do Scanner</label>
<select id="retakesCaso" class="select-field" onchange="retakesCarregarCaso()"></select></div>
<div><button class="button button-ghost" onclick="retakesListarCasos()" type="button">Atualizar lista</button></div>
</div>
<p class="field-help" id="retakesInfoCaso">Selecione um caso já processado pelo Scanner.</p>
</section>
<!-- 2/3. Configurações e critérios -->
<div class="scanner-options-grid">
<section class="card"><div class="card-head"><h2>Critérios</h2></div>
<label class="field-label" for="retakesCriterio">Critério de confirmação</label>
<select id="retakesCriterio" class="select-field">
<option value="conservador">Conservador — só evidências fortes</option>
<option value="equilibrado" selected>Equilibrado — prováveis e fortes</option>
<option value="sensivel">Sensível — também possíveis</option>
</select>
<label class="checkbox-field"><input id="retakesReaproveitar" type="checkbox" checked><span>Reaproveitar análise existente</span></label>
<p class="field-help">A comparação textual, os reinícios de fala e as pausas já fazem parte da análise da engine.</p>
</section>
<section class="card"><div class="card-head"><h2>Análise visual auxiliar</h2></div>
<label class="checkbox-field"><input id="retakesUsarVisual" type="checkbox"><span>Usar cortes visuais como evidência</span></label>
<p class="field-help">Reforça a confiança, mas nunca decide sozinha. Reutiliza as cenas já analisadas.</p>
<div class="button-row">
<button class="button button-primary" id="btnRetakesAnalisar" onclick="retakesAnalisar()" type="button">Analisar retakes</button>
</div>
</section>
</div>
<!-- 4. Progresso -->
<div class="callout callout-info" id="retakesProgressoCard" hidden>
<strong id="retakesProgressoEtapa">Analisando retakes…</strong>
<p id="retakesProgressoDetalhe">—</p>
</div>
<!-- 5/6. Resultados e revisão -->
<section class="card">
<div class="card-head"><h2>Resultados</h2><span class="card-kicker" id="retakesContagem">—</span></div>
<label class="field-label" for="retakesFiltro">Filtrar</label>
<select id="retakesFiltro" class="select-field" onchange="retakesRenderResultados()">
<option value="todos" selected>Todos</option>
<option value="fortes">Fortes</option>
<option value="possiveis">Possíveis e prováveis</option>
<option value="pendente_de_revisao">Pendentes de revisão</option>
<option value="confirmado">Confirmados</option>
<option value="nao_e_retake">Rejeitados</option>
<option value="ignorado">Ignorados</option>
</select>
<div id="retakesResultados" role="list" aria-label="Grupos de retakes"></div>
<p class="field-help" id="retakesSemResultados" hidden>Nenhum retake identificado com os critérios atuais.</p>
</section>
<div id="retakesLog" role="log" aria-live="polite" aria-relevant="additions" tabindex="0" aria-label="Log de retakes"></div>
</div>
<!-- ============================== CONEXÃO ============================== -->
<div class="tab-panel" id="tabBridge" role="tabpanel" aria-labelledby="tabBtnBridge" hidden>
+197 -3
View File
@@ -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");
+3
View File
@@ -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; }
+164
View File
@@ -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()
+87 -10
View File
@@ -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():
@@ -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 {
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: 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)
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)
}
let dados = try JSONEncoder().encode(saida)
FileHandle.standardOutput.write(dados)
saida = resultado
} catch {
FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8))
exit(1)
saida = Saida(evidencias: [], avisos: ["erro_fatal: \(error.localizedDescription)"])
}
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)
}
}
}
@@ -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
+25
View File
@@ -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",
]
+20
View File
@@ -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
+463
View File
@@ -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.")
+166
View File
@@ -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()
@@ -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))),
)
@@ -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
@@ -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,
)
+5 -1
View File
@@ -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",
@@ -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,
}
@@ -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):
+92
View File
@@ -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",
]
@@ -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}",
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 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,
))
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(
@@ -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],
@@ -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 ``<caso>-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)
@@ -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))
@@ -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()
@@ -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
@@ -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,
)
@@ -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
@@ -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:
@@ -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"),
)
+46 -8
View File
@@ -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),
)
@@ -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()
@@ -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()
@@ -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()