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
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user