feat: corrigido o KeyError 'totalTracks' no cliente MCP: extração

- corrigido o KeyError 'totalTracks' no cliente MCP: extração de structuredContent.data na resposta do servidor (SDK 2.0+)
- sincronizado cortes de vídeo e áudio usando razor_all_tracks em vez de split_clip por faixa, eliminando drift entre cortes
- atualizado hook para usar razor_all_tracks, isolando vídeo e áudio no mesmo instante
- adicionado simulador razor_all_tracks no dublê de teste

Resumo:
- 5 arquivos alterados
- 0 novos
- 5 modificados
- 0 removidos

 5 files changed, 264 insertions(+), 12 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/engine/editor/aplicador_de_plano_de_edicao.py
  - code/engine/editor/escrita/escrita_no_editor.py
  - code/engine/integracoes/visual/apple_vision_runner.swift
  - code/engine/testes/duplos_de_premiere.py
This commit is contained in:
João Henrique
2026-09-10 17:31:10 -04:00
parent fc7310be39
commit 2c11ba8dda
5 changed files with 264 additions and 12 deletions
@@ -22,6 +22,9 @@ from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
_TOLERANCIA_DE_BORDA_EM_SEGUNDOS = 0.05
_TOLERANCIA_DE_DURACAO_EM_SEGUNDOS = 0.3
_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS = 3.0
# Tolerância adicional para encontrar clipes cuja origem foi deslocada pelo
# drift do razor_all_tracks (drift observado no bridge real: até ~0.5s).
_TOLERANCIA_DE_ORIGEM_EM_SEGUNDOS = 0.5
@dataclass(frozen=True)
@@ -190,14 +193,38 @@ class AplicadorDePlanoDeEdicao:
``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.
Usa ``razor_all_tracks`` para cortar vídeo e áudio no mesmo instante,
evitando drift entre chamadas separadas de ``split_clip``.
"""
try:
faixas = self._timeline_atual().faixas
for faixa in faixas:
self._clipes_do_arquivo(faixa, arquivo_de_origem)
faixas_afetadas = [
faixa for faixa in faixas
if any(
self._sobrepoe(clipe, acao.inicio, acao.fim)
for clipe in self._clipes_do_arquivo(faixa, arquivo_de_origem)
)
]
if not faixas_afetadas:
raise ErroDeMapeamentoDeTempo(
f"Nenhum clipe de {arquivo_de_origem!r} sobrepõe o intervalo {acao.inicio}-{acao.fim}s de origem."
)
pontos_de_corte = self._calcular_pontos_de_corte(faixas_afetadas, acao, arquivo_de_origem)
for ponto in pontos_de_corte:
self.escrita.dividir_todas_as_faixas(ponto)
faixas = self._timeline_atual().faixas
for faixa_id in {f.identificador for f in faixas_afetadas}:
faixa_atualizada = next(f for f in faixas if f.identificador == faixa_id)
self._validar_bordas_do_razor(faixa_atualizada, acao, arquivo_de_origem)
alvos_por_faixa = {
faixa.identificador: self._preparar_alvos_da_faixa(faixa, acao, arquivo_de_origem)
faixa.identificador: self._encontrar_alvos_pos_corte(faixa, acao, arquivo_de_origem)
for faixa in faixas
}
trechos_removidos = self._remover_alvos_coordenados(faixas, alvos_por_faixa)
@@ -209,6 +236,95 @@ class AplicadorDePlanoDeEdicao:
except ErroDeEdicao as erro:
return ResultadoDaAcao(acao, False, str(erro))
def _calcular_pontos_de_corte(
self, faixas_afetadas: list[Faixa], acao: AcaoDeEdicao, arquivo_de_origem: str
) -> list[float]:
"""Calcula os pontos de timeline onde o razor deve ser aplicado.
Devolve uma lista ordenada de instantes de timeline (em segundos)
correspondentes às bordas de entrada e saída do corte, mapeados a
partir do intervalo de origem. Os pontos são deduplicados e
ordenados do fim para o começo para que o ``razor_all_tracks``
não desloque clipes entre um corte e o próximo.
"""
pontos: set[float] = set()
for faixa in faixas_afetadas:
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
for clipe in afetados:
if acao.inicio > clipe.intervalo_na_origem.inicio:
pontos.add(self.mapeador.instante_na_timeline(clipe, acao.inicio))
if acao.fim < clipe.intervalo_na_origem.fim:
pontos.add(self.mapeador.instante_na_timeline(clipe, acao.fim))
return sorted(pontos, reverse=True)
def _validar_bordas_do_razor(
self, faixa: Faixa, acao: AcaoDeEdicao, arquivo_de_origem: str
) -> None:
"""Verifica que o razor criou bordas perto dos pontos esperados.
Levanta :class:`ErroDeMapeamentoDeTempo` se nenhuma borda de clipe
estiver dentro de ``_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS`` de um dos
pontos de corte esperados — nesse caso o drift é grande demais para
confiar no resultado.
"""
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
if not afetados:
return
bordas_esperadas: list[float] = []
primeiro, ultimo = afetados[0], afetados[-1]
if acao.inicio > primeiro.intervalo_na_origem.inicio:
bordas_esperadas.append(self._borda_de_entrada(primeiro, acao.inicio))
if acao.fim < ultimo.intervalo_na_origem.fim:
bordas_esperadas.append(self._borda_de_saida(ultimo, acao.fim))
todas_bordas = {
clipe.intervalo_na_timeline.inicio for clipe in clipes
} | {
clipe.intervalo_na_timeline.fim for clipe in clipes
}
for esperada in bordas_esperadas:
mais_proxima = min(todas_bordas, key=lambda b: abs(b - esperada), default=None)
if mais_proxima is None or abs(mais_proxima - esperada) > _RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS:
raise ErroDeMapeamentoDeTempo(
f"Nenhuma borda de clipe foi encontrada perto de {esperada:.3f}s "
f"(raio de busca: {_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS}s); o corte não foi aplicado."
)
def _encontrar_alvos_pos_corte(
self, faixa: Faixa, acao: AcaoDeEdicao, arquivo_de_origem: str
) -> list[Clipe]:
"""Encontra os clipes de uma faixa que ficam entre as bordas do corte.
Chamado depois que ``razor_all_tracks`` já dividiu todas as faixas;
não repete nenhuma divisão. Usa o intervalo de origem para encontrar
clipes cuja origem está contida no intervalo do corte, já que o drift
do razor pode deslocar as bordas de timeline. Valida que a duração
total dos clipes encontrados é compatível com a sobreposição de
origem esperada.
"""
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
if not afetados:
return []
alvo = [
clipe for clipe in afetados
if clipe.intervalo_na_origem.inicio >= acao.inicio - _TOLERANCIA_DE_ORIGEM_EM_SEGUNDOS
and clipe.intervalo_na_origem.fim <= acao.fim + _TOLERANCIA_DE_ORIGEM_EM_SEGUNDOS
]
duracao_removida = sum(clipe.intervalo_na_timeline.duracao for clipe in alvo)
duracao_esperada = sum(
self._sobreposicao_em_segundos(clipe, acao.inicio, acao.fim) for clipe in afetados
)
if abs(duracao_removida - duracao_esperada) > _TOLERANCIA_DE_DURACAO_EM_SEGUNDOS:
raise ErroDeMapeamentoDeTempo(
f"Na faixa {faixa.identificador!r}, o corte em {acao.inicio:.3f}-{acao.fim:.3f}s "
f"não isolou o trecho esperado (esperado {duracao_esperada:.3f}s, "
f"encontrado {duracao_removida:.3f}s); nada foi removido para evitar apagar o trecho errado."
)
return alvo
def _aplicar_gancho(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> ResultadoDaAcao:
"""Recorta uma cópia de um par simples e a insere na abertura."""
try:
@@ -264,20 +380,41 @@ class AplicadorDePlanoDeEdicao:
# A ferramenta de gancho do bridge exige um par já isolado: ela lê
# o in/out do próprio clipe, não recebe limites de origem à
# parte. Por isso, ao contrário do fluxo antigo (que passava o
# clipe inteiro que continha o trecho), aqui é preciso dividir
# cada faixa nas bordas do gancho antes de duplicar — o mesmo
# padrão de isolamento que ``_preparar_alvos_da_faixa`` usa para
# cortes.
# parte. Usa ``razor_all_tracks`` para dividir vídeo e áudio
# no mesmo instante, evitando drift entre chamadas separadas.
pontos_para_cortar: set[float] = set()
for faixa in faixas_afetadas:
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
for clipe in afetados:
if acao.inicio > clipe.intervalo_na_origem.inicio:
pontos_para_cortar.add(self.mapeador.instante_na_timeline(clipe, acao.inicio))
if acao.fim < clipe.intervalo_na_origem.fim:
pontos_para_cortar.add(self.mapeador.instante_na_timeline(clipe, acao.fim))
for ponto in sorted(pontos_para_cortar, reverse=True):
self.escrita.dividir_todas_as_faixas(ponto)
faixas = self._timeline_atual().faixas
faixas_afetadas = [
faixa for faixa in faixas
if any(
self._sobrepoe(clipe, acao.inicio, acao.fim)
for clipe in self._clipes_do_arquivo(faixa, arquivo_de_origem)
)
]
alvos: list[Clipe] = []
for faixa in faixas_afetadas:
inicio_real = self._dividir_e_conferir(acao.inicio, faixa, arquivo_de_origem)
fim_real = self._dividir_e_conferir(acao.fim, faixa, arquivo_de_origem)
clipes_apos_a_divisao = self._clipes_do_arquivo(self._faixa_atualizada(faixa), arquivo_de_origem)
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
if not afetados:
continue
primeiro, ultimo = afetados[0], afetados[-1]
inicio_na_timeline = self._borda_de_entrada(primeiro, acao.inicio)
fim_na_timeline = self._borda_de_saida(ultimo, acao.fim)
isolados = [
clipe for clipe in clipes_apos_a_divisao
if inicio_real - _TOLERANCIA_DE_BORDA_EM_SEGUNDOS <= clipe.intervalo_na_timeline.inicio
and clipe.intervalo_na_timeline.fim <= fim_real + _TOLERANCIA_DE_BORDA_EM_SEGUNDOS
clipe for clipe in clipes
if inicio_na_timeline - _TOLERANCIA_DE_BORDA_EM_SEGUNDOS <= clipe.intervalo_na_timeline.inicio
and clipe.intervalo_na_timeline.fim <= fim_na_timeline + _TOLERANCIA_DE_BORDA_EM_SEGUNDOS
]
if len(isolados) != 1:
raise ErroDeMapeamentoDeTempo(
@@ -79,6 +79,23 @@ class EscritaNoEditor:
f"em {instante_da_timeline:.3f}s: {erro}"
) from erro
def dividir_todas_as_faixas(self, instante_da_timeline: float) -> None:
"""Corta todas as faixas de vídeo e áudio no mesmo instante.
Usa ``razor_all_tracks`` para garantir que vídeo e áudio sejam
divididos no mesmo ponto de tempo, evitando drift entre chamadas
separadas de ``split_clip``.
"""
try:
self.cliente_mcp.chamar(
"razor_all_tracks",
{"time_seconds": instante_da_timeline, "track_type": "both"},
)
except ErroDeFerramentaMCP as erro:
raise ErroDeEscritaNoEditor(
f"Não foi possível cortar todas as faixas em {instante_da_timeline:.3f}s: {erro}"
) from erro
def remover_trecho(self, identificador_do_clipe: str) -> None:
"""Remove o clipe da timeline fechando o espaço que ele ocupava.