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.rs の MediaTime(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 が無いので判別が失敗し、validateElementBasics が console.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_deleteopencut_undo/opencut_redoopencut_export— RendererManager.exportProject を awaitopencut_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 を空白埋めとして置いておきます。
この記事は役に立ちましたか?