Files
jhonny-editor/code/engine/editor/validacao_semantica.py
T
João Henrique 2c9bdf2ff0 feat: criado monitoramento contínuo de trilhas com varredura recur
- 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
2026-09-10 15:09:27 -04:00

301 lines
12 KiB
Python

"""Validação semântica de cortes e ganchos 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 editoriais."""
_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 os problemas semânticos encontrados em cortes e ganchos."""
cortes = tuple(
(indice, acao)
for indice, acao in enumerate(plano.acoes)
if acao.tipo is TipoDeAcao.CORTE
)
ganchos = tuple(
(indice, acao)
for indice, acao in enumerate(plano.acoes)
if acao.tipo is TipoDeAcao.GANCHO
)
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))
for indice, gancho in ganchos:
problemas.extend(self._validar_palavras_interceptadas(indice, gancho, palavras))
problemas.extend(self._validar_frase_do_gancho(indice, gancho, falas, palavras))
return tuple(problemas)
def garantir_valido(
self,
plano: PlanoDeEdicao,
falas: tuple[SegmentoDeTranscricao, ...],
) -> None:
"""Levanta erro explicativo quando uma emenda ameaça a integridade da fala."""
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_frase_do_gancho(
self,
indice: int,
gancho: AcaoDeEdicao,
falas: tuple[SegmentoDeTranscricao, ...],
palavras: tuple[PalavraDeTranscricao, ...],
) -> list[ProblemaSemanticoDoPlano]:
"""Confirma que o gancho duplicado começa e termina como uma fala completa."""
contidas = tuple(
palavra for palavra in palavras
if gancho.inicio - self._TOLERANCIA_EM_SEGUNDOS <= palavra.inicio
and palavra.fim <= gancho.fim + self._TOLERANCIA_EM_SEGUNDOS
)
if not contidas:
return [ProblemaSemanticoDoPlano(
codigo="gancho_sem_fala",
mensagem=f"ação {indice + 1}: o trecho do gancho não contém palavras temporizadas",
indice_da_acao=indice,
instante=gancho.inicio,
)]
primeira, ultima = contidas[0], contidas[-1]
problemas: list[ProblemaSemanticoDoPlano] = []
if primeira.inicio - gancho.inicio < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS:
problemas.append(ProblemaSemanticoDoPlano(
codigo="margem_inicial_do_gancho_insuficiente",
mensagem=(
f"ação {indice + 1}: deixe ao menos {self._margem_minima:.2f}s antes "
f'da primeira palavra do gancho "{primeira.texto}"'
),
indice_da_acao=indice,
instante=gancho.inicio,
))
if gancho.fim - ultima.fim < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS:
problemas.append(ProblemaSemanticoDoPlano(
codigo="margem_final_do_gancho_insuficiente",
mensagem=(
f"ação {indice + 1}: deixe ao menos {self._margem_minima:.2f}s após "
f'a última palavra do gancho "{ultima.texto}"'
),
indice_da_acao=indice,
instante=gancho.fim,
))
fala_inicial = self._fala_da_palavra(primeira, falas)
primeira_letra = next((letra for letra in primeira.texto if letra.isalpha()), "")
comeca_no_meio = (
fala_inicial is not None
and primeira.inicio > fala_inicial.inicio + self._TOLERANCIA_EM_SEGUNDOS
) or bool(primeira_letra and primeira_letra.islower())
if comeca_no_meio:
problemas.append(ProblemaSemanticoDoPlano(
codigo="gancho_comeca_no_meio_da_frase",
mensagem=f'ação {indice + 1}: o gancho começa em "{primeira.texto}", no meio da frase',
indice_da_acao=indice,
instante=gancho.inicio,
))
if not ultima.texto.rstrip().endswith(self._PONTUACAO_DE_FECHAMENTO):
problemas.append(ProblemaSemanticoDoPlano(
codigo="gancho_termina_no_meio_da_frase",
mensagem=f'ação {indice + 1}: o gancho termina em "{ultima.texto}" sem concluir a frase',
indice_da_acao=indice,
instante=gancho.fim,
))
return problemas
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)