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
+87 -27
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}
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."""