feat: criada a aba Análises no painel CEP para ler do banco (anali
- criada a aba Análises no painel CEP para ler do banco (analises.db) e copiar/abrir o J-SOM de som (transcrição por cena) e o de imagem (evidências visuais), com novo módulo engine/gerar_relatorio_de_analises.py e testes - criado o módulo engine/editor (leitura do plano de edição em JSON, tradução de tempos de origem para a timeline ativa e aplicação de cortes/zooms/marcadores no Premiere via MCP) e o ponto de entrada engine/aplicar_plano_de_edicao.py, com testes; corrigido engine/integracoes/premiere/cliente_mcp.py para levantar os erros específicos já declarados em erros_mcp.py em vez de RuntimeError genérico Resumo: - 15 arquivos alterados - 8 novos - 5 modificados - 2 removidos 7 files changed, 172 insertions(+), 8 deletions(-) Arquivos: - .jhonny/analises.db - .jhonny/analises.db-shm - .jhonny/analises.db-wal - code/cep-plugin/index.html - code/cep-plugin/main.js - code/cep-plugin/styles.css - code/engine/integracoes/premiere/cliente_mcp.py - code/engine/aplicar_plano_de_edicao.py - code/engine/editor/ - code/engine/gerar_relatorio_de_analises.py - code/engine/testes/duplos_de_premiere.py - code/engine/testes/test_aplicador_de_plano_de_edicao.py - code/engine/testes/test_gerar_relatorio_de_analises.py - code/engine/testes/test_leitor_de_plano_de_edicao.py - code/engine/testes/test_mapeador_de_tempo.py
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
"""Dublê de teste do cliente MCP do Premiere, para testar o editor sem o app aberto.
|
||||
|
||||
Simula, em memória, uma sequência com faixas de vídeo e áudio cujos clipes
|
||||
têm posição na timeline diferente da posição no arquivo de origem — o mesmo
|
||||
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``, ``set_clip_properties`` e ``add_marker``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from engine.integracoes.premiere.cliente_mcp import ClienteMCP
|
||||
from engine.integracoes.premiere.erros_mcp import ErroDeFerramentaMCP
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ClipeFalso:
|
||||
"""Representação mínima de um clipe dentro do dublê de sequência."""
|
||||
|
||||
identificador: str
|
||||
nome: str
|
||||
inicio_na_timeline: float
|
||||
fim_na_timeline: float
|
||||
inicio_na_origem: float
|
||||
fim_na_origem: float
|
||||
|
||||
|
||||
class ClienteMCPFalso(ClienteMCP):
|
||||
"""Simula o servidor MCP do Premiere para testes do módulo ``engine.editor``.
|
||||
|
||||
Vem pré-carregado com duas faixas (``video_0`` e ``audio_0``), cada uma
|
||||
com dois clipes do mesmo arquivo fictício, reproduzindo o descompasso
|
||||
real entre tempo de origem e tempo de timeline. Registra toda chamada em
|
||||
:attr:`chamadas` para os testes inspecionarem ordem e argumentos.
|
||||
"""
|
||||
|
||||
_ARQUIVO = "0E6A8290.mp4"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.chamadas: list[tuple[str, dict[str, Any]]] = []
|
||||
self._proximo_id = 0
|
||||
self._faixas: dict[str, list[_ClipeFalso]] = {
|
||||
"video_0": [
|
||||
self._novo_clipe(self._ARQUIVO, 0.0, 38.3, 2.3, 40.6, "clipe_a_video"),
|
||||
self._novo_clipe(self._ARQUIVO, 58.1, 66.7, 86.2, 94.8, "clipe_b_video"),
|
||||
],
|
||||
"audio_0": [
|
||||
self._novo_clipe(self._ARQUIVO, 0.0, 38.3, 2.3, 40.6, "clipe_a_audio"),
|
||||
self._novo_clipe(self._ARQUIVO, 58.1, 66.7, 86.2, 94.8, "clipe_b_audio"),
|
||||
],
|
||||
}
|
||||
|
||||
@property
|
||||
def chamadas_de_remocao(self) -> list[dict[str, Any]]:
|
||||
"""Argumentos de cada chamada a ``remove_from_timeline``, na ordem em que ocorreram."""
|
||||
return [argumentos for nome, argumentos in self.chamadas if nome == "remove_from_timeline"]
|
||||
|
||||
def conectar(self) -> None:
|
||||
"""Não há conexão real; existe só para satisfazer a interface de :class:`ClienteMCP`."""
|
||||
|
||||
def desconectar(self) -> None:
|
||||
"""Não há conexão real; existe só para satisfazer a interface de :class:`ClienteMCP`."""
|
||||
|
||||
def esta_conectado(self) -> bool:
|
||||
"""Sempre conectado: este dublê não simula falha de conexão."""
|
||||
return True
|
||||
|
||||
def chamar(self, nome_da_ferramenta: str, argumentos: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Despacha para o simulador da ferramenta pedida e registra a chamada."""
|
||||
argumentos = argumentos or {}
|
||||
self.chamadas.append((nome_da_ferramenta, argumentos))
|
||||
despachantes = {
|
||||
"get_active_sequence": self._simular_get_active_sequence,
|
||||
"split_clip": self._simular_split_clip,
|
||||
"remove_from_timeline": self._simular_remove_from_timeline,
|
||||
"set_clip_properties": self._simular_set_clip_properties,
|
||||
"add_marker": self._simular_add_marker,
|
||||
}
|
||||
simulador = despachantes.get(nome_da_ferramenta)
|
||||
if simulador is None:
|
||||
raise ErroDeFerramentaMCP(f"Ferramenta MCP não simulada neste dublê de teste: {nome_da_ferramenta!r}.")
|
||||
return simulador(argumentos)
|
||||
|
||||
def _novo_clipe(self, nome, tl_inicio, tl_fim, origem_inicio, origem_fim, identificador=None) -> _ClipeFalso:
|
||||
"""Cria um clipe falso com um identificador único (ou o informado, para os fixtures)."""
|
||||
if identificador is None:
|
||||
self._proximo_id += 1
|
||||
identificador = f"clipe_falso_{self._proximo_id}"
|
||||
return _ClipeFalso(identificador, nome, tl_inicio, tl_fim, origem_inicio, origem_fim)
|
||||
|
||||
def _simular_get_active_sequence(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Devolve a sequência atual no formato que :class:`ConversorDeTimeline` espera."""
|
||||
del argumentos
|
||||
|
||||
def _faixa_como_dict(identificador: str, tipo: str, indice: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": identificador,
|
||||
"name": identificador,
|
||||
"index": indice,
|
||||
"clips": [
|
||||
{
|
||||
"nodeId": clipe.identificador,
|
||||
"name": clipe.nome,
|
||||
"start": clipe.inicio_na_timeline,
|
||||
"end": clipe.fim_na_timeline,
|
||||
"inPoint": clipe.inicio_na_origem,
|
||||
"outPoint": clipe.fim_na_origem,
|
||||
}
|
||||
for clipe in self._faixas[identificador]
|
||||
],
|
||||
}
|
||||
|
||||
dados = {
|
||||
"id": "sequencia_falsa",
|
||||
"name": "Sequência de teste",
|
||||
"duration": 200.0,
|
||||
"frameRate": 29.97,
|
||||
"frameSizeHorizontal": 3840,
|
||||
"frameSizeVertical": 2160,
|
||||
"videoTracks": [_faixa_como_dict("video_0", "video", 0)],
|
||||
"audioTracks": [_faixa_como_dict("audio_0", "audio", 0)],
|
||||
}
|
||||
return {"structuredContent": {"data": dados}}
|
||||
|
||||
def _simular_split_clip(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Divide, na faixa pedida, o clipe que cobrir estritamente o instante informado."""
|
||||
identificador_da_faixa = self._identificador_da_faixa(argumentos["track_index"], argumentos["track_type"])
|
||||
instante = float(argumentos["time_seconds"])
|
||||
faixa = self._faixas[identificador_da_faixa]
|
||||
for posicao, clipe in enumerate(faixa):
|
||||
if clipe.inicio_na_timeline < instante < clipe.fim_na_timeline:
|
||||
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]
|
||||
return {"split": True}
|
||||
raise ErroDeFerramentaMCP(f"Nenhum clipe da faixa {identificador_da_faixa!r} cobre estritamente {instante}s.")
|
||||
|
||||
def _simular_remove_from_timeline(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Remove o clipe pedido e, com ``ripple``, fecha o espaço deixado na mesma faixa."""
|
||||
identificador_do_clipe = argumentos["node_id"]
|
||||
for identificador_da_faixa, clipes in self._faixas.items():
|
||||
for posicao, clipe in enumerate(clipes):
|
||||
if clipe.identificador == identificador_do_clipe:
|
||||
duracao = clipe.fim_na_timeline - clipe.inicio_na_timeline
|
||||
del clipes[posicao]
|
||||
if argumentos.get("ripple"):
|
||||
for outro in clipes:
|
||||
if outro.inicio_na_timeline >= clipe.fim_na_timeline:
|
||||
outro.inicio_na_timeline -= duracao
|
||||
outro.fim_na_timeline -= duracao
|
||||
return {"removed": True}
|
||||
raise ErroDeFerramentaMCP(f"Clipe não encontrado para remoção: {identificador_do_clipe!r}.")
|
||||
|
||||
def _simular_set_clip_properties(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Aceita a alteração de propriedades sem simular geometria (não é usada pelos testes)."""
|
||||
del argumentos
|
||||
return {"updated": True}
|
||||
|
||||
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
|
||||
return {"added": True}
|
||||
|
||||
@staticmethod
|
||||
def _identificador_da_faixa(indice: int, tipo: str) -> str:
|
||||
"""Reconstrói o identificador de faixa (``video_0``/``audio_0``) a partir de índice e tipo."""
|
||||
return f"{tipo}_{indice}"
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Testes do orquestrador de aplicação de plano de edição (sem Premiere real).
|
||||
|
||||
Usa um ClienteMCP falso, em memória, que simula uma sequência de vídeo com
|
||||
os mesmos tempos "torcidos" do projeto real: a timeline lida pelo scanner
|
||||
não bate com o tempo do arquivo de origem (ver relatório da análise), então
|
||||
os testes aqui garantem que o aplicador sempre recalcula a posição a partir
|
||||
do clipe lido ao vivo, nunca de um tempo fixo.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from typing import Any
|
||||
|
||||
from engine.editor.aplicador_de_plano_de_edicao import AplicadorDePlanoDeEdicao
|
||||
from engine.editor.escrita import EscritaNoEditor
|
||||
from engine.editor.mapeamento import MapeadorDeTempoDeOrigemParaTimeline
|
||||
from engine.editor.modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
|
||||
from engine.integracoes.premiere.conversores import ConversorDeTimeline
|
||||
from engine.integracoes.premiere.leitura import AcessoAoEditor, AcessoATimeline
|
||||
from engine.testes.duplos_de_premiere import ClienteMCPFalso
|
||||
|
||||
|
||||
class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
|
||||
"""Cobre a ordem de aplicação e o recálculo de posições a cada ação."""
|
||||
|
||||
def setUp(self):
|
||||
self.cliente = ClienteMCPFalso()
|
||||
self.aplicador = AplicadorDePlanoDeEdicao(
|
||||
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(self.cliente)),
|
||||
conversor_de_timeline=ConversorDeTimeline(),
|
||||
escrita=EscritaNoEditor(self.cliente),
|
||||
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
|
||||
)
|
||||
|
||||
def test_corte_remove_os_clipes_do_intervalo_e_fecha_o_espaco(self):
|
||||
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
||||
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=0.0, fim=40.6, motivo="bastidor"),
|
||||
))
|
||||
resultado = self.aplicador.aplicar(plano)
|
||||
self.assertTrue(resultado.todas_bem_sucedidas)
|
||||
self.assertNotIn("000f4763_video", [c["node_id"] for c in self.cliente.chamadas_de_remocao])
|
||||
# o clipe único que cobre 2.3-40.6s de origem foi removido em ambas as faixas
|
||||
self.assertEqual(
|
||||
{c["node_id"] for c in self.cliente.chamadas_de_remocao},
|
||||
{"clipe_a_video", "clipe_a_audio"},
|
||||
)
|
||||
|
||||
def test_marcador_e_zoom_sao_aplicados_antes_dos_cortes(self):
|
||||
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"}),
|
||||
AcaoDeEdicao(TipoDeAcao.ZOOM, inicio=86.42, fim=91.5, motivo="ênfase", parametros={"scale": 1.15}),
|
||||
))
|
||||
resultado = self.aplicador.aplicar(plano)
|
||||
self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas])
|
||||
# marcador e zoom aconteceram antes do corte 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_da_remocao = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "remove_from_timeline")
|
||||
self.assertLess(indice_do_marcador, indice_da_remocao)
|
||||
self.assertLess(indice_do_zoom, indice_da_remocao)
|
||||
|
||||
def test_dois_cortes_sao_aplicados_do_fim_para_o_comeco(self):
|
||||
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
||||
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=0.0, fim=40.6, motivo="primeiro trecho"),
|
||||
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=86.2, fim=94.8, motivo="segundo trecho"),
|
||||
))
|
||||
resultado = self.aplicador.aplicar(plano)
|
||||
self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas])
|
||||
remocoes = [c["node_id"] for c in self.cliente.chamadas_de_remocao]
|
||||
# o corte com início maior (86.2s) precisa ser removido antes do de início 0.0s
|
||||
self.assertLess(remocoes.index("clipe_b_video"), remocoes.index("clipe_a_video"))
|
||||
|
||||
def test_corte_sem_clipe_sobreposto_falha_sem_interromper_o_resto(self):
|
||||
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
||||
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=1000.0, fim=1010.0, motivo="fora do vídeo"),
|
||||
AcaoDeEdicao(TipoDeAcao.MARCADOR, inicio=90.0, fim=90.5, motivo="ok", parametros={"content": "X"}),
|
||||
))
|
||||
resultado = self.aplicador.aplicar(plano)
|
||||
self.assertFalse(resultado.todas_bem_sucedidas)
|
||||
self.assertEqual(len(resultado.falhas), 1)
|
||||
self.assertIn("Nenhum clipe", resultado.falhas[0].detalhe)
|
||||
# a ação válida (marcador) ainda foi aplicada
|
||||
self.assertTrue(any(c[0] == "add_marker" for c in self.cliente.chamadas))
|
||||
|
||||
def test_texto_e_convertido_em_marcador_por_falta_de_suporte_do_premiere(self):
|
||||
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
||||
AcaoDeEdicao(TipoDeAcao.TEXTO, inicio=90.0, fim=91.0, motivo="reforçar termo", parametros={"content": "MASTOPEXIA"}),
|
||||
))
|
||||
resultado = self.aplicador.aplicar(plano)
|
||||
self.assertTrue(resultado.todas_bem_sucedidas)
|
||||
chamadas_de_marcador = [c for c in self.cliente.chamadas if c[0] == "add_marker"]
|
||||
self.assertEqual(len(chamadas_de_marcador), 1)
|
||||
self.assertIn("MASTOPEXIA", chamadas_de_marcador[0][1]["comments"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Testes do relatório dos J-SOM de análise lido do banco de análises."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
|
||||
from engine.gerar_relatorio_de_analises import (
|
||||
ErroDeBancoSemVideos,
|
||||
MontadorDeRelatorioDeAnalises,
|
||||
executar,
|
||||
)
|
||||
from engine.persistencia import (
|
||||
RepositorioDeAnalisesSQLite,
|
||||
RepositorioDeTimelineSQLite,
|
||||
)
|
||||
from engine.scanner.modelos import Cena, EvidenciaVisual, SegmentoDeTranscricao
|
||||
from engine.scanner.transcricao_da_timeline import TranscricaoDoClipe
|
||||
|
||||
|
||||
class TesteMontadorDeRelatorioDeAnalises(unittest.TestCase):
|
||||
"""Cenário comum: um vídeo, duas cenas (uma duplicada), falas e evidências."""
|
||||
|
||||
def _banco_de_exemplo(self, pasta: Path) -> Path:
|
||||
banco = pasta / "analises.db"
|
||||
timeline = Timeline(
|
||||
identificador="v1", nome="Entrevista", duracao=30.0, largura=1920, altura=1080,
|
||||
faixas=[Faixa("f1", "V1", "video", 0, [
|
||||
Clipe("c1", "A.mp4", IntervaloDeTempo(0.0, 10.0)),
|
||||
Clipe("c2", "A.mp4", IntervaloDeTempo(10.0, 30.0)),
|
||||
])],
|
||||
)
|
||||
RepositorioDeTimelineSQLite(banco).registrar_timeline(timeline)
|
||||
|
||||
repositorio = RepositorioDeAnalisesSQLite(banco)
|
||||
repositorio.registrar_transcricoes("v1", [
|
||||
TranscricaoDoClipe(
|
||||
identificador_do_clipe="c1", arquivo="A.mp4",
|
||||
inicio_na_timeline=0.0, fim_na_timeline=10.0,
|
||||
segmentos=[SegmentoDeTranscricao(0.0, 5.0, "Olá, tudo bem?", emocao="hap")],
|
||||
),
|
||||
])
|
||||
repositorio.registrar_evidencias_visuais("v1", "c1", [
|
||||
EvidenciaVisual(tipo="rosto", inicio=0.0, fim=5.0, valor={"qualidade": 0.9},
|
||||
confianca=0.9, provider="apple_vision"),
|
||||
EvidenciaVisual(tipo="rosto", inicio=5.0, fim=10.0, valor={"qualidade": 0.8},
|
||||
confianca=0.8, provider="apple_vision"),
|
||||
])
|
||||
# Cena c1 gravada duas vezes: o relatório não deve duplicar a entrada.
|
||||
# c2 fica sem cena registrada: deve aparecer com cena_registrada=False.
|
||||
repositorio.registrar_cenas("v1", [Cena(0.0, 10.0), Cena(0.0, 10.0)], clipe_id="c1")
|
||||
return banco
|
||||
|
||||
def test_relatorio_unindo_som_e_imagem_por_cena(self):
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
banco = self._banco_de_exemplo(Path(pasta))
|
||||
relatorio = MontadorDeRelatorioDeAnalises(banco).montar()
|
||||
|
||||
self.assertEqual(relatorio["video"]["nome"], "Entrevista")
|
||||
self.assertEqual(len(relatorio["cenas"]), 2) # um item por clipe
|
||||
cena_um = relatorio["cenas"][0]
|
||||
self.assertTrue(cena_um["cena_registrada"])
|
||||
self.assertEqual(cena_um["falas"][0]["texto"], "Olá, tudo bem?")
|
||||
self.assertEqual(cena_um["falas"][0]["emocao"], "hap")
|
||||
self.assertEqual(cena_um["resumo_de_evidencias"], {"rosto": 2})
|
||||
self.assertEqual(cena_um["evidencias_visuais"][0]["provider"], "apple_vision")
|
||||
cena_dois = relatorio["cenas"][1]
|
||||
self.assertFalse(cena_dois["cena_registrada"])
|
||||
self.assertEqual(cena_dois["falas"], [])
|
||||
|
||||
def test_executar_grava_json_e_devolve_relatorio(self):
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
banco = self._banco_de_exemplo(Path(pasta))
|
||||
saida = Path(pasta) / "relatorio.json"
|
||||
relatorio = executar(banco, saida, "v1")
|
||||
gravado = json.loads(saida.read_text(encoding="utf-8"))
|
||||
self.assertEqual(gravado, relatorio)
|
||||
self.assertEqual(gravado["video"]["video_id"], "v1")
|
||||
|
||||
def test_banco_sem_videos_gera_erro_especifico(self):
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
banco = Path(pasta) / "vazio.db"
|
||||
RepositorioDeTimelineSQLite(banco) # cria o banco com o esquema
|
||||
with self.assertRaises(ErroDeBancoSemVideos):
|
||||
MontadorDeRelatorioDeAnalises(banco).montar()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Testes da leitura e validação do plano de edição (engine.editor.leitura)."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from engine.editor.erros import ErroDePlanoInvalido
|
||||
from engine.editor.leitura import LeitorDePlanoDeEdicao
|
||||
from engine.editor.modelos import TipoDeAcao
|
||||
|
||||
PLANO_VALIDO = {
|
||||
"source": "0E6A8290.mp4",
|
||||
"actions": [
|
||||
{"kind": "cut", "start": 0.0, "end": 10.0, "reason": "bastidor"},
|
||||
{"kind": "zoom", "start": 12.0, "end": 15.0, "params": {"scale": 1.2}, "reason": "ênfase"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TesteLeitorDePlanoDeEdicao(unittest.TestCase):
|
||||
"""Cobre a validação do formato JSON do plano de edição."""
|
||||
|
||||
def setUp(self):
|
||||
self.leitor = LeitorDePlanoDeEdicao()
|
||||
|
||||
def test_le_plano_valido_de_dicionario(self):
|
||||
plano = self.leitor.ler_de_dicionario(PLANO_VALIDO)
|
||||
self.assertEqual(plano.arquivo_de_origem, "0E6A8290.mp4")
|
||||
self.assertEqual(len(plano.acoes), 2)
|
||||
self.assertEqual(plano.acoes[0].tipo, TipoDeAcao.CORTE)
|
||||
self.assertEqual(plano.acoes[1].parametros, {"scale": 1.2})
|
||||
|
||||
def test_le_plano_valido_de_arquivo(self):
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
caminho = Path(pasta) / "plano.json"
|
||||
caminho.write_text(
|
||||
'{"source": "a.mp4", "actions": [{"kind": "marker", "start": 1, "end": 2, "reason": "ok"}]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
plano = self.leitor.ler(caminho)
|
||||
self.assertEqual(plano.arquivo_de_origem, "a.mp4")
|
||||
|
||||
def test_arquivo_inexistente_levanta_erro_especifico(self):
|
||||
with self.assertRaises(ErroDePlanoInvalido):
|
||||
self.leitor.ler(Path("/caminho/que/nao/existe.json"))
|
||||
|
||||
def test_json_malformado_levanta_erro_especifico(self):
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
caminho = Path(pasta) / "plano.json"
|
||||
caminho.write_text("{isso nao é json", encoding="utf-8")
|
||||
with self.assertRaises(ErroDePlanoInvalido):
|
||||
self.leitor.ler(caminho)
|
||||
|
||||
def test_sem_source_levanta_erro(self):
|
||||
with self.assertRaises(ErroDePlanoInvalido):
|
||||
self.leitor.ler_de_dicionario({"actions": PLANO_VALIDO["actions"]})
|
||||
|
||||
def test_actions_vazia_levanta_erro(self):
|
||||
with self.assertRaises(ErroDePlanoInvalido):
|
||||
self.leitor.ler_de_dicionario({"source": "a.mp4", "actions": []})
|
||||
|
||||
def test_kind_desconhecido_levanta_erro(self):
|
||||
with self.assertRaises(ErroDePlanoInvalido):
|
||||
self.leitor.ler_de_dicionario({
|
||||
"source": "a.mp4",
|
||||
"actions": [{"kind": "voar", "start": 0, "end": 1, "reason": "x"}],
|
||||
})
|
||||
|
||||
def test_fim_menor_ou_igual_ao_inicio_levanta_erro(self):
|
||||
with self.assertRaises(ErroDePlanoInvalido):
|
||||
self.leitor.ler_de_dicionario({
|
||||
"source": "a.mp4",
|
||||
"actions": [{"kind": "cut", "start": 5, "end": 5, "reason": "x"}],
|
||||
})
|
||||
|
||||
def test_reason_vazio_levanta_erro(self):
|
||||
with self.assertRaises(ErroDePlanoInvalido):
|
||||
self.leitor.ler_de_dicionario({
|
||||
"source": "a.mp4",
|
||||
"actions": [{"kind": "cut", "start": 0, "end": 1, "reason": " "}],
|
||||
})
|
||||
|
||||
def test_campo_obrigatorio_faltando_levanta_erro(self):
|
||||
with self.assertRaises(ErroDePlanoInvalido):
|
||||
self.leitor.ler_de_dicionario({
|
||||
"source": "a.mp4",
|
||||
"actions": [{"kind": "cut", "start": 0, "reason": "x"}],
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Testes do mapeamento de tempos de origem para a timeline (engine.editor.mapeamento)."""
|
||||
|
||||
import unittest
|
||||
|
||||
from engine.dominio import Clipe, Faixa, IntervaloDeTempo
|
||||
from engine.editor.erros import ErroDeMapeamentoDeTempo
|
||||
from engine.editor.mapeamento import MapeadorDeTempoDeOrigemParaTimeline
|
||||
|
||||
|
||||
def _clipe(identificador, tl_inicio, tl_fim, origem_inicio, origem_fim, nome="0E6A8290.MP4"):
|
||||
"""Monta um Clipe de teste com intervalo na timeline e na origem."""
|
||||
return Clipe(
|
||||
identificador=identificador,
|
||||
nome=nome,
|
||||
intervalo_na_timeline=IntervaloDeTempo(tl_inicio, tl_fim),
|
||||
intervalo_na_origem=IntervaloDeTempo(origem_inicio, origem_fim),
|
||||
)
|
||||
|
||||
|
||||
class TesteMapeadorDeTempoDeOrigemParaTimeline(unittest.TestCase):
|
||||
"""Cobre a tradução de tempos de origem, reproduzindo o caso real do projeto:
|
||||
a timeline reindexada pelo detector de cena não bate com o tempo do arquivo."""
|
||||
|
||||
def setUp(self):
|
||||
self.mapeador = MapeadorDeTempoDeOrigemParaTimeline()
|
||||
# Reproduz o caso real: clip 000f4775 está em 86.2-114.0s de origem,
|
||||
# mas em 58.1-86.0s na timeline reindexada pelo scanner.
|
||||
self.faixa = Faixa("video_0", "Vídeo 1", "video", 0, clipes=[
|
||||
_clipe("000f4763", 0.0, 38.3, 2.3, 40.6),
|
||||
_clipe("000f4775", 58.1, 86.0, 86.2, 114.0),
|
||||
_clipe("outro_arquivo", 86.0, 90.0, 0.0, 4.0, nome="outro.mp4"),
|
||||
])
|
||||
|
||||
def test_clipes_do_arquivo_filtra_por_nome_ignorando_caixa(self):
|
||||
clipes = self.mapeador.clipes_do_arquivo(self.faixa, "0e6a8290.mp4")
|
||||
self.assertEqual([c.identificador for c in clipes], ["000f4763", "000f4775"])
|
||||
|
||||
def test_localizar_clipe_no_instante_encontra_o_clipe_certo(self):
|
||||
clipes = self.mapeador.clipes_do_arquivo(self.faixa, "0E6A8290.MP4")
|
||||
clipe = self.mapeador.localizar_clipe_no_instante(clipes, 90.0)
|
||||
self.assertEqual(clipe.identificador, "000f4775")
|
||||
|
||||
def test_localizar_clipe_no_instante_fora_de_qualquer_clipe_devolve_none(self):
|
||||
clipes = self.mapeador.clipes_do_arquivo(self.faixa, "0E6A8290.MP4")
|
||||
self.assertIsNone(self.mapeador.localizar_clipe_no_instante(clipes, 500.0))
|
||||
|
||||
def test_instante_na_timeline_traduz_usando_o_deslocamento_do_clipe(self):
|
||||
clipe = _clipe("000f4775", 58.1, 86.0, 86.2, 114.0)
|
||||
# 86.42s de origem (início real da fala) cai 0.22s depois do início
|
||||
# do clipe (86.2s) e deve mapear para 58.1 + 0.22 = 58.32s na timeline.
|
||||
self.assertAlmostEqual(self.mapeador.instante_na_timeline(clipe, 86.42), 58.32, places=6)
|
||||
|
||||
def test_instante_na_timeline_sem_intervalo_de_origem_levanta_erro(self):
|
||||
clipe = Clipe("x", "n", IntervaloDeTempo(0, 1), intervalo_na_origem=None)
|
||||
with self.assertRaises(ErroDeMapeamentoDeTempo):
|
||||
self.mapeador.instante_na_timeline(clipe, 0.5)
|
||||
|
||||
def test_instante_na_timeline_por_arquivo_encontra_e_traduz(self):
|
||||
timeline = None # não é usado pelo atalho; mantido para deixar a chamada explícita
|
||||
instante = self.mapeador.instante_na_timeline_por_arquivo(timeline, self.faixa, "0E6A8290.MP4", 90.0)
|
||||
self.assertAlmostEqual(instante, 58.1 + (90.0 - 86.2), places=6)
|
||||
|
||||
def test_instante_na_timeline_por_arquivo_sem_clipe_levanta_erro(self):
|
||||
with self.assertRaises(ErroDeMapeamentoDeTempo):
|
||||
self.mapeador.instante_na_timeline_por_arquivo(None, self.faixa, "0E6A8290.MP4", 500.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user