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
Binary file not shown.
+23
View File
@@ -69,6 +69,29 @@ When working on this project, use these skills to:
2. **Design modules**: Use `/codebase-design` when creating new modules
3. **Model domain**: Use `/domain-modeling` to build shared vocabulary
## Aplicar um plano de edição sem o painel CEP
O painel CEP (aba "Editar Vídeo", botão "Aplicar plano na timeline") chama
`code/scripts/apply-editorial-actions.mjs` — esse é o caminho de produção,
comprovado, e continua sendo o padrão.
Existe uma segunda via em Python, para quando o painel não estiver aberto:
```bash
python code/engine/aplicar_plano_de_edicao.py "/caminho/plano_de_acoes.json"
```
Pré-requisito: o Premiere aberto, com a sequência correta **ativa** (o
script nunca troca de sequência — opera sobre a que estiver ativa no
momento), e o bridge MCP (`dist/index.js`) disponível. Cria backup
automático da sequência (`duplicate_sequence`) antes de aplicar qualquer
corte, igual ao painel — se o backup falhar, avisa em stderr e segue
aplicando mesmo assim, sem bloquear.
Ver [code/engine/editor/aplicador_de_plano_de_edicao.py](code/engine/editor/aplicador_de_plano_de_edicao.py)
para as diferenças deliberadas entre essa implementação e o `.mjs` do
painel (documentadas no docstring da classe `AplicadorDePlanoDeEdicao`).
## Code Standards
- Python in `code/engine/`: follow `.agents/skills/boas-praticas-oo/SKILL.md` and [CODING_STANDARDS.md](CODING_STANDARDS.md) — mandatory, not a suggestion
+27 -3
View File
@@ -25,8 +25,15 @@ 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))
from engine.editor import AplicadorDePlanoDeEdicao, EscritaNoEditor, LeitorDePlanoDeEdicao, MapeadorDeTempoDeOrigemParaTimeline
from engine.editor import (
AplicadorDePlanoDeEdicao,
BackupDaSequencia,
EscritaNoEditor,
LeitorDePlanoDeEdicao,
MapeadorDeTempoDeOrigemParaTimeline,
)
from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio
from engine.integracoes.premiere.cliente_mcp_com_pausa import ClienteMCPComPausa
from engine.integracoes.premiere.conversores import ConversorDeTimeline
from engine.integracoes.premiere.leitura import AcessoAoEditor, AcessoATimeline
from engine.integracoes.premiere.sessao_mcp import SessaoMCP
@@ -42,12 +49,29 @@ def executar(caminho_do_plano: Path) -> dict:
"""
plano = LeitorDePlanoDeEdicao().ler(caminho_do_plano)
cliente = ClienteMCPPorStdio(["node", str(CAMINHO_DO_SERVIDOR_MCP)])
# A pausa é aplicada no cliente, e não dentro do aplicador, para valer
# igualmente nas leituras da timeline e nas escritas — são todas
# round-trips síncronos no processo principal do Premiere.
cliente = ClienteMCPComPausa(ClienteMCPPorStdio(["node", str(CAMINHO_DO_SERVIDOR_MCP)]))
with SessaoMCP(cliente):
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.
sequencia_ativa = conversor_de_timeline.converter(acesso_ao_editor.obter_timeline_ativa())
backup_ok = BackupDaSequencia(cliente).criar_backup(sequencia_ativa.identificador)
print(
"Backup da sequência criado." if backup_ok else "Não foi possível criar o backup automático; aplicando mesmo assim.",
file=sys.stderr,
)
aplicador = AplicadorDePlanoDeEdicao(
acesso_ao_editor=acesso_ao_editor,
conversor_de_timeline=ConversorDeTimeline(),
conversor_de_timeline=conversor_de_timeline,
escrita=EscritaNoEditor(cliente),
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
)
+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:
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
+2 -1
View File
@@ -1,7 +1,8 @@
from .cliente_mcp import ClienteMCP, ClienteMCPPorStdio
from .cliente_mcp_com_pausa import ClienteMCPComPausa
from .erros_mcp import ErroMCP, ErroDeConexaoMCP, ErroDeRespostaMCP, ErroDeFerramentaMCP
__all__ = ["ClienteMCP", "ClienteMCPPorStdio", "ErroMCP", "ErroDeConexaoMCP", "ErroDeRespostaMCP", "ErroDeFerramentaMCP"]
__all__ = ["ClienteMCP", "ClienteMCPPorStdio", "ClienteMCPComPausa", "ErroMCP", "ErroDeConexaoMCP", "ErroDeRespostaMCP", "ErroDeFerramentaMCP"]
from .sessao_mcp import SessaoMCP
__all__ = ["SessaoMCP"]
@@ -89,7 +89,28 @@ class ClienteMCPPorStdio(ClienteMCP):
resposta = self._enviar("tools/call", {"name": nome_da_ferramenta, "arguments": argumentos or {}})
if resposta.get("error"):
raise ErroDeFerramentaMCP(f"Erro ao executar {nome_da_ferramenta!r} no MCP: {resposta['error']}")
return resposta.get("result", {})
resultado = resposta.get("result", {})
# Uma ferramenta que falha não devolve erro no nível do JSON-RPC: o
# servidor responde "com sucesso" e marca `isError` no resultado. Sem
# olhar esse campo, uma escrita recusada pelo Premiere passaria por
# bem-sucedida — foi assim que uma chamada a uma ferramenta inexistente
# ficou invisível até o efeito aparecer na timeline.
if resultado.get("isError"):
raise ErroDeFerramentaMCP(
f"A ferramenta {nome_da_ferramenta!r} falhou: {self._mensagem_de_erro(resultado)}"
)
return resultado
@staticmethod
def _mensagem_de_erro(resultado: dict[str, Any]) -> str:
"""Extrai a mensagem de erro que o servidor MCP devolveu no resultado."""
conteudo_estruturado = resultado.get("structuredContent")
if isinstance(conteudo_estruturado, dict) and conteudo_estruturado.get("error"):
return str(conteudo_estruturado["error"])
for bloco in resultado.get("content", []):
if isinstance(bloco, dict) and bloco.get("type") == "text":
return str(bloco.get("text", ""))
return "sem detalhes"
def _enviar(self, metodo: str, parametros: dict[str, Any]) -> dict[str, Any]:
"""Envia uma requisição JSON-RPC pela stdin do processo e lê a resposta da stdout.
@@ -0,0 +1,72 @@
"""Espaçamento das chamadas ao bridge do Premiere.
As ferramentas de edição do Premiere são round-trips síncronos de QE
DOM/ExtendScript no processo principal do aplicativo. Uma rajada dessas
chamadas sem folga entre elas trava a interface do Premiere — observado na
prática ao aplicar um plano com 17 cortes seguidos de zooms e marcadores.
O script ``scripts/apply-editorial-actions.mjs``, que aplica planos por este
mesmo bridge sem travar, resolve isso com uma pausa fixa entre chamadas;
este módulo traz a mesma folga para o caminho Python.
"""
from __future__ import annotations
import time
from typing import Any, Callable
from .cliente_mcp import ClienteMCP
#: Folga adotada entre chamadas consecutivas, igual à do apply-editorial-actions.mjs
#: (``BRIDGE_DELAY_MS = 120``), que é o valor validado numa aplicação real.
PAUSA_PADRAO_ENTRE_CHAMADAS_EM_SEGUNDOS = 0.12
class ClienteMCPComPausa(ClienteMCP):
"""Envolve outro :class:`ClienteMCP` garantindo uma folga entre chamadas.
Decora um cliente já pronto em vez de espalhar esperas pelas classes de
domínio: assim a folga vale tanto para as leituras
(``get_active_sequence``) quanto para as escritas (``split_clip``,
``ripple_delete``), que é o que de fato ocupa o processo principal do
Premiere, e as classes de edição continuam sem saber que existe um
bridge lento do outro lado.
A pausa só entra *entre* chamadas — a primeira não espera — e vale
também depois de uma chamada que falhou, porque uma chamada recusada
ocupou o bridge do mesmo jeito.
"""
def __init__(
self,
cliente_mcp: ClienteMCP,
pausa_em_segundos: float = PAUSA_PADRAO_ENTRE_CHAMADAS_EM_SEGUNDOS,
esperar: Callable[[float], None] = time.sleep,
) -> None:
"""``esperar`` é injetável para que os testes não durmam de verdade."""
if pausa_em_segundos < 0:
raise ValueError(
f"A pausa entre chamadas não pode ser negativa (recebido: {pausa_em_segundos})."
)
self.cliente_mcp = cliente_mcp
self.pausa_em_segundos = pausa_em_segundos
self.esperar = esperar
self._ja_chamou = False
def conectar(self) -> None:
"""Delega ao cliente interno; conectar não é um round-trip de edição."""
self.cliente_mcp.conectar()
def desconectar(self) -> None:
"""Delega ao cliente interno; desconectar não é um round-trip de edição."""
self.cliente_mcp.desconectar()
def esta_conectado(self) -> bool:
"""Estado de conexão do cliente interno."""
return self.cliente_mcp.esta_conectado()
def chamar(self, nome_da_ferramenta: str, argumentos: dict[str, Any] | None = None) -> dict[str, Any]:
"""Espera a folga devida (menos na primeira chamada) e delega ao cliente interno."""
if self._ja_chamou and self.pausa_em_segundos > 0:
self.esperar(self.pausa_em_segundos)
self._ja_chamou = True
return self.cliente_mcp.chamar(nome_da_ferramenta, argumentos)
+83 -23
View File
@@ -5,11 +5,12 @@ 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``,
``remove_from_timeline``, ``set_clip_properties`` e ``add_marker``.
``ripple_delete``, ``set_clip_properties`` e ``add_marker``.
"""
from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import Any
@@ -40,10 +41,31 @@ class ClienteMCPFalso(ClienteMCP):
_ARQUIVO = "0E6A8290.mp4"
def __init__(self) -> None:
def __init__(
self,
sem_origem_no_audio: bool = False,
clipe_unico: bool = False,
desvio_do_split: float = 0.0,
) -> None:
"""``sem_origem_no_audio`` reproduz o bug real do bridge: relatar clipes de
áudio sem ``inPoint``/``outPoint`` na resposta de ``get_active_sequence``.
``clipe_unico`` reproduz a gravação "sem corte": um só clipe cobrindo
todo o arquivo, com origem e timeline idênticas — o cenário em que
dois zooms cairiam no mesmo clipe se rodassem antes dos cortes.
``desvio_do_split`` reproduz o drift real do ``split_clip``: em vez
de cortar exatamente no instante pedido, corta em
``instante + desvio_do_split``."""
self.sem_origem_no_audio = sem_origem_no_audio
self.desvio_do_split = desvio_do_split
self.chamadas: list[tuple[str, dict[str, Any]]] = []
self._proximo_id = 0
self._historico: list[dict[str, list[_ClipeFalso]]] = []
self._faixas: dict[str, list[_ClipeFalso]] = {
"video_0": [self._novo_clipe(self._ARQUIVO, 0.0, 200.0, 0.0, 200.0, "clipe_unico_video")],
"audio_0": [self._novo_clipe(self._ARQUIVO, 0.0, 200.0, 0.0, 200.0, "clipe_unico_audio")],
} if clipe_unico else {
"video_0": [
self._novo_clipe(self._ARQUIVO, 0.0, 38.3, 2.3, 40.6, "clipe_a_video"),
self._novo_clipe(self._ARQUIVO, 58.1, 66.7, 86.2, 94.8, "clipe_b_video"),
@@ -56,8 +78,8 @@ class ClienteMCPFalso(ClienteMCP):
@property
def chamadas_de_remocao(self) -> list[dict[str, Any]]:
"""Argumentos de cada chamada a ``remove_from_timeline``, na ordem em que ocorreram."""
return [argumentos for nome, argumentos in self.chamadas if nome == "remove_from_timeline"]
"""Argumentos de cada chamada a ``ripple_delete``, na ordem em que ocorreram."""
return [argumentos for nome, argumentos in self.chamadas if nome == "ripple_delete"]
def conectar(self) -> None:
"""Não há conexão real; existe só para satisfazer a interface de :class:`ClienteMCP`."""
@@ -76,9 +98,10 @@ class ClienteMCPFalso(ClienteMCP):
despachantes = {
"get_active_sequence": self._simular_get_active_sequence,
"split_clip": self._simular_split_clip,
"remove_from_timeline": self._simular_remove_from_timeline,
"ripple_delete": self._simular_ripple_delete,
"set_clip_properties": self._simular_set_clip_properties,
"add_marker": self._simular_add_marker,
"undo": self._simular_undo,
}
simulador = despachantes.get(nome_da_ferramenta)
if simulador is None:
@@ -96,22 +119,23 @@ class ClienteMCPFalso(ClienteMCP):
"""Devolve a sequência atual no formato que :class:`ConversorDeTimeline` espera."""
del argumentos
def _clipe_como_dict(clipe: _ClipeFalso, tipo: str) -> dict[str, Any]:
base = {
"nodeId": clipe.identificador,
"name": clipe.nome,
"start": clipe.inicio_na_timeline,
"end": clipe.fim_na_timeline,
}
if tipo == "audio" and self.sem_origem_no_audio:
return base
return {**base, "inPoint": clipe.inicio_na_origem, "outPoint": clipe.fim_na_origem}
def _faixa_como_dict(identificador: str, tipo: str, indice: int) -> dict[str, Any]:
return {
"id": identificador,
"name": identificador,
"index": indice,
"clips": [
{
"nodeId": clipe.identificador,
"name": clipe.nome,
"start": clipe.inicio_na_timeline,
"end": clipe.fim_na_timeline,
"inPoint": clipe.inicio_na_origem,
"outPoint": clipe.fim_na_origem,
}
for clipe in self._faixas[identificador]
],
"clips": [_clipe_como_dict(clipe, tipo) for clipe in self._faixas[identificador]],
}
dados = {
@@ -127,10 +151,31 @@ class ClienteMCPFalso(ClienteMCP):
return {"structuredContent": {"data": dados}}
def _simular_split_clip(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Divide, na faixa pedida, o clipe que cobrir estritamente o instante informado."""
"""Divide, na faixa pedida, o clipe que cobrir estritamente o instante informado.
Aplica ``self.desvio_do_split`` ao ponto de corte, reproduzindo o
drift real do bridge — o corte pode sair do instante pedido.
A recusa usa a mensagem em inglês do bridge real ("No clip strictly
spans ...") porque :class:`EscritaNoEditor` distingue no-op de erro
real pelo texto da recusa; um dublê com mensagem própria testaria um
caminho que não existe em produção.
"""
identificador_da_faixa = self._identificador_da_faixa(argumentos["track_index"], argumentos["track_type"])
instante = float(argumentos["time_seconds"])
instante_pedido = float(argumentos["time_seconds"])
faixa = self._faixas[identificador_da_faixa]
clipe_no_instante_pedido = next(
(c for c in faixa if c.inicio_na_timeline < instante_pedido < c.fim_na_timeline), None
)
if clipe_no_instante_pedido is None:
raise ErroDeFerramentaMCP(
f"No clip strictly spans {instante_pedido}s on track {identificador_da_faixa}."
)
instante = min(
max(instante_pedido + self.desvio_do_split, clipe_no_instante_pedido.inicio_na_timeline + 0.001),
clipe_no_instante_pedido.fim_na_timeline - 0.001,
)
self._salvar_estado()
for posicao, clipe in enumerate(faixa):
if clipe.inicio_na_timeline < instante < clipe.fim_na_timeline:
deslocamento = instante - clipe.inicio_na_timeline
@@ -148,22 +193,24 @@ class ClienteMCPFalso(ClienteMCP):
)
faixa[posicao:posicao + 1] = [parte_esquerda, parte_direita]
return {"split": True}
raise ErroDeFerramentaMCP(f"Nenhum clipe da faixa {identificador_da_faixa!r} cobre estritamente {instante}s.")
raise ErroDeFerramentaMCP(
f"No clip strictly spans {instante}s on track {identificador_da_faixa}."
)
def _simular_remove_from_timeline(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Remove o clipe pedido e, com ``ripple``, fecha o espaço deixado na mesma faixa."""
def _simular_ripple_delete(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Remove o clipe pedido e fecha o espaço deixado na mesma faixa."""
identificador_do_clipe = argumentos["node_id"]
for identificador_da_faixa, clipes in self._faixas.items():
for posicao, clipe in enumerate(clipes):
if clipe.identificador == identificador_do_clipe:
self._salvar_estado()
duracao = clipe.fim_na_timeline - clipe.inicio_na_timeline
del clipes[posicao]
if argumentos.get("ripple"):
for outro in clipes:
if outro.inicio_na_timeline >= clipe.fim_na_timeline:
outro.inicio_na_timeline -= duracao
outro.fim_na_timeline -= duracao
return {"removed": True}
return {"rippleDeleted": True, "verified": True}
raise ErroDeFerramentaMCP(f"Clipe não encontrado para remoção: {identificador_do_clipe!r}.")
def _simular_set_clip_properties(self, argumentos: dict[str, Any]) -> dict[str, Any]:
@@ -176,6 +223,19 @@ class ClienteMCPFalso(ClienteMCP):
del argumentos
return {"added": True}
def _salvar_estado(self) -> None:
"""Guarda uma cópia do estado das faixas para uma futura chamada a ``undo``."""
self._historico.append(copy.deepcopy(self._faixas))
def _simular_undo(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Restaura o estado das faixas anterior à última mutação salva."""
contagem = int(argumentos.get("count", 1))
for _ in range(contagem):
if not self._historico:
raise ErroDeFerramentaMCP("Nada para desfazer neste dublê de teste.")
self._faixas = self._historico.pop()
return {"undone": contagem}
@staticmethod
def _identificador_da_faixa(indice: int, tipo: str) -> str:
"""Reconstrói o identificador de faixa (``video_0``/``audio_0``) a partir de índice e tipo."""
@@ -44,7 +44,12 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
{"clipe_a_video", "clipe_a_audio"},
)
def test_marcador_e_zoom_sao_aplicados_antes_dos_cortes(self):
def test_marcador_e_zoom_sao_aplicados_depois_dos_cortes(self):
# Zoom muda uma propriedade do clipe inteiro (set_clip_properties),
# não de um trecho dele. Se o zoom rodasse antes do corte, cairia no
# clipe original ainda não dividido; dois zooms em pontos diferentes
# do mesmo clipe se sobrescreveriam, e a divisão posterior faria os
# pedaços herdarem só o último valor. Por isso cortes vêm primeiro.
plano = PlanoDeEdicao("0E6A8290.mp4", (
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=0.0, fim=40.6, motivo="bastidor"),
AcaoDeEdicao(TipoDeAcao.MARCADOR, inicio=90.0, fim=90.5, motivo="conferir emenda", parametros={"content": "EMENDA"}),
@@ -52,12 +57,12 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
))
resultado = self.aplicador.aplicar(plano)
self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas])
# marcador e zoom aconteceram antes do corte na sequência de chamadas
# 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] == "remove_from_timeline")
self.assertLess(indice_do_marcador, indice_da_remocao)
self.assertLess(indice_do_zoom, indice_da_remocao)
indice_da_remocao = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "ripple_delete")
self.assertLess(indice_da_remocao, indice_do_marcador)
self.assertLess(indice_da_remocao, indice_do_zoom)
def test_dois_cortes_sao_aplicados_do_fim_para_o_comeco(self):
plano = PlanoDeEdicao("0E6A8290.mp4", (
@@ -82,6 +87,124 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
# a ação válida (marcador) ainda foi aplicada
self.assertTrue(any(c[0] == "add_marker" for c in self.cliente.chamadas))
def test_dois_zooms_no_mesmo_clipe_original_nao_se_sobrescrevem(self):
# Bug real: com um só clipe cobrindo o arquivo inteiro (gravação
# "sem corte"), dois zooms em trechos diferentes caíam no mesmo
# clipe ainda não dividido; o segundo sobrescrevia a escala do
# primeiro no clipe inteiro, e ambos os pedaços resultantes da
# divisão herdavam só o último valor.
cliente = ClienteMCPFalso(clipe_unico=True)
aplicador = AplicadorDePlanoDeEdicao(
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
conversor_de_timeline=ConversorDeTimeline(),
escrita=EscritaNoEditor(cliente),
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
)
plano = PlanoDeEdicao("0E6A8290.mp4", (
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=90.0, fim=100.0, motivo="bastidor no meio"),
AcaoDeEdicao(TipoDeAcao.ZOOM, inicio=10.0, fim=20.0, motivo="ênfase 1", parametros={"scale": 1.15}),
AcaoDeEdicao(TipoDeAcao.ZOOM, inicio=150.0, fim=160.0, motivo="ênfase 2", parametros={"scale": 1.30}),
))
resultado = aplicador.aplicar(plano)
self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas])
chamadas_de_zoom = [c for c in cliente.chamadas if c[0] == "set_clip_properties"]
self.assertEqual(len(chamadas_de_zoom), 2)
node_ids_zoomados = {c[1]["node_id"] for c in chamadas_de_zoom}
# os dois zooms caíram em clipes diferentes, um por trecho
self.assertEqual(len(node_ids_zoomados), 2)
escalas = sorted(round(c[1]["scale"], 3) for c in chamadas_de_zoom)
self.assertEqual(escalas, [115.0, 130.0])
def test_corte_com_desvio_de_bridge_usa_a_borda_real_em_vez_de_falhar(self):
# O split_clip do bridge do Premiere corta alguns décimos de segundo
# antes ou depois do instante pedido, e o desvio cresce com o tempo
# absoluto (drift de conversão segundos↔ticks, não bug pontual) — é
# comportamento normal, não uma falha. _borda_realmente_criada já
# localiza onde o Premiere de fato cortou; o corte usa essa borda em
# vez de rejeitar o desvio.
cliente = ClienteMCPFalso(clipe_unico=True, desvio_do_split=-0.3)
aplicador = AplicadorDePlanoDeEdicao(
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
conversor_de_timeline=ConversorDeTimeline(),
escrita=EscritaNoEditor(cliente),
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
)
plano = PlanoDeEdicao("0E6A8290.mp4", (
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=50.0, fim=60.0, motivo="bastidor"),
))
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
# 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"
]
self.assertEqual(len(pedidos_por_faixa), len(set(pedidos_por_faixa)))
def test_corte_sem_nenhuma_borda_por_perto_ainda_falha(self):
# O desvio do bridge é tolerado, mas só dentro de um raio plausível
# (_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS): além disso o corte realmente
# falhou, não é mais drift — precisa continuar falhando alto, sem
# tentar de novo no mesmo ponto (o Premiere não expõe undo por
# script, então uma segunda tentativa deixaria o corte errado
# comendo espaço na timeline, com uma fatia inútil ao lado).
cliente = ClienteMCPFalso(clipe_unico=True, desvio_do_split=-5.0)
aplicador = AplicadorDePlanoDeEdicao(
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
conversor_de_timeline=ConversorDeTimeline(),
escrita=EscritaNoEditor(cliente),
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
)
plano = PlanoDeEdicao("0E6A8290.mp4", (
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=50.0, fim=60.0, motivo="bastidor"),
))
resultado = aplicador.aplicar(plano)
self.assertFalse(resultado.todas_bem_sucedidas)
self.assertIn("Nenhuma borda", resultado.falhas[0].detalhe)
# nada removido e nenhuma segunda tentativa de corte no mesmo ponto
self.assertEqual(cliente.chamadas_de_remocao, [])
instantes_pedidos = [c[1]["time_seconds"] for c in cliente.chamadas if c[0] == "split_clip"]
self.assertEqual(len(instantes_pedidos), len(set(instantes_pedidos)))
def test_corte_dentro_da_tolerancia_de_um_quadro_e_aceito(self):
# O Premiere encaixa o corte no quadro mais próximo, então uma
# diferença de fração de quadro é normal e não pode falhar.
cliente = ClienteMCPFalso(clipe_unico=True, desvio_do_split=-0.01)
aplicador = AplicadorDePlanoDeEdicao(
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
conversor_de_timeline=ConversorDeTimeline(),
escrita=EscritaNoEditor(cliente),
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
)
plano = PlanoDeEdicao("0E6A8290.mp4", (
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=50.0, fim=60.0, motivo="bastidor"),
))
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._faixas["video_0"]), 2) # sem fatia sobrando
def test_corte_falha_em_vez_de_ignorar_faixa_sem_intervalo_de_origem(self):
# Reproduz o bug real do bridge do Premiere: a faixa de áudio não
# reportava inPoint/outPoint, então o corte "afetava" 0 clipes ali e
# passava batido — a faixa de áudio inteira ficava sem ser cortada,
# dessincronizando o vídeo (já cortado) do áudio (intacto).
cliente = ClienteMCPFalso(sem_origem_no_audio=True)
aplicador = AplicadorDePlanoDeEdicao(
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
conversor_de_timeline=ConversorDeTimeline(),
escrita=EscritaNoEditor(cliente),
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
)
plano = PlanoDeEdicao("0E6A8290.mp4", (
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=0.0, fim=40.6, motivo="bastidor"),
))
resultado = aplicador.aplicar(plano)
self.assertFalse(resultado.todas_bem_sucedidas)
self.assertIn("sem intervalo de origem", resultado.falhas[0].detalhe)
# o vídeo não pode ter sido cortado sozinho: nada foi removido em faixa nenhuma
self.assertEqual(cliente.chamadas_de_remocao, [])
def test_texto_e_convertido_em_marcador_por_falta_de_suporte_do_premiere(self):
plano = PlanoDeEdicao("0E6A8290.mp4", (
AcaoDeEdicao(TipoDeAcao.TEXTO, inicio=90.0, fim=91.0, motivo="reforçar termo", parametros={"content": "MASTOPEXIA"}),
@@ -0,0 +1,54 @@
"""Testes do backup automático da sequência antes de aplicar um plano."""
import unittest
from typing import Any
from engine.editor.backup_de_sequencia import BackupDaSequencia
from engine.integracoes.premiere.cliente_mcp import ClienteMCP
from engine.integracoes.premiere.erros_mcp import ErroDeFerramentaMCP
class _ClienteQueRegistra(ClienteMCP):
"""Dublê que registra as chamadas e devolve sucesso ou o erro configurado."""
def __init__(self, erro_a_levantar: Exception | None = None) -> None:
self.chamadas: list[tuple[str, dict[str, Any]]] = []
self.erro_a_levantar = erro_a_levantar
def conectar(self) -> None:
"""Não há conexão real neste dublê."""
def desconectar(self) -> None:
"""Não há conexão real neste dublê."""
def esta_conectado(self) -> bool:
"""Sempre conectado neste dublê."""
return True
def chamar(self, nome_da_ferramenta: str, argumentos: dict[str, Any] | None = None) -> dict[str, Any]:
self.chamadas.append((nome_da_ferramenta, argumentos or {}))
if self.erro_a_levantar is not None:
raise self.erro_a_levantar
return {"success": True}
class TesteBackupDaSequencia(unittest.TestCase):
def test_duplica_a_sequencia_pelo_identificador(self):
cliente = _ClienteQueRegistra()
backup = BackupDaSequencia(cliente)
conseguiu = backup.criar_backup("sequencia_falsa")
self.assertTrue(conseguiu)
self.assertEqual(cliente.chamadas, [("duplicate_sequence", {"sequence_id": "sequencia_falsa"})])
def test_falha_no_backup_nao_levanta_excecao(self):
# Espelha o painel CEP: se o backup falhar, aplica o plano mesmo
# assim (loga e avisa, não bloqueia) — quem chama decide o que
# fazer com o retorno False, não uma exceção não tratada.
cliente = _ClienteQueRegistra(erro_a_levantar=ErroDeFerramentaMCP("recusado"))
backup = BackupDaSequencia(cliente)
conseguiu = backup.criar_backup("sequencia_falsa")
self.assertFalse(conseguiu)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,101 @@
"""Testes do decorator que espaça as chamadas ao bridge do Premiere."""
import unittest
from typing import Any
from engine.integracoes.premiere.cliente_mcp import ClienteMCP
from engine.integracoes.premiere.cliente_mcp_com_pausa import ClienteMCPComPausa
from engine.integracoes.premiere.erros_mcp import ErroDeFerramentaMCP
class _ClienteQueRegistra(ClienteMCP):
"""Dublê que só registra as chamadas recebidas e devolve um resultado fixo."""
def __init__(self, erro_a_levantar: Exception | None = None) -> None:
self.chamadas: list[tuple[str, dict[str, Any]]] = []
self.erro_a_levantar = erro_a_levantar
self.conectado = False
def conectar(self) -> None:
"""Marca o dublê como conectado."""
self.conectado = True
def desconectar(self) -> None:
"""Marca o dublê como desconectado."""
self.conectado = False
def esta_conectado(self) -> bool:
"""Estado de conexão registrado pelo dublê."""
return self.conectado
def chamar(self, nome_da_ferramenta: str, argumentos: dict[str, Any] | None = None) -> dict[str, Any]:
"""Registra a chamada e devolve um resultado fixo, ou levanta o erro configurado."""
self.chamadas.append((nome_da_ferramenta, argumentos or {}))
if self.erro_a_levantar is not None:
raise self.erro_a_levantar
return {"ok": True}
class _EsperaFalsa:
"""Substitui ``time.sleep`` registrando as pausas pedidas, sem esperar de verdade."""
def __init__(self) -> None:
self.pausas: list[float] = []
def __call__(self, segundos: float) -> None:
"""Registra a pausa pedida."""
self.pausas.append(segundos)
class TesteClienteMCPComPausa(unittest.TestCase):
"""Garante o espaçamento entre chamadas e a transparência do decorator."""
def setUp(self):
self.interno = _ClienteQueRegistra()
self.espera = _EsperaFalsa()
self.cliente = ClienteMCPComPausa(self.interno, pausa_em_segundos=0.12, esperar=self.espera)
def test_repassa_a_chamada_ao_cliente_interno(self):
resultado = self.cliente.chamar("split_clip", {"time_seconds": 10.0})
self.assertEqual(resultado, {"ok": True})
self.assertEqual(self.interno.chamadas, [("split_clip", {"time_seconds": 10.0})])
def test_nao_pausa_antes_da_primeira_chamada(self):
# A primeira chamada não precisa esperar: não há chamada anterior
# ocupando o processo principal do Premiere.
self.cliente.chamar("get_active_sequence")
self.assertEqual(self.espera.pausas, [])
def test_pausa_entre_chamadas_consecutivas(self):
# Sem essa folga, uma rajada de round-trips síncronos de QE/ExtendScript
# trava a UI do Premiere — foi o que se viu com um plano de 17 cortes.
for _ in range(3):
self.cliente.chamar("split_clip", {"time_seconds": 1.0})
self.assertEqual(self.espera.pausas, [0.12, 0.12])
def test_pausa_tambem_depois_de_uma_chamada_que_falhou(self):
# Uma chamada que falhou ocupou o bridge do mesmo jeito; emendar a
# próxima sem folga é justamente o que derruba a UI.
interno = _ClienteQueRegistra(erro_a_levantar=ErroDeFerramentaMCP("recusado"))
espera = _EsperaFalsa()
cliente = ClienteMCPComPausa(interno, pausa_em_segundos=0.12, esperar=espera)
for _ in range(2):
with self.assertRaises(ErroDeFerramentaMCP):
cliente.chamar("split_clip", {"time_seconds": 1.0})
self.assertEqual(espera.pausas, [0.12])
def test_conexao_e_delegada_sem_pausa(self):
# Conectar/desconectar não são round-trips de edição; não precisam de folga.
self.cliente.conectar()
self.assertTrue(self.cliente.esta_conectado())
self.cliente.desconectar()
self.assertFalse(self.cliente.esta_conectado())
self.assertEqual(self.espera.pausas, [])
def test_pausa_negativa_e_recusada(self):
with self.assertRaises(ValueError):
ClienteMCPComPausa(self.interno, pausa_em_segundos=-0.1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,75 @@
"""Testes da escrita de mutações no Premiere, sem o app aberto.
Cobrem a distinção — que já custou um corte silenciosamente perdido — entre
um ``split_clip`` que não fez nada porque o ponto já era borda de clipe (um
no-op legítimo) e um ``split_clip`` que falhou de verdade no bridge.
"""
import unittest
from typing import Any
from engine.editor.erros import ErroDeEscritaNoEditor
from engine.editor.escrita import EscritaNoEditor
from engine.integracoes.premiere.cliente_mcp import ClienteMCP
from engine.integracoes.premiere.erros_mcp import ErroDeFerramentaMCP
class _ClienteQueRecusa(ClienteMCP):
"""Dublê que recusa toda chamada com a mensagem informada no construtor."""
def __init__(self, mensagem_de_erro: str) -> None:
self.mensagem_de_erro = mensagem_de_erro
self.chamadas: list[tuple[str, dict[str, Any]]] = []
def conectar(self) -> None:
"""Não há conexão real neste dublê."""
def desconectar(self) -> None:
"""Não há conexão real neste dublê."""
def esta_conectado(self) -> bool:
"""Sempre conectado: este dublê não simula falha de conexão."""
return True
def chamar(self, nome_da_ferramenta: str, argumentos: dict[str, Any] | None = None) -> dict[str, Any]:
"""Registra a chamada e recusa com :class:`ErroDeFerramentaMCP`."""
self.chamadas.append((nome_da_ferramenta, argumentos or {}))
raise ErroDeFerramentaMCP(self.mensagem_de_erro)
class TesteDividirClipeSeNecessario(unittest.TestCase):
"""Separa o no-op esperado do erro real de bridge."""
def _dividir(self, mensagem_de_erro: str) -> bool:
escrita = EscritaNoEditor(_ClienteQueRecusa(mensagem_de_erro))
return escrita.dividir_clipe_se_necessario(10.0, 0, "video")
def test_ponto_que_ja_era_borda_e_no_op_e_nao_erro(self):
# O Premiere recusa dividir onde nenhum clipe atravessa o instante:
# é o resultado esperado quando o corte pedido coincide com uma borda
# já existente (inclusive ao reexecutar um plano parcialmente aplicado).
self.assertFalse(self._dividir("No clip strictly spans 10.0s on track video_0."))
def test_contagem_de_clipes_inalterada_e_no_op_e_nao_erro(self):
# Segunda forma observada do mesmo no-op: o razor roda, mas o ponto já
# coincide por arredondamento de quadro com uma borda existente, então
# a contagem de clipes da faixa não muda.
self.assertFalse(self._dividir("razor changed the track clip count from 4 to 4, expected 5"))
def test_erro_real_do_bridge_nao_e_confundido_com_no_op(self):
# Bug real: qualquer ErroDeFerramentaMCP era engolido como "já era
# borda", então uma falha de conexão ou uma faixa bloqueada passava
# por corte bem-sucedido e o trecho seguia na timeline sem aviso.
with self.assertRaises(ErroDeEscritaNoEditor) as capturado:
self._dividir("Track video_0 is locked.")
self.assertIn("locked", str(capturado.exception))
def test_contagem_de_clipes_que_mudou_errado_nao_e_no_op(self):
# A contagem mudou, mas não para o esperado: o razor fez algo, e algo
# diferente do pedido — isso é erro, não no-op.
with self.assertRaises(ErroDeEscritaNoEditor):
self._dividir("razor changed the track clip count from 4 to 7, expected 5")
if __name__ == "__main__":
unittest.main()
+67 -7
View File
@@ -11,8 +11,12 @@
* "coordenada pós-corte" (ver adjustTimeForCuts) antes de procurar o clipe.
*
* Limitações conhecidas:
* - "zoom" aplica um scale estático (set_clip_scale) no clipe do intervalo, sem rampa
* de entrada/saída. Suficiente para um punch-in simples; não é um Ken Burns animado.
* - "zoom" cria uma camada de ajuste (adjustment layer) numa track de vídeo dedicada,
* posicionada e recortada para durar exatamente o intervalo da action, com o Scale
* do próprio Motion da camada de ajuste ajustado — não altera o clipe original.
* É um scale estático (sem rampa de entrada/saída); suficiente para um punch-in
* simples, não é um Ken Burns animado. A track de ajuste é criada uma vez (no
* topo da pilha de vídeo) e reaproveitada por todas as actions "zoom" do plano.
* - "text" não tem API de scripting suportada no Premiere para criar clipe de texto
* a partir de string crua (add_text_overlay do MCP já retorna erro por design).
* Em vez de falhar o plano inteiro, cada action "text" vira um marker de sequência
@@ -28,6 +32,9 @@ import { getDiscoveryTools } from "../dist/tools/discovery.js";
import { getProjectTools } from "../dist/tools/project.js";
import { getMarkerTools } from "../dist/tools/markers.js";
import { getTrackTargetingTools } from "../dist/tools/track-targeting.js";
import { getTrackTools } from "../dist/tools/tracks.js";
import { getPlayheadTools } from "../dist/tools/playhead.js";
import { getUtilityTools } from "../dist/tools/utility.js";
// Progresso para a barra do painel CEP: linhas @@PROGRESS em stderr, que o painel
// intercepta em vez de despejar no log. Fases A/B/C viram uma barra única.
@@ -121,6 +128,9 @@ async function main() {
const project = getProjectTools(bridgeOptions);
const markers = getMarkerTools(bridgeOptions);
const trackTargeting = getTrackTargetingTools(bridgeOptions);
const tracks = getTrackTools(bridgeOptions);
const playhead = getPlayheadTools(bridgeOptions);
const utility = getUtilityTools(bridgeOptions);
console.error(
`[apply-editorial-actions] sequência="${args.sequence}" cuts=${cuts.length} outras=${others.length} dryRun=${args.dryRun}`
@@ -236,6 +246,9 @@ async function main() {
reportProgress("Aplicando zooms, textos e marcadores", phaseCBase, `0 de ${others.length} ações`);
let applied = 0;
const warnings = [];
// Criada sob demanda, na primeira action "zoom" do plano — planos sem zoom não
// ganham uma track extra à toa.
let adjustmentTrackIndex = null;
for (const action of others) {
const adjStart = adjustTimeForCuts(action.start, sortedCutsForAdjust);
const adjEnd = adjustTimeForCuts(action.end, sortedCutsForAdjust);
@@ -258,13 +271,60 @@ async function main() {
warnings.push(`zoom @${action.start} sem params.scale numérico — ignorado.`);
continue;
}
const videoClip = unwrap(
await discovery.get_clip_at_position.handler({ time_seconds: mid, track_index: args.videoTrack, track_type: "video" }),
`get_clip_at_position video @${mid}`
const duration = adjEnd - adjStart;
if (duration <= 0) {
warnings.push(`zoom @${action.start} com duração inválida (${duration}s) — ignorado.`);
continue;
}
// A camada de ajuste é criada uma vez, no topo da pilha de vídeo, e
// reaproveitada por todas as actions "zoom" — não altera o clipe
// original, então cortes/re-splits feitos na Fase A/B não a afetam.
if (adjustmentTrackIndex === null) {
const trackResult = unwrap(
await tracks.add_track.handler({ track_type: "video", count: 1 }),
"add_track (camada de ajuste para zoom)"
);
adjustmentTrackIndex = trackResult.totalTracks - 1;
await sleep(BRIDGE_DELAY_MS);
}
unwrap(
await trackTargeting.set_clip_scale.handler({ node_id: videoClip.nodeId, scale: scale * 100 }),
`set_clip_scale ${videoClip.nodeId}`
await playhead.set_playhead_position.handler({ time_seconds: adjStart }),
`set_playhead_position @${adjStart}`
);
await sleep(BRIDGE_DELAY_MS);
unwrap(
await utility.add_adjustment_layer.handler({ track_index: adjustmentTrackIndex }),
`add_adjustment_layer @${adjStart}`
);
await sleep(BRIDGE_DELAY_MS);
const adjClip = unwrap(
await discovery.get_clip_at_position.handler({
time_seconds: adjStart + 0.01,
track_index: adjustmentTrackIndex,
track_type: "video",
}),
`get_clip_at_position (camada de ajuste) @${adjStart}`
);
unwrap(
await timeline.trim_clip.handler({ node_id: adjClip.nodeId, new_out_seconds: duration }),
`trim_clip (camada de ajuste) @${adjStart}`
);
await sleep(BRIDGE_DELAY_MS);
unwrap(
await timeline.move_clip.handler({ node_id: adjClip.nodeId, new_start_seconds: adjStart }),
`move_clip (camada de ajuste) @${adjStart}`
);
await sleep(BRIDGE_DELAY_MS);
unwrap(
await trackTargeting.set_clip_scale.handler({ node_id: adjClip.nodeId, scale: scale * 100 }),
`set_clip_scale (camada de ajuste) ${adjClip.nodeId}`
);
} else if (action.kind === "text") {
// Sem API de scripting suportada para clipe de texto a partir de string crua
+10 -1
View File
@@ -166,7 +166,16 @@ export function getDiscoveryTools(bridgeOptions: BridgeOptions) {
name: clip.name,
start: __ticksToSeconds(clip.start.ticks),
end: __ticksToSeconds(clip.end.ticks),
duration: __ticksToSeconds(clip.duration.ticks)
inPoint: __ticksToSeconds(clip.inPoint.ticks),
outPoint: __ticksToSeconds(clip.outPoint.ticks),
duration: __ticksToSeconds(clip.duration.ticks),
sourceFile: clip.projectItem && clip.projectItem.getMediaPath
? String(clip.projectItem.getMediaPath() || "")
: "",
sourceProjectItemId: clip.projectItem ? String(clip.projectItem.nodeId || "") : "",
sourceOffline: clip.projectItem && clip.projectItem.isOffline
? !!clip.projectItem.isOffline()
: false
});
}
audioTracks.push({
+104 -26
View File
@@ -418,7 +418,22 @@ export function getTimelineTools(bridgeOptions: BridgeOptions) {
return __error("Refusing trim before mutation: " + beforeKeyframes.outside.length + " effect keyframe(s) would remain outside the visible clip. Use keyframe_policy: preserve only if retaining those keyframes is intentional, or adjust them explicitly with the keyframe tools.");
}
${args.new_in_seconds !== undefined ? `clip.inPoint = __secondsToTicks(${args.new_in_seconds}).toString();` : "clip.outPoint = __secondsToTicks(" + args.new_out_seconds + ").toString();"}
// Set the source point AND the corresponding timeline boundary
// together, computed from the same before-snapshot, instead of
// setting only the source point and trusting Premiere to move the
// timeline boundary on its own: that auto-derivation is exactly
// what silently no-op'd on the timeline side while still moving
// the source point on a real Premiere Pro 26.x install, leaving a
// clip whose visible duration no longer matched its source range.
${args.new_in_seconds !== undefined ? `
var expectedStartOnWrite = before.start + (${args.new_in_seconds} - before.inPoint);
clip.start = __secondsToTicks(expectedStartOnWrite).toString();
clip.inPoint = __secondsToTicks(${args.new_in_seconds}).toString();
` : `
var expectedEndOnWrite = before.end + (${args.new_out_seconds} - before.outPoint);
clip.end = __secondsToTicks(expectedEndOnWrite).toString();
clip.outPoint = __secondsToTicks(${args.new_out_seconds}).toString();
`}
// Re-find the TrackItem after the write. Premiere can replace stale
// DOM references during an edit, especially for audio clips.
@@ -586,10 +601,50 @@ export function getTimelineTools(bridgeOptions: BridgeOptions) {
if (!__razorFps || !isFinite(__razorFps) || __razorFps < 1) __razorFps = 30;
var __razorFrames = Math.round(cutTicks / frameTicks);
function __pad2(n) { return n < 10 ? "0" + n : "" + n; }
var __razorTc = __pad2(Math.floor(__razorFrames / (__razorFps * 3600))) + ":" +
// A sequence whose display format is drop-frame timecode (videoDisplayFormat
// 102 for 29.97 or 106 for 59.94) numbers frames as SMPTE drop-frame: 2 frame
// NUMBERS are skipped at the start of every minute except every 10th. razor()
// parses its timecode string using that same numbering, so handing it a plain
// non-drop-frame HH:MM:SS:FF built from the raw frame count lands the cut at
// the wrong absolute time, worse the further into the sequence the cut is —
// exactly the growing drift this tool used to report as an unexplained no-op
// on some cuts. __razorFrames is the true physical frame count from ticks, so
// it must be converted to the DROP-FRAME-numbered timecode when that display
// format is active, not just formatted with a rounded fps.
var __isDropFrame = false;
try {
// getSettings() existe na sequência do DOM, não na do QE: ler pelo
// objeto errado lança, cai no catch e desliga o drop-frame em
// silêncio, mantendo o desvio que esta correção existe para tirar.
var __seqSettings = domSequence.getSettings ? domSequence.getSettings() : null;
var __displayFormat = __seqSettings ? __seqSettings.videoDisplayFormat : null;
__isDropFrame = __displayFormat === 102 || __displayFormat === 106;
} catch (__dfErr) { __isDropFrame = false; }
var __razorTc;
if (__isDropFrame) {
var __dropPerMin = Math.round(__razorFps * 2 / 30);
var __framesPerMinuteNominal = __razorFps * 60;
var __framesPer10Minutes = __framesPerMinuteNominal * 10 - __dropPerMin * 9;
var __framesPerMinuteDropped = __framesPerMinuteNominal - __dropPerMin;
var __d = Math.floor(__razorFrames / __framesPer10Minutes);
var __m = __razorFrames % __framesPer10Minutes;
var __labelFrames = __razorFrames + __dropPerMin * 9 * __d;
if (__m > __dropPerMin) {
__labelFrames += __dropPerMin * Math.floor((__m - __dropPerMin) / __framesPerMinuteDropped);
}
var __dfFrames = __labelFrames % __razorFps;
var __dfSeconds = Math.floor(__labelFrames / __razorFps) % 60;
var __dfMinutes = Math.floor(__labelFrames / (__razorFps * 60)) % 60;
var __dfHours = Math.floor(__labelFrames / (__razorFps * 3600)) % 24;
__razorTc = __pad2(__dfHours) + ":" + __pad2(__dfMinutes) + ":" + __pad2(__dfSeconds) + ";" + __pad2(__dfFrames);
} else {
__razorTc = __pad2(Math.floor(__razorFrames / (__razorFps * 3600))) + ":" +
__pad2(Math.floor((__razorFrames % (__razorFps * 3600)) / (__razorFps * 60))) + ":" +
__pad2(Math.floor((__razorFrames % (__razorFps * 60)) / __razorFps)) + ":" +
__pad2(__razorFrames % __razorFps);
}
try {
track.razor(__razorTc);
@@ -753,52 +808,75 @@ export function getTimelineTools(bridgeOptions: BridgeOptions) {
var clip = result.clip;
var changes = {};
var missing = [];
// Property lookup uses the fixed index of Premiere's built-in Motion
// and Opacity effects, not displayName: displayName is localized
// (e.g. "Escala"/"Posição"/"Rotação" in pt_BR Premiere), so a plain
// string match against the English name silently matches nothing
// and no-ops on non-English installs. Index 0 of Opacity is its
// only property; Motion is fixed at 0=Position, 1=Scale, 4=Rotation
// across locales — confirmed against a pt_BR install.
function __motionComponent() {
for (var i = 0; i < clip.components.numItems; i++) {
if (clip.components[i].matchName === "AE.ADBE Motion") return clip.components[i];
}
return null;
}
function __opacityComponent() {
for (var i = 0; i < clip.components.numItems; i++) {
if (clip.components[i].matchName === "AE.ADBE Opacity") return clip.components[i];
}
return null;
}
${args.opacity !== undefined ? `
// Set opacity via Motion component
for (var i = 0; i < clip.components.numItems; i++) {
var comp = clip.components[i];
if (comp.matchName === "AE.ADBE Opacity" || comp.displayName === "Opacity") {
for (var p = 0; p < comp.properties.numItems; p++) {
if (comp.properties[p].displayName === "Opacity") {
comp.properties[p].setValue(${args.opacity}, true);
var opacityComp = __opacityComponent();
if (!opacityComp || opacityComp.properties.numItems < 1) {
missing.push("opacity: componente Opacity não encontrado no clipe");
} else {
opacityComp.properties[0].setValue(${args.opacity}, true);
changes.opacity = ${args.opacity};
}
}
}
}
` : ""}
${args.scale !== undefined || args.position_x !== undefined || args.position_y !== undefined || args.rotation !== undefined ? `
for (var i = 0; i < clip.components.numItems; i++) {
var comp = clip.components[i];
if (comp.matchName === "AE.ADBE Motion" || comp.displayName === "Motion") {
for (var p = 0; p < comp.properties.numItems; p++) {
var prop = comp.properties[p];
var motionComp = __motionComponent();
if (!motionComp) {
missing.push("componente Motion não encontrado no clipe");
} else {
${args.scale !== undefined ? `
if (prop.displayName === "Scale") {
prop.setValue(${args.scale}, true);
if (motionComp.properties.numItems < 2) {
missing.push("scale: propriedade Escala (índice 1) não encontrada");
} else {
motionComp.properties[1].setValue(${args.scale}, true);
changes.scale = ${args.scale};
}` : ""}
${args.position_x !== undefined || args.position_y !== undefined ? `
if (prop.displayName === "Position") {
var posVal = prop.getValue();
if (motionComp.properties.numItems < 1) {
missing.push("position: propriedade Posição (índice 0) não encontrada");
} else {
var posProp = motionComp.properties[0];
var posVal = posProp.getValue();
var px = posVal && typeof posVal === "object" && posVal.length >= 2 ? posVal[0] : 0;
var py = posVal && typeof posVal === "object" && posVal.length >= 2 ? posVal[1] : 0;
${args.position_x !== undefined ? `px = ${args.position_x}; changes.position_x = ${args.position_x};` : ""}
${args.position_y !== undefined ? `py = ${args.position_y}; changes.position_y = ${args.position_y};` : ""}
prop.setValue([px, py], true);
posProp.setValue([px, py], true);
}` : ""}
${args.rotation !== undefined ? `
if (prop.displayName === "Rotation") {
prop.setValue(${args.rotation}, true);
if (motionComp.properties.numItems < 5) {
missing.push("rotation: propriedade Rotação (índice 4) não encontrada");
} else {
motionComp.properties[4].setValue(${args.rotation}, true);
changes.rotation = ${args.rotation};
}` : ""}
}
}
}
` : ""}
if (missing.length) {
return __error("Não foi possível aplicar todas as propriedades pedidas: " + missing.join("; ") + ". Nenhuma mudança parcial foi reportada como sucesso.");
}
return __result({ updated: true, clipName: clip.name, changes: changes });
`);
return sendCommand(script, bridgeOptions);