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:
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user