feat: criado repositório jhonny-editor no Gitea
criado repositório jhonny-editor no Gitea adicionado script admin/deploy.command com commit automático atualizado admin/DEV-NOTES.md com template limpo Resumo: - 32 arquivos alterados - 16 novos - 15 modificados - 0 removidos 16 files changed, 638 insertions(+), 61 deletions(-) Arquivos: - code/engine/skills/boas-praticas-oo.md -> .agents/skills/boas-praticas-oo/SKILL.md - AGENTS.md - code/cep-plugin/index.html - code/cep-plugin/main.js - code/cep-plugin/styles.css - code/engine/integracoes/visual/apple_vision.py - code/engine/integracoes/visual/apple_vision_runner.swift - code/engine/integracoes/visual/extrator_ffmpeg.py - code/engine/scanner/__init__.py - code/engine/scanner/configuracao_visual.py - code/engine/scanner/coordenacao/__init__.py - code/engine/scanner/retakes/adaptador_de_falas.py - code/engine/scanner/retakes/agrupador_de_takes.py - code/engine/scanner/retakes/detector_de_reinicios.py - code/engine/scanner/retakes/modelos_de_retakes.py - code/engine/scanner/visual.py - .jhonny/ - .retakes/ - CODING_STANDARDS.md - code/engine/executar_retakes.py - code/engine/persistencia/ - code/engine/scanner/retakes/__init__.py - code/engine/scanner/retakes/carregador_de_artefatos.py - code/engine/scanner/retakes/classificador_de_retakes.py - code/engine/scanner/retakes/coordenador_de_retakes.py - code/engine/scanner/retakes/deteccao_de_retakes.py - code/engine/scanner/retakes/gerador_de_evidencias.py - code/engine/scanner/retakes/repositorio_de_retakes.py - code/engine/testes/test_consultas_de_persistencia.py - code/engine/testes/test_deteccao_de_retakes.py - code/engine/testes/test_persistencia_sqlite.py - code/relatorios/audio/arquivos/92f97877259a86a8095b1eecafdefe357212a12247d04b78df60e0c0ae38b2ea/
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
"""Persistência SQLite das análises de vídeo, imagem e áudio do Scanner.
|
||||
|
||||
Este pacote implementa a "camada de banco de dados" prevista no docstring
|
||||
de ``scanner/retakes/repositorio_de_retakes.py``: um schema SQLite único
|
||||
para vídeos, metadados de arquivo, transcrição, evidências visuais/cenas e
|
||||
grupos de retake, com repositórios que espelham a interface dos
|
||||
repositórios JSON já existentes.
|
||||
"""
|
||||
|
||||
from .conexao import abrir_banco
|
||||
from .consultas import ConsultasDeAnalises, ErroDeConsultaInvalida
|
||||
from .esquema import criar_esquema
|
||||
from .repositorio_de_analises_sqlite import RepositorioDeAnalisesSQLite
|
||||
from .repositorio_de_retakes_sqlite import RepositorioDeRetakesSQLite
|
||||
from .repositorio_de_timeline_sqlite import RepositorioDeTimelineSQLite
|
||||
|
||||
__all__ = [
|
||||
"abrir_banco",
|
||||
"criar_esquema",
|
||||
"RepositorioDeAnalisesSQLite",
|
||||
"RepositorioDeRetakesSQLite",
|
||||
"RepositorioDeTimelineSQLite",
|
||||
"ConsultasDeAnalises",
|
||||
"ErroDeConsultaInvalida",
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Abertura de conexão com o banco SQLite de análises."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from .esquema import criar_esquema
|
||||
|
||||
|
||||
def abrir_banco(caminho: str | Path = ".jhonny/analises.db") -> sqlite3.Connection:
|
||||
"""Abre (criando se necessário) o banco SQLite de análises já com o esquema aplicado."""
|
||||
caminho = Path(caminho)
|
||||
caminho.parent.mkdir(parents=True, exist_ok=True)
|
||||
conexao = sqlite3.connect(str(caminho))
|
||||
conexao.row_factory = sqlite3.Row
|
||||
conexao.execute("PRAGMA foreign_keys = ON")
|
||||
conexao.execute("PRAGMA journal_mode = WAL")
|
||||
criar_esquema(conexao)
|
||||
return conexao
|
||||
@@ -0,0 +1,463 @@
|
||||
"""
|
||||
Módulo de consultas de acesso rápido ao banco de análises.
|
||||
|
||||
Este módulo define ``ConsultasDeAnalises``, responsável por ler o banco de
|
||||
análises por intenção (visão geral do vídeo, falas num intervalo, retakes,
|
||||
evidências visuais, cenas), para que um agente que vai montar ou editar um
|
||||
vídeo consulte o banco sem precisar escrever SQL nem carregar tabelas
|
||||
inteiras.
|
||||
|
||||
Este módulo não persiste dados — apenas lê. A escrita fica a cargo de
|
||||
``RepositorioDeTimelineSQLite``, ``RepositorioDeAnalisesSQLite`` e
|
||||
``RepositorioDeRetakesSQLite``, no mesmo pacote.
|
||||
|
||||
Cada método devolve apenas os campos relevantes para a decisão daquele
|
||||
nível (nunca a linha inteira da tabela, nunca sub-recursos que não foram
|
||||
pedidos), para manter as respostas compactas em tokens quando consumidas
|
||||
por um agente de IA.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from .conexao import abrir_banco
|
||||
|
||||
|
||||
class ErroDeConsultaInvalida(ValueError):
|
||||
"""Representa uma consulta feita com parâmetros inválidos ou incompletos."""
|
||||
|
||||
|
||||
class ConsultasDeAnalises:
|
||||
"""
|
||||
Consulta o banco de análises por intenção, devolvendo respostas compactas.
|
||||
|
||||
Esta classe não persiste dados e não altera o banco — todos os métodos
|
||||
são operações de leitura. A escrita é responsabilidade dos
|
||||
repositórios do pacote (``RepositorioDeTimelineSQLite``,
|
||||
``RepositorioDeAnalisesSQLite``, ``RepositorioDeRetakesSQLite``).
|
||||
|
||||
Atributos:
|
||||
conexao: Conexão SQLite já aberta e com o esquema aplicado.
|
||||
"""
|
||||
|
||||
def __init__(self, banco: str | Path | sqlite3.Connection = ".jhonny/analises.db") -> None:
|
||||
"""
|
||||
Inicializa as consultas a partir de um caminho de banco ou conexão existente.
|
||||
|
||||
Parâmetros:
|
||||
banco: Caminho do arquivo do banco de análises, ou uma conexão
|
||||
SQLite já aberta (reaproveitada sem reabrir).
|
||||
"""
|
||||
self.conexao = banco if isinstance(banco, sqlite3.Connection) else abrir_banco(banco)
|
||||
|
||||
def consultar_resumo_do_video(self, video_id: str) -> dict | None:
|
||||
"""
|
||||
Consulta a visão geral de um vídeo: o ponto de partida antes de descer a qualquer detalhe.
|
||||
|
||||
Parâmetros:
|
||||
video_id: Identificador do vídeo (mesmo id usado na timeline do Premiere).
|
||||
|
||||
Retorna:
|
||||
Um dicionário com nome, duração, resolução e as contagens de
|
||||
clipes, falas, retakes e cenas do vídeo, ou None quando o
|
||||
vídeo não existe no banco.
|
||||
|
||||
Pode gerar:
|
||||
ErroDeConsultaInvalida: quando ``video_id`` for vazio.
|
||||
"""
|
||||
self._validar_texto_obrigatorio("video_id", video_id)
|
||||
|
||||
video = self.conexao.execute(
|
||||
"SELECT nome, duracao, taxa_de_quadros, largura, altura FROM videos WHERE id = ?",
|
||||
(video_id,),
|
||||
).fetchone()
|
||||
if video is None:
|
||||
return None
|
||||
contagens = self.conexao.execute(
|
||||
"""SELECT
|
||||
(SELECT COUNT(*) FROM clipes WHERE video_id = ?) AS clipes,
|
||||
(SELECT COUNT(*) FROM segmentos_de_transcricao WHERE video_id = ?) AS falas,
|
||||
(SELECT COUNT(*) FROM grupos_de_retake WHERE video_id = ?) AS retakes,
|
||||
(SELECT COUNT(*) FROM cenas WHERE video_id = ?) AS cenas""",
|
||||
(video_id, video_id, video_id, video_id),
|
||||
).fetchone()
|
||||
return {
|
||||
"video_id": video_id,
|
||||
"nome": video["nome"],
|
||||
"duracao": video["duracao"],
|
||||
"taxa_de_quadros": video["taxa_de_quadros"],
|
||||
"resolucao": f"{video['largura']}x{video['altura']}" if video["largura"] else None,
|
||||
"total_clipes": contagens["clipes"],
|
||||
"total_falas": contagens["falas"],
|
||||
"total_retakes": contagens["retakes"],
|
||||
"total_cenas": contagens["cenas"],
|
||||
}
|
||||
|
||||
def listar_falas_no_intervalo(
|
||||
self,
|
||||
video_id: str,
|
||||
inicio: float = 0.0,
|
||||
fim: float | None = None,
|
||||
incluir_palavras: bool = False,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Lista as falas transcritas num intervalo da timeline, ordenadas por posição.
|
||||
|
||||
Não traz ``caracteristicas_acusticas`` nem palavra a palavra por
|
||||
padrão — só o necessário para decidir o que cortar. Use
|
||||
``incluir_palavras=True`` apenas quando o corte precisar acontecer
|
||||
no meio de uma frase.
|
||||
|
||||
Parâmetros:
|
||||
video_id: Identificador do vídeo.
|
||||
inicio: Início do intervalo, em segundos na timeline (padrão: 0.0).
|
||||
fim: Fim do intervalo, em segundos na timeline. Quando None,
|
||||
não há limite superior.
|
||||
incluir_palavras: Quando True, inclui a lista de palavras de
|
||||
cada fala (com início e fim próprios). Aumenta bastante o
|
||||
tamanho da resposta — usar somente quando necessário.
|
||||
|
||||
Retorna:
|
||||
Lista de dicionários com clipe, intervalo, texto, falante e
|
||||
emoção de cada fala encontrada. Lista vazia quando não há
|
||||
falas no intervalo.
|
||||
|
||||
Pode gerar:
|
||||
ErroDeConsultaInvalida: quando ``video_id`` for vazio, ``inicio``
|
||||
for negativo, ou ``fim`` for anterior a ``inicio``.
|
||||
"""
|
||||
self._validar_texto_obrigatorio("video_id", video_id)
|
||||
self._validar_intervalo(inicio, fim)
|
||||
|
||||
condicoes = ["video_id = ?", "fim >= ?"]
|
||||
parametros: list = [video_id, inicio]
|
||||
self._acrescentar_se_definido(condicoes, parametros, "inicio <= ?", fim)
|
||||
|
||||
linhas = self.conexao.execute(
|
||||
f"""SELECT id, clipe_id, inicio, fim, texto, confianca, falante, emocao
|
||||
FROM segmentos_de_transcricao
|
||||
WHERE {self._clausula_where(condicoes)}
|
||||
ORDER BY inicio""",
|
||||
parametros,
|
||||
).fetchall()
|
||||
|
||||
resultado = []
|
||||
for linha in linhas:
|
||||
item = {
|
||||
"clipe_id": linha["clipe_id"],
|
||||
"inicio": linha["inicio"],
|
||||
"fim": linha["fim"],
|
||||
"texto": linha["texto"],
|
||||
"falante": linha["falante"],
|
||||
"emocao": linha["emocao"],
|
||||
}
|
||||
if incluir_palavras:
|
||||
item["palavras"] = [
|
||||
{"texto": p["texto"], "inicio": p["inicio"], "fim": p["fim"]}
|
||||
for p in self.conexao.execute(
|
||||
"""SELECT texto, inicio, fim FROM palavras_de_transcricao
|
||||
WHERE segmento_id = ? ORDER BY ordem""",
|
||||
(linha["id"],),
|
||||
).fetchall()
|
||||
]
|
||||
resultado.append(item)
|
||||
return resultado
|
||||
|
||||
def consultar_clipe_no_instante(self, video_id: str, instante: float) -> dict | None:
|
||||
"""
|
||||
Consulta qual clipe da timeline cobre um instante específico.
|
||||
|
||||
Parâmetros:
|
||||
video_id: Identificador do vídeo.
|
||||
instante: Posição na timeline, em segundos.
|
||||
|
||||
Retorna:
|
||||
Um dicionário com o clipe encontrado (id, faixa, nome,
|
||||
intervalo e arquivo de origem), ou None quando nenhum clipe
|
||||
cobre o instante informado.
|
||||
|
||||
Pode gerar:
|
||||
ErroDeConsultaInvalida: quando ``video_id`` for vazio ou
|
||||
``instante`` for negativo.
|
||||
"""
|
||||
self._validar_texto_obrigatorio("video_id", video_id)
|
||||
if instante < 0:
|
||||
raise ErroDeConsultaInvalida("O instante consultado não pode ser negativo.")
|
||||
|
||||
linha = self.conexao.execute(
|
||||
"""SELECT id, faixa_id, nome, inicio_na_timeline, fim_na_timeline, arquivo
|
||||
FROM clipes
|
||||
WHERE video_id = ? AND inicio_na_timeline <= ? AND fim_na_timeline >= ?
|
||||
LIMIT 1""",
|
||||
(video_id, instante, instante),
|
||||
).fetchone()
|
||||
if linha is None:
|
||||
return None
|
||||
return {
|
||||
"clipe_id": linha["id"], "faixa_id": linha["faixa_id"], "nome": linha["nome"],
|
||||
"inicio": linha["inicio_na_timeline"], "fim": linha["fim_na_timeline"],
|
||||
"arquivo": linha["arquivo"],
|
||||
}
|
||||
|
||||
def listar_retakes_do_video(self, video_id: str) -> list[dict]:
|
||||
"""
|
||||
Lista o resumo dos grupos de retake de um vídeo.
|
||||
|
||||
Não traz as tomadas nem as evidências completas de cada grupo —
|
||||
use ``consultar_detalhes_do_retake`` para descer a esse nível
|
||||
quando o agente já tiver decidido qual grupo analisar.
|
||||
|
||||
Parâmetros:
|
||||
video_id: Identificador do vídeo.
|
||||
|
||||
Retorna:
|
||||
Lista de dicionários com o resumo de cada grupo (tipo,
|
||||
confiança, total de tomadas, intervalo coberto e a tomada de
|
||||
maior confiança). Lista vazia quando o vídeo não tem retakes.
|
||||
|
||||
Pode gerar:
|
||||
ErroDeConsultaInvalida: quando ``video_id`` for vazio.
|
||||
"""
|
||||
self._validar_texto_obrigatorio("video_id", video_id)
|
||||
|
||||
grupos = self.conexao.execute(
|
||||
"""SELECT id, tipo, confianca FROM grupos_de_retake
|
||||
WHERE video_id = ? ORDER BY criado_em""",
|
||||
(video_id,),
|
||||
).fetchall()
|
||||
resultado = []
|
||||
for grupo in grupos:
|
||||
extremos = self.conexao.execute(
|
||||
"""SELECT COUNT(*) AS n, MIN(inicio) AS inicio, MAX(fim) AS fim
|
||||
FROM tomadas_de_retake WHERE grupo_id = ?""",
|
||||
(grupo["id"],),
|
||||
).fetchone()
|
||||
tomada_principal = self.conexao.execute(
|
||||
"""SELECT fala_id, inicio, fim FROM tomadas_de_retake
|
||||
WHERE grupo_id = ? ORDER BY confianca DESC LIMIT 1""",
|
||||
(grupo["id"],),
|
||||
).fetchone()
|
||||
resultado.append({
|
||||
"grupo_id": grupo["id"], "tipo": grupo["tipo"], "confianca": grupo["confianca"],
|
||||
"total_tomadas": extremos["n"], "inicio": extremos["inicio"],
|
||||
"fim": extremos["fim"],
|
||||
"tomada_principal": (
|
||||
{"fala_id": tomada_principal["fala_id"], "inicio": tomada_principal["inicio"],
|
||||
"fim": tomada_principal["fim"]} if tomada_principal else None
|
||||
),
|
||||
})
|
||||
return resultado
|
||||
|
||||
def consultar_detalhes_do_retake(self, grupo_id: str) -> dict | None:
|
||||
"""
|
||||
Consulta as tomadas e evidências completas de um grupo de retake específico.
|
||||
|
||||
Parâmetros:
|
||||
grupo_id: Identificador do grupo de retake (ex.: ``retake_421fa7``).
|
||||
|
||||
Retorna:
|
||||
Um dicionário com tipo, confiança, a lista completa de
|
||||
tomadas (ordem, fala, intervalo, texto) e a lista completa de
|
||||
evidências (tipo, descrição, valor) do grupo, ou None quando
|
||||
o grupo não existe.
|
||||
|
||||
Pode gerar:
|
||||
ErroDeConsultaInvalida: quando ``grupo_id`` for vazio.
|
||||
"""
|
||||
self._validar_texto_obrigatorio("grupo_id", grupo_id)
|
||||
|
||||
grupo = self.conexao.execute(
|
||||
"SELECT id, video_id, faixa_id, tipo, confianca FROM grupos_de_retake WHERE id = ?",
|
||||
(grupo_id,),
|
||||
).fetchone()
|
||||
if grupo is None:
|
||||
return None
|
||||
tomadas = [
|
||||
{"ordem": t["ordem"], "fala_id": t["fala_id"], "inicio": t["inicio"], "fim": t["fim"],
|
||||
"texto": t["texto"], "confianca": t["confianca"]}
|
||||
for t in self.conexao.execute(
|
||||
"""SELECT ordem, fala_id, inicio, fim, texto, confianca
|
||||
FROM tomadas_de_retake WHERE grupo_id = ? ORDER BY ordem""",
|
||||
(grupo_id,),
|
||||
).fetchall()
|
||||
]
|
||||
evidencias = [
|
||||
{"tipo": e["tipo"], "descricao": e["descricao"], "valor": e["valor"]}
|
||||
for e in self.conexao.execute(
|
||||
"SELECT tipo, descricao, valor FROM evidencias_de_retake WHERE grupo_id = ?",
|
||||
(grupo_id,),
|
||||
).fetchall()
|
||||
]
|
||||
return {
|
||||
"grupo_id": grupo["id"], "tipo": grupo["tipo"], "confianca": grupo["confianca"],
|
||||
"tomadas": tomadas, "evidencias": evidencias,
|
||||
}
|
||||
|
||||
def listar_evidencias_visuais_do_clipe(
|
||||
self,
|
||||
video_id: str,
|
||||
clipe_id: str,
|
||||
tipo: str | None = None,
|
||||
confianca_minima: float | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Lista as evidências visuais de um clipe (qualidade, rosto, composição, tremor...).
|
||||
|
||||
Parâmetros:
|
||||
video_id: Identificador do vídeo.
|
||||
clipe_id: Identificador do clipe dentro da timeline do vídeo.
|
||||
tipo: Quando informado, filtra só as evidências desse tipo
|
||||
(ex.: ``"qualidade"``, ``"rosto"``). Recomendado sempre
|
||||
que o agente já souber o que procura, para reduzir o
|
||||
tamanho da resposta.
|
||||
confianca_minima: Quando informado, descarta evidências com
|
||||
confiança abaixo desse valor.
|
||||
|
||||
Retorna:
|
||||
Lista de dicionários com tipo, intervalo, valor (payload
|
||||
próprio de cada analisador), confiança e provider de cada
|
||||
evidência. Lista vazia quando não há evidências que atendam
|
||||
ao filtro.
|
||||
|
||||
Pode gerar:
|
||||
ErroDeConsultaInvalida: quando ``video_id``/``clipe_id`` forem
|
||||
vazios ou ``confianca_minima`` estiver fora de [0.0, 1.0].
|
||||
"""
|
||||
self._validar_texto_obrigatorio("video_id", video_id)
|
||||
self._validar_texto_obrigatorio("clipe_id", clipe_id)
|
||||
self._validar_confianca(confianca_minima)
|
||||
|
||||
condicoes = ["video_id = ?", "clipe_id = ?"]
|
||||
parametros: list = [video_id, clipe_id]
|
||||
self._acrescentar_se_definido(condicoes, parametros, "tipo = ?", tipo)
|
||||
self._acrescentar_se_definido(
|
||||
condicoes, parametros, "(confianca IS NULL OR confianca >= ?)", confianca_minima)
|
||||
|
||||
linhas = self.conexao.execute(
|
||||
f"""SELECT tipo, inicio, fim, valor, confianca, provider
|
||||
FROM evidencias_visuais
|
||||
WHERE {self._clausula_where(condicoes)}
|
||||
ORDER BY inicio""",
|
||||
parametros,
|
||||
).fetchall()
|
||||
return [
|
||||
{"tipo": linha["tipo"], "inicio": linha["inicio"], "fim": linha["fim"],
|
||||
"valor": json.loads(linha["valor"]), "confianca": linha["confianca"],
|
||||
"provider": linha["provider"]}
|
||||
for linha in linhas
|
||||
]
|
||||
|
||||
def listar_cenas_do_video(self, video_id: str, clipe_id: str | None = None) -> list[dict]:
|
||||
"""
|
||||
Lista os cortes de cena detectados no vídeo, ou só de um clipe específico.
|
||||
|
||||
Parâmetros:
|
||||
video_id: Identificador do vídeo.
|
||||
clipe_id: Quando informado, restringe o resultado às cenas
|
||||
desse clipe. Quando None, traz as cenas do vídeo inteiro.
|
||||
|
||||
Retorna:
|
||||
Lista de dicionários com clipe, intervalo e confiança de cada
|
||||
cena, ordenados pela posição na timeline. Lista vazia quando
|
||||
não há cenas detectadas.
|
||||
|
||||
Pode gerar:
|
||||
ErroDeConsultaInvalida: quando ``video_id`` for vazio.
|
||||
"""
|
||||
self._validar_texto_obrigatorio("video_id", video_id)
|
||||
|
||||
condicoes = ["video_id = ?"]
|
||||
parametros: list = [video_id]
|
||||
self._acrescentar_se_definido(condicoes, parametros, "clipe_id = ?", clipe_id)
|
||||
|
||||
linhas = self.conexao.execute(
|
||||
f"""SELECT clipe_id, inicio, fim, confianca FROM cenas
|
||||
WHERE {self._clausula_where(condicoes)} ORDER BY inicio""",
|
||||
parametros,
|
||||
).fetchall()
|
||||
return [
|
||||
{"clipe_id": linha["clipe_id"], "inicio": linha["inicio"], "fim": linha["fim"],
|
||||
"confianca": linha["confianca"]}
|
||||
for linha in linhas
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _clausula_where(condicoes: list[str]) -> str:
|
||||
"""Monta a cláusula ``WHERE`` a partir das condições já validadas."""
|
||||
return " AND ".join(condicoes)
|
||||
|
||||
@staticmethod
|
||||
def _acrescentar_se_definido(
|
||||
condicoes: list[str], parametros: list, fragmento_sql: str, valor: object,
|
||||
) -> None:
|
||||
"""
|
||||
Acrescenta um filtro opcional à consulta, apenas quando o valor foi informado.
|
||||
|
||||
Centraliza o padrão repetido de "só filtra por este campo quando
|
||||
o chamador passou um valor", usado por várias consultas deste
|
||||
módulo para evitar duplicar a montagem do ``WHERE`` dinâmico.
|
||||
|
||||
Parâmetros:
|
||||
condicoes: Lista de condições SQL já acumuladas (alterada
|
||||
no local).
|
||||
parametros: Lista de parâmetros posicionais já acumulados
|
||||
(alterada no local).
|
||||
fragmento_sql: Condição SQL com um único placeholder ``?``.
|
||||
valor: Valor do filtro. Quando None, nada é acrescentado.
|
||||
"""
|
||||
if valor is not None:
|
||||
condicoes.append(fragmento_sql)
|
||||
parametros.append(valor)
|
||||
|
||||
@staticmethod
|
||||
def _validar_texto_obrigatorio(nome_do_campo: str, valor: str) -> None:
|
||||
"""
|
||||
Garante que um identificador obrigatório foi informado e não é vazio.
|
||||
|
||||
Parâmetros:
|
||||
nome_do_campo: Nome do parâmetro, usado na mensagem de erro.
|
||||
valor: Valor recebido para o campo.
|
||||
|
||||
Pode gerar:
|
||||
ErroDeConsultaInvalida: quando ``valor`` for None, vazio ou
|
||||
composto só por espaços.
|
||||
"""
|
||||
if not valor or not str(valor).strip():
|
||||
raise ErroDeConsultaInvalida(
|
||||
f"O campo '{nome_do_campo}' é obrigatório e não pode ser vazio.")
|
||||
|
||||
@staticmethod
|
||||
def _validar_intervalo(inicio: float, fim: float | None) -> None:
|
||||
"""
|
||||
Garante que um intervalo de tempo é consistente.
|
||||
|
||||
Parâmetros:
|
||||
inicio: Início do intervalo, em segundos.
|
||||
fim: Fim do intervalo, em segundos, ou None quando não há limite.
|
||||
|
||||
Pode gerar:
|
||||
ErroDeConsultaInvalida: quando ``inicio`` for negativo ou
|
||||
``fim`` for anterior a ``inicio``.
|
||||
"""
|
||||
if inicio < 0:
|
||||
raise ErroDeConsultaInvalida("O início do intervalo não pode ser negativo.")
|
||||
if fim is not None and fim < inicio:
|
||||
raise ErroDeConsultaInvalida("O fim do intervalo não pode ser anterior ao início.")
|
||||
|
||||
@staticmethod
|
||||
def _validar_confianca(confianca_minima: float | None) -> None:
|
||||
"""
|
||||
Garante que um limiar de confiança está dentro da faixa válida.
|
||||
|
||||
Parâmetros:
|
||||
confianca_minima: Valor a validar, ou None quando não informado.
|
||||
|
||||
Pode gerar:
|
||||
ErroDeConsultaInvalida: quando o valor estiver fora de
|
||||
[0.0, 1.0].
|
||||
"""
|
||||
if confianca_minima is not None and not (0.0 <= confianca_minima <= 1.0):
|
||||
raise ErroDeConsultaInvalida("confianca_minima deve estar entre 0.0 e 1.0.")
|
||||
@@ -0,0 +1,166 @@
|
||||
"""DDL do banco SQLite de análises (vídeo, imagem e áudio)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS videos (
|
||||
id TEXT PRIMARY KEY,
|
||||
nome TEXT,
|
||||
duracao REAL,
|
||||
taxa_de_quadros REAL,
|
||||
largura INTEGER,
|
||||
altura INTEGER,
|
||||
criado_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS faixas (
|
||||
id TEXT NOT NULL,
|
||||
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
|
||||
nome TEXT,
|
||||
tipo TEXT,
|
||||
indice INTEGER,
|
||||
PRIMARY KEY (video_id, id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clipes (
|
||||
id TEXT NOT NULL,
|
||||
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
|
||||
faixa_id TEXT NOT NULL,
|
||||
nome TEXT,
|
||||
inicio_na_timeline REAL NOT NULL,
|
||||
fim_na_timeline REAL NOT NULL,
|
||||
inicio_na_origem REAL,
|
||||
fim_na_origem REAL,
|
||||
arquivo TEXT,
|
||||
offline INTEGER NOT NULL DEFAULT 0,
|
||||
metadados TEXT,
|
||||
PRIMARY KEY (video_id, id),
|
||||
FOREIGN KEY (video_id, faixa_id) REFERENCES faixas(video_id, id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_clipes_faixa ON clipes(video_id, faixa_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arquivos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_id TEXT REFERENCES videos(id) ON DELETE CASCADE,
|
||||
caminho TEXT NOT NULL,
|
||||
formato TEXT,
|
||||
duracao REAL,
|
||||
codec_de_video TEXT,
|
||||
codec_de_audio TEXT,
|
||||
largura INTEGER,
|
||||
altura INTEGER,
|
||||
taxa_de_quadros REAL,
|
||||
canais_de_audio INTEGER,
|
||||
taxa_de_amostragem INTEGER,
|
||||
tamanho_em_bytes INTEGER,
|
||||
orientacao TEXT,
|
||||
timecode TEXT,
|
||||
hash_do_conteudo TEXT,
|
||||
UNIQUE (video_id, caminho)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analises_versao (
|
||||
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
|
||||
etapa TEXT NOT NULL,
|
||||
hash_versao TEXT NOT NULL,
|
||||
concluido_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
PRIMARY KEY (video_id, etapa)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS segmentos_de_transcricao (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
|
||||
clipe_id TEXT NOT NULL,
|
||||
inicio REAL NOT NULL,
|
||||
fim REAL NOT NULL,
|
||||
texto TEXT NOT NULL,
|
||||
confianca REAL,
|
||||
falante TEXT,
|
||||
voz_aparente TEXT,
|
||||
confianca_voz REAL,
|
||||
emocao TEXT,
|
||||
confianca_emocao REAL,
|
||||
caracteristicas_acusticas TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_segmentos_video_clipe
|
||||
ON segmentos_de_transcricao(video_id, clipe_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS palavras_de_transcricao (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
segmento_id INTEGER NOT NULL REFERENCES segmentos_de_transcricao(id) ON DELETE CASCADE,
|
||||
ordem INTEGER NOT NULL,
|
||||
texto TEXT NOT NULL,
|
||||
inicio REAL NOT NULL,
|
||||
fim REAL NOT NULL,
|
||||
confianca REAL,
|
||||
falante TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_palavras_segmento ON palavras_de_transcricao(segmento_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evidencias_visuais (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
|
||||
clipe_id TEXT NOT NULL,
|
||||
tipo TEXT NOT NULL,
|
||||
inicio REAL NOT NULL,
|
||||
fim REAL NOT NULL,
|
||||
valor TEXT NOT NULL,
|
||||
confianca REAL,
|
||||
provider TEXT NOT NULL,
|
||||
modelo TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_evidencias_video_clipe
|
||||
ON evidencias_visuais(video_id, clipe_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cenas (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
|
||||
clipe_id TEXT,
|
||||
inicio REAL NOT NULL,
|
||||
fim REAL NOT NULL,
|
||||
confianca REAL,
|
||||
referencias TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cenas_video ON cenas(video_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS grupos_de_retake (
|
||||
id TEXT PRIMARY KEY,
|
||||
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
|
||||
faixa_id TEXT NOT NULL,
|
||||
tipo TEXT NOT NULL,
|
||||
confianca REAL NOT NULL,
|
||||
criado_em TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_grupos_video ON grupos_de_retake(video_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tomadas_de_retake (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
grupo_id TEXT NOT NULL REFERENCES grupos_de_retake(id) ON DELETE CASCADE,
|
||||
ordem INTEGER NOT NULL,
|
||||
fala_id TEXT NOT NULL,
|
||||
segmento_id TEXT NOT NULL,
|
||||
inicio REAL NOT NULL,
|
||||
fim REAL NOT NULL,
|
||||
texto TEXT NOT NULL,
|
||||
similaridade_com_anterior REAL NOT NULL,
|
||||
confianca REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tomadas_grupo ON tomadas_de_retake(grupo_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evidencias_de_retake (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
grupo_id TEXT NOT NULL REFERENCES grupos_de_retake(id) ON DELETE CASCADE,
|
||||
tipo TEXT NOT NULL,
|
||||
descricao TEXT NOT NULL,
|
||||
valor REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_evidencias_retake_grupo ON evidencias_de_retake(grupo_id);
|
||||
"""
|
||||
|
||||
|
||||
def criar_esquema(conexao: sqlite3.Connection) -> None:
|
||||
"""Cria (de forma idempotente) todas as tabelas do banco de análises."""
|
||||
conexao.executescript(_DDL)
|
||||
conexao.commit()
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Persistência SQLite de metadados de arquivo, transcrição e evidências visuais.
|
||||
|
||||
Complementa ``RepositorioDeRetakesSQLite`` gravando o restante do que o
|
||||
``ContextoDeAnalise`` do pipeline produz e que hoje só vive em memória
|
||||
(metadados técnicos) ou em cache JSON por arquivo (transcrição).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from ..integracoes.midia.extracao_de_metadados import MetadadosDoArquivo
|
||||
from ..scanner.modelos import Cena, EvidenciaVisual, PalavraDeTranscricao, SegmentoDeTranscricao
|
||||
from ..scanner.transcricao_da_timeline import TranscricaoDoClipe
|
||||
from .conexao import abrir_banco
|
||||
|
||||
|
||||
class RepositorioDeAnalisesSQLite:
|
||||
"""Grava metadados de arquivo, transcrição e evidências/cenas visuais."""
|
||||
|
||||
def __init__(self, banco: str | Path | sqlite3.Connection = ".jhonny/analises.db") -> None:
|
||||
self.conexao = banco if isinstance(banco, sqlite3.Connection) else abrir_banco(banco)
|
||||
|
||||
def _garantir_video(self, video_id: str) -> None:
|
||||
self.conexao.execute("INSERT OR IGNORE INTO videos (id) VALUES (?)", (video_id,))
|
||||
|
||||
def registrar_metadados_de_arquivo(
|
||||
self, video_id: str, metadados: MetadadosDoArquivo, hash_do_conteudo: str | None = None,
|
||||
) -> None:
|
||||
with self.conexao:
|
||||
self._garantir_video(video_id)
|
||||
self.conexao.execute(
|
||||
"""INSERT INTO arquivos
|
||||
(video_id, caminho, formato, duracao, codec_de_video, codec_de_audio,
|
||||
largura, altura, taxa_de_quadros, canais_de_audio, taxa_de_amostragem,
|
||||
tamanho_em_bytes, orientacao, timecode, hash_do_conteudo)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (video_id, caminho) DO UPDATE SET
|
||||
formato = excluded.formato, duracao = excluded.duracao,
|
||||
codec_de_video = excluded.codec_de_video,
|
||||
codec_de_audio = excluded.codec_de_audio,
|
||||
largura = excluded.largura, altura = excluded.altura,
|
||||
taxa_de_quadros = excluded.taxa_de_quadros,
|
||||
canais_de_audio = excluded.canais_de_audio,
|
||||
taxa_de_amostragem = excluded.taxa_de_amostragem,
|
||||
tamanho_em_bytes = excluded.tamanho_em_bytes,
|
||||
orientacao = excluded.orientacao, timecode = excluded.timecode,
|
||||
hash_do_conteudo = excluded.hash_do_conteudo""",
|
||||
(video_id, str(metadados.caminho), metadados.formato, metadados.duracao,
|
||||
metadados.codec_de_video, metadados.codec_de_audio, metadados.largura,
|
||||
metadados.altura, metadados.taxa_de_quadros, metadados.canais_de_audio,
|
||||
metadados.taxa_de_amostragem, metadados.tamanho_em_bytes,
|
||||
metadados.orientacao, metadados.timecode, hash_do_conteudo),
|
||||
)
|
||||
|
||||
def registrar_transcricoes(
|
||||
self, video_id: str, transcricoes: Iterable[TranscricaoDoClipe],
|
||||
) -> None:
|
||||
with self.conexao:
|
||||
self._garantir_video(video_id)
|
||||
for transcricao in transcricoes:
|
||||
for segmento in transcricao.segmentos:
|
||||
self._inserir_segmento(video_id, transcricao.identificador_do_clipe, segmento)
|
||||
|
||||
def _inserir_segmento(
|
||||
self, video_id: str, clipe_id: str, segmento: SegmentoDeTranscricao,
|
||||
) -> int:
|
||||
cursor = self.conexao.execute(
|
||||
"""INSERT INTO segmentos_de_transcricao
|
||||
(video_id, clipe_id, inicio, fim, texto, confianca, falante, voz_aparente,
|
||||
confianca_voz, emocao, confianca_emocao, caracteristicas_acusticas)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(video_id, clipe_id, segmento.inicio, segmento.fim, segmento.texto,
|
||||
segmento.confianca, segmento.falante, segmento.voz_aparente,
|
||||
segmento.confianca_voz, segmento.emocao, segmento.confianca_emocao,
|
||||
json.dumps(segmento.caracteristicas_acusticas, ensure_ascii=False)),
|
||||
)
|
||||
segmento_id = cursor.lastrowid
|
||||
for ordem, palavra in enumerate(segmento.palavras):
|
||||
self.conexao.execute(
|
||||
"""INSERT INTO palavras_de_transcricao
|
||||
(segmento_id, ordem, texto, inicio, fim, confianca, falante)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(segmento_id, ordem, palavra.texto, palavra.inicio, palavra.fim,
|
||||
palavra.confianca, palavra.falante),
|
||||
)
|
||||
return segmento_id
|
||||
|
||||
def carregar_transcricoes(self, video_id: str, clipe_id: str) -> list[SegmentoDeTranscricao]:
|
||||
segmentos: list[SegmentoDeTranscricao] = []
|
||||
for linha in self.conexao.execute(
|
||||
"""SELECT * FROM segmentos_de_transcricao
|
||||
WHERE video_id = ? AND clipe_id = ? ORDER BY inicio""",
|
||||
(video_id, clipe_id),
|
||||
).fetchall():
|
||||
palavras = tuple(
|
||||
PalavraDeTranscricao(
|
||||
texto=item["texto"], inicio=item["inicio"], fim=item["fim"],
|
||||
confianca=item["confianca"], falante=item["falante"],
|
||||
)
|
||||
for item in self.conexao.execute(
|
||||
"SELECT * FROM palavras_de_transcricao WHERE segmento_id = ? ORDER BY ordem",
|
||||
(linha["id"],),
|
||||
).fetchall()
|
||||
)
|
||||
segmentos.append(SegmentoDeTranscricao(
|
||||
inicio=linha["inicio"], fim=linha["fim"], texto=linha["texto"],
|
||||
confianca=linha["confianca"], palavras=palavras,
|
||||
emocao=linha["emocao"], confianca_emocao=linha["confianca_emocao"],
|
||||
caracteristicas_acusticas=json.loads(linha["caracteristicas_acusticas"] or "{}"),
|
||||
falante=linha["falante"], voz_aparente=linha["voz_aparente"],
|
||||
confianca_voz=linha["confianca_voz"],
|
||||
))
|
||||
return segmentos
|
||||
|
||||
def registrar_evidencias_visuais(
|
||||
self, video_id: str, clipe_id: str, evidencias: Iterable[EvidenciaVisual],
|
||||
) -> None:
|
||||
with self.conexao:
|
||||
self._garantir_video(video_id)
|
||||
for evidencia in evidencias:
|
||||
self.conexao.execute(
|
||||
"""INSERT INTO evidencias_visuais
|
||||
(video_id, clipe_id, tipo, inicio, fim, valor, confianca, provider, modelo)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(video_id, clipe_id, evidencia.tipo, evidencia.inicio, evidencia.fim,
|
||||
json.dumps(evidencia.valor, ensure_ascii=False), evidencia.confianca,
|
||||
evidencia.provider, evidencia.modelo),
|
||||
)
|
||||
|
||||
def registrar_cenas(
|
||||
self, video_id: str, cenas: Iterable[Cena], clipe_id: str | None = None,
|
||||
) -> None:
|
||||
with self.conexao:
|
||||
self._garantir_video(video_id)
|
||||
for cena in cenas:
|
||||
self.conexao.execute(
|
||||
"""INSERT INTO cenas (video_id, clipe_id, inicio, fim, confianca, referencias)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(video_id, clipe_id, cena.inicio, cena.fim, cena.confianca,
|
||||
json.dumps(list(cena.referencias))),
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Persistência SQLite dos grupos de retakes.
|
||||
|
||||
Implementação alternativa a ``scanner/retakes/repositorio_de_retakes.RepositorioDeRetakes``,
|
||||
com a mesma interface pública (``registrar``/``carregar``/``ja_analisado``), gravando nas
|
||||
tabelas ``grupos_de_retake``, ``tomadas_de_retake`` e ``evidencias_de_retake`` e usando
|
||||
``analises_versao`` (etapa ``"retakes"``) para a idempotência por hash de versão.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from ..scanner.retakes.modelos_de_retakes import EvidenciaDeRetake, GrupoDeRetake, TomadaDeRetake
|
||||
from .conexao import abrir_banco
|
||||
|
||||
VERSAO_REGRA_FALAS = 1
|
||||
VERSAO_REGRA_TEXTO = 1
|
||||
VERSAO_MODELO_PADRAO = "lexico"
|
||||
ETAPA = "retakes"
|
||||
|
||||
|
||||
class RepositorioDeRetakesSQLite:
|
||||
"""Persiste e recupera grupos de retakes em um banco SQLite."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
banco: str | Path | sqlite3.Connection = ".jhonny/analises.db",
|
||||
versao_transcricao: str = "1",
|
||||
versao_regras: str = f"regras:{VERSAO_REGRA_FALAS}.{VERSAO_REGRA_TEXTO}",
|
||||
versao_modelo: str = VERSAO_MODELO_PADRAO,
|
||||
) -> None:
|
||||
self.conexao = banco if isinstance(banco, sqlite3.Connection) else abrir_banco(banco)
|
||||
self.versao_transcricao = versao_transcricao
|
||||
self.versao_regras = versao_regras
|
||||
self.versao_modelo = versao_modelo
|
||||
|
||||
def _hash_de_identificacao(self, video_id: str, versao_visual: str = "sem_visual") -> str:
|
||||
origem = "|".join([video_id, self.versao_transcricao, self.versao_regras,
|
||||
self.versao_modelo, versao_visual])
|
||||
return hashlib.sha256(origem.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
def ja_analisado(self, video_id: str, versao_visual: str = "sem_visual") -> bool:
|
||||
"""Verdadeiro se o vídeo já foi analisado com exatamente estas versões."""
|
||||
linha = self.conexao.execute(
|
||||
"SELECT hash_versao FROM analises_versao WHERE video_id = ? AND etapa = ?",
|
||||
(video_id, ETAPA),
|
||||
).fetchone()
|
||||
if linha is None:
|
||||
return False
|
||||
return linha["hash_versao"] == self._hash_de_identificacao(video_id, versao_visual)
|
||||
|
||||
def registrar(
|
||||
self,
|
||||
video_id: str,
|
||||
grupos: Iterable[GrupoDeRetake],
|
||||
versao_visual: str = "sem_visual",
|
||||
) -> None:
|
||||
"""Grava os grupos de forma idempotente (substitui os grupos anteriores do vídeo)."""
|
||||
grupos = list(grupos)
|
||||
with self.conexao:
|
||||
self.conexao.execute(
|
||||
"INSERT OR IGNORE INTO videos (id) VALUES (?)", (video_id,)
|
||||
)
|
||||
self.conexao.execute(
|
||||
"DELETE FROM grupos_de_retake WHERE video_id = ?", (video_id,)
|
||||
)
|
||||
for grupo in grupos:
|
||||
self.conexao.execute(
|
||||
"""INSERT INTO grupos_de_retake
|
||||
(id, video_id, faixa_id, tipo, confianca, criado_em)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(grupo.id, grupo.video_id, grupo.faixa_id, grupo.tipo,
|
||||
grupo.confianca, grupo.criado_em),
|
||||
)
|
||||
for tomada in grupo.tomadas:
|
||||
self.conexao.execute(
|
||||
"""INSERT INTO tomadas_de_retake
|
||||
(grupo_id, ordem, fala_id, segmento_id, inicio, fim, texto,
|
||||
similaridade_com_anterior, confianca)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(grupo.id, tomada.ordem, tomada.fala_id, tomada.segmento_id,
|
||||
tomada.inicio, tomada.fim, tomada.texto,
|
||||
tomada.similaridade_com_anterior, tomada.confianca),
|
||||
)
|
||||
for evidencia in grupo.evidencias:
|
||||
self.conexao.execute(
|
||||
"""INSERT INTO evidencias_de_retake (grupo_id, tipo, descricao, valor)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(grupo.id, evidencia.tipo, evidencia.descricao, evidencia.valor),
|
||||
)
|
||||
self.conexao.execute(
|
||||
"""INSERT INTO analises_versao (video_id, etapa, hash_versao)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (video_id, etapa)
|
||||
DO UPDATE SET hash_versao = excluded.hash_versao,
|
||||
concluido_em = strftime('%Y-%m-%dT%H:%M:%fZ','now')""",
|
||||
(video_id, ETAPA, self._hash_de_identificacao(video_id, versao_visual)),
|
||||
)
|
||||
|
||||
def carregar(self, video_id: str) -> list[GrupoDeRetake]:
|
||||
"""Carrega os grupos já persistidos para o vídeo."""
|
||||
linhas_de_grupos = self.conexao.execute(
|
||||
"SELECT * FROM grupos_de_retake WHERE video_id = ? ORDER BY criado_em",
|
||||
(video_id,),
|
||||
).fetchall()
|
||||
|
||||
grupos: list[GrupoDeRetake] = []
|
||||
for linha in linhas_de_grupos:
|
||||
tomadas = [
|
||||
TomadaDeRetake(
|
||||
ordem=item["ordem"],
|
||||
fala_id=item["fala_id"],
|
||||
segmento_id=item["segmento_id"],
|
||||
inicio=item["inicio"],
|
||||
fim=item["fim"],
|
||||
texto=item["texto"],
|
||||
similaridade_com_anterior=item["similaridade_com_anterior"],
|
||||
confianca=item["confianca"],
|
||||
)
|
||||
for item in self.conexao.execute(
|
||||
"SELECT * FROM tomadas_de_retake WHERE grupo_id = ? ORDER BY ordem",
|
||||
(linha["id"],),
|
||||
).fetchall()
|
||||
]
|
||||
evidencias = [
|
||||
EvidenciaDeRetake(
|
||||
tipo=item["tipo"], descricao=item["descricao"], valor=item["valor"],
|
||||
)
|
||||
for item in self.conexao.execute(
|
||||
"SELECT * FROM evidencias_de_retake WHERE grupo_id = ? ORDER BY id",
|
||||
(linha["id"],),
|
||||
).fetchall()
|
||||
]
|
||||
grupos.append(GrupoDeRetake(
|
||||
id=linha["id"],
|
||||
video_id=linha["video_id"],
|
||||
faixa_id=linha["faixa_id"],
|
||||
tipo=linha["tipo"],
|
||||
confianca=linha["confianca"],
|
||||
tomadas=tomadas,
|
||||
evidencias=evidencias,
|
||||
criado_em=linha["criado_em"],
|
||||
))
|
||||
return grupos
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Persistência SQLite da estrutura da timeline (faixas e clipes).
|
||||
|
||||
É a "coluna vertebral" que liga os `clipe_id`/`video_id` usados em
|
||||
transcrição, evidências visuais e retakes de volta à timeline real —
|
||||
para que um agente de edição possa navegar tudo por consultas SQL, sem
|
||||
precisar reabrir os arquivos de origem ou o projeto de edição.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from ..dominio.entidades.modelos import Clipe, Faixa, IntervaloDeTempo, Timeline
|
||||
from .conexao import abrir_banco
|
||||
|
||||
|
||||
class RepositorioDeTimelineSQLite:
|
||||
"""Grava e recarrega uma `Timeline` (faixas e clipes) por vídeo."""
|
||||
|
||||
def __init__(self, banco: str | Path | sqlite3.Connection = ".jhonny/analises.db") -> None:
|
||||
self.conexao = banco if isinstance(banco, sqlite3.Connection) else abrir_banco(banco)
|
||||
|
||||
def registrar_timeline(self, timeline: Timeline) -> None:
|
||||
"""Grava a timeline de forma idempotente (substitui faixas/clipes anteriores)."""
|
||||
video_id = timeline.identificador
|
||||
with self.conexao:
|
||||
self.conexao.execute(
|
||||
"""INSERT INTO videos (id, nome, duracao, taxa_de_quadros, largura, altura)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
nome = excluded.nome, duracao = excluded.duracao,
|
||||
taxa_de_quadros = excluded.taxa_de_quadros,
|
||||
largura = excluded.largura, altura = excluded.altura""",
|
||||
(video_id, timeline.nome, timeline.duracao, timeline.taxa_de_quadros,
|
||||
timeline.largura, timeline.altura),
|
||||
)
|
||||
# CASCADE em clipes/faixas cuida da limpeza ao apagar as faixas do vídeo.
|
||||
self.conexao.execute("DELETE FROM faixas WHERE video_id = ?", (video_id,))
|
||||
for faixa in timeline.faixas:
|
||||
self.conexao.execute(
|
||||
"INSERT INTO faixas (id, video_id, nome, tipo, indice) VALUES (?, ?, ?, ?, ?)",
|
||||
(faixa.identificador, video_id, faixa.nome, faixa.tipo, faixa.indice),
|
||||
)
|
||||
for clipe in faixa.clipes:
|
||||
origem = clipe.intervalo_na_origem
|
||||
self.conexao.execute(
|
||||
"""INSERT INTO clipes
|
||||
(id, video_id, faixa_id, nome, inicio_na_timeline, fim_na_timeline,
|
||||
inicio_na_origem, fim_na_origem, arquivo, offline, metadados)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(clipe.identificador, video_id, faixa.identificador, clipe.nome,
|
||||
clipe.intervalo_na_timeline.inicio, clipe.intervalo_na_timeline.fim,
|
||||
origem.inicio if origem else None, origem.fim if origem else None,
|
||||
clipe.arquivo, int(clipe.offline),
|
||||
json.dumps(clipe.metadados, ensure_ascii=False)),
|
||||
)
|
||||
|
||||
def carregar_timeline(self, video_id: str) -> Timeline | None:
|
||||
"""Reconstrói a `Timeline` (faixas e clipes) a partir do banco."""
|
||||
video = self.conexao.execute(
|
||||
"SELECT * FROM videos WHERE id = ?", (video_id,),
|
||||
).fetchone()
|
||||
if video is None:
|
||||
return None
|
||||
|
||||
faixas: list[Faixa] = []
|
||||
for linha_faixa in self.conexao.execute(
|
||||
"SELECT * FROM faixas WHERE video_id = ? ORDER BY indice", (video_id,),
|
||||
).fetchall():
|
||||
clipes = [
|
||||
Clipe(
|
||||
identificador=item["id"],
|
||||
nome=item["nome"],
|
||||
intervalo_na_timeline=IntervaloDeTempo(
|
||||
item["inicio_na_timeline"], item["fim_na_timeline"]),
|
||||
intervalo_na_origem=(
|
||||
IntervaloDeTempo(item["inicio_na_origem"], item["fim_na_origem"])
|
||||
if item["inicio_na_origem"] is not None else None
|
||||
),
|
||||
arquivo=item["arquivo"],
|
||||
identificador_da_faixa=item["faixa_id"],
|
||||
offline=bool(item["offline"]),
|
||||
metadados=json.loads(item["metadados"] or "{}"),
|
||||
)
|
||||
for item in self.conexao.execute(
|
||||
"SELECT * FROM clipes WHERE video_id = ? AND faixa_id = ? "
|
||||
"ORDER BY inicio_na_timeline",
|
||||
(video_id, linha_faixa["id"]),
|
||||
).fetchall()
|
||||
]
|
||||
faixas.append(Faixa(
|
||||
identificador=linha_faixa["id"], nome=linha_faixa["nome"],
|
||||
tipo=linha_faixa["tipo"], indice=linha_faixa["indice"], clipes=clipes,
|
||||
))
|
||||
|
||||
return Timeline(
|
||||
identificador=video["id"], nome=video["nome"], duracao=video["duracao"],
|
||||
taxa_de_quadros=video["taxa_de_quadros"], largura=video["largura"],
|
||||
altura=video["altura"], faixas=faixas,
|
||||
)
|
||||
Reference in New Issue
Block a user