Yyatmita

Docker コンテナの Claude Code から Windows ホストの OBS を録画制御する

OBS WebSocket 5.x + simpleobsws + bind mount で、コンテナ内から OBS のシーン切替・録画開始/停止・ファイル回収まで一連でやる仕組み。NVENC 失敗・Session 0 isolation・ウィンドウタイトル固定のハマりどころ込み

Claude Codeサブエージェント検証#claude-code#obs#websocket#docker#automation

技術書に貼る demo 動画を、Claude Code に録らせる仕組みを組んだ。Docker コンテナの中で動いているエージェントから、Windows ホスト上の OBS Studio を WebSocket 経由で叩いて、シーン切替 → 録画開始 → 待機 → 停止 → ファイル回収まで全部やる。

uv run python /workspace/scripts/obs_record.py --scene "ブラウザ" --duration 30 --output m-§06-01

これ 1 行で /workspace/demos/m-§06-01.mkv が出てくる。本に貼りたい動画 34 本を、こうやって 1 本ずつ録っていく。

構成と、組み立てる過程で踏んだ地雷 4 つを残す。

全体像

[Docker コンテナ dev]
  Claude Code
    └─ obs_record.py  ─ uv run --script (PEP 723)
        │  simpleobsws
        ↓ WebSocket
[Windows ホスト]
  OBS Studio 31.x
    ├─ WebSocket Server (port 4455)
    ├─ Scene: "ブラウザ" / "terminal" / ...
    └─ RecordDirectory = D:\projects\my_home_docker\demos
                         = /workspace/demos/  (bind mount)

OBS 側の準備:

  • WebSocket Server を有効化(Tools → WebSocket Server Settings → Enable)
  • ポート 4455(デフォルト)
  • パスワードを発行してコピー(後で /workspace/.obs_pw に保存)
  • RecordDirectory を bind mount 配下に向ける(コンテナから直接読めるように)

/etc/hosts 的な話で、コンテナから Windows ホストへの接続は host.docker.internal を使う。だから WebSocket の URL は:

OBS_URL = "ws://host.docker.internal:4455"

Docker Desktop の WSL2 統合があれば、これだけで通る。

obs_record.py:PEP 723 で 1 ファイル完結

スクリプトは 1 ファイル。uv run の inline script metadata(PEP 723)で依存も同梱する。

# /// script
# requires-python = ">=3.10"
# dependencies = ["simpleobsws"]
# ///
"""OBS WebSocket 経由で demo 録画を回す CLI。"""
import asyncio
import simpleobsws
 
OBS_URL = "ws://host.docker.internal:4455"

これで pip install も venv も要らない。uv run python obs_record.py ... するだけで simpleobsws を取ってきて走る。エージェントから呼ぶときに依存周りで悩まないのが大事。

最小の流れ:

async def record(args):
    ws = simpleobsws.WebSocketClient(url=OBS_URL, password=read_password())
    await ws.connect()
    await ws.wait_until_identified(timeout=10)
 
    # シーン切替
    await call(ws, "SetCurrentProgramScene", {"sceneName": args.scene})
 
    # 録画開始
    await call(ws, "StartRecord")
 
    # active になるまで待つ
    if not await wait_for_active(ws, timeout=5):
        await call(ws, "StopRecord")
        return 5  # エンコーダ問題
 
    # 待機
    if args.manual_stop:
        await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline)
    else:
        await asyncio.sleep(args.duration)
 
    # 停止
    res = await call(ws, "StopRecord")
    raw_path = res.responseData.get("outputPath")

OBS WebSocket 5.x のリクエスト名は固定で SetCurrentProgramScene / StartRecord / StopRecord / GetRecordStatus あたりを使う。simpleobsws は薄いラッパーで、コルーチンと Request(name, data) をそのまま投げるだけ。

ポイントが 2 つある。

(1) 録画 active 待ち

StartRecord が成功を返しても、実際に出力が active になるまで微妙にタイムラグがある。さらに、エンコーダ初期化が失敗すると StartRecord が OK を返しても録画が始まらない(後述の NVENC 罠)。

なので GetRecordStatus をポーリングして outputActiveTrue になるまで待つ。タイムアウトしたら StopRecord で掃除して exit 5 で抜ける。これがないと「録画したつもりで 0 バイトのファイルが残る」事故が起きる。

async def wait_for_active(ws, timeout=5.0):
    start = asyncio.get_event_loop().time()
    while asyncio.get_event_loop().time() - start < timeout:
        await asyncio.sleep(0.5)
        res = await call(ws, "GetRecordStatus")
        if res.ok() and res.responseData.get("outputActive"):
            return True
    return False

(2) Windows パス → コンテナパス変換

StopRecord のレスポンスに含まれる outputPath は Windows のフルパス(D:/projects/my_home_docker/demos/...)で返ってくる。コンテナの中から触れるパスに直す必要がある。

if raw_path.startswith("D:/projects/my_home_docker/"):
    container_path = Path("/workspace") / Path(raw_path[len("D:/projects/my_home_docker/"):])

bind mount の対応関係がスクリプトの中にハードコードされるのは美しくないが、ここを抽象化しても得がない(環境変数化はやってもいい)。

その後 shutil.move/workspace/demos/{output}.mkv にリネームする。bind mount は書き込み直後に反映されないことが稀にあるので、await asyncio.sleep(2) を 1 回挟む保険を入れた。

Brave のウィンドウタイトルを iframe で固定する

ここからが Window Capture 周りの工夫。

OBS の Window Capture は、ターゲットウィンドウを {title}:{class}:{exe} の形式で識別する。

manginus-demo:Chrome_WidgetWin_1:brave.exe

title 部分は ページタイトル依存で変わる。http://localhost:8188 を開けば Unsaved Workflow - ComfyUIhttp://localhost:8000 を開けば manginus、と振れる。シーンを使い回すなら、ここを固定したい。

iframe ラッパーで挟むと安定する。launch-demo-brave.ps1 がやってるのはこれ:

$html = @"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>manginus-demo</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; }
iframe { width: 100vw; height: 100vh; border: 0; display: block; }
</style>
</head>
<body>
<iframe src="$Url"></iframe>
</body>
</html>
"@
[System.IO.File]::WriteAllText($wrapperFile, $html, [System.Text.Encoding]::UTF8)
 
Start-Process -FilePath $brave -ArgumentList @(
    "--app=file:///$wrapperFile",
    "--user-data-dir=C:\demo-profile-brave",
    "--window-size=1920,1080",
    "--window-position=0,0",
    "--no-first-run",
    "--no-default-browser-check"
)
  • <title>manginus-demo</title> ─ Brave のウィンドウタイトルをこれに固定(外側の HTML が親なので、iframe 内のタイトルは無視される)
  • --app=file:///... ─ chromeless モード(タブバー・URL バーなし)で起動
  • --user-data-dir=C:\demo-profile-brave ─ クリーンプロファイルで、ブックマークも履歴も汚さない
  • --window-size=1920,1080 ─ OBS の 1920x1080 キャンバスに 1:1 で乗る

これで OBS のシーンは "title=manginus-demo" 固定で参照できて、中身(iframe の src)だけ差し替える運用になる。

.\launch-demo-brave.ps1 http://localhost:8188      # ComfyUI
.\launch-demo-brave.ps1 http://localhost:8000      # manginus
.\launch-demo-brave.ps1 https://example.com -NoWrap  # X-Frame-Options で iframe 不可なサイト

OpenAI のダッシュボードなど、X-Frame-Options: DENY で iframe 拒否してくる相手は iframe で挟めない。その場合は -NoWrap で素の --app= 起動に逃げる(タイトルはページ依存になる)。

ハマった地雷 4 つ

地雷 1: NVENC が初期化失敗する

RTX 3090 を積んでいて、Windows のドライバも最新(32.0.15.9186)。それでも OBS の録画ボタンを押すとログにこれが出る:

[NVENC] Test process failed: unknown

理由は深追いしてないが、Windows 側の OBS は NVENC 初期化に失敗することがあるらしい。

エージェントから録画を仕掛けたとき、StartRecord が成功を返すのに outputActive が永遠に false のまま、というのが症状。wait_for_active のタイムアウトで exit 5 が返ってくる。

対症療法は WebSocket でエンコーダを切り替える。

await call(ws, "SetProfileParameter", {
    "parameterCategory": "Output",
    "parameterName": "Mode",
    "parameterValue": "Simple"
})
await call(ws, "SetProfileParameter", {
    "parameterCategory": "SimpleOutput",
    "parameterName": "RecEncoder",
    "parameterValue": "x264"
})

これでソフトウェアエンコーダにフォールバックする。CPU は食うが録画自体は安定する。

地雷 2: OBS は設定変更後に再起動が要る

WebSocket で SetProfileParameter を呼んでも、走っている OBS の挙動には反映されない。設定ファイル(.ini)は更新されるけれど、ランタイム側はまだ古い設定で動いている。

なので NVENC → x264 を切り替えた直後にもう一度 StartRecord しても、依然として NVENC で起動しようとして失敗する。OBS のメインウィンドウを一旦閉じて開き直すか、Profile を切り替え直す必要がある。

これは WebSocket では完結しない。人間が OBS の GUI から再起動するしかない。エージェントは「設定は書いたので、OBS を再起動してください」と人間に依頼するしか道がない。

地雷 3: Session 0 isolation で SSH から Brave を起動できない

最初は「OBS だけじゃなくて Brave 起動もエージェントに任せたい」と思って、SSH で Windows に入って Start-Process brave.exe ... を投げる構成を作った。SSH 接続は通る。コマンドも返ってくる。でも Brave は画面に出てこない

これは Windows の Session 0 isolation という仕組みで、サービス系のセッション(SSH デーモンが動く Session 0)と、ユーザーの GUI セッション(Session 1)は隔離されていて、Session 0 から起動したプロセスは Session 1 のデスクトップに描画されない。Brave は起動しているが、ユーザーの目には見えない。

これも WebSocket では解決しない。Brave は人間が手動で起動するか、Windows のスケジューラタスクで Session 1 のコンテキストから起動する必要がある。今回は割り切って 「Brave 起動は人間、その後の OBS 制御はエージェント」 の分業にした。

地雷 4: Window Capture のキャッシュ

シーンに登録した Window Capture ソースは、最初に登録したときのウィンドウ識別子を文字列としてキャッシュする。Brave のタイトルを manginus-demo に固定した後でも、以前のセッションで Unsaved Workflow - ComfyUI:Chrome_WidgetWin_1:brave.exe で登録していたソースは古い文字列のまま。新しい title を掴んでくれない。

WebSocket で更新する:

await call(ws, "SetInputSettings", {
    "inputName": "brave-source",  # ソース名
    "inputSettings": {
        "window": "manginus-demo:Chrome_WidgetWin_1:brave.exe",
        "priority": 1,  # 1 = タイトル一致を優先
        "method": 2     # 2 = WGC(Windows Graphics Capture)
    },
    "overlay": True
})

priority を 1 にしておくと、起動時にタイトル一致のウィンドウを探してくれる。これを忘れると、Brave を起動し直すたびに「ソースが真っ黒」になる事故が起きる。

ついでに OBS の 1920x1080 キャンバスに 1:1 で乗せるため、ソースの bounds を STRETCH に切り替えるのもやっておく:

await call(ws, "SetSceneItemTransform", {
    "sceneName": "ブラウザ",
    "sceneItemId": 1,
    "sceneItemTransform": {
        "boundsType": "OBS_BOUNDS_STRETCH",
        "boundsWidth": 1920,
        "boundsHeight": 1080,
    }
})

スキル化して 1 コマンド

これだけ揃ったら、Claude Code のスキルにラップする。/workspace/.claude/skills/obs-demo-record/SKILL.md:

---
name: obs-demo-record
description: |
  ホスト Windows 上の OBS Studio に WebSocket 経由で接続し、demo 録画を行う。
  シーン切替 → 録画 start → 待機 → stop → 出力ファイルを `/workspace/demos/` にリネームして保存。
  ユーザーが「demo を録画して」「OBS で録画」「§NN の動画作って」と言ったときに使う。
allowed-tools: Bash(uv run python /workspace/scripts/obs_record.py:*), Read
argument-hint: --scene <name> [--duration N | --manual-stop] [--output FILENAME]
---

これで Claude に「§06 のデモ動画録って」と言うだけで:

  1. スキルが発火
  2. シーン名(ブラウザ / terminal etc.)を Claude が選ぶ
  3. 出力ファイル名を本のセクション ID(m-§06-01)に揃える
  4. uv run で obs_record.py を起動
  5. 録画完了して /workspace/demos/m-§06-01.mkv が出てくる

人間の操作は Brave を最初に立ち上げるだけ。シーンの中身を変えたいなら launch-demo-brave.ps1 http://localhost:8000 で URL だけ差し替える。あとは Claude が WebSocket で全部やる。

何が嬉しいか

技術書の章にデモ動画を貼るのは、撮るコストが地味に高い。OBS を開いて、シーンを選んで、録画ボタンを押して、操作して、止めて、ファイル名を整理して、保存場所に移動して、参照を本文に書き戻す——1 本 5 分の動画を作るのに 15 分かかる。34 本あったら丸 1 日潰れる。

この仕組みだと、Claude に「§NN のデモ録って」と頼むと、Brave で操作するところ以外は全部やってくれる。人間がやるのは「いま操作する」ことだけで、ファイル管理は完全に消える

OBS WebSocket は本来配信向けに作られた API だけど、録画自動化と組み合わせると「デモ撮影パイプライン」として使い回せる。Docker 環境 + bind mount + PEP 723 + スキル化が揃うと、エージェント側からは「コマンド 1 個で動画 1 本」になる。

地雷は 4 つ踏んだ:

  1. NVENC 初期化失敗 → x264 にフォールバック
  2. 設定変更後は OBS 再起動が要る → 人間にお願い
  3. Session 0 isolation で SSH から GUI 起動不可 → Brave 起動は人間
  4. Window Capture のソース識別子はキャッシュされる → タイトル固定 + WebSocket で再設定

このうち 2 つ(OBS 再起動・Brave 起動)は WebSocket では解決しなくて、人間に残る。コンテナ内エージェントから Windows GUI を完全自動化する道は閉ざされている、というのが今回の収穫だった。それでも 34 本の動画を録るペースは劇的に速くなった。