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:
- 48 arquivos alterados
- 26 novos
- 19 modificados
- 3 removidos

 22 files changed, 658 insertions(+), 568 deletions(-)

Arquivos:
  - AGENTS.md
  - admin/DEV-NOTES.md
  - admin/OpenCut.command
  - admin/commit.command
  - admin/deploy.command
  - admin/update.command
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
  - code/engine/ARQUITETURA.md
  - code/engine/arquitetura/README.md
  - code/engine/gerar_relatorio_timeline.py
  - code/engine/integracoes/apple_speech/apple_speech_transcriber.swift
  - code/engine/integracoes/apple_speech/provider_de_transcricao_apple.py
  - code/engine/integracoes/midia/__init__.py
  - code/engine/integracoes/whisper/provider_de_transcricao_local.py
  - code/engine/scanner/__init__.py
  - code/engine/scanner/coordenacao/__init__.py
  - code/engine/scanner/descoberta/__init__.py
  - code/engine/scanner/modelos.py
  - code/engine/scanner/transcricao_da_timeline.py
  - code/src/tools/discovery.ts
  - :memory:.ses
  - admin/Jhonny.command
  - admin/inativos/OpenCut.command
  - admin/inativos/update.command
  - code/docs/glossario-analise-emocional.md
  - code/docs/levantamento-apple-vision-e-apple-intelligence.md
  - code/docs/levantamento-ferramentas-analise-visual-local.md
  - code/engine/arquitetura/biblioteca-inteligente-de-videos.md
  - code/engine/executar_scanner.py
  - code/engine/integracoes/huggingface/
  - code/engine/integracoes/midia/extracao_de_metadados.py
  - code/engine/integracoes/visual/
  - code/engine/requirements-visual.txt
  - code/engine/scanner/configuracao_visual.py
  - code/engine/scanner/descoberta/descoberta_de_arquivos.py
  - code/engine/scanner/metadados.py
  - code/engine/scanner/relatorio_visual.py
  - code/engine/scanner/retakes/
  - code/engine/scanner/visual.py
  - code/engine/testes/test_analise_visual_local.py
  - code/engine/testes/test_arquivos_e_metadados.py
  - code/engine/testes/test_provider_de_transcricao_apple.py
  - code/relatorios/analise-brools/
  - code/relatorios/analise-visual/
  - code/relatorios/audio/arquivos/
  - code/relatorios/transcricao-timeline.md
This commit is contained in:
João Henrique
2026-09-08 16:13:27 -04:00
parent b541f502ba
commit b9bf3b2863
76 changed files with 16147 additions and 322 deletions
+12
View File
@@ -364,6 +364,18 @@ O modelo utilizado deverá ficar no módulo `providers/transcricao/`.
# 6. Submódulo `visual`
## Regra de acesso ao Premiere
Sempre que o Scanner precisar acessar a timeline, a sequência ativa, as faixas
ou os clipes, deve utilizar `AcessoAoEditor` em
`engine.integracoes.premiere.leitura`. Essa classe já encapsula o cliente MCP e
é a única porta de leitura do Premiere para o Engine.
O submódulo `visual` recebe uma `Timeline` de domínio pelo
`ContextoDeAnalise`; seus adapters não devem chamar o MCP, o CEP ou o UXP
diretamente. Em testes, injete uma timeline pronta ou um fake de
`AcessoAoEditor` na etapa de descoberta.
## `ExtracaoDeQuadros`
### Papel
+1
View File
@@ -26,6 +26,7 @@ Cada módulo principal deverá possuir um documento próprio nesta pasta.
arquitetura/
├── README.md
├── scanner.md
├── biblioteca-inteligente-de-videos.md
├── integracao-com-premiere.md
├── leitura-da-timeline-via-mcp.md
├── plano-primeira-etapa-scanner-e-mcp.md
@@ -0,0 +1,466 @@
# Biblioteca Inteligente de Vídeos
## Objetivo
Transformar uma pasta selecionada pelo usuário em uma biblioteca audiovisual documentada e pesquisável. O sistema deve responder não apenas quais arquivos existem, mas quais evidências de conteúdo aparecem em cada arquivo e em que intervalo temporal.
Exemplos de consultas:
- “Encontre vídeos em que uma pessoa caminha na rua.”
- “Mostre cenas em que alguém fala diante de uma câmera.”
- “Encontre momentos em que aparece um carro vermelho.”
O módulo é de descoberta, análise e recuperação. Ele não decide cortes, não altera a timeline e não deve ser confundido com o índice de Media Intelligence do Premiere. Será um índice local próprio, alimentado pelos adapters disponíveis no projeto.
## Decisões arquiteturais
### 1. Nova unidade de domínio: biblioteca, ativo e evidência
O scanner atual é orientado a uma `Timeline`; este requisito é orientado a uma `BibliotecaDeVideos`. Os dois fluxos podem compartilhar adapters de mídia, mas não devem compartilhar o mesmo contexto de execução.
```text
BibliotecaDeVideos
└── AtivoDeVideo
├── MetadadosDoArquivo
├── SegmentoDeConteudo
│ ├── EvidenciaVisual
│ ├── SegmentoDeTranscricao
│ └── EvidenciaDeAudio
└── RepresentacoesDeBusca
```
Um `AtivoDeVideo` é identificado por uma identidade estável do conteúdo, não apenas pelo nome do arquivo. A identidade inicial deve combinar caminho normalizado, tamanho, data de modificação e uma impressão digital do arquivo. Quando houver colisão ou suspeita de alteração, o hash completo deve confirmar a identidade.
Uma `Evidencia` é um fato observado por um provider, com tipo, valor, intervalo no arquivo de origem, confiança, provider e versão do modelo. Descrições geradas por IA são evidências com proveniência; não são fatos absolutos nem instruções executáveis.
### 2. O módulo externo deve ser profundo
O chamador não deve conhecer FFmpeg, OpenCV, Vision, Whisper, filas, SQLite ou o mecanismo vetorial. A seam pública deve oferecer poucas operações orientadas a intenção:
```python
class BibliotecaDeVideos:
def indexar_pasta(self, pasta: str | Path, configuracao: ConfiguracaoDaBiblioteca) -> IdDaExecucao: ...
def obter_status(self, execucao: IdDaExecucao) -> StatusDaIndexacao: ...
def buscar(self, consulta: str, opcoes: OpcoesDeBusca | None = None) -> list[ResultadoDeBusca]: ...
def obter_ativo(self, identificador: str) -> AtivoDeVideo | None: ...
```
`indexar_pasta` inicia ou agenda uma execução idempotente e retorna imediatamente. O progresso é consultado pelo identificador da execução. `buscar` retorna resultados agrupados por ativo, com trechos temporais e evidências que justificam cada resultado.
O módulo esconde a coordenação entre descoberta incremental, análise, persistência e indexação de busca. Adapters internos podem variar sem alterar essa interface.
### 3. Indexação incremental é regra do domínio
Cada ativo deve registrar:
- impressão digital observada;
- versão do contrato de análise;
- versão de cada provider/modelo usado;
- etapas concluídas;
- etapas com erro ou aviso;
- último status e última execução.
Um arquivo inalterado e já concluído não deve ser analisado novamente. Um arquivo novo entra na fila. Um arquivo cuja impressão digital mudou invalida apenas as etapas derivadas daquele conteúdo. Se somente um provider ou sua versão mudou, a política pode invalidar apenas a etapa correspondente.
Arquivos removidos da pasta não devem ser apagados imediatamente do índice: passam a `ausente_na_origem`, preservando resultados históricos e permitindo recuperação caso retornem. A remoção definitiva deve ser uma operação explícita futura.
## Módulos e responsabilidades
### `biblioteca`
Módulo profundo e seam principal. Recebe a intenção do usuário, cria uma execução, delega o trabalho ao coordenador e expõe status, resultados e erros normalizados.
Não conhece detalhes de providers nem executa chamadas de sistema diretamente.
### `descoberta_da_pasta`
Percorre a pasta autorizada, respeitando configuração de recursão, extensões suportadas, exclusões e links simbólicos. Produz candidatos de arquivos de vídeo e reconcilia o snapshot atual com o último snapshot persistido.
Não extrai metadados, não abre frames e não chama IA.
### `identidade_do_ativo`
Calcula e compara a impressão digital do arquivo. Encapsula a política de “novo”, “inalterado”, “alterado” e “ausente”. Deve ser determinística e testável com um filesystem falso.
### `coordenacao_da_indexacao`
Transforma candidatos em trabalhos por ativo e etapa, respeita dependências, atualiza progresso, trata cancelamento, permite retomada e aplica a política de erro por ativo.
Dependências sugeridas:
```text
descoberta
└── metadados
├── amostragem_de_frames ── análise_visual ── segmentos_visuais
└── extração_de_audio ── transcrição ── segmentos_de_fala
└── análise_de_audio
segmentos + evidências ── consolidação_temporal ── indexação_de_busca
```
Metadados devem ser pré-requisito para calcular duração e amostragem. O ramo visual e o ramo de áudio podem executar em paralelo. A consolidação e a indexação só ocorrem depois dos ramos habilitados, sem exigir que todos tenham sucesso.
### `analise_de_conteudo_audiovisual`
Orquestra os adapters já existentes no projeto:
- `ExtracaoDeMetadados` para dados técnicos;
- `ExtratorDeQuadros` e `DetectorDeCenas` para amostragem e intervalos;
- analisadores OpenCV, Apple Vision, ONNX ou MediaPipe para evidências visuais;
- adapters Whisper, Apple Speech ou Groq para transcrição;
- diarização e emoção local quando habilitadas.
O resultado deve usar modelos do domínio, especialmente `EvidenciaVisual`, `CenaVisual` e `SegmentoDeTranscricao`. Um provider indisponível gera aviso de capacidade e não deve apagar evidências produzidas por outros providers.
O módulo não deve pedir que um modelo de linguagem “assista” ao arquivo inteiro. A análise deve trabalhar com amostras temporais, cenas e agregação de evidências. Uma descrição de cena pode ser criada a partir de um conjunto limitado de frames, sempre mantendo os timestamps que a sustentam.
### `consolidacao_temporal`
Converte observações pontuais em intervalos úteis para busca. Observações do mesmo tipo e valor semelhante podem ser agrupadas quando estão próximas, com uma margem configurável. O resultado precisa conservar as observações originais, pois elas são a evidência auditável do intervalo consolidado.
Esse módulo é o lugar correto para responder “em que momento” e para evitar que a busca retorne apenas o arquivo inteiro.
### `persistencia_do_indice`
Adapter responsável por salvar e ler o estado durável. A primeira implementação deve ser local e transacional, preferencialmente SQLite, com arquivos de frames/áudio tratados como cache reconstruível fora do banco.
Interface mínima:
```python
class RepositorioDaBiblioteca(Protocol):
def reconciliar_snapshot(self, snapshot: SnapshotDaPasta) -> ResultadoDaReconciliacao: ...
def salvar_resultado(self, resultado: ResultadoDoAtivo) -> None: ...
def obter_trabalho_pendente(self, limite: int) -> list[TrabalhoDeIndexacao]: ...
def atualizar_status(self, status: StatusDaIndexacao) -> None: ...
def buscar_evidencias(self, consulta: ConsultaNormalizada) -> list[ResultadoDeBusca]: ...
```
O contrato deve permitir um adapter em memória para testes. Nenhum provider deve escrever diretamente no banco.
### `busca_semantica`
Recebe texto livre, normaliza a consulta e combina três fontes:
1. busca lexical em nomes, transcrições, rótulos e descrições;
2. busca vetorial em descrições de cenas, transcrições e evidências;
3. filtros estruturados por ativo, intervalo, tipo de evidência, confiança e status.
O MVP deve priorizar busca híbrida com ranking explicável. Cada resultado deve informar score, trecho, arquivo e evidências correspondentes. Busca vetorial sem evidência temporal não atende ao requisito.
Um adapter vetorial pode ser adicionado depois. A persistência deve permitir começar com SQLite FTS e embeddings opcionais, sem acoplar o domínio a um banco vetorial específico.
### `integracao_com_timeline_e_editor`
Traduz um `ResultadoDeBusca` para ações de revisão: abrir o arquivo, posicionar o playhead, criar marcador ou propor um clipe para o fluxo editorial. A integração deve ser somente leitura/proposição na primeira versão.
Não deve alterar a timeline automaticamente nem tratar um resultado sem revisão como autorização de edição.
## Modelo de dados lógico
```text
bibliotecas
id, raiz, configuracao_json, criada_em, atualizada_em
ativos
id, biblioteca_id, caminho, nome, extensao, status_origem,
tamanho, modificado_em, fingerprint, criado_em, atualizado_em
metadados_dos_ativos
ativo_id, duracao, largura, altura, fps, codecs, formato, json_extra
execucoes_de_indexacao
id, biblioteca_id, status, motivo, iniciada_em, finalizada_em,
total_trabalhos, concluidos, falhos, cancelada_em
trabalhos_de_indexacao
id, execucao_id, ativo_id, etapa, versao, status, tentativas,
erro, iniciada_em, finalizada_em
segmentos_de_conteudo
id, ativo_id, inicio, fim, tipo, resumo, confianca, origem
evidencias
id, segmento_id, tipo, valor_json, inicio, fim, confianca,
provider, modelo, versao
transcricoes
id, ativo_id, inicio, fim, texto, falante, confianca, provider, versao
representacoes_de_busca
id, alvo_tipo, alvo_id, texto, embedding, indice_lexical
```
Os intervalos são sempre relativos ao arquivo de origem. Quando houver uso numa timeline, a tradução para o intervalo da timeline pertence à integração com o editor, usando o `intervalo_na_origem` do domínio existente.
## Fluxo completo
```text
Usuário seleciona pasta
↓
BibliotecaDeVideos.indexar_pasta()
↓
Execução persistida + trabalhos pendentes
↓
Snapshot da pasta e reconciliação por fingerprint
↓
Metadados por ativo novo/alterado
↓
Ramos visual e áudio em segundo plano
↓
Consolidação de cenas, evidências e falas
↓
Atualização transacional do índice lexical/vetorial
↓
Status consultável e busca com timestamps
```
Cada trabalho deve ser retomável. A gravação de uma etapa deve ser atômica: o índice não pode anunciar uma etapa concluída antes de seus dados e sua versão estarem persistidos.
## Contrato de status e falhas
Status da biblioteca: `nao_indexada`, `indexando`, `parcial`, `concluida`, `falhou` ou `ausente_na_origem`.
Status da etapa: `pendente`, `em_execucao`, `concluida`, `concluida_com_avisos`, `falhou`, `cancelada`.
Falha de um arquivo não deve interromper toda a biblioteca. Falha de descoberta ou persistência deve interromper a execução, pois torna o resultado inconsistente. Falha de um provider deve marcar a capacidade correspondente como indisponível e preservar as demais etapas.
O status deve conter progresso por contagem de trabalhos e por ativo; percentual baseado apenas em duração de vídeo pode ficar enganoso quando há arquivos muito diferentes.
## Escopo recomendado do MVP
1. Seleção e persistência de uma biblioteca local.
2. Descoberta recursiva de extensões configuradas.
3. Fingerprint e reconciliação incremental.
4. Metadados via FFprobe.
5. Amostragem de frames e análise visual já disponível localmente.
6. Transcrição por um adapter configurado, com timestamps.
7. Segmentos temporais e evidências persistidos em SQLite.
8. Busca lexical por nome, transcrição, rótulos e descrições normalizadas.
9. Status, retomada e cancelamento do processamento.
10. Resultado com caminho, intervalo, confiança e evidência.
Ficam para uma segunda etapa: embeddings, ranking híbrido, diarização avançada, reconhecimento de identidade, monitoramento contínuo por filesystem watcher, agrupamento de takes semelhantes e criação automática de marcadores no Premiere.
## Integração com o que já existe
O novo módulo deve reutilizar os adapters de `code/engine/integracoes/midia`, `code/engine/integracoes/visual` e `code/engine/integracoes/whisper`. A implementação atual de `scanner` pode continuar atendendo análises de timeline; o novo coordenador transforma `AtivoDeVideo` em uma entrada compatível com os adapters, sem fazer o domínio da biblioteca depender de `Timeline`.
O `DescobertaDeArquivos` existente contém parte da política de validação local e pode fornecer um adapter compartilhado, desde que sua interface não passe a conhecer biblioteca, fila ou persistência. A análise visual local já produz evidências temporais adequadas, mas a etapa de consolidação deve ficar fora do detector de cenas para manter a separação entre observação e indexação.
As limitações documentadas em `advanced-feature-support.ts` permanecem válidas: o sistema não deve alegar acesso ao índice nativo do Premiere nem iniciar/monitorar operações não expostas por API pública. A biblioteca local é uma capacidade independente.
## Testabilidade e seams
O domínio deve ser testável sem FFmpeg, GPU, macOS Vision, rede ou arquivos reais. Adapters necessários para testes:
- filesystem que retorna snapshots controlados;
- calculador de fingerprint determinístico;
- provider de metadados falso;
- extrator de frames falso;
- providers visual e de transcrição falsos;
- relógio injetável;
- repositório em memória;
- fila síncrona ou executor controlado;
- ranking lexical/vetorial falso.
Testes prioritários:
- arquivo inalterado não gera trabalho novamente;
- novo arquivo gera apenas as etapas necessárias;
- arquivo alterado invalida resultados derivados;
- mudança de versão de provider invalida somente sua etapa;
- falha visual não apaga transcrição;
- timestamps nunca ultrapassam a duração conhecida;
- resultados de busca preservam evidências e intervalo;
- retomada continua do último trabalho persistido;
- cancelamento não deixa etapa marcada como concluída;
- arquivo removido vira ausente sem perder histórico.
## Decisões em aberto
Antes da implementação, ainda precisamos escolher:
1. banco local definitivo: SQLite puro, SQLite com FTS5 e embeddings em arquivo, ou outro adapter;
2. executor em segundo plano: processo Python separado, thread controlada ou integração com o host;
3. provider visual padrão do MVP: OpenCV, Apple Vision ou configuração por perfil;
4. provider de transcrição padrão e política de privacidade para enviar áudio a serviços externos;
5. extensões, exclusões e limite de profundidade da pasta;
6. política de retenção do cache de frames e áudio;
7. formato do contrato de descrição semântica produzido pelo modelo de IA.
Essas escolhas não devem alterar a interface de `BibliotecaDeVideos`; devem apenas selecionar adapters e configuração.
## Catálogo SQLite e processamento contínuo
### SQLite como catálogo, não como depósito de mídia
SQLite é adequado para o catálogo local porque oferece transações, consultas relacionais, FTS5 para busca textual e baixo custo operacional. O banco não deve armazenar vídeos, áudio extraído ou imagens em escala. Esses dados ficam na pasta original ou em um cache controlado; no banco ficam referências, metadados, resultados compactos e estado de processamento.
O arquivo do catálogo deve ficar fora da pasta indexada, em um diretório de dados do aplicativo. Assim, uma pasta pode ser removida, movida ou compartilhada sem levar o estado interno do agente junto com ela. A configuração deve permitir uma biblioteca por pasta e várias bibliotecas no mesmo catálogo.
Para lidar com nomes repetidos, a chave do ativo não será `nome`. O cadastro deve usar um identificador interno e único, por exemplo:
```text
identificador_do_ativo = UUID interno
identidade_do_conteudo = hash do conteúdo + tamanho
localizacao = biblioteca + caminho relativo normalizado
```
O caminho relativo distingue duas cópias iguais em locais diferentes; a identidade do conteúdo permite reconhecer um arquivo renomeado ou movido dentro da mesma biblioteca. O cálculo deve ser progressivo: primeiro comparar tamanho e data de modificação, depois calcular uma impressão digital parcial; o hash completo fica reservado para arquivos suspeitos, duplicatas ou confirmação de identidade.
### Estado persistido por etapa
O catálogo deve tratar a análise como um conjunto de trabalhos independentes, não como uma operação monolítica por vídeo. Cada trabalho tem `ativo_id`, `etapa`, `versao_da_etapa`, `status`, `progresso`, `checkpoint`, `tentativas`, `erro` e timestamps.
Checkpoints possíveis:
- último timestamp de frame processado;
- último intervalo de áudio transcrito;
- identificador da janela de cena atual;
- lote de evidências já persistido;
- versão do modelo e parâmetros efetivos.
Um checkpoint só é confirmado junto com os resultados daquele lote. Em caso de interrupção, o executor retoma a partir do último checkpoint confirmado, com uma pequena sobreposição temporal para não perder eventos na transição entre lotes. A escrita deve ser idempotente usando uma chave lógica como `ativo + etapa + versão + intervalo + provider`.
### Worker de baixa prioridade
O processamento contínuo deve ser implementado como um worker controlado pelo sistema, com uma iteração curta e cooperativa. Ele não deve manter vídeos, frames ou áudios inteiros em memória.
Política inicial:
- um ativo por vez por padrão;
- um lote pequeno de frames por vez;
- áudio extraído em arquivo temporário ou stream, nunca inteiro em memória;
- transação SQLite curta por lote;
- pausa entre lotes quando a máquina estiver ocupada;
- suspensão com bateria, modo de economia de energia, temperatura alta ou pressão de memória;
- limite configurável de CPU, memória, espaço de cache e tempo por ciclo;
- cancelamento cooperativo verificado entre frames, lotes e etapas;
- processos de provider isolados quando uma biblioteca nativa puder bloquear ou consumir memória excessiva.
O worker deve consultar uma política de recursos antes de iniciar cada lote:
```python
class PoliticaDeRecursos(Protocol):
def pode_executar(self, trabalho: TrabalhoDeIndexacao) -> bool: ...
def tamanho_do_lote(self, trabalho: TrabalhoDeIndexacao) -> int: ...
def deve_pausar(self) -> bool: ...
```
O objetivo não é manter o agente analisando a qualquer custo, mas aproveitar períodos ociosos. Se a máquina estiver ocupada, o worker deve dormir e deixar o sistema responsivo. A prioridade do processo e a afinidade de CPU são detalhes de um adapter do host, não regras espalhadas pelo domínio.
### Varredura contínua
O modo contínuo deve combinar duas estratégias:
1. uma varredura periódica e lenta da pasta, que é a fonte de verdade;
2. um watcher opcional para antecipar a descoberta de arquivos novos ou alterados.
O watcher não deve iniciar análise diretamente. Ele apenas marca a biblioteca como “precisa reconciliar”; a próxima varredura confirma o estado, evitando arquivos ainda sendo copiados. Um arquivo só entra na fila depois de permanecer estável por um intervalo configurável e passar por uma leitura mínima de metadados.
Ao iniciar, retomar ou acordar após ocioso, o worker deve:
```text
reconciliar pasta → atualizar origem dos ativos → recalcular trabalhos necessários
→ escolher próximo trabalho → processar lote → persistir checkpoint → repetir
```
### Frequência de frames
Não existe uma frequência única ideal. Analisar todos os frames é caro e, para busca semântica, geralmente redundante. A recomendação para o MVP é uma amostragem em camadas:
| Camada | Frequência inicial | Finalidade |
|---|---:|---|
| triagem | 1 frame a cada 2–5 s | descobrir duração, atividade e mudanças grosseiras |
| cena ativa | 1–2 frames/s | descrever objetos, pessoas e composição |
| transição | frequência temporariamente maior | refinar início/fim de uma mudança de cena |
| áudio | janelas contínuas | transcrição e detecção de fala/silêncio |
O valor de `1 frame/s` é um bom ponto de partida para vídeos comuns, mas deve ser uma configuração, não uma regra fixa. Conteúdo com movimento rápido, cortes frequentes, texto na tela ou ações curtas precisa de amostragem adaptativa. Conteúdo estático pode reduzir a frequência quando os frames consecutivos têm baixa diferença visual.
O detector deve aumentar a frequência localmente quando detectar mudança de cena, movimento relevante, fala, texto novo ou baixa confiança. Deve reduzir a frequência quando houver continuidade visual, silêncio prolongado ou repetição de frames. Cada evidência precisa registrar a amostragem usada, para que a ausência de detecção não seja interpretada como prova de ausência.
### Transcrição
O adapter local baseado em `faster-whisper` é a escolha padrão recomendada para o catálogo: mantém o processamento privado, entrega timestamps e já se encaixa nos providers existentes. O tamanho do modelo deve ser configurável por perfil de recurso:
- perfil econômico: modelo menor, CPU e baixa prioridade;
- perfil equilibrado: modelo intermediário, possivelmente aceleração disponível;
- perfil qualidade: modelo maior, somente quando solicitado ou quando houver tempo ocioso.
Apple Speech pode ser um adapter preferencial em macOS quando o requisito for baixo consumo e o idioma suportado for suficiente. Groq ou outro provider remoto deve ser opt-in, com consentimento explícito, indicação de que o áudio sai da máquina e registro do provider usado. A transcrição persistida deve guardar idioma, modelo, provider, confiança e timestamps; trocar o modelo invalida somente a etapa de transcrição, não os metadados nem necessariamente a análise visual.
### Combinação de fontes
O cadastro útil para o agente deve manter as fontes separadas e criar uma representação consolidada:
```text
metadados técnicos
+ transcrição temporal
+ evidências visuais temporais
+ evidências de áudio
+ mudanças de cena
↓
segmento de conteúdo
↓
texto indexável + filtros + evidências
```
Um segmento pode ter o resumo “pessoa entra em uma sala enquanto fala”, mas deve apontar para: o intervalo temporal, os frames que sustentam “pessoa” e “entra”, e o trecho de transcrição que sustenta “fala”. O resumo serve para recuperação; as evidências servem para auditoria, ranking e revisão humana.
### Estratégia de prioridade
A prioridade deve ser calculada no momento de escolher o próximo trabalho, sem alterar a identidade nem o resultado do ativo. Uma pontuação inicial pode considerar:
```text
prioridade = intenção explícita
+ ativo aberto ou usado recentemente no editor
+ arquivo novo ou alterado
+ etapa necessária para uma busca pendente
+ tamanho/tempo estimado que permita concluir um lote
- custo estimado
- idade da última tentativa com falha
```
Categorias práticas:
1. urgente: ativo solicitado numa busca ou aberto para edição;
2. alta: arquivo novo, alterado ou associado ao projeto atual;
3. normal: arquivos ainda não indexados;
4. baixa: reprocessamento por melhoria de modelo ou análise opcional.
Para evitar starvation, trabalhos de baixa prioridade recebem um aumento gradual de prioridade conforme envelhecem. Um arquivo grande não deve bloquear a fila inteira: o scheduler o divide em lotes e alterna com trabalhos menores.
## Configuração operacional proposta
```python
@dataclass(frozen=True)
class ConfiguracaoDaBiblioteca:
extensoes: tuple[str, ...] = (".mp4", ".mov", ".mxf", ".mkv", ".avi")
recursiva: bool = True
intervalo_da_varredura_em_segundos: int = 900
estabilidade_do_arquivo_em_segundos: int = 30
frequencia_de_triagem_em_fps: float = 0.25
frequencia_de_cena_em_fps: float = 1.0
tamanho_do_lote_de_frames: int = 32
concorrencia: int = 1
limite_de_cache_em_gb: float = 10.0
permitir_provider_remoto: bool = False
perfil_de_recursos: str = "economico"
```
Os defaults favorecem responsividade e privacidade. A configuração não deve expor detalhes de cada biblioteca de visão; deve selecionar perfis e permitir ajustes apenas onde houver evidência de necessidade.
## Fases de evolução
### Fase 1 — catálogo confiável
SQLite, descoberta periódica, identidade por fingerprint, metadados, fila persistida, checkpoints, análise visual em baixa frequência, transcrição local e busca textual temporal.
### Fase 2 — recuperação semântica
Embeddings para segmentos e transcrições, busca híbrida, reranking e consultas por conceitos não presentes literalmente no texto. O embedding deve ser versionado e reconstruível; não deve ser a única representação do conhecimento.
### Fase 3 — integração editorial
Busca a partir do contexto da timeline, abertura no trecho encontrado, marcadores de revisão e propostas de stringout. Qualquer mutação na timeline continua exigindo uma etapa explícita de revisão e autorização.
+136
View File
@@ -0,0 +1,136 @@
"""Entrada local do painel CEP para uma execução configurada do Scanner."""
import argparse
import json
import math
import os
from pathlib import Path
import tempfile
import re
from engine.integracoes.premiere.conversores import ConversorDeTimeline
from engine.scanner import ConfiguracaoVisualDoScanner, criar_detector_apple_vision, gerar_relatorio_visual
from engine.scanner.transcricao_da_timeline import TranscricaoDaTimeline
MODELO_DIARIZACAO_PADRAO = "pyannote/speaker-diarization-community-1"
def nome_seguro(nome: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]+", "-", nome.strip()).strip(".-") or "timeline"
def executar(entrada: Path, saida: Path) -> Path:
pedido = json.loads(entrada.read_text(encoding="utf-8"))
configuracao = ConfiguracaoVisualDoScanner.de_dict(pedido["perfil"])
timeline = ConversorDeTimeline().converter(pedido["timeline"])
clipes = [clipe for faixa in timeline.faixas if faixa.tipo == "video"
and faixa.indice in configuracao.faixas_de_video for clipe in faixa.clipes]
total_estimado = sum(max(1, math.ceil((clipe.intervalo_na_origem or clipe.intervalo_na_timeline).duracao /
configuracao.intervalo_em_segundos)) + 1 for clipe in clipes) or 1
concluidos = 0
def emitir(etapa: str, percentual: float, clipe: str = "") -> None:
print(json.dumps({"evento": "progresso", "etapa": etapa, "percentual": round(percentual, 1),
"clipe": clipe}, ensure_ascii=False), flush=True)
resultados = []
for clipe in clipes:
peso = max(1, math.ceil((clipe.intervalo_na_origem or clipe.intervalo_na_timeline).duracao /
configuracao.intervalo_em_segundos)) + 1
inicio_do_clipe = concluidos
def progresso_atual(atual: int, total: int, nome: str = clipe.nome,
base: int = inicio_do_clipe, peso_do_clipe: int = peso) -> None:
emitir("Analisando frames", 100 * (base + peso_do_clipe * atual / max(total, 1)) / total_estimado, nome)
detector = criar_detector_apple_vision(Path(tempfile.gettempdir()) / "premiere-mcp-scanner",
configuracao=configuracao)
detector.ao_progresso = progresso_atual
emitir("Extraindo frames", 100 * inicio_do_clipe / total_estimado, clipe.nome)
try:
resultado = detector.detectar(clipe)
arquivo = Path(tempfile.gettempdir()) / f"scanner-{clipe.identificador}.json"
gerar_relatorio_visual(clipe, resultado, arquivo, configuracao.intervalo_em_segundos)
resultados.append(json.loads(arquivo.read_text(encoding="utf-8")))
except Exception as erro:
resultados.append({"clipe": {"identificador": clipe.identificador, "nome": clipe.nome,
"arquivo_original": clipe.arquivo}, "erro": str(erro)})
concluidos += peso
faixas_de_audio = [faixa for faixa in timeline.faixas
if faixa.tipo == "audio" and faixa.indice in configuracao.faixas_de_audio]
audio = [{"indice": faixa.indice, "nome": faixa.nome,
"clipes": [{"identificador": clipe.identificador, "nome": clipe.nome,
"inicio": clipe.intervalo_na_timeline.inicio, "fim": clipe.intervalo_na_timeline.fim}
for clipe in faixa.clipes]}
for faixa in faixas_de_audio]
caso = nome_seguro(timeline.nome)
pasta = saida.parent / caso
pasta.mkdir(parents=True, exist_ok=True)
saida = pasta / f"{caso}-resultado.json"
detalhado = pasta / "detalhado"
detalhado.mkdir(parents=True, exist_ok=True)
geometria = []
for resultado in resultados:
for frame in resultado.get("observacoes_por_frame", []):
fatos = [item for item in frame["evidencias"] if item["tipo"] in {"rosto", "pessoa", "pose", "mao"}]
if fatos:
geometria.append({"clipe": resultado["clipe"]["identificador"], "timestamp_na_origem": frame["timestamp_na_origem"], "evidencias": fatos})
frame["evidencias"] = [item for item in frame["evidencias"] if item not in fatos]
(detalhado / "geometria.jsonl").write_text("".join(json.dumps(item, ensure_ascii=False) + "\n" for item in geometria), encoding="utf-8")
video_arquivo = f"{caso}-video.json"
(pasta / video_arquivo).write_text(json.dumps({"clipes": resultados}, ensure_ascii=False, indent=2), encoding="utf-8")
audio_arquivos = []
transcricoes_por_clipe: dict[str, list[dict]] = {}
transcricao_configurada = pedido["perfil"].get("transcricao", {})
if faixas_de_audio and transcricao_configurada.get("caminho_modelo"):
try:
from engine.integracoes.whisper import ProviderDeTranscricaoLocal
from engine.dominio import Timeline
emitir("Transcrevendo áudio", 5)
provider = ProviderDeTranscricaoLocal(transcricao_configurada["caminho_modelo"],
idioma=transcricao_configurada.get("idioma", "pt"))
diarizador = None
if transcricao_configurada.get("diarizacao"):
if not os.environ.get("HF_TOKEN"):
transcricao_configurada = {**transcricao_configurada,
"aviso_diarizacao": "Token do Hugging Face não configurado."}
else:
from engine.integracoes.huggingface import ProviderDeDiarizacaoHuggingFace
modelo_diarizacao = os.environ.get("HF_DIARIZATION_MODEL", MODELO_DIARIZACAO_PADRAO)
diarizador = ProviderDeDiarizacaoHuggingFace(modelo_diarizacao)
transcricao_configurada = {**transcricao_configurada,
"modelo_diarizacao": modelo_diarizacao}
transcricoes = TranscricaoDaTimeline(provider, diretoria_de_trabalho=pasta / ".cache-audio",
diarizador=diarizador).executar(
Timeline(timeline.identificador, timeline.nome, faixas=faixas_de_audio))
for transcricao in transcricoes:
transcricoes_por_clipe[transcricao.identificador_do_clipe] = [
{"inicio": item.inicio, "fim": item.fim, "texto": item.texto,
"confianca": item.confianca, "falante": item.falante}
for item in transcricao.segmentos]
except Exception as erro:
transcricao_configurada = {**transcricao_configurada, "erro": str(erro)}
for faixa in audio:
arquivo = f"{caso}-audio-faixa-{faixa['indice'] + 1}.json"
segmentos = [{"clipe": clipe["identificador"], "segmentos": transcricoes_por_clipe.get(clipe["identificador"], [])}
for clipe in faixa["clipes"]]
(pasta / arquivo).write_text(json.dumps({"faixa": faixa, "transcricao": transcricao_configurada,
"segmentos_por_clipe": segmentos,
"status": "concluida" if transcricoes_por_clipe else "indisponivel"},
ensure_ascii=False, indent=2), encoding="utf-8")
audio_arquivos.append(arquivo)
saida.write_text(json.dumps({"versao": 1, "perfil": pedido["perfil"],
"sequencia": {"id": timeline.identificador, "nome": timeline.nome},
"artefatos": {"audio": audio_arquivos, "video": video_arquivo,
"geometria": "detalhado/geometria.jsonl"}}, ensure_ascii=False, indent=2), encoding="utf-8")
emitir("Relatório JSON gerado", 100)
return saida
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("entrada", type=Path)
parser.add_argument("saida", type=Path)
args = parser.parse_args()
print(json.dumps({"evento": "concluido", "arquivo": str(executar(args.entrada, args.saida))}, ensure_ascii=False))
+23 -4
View File
@@ -1,7 +1,9 @@
import json
from pathlib import Path
from engine.integracoes.midia import ExtracaoDeAudio
from engine.integracoes.groq import ProviderDeTranscricaoGroq
from engine.integracoes.whisper import ProviderDeTranscricaoLocal
from engine.integracoes.huggingface import (ProviderDeDiarizacaoHuggingFace,
ProviderDeEmocaoHuggingFace)
from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio
from engine.integracoes.premiere.conversores import ConversorDeTimeline
from engine.scanner.transcricao_da_timeline import TranscricaoDaTimeline
@@ -10,7 +12,7 @@ ARQUIVO = Path('/Volumes/Merongo/PROJETOS/03 - Mastopexia/0E6A8290.MP4')
CHAVE = json.loads((Path.home()/'.premiere-mcp/config.json').read_text()).get('groqApiKey','')
SAIDA = Path('/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/relatorios')
cliente = ClienteMCPPorStdio(['node', 'dist/index.js'])
cliente = ClienteMCPPorStdio(['node', str(Path(__file__).resolve().parents[1] / 'dist/index.js')])
cliente.conectar()
bruta = cliente.chamar('get_active_sequence', {})['structuredContent']['data']
timeline = ConversorDeTimeline().converter(bruta)
@@ -18,8 +20,13 @@ for faixa in timeline.faixas:
for clipe in faixa.clipes:
if clipe.nome == '0E6A8290.MP4': clipe.arquivo = str(ARQUIVO)
provider = ProviderDeTranscricaoGroq(CHAVE)
transcricoes = TranscricaoDaTimeline(provider, ExtracaoDeAudio(duracao_da_parte=600), SAIDA/'audio').executar(timeline)
MODELO = Path('/Volumes/Merongo/SISTEMAS/MODELOSIA/hf-cache/hub/models--mobiuslabsgmbh--faster-whisper-large-v3-turbo/snapshots/0a363e9161cbc7ed1431c9597a8ceaf0c4f78fcf')
provider = ProviderDeTranscricaoLocal(str(MODELO), idioma='pt')
MODELO_EMOCAO = Path('/Volumes/Merongo/SISTEMAS/MODELOSIA/models/wav2vec2-xls-r-300m-pt-br-spontaneous-speech-emotion-recognition')
analisador_de_emocao = ProviderDeEmocaoHuggingFace(str(MODELO_EMOCAO))
MODELO_DIARIZACAO = Path('/Volumes/Merongo/SISTEMAS/MODELOSIA/models/pyannote-speaker-diarization-community-1')
diarizador = ProviderDeDiarizacaoHuggingFace(str(MODELO_DIARIZACAO))
transcricoes = TranscricaoDaTimeline(provider, ExtracaoDeAudio(duracao_da_parte=600), SAIDA/'audio', analisador_de_emocao, diarizador).executar(timeline)
linhas = [f'# Relatório de transcrição — {timeline.nome}', '', f'Duração: {bruta["end"]:.3f} s',
f'Clipes processados: {sum(len(f.clipes) for f in timeline.faixas if f.tipo == "video")}', '']
@@ -32,5 +39,17 @@ for faixa in timeline.faixas:
texto = resultado.texto if resultado else ''
linhas += [f'## {clipe.identificador} — {clipe.intervalo_na_timeline.inicio:.3f}s–{clipe.intervalo_na_timeline.fim:.3f}s',
f'Origem: {intervalo.inicio:.3f}s–{intervalo.fim:.3f}s', '', texto or '_Sem fala detectada._', '']
if resultado and resultado.segmentos:
linhas.append('Emoções detectadas por segmento:')
for segmento in resultado.segmentos:
if segmento.emocao:
voz = f', voz {segmento.voz_aparente}' if segmento.voz_aparente else ''
linhas.append(f'- [{segmento.inicio:.3f}s–{segmento.fim:.3f}s] {segmento.falante or "falante_indefinido"}: {segmento.emocao} ({segmento.confianca_emocao:.2f}{voz})')
linhas.append('')
for segmento in resultado.segmentos if resultado else []:
for palavra in segmento.palavras:
linhas.append(f'- [{palavra.inicio:.3f}s–{palavra.fim:.3f}s] {palavra.texto}')
if resultado and resultado.segmentos:
linhas.append('')
(SAIDA/'transcricao-timeline.md').write_text('\n'.join(linhas), encoding='utf-8')
print(SAIDA/'transcricao-timeline.md')
@@ -1,44 +1,82 @@
import AppKit
import Foundation
import Speech
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
guard CommandLine.arguments.count >= 3 else { finalizar("uso: arquivo locale [somente_no_dispositivo]", 2); return }
let url = URL(fileURLWithPath: CommandLine.arguments[1])
let locale = Locale(identifier: CommandLine.arguments[2])
let somenteNoDispositivo = CommandLine.arguments.count > 3 && CommandLine.arguments[3] == "true"
guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else {
finalizar("Apple Speech indisponível para o locale solicitado", 3); return
/// Encapsula o ciclo de vida do Apple Speech para a Engine.
///
/// A classe não conhece o scanner, o catálogo ou a persistência. Sua única
/// responsabilidade é converter um arquivo de mídia em JSON temporal. O
/// processo é Foundation-only para poder ser executado como CLI sem iniciar
/// AppKit ou NSApplication.
final class TranscritorDeFalaApple {
private let semaforo = DispatchSemaphore(value: 0)
private var payload: [String: Any] = ["segmentos": []]
private var codigoDeSaida: Int32 = 0
private var finalizado = false
func executar(argumentos: [String]) -> Int32 {
guard argumentos.count >= 3 else {
return finalizar("uso: apple-speech-transcriber arquivo locale [somente_no_dispositivo]", 2)
}
SFSpeechRecognizer.requestAuthorization { status in
guard status == .authorized else { self.finalizar("Permissão do Apple Speech não concedida", 4); return }
let request = SFSpeechURLRecognitionRequest(url: url)
let arquivo = URL(fileURLWithPath: argumentos[1])
let locale = Locale(identifier: argumentos[2])
let somenteNoDispositivo = argumentos.count > 3 && argumentos[3] == "true"
guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else {
return finalizar("Apple Speech indisponível para o locale solicitado", 3)
}
SFSpeechRecognizer.requestAuthorization { [weak self] status in
guard let self else { return }
guard status == .authorized else {
self.finalizar("Permissão do Apple Speech não concedida", 4)
return
}
let request = SFSpeechURLRecognitionRequest(url: arquivo)
request.shouldReportPartialResults = false
if somenteNoDispositivo && recognizer.supportsOnDeviceRecognition { request.requiresOnDeviceRecognition = true }
recognizer.recognitionTask(with: request) { result, error in
if let result = result, result.isFinal {
let segmentos = result.bestTranscription.segments.map {
if somenteNoDispositivo && recognizer.supportsOnDeviceRecognition {
request.requiresOnDeviceRecognition = true
}
recognizer.recognitionTask(with: request) { [weak self] resultado, erro in
guard let self else { return }
if let resultado, resultado.isFinal {
self.payload["segmentos"] = resultado.bestTranscription.segments.map {
["inicio": $0.timestamp, "fim": $0.timestamp + $0.duration,
"texto": $0.substring, "confianca": $0.confidence] as [String: Any]
}
let data = try! JSONSerialization.data(withJSONObject: ["segmentos": segmentos])
print(String(data: data, encoding: .utf8)!)
self.finalizar(nil, 0)
} else if let erro {
self.finalizar("Falha no Apple Speech: \(erro.localizedDescription)", 5)
}
if error != nil || result?.isFinal == true { self.finalizar(nil, 0) }
}
}
semaforo.wait()
imprimirResultado()
return codigoDeSaida
}
private func finalizar(_ mensagem: String?, _ codigo: Int32) {
@discardableResult
private func finalizar(_ mensagem: String?, _ codigo: Int32) -> Int32 {
guard !finalizado else { return codigoDeSaida }
finalizado = true
if let mensagem { fputs(mensagem + "\n", stderr) }
NSApplication.shared.terminate(nil)
exit(codigo)
codigoDeSaida = codigo
semaforo.signal()
return codigo
}
private func imprimirResultado() {
guard let dados = try? JSONSerialization.data(withJSONObject: payload),
let texto = String(data: dados, encoding: .utf8) else {
fputs("Não foi possível serializar o resultado do Apple Speech\n", stderr)
codigoDeSaida = 6
return
}
print(texto)
}
}
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()
let transcritor = TranscritorDeFalaApple()
exit(transcritor.executar(argumentos: CommandLine.arguments))
@@ -7,10 +7,10 @@ from ...scanner.modelos import SegmentoDeTranscricao
class ProviderDeTranscricaoApple:
"""Provider nativo macOS Speech; o áudio não passa por API Groq."""
"""Provider nativo macOS Speech, local por padrão e sem AppKit."""
def __init__(self, executavel: str = "/private/tmp/AppleSpeechTranscriber.app/Contents/MacOS/apple-speech-transcriber", locale: str = "pt-BR",
somente_no_dispositivo: bool = False) -> None:
somente_no_dispositivo: bool = True) -> None:
self.executavel = executavel
self.locale = locale
self.somente_no_dispositivo = somente_no_dispositivo
@@ -24,5 +24,5 @@ class ProviderDeTranscricaoApple:
check=True, capture_output=True, text=True,
)
dados = json.loads(resultado.stdout)
return [SegmentoDeTranscricao(float(s["inicio"]), float(s["fim"]), str(s["texto"]), s.get("confianca"))
return [SegmentoDeTranscricao(float(s["inicio"]), float(s["fim"]), str(s["texto"]).strip(), s.get("confianca"))
for s in dados.get("segmentos", [])]
@@ -0,0 +1,4 @@
from .provider_de_emocao_local import ProviderDeEmocaoHuggingFace
from .provider_de_diarizacao_local import ProviderDeDiarizacaoHuggingFace
__all__ = ["ProviderDeDiarizacaoHuggingFace", "ProviderDeEmocaoHuggingFace"]
@@ -0,0 +1,21 @@
from pathlib import Path
class ProviderDeDiarizacaoHuggingFace:
"""Identifica intervalos de falas por participante em áudio local."""
def __init__(self, modelo: str) -> None:
from pyannote.audio import Pipeline
self.pipeline = Pipeline.from_pretrained(modelo)
def analisar(self, arquivo: str | Path) -> list[tuple[float, float, str]]:
import soundfile as sf
import torch
audio, taxa = sf.read(str(arquivo), dtype="float32")
if getattr(audio, "ndim", 1) > 1:
audio = audio.mean(axis=1)
saida = self.pipeline({"waveform": torch.from_numpy(audio).unsqueeze(0),
"sample_rate": taxa})
diarizacao = getattr(saida, "exclusive_speaker_diarization", saida)
return [(float(turno.start), float(turno.end), str(falante))
for turno, _, falante in diarizacao.itertracks(yield_label=True)]
@@ -0,0 +1,35 @@
from pathlib import Path
from typing import Any
class ProviderDeEmocaoHuggingFace:
"""Classifica a emoção de uma fala localmente com Transformers."""
def __init__(self, modelo: str = "superb/wav2vec2-base-superb-er") -> None:
from transformers import AutoFeatureExtractor, AutoModelForAudioClassification
self.modelo = modelo
self.processador = AutoFeatureExtractor.from_pretrained(modelo, local_files_only=True)
self.classificador = AutoModelForAudioClassification.from_pretrained(
modelo, local_files_only=True
)
self.classificador.eval()
def analisar(self, arquivo: str | Path, inicio: float, fim: float) -> dict[str, Any]:
import soundfile as sf
import torch
audio, taxa = sf.read(str(arquivo), start=max(0, int(inicio * 16000)),
stop=max(0, int(fim * 16000)), dtype="float32")
if getattr(audio, "ndim", 1) > 1:
audio = audio.mean(axis=1)
entradas = self.processador(audio, sampling_rate=taxa, return_tensors="pt")
with torch.no_grad():
logits = self.classificador(**entradas).logits
probabilidades = torch.softmax(logits[0], dim=-1)
indice = int(torch.argmax(probabilidades))
rotulo = self.classificador.config.id2label[indice]
voz_aparente = {"non-neutral-female": "feminina",
"non-neutral-male": "masculina"}.get(rotulo)
return {"emocao": self.classificador.config.id2label[indice],
"confianca": float(probabilidades[indice]),
"voz_aparente": voz_aparente,
"confianca_voz": float(probabilidades[indice]) if voz_aparente else None}
+2 -1
View File
@@ -1,3 +1,4 @@
from .extracao_de_audio import ExtracaoDeAudio, ParteDeAudio
from .extracao_de_metadados import ExtracaoDeMetadados, MetadadosDoArquivo
__all__ = ["ExtracaoDeAudio", "ParteDeAudio"]
__all__ = ["ExtracaoDeAudio", "ExtracaoDeMetadados", "MetadadosDoArquivo", "ParteDeAudio"]
@@ -0,0 +1,106 @@
import json
import subprocess
from dataclasses import dataclass
from fractions import Fraction
from pathlib import Path
from typing import Any
from ...scanner.modelos import ErroDeAnalise
@dataclass(frozen=True)
class MetadadosDoArquivo:
caminho: Path
formato: str | None = None
duracao: float | None = None
codec_de_video: str | None = None
codec_de_audio: str | None = None
largura: int | None = None
altura: int | None = None
taxa_de_quadros: float | None = None
canais_de_audio: int | None = None
taxa_de_amostragem: int | None = None
tamanho_em_bytes: int | None = None
orientacao: str | None = None
timecode: str | None = None
class ExtracaoDeMetadados:
"""Extrai metadados técnicos via ffprobe, com cache por caminho normalizado."""
def __init__(self, ffprobe: str = "ffprobe") -> None:
self.ffprobe = ffprobe
self._cache: dict[Path, MetadadosDoArquivo] = {}
def extrair(self, arquivo: str | Path) -> MetadadosDoArquivo:
caminho = Path(arquivo).expanduser().resolve(strict=False)
if not caminho.is_file():
raise ErroDeAnalise("arquivo_inacessivel", "Arquivo de mídia não encontrado.", str(caminho))
if caminho in self._cache:
return self._cache[caminho]
try:
processo = subprocess.run(
[self.ffprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(caminho)],
check=True, capture_output=True, text=True,
)
dados = json.loads(processo.stdout)
except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as exc:
raise ErroDeAnalise("metadados_indisponiveis", f"Não foi possível ler os metadados: {exc}", str(caminho)) from exc
metadados = self._converter(caminho, dados)
self._cache[caminho] = metadados
return metadados
def limpar_cache(self) -> None:
self._cache.clear()
@classmethod
def _converter(cls, caminho: Path, dados: dict[str, Any]) -> MetadadosDoArquivo:
formato = dados.get("format") or {}
streams = dados.get("streams") or []
video = next((item for item in streams if item.get("codec_type") == "video"), {})
audio = next((item for item in streams if item.get("codec_type") == "audio"), {})
duracao = cls._float(formato.get("duration"))
if duracao is None:
duracao = cls._float(video.get("duration") or audio.get("duration"))
return MetadadosDoArquivo(
caminho=caminho,
formato=cls._format_name(formato.get("format_name")),
duracao=duracao,
codec_de_video=video.get("codec_name") or None,
codec_de_audio=audio.get("codec_name") or None,
largura=cls._int(video.get("width")), altura=cls._int(video.get("height")),
taxa_de_quadros=cls._fps(video.get("avg_frame_rate") or video.get("r_frame_rate")),
canais_de_audio=cls._int(audio.get("channels")),
taxa_de_amostragem=cls._int(audio.get("sample_rate")),
tamanho_em_bytes=cls._int(formato.get("size")) or caminho.stat().st_size,
orientacao=video.get("side_data_list", [{}])[0].get("rotation") if video.get("side_data_list") else None,
timecode=(formato.get("tags") or {}).get("timecode") or (video.get("tags") or {}).get("timecode"),
)
@staticmethod
def _float(valor: Any) -> float | None:
try:
return None if valor in (None, "", "N/A") else float(valor)
except (TypeError, ValueError):
return None
@staticmethod
def _int(valor: Any) -> int | None:
try:
return None if valor in (None, "", "N/A") else int(valor)
except (TypeError, ValueError):
return None
@staticmethod
def _fps(valor: Any) -> float | None:
if valor in (None, "", "0/0", "N/A"):
return None
try:
return float(Fraction(str(valor)))
except (ValueError, ZeroDivisionError):
return None
@staticmethod
def _format_name(valor: Any) -> str | None:
return str(valor).split(",", 1)[0] if valor else None
+161
View File
@@ -0,0 +1,161 @@
# Análise visual local
## Configuração no painel
A configuração do Scanner é apresentada na aba **Scanner** do painel CEP em
`code/cep-plugin/`. O perfil salvo segue o contrato versionado de
`ConfiguracaoVisualDoScanner`; ele define amostragem, faixas, recursos Vision,
cenas, continuidade, reenquadramento e interpretação antes de montar os
adapters.
## Acesso à timeline do Premiere
Quando a análise precisar ler a timeline, a sequência ativa, as faixas ou os
clipes do Premiere, use sempre a classe existente
`engine.integracoes.premiere.leitura.AcessoAoEditor`. O módulo visual não deve
criar chamadas MCP, abrir processos do Premiere ou acessar o bridge diretamente.
O acesso deve seguir esta composição:
```python
from engine.integracoes.premiere.leitura import AcessoAoEditor, AcessoATimeline
from engine.scanner import DescobertaDaTimeline
from engine.integracoes.premiere.conversores import ConversorDeTimeline
acesso_ao_editor = AcessoAoEditor(AcessoATimeline(cliente_mcp))
descoberta = DescobertaDaTimeline(acesso_ao_editor, ConversorDeTimeline())
```
Depois, a timeline convertida entra no `ContextoDeAnalise` e o Scanner executa
`AnaliseVisualDaTimeline`. A classe de acesso isola o MCP e preserva a separação
entre a integração com o Premiere e os analyzers locais.
Não duplicar esse acesso em novos adapters ou analyzers. Para testes, injetar um
fake de `AcessoAoEditor`/`AcessoATimeline` ou usar uma timeline de domínio pronta.
## Captura para análise visual
A análise visual não exporta um frame renderizado pelo Premiere. Para cada clipe
de vídeo, o fluxo usa os campos retornados pelo MCP em `get_active_sequence`:
1. `sourceFile` identifica o arquivo original do `projectItem`.
2. `inPoint` e `outPoint` delimitam o trecho usado pelo clipe.
3. O extrator abre `sourceFile` localmente e amostra o primeiro frame do trecho,
usando `inPoint` como tempo inicial na origem.
4. Vision/OpenCV/ONNX recebem esse frame persistido, mantendo o timestamp de
origem; nenhum frame é exportado da timeline do Premiere.
Portanto, `start`/`end` são tempos da timeline e `inPoint`/`outPoint` são tempos
do arquivo original. Não misturar esses relógios ao analisar um clipe.
O Scanner usa apenas as interfaces em `contratos.py`. As bibliotecas externas
ficam em adapters concretos, para que possam ser habilitadas, substituídas ou
testadas isoladamente.
| Papel | Adapter |
| --- | --- |
| Extrair frames | `ExtratorDeQuadrosOpenCV` |
| Medir qualidade | `AnalisadorDeQualidadeOpenCV` |
| Rosto e olhos visíveis | `AnalisadorDeRostosOpenCV` |
| Composição e área para legendas | `AnalisadorDeComposicaoOpenCV` |
| Tremor/movimento de câmera | `AnalisadorDeTremorOpenCV` |
| Continuidade e frame congelado | `AnalisadorDeContinuidadeOpenCV` |
| Detectar objetos | `AnalisadorDeObjetosONNX` |
| Detectar pose e mãos | `AnalisadorDePoseMediaPipe` |
| OCR e faces macOS | `AnalisadorAppleVision` |
| Vision + Apple Intelligence (Swift isolado) | `AnalisadorAppleVisionNativo` |
| Similaridade temporal por feature print | `AnalisadorSequenciaAppleVisionNativo` |
| Extrair frames para Vision sem OpenCV | `ExtratorDeQuadrosFFmpeg` |
| Detectar cenas | `DetectorDeCenasPySceneDetect` |
`AnalisadorDeObjetosONNX` exige dois adapters específicos do modelo:
`preparar(imagem, entradas)` e `decodificar(saidas, quadro)`. Isso mantém os
detalhes de cada arquivo ONNX fora do Scanner e permite trocar de modelo sem
alterar a interface do domínio.
`AnalisadorDePoseMediaPipe` recebe caminhos explícitos para os arquivos `.task`
de pose e mãos. Os modelos ficam em `/Volumes/Merongo/SISTEMAS/MODELOSIA/mediapipe/`
e não são acoplados ao pacote Python. Ele executa em subprocesso: falhas nativas
do MediaPipe são contidas e retornam como indisponibilidade do adapter, sem
encerrar o processo do Scanner.
`AnalisadorDeRostosOpenCV` usa os arquivos locais
`haarcascade_frontalface_default.xml` e `haarcascade_eye.xml`. Quando a
distribuição do OpenCV não os incluir, forneça `diretorio_de_modelos` apontando
para a pasta que os contém.
`AnalisadorAppleVisionNativo` é a opção Apple recomendada. Seu runner Swift
executa faces/landmarks, qualidade facial, pessoas, pose, mãos, OCR,
classificação e estética de forma independente. A interpretação editorial pelo
`FoundationModels` só é criada a partir dessas evidências e é salva em separado.
Falhas nativas por recurso viram `diagnostico_apple_vision`, sem derrubar o
Scanner nem descartar os fatos que funcionaram.
Além de rostos, pessoas, pose, mãos, OCR, categorias e estética, o runner
Apple retorna códigos/QR, horizonte, retângulos, densidade de contornos,
ocupação da máscara de pessoas e similaridade visual entre frames. Os valores
são fatos normalizados; descrição, contexto e ação devem ser derivados depois,
a partir dessas evidências temporizadas.
## Montagem
```python
from engine.integracoes.visual import (
AnalisadorDeQualidadeOpenCV,
AnalisadorDeRostosOpenCV,
AnalisadorDeComposicaoOpenCV,
AnalisadorDeTremorOpenCV,
AnalisadorDeContinuidadeOpenCV,
DetectorDeCenasPySceneDetect,
ExtratorDeQuadrosOpenCV,
)
from engine.scanner.visual import DetectorDeCenas
detector = DetectorDeCenas(
ExtratorDeQuadrosOpenCV(intervalo_em_segundos=1.0, diretorio_de_cache=".frames"),
analisadores_de_frame=[
AnalisadorDeQualidadeOpenCV(), AnalisadorDeRostosOpenCV(),
AnalisadorDeComposicaoOpenCV(),
],
analisadores_de_sequencia=[
AnalisadorDeTremorOpenCV(), AnalisadorDeContinuidadeOpenCV(),
],
detectores_de_intervalo=[DetectorDeCenasPySceneDetect()],
)
```
Depois, passe esse `DetectorDeCenas` para `AnaliseVisualDaTimeline` ao montar o
`PipelineDoScanner`.
Para o caminho local padrão, a montagem pode ser reduzida a:
```python
from engine.scanner import AnaliseVisualDaTimeline, PipelineDoScanner, criar_detector_visual_local
pipeline = PipelineDoScanner([AnaliseVisualDaTimeline(criar_detector_visual_local())])
contexto = pipeline.executar(contexto)
```
O resultado fica em `contexto.caracteristicas_visuais` (por clipe),
`contexto.cenas_visuais` (cenas com observações) e `contexto.avisos`.
Quando OpenCV/FFmpeg ou outro adapter opcional não estiver disponível, o clipe
é marcado com `disponivel=False` e os demais continuam sendo processados.
## Montagem recomendada para Apple Vision
```python
from engine.scanner import AnaliseVisualDaTimeline, PipelineDoScanner, criar_detector_apple_vision
pipeline = PipelineDoScanner([
AnaliseVisualDaTimeline(criar_detector_apple_vision(intervalo_em_segundos=1.0)),
])
```
Esse detector extrai PNGs do arquivo original com FFmpeg, usando o intervalo
`inPoint`/`outPoint` do clipe, e não exporta imagens renderizadas pelo Premiere.
Ele também recua a amostra no fim de vídeos muito curtos quando não há um frame
decodificável exatamente no timestamp solicitado.
Os analyzers de sequência não tentam concluir a intenção de uma pessoa. Eles
retornam indícios temporais (`possivel_tremor`, `possivel_descontinuidade` e
`possivel_frame_congelado`) para que a camada editorial decida como tratá-los.
@@ -0,0 +1,20 @@
from .analisadores import (AnalisadorAppleVision, AnalisadorDeComposicaoOpenCV,
AnalisadorDeContinuidadeOpenCV, AnalisadorDeObjetosONNX,
AnalisadorDePoseMediaPipe, AnalisadorDeQualidadeOpenCV,
AnalisadorDeRostosOpenCV, AnalisadorDeTremorOpenCV)
from .apple_vision import (AnalisadorAppleVisionNativo, AnalisadorSequenciaAppleVisionNativo,
ExecutorDoRunnerApple)
from .contratos import (AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros,
DetectorDeIntervalosDeCena, ExtratorDeQuadros)
from .detectores_de_cena import DetectorDeCenasPySceneDetect
from .extrator_open_cv import ExtratorDeQuadrosOpenCV
from .extrator_ffmpeg import ExtratorDeQuadrosFFmpeg
from .modelos import QuadroDeVideo
__all__ = ["AnalisadorAppleVision", "AnalisadorAppleVisionNativo", "AnalisadorSequenciaAppleVisionNativo", "AnalisadorDeComposicaoOpenCV",
"AnalisadorDeContinuidadeOpenCV", "AnalisadorDeFrame",
"AnalisadorDeObjetosONNX", "AnalisadorDePoseMediaPipe",
"AnalisadorDeQualidadeOpenCV", "AnalisadorDeRostosOpenCV",
"AnalisadorDeSequenciaDeQuadros", "AnalisadorDeTremorOpenCV",
"DetectorDeCenasPySceneDetect", "DetectorDeIntervalosDeCena",
"ExecutorDoRunnerApple", "ExtratorDeQuadros", "ExtratorDeQuadrosFFmpeg", "ExtratorDeQuadrosOpenCV", "QuadroDeVideo"]
@@ -0,0 +1,331 @@
import json
from pathlib import Path
import subprocess
import sys
from typing import Any, Callable
from ...scanner.modelos import EvidenciaVisual
from .contratos import AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros
from .modelos import QuadroDeVideo
class AnalisadorDeQualidadeOpenCV(AnalisadorDeFrame):
"""Mede nitidez, brilho e contraste de cada frame usando OpenCV."""
nome = "opencv"
def __init__(self, cv2_module: Any | None = None) -> None:
self._cv2 = cv2_module
@property
def cv2(self) -> Any:
if self._cv2 is None:
try:
import cv2
except ImportError as exc:
raise RuntimeError("OpenCV não está instalado. Instale opencv-python.") from exc
self._cv2 = cv2
return self._cv2
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
imagem = quadro.imagem
cinza = self.cv2.cvtColor(imagem, self.cv2.COLOR_BGR2GRAY) if len(imagem.shape) == 3 else imagem
media, desvio = self.cv2.meanStdDev(cinza)
nitidez = float(self.cv2.Laplacian(cinza, self.cv2.CV_64F).var())
valor = {
"largura": quadro.largura,
"altura": quadro.altura,
"brilho": float(media[0][0]),
"contraste": float(desvio[0][0]),
"nitidez_laplaciana": nitidez,
}
return [EvidenciaVisual("qualidade", quadro.timestamp, quadro.timestamp, valor,
provider=self.nome)]
class _AdapterOpenCV:
"""Implementação compartilhada para adapters OpenCV, com importação tardia."""
def __init__(self, cv2_module: Any | None = None) -> None:
self._cv2 = cv2_module
@property
def cv2(self) -> Any:
if self._cv2 is None:
try:
import cv2
except ImportError as exc:
raise RuntimeError("OpenCV não está instalado. Instale opencv-python.") from exc
self._cv2 = cv2
return self._cv2
class AnalisadorDeRostosOpenCV(_AdapterOpenCV, AnalisadorDeFrame):
"""Encontra rostos e olhos localmente; não infere identidade nem emoção."""
nome = "opencv_haar"
def __init__(self, escala: float = 1.1, vizinhos_minimos: int = 5,
diretorio_de_modelos: str | Path | None = None,
tamanho_minimo_relativo: float = 0.04,
cv2_module: Any | None = None) -> None:
super().__init__(cv2_module)
self.escala = escala
self.vizinhos_minimos = vizinhos_minimos
self.diretorio_de_modelos = Path(diretorio_de_modelos) if diretorio_de_modelos else None
self.tamanho_minimo_relativo = tamanho_minimo_relativo
self._detectores: tuple[Any, Any] | None = None
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
detector_de_rostos, detector_de_olhos = self._obter_detectores()
cinza = self.cv2.cvtColor(quadro.imagem, self.cv2.COLOR_BGR2GRAY)
rostos = detector_de_rostos.detectMultiScale(cinza, scaleFactor=self.escala,
minNeighbors=self.vizinhos_minimos)
evidencias: list[EvidenciaVisual] = []
for x, y, largura, altura in rostos:
caixa = _caixa_normalizada(x, y, largura, altura, quadro.largura, quadro.altura)
if min(caixa["largura"], caixa["altura"]) < self.tamanho_minimo_relativo:
continue
rosto = EvidenciaVisual("rosto", quadro.timestamp, quadro.timestamp,
{"bounding_box": caixa, "proximo_da_borda": _proximo_da_borda(caixa)}, provider=self.nome)
olhos = detector_de_olhos.detectMultiScale(cinza[y:y + altura, x:x + largura],
scaleFactor=1.1, minNeighbors=4)
evidencias.extend((rosto, EvidenciaVisual("olhos_visiveis", quadro.timestamp, quadro.timestamp,
{"quantidade": len(olhos), "rosto": caixa}, provider=self.nome)))
return evidencias
def _obter_detectores(self) -> tuple[Any, Any]:
if self._detectores is None:
base = self.diretorio_de_modelos or Path(self.cv2.data.haarcascades)
rostos = self.cv2.CascadeClassifier(str(base / "haarcascade_frontalface_default.xml"))
olhos = self.cv2.CascadeClassifier(str(base / "haarcascade_eye.xml"))
if rostos.empty() or olhos.empty():
raise RuntimeError(f"Cascades Haar do OpenCV não estão disponíveis em: {base}")
self._detectores = rostos, olhos
return self._detectores
class AnalisadorDeComposicaoOpenCV(_AdapterOpenCV, AnalisadorDeFrame):
"""Mede orientação, poluição visual e disponibilidade das zonas para legendas."""
nome = "opencv_composicao"
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
cinza = self.cv2.cvtColor(quadro.imagem, self.cv2.COLOR_BGR2GRAY)
bordas = self.cv2.Canny(cinza, 80, 160)
densidade_de_bordas = float((bordas > 0).mean())
terco_inferior = cinza[int(quadro.altura * 2 / 3):, :]
zona_de_legenda_livre = float((self.cv2.Canny(terco_inferior, 80, 160) > 0).mean()) < 0.08
valor = {
"orientacao": "vertical" if quadro.altura > quadro.largura else "horizontal",
"densidade_de_bordas": densidade_de_bordas,
"fundo_visual_poluido": densidade_de_bordas > 0.16,
"zona_inferior_livre_para_legenda": zona_de_legenda_livre,
}
return [EvidenciaVisual("composicao", quadro.timestamp, quadro.timestamp, valor, provider=self.nome)]
class AnalisadorDeTremorOpenCV(_AdapterOpenCV, AnalisadorDeSequenciaDeQuadros):
"""Estima deslocamento global entre frames para sinalizar possível tremor de câmera."""
nome = "opencv_movimento"
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]:
if len(quadros) < 2:
return []
deslocamentos = [_deslocamento_global(self.cv2, anterior.imagem, atual.imagem)
for anterior, atual in zip(quadros, quadros[1:])]
deslocamentos = [item for item in deslocamentos if item is not None]
if not deslocamentos:
return []
medio = sum(deslocamentos) / len(deslocamentos)
variacao = sum(abs(item - medio) for item in deslocamentos) / len(deslocamentos)
inicio, fim = quadros[0].timestamp, quadros[-1].timestamp
return [EvidenciaVisual("movimento_de_camera", inicio, fim, {
"deslocamento_medio_pixels": medio,
"variacao_do_deslocamento": variacao,
"possivel_tremor": variacao > 2.0 and medio > 1.0,
"amostras": len(deslocamentos),
}, provider=self.nome)]
class AnalisadorDeContinuidadeOpenCV(_AdapterOpenCV, AnalisadorDeSequenciaDeQuadros):
"""Compara pares de frames e retorna indícios, não certezas, de descontinuidade ou congelamento."""
nome = "opencv_continuidade"
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]:
evidencias: list[EvidenciaVisual] = []
for anterior, atual in zip(quadros, quadros[1:]):
cinza_anterior = self.cv2.cvtColor(anterior.imagem, self.cv2.COLOR_BGR2GRAY)
cinza_atual = self.cv2.cvtColor(atual.imagem, self.cv2.COLOR_BGR2GRAY)
diferenca = float(self.cv2.absdiff(cinza_anterior, cinza_atual).mean())
evidencias.append(EvidenciaVisual("continuidade", anterior.timestamp, atual.timestamp, {
"diferenca_media_de_luminancia": diferenca,
"possivel_frame_congelado": diferenca < 0.8,
"possivel_descontinuidade": diferenca > 38.0,
}, provider=self.nome))
return evidencias
class AnalisadorDeObjetosONNX(AnalisadorDeFrame):
"""Adapter de modelo ONNX; pré e pós-processamento pertencem ao modelo escolhido."""
nome = "onnxruntime"
def __init__(self, modelo: str | Path, preparar: Callable[[Any, list[str]], dict[str, Any]],
decodificar: Callable[[list[Any], QuadroDeVideo], list[dict[str, Any]]],
usar_coreml: bool = True, ort_module: Any | None = None) -> None:
self.modelo = str(modelo)
self.preparar = preparar
self.decodificar = decodificar
self._ort = ort_module
ort = self.ort
preferidos = ["CoreMLExecutionProvider", "CPUExecutionProvider"] if usar_coreml else ["CPUExecutionProvider"]
disponiveis = set(ort.get_available_providers())
self.providers = [provider for provider in preferidos if provider in disponiveis]
if not self.providers:
raise RuntimeError("ONNX Runtime não possui provider compatível neste ambiente.")
self.sessao = ort.InferenceSession(self.modelo, providers=self.providers)
self.entradas = [entrada.name for entrada in self.sessao.get_inputs()]
@property
def ort(self) -> Any:
if self._ort is None:
try:
import onnxruntime
except ImportError as exc:
raise RuntimeError("ONNX Runtime não está instalado. Instale onnxruntime.") from exc
self._ort = onnxruntime
return self._ort
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
entradas = self.preparar(quadro.imagem, self.entradas)
saidas = self.sessao.run(None, entradas)
deteccoes = self.decodificar(saidas, quadro)
return [EvidenciaVisual("objeto", quadro.timestamp, quadro.timestamp, deteccao,
confianca=deteccao.get("confianca"), provider=self.nome,
modelo=Path(self.modelo).name) for deteccao in deteccoes]
class AnalisadorDePoseMediaPipe(AnalisadorDeFrame):
"""Adapter MediaPipe Tasks para pose e mãos em um frame."""
nome = "mediapipe"
def __init__(self, modelo_de_pose: str | Path | None = None,
modelo_de_maos: str | Path | None = None) -> None:
self.modelo_de_pose = Path(modelo_de_pose) if modelo_de_pose else None
self.modelo_de_maos = Path(modelo_de_maos) if modelo_de_maos else None
if self.modelo_de_pose is None and self.modelo_de_maos is None:
raise ValueError("Informe ao menos um modelo MediaPipe de pose ou mãos.")
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
if quadro.caminho is None:
raise ValueError("MediaPipe requer que o frame tenha caminho persistido em disco.")
for modelo in (self.modelo_de_pose, self.modelo_de_maos):
if modelo:
self._validar_modelo(modelo)
carga = {
"frame": str(quadro.caminho), "pose": str(self.modelo_de_pose) if self.modelo_de_pose else None,
"maos": str(self.modelo_de_maos) if self.modelo_de_maos else None,
}
processo = subprocess.run(
[sys.executable, "-m", "engine.integracoes.visual.mediapipe_runner"],
input=json.dumps(carga), capture_output=True, text=True, timeout=60,
)
if processo.returncode != 0:
detalhe = processo.stderr.strip().splitlines()[-1] if processo.stderr.strip() else "falha nativa sem diagnóstico"
raise RuntimeError(f"MediaPipe falhou em processo isolado (código {processo.returncode}): {detalhe}")
dados = json.loads(processo.stdout)
return [EvidenciaVisual(item["tipo"], quadro.timestamp, quadro.timestamp, item["valor"],
provider=self.nome) for item in dados]
@staticmethod
def _validar_modelo(caminho: Path) -> None:
if not caminho.is_file():
raise FileNotFoundError(f"Modelo MediaPipe não encontrado: {caminho}")
class AnalisadorAppleVision(AnalisadorDeFrame):
"""Usa PyObjC/Vision para OCR e detecção de faces em frames persistidos."""
nome = "apple_vision"
def __init__(self, reconhecer_texto: bool = True, detectar_faces: bool = True) -> None:
self.reconhecer_texto = reconhecer_texto
self.detectar_faces = detectar_faces
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
if quadro.caminho is None:
raise ValueError("Apple Vision requer que o frame tenha caminho persistido em disco.")
try:
from Foundation import NSURL
from Vision import VNDetectFaceRectanglesRequest, VNImageRequestHandler, VNRecognizeTextRequest
except ImportError as exc:
raise RuntimeError("Apple Vision requer PyObjC e macOS.") from exc
requisicoes = []
texto = VNRecognizeTextRequest.alloc().initWithCompletionHandler_(None) if self.reconhecer_texto else None
faces = VNDetectFaceRectanglesRequest.alloc().initWithCompletionHandler_(None) if self.detectar_faces else None
if texto:
requisicoes.append(texto)
if faces:
requisicoes.append(faces)
handler = VNImageRequestHandler.alloc().initWithURL_options_(NSURL.fileURLWithPath_(str(quadro.caminho)), {})
sucesso, erro = handler.performRequests_error_(requisicoes, None)
if not sucesso:
detalhe = str(erro) if erro else "o macOS não retornou detalhe; verifique Vision/Neural Engine no host"
raise RuntimeError(f"Apple Vision falhou: {detalhe}")
evidencias: list[EvidenciaVisual] = []
if texto:
for observacao in texto.results() or []:
candidatos = observacao.topCandidates_(1)
if candidatos:
candidato = candidatos[0]
evidencias.append(EvidenciaVisual("ocr", quadro.timestamp, quadro.timestamp,
{"texto": str(candidato.string()), "bounding_box": _retangulo(observacao.boundingBox())},
confianca=float(candidato.confidence()), provider=self.nome))
if faces:
for observacao in faces.results() or []:
evidencias.append(EvidenciaVisual("rosto", quadro.timestamp, quadro.timestamp,
{"bounding_box": _retangulo(observacao.boundingBox())}, provider=self.nome))
return evidencias
def _retangulo(retangulo: Any) -> dict[str, float]:
return {"x": float(retangulo.origin.x), "y": float(retangulo.origin.y),
"largura": float(retangulo.size.width), "altura": float(retangulo.size.height)}
def _caixa_normalizada(x: int, y: int, largura: int, altura: int,
largura_do_frame: int, altura_do_frame: int) -> dict[str, float]:
return {"x": float(x / largura_do_frame), "y": float(y / altura_do_frame),
"largura": float(largura / largura_do_frame), "altura": float(altura / altura_do_frame)}
def _proximo_da_borda(caixa: dict[str, float], margem: float = 0.04) -> bool:
return (caixa["x"] < margem or caixa["y"] < margem
or caixa["x"] + caixa["largura"] > 1 - margem
or caixa["y"] + caixa["altura"] > 1 - margem)
def _deslocamento_global(cv2: Any, imagem_anterior: Any, imagem_atual: Any) -> float | None:
"""Retorna o módulo do deslocamento de câmera, descartando pares sem pontos úteis."""
import math
anterior = cv2.cvtColor(imagem_anterior, cv2.COLOR_BGR2GRAY)
atual = cv2.cvtColor(imagem_atual, cv2.COLOR_BGR2GRAY)
pontos = cv2.goodFeaturesToTrack(anterior, maxCorners=150, qualityLevel=0.01,
minDistance=12, blockSize=7)
if pontos is None or len(pontos) < 8:
return None
encontrados, status, _ = cv2.calcOpticalFlowPyrLK(anterior, atual, pontos, None)
if encontrados is None or status is None:
return None
origem = pontos[status.ravel() == 1]
destino = encontrados[status.ravel() == 1]
if len(origem) < 8:
return None
matriz, _ = cv2.estimateAffinePartial2D(origem, destino, method=cv2.RANSAC)
if matriz is None:
return None
return math.hypot(float(matriz[0, 2]), float(matriz[1, 2]))
@@ -0,0 +1,126 @@
"""Adapter Python para o runner Swift que concentra Vision e Apple Intelligence."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
from typing import Any, Callable
from ...scanner.modelos import EvidenciaVisual
from .contratos import AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros
from .modelos import QuadroDeVideo
RECURSOS_PADRAO = (
"faces", "qualidade_facial", "pessoas", "pose", "maos", "ocr", "categorias",
"estetica", "codigos", "horizonte", "retangulos", "contornos", "segmentacao_de_pessoas",
)
class ExecutorDoRunnerApple:
"""Compila o runner Swift quando necessário e o executa em processo isolado."""
def __init__(self, fonte: str | Path | None = None, compilador: str = "swiftc",
diretorio_de_build: str | Path | None = None) -> None:
self.fonte = Path(fonte) if fonte else Path(__file__).with_name("apple_vision_runner.swift")
self.compilador = compilador
self.diretorio_de_build = Path(diretorio_de_build) if diretorio_de_build else (
Path(tempfile.gettempdir()) / "jhonny-apple-vision")
def executar(self, carga: dict[str, Any], timeout: float) -> dict[str, Any]:
executavel = self._garantir_compilado()
processo = subprocess.run([str(executavel)], input=json.dumps(carga), capture_output=True,
text=True, timeout=timeout)
if processo.returncode != 0:
detalhe = processo.stderr.strip() or "runner Apple terminou sem diagnóstico"
raise RuntimeError(f"Runner Apple Vision falhou: {detalhe}")
try:
return json.loads(processo.stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Runner Apple Vision devolveu JSON inválido: {processo.stdout!r}") from exc
def _garantir_compilado(self) -> Path:
if not self.fonte.is_file():
raise FileNotFoundError(f"Fonte do runner Apple não encontrada: {self.fonte}")
self.diretorio_de_build.mkdir(parents=True, exist_ok=True)
executavel = self.diretorio_de_build / "apple-vision-runner"
if not executavel.exists() or executavel.stat().st_mtime < self.fonte.stat().st_mtime:
ambiente = os.environ | {"CLANG_MODULE_CACHE_PATH": str(self.diretorio_de_build / "module-cache")}
processo = subprocess.run([self.compilador, "-parse-as-library", str(self.fonte), "-o", str(executavel)],
capture_output=True, text=True, timeout=120, env=ambiente)
if processo.returncode != 0:
raise RuntimeError(f"Não foi possível compilar runner Apple Vision: {processo.stderr.strip()}")
return executavel
class AnalisadorAppleVisionNativo(AnalisadorDeFrame):
"""Módulo profundo: pede fatos visuais nativos e opcionalmente interpretação local.
A interface recebe somente um `QuadroDeVideo`; Vision, Foundation Models,
Swift, compilação e erros nativos permanecem atrás deste adapter.
"""
nome = "apple_vision"
def __init__(self, recursos: tuple[str, ...] = RECURSOS_PADRAO,
interpretar_com_apple_intelligence: bool = True,
executor: ExecutorDoRunnerApple | None = None,
timeout_em_segundos: float = 90.0) -> None:
self.recursos = recursos
self.interpretar_com_apple_intelligence = interpretar_com_apple_intelligence
self.executor = executor or ExecutorDoRunnerApple()
self.timeout_em_segundos = timeout_em_segundos
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
if quadro.caminho is None:
raise ValueError("Apple Vision requer que o frame tenha caminho persistido em disco.")
dados = self.executor.executar({"frame": str(quadro.caminho), "recursos": list(self.recursos),
"resumir": self.interpretar_com_apple_intelligence},
self.timeout_em_segundos)
evidencias = [self._converter(item, quadro) for item in dados.get("evidencias", [])]
avisos = dados.get("avisos", [])
if avisos:
evidencias.append(EvidenciaVisual("diagnostico_apple_vision", quadro.timestamp, quadro.timestamp,
{"avisos": avisos, "recursos_solicitados": list(self.recursos)}, provider=self.nome))
return evidencias
def _converter(self, item: dict[str, Any], quadro: QuadroDeVideo) -> EvidenciaVisual:
return EvidenciaVisual(item["tipo"], quadro.timestamp, quadro.timestamp, item["valor"],
confianca=item.get("confianca"), provider=self.nome,
modelo=item.get("modelo"))
class AnalisadorSequenciaAppleVisionNativo(AnalisadorDeSequenciaDeQuadros):
"""Compara frames com feature prints nativos sem expor o protocolo Swift."""
nome = "apple_vision_sequencia"
def __init__(self, executor: ExecutorDoRunnerApple | None = None,
timeout_em_segundos: float = 90.0) -> None:
self.executor = executor or ExecutorDoRunnerApple()
self.timeout_em_segundos = timeout_em_segundos
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]:
if len(quadros) < 2:
return []
if any(quadro.caminho is None for quadro in quadros):
raise ValueError("Apple Vision de sequência requer frames persistidos em disco.")
dados = self.executor.executar({"frames": [str(quadro.caminho) for quadro in quadros],
"recursos": [], "resumir": False}, self.timeout_em_segundos)
evidencias: list[EvidenciaVisual] = []
for item in dados.get("evidencias", []):
valor = dict(item["valor"])
inicio = int(valor.pop("frame_inicial", 0))
fim = int(valor.pop("frame_final", inicio + 1))
if inicio < 0 or fim >= len(quadros) or fim < inicio:
raise RuntimeError("Runner Apple Vision devolveu índices temporais inválidos.")
evidencias.append(EvidenciaVisual(item["tipo"], quadros[inicio].timestamp,
quadros[fim].timestamp, valor,
confianca=item.get("confianca"), provider=self.nome,
modelo=item.get("modelo")))
avisos = dados.get("avisos", [])
if avisos:
evidencias.append(EvidenciaVisual("diagnostico_apple_vision", quadros[0].timestamp,
quadros[-1].timestamp, {"avisos": avisos}, provider=self.nome))
return evidencias
@@ -0,0 +1,368 @@
import Foundation
import ImageIO
import Vision
import FoundationModels
import CoreVideo
struct Entrada: Decodable {
let frame: String?
let frames: [String]?
let recursos: [String]
let resumir: Bool
}
struct Evidencia: Encodable {
let tipo: String
let valor: [String: JSONValue]
let confianca: Double?
let modelo: String?
}
struct Saida: Encodable {
let evidencias: [Evidencia]
let avisos: [String]
}
enum JSONValue: Encodable {
case texto(String), numero(Double), booleano(Bool), objeto([String: JSONValue]), lista([JSONValue]), nulo
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .texto(let value): try container.encode(value)
case .numero(let value): try container.encode(value)
case .booleano(let value): try container.encode(value)
case .objeto(let value): try container.encode(value)
case .lista(let value): try container.encode(value)
case .nulo: try container.encodeNil()
}
}
}
func caixa(_ rect: CGRect) -> [String: JSONValue] {
["x": .numero(rect.origin.x), "y": .numero(rect.origin.y),
"largura": .numero(rect.size.width), "altura": .numero(rect.size.height)]
}
func ponto(_ point: CGPoint) -> JSONValue {
.objeto(["x": .numero(point.x), "y": .numero(point.y)])
}
func ponto(_ point: VNRecognizedPoint) -> JSONValue {
.objeto(["x": .numero(point.location.x), "y": .numero(point.location.y),
"confianca": .numero(Double(point.confidence))])
}
func executar<T: VNRequest>(_ request: T, em url: URL) throws -> [VNObservation] {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let imagem = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
throw NSError(domain: "JhonnyAppleVision", code: 1,
userInfo: [NSLocalizedDescriptionKey: "ImageIO não conseguiu decodificar o frame"])
}
let handler = VNImageRequestHandler(cgImage: imagem, options: [:])
try handler.perform([request])
return request.results ?? []
}
func coberturaDaMascara(_ pixelBuffer: CVPixelBuffer) -> Double {
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
guard let base = CVPixelBufferGetBaseAddress(pixelBuffer) else { return 0 }
let altura = CVPixelBufferGetHeight(pixelBuffer)
let largura = CVPixelBufferGetWidth(pixelBuffer)
let stride = CVPixelBufferGetBytesPerRow(pixelBuffer)
let bytes = base.assumingMemoryBound(to: UInt8.self)
var ocupados = 0
for y in 0..<altura {
for x in 0..<largura where bytes[y * stride + x] > 12 { ocupados += 1 }
}
return Double(ocupados) / Double(max(1, altura * largura))
}
func analisarVision(_ entrada: Entrada) -> Saida {
guard let frame = entrada.frame else {
return Saida(evidencias: [], avisos: ["frame: caminho obrigatório para análise individual"])
}
let url = URL(fileURLWithPath: frame)
var evidencias: [Evidencia] = []
var avisos: [String] = []
func tentar(_ recurso: String, _ bloco: () throws -> [Evidencia]) {
guard entrada.recursos.contains(recurso) else { return }
do { evidencias.append(contentsOf: try bloco()) }
catch { avisos.append("\(recurso): \(error.localizedDescription)") }
}
tentar("faces") {
let request = VNDetectFaceLandmarksRequest()
return try executar(request, em: url).compactMap { observacao -> Evidencia? in
guard let face = observacao as? VNFaceObservation else { return nil }
var valor: [String: JSONValue] = ["bounding_box": .objeto(caixa(face.boundingBox))]
if let landmarks = face.landmarks {
let grupos: [(String, VNFaceLandmarkRegion2D?)] = [
("olho_esquerdo", landmarks.leftEye), ("olho_direito", landmarks.rightEye),
("sobrancelha_esquerda", landmarks.leftEyebrow), ("sobrancelha_direita", landmarks.rightEyebrow),
("nariz", landmarks.nose), ("labios", landmarks.outerLips), ("rosto", landmarks.faceContour)
]
valor["landmarks"] = .objeto(Dictionary(uniqueKeysWithValues: grupos.compactMap { nome, regiao in
guard let regiao else { return nil }
return (nome, .lista(regiao.normalizedPoints.map { .objeto(["x": .numero($0.x), "y": .numero($0.y)]) }))
}))
}
return Evidencia(tipo: "rosto", valor: valor, confianca: Double(face.confidence), modelo: "VNDetectFaceLandmarksRequest")
}
}
tentar("qualidade_facial") {
let request = VNDetectFaceCaptureQualityRequest()
return try executar(request, em: url).compactMap { observacao -> Evidencia? in
guard let face = observacao as? VNFaceObservation, let qualidade = face.faceCaptureQuality else { return nil }
return Evidencia(tipo: "qualidade_do_rosto", valor: ["bounding_box": .objeto(caixa(face.boundingBox)),
"score": .numero(Double(qualidade))], confianca: Double(face.confidence), modelo: "VNDetectFaceCaptureQualityRequest")
}
}
tentar("pessoas") {
let request = VNDetectHumanRectanglesRequest()
request.upperBodyOnly = false
return try executar(request, em: url).compactMap { observacao in
guard let pessoa = observacao as? VNHumanObservation else { return nil }
return Evidencia(tipo: "pessoa", valor: ["bounding_box": .objeto(caixa(pessoa.boundingBox)),
"ocupacao_do_quadro": .numero(pessoa.boundingBox.width * pessoa.boundingBox.height)],
confianca: Double(pessoa.confidence), modelo: "VNDetectHumanRectanglesRequest")
}
}
tentar("pose") {
let request = VNDetectHumanBodyPoseRequest()
return try executar(request, em: url).compactMap { observacao in
guard let pose = observacao as? VNHumanBodyPoseObservation else { return nil }
let pontos = try? pose.recognizedPoints(.all)
let dados: [String: JSONValue] = pontos.map { Dictionary(uniqueKeysWithValues: $0.map { (String(describing: $0.key), ponto($0.value)) }) } ?? [:]
return Evidencia(tipo: "pose", valor: ["pontos": .objeto(dados)], confianca: Double(pose.confidence), modelo: "VNDetectHumanBodyPoseRequest")
}
}
tentar("maos") {
let request = VNDetectHumanHandPoseRequest()
request.maximumHandCount = 4
return try executar(request, em: url).compactMap { observacao in
guard let mao = observacao as? VNHumanHandPoseObservation else { return nil }
let pontos = try? mao.recognizedPoints(.all)
let dados: [String: JSONValue] = pontos.map { Dictionary(uniqueKeysWithValues: $0.map { (String(describing: $0.key), ponto($0.value)) }) } ?? [:]
return Evidencia(tipo: "mao", valor: ["pontos": .objeto(dados)], confianca: Double(mao.confidence), modelo: "VNDetectHumanHandPoseRequest")
}
}
tentar("ocr") {
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.recognitionLanguages = ["pt-BR", "en-US"]
return try executar(request, em: url).compactMap { observacao in
guard let texto = observacao as? VNRecognizedTextObservation,
let candidato = texto.topCandidates(1).first else { return nil }
return Evidencia(tipo: "ocr", valor: ["texto": .texto(candidato.string),
"bounding_box": .objeto(caixa(texto.boundingBox))], confianca: Double(candidato.confidence), modelo: "VNRecognizeTextRequest")
}
}
tentar("categorias") {
let request = VNClassifyImageRequest()
return try executar(request, em: url).compactMap { observacao in
guard let categoria = observacao as? VNClassificationObservation, categoria.confidence >= 0.2 else { return nil }
return Evidencia(tipo: "categoria", valor: ["identificador": .texto(categoria.identifier)],
confianca: Double(categoria.confidence), modelo: "VNClassifyImageRequest")
}
}
tentar("estetica") {
let request = VNCalculateImageAestheticsScoresRequest()
return try executar(request, em: url).compactMap { observacao in
guard let score = observacao as? VNImageAestheticsScoresObservation else { return nil }
return Evidencia(tipo: "estetica", valor: ["score_global": .numero(Double(score.overallScore))],
confianca: nil, modelo: "VNCalculateImageAestheticsScoresRequest")
}
}
tentar("codigos") {
let request = VNDetectBarcodesRequest()
return try executar(request, em: url).compactMap { observacao in
guard let codigo = observacao as? VNBarcodeObservation else { return nil }
return Evidencia(tipo: "codigo", valor: ["payload": .texto(codigo.payloadStringValue ?? ""),
"simbologia": .texto(codigo.symbology.rawValue), "bounding_box": .objeto(caixa(codigo.boundingBox))],
confianca: Double(codigo.confidence), modelo: "VNDetectBarcodesRequest")
}
}
tentar("horizonte") {
let request = VNDetectHorizonRequest()
return try executar(request, em: url).compactMap { observacao in
guard let horizonte = observacao as? VNHorizonObservation else { return nil }
return Evidencia(tipo: "horizonte", valor: ["angulo_radianos": .numero(Double(horizonte.angle))],
confianca: Double(horizonte.confidence), modelo: "VNDetectHorizonRequest")
}
}
tentar("retangulos") {
let request = VNDetectRectanglesRequest()
return try executar(request, em: url).compactMap { observacao in
guard let retangulo = observacao as? VNRectangleObservation else { return nil }
return Evidencia(tipo: "retangulo", valor: ["bounding_box": .objeto(caixa(retangulo.boundingBox)),
"cantos": .objeto(["superior_esquerdo": ponto(retangulo.topLeft),
"superior_direito": ponto(retangulo.topRight),
"inferior_esquerdo": ponto(retangulo.bottomLeft),
"inferior_direito": ponto(retangulo.bottomRight)])],
confianca: Double(retangulo.confidence), modelo: "VNDetectRectanglesRequest")
}
}
tentar("contornos") {
let request = VNDetectContoursRequest()
return try executar(request, em: url).compactMap { observacao in
guard let contornos = observacao as? VNContoursObservation else { return nil }
return Evidencia(tipo: "contornos", valor: ["quantidade_principal": .numero(Double(contornos.topLevelContours.count))],
confianca: Double(contornos.confidence), modelo: "VNDetectContoursRequest")
}
}
tentar("segmentacao_de_pessoas") {
let request = VNGeneratePersonSegmentationRequest()
request.qualityLevel = .balanced
return try executar(request, em: url).compactMap { observacao in
guard let mascara = observacao as? VNPixelBufferObservation else { return nil }
return Evidencia(tipo: "segmentacao_de_pessoas", valor: ["ocupacao_do_quadro": .numero(coberturaDaMascara(mascara.pixelBuffer))],
confianca: nil, modelo: "VNGeneratePersonSegmentationRequest")
}
}
return Saida(evidencias: evidencias, avisos: avisos)
}
func featurePrint(_ url: URL) throws -> VNFeaturePrintObservation {
let request = VNGenerateImageFeaturePrintRequest()
guard let resultado = try executar(request, em: url).first as? VNFeaturePrintObservation else {
throw NSError(domain: "JhonnyAppleVision", code: 2, userInfo: [NSLocalizedDescriptionKey: "Vision não produziu feature print"])
}
return resultado
}
func analisarSequencia(_ entrada: Entrada) -> Saida {
let caminhos = entrada.frames ?? []
guard caminhos.count >= 2 else { return Saida(evidencias: [], avisos: ["sequencia: informe ao menos dois frames"]) }
var evidencias: [Evidencia] = []
var avisos: [String] = []
for indice in 0..<(caminhos.count - 1) {
do {
var distancia: Float = 0
try featurePrint(URL(fileURLWithPath: caminhos[indice])).computeDistance(
&distancia, to: featurePrint(URL(fileURLWithPath: caminhos[indice + 1])))
evidencias.append(Evidencia(tipo: "similaridade_visual", valor: [
"frame_inicial": .numero(Double(indice)), "frame_final": .numero(Double(indice + 1)),
"distancia_feature_print": .numero(Double(distancia)),
"possivel_mudanca_de_cena": .booleano(distancia > 12),
], confianca: nil, modelo: "VNGenerateImageFeaturePrintRequest"))
} catch { avisos.append("similaridade_visual[\(indice)]: \(error.localizedDescription)") }
}
return Saida(evidencias: evidencias, avisos: avisos)
}
extension Dictionary where Key == String, Value == JSONValue {
func numero(_ chave: String) -> Double? {
if case .numero(let valor)? = self[chave] { return valor }
return nil
}
func texto(_ chave: String) -> String? {
if case .texto(let valor)? = self[chave] { return valor }
return nil
}
func booleano(_ chave: String) -> Bool? {
if case .booleano(let valor)? = self[chave] { return valor }
return nil
}
func objeto(_ chave: String) -> [String: JSONValue]? {
if case .objeto(let valor)? = self[chave] { return valor }
return nil
}
}
/// Traduz uma evidência em uma frase factual com os valores medidos, nunca só o nome do tipo —
/// é isso que alimenta a interpretação editorial, então precisa carregar o fato, não só o rótulo.
func descreverEvidencia(_ evidencia: Evidencia) -> String {
let valor = evidencia.valor
switch evidencia.tipo {
case "rosto":
let grupos = valor.objeto("landmarks")?.count ?? 0
return "rosto detectado (\(grupos) grupos de landmarks mapeados)"
case "qualidade_do_rosto":
guard let score = valor.numero("score") else { return "qualidade do rosto avaliada" }
return "qualidade do rosto: score \(String(format: "%.2f", score))"
case "pessoa":
guard let ocupacao = valor.numero("ocupacao_do_quadro") else { return "pessoa detectada" }
return "pessoa ocupando \(String(format: "%.0f", ocupacao * 100))% do quadro"
case "pose":
let pontos = valor.objeto("pontos")?.count ?? 0
return "pose corporal com \(pontos) pontos reconhecidos"
case "mao":
let pontos = valor.objeto("pontos")?.count ?? 0
return "mão com \(pontos) pontos reconhecidos"
case "ocr":
guard let texto = valor.texto("texto"), !texto.isEmpty else { return "nenhum texto legível na imagem" }
return "texto detectado na imagem: \"\(texto)\""
case "categoria":
guard let identificador = valor.texto("identificador") else { return "categoria detectada" }
let confianca = evidencia.confianca.map { String(format: "%.0f%%", $0 * 100) } ?? "confiança desconhecida"
return "categoria \"\(identificador)\" (\(confianca))"
case "estetica":
guard let score = valor.numero("score_global") else { return "score estético calculado" }
return "score estético global: \(String(format: "%.2f", score))"
case "codigo":
guard let payload = valor.texto("payload"), !payload.isEmpty else { return "código detectado sem payload legível" }
return "código detectado: \"\(payload)\""
case "horizonte":
guard let angulo = valor.numero("angulo_radianos") else { return "horizonte detectado" }
return "horizonte inclinado \(String(format: "%.1f", angulo * 180 / .pi))°"
case "retangulo":
guard let bbox = valor.objeto("bounding_box"), let largura = bbox.numero("largura"), let altura = bbox.numero("altura") else {
return "retângulo/documento detectado no quadro"
}
return "retângulo detectado ocupando \(String(format: "%.0f", largura * 100))%x\(String(format: "%.0f", altura * 100))% do quadro"
case "contornos":
guard let quantidade = valor.numero("quantidade_principal") else { return "contornos detectados" }
return "\(Int(quantidade)) contornos principais detectados"
case "segmentacao_de_pessoas":
guard let ocupacao = valor.numero("ocupacao_do_quadro") else { return "segmentação de pessoa calculada" }
return "silhueta de pessoa cobrindo \(String(format: "%.0f", ocupacao * 100))% do quadro"
case "similaridade_visual":
let mudanca = valor.booleano("possivel_mudanca_de_cena") ?? false
return mudanca ? "possível corte de cena entre os quadros" : "quadros visualmente semelhantes, sem corte de cena"
default:
return evidencia.tipo
}
}
func interpretar(_ saida: Saida) async -> String? {
guard SystemLanguageModel.default.isAvailable else { return nil }
let fatos = saida.evidencias.map(descreverEvidencia).joined(separator: "; ")
guard !fatos.isEmpty else { return nil }
do {
let sessao = LanguageModelSession(instructions: "Você é um assistente editorial. Descreva apenas fatos explicitamente fornecidos; não invente pessoas, emoções, intenção ou identidade. Responda em uma frase curta em português.")
return try await sessao.respond(to: "Evidências visuais disponíveis: \(fatos). Gere uma observação editorial conservadora.").content
} catch { return nil }
}
@main struct Principal {
static func main() async {
do {
let entrada = try JSONDecoder().decode(Entrada.self, from: FileHandle.standardInput.readDataToEndOfFile())
var saida = entrada.frames?.count ?? 0 > 1 ? analisarSequencia(entrada) : analisarVision(entrada)
if entrada.resumir, let resumo = await interpretar(saida) {
saida = Saida(evidencias: saida.evidencias + [Evidencia(tipo: "interpretacao_editorial", valor: ["texto": .texto(resumo)], confianca: nil, modelo: "AppleFoundationModels")], avisos: saida.avisos)
}
let dados = try JSONEncoder().encode(saida)
FileHandle.standardOutput.write(dados)
} catch {
FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8))
exit(1)
}
}
}
@@ -0,0 +1,40 @@
from abc import ABC, abstractmethod
from typing import Any
from ...dominio import Clipe
from ...scanner.modelos import Cena, EvidenciaVisual
from .modelos import QuadroDeVideo
class ExtratorDeQuadros(ABC):
"""Interface para obter uma amostra temporal de frames de um clipe."""
@abstractmethod
def extrair(self, clipe: Clipe) -> list[QuadroDeVideo]: ...
class AnalisadorDeFrame(ABC):
"""Interface comum: recebe um frame e devolve evidências do domínio."""
nome: str
@abstractmethod
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]: ...
class AnalisadorDeSequenciaDeQuadros(ABC):
"""Interface para evidências que só existem ao comparar vários frames."""
nome: str
@abstractmethod
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]: ...
class DetectorDeIntervalosDeCena(ABC):
"""Interface para algoritmos que encontram intervalos de cena no clipe."""
nome: str
@abstractmethod
def detectar(self, clipe: Clipe, quadros: list[QuadroDeVideo]) -> list[Cena]: ...
@@ -0,0 +1,34 @@
from typing import Any
from ...dominio import Clipe
from ...scanner.modelos import Cena
from .contratos import DetectorDeIntervalosDeCena
from .modelos import QuadroDeVideo
class DetectorDeCenasPySceneDetect(DetectorDeIntervalosDeCena):
"""Adapter PySceneDetect que devolve somente cenas do domínio."""
nome = "pyscenedetect"
def __init__(self, limiar: float = 27.0, detector: Any | None = None,
detectar_funcao: Any | None = None) -> None:
self.limiar = limiar
self._detector = detector
self._detectar_funcao = detectar_funcao
def detectar(self, clipe: Clipe, quadros: list[QuadroDeVideo]) -> list[Cena]:
if not clipe.arquivo:
raise ValueError("Clipe não possui arquivo de origem.")
if self._detectar_funcao is None:
try:
from scenedetect import ContentDetector, detect
except ImportError as exc:
raise RuntimeError("PySceneDetect não está instalado. Instale scenedetect.") from exc
detector = self._detector or ContentDetector(threshold=self.limiar)
detectar = lambda arquivo: detect(arquivo, detector)
else:
detectar = self._detectar_funcao
resultado = detectar(str(clipe.arquivo))
return [Cena(float(inicio.get_seconds()), float(fim.get_seconds()), referencias=(float(inicio.get_seconds()),))
for inicio, fim in resultado]
@@ -0,0 +1,77 @@
"""Extrator de frames para Vision usando somente ferramentas locais do sistema."""
import json
from pathlib import Path
import subprocess
from ...dominio import Clipe
from .contratos import ExtratorDeQuadros
from .extrator_open_cv import ExtratorDeQuadrosOpenCV
from .modelos import QuadroDeVideo
class ExtratorDeQuadrosFFmpeg(ExtratorDeQuadros):
"""Persiste amostras de um arquivo original sem carregar OpenCV no processo."""
def __init__(self, intervalo_em_segundos: float = 1.0,
diretorio_de_cache: str | Path = ".frames",
maximo_de_quadros: int | None = None,
ffmpeg: str = "ffmpeg", ffprobe: str = "ffprobe") -> None:
if intervalo_em_segundos <= 0:
raise ValueError("intervalo_em_segundos deve ser positivo.")
if maximo_de_quadros is not None and maximo_de_quadros < 1:
raise ValueError("maximo_de_quadros deve ser maior que zero.")
self.intervalo_em_segundos = intervalo_em_segundos
self.diretorio_de_cache = Path(diretorio_de_cache)
self.maximo_de_quadros = maximo_de_quadros
self.ffmpeg = ffmpeg
self.ffprobe = ffprobe
def extrair(self, clipe: Clipe) -> list[QuadroDeVideo]:
if not clipe.arquivo:
raise ValueError("Clipe não possui arquivo de origem.")
arquivo = Path(clipe.arquivo)
if not arquivo.is_file():
raise FileNotFoundError(f"Arquivo do clipe não encontrado: {arquivo}")
largura, altura, duracao = self._metadados(arquivo)
inicio, fim = ExtratorDeQuadrosOpenCV._intervalo_de_origem(clipe, duracao)
timestamps = ExtratorDeQuadrosOpenCV(self.intervalo_em_segundos,
maximo_de_quadros=self.maximo_de_quadros)._timestamps(inicio, fim)
resultado: list[QuadroDeVideo] = []
for indice, timestamp in enumerate(timestamps):
# PNG evita a recodificação JPEG de YUV limitado, que falha em parte
# dos vídeos móveis e ainda preserva melhor OCR/segmentação para Vision.
destino = self.diretorio_de_cache / arquivo.stem / f"frame-{indice:06d}.png"
destino.parent.mkdir(parents=True, exist_ok=True)
amostrado, processo = self._extrair_frame(arquivo, destino, timestamp, inicio)
if processo.returncode != 0 or not destino.is_file():
detalhe = processo.stderr.strip() or "ffmpeg não produziu o frame"
raise RuntimeError(f"Não foi possível extrair frame em {timestamp}s: {detalhe}")
resultado.append(QuadroDeVideo(amostrado, indice, largura, altura, None, destino))
return resultado
def _extrair_frame(self, arquivo: Path, destino: Path, timestamp: float,
inicio: float) -> tuple[float, subprocess.CompletedProcess[str]]:
"""Recua um pouco no fim do arquivo se o decoder não encontrar frame no timestamp."""
ultimo: subprocess.CompletedProcess[str] | None = None
for candidato in (timestamp, max(inicio, timestamp - 0.1)):
if destino.exists():
destino.unlink()
processo = subprocess.run([self.ffmpeg, "-hide_banner", "-loglevel", "error", "-ss", str(candidato),
"-i", str(arquivo), "-frames:v", "1", "-y", str(destino)],
capture_output=True, text=True, timeout=60)
if processo.returncode == 0 and destino.is_file():
return candidato, processo
ultimo = processo
assert ultimo is not None
return timestamp, ultimo
def _metadados(self, arquivo: Path) -> tuple[int, int, float]:
processo = subprocess.run([self.ffprobe, "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height:format=duration",
"-of", "json", str(arquivo)], capture_output=True, text=True, timeout=30)
if processo.returncode != 0:
raise RuntimeError(processo.stderr.strip() or "ffprobe não conseguiu ler o vídeo")
dados = json.loads(processo.stdout)
stream = (dados.get("streams") or [{}])[0]
return int(stream["width"]), int(stream["height"]), float((dados.get("format") or {}).get("duration") or 0)
@@ -0,0 +1,87 @@
from pathlib import Path
from typing import Any
from ...dominio import Clipe
from .contratos import ExtratorDeQuadros
from .modelos import QuadroDeVideo
class ExtratorDeQuadrosOpenCV(ExtratorDeQuadros):
"""Extrai frames em intervalos regulares sem expor detalhes do OpenCV."""
def __init__(self, intervalo_em_segundos: float = 1.0,
diretorio_de_cache: str | Path | None = None,
maximo_de_quadros: int | None = None,
cv2_module: Any | None = None) -> None:
if intervalo_em_segundos <= 0:
raise ValueError("intervalo_em_segundos deve ser positivo.")
if maximo_de_quadros is not None and maximo_de_quadros < 1:
raise ValueError("maximo_de_quadros deve ser maior que zero.")
self.intervalo_em_segundos = intervalo_em_segundos
self.diretorio_de_cache = Path(diretorio_de_cache) if diretorio_de_cache else None
self.maximo_de_quadros = maximo_de_quadros
self._cv2 = cv2_module
@property
def cv2(self) -> Any:
if self._cv2 is None:
try:
import cv2
except ImportError as exc:
raise RuntimeError("OpenCV não está instalado. Instale opencv-python.") from exc
self._cv2 = cv2
return self._cv2
def extrair(self, clipe: Clipe) -> list[QuadroDeVideo]:
if not clipe.arquivo:
raise ValueError("Clipe não possui arquivo de origem.")
caminho = Path(clipe.arquivo)
if not caminho.is_file():
raise FileNotFoundError(f"Arquivo do clipe não encontrado: {caminho}")
captura = self.cv2.VideoCapture(str(caminho))
if not captura.isOpened():
raise RuntimeError(f"OpenCV não conseguiu abrir o vídeo: {caminho}")
try:
fps = float(captura.get(self.cv2.CAP_PROP_FPS) or 0)
total_de_frames = int(captura.get(self.cv2.CAP_PROP_FRAME_COUNT) or 0)
duracao = total_de_frames / fps if fps > 0 else 0.0
inicio, fim = self._intervalo_de_origem(clipe, duracao)
timestamps = self._timestamps(inicio, fim)
resultado: list[QuadroDeVideo] = []
for indice, timestamp in enumerate(timestamps):
captura.set(self.cv2.CAP_PROP_POS_MSEC, timestamp * 1000.0)
sucesso, imagem = captura.read()
if not sucesso or imagem is None:
continue
altura, largura = imagem.shape[:2]
salvo = self._salvar(caminho, indice, imagem)
resultado.append(QuadroDeVideo(timestamp, indice, largura, altura, imagem, salvo))
return resultado
finally:
captura.release()
def _timestamps(self, inicio: float, fim: float) -> list[float]:
if fim <= inicio:
return [inicio]
quantidade = int((fim - inicio) / self.intervalo_em_segundos) + 1
limite = max(inicio, fim - 0.001)
timestamps = [min(inicio + indice * self.intervalo_em_segundos, limite)
for indice in range(quantidade)]
return timestamps[:self.maximo_de_quadros]
@staticmethod
def _intervalo_de_origem(clipe: Clipe, duracao: float) -> tuple[float, float]:
if clipe.intervalo_na_origem is None:
return 0.0, duracao
inicio = max(0.0, clipe.intervalo_na_origem.inicio)
fim = min(duracao, clipe.intervalo_na_origem.fim) if duracao > 0 else clipe.intervalo_na_origem.fim
return inicio, max(inicio, fim)
def _salvar(self, arquivo: Path, indice: int, imagem: Any) -> Path | None:
if self.diretorio_de_cache is None:
return None
destino = self.diretorio_de_cache / arquivo.stem / f"frame-{indice:06d}.jpg"
destino.parent.mkdir(parents=True, exist_ok=True)
if not self.cv2.imwrite(str(destino), imagem):
raise RuntimeError(f"Não foi possível salvar frame: {destino}")
return destino
@@ -0,0 +1,47 @@
"""Executável interno isolado para evitar que falhas nativas do MediaPipe derrubem o Scanner."""
import json
import sys
from pathlib import Path
def _pontos(landmarks, visibilidade: bool = False):
return [{"x": ponto.x, "y": ponto.y, "z": ponto.z,
**({"visibilidade": getattr(ponto, "visibility", None)} if visibilidade else {})}
for ponto in landmarks]
def executar(carga: dict) -> list[dict]:
import cv2
import mediapipe as mp
imagem_bgr = cv2.imread(carga["frame"])
if imagem_bgr is None:
raise FileNotFoundError(f"Frame não encontrado: {carga['frame']}")
imagem = mp.Image(image_format=mp.ImageFormat.SRGB, data=imagem_bgr[:, :, ::-1])
resultado: list[dict] = []
if carga.get("pose"):
opcoes = mp.tasks.vision.PoseLandmarkerOptions(
base_options=mp.tasks.BaseOptions(model_asset_path=carga["pose"], delegate=mp.tasks.BaseOptions.Delegate.CPU),
running_mode=mp.tasks.vision.RunningMode.IMAGE,
)
with mp.tasks.vision.PoseLandmarker.create_from_options(opcoes) as detector:
for pose in detector.detect(imagem).pose_landmarks:
resultado.append({"tipo": "pose", "valor": {"pontos": _pontos(pose, visibilidade=True)}})
if carga.get("maos"):
opcoes = mp.tasks.vision.HandLandmarkerOptions(
base_options=mp.tasks.BaseOptions(model_asset_path=carga["maos"], delegate=mp.tasks.BaseOptions.Delegate.CPU),
running_mode=mp.tasks.vision.RunningMode.IMAGE, num_hands=4,
)
with mp.tasks.vision.HandLandmarker.create_from_options(opcoes) as detector:
for mao in detector.detect(imagem).hand_landmarks:
resultado.append({"tipo": "mao", "valor": {"pontos": _pontos(mao)}})
return resultado
if __name__ == "__main__":
try:
print(json.dumps(executar(json.loads(sys.stdin.read()))))
except Exception as erro:
print(str(erro), file=sys.stderr)
raise
+15
View File
@@ -0,0 +1,15 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class QuadroDeVideo:
"""Frame extraído, com timestamp relativo ao arquivo de origem."""
timestamp: float
indice: int
largura: int
altura: int
imagem: Any
caminho: Path | None = None
@@ -1,7 +1,7 @@
from pathlib import Path
from typing import Any
from ...scanner.modelos import SegmentoDeTranscricao
from ...scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
class ProviderDeTranscricaoLocal:
@@ -9,6 +9,9 @@ class ProviderDeTranscricaoLocal:
def __init__(self, modelo: str, dispositivo: str = "cpu", tipo_de_calculo: str = "int8",
idioma: str = "pt") -> None:
self.nome_do_modelo = Path(modelo).name
if "faster-whisper-" in modelo:
self.nome_do_modelo = modelo.split("faster-whisper-", 1)[1].split("/", 1)[0]
from faster_whisper import WhisperModel
self.modelo = WhisperModel(modelo, device=dispositivo, compute_type=tipo_de_calculo)
self.idioma = idioma
@@ -17,5 +20,9 @@ class ProviderDeTranscricaoLocal:
arquivo = Path(clipe.arquivo)
if not arquivo.is_file():
raise FileNotFoundError(f"Arquivo de áudio não encontrado: {arquivo}")
segmentos, _ = self.modelo.transcribe(str(arquivo), language=self.idioma, vad_filter=True)
return [SegmentoDeTranscricao(float(s.start), float(s.end), s.text.strip()) for s in segmentos]
segmentos, _ = self.modelo.transcribe(str(arquivo), language=self.idioma,
vad_filter=True, word_timestamps=True)
return [SegmentoDeTranscricao(float(s.start), float(s.end), s.text.strip(),
None, tuple(PalavraDeTranscricao(w.word.strip(), float(w.start), float(w.end),
getattr(w, "probability", None))
for w in (s.words or []) if w.word.strip())) for s in segmentos]
+7
View File
@@ -0,0 +1,7 @@
# Dependências opcionais para os adapters locais de análise visual.
opencv-python
scenedetect
onnxruntime
mediapipe
# Apenas macOS; permite usar Vision diretamente a partir do Python.
pyobjc-framework-Vision
+19 -4
View File
@@ -1,7 +1,22 @@
from .coordenacao import AnalisadorDeTimeline, ContextoDeAnalise, PipelineDoScanner
__all__ = ["AnalisadorDeTimeline", "ContextoDeAnalise", "PipelineDoScanner"]
from .modelos import Cena, ErroDeAnalise, Evento, ResultadoDaAnalise, SegmentoDeTranscricao, StatusDaAnalise
from .descoberta import ArquivoDescoberto, DescobertaDeArquivos, LeitorLocalDeArquivos
from .metadados import ExtracaoDeMetadados, MetadadosDoArquivo
from .modelos import (Cena, CenaVisual, EvidenciaVisual, ErroDeAnalise, Evento, ObservacaoVisual,
PalavraDeTranscricao,
ResultadoDaAnalise, SegmentoDeTranscricao, StatusDaAnalise)
from .transcricao_da_timeline import TranscricaoDaTimeline, TranscricaoDoClipe
from .visual import (AnaliseVisualDaTimeline, DetectorDeCenas, ResultadoVisualDoClipe,
criar_detector_apple_vision, criar_detector_visual_local)
from .relatorio_visual import gerar_relatorio_visual, gerar_resumo_visual_markdown
from .configuracao_visual import ConfiguracaoVisualDoScanner
__all__ = ["Cena", "ErroDeAnalise", "Evento", "ResultadoDaAnalise", "SegmentoDeTranscricao", "StatusDaAnalise", "TranscricaoDaTimeline", "TranscricaoDoClipe"]
__all__ = ["AnaliseVisualDaTimeline", "AnalisadorDeTimeline", "ArquivoDescoberto",
"Cena", "CenaVisual", "ContextoDeAnalise", "criar_detector_apple_vision", "criar_detector_visual_local", "DescobertaDeArquivos", "DetectorDeCenas",
"ConfiguracaoVisualDoScanner",
"EvidenciaVisual", "ErroDeAnalise", "Evento", "ExtracaoDeMetadados",
"gerar_relatorio_visual",
"gerar_resumo_visual_markdown",
"LeitorLocalDeArquivos", "MetadadosDoArquivo", "ObservacaoVisual", "PalavraDeTranscricao",
"PipelineDoScanner", "ResultadoDaAnalise", "ResultadoVisualDoClipe",
"SegmentoDeTranscricao", "StatusDaAnalise", "TranscricaoDaTimeline",
"TranscricaoDoClipe"]
@@ -0,0 +1,60 @@
"""Perfil declarativo que liga a configuração do painel à leitura visual."""
from dataclasses import dataclass
from typing import Any
RECURSOS_VISION = frozenset((
"faces", "qualidade_facial", "pessoas", "pose", "maos", "ocr", "categorias",
"estetica", "codigos", "horizonte", "retangulos", "contornos", "segmentacao_de_pessoas",
))
@dataclass(frozen=True)
class ConfiguracaoVisualDoScanner:
"""Interface curta para uma execução de leitura visual da timeline."""
intervalo_em_segundos: float = 1.0
faixas_de_video: tuple[int, ...] = ()
faixas_de_audio: tuple[int, ...] = ()
recursos_vision: frozenset[str] = RECURSOS_VISION
detectar_cenas: bool = True
analisar_continuidade: bool = True
preparar_reenquadramento: bool = False
interpretar_cena: bool = True
def __post_init__(self) -> None:
if not 0.04 <= self.intervalo_em_segundos <= 60:
raise ValueError("intervalo_em_segundos deve estar entre 0,04 e 60.")
desconhecidos = self.recursos_vision - RECURSOS_VISION
if desconhecidos:
raise ValueError(f"Recursos Vision desconhecidos: {', '.join(sorted(desconhecidos))}")
if any(indice < 0 for indice in self.faixas_de_video + self.faixas_de_audio):
raise ValueError("Índices de faixa não podem ser negativos.")
@classmethod
def de_dict(cls, dados: dict[str, Any]) -> "ConfiguracaoVisualDoScanner":
"""Lê o mesmo JSON produzido pelo painel, sem vazar detalhes de adapters."""
return cls(
intervalo_em_segundos=float(dados.get("intervalo_em_segundos", 1)),
faixas_de_video=tuple(sorted(set(int(item) for item in dados.get("faixas_de_video", ())))),
faixas_de_audio=tuple(sorted(set(int(item) for item in dados.get("faixas_de_audio", ())))),
recursos_vision=frozenset(dados.get("recursos_vision", RECURSOS_VISION)),
detectar_cenas=bool(dados.get("detectar_cenas", True)),
analisar_continuidade=bool(dados.get("analisar_continuidade", True)),
preparar_reenquadramento=bool(dados.get("preparar_reenquadramento", False)),
interpretar_cena=bool(dados.get("interpretar_cena", True)),
)
def para_dict(self) -> dict[str, Any]:
return {
"versao": 1,
"intervalo_em_segundos": self.intervalo_em_segundos,
"faixas_de_video": list(self.faixas_de_video),
"faixas_de_audio": list(self.faixas_de_audio),
"recursos_vision": sorted(self.recursos_vision),
"detectar_cenas": self.detectar_cenas,
"analisar_continuidade": self.analisar_continuidade,
"preparar_reenquadramento": self.preparar_reenquadramento,
"interpretar_cena": self.interpretar_cena,
}
+5 -2
View File
@@ -14,7 +14,10 @@ class ContextoDeAnalise:
transcricoes: dict[str, list[Any]] = field(default_factory=dict)
caracteristicas_visuais: dict[str, dict[str, Any]] = field(default_factory=dict)
cenas: list[Any] = field(default_factory=list)
cenas_visuais: list[Any] = field(default_factory=list)
eventos: list[Any] = field(default_factory=list)
arquivos: dict[str, Any] = field(default_factory=dict)
metadados_dos_arquivos: dict[str, Any] = field(default_factory=dict)
class Analisador(Protocol):
@@ -42,5 +45,5 @@ class AnalisadorDeTimeline:
def __init__(self, pipeline: PipelineDoScanner) -> None:
self.pipeline = pipeline
def analisar(self) -> ContextoDeAnalise:
return self.pipeline.executar(ContextoDeAnalise())
def analisar(self, timeline: Timeline | None = None) -> ContextoDeAnalise:
return self.pipeline.executar(ContextoDeAnalise(timeline=timeline))
+2 -1
View File
@@ -1,3 +1,4 @@
from .descoberta_da_timeline import DescobertaDaTimeline
from .descoberta_de_arquivos import ArquivoDescoberto, DescobertaDeArquivos, LeitorLocalDeArquivos
__all__ = ["DescobertaDaTimeline"]
__all__ = ["ArquivoDescoberto", "DescobertaDaTimeline", "DescobertaDeArquivos", "LeitorLocalDeArquivos"]
@@ -0,0 +1,97 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
from ...dominio import Clipe
from ...scanner.modelos import ErroDeAnalise
from ..coordenacao import ContextoDeAnalise
@dataclass(frozen=True)
class ArquivoDescoberto:
"""Resultado da validação do arquivo associado a um clipe."""
caminho: Path
existe: bool
acessivel: bool
tipo_de_midia: str | None = None
tamanho_em_bytes: int | None = None
@property
def offline(self) -> bool:
return not self.existe or not self.acessivel
class LeitorDeArquivos(Protocol):
def descobrir(self, caminho: str | Path) -> ArquivoDescoberto: ...
class LeitorLocalDeArquivos:
"""Adapter que resolve e valida caminhos no sistema de arquivos local."""
def descobrir(self, caminho: str | Path) -> ArquivoDescoberto:
caminho_resolvido = Path(caminho).expanduser().resolve(strict=False)
existe = caminho_resolvido.is_file()
acessivel = existe
tamanho = None
if existe:
try:
tamanho = caminho_resolvido.stat().st_size
with caminho_resolvido.open("rb"):
pass
except (OSError, PermissionError):
acessivel = False
return ArquivoDescoberto(
caminho=caminho_resolvido,
existe=existe,
acessivel=acessivel,
tipo_de_midia=caminho_resolvido.suffix.lower().lstrip(".") or None,
tamanho_em_bytes=tamanho,
)
class DescobertaDeArquivos:
"""Relaciona os clipes da timeline aos arquivos físicos de origem."""
nome = "descoberta_de_arquivos"
def __init__(self, leitor: LeitorDeArquivos | None = None) -> None:
self.leitor = leitor or LeitorLocalDeArquivos()
def executar(self, contexto: ContextoDeAnalise) -> ContextoDeAnalise:
if contexto.timeline is None:
return contexto
vistos: dict[Path, str] = {}
for clipe in self._clipes(contexto):
if not clipe.arquivo:
clipe.offline = True
self._registrar_erro(contexto, "arquivo_ausente", "Clipe sem sourceFile.", clipe)
continue
try:
descoberto = self.leitor.descobrir(clipe.arquivo)
except (OSError, ValueError, TypeError) as exc:
clipe.offline = True
self._registrar_erro(contexto, "arquivo_inacessivel", str(exc), clipe)
continue
clipe.arquivo = str(descoberto.caminho)
clipe.offline = descoberto.offline
contexto.arquivos[clipe.identificador] = descoberto
if descoberto.offline:
self._registrar_erro(contexto, "arquivo_inacessivel", "Arquivo não encontrado ou sem permissão de leitura.", clipe)
elif descoberto.caminho in vistos:
contexto.avisos.append(
f"Arquivo duplicado entre os clipes {vistos[descoberto.caminho]} e {clipe.identificador}."
)
else:
vistos[descoberto.caminho] = clipe.identificador
return contexto
@staticmethod
def _clipes(contexto: ContextoDeAnalise) -> list[Clipe]:
return [clipe for faixa in contexto.timeline.faixas for clipe in faixa.clipes]
@staticmethod
def _registrar_erro(contexto: ContextoDeAnalise, codigo: str, mensagem: str, clipe: Clipe) -> None:
contexto.erros.append(str(ErroDeAnalise(codigo, mensagem, f"clipe[{clipe.identificador}].sourceFile")))
+40
View File
@@ -0,0 +1,40 @@
from typing import Protocol
from ..integracoes.midia.extracao_de_metadados import (
ExtracaoDeMetadados as ExtratorDeMetadados,
MetadadosDoArquivo,
)
from .coordenacao import ContextoDeAnalise
class ExtratorDeMetadadosProtocol(Protocol):
def extrair(self, arquivo: str) -> MetadadosDoArquivo: ...
class ExtracaoDeMetadados:
"""Enriquece cada clipe com os metadados técnicos do seu arquivo."""
nome = "extracao_de_metadados"
def __init__(self, extrator: ExtratorDeMetadadosProtocol | None = None) -> None:
self.extrator = extrator or ExtratorDeMetadados()
def executar(self, contexto: ContextoDeAnalise) -> ContextoDeAnalise:
for identificador, arquivo in contexto.arquivos.items():
if arquivo.offline:
continue
try:
metadados = self.extrator.extrair(str(arquivo.caminho))
contexto.metadados_dos_arquivos[identificador] = metadados
for faixa in contexto.timeline.faixas if contexto.timeline else []:
for clipe in faixa.clipes:
if clipe.identificador == identificador:
clipe.metadados["arquivo"] = metadados
break
except Exception as exc:
# Uma mídia inválida não deve descartar os resultados dos demais clipes.
contexto.erros.append(f"metadados_indisponiveis: {arquivo.caminho}: {exc}")
return contexto
__all__ = ["ExtracaoDeMetadados", "MetadadosDoArquivo"]
+55
View File
@@ -28,12 +28,32 @@ class ResultadoDaAnalise:
avisos: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class PalavraDeTranscricao:
texto: str
inicio: float
fim: float
confianca: float | None = None
falante: str | None = None
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim < self.inicio:
raise ValueError("Intervalo de palavra inválido.")
@dataclass(frozen=True)
class SegmentoDeTranscricao:
inicio: float
fim: float
texto: str
confianca: float | None = None
palavras: tuple[PalavraDeTranscricao, ...] = ()
emocao: str | None = None
confianca_emocao: float | None = None
caracteristicas_acusticas: dict[str, float] = field(default_factory=dict)
falante: str | None = None
voz_aparente: str | None = None
confianca_voz: float | None = None
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim < self.inicio:
@@ -54,3 +74,38 @@ class Evento:
inicio: float
fim: float
confianca: float | None = None
@dataclass(frozen=True)
class EvidenciaVisual:
"""Fato visual normalizado, independente da biblioteca que o produziu."""
tipo: str
inicio: float
fim: float
valor: dict[str, Any]
confianca: float | None = None
provider: str = ""
modelo: str | None = None
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim < self.inicio:
raise ValueError("Intervalo de evidência visual inválido.")
ObservacaoVisual = EvidenciaVisual
@dataclass(frozen=True)
class CenaVisual:
"""Intervalo visual enriquecido com as observações que o sustentam."""
identificador_do_clipe: str
inicio: float
fim: float
observacoes: tuple[EvidenciaVisual, ...] = ()
referencias_de_cena: tuple[float, ...] = ()
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim < self.inicio:
raise ValueError("Intervalo de cena visual inválido.")
+79
View File
@@ -0,0 +1,79 @@
"""Exportação estruturada de uma leitura visual de clipe."""
from collections import defaultdict
from dataclasses import asdict
from datetime import datetime, timezone
import json
from pathlib import Path
from ..dominio import Clipe
from .visual import ResultadoVisualDoClipe
VERSAO_DO_RELATORIO_VISUAL = "1.0"
def gerar_relatorio_visual(clipe: Clipe, resultado: ResultadoVisualDoClipe,
destino: str | Path, intervalo_de_amostragem: float) -> Path:
"""Grava um JSON autocontido, com fatos por frame e fatos temporais separados."""
if intervalo_de_amostragem <= 0:
raise ValueError("intervalo_de_amostragem deve ser positivo.")
caminho = Path(destino)
caminho.parent.mkdir(parents=True, exist_ok=True)
caminho.write_text(json.dumps(_dados_do_relatorio(clipe, resultado, intervalo_de_amostragem),
ensure_ascii=False, indent=2), encoding="utf-8")
return caminho
def gerar_resumo_visual_markdown(clipe: Clipe, resultado: ResultadoVisualDoClipe,
destino: str | Path, intervalo_de_amostragem: float) -> Path:
"""Grava uma visão humana do mesmo resultado exportado em JSON."""
caminho = Path(destino)
caminho.parent.mkdir(parents=True, exist_ok=True)
dados = _dados_do_relatorio(clipe, resultado, intervalo_de_amostragem)
linhas = [f"# Relatório visual — {clipe.nome}", "",
f"- Clipe: `{clipe.identificador}`",
f"- Origem: `{clipe.arquivo}`",
f"- Timeline: {clipe.intervalo_na_timeline.inicio:.3f}s–{clipe.intervalo_na_timeline.fim:.3f}s",
f"- Amostragem: a cada {intervalo_de_amostragem:g} segundo(s)", "",
"## Observações por frame", ""]
for frame in dados["observacoes_por_frame"]:
tipos = ", ".join(item["tipo"] for item in frame["evidencias"])
linhas.extend([f"### Origem {frame['timestamp_na_origem']:.3f}s", "", tipos or "Sem evidências.", ""])
linhas.extend(["## Continuidade", ""])
for evidencia in dados["evidencias_temporais"]:
linhas.append(f"- {evidencia['tipo']}: {evidencia['inicio']:.3f}s–{evidencia['fim']:.3f}s — "
f"`{json.dumps(evidencia['valor'], ensure_ascii=False)}`")
caminho.write_text("\n".join(linhas) + "\n", encoding="utf-8")
return caminho
def _dados_do_relatorio(clipe: Clipe, resultado: ResultadoVisualDoClipe,
intervalo_de_amostragem: float) -> dict:
por_frame = defaultdict(list)
temporais = []
for evidencia in resultado.evidencias:
item = asdict(evidencia)
if evidencia.inicio == evidencia.fim:
por_frame[evidencia.inicio].append(item)
else:
temporais.append(item)
origem = clipe.intervalo_na_origem
return {
"versao": VERSAO_DO_RELATORIO_VISUAL,
"gerado_em": datetime.now(timezone.utc).isoformat(),
"clipe": {
"identificador": clipe.identificador,
"nome": clipe.nome,
"arquivo_original": clipe.arquivo,
"intervalo_na_timeline": asdict(clipe.intervalo_na_timeline),
"intervalo_na_origem": asdict(origem) if origem else None,
},
"amostragem": {"intervalo_em_segundos": intervalo_de_amostragem},
"observacoes_por_frame": [
{"timestamp_na_origem": timestamp, "evidencias": evidencias}
for timestamp, evidencias in sorted(por_frame.items())
],
"evidencias_temporais": temporais,
"cenas": [asdict(cena) for cena in resultado.cenas_visuais],
}
@@ -0,0 +1,116 @@
"""Converte a transcrição existente em falas ordenadas para a análise.
O módulo de retakes não realiza transcrição. Ele recebe a saída do
``TranscricaoDaTimeline`` (lista de ``TranscricaoDoClipe``) e a converte
em ``Fala``s ordenadas ao longo da timeline, preservando o vínculo com o
segmento/clipe de origem e as palavras alinhadas quando disponíveis.
"""
from __future__ import annotations
import re
from typing import Iterable
from ..transcricao_da_timeline import TranscricaoDoClipe
from .modelos_de_retakes import Fala
# Sinais de erro mais comuns vindos dos providers de transcrição.
_PALAVRAS_DE_ERRO = {"", "...", "…"}
_VOYELS = "aeiouáéíóúâêôãõàèìòù"
_CONSOANTES = "bcdfghjklmnpqrstvwxyz"
_PADRAO_SILABA = re.compile(
rf"([{_VOYELS}][^aeiouáéíóúâêôãõàèìòù]*)|([{_CONSOANTES}]+[aeiouáéíóúâêôãõàèìòù]?)",
re.IGNORECASE,
)
def _normalizar(texto: str) -> str:
"""Minúsculas, sem pontuação e sem espaços duplicados."""
sem_pontuacao = re.sub(r"[^\w\s]", " ", texto)
return " ".join(sem_pontuacao.lower().split())
def _prefixo_comum(a: list[str], b: list[str]) -> int:
n = 0
for x, y in zip(a, b):
if x != y:
break
n += 1
return n
def _sucesso_de_silabas(a: list[str], b: list[str]) -> float:
if not a or not b:
return 0.0
silabas_a = [_PADRAO_SILABA.findall(p)[0][0] for p in a]
silabas_b = [_PADRAO_SILABA.findall(p)[0][0] for p in b]
n = _prefixo_comum(silabas_a, silabas_b)
return n / max(len(silabas_a), len(silabas_b))
def _e_ruido(texto: str) -> bool:
texto_limpo = _normalizar(texto)
if texto_limpo in _PALAVRAS_DE_ERRO:
return True
# Fala em branco ou "vazia" por provedor.
return not texto_limpo
class AdaptadorDeFalas:
"""Transforma a transcrição da timeline em falas prontas para análise.
Recebe uma coleção de ``TranscricaoDoClipe`` (uma por faixa de áudio do
vídeo) e devolve as falas ordenadas por tempo. O ``video_id`` é um rótulo
informado pelo chamador; quando ausente, usa o identificador da timeline.
"""
def __init__(self, video_id: str | None = None, faixa_id: str | None = None) -> None:
self.video_id = video_id
self.faixa_id = faixa_id
def converter(
self,
transcricoes: Iterable[TranscricaoDoClipe],
video_id: str | None = None,
faixa_id: str | None = None,
) -> list[Fala]:
video_id = video_id or self.video_id or "video"
faixa_id = faixa_id or self.faixa_id or "faixa"
falas: list[Fala] = []
for transcricao in transcricoes:
segmento_id = transcricao.identificador_do_clipe
for segmento in transcricao.segmentos:
if _e_ruido(segmento.texto):
continue
falas.append(Fala(
id=f"{segmento_id}:{segmento.inicio:.3f}",
video_id=video_id,
faixa_id=faixa_id,
segmento_id=segmento_id,
ordem=-1, # preenchida após a ordenação
inicio=segmento.inicio,
fim=segmento.fim,
texto=segmento.texto,
texto_normalizado=_normalizar(segmento.texto),
palavras=segmento.palavras,
falante=segmento.falante,
))
falas.sort(key=lambda item: (item.inicio, item.fim))
for indice, fala in enumerate(falas):
falas[indice] = Fala(
id=fala.id,
video_id=fala.video_id,
faixa_id=fala.faixa_id,
segmento_id=fala.segmento_id,
ordem=indice,
inicio=fala.inicio,
fim=fala.fim,
texto=fala.texto,
texto_normalizado=fala.texto_normalizado,
palavras=fala.palavras,
falante=fala.falante,
)
return falas
@@ -0,0 +1,148 @@
"""Agrupamento de falas que representam retakes da mesma fala ou ideia.
Trabalha com uma janela temporal: compara cada fala com as ``n`` seguintes
(e só as da mesma faixa de áudio), evitando agrupar frases iguais que
aparecem em partes muito distantes do vídeo. Não decide a classificação —
apenas forma grupos candidatos e reúne as métricas.
"""
from __future__ import annotations
from typing import Iterable
from .analisador_de_intervalos import AnalisadorDeIntervalos
from .comparador_de_falas import ComparadorDeFalas
from .comparador_semantico import ComparadorSemantico
from .detector_de_reinicios import DetectorDeReinicios
from .modelos_de_retakes import (
AgrupamentoDeFalas,
Fala,
ParAgrupado,
ResultadoDoIntervalo,
SinalDeReinicio,
)
# Limiares de candidatura de um par a pertencer a um grupo de retake.
_SIMILARIDADE_MINIMA_CANDIDATO = 0.55
_SIMILARIDADE_FORTE = 0.75
_PROXIMIDADE_AUXILIAR = 0.60
# Janela padrão de falas a serem comparadas com cada uma.
_JANELA_PADRAO = 5
# Região de dúvida que dispara a comparação semântica.
_ZONA_DE_DUVIDA_INFERIOR = 0.60
_ZONA_DE_DUVIDA_SUPERIOR = 0.85
class AgrupadorDeTakes:
"""Gera grupos candidatos conectando falas semelhantes e próximas."""
def __init__(
self,
comparador: ComparadorDeFalas,
comparador_semantico: ComparadorSemantico,
analisador_de_intervalos: AnalisadorDeIntervalos,
detector_de_reinicios: DetectorDeReinicios,
janela: int = _JANELA_PADRAO,
) -> None:
self.comparador = comparador
self.comparador_semantico = comparador_semantico
self.analisador_de_intervalos = analisador_de_intervalos
self.detector_de_reinicios = detector_de_reinicios
self.janela = janela
def agrupar(self, falas: Iterable[Fala]) -> list[AgrupamentoDeFalas]:
falas = sorted(falas, key=lambda item: (item.ordem, item.inicio))
sinais = {sinal.fala_id: sinal for sinal in self.detector_de_reinicios.detectar(falas)}
arestas: list[tuple[int, int, ParAgrupado]] = []
for indice, fala_a in enumerate(falas):
limite = min(len(falas), indice + 1 + self.janela)
for jindice in range(indice + 1, limite):
fala_b = falas[jindice]
if fala_a.faixa_id != fala_b.faixa_id:
continue
par = self._avaliar_par(fala_a, fala_b, sinais)
if par is not None:
arestas.append((indice, jindice, par))
return self._agrupar_por_arestas(falas, arestas, sinais)
def _avaliar_par(
self,
fala_a: Fala,
fala_b: Fala,
sinais: dict[str, SinalDeReinicio],
) -> ParAgrupado | None:
comparacao = self.comparador.comparar(fala_a, fala_b)
intervalo = self.analisador_de_intervalos.analisar(fala_a, fala_b)
tem_reinicio = sinais.get(fala_b.id) is not None
semantica = 0.0
# Comparação semântica só na zona de dúvida, para evitar custo.
texto = comparacao.similaridade_final
if _ZONA_DE_DUVIDA_INFERIOR <= texto <= _ZONA_DE_DUVIDA_SUPERIOR:
semantica = self.comparador_semantico.comparar(fala_a, fala_b)
melhor = max(comparacao.similaridade_final, semantica)
candidato = (
melhor >= _SIMILARIDADE_FORTE
or (melhor >= _SIMILARIDADE_MINIMA_CANDIDATO
and intervalo.proximidade_temporal >= _PROXIMIDADE_AUXILIAR
and (tem_reinicio or intervalo.indicio_de_nova_tentativa))
)
if candidato:
return ParAgrupado(fala_a, fala_b, comparacao, intervalo,
round(semantica, 4))
return None
@staticmethod
def _agrupar_por_arestas(
falas: list[Fala],
arestas: list[tuple[int, int, ParAgrupado]],
sinais: dict[str, SinalDeReinicio],
) -> list[AgrupamentoDeFalas]:
pai = list(range(len(falas)))
def encontrar(x: int) -> int:
while pai[x] != x:
pai[x] = pai[pai[x]]
x = pai[x]
return x
arestas.sort(key=lambda item: (item[0], item[1]))
for a, b, _ in arestas:
raiz_a, raiz_b = encontrar(a), encontrar(b)
if raiz_a != raiz_b:
pai[raiz_b] = raiz_a
componentes: dict[int, list[int]] = {}
for indice in range(len(falas)):
componentes.setdefault(encontrar(indice), []).append(indice)
grupos: list[AgrupamentoDeFalas] = []
for inds in componentes.values():
if len(inds) < 2:
continue
inds.sort()
fala_ids = tuple(falas[indice].id for indice in inds)
pares_por_chave: dict[tuple[str, str], ParAgrupado] = {
(par.fala_a.id, par.fala_b.id): par
for _, _, par in arestas
if par.fala_a.id in fala_ids and par.fala_b.id in fala_ids
}
pares: list[ParAgrupado] = []
for a, b in zip(inds, inds[1:]):
chave = (falas[a].id, falas[b].id)
par = pares_por_chave.get(chave)
if par is None:
par = pares_por_chave.get((falas[b].id, falas[a].id))
if par is not None:
pares.append(par)
sinais_do_grupo = tuple(
sinais[fala_id] for fala_id in fala_ids if fala_id in sinais)
if pares:
grupos.append(AgrupamentoDeFalas(fala_ids, tuple(pares), sinais_do_grupo))
return grupos
@@ -0,0 +1,41 @@
"""Análise da relação temporal entre falas.
Verifica quanto tempo há entre duas falas, se estão na mesma faixa/segmento
e se a proximidade sugere uma nova tentativa. Um intervalo muito grande
reduz a confiança de que seja um retake imediato.
"""
from __future__ import annotations
from .modelos_de_retakes import Fala, ResultadoDoIntervalo
# A partir de quantos segundos o intervalo passa a não sugerir retake.
_INTERVALO_MAXIMO = 12.0
class AnalisadorDeIntervalos:
"""Calcula a proximidade temporal e o indício de nova tentativa."""
def __init__(self, intervalo_maximo: float = _INTERVALO_MAXIMO) -> None:
self.intervalo_maximo = intervalo_maximo
def analisar(self, fala_a: Fala, fala_b: Fala) -> ResultadoDoIntervalo:
intervalo = max(0.0, fala_b.inicio - fala_a.fim)
mesma_faixa = fala_a.faixa_id == fala_b.faixa_id
mesmo_segmento = mesma_faixa and fala_a.segmento_id == fala_b.segmento_id
if intervalo >= self.intervalo_maximo:
proximidade = 0.0
elif intervalo <= 0:
proximidade = 1.0
else:
# Decai suavemente com o intervalo: 1.0 → ~0 em 12s.
proximidade = max(0.0, 1.0 - intervalo / self.intervalo_maximo)
indicio = mesma_faixa and intervalo <= self.intervalo_maximo and proximidade >= 0.5
return ResultadoDoIntervalo(
intervalo_em_segundos=round(intervalo, 3),
mesmo_segmento=mesmo_segmento,
proximidade_temporal=round(proximidade, 4),
indicio_de_nova_tentativa=bool(indicio),
)
@@ -0,0 +1,112 @@
"""Comparação textual entre falas.
Responsável pelos algoritmos de similaridade: Jaccard (conjunto de
palavras), sequência (ordem das palavras via maior subsequência comum) e
similaridade do início/fim da frase. Não decide nada sozinho — apenas
produz métricas para o classificador.
"""
from __future__ import annotations
import re
from .modelos_de_retakes import Fala, ResultadoDaComparacao
def _normalizar(texto: str) -> str:
"""Minúsculas, sem pontuação e sem espaços duplicados."""
sem_pontuacao = re.sub(r"[^\w\s]", " ", texto)
return " ".join(sem_pontuacao.lower().split())
def _lcs(a: list[str], b: list[str]) -> int:
"""Tamanho da maior subsequência comum preservando a ordem."""
anterior = [0] * (len(b) + 1)
for palavra_a in a:
atual = [0] * (len(b) + 1)
for j, palavra_b in enumerate(b):
if palavra_a == palavra_b:
atual[j + 1] = anterior[j] + 1
else:
atual[j + 1] = max(atual[j], anterior[j + 1])
anterior = atual
return anterior[-1]
class ComparadorDeFalas:
"""Compara duas falas e calcula as métricas de similaridade textual."""
@staticmethod
def normalizar(texto: str) -> str:
return _normalizar(texto)
@staticmethod
def _palavras_de(fala: Fala) -> list[str]:
return fala.texto_normalizado.split() or _normalizar(fala.texto).split()
def comparar(self, fala_a: Fala, fala_b: Fala) -> ResultadoDaComparacao:
palavras_a = self._palavras_de(fala_a)
palavras_b = self._palavras_de(fala_b)
if not palavras_a or not palavras_b:
return ResultadoDaComparacao(fala_a.id, fala_b.id)
# 1. Jaccard — conjunto de palavras (rápido, ótimo candidato).
conjunto_a = set(palavras_a)
conjunto_b = set(palavras_b)
intersecao = len(conjunto_a & conjunto_b)
uniao = len(conjunto_a | conjunto_b)
jaccard = intersecao / uniao if uniao else 0.0
# 2. Sequência — ordem das palavras via LCS normalizada.
lcs = _lcs(palavras_a, palavras_b)
sequencia = lcs / max(len(palavras_a), len(palavras_b))
# 3. Início e final da frase.
inicio = self._similaridade_de_prefijo(palavras_a, palavras_b)
final = self._similaridade_de_sufixo(palavras_a, palavras_b)
# 4. Similaridade final ponderada.
fim = 0.45 * sequencia + 0.30 * jaccard + 0.15 * inicio + 0.10 * final
return ResultadoDaComparacao(
fala_a_id=fala_a.id,
fala_b_id=fala_b.id,
similaridade_jaccard=round(jaccard, 4),
similaridade_de_sequencia=round(sequencia, 4),
similaridade_do_inicio=round(inicio, 4),
similaridade_do_final=round(final, 4),
similaridade_final=round(min(1.0, fim), 4),
)
@staticmethod
def _similaridade_de_prefijo(a: list[str], b: list[str]) -> float:
if not a or not b:
return 0.0
n = 0
for x, y in zip(a, b):
if x != y:
break
n += 1
return n / max(len(a), len(b))
@staticmethod
def _similaridade_de_sufixo(a: list[str], b: list[str]) -> float:
if not a or not b:
return 0.0
n = 0
for x, y in zip(reversed(a), reversed(b)):
if x != y:
break
n += 1
return n / max(len(a), len(b))
@staticmethod
def prefixo_comum_em_palavras(texto_a: str, texto_b: str) -> int:
a = _normalizar(texto_a).split()
b = _normalizar(texto_b).split()
n = 0
for x, y in zip(a, b):
if x != y:
break
n += 1
return n
@@ -0,0 +1,113 @@
"""Comparação semântica entre falas (opcional).
A similaridade textual não cobre casos em que as palavras são diferentes
mas a ideia é a mesma. Este módulo define um protocolo para providers de
embeddings e uma implementação local (via Hugging Face Transformers), além
de um fallback puramente lexical usado quando nenhum provider está
disponível.
O `ComparadorSemantico` nunca decide sozinho que há retake — apenas
fornece uma métrica adicional para o classificador.
"""
from __future__ import annotations
import math
from typing import Protocol
from .modelos_de_retakes import Fala
class ProviderDeSimilaridadeSemantica(Protocol):
"""Contrato para quem calcula similaridade semântica entre dois textos."""
def similaridade(self, texto_a: str, texto_b: str) -> float: ...
class ComparadorLexicalSemantico:
"""Fallback puramente lexical para quando não há embeddings.
Reconstrói uma "similaridade semântica" a partir de palavras que
compartilham a mesma raiz (stemming simples por prefixo comum) e de
sinônimos frequentes em pt-PT. Destina-se a permitir executar a análise
sem carregar modelos pesados.
"""
_PARES_SINONIMOS = (
({"mostrar", "explicar", "demonstrar", "apresentar"},),
({"sistema", "programa", "aplicativo", "software"},),
({"configurar", "configuracao", "instalar", "ajustar"},),
)
def similaridade(self, texto_a: str, texto_b: str) -> float:
palavras_a = {p for p in self._palavras(texto_a)}
palavras_b = {p for p in self._palavras(texto_b)}
if not palavras_a or not palavras_b:
return 0.0
intersecao = 0.0
for palavra_a in palavras_a:
for palavra_b in palavras_b:
if palavra_a == palavra_b:
intersecao += 1.0
elif self._mesma_raiz(palavra_a, palavra_b):
intersecao += 0.7
elif self._mesmo_sinonimo(palavra_a, palavra_b):
intersecao += 0.6
tam = max(len(palavras_a), len(palavras_b))
return round(min(1.0, intersecao / tam), 4)
@staticmethod
def _palavras(texto: str) -> list[str]:
return [p for p in texto.lower().split()]
@staticmethod
def _mesma_raiz(a: str, b: str) -> bool:
raiz = min(len(a), len(b), 4)
return raiz >= 4 and a[:raiz] == b[:raiz]
@staticmethod
def _mesmo_sinonimo(a: str, b: str) -> bool:
return any(a in grupo and b in grupo for grupo in ComparadorLexicalSemantico._PARES_SINONIMOS)
class ComparadorSemantico:
"""Calcula similaridade semântica usando um provider injetável.
Quando o provider é ``None``, cai no ``ComparadorLexicalSemantico``
para não bloquear a análise na ausência de modelos locais.
"""
def __init__(self, provider: ProviderDeSimilaridadeSemantica | None = None) -> None:
self.provider = provider or ComparadorLexicalSemantico()
def comparar(self, fala_a: Fala, fala_b: Fala) -> float:
return float(self.provider.similaridade(fala_a.texto, fala_b.texto))
class ProviderDeSimilaridadeHuggingFace:
"""Embeds os textos localmente com um modelo de embeddings.
O modelo é carregado de forma preguiçosa para não custar nada até ser
de fato necessário (zona de dúvida do classificador).
"""
def __init__(self, modelo: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2") -> None:
self.modelo = modelo
self._encoder = None
def similaridade(self, texto_a: str, texto_b: str) -> float:
if self._encoder is None:
from sentence_transformers import SentenceTransformer
self._encoder = SentenceTransformer(self.modelo)
embeddings = self._encoder.encode([texto_a, texto_b], normalize_embeddings=True)
return float(_cosseno(embeddings[0], embeddings[1]))
def _cosseno(a: list[float], b: list[float]) -> float:
produto = sum(x * y for x, y in zip(a, b))
norma_a = math.sqrt(sum(x * x for x in a))
norma_b = math.sqrt(sum(y * y for y in b))
if norma_a == 0 or norma_b == 0:
return 0.0
return max(0.0, min(1.0, produto / (norma_a * norma_b)))
@@ -0,0 +1,86 @@
"""Detecção de sinais de reinício/recomeço de fala.
Analisa padrões de que a pessoa começou novamente uma ideia: repetição do
início de uma frase, pausas longas, marcadores explícitos ("não", "pera",
"vamos de novo", "desculpa") e repetição de palavras consecutivas.
"""
from __future__ import annotations
import re
from typing import Iterable
from .comparador_de_falas import ComparadorDeFalas
from .modelos_de_retakes import Fala, SinalDeReinicio
_MARCADORES = (
"não", "pera", "peraí", "pera ai", "espera", "vamos de novo", "de novo",
"vamos recomeçar", "recomeçar", "desculpa", "desculpe", "deixa eu ver",
"vou repetir", "esquece", "hmm", "hm", "uhm", "tá", "ta",
)
_RE_MARCADOR = re.compile(
r"\b(?:" + "|".join(re.escape(m) for m in _MARCADORES) + r")\b",
re.IGNORECASE,
)
_PAUSA_LONGA = 1.2 # segundos a partir dos quais a pausa sugere recomeço.
class DetectorDeReinicios:
"""Encontra ``SinalDeReinicio`` em cada fala em relação à anterior."""
def __init__(
self,
comparador: ComparadorDeFalas | None = None,
pausa_longa: float = _PAUSA_LONGA,
) -> None:
self.comparador = comparador or ComparadorDeFalas()
self.pausa_longa = pausa_longa
def detectar(self, falas: Iterable[Fala]) -> list[SinalDeReinicio]:
falas = list(falas)
sinais: list[SinalDeReinicio] = []
for indice, fala in enumerate(falas):
sinal = self._analisar(fala, falas[indice - 1] if indice > 0 else None)
if sinal:
sinais.append(sinal)
return sinais
def _analisar(self, fala: Fala, anterior: Fala | None) -> SinalDeReinicio | None:
evidencias: list[str] = []
intensidades: list[float] = []
palavras = fala.texto_normalizado.split()
# 1. Marcadores explícitos de recomeço no início.
if palavras and _RE_MARCADOR.match(palavras[0]):
evidencias.append("marcador explícito de recomeço")
intensidades.append(0.9)
if anterior is None:
if intensidades:
return SinalDeReinicio(fala.id, "reinicio_explicito",
round(max(intensidades), 3), evidencias)
return None
# 2. Pausa longa antes da fala.
pausa = fala.inicio - anterior.fim
if pausa >= self.pausa_longa:
evidencias.append(f"pausa de {pausa:.1f} segundos")
intensidades.append(min(1.0, 0.5 + pausa / 8.0))
# 3. Repetição do início da frase anterior.
prefixo = self.comparador.prefixo_comum_em_palavras(anterior.texto, fala.texto)
if prefixo >= 3:
evidencias.append(f"repetição das primeiras {prefixo} palavras")
intensidades.append(min(1.0, 0.4 + prefixo * 0.08))
if not evidencias:
return None
return SinalDeReinicio(
fala_id=fala.id,
tipo="repeticao_do_inicio" if any("repetição" in e for e in evidencias) else "reinicio_pos_pausa",
intensidade=round(min(1.0, max(intensidades)), 3),
evidencias=evidencias,
)
@@ -0,0 +1,154 @@
"""Modelos de dados do submódulo de detecção de retakes.
Mantemos os mesmos princípios de ``scanner/modelos.py``: dataclasses
imutáveis (``frozen=True``) com validação em ``__post_init__`` e sem
dependência de implementações concretas de providers.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from ..modelos import PalavraDeTranscricao
@dataclass(frozen=True)
class Fala:
"""Uma fala normalizada, já posicionada na timeline do vídeo.
Corresponde a um ``SegmentoDeTranscricao`` convertido pelo adaptador,
mas carrega o contexto necessário para a análise temporal e de
agrupamento: vídeo, faixa de áudio, número de sequência e o vínculo
com o segmento/clipe de origem.
"""
id: str
video_id: str
faixa_id: str
segmento_id: str
ordem: int
inicio: float
fim: float
texto: str
texto_normalizado: str = ""
palavras: tuple[PalavraDeTranscricao, ...] = ()
falante: str | None = None
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim < self.inicio:
raise ValueError(f"Intervalo da fala {self.id} inválido.")
@dataclass(frozen=True)
class SinalDeReinicio:
"""Indício de que uma fala recomeçou uma ideia já iniciada."""
fala_id: str
tipo: str
intensidade: float
evidencias: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
if not 0.0 <= self.intensidade <= 1.0:
raise ValueError(f"Intensidade do reinício {self.fala_id} fora de [0, 1].")
@dataclass(frozen=True)
class ResultadoDaComparacao:
"""Resultado da comparação de duas falas, textual e por sequência."""
fala_a_id: str
fala_b_id: str
similaridade_jaccard: float = 0.0
similaridade_de_sequencia: float = 0.0
similaridade_do_inicio: float = 0.0
similaridade_do_final: float = 0.0
similaridade_final: float = 0.0
@dataclass(frozen=True)
class ResultadoDoIntervalo:
"""Relação temporal entre duas falas."""
intervalo_em_segundos: float
mesmo_segmento: bool = False
proximidade_temporal: float = 0.0
indicio_de_nova_tentativa: bool = False
@dataclass(frozen=True)
class EvidenciaDeRetake:
"""Uma evidência legível que sustenta a classificação de um grupo."""
tipo: str
descricao: str
valor: float | None = None
@dataclass(frozen=True)
class TomadaDeRetake:
"""Uma tomada (fala) pertencente a um grupo de retakes."""
ordem: int
fala_id: str
segmento_id: str
inicio: float
fim: float
texto: str
similaridade_com_anterior: float = 0.0
confianca: float = 0.0
def gerar_id_do_grupo() -> str:
"""Gera um id curto e monótono para um grupo de retakes."""
return f"retake_{uuid.uuid4().hex[:6]}"
@dataclass(frozen=True)
class ParAgrupado:
"""Um par de falas que o agrupador juntou como candidato a retake."""
fala_a: Fala
fala_b: Fala
comparacao: ResultadoDaComparacao
intervalo: ResultadoDoIntervalo
similaridade_semantica: float = 0.0
@dataclass(frozen=True)
class AgrupamentoDeFalas:
"""Um grupo candidato formado pelo agrupador, antes da classificação."""
fala_ids: tuple[str, ...]
pares: tuple[ParAgrupado, ...]
sinais_de_reinicio: tuple[SinalDeReinicio, ...] = ()
@dataclass(frozen=True)
class GrupoDeRetake:
"""Agrupa as tomadas que representam tentativas da mesma fala."""
id: str
video_id: str
faixa_id: str
tipo: str
confianca: float
tomadas: list[TomadaDeRetake] = field(default_factory=list)
evidencias: list[EvidenciaDeRetake] = field(default_factory=list)
criado_em: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def __post_init__(self) -> None:
if not 0.0 <= self.confianca <= 1.0:
raise ValueError(f"Confiança do grupo {self.id} fora de [0, 1].")
@property
def tomada_principal_id(self) -> str | None:
if not self.tomadas:
return None
return max(self.tomadas, key=lambda item: item.confianca).fala_id
@property
def fala_ids(self) -> list[str]:
return [tomada.fala_id for tomada in self.tomadas]
+152 -21
View File
@@ -1,10 +1,11 @@
from dataclasses import asdict, dataclass
import hashlib
import json
from pathlib import Path
from typing import Any, Callable
from ..integracoes.midia import ExtracaoDeAudio
from .modelos import SegmentoDeTranscricao
from .modelos import PalavraDeTranscricao, SegmentoDeTranscricao
@dataclass(frozen=True)
@@ -14,6 +15,7 @@ class TranscricaoDoClipe:
inicio_na_timeline: float
fim_na_timeline: float
segmentos: list[SegmentoDeTranscricao]
intervalo_na_origem: tuple[float, float] | None = None
@property
def texto(self) -> str:
@@ -21,39 +23,168 @@ class TranscricaoDoClipe:
class TranscricaoDaTimeline:
"""Transcreve somente os intervalos efetivamente usados na timeline."""
"""Transcreve cada arquivo uma vez e projeta o resultado nos cortes.
A primeira versão suporta apenas mapeamento linear em velocidade normal.
Os timestamps retornados pelo provider são sempre relativos ao arquivo
original; somente a cópia materializada para cada clipe recebe timestamps
da timeline.
"""
def __init__(self, provider: Any, extrator: ExtracaoDeAudio | None = None,
diretoria_de_trabalho: str | Path = ".transcricao") -> None:
diretoria_de_trabalho: str | Path = ".transcricao",
analisador_de_emocao: Any | None = None,
diarizador: Any | None = None) -> None:
self.provider = provider
self.extrator = extrator or ExtracaoDeAudio()
self.diretoria_de_trabalho = Path(diretoria_de_trabalho)
self.analisador_de_emocao = analisador_de_emocao
self.diarizador = diarizador
def executar(self, timeline: Any) -> list[TranscricaoDoClipe]:
resultados: list[TranscricaoDoClipe] = []
clipes: list[Any] = []
for faixa in timeline.faixas:
for clipe in faixa.clipes:
if not clipe.arquivo or clipe.offline:
continue
intervalo = clipe.intervalo_na_timeline
pasta = self.diretoria_de_trabalho / clipe.identificador
origem = clipe.intervalo_na_origem
partes = self.extrator.extrair_intervalo(
clipe.arquivo, pasta, origem.inicio if origem else 0.0,
origem.fim if origem else None,
)
segmentos: list[SegmentoDeTranscricao] = []
for parte in partes:
for segmento in self.provider.transcrever(type("Audio", (), {"arquivo": str(parte.caminho)})()):
base_origem = origem.inicio if origem else 0.0
inicio = max(intervalo.inicio, intervalo.inicio + (parte.inicio - base_origem) + segmento.inicio)
fim = min(intervalo.fim, intervalo.inicio + (parte.inicio - base_origem) + segmento.fim)
if inicio < intervalo.fim and fim > inicio:
segmentos.append(SegmentoDeTranscricao(inicio, fim, segmento.texto, segmento.confianca))
resultados.append(TranscricaoDoClipe(clipe.identificador, clipe.arquivo,
intervalo.inicio, intervalo.fim, segmentos))
clipes.append(clipe)
transcricoes: dict[str, list[SegmentoDeTranscricao]] = {}
for clipe in clipes:
chave = self._chave_do_arquivo(clipe.arquivo)
if chave not in transcricoes:
transcricoes[chave] = self._transcrever_arquivo(clipe.arquivo, chave)
resultados: list[TranscricaoDoClipe] = []
vistos: set[tuple[str, float, float, float, float]] = set()
for clipe in clipes:
intervalo = clipe.intervalo_na_timeline
origem = clipe.intervalo_na_origem
inicio_origem = origem.inicio if origem else 0.0
fim_origem = origem.fim if origem else float("inf")
chave = (self._chave_do_arquivo(clipe.arquivo), inicio_origem, fim_origem,
intervalo.inicio, intervalo.fim)
# Vídeo e áudio vinculados podem representar o mesmo corte.
if chave in vistos:
continue
vistos.add(chave)
segmentos = []
for segmento in transcricoes[chave[0]]:
fim = min(segmento.fim, fim_origem)
inicio = max(segmento.inicio, inicio_origem)
if inicio < fim:
palavras = tuple(
PalavraDeTranscricao(
palavra.texto,
intervalo.inicio + max(palavra.inicio, inicio_origem) - inicio_origem,
intervalo.inicio + min(palavra.fim, fim_origem) - inicio_origem,
palavra.confianca,
palavra.falante,
)
for palavra in segmento.palavras
if palavra.inicio < fim_origem and palavra.fim > inicio_origem
)
segmentos.append(SegmentoDeTranscricao(
intervalo.inicio + inicio - inicio_origem,
intervalo.inicio + fim - inicio_origem,
segmento.texto, segmento.confianca, palavras,
segmento.emocao, segmento.confianca_emocao,
segmento.caracteristicas_acusticas, segmento.falante,
segmento.voz_aparente, segmento.confianca_voz))
resultados.append(TranscricaoDoClipe(clipe.identificador, clipe.arquivo,
intervalo.inicio, intervalo.fim, segmentos,
(inicio_origem, fim_origem) if origem else None))
return resultados
def _chave_do_arquivo(self, arquivo: str) -> str:
caminho = Path(arquivo).expanduser().resolve()
digest = hashlib.sha256()
with caminho.open("rb") as conteudo:
for bloco in iter(lambda: conteudo.read(1024 * 1024), b""):
digest.update(bloco)
return digest.hexdigest()
def _transcrever_arquivo(self, arquivo: str, chave: str) -> list[SegmentoDeTranscricao]:
pasta = self.diretoria_de_trabalho / "arquivos" / chave
nome_arquivo = Path(arquivo).stem
modelo = self._nome_do_modelo()
prefixo = f"transcricao-{self._nome_seguro(nome_arquivo)}-{self._nome_seguro(modelo)}"
cache = pasta / f"{prefixo}.json"
if cache.is_file():
dados = json.loads(cache.read_text(encoding="utf-8"))
if all("palavras" in item and
(self.analisador_de_emocao is None or
("emocao" in item and "voz_aparente" in item))
for item in dados):
return [self._segmento_do_json(item) for item in dados]
partes = self.extrator.extrair(arquivo, pasta / "audio")
segmentos: list[SegmentoDeTranscricao] = []
for parte in partes:
falas = self._diarizar(parte.caminho)
for segmento in self.provider.transcrever(type("Audio", (), {"arquivo": str(parte.caminho)})()):
analise = self._analisar_emocao(parte.caminho, segmento.inicio, segmento.fim)
falante = self._falante_em(falas, segmento.inicio, segmento.fim)
segmentos.append(SegmentoDeTranscricao(parte.inicio + segmento.inicio,
parte.inicio + segmento.fim, segmento.texto, segmento.confianca,
tuple(PalavraDeTranscricao(p.texto, parte.inicio + p.inicio,
parte.inicio + p.fim, p.confianca,
self._falante_em(falas, p.inicio, p.fim))
for p in segmento.palavras),
analise.get("emocao"), analise.get("confianca"),
dict(analise.get("caracteristicas_acusticas", {})), falante,
analise.get("voz_aparente"), analise.get("confianca_voz")))
pasta.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps([asdict(item) for item in segmentos], ensure_ascii=False, indent=2), encoding="utf-8")
(pasta / f"{prefixo}.txt").write_text(
" ".join(item.texto for item in segmentos if item.texto).strip() + "\n",
encoding="utf-8",
)
return segmentos
@staticmethod
def _segmento_do_json(item: dict[str, Any]) -> SegmentoDeTranscricao:
palavras = tuple(PalavraDeTranscricao(str(p["texto"]), float(p["inicio"]),
float(p["fim"]), p.get("confianca"),
p.get("falante"))
for p in item.get("palavras", []))
return SegmentoDeTranscricao(float(item["inicio"]), float(item["fim"]),
str(item["texto"]), item.get("confianca"), palavras,
item.get("emocao"), item.get("confianca_emocao"),
dict(item.get("caracteristicas_acusticas", {})), item.get("falante"),
item.get("voz_aparente"), item.get("confianca_voz"))
def _analisar_emocao(self, arquivo: Path, inicio: float, fim: float) -> dict[str, Any]:
if self.analisador_de_emocao is None:
return {}
return dict(self.analisador_de_emocao.analisar(arquivo, inicio, fim))
def _diarizar(self, arquivo: Path) -> list[tuple[float, float, str]]:
if self.diarizador is None:
return []
return list(self.diarizador.analisar(arquivo))
@staticmethod
def _falante_em(falas: list[tuple[float, float, str]], inicio: float, fim: float) -> str | None:
sobreposicoes = [(min(fim, saida) - max(inicio, entrada), falante)
for entrada, saida, falante in falas
if entrada < fim and saida > inicio]
return max(sobreposicoes, default=(0.0, None))[1]
def _nome_do_modelo(self) -> str:
"""Obtém o nome público do modelo sem acoplar o scanner ao provider."""
modelo = getattr(self.provider, "nome_do_modelo", None)
if modelo:
return str(modelo)
modelo = getattr(self.provider, "modelo", None)
if modelo:
return Path(str(modelo)).name
return self.provider.__class__.__name__.lower()
@staticmethod
def _nome_seguro(valor: str) -> str:
return "".join(caractere if caractere.isalnum() or caractere in "._-" else "_"
for caractere in valor).strip("._") or "arquivo"
@staticmethod
def gerar_relatorio(resultados: list[TranscricaoDoClipe], destino: str | Path) -> Path:
caminho = Path(destino)
+184
View File
@@ -0,0 +1,184 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from ..dominio import Clipe
from ..integracoes.visual.contratos import (AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros,
DetectorDeIntervalosDeCena, ExtratorDeQuadros)
from ..integracoes.visual.modelos import QuadroDeVideo
from .modelos import Cena, CenaVisual, EvidenciaVisual
@dataclass(frozen=True)
class ResultadoVisualDoClipe:
identificador_do_clipe: str
evidencias: list[EvidenciaVisual] = field(default_factory=list)
cenas: list[Cena] = field(default_factory=list)
cenas_visuais: list[CenaVisual] = field(default_factory=list)
class DetectorDeCenas:
"""Módulo profundo que orquestra frames, analisadores e cenas de um clipe.
A interface não expõe OpenCV, ONNX, MediaPipe, PySceneDetect ou Vision.
Cada adapter pode ser trocado sem alterar chamadores nem resultados do domínio.
"""
def __init__(self, extrator: ExtratorDeQuadros,
analisadores_de_frame: list[AnalisadorDeFrame] | None = None,
detectores_de_intervalo: list[DetectorDeIntervalosDeCena] | None = None,
analisadores_de_sequencia: list[AnalisadorDeSequenciaDeQuadros] | None = None,
ao_progresso: Callable[[int, int], None] | None = None) -> None:
self.extrator = extrator
self.analisadores_de_frame = analisadores_de_frame or []
self.analisadores_de_sequencia = analisadores_de_sequencia or []
self.detectores_de_intervalo = detectores_de_intervalo or []
self.ao_progresso = ao_progresso
def detectar(self, clipe: Clipe) -> ResultadoVisualDoClipe:
quadros = self.extrator.extrair(clipe)
total = len(quadros) * len(self.analisadores_de_frame) + len(self.analisadores_de_sequencia)
progresso = [0]
evidencias = self._analisar_quadros(quadros, progresso, total) + self._analisar_sequencia(quadros, progresso, total)
cenas = self._detectar_intervalos(clipe, quadros)
cenas_visuais = [CenaVisual(
clipe.identificador, cena.inicio, cena.fim,
tuple(item for item in evidencias if item.inicio <= cena.fim and item.fim >= cena.inicio),
cena.referencias,
) for cena in cenas]
return ResultadoVisualDoClipe(clipe.identificador, evidencias, cenas, cenas_visuais)
def _analisar_quadros(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
total: int = 0) -> list[EvidenciaVisual]:
resultado: list[EvidenciaVisual] = []
for quadro in quadros:
for analisador in self.analisadores_de_frame:
resultado.extend(analisador.analisar(quadro))
self._avancar_progresso(progresso, total)
return resultado
def _analisar_sequencia(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
total: int = 0) -> list[EvidenciaVisual]:
resultado: list[EvidenciaVisual] = []
for analisador in self.analisadores_de_sequencia:
resultado.extend(analisador.analisar(quadros))
self._avancar_progresso(progresso, total)
return resultado
def _avancar_progresso(self, progresso: list[int] | None, total: int) -> None:
if progresso is None or total <= 0:
return
progresso[0] += 1
if self.ao_progresso:
self.ao_progresso(progresso[0], total)
def _detectar_intervalos(self, clipe: Clipe, quadros: list[QuadroDeVideo]) -> list[Cena]:
cenas: list[Cena] = []
for detector in self.detectores_de_intervalo:
cenas.extend(detector.detectar(clipe, quadros))
cenas = self._consolidar_cenas(cenas)
if not cenas and quadros:
cenas = [Cena(quadros[0].timestamp, quadros[-1].timestamp)]
return cenas
@staticmethod
def _consolidar_cenas(cenas: list[Cena]) -> list[Cena]:
resultado: list[Cena] = []
for cena in sorted(cenas, key=lambda item: (item.inicio, item.fim)):
if resultado and cena.inicio <= resultado[-1].fim:
anterior = resultado[-1]
resultado[-1] = Cena(anterior.inicio, max(anterior.fim, cena.fim),
anterior.confianca or cena.confianca,
anterior.referencias + cena.referencias)
else:
resultado.append(cena)
return resultado
class AnaliseVisualDaTimeline:
"""Etapa do Scanner que guarda evidências e cenas por clipe no contexto."""
nome = "analise_visual_local"
def __init__(self, detector: DetectorDeCenas, continuar_em_falha: bool = True) -> None:
self.detector = detector
self.continuar_em_falha = continuar_em_falha
def executar(self, contexto):
if contexto.timeline is None:
return contexto
for faixa in contexto.timeline.faixas:
for clipe in faixa.clipes:
if clipe.offline or not clipe.arquivo:
continue
try:
resultado = self.detector.detectar(clipe)
except Exception as exc:
if not self.continuar_em_falha:
raise
contexto.avisos.append(
f"analise_visual_indisponivel: clipe {clipe.identificador}: {exc}"
)
contexto.caracteristicas_visuais[clipe.identificador] = {
"evidencias": [], "cenas": [], "cenas_visuais": [], "disponivel": False,
}
continue
contexto.caracteristicas_visuais[clipe.identificador] = {
"evidencias": resultado.evidencias,
"cenas": resultado.cenas,
"cenas_visuais": resultado.cenas_visuais,
"disponivel": True,
}
contexto.cenas.extend(resultado.cenas)
contexto.cenas_visuais.extend(resultado.cenas_visuais)
contexto.cenas = DetectorDeCenas._consolidar_cenas(contexto.cenas)
return contexto
def criar_detector_visual_local(
diretorio_de_cache: str | None = ".frames",
intervalo_em_segundos: float = 1.0,
) -> DetectorDeCenas:
"""Monta o detector local padrão com uma interface curta para o Scanner."""
from ..integracoes.visual import (
AnalisadorDeComposicaoOpenCV,
AnalisadorDeContinuidadeOpenCV,
AnalisadorDeQualidadeOpenCV,
ExtratorDeQuadrosOpenCV,
)
return DetectorDeCenas(
ExtratorDeQuadrosOpenCV(
intervalo_em_segundos=intervalo_em_segundos,
diretorio_de_cache=diretorio_de_cache,
),
analisadores_de_frame=[
AnalisadorDeQualidadeOpenCV(),
AnalisadorDeComposicaoOpenCV(),
],
analisadores_de_sequencia=[AnalisadorDeContinuidadeOpenCV()],
)
def criar_detector_apple_vision(
diretorio_de_cache: str | None = ".frames",
intervalo_em_segundos: float = 1.0,
configuracao=None,
) -> DetectorDeCenas:
"""Monta a análise local de cena baseada em Vision e arquivos de origem."""
from .configuracao_visual import ConfiguracaoVisualDoScanner
from ..integracoes.visual import (AnalisadorAppleVisionNativo,
AnalisadorSequenciaAppleVisionNativo,
ExtratorDeQuadrosFFmpeg)
perfil = configuracao or ConfiguracaoVisualDoScanner(intervalo_em_segundos=intervalo_em_segundos)
return DetectorDeCenas(
ExtratorDeQuadrosFFmpeg(intervalo_em_segundos=perfil.intervalo_em_segundos,
diretorio_de_cache=diretorio_de_cache or ".frames"),
analisadores_de_frame=[AnalisadorAppleVisionNativo(
recursos=tuple(sorted(perfil.recursos_vision)),
interpretar_com_apple_intelligence=perfil.interpretar_cena,
)],
analisadores_de_sequencia=[AnalisadorSequenciaAppleVisionNativo()]
if perfil.analisar_continuidade else [],
)
@@ -0,0 +1,216 @@
import unittest
from pathlib import Path
import json
import tempfile
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
from engine.integracoes.visual.contratos import (AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros,
DetectorDeIntervalosDeCena,
ExtratorDeQuadros)
from engine.integracoes.visual.modelos import QuadroDeVideo
from engine.integracoes.visual.extrator_open_cv import ExtratorDeQuadrosOpenCV
from engine.integracoes.visual.apple_vision import (AnalisadorAppleVisionNativo,
AnalisadorSequenciaAppleVisionNativo)
from engine.scanner.coordenacao import ContextoDeAnalise
from engine.scanner.modelos import Cena, EvidenciaVisual
from engine.scanner.visual import AnaliseVisualDaTimeline, DetectorDeCenas, ResultadoVisualDoClipe
from engine.scanner.relatorio_visual import gerar_relatorio_visual, gerar_resumo_visual_markdown
from engine.scanner.configuracao_visual import ConfiguracaoVisualDoScanner
class ExtratorSimulado(ExtratorDeQuadros):
def extrair(self, clipe):
return [
QuadroDeVideo(0.0, 0, 1920, 1080, "frame-0"),
QuadroDeVideo(1.0, 1, 1920, 1080, "frame-1"),
]
class AnalisadorSimulado(AnalisadorDeFrame):
nome = "simulado"
def analisar(self, quadro):
return [EvidenciaVisual("qualidade", quadro.timestamp, quadro.timestamp,
{"nitidez_laplaciana": 42.0}, provider=self.nome)]
class DetectorSimulado(DetectorDeIntervalosDeCena):
nome = "simulado"
def detectar(self, clipe, quadros):
return [Cena(0.0, 1.0, referencias=(0.0,)), Cena(1.0, 2.0, referencias=(1.0,))]
class AnalisadorTemporalSimulado(AnalisadorDeSequenciaDeQuadros):
nome = "temporal_simulado"
def analisar(self, quadros):
return [EvidenciaVisual("continuidade", quadros[0].timestamp, quadros[-1].timestamp,
{"possivel_descontinuidade": False}, provider=self.nome)]
class ExecutorAppleSimulado:
def executar(self, carga, timeout):
self.carga, self.timeout = carga, timeout
return {"evidencias": [{"tipo": "pessoa", "valor": {"ocupacao_do_quadro": 0.3},
"confianca": 0.9, "modelo": "VNDetectHumanRectanglesRequest"}],
"avisos": ["ocr: indisponível"]}
class ExecutorAppleSequenciaSimulado:
def executar(self, carga, timeout):
self.carga, self.timeout = carga, timeout
return {"evidencias": [{"tipo": "similaridade_visual", "valor": {
"frame_inicial": 0, "frame_final": 1, "distancia_feature_print": 0.2,
"possivel_mudanca_de_cena": False,
}, "modelo": "VNGenerateImageFeaturePrintRequest"}], "avisos": []}
class TesteAnaliseVisualLocal(unittest.TestCase):
def setUp(self):
self.clipe = Clipe("clip-1", "camera", IntervaloDeTempo(0, 2), arquivo="/video.mp4")
def test_detector_envia_cada_frame_aos_analisadores_e_consolida_cenas(self):
detector = DetectorDeCenas(ExtratorSimulado(), [AnalisadorSimulado()], [DetectorSimulado()])
resultado = detector.detectar(self.clipe)
self.assertEqual(resultado.identificador_do_clipe, "clip-1")
self.assertEqual([item.inicio for item in resultado.evidencias], [0.0, 1.0])
self.assertEqual(len(resultado.cenas), 1)
self.assertEqual((resultado.cenas[0].inicio, resultado.cenas[0].fim), (0.0, 2.0))
def test_etapa_da_timeline_guarda_evidencias_por_clipe(self):
detector = DetectorDeCenas(ExtratorSimulado(), [AnalisadorSimulado()], [DetectorSimulado()])
timeline = Timeline("timeline-1", "Principal", faixas=[
Faixa("video-1", "V1", "video", 0, [self.clipe]),
])
contexto = ContextoDeAnalise(timeline=timeline)
AnaliseVisualDaTimeline(detector).executar(contexto)
self.assertEqual(len(contexto.caracteristicas_visuais["clip-1"]["evidencias"]), 2)
self.assertEqual(len(contexto.cenas), 1)
def test_detector_executa_analisadores_de_sequencia_uma_vez_por_clipe(self):
detector = DetectorDeCenas(ExtratorSimulado(), analisadores_de_sequencia=[AnalisadorTemporalSimulado()])
resultado = detector.detectar(self.clipe)
self.assertEqual(len(resultado.evidencias), 1)
self.assertEqual(resultado.evidencias[0].tipo, "continuidade")
def test_fluxo_produz_cena_visual_com_observacoes_e_continuidade(self):
detector = DetectorDeCenas(
ExtratorSimulado(), [AnalisadorSimulado()],
analisadores_de_sequencia=[AnalisadorTemporalSimulado()],
)
contexto = ContextoDeAnalise(timeline=Timeline(
"timeline-1", "Principal", faixas=[Faixa("video-1", "V1", "video", 0, [self.clipe])]
))
AnaliseVisualDaTimeline(detector).executar(contexto)
cena = contexto.cenas_visuais[0]
self.assertEqual(cena.identificador_do_clipe, "clip-1")
self.assertEqual([item.tipo for item in cena.observacoes], ["qualidade", "qualidade", "continuidade"])
self.assertTrue(contexto.caracteristicas_visuais["clip-1"]["disponivel"])
def test_falha_de_adapter_e_registrada_sem_interromper_outro_clipe(self):
class ExtratorQueFalha(ExtratorDeQuadros):
def extrair(self, clipe):
if clipe.identificador == "clip-1":
raise RuntimeError("OpenCV não instalado")
return [QuadroDeVideo(0.0, 0, 1920, 1080, "frame")]
segundo = Clipe("clip-2", "camera-2", IntervaloDeTempo(2, 3), arquivo="/video-2.mp4")
timeline = Timeline("timeline-1", "Principal", faixas=[
Faixa("video-1", "V1", "video", 0, [self.clipe, segundo]),
])
contexto = ContextoDeAnalise(timeline=timeline)
detector = DetectorDeCenas(ExtratorQueFalha(), [AnalisadorSimulado()])
AnaliseVisualDaTimeline(detector).executar(contexto)
self.assertFalse(contexto.caracteristicas_visuais["clip-1"]["disponivel"])
self.assertTrue(contexto.caracteristicas_visuais["clip-2"]["disponivel"])
self.assertEqual(len(contexto.avisos), 1)
def test_adapter_apple_converte_fatos_e_preserva_avisos_por_recurso(self):
executor = ExecutorAppleSimulado()
quadro = QuadroDeVideo(0.0, 0, 1920, 1080, "frame", Path("/frame.jpg"))
evidencias = AnalisadorAppleVisionNativo(executor=executor).analisar(quadro)
self.assertEqual(evidencias[0].tipo, "pessoa")
self.assertEqual(evidencias[0].modelo, "VNDetectHumanRectanglesRequest")
self.assertEqual(evidencias[1].tipo, "diagnostico_apple_vision")
self.assertTrue(executor.carga["resumir"])
def test_adapter_apple_de_sequencia_converte_indices_em_intervalos(self):
executor = ExecutorAppleSequenciaSimulado()
quadros = [QuadroDeVideo(2.0, 0, 1920, 1080, "frame", Path("/frame-0.jpg")),
QuadroDeVideo(3.0, 1, 1920, 1080, "frame", Path("/frame-1.jpg"))]
evidencias = AnalisadorSequenciaAppleVisionNativo(executor=executor).analisar(quadros)
self.assertEqual(evidencias[0].tipo, "similaridade_visual")
self.assertEqual((evidencias[0].inicio, evidencias[0].fim), (2.0, 3.0))
self.assertEqual(evidencias[0].valor["distancia_feature_print"], 0.2)
self.assertEqual(executor.carga["frames"], ["/frame-0.jpg", "/frame-1.jpg"])
def test_evidencia_rejeita_intervalo_invalido(self):
with self.assertRaises(ValueError):
EvidenciaVisual("objeto", 2.0, 1.0, {})
def test_relatorio_visual_separa_frames_de_evidencias_temporais(self):
resultado = ResultadoVisualDoClipe("clip-1", [
EvidenciaVisual("categoria", 2.0, 2.0, {"identificador": "drink"}),
EvidenciaVisual("similaridade_visual", 2.0, 3.0, {"distancia": 0.2}),
])
with tempfile.TemporaryDirectory() as pasta:
caminho = gerar_relatorio_visual(self.clipe, resultado, Path(pasta) / "visual.json", 1.0)
dados = json.loads(caminho.read_text(encoding="utf-8"))
self.assertEqual(dados["clipe"]["identificador"], "clip-1")
self.assertEqual(dados["observacoes_por_frame"][0]["timestamp_na_origem"], 2.0)
self.assertEqual(dados["observacoes_por_frame"][0]["evidencias"][0]["tipo"], "categoria")
self.assertEqual(dados["evidencias_temporais"][0]["tipo"], "similaridade_visual")
def test_resumo_visual_markdown_lista_frames_e_transicoes(self):
resultado = ResultadoVisualDoClipe("clip-1", [
EvidenciaVisual("categoria", 2.0, 2.0, {"identificador": "drink"}),
EvidenciaVisual("similaridade_visual", 2.0, 3.0, {"distancia": 0.2}),
])
with tempfile.TemporaryDirectory() as pasta:
caminho = gerar_resumo_visual_markdown(self.clipe, resultado, Path(pasta) / "visual.md", 1.0)
conteudo = caminho.read_text(encoding="utf-8")
self.assertIn("Origem 2.000s", conteudo)
self.assertIn("similaridade_visual", conteudo)
def test_configuracao_visual_valida_o_perfil_do_painel(self):
perfil = ConfiguracaoVisualDoScanner.de_dict({
"intervalo_em_segundos": 5,
"faixas_de_video": [2, 0, 2],
"recursos_vision": ["faces", "pessoas"],
"analisar_continuidade": False,
})
self.assertEqual(perfil.faixas_de_video, (0, 2))
self.assertEqual(perfil.para_dict()["recursos_vision"], ["faces", "pessoas"])
self.assertFalse(perfil.analisar_continuidade)
def test_extrator_respeita_intervalo_na_origem_e_limite_de_frames(self):
clipe = Clipe("clip-1", "camera", IntervaloDeTempo(0, 2),
intervalo_na_origem=IntervaloDeTempo(2.3, 5.0), arquivo="/video.mp4")
extrator = ExtratorDeQuadrosOpenCV(intervalo_em_segundos=1.0, maximo_de_quadros=1,
cv2_module=object())
inicio, fim = extrator._intervalo_de_origem(clipe, 10.0)
self.assertEqual(extrator._timestamps(inicio, fim), [2.3])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,86 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
from engine.scanner.coordenacao import ContextoDeAnalise
from engine.scanner.descoberta import ArquivoDescoberto, DescobertaDeArquivos
from engine.scanner.metadados import ExtracaoDeMetadados
from engine.integracoes.midia.extracao_de_metadados import ExtracaoDeMetadados as Adapter
class TesteArquivosEMetadados(unittest.TestCase):
def _contexto(self, *arquivos: str | None) -> ContextoDeAnalise:
clipes = [Clipe(str(i), f"clipe-{i}", IntervaloDeTempo(0, 1), arquivo=arquivo)
for i, arquivo in enumerate(arquivos)]
timeline = Timeline("timeline", "Principal", faixas=[Faixa("v1", "V1", "video", 0, clipes)])
return ContextoDeAnalise(timeline=timeline)
def test_descoberta_normaliza_caminho_e_marca_offline(self):
with tempfile.TemporaryDirectory() as pasta:
arquivo = Path(pasta) / "camera.mp4"
arquivo.touch()
contexto = self._contexto(str(arquivo), str(Path(pasta) / "ausente.mp4"), None)
DescobertaDeArquivos().executar(contexto)
self.assertEqual(contexto.timeline.faixas[0].clipes[0].arquivo, str(arquivo.resolve()))
self.assertFalse(contexto.timeline.faixas[0].clipes[0].offline)
self.assertTrue(contexto.timeline.faixas[0].clipes[1].offline)
self.assertTrue(contexto.timeline.faixas[0].clipes[2].offline)
self.assertEqual(len(contexto.erros), 2)
def test_descoberta_identifica_arquivo_duplicado(self):
with tempfile.TemporaryDirectory() as pasta:
arquivo = Path(pasta) / "camera.mov"
arquivo.touch()
contexto = self._contexto(str(arquivo), str(arquivo))
DescobertaDeArquivos().executar(contexto)
self.assertEqual(len(contexto.avisos), 1)
def test_extracao_converte_ffprobe_e_usa_cache(self):
dados = {"format": {"format_name": "mov,mp4,m4a", "duration": "12.5", "size": "900"},
"streams": [{"codec_type": "video", "codec_name": "h264", "width": 1920,
"height": 1080, "avg_frame_rate": "30000/1001"},
{"codec_type": "audio", "codec_name": "aac", "channels": 2,
"sample_rate": "48000"}]}
with tempfile.TemporaryDirectory() as pasta:
arquivo = Path(pasta) / "camera.mp4"
arquivo.touch()
adapter = Adapter(ffprobe="ffprobe-simulado")
processo = type("Processo", (), {"stdout": json.dumps(dados)})()
with patch("engine.integracoes.midia.extracao_de_metadados.subprocess.run", return_value=processo) as run:
primeiro = adapter.extrair(arquivo)
segundo = adapter.extrair(arquivo)
self.assertEqual(primeiro.codec_de_video, "h264")
self.assertEqual(primeiro.codec_de_audio, "aac")
self.assertEqual((primeiro.largura, primeiro.altura), (1920, 1080))
self.assertAlmostEqual(primeiro.taxa_de_quadros, 29.97002997, places=5)
self.assertEqual(primeiro.duracao, 12.5)
self.assertIs(primeiro, segundo)
run.assert_called_once()
def test_etapa_de_metadados_ignora_arquivos_offline(self):
class ExtratorSimulado:
def extrair(self, arquivo):
return "metadados"
with tempfile.TemporaryDirectory() as pasta:
arquivo = Path(pasta) / "camera.mp4"
arquivo.touch()
contexto = self._contexto(str(arquivo), str(Path(pasta) / "ausente.mp4"))
DescobertaDeArquivos().executar(contexto)
ExtracaoDeMetadados(ExtratorSimulado()).executar(contexto)
self.assertEqual(contexto.metadados_dos_arquivos["0"], "metadados")
self.assertNotIn("1", contexto.metadados_dos_arquivos)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,32 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from engine.integracoes.apple_speech import ProviderDeTranscricaoApple
class TesteProviderDeTranscricaoApple(unittest.TestCase):
@patch("engine.integracoes.apple_speech.provider_de_transcricao_apple.subprocess.run")
def test_transcreve_em_pt_br_no_dispositivo_e_preserva_timestamps(self, executar):
executar.return_value = type("Resultado", (), {
"stdout": json.dumps({"segmentos": [
{"inicio": 1.25, "fim": 2.75, "texto": " Olá mundo ", "confianca": 0.93},
]}),
})()
with tempfile.TemporaryDirectory() as pasta:
arquivo = Path(pasta) / "video.mp4"
arquivo.write_bytes(b"video")
clipe = type("Clipe", (), {"arquivo": str(arquivo)})()
resultado = ProviderDeTranscricaoApple(executavel="transcritor").transcrever(clipe)
self.assertEqual(resultado[0].texto, "Olá mundo")
self.assertEqual((resultado[0].inicio, resultado[0].fim), (1.25, 2.75))
comando = executar.call_args.args[0]
self.assertEqual(comando, ["transcritor", str(arquivo), "pt-BR", "true"])
if __name__ == "__main__":
unittest.main()