feat: Alinhado o aplicador Python de plano de edição (aplicador_de

- Alinhado o aplicador Python de plano de edição (aplicador_de_plano_de_edicao.py) com o script apply-editorial-actions.mjs de referência: no-op de split restrito aos padrões reais do bridge, pausa entre chamadas ao MCP, tolerância de borda de corte usa a posição real em vez de rejeitar desvio; adicionado backup automático da sequência (duplicate_sequence) e documentado o comando python code/engine/aplicar_plano_de_edicao.py como via alternativa ao painel CEP.

Resumo:
- 18 arquivos alterados
- 5 novos
- 13 modificados
- 0 removidos

 13 files changed, 704 insertions(+), 133 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - CLAUDE.md
  - code/engine/aplicar_plano_de_edicao.py
  - code/engine/editor/__init__.py
  - code/engine/editor/aplicador_de_plano_de_edicao.py
  - code/engine/editor/escrita/escrita_no_editor.py
  - code/engine/integracoes/premiere/__init__.py
  - code/engine/integracoes/premiere/cliente_mcp.py
  - code/engine/testes/duplos_de_premiere.py
  - code/engine/testes/test_aplicador_de_plano_de_edicao.py
  - code/scripts/apply-editorial-actions.mjs
  - code/src/tools/discovery.ts
  - code/src/tools/timeline.ts
  - code/engine/editor/backup_de_sequencia.py
  - code/engine/integracoes/premiere/cliente_mcp_com_pausa.py
  - code/engine/testes/test_backup_de_sequencia.py
  - code/engine/testes/test_cliente_mcp_com_pausa.py
  - code/engine/testes/test_escrita_no_editor.py
This commit is contained in:
João Henrique
2026-09-09 09:31:29 -04:00
parent 4308c72441
commit 69e931eb45
18 changed files with 1040 additions and 133 deletions
+2
View File
@@ -12,6 +12,7 @@ não deu.
"""
from .aplicador_de_plano_de_edicao import AplicadorDePlanoDeEdicao, ResultadoDaAcao, ResultadoDaAplicacao
from .backup_de_sequencia import BackupDaSequencia
from .erros import ErroDeEdicao, ErroDeEscritaNoEditor, ErroDeMapeamentoDeTempo, ErroDePlanoInvalido
from .escrita import EscritaNoEditor
from .leitura import LeitorDePlanoDeEdicao
@@ -21,6 +22,7 @@ from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
__all__ = [
"AcaoDeEdicao",
"AplicadorDePlanoDeEdicao",
"BackupDaSequencia",
"EscritaNoEditor",
"ErroDeEdicao",
"ErroDeEscritaNoEditor",
@@ -19,6 +19,8 @@ from .mapeamento.mapeador_de_tempo import MapeadorDeTempoDeOrigemParaTimeline
from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
_TOLERANCIA_DE_BORDA_EM_SEGUNDOS = 0.05
_TOLERANCIA_DE_DURACAO_EM_SEGUNDOS = 0.3
_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS = 3.0
@dataclass(frozen=True)
@@ -50,21 +52,44 @@ class ResultadoDaAplicacao:
class AplicadorDePlanoDeEdicao:
"""Aplica um :class:`PlanoDeEdicao` inteiro na sequência ativa do Premiere.
Ordem de aplicação, para preservar a validade das posições calculadas:
Ordem de aplicação:
1. Marcadores e zooms primeiro — não mudam a duração da timeline, então
a posição calculada para cada um continua válida até o fim.
2. Cortes por último, do fim para o começo (maior início primeiro) —
cada corte fecha o espaço que ocupava (ripple), deslocando para a
esquerda tudo que vem depois dele. Processar do fim para o começo
garante que a posição de um corte ainda não aplicado nunca é afetada
pelos cortes já aplicados.
1. Cortes primeiro, do fim para o começo (maior início primeiro) — cada
corte fecha o espaço que ocupava (ripple), deslocando para a esquerda
tudo que vem depois dele. Processar do fim para o começo garante que
a posição de um corte ainda não aplicado nunca é afetada pelos
cortes já aplicados.
2. Marcadores e zooms por último, depois que a timeline já está no
formato final. Zoom (``set_clip_properties``) muda uma propriedade do
*clipe inteiro*, não de um trecho dele — se dois zooms caíssem em
instantes diferentes do mesmo clipe ainda não dividido pelos cortes,
o segundo sobrescreveria o primeiro no clipe inteiro, e a divisão
posterior faria os dois pedaços herdarem só o último valor. Cortar
primeiro garante que cada zoom já encontra um clipe próprio.
A cada ação a timeline ativa é lida de novo no Premiere — nunca se
reaproveita uma leitura anterior — porque a ação anterior pode ter
mudado a posição dos clipes. Uma ação que falha é registrada no
resultado e não interrompe as demais; quem chama decide o que fazer com
as falhas.
mudado a posição ou a divisão dos clipes. Uma ação que falha é
registrada no resultado e não interrompe as demais; quem chama decide o
que fazer com as falhas.
Diferença deliberada do outro aplicador de plano do projeto
(``scripts/apply-editorial-actions.mjs``, chamado pelo painel CEP):
aquele script separa "Fase A" (todos os splits do plano inteiro,
coordenadas originais) de "Fase B" (todas as remoções, feitas depois),
porque só sabe fazer aritmética pura sobre os cortes pedidos — não relê
o estado do Premiere no meio do caminho. Esta classe faz split e
remoção corte a corte, relendo a faixa a cada passo
(:meth:`_clipes_do_arquivo` exige ``intervalo_na_origem`` em todo
clipe), porque isso permite achar cada clipe pelo tempo de *origem*
real reportado pelo Premiere em vez de confiar só em aritmética — mais
robusto quando o bridge relata ``inPoint``/``outPoint`` de cada clipe,
mas por isso mesmo falha alto (em vez de simplesmente pular a faixa)
quando essa garantia falta numa faixa (ver
:meth:`_clipes_do_arquivo` e o teste
``test_corte_falha_em_vez_de_ignorar_faixa_sem_intervalo_de_origem``,
que comprova que um corte nessas condições não deixa vídeo cortado e
áudio intacto).
"""
def __init__(
@@ -84,8 +109,8 @@ class AplicadorDePlanoDeEdicao:
pontuais = [acao for acao in plano.acoes if acao.tipo is not TipoDeAcao.CORTE]
cortes = sorted(plano.acoes_do_tipo(TipoDeAcao.CORTE), key=lambda acao: acao.inicio, reverse=True)
resultados = [self._aplicar_acao_pontual(acao, plano.arquivo_de_origem) for acao in pontuais]
resultados += [self._aplicar_corte(acao, plano.arquivo_de_origem) for acao in cortes]
resultados = [self._aplicar_corte(acao, plano.arquivo_de_origem) for acao in cortes]
resultados += [self._aplicar_acao_pontual(acao, plano.arquivo_de_origem) for acao in pontuais]
return ResultadoDaAplicacao(tuple(resultados))
def _timeline_atual(self) -> Timeline:
@@ -125,7 +150,7 @@ class AplicadorDePlanoDeEdicao:
def _aplicar_zoom(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> int:
fator_de_escala = float(acao.parametros.get("scale", 1.3))
faixa_de_video = self._faixa_de_video(self._timeline_atual())
clipes = self.mapeador.clipes_do_arquivo(faixa_de_video, arquivo_de_origem)
clipes = self._clipes_do_arquivo(faixa_de_video, arquivo_de_origem)
alvos = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
if not alvos:
raise ErroDeMapeamentoDeTempo(
@@ -136,10 +161,22 @@ class AplicadorDePlanoDeEdicao:
return len(alvos)
def _aplicar_corte(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> ResultadoDaAcao:
"""Remove, em cada faixa afetada, todos os clipes contidos no intervalo do corte."""
"""Remove, em cada faixa afetada, todos os clipes contidos no intervalo do corte.
Antes de mudar qualquer faixa, valida que todas as faixas com clipes
de ``arquivo_de_origem`` têm intervalo de origem conhecido. Sem essa
validação prévia, uma faixa problemática (ex.: áudio sem
``inPoint``/``outPoint``) só falharia depois de outra faixa já ter
sido cortada — deixando vídeo e áudio dessincronizados mesmo com o
corte "reportado" como falho.
"""
try:
faixas = self._timeline_atual().faixas
for faixa in faixas:
self._clipes_do_arquivo(faixa, arquivo_de_origem)
trechos_removidos = 0
for faixa in self._timeline_atual().faixas:
for faixa in faixas:
trechos_removidos += self._cortar_faixa(faixa, acao, arquivo_de_origem)
if trechos_removidos == 0:
raise ErroDeMapeamentoDeTempo(
@@ -151,44 +188,104 @@ class AplicadorDePlanoDeEdicao:
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."""
clipes = self.mapeador.clipes_do_arquivo(faixa, arquivo_de_origem)
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
primeiro, ultimo = afetados[0], afetados[-1]
inicio_na_timeline = self._borda_de_entrada(primeiro, acao.inicio)
fim_na_timeline = self._borda_de_saida(ultimo, acao.fim)
inicio_pedido = self._borda_de_entrada(primeiro, acao.inicio)
fim_pedido = self._borda_de_saida(ultimo, acao.fim)
self.escrita.dividir_clipe_se_necessario(inicio_na_timeline, faixa.indice, faixa.tipo)
self.escrita.dividir_clipe_se_necessario(fim_na_timeline, faixa.indice, faixa.tipo)
inicio_real = self._dividir_e_conferir(inicio_pedido, faixa, arquivo_de_origem)
fim_real = self._dividir_e_conferir(fim_pedido, faixa, arquivo_de_origem)
clipes_apos_a_divisao = self._clipes_do_arquivo(self._faixa_atualizada(faixa), arquivo_de_origem)
clipes_apos_a_divisao = self.mapeador.clipes_do_arquivo(
next(f for f in self._timeline_atual().faixas if f.identificador == faixa.identificador),
arquivo_de_origem,
)
alvo = [
clipe for clipe in clipes_apos_a_divisao
if inicio_na_timeline - _TOLERANCIA_DE_BORDA_EM_SEGUNDOS <= clipe.intervalo_na_timeline.inicio
and clipe.intervalo_na_timeline.fim <= fim_na_timeline + _TOLERANCIA_DE_BORDA_EM_SEGUNDOS
if inicio_real - _TOLERANCIA_DE_BORDA_EM_SEGUNDOS <= clipe.intervalo_na_timeline.inicio
and clipe.intervalo_na_timeline.fim <= fim_real + _TOLERANCIA_DE_BORDA_EM_SEGUNDOS
]
duracao_removida = sum(clipe.intervalo_na_timeline.duracao for clipe in alvo)
duracao_esperada = fim_na_timeline - inicio_na_timeline
if abs(duracao_removida - duracao_esperada) > _TOLERANCIA_DE_BORDA_EM_SEGUNDOS:
# A duração esperada é a sobreposição real entre o intervalo da ação
# e os clipes afetados, não `acao.duracao` — se a ação começar ou
# terminar fora de qualquer clipe existente (ex.: pede corte a partir
# de um instante anterior ao início do material disponível), só a
# parte que realmente existe como filmagem deve ser contada.
duracao_esperada_pela_origem = sum(
self._sobreposicao_em_segundos(clipe, acao.inicio, acao.fim) for clipe in afetados
)
if abs(duracao_removida - duracao_esperada_pela_origem) > _TOLERANCIA_DE_DURACAO_EM_SEGUNDOS:
raise ErroDeMapeamentoDeTempo(
f"Na faixa {faixa.identificador!r}, os cortes em {inicio_na_timeline:.3f}s e "
f"{fim_na_timeline:.3f}s não isolaram o trecho esperado (esperado {duracao_esperada:.3f}s, "
f"encontrado {duracao_removida:.3f}s cobertos por clipe); nada foi removido para evitar "
"apagar o trecho errado."
f"Na faixa {faixa.identificador!r}, os cortes pedidos em {inicio_pedido:.3f}s e "
f"{fim_pedido:.3f}s (o Premiere criou as bordas em {inicio_real:.3f}s e {fim_real:.3f}s) "
f"não isolaram o trecho esperado (esperado {duracao_esperada_pela_origem:.3f}s pela duração "
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)
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.
Corta **uma vez só** — nunca tenta de novo no mesmo ponto: uma
segunda tentativa deixaria dois cortes reais na timeline (o
"errado" e o corrigido), com uma fatia inútil entre eles, e o
Premiere não expõe undo por script para desfazer o primeiro
(``app.project.undo`` não existe nesta versão).
O desvio entre o instante pedido e a borda criada não é comparado
contra um limite fixo aqui: o ``split_clip`` do bridge do Premiere
desvia alguns décimos de segundo do pedido, e esse desvio cresce
com o tempo absoluto (ver :meth:`_borda_realmente_criada`) — é
comportamento normal do bridge, não um bug a rejeitar. A única
checagem de sanidade é o raio de busca de
:meth:`_borda_realmente_criada`
(:data:`_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS`): além dele, não é
drift, é falha real, e essa função já levanta
:class:`ErroDeMapeamentoDeTempo` nesse caso.
"""
self.escrita.dividir_clipe_se_necessario(instante_pedido, faixa.indice, faixa.tipo)
clipes = self._clipes_do_arquivo(self._faixa_atualizada(faixa), arquivo_de_origem)
return self._borda_realmente_criada(clipes, instante_pedido)
def _faixa_atualizada(self, faixa: Faixa) -> Faixa:
"""Relê a timeline e devolve a versão atual da mesma faixa, pelo identificador."""
return next(f for f in self._timeline_atual().faixas if f.identificador == faixa.identificador)
def _borda_realmente_criada(self, clipes: list[Clipe], instante_pedido: float) -> float:
"""Acha, entre as bordas de clipe existentes, a mais próxima do instante pedido a Premiere.
O ``split_clip`` do bridge do Premiere pode cortar alguns décimos de
segundo antes ou depois do instante pedido (drift observado: cresce
com o tempo absoluto, compatível com uma conversão segundos↔ticks
que não usa a taxa de quadros real da sequência). As durações
relativas continuam corretas — só a posição absoluta desvia — por
isso a seleção do trecho a remover deve usar a borda que o Premiere
de fato criou, não o valor pedido.
Levanta :class:`ErroDeMapeamentoDeTempo` se nenhuma borda estiver
dentro de :data:`_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS` do instante
pedido — nesse caso o corte realmente falhou, não é só drift.
"""
bordas = {clipe.intervalo_na_timeline.inicio for clipe in clipes} | {
clipe.intervalo_na_timeline.fim for clipe in clipes
}
borda_mais_proxima = min(bordas, key=lambda borda: abs(borda - instante_pedido), default=None)
if borda_mais_proxima is None or abs(borda_mais_proxima - instante_pedido) > _RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS:
raise ErroDeMapeamentoDeTempo(
f"Nenhuma borda de clipe foi encontrada perto de {instante_pedido:.3f}s "
f"(raio de busca: {_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS}s); o corte não foi aplicado."
)
return borda_mais_proxima
def _instante_na_timeline(self, instante_de_origem: float, arquivo_de_origem: str) -> float:
"""Traduz um instante de origem usando a primeira faixa de vídeo do arquivo."""
faixa_de_video = self._faixa_de_video(self._timeline_atual())
clipes = self.mapeador.clipes_do_arquivo(faixa_de_video, arquivo_de_origem)
clipes = self._clipes_do_arquivo(faixa_de_video, arquivo_de_origem)
clipe = self.mapeador.localizar_clipe_no_instante(clipes, instante_de_origem)
if clipe is None:
raise ErroDeMapeamentoDeTempo(
@@ -196,6 +293,29 @@ class AplicadorDePlanoDeEdicao:
)
return self.mapeador.instante_na_timeline(clipe, instante_de_origem)
def _clipes_do_arquivo(self, faixa: Faixa, arquivo_de_origem: str) -> list[Clipe]:
"""Clipes de ``arquivo_de_origem`` em ``faixa``, exigindo intervalo de origem conhecido em todos.
Levanta :class:`ErroDeMapeamentoDeTempo` se algum clipe com esse nome
não tiver intervalo de origem — por exemplo, por uma lacuna na
ferramenta MCP que não reporta ``inPoint``/``outPoint`` para uma
faixa de áudio. Sem isso, o corte nessa faixa seria silenciosamente
ignorado (nenhum clipe "afetado" seria encontrado) em vez de avisar
que a faixa não pôde ser avaliada — foi exatamente essa lacuna que
deixou uma faixa de áudio inteira sem ser cortada numa aplicação
real deste plano.
"""
alvo = arquivo_de_origem.strip().lower()
nomeados = [clipe for clipe in faixa.clipes if (clipe.nome or "").strip().lower() == alvo]
sem_origem = [clipe for clipe in nomeados if clipe.intervalo_na_origem is None]
if sem_origem:
raise ErroDeMapeamentoDeTempo(
f"A faixa {faixa.identificador!r} tem {len(sem_origem)} clipe(s) de {arquivo_de_origem!r} "
"sem intervalo de origem reportado pelo Premiere; não é seguro decidir o que cortar, "
"mover ou marcar nessa faixa sem saber a que trecho de origem cada clipe corresponde."
)
return sorted(nomeados, key=lambda clipe: clipe.intervalo_na_origem.inicio)
def _borda_de_entrada(self, clipe: Clipe, inicio_de_origem: float) -> float:
"""Posição de entrada do corte na timeline: a borda do clipe se o corte começa antes dele."""
if inicio_de_origem <= clipe.intervalo_na_origem.inicio:
@@ -214,6 +334,14 @@ class AplicadorDePlanoDeEdicao:
intervalo = clipe.intervalo_na_origem
return intervalo is not None and intervalo.inicio < fim and inicio < intervalo.fim
@staticmethod
def _sobreposicao_em_segundos(clipe: Clipe, inicio: float, fim: float) -> float:
"""Quantos segundos do intervalo de origem do clipe caem dentro de [inicio, fim]."""
intervalo = clipe.intervalo_na_origem
if intervalo is None:
return 0.0
return max(0.0, min(intervalo.fim, fim) - max(intervalo.inicio, inicio))
@staticmethod
def _faixa_de_video(timeline: Timeline) -> Faixa:
"""A primeira faixa de vídeo da timeline; é onde marcadores e zooms são resolvidos."""
+34
View File
@@ -0,0 +1,34 @@
"""Backup automático da sequência antes de aplicar um plano de edição."""
from __future__ import annotations
from ..integracoes.premiere.cliente_mcp import ClienteMCP
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.
"""
def __init__(self, cliente_mcp: ClienteMCP) -> None:
self.cliente_mcp = cliente_mcp
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.
"""
try:
self.cliente_mcp.chamar("duplicate_sequence", {"sequence_id": identificador_da_sequencia})
return True
except ErroDeFerramentaMCP:
return False
@@ -9,10 +9,33 @@ nomes de ferramenta ou ticks do Premiere.
from __future__ import annotations
import re
from ...integracoes.premiere.cliente_mcp import ClienteMCP
from ...integracoes.premiere.erros_mcp import ErroDeFerramentaMCP
from ..erros import ErroDeEscritaNoEditor
# O bridge recusa a divisão de duas formas diferentes quando o ponto pedido
# JÁ é uma borda de clipe. Ambas são no-op, não falha:
# 1. nenhum clipe atravessa estritamente o instante ("strictly spans");
# 2. o razor roda mas a contagem de clipes da faixa não muda, porque o ponto
# coincide por arredondamento de quadro com uma borda existente.
# Os mesmos dois padrões são reconhecidos por scripts/apply-editorial-actions.mjs,
# que aplica planos por este mesmo bridge.
_RECUSA_POR_NENHUM_CLIPE_ATRAVESSANDO = re.compile(r"strictly spans", re.IGNORECASE)
_RECUSA_COM_CONTAGEM_DE_CLIPES = re.compile(
r"changed the track clip count from (\d+) to (\d+)", re.IGNORECASE
)
def _e_divisao_ja_existente(mensagem_de_erro: str) -> bool:
"""``True`` se a recusa do bridge significa "esse ponto já era uma borda"."""
if _RECUSA_POR_NENHUM_CLIPE_ATRAVESSANDO.search(mensagem_de_erro):
return True
contagem = _RECUSA_COM_CONTAGEM_DE_CLIPES.search(mensagem_de_erro)
return contagem is not None and contagem.group(1) == contagem.group(2)
_CORES_POR_NOME = {
"revisar": 3, # laranja: chama atenção sem parecer um erro (vermelho)
"informar": 5, # branco: marcador neutro
@@ -29,10 +52,17 @@ class EscritaNoEditor:
"""Corta, na timeline, o clipe que cobrir ``instante_da_timeline``.
Devolve ``True`` se um corte novo foi criado e ``False`` quando o
instante já era uma borda de clipe (o Premiere recusa dividir onde
não há clipe atravessando o ponto) — esse segundo caso não é um erro,
instante já era uma borda de clipe — esse segundo caso não é um erro,
é o resultado esperado quando o corte pedido coincide com um corte já
existente da análise de cenas.
existente da análise de cenas, ou quando um plano parcialmente
aplicado é reexecutado.
Só as recusas reconhecidas por :func:`_e_divisao_ja_existente` contam
como no-op. Qualquer outra recusa vira
:class:`ErroDeEscritaNoEditor`: antes, um ``except`` largo tratava
toda :class:`ErroDeFerramentaMCP` como "já era borda", então uma
faixa bloqueada ou uma queda de bridge passava por corte
bem-sucedido e o trecho seguia inteiro na timeline, sem aviso.
"""
try:
self.cliente_mcp.chamar(
@@ -40,17 +70,29 @@ class EscritaNoEditor:
{"time_seconds": instante_da_timeline, "track_index": indice_da_faixa, "track_type": tipo_da_faixa},
)
return True
except ErroDeFerramentaMCP:
return False
except ErroDeFerramentaMCP as erro:
if _e_divisao_ja_existente(str(erro)):
return False
raise ErroDeEscritaNoEditor(
f"Não foi possível dividir a faixa {tipo_da_faixa}_{indice_da_faixa} "
f"em {instante_da_timeline:.3f}s: {erro}"
) from erro
def remover_trecho(self, identificador_do_clipe: str) -> None:
"""Remove o clipe da timeline, fechando o espaço deixado (ripple).
"""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.
Levanta :class:`ErroDeEscritaNoEditor` se o Premiere recusar a
remoção.
"""
try:
self.cliente_mcp.chamar("remove_from_timeline", {"node_id": identificador_do_clipe, "ripple": True})
self.cliente_mcp.chamar("ripple_delete", {"node_id": identificador_do_clipe})
except ErroDeFerramentaMCP as erro:
raise ErroDeEscritaNoEditor(f"Não foi possível remover o clipe {identificador_do_clipe!r}: {erro}") from erro