From 2c11ba8dda4ceeaf97d0b2f4beb518b043ed9573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Henrique?= Date: Thu, 10 Sep 2026 17:31:10 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20corrigido=20o=20KeyError=20'totalTracks?= =?UTF-8?q?'=20no=20cliente=20MCP:=20extra=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .jhonny/analises.db | Bin 1175552 -> 1175552 bytes .../editor/aplicador_de_plano_de_edicao.py | 161 ++++++++++++++++-- .../editor/escrita/escrita_no_editor.py | 17 ++ .../visual/apple_vision_runner.swift | 57 +++++++ code/engine/testes/duplos_de_premiere.py | 41 +++++ 5 files changed, 264 insertions(+), 12 deletions(-) diff --git a/.jhonny/analises.db b/.jhonny/analises.db index c7fdeb786ea92cbbd00d0cfe70b75ac686e044b9..f165844ddd967f2ac0265659982ef968e323181e 100644 GIT binary patch delta 262 zcmZp8;NI}SeS);$e+C9dc?LKT5S^%F%=mv}!q#<+T+L$Z+r`#1ZWmk6^v*$oizkzT z{}6vKzboGhzA`=sJ}zE!o(nvgn-vXwdAL)#I2agRZ8;dD*`^ErXHw@eGPE)@vNAE& zGc+-pUjCO!lRK3YQ;C7Om4T6-k*P6Q$#(O 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( diff --git a/code/engine/editor/escrita/escrita_no_editor.py b/code/engine/editor/escrita/escrita_no_editor.py index aff280b..ac5366c 100644 --- a/code/engine/editor/escrita/escrita_no_editor.py +++ b/code/engine/editor/escrita/escrita_no_editor.py @@ -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. diff --git a/code/engine/integracoes/visual/apple_vision_runner.swift b/code/engine/integracoes/visual/apple_vision_runner.swift index 1d956b3..34ab4b1 100644 --- a/code/engine/integracoes/visual/apple_vision_runner.swift +++ b/code/engine/integracoes/visual/apple_vision_runner.swift @@ -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 } diff --git a/code/engine/testes/duplos_de_premiere.py b/code/engine/testes/duplos_de_premiere.py index fbfabe7..836b33b 100644 --- a/code/engine/testes/duplos_de_premiere.py +++ b/code/engine/testes/duplos_de_premiere.py @@ -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"]