diff --git a/.jhonny/analises.db b/.jhonny/analises.db index 4acc632..aa65038 100644 Binary files a/.jhonny/analises.db and b/.jhonny/analises.db differ diff --git a/CLAUDE.md b/CLAUDE.md index 077c4c8..631cec9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/code/engine/aplicar_plano_de_edicao.py b/code/engine/aplicar_plano_de_edicao.py index 6c0f38b..875de4e 100644 --- a/code/engine/aplicar_plano_de_edicao.py +++ b/code/engine/aplicar_plano_de_edicao.py @@ -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(), ) diff --git a/code/engine/editor/__init__.py b/code/engine/editor/__init__.py index c3780b8..c7d689a 100644 --- a/code/engine/editor/__init__.py +++ b/code/engine/editor/__init__.py @@ -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", diff --git a/code/engine/editor/aplicador_de_plano_de_edicao.py b/code/engine/editor/aplicador_de_plano_de_edicao.py index 619302f..2b4214e 100644 --- a/code/engine/editor/aplicador_de_plano_de_edicao.py +++ b/code/engine/editor/aplicador_de_plano_de_edicao.py @@ -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.""" diff --git a/code/engine/editor/backup_de_sequencia.py b/code/engine/editor/backup_de_sequencia.py new file mode 100644 index 0000000..114a8e8 --- /dev/null +++ b/code/engine/editor/backup_de_sequencia.py @@ -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 diff --git a/code/engine/editor/escrita/escrita_no_editor.py b/code/engine/editor/escrita/escrita_no_editor.py index fd39238..656048b 100644 --- a/code/engine/editor/escrita/escrita_no_editor.py +++ b/code/engine/editor/escrita/escrita_no_editor.py @@ -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 diff --git a/code/engine/integracoes/premiere/__init__.py b/code/engine/integracoes/premiere/__init__.py index 72b4cff..24291eb 100644 --- a/code/engine/integracoes/premiere/__init__.py +++ b/code/engine/integracoes/premiere/__init__.py @@ -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"] diff --git a/code/engine/integracoes/premiere/cliente_mcp.py b/code/engine/integracoes/premiere/cliente_mcp.py index dac4b86..4b43941 100644 --- a/code/engine/integracoes/premiere/cliente_mcp.py +++ b/code/engine/integracoes/premiere/cliente_mcp.py @@ -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. diff --git a/code/engine/integracoes/premiere/cliente_mcp_com_pausa.py b/code/engine/integracoes/premiere/cliente_mcp_com_pausa.py new file mode 100644 index 0000000..9f67746 --- /dev/null +++ b/code/engine/integracoes/premiere/cliente_mcp_com_pausa.py @@ -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) diff --git a/code/engine/testes/duplos_de_premiere.py b/code/engine/testes/duplos_de_premiere.py index 62a9a1a..990ac0f 100644 --- a/code/engine/testes/duplos_de_premiere.py +++ b/code/engine/testes/duplos_de_premiere.py @@ -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} + for outro in clipes: + if outro.inicio_na_timeline >= clipe.fim_na_timeline: + outro.inicio_na_timeline -= duracao + outro.fim_na_timeline -= duracao + 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.""" diff --git a/code/engine/testes/test_aplicador_de_plano_de_edicao.py b/code/engine/testes/test_aplicador_de_plano_de_edicao.py index a6e99b8..b5141db 100644 --- a/code/engine/testes/test_aplicador_de_plano_de_edicao.py +++ b/code/engine/testes/test_aplicador_de_plano_de_edicao.py @@ -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"}), diff --git a/code/engine/testes/test_backup_de_sequencia.py b/code/engine/testes/test_backup_de_sequencia.py new file mode 100644 index 0000000..d26c046 --- /dev/null +++ b/code/engine/testes/test_backup_de_sequencia.py @@ -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() diff --git a/code/engine/testes/test_cliente_mcp_com_pausa.py b/code/engine/testes/test_cliente_mcp_com_pausa.py new file mode 100644 index 0000000..10006d6 --- /dev/null +++ b/code/engine/testes/test_cliente_mcp_com_pausa.py @@ -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() diff --git a/code/engine/testes/test_escrita_no_editor.py b/code/engine/testes/test_escrita_no_editor.py new file mode 100644 index 0000000..5a8697a --- /dev/null +++ b/code/engine/testes/test_escrita_no_editor.py @@ -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() diff --git a/code/scripts/apply-editorial-actions.mjs b/code/scripts/apply-editorial-actions.mjs index 8a590be..dc609d1 100644 --- a/code/scripts/apply-editorial-actions.mjs +++ b/code/scripts/apply-editorial-actions.mjs @@ -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 diff --git a/code/src/tools/discovery.ts b/code/src/tools/discovery.ts index c2cf602..faf5068 100755 --- a/code/src/tools/discovery.ts +++ b/code/src/tools/discovery.ts @@ -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({ diff --git a/code/src/tools/timeline.ts b/code/src/tools/timeline.ts index 338e750..ab654f2 100755 --- a/code/src/tools/timeline.ts +++ b/code/src/tools/timeline.ts @@ -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))) + ":" + - __pad2(Math.floor((__razorFrames % (__razorFps * 3600)) / (__razorFps * 60))) + ":" + - __pad2(Math.floor((__razorFrames % (__razorFps * 60)) / __razorFps)) + ":" + - __pad2(__razorFrames % __razorFps); + + // 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); @@ -750,55 +805,78 @@ export function getTimelineTools(bridgeOptions: BridgeOptions) { const script = buildToolScript(` var result = __findClip("${escapeForExtendScript(args.node_id)}"); if (!result) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}"); - + 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); - changes.opacity = ${args.opacity}; - } - } - } + 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]; - ${args.scale !== undefined ? ` - if (prop.displayName === "Scale") { - prop.setValue(${args.scale}, true); - changes.scale = ${args.scale}; - }` : ""} - ${args.position_x !== undefined || args.position_y !== undefined ? ` - if (prop.displayName === "Position") { - var posVal = prop.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); - }` : ""} - ${args.rotation !== undefined ? ` - if (prop.displayName === "Rotation") { - prop.setValue(${args.rotation}, true); - changes.rotation = ${args.rotation}; - }` : ""} - } - } + var motionComp = __motionComponent(); + if (!motionComp) { + missing.push("componente Motion não encontrado no clipe"); + } else { + ${args.scale !== undefined ? ` + 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 (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};` : ""} + posProp.setValue([px, py], true); + }` : ""} + ${args.rotation !== undefined ? ` + 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);