OpenCut classic tem 63.8k estrelas e quase nenhuma automação. Um patch de uma linha muda isso, e daí saí com quatro armadilhas.
O editor de vídeo open source OpenCut coleciona 63.8k estrelas no GitHub, mas está em reescrita. A versão nova só serve hello world no navegador. O editor de vídeo em si só vive no classic (arquivado). Fiz o fork e coloquei um servidor MCP nele para que o Claude Code consiga operar a timeline sozinho. Publiquei como opencut-mcp v0.1.0.
Nesse caminho apareceram quatro armadilhas que ninguém documentou. Elas moram no código do editor, não no README. Vou detalhar cada uma na ordem em que apareceram, porque a leitura importa: o design que cria as armadilhas também é o que torna a automação viável.
Antes de entrar: os deliverables são kenimo49/opencut-mcp v0.1.0 (MIT) e a página do produto com o screencast está em kenimoto.dev/products/opencut-mcp/.
O EditorCore do classic já era feito para automação
Quando abri o apps/web/src/core/index.ts pela primeira vez, esperava algo espalhado por Zustand ou Redux, com o estado escondido em stores separadas que eu teria que caçar via DOM. Encontrei um singleton limpo com subdivisão por Manager:
export class EditorCore {
private static instance: EditorCore | null = null;
public readonly timeline: TimelineManager;
public readonly command: CommandManager;
public readonly playback: PlaybackManager;
public readonly scenes: ScenesManager;
public readonly project: ProjectManager;
public readonly media: MediaManager;
public readonly renderer: RendererManager;
// ...
static getInstance(): EditorCore { ... }
}
Cada Manager expõe métodos públicos tipados como addTrack({ type, index }) ou insertElement({ element, placement }). Todas as mutações passam pelo CommandManager, o que dá undo/redo de graça. A intenção do time provavelmente era separação interna, mas de fora parece uma superfície de API pronta para virar ferramentas MCP.
Ou seja: uma única linha window.__editor = EditorCore.getInstance() transforma o page.evaluate do Playwright numa ponte completa. O patch Phase 1 do fork tem exatamente uma linha de código funcional:
if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
(window as unknown as { __editor: EditorCore }).__editor =
EditorCore.getInstance();
}
O guard NODE_ENV mantém isso fora dos builds de produção. Nesse ponto os doze Managers e os nove métodos do TimelineManager já estavam acessíveis. O opencut-mcp em si é um processo fino em stdio transport que mantém uma sessão Playwright e traduz cada chamada MCP num page.evaluate.
Armadilha 1: trilhas vazias são removidas a cada comando
Primeiro bug. A sequência addTrack({ type: "audio" }) seguida de insertElement({ ..., placement: { mode: "explicit", trackId } }) falhava na segunda chamada com "Track not found". O addTrack devolvia um UUID válido, mas scenes.getActiveScene().tracks.audio continuava vazio.
A causa mora no construtor do EditorCore:
this.command.registerReactor(() => {
const activeScene = this.scenes.getActiveSceneOrNull();
if (!activeScene) return;
const tracks = activeScene.tracks;
const prunedTracks = {
...tracks,
overlay: tracks.overlay.filter((track) => track.elements.length > 0),
audio: tracks.audio.filter((track) => track.elements.length > 0),
};
// updateTracks caso o resultado seja diferente
});
O CommandManager.execute() roda todos os reactors registrados logo depois de command.execute(). Esse reactor específico remove trilhas de overlay e audio que estão sem elementos. Então:
addTrackacrescenta uma trilha nova (vazia) na cena- O reactor vê a trilha vazia e a remove
- Quando o
insertElementroda com o trackId devolvido, a trilha já sumiu
A saída é fazer numa chamada só: insertElement com placement: { mode: "auto", trackType: "audio" }. O auto-placement cria a trilha sob demanda enquanto insere o elemento; ela sobrevive ao reactor porque agora tem um elemento dentro.
Nada disso está no README nem em comentários. A leitura vem da definição do reactor cruzada com o sintoma. Se o OpenCut algum dia colocar um botão "New track" na interface, o reactor vai precisar de uma escape hatch para placeholders vazios. Como o classic não tem esse botão, o design é consistente do jeito que está.
Armadilha 2: MediaTime é tick inteiro, não segundo
Depois de resolver o posicionamento de trilha, passei duration: 15.008 (a duração real do asset em segundos) para insertElement e caí no seguinte:
Error: addMediaTime(): expected an integer tick count, got 15.008
O apps/web/src/wasm/media-time.ts explica:
export type MediaTime = number & { readonly __mediaTime: unique symbol };
function isMediaTime(value: number): value is MediaTime {
return Number.isInteger(value);
}
function requireMediaTime({ value, context }) {
if (!isMediaTime(value)) {
throw new Error(`${context}: expected an integer tick count, got ${value}`);
}
return value;
}
MediaTime é um tick inteiro com brand type, refletindo o MediaTime(i64) do Rust core em rust/crates/time/src/media_time.rs. Em runtime, TICKS_PER_SECOND vale 120000, então 15.008 segundos = 1.800.960 ticks.
O que quebra é o descasamento: MediaAsset.duration guarda segundos em float, mas todo TimelineElement espera ticks inteiros. A conversão precisa acontecer na fronteira. O wasm já expõe mediaTimeFromSeconds para isso, e eu adicionei ele no patch Phase 1.1:
w.__opencut = { TICKS_PER_SECOND, mediaTime, mediaTimeFromSeconds, roundMediaTime };
Do lado MCP, todos os argumentos temporais (startTime, duration, trimStart, trimEnd, sourceDuration, splitAt.time, move.newStartTime) passam por window.__opencut.mediaTimeFromSeconds({ seconds }) antes de tocar em insertElement. Concentrar a fronteira num ponto só deixa o chamador MCP trabalhar com segundos crus.
Armadilha 3: AudioElement é união discriminada e rejeita em silêncio
Clipes de vídeo entravam. Clipes de áudio simplesmente não faziam nada. Sem erro, sem exception, tracks.audio continuava vazio.
Levei tempo para achar a razão. O InsertElementCommand.validateElementBasics escreve em console.error e retorna false; o comando sai sem tocar em nada:
private validateElementBasics({ element }) {
if (requiresMediaId({ element }) && !("mediaId" in element)) {
console.error("Element requires mediaId");
return false;
}
if (
element.type === "audio" &&
element.sourceType === "library" &&
!element.sourceUrl
) {
console.error("Library audio element must have sourceUrl");
return false;
}
// ...
}
O AudioElement é união:
export interface UploadAudioElement extends BaseAudioElement {
sourceType: "upload";
mediaId: string;
}
export interface LibraryAudioElement extends BaseAudioElement {
sourceType: "library";
sourceUrl: string;
}
export type AudioElement = UploadAudioElement | LibraryAudioElement;
Eu passava { type: "audio", mediaId, ... }. Sem sourceType, o discriminador não bate em nenhum dos ramos, a validação retorna false, o comando encerra em silêncio. CommandManager.execute não lança exception, então o chamador acha que deu certo.
A correção tem duas partes:
- Sempre acrescentar
sourceType: "upload"no lado do MCP quandoelementType === "audio" - Enganchar
console.errorem volta da chamada do comando e devolver as mensagens capturadas comovalidationErrorsna resposta da ferramenta
O gancho é obrigatório. Ferramentas que falham em silêncio são piores do que ferramentas que lançam exception: o chamador não consegue distinguir "resultado vazio" de "seu input estava errado".
Armadilha 4: o file input do Import fica sempre no DOM, só com display:none
O upload de mídia, conceitualmente, passa pelo botão Import do painel de Assets. Se você tentar a rota óbvia (clicar em Import, esperar o file picker do sistema, dirigir o picker pelo Playwright), cai num caminho frágil e dependente de OS.
A leitura de apps/web/src/media/use-file-upload.ts revela o atalho:
fileInputProps: {
ref: inputRef,
type: "file",
style: { display: "none" },
onChange: handleFileChange,
},
O hook useFileUpload renderiza um <input type="file"> escondido o tempo todo. display: none não tira o elemento do DOM, então page.locator('input[type="file"]').setInputFiles(path) escreve direto nele. O caminho pelo botão Import some da conta; você aterrissa no mesmo handleFileChange que a UI acabaria acionando.
A ferramenta MCP opencut_add_media tem uns 60 LOC, e a maior parte cuida da detecção assíncrona de conclusão: esperar media.getAssets() receber o asset novo via page.waitForFunction. Sincronizar estado é mais difícil do que a interação em si, o que é um padrão recorrente em trabalho com Playwright.
As doze ferramentas MCP e a demo
Com as quatro armadilhas neutralizadas, o opencut-mcp v0.1.0 expõe doze ferramentas via stdio. Todas passam por Playwright + window.__editor e envolvem TimelineManager / MediaManager / RendererManager um-a-um:
opencut_get_state— snapshot de timeline, assets e duração totalopencut_add_media— sobe um arquivo local pelo file input escondidoopencut_insert_clip— auto-placement cria a trilha e insere o elemento num comando sóopencut_split_at— corta num tempo em segundos (com undo)opencut_move/opencut_trim/opencut_deleteopencut_undo/opencut_redoopencut_export— aguardaRendererManager.exportProjectopencut_screenshot— captura de tela para debugopencut_add_track— adição explícita (quase nunca usado por causa da armadilha 1)
O screencast está embutido na página do produto. Dez segundos de execução ponta a ponta: criar projeto, subir vídeo, subir áudio, colocar cada um numa trilha via auto-placement, cortar o vídeo aos 5 segundos. Tudo via chamadas MCP a partir do Claude Code, zero cliques manuais.
Rodando no seu ambiente
O opencut-mcp aponta para o fork kenimo49/opencut-classic, não para o upstream arquivado. O upstream não tem o patch de exposição.
# 1. Sobe o fork do classic (docker + bun dev:web na porta 3000)
git clone https://github.com/kenimo49/opencut-classic.git
cd opencut-classic
docker compose up -d db redis serverless-redis-http
bun install && bun run dev:web
# 2. Noutro shell, sobe o opencut-mcp
git clone https://github.com/kenimo49/opencut-mcp.git
cd opencut-mcp && bun install
bunx tsx src/index.ts # stdio transport
Aponte o config do seu cliente MCP para o binário do opencut-mcp e o Claude passa a chamar opencut_insert_clip direto. O fork vai se afastar mais do upstream conforme entram novos hooks (data-testid para wait/assert, headless-export toggle), então acompanhe os dois por commit por enquanto. Semver ainda não faz sentido aqui.
Fecho
- O EditorCore do OpenCut classic é singleton com subdivisão por Manager. A superfície de API para drivers externos já estava pronta.
- A exposição por window é uma linha (Phase 1) mais os helpers wasm (Phase 1.1). Dois commits no total, ambos limitados a dev mode.
- As quatro armadilhas são: pruning de trilhas vazias pelo reactor, MediaTime como tick inteiro, união discriminada de AudioElement com silent reject e file input sempre no DOM em display:none. Nenhuma delas está na documentação; a leitura sai do código.
opencut-mcp v0.1.0tem 12 ferramentas, 707 LOC e nota B(87) no code-health (kenimo49/code-health-ops)
Próximo da série: conectar o opencut-mcp a um LLM local (Wan2GP + qwen) para o loop de edição rodar inteiro na RTX 4070, sem chamadas para cloud. As ferramentas que precisam de conteúdo gerado (add_media para imagens, add_text para legendas) plugam direto na saída do Wan2GP.
Quando o reescrito do OpenCut lançar o servidor MCP oficial, essa rota via fork vira obsoleta. Até lá, o opencut-mcp preenche o espaço.