"""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, energia_rms REAL, pitch_mediano_hz REAL, pitch_desvio_hz REAL, velocidade_de_fala_pps REAL, maior_pausa_interna_s REAL, intervalo_anterior_s REAL ); CREATE INDEX IF NOT EXISTS idx_segmentos_video_clipe ON segmentos_de_transcricao(video_id, clipe_id); -- Consulta temporal: "quais falas entre X e Y segundos". É o acesso mais -- usado pelo agente de edição, por isso tem índice próprio. CREATE INDEX IF NOT EXISTS idx_segmentos_intervalo ON segmentos_de_transcricao(video_id, inicio, fim); CREATE INDEX IF NOT EXISTS idx_segmentos_falante ON segmentos_de_transcricao(video_id, falante); 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, status_de_revisao TEXT NOT NULL DEFAULT 'pendente' ); 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); -- --------------------------------------------------------------------------- -- Busca lexical (FTS5) -- --------------------------------------------------------------------------- -- Índice de texto completo sobre as falas. Usa ``content=`` (external content) -- para não duplicar o texto: o FTS5 guarda só o índice invertido e lê o texto -- da tabela original pelo rowid. ``remove_diacritics 2`` faz "elegancia" -- encontrar "elegância", que é o comportamento esperado em pt-BR. CREATE VIRTUAL TABLE IF NOT EXISTS busca_de_falas USING fts5( texto, content='segmentos_de_transcricao', content_rowid='id', tokenize="unicode61 remove_diacritics 2" ); CREATE TRIGGER IF NOT EXISTS trg_busca_de_falas_inserir AFTER INSERT ON segmentos_de_transcricao BEGIN INSERT INTO busca_de_falas(rowid, texto) VALUES (new.id, new.texto); END; CREATE TRIGGER IF NOT EXISTS trg_busca_de_falas_remover AFTER DELETE ON segmentos_de_transcricao BEGIN INSERT INTO busca_de_falas(busca_de_falas, rowid, texto) VALUES ('delete', old.id, old.texto); END; CREATE TRIGGER IF NOT EXISTS trg_busca_de_falas_atualizar AFTER UPDATE OF texto ON segmentos_de_transcricao BEGIN INSERT INTO busca_de_falas(busca_de_falas, rowid, texto) VALUES ('delete', old.id, old.texto); INSERT INTO busca_de_falas(rowid, texto) VALUES (new.id, new.texto); END; -- --------------------------------------------------------------------------- -- Busca semântica (enunciados + embeddings) -- --------------------------------------------------------------------------- -- Uma fala do Whisper tem 3 a 8 segundos: curta demais para gerar um embedding -- com significado estável. O enunciado agrupa falas vizinhas do mesmo falante -- num bloco de dezenas de segundos, que é a unidade embedável. O vínculo com -- as falas de origem é preservado em ``falas_do_enunciado`` para que todo -- acerto semântico volte com timecode utilizável para um corte. CREATE TABLE IF NOT EXISTS enunciados ( 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, falante TEXT, total_de_falas INTEGER NOT NULL, criado_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_enunciados_video ON enunciados(video_id, inicio); CREATE TABLE IF NOT EXISTS falas_do_enunciado ( enunciado_id INTEGER NOT NULL REFERENCES enunciados(id) ON DELETE CASCADE, segmento_id INTEGER NOT NULL REFERENCES segmentos_de_transcricao(id) ON DELETE CASCADE, ordem INTEGER NOT NULL, PRIMARY KEY (enunciado_id, segmento_id) ); CREATE INDEX IF NOT EXISTS idx_falas_do_enunciado_segmento ON falas_do_enunciado(segmento_id); CREATE TABLE IF NOT EXISTS embeddings_de_enunciado ( enunciado_id INTEGER PRIMARY KEY REFERENCES enunciados(id) ON DELETE CASCADE, modelo TEXT NOT NULL, dimensoes INTEGER NOT NULL, vetor BLOB NOT NULL, gerado_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); -- --------------------------------------------------------------------------- -- Planos de edição -- --------------------------------------------------------------------------- -- O plano que a IA devolve é a única evidência de como uma edição foi -- decidida, e antes disto ele vivia num arquivo temporário que sumia depois de -- aplicado. Guardá-lo é o que permite comparar o que foi proposto com o que o -- editor manteve — o único sinal disponível para aprender o estilo de corte. CREATE TABLE IF NOT EXISTS planos_de_edicao ( id INTEGER PRIMARY KEY AUTOINCREMENT, video_id TEXT REFERENCES videos(id) ON DELETE CASCADE, origem TEXT NOT NULL, tipo_de_video TEXT, modelo_da_ia TEXT, intencao TEXT, criado_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_planos_video ON planos_de_edicao(video_id, criado_em); CREATE TABLE IF NOT EXISTS acoes_do_plano ( id INTEGER PRIMARY KEY AUTOINCREMENT, plano_id INTEGER NOT NULL REFERENCES planos_de_edicao(id) ON DELETE CASCADE, ordem INTEGER NOT NULL, tipo TEXT NOT NULL, inicio REAL, fim REAL, motivo TEXT, -- Cada tipo de ação tem parâmetros próprios e incompatíveis entre si (um -- zoom tem escala, um texto tem conteúdo e posição). É carga polimórfica -- de verdade, consumida inteira por quem aplica: por isso fica em JSON. parametros TEXT ); CREATE INDEX IF NOT EXISTS idx_acoes_do_plano ON acoes_do_plano(plano_id, ordem); CREATE TABLE IF NOT EXISTS aplicacoes_do_plano ( id INTEGER PRIMARY KEY AUTOINCREMENT, plano_id INTEGER NOT NULL REFERENCES planos_de_edicao(id) ON DELETE CASCADE, sequencia TEXT, aplicado_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), sucesso INTEGER NOT NULL, acoes_aplicadas INTEGER NOT NULL DEFAULT 0, mensagem TEXT ); CREATE INDEX IF NOT EXISTS idx_aplicacoes_plano ON aplicacoes_do_plano(plano_id); -- --------------------------------------------------------------------------- -- Leituras achatadas -- --------------------------------------------------------------------------- -- As tabelas acima guardam cada fato uma única vez, no nível em que ele é -- verdadeiro: a emoção e o falante valem para a frase, os tempos exatos valem -- para a palavra. As views abaixo reapresentam esses mesmos dados já juntos, -- para que o agente de edição leia tudo numa consulta só sem que o banco -- precise repetir valor em disco. Normalizado para gravar, achatado para ler. -- Uma linha por fala, com as três análises de áudio e o resumo das palavras. CREATE VIEW IF NOT EXISTS vw_falas_completas AS SELECT s.id AS fala_id, s.video_id, s.clipe_id, s.inicio, s.fim, s.fim - s.inicio AS duracao, s.texto, s.falante, s.emocao, s.confianca_emocao, s.energia_rms, s.pitch_mediano_hz, s.pitch_desvio_hz, s.velocidade_de_fala_pps, s.maior_pausa_interna_s, s.intervalo_anterior_s, count(p.id) AS total_de_palavras FROM segmentos_de_transcricao s LEFT JOIN palavras_de_transcricao p ON p.segmento_id = s.id GROUP BY s.id; -- Uma linha por palavra, repetindo o que vale para a frase inteira. É a forma -- mais plana possível de ler a transcrição — sem custo de duplicação em disco, -- porque a repetição acontece na leitura e não na gravação. CREATE VIEW IF NOT EXISTS vw_palavras_completas AS SELECT p.id AS palavra_id, p.segmento_id AS fala_id, s.video_id, s.clipe_id, p.ordem, p.texto AS palavra, p.inicio AS palavra_inicio, p.fim AS palavra_fim, p.confianca AS palavra_confianca, s.texto AS frase, s.inicio AS frase_inicio, s.fim AS frase_fim, s.falante, s.emocao, s.confianca_emocao, s.energia_rms, s.pitch_mediano_hz, s.velocidade_de_fala_pps FROM palavras_de_transcricao p JOIN segmentos_de_transcricao s ON s.id = p.segmento_id; -- Linha do tempo unificada: fala e imagem no mesmo eixo, para percorrer o -- vídeo em ordem cronológica lendo áudio e imagem juntos. A geometria bruta -- (landmarks, bounding boxes) fica de fora de propósito: o que decide corte é -- o fato editorial, e a geometria continua disponível em evidencias_visuais -- para quem precisar dela. CREATE VIEW IF NOT EXISTS vw_linha_do_tempo AS SELECT video_id, clipe_id, inicio, fim, 'fala' AS tipo, texto AS descricao, falante AS detalhe, confianca_emocao AS confianca FROM segmentos_de_transcricao UNION ALL SELECT video_id, clipe_id, inicio, fim, 'cena' AS tipo, 'mudança de cena' AS descricao, NULL AS detalhe, confianca FROM cenas UNION ALL SELECT video_id, clipe_id, inicio, fim, tipo, json_extract(valor, '$.identificador') AS descricao, NULL AS detalhe, confianca FROM evidencias_visuais WHERE tipo = 'categoria' UNION ALL SELECT video_id, clipe_id, inicio, fim, tipo, json_extract(valor, '$.texto') AS descricao, NULL AS detalhe, confianca FROM evidencias_visuais WHERE tipo = 'interpretacao_editorial'; -- Cada fala com o que estava em quadro enquanto ela era dita. O vínculo é por -- sobreposição de tempo, não por chave estrangeira: fala e imagem são medidas -- em grades diferentes (a fala é um intervalo, a imagem é amostrada por -- quadro) e nunca coincidem exatamente. As categorias visuais entram -- agregadas, e não uma linha por amostra, para a resposta caber num prompt. CREATE VIEW IF NOT EXISTS vw_falas_com_visual AS SELECT f.fala_id, f.video_id, f.clipe_id, f.inicio, f.fim, f.texto, f.falante, f.emocao, f.energia_rms, f.velocidade_de_fala_pps, (SELECT group_concat(DISTINCT json_extract(e.valor, '$.identificador')) FROM evidencias_visuais e WHERE e.video_id = f.video_id AND e.tipo = 'categoria' AND e.confianca >= 0.5 AND e.inicio BETWEEN f.inicio AND f.fim) AS categorias_em_quadro, (SELECT count(*) FROM evidencias_visuais e WHERE e.video_id = f.video_id AND e.tipo = 'rosto' AND e.inicio BETWEEN f.inicio AND f.fim) AS amostras_com_rosto, (SELECT round(avg(json_extract(e.valor, '$.score_global')), 3) FROM evidencias_visuais e WHERE e.video_id = f.video_id AND e.tipo = 'estetica' AND e.inicio BETWEEN f.inicio AND f.fim) AS estetica_media, (SELECT count(*) FROM cenas c WHERE c.video_id = f.video_id AND c.inicio > f.inicio AND c.inicio < f.fim) AS cortes_de_cena_dentro FROM vw_falas_completas f; """ # Colunas acrescentadas depois da primeira versão do esquema. ``CREATE TABLE IF # NOT EXISTS`` não altera tabela existente, então cada uma é aplicada por # ``_migrar_colunas`` em bancos que já foram criados. # Views recriadas a cada abertura do banco. "CREATE VIEW IF NOT EXISTS" não # atualiza a definição de uma view já existente, então uma correção na # consulta de uma view só chegaria a bancos novos sem isso — o banco de # produção ficaria preso para sempre na primeira versão que foi criada nele. _VIEWS = ( "vw_falas_completas", "vw_palavras_completas", "vw_linha_do_tempo", "vw_falas_com_visual", ) _COLUNAS_ACRESCENTADAS: tuple[tuple[str, str, str], ...] = ( ("segmentos_de_transcricao", "energia_rms", "REAL"), ("segmentos_de_transcricao", "pitch_mediano_hz", "REAL"), ("segmentos_de_transcricao", "pitch_desvio_hz", "REAL"), ("segmentos_de_transcricao", "velocidade_de_fala_pps", "REAL"), ("segmentos_de_transcricao", "maior_pausa_interna_s", "REAL"), ("segmentos_de_transcricao", "intervalo_anterior_s", "REAL"), ("grupos_de_retake", "status_de_revisao", "TEXT NOT NULL DEFAULT 'pendente'"), ) class ErroDeEsquema(RuntimeError): """Falha ao criar ou migrar o esquema do banco de análises.""" def _colunas_existentes(conexao: sqlite3.Connection, tabela: str) -> set[str]: """Lê os nomes de coluna já presentes numa tabela.""" return {linha[1] for linha in conexao.execute(f"PRAGMA table_info({tabela})")} def _migrar_colunas(conexao: sqlite3.Connection) -> None: """Acrescenta colunas novas em bancos criados por versões anteriores.""" for tabela, coluna, tipo in _COLUNAS_ACRESCENTADAS: if not _colunas_existentes(conexao, tabela): continue if coluna in _colunas_existentes(conexao, tabela): continue conexao.execute(f"ALTER TABLE {tabela} ADD COLUMN {coluna} {tipo}") def reconstruir_indice_de_busca(conexao: sqlite3.Connection) -> int: """ Reindexa do zero a busca lexical a partir das falas já persistidas. Necessário em bancos criados antes de ``busca_de_falas`` existir: os gatilhos só cobrem escritas futuras, então as falas antigas precisam ser carregadas uma vez. Parâmetros: conexao: Conexão SQLite já aberta e com o esquema aplicado. Retorna: A quantidade de falas presentes no índice depois da reconstrução. """ with conexao: conexao.execute("INSERT INTO busca_de_falas(busca_de_falas) VALUES ('rebuild')") return int(conexao.execute("SELECT count(*) FROM busca_de_falas").fetchone()[0]) def criar_esquema(conexao: sqlite3.Connection) -> None: """ Cria (de forma idempotente) todas as tabelas do banco de análises. Aplica também as migrações de coluna necessárias em bancos criados por versões anteriores do esquema, para que abrir um banco antigo baste para deixá-lo no formato atual. Parâmetros: conexao: Conexão SQLite aberta onde o esquema será aplicado. Pode gerar: ErroDeEsquema: quando o SQLite recusa a DDL — tipicamente por falta da extensão FTS5 na build em uso. """ try: _migrar_colunas(conexao) for view in _VIEWS: conexao.execute(f"DROP VIEW IF EXISTS {view}") conexao.executescript(_DDL) except sqlite3.OperationalError as erro: raise ErroDeEsquema(f"Não foi possível aplicar o esquema de análises: {erro}") from erro conexao.commit()