DEV Community

Cover image for I Forked OpenCut classic to Put an MCP Server on It — Four Traps I Hit Before Claude Could Drive the Video Editor
Ken Imoto
Ken Imoto

Posted on • Originally published at kenimoto.dev

I Forked OpenCut classic to Put an MCP Server on It — Four Traps I Hit Before Claude Could Drive the Video Editor

In the previous post OpenCut setup refugees, open opencut.app first I confirmed that the rewrite is still a hello world! page with no editor. The video-editing UI only exists on the archived classic. This post is what happened next: I forked classic and put an MCP server on it so Claude Code can drive the timeline.

Deliverables: kenimo49/opencut-mcp v0.1.0 (MIT). Full product page with demo screencast at kenimoto.dev/products/opencut-mcp/.

Four things surprised me while implementing this. I'll walk them in the order I hit them.

OpenCut classic's EditorCore was already built for automation

The first thing I opened after cloning the fork was apps/web/src/core/index.ts. I expected the state to be scattered across Zustand or Redux stores that I'd have to fish out of the DOM. What I got was a clean singleton with Manager subdivision.

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 { ... }
}
Enter fullscreen mode Exit fullscreen mode

Every Manager exposes typed public methods like addTrack({ type, index }) or insertElement({ element, placement }), and everything routes through CommandManager — so undo/redo comes for free. The intent was probably a clean internal separation, but from the outside it reads as "an API surface that pastes one-to-one onto MCP tool definitions."

Meaning: a single window.__editor = EditorCore.getInstance() line is enough to let Playwright's page.evaluate call anything. The Phase 1 patch I put on the fork is one line of real code:

if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
  (window as unknown as { __editor: EditorCore }).__editor =
    EditorCore.getInstance();
}
Enter fullscreen mode Exit fullscreen mode

NODE_ENV gate keeps it out of production builds. At that point all twelve managers and the nine timeline methods were reachable. Credit to the OpenCut team for the redesign — the automation seam was accidental but not painful.

opencut-mcp itself is a thin stdio-transport process that holds a Playwright session and forwards every tool call as page.evaluate. Full breakdown lives in the opencut-mcp README and the product page.

Trap 1: empty tracks are pruned every command

First bug. Calling addTrack({ type: "audio" }) followed by insertElement({ ..., placement: { mode: "explicit", trackId } }) failed the second call with "Track not found". The addTrack return value is a valid UUID, but scenes.getActiveScene().tracks.audio is empty.

The cause is a reactor wired into the EditorCore constructor:

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 if pruned differs
});
Enter fullscreen mode Exit fullscreen mode

CommandManager.execute() runs every registered reactor right after command.execute(). This reactor removes overlay and audio tracks that have zero elements. So:

  1. addTrack adds a fresh (empty) track to the scene
  2. the reactor sees the new empty track and removes it
  3. by the time my insertElement fires with the returned trackId, that track no longer exists

The fix is one call: use insertElement with placement: { mode: "auto", trackType: "audio" }. Auto-placement creates the track on demand while inserting the element, so the track survives the reactor (it has an element in it now).

This behavior is not in the README or any comment. You read it from the reactor definition and correlate the failure. If OpenCut ever ships a "New track" button, the reactor would need an escape hatch for empty placeholders — but classic doesn't have that UI, so the current design is self-consistent.

Trap 2: MediaTime is integer ticks, not seconds

After track placement worked, I passed duration: 15.008 (the asset's own duration in seconds) to insertElement and got this:

Error: addMediaTime(): expected an integer tick count, got 15.008
Enter fullscreen mode Exit fullscreen mode

apps/web/src/wasm/media-time.ts explains it:

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;
}
Enter fullscreen mode Exit fullscreen mode

MediaTime is a branded integer-tick type mirroring the Rust core MediaTime(i64) in rust/crates/time/src/media_time.rs. TICKS_PER_SECOND is 120,000 at runtime, so 15.008 seconds is 1,800,960 ticks.

The mismatch is that MediaAsset.duration is plain seconds (float). Every time you cross into a TimelineElement, you convert. The wasm module ships mediaTimeFromSeconds for exactly this, and I exposed it in the Phase 1.1 patch:

w.__opencut = { TICKS_PER_SECOND, mediaTime, mediaTimeFromSeconds, roundMediaTime };
Enter fullscreen mode Exit fullscreen mode

On the MCP side, all time arguments (startTime, duration, trimStart, trimEnd, sourceDuration, splitAt.time, move.newStartTime) go through window.__opencut.mediaTimeFromSeconds({ seconds }) before touching insertElement or friends. Concentrating the boundary in one place means MCP callers can pass raw seconds.

Trap 3: AudioElement is a discriminated union, and it silent-rejects

Video clips inserted fine. Audio clips did nothing. No error, no throw, tracks.audio still empty.

The reason took a while to surface: InsertElementCommand.validateElementBasics writes console.error and returns false, and the command exits without doing anything.

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;
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

AudioElement is a union:

export interface UploadAudioElement extends BaseAudioElement {
  sourceType: "upload";
  mediaId: string;
}

export interface LibraryAudioElement extends BaseAudioElement {
  sourceType: "library";
  sourceUrl: string;
}

export type AudioElement = UploadAudioElement | LibraryAudioElement;
Enter fullscreen mode Exit fullscreen mode

I was passing { type: "audio", mediaId, ... }. No sourceType, so the discriminator resolves to neither branch, validation returns false, the command silently exits. CommandManager.execute does not throw, so the caller thinks it worked.

Fix has two halves:

  1. Always add sourceType: "upload" in the MCP tool when elementType === "audio"
  2. Hook console.error around the command call and return the captured messages as validationErrors in the tool response

The hook is essential. Tools that fail silently are worse than tools that throw — the caller has no way to tell "empty result" apart from "your input was wrong."

Trap 4: the Import file input is always mounted, just display:none

Media upload conceptually goes through the Assets panel's Import button. Naively that means "click Import, wait for the OS file picker, drive it with Playwright." That path is fragile and OS-dependent.

Reading apps/web/src/media/use-file-upload.ts shows the escape:

fileInputProps: {
  ref: inputRef,
  type: "file",
  style: { display: "none" },
  onChange: handleFileChange,
},
Enter fullscreen mode Exit fullscreen mode

The useFileUpload hook renders a hidden <input type="file"> all the time. display: none doesn't remove it from the DOM, so page.locator('input[type="file"]').setInputFiles(path) writes straight into it. The Import-button click path drops out entirely — you land on the same handleFileChange the UI would eventually reach.

The MCP tool opencut_add_media is about 60 lines, and most of them handle the async completion detection — waiting for media.getAssets() to gain the new asset via page.waitForFunction. State synchronization is harder than the interaction itself, which is a recurring pattern in Playwright work.

The twelve MCP tools

With the four traps handled, opencut-mcp v0.1.0 exposes twelve tools over stdio. All of them go through Playwright + window.__editor and wrap TimelineManager / MediaManager / RendererManager one-to-one:

  • opencut_get_state — timeline + assets + total duration snapshot
  • opencut_add_media — upload a local file through the hidden input
  • opencut_insert_clip — auto-placement creates the track and inserts the element in one command
  • opencut_split_at — split at a time in seconds (undoable)
  • opencut_move / opencut_trim / opencut_delete
  • opencut_undo / opencut_redo
  • opencut_export — awaits RendererManager.exportProject
  • opencut_screenshot — debug screenshot
  • opencut_add_track — explicit add (rarely used because of trap 1)

The demo screencast is embedded on the product page. Ten seconds end-to-end: project creation, video upload, audio upload, auto-placement on separate tracks, split the video at 5 seconds — all driven by MCP tool calls from Claude Code, zero manual clicks.

If you're wiring up MCP servers seriously, read The seven mines I stepped on with MCP and 38% of MCP servers I connected to have no auth first. The attack surface for a video-editor MCP is real (project file read/write, render-pipeline hijack), and building it without security context is a bad start.

Running it yourself

opencut-mcp targets the kenimo49/opencut-classic fork, not the archived upstream. The upstream doesn't have the window exposure patch.

# 1. Start the fork of classic (docker + bun dev:web on :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. In another shell, start opencut-mcp
git clone https://github.com/kenimo49/opencut-mcp.git
cd opencut-mcp && bun install
bunx tsx src/index.ts       # stdio transport
Enter fullscreen mode Exit fullscreen mode

Point your MCP client's config at the opencut-mcp binary and Claude can call opencut_insert_clip and friends directly. The fork will drift further from upstream as more hooks land (data-testid anchors for wait/assert, headless-export toggle), so track the two by commit rather than semver for now.

Wrap-up

  • OpenCut classic's EditorCore is a singleton with Manager subdivision — the API surface for external drivers was already there, no rewrite needed
  • Window exposure is one line for Phase 1, plus wasm helpers in Phase 1.1 — two commits total, both dev-mode-only
  • The four traps are empty-track pruning by the reactor, MediaTime as integer ticks, AudioElement discriminated union with silent reject, and the always-mounted display:none file input. None are in the docs; you read them out of the code.
  • opencut-mcp v0.1.0 ships as 12 tools, 707 LOC, and a B(87) code-health score (measured in kenimo49/code-health-ops)

Next in this series: connect opencut-mcp to a local LLM (Wan2GP + qwen) so the whole video-editing loop runs on the RTX 4070 without cloud calls. The tools that need generated content (add_media for images, add_text for captions) plug directly into Wan2GP output.

When the OpenCut rewrite ships its own MCP server, this fork route becomes obsolete. Until then, opencut-mcp fills the gap.

Top comments (0)