- criado monitoramento contínuo de trilhas com varredura recursiva e baixa prioridade - gerado e validado plano editorial revisável do depoimento da Erika sem aplicação na timeline - adicionada leitura e comparação estrutural da timeline após aplicar planos de edição - limitadas as classificações de trilhas às duas maiores probabilidades de cada categoria - limpo o catálogo musical para reiniciar as análises sem remover dados de vídeo Resumo: - 34 arquivos alterados - 6 novos - 28 modificados - 0 removidos 28 files changed, 833 insertions(+), 75 deletions(-) Arquivos: - .jhonny/analises.db - code/cep-plugin/index.html - code/cep-plugin/main.js - code/engine/analisar_trilhas.py - code/engine/aplicar_plano_de_edicao.py - code/engine/consultar_detalhes_de_trilha.py - code/engine/editor/__init__.py - code/engine/editor/aplicador_de_plano_de_edicao.py - code/engine/editor/backup_de_sequencia.py - code/engine/editor/escrita/escrita_no_editor.py - code/engine/editor/modelos.py - code/engine/editor/validacao_semantica.py - code/engine/integracoes/audio/classificador_de_musica_essentia.py - code/engine/integracoes/audio/modelos_de_analise_musical.py - code/engine/integracoes/audio/provider_de_analise_musical_essentia.py - code/engine/persistencia/esquema.py - code/engine/persistencia/repositorio_de_trilhas.py - code/engine/testes/duplos_de_premiere.py - code/engine/testes/test_aplicador_de_plano_de_edicao.py - code/engine/testes/test_aplicar_plano_de_edicao.py - code/engine/testes/test_escrita_no_editor.py - code/engine/testes/test_leitor_de_plano_de_edicao.py - code/engine/testes/test_validador_semantico_de_plano.py - code/plugins/premiere-pro/skills/editar-por-voz/SKILL.md - code/plugins/premiere-pro/skills/editar-por-voz/criterios/08-formato-de-saida.md - code/plugins/premiere-pro/skills/editar-por-voz/criterios/10-revisao-humana.md - code/src/tools/timeline.ts - code/tests/tools/tool-modules.test.ts - .jhonny/planos/ - code/.jhonny/analises.db-shm - code/.jhonny/analises.db-wal - code/engine/editor/verificador_da_aplicacao.py - code/engine/testes/test_verificador_da_aplicacao.py - code/plugins/premiere-pro/skills/editar-por-voz/criterios/11-gancho.md
135 lines
7.4 KiB
Python
135 lines
7.4 KiB
Python
"""Persistência do catálogo independente de trilhas musicais."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import sqlite3
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
from ..integracoes.audio.modelos_de_analise_musical import ResultadoDaAnaliseMusical
|
|
from .conexao import abrir_banco
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TrilhaMusical:
|
|
"""Representa um arquivo de música catalogado, sem vínculo com vídeo."""
|
|
|
|
identificador: int
|
|
caminho: Path
|
|
nome_do_arquivo: str
|
|
status: str
|
|
erro: str | None = None
|
|
|
|
|
|
class RepositorioDeTrilhasSQLite:
|
|
"""Salva pastas, trilhas e versões das análises musicais no SQLite."""
|
|
|
|
def __init__(self, banco: str | Path | sqlite3.Connection = ".jhonny/analises.db") -> None:
|
|
"""Abre o banco informado e garante que o esquema esteja disponível."""
|
|
self.conexao = banco if isinstance(banco, sqlite3.Connection) else abrir_banco(banco)
|
|
|
|
def registrar_pasta_e_arquivos(self, pasta: str | Path, arquivos: Iterable[Path]) -> list[TrilhaMusical]:
|
|
"""Registra a pasta e torna sua lista de arquivos pronta para análise."""
|
|
caminho_da_pasta = Path(pasta).expanduser().resolve(strict=True)
|
|
if not caminho_da_pasta.is_dir():
|
|
raise NotADirectoryError(f"A pasta de trilhas não existe: {caminho_da_pasta}")
|
|
arquivos_materializados = tuple(Path(arquivo).resolve() for arquivo in arquivos)
|
|
with self.conexao:
|
|
self.conexao.execute(
|
|
"""INSERT INTO pastas_de_trilhas(caminho) VALUES (?)
|
|
ON CONFLICT(caminho) DO UPDATE SET atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now')""",
|
|
(str(caminho_da_pasta),),
|
|
)
|
|
pasta_id = self.conexao.execute(
|
|
"SELECT id FROM pastas_de_trilhas WHERE caminho = ?", (str(caminho_da_pasta),)
|
|
).fetchone()[0]
|
|
resultado: list[TrilhaMusical] = []
|
|
for arquivo in arquivos_materializados:
|
|
linha = self.conexao.execute(
|
|
"""INSERT INTO trilhas_musicais(pasta_id, caminho, nome_do_arquivo, tamanho_em_bytes)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(caminho) DO UPDATE SET nome_do_arquivo = excluded.nome_do_arquivo,
|
|
status = CASE WHEN trilhas_musicais.tamanho_em_bytes != excluded.tamanho_em_bytes
|
|
THEN 'aguardando' ELSE trilhas_musicais.status END,
|
|
hash_do_conteudo = CASE WHEN trilhas_musicais.tamanho_em_bytes != excluded.tamanho_em_bytes
|
|
THEN NULL ELSE trilhas_musicais.hash_do_conteudo END,
|
|
tamanho_em_bytes = excluded.tamanho_em_bytes,
|
|
atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now')""",
|
|
(pasta_id, str(arquivo), arquivo.name, arquivo.stat().st_size),
|
|
)
|
|
identificador = linha.lastrowid or self.conexao.execute(
|
|
"SELECT id FROM trilhas_musicais WHERE caminho = ?", (str(arquivo),)
|
|
).fetchone()[0]
|
|
registro = self.conexao.execute(
|
|
"SELECT id, caminho, nome_do_arquivo, status, erro FROM trilhas_musicais WHERE id = ?",
|
|
(identificador,),
|
|
).fetchone()
|
|
resultado.append(TrilhaMusical(registro[0], Path(registro[1]), registro[2], registro[3], registro[4]))
|
|
return resultado
|
|
|
|
def iniciar_analise(self, trilha_id: int) -> None:
|
|
"""Marca uma trilha como em análise e remove erro anterior."""
|
|
with self.conexao:
|
|
self.conexao.execute(
|
|
"UPDATE trilhas_musicais SET status = 'analisando', erro = NULL, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
|
(trilha_id,),
|
|
)
|
|
|
|
def reabrir_analises_sem_etiquetas(self, pasta_id: int | None = None) -> None:
|
|
"""Volta a enfileirar análises antigas que não têm classificação semântica."""
|
|
filtro = "" if pasta_id is None else " AND t.pasta_id = ?"
|
|
parametros = () if pasta_id is None else (pasta_id,)
|
|
with self.conexao:
|
|
self.conexao.execute(
|
|
"""UPDATE trilhas_musicais AS t SET status = 'aguardando', erro = NULL
|
|
WHERE t.status = 'concluida' AND EXISTS (
|
|
SELECT 1 FROM analises_musicais AS a
|
|
WHERE a.trilha_id = t.id
|
|
AND NOT EXISTS (SELECT 1 FROM etiquetas_musicais e WHERE e.analise_id = a.id)
|
|
)""" + filtro,
|
|
parametros,
|
|
)
|
|
|
|
def registrar_analise(self, trilha: TrilhaMusical, resultado: ResultadoDaAnaliseMusical) -> None:
|
|
"""Grava descritores e etiquetas produzidos pelo áudio da trilha."""
|
|
bruto = json.dumps({"generos": resultado.generos, "humores": resultado.humores, "temas": resultado.temas, "instrumentos": resultado.instrumentos, "etiquetas": resultado.etiquetas}, ensure_ascii=False)
|
|
with self.conexao:
|
|
self.conexao.execute(
|
|
"UPDATE trilhas_musicais SET hash_do_conteudo = ?, status = 'concluida', erro = NULL, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
|
(self._calcular_hash(trilha.caminho), trilha.identificador),
|
|
)
|
|
cursor = self.conexao.execute(
|
|
"""INSERT INTO analises_musicais
|
|
(trilha_id, provedor, modelo, duracao_em_segundos, batidas_por_minuto, tonalidade, modo, intensidade, dancabilidade, temas_json, instrumentos_json, etiquetas_json, resultado_bruto_json)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(trilha.identificador, resultado.provedor, resultado.modelo, resultado.duracao_em_segundos,
|
|
resultado.batidas_por_minuto, resultado.tonalidade, resultado.modo, resultado.intensidade,
|
|
resultado.dancabilidade, json.dumps(resultado.temas, ensure_ascii=False), json.dumps(resultado.instrumentos, ensure_ascii=False), json.dumps(resultado.etiquetas, ensure_ascii=False), bruto),
|
|
)
|
|
analise_id = cursor.lastrowid
|
|
for tipo, etiquetas in (("genero", resultado.pontuacoes_de_genero), ("humor", resultado.pontuacoes_de_humor), ("tema", resultado.pontuacoes_de_tema)):
|
|
self.conexao.executemany(
|
|
"INSERT INTO etiquetas_musicais(analise_id, tipo, etiqueta, confianca, ordem) VALUES (?, ?, ?, ?, ?)",
|
|
((analise_id, tipo, item.etiqueta, item.confianca, ordem) for ordem, item in enumerate(etiquetas)),
|
|
)
|
|
|
|
def registrar_erro(self, trilha_id: int, erro: str) -> None:
|
|
"""Marca uma trilha como erro sem interromper o lote inteiro."""
|
|
with self.conexao:
|
|
self.conexao.execute(
|
|
"UPDATE trilhas_musicais SET status = 'erro', erro = ?, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
|
(erro[:2000], trilha_id),
|
|
)
|
|
|
|
@staticmethod
|
|
def _calcular_hash(caminho: Path) -> str:
|
|
"""Calcula SHA-256 em blocos para não carregar o áudio inteiro na memória."""
|
|
resumo = hashlib.sha256()
|
|
with caminho.open("rb") as arquivo:
|
|
for bloco in iter(lambda: arquivo.read(1024 * 1024), b""):
|
|
resumo.update(bloco)
|
|
return resumo.hexdigest()
|