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 ## 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 ## 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/`. 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"> <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 <span class="tab-icon" aria-hidden="true">◉</span>Scanner
</button> </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"> <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 <span class="tab-icon" aria-hidden="true">⇄</span>Conexão
</button> </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> <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"> <section class="card">
<div class="card-head"><h2>Amostragem e faixas</h2></div> <div class="card-head"><h2>Amostragem e faixas</h2></div>
<label class="field-label" for="scannerInterval">Frames de vídeo</label> <label class="field-label" for="scannerInterval">Segundos por quadro</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> <div class="range-field-row">
<p class="field-help">Use a amostragem precisa somente no trecho que será reenquadrado.</p> <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> <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> <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> <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> <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> </section></div>
</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 ============================== --> <!-- ============================== CONEXÃO ============================== -->
<div class="tab-panel" id="tabBridge" role="tabpanel" aria-labelledby="tabBtnBridge" hidden> <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 = [ var tabs = [
{ key: "silence", panel: "tabSilence", btn: "tabBtnSilence" }, { key: "silence", panel: "tabSilence", btn: "tabBtnSilence" },
{ key: "scanner", panel: "tabScanner", btn: "tabBtnScanner" }, { key: "scanner", panel: "tabScanner", btn: "tabBtnScanner" },
{ key: "retakes", panel: "tabRetakes", btn: "tabBtnRetakes" },
{ key: "bridge", panel: "tabBridge", btn: "tabBtnBridge" }, { key: "bridge", panel: "tabBridge", btn: "tabBtnBridge" },
{ key: "models", panel: "tabModels", btn: "tabBtnModels" }, { key: "models", panel: "tabModels", btn: "tabBtnModels" },
{ key: "settings", panel: "tabSettings", btn: "tabBtnSettings" }, { key: "settings", panel: "tabSettings", btn: "tabBtnSettings" },
@@ -166,6 +167,190 @@ function switchTab(name) {
if (name === "silence") { syncTranscribeModelSelect(); syncLanguageInfo(); } if (name === "silence") { syncTranscribeModelSelect(); syncLanguageInfo(); }
if (name === "settings") renderEditorPersonalities(); if (name === "settings") renderEditorPersonalities();
if (name === "scanner") { renderScannerTranscriptionOptions(); loadScannerProfile(); renderScannerHuggingFaceStatus(); } 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 ----------------------------------------------------- // ---- 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); }); 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() { function scannerProfileFromScreen() {
var enabled = Array.prototype.slice.call(document.querySelectorAll("[data-scanner-resource]:checked")).map(function (item) { return item.getAttribute("data-scanner-resource"); }); 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); }); } 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"]); include("faces", ["qualidade_facial"]); include("pose", ["maos"]); include("ocr", ["codigos"]); include("estetica", ["horizonte", "retangulos", "contornos"]);
var modelo = document.getElementById("scannerTranscribeModel").value; var modelo = document.getElementById("scannerTranscribeModel").value;
var opcao = document.getElementById("scannerTranscribeModel").selectedOptions[0]; 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() { 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() { function loadScannerProfile() {
var saved; try { saved = JSON.parse(localStorage.getItem(SCANNER_PROFILE_KEY) || "null"); } catch (_) { saved = null; } 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); 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 || []); } 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; 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; }); 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 path = nodeRequire("path");
var os = nodeRequire("os"); var os = nodeRequire("os");
var https = nodeRequire("https"); var https = nodeRequire("https");
var childProcess = nodeRequire("child_process");
function defaultBridgeDirectory() { function defaultBridgeDirectory() {
try { try {
var nodeProcess = nodeRequire("process"); var nodeProcess = nodeRequire("process");
+3
View File
@@ -339,6 +339,9 @@ h1, h2, h3 { margin: 0; }
transition: border-color .15s, box-shadow .15s; transition: border-color .15s, box-shadow .15s;
} }
.select-field { height: 32px; padding: 0 8px; font-size: 10.5px; } .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 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; } .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; } 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.""" """Adapter Python para o runner Swift que concentra Vision e Apple Intelligence."""
import atexit
import json import json
import os import os
from pathlib import Path from pathlib import Path
import queue
import subprocess import subprocess
import tempfile import tempfile
import threading
from typing import Any, Callable from typing import Any, Callable
from ...scanner.modelos import EvidenciaVisual 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: 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", 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.fonte = Path(fonte) if fonte else Path(__file__).with_name("apple_vision_runner.swift")
self.compilador = compilador self.compilador = compilador
self.diretorio_de_build = Path(diretorio_de_build) if diretorio_de_build else ( self.diretorio_de_build = Path(diretorio_de_build) if diretorio_de_build else (
Path(tempfile.gettempdir()) / "jhonny-apple-vision") 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]: def executar(self, carga: dict[str, Any], timeout: float) -> dict[str, Any]:
executavel = self._garantir_compilado() executavel = self._garantir_compilado()
processo = subprocess.run([str(executavel)], input=json.dumps(carga), capture_output=True, pool = self._garantir_pool(executavel)
text=True, timeout=timeout) trabalhador = pool.get()
if processo.returncode != 0:
detalhe = processo.stderr.strip() or "runner Apple terminou sem diagnóstico"
raise RuntimeError(f"Runner Apple Vision falhou: {detalhe}")
try: try:
return json.loads(processo.stdout) return trabalhador.executar(carga, timeout)
except json.JSONDecodeError as exc: except Exception:
raise RuntimeError(f"Runner Apple Vision devolveu JSON inválido: {processo.stdout!r}") from exc 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: def _garantir_compilado(self) -> Path:
if not self.fonte.is_file(): if not self.fonte.is_file():
@@ -350,19 +350,32 @@ func interpretar(_ saida: Saida) async -> String? {
} catch { return nil } } 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 { @main struct Principal {
static func main() async { 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 { do {
let entrada = try JSONDecoder().decode(Entrada.self, from: FileHandle.standardInput.readDataToEndOfFile()) let entrada = try JSONDecoder().decode(Entrada.self, from: entradaDados)
var saida = entrada.frames?.count ?? 0 > 1 ? analisarSequencia(entrada) : analisarVision(entrada) var resultado = entrada.frames?.count ?? 0 > 1 ? analisarSequencia(entrada) : analisarVision(entrada)
if entrada.resumir, let resumo = await interpretar(saida) { if entrada.resumir, let resumo = await interpretar(resultado) {
saida = Saida(evidencias: saida.evidencias + [Evidencia(tipo: "interpretacao_editorial", valor: ["texto": .texto(resumo)], confianca: nil, modelo: "AppleFoundationModels")], avisos: saida.avisos) 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) saida = resultado
FileHandle.standardOutput.write(dados)
} catch { } catch {
FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) saida = Saida(evidencias: [], avisos: ["erro_fatal: \(error.localizedDescription)"])
exit(1) }
guard let dados = try? JSONEncoder().encode(saida), let linhaDeSaida = String(data: dados, encoding: .utf8) else {
FileHandle.standardError.write(Data("Falha ao serializar resposta\n".utf8))
continue
}
print(linhaDeSaida)
fflush(stdout)
} }
} }
} }
@@ -1,6 +1,7 @@
"""Extrator de frames para Vision usando somente ferramentas locais do sistema.""" """Extrator de frames para Vision usando somente ferramentas locais do sistema."""
import json import json
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path from pathlib import Path
import subprocess import subprocess
@@ -16,16 +17,24 @@ class ExtratorDeQuadrosFFmpeg(ExtratorDeQuadros):
def __init__(self, intervalo_em_segundos: float = 1.0, def __init__(self, intervalo_em_segundos: float = 1.0,
diretorio_de_cache: str | Path = ".frames", diretorio_de_cache: str | Path = ".frames",
maximo_de_quadros: int | None = None, 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: if intervalo_em_segundos <= 0:
raise ValueError("intervalo_em_segundos deve ser positivo.") raise ValueError("intervalo_em_segundos deve ser positivo.")
if maximo_de_quadros is not None and maximo_de_quadros < 1: if maximo_de_quadros is not None and maximo_de_quadros < 1:
raise ValueError("maximo_de_quadros deve ser maior que zero.") 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.intervalo_em_segundos = intervalo_em_segundos
self.diretorio_de_cache = Path(diretorio_de_cache) self.diretorio_de_cache = Path(diretorio_de_cache)
self.maximo_de_quadros = maximo_de_quadros self.maximo_de_quadros = maximo_de_quadros
self.ffmpeg = ffmpeg self.ffmpeg = ffmpeg
self.ffprobe = ffprobe 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]: def extrair(self, clipe: Clipe) -> list[QuadroDeVideo]:
if not clipe.arquivo: if not clipe.arquivo:
@@ -37,28 +46,44 @@ class ExtratorDeQuadrosFFmpeg(ExtratorDeQuadros):
inicio, fim = ExtratorDeQuadrosOpenCV._intervalo_de_origem(clipe, duracao) inicio, fim = ExtratorDeQuadrosOpenCV._intervalo_de_origem(clipe, duracao)
timestamps = ExtratorDeQuadrosOpenCV(self.intervalo_em_segundos, timestamps = ExtratorDeQuadrosOpenCV(self.intervalo_em_segundos,
maximo_de_quadros=self.maximo_de_quadros)._timestamps(inicio, fim) 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 # 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. # 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) 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) amostrado, processo = self._extrair_frame(arquivo, destino, timestamp, inicio)
if processo.returncode != 0 or not destino.is_file(): if processo.returncode != 0 or not destino.is_file():
detalhe = processo.stderr.strip() or "ffmpeg não produziu o frame" 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}") raise RuntimeError(f"Não foi possível extrair frame em {timestamp}s: {detalhe}")
resultado.append(QuadroDeVideo(amostrado, indice, largura, altura, None, destino)) return QuadroDeVideo(amostrado, indice, largura, altura, None, destino)
return resultado
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, def _extrair_frame(self, arquivo: Path, destino: Path, timestamp: float,
inicio: float) -> tuple[float, subprocess.CompletedProcess[str]]: inicio: float) -> tuple[float, subprocess.CompletedProcess[str]]:
"""Recua um pouco no fim do arquivo se o decoder não encontrar frame no timestamp.""" """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 ultimo: subprocess.CompletedProcess[str] | None = None
for candidato in (timestamp, max(inicio, timestamp - 0.1)): for candidato in (timestamp, max(inicio, timestamp - 0.1)):
if destino.exists(): if destino.exists():
destino.unlink() destino.unlink()
processo = subprocess.run([self.ffmpeg, "-hide_banner", "-loglevel", "error", "-ss", str(candidato), processo = subprocess.run([*comando_base, "-ss", str(candidato), "-i", str(arquivo),
"-i", str(arquivo), "-frames:v", "1", "-y", str(destino)], "-frames:v", "1", *filtro, "-y", str(destino)],
capture_output=True, text=True, timeout=60) capture_output=True, text=True, timeout=60)
if processo.returncode == 0 and destino.is_file(): if processo.returncode == 0 and destino.is_file():
return candidato, processo 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) criar_detector_apple_vision, criar_detector_visual_local)
from .relatorio_visual import gerar_relatorio_visual, gerar_resumo_visual_markdown from .relatorio_visual import gerar_relatorio_visual, gerar_resumo_visual_markdown
from .configuracao_visual import ConfiguracaoVisualDoScanner from .configuracao_visual import ConfiguracaoVisualDoScanner
from .retakes import (AgrupadorDeTakes, CoordenadorDeRetakes, DeteccaoDeRetakes,
Fala, GrupoDeRetake)
__all__ = ["AnaliseVisualDaTimeline", "AnalisadorDeTimeline", "ArquivoDescoberto", __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", "ConfiguracaoVisualDoScanner",
"EvidenciaVisual", "ErroDeAnalise", "Evento", "ExtracaoDeMetadados", "EvidenciaVisual", "ErroDeAnalise", "Evento", "ExtracaoDeMetadados",
"gerar_relatorio_visual", "gerar_relatorio_visual",
@@ -9,12 +9,24 @@ RECURSOS_VISION = frozenset((
"estetica", "codigos", "horizonte", "retangulos", "contornos", "segmentacao_de_pessoas", "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) @dataclass(frozen=True)
class ConfiguracaoVisualDoScanner: class ConfiguracaoVisualDoScanner:
"""Interface curta para uma execução de leitura visual da timeline.""" """Interface curta para uma execução de leitura visual da timeline."""
intervalo_em_segundos: float = 1.0 intervalo_em_segundos: float = 1.0
resolucao_maxima: int = 1080
faixas_de_video: tuple[int, ...] = () faixas_de_video: tuple[int, ...] = ()
faixas_de_audio: tuple[int, ...] = () faixas_de_audio: tuple[int, ...] = ()
recursos_vision: frozenset[str] = RECURSOS_VISION recursos_vision: frozenset[str] = RECURSOS_VISION
@@ -22,21 +34,37 @@ class ConfiguracaoVisualDoScanner:
analisar_continuidade: bool = True analisar_continuidade: bool = True
preparar_reenquadramento: bool = False preparar_reenquadramento: bool = False
interpretar_cena: bool = True 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: def __post_init__(self) -> None:
if not 0.04 <= self.intervalo_em_segundos <= 60: if not 0.04 <= self.intervalo_em_segundos <= 60:
raise ValueError("intervalo_em_segundos deve estar entre 0,04 e 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 desconhecidos = self.recursos_vision - RECURSOS_VISION
if desconhecidos: if desconhecidos:
raise ValueError(f"Recursos Vision desconhecidos: {', '.join(sorted(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): 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.") 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 @classmethod
def de_dict(cls, dados: dict[str, Any]) -> "ConfiguracaoVisualDoScanner": def de_dict(cls, dados: dict[str, Any]) -> "ConfiguracaoVisualDoScanner":
"""Lê o mesmo JSON produzido pelo painel, sem vazar detalhes de adapters.""" """Lê o mesmo JSON produzido pelo painel, sem vazar detalhes de adapters."""
return cls( return cls(
intervalo_em_segundos=float(dados.get("intervalo_em_segundos", 1)), 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_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", ())))), 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)), recursos_vision=frozenset(dados.get("recursos_vision", RECURSOS_VISION)),
@@ -44,12 +72,16 @@ class ConfiguracaoVisualDoScanner:
analisar_continuidade=bool(dados.get("analisar_continuidade", True)), analisar_continuidade=bool(dados.get("analisar_continuidade", True)),
preparar_reenquadramento=bool(dados.get("preparar_reenquadramento", False)), preparar_reenquadramento=bool(dados.get("preparar_reenquadramento", False)),
interpretar_cena=bool(dados.get("interpretar_cena", True)), 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]: def para_dict(self) -> dict[str, Any]:
return { return {
"versao": 1, "versao": 1,
"intervalo_em_segundos": self.intervalo_em_segundos, "intervalo_em_segundos": self.intervalo_em_segundos,
"resolucao_maxima": self.resolucao_maxima,
"faixas_de_video": list(self.faixas_de_video), "faixas_de_video": list(self.faixas_de_video),
"faixas_de_audio": list(self.faixas_de_audio), "faixas_de_audio": list(self.faixas_de_audio),
"recursos_vision": sorted(self.recursos_vision), "recursos_vision": sorted(self.recursos_vision),
@@ -57,4 +89,7 @@ class ConfiguracaoVisualDoScanner:
"analisar_continuidade": self.analisar_continuidade, "analisar_continuidade": self.analisar_continuidade,
"preparar_reenquadramento": self.preparar_reenquadramento, "preparar_reenquadramento": self.preparar_reenquadramento,
"interpretar_cena": self.interpretar_cena, "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) eventos: list[Any] = field(default_factory=list)
arquivos: dict[str, Any] = field(default_factory=dict) arquivos: dict[str, Any] = field(default_factory=dict)
metadados_dos_arquivos: dict[str, Any] = field(default_factory=dict) metadados_dos_arquivos: dict[str, Any] = field(default_factory=dict)
retakes: Any = None
class Analisador(Protocol): 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.video_id = video_id
self.faixa_id = faixa_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( def converter(
self, self,
transcricoes: Iterable[TranscricaoDoClipe], transcricoes: Iterable[TranscricaoDoClipe],
@@ -84,20 +89,59 @@ class AdaptadorDeFalas:
for segmento in transcricao.segmentos: for segmento in transcricao.segmentos:
if _e_ruido(segmento.texto): if _e_ruido(segmento.texto):
continue continue
falas.append(Fala( falas.append(self._fala_de_segmento(segmento, segmento_id, video_id, faixa_id))
id=f"{segmento_id}:{segmento.inicio:.3f}",
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, video_id=video_id,
faixa_id=faixa_id, faixa_id=faixa_id,
segmento_id=segmento_id, segmento_id=segmento_id,
ordem=-1, # preenchida após a ordenação ordem=-1, # preenchida na ordenação
inicio=segmento.inicio, inicio=inicio,
fim=segmento.fim, fim=fim,
texto=segmento.texto, texto=texto,
texto_normalizado=_normalizar(segmento.texto), texto_normalizado=_normalizar(texto),
palavras=segmento.palavras, palavras=getattr(segmento, "palavras", ()) or (),
falante=segmento.falante, falante=getattr(segmento, "falante", None),
)) )
@staticmethod
def _ordenar(falas: list[Fala]) -> list[Fala]:
falas.sort(key=lambda item: (item.inicio, item.fim)) falas.sort(key=lambda item: (item.inicio, item.fim))
for indice, fala in enumerate(falas): for indice, fala in enumerate(falas):
falas[indice] = Fala( falas[indice] = Fala(
@@ -18,7 +18,6 @@ from .modelos_de_retakes import (
AgrupamentoDeFalas, AgrupamentoDeFalas,
Fala, Fala,
ParAgrupado, ParAgrupado,
ResultadoDoIntervalo,
SinalDeReinicio, SinalDeReinicio,
) )
@@ -34,6 +33,10 @@ _JANELA_PADRAO = 5
_ZONA_DE_DUVIDA_INFERIOR = 0.60 _ZONA_DE_DUVIDA_INFERIOR = 0.60
_ZONA_DE_DUVIDA_SUPERIOR = 0.85 _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: class AgrupadorDeTakes:
"""Gera grupos candidatos conectando falas semelhantes e próximas.""" """Gera grupos candidatos conectando falas semelhantes e próximas."""
@@ -52,8 +55,13 @@ class AgrupadorDeTakes:
self.detector_de_reinicios = detector_de_reinicios self.detector_de_reinicios = detector_de_reinicios
self.janela = janela 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)) 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)} sinais = {sinal.fala_id: sinal for sinal in self.detector_de_reinicios.detectar(falas)}
arestas: list[tuple[int, int, ParAgrupado]] = [] arestas: list[tuple[int, int, ParAgrupado]] = []
@@ -63,7 +71,7 @@ class AgrupadorDeTakes:
fala_b = falas[jindice] fala_b = falas[jindice]
if fala_a.faixa_id != fala_b.faixa_id: if fala_a.faixa_id != fala_b.faixa_id:
continue 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: if par is not None:
arestas.append((indice, jindice, par)) arestas.append((indice, jindice, par))
@@ -74,6 +82,7 @@ class AgrupadorDeTakes:
fala_a: Fala, fala_a: Fala,
fala_b: Fala, fala_b: Fala,
sinais: dict[str, SinalDeReinicio], sinais: dict[str, SinalDeReinicio],
limites_de_cena: list[float] | None = None,
) -> ParAgrupado | None: ) -> ParAgrupado | None:
comparacao = self.comparador.comparar(fala_a, fala_b) comparacao = self.comparador.comparar(fala_a, fala_b)
intervalo = self.analisador_de_intervalos.analisar(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)) and (tem_reinicio or intervalo.indicio_de_nova_tentativa))
) )
if candidato: 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, return ParAgrupado(fala_a, fala_b, comparacao, intervalo,
round(semantica, 4)) round(semantica, 4), mudanca_visual)
return None 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 @staticmethod
def _agrupar_por_arestas( def _agrupar_por_arestas(
falas: list[Fala], 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: if not evidencias:
return None 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( return SinalDeReinicio(
fala_id=fala.id, 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), intensidade=round(min(1.0, max(intensidades)), 3),
evidencias=evidencias, 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 comparacao: ResultadoDaComparacao
intervalo: ResultadoDoIntervalo intervalo: ResultadoDoIntervalo
similaridade_semantica: float = 0.0 similaridade_semantica: float = 0.0
mudanca_visual_proxima: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -126,6 +127,24 @@ class AgrupamentoDeFalas:
sinais_de_reinicio: tuple[SinalDeReinicio, ...] = () 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) @dataclass(frozen=True)
class GrupoDeRetake: class GrupoDeRetake:
"""Agrupa as tomadas que representam tentativas da mesma fala.""" """Agrupa as tomadas que representam tentativas da mesma fala."""
@@ -138,6 +157,7 @@ class GrupoDeRetake:
tomadas: list[TomadaDeRetake] = field(default_factory=list) tomadas: list[TomadaDeRetake] = field(default_factory=list)
evidencias: list[EvidenciaDeRetake] = field(default_factory=list) evidencias: list[EvidenciaDeRetake] = field(default_factory=list)
criado_em: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) criado_em: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
status_de_revisao: str = STATUS_PENDENTE
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not 0.0 <= self.confianca <= 1.0: 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 collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field from dataclasses import dataclass, field
from ..dominio import Clipe from ..dominio import Clipe
@@ -27,12 +29,14 @@ class DetectorDeCenas:
analisadores_de_frame: list[AnalisadorDeFrame] | None = None, analisadores_de_frame: list[AnalisadorDeFrame] | None = None,
detectores_de_intervalo: list[DetectorDeIntervalosDeCena] | None = None, detectores_de_intervalo: list[DetectorDeIntervalosDeCena] | None = None,
analisadores_de_sequencia: list[AnalisadorDeSequenciaDeQuadros] | 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.extrator = extrator
self.analisadores_de_frame = analisadores_de_frame or [] self.analisadores_de_frame = analisadores_de_frame or []
self.analisadores_de_sequencia = analisadores_de_sequencia or [] self.analisadores_de_sequencia = analisadores_de_sequencia or []
self.detectores_de_intervalo = detectores_de_intervalo or [] self.detectores_de_intervalo = detectores_de_intervalo or []
self.ao_progresso = ao_progresso self.ao_progresso = ao_progresso
self.trabalhadores_em_paralelo = max(1, trabalhadores_em_paralelo)
def detectar(self, clipe: Clipe) -> ResultadoVisualDoClipe: def detectar(self, clipe: Clipe) -> ResultadoVisualDoClipe:
quadros = self.extrator.extrair(clipe) quadros = self.extrator.extrair(clipe)
@@ -49,12 +53,24 @@ class DetectorDeCenas:
def _analisar_quadros(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None, def _analisar_quadros(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
total: int = 0) -> list[EvidenciaVisual]: total: int = 0) -> list[EvidenciaVisual]:
resultado: list[EvidenciaVisual] = [] if not self.analisadores_de_frame or not quadros:
for quadro in quadros: return []
for analisador in self.analisadores_de_frame: tarefas = [(quadro, analisador) for quadro in quadros for analisador in self.analisadores_de_frame]
resultado.extend(analisador.analisar(quadro)) 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) 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, def _analisar_sequencia(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
total: int = 0) -> list[EvidenciaVisual]: total: int = 0) -> list[EvidenciaVisual]:
@@ -134,11 +150,28 @@ class AnaliseVisualDaTimeline:
return contexto 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( def criar_detector_visual_local(
diretorio_de_cache: str | None = ".frames", diretorio_de_cache: str | None = ".frames",
intervalo_em_segundos: float = 1.0, intervalo_em_segundos: float = 1.0,
configuracao: ConfiguracaoVisualDoScanner | None = None,
) -> DetectorDeCenas: ) -> DetectorDeCenas:
"""Monta o detector local padrão com uma interface curta para o Scanner.""" """Monta o detector local padrão com uma interface curta para o Scanner."""
from .configuracao_visual import ConfiguracaoVisualDoScanner
from ..integracoes.visual import ( from ..integracoes.visual import (
AnalisadorDeComposicaoOpenCV, AnalisadorDeComposicaoOpenCV,
AnalisadorDeContinuidadeOpenCV, AnalisadorDeContinuidadeOpenCV,
@@ -146,9 +179,11 @@ def criar_detector_visual_local(
ExtratorDeQuadrosOpenCV, ExtratorDeQuadrosOpenCV,
) )
perfil = configuracao or ConfiguracaoVisualDoScanner(intervalo_em_segundos=intervalo_em_segundos)
return DetectorDeCenas( return DetectorDeCenas(
ExtratorDeQuadrosOpenCV( ExtratorDeQuadrosOpenCV(
intervalo_em_segundos=intervalo_em_segundos, intervalo_em_segundos=perfil.intervalo_em_segundos,
diretorio_de_cache=diretorio_de_cache, diretorio_de_cache=diretorio_de_cache,
), ),
analisadores_de_frame=[ analisadores_de_frame=[
@@ -156,6 +191,7 @@ def criar_detector_visual_local(
AnalisadorDeComposicaoOpenCV(), AnalisadorDeComposicaoOpenCV(),
], ],
analisadores_de_sequencia=[AnalisadorDeContinuidadeOpenCV()], analisadores_de_sequencia=[AnalisadorDeContinuidadeOpenCV()],
detectores_de_intervalo=_detectores_de_cena_do_perfil(perfil),
) )
@@ -174,11 +210,13 @@ def criar_detector_apple_vision(
return DetectorDeCenas( return DetectorDeCenas(
ExtratorDeQuadrosFFmpeg(intervalo_em_segundos=perfil.intervalo_em_segundos, 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( analisadores_de_frame=[AnalisadorAppleVisionNativo(
recursos=tuple(sorted(perfil.recursos_vision)), recursos=tuple(sorted(perfil.recursos_vision)),
interpretar_com_apple_intelligence=perfil.interpretar_cena, interpretar_com_apple_intelligence=perfil.interpretar_cena,
)], )],
analisadores_de_sequencia=[AnalisadorSequenciaAppleVisionNativo()] analisadores_de_sequencia=[AnalisadorSequenciaAppleVisionNativo()]
if perfil.analisar_continuidade else [], 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()