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:
Binary file not shown.
@@ -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.
|
||||
|
||||
|
||||
@@ -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? {
|
||||
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 }
|
||||
let fatos = saida.evidencias.map(descreverEvidencia).joined(separator: "; ")
|
||||
guard !fatos.isEmpty else { return nil }
|
||||
|
||||
@@ -111,6 +111,7 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
"add_track": self._simular_add_track,
|
||||
"set_playhead_position": self._simular_set_playhead_position,
|
||||
"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,
|
||||
"trim_clip": self._simular_trim_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}."
|
||||
)
|
||||
|
||||
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]:
|
||||
"""Remove o clipe pedido e fecha o espaço deixado na mesma faixa."""
|
||||
identificador_do_clipe = argumentos["node_id"]
|
||||
|
||||
Reference in New Issue
Block a user