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,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.")
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user