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
+35
View File
@@ -0,0 +1,35 @@
"""Aplicação de planos de edição (cortes, zooms, marcadores) na timeline do Premiere.
Lê o JSON de ações produzido pelas skills de seleção de trechos
(``kind``/``start``/``end``/``params``/``reason``), traduz os tempos de
origem para a sequência ativa do Premiere e aplica as mutações por meio do
MCP existente em ``engine.integracoes.premiere``.
Não decide o que cortar — isso já vem pronto no plano. A responsabilidade
deste módulo é só: ler o plano, achar a posição atual de cada trecho na
timeline e executar a mutação correta, relatando o que deu certo e o que
não deu.
"""
from .aplicador_de_plano_de_edicao import AplicadorDePlanoDeEdicao, ResultadoDaAcao, ResultadoDaAplicacao
from .erros import ErroDeEdicao, ErroDeEscritaNoEditor, ErroDeMapeamentoDeTempo, ErroDePlanoInvalido
from .escrita import EscritaNoEditor
from .leitura import LeitorDePlanoDeEdicao
from .mapeamento import MapeadorDeTempoDeOrigemParaTimeline
from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
__all__ = [
"AcaoDeEdicao",
"AplicadorDePlanoDeEdicao",
"EscritaNoEditor",
"ErroDeEdicao",
"ErroDeEscritaNoEditor",
"ErroDeMapeamentoDeTempo",
"ErroDePlanoInvalido",
"LeitorDePlanoDeEdicao",
"MapeadorDeTempoDeOrigemParaTimeline",
"PlanoDeEdicao",
"ResultadoDaAcao",
"ResultadoDaAplicacao",
"TipoDeAcao",
]
@@ -0,0 +1,223 @@
"""Orquestração da aplicação de um plano de edição na sequência ativa do Premiere.
Liga o plano já validado (:mod:`.leitura`) à tradução de tempos
(:mod:`.mapeamento`) e à escrita no editor (:mod:`.escrita`), na ordem que
preserva a validade das posições calculadas — ver docstring de
:class:`AplicadorDePlanoDeEdicao`.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from ..dominio import Clipe, Faixa, Timeline
from ..integracoes.premiere.conversores import ConversorDeTimeline
from ..integracoes.premiere.leitura import AcessoAoEditor
from .erros import ErroDeEdicao, ErroDeMapeamentoDeTempo
from .escrita.escrita_no_editor import EscritaNoEditor
from .mapeamento.mapeador_de_tempo import MapeadorDeTempoDeOrigemParaTimeline
from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
_TOLERANCIA_DE_BORDA_EM_SEGUNDOS = 0.05
@dataclass(frozen=True)
class ResultadoDaAcao:
"""O que aconteceu ao tentar aplicar uma :class:`AcaoDeEdicao`."""
acao: AcaoDeEdicao
sucesso: bool
detalhe: str
@dataclass(frozen=True)
class ResultadoDaAplicacao:
"""O resultado, ação por ação, de aplicar um :class:`PlanoDeEdicao` inteiro."""
resultados: tuple[ResultadoDaAcao, ...] = field(default_factory=tuple)
@property
def todas_bem_sucedidas(self) -> bool:
"""``True`` só se nenhuma ação falhou."""
return all(resultado.sucesso for resultado in self.resultados)
@property
def falhas(self) -> tuple[ResultadoDaAcao, ...]:
"""Só as ações que não foram aplicadas."""
return tuple(resultado for resultado in self.resultados if not resultado.sucesso)
class AplicadorDePlanoDeEdicao:
"""Aplica um :class:`PlanoDeEdicao` inteiro na sequência ativa do Premiere.
Ordem de aplicação, para preservar a validade das posições calculadas:
1. Marcadores e zooms primeiro — não mudam a duração da timeline, então
a posição calculada para cada um continua válida até o fim.
2. Cortes por último, do fim para o começo (maior início primeiro) —
cada corte fecha o espaço que ocupava (ripple), deslocando para a
esquerda tudo que vem depois dele. Processar do fim para o começo
garante que a posição de um corte ainda não aplicado nunca é afetada
pelos cortes já aplicados.
A cada ação a timeline ativa é lida de novo no Premiere — nunca se
reaproveita uma leitura anterior — porque a ação anterior pode ter
mudado a posição dos clipes. Uma ação que falha é registrada no
resultado e não interrompe as demais; quem chama decide o que fazer com
as falhas.
"""
def __init__(
self,
acesso_ao_editor: AcessoAoEditor,
conversor_de_timeline: ConversorDeTimeline,
escrita: EscritaNoEditor,
mapeador: MapeadorDeTempoDeOrigemParaTimeline,
) -> None:
self.acesso_ao_editor = acesso_ao_editor
self.conversor_de_timeline = conversor_de_timeline
self.escrita = escrita
self.mapeador = mapeador
def aplicar(self, plano: PlanoDeEdicao) -> ResultadoDaAplicacao:
"""Aplica todas as ações de ``plano`` e devolve o resultado de cada uma."""
pontuais = [acao for acao in plano.acoes if acao.tipo is not TipoDeAcao.CORTE]
cortes = sorted(plano.acoes_do_tipo(TipoDeAcao.CORTE), key=lambda acao: acao.inicio, reverse=True)
resultados = [self._aplicar_acao_pontual(acao, plano.arquivo_de_origem) for acao in pontuais]
resultados += [self._aplicar_corte(acao, plano.arquivo_de_origem) for acao in cortes]
return ResultadoDaAplicacao(tuple(resultados))
def _timeline_atual(self) -> Timeline:
"""Lê a sequência ativa do Premiere agora, sem cache."""
return self.conversor_de_timeline.converter(self.acesso_ao_editor.obter_timeline_ativa())
def _aplicar_acao_pontual(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> ResultadoDaAcao:
"""Aplica um marcador ou zoom, que não precisam dividir nem remover clipes."""
try:
if acao.tipo is TipoDeAcao.MARCADOR:
self._aplicar_marcador(acao, arquivo_de_origem)
return ResultadoDaAcao(acao, True, "marcador criado")
if acao.tipo is TipoDeAcao.ZOOM:
quantidade = self._aplicar_zoom(acao, arquivo_de_origem)
return ResultadoDaAcao(acao, True, f"zoom aplicado em {quantidade} clipe(s)")
if acao.tipo is TipoDeAcao.TEXTO:
self._aplicar_texto_como_marcador(acao, arquivo_de_origem)
return ResultadoDaAcao(
acao, True,
"texto na tela não é suportado pela API de scripting do Premiere; "
"registrado como marcador para o editor aplicar manualmente"
)
raise ErroDeEdicao(f"Tipo de ação sem tratamento: {acao.tipo!r}.")
except ErroDeEdicao as erro:
return ResultadoDaAcao(acao, False, str(erro))
def _aplicar_marcador(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> None:
instante = self._instante_na_timeline(acao.inicio, arquivo_de_origem)
nome = str(acao.parametros.get("content", "MARCADOR"))
self.escrita.adicionar_marcador(instante, nome, acao.motivo)
def _aplicar_texto_como_marcador(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> None:
instante = self._instante_na_timeline(acao.inicio, arquivo_de_origem)
conteudo = str(acao.parametros.get("content", ""))
self.escrita.adicionar_marcador(instante, "TEXTO (aplicar manualmente)", f"{conteudo} — {acao.motivo}")
def _aplicar_zoom(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> int:
fator_de_escala = float(acao.parametros.get("scale", 1.3))
faixa_de_video = self._faixa_de_video(self._timeline_atual())
clipes = self.mapeador.clipes_do_arquivo(faixa_de_video, arquivo_de_origem)
alvos = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
if not alvos:
raise ErroDeMapeamentoDeTempo(
f"Nenhum clipe de vídeo de {arquivo_de_origem!r} sobrepõe {acao.inicio}-{acao.fim}s de origem para o zoom."
)
for clipe in alvos:
self.escrita.aplicar_zoom(clipe.identificador, fator_de_escala)
return len(alvos)
def _aplicar_corte(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> ResultadoDaAcao:
"""Remove, em cada faixa afetada, todos os clipes contidos no intervalo do corte."""
try:
trechos_removidos = 0
for faixa in self._timeline_atual().faixas:
trechos_removidos += self._cortar_faixa(faixa, acao, arquivo_de_origem)
if trechos_removidos == 0:
raise ErroDeMapeamentoDeTempo(
f"Nenhum clipe de {arquivo_de_origem!r} sobrepõe o intervalo {acao.inicio}-{acao.fim}s de origem."
)
return ResultadoDaAcao(acao, True, f"{trechos_removidos} trecho(s) removido(s)")
except ErroDeEdicao as erro:
return ResultadoDaAcao(acao, False, str(erro))
def _cortar_faixa(self, faixa: Faixa, acao: AcaoDeEdicao, arquivo_de_origem: str) -> int:
"""Corta o trecho de ``acao`` em uma única faixa e devolve quantos clipes foram removidos."""
clipes = self.mapeador.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 0
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)
self.escrita.dividir_clipe_se_necessario(inicio_na_timeline, faixa.indice, faixa.tipo)
self.escrita.dividir_clipe_se_necessario(fim_na_timeline, faixa.indice, faixa.tipo)
clipes_apos_a_divisao = self.mapeador.clipes_do_arquivo(
next(f for f in self._timeline_atual().faixas if f.identificador == faixa.identificador),
arquivo_de_origem,
)
alvo = [
clipe for clipe in clipes_apos_a_divisao
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
]
duracao_removida = sum(clipe.intervalo_na_timeline.duracao for clipe in alvo)
duracao_esperada = fim_na_timeline - inicio_na_timeline
if abs(duracao_removida - duracao_esperada) > _TOLERANCIA_DE_BORDA_EM_SEGUNDOS:
raise ErroDeMapeamentoDeTempo(
f"Na faixa {faixa.identificador!r}, os cortes em {inicio_na_timeline:.3f}s e "
f"{fim_na_timeline:.3f}s não isolaram o trecho esperado (esperado {duracao_esperada:.3f}s, "
f"encontrado {duracao_removida:.3f}s cobertos por clipe); nada foi removido para evitar "
"apagar o trecho errado."
)
for clipe in alvo:
self.escrita.remover_trecho(clipe.identificador)
return len(alvo)
def _instante_na_timeline(self, instante_de_origem: float, arquivo_de_origem: str) -> float:
"""Traduz um instante de origem usando a primeira faixa de vídeo do arquivo."""
faixa_de_video = self._faixa_de_video(self._timeline_atual())
clipes = self.mapeador.clipes_do_arquivo(faixa_de_video, arquivo_de_origem)
clipe = self.mapeador.localizar_clipe_no_instante(clipes, instante_de_origem)
if clipe is None:
raise ErroDeMapeamentoDeTempo(
f"Nenhum clipe de vídeo de {arquivo_de_origem!r} cobre o instante {instante_de_origem}s de origem."
)
return self.mapeador.instante_na_timeline(clipe, instante_de_origem)
def _borda_de_entrada(self, clipe: Clipe, inicio_de_origem: float) -> float:
"""Posição de entrada do corte na timeline: a borda do clipe se o corte começa antes dele."""
if inicio_de_origem <= clipe.intervalo_na_origem.inicio:
return clipe.intervalo_na_timeline.inicio
return self.mapeador.instante_na_timeline(clipe, inicio_de_origem)
def _borda_de_saida(self, clipe: Clipe, fim_de_origem: float) -> float:
"""Posição de saída do corte na timeline: a borda do clipe se o corte termina depois dele."""
if fim_de_origem >= clipe.intervalo_na_origem.fim:
return clipe.intervalo_na_timeline.fim
return self.mapeador.instante_na_timeline(clipe, fim_de_origem)
@staticmethod
def _sobrepoe(clipe: Clipe, inicio: float, fim: float) -> bool:
"""``True`` se o intervalo de origem do clipe tiver interseção com [inicio, fim)."""
intervalo = clipe.intervalo_na_origem
return intervalo is not None and intervalo.inicio < fim and inicio < intervalo.fim
@staticmethod
def _faixa_de_video(timeline: Timeline) -> Faixa:
"""A primeira faixa de vídeo da timeline; é onde marcadores e zooms são resolvidos."""
for faixa in timeline.faixas:
if faixa.tipo == "video":
return faixa
raise ErroDeMapeamentoDeTempo("A sequência ativa não tem nenhuma faixa de vídeo.")
+22
View File
@@ -0,0 +1,22 @@
"""Erros do módulo de aplicação de planos de edição.
Cada classe cobre uma fase da aplicação (leitura do plano, mapeamento de
tempo, escrita no editor), para que quem chama saiba exatamente em qual
etapa a operação falhou sem precisar inspecionar mensagens de texto.
"""
class ErroDeEdicao(Exception):
"""Erro geral da aplicação de um plano de edição."""
class ErroDePlanoInvalido(ErroDeEdicao):
"""O arquivo de ações não segue o formato esperado (kind/start/end/params/reason)."""
class ErroDeMapeamentoDeTempo(ErroDeEdicao):
"""Não foi possível traduzir um tempo do arquivo de origem para a timeline ativa."""
class ErroDeEscritaNoEditor(ErroDeEdicao):
"""O Premiere recusou ou não confirmou uma mutação solicitada na timeline."""
+5
View File
@@ -0,0 +1,5 @@
"""Escrita de mutações na sequência ativa do Premiere."""
from .escrita_no_editor import EscritaNoEditor
__all__ = ["EscritaNoEditor"]
@@ -0,0 +1,91 @@
"""Escrita de mutações na sequência ativa do Premiere via MCP.
Isola o nome das ferramentas MCP e o formato de argumentos externos
(``split_clip``, ``remove_from_timeline``, ``set_clip_properties``,
``add_marker``) do resto do domínio. Quem chama esta classe fala só em
segundos de timeline, identificador de clipe e fator de escala — nunca em
nomes de ferramenta ou ticks do Premiere.
"""
from __future__ import annotations
from ...integracoes.premiere.cliente_mcp import ClienteMCP
from ...integracoes.premiere.erros_mcp import ErroDeFerramentaMCP
from ..erros import ErroDeEscritaNoEditor
_CORES_POR_NOME = {
"revisar": 3, # laranja: chama atenção sem parecer um erro (vermelho)
"informar": 5, # branco: marcador neutro
}
class EscritaNoEditor:
"""Aplica, uma de cada vez, as mutações que o aplicador de plano decide fazer."""
def __init__(self, cliente_mcp: ClienteMCP) -> None:
self.cliente_mcp = cliente_mcp
def dividir_clipe_se_necessario(self, instante_da_timeline: float, indice_da_faixa: int, tipo_da_faixa: str) -> bool:
"""Corta, na timeline, o clipe que cobrir ``instante_da_timeline``.
Devolve ``True`` se um corte novo foi criado e ``False`` quando o
instante já era uma borda de clipe (o Premiere recusa dividir onde
não há clipe atravessando o ponto) — esse segundo caso não é um erro,
é o resultado esperado quando o corte pedido coincide com um corte já
existente da análise de cenas.
"""
try:
self.cliente_mcp.chamar(
"split_clip",
{"time_seconds": instante_da_timeline, "track_index": indice_da_faixa, "track_type": tipo_da_faixa},
)
return True
except ErroDeFerramentaMCP:
return False
def remover_trecho(self, identificador_do_clipe: str) -> None:
"""Remove o clipe da timeline, fechando o espaço deixado (ripple).
Levanta :class:`ErroDeEscritaNoEditor` se o Premiere recusar a
remoção.
"""
try:
self.cliente_mcp.chamar("remove_from_timeline", {"node_id": identificador_do_clipe, "ripple": True})
except ErroDeFerramentaMCP as erro:
raise ErroDeEscritaNoEditor(f"Não foi possível remover o clipe {identificador_do_clipe!r}: {erro}") from erro
def aplicar_zoom(self, identificador_do_clipe: str, fator_de_escala: float) -> None:
"""Aplica um punch-in no clipe, escalando-o por ``fator_de_escala`` (1.0 = tamanho original).
Levanta :class:`ErroDeEscritaNoEditor` se o Premiere recusar a
alteração.
"""
try:
self.cliente_mcp.chamar(
"set_clip_properties", {"node_id": identificador_do_clipe, "scale": fator_de_escala * 100}
)
except ErroDeFerramentaMCP as erro:
raise ErroDeEscritaNoEditor(
f"Não foi possível aplicar zoom no clipe {identificador_do_clipe!r}: {erro}"
) from erro
def adicionar_marcador(self, instante_da_timeline: float, nome: str, comentario: str) -> None:
"""Cria um marcador de sequência em ``instante_da_timeline``.
Levanta :class:`ErroDeEscritaNoEditor` se o Premiere recusar a
criação do marcador.
"""
try:
self.cliente_mcp.chamar(
"add_marker",
{
"time_seconds": instante_da_timeline,
"name": nome,
"comments": comentario,
"color": _CORES_POR_NOME["revisar"],
},
)
except ErroDeFerramentaMCP as erro:
raise ErroDeEscritaNoEditor(
f"Não foi possível adicionar o marcador em {instante_da_timeline}s: {erro}"
) from erro
+5
View File
@@ -0,0 +1,5 @@
"""Leitura e validação de planos de edição."""
from .leitor_de_plano import LeitorDePlanoDeEdicao
__all__ = ["LeitorDePlanoDeEdicao"]
@@ -0,0 +1,88 @@
"""Leitura e validação do arquivo JSON de plano de edição.
Isola o formato externo (contrato ``kind``/``start``/``end``/``params``/
``reason`` das skills de seleção de trechos) do modelo interno em
:mod:`engine.editor.modelos`. Nenhuma outra parte do sistema deve interpretar
esse JSON diretamente.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from ..erros import ErroDePlanoInvalido
from ..modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
_CAMPOS_OBRIGATORIOS_DA_ACAO = ("kind", "start", "end", "reason")
class LeitorDePlanoDeEdicao:
"""Lê um arquivo de ações e devolve um :class:`PlanoDeEdicao` validado.
Não aplica nenhuma ação nem acessa o Premiere: apenas valida a estrutura
do arquivo e converte para os tipos internos do domínio.
"""
def ler(self, caminho: Path) -> PlanoDeEdicao:
"""Lê o arquivo em ``caminho`` e devolve o plano validado.
Levanta :class:`ErroDePlanoInvalido` se o arquivo não existir, não for
JSON válido ou não seguir o formato esperado.
"""
try:
texto = caminho.read_text(encoding="utf-8")
except OSError as erro:
raise ErroDePlanoInvalido(f"Não foi possível ler o plano de edição em {caminho}: {erro}") from erro
try:
dados = json.loads(texto)
except json.JSONDecodeError as erro:
raise ErroDePlanoInvalido(f"O plano de edição em {caminho} não é um JSON válido: {erro}") from erro
return self.ler_de_dicionario(dados)
def ler_de_dicionario(self, dados: Any) -> PlanoDeEdicao:
"""Converte um dicionário já carregado (ex.: de teste) em um plano validado."""
if not isinstance(dados, dict):
raise ErroDePlanoInvalido("O plano de edição precisa ser um objeto JSON no nível raiz.")
arquivo_de_origem = dados.get("source")
if not isinstance(arquivo_de_origem, str) or not arquivo_de_origem.strip():
raise ErroDePlanoInvalido("O plano de edição precisa de um campo 'source' com o nome do arquivo.")
acoes_brutas = dados.get("actions")
if not isinstance(acoes_brutas, list) or not acoes_brutas:
raise ErroDePlanoInvalido("O plano de edição precisa de uma lista não vazia em 'actions'.")
acoes = tuple(self._converter_acao(bruta, indice) for indice, bruta in enumerate(acoes_brutas))
try:
return PlanoDeEdicao(arquivo_de_origem=arquivo_de_origem, acoes=acoes)
except ValueError as erro:
raise ErroDePlanoInvalido(str(erro)) from erro
def _converter_acao(self, bruta: Any, indice: int) -> AcaoDeEdicao:
"""Converte um item de ``actions`` para :class:`AcaoDeEdicao`, com erros que apontam o índice."""
if not isinstance(bruta, dict):
raise ErroDePlanoInvalido(f"actions[{indice}] precisa ser um objeto JSON.")
faltando = [campo for campo in _CAMPOS_OBRIGATORIOS_DA_ACAO if campo not in bruta]
if faltando:
raise ErroDePlanoInvalido(f"actions[{indice}] está sem os campos obrigatórios: {', '.join(faltando)}.")
try:
tipo = TipoDeAcao(bruta["kind"])
except ValueError as erro:
tipos_validos = ", ".join(tipo.value for tipo in TipoDeAcao)
raise ErroDePlanoInvalido(
f"actions[{indice}].kind = {bruta['kind']!r} é desconhecido. Válidos: {tipos_validos}."
) from erro
try:
inicio = float(bruta["start"])
fim = float(bruta["end"])
except (TypeError, ValueError) as erro:
raise ErroDePlanoInvalido(f"actions[{indice}].start/end precisam ser numéricos.") from erro
parametros = bruta.get("params", {})
if not isinstance(parametros, dict):
raise ErroDePlanoInvalido(f"actions[{indice}].params precisa ser um objeto JSON quando presente.")
motivo = bruta["reason"]
if not isinstance(motivo, str):
raise ErroDePlanoInvalido(f"actions[{indice}].reason precisa ser texto.")
try:
return AcaoDeEdicao(tipo=tipo, inicio=inicio, fim=fim, motivo=motivo, parametros=dict(parametros))
except ValueError as erro:
raise ErroDePlanoInvalido(f"actions[{indice}]: {erro}") from erro
@@ -0,0 +1,5 @@
"""Tradução de tempos de origem para posições na timeline ativa."""
from .mapeador_de_tempo import MapeadorDeTempoDeOrigemParaTimeline
__all__ = ["MapeadorDeTempoDeOrigemParaTimeline"]
@@ -0,0 +1,81 @@
"""Tradução de tempos do arquivo de origem para posições na timeline ativa.
O plano de edição descreve tudo em segundos do arquivo de origem (regra do
formato: nunca compensar para "depois do corte"). Só a sequência do Premiere,
lida no momento da aplicação, sabe onde cada trecho de origem está
atualmente na timeline — por isso este mapeador nunca usa tempos gravados
durante a análise (ex.: ``inicio_na_timeline`` do banco de análises), sempre
o clipe lido ao vivo via :class:`~engine.dominio.Clipe`.
"""
from __future__ import annotations
from ...dominio import Clipe, Faixa, Timeline
from ..erros import ErroDeMapeamentoDeTempo
_TOLERANCIA_EM_SEGUNDOS = 0.05
class MapeadorDeTempoDeOrigemParaTimeline:
"""Localiza clipes pelo tempo de origem e converte instantes para a timeline."""
def clipes_do_arquivo(self, faixa: Faixa, arquivo_de_origem: str) -> list[Clipe]:
"""Clipes da faixa que pertencem a ``arquivo_de_origem``, ordenados pelo início na origem.
A comparação ignora maiúsculas/minúsculas porque o Premiere e o plano
de edição podem registrar a extensão do arquivo em casos diferentes
(``.MP4`` vs. ``.mp4``).
"""
alvo = arquivo_de_origem.strip().lower()
candidatos = [
clipe for clipe in faixa.clipes
if clipe.intervalo_na_origem is not None and (clipe.nome or "").strip().lower() == alvo
]
return sorted(candidatos, key=lambda clipe: clipe.intervalo_na_origem.inicio)
def localizar_clipe_no_instante(self, clipes: list[Clipe], instante_de_origem: float) -> Clipe | None:
"""Devolve o clipe cujo intervalo de origem contém ``instante_de_origem``, ou ``None``.
Tolera pequenas diferenças de arredondamento (meio quadro) nas bordas
do intervalo.
"""
for clipe in clipes:
intervalo = clipe.intervalo_na_origem
if intervalo is None:
continue
if intervalo.inicio - _TOLERANCIA_EM_SEGUNDOS <= instante_de_origem <= intervalo.fim + _TOLERANCIA_EM_SEGUNDOS:
return clipe
return None
def instante_na_timeline(self, clipe: Clipe, instante_de_origem: float) -> float:
"""Converte um instante de origem, dentro do intervalo de origem de ``clipe``, para a timeline.
Levanta :class:`ErroDeMapeamentoDeTempo` se ``clipe`` não tiver
intervalo de origem conhecido — não há como calcular o deslocamento
sem ele.
"""
if clipe.intervalo_na_origem is None:
raise ErroDeMapeamentoDeTempo(
f"O clipe {clipe.identificador!r} não tem intervalo de origem conhecido; "
"não é possível traduzir o tempo para a timeline."
)
deslocamento = instante_de_origem - clipe.intervalo_na_origem.inicio
return clipe.intervalo_na_timeline.inicio + deslocamento
def instante_na_timeline_por_arquivo(
self, timeline: Timeline, faixa: Faixa, arquivo_de_origem: str, instante_de_origem: float
) -> float:
"""Atalho que localiza o clipe do instante em ``faixa`` e já devolve a posição na timeline.
Levanta :class:`ErroDeMapeamentoDeTempo` se nenhum clipe de
``arquivo_de_origem`` cobrir ``instante_de_origem`` nessa faixa.
"""
del timeline # mantido na assinatura para leitura clara no local de chamada
clipes = self.clipes_do_arquivo(faixa, arquivo_de_origem)
clipe = self.localizar_clipe_no_instante(clipes, instante_de_origem)
if clipe is None:
raise ErroDeMapeamentoDeTempo(
f"Nenhum clipe de {arquivo_de_origem!r} na faixa {faixa.identificador!r} "
f"cobre o instante {instante_de_origem}s de origem."
)
return self.instante_na_timeline(clipe, instante_de_origem)
+69
View File
@@ -0,0 +1,69 @@
"""Modelo de domínio do plano de edição.
Representa, em memória, o contrato JSON produzido pelas skills de seleção de
trechos (``kind``/``start``/``end``/``params``/``reason``) já validado e
convertido para tipos internos. Nenhuma classe aqui conhece o Premiere nem o
formato de arquivo — isso é responsabilidade de ``leitura`` e ``escrita``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class TipoDeAcao(str, Enum):
"""Os quatro tipos de ação que o plano de edição pode descrever."""
CORTE = "cut"
ZOOM = "zoom"
TEXTO = "text"
MARCADOR = "marker"
@dataclass(frozen=True)
class AcaoDeEdicao:
"""Uma decisão de edição sobre um intervalo do arquivo de origem.
``inicio`` e ``fim`` são sempre segundos na mídia original — nunca na
timeline já cortada. ``motivo`` é obrigatório: é o texto que o editor lê
para decidir se aceita a decisão.
"""
tipo: TipoDeAcao
inicio: float
fim: float
motivo: str
parametros: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.inicio < 0:
raise ValueError(f"Ação inválida: início {self.inicio}s não pode ser negativo.")
if self.fim <= self.inicio:
raise ValueError(
f"Ação inválida: fim ({self.fim}s) deve ser maior que início ({self.inicio}s)."
)
if not self.motivo.strip():
raise ValueError("Ação inválida: motivo não pode ser vazio.")
@property
def duracao(self) -> float:
"""Duração da ação em segundos, sempre positiva."""
return self.fim - self.inicio
@dataclass(frozen=True)
class PlanoDeEdicao:
"""Conjunto ordenado de ações a aplicar sobre um arquivo de origem."""
arquivo_de_origem: str
acoes: tuple[AcaoDeEdicao, ...]
def __post_init__(self) -> None:
if not self.arquivo_de_origem.strip():
raise ValueError("Plano de edição inválido: arquivo de origem não informado.")
def acoes_do_tipo(self, tipo: TipoDeAcao) -> tuple[AcaoDeEdicao, ...]:
"""Devolve, na ordem original, só as ações do tipo informado."""
return tuple(acao for acao in self.acoes if acao.tipo is tipo)