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:
Binary file not shown.
@@ -69,6 +69,29 @@ When working on this project, use these skills to:
|
|||||||
2. **Design modules**: Use `/codebase-design` when creating new modules
|
2. **Design modules**: Use `/codebase-design` when creating new modules
|
||||||
3. **Model domain**: Use `/domain-modeling` to build shared vocabulary
|
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
|
## 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
|
- Python in `code/engine/`: follow `.agents/skills/boas-praticas-oo/SKILL.md` and [CODING_STANDARDS.md](CODING_STANDARDS.md) — mandatory, not a suggestion
|
||||||
|
|||||||
@@ -25,8 +25,15 @@ CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent
|
|||||||
if str(CAMINHO_DO_CODIGO) not in sys.path:
|
if str(CAMINHO_DO_CODIGO) not in sys.path:
|
||||||
sys.path.insert(0, str(CAMINHO_DO_CODIGO))
|
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 import ClienteMCPPorStdio
|
||||||
|
from engine.integracoes.premiere.cliente_mcp_com_pausa import ClienteMCPComPausa
|
||||||
from engine.integracoes.premiere.conversores import ConversorDeTimeline
|
from engine.integracoes.premiere.conversores import ConversorDeTimeline
|
||||||
from engine.integracoes.premiere.leitura import AcessoAoEditor, AcessoATimeline
|
from engine.integracoes.premiere.leitura import AcessoAoEditor, AcessoATimeline
|
||||||
from engine.integracoes.premiere.sessao_mcp import SessaoMCP
|
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)
|
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):
|
with SessaoMCP(cliente):
|
||||||
acesso_ao_editor = AcessoAoEditor(AcessoATimeline(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(
|
aplicador = AplicadorDePlanoDeEdicao(
|
||||||
acesso_ao_editor=acesso_ao_editor,
|
acesso_ao_editor=acesso_ao_editor,
|
||||||
conversor_de_timeline=ConversorDeTimeline(),
|
conversor_de_timeline=conversor_de_timeline,
|
||||||
escrita=EscritaNoEditor(cliente),
|
escrita=EscritaNoEditor(cliente),
|
||||||
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
|
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ não deu.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .aplicador_de_plano_de_edicao import AplicadorDePlanoDeEdicao, ResultadoDaAcao, ResultadoDaAplicacao
|
from .aplicador_de_plano_de_edicao import AplicadorDePlanoDeEdicao, ResultadoDaAcao, ResultadoDaAplicacao
|
||||||
|
from .backup_de_sequencia import BackupDaSequencia
|
||||||
from .erros import ErroDeEdicao, ErroDeEscritaNoEditor, ErroDeMapeamentoDeTempo, ErroDePlanoInvalido
|
from .erros import ErroDeEdicao, ErroDeEscritaNoEditor, ErroDeMapeamentoDeTempo, ErroDePlanoInvalido
|
||||||
from .escrita import EscritaNoEditor
|
from .escrita import EscritaNoEditor
|
||||||
from .leitura import LeitorDePlanoDeEdicao
|
from .leitura import LeitorDePlanoDeEdicao
|
||||||
@@ -21,6 +22,7 @@ from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"AcaoDeEdicao",
|
"AcaoDeEdicao",
|
||||||
"AplicadorDePlanoDeEdicao",
|
"AplicadorDePlanoDeEdicao",
|
||||||
|
"BackupDaSequencia",
|
||||||
"EscritaNoEditor",
|
"EscritaNoEditor",
|
||||||
"ErroDeEdicao",
|
"ErroDeEdicao",
|
||||||
"ErroDeEscritaNoEditor",
|
"ErroDeEscritaNoEditor",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from .mapeamento.mapeador_de_tempo import MapeadorDeTempoDeOrigemParaTimeline
|
|||||||
from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
|
from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
|
||||||
|
|
||||||
_TOLERANCIA_DE_BORDA_EM_SEGUNDOS = 0.05
|
_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)
|
@dataclass(frozen=True)
|
||||||
@@ -50,21 +52,44 @@ class ResultadoDaAplicacao:
|
|||||||
class AplicadorDePlanoDeEdicao:
|
class AplicadorDePlanoDeEdicao:
|
||||||
"""Aplica um :class:`PlanoDeEdicao` inteiro na sequência ativa do Premiere.
|
"""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
|
1. Cortes primeiro, do fim para o começo (maior início primeiro) — cada
|
||||||
a posição calculada para cada um continua válida até o fim.
|
corte fecha o espaço que ocupava (ripple), deslocando para a esquerda
|
||||||
2. Cortes por último, do fim para o começo (maior início primeiro) —
|
tudo que vem depois dele. Processar do fim para o começo garante que
|
||||||
cada corte fecha o espaço que ocupava (ripple), deslocando para a
|
a posição de um corte ainda não aplicado nunca é afetada pelos
|
||||||
esquerda tudo que vem depois dele. Processar do fim para o começo
|
cortes já aplicados.
|
||||||
garante que a posição de um corte ainda não aplicado nunca é afetada
|
2. Marcadores e zooms por último, depois que a timeline já está no
|
||||||
pelos cortes já aplicados.
|
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
|
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
|
reaproveita uma leitura anterior — porque a ação anterior pode ter
|
||||||
mudado a posição dos clipes. Uma ação que falha é registrada no
|
mudado a posição ou a divisão dos clipes. Uma ação que falha é
|
||||||
resultado e não interrompe as demais; quem chama decide o que fazer com
|
registrada no resultado e não interrompe as demais; quem chama decide o
|
||||||
as falhas.
|
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__(
|
def __init__(
|
||||||
@@ -84,8 +109,8 @@ class AplicadorDePlanoDeEdicao:
|
|||||||
pontuais = [acao for acao in plano.acoes if acao.tipo is not TipoDeAcao.CORTE]
|
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)
|
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))
|
return ResultadoDaAplicacao(tuple(resultados))
|
||||||
|
|
||||||
def _timeline_atual(self) -> Timeline:
|
def _timeline_atual(self) -> Timeline:
|
||||||
@@ -125,7 +150,7 @@ class AplicadorDePlanoDeEdicao:
|
|||||||
def _aplicar_zoom(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> int:
|
def _aplicar_zoom(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> int:
|
||||||
fator_de_escala = float(acao.parametros.get("scale", 1.3))
|
fator_de_escala = float(acao.parametros.get("scale", 1.3))
|
||||||
faixa_de_video = self._faixa_de_video(self._timeline_atual())
|
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)]
|
alvos = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
|
||||||
if not alvos:
|
if not alvos:
|
||||||
raise ErroDeMapeamentoDeTempo(
|
raise ErroDeMapeamentoDeTempo(
|
||||||
@@ -136,10 +161,22 @@ class AplicadorDePlanoDeEdicao:
|
|||||||
return len(alvos)
|
return len(alvos)
|
||||||
|
|
||||||
def _aplicar_corte(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> ResultadoDaAcao:
|
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:
|
try:
|
||||||
|
faixas = self._timeline_atual().faixas
|
||||||
|
for faixa in faixas:
|
||||||
|
self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
||||||
|
|
||||||
trechos_removidos = 0
|
trechos_removidos = 0
|
||||||
for faixa in self._timeline_atual().faixas:
|
for faixa in faixas:
|
||||||
trechos_removidos += self._cortar_faixa(faixa, acao, arquivo_de_origem)
|
trechos_removidos += self._cortar_faixa(faixa, acao, arquivo_de_origem)
|
||||||
if trechos_removidos == 0:
|
if trechos_removidos == 0:
|
||||||
raise ErroDeMapeamentoDeTempo(
|
raise ErroDeMapeamentoDeTempo(
|
||||||
@@ -151,44 +188,104 @@ class AplicadorDePlanoDeEdicao:
|
|||||||
|
|
||||||
def _cortar_faixa(self, faixa: Faixa, acao: AcaoDeEdicao, arquivo_de_origem: str) -> int:
|
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."""
|
"""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)]
|
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
|
||||||
if not afetados:
|
if not afetados:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
primeiro, ultimo = afetados[0], afetados[-1]
|
primeiro, ultimo = afetados[0], afetados[-1]
|
||||||
inicio_na_timeline = self._borda_de_entrada(primeiro, acao.inicio)
|
inicio_pedido = self._borda_de_entrada(primeiro, acao.inicio)
|
||||||
fim_na_timeline = self._borda_de_saida(ultimo, acao.fim)
|
fim_pedido = self._borda_de_saida(ultimo, acao.fim)
|
||||||
|
|
||||||
self.escrita.dividir_clipe_se_necessario(inicio_na_timeline, faixa.indice, faixa.tipo)
|
inicio_real = self._dividir_e_conferir(inicio_pedido, faixa, arquivo_de_origem)
|
||||||
self.escrita.dividir_clipe_se_necessario(fim_na_timeline, faixa.indice, faixa.tipo)
|
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 = [
|
alvo = [
|
||||||
clipe for clipe in clipes_apos_a_divisao
|
clipe for clipe in clipes_apos_a_divisao
|
||||||
if inicio_na_timeline - _TOLERANCIA_DE_BORDA_EM_SEGUNDOS <= clipe.intervalo_na_timeline.inicio
|
if inicio_real - _TOLERANCIA_DE_BORDA_EM_SEGUNDOS <= clipe.intervalo_na_timeline.inicio
|
||||||
and clipe.intervalo_na_timeline.fim <= fim_na_timeline + _TOLERANCIA_DE_BORDA_EM_SEGUNDOS
|
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_removida = sum(clipe.intervalo_na_timeline.duracao for clipe in alvo)
|
||||||
duracao_esperada = fim_na_timeline - inicio_na_timeline
|
# A duração esperada é a sobreposição real entre o intervalo da ação
|
||||||
if abs(duracao_removida - duracao_esperada) > _TOLERANCIA_DE_BORDA_EM_SEGUNDOS:
|
# 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(
|
raise ErroDeMapeamentoDeTempo(
|
||||||
f"Na faixa {faixa.identificador!r}, os cortes em {inicio_na_timeline:.3f}s e "
|
f"Na faixa {faixa.identificador!r}, os cortes pedidos em {inicio_pedido:.3f}s e "
|
||||||
f"{fim_na_timeline:.3f}s não isolaram o trecho esperado (esperado {duracao_esperada:.3f}s, "
|
f"{fim_pedido:.3f}s (o Premiere criou as bordas em {inicio_real:.3f}s e {fim_real:.3f}s) "
|
||||||
f"encontrado {duracao_removida:.3f}s cobertos por clipe); nada foi removido para evitar "
|
f"não isolaram o trecho esperado (esperado {duracao_esperada_pela_origem:.3f}s pela duração "
|
||||||
"apagar o trecho errado."
|
f"de origem, encontrado {duracao_removida:.3f}s cobertos por clipe); nada foi removido para "
|
||||||
|
"evitar apagar o trecho errado."
|
||||||
)
|
)
|
||||||
for clipe in alvo:
|
for clipe in alvo:
|
||||||
self.escrita.remover_trecho(clipe.identificador)
|
self.escrita.remover_trecho(clipe.identificador)
|
||||||
return len(alvo)
|
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:
|
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."""
|
"""Traduz um instante de origem usando a primeira faixa de vídeo do arquivo."""
|
||||||
faixa_de_video = self._faixa_de_video(self._timeline_atual())
|
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)
|
clipe = self.mapeador.localizar_clipe_no_instante(clipes, instante_de_origem)
|
||||||
if clipe is None:
|
if clipe is None:
|
||||||
raise ErroDeMapeamentoDeTempo(
|
raise ErroDeMapeamentoDeTempo(
|
||||||
@@ -196,6 +293,29 @@ class AplicadorDePlanoDeEdicao:
|
|||||||
)
|
)
|
||||||
return self.mapeador.instante_na_timeline(clipe, instante_de_origem)
|
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:
|
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."""
|
"""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:
|
if inicio_de_origem <= clipe.intervalo_na_origem.inicio:
|
||||||
@@ -214,6 +334,14 @@ class AplicadorDePlanoDeEdicao:
|
|||||||
intervalo = clipe.intervalo_na_origem
|
intervalo = clipe.intervalo_na_origem
|
||||||
return intervalo is not None and intervalo.inicio < fim and inicio < intervalo.fim
|
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
|
@staticmethod
|
||||||
def _faixa_de_video(timeline: Timeline) -> Faixa:
|
def _faixa_de_video(timeline: Timeline) -> Faixa:
|
||||||
"""A primeira faixa de vídeo da timeline; é onde marcadores e zooms são resolvidos."""
|
"""A primeira faixa de vídeo da timeline; é onde marcadores e zooms são resolvidos."""
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Backup automático da sequência antes de aplicar um plano de edição."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..integracoes.premiere.cliente_mcp import ClienteMCP
|
||||||
|
from ..integracoes.premiere.erros_mcp import ErroDeFerramentaMCP
|
||||||
|
|
||||||
|
|
||||||
|
class BackupDaSequencia:
|
||||||
|
"""Duplica a sequência ativa do Premiere antes de uma edição destrutiva.
|
||||||
|
|
||||||
|
Espelha o passo de segurança do painel CEP
|
||||||
|
(``app.project.activeSequence.clone()`` em ``applyEditorialActions()``,
|
||||||
|
``cep-plugin/main.js``): sem undo por script nesta versão do Premiere,
|
||||||
|
duplicar a sequência antes de cortar é a única forma de recuperar o
|
||||||
|
estado anterior se algo sair errado.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cliente_mcp: ClienteMCP) -> None:
|
||||||
|
self.cliente_mcp = cliente_mcp
|
||||||
|
|
||||||
|
def criar_backup(self, identificador_da_sequencia: str) -> bool:
|
||||||
|
"""Duplica a sequência. Devolve ``True`` se conseguiu, ``False`` se o Premiere recusou.
|
||||||
|
|
||||||
|
Não levanta exceção numa recusa: o painel CEP também segue aplicando
|
||||||
|
o plano mesmo se o backup falhar (loga e avisa, não bloqueia) — a
|
||||||
|
falta de backup não deve impedir a edição que o usuário pediu, só
|
||||||
|
precisa ficar visível para quem chama decidir o que fazer.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.cliente_mcp.chamar("duplicate_sequence", {"sequence_id": identificador_da_sequencia})
|
||||||
|
return True
|
||||||
|
except ErroDeFerramentaMCP:
|
||||||
|
return False
|
||||||
@@ -9,10 +9,33 @@ nomes de ferramenta ou ticks do Premiere.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from ...integracoes.premiere.cliente_mcp import ClienteMCP
|
from ...integracoes.premiere.cliente_mcp import ClienteMCP
|
||||||
from ...integracoes.premiere.erros_mcp import ErroDeFerramentaMCP
|
from ...integracoes.premiere.erros_mcp import ErroDeFerramentaMCP
|
||||||
from ..erros import ErroDeEscritaNoEditor
|
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 = {
|
_CORES_POR_NOME = {
|
||||||
"revisar": 3, # laranja: chama atenção sem parecer um erro (vermelho)
|
"revisar": 3, # laranja: chama atenção sem parecer um erro (vermelho)
|
||||||
"informar": 5, # branco: marcador neutro
|
"informar": 5, # branco: marcador neutro
|
||||||
@@ -29,10 +52,17 @@ class EscritaNoEditor:
|
|||||||
"""Corta, na timeline, o clipe que cobrir ``instante_da_timeline``.
|
"""Corta, na timeline, o clipe que cobrir ``instante_da_timeline``.
|
||||||
|
|
||||||
Devolve ``True`` se um corte novo foi criado e ``False`` quando o
|
Devolve ``True`` se um corte novo foi criado e ``False`` quando o
|
||||||
instante já era uma borda de clipe (o Premiere recusa dividir onde
|
instante já era uma borda de clipe — esse segundo caso não é um erro,
|
||||||
não há clipe atravessando o ponto) — esse segundo caso não é um erro,
|
|
||||||
é o resultado esperado quando o corte pedido coincide com um corte já
|
é 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:
|
try:
|
||||||
self.cliente_mcp.chamar(
|
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},
|
{"time_seconds": instante_da_timeline, "track_index": indice_da_faixa, "track_type": tipo_da_faixa},
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
except ErroDeFerramentaMCP:
|
except ErroDeFerramentaMCP as erro:
|
||||||
return False
|
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:
|
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
|
Levanta :class:`ErroDeEscritaNoEditor` se o Premiere recusar a
|
||||||
remoção.
|
remoção.
|
||||||
"""
|
"""
|
||||||
try:
|
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:
|
except ErroDeFerramentaMCP as erro:
|
||||||
raise ErroDeEscritaNoEditor(f"Não foi possível remover o clipe {identificador_do_clipe!r}: {erro}") from erro
|
raise ErroDeEscritaNoEditor(f"Não foi possível remover o clipe {identificador_do_clipe!r}: {erro}") from erro
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from .cliente_mcp import ClienteMCP, ClienteMCPPorStdio
|
from .cliente_mcp import ClienteMCP, ClienteMCPPorStdio
|
||||||
|
from .cliente_mcp_com_pausa import ClienteMCPComPausa
|
||||||
from .erros_mcp import ErroMCP, ErroDeConexaoMCP, ErroDeRespostaMCP, ErroDeFerramentaMCP
|
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
|
from .sessao_mcp import SessaoMCP
|
||||||
|
|
||||||
__all__ = ["SessaoMCP"]
|
__all__ = ["SessaoMCP"]
|
||||||
|
|||||||
@@ -89,7 +89,28 @@ class ClienteMCPPorStdio(ClienteMCP):
|
|||||||
resposta = self._enviar("tools/call", {"name": nome_da_ferramenta, "arguments": argumentos or {}})
|
resposta = self._enviar("tools/call", {"name": nome_da_ferramenta, "arguments": argumentos or {}})
|
||||||
if resposta.get("error"):
|
if resposta.get("error"):
|
||||||
raise ErroDeFerramentaMCP(f"Erro ao executar {nome_da_ferramenta!r} no MCP: {resposta['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]:
|
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.
|
"""Envia uma requisição JSON-RPC pela stdin do processo e lê a resposta da stdout.
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Espaçamento das chamadas ao bridge do Premiere.
|
||||||
|
|
||||||
|
As ferramentas de edição do Premiere são round-trips síncronos de QE
|
||||||
|
DOM/ExtendScript no processo principal do aplicativo. Uma rajada dessas
|
||||||
|
chamadas sem folga entre elas trava a interface do Premiere — observado na
|
||||||
|
prática ao aplicar um plano com 17 cortes seguidos de zooms e marcadores.
|
||||||
|
O script ``scripts/apply-editorial-actions.mjs``, que aplica planos por este
|
||||||
|
mesmo bridge sem travar, resolve isso com uma pausa fixa entre chamadas;
|
||||||
|
este módulo traz a mesma folga para o caminho Python.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from .cliente_mcp import ClienteMCP
|
||||||
|
|
||||||
|
#: Folga adotada entre chamadas consecutivas, igual à do apply-editorial-actions.mjs
|
||||||
|
#: (``BRIDGE_DELAY_MS = 120``), que é o valor validado numa aplicação real.
|
||||||
|
PAUSA_PADRAO_ENTRE_CHAMADAS_EM_SEGUNDOS = 0.12
|
||||||
|
|
||||||
|
|
||||||
|
class ClienteMCPComPausa(ClienteMCP):
|
||||||
|
"""Envolve outro :class:`ClienteMCP` garantindo uma folga entre chamadas.
|
||||||
|
|
||||||
|
Decora um cliente já pronto em vez de espalhar esperas pelas classes de
|
||||||
|
domínio: assim a folga vale tanto para as leituras
|
||||||
|
(``get_active_sequence``) quanto para as escritas (``split_clip``,
|
||||||
|
``ripple_delete``), que é o que de fato ocupa o processo principal do
|
||||||
|
Premiere, e as classes de edição continuam sem saber que existe um
|
||||||
|
bridge lento do outro lado.
|
||||||
|
|
||||||
|
A pausa só entra *entre* chamadas — a primeira não espera — e vale
|
||||||
|
também depois de uma chamada que falhou, porque uma chamada recusada
|
||||||
|
ocupou o bridge do mesmo jeito.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cliente_mcp: ClienteMCP,
|
||||||
|
pausa_em_segundos: float = PAUSA_PADRAO_ENTRE_CHAMADAS_EM_SEGUNDOS,
|
||||||
|
esperar: Callable[[float], None] = time.sleep,
|
||||||
|
) -> None:
|
||||||
|
"""``esperar`` é injetável para que os testes não durmam de verdade."""
|
||||||
|
if pausa_em_segundos < 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"A pausa entre chamadas não pode ser negativa (recebido: {pausa_em_segundos})."
|
||||||
|
)
|
||||||
|
self.cliente_mcp = cliente_mcp
|
||||||
|
self.pausa_em_segundos = pausa_em_segundos
|
||||||
|
self.esperar = esperar
|
||||||
|
self._ja_chamou = False
|
||||||
|
|
||||||
|
def conectar(self) -> None:
|
||||||
|
"""Delega ao cliente interno; conectar não é um round-trip de edição."""
|
||||||
|
self.cliente_mcp.conectar()
|
||||||
|
|
||||||
|
def desconectar(self) -> None:
|
||||||
|
"""Delega ao cliente interno; desconectar não é um round-trip de edição."""
|
||||||
|
self.cliente_mcp.desconectar()
|
||||||
|
|
||||||
|
def esta_conectado(self) -> bool:
|
||||||
|
"""Estado de conexão do cliente interno."""
|
||||||
|
return self.cliente_mcp.esta_conectado()
|
||||||
|
|
||||||
|
def chamar(self, nome_da_ferramenta: str, argumentos: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
"""Espera a folga devida (menos na primeira chamada) e delega ao cliente interno."""
|
||||||
|
if self._ja_chamou and self.pausa_em_segundos > 0:
|
||||||
|
self.esperar(self.pausa_em_segundos)
|
||||||
|
self._ja_chamou = True
|
||||||
|
return self.cliente_mcp.chamar(nome_da_ferramenta, argumentos)
|
||||||
@@ -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
|
descompasso encontrado no projeto real entre ``inicio_na_timeline`` e
|
||||||
``inicio_na_origem`` do banco de análises. Implementa só as ferramentas MCP
|
``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``,
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -40,10 +41,31 @@ class ClienteMCPFalso(ClienteMCP):
|
|||||||
|
|
||||||
_ARQUIVO = "0E6A8290.mp4"
|
_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.chamadas: list[tuple[str, dict[str, Any]]] = []
|
||||||
self._proximo_id = 0
|
self._proximo_id = 0
|
||||||
|
self._historico: list[dict[str, list[_ClipeFalso]]] = []
|
||||||
self._faixas: 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": [
|
"video_0": [
|
||||||
self._novo_clipe(self._ARQUIVO, 0.0, 38.3, 2.3, 40.6, "clipe_a_video"),
|
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"),
|
self._novo_clipe(self._ARQUIVO, 58.1, 66.7, 86.2, 94.8, "clipe_b_video"),
|
||||||
@@ -56,8 +78,8 @@ class ClienteMCPFalso(ClienteMCP):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def chamadas_de_remocao(self) -> list[dict[str, Any]]:
|
def chamadas_de_remocao(self) -> list[dict[str, Any]]:
|
||||||
"""Argumentos de cada chamada a ``remove_from_timeline``, na ordem em que ocorreram."""
|
"""Argumentos de cada chamada a ``ripple_delete``, na ordem em que ocorreram."""
|
||||||
return [argumentos for nome, argumentos in self.chamadas if nome == "remove_from_timeline"]
|
return [argumentos for nome, argumentos in self.chamadas if nome == "ripple_delete"]
|
||||||
|
|
||||||
def conectar(self) -> None:
|
def conectar(self) -> None:
|
||||||
"""Não há conexão real; existe só para satisfazer a interface de :class:`ClienteMCP`."""
|
"""Não há conexão real; existe só para satisfazer a interface de :class:`ClienteMCP`."""
|
||||||
@@ -76,9 +98,10 @@ class ClienteMCPFalso(ClienteMCP):
|
|||||||
despachantes = {
|
despachantes = {
|
||||||
"get_active_sequence": self._simular_get_active_sequence,
|
"get_active_sequence": self._simular_get_active_sequence,
|
||||||
"split_clip": self._simular_split_clip,
|
"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,
|
"set_clip_properties": self._simular_set_clip_properties,
|
||||||
"add_marker": self._simular_add_marker,
|
"add_marker": self._simular_add_marker,
|
||||||
|
"undo": self._simular_undo,
|
||||||
}
|
}
|
||||||
simulador = despachantes.get(nome_da_ferramenta)
|
simulador = despachantes.get(nome_da_ferramenta)
|
||||||
if simulador is None:
|
if simulador is None:
|
||||||
@@ -96,22 +119,23 @@ class ClienteMCPFalso(ClienteMCP):
|
|||||||
"""Devolve a sequência atual no formato que :class:`ConversorDeTimeline` espera."""
|
"""Devolve a sequência atual no formato que :class:`ConversorDeTimeline` espera."""
|
||||||
del argumentos
|
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]:
|
def _faixa_como_dict(identificador: str, tipo: str, indice: int) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": identificador,
|
"id": identificador,
|
||||||
"name": identificador,
|
"name": identificador,
|
||||||
"index": indice,
|
"index": indice,
|
||||||
"clips": [
|
"clips": [_clipe_como_dict(clipe, tipo) for clipe in self._faixas[identificador]],
|
||||||
{
|
|
||||||
"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]
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dados = {
|
dados = {
|
||||||
@@ -127,10 +151,31 @@ class ClienteMCPFalso(ClienteMCP):
|
|||||||
return {"structuredContent": {"data": dados}}
|
return {"structuredContent": {"data": dados}}
|
||||||
|
|
||||||
def _simular_split_clip(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
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"])
|
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]
|
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):
|
for posicao, clipe in enumerate(faixa):
|
||||||
if clipe.inicio_na_timeline < instante < clipe.fim_na_timeline:
|
if clipe.inicio_na_timeline < instante < clipe.fim_na_timeline:
|
||||||
deslocamento = instante - clipe.inicio_na_timeline
|
deslocamento = instante - clipe.inicio_na_timeline
|
||||||
@@ -148,22 +193,24 @@ class ClienteMCPFalso(ClienteMCP):
|
|||||||
)
|
)
|
||||||
faixa[posicao:posicao + 1] = [parte_esquerda, parte_direita]
|
faixa[posicao:posicao + 1] = [parte_esquerda, parte_direita]
|
||||||
return {"split": True}
|
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]:
|
def _simular_ripple_delete(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Remove o clipe pedido e, com ``ripple``, fecha o espaço deixado na mesma faixa."""
|
"""Remove o clipe pedido e fecha o espaço deixado na mesma faixa."""
|
||||||
identificador_do_clipe = argumentos["node_id"]
|
identificador_do_clipe = argumentos["node_id"]
|
||||||
for identificador_da_faixa, clipes in self._faixas.items():
|
for identificador_da_faixa, clipes in self._faixas.items():
|
||||||
for posicao, clipe in enumerate(clipes):
|
for posicao, clipe in enumerate(clipes):
|
||||||
if clipe.identificador == identificador_do_clipe:
|
if clipe.identificador == identificador_do_clipe:
|
||||||
|
self._salvar_estado()
|
||||||
duracao = clipe.fim_na_timeline - clipe.inicio_na_timeline
|
duracao = clipe.fim_na_timeline - clipe.inicio_na_timeline
|
||||||
del clipes[posicao]
|
del clipes[posicao]
|
||||||
if argumentos.get("ripple"):
|
for outro in clipes:
|
||||||
for outro in clipes:
|
if outro.inicio_na_timeline >= clipe.fim_na_timeline:
|
||||||
if outro.inicio_na_timeline >= clipe.fim_na_timeline:
|
outro.inicio_na_timeline -= duracao
|
||||||
outro.inicio_na_timeline -= duracao
|
outro.fim_na_timeline -= duracao
|
||||||
outro.fim_na_timeline -= duracao
|
return {"rippleDeleted": True, "verified": True}
|
||||||
return {"removed": True}
|
|
||||||
raise ErroDeFerramentaMCP(f"Clipe não encontrado para remoção: {identificador_do_clipe!r}.")
|
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]:
|
def _simular_set_clip_properties(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||||
@@ -176,6 +223,19 @@ class ClienteMCPFalso(ClienteMCP):
|
|||||||
del argumentos
|
del argumentos
|
||||||
return {"added": True}
|
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
|
@staticmethod
|
||||||
def _identificador_da_faixa(indice: int, tipo: str) -> str:
|
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."""
|
"""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"},
|
{"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", (
|
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
||||||
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=0.0, fim=40.6, motivo="bastidor"),
|
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"}),
|
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)
|
resultado = self.aplicador.aplicar(plano)
|
||||||
self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas])
|
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_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_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")
|
indice_da_remocao = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "ripple_delete")
|
||||||
self.assertLess(indice_do_marcador, indice_da_remocao)
|
self.assertLess(indice_da_remocao, indice_do_marcador)
|
||||||
self.assertLess(indice_do_zoom, indice_da_remocao)
|
self.assertLess(indice_da_remocao, indice_do_zoom)
|
||||||
|
|
||||||
def test_dois_cortes_sao_aplicados_do_fim_para_o_comeco(self):
|
def test_dois_cortes_sao_aplicados_do_fim_para_o_comeco(self):
|
||||||
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
||||||
@@ -82,6 +87,124 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
|
|||||||
# a ação válida (marcador) ainda foi aplicada
|
# a ação válida (marcador) ainda foi aplicada
|
||||||
self.assertTrue(any(c[0] == "add_marker" for c in self.cliente.chamadas))
|
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):
|
def test_texto_e_convertido_em_marcador_por_falta_de_suporte_do_premiere(self):
|
||||||
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
||||||
AcaoDeEdicao(TipoDeAcao.TEXTO, inicio=90.0, fim=91.0, motivo="reforçar termo", parametros={"content": "MASTOPEXIA"}),
|
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()
|
||||||
@@ -11,8 +11,12 @@
|
|||||||
* "coordenada pós-corte" (ver adjustTimeForCuts) antes de procurar o clipe.
|
* "coordenada pós-corte" (ver adjustTimeForCuts) antes de procurar o clipe.
|
||||||
*
|
*
|
||||||
* Limitações conhecidas:
|
* Limitações conhecidas:
|
||||||
* - "zoom" aplica um scale estático (set_clip_scale) no clipe do intervalo, sem rampa
|
* - "zoom" cria uma camada de ajuste (adjustment layer) numa track de vídeo dedicada,
|
||||||
* de entrada/saída. Suficiente para um punch-in simples; não é um Ken Burns animado.
|
* 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
|
* - "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).
|
* 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
|
* 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 { getProjectTools } from "../dist/tools/project.js";
|
||||||
import { getMarkerTools } from "../dist/tools/markers.js";
|
import { getMarkerTools } from "../dist/tools/markers.js";
|
||||||
import { getTrackTargetingTools } from "../dist/tools/track-targeting.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
|
// 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.
|
// 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 project = getProjectTools(bridgeOptions);
|
||||||
const markers = getMarkerTools(bridgeOptions);
|
const markers = getMarkerTools(bridgeOptions);
|
||||||
const trackTargeting = getTrackTargetingTools(bridgeOptions);
|
const trackTargeting = getTrackTargetingTools(bridgeOptions);
|
||||||
|
const tracks = getTrackTools(bridgeOptions);
|
||||||
|
const playhead = getPlayheadTools(bridgeOptions);
|
||||||
|
const utility = getUtilityTools(bridgeOptions);
|
||||||
|
|
||||||
console.error(
|
console.error(
|
||||||
`[apply-editorial-actions] sequência="${args.sequence}" cuts=${cuts.length} outras=${others.length} dryRun=${args.dryRun}`
|
`[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`);
|
reportProgress("Aplicando zooms, textos e marcadores", phaseCBase, `0 de ${others.length} ações`);
|
||||||
let applied = 0;
|
let applied = 0;
|
||||||
const warnings = [];
|
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) {
|
for (const action of others) {
|
||||||
const adjStart = adjustTimeForCuts(action.start, sortedCutsForAdjust);
|
const adjStart = adjustTimeForCuts(action.start, sortedCutsForAdjust);
|
||||||
const adjEnd = adjustTimeForCuts(action.end, 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.`);
|
warnings.push(`zoom @${action.start} sem params.scale numérico — ignorado.`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const videoClip = unwrap(
|
const duration = adjEnd - adjStart;
|
||||||
await discovery.get_clip_at_position.handler({ time_seconds: mid, track_index: args.videoTrack, track_type: "video" }),
|
if (duration <= 0) {
|
||||||
`get_clip_at_position video @${mid}`
|
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(
|
unwrap(
|
||||||
await trackTargeting.set_clip_scale.handler({ node_id: videoClip.nodeId, scale: scale * 100 }),
|
await playhead.set_playhead_position.handler({ time_seconds: adjStart }),
|
||||||
`set_clip_scale ${videoClip.nodeId}`
|
`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") {
|
} else if (action.kind === "text") {
|
||||||
// Sem API de scripting suportada para clipe de texto a partir de string crua
|
// Sem API de scripting suportada para clipe de texto a partir de string crua
|
||||||
|
|||||||
@@ -166,7 +166,16 @@ export function getDiscoveryTools(bridgeOptions: BridgeOptions) {
|
|||||||
name: clip.name,
|
name: clip.name,
|
||||||
start: __ticksToSeconds(clip.start.ticks),
|
start: __ticksToSeconds(clip.start.ticks),
|
||||||
end: __ticksToSeconds(clip.end.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({
|
audioTracks.push({
|
||||||
|
|||||||
+124
-46
@@ -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.");
|
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
|
// Re-find the TrackItem after the write. Premiere can replace stale
|
||||||
// DOM references during an edit, especially for audio clips.
|
// 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;
|
if (!__razorFps || !isFinite(__razorFps) || __razorFps < 1) __razorFps = 30;
|
||||||
var __razorFrames = Math.round(cutTicks / frameTicks);
|
var __razorFrames = Math.round(cutTicks / frameTicks);
|
||||||
function __pad2(n) { return n < 10 ? "0" + n : "" + n; }
|
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))) + ":" +
|
// A sequence whose display format is drop-frame timecode (videoDisplayFormat
|
||||||
__pad2(Math.floor((__razorFrames % (__razorFps * 60)) / __razorFps)) + ":" +
|
// 102 for 29.97 or 106 for 59.94) numbers frames as SMPTE drop-frame: 2 frame
|
||||||
__pad2(__razorFrames % __razorFps);
|
// 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 {
|
try {
|
||||||
track.razor(__razorTc);
|
track.razor(__razorTc);
|
||||||
@@ -750,55 +805,78 @@ export function getTimelineTools(bridgeOptions: BridgeOptions) {
|
|||||||
const script = buildToolScript(`
|
const script = buildToolScript(`
|
||||||
var result = __findClip("${escapeForExtendScript(args.node_id)}");
|
var result = __findClip("${escapeForExtendScript(args.node_id)}");
|
||||||
if (!result) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}");
|
if (!result) return __error("Clip not found: ${escapeForExtendScript(args.node_id)}");
|
||||||
|
|
||||||
var clip = result.clip;
|
var clip = result.clip;
|
||||||
var changes = {};
|
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 ? `
|
${args.opacity !== undefined ? `
|
||||||
// Set opacity via Motion component
|
var opacityComp = __opacityComponent();
|
||||||
for (var i = 0; i < clip.components.numItems; i++) {
|
if (!opacityComp || opacityComp.properties.numItems < 1) {
|
||||||
var comp = clip.components[i];
|
missing.push("opacity: componente Opacity não encontrado no clipe");
|
||||||
if (comp.matchName === "AE.ADBE Opacity" || comp.displayName === "Opacity") {
|
} else {
|
||||||
for (var p = 0; p < comp.properties.numItems; p++) {
|
opacityComp.properties[0].setValue(${args.opacity}, true);
|
||||||
if (comp.properties[p].displayName === "Opacity") {
|
changes.opacity = ${args.opacity};
|
||||||
comp.properties[p].setValue(${args.opacity}, true);
|
|
||||||
changes.opacity = ${args.opacity};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
` : ""}
|
` : ""}
|
||||||
|
|
||||||
${args.scale !== undefined || args.position_x !== undefined || args.position_y !== undefined || args.rotation !== undefined ? `
|
${args.scale !== undefined || args.position_x !== undefined || args.position_y !== undefined || args.rotation !== undefined ? `
|
||||||
for (var i = 0; i < clip.components.numItems; i++) {
|
var motionComp = __motionComponent();
|
||||||
var comp = clip.components[i];
|
if (!motionComp) {
|
||||||
if (comp.matchName === "AE.ADBE Motion" || comp.displayName === "Motion") {
|
missing.push("componente Motion não encontrado no clipe");
|
||||||
for (var p = 0; p < comp.properties.numItems; p++) {
|
} else {
|
||||||
var prop = comp.properties[p];
|
${args.scale !== undefined ? `
|
||||||
${args.scale !== undefined ? `
|
if (motionComp.properties.numItems < 2) {
|
||||||
if (prop.displayName === "Scale") {
|
missing.push("scale: propriedade Escala (índice 1) não encontrada");
|
||||||
prop.setValue(${args.scale}, true);
|
} else {
|
||||||
changes.scale = ${args.scale};
|
motionComp.properties[1].setValue(${args.scale}, true);
|
||||||
}` : ""}
|
changes.scale = ${args.scale};
|
||||||
${args.position_x !== undefined || args.position_y !== undefined ? `
|
}` : ""}
|
||||||
if (prop.displayName === "Position") {
|
${args.position_x !== undefined || args.position_y !== undefined ? `
|
||||||
var posVal = prop.getValue();
|
if (motionComp.properties.numItems < 1) {
|
||||||
var px = posVal && typeof posVal === "object" && posVal.length >= 2 ? posVal[0] : 0;
|
missing.push("position: propriedade Posição (índice 0) não encontrada");
|
||||||
var py = posVal && typeof posVal === "object" && posVal.length >= 2 ? posVal[1] : 0;
|
} else {
|
||||||
${args.position_x !== undefined ? `px = ${args.position_x}; changes.position_x = ${args.position_x};` : ""}
|
var posProp = motionComp.properties[0];
|
||||||
${args.position_y !== undefined ? `py = ${args.position_y}; changes.position_y = ${args.position_y};` : ""}
|
var posVal = posProp.getValue();
|
||||||
prop.setValue([px, py], true);
|
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.rotation !== undefined ? `
|
${args.position_x !== undefined ? `px = ${args.position_x}; changes.position_x = ${args.position_x};` : ""}
|
||||||
if (prop.displayName === "Rotation") {
|
${args.position_y !== undefined ? `py = ${args.position_y}; changes.position_y = ${args.position_y};` : ""}
|
||||||
prop.setValue(${args.rotation}, true);
|
posProp.setValue([px, py], true);
|
||||||
changes.rotation = ${args.rotation};
|
}` : ""}
|
||||||
}` : ""}
|
${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 __result({ updated: true, clipName: clip.name, changes: changes });
|
||||||
`);
|
`);
|
||||||
return sendCommand(script, bridgeOptions);
|
return sendCommand(script, bridgeOptions);
|
||||||
|
|||||||
Reference in New Issue
Block a user