|
|
|
@@ -19,6 +19,8 @@ from .mapeamento.mapeador_de_tempo import MapeadorDeTempoDeOrigemParaTimeline
|
|
|
|
|
from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
|
|
|
|
|
|
|
|
|
|
_TOLERANCIA_DE_BORDA_EM_SEGUNDOS = 0.05
|
|
|
|
|
_TOLERANCIA_DE_DURACAO_EM_SEGUNDOS = 0.3
|
|
|
|
|
_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS = 3.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
@@ -50,21 +52,44 @@ class ResultadoDaAplicacao:
|
|
|
|
|
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:
|
|
|
|
|
Ordem de aplicação:
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
1. Cortes primeiro, 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.
|
|
|
|
|
2. Marcadores e zooms por último, depois que a timeline já está no
|
|
|
|
|
formato final. Zoom (``set_clip_properties``) muda uma propriedade do
|
|
|
|
|
*clipe inteiro*, não de um trecho dele — se dois zooms caíssem em
|
|
|
|
|
instantes diferentes do mesmo clipe ainda não dividido pelos cortes,
|
|
|
|
|
o segundo sobrescreveria o primeiro no clipe inteiro, e a divisão
|
|
|
|
|
posterior faria os dois pedaços herdarem só o último valor. Cortar
|
|
|
|
|
primeiro garante que cada zoom já encontra um clipe próprio.
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
mudado a posição ou a divisã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.
|
|
|
|
|
|
|
|
|
|
Diferença deliberada do outro aplicador de plano do projeto
|
|
|
|
|
(``scripts/apply-editorial-actions.mjs``, chamado pelo painel CEP):
|
|
|
|
|
aquele script separa "Fase A" (todos os splits do plano inteiro,
|
|
|
|
|
coordenadas originais) de "Fase B" (todas as remoções, feitas depois),
|
|
|
|
|
porque só sabe fazer aritmética pura sobre os cortes pedidos — não relê
|
|
|
|
|
o estado do Premiere no meio do caminho. Esta classe faz split e
|
|
|
|
|
remoção corte a corte, relendo a faixa a cada passo
|
|
|
|
|
(:meth:`_clipes_do_arquivo` exige ``intervalo_na_origem`` em todo
|
|
|
|
|
clipe), porque isso permite achar cada clipe pelo tempo de *origem*
|
|
|
|
|
real reportado pelo Premiere em vez de confiar só em aritmética — mais
|
|
|
|
|
robusto quando o bridge relata ``inPoint``/``outPoint`` de cada clipe,
|
|
|
|
|
mas por isso mesmo falha alto (em vez de simplesmente pular a faixa)
|
|
|
|
|
quando essa garantia falta numa faixa (ver
|
|
|
|
|
:meth:`_clipes_do_arquivo` e o teste
|
|
|
|
|
``test_corte_falha_em_vez_de_ignorar_faixa_sem_intervalo_de_origem``,
|
|
|
|
|
que comprova que um corte nessas condições não deixa vídeo cortado e
|
|
|
|
|
áudio intacto).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
@@ -84,8 +109,8 @@ class AplicadorDePlanoDeEdicao:
|
|
|
|
|
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]
|
|
|
|
|
resultados = [self._aplicar_corte(acao, plano.arquivo_de_origem) for acao in cortes]
|
|
|
|
|
resultados += [self._aplicar_acao_pontual(acao, plano.arquivo_de_origem) for acao in pontuais]
|
|
|
|
|
return ResultadoDaAplicacao(tuple(resultados))
|
|
|
|
|
|
|
|
|
|
def _timeline_atual(self) -> Timeline:
|
|
|
|
@@ -125,7 +150,7 @@ class AplicadorDePlanoDeEdicao:
|
|
|
|
|
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)
|
|
|
|
|
clipes = self._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(
|
|
|
|
@@ -136,10 +161,22 @@ class AplicadorDePlanoDeEdicao:
|
|
|
|
|
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."""
|
|
|
|
|
"""Remove, em cada faixa afetada, todos os clipes contidos no intervalo do corte.
|
|
|
|
|
|
|
|
|
|
Antes de mudar qualquer faixa, valida que todas as faixas com clipes
|
|
|
|
|
de ``arquivo_de_origem`` têm intervalo de origem conhecido. Sem essa
|
|
|
|
|
validação prévia, uma faixa problemática (ex.: áudio sem
|
|
|
|
|
``inPoint``/``outPoint``) só falharia depois de outra faixa já ter
|
|
|
|
|
sido cortada — deixando vídeo e áudio dessincronizados mesmo com o
|
|
|
|
|
corte "reportado" como falho.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
faixas = self._timeline_atual().faixas
|
|
|
|
|
for faixa in faixas:
|
|
|
|
|
self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
|
|
|
|
|
|
|
|
|
trechos_removidos = 0
|
|
|
|
|
for faixa in self._timeline_atual().faixas:
|
|
|
|
|
for faixa in faixas:
|
|
|
|
|
trechos_removidos += self._cortar_faixa(faixa, acao, arquivo_de_origem)
|
|
|
|
|
if trechos_removidos == 0:
|
|
|
|
|
raise ErroDeMapeamentoDeTempo(
|
|
|
|
@@ -151,44 +188,104 @@ class AplicadorDePlanoDeEdicao:
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
clipes = self._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)
|
|
|
|
|
inicio_pedido = self._borda_de_entrada(primeiro, acao.inicio)
|
|
|
|
|
fim_pedido = 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)
|
|
|
|
|
inicio_real = self._dividir_e_conferir(inicio_pedido, faixa, arquivo_de_origem)
|
|
|
|
|
fim_real = self._dividir_e_conferir(fim_pedido, faixa, arquivo_de_origem)
|
|
|
|
|
|
|
|
|
|
clipes_apos_a_divisao = self._clipes_do_arquivo(self._faixa_atualizada(faixa), arquivo_de_origem)
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
if inicio_real - _TOLERANCIA_DE_BORDA_EM_SEGUNDOS <= clipe.intervalo_na_timeline.inicio
|
|
|
|
|
and clipe.intervalo_na_timeline.fim <= fim_real + _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:
|
|
|
|
|
# A duração esperada é a sobreposição real entre o intervalo da ação
|
|
|
|
|
# e os clipes afetados, não `acao.duracao` — se a ação começar ou
|
|
|
|
|
# terminar fora de qualquer clipe existente (ex.: pede corte a partir
|
|
|
|
|
# de um instante anterior ao início do material disponível), só a
|
|
|
|
|
# parte que realmente existe como filmagem deve ser contada.
|
|
|
|
|
duracao_esperada_pela_origem = sum(
|
|
|
|
|
self._sobreposicao_em_segundos(clipe, acao.inicio, acao.fim) for clipe in afetados
|
|
|
|
|
)
|
|
|
|
|
if abs(duracao_removida - duracao_esperada_pela_origem) > _TOLERANCIA_DE_DURACAO_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."
|
|
|
|
|
f"Na faixa {faixa.identificador!r}, os cortes pedidos em {inicio_pedido:.3f}s e "
|
|
|
|
|
f"{fim_pedido:.3f}s (o Premiere criou as bordas em {inicio_real:.3f}s e {fim_real:.3f}s) "
|
|
|
|
|
f"não isolaram o trecho esperado (esperado {duracao_esperada_pela_origem:.3f}s pela duração "
|
|
|
|
|
f"de origem, 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 _dividir_e_conferir(self, instante_pedido: float, faixa: Faixa, arquivo_de_origem: str) -> float:
|
|
|
|
|
"""Pede o corte em ``instante_pedido`` e devolve a borda que o Premiere realmente criou.
|
|
|
|
|
|
|
|
|
|
Corta **uma vez só** — nunca tenta de novo no mesmo ponto: uma
|
|
|
|
|
segunda tentativa deixaria dois cortes reais na timeline (o
|
|
|
|
|
"errado" e o corrigido), com uma fatia inútil entre eles, e o
|
|
|
|
|
Premiere não expõe undo por script para desfazer o primeiro
|
|
|
|
|
(``app.project.undo`` não existe nesta versão).
|
|
|
|
|
|
|
|
|
|
O desvio entre o instante pedido e a borda criada não é comparado
|
|
|
|
|
contra um limite fixo aqui: o ``split_clip`` do bridge do Premiere
|
|
|
|
|
desvia alguns décimos de segundo do pedido, e esse desvio cresce
|
|
|
|
|
com o tempo absoluto (ver :meth:`_borda_realmente_criada`) — é
|
|
|
|
|
comportamento normal do bridge, não um bug a rejeitar. A única
|
|
|
|
|
checagem de sanidade é o raio de busca de
|
|
|
|
|
:meth:`_borda_realmente_criada`
|
|
|
|
|
(:data:`_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS`): além dele, não é
|
|
|
|
|
drift, é falha real, e essa função já levanta
|
|
|
|
|
:class:`ErroDeMapeamentoDeTempo` nesse caso.
|
|
|
|
|
"""
|
|
|
|
|
self.escrita.dividir_clipe_se_necessario(instante_pedido, faixa.indice, faixa.tipo)
|
|
|
|
|
clipes = self._clipes_do_arquivo(self._faixa_atualizada(faixa), arquivo_de_origem)
|
|
|
|
|
return self._borda_realmente_criada(clipes, instante_pedido)
|
|
|
|
|
|
|
|
|
|
def _faixa_atualizada(self, faixa: Faixa) -> Faixa:
|
|
|
|
|
"""Relê a timeline e devolve a versão atual da mesma faixa, pelo identificador."""
|
|
|
|
|
return next(f for f in self._timeline_atual().faixas if f.identificador == faixa.identificador)
|
|
|
|
|
|
|
|
|
|
def _borda_realmente_criada(self, clipes: list[Clipe], instante_pedido: float) -> float:
|
|
|
|
|
"""Acha, entre as bordas de clipe existentes, a mais próxima do instante pedido a Premiere.
|
|
|
|
|
|
|
|
|
|
O ``split_clip`` do bridge do Premiere pode cortar alguns décimos de
|
|
|
|
|
segundo antes ou depois do instante pedido (drift observado: cresce
|
|
|
|
|
com o tempo absoluto, compatível com uma conversão segundos↔ticks
|
|
|
|
|
que não usa a taxa de quadros real da sequência). As durações
|
|
|
|
|
relativas continuam corretas — só a posição absoluta desvia — por
|
|
|
|
|
isso a seleção do trecho a remover deve usar a borda que o Premiere
|
|
|
|
|
de fato criou, não o valor pedido.
|
|
|
|
|
|
|
|
|
|
Levanta :class:`ErroDeMapeamentoDeTempo` se nenhuma borda estiver
|
|
|
|
|
dentro de :data:`_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS` do instante
|
|
|
|
|
pedido — nesse caso o corte realmente falhou, não é só drift.
|
|
|
|
|
"""
|
|
|
|
|
bordas = {clipe.intervalo_na_timeline.inicio for clipe in clipes} | {
|
|
|
|
|
clipe.intervalo_na_timeline.fim for clipe in clipes
|
|
|
|
|
}
|
|
|
|
|
borda_mais_proxima = min(bordas, key=lambda borda: abs(borda - instante_pedido), default=None)
|
|
|
|
|
if borda_mais_proxima is None or abs(borda_mais_proxima - instante_pedido) > _RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS:
|
|
|
|
|
raise ErroDeMapeamentoDeTempo(
|
|
|
|
|
f"Nenhuma borda de clipe foi encontrada perto de {instante_pedido:.3f}s "
|
|
|
|
|
f"(raio de busca: {_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS}s); o corte não foi aplicado."
|
|
|
|
|
)
|
|
|
|
|
return borda_mais_proxima
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
clipes = self._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(
|
|
|
|
@@ -196,6 +293,29 @@ class AplicadorDePlanoDeEdicao:
|
|
|
|
|
)
|
|
|
|
|
return self.mapeador.instante_na_timeline(clipe, instante_de_origem)
|
|
|
|
|
|
|
|
|
|
def _clipes_do_arquivo(self, faixa: Faixa, arquivo_de_origem: str) -> list[Clipe]:
|
|
|
|
|
"""Clipes de ``arquivo_de_origem`` em ``faixa``, exigindo intervalo de origem conhecido em todos.
|
|
|
|
|
|
|
|
|
|
Levanta :class:`ErroDeMapeamentoDeTempo` se algum clipe com esse nome
|
|
|
|
|
não tiver intervalo de origem — por exemplo, por uma lacuna na
|
|
|
|
|
ferramenta MCP que não reporta ``inPoint``/``outPoint`` para uma
|
|
|
|
|
faixa de áudio. Sem isso, o corte nessa faixa seria silenciosamente
|
|
|
|
|
ignorado (nenhum clipe "afetado" seria encontrado) em vez de avisar
|
|
|
|
|
que a faixa não pôde ser avaliada — foi exatamente essa lacuna que
|
|
|
|
|
deixou uma faixa de áudio inteira sem ser cortada numa aplicação
|
|
|
|
|
real deste plano.
|
|
|
|
|
"""
|
|
|
|
|
alvo = arquivo_de_origem.strip().lower()
|
|
|
|
|
nomeados = [clipe for clipe in faixa.clipes if (clipe.nome or "").strip().lower() == alvo]
|
|
|
|
|
sem_origem = [clipe for clipe in nomeados if clipe.intervalo_na_origem is None]
|
|
|
|
|
if sem_origem:
|
|
|
|
|
raise ErroDeMapeamentoDeTempo(
|
|
|
|
|
f"A faixa {faixa.identificador!r} tem {len(sem_origem)} clipe(s) de {arquivo_de_origem!r} "
|
|
|
|
|
"sem intervalo de origem reportado pelo Premiere; não é seguro decidir o que cortar, "
|
|
|
|
|
"mover ou marcar nessa faixa sem saber a que trecho de origem cada clipe corresponde."
|
|
|
|
|
)
|
|
|
|
|
return sorted(nomeados, key=lambda clipe: clipe.intervalo_na_origem.inicio)
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
@@ -214,6 +334,14 @@ class AplicadorDePlanoDeEdicao:
|
|
|
|
|
intervalo = clipe.intervalo_na_origem
|
|
|
|
|
return intervalo is not None and intervalo.inicio < fim and inicio < intervalo.fim
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _sobreposicao_em_segundos(clipe: Clipe, inicio: float, fim: float) -> float:
|
|
|
|
|
"""Quantos segundos do intervalo de origem do clipe caem dentro de [inicio, fim]."""
|
|
|
|
|
intervalo = clipe.intervalo_na_origem
|
|
|
|
|
if intervalo is None:
|
|
|
|
|
return 0.0
|
|
|
|
|
return max(0.0, min(intervalo.fim, fim) - max(intervalo.inicio, inicio))
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _faixa_de_video(timeline: Timeline) -> Faixa:
|
|
|
|
|
"""A primeira faixa de vídeo da timeline; é onde marcadores e zooms são resolvidos."""
|
|
|
|
|