Files
jhonny-editor/code/engine/persistencia/repositorio_de_analises_sqlite.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

320 lines
14 KiB
Python

"""Persistência SQLite de metadados de arquivo, transcrição e evidências visuais.
Complementa ``RepositorioDeRetakesSQLite`` gravando o restante do que o
``ContextoDeAnalise`` do pipeline produz e que hoje só vive em memória
(metadados técnicos) ou em cache JSON por arquivo (transcrição).
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typing import Iterable
from ..integracoes.midia.extracao_de_metadados import MetadadosDoArquivo
from ..scanner.modelos import Cena, EvidenciaVisual, PalavraDeTranscricao, SegmentoDeTranscricao
from ..scanner.transcricao_da_timeline import TranscricaoDoClipe
from .conexao import abrir_banco
def _numero_ou_nulo(valor: object) -> float | None:
"""Converte um valor de métrica em float, ou None quando ausente/ilegível."""
if isinstance(valor, bool) or not isinstance(valor, (int, float)):
return None
return float(valor)
class RepositorioDeAnalisesSQLite:
"""Grava metadados de arquivo, transcrição e evidências/cenas visuais."""
def __init__(self, banco: str | Path | sqlite3.Connection = ".jhonny/analises.db") -> None:
self.conexao = banco if isinstance(banco, sqlite3.Connection) else abrir_banco(banco)
def _garantir_video(self, video_id: str) -> None:
self.conexao.execute("INSERT OR IGNORE INTO videos (id) VALUES (?)", (video_id,))
def registrar_metadados_de_arquivo(
self, video_id: str, metadados: MetadadosDoArquivo, hash_do_conteudo: str | None = None,
) -> None:
"""
Grava os metadados técnicos de um arquivo de mídia do vídeo.
Regravar o mesmo caminho atualiza a linha existente em vez de criar
outra, então o método é seguro para reprocessamento.
Parâmetros:
video_id: Identificador do vídeo dono do arquivo.
metadados: Metadados técnicos extraídos da mídia.
hash_do_conteudo: Hash do conteúdo do arquivo, quando calculado.
"""
with self.conexao:
self._garantir_video(video_id)
self.conexao.execute(
"""INSERT INTO arquivos
(video_id, caminho, formato, duracao, codec_de_video, codec_de_audio,
largura, altura, taxa_de_quadros, canais_de_audio, taxa_de_amostragem,
tamanho_em_bytes, orientacao, timecode, hash_do_conteudo)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (video_id, caminho) DO UPDATE SET
formato = excluded.formato, duracao = excluded.duracao,
codec_de_video = excluded.codec_de_video,
codec_de_audio = excluded.codec_de_audio,
largura = excluded.largura, altura = excluded.altura,
taxa_de_quadros = excluded.taxa_de_quadros,
canais_de_audio = excluded.canais_de_audio,
taxa_de_amostragem = excluded.taxa_de_amostragem,
tamanho_em_bytes = excluded.tamanho_em_bytes,
orientacao = excluded.orientacao, timecode = excluded.timecode,
hash_do_conteudo = excluded.hash_do_conteudo""",
(video_id, str(metadados.caminho), metadados.formato, metadados.duracao,
metadados.codec_de_video, metadados.codec_de_audio, metadados.largura,
metadados.altura, metadados.taxa_de_quadros, metadados.canais_de_audio,
metadados.taxa_de_amostragem, metadados.tamanho_em_bytes,
metadados.orientacao, metadados.timecode, hash_do_conteudo),
)
def registrar_transcricoes(
self, video_id: str, transcricoes: Iterable[TranscricaoDoClipe],
) -> None:
"""
Acrescenta transcrições sem remover as já gravadas.
Para reprocessamento use ``substituir_transcricoes``: este método
acumula, e chamá-lo duas vezes para o mesmo clipe duplica as falas.
Parâmetros:
video_id: Identificador do vídeo dono das transcrições.
transcricoes: Transcrições por clipe a gravar.
"""
with self.conexao:
self._garantir_video(video_id)
for transcricao in transcricoes:
for segmento in transcricao.segmentos:
self._inserir_segmento(video_id, transcricao.identificador_do_clipe, segmento)
def substituir_transcricoes(
self, video_id: str, transcricoes: Iterable[TranscricaoDoClipe],
) -> None:
"""Substitui a transcrição somente dos clipes recebidos.
O método torna o reprocessamento idempotente sem apagar análises de
outros clipes ou faixas que não participaram da execução atual.
"""
with self.conexao:
self._garantir_video(video_id)
transcricoes_materializadas = tuple(transcricoes)
for transcricao in transcricoes_materializadas:
self.conexao.execute(
"""DELETE FROM segmentos_de_transcricao
WHERE video_id = ? AND clipe_id = ?""",
(video_id, transcricao.identificador_do_clipe),
)
for segmento in transcricao.segmentos:
self._inserir_segmento(video_id, transcricao.identificador_do_clipe, segmento)
def atualizar_enriquecimento_de_transcricao(
self,
video_id: str,
clipe_id: str,
inicio: float,
fim: float,
*,
falante: str | None = None,
caracteristicas_acusticas: dict[str, object] | None = None,
) -> None:
"""Atualiza diarização e métricas de um segmento já persistido."""
campos = []
parametros: list[object] = []
if falante is not None:
campos.append("falante = ?")
parametros.append(falante)
if caracteristicas_acusticas is not None:
campos.append("caracteristicas_acusticas = ?")
parametros.append(json.dumps(caracteristicas_acusticas, ensure_ascii=False))
if not campos:
return
parametros.extend((video_id, clipe_id, inicio, fim))
with self.conexao:
self.conexao.execute(
f"""UPDATE segmentos_de_transcricao
SET {', '.join(campos)}
WHERE video_id = ? AND clipe_id = ? AND inicio = ? AND fim = ?""",
parametros,
)
def _inserir_segmento(
self, video_id: str, clipe_id: str, segmento: SegmentoDeTranscricao,
) -> int:
"""Grava uma fala e devolve o id gerado, para as palavras se ligarem a ela.
As métricas acústicas são gravadas duas vezes de propósito: em
``caracteristicas_acusticas`` (JSON completo, para reconstrução fiel)
e em colunas dedicadas (para filtrar e ordenar por elas em SQL sem
precisar abrir o JSON).
"""
metricas = segmento.caracteristicas_acusticas or {}
cursor = self.conexao.execute(
"""INSERT INTO segmentos_de_transcricao
(video_id, clipe_id, inicio, fim, texto, confianca, falante, voz_aparente,
confianca_voz, emocao, confianca_emocao, caracteristicas_acusticas,
energia_rms, pitch_mediano_hz, pitch_desvio_hz, velocidade_de_fala_pps,
maior_pausa_interna_s, intervalo_anterior_s)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(video_id, clipe_id, segmento.inicio, segmento.fim, segmento.texto,
segmento.confianca, segmento.falante, segmento.voz_aparente,
segmento.confianca_voz, segmento.emocao, segmento.confianca_emocao,
json.dumps(segmento.caracteristicas_acusticas, ensure_ascii=False),
_numero_ou_nulo(metricas.get("energy_rms")),
_numero_ou_nulo(metricas.get("pitch_hz_median")),
_numero_ou_nulo(metricas.get("pitch_hz_std")),
_numero_ou_nulo(metricas.get("speaking_rate_wps")),
_numero_ou_nulo(metricas.get("longest_internal_pause_s")),
_numero_ou_nulo(metricas.get("gap_before_s"))),
)
segmento_id = cursor.lastrowid
for ordem, palavra in enumerate(segmento.palavras):
self.conexao.execute(
"""INSERT INTO palavras_de_transcricao
(segmento_id, ordem, texto, inicio, fim, confianca, falante)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(segmento_id, ordem, palavra.texto, palavra.inicio, palavra.fim,
palavra.confianca, palavra.falante),
)
return segmento_id
def carregar_transcricoes(self, video_id: str, clipe_id: str) -> list[SegmentoDeTranscricao]:
"""
Carrega as falas de um clipe, já com as palavras de cada uma.
Parâmetros:
video_id: Identificador do vídeo dono das falas.
clipe_id: Clipe cujas falas serão lidas.
Retorna:
As falas do clipe ordenadas por início, com as palavras em ordem.
"""
segmentos: list[SegmentoDeTranscricao] = []
for linha in self.conexao.execute(
"""SELECT * FROM segmentos_de_transcricao
WHERE video_id = ? AND clipe_id = ? ORDER BY inicio""",
(video_id, clipe_id),
).fetchall():
palavras = tuple(
PalavraDeTranscricao(
texto=item["texto"], inicio=item["inicio"], fim=item["fim"],
confianca=item["confianca"], falante=item["falante"],
)
for item in self.conexao.execute(
"SELECT * FROM palavras_de_transcricao WHERE segmento_id = ? ORDER BY ordem",
(linha["id"],),
).fetchall()
)
segmentos.append(SegmentoDeTranscricao(
inicio=linha["inicio"], fim=linha["fim"], texto=linha["texto"],
confianca=linha["confianca"], palavras=palavras,
emocao=linha["emocao"], confianca_emocao=linha["confianca_emocao"],
caracteristicas_acusticas=json.loads(linha["caracteristicas_acusticas"] or "{}"),
falante=linha["falante"], voz_aparente=linha["voz_aparente"],
confianca_voz=linha["confianca_voz"],
))
return segmentos
def substituir_evidencias_visuais(
self, video_id: str, clipe_id: str, evidencias: Iterable[EvidenciaVisual],
) -> int:
"""
Regrava as evidências visuais de um clipe, apagando as anteriores.
Torna o reprocessamento idempotente. ``registrar_evidencias_visuais``
só acrescenta, então reanalisar o mesmo clipe com ele acumula cópias
da mesma evidência e infla qualquer contagem feita sobre a tabela.
Parâmetros:
video_id: Identificador do vídeo dono das evidências.
clipe_id: Clipe cujas evidências serão substituídas.
evidencias: Evidências a gravar.
Retorna:
A quantidade de evidências gravadas.
"""
evidencias_materializadas = tuple(evidencias)
with self.conexao:
self._garantir_video(video_id)
self.conexao.execute(
"DELETE FROM evidencias_visuais WHERE video_id = ? AND clipe_id = ?",
(video_id, clipe_id),
)
self.registrar_evidencias_visuais(video_id, clipe_id, evidencias_materializadas)
return len(evidencias_materializadas)
def substituir_cenas(
self, video_id: str, cenas: Iterable[Cena], clipe_id: str | None = None,
) -> int:
"""
Regrava as cenas de um clipe, apagando as anteriores.
Parâmetros:
video_id: Identificador do vídeo dono das cenas.
cenas: Cenas a gravar.
clipe_id: Clipe cujas cenas serão substituídas. Quando ``None``,
substitui as cenas do vídeo que não pertencem a clipe algum.
Retorna:
A quantidade de cenas gravadas.
"""
cenas_materializadas = tuple(cenas)
with self.conexao:
self._garantir_video(video_id)
if clipe_id is None:
self.conexao.execute(
"DELETE FROM cenas WHERE video_id = ? AND clipe_id IS NULL", (video_id,))
else:
self.conexao.execute(
"DELETE FROM cenas WHERE video_id = ? AND clipe_id = ?", (video_id, clipe_id))
self.registrar_cenas(video_id, cenas_materializadas, clipe_id)
return len(cenas_materializadas)
def registrar_evidencias_visuais(
self, video_id: str, clipe_id: str, evidencias: Iterable[EvidenciaVisual],
) -> None:
"""Acrescenta evidências visuais sem remover as já gravadas.
Para reprocessamento use ``substituir_evidencias_visuais``: este método
acumula, e chamá-lo duas vezes para o mesmo clipe duplica as linhas.
"""
with self.conexao:
self._garantir_video(video_id)
for evidencia in evidencias:
self.conexao.execute(
"""INSERT INTO evidencias_visuais
(video_id, clipe_id, tipo, inicio, fim, valor, confianca, provider, modelo)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(video_id, clipe_id, evidencia.tipo, evidencia.inicio, evidencia.fim,
json.dumps(evidencia.valor, ensure_ascii=False), evidencia.confianca,
evidencia.provider, evidencia.modelo),
)
def registrar_cenas(
self, video_id: str, cenas: Iterable[Cena], clipe_id: str | None = None,
) -> None:
"""Acrescenta cenas sem remover as já gravadas.
Para reprocessamento use ``substituir_cenas``: este método acumula, e
chamá-lo duas vezes para o mesmo clipe duplica as cenas.
Parâmetros:
video_id: Identificador do vídeo dono das cenas.
cenas: Cenas detectadas a gravar.
clipe_id: Clipe a que as cenas pertencem, quando houver.
"""
with self.conexao:
self._garantir_video(video_id)
for cena in cenas:
self.conexao.execute(
"""INSERT INTO cenas (video_id, clipe_id, inicio, fim, confianca, referencias)
VALUES (?, ?, ?, ?, ?, ?)""",
(video_id, clipe_id, cena.inicio, cena.fim, cena.confianca,
json.dumps(list(cena.referencias))),
)