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:
João Henrique
2026-09-09 13:05:07 -04:00
parent f3e356340b
commit 1249b3aeb7
18 changed files with 730 additions and 33 deletions
+27
View File
@@ -49,6 +49,9 @@
<button class="tab-button" id="tabBtnTestarAcoes" role="tab" aria-selected="false" aria-controls="tabTestarAcoes" onclick="switchTab('testar-acoes')" type="button">
<span class="tab-icon" aria-hidden="true">⚗</span>Testar ações
</button>
<button class="tab-button" id="tabBtnLegendas" role="tab" aria-selected="false" aria-controls="tabLegendas" onclick="switchTab('legendas')" type="button">
<span class="tab-icon" aria-hidden="true">▤</span>Legendas
</button>
</nav>
<!-- O painel é reposicionado para a aba que iniciou a operação. -->
@@ -607,6 +610,30 @@
</details>
</div>
<!-- =============================== LEGENDAS =============================== -->
<div class="tab-panel" id="tabLegendas" role="tabpanel" aria-labelledby="tabBtnLegendas" hidden>
<p class="tab-intro">Configure como as legendas serão geradas automaticamente a partir da transcrição já existente.</p>
<section class="card">
<div class="card-head"><h2>Geração automática</h2><span class="step-chip">Offline</span></div>
<label class="checkbox-field"><input id="legendasHabilitadas" type="checkbox"><span>Gerar legendas quando o plano for aplicado</span></label>
<p class="field-help">A transcrição fornece o texto e os tempos. A IA decide o plano editorial; o painel gera as legendas localmente somente nos trechos mantidos.</p>
</section>
<section class="card">
<div class="card-head"><h2>Estilo e sincronização</h2></div>
<div class="form-grid form-grid-2">
<div><label class="field-label" for="legendasEstilo">Estilo visual</label><select class="select-field" id="legendasEstilo"><option value="word_top_down">Palavras animadas (MOGRT)</option><option value="subtitle_clean">Legenda limpa</option></select></div>
<div><label class="field-label" for="legendasFaixa">Faixa de vídeo</label><input class="text-field" id="legendasFaixa" type="number" min="0" step="1" value="3"></div>
<div><label class="field-label" for="legendasMaxPalavras">Máximo de palavras</label><input class="text-field" id="legendasMaxPalavras" type="number" min="1" max="12" step="1" value="5"></div>
<div><label class="field-label" for="legendasMaxCaracteres">Máximo de caracteres</label><input class="text-field" id="legendasMaxCaracteres" type="number" min="10" max="80" step="1" value="32"></div>
</div>
<label class="field-label" for="legendasMogrtPath">Arquivo MOGRT</label>
<input class="text-field" id="legendasMogrtPath" type="text" spellcheck="false" placeholder="/caminho/para/legenda.mogrt">
<p class="field-help">A fonte e a animação pertencem ao próprio template MOGRT.</p>
<button class="button button-primary" id="btnSalvarLegendas" onclick="salvarConfiguracaoDeLegendas()" type="button">Salvar configuração</button>
<p class="field-help" id="legendasStatus" aria-live="polite"></p>
</section>
</div>
<!-- =============================== AJUSTES =============================== -->
<div class="tab-panel" id="tabSettings" role="tabpanel" aria-labelledby="tabBtnSettings" hidden>
+41
View File
@@ -234,6 +234,7 @@ function switchTab(name) {
{ key: "models", panel: "tabModels", btn: "tabBtnModels" },
{ key: "settings", panel: "tabSettings", btn: "tabBtnSettings" },
{ key: "testar-acoes", panel: "tabTestarAcoes", btn: "tabBtnTestarAcoes" },
{ key: "legendas", panel: "tabLegendas", btn: "tabBtnLegendas" },
];
tabs.forEach(function (t) {
var active = t.key === name;
@@ -253,6 +254,44 @@ function switchTab(name) {
if (name === "tipos-video") tiposVideoAoAbrir();
}
var LEGENDAS_CONFIGURACAO_CHAVE = "schedule_light_configuracao_legendas";
function configuracaoDeLegendasPadrao() {
return { habilitadas: false, estilo: "word_top_down", faixa_video: 3, max_palavras: 5, max_caracteres: 32, mogrt_path: "" };
}
function carregarConfiguracaoDeLegendas() {
var configuracao = configuracaoDeLegendasPadrao();
try {
var salva = JSON.parse(localStorage.getItem(LEGENDAS_CONFIGURACAO_CHAVE) || "null");
if (salva && typeof salva === "object") configuracao = Object.assign(configuracao, salva);
} catch (e) {}
return configuracao;
}
function restaurarConfiguracaoDeLegendas() {
var configuracao = carregarConfiguracaoDeLegendas();
document.getElementById("legendasHabilitadas").checked = configuracao.habilitadas;
document.getElementById("legendasEstilo").value = configuracao.estilo;
document.getElementById("legendasFaixa").value = configuracao.faixa_video;
document.getElementById("legendasMaxPalavras").value = configuracao.max_palavras;
document.getElementById("legendasMaxCaracteres").value = configuracao.max_caracteres;
document.getElementById("legendasMogrtPath").value = configuracao.mogrt_path;
}
function salvarConfiguracaoDeLegendas() {
var configuracao = {
habilitadas: !!document.getElementById("legendasHabilitadas").checked,
estilo: document.getElementById("legendasEstilo").value,
faixa_video: Number(document.getElementById("legendasFaixa").value),
max_palavras: Number(document.getElementById("legendasMaxPalavras").value),
max_caracteres: Number(document.getElementById("legendasMaxCaracteres").value),
mogrt_path: document.getElementById("legendasMogrtPath").value.trim(),
};
try { localStorage.setItem(LEGENDAS_CONFIGURACAO_CHAVE, JSON.stringify(configuracao)); } catch (e) {}
document.getElementById("legendasStatus").textContent = "Configuração de legendas salva neste computador.";
}
// ---- Teste isolado de ações pela engine Python ----------------------------
var TESTAR_ACOES_ENGINE_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/aplicar_plano_de_edicao.py";
@@ -2530,6 +2569,7 @@ function silenceGenerateJson() {
};
transcript.settings = Object.assign({}, transcript.settings, {
silence_removal: { enabled: silenceEnabled, min_silence_seconds: minSilence },
legendas: carregarConfiguracaoDeLegendas(),
});
transcript.speaker_configuration = speakerConfigurationForJson();
@@ -3296,6 +3336,7 @@ function downloadModel(model, btn, approxMB) {
syncTranscribeModelSelect();
syncLanguageInfo();
renderEditorPersonalities();
restaurarConfiguracaoDeLegendas();
// Passo 1 é o único liberado até o vídeo ser detectado.
setStep(1, "ready", "Comece aqui");
+4 -3
View File
@@ -10,11 +10,11 @@ source catalog may include unreleased actions.
| Surface | Count | Availability |
| --- | ---: | --- |
| Registered core actions | 349 | CEP/local server catalog; host and authority checks still apply |
| Default-profile core actions | 347 | Advertised with `inspect,edit,export,filesystem` |
| Registered core actions | 350 | CEP/local server catalog; host and authority checks still apply |
| Default-profile core actions | 348 | Advertised with `inspect,edit,export,filesystem` |
| Restricted core actions | 2 | Require explicit `unsafe-script` authority |
| Authenticated UXP additions | 93 | Advertised only while a compatible authenticated UXP panel is connected |
| Default profile with UXP | 440 | 347 core plus 93 UXP tools |
| Default profile with UXP | 441 | 348 core plus 93 UXP tools |
## How to read support
@@ -282,6 +282,7 @@ operation” when the tool has no enum-based mode.
| `remove_from_timeline` | Default profile | Single operation | Remove a clip from the timeline |
| `remove_keyframe` | Default profile | Single operation | Remove a keyframe at a specific time from an effect property |
| `remove_keyframe_range` | Default profile | Single operation | Remove all keyframes in a time range from an effect property |
| `remove_linked_from_timeline` | Default profile | Single operation | Remove um par lógico de vídeo e áudio da timeline em uma única operação, fechando o espaço nos dois lados. |
| `remove_selected_clips` | Default profile | Single operation | Remove all currently selected clips from the timeline. |
| `rename_bin` | Default profile | Single operation | Rename a bin (folder) in the project panel |
| `rename_clip` | Default profile | Single operation | Rename a clip on the timeline. Uses QE DOM. |
+29
View File
@@ -0,0 +1,29 @@
# Módulo de legendas automáticas
O módulo de legendas recebe palavras com tempo da transcrição e os intervalos
de corte já aprovados. Ele devolve blocos de legenda na coordenada final da
timeline, sem acessar o Premiere, criar arquivos ou decidir o conteúdo
editorial.
## Interface
`GeradorDeLegendas.gerar(palavras, cortes, duracao_da_fonte, configuracao)`
Retorna `LegendaGerada` com texto, início e fim em segundos da timeline final.
Os cortes são intervalos da mídia original; o gerador calcula o deslocamento
causado pelos cortes anteriores.
## Responsabilidades
- descartar palavras que ficaram dentro de cortes;
- converter os tempos de origem para tempos pós-corte;
- agrupar palavras por limite de palavras, caracteres e pausas;
- manter o módulo determinístico e testável offline.
## Fora do módulo
- decisão editorial;
- leitura do banco ou do JSON externo;
- inserção de MOGRT;
- alteração da timeline;
- renderização ou validação visual.
@@ -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).
+5
View File
@@ -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])
+14 -4
View File
@@ -5,7 +5,7 @@ têm posição na timeline diferente da posição no arquivo de origem — o mes
descompasso encontrado no projeto real entre ``inicio_na_timeline`` e
``inicio_na_origem`` do banco de análises. Implementa só as ferramentas MCP
que o módulo ``engine.editor`` usa: ``get_active_sequence``, ``split_clip``,
``ripple_delete``, ``set_clip_properties`` e ``add_marker``.
``remove_from_timeline``, ``remove_linked_from_timeline``, ``set_clip_properties`` e ``add_marker``.
"""
from __future__ import annotations
@@ -78,8 +78,8 @@ class ClienteMCPFalso(ClienteMCP):
@property
def chamadas_de_remocao(self) -> list[dict[str, Any]]:
"""Argumentos de cada chamada a ``ripple_delete``, na ordem em que ocorreram."""
return [argumentos for nome, argumentos in self.chamadas if nome == "ripple_delete"]
"""Argumentos de cada remoção com ripple, na ordem em que ocorreram."""
return [argumentos for nome, argumentos in self.chamadas if nome in {"ripple_delete", "remove_from_timeline", "remove_linked_from_timeline"}]
def conectar(self) -> None:
"""Não há conexão real; existe só para satisfazer a interface de :class:`ClienteMCP`."""
@@ -98,7 +98,8 @@ class ClienteMCPFalso(ClienteMCP):
despachantes = {
"get_active_sequence": self._simular_get_active_sequence,
"split_clip": self._simular_split_clip,
"ripple_delete": self._simular_ripple_delete,
"remove_from_timeline": self._simular_ripple_delete,
"remove_linked_from_timeline": self._simular_remocao_vinculada,
"set_clip_properties": self._simular_set_clip_properties,
"add_marker": self._simular_add_marker,
"undo": self._simular_undo,
@@ -213,6 +214,15 @@ class ClienteMCPFalso(ClienteMCP):
return {"rippleDeleted": True, "verified": True}
raise ErroDeFerramentaMCP(f"Clipe não encontrado para remoção: {identificador_do_clipe!r}.")
def _simular_remocao_vinculada(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Remove o par de vídeo e áudio, fechando o espaço nos dois lados."""
identificadores = argumentos["node_ids"]
if len(identificadores) != 2:
raise ErroDeFerramentaMCP("O par vinculado precisa conter dois clipes.")
for identificador in identificadores:
self._simular_ripple_delete({"node_id": identificador, "ripple": True})
return {"removed": True, "verified": True}
def _simular_set_clip_properties(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Aceita a alteração de propriedades sem simular geometria (não é usada pelos testes)."""
del argumentos
@@ -38,13 +38,27 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
))
resultado = self.aplicador.aplicar(plano)
self.assertTrue(resultado.todas_bem_sucedidas)
self.assertNotIn("000f4763_video", [c["node_id"] for c in self.cliente.chamadas_de_remocao])
# o clipe único que cobre 2.3-40.6s de origem foi removido em ambas as faixas
chamadas = self.cliente.chamadas_de_remocao
self.assertEqual(len(chamadas), 1)
self.assertEqual(
{c["node_id"] for c in self.cliente.chamadas_de_remocao},
{identificador.split("_esq")[0] for identificador in chamadas[0]["node_ids"]},
{"clipe_a_video", "clipe_a_audio"},
)
def test_corte_de_video_e_audio_usa_uma_remocao_atomica(self):
plano = PlanoDeEdicao("0E6A8290.mp4", (
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=0.0, fim=40.6, motivo="bastidor"),
))
resultado = self.aplicador.aplicar(plano)
self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas])
chamadas = [c for c in self.cliente.chamadas if c[0] == "remove_linked_from_timeline"]
self.assertEqual(len(chamadas), 1)
self.assertEqual(
{identificador.split("_esq")[0] for identificador in chamadas[0][1]["node_ids"]},
{"clipe_a_video", "clipe_a_audio"},
)
self.assertFalse(any(c[0] == "remove_from_timeline" for c in self.cliente.chamadas))
def test_localiza_fonte_por_nome_base_do_source_file(self):
"""Aceita um source completo quando o Premiere retorna outro nome de clipe."""
faixa = Faixa("video_0", "Vídeo 1", "video", 0, [
@@ -75,7 +89,9 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
# corte aconteceu antes de marcador e zoom na sequência de chamadas
indice_do_marcador = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "add_marker")
indice_do_zoom = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "set_clip_properties")
indice_da_remocao = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "ripple_delete")
indice_da_remocao = next(
i for i, c in enumerate(self.cliente.chamadas) if c[0] == "remove_linked_from_timeline"
)
self.assertLess(indice_da_remocao, indice_do_marcador)
self.assertLess(indice_da_remocao, indice_do_zoom)
@@ -86,9 +102,12 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
))
resultado = self.aplicador.aplicar(plano)
self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas])
remocoes = [c["node_id"] for c in self.cliente.chamadas_de_remocao]
remocoes = [c["node_ids"][0] for c in self.cliente.chamadas_de_remocao]
# o corte com início maior (86.2s) precisa ser removido antes do de início 0.0s
self.assertLess(remocoes.index("clipe_b_video"), remocoes.index("clipe_a_video"))
self.assertLess(
next(i for i, identificador in enumerate(remocoes) if identificador.startswith("clipe_b_video")),
next(i for i, identificador in enumerate(remocoes) if identificador.startswith("clipe_a_video")),
)
def test_corte_sem_clipe_sobreposto_falha_sem_interromper_o_resto(self):
plano = PlanoDeEdicao("0E6A8290.mp4", (
@@ -149,7 +168,7 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
))
resultado = aplicador.aplicar(plano)
self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas])
self.assertEqual(len(cliente.chamadas_de_remocao), 2) # vídeo + áudio
self.assertEqual(len(cliente.chamadas_de_remocao), 1) # par vídeo + áudio
# nenhuma segunda tentativa de corte no mesmo ponto pedido, por faixa
pedidos_por_faixa = [
(c[1]["track_type"], c[1]["time_seconds"]) for c in cliente.chamadas if c[0] == "split_clip"
@@ -196,7 +215,7 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
))
resultado = aplicador.aplicar(plano)
self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas])
self.assertEqual(len(cliente.chamadas_de_remocao), 2) # vídeo + áudio
self.assertEqual(len(cliente.chamadas_de_remocao), 1) # par vídeo + áudio
self.assertEqual(len(cliente._faixas["video_0"]), 2) # sem fatia sobrando
def test_corte_falha_em_vez_de_ignorar_faixa_sem_intervalo_de_origem(self):
@@ -0,0 +1,44 @@
"""Testes do gerador offline de legendas."""
import unittest
from engine.editor.legendas import ConfiguracaoDeLegendas, GeradorDeLegendas
from engine.editor.modelos import AcaoDeEdicao, TipoDeAcao
from engine.scanner.modelos import PalavraDeTranscricao
class TesteGeradorDeLegendas(unittest.TestCase):
"""Verifica filtragem, reposicionamento e agrupamento de palavras."""
def setUp(self) -> None:
self.gerador = GeradorDeLegendas()
self.corte = AcaoDeEdicao(TipoDeAcao.CORTE, 0.0, 2.0, "abertura")
self.palavras = [
PalavraDeTranscricao("Olá", 2.1, 2.5),
PalavraDeTranscricao("mundo", 2.6, 3.0),
PalavraDeTranscricao("hoje", 5.0, 5.4),
]
def test_remove_palavras_cortadas_e_reposiciona_as_restantes(self) -> None:
legendas = self.gerador.gerar(self.palavras, [self.corte], 10.0)
self.assertEqual([legenda.texto for legenda in legendas], ["Olá mundo", "hoje"])
self.assertAlmostEqual(legendas[0].inicio, 0.1)
self.assertAlmostEqual(legendas[1].inicio, 3.0)
def test_respeita_limites_de_palavras_e_caracteres(self) -> None:
configuracao = ConfiguracaoDeLegendas(maximo_de_palavras=2, maximo_de_caracteres=10)
palavras = [
PalavraDeTranscricao("uma", 0.0, 0.2),
PalavraDeTranscricao("frase", 0.2, 0.4),
PalavraDeTranscricao("longa", 0.4, 0.6),
]
legendas = self.gerador.gerar(palavras, [], 2.0, configuracao)
self.assertEqual([legenda.texto for legenda in legendas], ["uma frase", "longa"])
def test_rejeita_cortes_sobrepostos(self) -> None:
cortes = [
self.corte,
AcaoDeEdicao(TipoDeAcao.CORTE, 1.5, 3.0, "repetição"),
]
with self.assertRaisesRegex(ValueError, "não podem se sobrepor"):
self.gerador.gerar(self.palavras, cortes, 10.0)
+50
View File
@@ -144,6 +144,56 @@ export function getTimelineTools(bridgeOptions: BridgeOptions) {
},
},
remove_linked_from_timeline: {
description:
"Remove um par lógico de vídeo e áudio da timeline em uma única operação, fechando o espaço nos dois lados.",
parameters: {
type: "object" as const,
properties: {
node_ids: {
type: "array",
items: { type: "string" },
minItems: 2,
maxItems: 2,
description: "IDs dos dois clipes correspondentes de vídeo e áudio.",
},
},
required: ["node_ids"],
},
handler: async (args: { node_ids: string[] }) => {
if (!Array.isArray(args.node_ids) || args.node_ids.length !== 2 || args.node_ids.some((id) => typeof id !== "string" || !id)) {
return { success: false, error: "node_ids deve conter exatamente dois IDs de clipes." };
}
const ids = args.node_ids.map(escapeForExtendScript);
const script = buildToolScript(`
var primeiro = __findClip("${ids[0]}");
var segundo = __findClip("${ids[1]}");
if (!primeiro || !segundo) return __error("Um dos clipes do par não foi encontrado; nenhuma remoção foi confirmada.");
if (primeiro.trackType === segundo.trackType) return __error("O par precisa conter um clipe de vídeo e um de áudio.");
var primeiroInicio = parseFloat(primeiro.clip.start.ticks);
var segundoInicio = parseFloat(segundo.clip.start.ticks);
var primeiroFim = parseFloat(primeiro.clip.end.ticks);
var segundoFim = parseFloat(segundo.clip.end.ticks);
if (Math.abs(primeiroInicio - segundoInicio) > 1 || Math.abs(primeiroFim - segundoFim) > 1) {
return __error("Os clipes de vídeo e áudio não ocupam o mesmo intervalo; nenhuma remoção foi feita.");
}
// alignToVideo=false evita que a remoção do áudio seja reposicionada
// pelo estado anterior do vídeo. As duas remoções são feitas nesta
// mesma chamada, depois da validação do par.
primeiro.clip.remove(true, false);
segundo.clip.remove(true, false);
if (__findClip("${ids[0]}") || __findClip("${ids[1]}")) {
return __error("O Premiere não confirmou a remoção dos dois clipes vinculados.");
}
return __result({ removed: true, verified: true, nodeIds: ["${ids[0]}", "${ids[1]}"] });
`);
return sendCommand(script, bridgeOptions);
},
},
move_clip: {
description: "Move a clip to a new position on the timeline",
parameters: {
@@ -140,6 +140,22 @@ describe("split_clip verification", () => {
});
});
describe("remove_linked_from_timeline", () => {
it("valida dois IDs e remove os dois clipes no mesmo script", async () => {
const invalid = await timeline.remove_linked_from_timeline.handler({ node_ids: ["video"] });
expect(invalid.success).toBe(false);
expect(mockedSendCommand).not.toHaveBeenCalled();
await timeline.remove_linked_from_timeline.handler({ node_ids: ["video", "audio"] });
const script = mockedSendCommand.mock.calls[0][0];
expect(script).toContain('var primeiro = __findClip("video")');
expect(script).toContain('var segundo = __findClip("audio")');
expect(script).toContain("primeiro.clip.remove(true, false)");
expect(script).toContain("segundo.clip.remove(true, false)");
expect(script).toContain("O Premiere não confirmou a remoção dos dois clipes vinculados");
});
});
describe("move_clip verification", () => {
it("re-finds the clip after the move rather than trusting a stale reference", async () => {
await timeline.move_clip.handler({ node_id: "abc", new_start_seconds: 5 });
+1 -1
View File
@@ -203,7 +203,7 @@ describe("Total Tool Count", () => {
for (const mod of ALL_MODULES) {
total += Object.keys(mod.getter(bridgeOptions)).length;
}
expect(total).toBe(342);
expect(total).toBe(343);
});
it("there are 40 directly enumerated modules", () => {