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:
João Henrique
2026-09-08 19:35:30 -04:00
parent 400c3c93dd
commit 4308c72441
24 changed files with 1591 additions and 8 deletions
+182
View File
@@ -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()