Files
jhonny-editor/code/scripts/apply-editorial-actions.mjs
T
João Henrique b541f502ba feat: initial commit - Jhonny Editor
- Adicionado estrutura completa do projeto
- Configurado MCP server para Premiere Pro
- Adicionado documentação e skills
- Configurado Gitignore para o projeto
2026-09-08 09:59:31 -04:00

321 lines
14 KiB
JavaScript

#!/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" aplica um scale estático (set_clip_scale) no clipe do intervalo, sem rampa
* de entrada/saída. Suficiente para um punch-in simples; não é um Ken Burns animado.
* - "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 <actions.json> --sequence "<nome>"
* [--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";
// 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 <actions.json> --sequence <nome> [--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);
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 = [];
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 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 trackTargeting.set_clip_scale.handler({ node_id: videoClip.nodeId, scale: scale * 100 }),
`set_clip_scale ${videoClip.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);
});