- Alinhado o aplicador Python de plano de edição (aplicador_de_plano_de_edicao.py) com o script apply-editorial-actions.mjs de referência: no-op de split restrito aos padrões reais do bridge, pausa entre chamadas ao MCP, tolerância de borda de corte usa a posição real em vez de rejeitar desvio; adicionado backup automático da sequência (duplicate_sequence) e documentado o comando python code/engine/aplicar_plano_de_edicao.py como via alternativa ao painel CEP. Resumo: - 18 arquivos alterados - 5 novos - 13 modificados - 0 removidos 13 files changed, 704 insertions(+), 133 deletions(-) Arquivos: - .jhonny/analises.db - CLAUDE.md - code/engine/aplicar_plano_de_edicao.py - code/engine/editor/__init__.py - code/engine/editor/aplicador_de_plano_de_edicao.py - code/engine/editor/escrita/escrita_no_editor.py - code/engine/integracoes/premiere/__init__.py - code/engine/integracoes/premiere/cliente_mcp.py - code/engine/testes/duplos_de_premiere.py - code/engine/testes/test_aplicador_de_plano_de_edicao.py - code/scripts/apply-editorial-actions.mjs - code/src/tools/discovery.ts - code/src/tools/timeline.ts - code/engine/editor/backup_de_sequencia.py - code/engine/integracoes/premiere/cliente_mcp_com_pausa.py - code/engine/testes/test_backup_de_sequencia.py - code/engine/testes/test_cliente_mcp_com_pausa.py - code/engine/testes/test_escrita_no_editor.py
381 lines
17 KiB
JavaScript
381 lines
17 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" 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 <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";
|
|
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 <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);
|
|
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);
|
|
});
|