feat: aprimorada a edição por voz com validação de frases, margens
- aprimorada a edição por voz com validação de frases, margens seguras e backup único antes da timeline. - adicionada consulta expansível dos detalhes de cada trilha diretamente do banco Resumo: - 23 arquivos alterados - 5 novos - 18 modificados - 0 removidos 18 files changed, 321 insertions(+), 216 deletions(-) Arquivos: - .gitignore - .jhonny/analises.db - code/cep-plugin/index.html - code/cep-plugin/main.js - code/cep-plugin/styles.css - code/engine/analisar_trilhas.py - code/engine/aplicar_plano_de_edicao.py - code/engine/editor/__init__.py - code/engine/editor/backup_de_sequencia.py - code/engine/persistencia/__init__.py - code/engine/testes/test_aplicar_plano_de_edicao.py - code/plugins/premiere-pro/skills/edit-video-by-voice/SKILL.md - code/plugins/premiere-pro/skills/editar-por-voz/SKILL.md - code/plugins/premiere-pro/skills/editar-por-voz/criterios/05-zoom.md - code/plugins/premiere-pro/skills/editar-por-voz/criterios/06-texto-corte-marcador.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/plugins/premiere-pro/skills/transcript-to-edit-actions/SKILL.md - code/engine/consultar_detalhes_de_trilha.py - code/engine/editor/validacao_semantica.py - code/engine/persistencia/leitura_semantica_do_plano.py - code/engine/testes/test_leitura_semantica_do_plano.py - code/engine/testes/test_validador_semantico_de_plano.py
This commit is contained in:
@@ -35,7 +35,7 @@ def executar(pasta: Path, banco: Path, diretorio_dos_modelos: Path | None, apena
|
||||
trilhas = repositorio.registrar_pasta_e_arquivos(pasta, arquivos)
|
||||
if apenas_listar:
|
||||
emitir("catalogo_atualizado", trilhas=[
|
||||
{"nome": trilha.nome_do_arquivo, "status": trilha.status}
|
||||
{"nome": trilha.nome_do_arquivo, "caminho": str(trilha.caminho), "status": trilha.status}
|
||||
for trilha in trilhas
|
||||
])
|
||||
return 0
|
||||
@@ -51,7 +51,7 @@ def executar(pasta: Path, banco: Path, diretorio_dos_modelos: Path | None, apena
|
||||
total=len(pendentes),
|
||||
pasta=str(pasta),
|
||||
nomes=[trilha.nome_do_arquivo for trilha in pendentes],
|
||||
trilhas=[{"nome": trilha.nome_do_arquivo, "status": trilha.status} for trilha in trilhas],
|
||||
trilhas=[{"nome": trilha.nome_do_arquivo, "caminho": str(trilha.caminho), "status": trilha.status} for trilha in trilhas],
|
||||
)
|
||||
for ordem, trilha in enumerate(pendentes, start=1):
|
||||
repositorio.iniciar_analise(trilha.identificador)
|
||||
|
||||
@@ -33,8 +33,12 @@ from engine.editor import (
|
||||
AplicadorDePlanoDeEdicao,
|
||||
BackupDaSequencia,
|
||||
EscritaNoEditor,
|
||||
ErroDePlanoInvalido,
|
||||
LeitorDePlanoDeEdicao,
|
||||
MapeadorDeTempoDeOrigemParaTimeline,
|
||||
PlanoDeEdicao,
|
||||
TipoDeAcao,
|
||||
ValidadorSemanticoDePlano,
|
||||
)
|
||||
from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio
|
||||
from engine.integracoes.premiere.cliente_mcp_com_pausa import ClienteMCPComPausa
|
||||
@@ -48,6 +52,33 @@ CAMINHO_DO_SERVIDOR_MCP = CAMINHO_DO_CODIGO / "dist" / "index.js"
|
||||
CAMINHO_DO_BANCO = CAMINHO_DO_CODIGO.parent / ".jhonny" / "analises.db"
|
||||
|
||||
|
||||
def _validar_integridade_semantica(
|
||||
plano: PlanoDeEdicao,
|
||||
plano_id: int | None,
|
||||
caminho_do_banco: Path,
|
||||
exigir_validacao: bool = False,
|
||||
) -> None:
|
||||
"""Valida palavras, frases e respiros antes de iniciar a sessão do Premiere."""
|
||||
if not plano.acoes_do_tipo(TipoDeAcao.CORTE):
|
||||
return
|
||||
if plano_id is None:
|
||||
if exigir_validacao:
|
||||
raise ErroDePlanoInvalido(
|
||||
"Salve ou carregue o plano no banco antes de aplicar: sem o identificador "
|
||||
"não é possível conferir a transcrição palavra por palavra."
|
||||
)
|
||||
return
|
||||
from engine.persistencia.conexao import abrir_banco
|
||||
from engine.persistencia.leitura_semantica_do_plano import LeitorDeTranscricaoDoPlano
|
||||
|
||||
conexao = abrir_banco(caminho_do_banco)
|
||||
try:
|
||||
falas = LeitorDeTranscricaoDoPlano(conexao).carregar(plano_id)
|
||||
finally:
|
||||
conexao.close()
|
||||
ValidadorSemanticoDePlano().garantir_valido(plano, falas)
|
||||
|
||||
|
||||
def resolver_caminho_do_node() -> str:
|
||||
"""Resolve o executável Node mesmo quando o painel CEP possui um PATH reduzido.
|
||||
|
||||
@@ -129,6 +160,7 @@ def executar(
|
||||
caminho_do_plano: Path,
|
||||
plano_id: int | None = None,
|
||||
caminho_do_banco: Path = CAMINHO_DO_BANCO,
|
||||
exigir_validacao_semantica: bool = False,
|
||||
) -> dict:
|
||||
"""Lê o plano em ``caminho_do_plano`` e o aplica na sequência ativa do Premiere.
|
||||
|
||||
@@ -136,6 +168,12 @@ def executar(
|
||||
ação — para o painel exibir o que foi aplicado e o que falhou.
|
||||
"""
|
||||
plano = LeitorDePlanoDeEdicao().ler(caminho_do_plano)
|
||||
_validar_integridade_semantica(
|
||||
plano,
|
||||
plano_id,
|
||||
caminho_do_banco,
|
||||
exigir_validacao_semantica,
|
||||
)
|
||||
|
||||
# A pausa é aplicada no cliente, e não dentro do aplicador, para valer
|
||||
# igualmente nas leituras da timeline e nas escritas — são todas
|
||||
@@ -146,11 +184,9 @@ def executar(
|
||||
acesso_ao_editor = AcessoAoEditor(AcessoATimeline(cliente))
|
||||
conversor_de_timeline = ConversorDeTimeline()
|
||||
|
||||
# Backup antes de qualquer corte, espelhando o painel CEP
|
||||
# (applyEditorialActions() em cep-plugin/main.js): sem undo por
|
||||
# script nesta versão do Premiere, é a única forma de recuperar o
|
||||
# estado anterior se algo sair errado. Uma falha aqui não impede a
|
||||
# aplicação — só fica registrada em stderr, igual ao painel.
|
||||
# A engine é a única responsável pelo backup. A validação semântica já
|
||||
# aconteceu antes da conexão, portanto um plano bloqueado não duplica
|
||||
# a sequência. Uma falha no backup fica registrada em stderr.
|
||||
sequencia_ativa = conversor_de_timeline.converter(acesso_ao_editor.obter_timeline_ativa())
|
||||
backup_ok = BackupDaSequencia(cliente).criar_backup(sequencia_ativa.identificador)
|
||||
print(
|
||||
@@ -194,8 +230,22 @@ def main() -> None:
|
||||
argumentos.add_argument("plano", type=Path)
|
||||
argumentos.add_argument("--plano-id", type=int)
|
||||
argumentos.add_argument("--banco", type=Path, default=CAMINHO_DO_BANCO)
|
||||
argumentos.add_argument("--exigir-validacao-semantica", action="store_true")
|
||||
opcoes = argumentos.parse_args()
|
||||
resultado = executar(opcoes.plano, opcoes.plano_id, opcoes.banco)
|
||||
try:
|
||||
resultado = executar(
|
||||
opcoes.plano,
|
||||
opcoes.plano_id,
|
||||
opcoes.banco,
|
||||
opcoes.exigir_validacao_semantica,
|
||||
)
|
||||
except ErroDePlanoInvalido as erro:
|
||||
print(json.dumps({
|
||||
"todas_bem_sucedidas": False,
|
||||
"erro": str(erro),
|
||||
"acoes": [],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
print(json.dumps(resultado, ensure_ascii=False, indent=2))
|
||||
if not resultado["todas_bem_sucedidas"]:
|
||||
sys.exit(1)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Consulta no SQLite os detalhes da análise mais recente de uma trilha."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent
|
||||
if str(CAMINHO_DO_CODIGO) not in sys.path:
|
||||
sys.path.insert(0, str(CAMINHO_DO_CODIGO))
|
||||
|
||||
def consultar(caminho: Path, banco: Path) -> dict[str, object]:
|
||||
"""Retorna descritores e etiquetas da última análise do arquivo informado."""
|
||||
caminho_do_banco = banco.expanduser().resolve()
|
||||
conexao = sqlite3.connect(f"file:{caminho_do_banco}?mode=ro", uri=True)
|
||||
conexao.row_factory = sqlite3.Row
|
||||
try:
|
||||
analise = conexao.execute(
|
||||
"""SELECT a.id, a.provedor, a.modelo, a.duracao_em_segundos,
|
||||
a.batidas_por_minuto, a.tonalidade, a.modo,
|
||||
a.intensidade, a.dancabilidade
|
||||
FROM trilhas_musicais t
|
||||
JOIN analises_musicais a ON a.trilha_id = t.id
|
||||
WHERE t.caminho = ?
|
||||
ORDER BY a.criada_em DESC, a.id DESC LIMIT 1""",
|
||||
(str(caminho.expanduser().resolve()),),
|
||||
).fetchone()
|
||||
if analise is None:
|
||||
return {"encontrada": False, "mensagem": "Esta trilha ainda não possui uma análise salva."}
|
||||
etiquetas = conexao.execute(
|
||||
"SELECT tipo, etiqueta, confianca FROM etiquetas_musicais WHERE analise_id = ? ORDER BY tipo, ordem",
|
||||
(analise["id"],),
|
||||
).fetchall()
|
||||
return {
|
||||
"encontrada": True,
|
||||
"provedor": analise["provedor"],
|
||||
"modelo": analise["modelo"],
|
||||
"duracao_em_segundos": analise["duracao_em_segundos"],
|
||||
"batidas_por_minuto": analise["batidas_por_minuto"],
|
||||
"tonalidade": analise["tonalidade"],
|
||||
"modo": analise["modo"],
|
||||
"intensidade": analise["intensidade"],
|
||||
"dancabilidade": analise["dancabilidade"],
|
||||
"etiquetas": [dict(etiqueta) for etiqueta in etiquetas],
|
||||
}
|
||||
finally:
|
||||
conexao.close()
|
||||
|
||||
|
||||
def principal() -> None:
|
||||
"""Lê os argumentos e imprime a consulta como JSON."""
|
||||
argumentos = argparse.ArgumentParser()
|
||||
argumentos.add_argument("caminho", type=Path)
|
||||
argumentos.add_argument("--banco", type=Path, required=True)
|
||||
opcoes = argumentos.parse_args()
|
||||
print(json.dumps(consultar(opcoes.caminho, opcoes.banco), ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
principal()
|
||||
@@ -18,6 +18,7 @@ from .escrita import EscritaNoEditor
|
||||
from .leitura import LeitorDePlanoDeEdicao
|
||||
from .mapeamento import MapeadorDeTempoDeOrigemParaTimeline
|
||||
from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
|
||||
from .validacao_semantica import ProblemaSemanticoDoPlano, ValidadorSemanticoDePlano
|
||||
|
||||
__all__ = [
|
||||
"AcaoDeEdicao",
|
||||
@@ -31,7 +32,9 @@ __all__ = [
|
||||
"LeitorDePlanoDeEdicao",
|
||||
"MapeadorDeTempoDeOrigemParaTimeline",
|
||||
"PlanoDeEdicao",
|
||||
"ProblemaSemanticoDoPlano",
|
||||
"ResultadoDaAcao",
|
||||
"ResultadoDaAplicacao",
|
||||
"TipoDeAcao",
|
||||
"ValidadorSemanticoDePlano",
|
||||
]
|
||||
|
||||
@@ -9,11 +9,10 @@ from ..integracoes.premiere.erros_mcp import ErroDeFerramentaMCP
|
||||
class BackupDaSequencia:
|
||||
"""Duplica a sequência ativa do Premiere antes de uma edição destrutiva.
|
||||
|
||||
Espelha o passo de segurança do painel CEP
|
||||
(``app.project.activeSequence.clone()`` em ``applyEditorialActions()``,
|
||||
``cep-plugin/main.js``): sem undo por script nesta versão do Premiere,
|
||||
duplicar a sequência antes de cortar é a única forma de recuperar o
|
||||
estado anterior se algo sair errado.
|
||||
A engine concentra este passo de segurança para que painel e linha de
|
||||
comando não criem cópias duplicadas. Sem undo por script nesta versão do
|
||||
Premiere, duplicar a sequência antes de cortar é a única forma de recuperar
|
||||
o estado anterior se algo sair errado.
|
||||
"""
|
||||
|
||||
def __init__(self, cliente_mcp: ClienteMCP) -> None:
|
||||
@@ -22,10 +21,8 @@ class BackupDaSequencia:
|
||||
def criar_backup(self, identificador_da_sequencia: str) -> bool:
|
||||
"""Duplica a sequência. Devolve ``True`` se conseguiu, ``False`` se o Premiere recusou.
|
||||
|
||||
Não levanta exceção numa recusa: o painel CEP também segue aplicando
|
||||
o plano mesmo se o backup falhar (loga e avisa, não bloqueia) — a
|
||||
falta de backup não deve impedir a edição que o usuário pediu, só
|
||||
precisa ficar visível para quem chama decidir o que fazer.
|
||||
Não levanta exceção numa recusa: a engine registra a falha e continua
|
||||
a aplicação solicitada, deixando a ausência de backup visível.
|
||||
"""
|
||||
try:
|
||||
self.cliente_mcp.chamar("duplicate_sequence", {"sequence_id": identificador_da_sequencia})
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Validação semântica das bordas de cortes antes de alterar a timeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from engine.scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
|
||||
|
||||
from .erros import ErroDePlanoInvalido
|
||||
from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProblemaSemanticoDoPlano:
|
||||
"""Descreve uma borda insegura e a ação em que ela foi encontrada."""
|
||||
|
||||
codigo: str
|
||||
mensagem: str
|
||||
indice_da_acao: int
|
||||
instante: float
|
||||
|
||||
|
||||
class ValidadorSemanticoDePlano:
|
||||
"""Protege palavras, frases completas e respiros nas emendas de cortes."""
|
||||
|
||||
_TOLERANCIA_EM_SEGUNDOS = 0.01
|
||||
_PONTUACAO_DE_FECHAMENTO = (".", "!", "?", "…")
|
||||
|
||||
def __init__(self, margem_minima_em_segundos: float = 0.3) -> None:
|
||||
"""Configura a folga acústica mínima exigida junto à fala mantida."""
|
||||
if margem_minima_em_segundos < 0:
|
||||
raise ValueError("A margem mínima não pode ser negativa.")
|
||||
self._margem_minima = margem_minima_em_segundos
|
||||
|
||||
def validar(
|
||||
self,
|
||||
plano: PlanoDeEdicao,
|
||||
falas: tuple[SegmentoDeTranscricao, ...],
|
||||
) -> tuple[ProblemaSemanticoDoPlano, ...]:
|
||||
"""Devolve todos os problemas semânticos encontrados nas ações de corte."""
|
||||
cortes = tuple(
|
||||
(indice, acao)
|
||||
for indice, acao in enumerate(plano.acoes)
|
||||
if acao.tipo is TipoDeAcao.CORTE
|
||||
)
|
||||
palavras = tuple(
|
||||
sorted(
|
||||
(palavra for fala in falas for palavra in fala.palavras),
|
||||
key=lambda palavra: (palavra.inicio, palavra.fim),
|
||||
)
|
||||
)
|
||||
problemas: list[ProblemaSemanticoDoPlano] = []
|
||||
for indice, corte in cortes:
|
||||
problemas.extend(self._validar_palavras_interceptadas(indice, corte, palavras))
|
||||
problemas.extend(self._validar_borda_inicial(indice, corte, cortes, palavras))
|
||||
problemas.extend(self._validar_borda_final(indice, corte, cortes, falas, palavras))
|
||||
return tuple(problemas)
|
||||
|
||||
def garantir_valido(
|
||||
self,
|
||||
plano: PlanoDeEdicao,
|
||||
falas: tuple[SegmentoDeTranscricao, ...],
|
||||
) -> None:
|
||||
"""Levanta erro explicativo quando qualquer corte ameaça a fala mantida."""
|
||||
problemas = self.validar(plano, falas)
|
||||
if not problemas:
|
||||
return
|
||||
detalhes = " | ".join(problema.mensagem for problema in problemas)
|
||||
raise ErroDePlanoInvalido(
|
||||
"Plano bloqueado pela validação de integridade da fala: " + detalhes
|
||||
)
|
||||
|
||||
def _validar_palavras_interceptadas(
|
||||
self,
|
||||
indice: int,
|
||||
corte: AcaoDeEdicao,
|
||||
palavras: tuple[PalavraDeTranscricao, ...],
|
||||
) -> list[ProblemaSemanticoDoPlano]:
|
||||
problemas = []
|
||||
for borda, instante in (("início", corte.inicio), ("fim", corte.fim)):
|
||||
palavra = next(
|
||||
(
|
||||
item
|
||||
for item in palavras
|
||||
if item.inicio + self._TOLERANCIA_EM_SEGUNDOS
|
||||
< instante
|
||||
< item.fim - self._TOLERANCIA_EM_SEGUNDOS
|
||||
),
|
||||
None,
|
||||
)
|
||||
if palavra is not None:
|
||||
problemas.append(ProblemaSemanticoDoPlano(
|
||||
codigo="palavra_interceptada",
|
||||
mensagem=(
|
||||
f"ação {indice + 1}: o {borda} em {instante:.2f}s corta "
|
||||
f'a palavra "{palavra.texto}"'
|
||||
),
|
||||
indice_da_acao=indice,
|
||||
instante=instante,
|
||||
))
|
||||
return problemas
|
||||
|
||||
def _validar_borda_inicial(
|
||||
self,
|
||||
indice: int,
|
||||
corte: AcaoDeEdicao,
|
||||
cortes: tuple[tuple[int, AcaoDeEdicao], ...],
|
||||
palavras: tuple[PalavraDeTranscricao, ...],
|
||||
) -> list[ProblemaSemanticoDoPlano]:
|
||||
palavra_anterior = self._ultima_palavra_mantida_antes(corte.inicio, cortes, palavras)
|
||||
if palavra_anterior is None:
|
||||
return []
|
||||
problemas = []
|
||||
margem = corte.inicio - palavra_anterior.fim
|
||||
if margem < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS:
|
||||
problemas.append(ProblemaSemanticoDoPlano(
|
||||
codigo="margem_antes_do_corte_insuficiente",
|
||||
mensagem=(
|
||||
f"ação {indice + 1}: deixe ao menos {self._margem_minima:.2f}s após "
|
||||
f'a palavra mantida "{palavra_anterior.texto}" antes de iniciar o corte'
|
||||
),
|
||||
indice_da_acao=indice,
|
||||
instante=corte.inicio,
|
||||
))
|
||||
if not palavra_anterior.texto.rstrip().endswith(self._PONTUACAO_DE_FECHAMENTO):
|
||||
problemas.append(ProblemaSemanticoDoPlano(
|
||||
codigo="frase_interrompida_antes_do_corte",
|
||||
mensagem=(
|
||||
f"ação {indice + 1}: a fala mantida termina em "
|
||||
f'"{palavra_anterior.texto}" sem concluir a frase'
|
||||
),
|
||||
indice_da_acao=indice,
|
||||
instante=corte.inicio,
|
||||
))
|
||||
return problemas
|
||||
|
||||
def _validar_borda_final(
|
||||
self,
|
||||
indice: int,
|
||||
corte: AcaoDeEdicao,
|
||||
cortes: tuple[tuple[int, AcaoDeEdicao], ...],
|
||||
falas: tuple[SegmentoDeTranscricao, ...],
|
||||
palavras: tuple[PalavraDeTranscricao, ...],
|
||||
) -> list[ProblemaSemanticoDoPlano]:
|
||||
proxima_palavra = self._primeira_palavra_mantida_depois(corte.fim, cortes, palavras)
|
||||
if proxima_palavra is None:
|
||||
return []
|
||||
problemas = []
|
||||
margem = proxima_palavra.inicio - corte.fim
|
||||
if margem < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS:
|
||||
problemas.append(ProblemaSemanticoDoPlano(
|
||||
codigo="margem_depois_do_corte_insuficiente",
|
||||
mensagem=(
|
||||
f"ação {indice + 1}: termine o corte ao menos {self._margem_minima:.2f}s "
|
||||
f'antes da palavra mantida "{proxima_palavra.texto}"'
|
||||
),
|
||||
indice_da_acao=indice,
|
||||
instante=corte.fim,
|
||||
))
|
||||
fala = self._fala_da_palavra(proxima_palavra, falas)
|
||||
comeca_no_meio_do_segmento = (
|
||||
fala is not None
|
||||
and proxima_palavra.inicio > fala.inicio + self._TOLERANCIA_EM_SEGUNDOS
|
||||
)
|
||||
primeira_letra = next((letra for letra in proxima_palavra.texto if letra.isalpha()), "")
|
||||
comeca_em_minuscula = bool(primeira_letra and primeira_letra.islower())
|
||||
if comeca_no_meio_do_segmento or comeca_em_minuscula:
|
||||
problemas.append(ProblemaSemanticoDoPlano(
|
||||
codigo="retomada_no_meio_da_frase",
|
||||
mensagem=(
|
||||
f"ação {indice + 1}: o trecho mantido retoma em "
|
||||
f'"{proxima_palavra.texto}" ({proxima_palavra.inicio:.2f}s), no meio da frase'
|
||||
),
|
||||
indice_da_acao=indice,
|
||||
instante=corte.fim,
|
||||
))
|
||||
return problemas
|
||||
|
||||
@staticmethod
|
||||
def _fala_da_palavra(
|
||||
palavra: PalavraDeTranscricao,
|
||||
falas: tuple[SegmentoDeTranscricao, ...],
|
||||
) -> SegmentoDeTranscricao | None:
|
||||
return next(
|
||||
(
|
||||
fala
|
||||
for fala in falas
|
||||
if fala.inicio - 0.01 <= palavra.inicio <= fala.fim + 0.01
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _palavra_esta_mantida(
|
||||
palavra: PalavraDeTranscricao,
|
||||
cortes: tuple[tuple[int, AcaoDeEdicao], ...],
|
||||
) -> bool:
|
||||
centro = (palavra.inicio + palavra.fim) / 2
|
||||
return not any(corte.inicio <= centro <= corte.fim for _, corte in cortes)
|
||||
|
||||
def _ultima_palavra_mantida_antes(
|
||||
self,
|
||||
instante: float,
|
||||
cortes: tuple[tuple[int, AcaoDeEdicao], ...],
|
||||
palavras: tuple[PalavraDeTranscricao, ...],
|
||||
) -> PalavraDeTranscricao | None:
|
||||
candidatas = (
|
||||
palavra
|
||||
for palavra in palavras
|
||||
if palavra.fim <= instante + self._TOLERANCIA_EM_SEGUNDOS
|
||||
and self._palavra_esta_mantida(palavra, cortes)
|
||||
)
|
||||
return max(candidatas, key=lambda palavra: palavra.fim, default=None)
|
||||
|
||||
def _primeira_palavra_mantida_depois(
|
||||
self,
|
||||
instante: float,
|
||||
cortes: tuple[tuple[int, AcaoDeEdicao], ...],
|
||||
palavras: tuple[PalavraDeTranscricao, ...],
|
||||
) -> PalavraDeTranscricao | None:
|
||||
candidatas = (
|
||||
palavra
|
||||
for palavra in palavras
|
||||
if palavra.inicio >= instante - self._TOLERANCIA_EM_SEGUNDOS
|
||||
and self._palavra_esta_mantida(palavra, cortes)
|
||||
)
|
||||
return min(candidatas, key=lambda palavra: palavra.inicio, default=None)
|
||||
@@ -18,6 +18,7 @@ from .ingestao_de_voz import (
|
||||
LeitorDeDadosParaIA,
|
||||
)
|
||||
from .limpeza import LimpadorDeDuplicatas
|
||||
from .leitura_semantica_do_plano import LeitorDeTranscricaoDoPlano
|
||||
from .repositorio_de_planos import (
|
||||
AcaoDoPlano,
|
||||
ErroDePlano,
|
||||
@@ -45,6 +46,7 @@ __all__ = [
|
||||
"IndexadorSemantico",
|
||||
"IngestorDeVozNoBanco",
|
||||
"LeitorDeDadosParaIA",
|
||||
"LeitorDeTranscricaoDoPlano",
|
||||
"LimpadorDeDuplicatas",
|
||||
"PlanoDeEdicao",
|
||||
"RepositorioDeEnunciados",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Leitura da transcrição vinculada a um plano para validação prévia."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from engine.editor.erros import ErroDePlanoInvalido
|
||||
from engine.scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
|
||||
|
||||
|
||||
class LeitorDeTranscricaoDoPlano:
|
||||
"""Reconstrói tempos de origem das falas associadas a um plano salvo."""
|
||||
|
||||
def __init__(self, conexao: sqlite3.Connection) -> None:
|
||||
"""Recebe a conexão de leitura do banco de análises."""
|
||||
self._conexao = conexao
|
||||
|
||||
def carregar(self, plano_id: int) -> tuple[SegmentoDeTranscricao, ...]:
|
||||
"""Carrega falas e palavras do plano, convertidas para a mídia original."""
|
||||
plano = self._conexao.execute(
|
||||
"SELECT video_id FROM planos_de_edicao WHERE id = ?",
|
||||
(plano_id,),
|
||||
).fetchone()
|
||||
if plano is None:
|
||||
raise ErroDePlanoInvalido(f"O plano {plano_id} não existe no banco de análises.")
|
||||
video_id = plano["video_id"]
|
||||
if not video_id:
|
||||
raise ErroDePlanoInvalido(
|
||||
f"O plano {plano_id} não está associado a um vídeo analisado."
|
||||
)
|
||||
linhas = self._conexao.execute(
|
||||
"""SELECT s.id, s.inicio, s.fim, s.texto, s.confianca, s.falante,
|
||||
c.inicio_na_timeline, c.inicio_na_origem
|
||||
FROM segmentos_de_transcricao AS s
|
||||
LEFT JOIN clipes AS c
|
||||
ON c.video_id = s.video_id AND c.id = s.clipe_id
|
||||
WHERE s.video_id = ?
|
||||
ORDER BY s.inicio, s.id""",
|
||||
(video_id,),
|
||||
).fetchall()
|
||||
if not linhas:
|
||||
raise ErroDePlanoInvalido(
|
||||
"Não há transcrição para validar as frases deste plano. "
|
||||
"Rode o Scanner novamente."
|
||||
)
|
||||
falas = tuple(self._converter_fala(linha) for linha in linhas)
|
||||
if not any(fala.palavras for fala in falas):
|
||||
raise ErroDePlanoInvalido(
|
||||
"A transcrição não possui tempos por palavra. "
|
||||
"Rode o Scanner novamente antes de aplicar."
|
||||
)
|
||||
return falas
|
||||
|
||||
def _converter_fala(self, linha: sqlite3.Row) -> SegmentoDeTranscricao:
|
||||
deslocamento = self._calcular_deslocamento(linha)
|
||||
palavras = tuple(
|
||||
PalavraDeTranscricao(
|
||||
texto=palavra["texto"],
|
||||
inicio=max(0.0, float(palavra["inicio"]) + deslocamento),
|
||||
fim=max(0.0, float(palavra["fim"]) + deslocamento),
|
||||
confianca=palavra["confianca"],
|
||||
falante=palavra["falante"],
|
||||
)
|
||||
for palavra in self._conexao.execute(
|
||||
"""SELECT texto, inicio, fim, confianca, falante
|
||||
FROM palavras_de_transcricao
|
||||
WHERE segmento_id = ?
|
||||
ORDER BY ordem""",
|
||||
(linha["id"],),
|
||||
).fetchall()
|
||||
)
|
||||
return SegmentoDeTranscricao(
|
||||
inicio=max(0.0, float(linha["inicio"]) + deslocamento),
|
||||
fim=max(0.0, float(linha["fim"]) + deslocamento),
|
||||
texto=linha["texto"],
|
||||
confianca=linha["confianca"],
|
||||
palavras=palavras,
|
||||
falante=linha["falante"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _calcular_deslocamento(linha: sqlite3.Row) -> float:
|
||||
if linha["inicio_na_origem"] is None or linha["inicio_na_timeline"] is None:
|
||||
return 0.0
|
||||
return float(linha["inicio_na_origem"]) - float(linha["inicio_na_timeline"])
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Testes da resolução do executável Node usada pelo aplicador de planos."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from engine import aplicar_plano_de_edicao
|
||||
from engine.editor import ErroDePlanoInvalido
|
||||
|
||||
|
||||
class TesteResolucaoDoNode(unittest.TestCase):
|
||||
@@ -35,5 +38,50 @@ class TesteResolucaoDoNode(unittest.TestCase):
|
||||
aplicar_plano_de_edicao.resolver_caminho_do_node()
|
||||
|
||||
|
||||
class TestePreValidacaoDoPlano(unittest.TestCase):
|
||||
"""Garante que planos semanticamente perigosos não alcancem o Premiere."""
|
||||
|
||||
def test_interrompe_antes_de_resolver_node_e_criar_backup(self) -> None:
|
||||
"""A validação semântica ocorre antes de abrir a sessão do editor."""
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
caminho_do_plano = Path(pasta) / "plano.json"
|
||||
caminho_do_plano.write_text(
|
||||
'{"source":"video.mp4","actions":['
|
||||
'{"kind":"cut","start":1.0,"end":2.0,"reason":"teste"}]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch(
|
||||
"engine.aplicar_plano_de_edicao._validar_integridade_semantica",
|
||||
side_effect=ErroDePlanoInvalido("frase interrompida"),
|
||||
) as validar, patch(
|
||||
"engine.aplicar_plano_de_edicao.resolver_caminho_do_node"
|
||||
) as resolver_node:
|
||||
with self.assertRaisesRegex(ErroDePlanoInvalido, "frase interrompida"):
|
||||
aplicar_plano_de_edicao.executar(caminho_do_plano, plano_id=7)
|
||||
|
||||
validar.assert_called_once()
|
||||
resolver_node.assert_not_called()
|
||||
|
||||
def test_painel_rejeita_corte_sem_plano_associado_ao_banco(self) -> None:
|
||||
"""Não permite contornar a validação colando um corte sem transcrição vinculada."""
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
caminho_do_plano = Path(pasta) / "plano.json"
|
||||
caminho_do_plano.write_text(
|
||||
'{"source":"video.mp4","actions":['
|
||||
'{"kind":"cut","start":1.0,"end":2.0,"reason":"teste"}]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch(
|
||||
"engine.aplicar_plano_de_edicao.resolver_caminho_do_node"
|
||||
) as resolver_node:
|
||||
with self.assertRaisesRegex(ErroDePlanoInvalido, "Salve ou carregue"):
|
||||
aplicar_plano_de_edicao.executar(
|
||||
caminho_do_plano,
|
||||
exigir_validacao_semantica=True,
|
||||
)
|
||||
|
||||
resolver_node.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Testes da leitura de falas usada antes da aplicação de um plano."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from engine.editor import ErroDePlanoInvalido
|
||||
from engine.persistencia import LeitorDeTranscricaoDoPlano, abrir_banco
|
||||
|
||||
|
||||
class TesteLeitorDeTranscricaoDoPlano(unittest.TestCase):
|
||||
"""Valida vínculo do plano e conversão de timeline para mídia original."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Cria um banco isolado com um clipe deslocado na timeline."""
|
||||
self._pasta_temporaria = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._pasta_temporaria.cleanup)
|
||||
caminho_do_banco = Path(self._pasta_temporaria.name) / "analises.db"
|
||||
self.conexao = abrir_banco(caminho_do_banco)
|
||||
self.addCleanup(self.conexao.close)
|
||||
with self.conexao:
|
||||
self.conexao.execute(
|
||||
"INSERT INTO videos (id, nome, duracao) VALUES ('video-1', 'Vídeo', 40.0)"
|
||||
)
|
||||
self.conexao.execute(
|
||||
"""INSERT INTO faixas (id, video_id, nome, tipo, indice)
|
||||
VALUES ('audio-1', 'video-1', 'A1', 'audio', 0)"""
|
||||
)
|
||||
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-1', 'video-1', 'audio-1', 'Clipe', 10.0, 30.0,
|
||||
2.0, 22.0, '/video.mp4', 0, '{}')"""
|
||||
)
|
||||
segmento = self.conexao.execute(
|
||||
"""INSERT INTO segmentos_de_transcricao
|
||||
(video_id, clipe_id, inicio, fim, texto)
|
||||
VALUES ('video-1', 'clipe-1', 12.0, 14.0, 'Frase completa.')"""
|
||||
)
|
||||
self.conexao.execute(
|
||||
"""INSERT INTO palavras_de_transcricao
|
||||
(segmento_id, ordem, texto, inicio, fim)
|
||||
VALUES (?, 0, 'Frase', 12.0, 12.5)""",
|
||||
(segmento.lastrowid,),
|
||||
)
|
||||
self.conexao.execute(
|
||||
"""INSERT INTO planos_de_edicao (video_id, origem)
|
||||
VALUES ('video-1', '/video.mp4')"""
|
||||
)
|
||||
|
||||
def test_converte_tempos_para_a_origem_do_clipe(self) -> None:
|
||||
"""Subtrai a posição da timeline e soma o início na mídia original."""
|
||||
falas = LeitorDeTranscricaoDoPlano(self.conexao).carregar(1)
|
||||
|
||||
self.assertEqual(falas[0].inicio, 4.0)
|
||||
self.assertEqual(falas[0].fim, 6.0)
|
||||
self.assertEqual(falas[0].palavras[0].inicio, 4.0)
|
||||
self.assertEqual(falas[0].palavras[0].fim, 4.5)
|
||||
|
||||
def test_rejeita_plano_inexistente(self) -> None:
|
||||
"""Evita validar uma transcrição que não pertence ao plano solicitado."""
|
||||
with self.assertRaisesRegex(ErroDePlanoInvalido, "não existe"):
|
||||
LeitorDeTranscricaoDoPlano(self.conexao).carregar(999)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Testes de regressão da integridade semântica dos cortes editoriais."""
|
||||
|
||||
import unittest
|
||||
|
||||
from engine.editor import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
|
||||
from engine.editor.validacao_semantica import ValidadorSemanticoDePlano
|
||||
from engine.scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
|
||||
|
||||
|
||||
class TesteValidadorSemanticoDePlano(unittest.TestCase):
|
||||
"""Impede cortes de palavra e retomadas no meio de uma frase."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Cria o validador com a margem segura usada pela aplicação real."""
|
||||
self.validador = ValidadorSemanticoDePlano(margem_minima_em_segundos=0.3)
|
||||
|
||||
def test_rejeita_caso_real_que_resultou_em_trazer_uma_seguranca_cortado(self) -> None:
|
||||
"""Reproduz as duas bordas problemáticas do último clipe aplicado."""
|
||||
falas = (
|
||||
SegmentoDeTranscricao(
|
||||
inicio=906.15,
|
||||
fim=934.63,
|
||||
texto="Eu acho que hoje, além de uma técnica boa, é você...",
|
||||
palavras=(
|
||||
PalavraDeTranscricao("você...", 934.10, 934.63),
|
||||
),
|
||||
),
|
||||
SegmentoDeTranscricao(
|
||||
inicio=934.65,
|
||||
fim=962.60,
|
||||
texto=(
|
||||
"conseguir achar um profissional que te passe confiança. "
|
||||
"Então é trazer uma segurança."
|
||||
),
|
||||
palavras=(
|
||||
PalavraDeTranscricao("conseguir", 934.65, 935.20),
|
||||
PalavraDeTranscricao("Então", 959.05, 959.55),
|
||||
PalavraDeTranscricao("é", 959.55, 960.45),
|
||||
PalavraDeTranscricao("trazer", 960.45, 961.81),
|
||||
PalavraDeTranscricao("uma", 961.94, 962.20),
|
||||
PalavraDeTranscricao("segurança.", 962.20, 962.60),
|
||||
),
|
||||
),
|
||||
SegmentoDeTranscricao(
|
||||
inicio=963.10,
|
||||
fim=970.0,
|
||||
texto="Eu recomendaria esse profissional.",
|
||||
palavras=(PalavraDeTranscricao("Eu", 963.10, 963.30),),
|
||||
),
|
||||
)
|
||||
plano = PlanoDeEdicao(
|
||||
"0E6A8829.MP4",
|
||||
(
|
||||
AcaoDeEdicao(TipoDeAcao.CORTE, 875.50, 934.65, "Remove trecho anterior."),
|
||||
AcaoDeEdicao(TipoDeAcao.CORTE, 962.60, 1018.22, "Encerra após a conclusão."),
|
||||
),
|
||||
)
|
||||
|
||||
problemas = self.validador.validar(plano, falas)
|
||||
|
||||
codigos = {problema.codigo for problema in problemas}
|
||||
self.assertIn("retomada_no_meio_da_frase", codigos)
|
||||
self.assertIn("margem_antes_do_corte_insuficiente", codigos)
|
||||
|
||||
def test_aceita_frases_completas_com_respiro_nas_duas_bordas(self) -> None:
|
||||
"""Aceita a remoção posicionada no silêncio entre frases completas."""
|
||||
falas = (
|
||||
SegmentoDeTranscricao(
|
||||
inicio=0.0,
|
||||
fim=4.0,
|
||||
texto="Esta é uma frase completa.",
|
||||
palavras=(PalavraDeTranscricao("completa.", 3.4, 4.0),),
|
||||
),
|
||||
SegmentoDeTranscricao(
|
||||
inicio=8.0,
|
||||
fim=12.0,
|
||||
texto="Esta é a próxima frase.",
|
||||
palavras=(PalavraDeTranscricao("Esta", 8.0, 8.4),),
|
||||
),
|
||||
)
|
||||
plano = PlanoDeEdicao(
|
||||
"video.mp4",
|
||||
(AcaoDeEdicao(TipoDeAcao.CORTE, 4.30, 7.70, "Remove silêncio longo."),),
|
||||
)
|
||||
|
||||
self.assertEqual(self.validador.validar(plano, falas), ())
|
||||
|
||||
def test_rejeita_borda_que_intercepta_uma_palavra(self) -> None:
|
||||
"""Distingue palavra realmente cortada de simples ausência de margem."""
|
||||
falas = (
|
||||
SegmentoDeTranscricao(
|
||||
inicio=0.0,
|
||||
fim=3.0,
|
||||
texto="Vamos trazer segurança.",
|
||||
palavras=(PalavraDeTranscricao("segurança.", 2.0, 3.0),),
|
||||
),
|
||||
)
|
||||
plano = PlanoDeEdicao(
|
||||
"video.mp4",
|
||||
(AcaoDeEdicao(TipoDeAcao.CORTE, 2.5, 5.0, "Corte inválido."),),
|
||||
)
|
||||
|
||||
problemas = self.validador.validar(plano, falas)
|
||||
|
||||
self.assertIn("palavra_interceptada", {problema.codigo for problema in problemas})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user