← ブログに戻る

OpenCut classic を fork して MCP サーバーを入れた — Claude Code から動画エディタを操作するまでに踏んだ4つの罠

前の記事 OpenCut セットアップ難民は、まず opencut.app を開いてください で、書き直し版はまだ hello world だけで動画エディタが無いことを確認しました。今回はその続きです。archived な旧版 (classic) を fork して、MCP サーバー経由で Claude Code から動画編集を操作できるようにしました。

成果物: kenimo49/opencut-mcp v0.1.0 (MIT)。プロダクトページは /products/opencut-mcp/ にあります。デモ動画も同ページに埋め込みました。

書きながら「これはドキュメントに無いだろう」と思った罠が4つあり、それを潰した順に書きます。

OpenCut classic の EditorCore は最初から自動化向けだった

fork して最初にやったのは apps/web/src/core/index.ts を開くことでした。予想は「Zustand か Redux で state が分散していて、DOM 側から window にぶら下がっている store を捕まえる」だったのですが、開けたのは違いました。

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 { ... }
}

シングルトン + Manager 分割済み。それぞれの Manager が addTrack({ type, index })insertElement({ element, placement }) みたいな型付きの公開メソッドを持っていて、すべて CommandManager 経由で実行されるため undo/redo にそのまま載る。作者の意図は「UI 内部の設計を綺麗にする」だったと思うのですが、外から見ると「MCP ツール定義とほぼ 1 対 1 で貼れる API」でした。

つまり window.__editor = EditorCore.getInstance() を 1 行仕込むだけで、Playwright の page.evaluate から window.__editor.timeline.insertElement(...) を叩けます。fork に入れた Phase 1 パッチ は本当に 1 行の実効変更です。

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

NODE_ENV ガードを入れて本番ビルドでは無効化。この時点で 12 個の Manager と 9 個の Timeline メソッドが全部触れる状態になりました。設計を書き直した OpenCut チームに感謝です。

MCP サーバー (opencut-mcp) は Playwright を持ちながら stdio transport で MCP クライアントに繋がり、tool 呼び出しごとに page.evaluate を投げるだけの薄いプロセスです。全体像は opencut-mcp の READMEプロダクトページ に書きました。

罠① 空トラックは reactor が毎コマンドで削除する

最初に addTrack({ type: "audio" })insertElement({ ... placement: { mode: "explicit", trackId } }) の 2 段構えで叩いたら、2 段目が「Track not found」で落ちました。addTrack が返した trackId は正しく UUID なのに、 scenes.getActiveScene().tracks.audio が空です。

原因は apps/web/src/core/index.ts の EditorCore コンストラクタに仕掛けられた reactor でした。

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),
  };
  // ...pruned が違えば updateTracks
});

CommandManager.execute()command.execute() を呼んだ直後に登録されたすべての reactor を実行します。この reactor は「要素がないオーバーレイ / 音声トラックを消す」というものでした。

  • addTrack コマンドは新しい (空の) トラックをシーンに追加する
  • reactor が要素 0 の新しいトラックを検出して削除する
  • 私が返された trackId で insertElement を呼ぶ頃には、そのトラックはもう存在しない

素直な対処は「insertElement を placement: { mode: "auto", trackType: "audio" } で 1 段で叩く」です。auto placement は要素を挿入するときに必要なトラックをその場で作ります。要素が入っているので reactor に消されません。addTrack を直接叩く場面は「空トラックを事前に用意したい特殊ケース」だけで、そこは今回の MCP ツールには含めていません。

empty tracks are pruned という挙動は README にもコード内のコメントにも書かれておらず、実装の副作用として reactor 定義から読み取るしかありませんでした。将来 UI に「トラック追加」ボタンが載ったら「要素なしのプレースホルダを許す」設計変更が要りますが、現状の classic はそういう UI を持たないためこれで整合が取れています。

罠② MediaTime は秒ではなく整数ティック

トラック配置が通ったあと、動画クリップを挿入するために duration: 15.008 (アセットから取れた秒数) を渡したら、次のエラーで死にました。

Error: addMediaTime(): expected an integer tick count, got 15.008

apps/web/src/wasm/media-time.ts を読むと分かります。

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 は brand type つきの整数ティックで、Rust core (rust/crates/time/src/media_time.rsMediaTime(i64)) を反映しています。実行時の TICKS_PER_SECOND は 120000 でした (_TICKS_PER_SECOND() を wasm から呼び出して確認)。つまり 15.008 秒は 1,800,960 ティックです。

MediaAsset.duration は普通の秒 (float) で保持されているので、TimelineElement に渡す前に変換が要ります。変換関数 mediaTimeFromSeconds も wasm 側にあります。これを外部から触れるようにするために Phase 1.1 パッチ を追加して window.__opencut に露出させました。

w.__opencut = { TICKS_PER_SECOND, mediaTime, mediaTimeFromSeconds, roundMediaTime };

MCP ツール側では startTime / duration / trimStart / trimEnd / sourceDuration / splitAt.time / move.newStartTime の全部を window.__opencut.mediaTimeFromSeconds({ seconds }) に通してから insertElement などに渡します。境界を1箇所に集約したので、呼び出し側は素の秒で書けます。

罠③ AudioElement は discriminated union で silent reject する

動画クリップは通ったのに、音声クリップの挿入だけ何も起こりません。エラーも出ません。ただ tracks.audio が空のままです。

見つけるのに手間取ったのですが、InsertElementCommand.validateElementBasics に理由が書いてありました。

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;
  }
  // ...
}

AudioElement は Union です。

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

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

export type AudioElement = UploadAudioElement | LibraryAudioElement;

私が渡していたのは { type: "audio", mediaId, ... } だけ。sourceType が無いので判別が失敗し、validateElementBasicsconsole.error を吐いて false を返し、コマンドはそのまま何もせず終わる。CommandManager.execute は throw しないので、呼び出し側は成功したように見えます。

対処は sourceType: "upload" を必ず載せることです。MCP ツール側で elementType === "audio" のとき自動で足すようにしました。ついでに、コマンド実行の前後で console.error を hook して validationErrors を返り値に含めるようにしたので、次に silent reject が起きたときは呼び出し側にも見えます。

silent reject は「メッセージがあるだけ親切」とも言えますが、tool 経由で叩く側からするとエラーが返らないのは怖いパターンです。

罠④ Import の file input は display
で常に DOM に居る

メディアアップロードは Assets パネルの Import ボタン経由が想定されていますが、Playwright から実行するときに「Import をクリック → ファイルピッカーが開く → OS ダイアログを Playwright で操作する」を素直にやると壊れやすい。

apps/web/src/media/use-file-upload.ts を見ると、useFileUpload フックが常時 hidden な <input type="file"> をレンダリングしていました。

fileInputProps: {
  ref: inputRef,
  type: "file",
  style: { display: "none" },
  onChange: handleFileChange,
},

display: none でも DOM には居るので、page.locator('input[type="file"]').setInputFiles(path) で直接ファイルを流し込めます。Import ボタンのクリック経路は完全に skip して、UI が最終的に呼ぶ handleFileChange にファイルを届けることができました。

書いた MCP ツールの opencut_add_media は 60 行ほどですが、そのうち大半は「アップロード後、media.getAssets() に新しいアセットが現れるまで page.waitForFunction で待つ」の非同期完了検出でした。UI 操作より状態変化の同期の方が難しい、というのは一般則としても書けそうです。

12 個の MCP ツールとデモ

opencut-mcp v0.1.0 で公開している tool は次のとおりです。すべて Playwright + window.__editor 経由で、内部的には CommandManager を通ります。

  • opencut_get_state — タイムライン + アセット + 総尺のスナップショット
  • opencut_add_media — file input 経由でメディアをアップロード
  • opencut_insert_clip — auto-placement で要素とトラックを同時に配置
  • opencut_split_at — 指定秒で分割 (undo 可)
  • opencut_move / opencut_trim / opencut_delete
  • opencut_undo / opencut_redo
  • opencut_export — RendererManager.exportProject を await
  • opencut_screenshot — デバッグ用スクリーンショット
  • opencut_add_track — 明示追加 (罠①のため通常不要)

デモ動画は プロダクトページ に埋め込みました。10 秒の end-to-end 実行で「プロジェクト作成 → 動画アップロード → 音声アップロード → 別トラックに自動配置 → 5 秒で split」を Claude Code から MCP ツール呼び出しで回しています。手動クリックは 0 回です。

MCP サーバーの実装体感については前に書いた MCPで私が踏んだ地雷7つ接続したMCPサーバーの38%は認証なしだった も併読すると、動画エディタに MCP を差し込む際の攻撃面 (プロジェクトファイル読み書き / レンダリング処理の乗っ取り) の心構えができます。

使ってみたい人へ

opencut-mcp は kenimo49/opencut-classic の fork に対して動きます。本家 opencut-app/opencut-classic は archived で、window 露出を持っていません。

# 1. fork の classic を起動 (docker + bun dev:web が :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. opencut-mcp を別シェルで
git clone https://github.com/kenimo49/opencut-mcp.git
cd opencut-mcp && bun install
bunx tsx src/index.ts       # stdio transport

Claude Code / Cline / 任意の MCP クライアントの mcp 設定に opencut-mcp を registered server として登録すれば、Claude が opencut_insert_clip などを直接呼べます。fork は今後 data-testid や headless export のフックが追加される予定なので、commit 単位のペア追跡になります。

まとめ

  • OpenCut classic の EditorCore はシングルトン + Manager 分割で、外から触るための API 面が最初から揃っていました
  • window 露出は dev モード限定の 1 行 (Phase 1) + wasm time helper (Phase 1.1) の合計 2 commit
  • 4罠は reactor による空トラック prune / MediaTime = 整数ティック / AudioElement の discriminated union / file input が常に DOM に居る (display
    )
    。全部ドキュメントには無く、実装から読み取るしかありませんでした
  • opencut-mcp v0.1.0 は 12 tool、707 LOC、B(87) grade で公開済み (code-health-ops で計測)

このシリーズの次は ローカル LLM (Wan2GP + qwen) と繋いで動画編集アシスタントを自宅で完結させる 記事を書きます。opencut-mcp の add_media / add_text あたりに、Wan2GP で生成した画像やテロップを直接渡す構成になる予定です。

書き直し版 (opencut-app/opencut) が MCP を公式にサポートしたら、本記事の fork ルートは陳腐化します。それまでは opencut-mcp を空白埋めとして置いておきます。