Files
jhonny-editor/code/engine/scanner/visual.py
T
João Henrique c1b544f4b5 feat: reorganizado o fluxo de edição para validar a análise do Sca
- reorganizado o fluxo de edição para validar a análise do Scanner, selecionar o tipo de vídeo e enviar suas instruções no JSON.
- banco de análises: métricas de fala viraram colunas, busca lexical FTS5, enunciados com embeddings, views achatadas de leitura (fala/palavra/linha do tempo/fala com visual), ingestor do pipeline de voz e gravação idempotente de evidências visuais e cenas.
- retakes passam a ser gravados no banco de análises (SQLite) em vez de JSON por caso, com status de revisão persistido.
- planos de edição e suas aplicações passam a ser registrados no banco, em vez de se perderem no arquivo temporário.
- comando de limpeza das evidências visuais e cenas duplicadas por execuções antigas do Scanner.
- análise visual: OpenCV (rostos) e PySceneDetect passam a entrar no detector local por padrão; novo adapter InsightFace gera assinatura facial (embedding) para reconhecer a mesma pessoa entre tomadas, gravada como evidência visual no banco.
- corrigido: métricas de fala (energia, pitch, velocidade) agora gravam nas colunas dedicadas, não só no JSON; a view fala+visual passa a casar por vídeo e tempo, já que o mesmo arquivo entra na timeline como clipes distintos de vídeo e áudio; views são recriadas a cada abertura do banco para uma correção de consulta chegar a bancos já existentes.
- reordenadas as abas do painel CEP para Scanner, Refinar e Editar vídeo

Resumo:
- 24 arquivos alterados
- 9 novos
- 15 modificados
- 0 removidos

 15 files changed, 872 insertions(+), 29 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/engine/aplicar_plano_de_edicao.py
  - code/engine/integracoes/visual/README.md
  - code/engine/integracoes/visual/__init__.py
  - code/engine/integracoes/visual/analisadores.py
  - code/engine/persistencia/__init__.py
  - code/engine/persistencia/esquema.py
  - code/engine/persistencia/repositorio_de_analises_sqlite.py
  - code/engine/persistencia/repositorio_de_retakes_sqlite.py
  - code/engine/requirements-visual.txt
  - code/engine/scanner/configuracao_visual.py
  - code/engine/scanner/visual.py
  - code/engine/testes/test_analise_visual_local.py
  - .jhonny/analises.db.pos-scanner-084841
  - code/engine/integracoes/embeddings/
  - code/engine/limpar_duplicatas.py
  - code/engine/persistencia/busca_de_conteudo.py
  - code/engine/persistencia/enunciados.py
  - code/engine/persistencia/ingestao_de_voz.py
  - code/engine/persistencia/limpeza.py
  - code/engine/persistencia/repositorio_de_planos.py
  - code/engine/testes/test_busca_de_conteudo.py
2026-09-10 08:58:22 -04:00

272 lines
12 KiB
Python

import threading
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
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,
trabalhadores_em_paralelo: int = 4) -> None:
self.extrator = extrator
self.analisadores_de_frame = analisadores_de_frame or []
self.analisadores_de_sequencia = analisadores_de_sequencia or []
self.detectores_de_intervalo = detectores_de_intervalo or []
self.ao_progresso = ao_progresso
self.trabalhadores_em_paralelo = max(1, trabalhadores_em_paralelo)
def detectar(self, clipe: Clipe) -> ResultadoVisualDoClipe:
quadros = self.extrator.extrair(clipe)
self._preparar_analisadores(quadros)
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 _preparar_analisadores(self, quadros: list[QuadroDeVideo]) -> None:
"""Prepara analisadores que precisam saber a quantidade de frames do clipe.
Caso especial do `AnalisadorAppleVisionNativo`: ele distribui a
interpretação editorial (Apple Intelligence / Foundation Models) em um
subconjunto dos frames para manter a cobertura de cena com custo menor.
"""
quantidade_de_quadros = len(quadros)
for analisador in self.analisadores_de_frame:
preparar = getattr(analisador, "preparar_interpretacao_por_clipe", None)
if callable(preparar):
preparar(quantidade_de_quadros)
def _analisar_quadros(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
total: int = 0) -> list[EvidenciaVisual]:
if not self.analisadores_de_frame or not quadros:
return []
tarefas = [(quadro, analisador) for quadro in quadros for analisador in self.analisadores_de_frame]
travamento = threading.Lock()
def processar(tarefa: tuple[QuadroDeVideo, AnalisadorDeFrame]) -> list[EvidenciaVisual]:
quadro, analisador = tarefa
evidencias = analisador.analisar(quadro)
with travamento:
self._avancar_progresso(progresso, total)
return evidencias
if self.trabalhadores_em_paralelo == 1 or len(tarefas) == 1:
resultados = [processar(tarefa) for tarefa in tarefas]
else:
with ThreadPoolExecutor(max_workers=self.trabalhadores_em_paralelo) as executor:
resultados = list(executor.map(processar, tarefas))
return [evidencia for lista in resultados for evidencia in lista]
def _analisar_sequencia(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
total: int = 0) -> list[EvidenciaVisual]:
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 _detectores_de_cena_do_perfil(perfil: ConfiguracaoVisualDoScanner) -> list:
"""Monta os detectores de intervalo de cena ativos para o perfil.
A análise visual auxiliar (PySceneDetect) é opcional. Só entra como um
``DetectorDeIntervalosDeCena`` quando a configuração a ativa — e nunca
decide sozinho se há retake.
"""
from ..integracoes.visual import DetectorDeCenasPySceneDetect
detectores: list = []
if getattr(perfil, "usar_analise_visual", False) and getattr(perfil, "detectar_cortes", True):
detectores.append(DetectorDeCenasPySceneDetect(limiar=perfil.limiar_de_corte))
return detectores
def criar_detector_visual_local(
diretorio_de_cache: str | None = ".frames",
intervalo_em_segundos: float = 1.0,
configuracao: ConfiguracaoVisualDoScanner | None = None,
) -> DetectorDeCenas:
"""Monta o detector local padrão com uma interface curta para o Scanner."""
from .configuracao_visual import ConfiguracaoVisualDoScanner
from ..integracoes.visual import (
AnalisadorDeComposicaoOpenCV,
AnalisadorDeContinuidadeOpenCV,
AnalisadorDeQualidadeOpenCV,
AnalisadorDeRostosOpenCV,
ExtratorDeQuadrosOpenCV,
)
perfil = configuracao or ConfiguracaoVisualDoScanner(intervalo_em_segundos=intervalo_em_segundos)
analisadores_de_frame: list = [
AnalisadorDeQualidadeOpenCV(),
AnalisadorDeComposicaoOpenCV(),
]
if "faces" in perfil.recursos_vision:
analisadores_de_frame.append(AnalisadorDeRostosOpenCV())
if perfil.identidade_facial:
analisadores_de_frame.append(_analisador_de_identidade_facial())
return DetectorDeCenas(
ExtratorDeQuadrosOpenCV(
intervalo_em_segundos=perfil.intervalo_em_segundos,
diretorio_de_cache=diretorio_de_cache,
),
analisadores_de_frame=analisadores_de_frame,
analisadores_de_sequencia=[AnalisadorDeContinuidadeOpenCV()],
detectores_de_intervalo=_detectores_de_cena_do_perfil(perfil),
)
def _analisador_de_identidade_facial():
"""Monta o analisador InsightFace, compartilhado pelos dois detectores locais."""
from ..integracoes.visual import AnalisadorDeRostosInsightFace
return AnalisadorDeRostosInsightFace()
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)
from ..integracoes.midia import CompactadorDeProxy, ExtratorDeQuadrosDoProxy
perfil = configuracao or ConfiguracaoVisualDoScanner(intervalo_em_segundos=intervalo_em_segundos)
# Quantidade fixa de trabalhadores para extração e análise de frames.
# Em volumes externos (ex.: /Volumes/Merongo), mais threads do que isso
# satura a E/S e trava os processos de mídia a ~0% de CPU.
QUANTIDADE_DE_TRABALHADORES = 4
extrator = ExtratorDeQuadrosFFmpeg(intervalo_em_segundos=perfil.intervalo_em_segundos,
diretorio_de_cache=diretorio_de_cache or ".frames",
resolucao_maxima=perfil.resolucao_maxima,
trabalhadores_em_paralelo=QUANTIDADE_DE_TRABALHADORES)
# Leitura via proxy compactado: para arquivos pesados, evita buscar frame
# por frame no arquivo original (um -ss por frame num MOV de vários GB).
if perfil.usar_proxy:
extrator = ExtratorDeQuadrosDoProxy(
extrator, CompactadorDeProxy(), largura_maxima=perfil.resolucao_do_proxy
)
analisadores_de_frame: list = [AnalisadorAppleVisionNativo(
recursos=tuple(sorted(perfil.recursos_vision)),
interpretar_com_apple_intelligence=perfil.interpretar_cena,
maximo_de_frames_interpretados=perfil.maximo_de_frames_interpretados,
)]
if perfil.identidade_facial:
analisadores_de_frame.append(_analisador_de_identidade_facial())
return DetectorDeCenas(
extrator,
analisadores_de_frame=analisadores_de_frame,
analisadores_de_sequencia=[AnalisadorSequenciaAppleVisionNativo()]
if perfil.analisar_continuidade else [],
detectores_de_intervalo=_detectores_de_cena_do_perfil(perfil),
trabalhadores_em_paralelo=QUANTIDADE_DE_TRABALHADORES,
)