#!/usr/bin/env node /** * Aplica um plano de "actions" (formato da skill selecao-trechos: cut/zoom/text/marker) * numa sequência do Premiere Pro, reaproveitando o bridge já usado pelo servidor MCP * (dist/tools/*.js) — mesmo padrão de scripts/apply-silence-cuts.mjs. * * Fase A: splits em todos os limites das actions de tipo "cut", coordenadas originais. * Fase B: remove os cortes em ordem DECRESCENTE de início (ripple=true). * Fase C: aplica marker/zoom/text — como o vídeo já encolheu na Fase B, os tempos * de cada action são primeiro traduzidos de "coordenada original" para * "coordenada pós-corte" (ver adjustTimeForCuts) antes de procurar o clipe. * * Limitações conhecidas: * - "zoom" cria uma camada de ajuste (adjustment layer) numa track de vídeo dedicada, * posicionada e recortada para durar exatamente o intervalo da action, com o Scale * do próprio Motion da camada de ajuste ajustado — não altera o clipe original. * É um scale estático (sem rampa de entrada/saída); suficiente para um punch-in * simples, não é um Ken Burns animado. A track de ajuste é criada uma vez (no * topo da pilha de vídeo) e reaproveitada por todas as actions "zoom" do plano. * - "text" não tem API de scripting suportada no Premiere para criar clipe de texto * a partir de string crua (add_text_overlay do MCP já retorna erro por design). * Em vez de falhar o plano inteiro, cada action "text" vira um marker de sequência * com o conteúdo no nome, para revisão manual (import de .srt/.vtt ou MOGRT depois). * * Uso: * node scripts/apply-editorial-actions.mjs --plan --sequence "" * [--video-track 0] [--audio-track 0] [--dry-run] */ import { readFileSync } from "node:fs"; import { getTimelineTools } from "../dist/tools/timeline.js"; import { getDiscoveryTools } from "../dist/tools/discovery.js"; import { getProjectTools } from "../dist/tools/project.js"; import { getMarkerTools } from "../dist/tools/markers.js"; import { getTrackTargetingTools } from "../dist/tools/track-targeting.js"; import { getTrackTools } from "../dist/tools/tracks.js"; import { getPlayheadTools } from "../dist/tools/playhead.js"; import { getUtilityTools } from "../dist/tools/utility.js"; // Progresso para a barra do painel CEP: linhas @@PROGRESS em stderr, que o painel // intercepta em vez de despejar no log. Fases A/B/C viram uma barra única. function reportProgress(stage, pct, detail) { const payload = { stage }; if (typeof pct === "number" && Number.isFinite(pct)) payload.pct = Math.max(0, Math.min(100, Math.round(pct * 10) / 10)); if (detail) payload.detail = detail; console.error("@@PROGRESS " + JSON.stringify(payload)); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // Traduz um instante em coordenadas do vídeo ORIGINAL para o instante equivalente // depois que todos os `cuts` já foram removidos com ripple. Cada corte inteiramente // antes de `t` empurra tudo depois dele para trás pela duração do corte; um corte // que contém `t` (não deveria acontecer num plano bem-formado, mas é tratado sem // lançar erro) gruda `t` no início daquele corte. function adjustTimeForCuts(t, sortedCuts) { let removedBefore = 0; for (const cut of sortedCuts) { if (cut.end <= t) { removedBefore += cut.end - cut.start; } else if (cut.start < t) { removedBefore += t - cut.start; break; } else { break; } } return Math.max(0, t - removedBefore); } function parseArgs(argv) { const args = { videoTrack: 0, audioTrack: 0, dryRun: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--plan") args.plan = argv[++i]; else if (a === "--sequence") args.sequence = argv[++i]; else if (a === "--video-track") args.videoTrack = Number(argv[++i]); else if (a === "--audio-track") args.audioTrack = Number(argv[++i]); else if (a === "--dry-run") args.dryRun = true; } if (!args.plan || !args.sequence) { console.error("Uso: --plan --sequence [--video-track N] [--audio-track N] [--dry-run]"); process.exit(1); } return args; } function unwrap(result, label) { if (!result || result.success === false) { throw new Error(`${label} falhou: ${result?.error ?? JSON.stringify(result)}`); } return result.data ?? result; } function validateActions(actions) { if (!Array.isArray(actions) || actions.length === 0) { throw new Error("Plano não tem nenhuma action."); } for (const action of actions) { if (typeof action.start !== "number" || typeof action.end !== "number") { throw new Error(`Action sem start/end numéricos: ${JSON.stringify(action)}`); } const minEnd = action.kind === "marker" ? action.start : action.start + Number.EPSILON; if (action.end < minEnd) { throw new Error(`Action com end < start: ${JSON.stringify(action)}`); } if (!["cut", "zoom", "text", "marker"].includes(action.kind)) { throw new Error(`Tipo de action desconhecido: "${action.kind}"`); } } } async function main() { const args = parseArgs(process.argv.slice(2)); const plan = JSON.parse(readFileSync(args.plan, "utf-8")); const actions = plan.actions; validateActions(actions); const cuts = actions.filter((a) => a.kind === "cut"); const others = actions.filter((a) => a.kind !== "cut"); const bridgeOptions = {}; const timeline = getTimelineTools(bridgeOptions); const discovery = getDiscoveryTools(bridgeOptions); reportProgress("Conectando ao Premiere", 0); const project = getProjectTools(bridgeOptions); const markers = getMarkerTools(bridgeOptions); const trackTargeting = getTrackTargetingTools(bridgeOptions); const tracks = getTrackTools(bridgeOptions); const playhead = getPlayheadTools(bridgeOptions); const utility = getUtilityTools(bridgeOptions); console.error( `[apply-editorial-actions] sequência="${args.sequence}" cuts=${cuts.length} outras=${others.length} dryRun=${args.dryRun}` ); if (args.dryRun) { console.log(JSON.stringify({ ok: true, dryRun: true, numCuts: cuts.length, numOutras: others.length, actions }, null, 2)); return; } unwrap(await project.set_active_sequence.handler({ sequence_id: args.sequence }), "set_active_sequence"); const initialSeq = unwrap(await discovery.get_active_sequence.handler({}), "get_active_sequence"); const sequenceDuration = initialSeq.end; // Pequena folga entre chamadas de QE DOM/ExtendScript: são round-trips síncronos // no processo principal do Premiere, e o próprio dist/tools/timeline.js documenta // que edições estruturais via QE são flakeys em algumas versões do Premiere Pro // 26.x. Sem essa folga, uma sequência de ~100 chamadas nesse ritmo é o cenário // mais provável de deixar a UI do Premiere travada (viu-se isso na prática com // um plano de 17 cortes + zooms/marcadores depois deles). const BRIDGE_DELAY_MS = 120; if (cuts.length > 0) { // Dedup dos pontos de corte: cada cut contribui com `start` e `end`, mas planos // podem ter pontos repetidos entre ações vizinhas. Splitar duas vezes no mesmo // ponto falha ("no clip strictly spans X") porque depois do 1º split não sobra // clipe que atravesse aquele instante — então evitamos repetir. Também descartamos // pontos colados no início (0) ou no fim da sequência: ali não existe clipe que // "atravesse estritamente" o instante, então não é split, é só a borda que já existe. // A margem é generosa (50ms) porque a duração real da sequência no Premiere e a // duração do arquivo dados-para-ia.json podem divergir por alguns ms (visto na // prática: 1018.2186875 no plano vs 1018.22554166667 na sequência) — perto da // borda isso é menos que 1 frame e o razor não consegue criar um pedaço válido ali. const EDGE_EPS = 0.05; const splitPoints = [...new Set(cuts.flatMap((c) => [c.start, c.end]))] .filter((t) => t > EDGE_EPS && t < sequenceDuration - EDGE_EPS) .sort((a, b) => a - b); console.error(`[apply-editorial-actions] Fase A: ${splitPoints.length * 2} splits...`); for (let i = 0; i < splitPoints.length; i++) { const time_seconds = splitPoints[i]; for (const trackArgs of [ { track_index: args.videoTrack, track_type: "video" }, { track_index: args.audioTrack, track_type: "audio" }, ]) { const result = await timeline.split_clip.handler({ time_seconds, ...trackArgs }); const errText = String(result?.error ?? ""); // Duas formas observadas de "esse ponto já foi dividido antes": (1) depois do // 1º split não sobra clipe que atravesse o instante ("strictly spans"); (2) o // ponto já coincide (por arredondamento de frame) com uma borda existente, então // o razor roda mas a contagem de clipes não muda ("from N to N, expected N+k"). const noopCounts = /changed the track clip count from (\d+) to (\d+)/i.exec(errText); const alreadySplit = result?.success === false && (/strictly spans/i.test(errText) || (noopCounts && noopCounts[1] === noopCounts[2])); if (alreadySplit) { // Reentrância: esse ponto já foi dividido numa tentativa anterior (o script // pode ser reexecutado depois de uma falha parcial). Não é erro, é um no-op. console.error(`[apply-editorial-actions] split_clip ${trackArgs.track_type} @${time_seconds} já existia — pulando.`); } else { unwrap(result, `split_clip ${trackArgs.track_type} @${time_seconds}`); } await sleep(BRIDGE_DELAY_MS); } reportProgress("Dividindo a timeline", ((i + 1) / splitPoints.length) * 35, `${i + 1} de ${splitPoints.length} pontos`); if ((i + 1) % 10 === 0 || i === splitPoints.length - 1) { console.error(`[apply-editorial-actions] Fase A: ${i + 1}/${splitPoints.length} pontos divididos`); } } const ordered = [...cuts].sort((a, b) => b.start - a.start); console.error(`[apply-editorial-actions] Fase B: removendo ${ordered.length} cortes (ordem decrescente)...`); let removed = 0; for (const cut of ordered) { const mid = (cut.start + cut.end) / 2; const videoClip = unwrap( await discovery.get_clip_at_position.handler({ time_seconds: mid, track_index: args.videoTrack, track_type: "video" }), `get_clip_at_position video @${mid}` ); unwrap( await timeline.remove_from_timeline.handler({ node_id: videoClip.nodeId, ripple: true }), `remove_from_timeline video ${videoClip.nodeId}` ); await sleep(BRIDGE_DELAY_MS); const audioClip = unwrap( await discovery.get_clip_at_position.handler({ time_seconds: mid, track_index: args.audioTrack, track_type: "audio" }), `get_clip_at_position audio @${mid}` ); unwrap( await timeline.remove_from_timeline.handler({ node_id: audioClip.nodeId, ripple: true }), `remove_from_timeline audio ${audioClip.nodeId}` ); await sleep(BRIDGE_DELAY_MS); removed++; reportProgress("Removendo os cortes", 35 + (removed / ordered.length) * 35, `${removed} de ${ordered.length} cortes`); if (removed % 10 === 0 || removed === ordered.length) { console.error(`[apply-editorial-actions] Fase B: ${removed}/${ordered.length} cortes removidos`); } } } // Fase C roda DEPOIS que a Fase B já rippou (encolheu) a timeline. As actions de // marker/zoom/text ainda trazem tempos em coordenada ORIGINAL do vídeo — é preciso // traduzir para a coordenada pós-corte antes de procurar clipe, senão os tempos das // ações que ficam depois de algum corte apontam para o lugar errado (ou além do fim // da sequência já encolhida), o que foi a causa real do travamento anterior. const sortedCutsForAdjust = [...cuts].sort((a, b) => a.start - b.start); console.error(`[apply-editorial-actions] Fase C: aplicando ${others.length} action(s) de marker/zoom/text...`); const phaseCBase = cuts.length > 0 ? 70 : 0; reportProgress("Aplicando zooms, textos e marcadores", phaseCBase, `0 de ${others.length} ações`); let applied = 0; const warnings = []; // Criada sob demanda, na primeira action "zoom" do plano — planos sem zoom não // ganham uma track extra à toa. let adjustmentTrackIndex = null; for (const action of others) { const adjStart = adjustTimeForCuts(action.start, sortedCutsForAdjust); const adjEnd = adjustTimeForCuts(action.end, sortedCutsForAdjust); const mid = (adjStart + adjEnd) / 2; try { if (action.kind === "marker") { unwrap( await markers.add_marker.handler({ time_seconds: adjStart, duration_seconds: Math.max(0, adjEnd - adjStart), name: action.params?.name ?? action.reason ?? "marker", comments: action.reason ?? "", }), `add_marker @${adjStart}` ); } else if (action.kind === "zoom") { const scale = action.params?.scale; if (typeof scale !== "number") { warnings.push(`zoom @${action.start} sem params.scale numérico — ignorado.`); continue; } const duration = adjEnd - adjStart; if (duration <= 0) { warnings.push(`zoom @${action.start} com duração inválida (${duration}s) — ignorado.`); continue; } // A camada de ajuste é criada uma vez, no topo da pilha de vídeo, e // reaproveitada por todas as actions "zoom" — não altera o clipe // original, então cortes/re-splits feitos na Fase A/B não a afetam. if (adjustmentTrackIndex === null) { const trackResult = unwrap( await tracks.add_track.handler({ track_type: "video", count: 1 }), "add_track (camada de ajuste para zoom)" ); adjustmentTrackIndex = trackResult.totalTracks - 1; await sleep(BRIDGE_DELAY_MS); } unwrap( await playhead.set_playhead_position.handler({ time_seconds: adjStart }), `set_playhead_position @${adjStart}` ); await sleep(BRIDGE_DELAY_MS); unwrap( await utility.add_adjustment_layer.handler({ track_index: adjustmentTrackIndex }), `add_adjustment_layer @${adjStart}` ); await sleep(BRIDGE_DELAY_MS); const adjClip = unwrap( await discovery.get_clip_at_position.handler({ time_seconds: adjStart + 0.01, track_index: adjustmentTrackIndex, track_type: "video", }), `get_clip_at_position (camada de ajuste) @${adjStart}` ); unwrap( await timeline.trim_clip.handler({ node_id: adjClip.nodeId, new_out_seconds: duration }), `trim_clip (camada de ajuste) @${adjStart}` ); await sleep(BRIDGE_DELAY_MS); unwrap( await timeline.move_clip.handler({ node_id: adjClip.nodeId, new_start_seconds: adjStart }), `move_clip (camada de ajuste) @${adjStart}` ); await sleep(BRIDGE_DELAY_MS); unwrap( await trackTargeting.set_clip_scale.handler({ node_id: adjClip.nodeId, scale: scale * 100 }), `set_clip_scale (camada de ajuste) ${adjClip.nodeId}` ); } else if (action.kind === "text") { // Sem API de scripting suportada para clipe de texto a partir de string crua // (ver dist/tools/text.js: add_text_overlay). Fica como marker para revisão manual. unwrap( await markers.add_marker.handler({ time_seconds: adjStart, duration_seconds: Math.max(0, adjEnd - adjStart), name: "TEXTO: " + String(action.params?.content ?? ""), comments: "Ação 'text' do plano — adicionar manualmente (import .srt/.vtt ou MOGRT). " + (action.reason ?? ""), color: 3, }), `add_marker(text) @${adjStart}` ); warnings.push(`text @${action.start} virou marker — Premiere não expõe API para criar clipe de texto direto.`); } } catch (err) { // Uma action de marker/zoom/text que falha (ex.: caiu numa junção estranha) // não deve derrubar o plano inteiro — os cortes já foram aplicados e valem // mais que um zoom a menos. Registra e segue. warnings.push(`${action.kind} @${action.start} falhou: ${err.message}`); } await sleep(BRIDGE_DELAY_MS); applied++; reportProgress( "Aplicando zooms, textos e marcadores", phaseCBase + (applied / (others.length || 1)) * (100 - phaseCBase), `${applied} de ${others.length} ações` ); } reportProgress("Conferindo o resultado", 100); const finalSeq = unwrap(await discovery.get_active_sequence.handler({}), "get_active_sequence"); console.log( JSON.stringify( { ok: true, cutsApplied: cuts.length, othersApplied: applied, warnings, finalSequenceEndSeconds: finalSeq.end, }, null, 2 ) ); } main().catch((err) => { console.error("[apply-editorial-actions] ERRO:", err.message); process.exit(1); });