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:
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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/`.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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 {
|
||||||
do {
|
while let linha = readLine(strippingNewline: true) {
|
||||||
let entrada = try JSONDecoder().decode(Entrada.self, from: FileHandle.standardInput.readDataToEndOfFile())
|
guard !linha.isEmpty, let entradaDados = linha.data(using: .utf8) else { continue }
|
||||||
var saida = entrada.frames?.count ?? 0 > 1 ? analisarSequencia(entrada) : analisarVision(entrada)
|
let saida: Saida
|
||||||
if entrada.resumir, let resumo = await interpretar(saida) {
|
do {
|
||||||
saida = Saida(evidencias: saida.evidencias + [Evidencia(tipo: "interpretacao_editorial", valor: ["texto": .texto(resumo)], confianca: nil, modelo: "AppleFoundationModels")], avisos: saida.avisos)
|
let entrada = try JSONDecoder().decode(Entrada.self, from: entradaDados)
|
||||||
|
var resultado = entrada.frames?.count ?? 0 > 1 ? analisarSequencia(entrada) : analisarVision(entrada)
|
||||||
|
if entrada.resumir, let resumo = await interpretar(resultado) {
|
||||||
|
resultado = Saida(evidencias: resultado.evidencias + [Evidencia(
|
||||||
|
tipo: "interpretacao_editorial", valor: ["texto": .texto(resumo)],
|
||||||
|
confianca: nil, modelo: "AppleFoundationModels")], avisos: resultado.avisos)
|
||||||
|
}
|
||||||
|
saida = resultado
|
||||||
|
} catch {
|
||||||
|
saida = Saida(evidencias: [], avisos: ["erro_fatal: \(error.localizedDescription)"])
|
||||||
}
|
}
|
||||||
let dados = try JSONEncoder().encode(saida)
|
guard let dados = try? JSONEncoder().encode(saida), let linhaDeSaida = String(data: dados, encoding: .utf8) else {
|
||||||
FileHandle.standardOutput.write(dados)
|
FileHandle.standardError.write(Data("Falha ao serializar resposta\n".utf8))
|
||||||
} catch {
|
continue
|
||||||
FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8))
|
}
|
||||||
exit(1)
|
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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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.")
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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}",
|
|
||||||
video_id=video_id,
|
|
||||||
faixa_id=faixa_id,
|
|
||||||
segmento_id=segmento_id,
|
|
||||||
ordem=-1, # preenchida após a ordenação
|
|
||||||
inicio=segmento.inicio,
|
|
||||||
fim=segmento.fim,
|
|
||||||
texto=segmento.texto,
|
|
||||||
texto_normalizado=_normalizar(segmento.texto),
|
|
||||||
palavras=segmento.palavras,
|
|
||||||
falante=segmento.falante,
|
|
||||||
))
|
|
||||||
|
|
||||||
|
return self._ordenar(falas)
|
||||||
|
|
||||||
|
def converter_de_segmentos(
|
||||||
|
self,
|
||||||
|
segmentos_por_clipe: Iterable[tuple[str, Iterable[object]]],
|
||||||
|
video_id: str | None = None,
|
||||||
|
faixa_id: str | None = None,
|
||||||
|
) -> list[Fala]:
|
||||||
|
"""Converte ``(segmento_id, [SegmentoDeTranscricao])`` em falas.
|
||||||
|
|
||||||
|
Usado quando a transcrição chega no ``ContextoDeAnalise`` do scanner
|
||||||
|
já como segmentos individuais (padrão do pipeline), em vez de
|
||||||
|
``TranscricaoDoClipe``.
|
||||||
|
"""
|
||||||
|
video_id = video_id or self.video_id or "video"
|
||||||
|
faixa_id = faixa_id or self.faixa_id or "faixa"
|
||||||
|
|
||||||
|
falas: list[Fala] = []
|
||||||
|
for segmento_id, segmentos in segmentos_por_clipe:
|
||||||
|
for segmento in segmentos:
|
||||||
|
if _e_ruido(getattr(segmento, "texto", "")):
|
||||||
|
continue
|
||||||
|
falas.append(self._fala_de_segmento(segmento, segmento_id, video_id, faixa_id))
|
||||||
|
return self._ordenar(falas)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fala_de_segmento(
|
||||||
|
segmento: object,
|
||||||
|
segmento_id: str,
|
||||||
|
video_id: str,
|
||||||
|
faixa_id: str,
|
||||||
|
) -> Fala:
|
||||||
|
texto = str(getattr(segmento, "texto", "")).strip()
|
||||||
|
inicio = float(getattr(segmento, "inicio", 0.0))
|
||||||
|
fim = float(getattr(segmento, "fim", inicio))
|
||||||
|
return Fala(
|
||||||
|
id=f"{segmento_id}:{inicio:.3f}",
|
||||||
|
video_id=video_id,
|
||||||
|
faixa_id=faixa_id,
|
||||||
|
segmento_id=segmento_id,
|
||||||
|
ordem=-1, # preenchida na ordenação
|
||||||
|
inicio=inicio,
|
||||||
|
fim=fim,
|
||||||
|
texto=texto,
|
||||||
|
texto_normalizado=_normalizar(texto),
|
||||||
|
palavras=getattr(segmento, "palavras", ()) or (),
|
||||||
|
falante=getattr(segmento, "falante", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ordenar(falas: list[Fala]) -> list[Fala]:
|
||||||
falas.sort(key=lambda item: (item.inicio, item.fim))
|
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"),
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user