feat: Instalado essentia-tensorflow no venv whisperx-transcricao (
- Instalado essentia-tensorflow no venv whisperx-transcricao (usado pelo painel CEP), corrigindo erro "A classificação musical precisa do suporte TensorFlow do Essentia" no catálogo de trilhas. - Ligada a classificação de emoção da fala (ProviderDeEmocaoHuggingFace) ao scanner via nova flag `classificar_emocao`, e adicionado emoção/confiança como critério de desempate secundário no zoom da skill editar-por-voz. - Zoom do caminho Python (aplicar_plano_de_edicao.py) passou a usar adjustment layer numa faixa de vídeo própria, em vez de escalar o clipe inteiro via set_clip_properties — mesma técnica já usada pelo executor .mjs do painel CEP. - Adicionado campo "Carregar por ID" na aba Editar Vídeo do painel CEP (step 2), para carregar um plano do SQLite direto pelo plano_id quando ele não está associado a nenhum video_id conhecido (ex.: plano de um vídeo que este banco nunca escaneou). - Trocado o campo de ID numérico por uma lista suspensa (novo listar_planos_de_edicao.py + RepositorioDePlanos.listar_todos_os_planos) que mostra data/hora e a intenção registrada pela skill em cada plano salvo, para escolher sem decorar o id. Resumo: - 15 arquivos alterados - 2 novos - 13 modificados - 0 removidos 13 files changed, 451 insertions(+), 65 deletions(-) Arquivos: - .jhonny/analises.db - code/cep-plugin/index.html - code/cep-plugin/main.js - code/engine/editor/aplicador_de_plano_de_edicao.py - code/engine/editor/escrita/escrita_no_editor.py - code/engine/executar_scanner.py - code/engine/persistencia/repositorio_de_planos.py - code/engine/scanner/configuracao_visual.py - code/engine/testes/duplos_de_premiere.py - code/engine/testes/test_aplicador_de_plano_de_edicao.py - code/engine/testes/test_escrita_no_editor.py - code/plugins/premiere-pro/skills/editar-por-voz/criterios/01-leitura-do-json.md - code/plugins/premiere-pro/skills/editar-por-voz/criterios/05-zoom.md - code/engine/listar_planos_de_edicao.py - code/engine/testes/test_configuracao_visual_do_scanner.py
This commit is contained in:
@@ -6,7 +6,9 @@ descompasso encontrado no projeto real entre ``inicio_na_timeline`` e
|
||||
``inicio_na_origem`` do banco de análises. Implementa só as ferramentas MCP
|
||||
que o módulo ``engine.editor`` usa: ``get_active_sequence``, ``split_clip``,
|
||||
``remove_from_timeline``, ``remove_linked_from_timeline``,
|
||||
``duplicate_linked_range_to_start``, ``set_clip_properties`` e ``add_marker``.
|
||||
``duplicate_linked_range_to_start``, ``set_clip_properties``, ``add_marker``,
|
||||
``add_track``, ``set_playhead_position``, ``add_adjustment_layer``,
|
||||
``get_clip_at_position``, ``trim_clip`` e ``move_clip``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -63,6 +65,7 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
self.chamadas: list[tuple[str, dict[str, Any]]] = []
|
||||
self._proximo_id = 0
|
||||
self._historico: list[dict[str, list[_ClipeFalso]]] = []
|
||||
self._playhead = 0.0
|
||||
self._faixas: dict[str, list[_ClipeFalso]] = {
|
||||
"video_0": [self._novo_clipe(self._ARQUIVO, 0.0, 200.0, 0.0, 200.0, "clipe_unico_video")],
|
||||
"audio_0": [self._novo_clipe(self._ARQUIVO, 0.0, 200.0, 0.0, 200.0, "clipe_unico_audio")],
|
||||
@@ -105,6 +108,12 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
"set_clip_properties": self._simular_set_clip_properties,
|
||||
"add_marker": self._simular_add_marker,
|
||||
"undo": self._simular_undo,
|
||||
"add_track": self._simular_add_track,
|
||||
"set_playhead_position": self._simular_set_playhead_position,
|
||||
"add_adjustment_layer": self._simular_add_adjustment_layer,
|
||||
"get_clip_at_position": self._simular_get_clip_at_position,
|
||||
"trim_clip": self._simular_trim_clip,
|
||||
"move_clip": self._simular_move_clip,
|
||||
}
|
||||
simulador = despachantes.get(nome_da_ferramenta)
|
||||
if simulador is None:
|
||||
@@ -141,6 +150,14 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
"clips": [_clipe_como_dict(clipe, tipo) for clipe in self._faixas[identificador]],
|
||||
}
|
||||
|
||||
faixas_de_video = sorted(
|
||||
(identificador for identificador in self._faixas if identificador.startswith("video_")),
|
||||
key=lambda identificador: int(identificador.split("_")[1]),
|
||||
)
|
||||
faixas_de_audio = sorted(
|
||||
(identificador for identificador in self._faixas if identificador.startswith("audio_")),
|
||||
key=lambda identificador: int(identificador.split("_")[1]),
|
||||
)
|
||||
dados = {
|
||||
"id": "sequencia_falsa",
|
||||
"name": "Sequência de teste",
|
||||
@@ -148,8 +165,14 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
"frameRate": 29.97,
|
||||
"frameSizeHorizontal": 3840,
|
||||
"frameSizeVertical": 2160,
|
||||
"videoTracks": [_faixa_como_dict("video_0", "video", 0)],
|
||||
"audioTracks": [_faixa_como_dict("audio_0", "audio", 0)],
|
||||
"videoTracks": [
|
||||
_faixa_como_dict(identificador, "video", int(identificador.split("_")[1]))
|
||||
for identificador in faixas_de_video
|
||||
],
|
||||
"audioTracks": [
|
||||
_faixa_como_dict(identificador, "audio", int(identificador.split("_")[1]))
|
||||
for identificador in faixas_de_audio
|
||||
],
|
||||
}
|
||||
return {"structuredContent": {"data": dados}}
|
||||
|
||||
@@ -226,7 +249,13 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
return {"removed": True, "verified": True}
|
||||
|
||||
def _simular_gancho_vinculado_no_inicio(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Duplica um par isolado no início e preserva sua ocorrência original."""
|
||||
"""Duplica um par já isolado no início e preserva sua ocorrência original.
|
||||
|
||||
Espelha o bridge real: não recebe limites de origem à parte, lê o
|
||||
in/out do próprio clipe. Por isso os identificadores precisam já
|
||||
corresponder a um par isolado por ``split_clip`` — o mesmo clipe
|
||||
inteiro, sem sobra de trecho vizinho.
|
||||
"""
|
||||
identificadores = argumentos["node_ids"]
|
||||
encontrados: list[tuple[str, _ClipeFalso]] = []
|
||||
for identificador_da_faixa, clipes in self._faixas.items():
|
||||
@@ -239,11 +268,11 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
fins = {round(clipe.fim_na_timeline, 6) for _, clipe in encontrados}
|
||||
if len(inicios) != 1 or len(fins) != 1:
|
||||
raise ErroDeFerramentaMCP("O par não ocupa o mesmo intervalo.")
|
||||
if next(iter(inicios)) <= 0.0:
|
||||
raise ErroDeFerramentaMCP("O trecho pedido já está no início da sequência.")
|
||||
|
||||
self._salvar_estado()
|
||||
inicio_na_origem = float(argumentos["source_in_seconds"])
|
||||
fim_na_origem = float(argumentos["source_out_seconds"])
|
||||
duracao = fim_na_origem - inicio_na_origem
|
||||
duracao = next(iter(fins)) - next(iter(inicios))
|
||||
novos_ids: list[str] = []
|
||||
for identificador_da_faixa, clipe in encontrados:
|
||||
faixa = self._faixas[identificador_da_faixa]
|
||||
@@ -254,8 +283,8 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
clipe.nome,
|
||||
0.0,
|
||||
duracao,
|
||||
inicio_na_origem,
|
||||
fim_na_origem,
|
||||
clipe.inicio_na_origem,
|
||||
clipe.fim_na_origem,
|
||||
)
|
||||
faixa.append(novo)
|
||||
faixa.sort(key=lambda item: item.inicio_na_timeline)
|
||||
@@ -267,6 +296,95 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
del argumentos
|
||||
return {"updated": True}
|
||||
|
||||
def _simular_add_track(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Cria ``count`` faixas de vídeo novas, vazias, no topo da pilha.
|
||||
|
||||
Espelha ``add_track`` do bridge real (usado pelo `.mjs` para criar a
|
||||
track de camada de ajuste): devolve ``totalTracks`` já contando as
|
||||
novas, de forma que ``totalTracks - 1`` seja o índice da última
|
||||
faixa criada.
|
||||
"""
|
||||
if argumentos.get("track_type") != "video":
|
||||
raise ErroDeFerramentaMCP("Este dublê só simula add_track para vídeo.")
|
||||
contagem = int(argumentos.get("count", 1))
|
||||
indices_existentes = [
|
||||
int(identificador.split("_")[1])
|
||||
for identificador in self._faixas
|
||||
if identificador.startswith("video_")
|
||||
]
|
||||
proximo_indice = max(indices_existentes, default=-1) + 1
|
||||
for deslocamento in range(contagem):
|
||||
self._faixas[f"video_{proximo_indice + deslocamento}"] = []
|
||||
total_de_faixas_de_video = proximo_indice + contagem
|
||||
return {"totalTracks": total_de_faixas_de_video}
|
||||
|
||||
def _simular_set_playhead_position(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Guarda a posição do playhead, usada por :meth:`_simular_add_adjustment_layer`."""
|
||||
self._playhead = float(argumentos["time_seconds"])
|
||||
return {"positioned": True}
|
||||
|
||||
def _simular_add_adjustment_layer(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Cria uma camada de ajuste na track pedida, no instante atual do playhead.
|
||||
|
||||
A camada nasce com uma duração fixa arbitrária (o `.mjs` real conta
|
||||
com isso: sempre chama ``trim_clip``/``move_clip`` em seguida para
|
||||
ajustar duração e posição definitivas).
|
||||
"""
|
||||
indice_da_faixa = int(argumentos["track_index"])
|
||||
identificador_da_faixa = self._identificador_da_faixa(indice_da_faixa, "video")
|
||||
if identificador_da_faixa not in self._faixas:
|
||||
raise ErroDeFerramentaMCP(f"Faixa de vídeo inexistente: {identificador_da_faixa!r}.")
|
||||
duracao_inicial = 5.0
|
||||
clipe = self._novo_clipe(
|
||||
"Adjustment Layer",
|
||||
self._playhead,
|
||||
self._playhead + duracao_inicial,
|
||||
self._playhead,
|
||||
self._playhead + duracao_inicial,
|
||||
)
|
||||
self._faixas[identificador_da_faixa].append(clipe)
|
||||
self._faixas[identificador_da_faixa].sort(key=lambda item: item.inicio_na_timeline)
|
||||
return {"added": True, "nodeId": clipe.identificador}
|
||||
|
||||
def _simular_get_clip_at_position(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Devolve o clipe da faixa pedida que cobre ``time_seconds``."""
|
||||
identificador_da_faixa = self._identificador_da_faixa(argumentos["track_index"], argumentos["track_type"])
|
||||
instante = float(argumentos["time_seconds"])
|
||||
faixa = self._faixas.get(identificador_da_faixa, [])
|
||||
clipe = next(
|
||||
(c for c in faixa if c.inicio_na_timeline <= instante < c.fim_na_timeline), None
|
||||
)
|
||||
if clipe is None:
|
||||
raise ErroDeFerramentaMCP(f"Nenhum clipe em {identificador_da_faixa!r} no instante {instante}s.")
|
||||
return {"nodeId": clipe.identificador}
|
||||
|
||||
def _simular_trim_clip(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Ajusta a duração do clipe, medida a partir do próprio início atual."""
|
||||
clipe = self._localizar_clipe(argumentos["node_id"])
|
||||
nova_duracao = float(argumentos["new_out_seconds"])
|
||||
clipe.fim_na_timeline = clipe.inicio_na_timeline + nova_duracao
|
||||
clipe.fim_na_origem = clipe.inicio_na_origem + nova_duracao
|
||||
return {"trimmed": True}
|
||||
|
||||
def _simular_move_clip(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Move o clipe para ``new_start_seconds``, preservando sua duração."""
|
||||
clipe = self._localizar_clipe(argumentos["node_id"])
|
||||
duracao = clipe.fim_na_timeline - clipe.inicio_na_timeline
|
||||
novo_inicio = float(argumentos["new_start_seconds"])
|
||||
clipe.inicio_na_timeline = novo_inicio
|
||||
clipe.fim_na_timeline = novo_inicio + duracao
|
||||
for faixa in self._faixas.values():
|
||||
faixa.sort(key=lambda item: item.inicio_na_timeline)
|
||||
return {"moved": True}
|
||||
|
||||
def _localizar_clipe(self, identificador_do_clipe: str) -> _ClipeFalso:
|
||||
"""Encontra um clipe pelo identificador em qualquer faixa, ou levanta erro."""
|
||||
for faixa in self._faixas.values():
|
||||
for clipe in faixa:
|
||||
if clipe.identificador == identificador_do_clipe:
|
||||
return clipe
|
||||
raise ErroDeFerramentaMCP(f"Clipe não encontrado: {identificador_do_clipe!r}.")
|
||||
|
||||
def _simular_add_marker(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Aceita a criação de marcador sem manter estado (a chamada já fica em :attr:`chamadas`)."""
|
||||
del argumentos
|
||||
|
||||
@@ -74,11 +74,8 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
|
||||
self.assertEqual([clipe.identificador for clipe in clipes], ["clipe_real"])
|
||||
|
||||
def test_marcador_e_zoom_sao_aplicados_depois_dos_cortes(self):
|
||||
# Zoom muda uma propriedade do clipe inteiro (set_clip_properties),
|
||||
# não de um trecho dele. Se o zoom rodasse antes do corte, cairia no
|
||||
# clipe original ainda não dividido; dois zooms em pontos diferentes
|
||||
# do mesmo clipe se sobrescreveriam, e a divisão posterior faria os
|
||||
# pedaços herdarem só o último valor. Por isso cortes vêm primeiro.
|
||||
# Zoom e marcador precisam do instante definitivo na timeline, e
|
||||
# cortes deslocam clipes (ripple) — por isso cortes vêm primeiro.
|
||||
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
||||
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=0.0, fim=40.6, motivo="bastidor"),
|
||||
AcaoDeEdicao(TipoDeAcao.MARCADOR, inicio=90.0, fim=90.5, motivo="conferir emenda", parametros={"content": "EMENDA"}),
|
||||
@@ -88,12 +85,16 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
|
||||
self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas])
|
||||
# corte aconteceu antes de marcador e zoom na sequência de chamadas
|
||||
indice_do_marcador = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "add_marker")
|
||||
indice_do_zoom = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "set_clip_properties")
|
||||
indice_do_zoom = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "add_adjustment_layer")
|
||||
indice_da_remocao = next(
|
||||
i for i, c in enumerate(self.cliente.chamadas) if c[0] == "remove_linked_from_timeline"
|
||||
)
|
||||
self.assertLess(indice_da_remocao, indice_do_marcador)
|
||||
self.assertLess(indice_da_remocao, indice_do_zoom)
|
||||
# a adjustment layer foi criada e escalada, sem tocar o clipe original
|
||||
self.assertTrue(any(c[0] == "add_track" for c in self.cliente.chamadas))
|
||||
chamada_de_escala = next(c for c in self.cliente.chamadas if c[0] == "set_clip_properties")
|
||||
self.assertAlmostEqual(chamada_de_escala[1]["scale"], 115.0)
|
||||
|
||||
def test_dois_cortes_sao_aplicados_do_fim_para_o_comeco(self):
|
||||
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
||||
@@ -121,12 +122,11 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
|
||||
# a ação válida (marcador) ainda foi aplicada
|
||||
self.assertTrue(any(c[0] == "add_marker" for c in self.cliente.chamadas))
|
||||
|
||||
def test_dois_zooms_no_mesmo_clipe_original_nao_se_sobrescrevem(self):
|
||||
# Bug real: com um só clipe cobrindo o arquivo inteiro (gravação
|
||||
# "sem corte"), dois zooms em trechos diferentes caíam no mesmo
|
||||
# clipe ainda não dividido; o segundo sobrescrevia a escala do
|
||||
# primeiro no clipe inteiro, e ambos os pedaços resultantes da
|
||||
# divisão herdavam só o último valor.
|
||||
def test_dois_zooms_geram_duas_camadas_de_ajuste_na_mesma_faixa(self):
|
||||
# Com a técnica de adjustment layer, dois zooms em trechos
|
||||
# diferentes do mesmo clipe original (gravação "sem corte") não
|
||||
# competem por uma propriedade do clipe inteiro: cada um ganha sua
|
||||
# própria camada, na mesma faixa de ajuste reaproveitada.
|
||||
cliente = ClienteMCPFalso(clipe_unico=True)
|
||||
aplicador = AplicadorDePlanoDeEdicao(
|
||||
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
|
||||
@@ -141,13 +141,23 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
|
||||
))
|
||||
resultado = aplicador.aplicar(plano)
|
||||
self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas])
|
||||
# só uma faixa de ajuste foi criada, reaproveitada pelos dois zooms
|
||||
chamadas_de_track = [c for c in cliente.chamadas if c[0] == "add_track"]
|
||||
self.assertEqual(len(chamadas_de_track), 1)
|
||||
chamadas_de_camada = [c for c in cliente.chamadas if c[0] == "add_adjustment_layer"]
|
||||
self.assertEqual(len(chamadas_de_camada), 2)
|
||||
indices_de_faixa = {c[1]["track_index"] for c in chamadas_de_camada}
|
||||
self.assertEqual(len(indices_de_faixa), 1)
|
||||
chamadas_de_zoom = [c for c in cliente.chamadas if c[0] == "set_clip_properties"]
|
||||
self.assertEqual(len(chamadas_de_zoom), 2)
|
||||
node_ids_zoomados = {c[1]["node_id"] for c in chamadas_de_zoom}
|
||||
# os dois zooms caíram em clipes diferentes, um por trecho
|
||||
# os dois zooms caíram em camadas diferentes, uma por trecho
|
||||
self.assertEqual(len(node_ids_zoomados), 2)
|
||||
escalas = sorted(round(c[1]["scale"], 3) for c in chamadas_de_zoom)
|
||||
self.assertEqual(escalas, [115.0, 130.0])
|
||||
# o clipe original não foi escalado
|
||||
node_ids_clipe_original = {"clipe_unico_video", "clipe_unico_audio"}
|
||||
self.assertFalse(node_ids_zoomados & node_ids_clipe_original)
|
||||
|
||||
def test_corte_com_desvio_de_bridge_usa_a_borda_real_em_vez_de_falhar(self):
|
||||
# O split_clip do bridge do Premiere corta alguns décimos de segundo
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Testes da configuração declarativa do Scanner, incluindo classificar_emocao."""
|
||||
|
||||
import unittest
|
||||
|
||||
from engine.scanner.configuracao_visual import ConfiguracaoVisualDoScanner
|
||||
|
||||
|
||||
class TesteDaFlagClassificarEmocao(unittest.TestCase):
|
||||
"""Garante que classificar_emocao siga o mesmo padrão de metricas_de_fala."""
|
||||
|
||||
def test_classificar_emocao_e_desligada_por_padrao(self):
|
||||
configuracao = ConfiguracaoVisualDoScanner()
|
||||
|
||||
self.assertFalse(configuracao.classificar_emocao)
|
||||
|
||||
def test_de_dict_le_classificar_emocao_do_perfil_do_painel(self):
|
||||
configuracao = ConfiguracaoVisualDoScanner.de_dict({"classificar_emocao": True})
|
||||
|
||||
self.assertTrue(configuracao.classificar_emocao)
|
||||
|
||||
def test_para_dict_serializa_classificar_emocao(self):
|
||||
configuracao = ConfiguracaoVisualDoScanner(classificar_emocao=True)
|
||||
|
||||
self.assertTrue(configuracao.para_dict()["classificar_emocao"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -77,12 +77,12 @@ class TesteDuplicarTrechosVinculados(unittest.TestCase):
|
||||
def test_exige_exatamente_dois_identificadores(self):
|
||||
escrita = EscritaNoEditor(_ClienteQueRecusa("não deveria chamar"))
|
||||
with self.assertRaisesRegex(ErroDeEscritaNoEditor, "exatamente"):
|
||||
escrita.duplicar_trecho_vinculado_para_o_inicio(["somente-video"], 10.0, 15.0)
|
||||
escrita.duplicar_trecho_vinculado_para_o_inicio(["somente-video"])
|
||||
|
||||
def test_converte_falha_da_ferramenta_em_erro_de_escrita(self):
|
||||
escrita = EscritaNoEditor(_ClienteQueRecusa("faixa adicional ocupada"))
|
||||
with self.assertRaisesRegex(ErroDeEscritaNoEditor, "faixa adicional ocupada"):
|
||||
escrita.duplicar_trecho_vinculado_para_o_inicio(["video", "audio"], 10.0, 15.0)
|
||||
escrita.duplicar_trecho_vinculado_para_o_inicio(["video", "audio"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user