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
Binary file not shown.
@@ -22,6 +22,9 @@ 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 _TOLERANCIA_DE_DURACAO_EM_SEGUNDOS = 0.3
_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS = 3.0 _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) @dataclass(frozen=True)
@@ -190,14 +193,38 @@ class AplicadorDePlanoDeEdicao:
``inPoint``/``outPoint``) só falharia depois de outra faixa já ter ``inPoint``/``outPoint``) só falharia depois de outra faixa já ter
sido cortada — deixando vídeo e áudio dessincronizados mesmo com o sido cortada — deixando vídeo e áudio dessincronizados mesmo com o
corte "reportado" como falho. 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: try:
faixas = self._timeline_atual().faixas faixas = self._timeline_atual().faixas
for faixa in faixas: for faixa in faixas:
self._clipes_do_arquivo(faixa, arquivo_de_origem) 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 = { 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 for faixa in faixas
} }
trechos_removidos = self._remover_alvos_coordenados(faixas, alvos_por_faixa) trechos_removidos = self._remover_alvos_coordenados(faixas, alvos_por_faixa)
@@ -209,6 +236,95 @@ class AplicadorDePlanoDeEdicao:
except ErroDeEdicao as erro: except ErroDeEdicao as erro:
return ResultadoDaAcao(acao, False, str(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: def _aplicar_gancho(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> ResultadoDaAcao:
"""Recorta uma cópia de um par simples e a insere na abertura.""" """Recorta uma cópia de um par simples e a insere na abertura."""
try: try:
@@ -264,20 +380,41 @@ class AplicadorDePlanoDeEdicao:
# A ferramenta de gancho do bridge exige um par já isolado: ela lê # 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 à # 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 # parte. Usa ``razor_all_tracks`` para dividir vídeo e áudio
# clipe inteiro que continha o trecho), aqui é preciso dividir # no mesmo instante, evitando drift entre chamadas separadas.
# cada faixa nas bordas do gancho antes de duplicar — o mesmo pontos_para_cortar: set[float] = set()
# padrão de isolamento que ``_preparar_alvos_da_faixa`` usa para for faixa in faixas_afetadas:
# cortes. 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] = [] alvos: list[Clipe] = []
for faixa in faixas_afetadas: for faixa in faixas_afetadas:
inicio_real = self._dividir_e_conferir(acao.inicio, faixa, arquivo_de_origem) clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
fim_real = self._dividir_e_conferir(acao.fim, faixa, arquivo_de_origem) afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
clipes_apos_a_divisao = self._clipes_do_arquivo(self._faixa_atualizada(faixa), arquivo_de_origem) 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 = [ isolados = [
clipe for clipe in clipes_apos_a_divisao clipe for clipe in clipes
if inicio_real - _TOLERANCIA_DE_BORDA_EM_SEGUNDOS <= clipe.intervalo_na_timeline.inicio if inicio_na_timeline - _TOLERANCIA_DE_BORDA_EM_SEGUNDOS <= clipe.intervalo_na_timeline.inicio
and clipe.intervalo_na_timeline.fim <= fim_real + _TOLERANCIA_DE_BORDA_EM_SEGUNDOS and clipe.intervalo_na_timeline.fim <= fim_na_timeline + _TOLERANCIA_DE_BORDA_EM_SEGUNDOS
] ]
if len(isolados) != 1: if len(isolados) != 1:
raise ErroDeMapeamentoDeTempo( raise ErroDeMapeamentoDeTempo(
@@ -79,6 +79,23 @@ class EscritaNoEditor:
f"em {instante_da_timeline:.3f}s: {erro}" f"em {instante_da_timeline:.3f}s: {erro}"
) from 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: def remover_trecho(self, identificador_do_clipe: str) -> None:
"""Remove o clipe da timeline fechando o espaço que ele ocupava. """Remove o clipe da timeline fechando o espaço que ele ocupava.
@@ -560,7 +560,64 @@ func descreverEvidencia(_ evidencia: Evidencia) -> String {
} }
} }
/// Classificação de ambiente extraída das evidências visuais.
/// Usado pelo Foundation Models para categorizar o cenário do frame.
struct ClassificacaoAmbiente: Encodable {
let ambiente: String
let confianca: Double
let evidencias_chave: [String]
}
/// Taxonomia de ambientes para classificação editorial.
/// Cada ambiente tem descrição de características visuais que o Foundation Models deve procurar.
enum TaxonomiaAmbientes {
static let instrucoes = """
Você identifica o ambiente de cenas de vídeo. Analise as evidências visuais fornecidas \
e classifique o ambiente segundo a taxonomia abaixo. Responda APENAS com JSON válido.
TAXONOMIA DE AMBIENTES:
- centro_cirurgico: pessoas com touca/avental/máscara/camisinha cirúrgica, cores branco/azul cirúrgico, \
mesa cirúrgica, luzes de sala de operação, monitores cardíacos, instrumentos cirúrgicos, campo estéril verde/azul.
- consultorio: mobiliário médico simples, mesa de exame, quadro na parede, equipamento clínico de escritório.
- hospital_ambiente: corredor hospitalar, leito, roupa de paciente, enfermeiros, soro, monitor.
- laboratorio: microscópio, tubos de ensaio, centroides, bancada com reagentes, jaleco branco.
- escritorio: mesa de trabalho, computador, cadeira ergonômica, estantes, iluminação artificial.
- sala_reuniao: mesa comprida, cadeiras ao redor, projetor/tela, quadro branco.
- externo_urbano: rua, edifícios, trânsito, calçada, céu aberto.
- externo_natural: árvores, gramado, água, montanha, praia, campo.
- studio: fundo neutro/escuro, iluminação profissional, equipamento de gravação.
- industria: maquinário, linha de produção, capacete, colete, ambiente fabril.
- domesticos: sala, cozinha, quarto, mobiliário residencial, iluminação caseira.
- academia: equipamentos de musculação, esteira, peso, pessoa se exercitando.
- outro: qualquer ambiente que não se encaixe nas categorias acima.
REGRAS:
1. Analise as evidências visuais (pessoas, roupas, objetos, texto OCR, categorias Vision).
2. Identifique os 2-3 indícios mais fortes para a classificação.
3. Atribua confiança de 0.0 a 1.0 baseada na força dos indícios.
4. Se houver texto OCR relevante (placas, placas de identificação), use como evidência forte.
5. Não invente informações não presentes nas evidências.
"""
static let promptClassificar = """
Classifique o ambiente deste frame de vídeo com base nas evidências visuais fornecidas.
Responda APENAS com JSON no formato: {"ambiente": "categoria", "confianca": 0.0-1.0, "evidencias_chave": ["evidência 1", "evidência 2"]}
"""
}
func interpretar(_ saida: Saida) async -> String? { func interpretar(_ saida: Saida) async -> String? {
guard SystemLanguageModel.default.isAvailable else { return nil }
let fatos = saida.evidencias.map(descreverEvidencia).joined(separator: "; ")
guard !fatos.isEmpty else { return nil }
do {
let sessao = LanguageModelSession(instructions: TaxonomiaAmbientes.instrucoes)
let resposta = try await sessao.respond(to: "\(TaxonomiaAmbientes.promptClassificar)\n\nEvidências visuais: \(fatos)")
return resposta.content
} catch { return nil }
}
/// Interpretação editorial legível (frase curta) — mantida para compatibilidade.
func interpretarFrase(_ saida: Saida) async -> String? {
guard SystemLanguageModel.default.isAvailable else { return nil } guard SystemLanguageModel.default.isAvailable else { return nil }
let fatos = saida.evidencias.map(descreverEvidencia).joined(separator: "; ") let fatos = saida.evidencias.map(descreverEvidencia).joined(separator: "; ")
guard !fatos.isEmpty else { return nil } guard !fatos.isEmpty else { return nil }
+41
View File
@@ -111,6 +111,7 @@ class ClienteMCPFalso(ClienteMCP):
"add_track": self._simular_add_track, "add_track": self._simular_add_track,
"set_playhead_position": self._simular_set_playhead_position, "set_playhead_position": self._simular_set_playhead_position,
"add_adjustment_layer": self._simular_add_adjustment_layer, "add_adjustment_layer": self._simular_add_adjustment_layer,
"razor_all_tracks": self._simular_razor_all_tracks,
"get_clip_at_position": self._simular_get_clip_at_position, "get_clip_at_position": self._simular_get_clip_at_position,
"trim_clip": self._simular_trim_clip, "trim_clip": self._simular_trim_clip,
"move_clip": self._simular_move_clip, "move_clip": self._simular_move_clip,
@@ -223,6 +224,46 @@ class ClienteMCPFalso(ClienteMCP):
f"No clip strictly spans {instante}s on track {identificador_da_faixa}." f"No clip strictly spans {instante}s on track {identificador_da_faixa}."
) )
def _simular_razor_all_tracks(self, argumentos: dict[str, Any]) -> dict[str, Any]:
"""Divide todos os clipes de todas as faixas no instante informado.
Reproduz o ``razor_all_tracks`` do bridge real: corta todas as faixas
de vídeo e/ou áudio no mesmo ponto de tempo, garantindo
sincronização entre elas.
"""
instante_pedido = float(argumentos["time_seconds"])
track_type = argumentos.get("track_type", "both")
cortados = 0
self._salvar_estado()
for identificador_da_faixa, faixa in self._faixas.items():
if track_type == "video" and not identificador_da_faixa.startswith("video_"):
continue
if track_type == "audio" and not identificador_da_faixa.startswith("audio_"):
continue
for posicao, clipe in enumerate(faixa):
if clipe.inicio_na_timeline < instante_pedido < clipe.fim_na_timeline:
instante = min(
max(instante_pedido + self.desvio_do_split, clipe.inicio_na_timeline + 0.001),
clipe.fim_na_timeline - 0.001,
)
deslocamento = instante - clipe.inicio_na_timeline
self._proximo_id += 1
parte_esquerda = _ClipeFalso(
f"{clipe.identificador}_esq{self._proximo_id}", clipe.nome,
clipe.inicio_na_timeline, instante,
clipe.inicio_na_origem, clipe.inicio_na_origem + deslocamento,
)
self._proximo_id += 1
parte_direita = _ClipeFalso(
f"{clipe.identificador}_dir{self._proximo_id}", clipe.nome,
instante, clipe.fim_na_timeline,
clipe.inicio_na_origem + deslocamento, clipe.fim_na_origem,
)
faixa[posicao:posicao + 1] = [parte_esquerda, parte_direita]
cortados += 1
break
return {"razored": cortados, "verified": True}
def _simular_ripple_delete(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 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"]