diff --git a/.jhonny/analises.db b/.jhonny/analises.db index c7fdeb7..f165844 100644 Binary files a/.jhonny/analises.db and b/.jhonny/analises.db differ diff --git a/code/engine/editor/aplicador_de_plano_de_edicao.py b/code/engine/editor/aplicador_de_plano_de_edicao.py index 5477963..362bca3 100644 --- a/code/engine/editor/aplicador_de_plano_de_edicao.py +++ b/code/engine/editor/aplicador_de_plano_de_edicao.py @@ -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( 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"]