4

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:

  1. addTrack acrescenta uma trilha nova (vazia) na cena
  2. O reactor vê a trilha vazia e a remove
  3. Quando o insertElement roda 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:

  1. Sempre acrescentar sourceType: "upload" no lado do MCP quando elementType === "audio"
  2. Enganchar console.error em volta da chamada do comando e devolver as mensagens capturadas como validationErrors na 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 total
  • opencut_add_media — sobe um arquivo local pelo file input escondido
  • opencut_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_delete
  • opencut_undo / opencut_redo
  • opencut_export — aguarda RendererManager.exportProject
  • opencut_screenshot — captura de tela para debug
  • opencut_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.0 tem 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.

Carregando publicação patrocinada...
1

Meus 2 cents,

Parabens pela iniciativa !

Tenho acompanhado o que alguns MCP estao trazendo com programas de editoracao/criacao (Blender, Unreal, Maya) e estou impressionado: tem videos de demonstracao onde uma cidade inteira eh renderizada a partir de prompts no ClaudeCode - genial !

O teu fork vai na mesma direcao: facilita um bocado !

Repositorio devidamente starreado e forkeado (ate a versao definitiva) - obrigado por compartilhar !

Saude e Sucesso !


Este post foi favoritado via extensão TABNEWS FAVORITOS

Tem curiosidade sobre IA ? Da uma olhada no meu LIVRO: IA PARA ENGENHEIROS