feat: alinhada a remoção da engine Python ao fluxo ripple do execu
- alinhada a remoção da engine Python ao fluxo ripple do executor CEP para cortar vídeo e áudio - criada remoção atômica para manter vídeo e áudio vinculados sincronizados - criada aba de configuração de legendas offline e inclusão das preferências no pacote para a IA - criado gerador offline de blocos de legenda sincronizados com palavras da transcrição e cortes aprovados - criada uma versão base portátil da skill edit-video-by-voice para testes em outros agentes de IA - incorporado perfil editorial opcional à skill portátil para orientar decisões de edição Resumo: - 14 arquivos alterados - 4 novos - 10 modificados - 0 removidos 10 files changed, 278 insertions(+), 33 deletions(-) Arquivos: - code/cep-plugin/index.html - code/cep-plugin/main.js - code/docs/supported-actions.md - code/engine/editor/aplicador_de_plano_de_edicao.py - code/engine/editor/escrita/escrita_no_editor.py - code/engine/testes/duplos_de_premiere.py - code/engine/testes/test_aplicador_de_plano_de_edicao.py - code/src/tools/timeline.ts - code/tests/tools/structural-verification.test.ts - code/tests/tools/tool-modules.test.ts - code/engine/arquitetura/legendas.md - code/engine/editor/legendas/ - code/engine/testes/test_gerador_de_legendas.py - skills/
This commit is contained in:
@@ -176,9 +176,11 @@ class AplicadorDePlanoDeEdicao:
|
||||
for faixa in faixas:
|
||||
self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
||||
|
||||
trechos_removidos = 0
|
||||
for faixa in faixas:
|
||||
trechos_removidos += self._cortar_faixa(faixa, acao, arquivo_de_origem)
|
||||
alvos_por_faixa = {
|
||||
faixa.identificador: self._preparar_alvos_da_faixa(faixa, acao, arquivo_de_origem)
|
||||
for faixa in faixas
|
||||
}
|
||||
trechos_removidos = self._remover_alvos_coordenados(faixas, alvos_por_faixa)
|
||||
if trechos_removidos == 0:
|
||||
raise ErroDeMapeamentoDeTempo(
|
||||
f"Nenhum clipe de {arquivo_de_origem!r} sobrepõe o intervalo {acao.inicio}-{acao.fim}s de origem."
|
||||
@@ -187,12 +189,14 @@ class AplicadorDePlanoDeEdicao:
|
||||
except ErroDeEdicao as erro:
|
||||
return ResultadoDaAcao(acao, False, str(erro))
|
||||
|
||||
def _cortar_faixa(self, faixa: Faixa, acao: AcaoDeEdicao, arquivo_de_origem: str) -> int:
|
||||
"""Corta o trecho de ``acao`` em uma única faixa e devolve quantos clipes foram removidos."""
|
||||
def _preparar_alvos_da_faixa(
|
||||
self, faixa: Faixa, acao: AcaoDeEdicao, arquivo_de_origem: str
|
||||
) -> list[Clipe]:
|
||||
"""Divide uma faixa e devolve os clipes que devem ser removidos, sem removê-los."""
|
||||
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
||||
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
|
||||
if not afetados:
|
||||
return 0
|
||||
return []
|
||||
|
||||
primeiro, ultimo = afetados[0], afetados[-1]
|
||||
inicio_pedido = self._borda_de_entrada(primeiro, acao.inicio)
|
||||
@@ -225,9 +229,69 @@ class AplicadorDePlanoDeEdicao:
|
||||
f"de origem, encontrado {duracao_removida:.3f}s cobertos por clipe); nada foi removido para "
|
||||
"evitar apagar o trecho errado."
|
||||
)
|
||||
for clipe in alvo:
|
||||
self.escrita.remover_trecho(clipe.identificador)
|
||||
return len(alvo)
|
||||
return alvo
|
||||
|
||||
def _remover_alvos_coordenados(
|
||||
self, faixas: list[Faixa], alvos_por_faixa: dict[str, list[Clipe]]
|
||||
) -> int:
|
||||
"""Remove alvos de vídeo e áudio em pares antes de aplicar o ripple.
|
||||
|
||||
O bridge não preserva de forma confiável o vínculo nativo depois de
|
||||
uma divisão. O pareamento por intervalo de timeline é deliberado:
|
||||
ambos os lados já foram divididos e validados antes desta etapa.
|
||||
"""
|
||||
faixas_de_video = [faixa for faixa in faixas if faixa.tipo == "video"]
|
||||
faixas_de_audio = [faixa for faixa in faixas if faixa.tipo == "audio"]
|
||||
removidos: set[str] = set()
|
||||
quantidade = 0
|
||||
|
||||
for faixa_de_video in faixas_de_video:
|
||||
for clipe_de_video in alvos_por_faixa[faixa_de_video.identificador]:
|
||||
par = self._localizar_par_de_audio(
|
||||
clipe_de_video,
|
||||
faixas_de_audio,
|
||||
alvos_por_faixa,
|
||||
removidos,
|
||||
)
|
||||
if par is None:
|
||||
self.escrita.remover_trecho(clipe_de_video.identificador)
|
||||
removidos.add(clipe_de_video.identificador)
|
||||
quantidade += 1
|
||||
continue
|
||||
self.escrita.remover_trechos_vinculados([clipe_de_video.identificador, par.identificador])
|
||||
removidos.update({clipe_de_video.identificador, par.identificador})
|
||||
quantidade += 2
|
||||
|
||||
for faixa in faixas:
|
||||
for clipe in alvos_por_faixa[faixa.identificador]:
|
||||
if clipe.identificador in removidos:
|
||||
continue
|
||||
self.escrita.remover_trecho(clipe.identificador)
|
||||
removidos.add(clipe.identificador)
|
||||
quantidade += 1
|
||||
return quantidade
|
||||
|
||||
def _localizar_par_de_audio(
|
||||
self,
|
||||
clipe_de_video: Clipe,
|
||||
faixas_de_audio: list[Faixa],
|
||||
alvos_por_faixa: dict[str, list[Clipe]],
|
||||
identificadores_ja_removidos: set[str],
|
||||
) -> Clipe | None:
|
||||
"""Encontra o áudio do mesmo intervalo de timeline do vídeo alvo."""
|
||||
for faixa_de_audio in faixas_de_audio:
|
||||
for clipe_de_audio in alvos_por_faixa[faixa_de_audio.identificador]:
|
||||
if clipe_de_audio.identificador in identificadores_ja_removidos:
|
||||
continue
|
||||
inicio_igual = abs(
|
||||
clipe_de_video.intervalo_na_timeline.inicio - clipe_de_audio.intervalo_na_timeline.inicio
|
||||
) <= _TOLERANCIA_DE_BORDA_EM_SEGUNDOS
|
||||
fim_igual = abs(
|
||||
clipe_de_video.intervalo_na_timeline.fim - clipe_de_audio.intervalo_na_timeline.fim
|
||||
) <= _TOLERANCIA_DE_BORDA_EM_SEGUNDOS
|
||||
if inicio_igual and fim_igual:
|
||||
return clipe_de_audio
|
||||
return None
|
||||
|
||||
def _dividir_e_conferir(self, instante_pedido: float, faixa: Faixa, arquivo_de_origem: str) -> float:
|
||||
"""Pede o corte em ``instante_pedido`` e devolve a borda que o Premiere realmente criou.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Escrita de mutações na sequência ativa do Premiere via MCP.
|
||||
|
||||
Isola o nome das ferramentas MCP e o formato de argumentos externos
|
||||
(``split_clip``, ``remove_from_timeline``, ``set_clip_properties``,
|
||||
(``split_clip``, ``remove_from_timeline``, ``remove_linked_from_timeline``, ``set_clip_properties``,
|
||||
``add_marker``) do resto do domínio. Quem chama esta classe fala só em
|
||||
segundos de timeline, identificador de clipe e fator de escala — nunca em
|
||||
nomes de ferramenta ou ticks do Premiere.
|
||||
@@ -81,21 +81,38 @@ class EscritaNoEditor:
|
||||
def remover_trecho(self, identificador_do_clipe: str) -> None:
|
||||
"""Remove o clipe da timeline fechando o espaço que ele ocupava.
|
||||
|
||||
Usa ``ripple_delete`` (QE ``rippleDelete``), não
|
||||
``remove_from_timeline``: numa sequência real com outras faixas
|
||||
ocupadas, o ``remove_from_timeline`` com ``ripple`` apagou o clipe
|
||||
mas deixou o buraco aberto, e um buraco no meio do corte é um corte
|
||||
errado. O ``ripple_delete`` confere, do lado do Premiere, que o
|
||||
clipe realmente sumiu.
|
||||
Usa ``remove_from_timeline`` com ``ripple=True``, o mesmo caminho
|
||||
usado pelo executor da aba Editar vídeo. O ``ripple_delete`` do QE
|
||||
retorna sem alterar a timeline em algumas versões do Premiere; isso
|
||||
deixava o clipe de vídeo dividido, mas ainda presente, e impedia o
|
||||
processamento do áudio. O aplicador relê a timeline entre as ações
|
||||
para localizar os identificadores atualizados.
|
||||
|
||||
Levanta :class:`ErroDeEscritaNoEditor` se o Premiere recusar a
|
||||
remoção.
|
||||
"""
|
||||
try:
|
||||
self.cliente_mcp.chamar("ripple_delete", {"node_id": identificador_do_clipe})
|
||||
self.cliente_mcp.chamar("remove_from_timeline", {"node_id": identificador_do_clipe, "ripple": True})
|
||||
except ErroDeFerramentaMCP as erro:
|
||||
raise ErroDeEscritaNoEditor(f"Não foi possível remover o clipe {identificador_do_clipe!r}: {erro}") from erro
|
||||
|
||||
def remover_trechos_vinculados(self, identificadores_dos_clipes: list[str]) -> None:
|
||||
"""Remove simultaneamente os dois clipes correspondentes de vídeo e áudio.
|
||||
|
||||
A operação exige exatamente dois IDs e é verificada pelo adaptador do
|
||||
Premiere como um par que ocupa o mesmo intervalo. Isso evita que o
|
||||
``ripple`` de uma faixa deixe a outra com uma lacuna.
|
||||
"""
|
||||
try:
|
||||
self.cliente_mcp.chamar(
|
||||
"remove_linked_from_timeline",
|
||||
{"node_ids": identificadores_dos_clipes},
|
||||
)
|
||||
except ErroDeFerramentaMCP as erro:
|
||||
raise ErroDeEscritaNoEditor(
|
||||
f"Não foi possível remover o par vinculado {identificadores_dos_clipes!r}: {erro}"
|
||||
) from erro
|
||||
|
||||
def aplicar_zoom(self, identificador_do_clipe: str, fator_de_escala: float) -> None:
|
||||
"""Aplica um punch-in no clipe, escalando-o por ``fator_de_escala`` (1.0 = tamanho original).
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Geração determinística de blocos de legenda a partir da transcrição."""
|
||||
|
||||
from .gerador_de_legendas import ConfiguracaoDeLegendas, GeradorDeLegendas, LegendaGerada
|
||||
|
||||
__all__ = ["ConfiguracaoDeLegendas", "GeradorDeLegendas", "LegendaGerada"]
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Geração offline de legendas sincronizadas com uma timeline pós-corte.
|
||||
|
||||
Este módulo transforma palavras já temporizadas em blocos curtos de texto.
|
||||
Ele não chama IA, não lê arquivos e não altera o Premiere; essas integrações
|
||||
devem permanecer nos adaptadores e no caso de uso que o coordena.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ...scanner.modelos import PalavraDeTranscricao
|
||||
from ..modelos import AcaoDeEdicao, TipoDeAcao
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfiguracaoDeLegendas:
|
||||
"""Define os limites determinísticos usados para agrupar palavras."""
|
||||
|
||||
maximo_de_palavras: int = 5
|
||||
maximo_de_caracteres: int = 32
|
||||
pausa_para_quebrar_em_segundos: float = 0.65
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.maximo_de_palavras < 1:
|
||||
raise ValueError("O máximo de palavras deve ser maior que zero.")
|
||||
if self.maximo_de_caracteres < 1:
|
||||
raise ValueError("O máximo de caracteres deve ser maior que zero.")
|
||||
if self.pausa_para_quebrar_em_segundos < 0:
|
||||
raise ValueError("A pausa para quebra não pode ser negativa.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LegendaGerada:
|
||||
"""Representa um bloco de legenda já posicionado na timeline final."""
|
||||
|
||||
texto: str
|
||||
inicio: float
|
||||
fim: float
|
||||
|
||||
|
||||
class GeradorDeLegendas:
|
||||
"""Converte palavras da fonte em blocos de legenda pós-corte."""
|
||||
|
||||
def gerar(
|
||||
self,
|
||||
palavras: list[PalavraDeTranscricao],
|
||||
cortes: list[AcaoDeEdicao],
|
||||
duracao_da_fonte: float,
|
||||
configuracao: ConfiguracaoDeLegendas | None = None,
|
||||
) -> list[LegendaGerada]:
|
||||
"""Gera legendas usando somente palavras que permanecem no vídeo.
|
||||
|
||||
Parâmetros:
|
||||
palavras: Palavras com tempos em segundos da mídia original.
|
||||
cortes: Ações ``cut`` em coordenadas da mídia original.
|
||||
duracao_da_fonte: Duração total da mídia original.
|
||||
configuracao: Limites de agrupamento; usa os padrões quando omitida.
|
||||
|
||||
Retorna:
|
||||
Lista ordenada de blocos com tempos na timeline depois dos cortes.
|
||||
"""
|
||||
configuracao = configuracao or ConfiguracaoDeLegendas()
|
||||
self._validar_duracao(duracao_da_fonte)
|
||||
intervalos_de_corte = self._normalizar_cortes(cortes, duracao_da_fonte)
|
||||
palavras_mantidas = [
|
||||
palavra for palavra in sorted(palavras, key=lambda item: (item.inicio, item.fim))
|
||||
if self._palavra_mantida(palavra, intervalos_de_corte)
|
||||
]
|
||||
palavras_reposicionadas = [
|
||||
(palavra, self._reposicionar_tempo(palavra, intervalos_de_corte))
|
||||
for palavra in palavras_mantidas
|
||||
]
|
||||
return self._agrupar(palavras_reposicionadas, configuracao)
|
||||
|
||||
@staticmethod
|
||||
def _validar_duracao(duracao_da_fonte: float) -> None:
|
||||
"""Valida a duração usada para limitar os intervalos."""
|
||||
if duracao_da_fonte <= 0:
|
||||
raise ValueError("A duração da fonte deve ser maior que zero.")
|
||||
|
||||
@staticmethod
|
||||
def _normalizar_cortes(cortes: list[AcaoDeEdicao], duracao_da_fonte: float) -> list[tuple[float, float]]:
|
||||
"""Ordena, limita e valida as ações de corte."""
|
||||
intervalos = sorted(
|
||||
(acao.inicio, min(acao.fim, duracao_da_fonte))
|
||||
for acao in cortes
|
||||
if acao.tipo is TipoDeAcao.CORTE and acao.inicio < duracao_da_fonte
|
||||
)
|
||||
anteriores: list[tuple[float, float]] = []
|
||||
for inicio, fim in intervalos:
|
||||
if fim <= inicio:
|
||||
continue
|
||||
if anteriores and inicio < anteriores[-1][1]:
|
||||
raise ValueError("Os cortes das legendas não podem se sobrepor.")
|
||||
anteriores.append((max(0.0, inicio), fim))
|
||||
return anteriores
|
||||
|
||||
@staticmethod
|
||||
def _palavra_mantida(palavra: PalavraDeTranscricao, cortes: list[tuple[float, float]]) -> bool:
|
||||
"""Retorna falso quando a palavra está dentro ou atravessa um corte."""
|
||||
return not any(palavra.inicio < fim and palavra.fim > inicio for inicio, fim in cortes)
|
||||
|
||||
@staticmethod
|
||||
def _reposicionar_tempo(palavra: PalavraDeTranscricao, cortes: list[tuple[float, float]]) -> tuple[float, float]:
|
||||
"""Subtrai os intervalos removidos anteriores à palavra."""
|
||||
deslocamento = sum(fim - inicio for inicio, fim in cortes if fim <= palavra.inicio)
|
||||
return palavra.inicio - deslocamento, palavra.fim - deslocamento
|
||||
|
||||
def _agrupar(
|
||||
self,
|
||||
palavras: list[tuple[PalavraDeTranscricao, tuple[float, float]]],
|
||||
configuracao: ConfiguracaoDeLegendas,
|
||||
) -> list[LegendaGerada]:
|
||||
"""Agrupa palavras consecutivas respeitando os limites configurados."""
|
||||
legendas: list[LegendaGerada] = []
|
||||
bloco: list[tuple[PalavraDeTranscricao, tuple[float, float]]] = []
|
||||
quantidade_de_caracteres = 0
|
||||
for palavra, intervalo in palavras:
|
||||
texto = palavra.texto.strip()
|
||||
separador = 1 if bloco else 0
|
||||
estoura_limites = (
|
||||
len(bloco) >= configuracao.maximo_de_palavras
|
||||
or quantidade_de_caracteres + separador + len(texto) > configuracao.maximo_de_caracteres
|
||||
)
|
||||
tem_pausa = bool(bloco) and intervalo[0] - bloco[-1][1][1] >= configuracao.pausa_para_quebrar_em_segundos
|
||||
if bloco and (estoura_limites or tem_pausa):
|
||||
legendas.append(self._criar_legenda(bloco))
|
||||
bloco = []
|
||||
quantidade_de_caracteres = 0
|
||||
bloco.append((palavra, intervalo))
|
||||
quantidade_de_caracteres += (1 if len(bloco) > 1 else 0) + len(texto)
|
||||
if bloco:
|
||||
legendas.append(self._criar_legenda(bloco))
|
||||
return legendas
|
||||
|
||||
@staticmethod
|
||||
def _criar_legenda(bloco: list[tuple[PalavraDeTranscricao, tuple[float, float]]]) -> LegendaGerada:
|
||||
"""Materializa um bloco de palavras em uma legenda."""
|
||||
texto = " ".join(palavra.texto.strip() for palavra, _ in bloco)
|
||||
return LegendaGerada(texto=texto, inicio=bloco[0][1][0], fim=bloco[-1][1][1])
|
||||
Reference in New Issue
Block a user