From 59f1358f1838ff839907de001411eea18b6e0111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Henrique?= Date: Thu, 10 Sep 2026 19:21:07 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20criado=20prot=C3=B3tipo=20local=20de=20?= =?UTF-8?q?montagem=20incremental=20com=20gancho=20e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - criado protótipo local de montagem incremental com gancho e trechos preservados - adicionado checkbox no painel para executar planos pelo modo de montagem incremental Resumo: - 8 arquivos alterados - 4 novos - 4 modificados - 0 removidos 4 files changed, 230 insertions(+) Arquivos: - .jhonny/analises.db - code/cep-plugin/index.html - code/cep-plugin/main.js - code/src/tools/timeline.ts - code/engine/aplicar_montagem_incremental_prototipo.py - code/engine/editor/pedido_montagem_incremental_exemplo.json - code/engine/editor/plano_montagem_incremental_6.json - code/engine/editor/prototipo_montagem_incremental.py --- .jhonny/analises.db | Bin 1175552 -> 1175552 bytes code/cep-plugin/index.html | 2 + code/cep-plugin/main.js | 50 +++++ .../aplicar_montagem_incremental_prototipo.py | 133 +++++++++++ .../pedido_montagem_incremental_exemplo.json | 23 ++ .../editor/plano_montagem_incremental_6.json | 46 ++++ .../editor/prototipo_montagem_incremental.py | 211 ++++++++++++++++++ code/src/tools/timeline.ts | 178 +++++++++++++++ 8 files changed, 643 insertions(+) create mode 100644 code/engine/aplicar_montagem_incremental_prototipo.py create mode 100644 code/engine/editor/pedido_montagem_incremental_exemplo.json create mode 100644 code/engine/editor/plano_montagem_incremental_6.json create mode 100644 code/engine/editor/prototipo_montagem_incremental.py diff --git a/.jhonny/analises.db b/.jhonny/analises.db index 4b46ebf981f04a01dc0af26971a3ff00c2d99307..246cdf1b62c5c955e64316be7a93814c2bd499ad 100644 GIT binary patch delta 255 zcmZp8;NI}SeS);0HUk5rJOdmEmQK_$X4Kx8uyq|HU$fZycCq!0+r`#1y>n3D<2uX0 ze~7=A-<9tLUm2eR9~ZAV&jp@L9wzSD+$LOSH!B*fw9qOxoM${AS8vQQ+g>$iV-H|0Dk!{^$JP_#g7$ z=D*5+p8q8OVg9}R+xa(c7A)xH=V4`LWMtz5lf3PF`I&*31&CRJm<@>8ftUk`If0l9 Zh`E872Z%vx`GA-ohy}LqpP^;hxRF ze~7=A-<9tLUm2eR9~ZAV&jp@L9wzSDn-vYJxtUUVrl0@CWVrp@ZzeMq86N(<4E%ri zKk~off6o7n{~`Zv{;T}w`A_m6=HI(ruz+zcfBRm3W*}w(Vpbq#17da{<^W<&Am##M YZXo6XVqPHT17eUKf$e+w1)U!N09?*LLjV8( diff --git a/code/cep-plugin/index.html b/code/cep-plugin/index.html index 70eaa5f..758e029 100755 --- a/code/cep-plugin/index.html +++ b/code/cep-plugin/index.html @@ -216,6 +216,8 @@ Sem desfazer

Esta versão do Premiere não expõe undo para o painel. Um backup da sequência é criado automaticamente antes de aplicar.

+ +

Marcado: cria uma sequência nova, limpa somente a cópia e monta nela os trechos preservados. A sequência original não é cortada.

diff --git a/code/cep-plugin/main.js b/code/cep-plugin/main.js index cfd720e..6341bf4 100755 --- a/code/cep-plugin/main.js +++ b/code/cep-plugin/main.js @@ -419,6 +419,7 @@ function inserirLegendasNaTimeline() { // ---- Teste isolado de ações pela engine Python ---------------------------- var TESTAR_ACOES_ENGINE_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/aplicar_plano_de_edicao.py"; +var MONTAGEM_INCREMENTAL_ENGINE_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/aplicar_montagem_incremental_prototipo.py"; function testarAcoesCarregarExemplo() { var exemplo = { @@ -3805,6 +3806,11 @@ function applyEditorialActions() { } var numActions = plan.actions.length; + if (document.getElementById("editorialIncrementalMode") && document.getElementById("editorialIncrementalMode").checked) { + aplicarMontagemIncremental(plan, numActions); + return; + } + var summary = numActions + " ação(ões) no plano (cortes, ganchos, zooms, textos e/ou marcadores).\n\n" + "Um backup da sequência atual será criado antes de aplicar. Confirmar?"; @@ -3867,6 +3873,50 @@ function applyEditorialActions() { ); } +function aplicarMontagemIncremental(plan, numActions) { + var summary = + numActions + " ação(ões) serão convertidas em trechos preservados.\n\n" + + "Será criada uma nova sequência clonada; a sequência original não será cortada. Confirmar?"; + if (typeof window.confirm === "function" && !window.confirm(summary)) return; + setBusy("btnApplyEditorialActions", true); + setStep(3, "running", "Montando cópia…"); + taskStart("Montagem incremental (teste)", "Clonando a sequência e preparando os trechos"); + var caminhoDoPlano = path.join(silenceState.outputDir || os.tmpdir(), "plano-incremental-" + Date.now() + ".json"); + try { fs.writeFileSync(caminhoDoPlano, JSON.stringify(plan, null, 2), "utf-8"); } catch (erroEscrita) { + setBusy("btnApplyEditorialActions", false); + setStep(3, "error", "Falhou"); + taskEnd(false, "Não foi possível salvar o plano incremental.", erroEscrita.message); + return; + } + var processoIncremental = null; + taskStart("Montagem incremental (teste)", "Executando na sequência clonada", null, function () { + if (processoIncremental) processoIncremental.kill("SIGTERM"); + }); + processoIncremental = runStreamed( + SCANNER_PYTHON_BIN, + [MONTAGEM_INCREMENTAL_ENGINE_SCRIPT, caminhoDoPlano], + function (line) { silenceLog(line); }, + function (code, lastErr, lastOutput) { + processoIncremental = null; + setBusy("btnApplyEditorialActions", false); + if (code !== 0) { + setStep(3, "error", "Falhou"); + var detalhe = lastErr || "A montagem incremental não retornou detalhes."; + try { detalhe = JSON.parse(lastOutput || "{}").erro || detalhe; } catch (_) {} + taskEnd(false, "A montagem incremental falhou.", detalhe); + silenceLog("Montagem incremental não aplicada: " + detalhe, "err"); + return; + } + setStep(3, "done", "Cópia montada"); + var resultado = null; + try { resultado = JSON.parse(lastOutput || "{}").resultado || null; } catch (_) {} + var nome = resultado && resultado.destinationSequenceName ? resultado.destinationSequenceName : "nova sequência"; + taskEnd(true, "Montagem incremental concluída em " + nome + "."); + silenceLog("Montagem incremental concluída em " + nome + ".", "ok"); + } + ); +} + // Abre o seletor de arquivos nativo do CEP em vez de exigir que a pessoa cole // um caminho absoluto à mão. function pickEditorialPlanFile() { diff --git a/code/engine/aplicar_montagem_incremental_prototipo.py b/code/engine/aplicar_montagem_incremental_prototipo.py new file mode 100644 index 0000000..9c3f6f2 --- /dev/null +++ b/code/engine/aplicar_montagem_incremental_prototipo.py @@ -0,0 +1,133 @@ +"""Executa o protótipo de montagem incremental a partir de um plano editorial. + +O script lê um plano ``cut``/``hook`` já carregado pelo painel, calcula o +complemento dos cortes e chama uma ferramenta experimental que clona a +sequência ativa e monta os intervalos preservados na cópia. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys +from datetime import datetime + +CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent +if str(CAMINHO_DO_CODIGO) not in sys.path: + sys.path.insert(0, str(CAMINHO_DO_CODIGO)) + +from engine.editor.prototipo_montagem_incremental import AcaoDoPlano, PlanejadorDeMontagemIncremental +from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio + + +class ErroDeMontagemIncremental(RuntimeError): + """Indica que o plano não pôde ser preparado para a montagem experimental.""" + + +def carregar_plano(caminho: Path) -> dict[str, object]: + """Lê o plano editorial JSON fornecido pelo painel.""" + try: + dados = json.loads(caminho.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as erro: + raise ErroDeMontagemIncremental(f"Não foi possível ler o plano: {erro}") from erro + if not isinstance(dados, dict) or not isinstance(dados.get("actions"), list): + raise ErroDeMontagemIncremental("O plano precisa conter uma lista actions válida.") + return dados + + +def preparar_intervalos( + plano: dict[str, object], duracao_da_midia: float +) -> list[dict[str, object]]: + """Converte cortes e gancho em intervalos contíguos de montagem.""" + acoes = [ + AcaoDoPlano( + tipo=str(acao["kind"]), + inicio=float(acao["start"]), + fim=float(acao["end"]), + motivo=str(acao.get("reason", "")), + ) + for acao in plano["actions"] + if isinstance(acao, dict) and acao.get("kind") in {"cut", "hook"} + ] + insercoes = PlanejadorDeMontagemIncremental(duracao_da_midia).planejar(acoes) + return [ + { + "in_seconds": insercao.origem.inicio, + "out_seconds": insercao.origem.fim, + "destination_seconds": insercao.destino_inicio, + "label": insercao.tipo.value, + } + for insercao in insercoes + ] + + +def executar(caminho_do_plano: Path) -> dict[str, object]: + """Clona a sequência ativa e monta o plano incremental na nova sequência.""" + plano = carregar_plano(caminho_do_plano) + cliente = ClienteMCPPorStdio([resolver_node(), str(CAMINHO_DO_CODIGO / "dist" / "index.js")], tempo_limite=60) + cliente.conectar() + try: + sequencia = cliente.chamar("get_active_sequence") + if not isinstance(sequencia, dict) or not sequencia.get("id"): + raise ErroDeMontagemIncremental("Nenhuma sequência ativa foi encontrada no Premiere.") + clipe = _primeiro_clipe_de_video(sequencia) + intervalos = preparar_intervalos(plano, float(sequencia["end"])) + resultado = cliente.chamar( + "assemble_incremental_edit_prototype", + { + "source_sequence_id": str(sequencia["id"]), + "source_item_id": str(clipe["sourceProjectItemId"]), + "target_name": _nome_da_sequencia(str(sequencia["name"])), + "ranges": intervalos, + "confirm_prototype": True, + }, + ) + return {"ok": True, "plano_incremental": True, "resultado": resultado} + finally: + cliente.desconectar() + + +def _primeiro_clipe_de_video(sequencia: dict[str, object]) -> dict[str, object]: + """Obtém a mídia do primeiro clipe da faixa de vídeo principal.""" + faixas = sequencia.get("videoTracks", []) + if not isinstance(faixas, list) or not faixas or not isinstance(faixas[0], dict): + raise ErroDeMontagemIncremental("A sequência ativa não possui faixa de vídeo.") + clipes = faixas[0].get("clips", []) + if not isinstance(clipes, list) or not clipes or not isinstance(clipes[0], dict): + raise ErroDeMontagemIncremental("A faixa de vídeo principal está vazia.") + if not clipes[0].get("sourceProjectItemId"): + raise ErroDeMontagemIncremental("Não foi possível identificar a mídia do primeiro clipe.") + return clipes[0] + + +def _nome_da_sequencia(nome_da_origem: str) -> str: + """Cria um nome identificável e único para a sequência experimental.""" + carimbo = datetime.now().strftime("%Y%m%d-%H%M%S") + return f"PROTÓTIPO incremental - {nome_da_origem} - {carimbo}"[:255] + + +def resolver_node() -> str: + """Resolve o Node usado pelo servidor MCP local.""" + import shutil + + caminho = shutil.which("node") + if caminho: + return caminho + raise ErroDeMontagemIncremental("Node.js não foi encontrado no PATH do painel.") + + +def principal() -> None: + """Executa o script e imprime um resultado JSON para o painel.""" + argumentos = argparse.ArgumentParser() + argumentos.add_argument("plano", type=Path) + opcoes = argumentos.parse_args() + try: + print(json.dumps(executar(opcoes.plano), ensure_ascii=False)) + except (ErroDeMontagemIncremental, OSError, ValueError) as erro: + print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False)) + raise SystemExit(1) from erro + + +if __name__ == "__main__": + principal() diff --git a/code/engine/editor/pedido_montagem_incremental_exemplo.json b/code/engine/editor/pedido_montagem_incremental_exemplo.json new file mode 100644 index 0000000..ff684e7 --- /dev/null +++ b/code/engine/editor/pedido_montagem_incremental_exemplo.json @@ -0,0 +1,23 @@ +{ + "duracao_da_midia": 180, + "acoes": [ + { + "tipo": "hook", + "inicio": 141.2, + "fim": 147.8, + "motivo": "frase completa para abrir o vídeo" + }, + { + "tipo": "cut", + "inicio": 32.5, + "fim": 48.0, + "motivo": "remover repetição" + }, + { + "tipo": "cut", + "inicio": 95.4, + "fim": 110.0, + "motivo": "remover pausa longa" + } + ] +} diff --git a/code/engine/editor/plano_montagem_incremental_6.json b/code/engine/editor/plano_montagem_incremental_6.json new file mode 100644 index 0000000..40983ec --- /dev/null +++ b/code/engine/editor/plano_montagem_incremental_6.json @@ -0,0 +1,46 @@ +{ + "modo_de_execucao": "montagem_incremental", + "plano_de_origem": 6, + "source_sequence_id": "12669c9c-038d-4a3f-99f9-fe6f6827b5f4", + "source_item_id": "000f4289", + "target_name": "PROTÓTIPO - plano 6 - montagem incremental", + "duracao_da_midia": 1018.22554166667, + "ranges": [ + { + "in_seconds": 255.98, + "out_seconds": 274.96, + "destination_seconds": 0, + "label": "gancho de abertura" + }, + { + "in_seconds": 124.47, + "out_seconds": 215.06, + "destination_seconds": 18.98, + "label": "corpo 1" + }, + { + "in_seconds": 255.98, + "out_seconds": 427.32, + "destination_seconds": 109.57, + "label": "corpo 2 - ocorrência original do gancho" + }, + { + "in_seconds": 469.24, + "out_seconds": 552.28, + "destination_seconds": 280.91, + "label": "corpo 3" + }, + { + "in_seconds": 848.42, + "out_seconds": 879.89, + "destination_seconds": 363.95, + "label": "corpo 4" + }, + { + "in_seconds": 962.78, + "out_seconds": 1014.99, + "label": "corpo 5", + "destination_seconds": 395.42 + } + ] +} diff --git a/code/engine/editor/prototipo_montagem_incremental.py b/code/engine/editor/prototipo_montagem_incremental.py new file mode 100644 index 0000000..92a068a --- /dev/null +++ b/code/engine/editor/prototipo_montagem_incremental.py @@ -0,0 +1,211 @@ +"""Protótipo descartável da montagem incremental de uma edição. + +Este módulo não se conecta ao Premiere. Ele responde à pergunta: +"Dado um plano em intervalos da mídia original, qual sequência de inserções +seria feita em uma sequência de destino inicialmente vazia?" + +O protótipo mantém a sequência original intacta e transforma ações de corte +em trechos preservados. O gancho, quando existir, é colocado primeiro. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +import json +from pathlib import Path +import sys +from typing import Any + + +class TipoDeTrecho(str, Enum): + """Origem editorial de um trecho que será inserido na montagem.""" + + GANCHO = "gancho" + CORPO = "corpo" + + +@dataclass(frozen=True) +class IntervaloDeOrigem: + """Representa um intervalo contínuo em segundos na mídia original.""" + + inicio: float + fim: float + + def __post_init__(self) -> None: + if self.inicio < 0 or self.fim <= self.inicio: + raise ValueError("Intervalo inválido: o fim precisa ser maior que o início.") + + @property + def duracao(self) -> float: + """Devolve a duração do intervalo em segundos.""" + return self.fim - self.inicio + + +@dataclass(frozen=True) +class AcaoDoPlano: + """Ação mínima usada pelo protótipo para representar um plano editorial.""" + + tipo: str + inicio: float + fim: float + motivo: str = "" + + def intervalo(self) -> IntervaloDeOrigem: + """Converte a ação em intervalo na mídia original.""" + return IntervaloDeOrigem(self.inicio, self.fim) + + +@dataclass(frozen=True) +class InsercaoDeMontagem: + """Uma inserção A/V planejada para a sequência de destino.""" + + ordem: int + tipo: TipoDeTrecho + origem: IntervaloDeOrigem + destino_inicio: float + destino_fim: float + motivo: str + + @property + def duracao(self) -> float: + """Devolve a duração que será inserida na montagem.""" + return self.destino_fim - self.destino_inicio + + def como_dicionario(self) -> dict[str, Any]: + """Serializa a inserção para inspeção humana ou integração futura.""" + return { + "ordem": self.ordem, + "tipo": self.tipo.value, + "origem": {"inicio": self.origem.inicio, "fim": self.origem.fim}, + "destino": {"inicio": self.destino_inicio, "fim": self.destino_fim}, + "duracao": self.duracao, + "motivo": self.motivo, + } + + +class PlanejadorDeMontagemIncremental: + """Converte cortes e gancho em uma montagem crescente e sem ripple de origem.""" + + def __init__(self, duracao_da_midia: float) -> None: + if duracao_da_midia <= 0: + raise ValueError("A duração da mídia precisa ser positiva.") + self.duracao_da_midia = duracao_da_midia + + def planejar(self, acoes: list[AcaoDoPlano]) -> list[InsercaoDeMontagem]: + """Gera as inserções ordenadas que reconstruiriam a edição final.""" + cortes = self._normalizar_intervalos( + acao.intervalo() for acao in acoes if acao.tipo == "cut" + ) + gancho = next((acao for acao in acoes if acao.tipo == "hook"), None) + if sum(acao.tipo == "hook" for acao in acoes) > 1: + raise ValueError("O protótipo aceita no máximo um gancho.") + + trechos = self._complemento_da_midia(cortes) + insercoes: list[InsercaoDeMontagem] = [] + proximo_destino = 0.0 + + if gancho is not None: + intervalo_do_gancho = gancho.intervalo() + self._validar_intervalo_do_gancho(intervalo_do_gancho, trechos) + insercoes.append( + self._criar_insercao( + len(insercoes) + 1, + TipoDeTrecho.GANCHO, + intervalo_do_gancho, + proximo_destino, + gancho.motivo or "gancho de abertura", + ) + ) + proximo_destino += intervalo_do_gancho.duracao + + for trecho in trechos: + insercoes.append( + self._criar_insercao( + len(insercoes) + 1, + TipoDeTrecho.CORPO, + trecho, + proximo_destino, + "trecho preservado após os cortes", + ) + ) + proximo_destino += trecho.duracao + return insercoes + + def _normalizar_intervalos(self, intervalos: Any) -> list[IntervaloDeOrigem]: + """Ordena, limita e rejeita cortes sobrepostos.""" + normalizados = [] + for intervalo in sorted(intervalos, key=lambda item: item.inicio): + inicio = max(0.0, intervalo.inicio) + fim = min(self.duracao_da_midia, intervalo.fim) + if fim <= inicio: + continue + atual = IntervaloDeOrigem(inicio, fim) + if normalizados and atual.inicio < normalizados[-1].fim: + raise ValueError("Os cortes do protótipo não podem se sobrepor.") + normalizados.append(atual) + return normalizados + + def _complemento_da_midia(self, cortes: list[IntervaloDeOrigem]) -> list[IntervaloDeOrigem]: + """Calcula os trechos que permanecem depois dos cortes.""" + trechos: list[IntervaloDeOrigem] = [] + cursor = 0.0 + for corte in cortes: + if corte.inicio > cursor: + trechos.append(IntervaloDeOrigem(cursor, corte.inicio)) + cursor = corte.fim + if cursor < self.duracao_da_midia: + trechos.append(IntervaloDeOrigem(cursor, self.duracao_da_midia)) + return trechos + + def _validar_intervalo_do_gancho( + self, gancho: IntervaloDeOrigem, trechos: list[IntervaloDeOrigem] + ) -> None: + """Confirma que o gancho não foi removido pelo conjunto de cortes.""" + if not any(self._contem_intervalo(trecho, gancho) for trecho in trechos): + raise ValueError("O gancho precisa estar em um trecho preservado da mídia.") + + @staticmethod + def _contem_intervalo( + recipiente: IntervaloDeOrigem, intervalo: IntervaloDeOrigem + ) -> bool: + """Informa se um intervalo está completamente dentro de outro.""" + return recipiente.inicio <= intervalo.inicio and intervalo.fim <= recipiente.fim + + @staticmethod + def _criar_insercao( + ordem: int, + tipo: TipoDeTrecho, + origem: IntervaloDeOrigem, + destino_inicio: float, + motivo: str, + ) -> InsercaoDeMontagem: + """Cria uma inserção com destino contíguo na sequência de montagem.""" + return InsercaoDeMontagem( + ordem=ordem, + tipo=tipo, + origem=origem, + destino_inicio=destino_inicio, + destino_fim=destino_inicio + origem.duracao, + motivo=motivo, + ) + + +def carregar_pedido(caminho: Path) -> tuple[float, list[AcaoDoPlano]]: + """Carrega um pedido JSON pequeno para execução local do protótipo.""" + dados = json.loads(caminho.read_text(encoding="utf-8")) + acoes = [AcaoDoPlano(**acao) for acao in dados["acoes"]] + return float(dados["duracao_da_midia"]), acoes + + +def executar(caminho: Path) -> None: + """Executa o protótipo e imprime o plano de montagem resultante.""" + duracao, acoes = carregar_pedido(caminho) + insercoes = PlanejadorDeMontagemIncremental(duracao).planejar(acoes) + print(json.dumps({"sequencia_de_destino": insercoes}, default=lambda item: item.como_dicionario(), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("Uso: python3 prototipo_montagem_incremental.py pedido.json") + executar(Path(sys.argv[1])) diff --git a/code/src/tools/timeline.ts b/code/src/tools/timeline.ts index 03e44c0..41ca691 100755 --- a/code/src/tools/timeline.ts +++ b/code/src/tools/timeline.ts @@ -3,6 +3,184 @@ import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js"; export function getTimelineTools(bridgeOptions: BridgeOptions) { return { + assemble_incremental_edit_prototype: { + description: + "PROTOTYPE: clone a source sequence, clear only the clone, and assemble one project item's A/V source ranges in order. The source sequence is not edited.", + parameters: { + type: "object" as const, + additionalProperties: false, + properties: { + source_sequence_id: { + type: "string", + minLength: 1, + description: "Source sequence name or ID to clone", + }, + source_item_id: { + type: "string", + minLength: 1, + description: "Project item name or node ID used by every source range", + }, + target_name: { + type: "string", + minLength: 1, + maxLength: 255, + description: "Name for the cloned destination sequence", + }, + ranges: { + type: "array", + minItems: 1, + maxItems: 64, + items: { + type: "object", + additionalProperties: false, + properties: { + in_seconds: { type: "number", minimum: 0 }, + out_seconds: { type: "number", minimum: 0 }, + destination_seconds: { type: "number", minimum: 0 }, + label: { type: "string", maxLength: 255 }, + }, + required: ["in_seconds", "out_seconds", "destination_seconds"], + }, + }, + confirm_prototype: { + type: "boolean", + description: "Must be true because this creates and mutates a Premiere sequence", + }, + }, + required: ["source_sequence_id", "source_item_id", "target_name", "ranges", "confirm_prototype"], + }, + handler: async (args: { + source_sequence_id: string; + source_item_id: string; + target_name: string; + ranges: Array<{ + in_seconds: number; + out_seconds: number; + destination_seconds: number; + label?: string; + }>; + confirm_prototype: boolean; + }) => { + if (args.confirm_prototype !== true) { + return { success: false, error: "confirm_prototype must be true for the experimental sequence assembly." }; + } + if (!Array.isArray(args.ranges) || args.ranges.length === 0 || args.ranges.length > 64) { + return { success: false, error: "ranges must contain between 1 and 64 source ranges." }; + } + for (const [index, range] of args.ranges.entries()) { + if (!Number.isFinite(range.in_seconds) || !Number.isFinite(range.out_seconds) || !Number.isFinite(range.destination_seconds) || + range.in_seconds < 0 || range.out_seconds <= range.in_seconds || range.destination_seconds < 0) { + return { success: false, error: `ranges[${index}] has invalid source or destination timing.` }; + } + } + + const sourceSequenceId = escapeForExtendScript(args.source_sequence_id); + const sourceItemId = escapeForExtendScript(args.source_item_id); + const targetName = escapeForExtendScript(args.target_name); + const ranges = args.ranges.map((range) => ({ + inSeconds: range.in_seconds, + outSeconds: range.out_seconds, + destinationSeconds: range.destination_seconds, + label: range.label || "", + })); + const rangeJson = JSON.stringify(ranges); + + const script = buildToolScript(` + var sourceSequence = __findSequence("${sourceSequenceId}"); + if (!sourceSequence) return __error("Source sequence not found; no prototype sequence was created."); + var item = __findProjectItem("${sourceItemId}"); + if (!item) return __error("Source project item not found; no prototype sequence was created."); + + var beforeIds = {}; + var sequenceIndex; + for (sequenceIndex = 0; sequenceIndex < app.project.sequences.numSequences; sequenceIndex++) { + beforeIds[String(app.project.sequences[sequenceIndex].sequenceID)] = true; + } + sourceSequence.clone(); + + var destinationSequence = null; + for (sequenceIndex = 0; sequenceIndex < app.project.sequences.numSequences; sequenceIndex++) { + var candidate = app.project.sequences[sequenceIndex]; + if (!beforeIds[String(candidate.sequenceID)]) { + destinationSequence = candidate; + break; + } + } + if (!destinationSequence) return __error("Premiere did not expose the cloned destination sequence."); + destinationSequence.name = "${targetName}"; + + var trackGroup; + var trackIndex; + var clipIndex; + var trackGroups = [destinationSequence.videoTracks, destinationSequence.audioTracks]; + for (trackGroup = 0; trackGroup < trackGroups.length; trackGroup++) { + for (trackIndex = 0; trackIndex < trackGroups[trackGroup].numTracks; trackIndex++) { + var track = trackGroups[trackGroup][trackIndex]; + for (clipIndex = track.clips.numItems - 1; clipIndex >= 0; clipIndex--) { + track.clips[clipIndex].remove(false, false); + } + if (track.clips.numItems !== 0) return __error("Could not clear the cloned destination sequence."); + } + } + if (!destinationSequence.videoTracks.numTracks || !destinationSequence.audioTracks.numTracks) { + return __error("The cloned destination sequence must have at least one video and one audio track."); + } + + var ranges = ${rangeJson}; + var oldIn = item.getInPoint(4); + var oldOut = item.getOutPoint(4); + var oldInTicks = oldIn ? oldIn.ticks : null; + var oldOutTicks = oldOut ? oldOut.ticks : null; + var placements = []; + var insertionError = null; + try { + for (var rangeIndex = 0; rangeIndex < ranges.length; rangeIndex++) { + var range = ranges[rangeIndex]; + if (range.outSeconds <= range.inSeconds) return __error("A prototype source range has non-positive duration."); + item.setInPoint(__secondsToTicks(range.inSeconds).toString(), 4); + item.setOutPoint(__secondsToTicks(range.outSeconds).toString(), 4); + destinationSequence.insertClip(item, __secondsToTicks(range.destinationSeconds).toString(), 0, 0); + placements.push({ + label: range.label, + sourceInSeconds: range.inSeconds, + sourceOutSeconds: range.outSeconds, + destinationSeconds: range.destinationSeconds, + durationSeconds: range.outSeconds - range.inSeconds + }); + } + } catch (insertError) { + insertionError = insertError.toString(); + } finally { + try { + if (oldInTicks !== null) item.setInPoint(String(oldInTicks), 4); else item.clearInPoint(); + if (oldOutTicks !== null) item.setOutPoint(String(oldOutTicks), 4); else item.clearOutPoint(); + } catch (restoreError) { + return __error("The source item's in/out could not be restored: " + restoreError.toString()); + } + } + if (insertionError !== null) return __error("Premiere rejected a prototype source-range insertion: " + insertionError); + + var videoCount = destinationSequence.videoTracks[0].clips.numItems; + var audioCount = destinationSequence.audioTracks[0].clips.numItems; + if (videoCount !== ranges.length || audioCount !== ranges.length) { + return __error("The prototype destination did not receive exactly one video and one audio clip per range."); + } + return __result({ + prototype: true, + verified: true, + sourceSequenceId: String(sourceSequence.sequenceID), + destinationSequenceId: String(destinationSequence.sequenceID), + destinationSequenceName: destinationSequence.name, + videoClipCount: videoCount, + audioClipCount: audioCount, + placements: placements, + note: "The source sequence was not edited. Review the cloned sequence manually before keeping it." + }); + `); + return sendCommand(script, bridgeOptions); + }, + }, + add_to_timeline: { description: "Insert a project item at a timeline position and verify Premiere added no unexpected same-track fragments.",