# OBS 画面収録キット v1.0（Claude Code 自動セットアップ）

このファイルは、Claude Code に読み込ませることで **OBS Studio のインストール → 録画設定 → 動作確認**まで自動で進めてもらうためのキットです。
朝倉駿の顧問生向けに配布しています。朝倉が添削動画・講義収録・Claude Code に見せる作業録画で使っている設定を、そのまま再現します。

- **できあがるもの**: 「画面全体 ＋ 右下に自分のカメラ（ワイプ）＋ マイク音声」をボタン1つで録画できる OBS。録画は MP4 でデスクトップに保存
- **対応**: macOS 13 以降（Apple Silicon / Intel）、Windows 10・11
- **費用**: 0円（OBS Studio は無料）
- **所要時間**: 15〜20分（ダウンロード込み）
- **すでに OBS を使っている方**: 今の設定は消しません。別プロファイル「画面収録」を追加します

---

## Claude Code への指示

**このファイルを読んだ Claude Code は、ここから下を「あなたへの指示書」として実行してください。**

### あなたの役割

あなたはこのキットのインストーラーです。ユーザー（このキットを受け取った方）は料理家・講師などの事業者で、プログラミング経験はありません。次の原則を守ってください。

1. **日本語・敬語**で応答する。専門用語は避け、使う場合は一言で意味を添える
2. **STEP 0 → 8 の順に、1ステップずつ**進める。ステップを飛ばさない・まとめて実行しない
3. 作業は**同梱スクリプトのコマンドだけ**で行う。OBS の設定ファイルを自分で書かない・GUI 操作の自動化や別の方法に変えない（スクリプトは実機検証済み。手作りの設定は画面が映らない原因になります）
4. **ユーザーの操作が要る場面**（macOS の許可ダイアログ、Windows の UAC「はい」、管理者パスワード）は、何を押すかを具体的に伝えて待つ。代わりに操作しようとしない
5. 質問はまとめて並べず、**1つずつ会話で**聞く
6. コマンドは**毎回絶対パス**で実行する（コマンド実行のたびに作業フォルダが変わる環境があるため）
7. `ws_password` ファイルの中身（遠隔操作用パスワード）は表示しない
8. エラーが出たら、まず本キット末尾の「トラブルシューティング」を参照する

### STEP 0: 説明と確認（質問は1つだけ）

作業を始める前に、次の3点を**そのまま**伝えてください。

> 始める前に、このキットでやることを3つだけ共有します。
> 1. OBS Studio（無料の画面収録ソフト）が入っていなければインストールし、「画面全体＋右下に自分のカメラ＋マイク」を録画する設定を作ります
> 2. すでに OBS をお使いでも、今の設定はそのまま残します（「画面収録」という別プロファイルを足すだけ）
> 3. Mac の場合、途中で「画面収録」「カメラ」「マイク」の許可を求める画面が出ます。そこだけ手で「許可」を押していただきます

そのうえで、1つだけ聞いてください。

> 録画に自分の顔（右下の小さなカメラ映像＝ワイプ）を入れますか？ 入れる／入れない でお答えください。あとから変えられます。

- 入れる（既定）→ そのまま進む
- 入れない → STEP 4 と STEP 6 で「ワイプなし」のオプションを付ける

### STEP 1: キットのファイルを置く

キット用フォルダを作り、末尾「■ 同梱ファイル」の内容を**一字一句そのまま**書き出してください（zip 版で受け取っていて、フォルダにすでにファイルがある場合はそれを使います）。

| OS | フォルダ | 書き出すファイル |
|---|---|---|
| Mac | `~/obs-kit/` | `obs_kit_mac.sh`、`obs_kit_ws.py` |
| Windows | `%USERPROFILE%\obs-kit\`（例: `C:\Users\名前\obs-kit\`） | `obs_kit_win.ps1` |

- Windows の `obs_kit_win.ps1` は**わざと ASCII 文字だけ**で書かれています（日本語は `\u753b` のような形で埋め込み済み）。書き出すときに日本語へ直したり、整形したりしないでください
- 以下、コマンド例は Mac を `bash ~/obs-kit/obs_kit_mac.sh ...`、Windows を `powershell -ExecutionPolicy Bypass -File "C:\Users\名前\obs-kit\obs_kit_win.ps1" ...` と書きます。Windows は実際のパスに置き換えてください

### STEP 2: 環境チェック

```bash
bash ~/obs-kit/obs_kit_mac.sh check
```
```powershell
powershell -ExecutionPolicy Bypass -File "C:\Users\名前\obs-kit\obs_kit_win.ps1" check
```

出力の見方:
- `obs_installed=yes` → STEP 3 は飛ばして STEP 4 へ。`no` → STEP 3 へ
- `obs_running=yes` → 先に `quit` を実行して OBS を閉じる（設定は OBS 終了中にだけ書き換えます）
- Mac の `macos` が 12 以下 → OBS 32 は macOS 13 以降が必要です。「OBS 公式サイトから旧バージョン（30.x）を入れる必要がある」と伝えて、このキットの自動導入は終了します
- Mac の `python3=none` → STEP 6 の自動調整ができません。`xcode-select --install` を案内するか、STEP 6 を手動確認に切り替えます
- `screen`（録画サイズの元になる画面解像度）/ `cameras` / `default_mic` は参考情報です。Windows の `default_mic` は「OS の既定の入力デバイスを使う」の意味で、名前は出ません

### STEP 3: OBS をインストール（未導入のときだけ）

```bash
bash ~/obs-kit/obs_kit_mac.sh install
```
- Mac: Homebrew があれば `brew install --cask obs`、無ければ OBS 公式の DMG をダウンロードして `/Applications` にコピーします。管理者パスワードは不要です。数分かかります
- Windows: `winget` で入れます。途中で **UAC（ユーザーアカウント制御）の「はい」**をユーザーに押してもらってください。`winget` が無い場合は公式インストーラーをダウンロードして実行します（同じく UAC あり）

終わったら `check` をもう一度実行し、`obs_installed=yes` と `obs_version` を確認します。

### STEP 4: 設定ファイルを配置する

```bash
bash ~/obs-kit/obs_kit_mac.sh configure
```
```powershell
powershell -ExecutionPolicy Bypass -File "C:\Users\名前\obs-kit\obs_kit_win.ps1" configure
```

- ワイプを入れない場合: Mac `configure --wipe off` / Windows `configure -Wipe off`
- 出力の `canvas=`（録画サイズ）、`camera=`、`save_dir=`（保存先）、`backup=`（元の設定の退避先）をユーザーに一言で伝えてください
- この時点で、遠隔操作用の WebSocket（OBS 標準機能・自分の PC 内だけ・パスワード付き）が有効になります。STEP 8 の `finish` で無効に戻します

### STEP 5: 初回起動と許可（ユーザー操作あり）

```bash
bash ~/obs-kit/obs_kit_mac.sh launch
```

**Mac の場合**（重要）:
1. 初回は OBS 自身の「権限の確認」画面と、macOS の許可ダイアログ（カメラ／マイク／画面収録）が出ます。ユーザーに次を**そのまま**案内してください:
   > 「カメラ」「マイク」は **許可** を押してください。
   > 「画面収録」は **システム設定 › プライバシーとセキュリティ › 画面収録** で **OBS をオン**にしてください（ダイアログの「システム設定を開く」から行けます）。
   > 画面収録の許可は **OBS を再起動するまで効きません**。オンにしたら教えてください。
2. ユーザーが許可を終えたら `quit` → `launch` で OBS を起動し直します
3. DMG から入れた場合、最初に「インターネットからダウンロードされたアプリケーションです。開いてもよろしいですか?」が出ることがあります → 「開く」

**Windows の場合**: 通常は許可ダイアログなしで起動します。OBS の初期設定ウィザード（自動構成）は出ない設計ですが、もし出たら「キャンセル」で閉じてもらいます。

**共通**: 「セーフモードで起動しますか?」と出たら **「通常モードで起動」** を選んでもらいます（前回 OBS が異常終了したときに出る確認です）。

### STEP 6: 自動調整（ディスプレイ・カメラ・マイクを実機に合わせる）

OBS が起動した状態で実行します。

```bash
bash ~/obs-kit/obs_kit_mac.sh tune
```
```powershell
powershell -ExecutionPolicy Bypass -File "C:\Users\名前\obs-kit\obs_kit_win.ps1" tune
```
- ワイプなしなら Mac `tune --no-camera` / Windows `tune -NoCamera`
- OBS の起動を最大90秒待ってから、ディスプレイ（Mac は OS から UUID を取得、Windows はプライマリモニター）、カメラ、マイクを設定し、`preview.png`（キットフォルダ内）に今の画面を保存します

出力 JSON の見方:
- `display` … 使うディスプレイ。`camera` … 使うカメラ（`camera_frame` があれば映像が来ています）。`mic.using=default` … OS の既定マイク
- `video.canvas` … 録画サイズ。Windows でモニター解像度と違っていた場合は `canvas_fixed` で自動修正されます
- `preview` … スクリーンショットの保存先。**必ず Read で開いて中身を見て**、ユーザーに「画面全体が映り、右下に顔が出ています」のように**見えたものを**説明してください
- `screen_blank_ratio` が 0.97 超 または `screen_warning` あり … 画面が映っていません。Mac なら STEP 5 の「画面収録」許可が未反映です（許可 → `quit` → `launch` → `tune` をやり直す）。Windows なら OBS の「画面」ソースを右クリック › プロパティ › ディスプレイを選んでもらう
- `camera_warning` … カメラの映像がまだ来ていません。Zoom や FaceTime などがカメラを使っていないか確認してもらい、`tune` を再実行

### STEP 7: テスト録画（5秒）

```bash
bash ~/obs-kit/obs_kit_mac.sh test
```
```powershell
powershell -ExecutionPolicy Bypass -File "C:\Users\名前\obs-kit\obs_kit_win.ps1" test
```
- `recorded` のファイルをユーザーに再生してもらい（Mac は QuickTime、Windows はメディアプレーヤー）、「画面が映っている／右下に顔が出ている／声が入っている」の3点を確認してもらいます
- `ok=false` またはファイルが無い → トラブルシューティング「録画ファイルができない」

### STEP 8: 仕上げと使い方の説明

```bash
bash ~/obs-kit/obs_kit_mac.sh quit
bash ~/obs-kit/obs_kit_mac.sh finish
```
（Windows も同じく `quit` → `finish`。`finish` で遠隔操作用の WebSocket を無効に戻します）

最後に、使い方を次の内容で伝えてください（言い回しは自然に直して構いません）。

> **収録のしかた**
> 1. OBS を開く（プロファイル「画面収録」が選ばれた状態で開きます）
> 2. 右下の **「録画開始」** を押す → 話しながら画面を操作する → **「録画終了」**
> 3. デスクトップに「2026-09-13 17-18-33.mp4」のような日付名で保存されます
>
> **コツ**
> - 収録するときだけ OBS を起動し、終わったら終了してください（開きっぱなしはメモリを使います）
> - 添削なら Discord / LINE / Google ドライブ / 会員サイトに、その MP4 を渡すだけです
> - Claude Code に手順を覚えさせたいときは、録画した MP4 を渡して「この動画を見て手順に起こして」と頼めます
> - 画質・ワイプの位置・保存先は、Claude Code に「画質を上げて」「ワイプを左下に」のように頼めば変えられます（下の対応表）

---

## 使い方の調整（Claude Code の対応表）

ユーザーからこう言われたら、この手順で対応します。設定変更は **OBS 終了中に `configure` を実行 → `launch` → `tune` → `test`** の順です。`tune` で選んだデバイスは引き継がれます。

| 言われたら | Mac | Windows |
|---|---|---|
| 画質を上げて | `configure --bitrate 8000`（既定 5000kbps） | `configure -Quality HQ`（既定は Small＝「高品質、ファイルサイズ中」。HQ は「超高品質」） |
| 60fps にして | `configure --fps 60` | `configure -Fps 60` |
| ワイプを左下／右上／左上に | `configure --wipe lb` / `rt` / `lt` | `configure -Wipe lb` / `rt` / `lt` |
| ワイプを消して／入れて | `configure --wipe off` / `--wipe rb` | `configure -Wipe off` / `-Wipe rb` |
| 保存先を変えて | `configure --dir "/Users/名前/Movies"` | `configure -Dir "D:\rec"` |
| 今の設定を見せて | `status` | `status` |
| 前の設定に戻して | `restore`（最新のバックアップから戻す。日時指定は `restore 20260913_170659`） | `restore`（日時指定は `restore -Backup 20260913_170659`） |
| 録画のショートカットキー | OBS の 設定 › ホットキー › 「録画開始」「録画停止」に手動で登録（例: ⌘⇧R） | 同左（例: Ctrl+Shift+R） |
| マイクを変えて | OBS の「マイク」ソース › プロパティ › デバイス（既定は OS の入力デバイス） | 同左 |
| ファイルサイズを小さく | `configure --bitrate 3000` | `configure -Quality Small`（既定） |

複数の変更は 1回の `configure` にまとめられます（例: `configure --wipe lb --fps 60`）。

---

## トラブルシューティング

- **画面が真っ白／真っ黒（Mac）**: 画面収録の許可が未反映。システム設定 › プライバシーとセキュリティ › 画面収録 で OBS をオン → `quit` → `launch` → `tune`。許可済みでも直らないときは `tune` をもう一度（ディスプレイを再設定します）
- **画面が黒い（Windows）**: OBS の「画面」ソースを右クリック › プロパティ › ディスプレイを選ぶ。それでも黒いときは同じ画面の「キャプチャ方法」を「Windows 10 (1903 以降)」に
- **カメラが映らない**: Zoom / FaceTime / ブラウザがカメラを使っていないか確認 → `tune` を再実行。Windows は 設定 › プライバシーとセキュリティ › カメラ で「デスクトップ アプリにカメラへのアクセスを許可する」をオン
- **声が入らない**: OBS 下部の音声ミキサーで「マイク」がミュート（スピーカーに斜線）になっていないか。OS のサウンド設定で入力デバイスが正しいか
- **`tune` が「WebSocket に接続できません」**: OBS が起動しきっていない（30秒待って再実行）。OBS の ツール › WebSocket サーバー設定 で「サーバーを有効にする」が外れていたら `quit` → `configure` → `launch` で有効化し直す
- **Windows で `tune` / `test` が PowerShell のエラーで動かない**（`ClientWebSocket` や文字化けなど）: PowerShell 7 を入れて（`winget install --id Microsoft.PowerShell -e`）、`powershell` の代わりに `pwsh -ExecutionPolicy Bypass -File ...` で同じコマンドを実行する。標準の Windows PowerShell 5.1（Windows 10/11 の .NET 4.8）で動く想定ですが、実機未検証のためこの逃げ道を用意しています
- **`configure` が「OBS が起動中です」**: `quit` を実行してから
- **Homebrew が無い（Mac）**: `install` が自動で公式 DMG 経路に切り替えます。手動なら https://obsproject.com/ja/download
- **winget が無い（Windows）**: `install` が公式インストーラー経路に切り替えます。Microsoft Store で「アプリ インストーラー」を更新すると winget が使えるようになります
- **OBS が落ちた／「セーフモードで起動しますか?」**: 「通常モードで起動」。繰り返す場合は `tune` を使わず、OBS の画面でディスプレイとカメラを手で選ぶ
- **録画ファイルができない**: `status` で `save_dir` を確認。Mac は 設定 › 出力 › 録画 › エンコーダが「Apple VT H264 ハードウェアエンコーダ」で、古い Intel Mac で使えない場合は「x264」に変える
- **Retina で文字がぼやける（Mac）**: 出力解像度がキャンバスと同じになっているか（`status` の `canvas`）。既定で同じです
- **`python3` が無い（Mac）**: `xcode-select --install` でコマンドラインツールを入れると使えます。入れない場合、STEP 6 は「OBS の画面で映っているか目で確認」に置き換えます（ディスプレイは `configure` 時点で設定済みです）

---

## 仕組み（Claude Code が知っておくこと）

- **触るファイル**: OBS の設定フォルダ（Mac `~/Library/Application Support/obs-studio`、Windows `%APPDATA%\obs-studio`）の中の `basic/profiles/画面収録/`（`basic.ini`、Mac は `recordEncoder.json` も）、`basic/scenes/画面収録.json`、`user.ini` と `global.ini` の `[Basic]`（選択中のプロファイル名）と `[General] FirstRun=true`（初期設定ウィザードを出さない）、`plugin_config/obs-websocket/config.json`。変更前に `obs-kit/backup/<日時>/` へ退避し、`restore` で戻せます
- **録画設定（朝倉の実機と同じ）**: キャンバス＝画面の論理解像度（Retina は「見た目」の解像度）・出力＝同じ・30fps・Mac は Apple VT H264 ハードウェア 5000kbps（ABR・上限6000）・Windows は x264「高品質、ファイルサイズ中」・ハイブリッド MP4・AAC 48kHz ステレオ・保存先デスクトップ・ファイル名「年-月-日 時-分-秒」
- **シーン「画面＋カメラ」**: 画面（キャンバス全体にフィット）＋カメラ（幅はキャンバスの17%・右下、朝倉のレイアウトと同じ比率）＋マイク（OS 既定の入力）。Windows は「PC音声」（デスクトップ音声）も入ります。Mac の画面キャプチャはアプリの音声を含みます
- **`tune` の仕組み**: OBS 起動後に obs-websocket（OBS 標準・`127.0.0.1:4455`・パスワード認証）で ディスプレイ／カメラ／マイクを実デバイスに合わせ、スクリーンショットで確認します。`finish` で無効化します
- **Mac の注意（変更禁止）**: OBS 32.2 ＋ obs-websocket 5.7.4 では「画面」ソースのディスプレイ一覧を WebSocket から取得すると OBS が落ちます（実測）。そのため `tune` は一覧を取得せず、OS からディスプレイ UUID を直接取ります。また各ソースに `prev_ver` が無いと画面キャプチャが初期化されません（実測）。同梱スクリプトはこれらを織り込み済みなので、設定 JSON を自作しないでください
- **検証状況**: macOS 26 / OBS 32.2.2 / Apple Silicon（朝倉の実機）で通し検証済み。Windows は同じ仕組みで実装し、PowerShell 7.6 で構文・WebSocket 接続・設定生成を確認していますが、Windows 実機（PowerShell 5.1・winget・インストール先 `C:\Program Files\obs-studio`）は未検証です。STEP 6 の `preview.png` と STEP 7 のテスト録画で必ず確認し、詰まったらトラブルシューティングの PowerShell 7 の逃げ道を使ってください

---

## ■ 同梱ファイル

### `obs_kit_mac.sh`（Mac）

```bash
#!/usr/bin/env bash
# obs_kit_mac.sh — OBS 画面収録キット（Mac用）
#   使い方:  bash obs_kit_mac.sh <コマンド> [オプション]
#   コマンド: check | install | configure [--wipe rb|lb|rt|lt|off] [--bitrate 5000] [--fps 30] [--dir 保存先]
#             launch | tune [--no-camera] | test [--seconds 5] | quit | finish | status | restore [backup日時]
#   既存のOBS設定は壊さない: 別プロファイル「画面収録」を追加し、触るファイルは backup/ に退避する。
set -euo pipefail

NAME="${OBSKIT_NAME:-画面収録}"
KIT_DIR="${OBSKIT_DIR:-$HOME/obs-kit}"
CFG="${OBSKIT_CFG:-$HOME/Library/Application Support/obs-studio}"
APP="/Applications/OBS.app"
WSPW_FILE="$KIT_DIR/ws_password"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PY="$(command -v python3 || true)"

log() { printf '%s\n' "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
obs_ver() { defaults read "$APP/Contents/Info.plist" CFBundleShortVersionString 2>/dev/null || echo "?"; }
obs_running() { pgrep -x OBS >/dev/null 2>&1; }

# 画面の論理解像度（Retinaの「見た目の解像度」= キャンバスに使う値）。偶数に丸める
screen_res() {
  local r w h
  r=$(osascript -l JavaScript -e 'ObjC.import("AppKit"); var s=$.NSScreen.screens.objectAtIndex(0).frame.size; s.width+"x"+s.height' 2>/dev/null || true)
  if [ -z "$r" ] && [ -n "$PY" ]; then
    r=$(system_profiler SPDisplaysDataType -json 2>/dev/null | "$PY" -c '
import json,sys,re
d=json.load(sys.stdin)
for g in d.get("SPDisplaysDataType",[]):
    for m in g.get("spdisplays_ndrvs",[]):
        if m.get("spdisplays_main")=="spdisplays_yes":
            x=re.search(r"(\d+) x (\d+)", m.get("_spdisplays_resolution","")); print(x.group(1)+"x"+x.group(2)) if x else None
' 2>/dev/null || true)
  fi
  [ -n "$r" ] || r="1920x1080"
  w=${r%x*}; h=${r#*x}; w=${w%.*}; h=${h%.*}
  echo "$((w/2*2))x$((h/2*2))"
}
list_cameras() { # "UniqueID<TAB>名前"（OBS仮想カメラ・iPhone連携は除外）
  system_profiler SPCameraDataType 2>/dev/null | awk '
    /^    [^ ].*:$/ {name=$0; sub(/^ +/,"",name); sub(/:$/,"",name)}
    /Unique ID:/ {uid=$3; if (name !~ /OBS Virtual Camera/ && name !~ /iPhone/) print uid "\t" name}'
}
default_mic() {
  system_profiler SPAudioDataType 2>/dev/null | awk '/^        [^ ].*:$/{n=$0} /Default Input Device: Yes/{sub(/^ +/,"",n); sub(/:$/,"",n); print n; exit}'
}
# ini の [section] 内の key を置換（無ければ追加。section が無ければ末尾に作る）
ini_set() {
  local f="$1" s="$2" k="$3" v="$4"
  [ -f "$f" ] || printf '' > "$f"
  awk -v S="$s" -v K="$k" -v V="$v" '
    BEGIN{ins=0; done=0}
    /^\[/{ if(ins && !done){print K"="V; done=1} ins=($0=="["S"]") }
    { if(ins && index($0,K"=")==1){ if(!done){print K"="V; done=1}; next } print }
    END{ if(!done){ if(!ins){print "["S"]"} print K"="V } }
  ' "$f" > "$f.obskit.tmp" && mv "$f.obskit.tmp" "$f"
}
ini_get() { awk -v S="$2" -v K="$3" '/^\[/{ins=($0=="["S"]")} ins && index($0,K"=")==1{sub(/^[^=]*=/,""); print; exit}' "$1" 2>/dev/null || true; }
gen_uuid() { uuidgen | tr 'A-Z' 'a-z'; }
obs_ver_int() { # OBSのバージョンを整数化（32.2.2 → 537001986）。ソースの prev_ver に入れる（無いと旧形式扱いになり画面キャプチャが初期化されない）
  local v; v=$(obs_ver); v=${v%%-*}
  local a b c; IFS=. read -r a b c <<< "$v"
  case "$a" in ''|*[!0-9]*) echo 537001986; return;; esac
  echo $(( ${a:-32} * 16777216 + ${b:-0} * 65536 + ${c:-0} ))
}
gen_password() { printf '%s%s' "$(uuidgen)" "$(uuidgen)" | tr -d - | cut -c1-24; }

write_ws_config() { # $1 = true/false（WebSocketサーバの有効・無効）
  local pw
  mkdir -p "$KIT_DIR" "$CFG/plugin_config/obs-websocket"
  if [ -s "$WSPW_FILE" ]; then pw=$(cat "$WSPW_FILE"); else pw=$(gen_password); printf '%s' "$pw" > "$WSPW_FILE"; chmod 600 "$WSPW_FILE"; fi
  cat > "$CFG/plugin_config/obs-websocket/config.json" <<EOF
{
    "alerts_enabled": false,
    "auth_required": true,
    "first_load": false,
    "server_enabled": $1,
    "server_password": "$pw",
    "server_port": 4455
}
EOF
}

write_profile() { # W H FPS BITRATE DIR
  local W="$1" H="$2" FPS="$3" BR="$4" DIR="$5" maxbr
  maxbr=$((BR * 12 / 10))
  mkdir -p "$CFG/basic/profiles/$NAME"
  cat > "$CFG/basic/profiles/$NAME/basic.ini" <<EOF
[General]
Name=$NAME

[Video]
BaseCX=$W
BaseCY=$H
OutputCX=$W
OutputCY=$H
FPSType=0
FPSCommon=$FPS
ScaleType=lanczos
FPSInt=$FPS
FPSNum=$FPS
FPSDen=1
ColorFormat=NV12
ColorSpace=709
ColorRange=Partial
SdrWhiteLevel=300
HdrNominalPeakLevel=1000

[Output]
Mode=Advanced
FilenameFormatting=%CCYY-%MM-%DD %hh-%mm-%ss
DelayEnable=false
Reconnect=false

[AdvOut]
TrackIndex=1
RecType=Standard
RecTracks=1
RecEncoder=com.apple.videotoolbox.videoencoder.ave.avc
RecFormat2=hybrid_mp4
RecFilePath=$DIR
RecUseRescale=false
RecAudioEncoder=CoreAudio_AAC
AudioEncoder=CoreAudio_AAC
Track1Bitrate=128
RecSplitFile=false
RecRB=false
Encoder=obs_x264
ApplyServiceSettings=true

[SimpleOutput]
FilePath=$DIR
RecFormat2=hybrid_mp4
RecQuality=Small
RecEncoder=x264
ABitrate=160
UseAdvanced=false

[Audio]
SampleRate=48000
ChannelSetup=Stereo
MonitoringDeviceId=default
MonitoringDeviceName=既定
EOF
  cat > "$CFG/basic/profiles/$NAME/recordEncoder.json" <<EOF
{
    "rate_control": "ABR",
    "bitrate": $BR,
    "max_bitrate": $maxbr,
    "max_bitrate_window": 1.5,
    "limit_bitrate": true,
    "profile": "high",
    "keyint_sec": 2
}
EOF
}

source_common() { # 各ソース共通の末尾フィールド
  cat <<EOF
   "prev_ver": $PREV_VER, "mixers": 255, "sync": 0, "flags": 0, "volume": 1.0, "balance": 0.5, "enabled": true, "muted": false,
   "push-to-mute": false, "push-to-mute-delay": 0, "push-to-talk": false, "push-to-talk-delay": 0,
   "hotkeys": {}, "deinterlace_mode": 0, "deinterlace_field_order": 0, "monitoring_type": 0, "private_settings": {}
EOF
}
item_json() { # NAME UUID VISIBLE POSX POSY BOUNDS_TYPE BW BH ID SCALE_FILTER
  cat <<EOF
     {"name": "$1", "source_uuid": "$2", "visible": $3, "locked": false, "rot": 0.0, "align": 5,
      "bounds_type": $6, "bounds_align": 0, "bounds_crop": false,
      "crop_left": 0, "crop_top": 0, "crop_right": 0, "crop_bottom": 0, "id": $9, "group_item_backup": false,
      "pos": {"x": $4, "y": $5}, "scale": {"x": 1.0, "y": 1.0}, "bounds": {"x": $7, "y": $8},
      "scale_filter": "${10}", "blend_method": "default", "blend_type": "normal",
      "show_transition": {"duration": 0}, "hide_transition": {"duration": 0}, "private_settings": {}}
EOF
}

write_scene() { # W H WIPE
  local W="$1" H="$2" WIPE="$3" SCENE="画面＋カメラ"
  local u_scr u_cam u_mic u_scn cam_uid cam_name cam_vis cw ch mr mb mt cx cy camset
  PREV_VER=$(obs_ver_int)
  u_scr=$(gen_uuid); u_cam=$(gen_uuid); u_mic=$(gen_uuid); u_scn=$(gen_uuid)
  cam_uid=$(list_cameras | head -1 | cut -f1); cam_name=$(list_cameras | head -1 | cut -f2)
  camset='{}'; [ -n "$cam_uid" ] && camset="{\"device\": \"$cam_uid\", \"device_name\": \"$cam_name\"}"
  local disp_uuid="" scrset
  [ -n "$PY" ] && [ -f "$SCRIPT_DIR/obs_kit_ws.py" ] && disp_uuid=$("$PY" "$SCRIPT_DIR/obs_kit_ws.py" display 2>/dev/null || true)
  scrset='{"type": 0, "show_cursor": true, "hide_obs": true, "show_hidden_windows": false}'
  [ -n "$disp_uuid" ] && scrset="{\"type\": 0, \"display_uuid\": \"$disp_uuid\", \"show_cursor\": true, \"hide_obs\": true, \"show_hidden_windows\": false}"
  # ワイプ: 幅はキャンバスの17%、16:9。右下は右4.8%・下15.6%の余白（朝倉の配置と同じ比率）
  cw=$((W * 17 / 100)); ch=$((cw * 9 / 16)); mr=$((W * 48 / 1000)); mb=$((H * 156 / 1000)); mt=$((H * 4 / 100))
  cam_vis=true
  case "$WIPE" in
    rb) cx=$((W - mr - cw)); cy=$((H - mb - ch));;
    lb) cx=$mr; cy=$((H - mb - ch));;
    rt) cx=$((W - mr - cw)); cy=$mt;;
    lt) cx=$mr; cy=$mt;;
    off) cx=$((W - mr - cw)); cy=$((H - mb - ch)); cam_vis=false;;
    *) die "--wipe は rb / lb / rt / lt / off のいずれかです";;
  esac
  local tmp="$CFG/basic/scenes/$NAME.json.obskit.tmp"
  {
    echo '{'
    echo " \"name\": \"$NAME\","
    echo " \"current_scene\": \"$SCENE\", \"current_program_scene\": \"$SCENE\","
    echo " \"scene_order\": [{\"name\": \"$SCENE\"}],"
    echo ' "transition_duration": 300, "transitions": [], "saved_projectors": [], "groups": [], "modules": {},'
    echo ' "sources": ['
    echo "  {\"name\": \"画面\", \"uuid\": \"$u_scr\", \"id\": \"screen_capture\", \"versioned_id\": \"screen_capture\","
    echo "   \"settings\": $scrset,"
    source_common; echo '  },'
    echo "  {\"name\": \"カメラ\", \"uuid\": \"$u_cam\", \"id\": \"macos-avcapture\", \"versioned_id\": \"macos-avcapture\","
    echo "   \"settings\": $camset,"
    source_common; echo '  },'
    echo "  {\"name\": \"マイク\", \"uuid\": \"$u_mic\", \"id\": \"coreaudio_input_capture\", \"versioned_id\": \"coreaudio_input_capture\","
    echo '   "settings": {"device_id": "default"},'
    source_common; echo '  },'
    echo "  {\"name\": \"$SCENE\", \"uuid\": \"$u_scn\", \"id\": \"scene\", \"versioned_id\": \"scene\","
    echo '   "settings": {"custom_size": false, "id_counter": 3, "items": ['
    item_json "画面" "$u_scr" true 0 0 2 "$W" "$H" 1 "area"; echo ','
    item_json "マイク" "$u_mic" true 0 0 0 0 0 2 "disable"; echo ','
    item_json "カメラ" "$u_cam" "$cam_vis" "$cx" "$cy" 2 "$cw" "$ch" 3 "disable"
    echo '   ]},'
    source_common | sed 's/"mixers": 255/"mixers": 0/'; echo '  }'
    echo ' ],'
    echo " \"resolution\": {\"x\": $W, \"y\": $H},"
    echo ' "version": 2'
    echo '}'
  } > "$tmp"
  # 2回目以降: 前回 tune で選んだデバイス設定（画面・カメラ・マイク）を引き継ぐ
  local dst="$CFG/basic/scenes/$NAME.json"
  if [ -f "$dst" ] && [ -n "$PY" ]; then
    "$PY" - "$dst" "$tmp" <<'PYEOF'
import json, sys
old = json.load(open(sys.argv[1], encoding="utf-8")); new = json.load(open(sys.argv[2], encoding="utf-8"))
olds = {s["name"]: s for s in old.get("sources", [])}
for s in new["sources"]:
    o = olds.get(s["name"])
    if o and s["id"] != "scene" and o.get("settings"):
        merged = dict(o["settings"]); merged.update(s.get("settings") or {})  # 今回の検出値を優先し、足りない値だけ前回から引き継ぐ
        s["settings"] = merged
json.dump(new, open(sys.argv[2], "w", encoding="utf-8"), ensure_ascii=False, indent=1)
PYEOF
  fi
  if [ -n "$PY" ]; then "$PY" -c "import json,sys; json.load(open(sys.argv[1], encoding='utf-8'))" "$tmp" || die "生成したシーンJSONが不正です"; fi
  mv "$tmp" "$dst"
  echo "display_uuid=${disp_uuid:-unknown} camera=${cam_name:-none} wipe=$WIPE cam_pos=${cx},${cy} cam_size=${cw}x${ch}"
}

cmd_check() {
  echo "os=mac"
  echo "arch=$(uname -m)"
  echo "macos=$(sw_vers -productVersion)"
  if [ -d "$APP" ]; then echo "obs_installed=yes"; echo "obs_version=$(obs_ver)"; else echo "obs_installed=no"; fi
  echo "obs_running=$(obs_running && echo yes || echo no)"
  echo "homebrew=$(command -v brew || echo none)"
  echo "python3=${PY:-none}"
  echo "screen=$(screen_res)"
  echo "cameras=$(list_cameras | cut -f2 | paste -sd '|' - )"
  echo "default_mic=$(default_mic)"
  echo "config_dir=$CFG"
  echo "kit_dir=$KIT_DIR"
  echo "profile_exists=$([ -f "$CFG/basic/profiles/$NAME/basic.ini" ] && echo yes || echo no)"
}

cmd_install() {
  if [ -d "$APP" ]; then log "OBS はインストール済みです（$(obs_ver)）"; return 0; fi
  if command -v brew >/dev/null 2>&1; then
    log "Homebrew で OBS をインストールします（数分かかります）"
    brew install --cask obs
  else
    log "Homebrew が無いため、公式サイト(GitHub Releases)のDMGからインストールします"
    local asset url dmg mnt
    if [ "$(uname -m)" = "arm64" ]; then asset="macOS-Apple.dmg"; else asset="macOS-Intel.dmg"; fi
    # 2026-09 時点の資産名: OBS-Studio-<ver>-macOS-Apple.dmg / -macOS-Intel.dmg（GitHub Releases で確認済み）
    url=$(curl -fsSL https://api.github.com/repos/obsproject/obs-studio/releases/latest | grep -o "https://[^\"]*${asset}" | head -1)
    [ -n "$url" ] || die "DMGのURLを取得できません。https://obsproject.com/ja/download から手動でインストールしてください"
    mkdir -p "$KIT_DIR"; dmg="$KIT_DIR/obs-installer.dmg"
    log "ダウンロード中: $url"
    curl -L --progress-bar -o "$dmg" "$url"
    mnt=$(hdiutil attach -nobrowse -readonly "$dmg" | awk -F'\t' '/\/Volumes\//{print $NF}' | tail -1)
    [ -d "$mnt/OBS.app" ] || die "DMG内に OBS.app が見つかりません"
    cp -R "$mnt/OBS.app" /Applications/
    hdiutil detach "$mnt" -quiet || true
    rm -f "$dmg"
  fi
  [ -d "$APP" ] || die "インストールに失敗しました"
  log "OBS $(obs_ver) をインストールしました"
}

cmd_configure() {
  local wipe=rb bitrate=5000 fps=30 dir="$HOME/Desktop"
  while [ $# -gt 0 ]; do
    case "$1" in
      --wipe) wipe="$2"; shift 2;;
      --bitrate) bitrate="$2"; shift 2;;
      --fps) fps="$2"; shift 2;;
      --dir) dir="$2"; shift 2;;
      *) die "不明なオプション: $1";;
    esac
  done
  [ -d "$APP" ] || die "OBS が未インストールです。先に install を実行してください"
  obs_running && die "OBS が起動中です。先に quit を実行してください（設定ファイルはOBS終了中にだけ書き換えます）"
  [ -d "$dir" ] || die "保存先フォルダがありません: $dir"
  mkdir -p "$KIT_DIR" "$CFG/basic/profiles" "$CFG/basic/scenes" "$CFG/plugin_config/obs-websocket"
  local ts bk; ts=$(date +%Y%m%d_%H%M%S); bk="$KIT_DIR/backup/$ts"; mkdir -p "$bk"
  for f in user.ini global.ini; do [ -f "$CFG/$f" ] && cp "$CFG/$f" "$bk/$f"; done
  [ -f "$CFG/plugin_config/obs-websocket/config.json" ] && cp "$CFG/plugin_config/obs-websocket/config.json" "$bk/ws_config.json"
  [ -f "$CFG/basic/scenes/$NAME.json" ] && cp "$CFG/basic/scenes/$NAME.json" "$bk/scene.json"
  [ -f "$CFG/basic/profiles/$NAME/basic.ini" ] && cp "$CFG/basic/profiles/$NAME/basic.ini" "$bk/basic.ini"
  local res W H; res=$(screen_res); W=${res%x*}; H=${res#*x}
  write_profile "$W" "$H" "$fps" "$bitrate" "$dir"
  local sceneinfo; sceneinfo=$(write_scene "$W" "$H" "$wipe")
  for f in user.ini global.ini; do
    ini_set "$CFG/$f" General FirstRun true
    ini_set "$CFG/$f" Basic Profile "$NAME"
    ini_set "$CFG/$f" Basic ProfileDir "$NAME"
    ini_set "$CFG/$f" Basic SceneCollection "$NAME"
    if [ "$f" = "user.ini" ]; then ini_set "$CFG/$f" Basic SceneCollectionFile "$NAME.json"; else ini_set "$CFG/$f" Basic SceneCollectionFile "$NAME"; fi
  done
  write_ws_config true
  echo "configured=ok"
  echo "profile=$NAME"
  echo "canvas=${W}x${H} fps=$fps bitrate=${bitrate}kbps format=hybrid_mp4 encoder=AppleVT_H264"
  echo "$sceneinfo"
  echo "save_dir=$dir"
  echo "backup=$bk"
}

cmd_launch() {
  [ -d "$APP" ] || die "OBS が未インストールです"
  if obs_running; then log "OBS はすでに起動しています"; return 0; fi
  open -a OBS --args --profile "$NAME" --collection "$NAME" --disable-updater
  log "OBS を起動しました（プロファイル/シーンコレクション: $NAME）"
}

need_py() { [ -n "$PY" ] || die "python3 が見つかりません。Xcode Command Line Tools（xcode-select --install）を入れるか、OBSの画面で手動設定してください"; [ -f "$SCRIPT_DIR/obs_kit_ws.py" ] || die "obs_kit_ws.py が $SCRIPT_DIR にありません"; [ -s "$WSPW_FILE" ] || die "configure が未実行です"; }
# パスワードは環境変数で渡す（コマンドライン引数だと ps で見えるため）
run_ws()    { need_py; OBSKIT_WS_PASSWORD="$(cat "$WSPW_FILE")" "$PY" "$SCRIPT_DIR/obs_kit_ws.py" "$@"; }
cmd_tune()  { run_ws tune --name "$NAME" --out "$KIT_DIR" "$@"; }
cmd_test()  { run_ws test --out "$KIT_DIR" "$@"; }
cmd_shot()  { run_ws shot --out "$KIT_DIR" "$@"; }
cmd_info()  { run_ws info --out "$KIT_DIR" "$@"; }

cmd_quit() {
  obs_running || { log "OBS は起動していません"; return 0; }
  osascript -e 'tell application "OBS" to quit' >/dev/null 2>&1 || true
  local i; for i in $(seq 1 30); do obs_running || { log "OBS を終了しました"; return 0; }; sleep 1; done
  die "OBS が終了しません（録画中なら停止してから閉じてください）"
}

cmd_finish() {
  obs_running && die "OBS が起動中です。先に quit を実行してください"
  write_ws_config false
  log "セットアップ完了: 遠隔操作用の WebSocket を無効化しました（再有効化は configure を再実行）"
}

cmd_status() {
  echo "obs_installed=$([ -d "$APP" ] && echo yes || echo no) version=$(obs_ver)"
  echo "obs_running=$(obs_running && echo yes || echo no)"
  echo "selected_profile=$(ini_get "$CFG/user.ini" Basic Profile) collection=$(ini_get "$CFG/user.ini" Basic SceneCollection)"
  local p="$CFG/basic/profiles/$NAME/basic.ini"
  [ -f "$p" ] && echo "kit_profile=$NAME canvas=$(ini_get "$p" Video BaseCX)x$(ini_get "$p" Video BaseCY) fps=$(ini_get "$p" Video FPSCommon) save_dir=$(ini_get "$p" AdvOut RecFilePath)"
  [ -f "$CFG/basic/profiles/$NAME/recordEncoder.json" ] && echo "bitrate=$(grep -o '"bitrate": *[0-9]*' "$CFG/basic/profiles/$NAME/recordEncoder.json" | grep -o '[0-9]*$')kbps"
  [ -f "$CFG/plugin_config/obs-websocket/config.json" ] && echo "websocket_enabled=$(grep -o '"server_enabled": *[a-z]*' "$CFG/plugin_config/obs-websocket/config.json" | grep -o '[a-z]*$')"
  echo "backups=$(ls "$KIT_DIR/backup" 2>/dev/null | paste -sd ' ' - )"
}

cmd_restore() { # 指定バックアップ（省略時は最新）から user.ini / global.ini / WebSocket設定を戻す
  obs_running && die "OBS が起動中です。先に quit を実行してください"
  local ts="${1:-}" bk
  [ -n "$ts" ] || ts=$(ls "$KIT_DIR/backup" 2>/dev/null | sort | tail -1)
  bk="$KIT_DIR/backup/$ts"; [ -d "$bk" ] || die "バックアップがありません: $bk"
  for f in user.ini global.ini; do [ -f "$bk/$f" ] && cp "$bk/$f" "$CFG/$f"; done
  [ -f "$bk/ws_config.json" ] && cp "$bk/ws_config.json" "$CFG/plugin_config/obs-websocket/config.json"
  log "復元しました: $bk（プロファイル『$NAME』の実体は残しています。不要なら OBS の プロファイル > 削除 で消せます）"
}

cmd="${1:-}"; shift || true
case "$cmd" in
  check) cmd_check;;
  install) cmd_install;;
  configure) cmd_configure "$@";;
  launch) cmd_launch;;
  tune) cmd_tune "$@";;
  test) cmd_test "$@";;
  shot) cmd_shot "$@";;
  info) cmd_info "$@";;
  quit) cmd_quit;;
  finish) cmd_finish;;
  status) cmd_status;;
  restore) cmd_restore "$@";;
  *) sed -n '2,6p' "$0"; exit 1;;
esac
```

### `obs_kit_ws.py`（Mac・`tune` / `test` で使用）

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""obs_kit_ws.py — OBS起動後の自動調整・テスト録画（obs-websocket 5.x / 標準ライブラリのみ）

Mac 用（Windows は obs_kit_win.ps1 に同等の処理を内蔵）。monitor_capture の分岐は python3 のある Windows でも使えるよう残している。
パスワードは環境変数 OBSKIT_WS_PASSWORD か --password、無ければ <out>/ws_password から読む。

使い方:
  python3 obs_kit_ws.py tune   --password <pw> --name <プロファイル名> [--no-camera] [--out <dir>]
  python3 obs_kit_ws.py test   --password <pw> [--seconds 5]
  python3 obs_kit_ws.py shot   --password <pw> [--out <dir>]
  python3 obs_kit_ws.py info   --password <pw>

Mac / Windows 共通。OBSが起動していて obs-websocket が有効（configure で有効化済み）であること。
"""
import argparse, base64, hashlib, json, os, platform, random, socket, struct, sys, time

SOURCE_SCREEN = "画面"
SOURCE_CAMERA = "カメラ"
SOURCE_MIC = "マイク"
SOURCE_DESKTOP_AUDIO = "PC音声"


def mac_main_display_uuid():
    """macOS: メインディスプレイのUUID（OBSの screen_capture が使う値）を ctypes で取得。失敗時は空文字"""
    try:
        import ctypes
        cg = ctypes.CDLL("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")
        cs = ctypes.CDLL("/System/Library/Frameworks/ColorSync.framework/ColorSync")
        cf = ctypes.CDLL("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")
        cg.CGMainDisplayID.restype = ctypes.c_uint32
        f = cs.CGDisplayCreateUUIDFromDisplayID
        f.restype = ctypes.c_void_p
        f.argtypes = [ctypes.c_uint32]
        cf.CFUUIDCreateString.restype = ctypes.c_void_p
        cf.CFUUIDCreateString.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
        cf.CFStringGetCString.restype = ctypes.c_bool
        cf.CFStringGetCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_long, ctypes.c_uint32]
        u = f(cg.CGMainDisplayID())
        if not u:
            return ""
        s = cf.CFUUIDCreateString(None, u)
        buf = ctypes.create_string_buffer(64)
        if cf.CFStringGetCString(s, buf, 64, 0x08000100):
            return buf.value.decode()
    except Exception:  # noqa
        pass
    return ""


def png_blank_ratio(path, region=(0.0, 0.0, 0.6, 0.6)):
    """PNG（8bit RGB/RGBA・非インターレース）の指定領域が単色に近い割合を返す（1.0=真っ白/真っ黒など）。
    標準ライブラリのみ。解析できないときは None"""
    import zlib
    try:
        data = open(path, "rb").read()
        if data[:8] != b"\x89PNG\r\n\x1a\n":
            return None
        pos, idat, w, h, ctype, bitd, il = 8, b"", 0, 0, 0, 0, 0
        while pos < len(data):
            ln = struct.unpack(">I", data[pos:pos + 4])[0]; typ = data[pos + 4:pos + 8]; body = data[pos + 8:pos + 8 + ln]
            if typ == b"IHDR":
                w, h, bitd, ctype, _, _, il = struct.unpack(">IIBBBBB", body)
            elif typ == b"IDAT":
                idat += body
            elif typ == b"IEND":
                break
            pos += 12 + ln
        if bitd != 8 or il != 0 or ctype not in (2, 6):
            return None
        bpp = 3 if ctype == 2 else 4
        raw = zlib.decompress(idat)
        stride = w * bpp
        prev = bytearray(stride)
        rows = []
        i = 0
        for _ in range(h):
            ft = raw[i]; line = bytearray(raw[i + 1:i + 1 + stride]); i += 1 + stride
            for x in range(stride):
                a = line[x - bpp] if x >= bpp else 0
                b = prev[x]
                c = prev[x - bpp] if x >= bpp else 0
                if ft == 1: line[x] = (line[x] + a) & 255
                elif ft == 2: line[x] = (line[x] + b) & 255
                elif ft == 3: line[x] = (line[x] + ((a + b) >> 1)) & 255
                elif ft == 4:
                    pp = a + b - c; pa, pb, pc = abs(pp - a), abs(pp - b), abs(pp - c)
                    pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
                    line[x] = (line[x] + pr) & 255
            rows.append(bytes(line)); prev = line
        x0, y0, x1, y1 = int(w * region[0]), int(h * region[1]), int(w * region[2]), int(h * region[3])
        counts = {}
        total = 0
        for y in range(y0, y1, 4):
            r = rows[y]
            for x in range(x0, x1, 4):
                px = r[x * bpp:x * bpp + 3]
                key = (px[0] >> 4, px[1] >> 4, px[2] >> 4)
                counts[key] = counts.get(key, 0) + 1
                total += 1
        return (max(counts.values()) / total) if total else None
    except Exception:  # noqa
        return None


class WS:
    """RFC6455 の最小クライアント（テキストフレームのみ・分割フレーム対応）"""

    def __init__(self, host, port, timeout=15):
        self.sock = socket.create_connection((host, port), timeout=timeout)
        key = base64.b64encode(os.urandom(16)).decode()
        req = ("GET / HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
               "Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n"
               "Sec-WebSocket-Protocol: obswebsocket.json\r\n\r\n") % (host, port, key)
        self.sock.sendall(req.encode())
        resp = b""
        while b"\r\n\r\n" not in resp:
            chunk = self.sock.recv(4096)
            if not chunk:
                raise RuntimeError("WebSocket handshake failed (connection closed)")
            resp += chunk
        head, _, rest = resp.partition(b"\r\n\r\n")
        if b" 101 " not in head.split(b"\r\n")[0]:
            raise RuntimeError("WebSocket handshake failed: " + head.decode(errors="replace")[:200])
        self.buf = rest

    def _recv_exact(self, n):
        while len(self.buf) < n:
            chunk = self.sock.recv(65536)
            if not chunk:
                raise RuntimeError("connection closed by OBS")
            self.buf += chunk
        data, self.buf = self.buf[:n], self.buf[n:]
        return data

    def _send_frame(self, opcode, payload):
        header = bytes([0x80 | opcode])
        ln = len(payload)
        if ln < 126:
            header += bytes([0x80 | ln])
        elif ln < 65536:
            header += bytes([0x80 | 126]) + struct.pack(">H", ln)
        else:
            header += bytes([0x80 | 127]) + struct.pack(">Q", ln)
        mask = os.urandom(4)
        masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
        self.sock.sendall(header + mask + masked)

    def send(self, obj):
        self._send_frame(0x1, json.dumps(obj, ensure_ascii=False).encode("utf-8"))

    def recv(self):
        message = b""
        while True:
            b1, b2 = self._recv_exact(2)
            fin = b1 & 0x80
            opcode = b1 & 0x0F
            masked = b2 & 0x80
            ln = b2 & 0x7F
            if ln == 126:
                ln = struct.unpack(">H", self._recv_exact(2))[0]
            elif ln == 127:
                ln = struct.unpack(">Q", self._recv_exact(8))[0]
            mask = self._recv_exact(4) if masked else None
            payload = self._recv_exact(ln)
            if mask:
                payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
            if opcode == 0x8:
                code = struct.unpack(">H", payload[:2])[0] if len(payload) >= 2 else 0
                raise RuntimeError("closed by OBS (code %d) %s" % (code, payload[2:].decode(errors="replace")))
            if opcode == 0x9:
                self._send_frame(0xA, payload)
                continue
            if opcode in (0x1, 0x0):
                message += payload
                if fin:
                    return json.loads(message.decode("utf-8"))


class OBS:
    def __init__(self, password, host="127.0.0.1", port=4455, wait=90):
        deadline = time.time() + wait
        last = None
        while True:
            try:
                self.ws = WS(host, port)
                break
            except Exception as e:  # noqa
                last = e
                if time.time() > deadline:
                    raise RuntimeError("OBS の WebSocket (%s:%d) に接続できません: %s" % (host, port, last))
                time.sleep(2)
        hello = self.ws.recv()
        auth = None
        a = hello.get("d", {}).get("authentication")
        if a:
            secret = base64.b64encode(hashlib.sha256((password + a["salt"]).encode()).digest()).decode()
            auth = base64.b64encode(hashlib.sha256((secret + a["challenge"]).encode()).digest()).decode()
        self.ws.send({"op": 1, "d": {"rpcVersion": 1, "authentication": auth, "eventSubscriptions": 0}})
        ident = self.ws.recv()
        if ident.get("op") != 2:
            raise RuntimeError("OBS への認証に失敗しました（パスワード不一致の可能性）: %s" % json.dumps(ident)[:200])

    def req(self, rtype, data=None):
        rid = str(random.randrange(1 << 30))
        if os.environ.get("OBSKIT_DEBUG"):
            sys.stderr.write("[ws] -> %s %s\n" % (rtype, json.dumps(data or {}, ensure_ascii=False)[:200])); sys.stderr.flush()
        self.ws.send({"op": 6, "d": {"requestType": rtype, "requestId": rid, "requestData": data or {}}})
        while True:
            m = self.ws.recv()
            if m.get("op") == 7 and m["d"].get("requestId") == rid:
                st = m["d"]["requestStatus"]
                if not st.get("result"):
                    raise RuntimeError("%s failed: code=%s %s" % (rtype, st.get("code"), st.get("comment", "")))
                return m["d"].get("responseData") or {}

    # ---- helpers ----
    def inputs(self):
        return {i["inputName"]: i for i in self.req("GetInputList")["inputs"]}

    def prop_items(self, input_name, prop):
        return self.req("GetInputPropertiesListPropertyItems",
                        {"inputName": input_name, "propertyName": prop})["propertyItems"]

    def set_settings(self, input_name, settings):
        self.req("SetInputSettings", {"inputName": input_name, "inputSettings": settings, "overlay": True})

    def scene_items(self, scene):
        return self.req("GetSceneItemList", {"sceneName": scene})["sceneItems"]

    def set_item_enabled(self, scene, source_name, enabled):
        for it in self.scene_items(scene):
            if it["sourceName"] == source_name:
                self.req("SetSceneItemEnabled", {"sceneName": scene, "sceneItemId": it["sceneItemId"],
                                                 "sceneItemEnabled": bool(enabled)})
                return True
        return False

    def screenshot(self, source_name, path, width=960):
        r = self.req("GetSourceScreenshot", {"sourceName": source_name, "imageFormat": "png", "imageWidth": width})
        data = r["imageData"].split(",", 1)[1]
        with open(path, "wb") as f:
            f.write(base64.b64decode(data))
        return path


def out(obj):
    print(json.dumps(obj, ensure_ascii=False, indent=2))


def ensure_profile_and_collection(obs, name):
    changed = []
    p = obs.req("GetProfileList")
    if name in p["profiles"] and p["currentProfileName"] != name:
        obs.req("SetCurrentProfile", {"profileName": name}); changed.append("profile")
    c = obs.req("GetSceneCollectionList")
    if name in c["sceneCollections"] and c["currentSceneCollectionName"] != name:
        obs.req("SetCurrentSceneCollection", {"sceneCollectionName": name}); changed.append("collection")
    if changed:
        time.sleep(3)
    p = obs.req("GetProfileList"); c = obs.req("GetSceneCollectionList")
    return {"profile": p["currentProfileName"], "collection": c["currentSceneCollectionName"], "switched": changed}


def pick(items, avoid=()):
    """候補リストから最初の実デバイスを選ぶ（OBS仮想カメラ・iPhone連携などは避ける）"""
    for it in items:
        if not it.get("itemEnabled", True):
            continue
        n = str(it.get("itemName", ""))
        if not n.strip():
            continue
        if any(a.lower() in n.lower() for a in avoid):
            continue
        if it.get("itemValue") in ("", None, "DUMMY"):
            continue
        return it
    return None


def cmd_tune(args):
    obs = OBS(args.password, wait=args.wait)
    res = {"version": obs.req("GetVersion").get("obsVersion"), "platform": platform.system()}
    res["current"] = ensure_profile_and_collection(obs, args.name)
    inputs = obs.inputs()
    scenes = obs.req("GetSceneList")
    scene = scenes["currentProgramSceneName"]
    res["scene"] = scene
    missing = [n for n in (SOURCE_SCREEN, SOURCE_MIC) if n not in inputs]
    if missing:
        raise RuntimeError("シーンに次のソースがありません: %s（configure をやり直してください）" % missing)

    # --- 画面（ディスプレイ）---
    kind = inputs[SOURCE_SCREEN]["inputKind"]
    if kind == "screen_capture":           # macOS: UUIDをOSから直接取得（一覧取得はOBS 32.2/obs-websocket 5.7.4 でクラッシュするため使わない）
        uuid = args.display_uuid or mac_main_display_uuid()
        if uuid:
            obs.set_settings(SOURCE_SCREEN, {"type": 0, "display_uuid": uuid, "show_cursor": True, "hide_obs": True})
            res["display"] = {"uuid": uuid, "how": "CGMainDisplayID"}
        else:
            res["display"] = {"warning": "ディスプレイUUIDを取得できません。OBSの『画面』ソースのプロパティでディスプレイを選んでください"}
    elif kind == "monitor_capture":        # Windows: 一覧からプライマリを選ぶ
        items = obs.prop_items(SOURCE_SCREEN, "monitor_id")
        chosen = None
        for it in items:
            n = str(it.get("itemName", ""))
            if "primary" in n.lower() or "プライマリ" in n or "主" in n:
                chosen = it; break
        chosen = chosen or pick(items)
        if chosen:
            obs.set_settings(SOURCE_SCREEN, {"monitor_id": chosen["itemValue"], "method": 0, "capture_cursor": True})
            res["display"] = {"name": chosen["itemName"], "candidates": len(items)}
            import re
            m = re.search(r"(\d{3,5})\s*[x×]\s*(\d{3,5})", str(chosen["itemName"]))
            if m:
                w, h = int(m.group(1)) // 2 * 2, int(m.group(2)) // 2 * 2
                v = obs.req("GetVideoSettings")
                if (v["baseWidth"], v["baseHeight"]) != (w, h):
                    obs.req("SetVideoSettings", {"baseWidth": w, "baseHeight": h, "outputWidth": w, "outputHeight": h})
                    res["canvas_fixed"] = {"from": [v["baseWidth"], v["baseHeight"]], "to": [w, h]}
        else:
            res["display"] = {"error": "ディスプレイ候補が取得できません", "candidates": len(items)}

    # --- カメラ ---
    if SOURCE_CAMERA in inputs:
        if args.no_camera:
            obs.set_item_enabled(scene, SOURCE_CAMERA, False)
            res["camera"] = "hidden (--no-camera)"
        else:
            kind = inputs[SOURCE_CAMERA]["inputKind"]
            prop = "device" if kind.startswith("macos-avcapture") else "video_device_id"
            cur = obs.req("GetInputSettings", {"inputName": SOURCE_CAMERA}).get("inputSettings", {})
            items = [] if (kind.startswith("macos-avcapture") and cur.get("device")) else obs.prop_items(SOURCE_CAMERA, prop)
            chosen = pick(items, avoid=("OBS Virtual Camera", "OBS-Camera", "iPhone", "Snap Camera", "NVIDIA Broadcast", "デスクビュー", "Desk View"))
            if not items and cur.get("device"):
                obs.set_item_enabled(scene, SOURCE_CAMERA, True)
                res["camera"] = {"name": cur.get("device_name", ""), "how": "configure時に検出済み"}
            elif chosen:
                settings = {prop: chosen["itemValue"]}
                if prop == "device":
                    settings["device_name"] = chosen["itemName"]
                obs.set_settings(SOURCE_CAMERA, settings)
                obs.set_item_enabled(scene, SOURCE_CAMERA, True)
                res["camera"] = {"name": chosen["itemName"], "candidates": len(items)}
            else:
                obs.set_item_enabled(scene, SOURCE_CAMERA, False)
                res["camera"] = {"warning": "カメラが見つからないためワイプを非表示にしました", "candidates": len(items)}

    # --- カメラのフレーム確認（直前まで別プロセスが使っていた等で映らない場合は、デバイスを再設定して待つ）---
    if SOURCE_CAMERA in inputs and not args.no_camera:
        for k in range(3):
            time.sleep(3)
            ci = next((it for it in obs.scene_items(scene) if it["sourceName"] == SOURCE_CAMERA), None)
            if not ci:
                break
            t = obs.req("GetSceneItemTransform", {"sceneName": scene, "sceneItemId": ci["sceneItemId"]})["sceneItemTransform"]
            if t.get("sourceWidth", 0) > 0:
                res["camera_frame"] = "%dx%d" % (t["sourceWidth"], t["sourceHeight"])
                break
            cur = obs.req("GetInputSettings", {"inputName": SOURCE_CAMERA}).get("inputSettings", {})
            key = "device" if inputs[SOURCE_CAMERA]["inputKind"].startswith("macos-avcapture") else "video_device_id"
            if cur.get(key):
                obs.set_settings(SOURCE_CAMERA, {key: cur[key]})
            if k == 2:
                res["camera_warning"] = "カメラの映像がまだ来ていません（他のアプリが使用中か、起動直後）。OBSの画面でワイプが映るか確認してください"

    # --- マイク ---
    items = obs.prop_items(SOURCE_MIC, "device_id")
    res["mic"] = {"using": "default", "candidates": [i["itemName"] for i in items][:8]}
    obs.set_settings(SOURCE_MIC, {"device_id": "default"})
    if SOURCE_DESKTOP_AUDIO in inputs:
        obs.set_settings(SOURCE_DESKTOP_AUDIO, {"device_id": "default"})

    v = obs.req("GetVideoSettings")
    res["video"] = {"canvas": [v["baseWidth"], v["baseHeight"]], "output": [v["outputWidth"], v["outputHeight"]],
                    "fps": v["fpsNumerator"] / max(1, v["fpsDenominator"])}
    res["record_dir"] = obs.req("GetRecordDirectory").get("recordDirectory")
    time.sleep(2)
    os.makedirs(args.out, exist_ok=True)
    res["preview"] = obs.screenshot(scene, os.path.join(args.out, "preview.png"))
    ratio = png_blank_ratio(res["preview"])
    if ratio is not None:
        res["screen_blank_ratio"] = round(ratio, 3)
        if ratio > 0.97:
            res["screen_warning"] = ("画面部分がほぼ単色です。Macなら『システム設定 > プライバシーとセキュリティ > 画面収録』で OBS を許可して "
                                     "OBS を再起動、Windowsなら『画面』ソースのプロパティでディスプレイを選んでください")
    out(res)


def cmd_test(args):
    obs = OBS(args.password, wait=args.wait)
    st = obs.req("GetRecordStatus")
    if st.get("outputActive"):
        raise RuntimeError("すでに録画中です。OBSで録画を停止してからやり直してください")
    obs.req("StartRecord")
    time.sleep(args.seconds)
    r = obs.req("StopRecord")
    path = r.get("outputPath")
    for _ in range(20):  # ファイル確定待ち
        if path and os.path.exists(path) and os.path.getsize(path) > 0:
            break
        time.sleep(0.5)
    size = os.path.getsize(path) if path and os.path.exists(path) else 0
    out({"recorded": path, "bytes": size, "seconds": args.seconds,
         "ok": bool(size > 10000), "record_dir": obs.req("GetRecordDirectory").get("recordDirectory")})


def cmd_shot(args):
    obs = OBS(args.password, wait=args.wait)
    scene = obs.req("GetSceneList")["currentProgramSceneName"]
    os.makedirs(args.out, exist_ok=True)
    p = obs.screenshot(scene, os.path.join(args.out, "preview.png"))
    out({"preview": p, "screen_blank_ratio": png_blank_ratio(p)})


def cmd_display(args):
    print(mac_main_display_uuid())


def cmd_raw(args):
    obs = OBS(args.password, wait=args.wait)
    out(obs.req(args.request, json.loads(args.data or "{}")))


def cmd_info(args):
    obs = OBS(args.password, wait=args.wait)
    v = obs.req("GetVideoSettings")
    out({"version": obs.req("GetVersion").get("obsVersion"),
         "profile": obs.req("GetProfileList")["currentProfileName"],
         "collection": obs.req("GetSceneCollectionList")["currentSceneCollectionName"],
         "scene": obs.req("GetSceneList")["currentProgramSceneName"],
         "inputs": [(i["inputName"], i["inputKind"]) for i in obs.req("GetInputList")["inputs"]],
         "canvas": [v["baseWidth"], v["baseHeight"]], "fps": v["fpsNumerator"],
         "recording": obs.req("GetRecordStatus").get("outputActive"),
         "record_dir": obs.req("GetRecordDirectory").get("recordDirectory")})


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("cmd", choices=["tune", "test", "shot", "info", "raw", "display"])
    ap.add_argument("--request", default="GetVersion")
    ap.add_argument("--data", default="{}")
    ap.add_argument("--password", default=os.environ.get("OBSKIT_WS_PASSWORD", ""))
    ap.add_argument("--name", default="画面収録")
    ap.add_argument("--no-camera", action="store_true")
    ap.add_argument("--display-uuid", default="", help="macOS: 使うディスプレイのUUIDを指定（省略時はメインディスプレイ）")
    ap.add_argument("--seconds", type=int, default=5)
    ap.add_argument("--wait", type=int, default=90, help="OBS起動を待つ秒数")
    ap.add_argument("--out", default=os.path.join(os.path.expanduser("~"), "obs-kit"))
    args = ap.parse_args()
    if not args.password and os.path.exists(os.path.join(args.out, "ws_password")):
        args.password = open(os.path.join(args.out, "ws_password"), encoding="utf-8").read().strip()
    try:
        {"tune": cmd_tune, "test": cmd_test, "shot": cmd_shot, "info": cmd_info, "raw": cmd_raw, "display": cmd_display}[args.cmd](args)
    except Exception as e:  # noqa
        out({"error": str(e)})
        sys.exit(1)


if __name__ == "__main__":
    main()
```

### `obs_kit_win.ps1`（Windows）

```powershell
# obs_kit_win.ps1 - OBS screen-recording kit (Windows)
#   Usage:  powershell -ExecutionPolicy Bypass -File obs_kit_win.ps1 <command> [options]
#   Commands: check | install | configure [-Wipe rb|lb|rt|lt|off] [-Quality Small|HQ] [-Fps 30] [-Dir <folder>]
#             launch | tune [-NoCamera] | test [-Seconds 5] | shot | info | quit | finish | status | restore [-Backup <stamp>]
#   Existing OBS settings are kept: a separate profile/scene collection is added and touched files are backed up.
#   NOTE: this file is pure ASCII on purpose. Japanese strings are embedded as \uXXXX escapes (U 'xxx'),
#         because Windows PowerShell 5.1 reads BOM-less files as ANSI and would garble Japanese literals.
param(
  [Parameter(Position = 0)][string]$Command = "",
  [string]$Wipe = "rb",
  [string]$Quality = "Small",
  [int]$Fps = 30,
  [string]$Dir = "",
  [switch]$NoCamera,
  [int]$Seconds = 5,
  [int]$Wait = 90,
  [string]$Backup = "",
  [string]$Request = "GetVersion",
  [string]$Data = "{}",
  [string]$Name = "",
  [string]$KitDir = "",
  [string]$ConfigDir = ""
)
$ErrorActionPreference = "Stop"
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
try { $OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
function U([string]$s) { return [regex]::Unescape($s) }

$HomeDir = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME }
if (-not $Name)      { $Name = if ($env:OBSKIT_NAME) { $env:OBSKIT_NAME } else { U '\u753b\u9762\u53ce\u9332' } }
if (-not $KitDir)    { $KitDir = if ($env:OBSKIT_DIR) { $env:OBSKIT_DIR } else { Join-Path $HomeDir "obs-kit" } }
if (-not $ConfigDir) { $ConfigDir = if ($env:OBSKIT_CFG) { $env:OBSKIT_CFG } elseif ($env:APPDATA) { Join-Path $env:APPDATA "obs-studio" } else { Join-Path $HomeDir "obs-studio" } }
function Find-ObsExe {  # default install dir first, then the registry key written by the installer
  $cands = @()
  if ($env:ProgramFiles) { $cands += (Join-Path $env:ProgramFiles "obs-studio\bin\64bit\obs64.exe") }
  $cands += "C:\Program Files\obs-studio\bin\64bit\obs64.exe"
  try { $reg = (Get-ItemProperty -Path "HKLM:\SOFTWARE\OBS Studio" -ErrorAction Stop).'(default)'; if ($reg) { $cands += (Join-Path $reg "bin\64bit\obs64.exe") } } catch {}
  foreach ($c in $cands) { if ($c -and (Test-Path $c)) { return $c } }
  return $cands[-1]
}
$Exe = Find-ObsExe
$WsPwFile = Join-Path $KitDir "ws_password"
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$SRC = @{
  Screen  = U '\u753b\u9762'
  Camera  = U '\u30ab\u30e1\u30e9'
  Mic     = U '\u30de\u30a4\u30af'
  Desktop = U 'PC\u97f3\u58f0'
  Scene   = U '\u753b\u9762\uff0b\u30ab\u30e1\u30e9'
}

function Write-Utf8([string]$path, [string]$text) { [IO.File]::WriteAllText($path, $text, $Utf8NoBom) }
function Log([string]$s) { [Console]::Error.WriteLine($s) }
function Fail([string]$s) { Log ("ERROR: " + $s); exit 1 }
function Test-ObsRunning { return [bool](Get-Process -Name obs64 -ErrorAction SilentlyContinue) }
function Get-ObsVersion { if (Test-Path $Exe) { try { return (Get-Item $Exe).VersionInfo.ProductVersion } catch { return "?" } } else { return "" } }
function Get-ObsVerInt {
  $v = (Get-ObsVersion) -replace '[^0-9.].*$', ''
  $p = $v.Split('.')
  if ($p.Count -lt 2 -or -not ($p[0] -match '^\d+$')) { return 537001986 }
  $a = [int]$p[0]; $b = [int]$p[1]; $c = if ($p.Count -ge 3 -and $p[2] -match '^\d+$') { [int]$p[2] } else { 0 }
  return $a * 16777216 + $b * 65536 + $c
}
function Get-ScreenRes {
  $w = 0; $h = 0
  try {
    $vc = Get-CimInstance Win32_VideoController | Where-Object { $_.CurrentHorizontalResolution -gt 0 } | Select-Object -First 1
    if ($vc) { $w = [int]$vc.CurrentHorizontalResolution; $h = [int]$vc.CurrentVerticalResolution }
  } catch {}
  if ($w -lt 640 -or $h -lt 480) { $w = 1920; $h = 1080 }
  $w2 = [int]($w / 2) * 2   # note: in PowerShell the comma binds tighter than '*', so never write "a * 2, b * 2"
  $h2 = [int]($h / 2) * 2
  return @($w2, $h2)
}
function Get-DesktopDir { try { return [Environment]::GetFolderPath('Desktop') } catch { return (Join-Path $HomeDir "Desktop") } }
function New-Password { return -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ }) }

function Ini-Set([string]$file, [string]$section, [string]$key, [string]$value) {
  $lines = @(); if (Test-Path $file) { $lines = [IO.File]::ReadAllLines($file, [Text.Encoding]::UTF8) }
  $out = New-Object System.Collections.Generic.List[string]
  $inSec = $false; $done = $false; $seen = $false
  foreach ($line in $lines) {
    if ($line -match '^\[(.*)\]\s*$') {
      if ($inSec -and -not $done) { $out.Add("$key=$value"); $done = $true }
      $inSec = ($Matches[1] -eq $section); if ($inSec) { $seen = $true }
      $out.Add($line); continue
    }
    if ($inSec -and $line.StartsWith("$key=")) { if (-not $done) { $out.Add("$key=$value"); $done = $true }; continue }
    $out.Add($line)
  }
  if (-not $done) { if (-not $seen) { $out.Add("[$section]") }; $out.Add("$key=$value") }
  Write-Utf8 $file (($out -join "`r`n") + "`r`n")
}
function Ini-Get([string]$file, [string]$section, [string]$key) {
  if (-not (Test-Path $file)) { return "" }
  $inSec = $false
  foreach ($line in [IO.File]::ReadAllLines($file, [Text.Encoding]::UTF8)) {
    if ($line -match '^\[(.*)\]\s*$') { $inSec = ($Matches[1] -eq $section); continue }
    if ($inSec -and $line.StartsWith("$key=")) { return $line.Substring($key.Length + 1) }
  }
  return ""
}

function Write-WsConfig([bool]$enabled) {
  New-Item -ItemType Directory -Force -Path $KitDir, (Join-Path $ConfigDir "plugin_config\obs-websocket") | Out-Null
  if (Test-Path $WsPwFile) { $pw = (Get-Content $WsPwFile -Raw).Trim() } else {
    $pw = New-Password; Write-Utf8 $WsPwFile $pw
    try { & icacls $WsPwFile /inheritance:r /grant:r "$($env:USERNAME):(R,W)" | Out-Null } catch {}  # owner-only, like chmod 600
  }
  $cfg = @{ alerts_enabled = $false; auth_required = $true; first_load = $false; server_enabled = $enabled; server_password = $pw; server_port = 4455 }
  Write-Utf8 (Join-Path $ConfigDir "plugin_config\obs-websocket\config.json") ($cfg | ConvertTo-Json)
}

function Write-Profile([int]$W, [int]$H, [int]$Fps, [string]$Quality, [string]$Dir) {
  $pdir = Join-Path $ConfigDir "basic\profiles\$Name"
  New-Item -ItemType Directory -Force -Path $pdir | Out-Null
  $ini = @"
[General]
Name=$Name

[Video]
BaseCX=$W
BaseCY=$H
OutputCX=$W
OutputCY=$H
FPSType=0
FPSCommon=$Fps
ScaleType=lanczos
FPSInt=$Fps
FPSNum=$Fps
FPSDen=1
ColorFormat=NV12
ColorSpace=709
ColorRange=Partial
SdrWhiteLevel=300
HdrNominalPeakLevel=1000

[Output]
Mode=Simple
FilenameFormatting=%CCYY-%MM-%DD %hh-%mm-%ss
DelayEnable=false
Reconnect=false

[SimpleOutput]
FilePath=$Dir
RecFormat2=hybrid_mp4
RecQuality=$Quality
RecEncoder=x264
RecAudioEncoder=aac
RecTracks=1
ABitrate=160
UseAdvanced=false
Preset=veryfast
VBitrate=2500
StreamEncoder=x264
StreamAudioEncoder=aac

[AdvOut]
RecType=Standard
RecTracks=1
RecEncoder=obs_x264
RecFormat2=hybrid_mp4
RecFilePath=$Dir
RecUseRescale=false
RecAudioEncoder=ffmpeg_aac
Track1Bitrate=160

[Audio]
SampleRate=48000
ChannelSetup=Stereo
"@
  Write-Utf8 (Join-Path $pdir "basic.ini") $ini
}

function New-Source([string]$name, [string]$id, [hashtable]$settings, [int]$prevVer, [int]$mixers) {
  return [ordered]@{
    prev_ver = $prevVer; name = $name; uuid = [Guid]::NewGuid().ToString(); id = $id; versioned_id = $id
    settings = $settings; mixers = $mixers; sync = 0; flags = 0; volume = 1.0; balance = 0.5; enabled = $true; muted = $false
    'push-to-mute' = $false; 'push-to-mute-delay' = 0; 'push-to-talk' = $false; 'push-to-talk-delay' = 0
    hotkeys = @{}; deinterlace_mode = 0; deinterlace_field_order = 0; monitoring_type = 0; private_settings = @{}
  }
}
function New-Item2([string]$name, [string]$uuid, [bool]$visible, [int]$x, [int]$y, [int]$boundsType, [int]$bw, [int]$bh, [int]$id, [string]$filter) {
  return [ordered]@{
    name = $name; source_uuid = $uuid; visible = $visible; locked = $false; rot = 0.0; align = 5
    bounds_type = $boundsType; bounds_align = 0; bounds_crop = $false
    crop_left = 0; crop_top = 0; crop_right = 0; crop_bottom = 0; id = $id; group_item_backup = $false
    pos = @{ x = [double]$x; y = [double]$y }; scale = @{ x = 1.0; y = 1.0 }; bounds = @{ x = [double]$bw; y = [double]$bh }
    scale_filter = $filter; blend_method = "default"; blend_type = "normal"
    show_transition = @{ duration = 0 }; hide_transition = @{ duration = 0 }; private_settings = @{}
  }
}

function Write-Scene([int]$W, [int]$H, [string]$Wipe) {
  $prev = Get-ObsVerInt
  # wipe: 17% of canvas width, 16:9. Right-bottom keeps 4.8% right / 15.6% bottom margins (same ratio as Asakura's layout)
  $cw = [int]($W * 0.17); $ch = [int]($cw * 9 / 16); $mr = [int]($W * 0.048); $mb = [int]($H * 0.156); $mt = [int]($H * 0.04)
  $vis = $true
  switch ($Wipe) {
    "rb"  { $cx = $W - $mr - $cw; $cy = $H - $mb - $ch }
    "lb"  { $cx = $mr;            $cy = $H - $mb - $ch }
    "rt"  { $cx = $W - $mr - $cw; $cy = $mt }
    "lt"  { $cx = $mr;            $cy = $mt }
    "off" { $cx = $W - $mr - $cw; $cy = $H - $mb - $ch; $vis = $false }
    default { Fail "-Wipe must be rb / lb / rt / lt / off" }
  }
  $scr = New-Source $SRC.Screen  "monitor_capture"        ([ordered]@{ method = 0; capture_cursor = $true }) $prev 255
  $cam = New-Source $SRC.Camera  "dshow_input"            ([ordered]@{ active = $true }) $prev 255
  $mic = New-Source $SRC.Mic     "wasapi_input_capture"   ([ordered]@{ device_id = "default" }) $prev 255
  $dsk = New-Source $SRC.Desktop "wasapi_output_capture"  ([ordered]@{ device_id = "default" }) $prev 255
  $items = @(
    (New-Item2 $SRC.Screen  $scr.uuid $true 0 0 2 $W $H 1 "area"),
    (New-Item2 $SRC.Mic     $mic.uuid $true 0 0 0 0 0 2 "disable"),
    (New-Item2 $SRC.Desktop $dsk.uuid $true 0 0 0 0 0 3 "disable"),
    (New-Item2 $SRC.Camera  $cam.uuid $vis $cx $cy 2 $cw $ch 4 "disable")
  )
  $scene = New-Source $SRC.Scene "scene" ([ordered]@{ custom_size = $false; id_counter = 4; items = $items }) $prev 0
  $doc = [ordered]@{
    name = $Name; current_scene = $SRC.Scene; current_program_scene = $SRC.Scene
    scene_order = @(@{ name = $SRC.Scene }); transition_duration = 300; transitions = @(); saved_projectors = @(); groups = @(); modules = @{}
    sources = @($scr, $cam, $mic, $dsk, $scene); resolution = @{ x = $W; y = $H }; version = 2
  }
  $dst = Join-Path $ConfigDir "basic\scenes\$Name.json"
  if (Test-Path $dst) {  # keep device settings chosen by a previous tune (new values win, old ones fill gaps)
    try {
      $old = Get-Content $dst -Raw -Encoding UTF8 | ConvertFrom-Json
      foreach ($s in $doc.sources) {
        if ($s.id -eq "scene") { continue }
        $o = $old.sources | Where-Object { $_.name -eq $s.name } | Select-Object -First 1
        if ($o -and $o.settings) {
          $merged = [ordered]@{}
          foreach ($p in $o.settings.PSObject.Properties) { $merged[$p.Name] = $p.Value }
          foreach ($k in @($s.settings.Keys)) { $merged[$k] = $s.settings[$k] }
          $s.settings = $merged
        }
      }
    } catch { Log "previous scene could not be merged, regenerating: $($_.Exception.Message)" }
  }
  New-Item -ItemType Directory -Force -Path (Split-Path $dst) | Out-Null
  Write-Utf8 $dst ($doc | ConvertTo-Json -Depth 20)
  return "wipe=$Wipe cam_pos=$cx,$cy cam_size=${cw}x${ch}"
}

# ---------- obs-websocket 5.x client (System.Net.WebSockets) ----------
function Send-WsJson($ws, $obj) {
  $json = $obj | ConvertTo-Json -Depth 20 -Compress
  $bytes = [Text.Encoding]::UTF8.GetBytes($json)
  $ws.SendAsync([ArraySegment[byte]]::new($bytes), [Net.WebSockets.WebSocketMessageType]::Text, $true, [Threading.CancellationToken]::None).Wait()
}
function Receive-WsJson($ws) {
  $buf = New-Object byte[] 65536; $ms = New-Object IO.MemoryStream
  do {
    $r = $ws.ReceiveAsync([ArraySegment[byte]]::new($buf), [Threading.CancellationToken]::None).Result
    if ($r.MessageType -eq [Net.WebSockets.WebSocketMessageType]::Close) { throw "closed by OBS ($($r.CloseStatus) $($r.CloseStatusDescription))" }
    $ms.Write($buf, 0, $r.Count)
  } while (-not $r.EndOfMessage)
  return ([Text.Encoding]::UTF8.GetString($ms.ToArray()) | ConvertFrom-Json)
}
function Connect-Obs([string]$password, [int]$waitSec) {
  $deadline = (Get-Date).AddSeconds($waitSec); $ws = $null
  while ($true) {
    try {
      $ws = New-Object System.Net.WebSockets.ClientWebSocket
      $ws.Options.AddSubProtocol("obswebsocket.json")
      $ws.ConnectAsync([Uri]"ws://127.0.0.1:4455", [Threading.CancellationToken]::None).Wait()
      break
    } catch {
      if ((Get-Date) -gt $deadline) { throw "cannot connect to OBS WebSocket (127.0.0.1:4455): $($_.Exception.Message)" }
      Start-Sleep -Seconds 2
    }
  }
  $hello = Receive-WsJson $ws
  $auth = $null
  if ($hello.d.authentication) {
    $sha = [Security.Cryptography.SHA256]::Create()
    $secret = [Convert]::ToBase64String($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($password + $hello.d.authentication.salt)))
    $auth = [Convert]::ToBase64String($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($secret + $hello.d.authentication.challenge)))
  }
  Send-WsJson $ws @{ op = 1; d = @{ rpcVersion = 1; authentication = $auth; eventSubscriptions = 0 } }
  $ident = Receive-WsJson $ws
  if ($ident.op -ne 2) { throw "OBS authentication failed (password mismatch?)" }
  return $ws
}
function Invoke-Obs($ws, [string]$type, $data) {
  $rid = [Guid]::NewGuid().ToString()
  if ($null -eq $data) { $data = @{} }
  Send-WsJson $ws @{ op = 6; d = @{ requestType = $type; requestId = $rid; requestData = $data } }
  while ($true) {
    $m = Receive-WsJson $ws
    if ($m.op -eq 7 -and $m.d.requestId -eq $rid) {
      if (-not $m.d.requestStatus.result) { throw "$type failed: code=$($m.d.requestStatus.code) $($m.d.requestStatus.comment)" }
      return $m.d.responseData
    }
  }
}
function Get-WsPassword { if (-not (Test-Path $WsPwFile)) { Fail "configure has not been run (ws_password missing)" }; return (Get-Content $WsPwFile -Raw).Trim() }
function Get-PropItems($ws, [string]$input, [string]$prop) { return @((Invoke-Obs $ws "GetInputPropertiesListPropertyItems" @{ inputName = $input; propertyName = $prop }).propertyItems) }
function Set-InputSettings($ws, [string]$input, $settings) { Invoke-Obs $ws "SetInputSettings" @{ inputName = $input; inputSettings = $settings; overlay = $true } | Out-Null }
function Set-ItemEnabled($ws, [string]$scene, [string]$source, [bool]$enabled) {
  $items = (Invoke-Obs $ws "GetSceneItemList" @{ sceneName = $scene }).sceneItems
  foreach ($it in $items) { if ($it.sourceName -eq $source) { Invoke-Obs $ws "SetSceneItemEnabled" @{ sceneName = $scene; sceneItemId = $it.sceneItemId; sceneItemEnabled = $enabled } | Out-Null; return $true } }
  return $false
}
function Save-Screenshot($ws, [string]$source, [string]$path) {
  $r = Invoke-Obs $ws "GetSourceScreenshot" @{ sourceName = $source; imageFormat = "png"; imageWidth = 960 }
  $b64 = $r.imageData.Substring($r.imageData.IndexOf(",") + 1)
  [IO.File]::WriteAllBytes($path, [Convert]::FromBase64String($b64))
  return $path
}
function Get-BlankRatio([string]$path) {  # share of the most common colour in the top-left 60% (1.0 = blank). $null if unavailable
  try {
    Add-Type -AssemblyName System.Drawing
    $bmp = [System.Drawing.Bitmap]::FromFile($path)
    $counts = @{}; $total = 0
    for ($y = 0; $y -lt [int]($bmp.Height * 0.6); $y += 6) {
      for ($x = 0; $x -lt [int]($bmp.Width * 0.6); $x += 6) {
        $c = $bmp.GetPixel($x, $y); $k = "$($c.R -shr 4),$($c.G -shr 4),$($c.B -shr 4)"
        $counts[$k] = 1 + [int]$counts[$k]; $total++
      }
    }
    $bmp.Dispose()
    if ($total -eq 0) { return $null }
    $max = ($counts.Values | Measure-Object -Maximum).Maximum
    return [math]::Round($max / $total, 3)
  } catch { return $null }
}
function Pick-Item($items, [string[]]$avoid) {
  foreach ($it in $items) {
    if ($it.itemEnabled -eq $false) { continue }
    $n = [string]$it.itemName; if (-not $n.Trim()) { continue }
    $skip = $false; foreach ($a in $avoid) { if ($n.ToLower().Contains($a.ToLower())) { $skip = $true } }
    if ($skip) { continue }
    if ([string]$it.itemValue -eq "" -or [string]$it.itemValue -eq "DUMMY") { continue }
    return $it
  }
  return $null
}
function Out-Json($obj) { $obj | ConvertTo-Json -Depth 10 }

# ---------- commands ----------
function Cmd-Check {
  "os=windows"
  "windows=$([Environment]::OSVersion.Version.ToString())"
  if (Test-Path $Exe) { "obs_installed=yes"; "obs_version=$(Get-ObsVersion)" } else { "obs_installed=no" }
  "obs_running=$(if (Test-ObsRunning) { 'yes' } else { 'no' })"
  "winget=$(if (Get-Command winget -ErrorAction SilentlyContinue) { 'yes' } else { 'none' })"
  $r = Get-ScreenRes; "screen=$($r[0])x$($r[1])"
  try { "cameras=" + ((Get-PnpDevice -Class Camera, Image -PresentOnly -ErrorAction SilentlyContinue | ForEach-Object { $_.FriendlyName }) -join "|") } catch { "cameras=?" }
  "default_mic=(OS default input device)"
  "config_dir=$ConfigDir"
  "kit_dir=$KitDir"
  "profile_exists=$(if (Test-Path (Join-Path $ConfigDir "basic\profiles\$Name\basic.ini")) { 'yes' } else { 'no' })"
}
function Cmd-Install {
  if (Test-Path $Exe) { Log "OBS is already installed ($(Get-ObsVersion))"; return }
  if (Get-Command winget -ErrorAction SilentlyContinue) {
    Log "Installing OBS with winget (a UAC prompt will appear - please click Yes)"
    & winget install --id OBSProject.OBSStudio -e --accept-package-agreements --accept-source-agreements
  } else {
    Log "winget not found - downloading the official installer from GitHub Releases"
    $rel = Invoke-RestMethod -Uri "https://api.github.com/repos/obsproject/obs-studio/releases/latest" -UseBasicParsing
    $asset = $rel.assets | Where-Object { $_.name -like "*Windows-x64-Installer.exe" } | Select-Object -First 1
    if (-not $asset) { Fail "installer URL not found. Please install manually from https://obsproject.com/ja/download" }
    New-Item -ItemType Directory -Force -Path $KitDir | Out-Null
    $tmp = Join-Path $KitDir $asset.name
    Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $tmp -UseBasicParsing
    Start-Process -FilePath $tmp -ArgumentList "/S" -Wait -Verb RunAs
    Remove-Item $tmp -Force -ErrorAction SilentlyContinue
  }
  $script:Exe = Find-ObsExe
  if (-not (Test-Path $Exe)) { Fail "installation failed (obs64.exe not found)" }
  Log "OBS $(Get-ObsVersion) installed"
}
function Cmd-Configure {
  if (-not (Test-Path $Exe) -and -not $env:OBSKIT_CFG) { Fail "OBS is not installed. Run install first" }
  if (Test-ObsRunning) { Fail "OBS is running. Run quit first (config files are only rewritten while OBS is closed)" }
  if (-not $Dir) { $Dir = Get-DesktopDir }
  if (-not (Test-Path $Dir)) { Fail "save folder not found: $Dir" }
  if ($Quality -notin @("Small", "HQ")) { Fail "-Quality must be Small (high quality, medium file) or HQ (very high quality)" }
  New-Item -ItemType Directory -Force -Path $KitDir, (Join-Path $ConfigDir "basic\profiles"), (Join-Path $ConfigDir "basic\scenes"), (Join-Path $ConfigDir "plugin_config\obs-websocket") | Out-Null
  $stamp = Get-Date -Format "yyyyMMdd_HHmmss"; $bk = Join-Path $KitDir "backup\$stamp"; New-Item -ItemType Directory -Force -Path $bk | Out-Null
  foreach ($f in @("user.ini", "global.ini")) { $p = Join-Path $ConfigDir $f; if (Test-Path $p) { Copy-Item $p (Join-Path $bk $f) } }
  $wsc = Join-Path $ConfigDir "plugin_config\obs-websocket\config.json"; if (Test-Path $wsc) { Copy-Item $wsc (Join-Path $bk "ws_config.json") }
  $sc = Join-Path $ConfigDir "basic\scenes\$Name.json"; if (Test-Path $sc) { Copy-Item $sc (Join-Path $bk "scene.json") }
  $pi = Join-Path $ConfigDir "basic\profiles\$Name\basic.ini"; if (Test-Path $pi) { Copy-Item $pi (Join-Path $bk "basic.ini") }
  $r = Get-ScreenRes; $W = $r[0]; $H = $r[1]
  Write-Profile $W $H $Fps $Quality $Dir
  $sceneinfo = Write-Scene $W $H $Wipe
  foreach ($f in @("user.ini", "global.ini")) {
    $p = Join-Path $ConfigDir $f
    Ini-Set $p "General" "FirstRun" "true"
    Ini-Set $p "Basic" "Profile" $Name
    Ini-Set $p "Basic" "ProfileDir" $Name
    Ini-Set $p "Basic" "SceneCollection" $Name
    if ($f -eq "user.ini") { Ini-Set $p "Basic" "SceneCollectionFile" "$Name.json" } else { Ini-Set $p "Basic" "SceneCollectionFile" $Name }
  }
  Write-WsConfig $true
  "configured=ok"
  "profile=$Name"
  "canvas=${W}x${H} fps=$Fps quality=$Quality format=hybrid_mp4 encoder=x264 (simple output mode)"
  "$sceneinfo"
  "save_dir=$Dir"
  "backup=$bk"
}
function Cmd-Launch {
  if (-not (Test-Path $Exe)) { Fail "OBS is not installed" }
  if (Test-ObsRunning) { Log "OBS is already running"; return }
  Start-Process -FilePath $Exe -WorkingDirectory (Split-Path $Exe) -ArgumentList @("--profile", "`"$Name`"", "--collection", "`"$Name`"", "--disable-updater")
  Log "OBS launched (profile / scene collection: $Name)"
}
function Cmd-Tune {
  $ws = Connect-Obs (Get-WsPassword) $Wait
  $res = [ordered]@{ version = (Invoke-Obs $ws "GetVersion" $null).obsVersion; platform = "Windows" }
  $switched = @()
  $p = Invoke-Obs $ws "GetProfileList" $null
  if (($p.profiles -contains $Name) -and $p.currentProfileName -ne $Name) { Invoke-Obs $ws "SetCurrentProfile" @{ profileName = $Name } | Out-Null; $switched += "profile" }
  $c = Invoke-Obs $ws "GetSceneCollectionList" $null
  if (($c.sceneCollections -contains $Name) -and $c.currentSceneCollectionName -ne $Name) { Invoke-Obs $ws "SetCurrentSceneCollection" @{ sceneCollectionName = $Name } | Out-Null; $switched += "collection" }
  if ($switched.Count) { Start-Sleep -Seconds 3 }
  $res.current = @{ profile = (Invoke-Obs $ws "GetProfileList" $null).currentProfileName; collection = (Invoke-Obs $ws "GetSceneCollectionList" $null).currentSceneCollectionName; switched = $switched }
  $inputs = @{}; foreach ($i in (Invoke-Obs $ws "GetInputList" $null).inputs) { $inputs[$i.inputName] = $i }
  $scene = (Invoke-Obs $ws "GetSceneList" $null).currentProgramSceneName
  $res.scene = $scene
  foreach ($n in @($SRC.Screen, $SRC.Mic)) { if (-not $inputs.ContainsKey($n)) { throw "source '$n' is missing in the scene (run configure again)" } }

  # screen (display)
  $items = Get-PropItems $ws $SRC.Screen "monitor_id"
  $chosen = $null
  foreach ($it in $items) { $n = [string]$it.itemName; if ($n.ToLower().Contains("primary") -or $n.Contains((U '\u30d7\u30e9\u30a4\u30de\u30ea')) -or $n.Contains((U '\u4e3b'))) { $chosen = $it; break } }
  if (-not $chosen) { $chosen = Pick-Item $items @() }
  if ($chosen) {
    Set-InputSettings $ws $SRC.Screen @{ monitor_id = $chosen.itemValue; method = 0; capture_cursor = $true }
    $res.display = @{ name = $chosen.itemName; candidates = $items.Count }
    if ([string]$chosen.itemName -match '(\d{3,5})\s*[x\u00d7]\s*(\d{3,5})') {
      $w = [int]($Matches[1] / 2) * 2; $h = [int]($Matches[2] / 2) * 2
      $v = Invoke-Obs $ws "GetVideoSettings" $null
      if ($v.baseWidth -ne $w -or $v.baseHeight -ne $h) {
        Invoke-Obs $ws "SetVideoSettings" @{ baseWidth = $w; baseHeight = $h; outputWidth = $w; outputHeight = $h } | Out-Null
        $res.canvas_fixed = @{ from = @($v.baseWidth, $v.baseHeight); to = @($w, $h) }
      }
    }
  } else { $res.display = @{ error = "no display candidates"; candidates = $items.Count } }

  # camera
  if ($inputs.ContainsKey($SRC.Camera)) {
    if ($NoCamera) { Set-ItemEnabled $ws $scene $SRC.Camera $false | Out-Null; $res.camera = "hidden (-NoCamera)" }
    else {
      $items = Get-PropItems $ws $SRC.Camera "video_device_id"
      $chosen = Pick-Item $items @("OBS Virtual Camera", "OBS-Camera", "Snap Camera", "NVIDIA Broadcast", "XSplit")
      if ($chosen) { Set-InputSettings $ws $SRC.Camera @{ video_device_id = $chosen.itemValue }; Set-ItemEnabled $ws $scene $SRC.Camera $true | Out-Null; $res.camera = @{ name = $chosen.itemName; candidates = $items.Count } }
      else { Set-ItemEnabled $ws $scene $SRC.Camera $false | Out-Null; $res.camera = @{ warning = "no camera found - wipe hidden"; candidates = $items.Count } }
    }
  }
  # camera frame check: if no frames arrive yet, re-apply the device once and wait
  if ($inputs.ContainsKey($SRC.Camera) -and -not $NoCamera) {
    for ($k = 0; $k -lt 2; $k++) {
      Start-Sleep -Seconds 3
      $items2 = (Invoke-Obs $ws "GetSceneItemList" @{ sceneName = $scene }).sceneItems
      $ci = $items2 | Where-Object { $_.sourceName -eq $SRC.Camera } | Select-Object -First 1
      if (-not $ci) { break }
      $t = (Invoke-Obs $ws "GetSceneItemTransform" @{ sceneName = $scene; sceneItemId = $ci.sceneItemId }).sceneItemTransform
      if ($t.sourceWidth -gt 0) { $res.camera_frame = "$($t.sourceWidth)x$($t.sourceHeight)"; break }
      $cur = (Invoke-Obs $ws "GetInputSettings" @{ inputName = $SRC.Camera }).inputSettings
      if ($cur.video_device_id) { Set-InputSettings $ws $SRC.Camera @{ video_device_id = $cur.video_device_id; active = $true } }
      if ($k -eq 1) { $res.camera_warning = "camera delivers no frames yet (another app may be using it, or it needs a moment). Check the wipe in OBS" }
    }
  }
  # mic / desktop audio
  $mics = Get-PropItems $ws $SRC.Mic "device_id"
  Set-InputSettings $ws $SRC.Mic @{ device_id = "default" }
  $res.mic = @{ using = "default"; candidates = @($mics | ForEach-Object { $_.itemName } | Select-Object -First 8) }
  if ($inputs.ContainsKey($SRC.Desktop)) { Set-InputSettings $ws $SRC.Desktop @{ device_id = "default" } }

  $v = Invoke-Obs $ws "GetVideoSettings" $null
  $res.video = @{ canvas = @($v.baseWidth, $v.baseHeight); output = @($v.outputWidth, $v.outputHeight); fps = $v.fpsNumerator }
  $res.record_dir = (Invoke-Obs $ws "GetRecordDirectory" $null).recordDirectory
  Start-Sleep -Seconds 2
  New-Item -ItemType Directory -Force -Path $KitDir | Out-Null
  $res.preview = Save-Screenshot $ws $scene (Join-Path $KitDir "preview.png")
  $ratio = Get-BlankRatio $res.preview
  if ($null -ne $ratio) {
    $res.screen_blank_ratio = $ratio
    if ($ratio -gt 0.97) { $res.screen_warning = "screen area is almost a single colour: open the properties of the '$($SRC.Screen)' source in OBS and choose the display" }
  }
  Out-Json $res
}
function Cmd-Test {
  $ws = Connect-Obs (Get-WsPassword) $Wait
  if ((Invoke-Obs $ws "GetRecordStatus" $null).outputActive) { throw "already recording - stop it in OBS first" }
  Invoke-Obs $ws "StartRecord" $null | Out-Null
  Start-Sleep -Seconds $Seconds
  $path = (Invoke-Obs $ws "StopRecord" $null).outputPath
  $size = 0
  for ($i = 0; $i -lt 20; $i++) { if ($path -and (Test-Path $path)) { $size = (Get-Item $path).Length; if ($size -gt 0) { break } }; Start-Sleep -Milliseconds 500 }
  Out-Json ([ordered]@{ recorded = $path; bytes = $size; seconds = $Seconds; ok = ($size -gt 10000); record_dir = (Invoke-Obs $ws "GetRecordDirectory" $null).recordDirectory })
}
function Cmd-Shot {
  $ws = Connect-Obs (Get-WsPassword) $Wait
  $scene = (Invoke-Obs $ws "GetSceneList" $null).currentProgramSceneName
  New-Item -ItemType Directory -Force -Path $KitDir | Out-Null
  $p = Save-Screenshot $ws $scene (Join-Path $KitDir "preview.png")
  Out-Json ([ordered]@{ preview = $p; screen_blank_ratio = (Get-BlankRatio $p) })
}
function Cmd-Info {
  $ws = Connect-Obs (Get-WsPassword) $Wait
  $v = Invoke-Obs $ws "GetVideoSettings" $null
  Out-Json ([ordered]@{
    version = (Invoke-Obs $ws "GetVersion" $null).obsVersion
    profile = (Invoke-Obs $ws "GetProfileList" $null).currentProfileName
    collection = (Invoke-Obs $ws "GetSceneCollectionList" $null).currentSceneCollectionName
    scene = (Invoke-Obs $ws "GetSceneList" $null).currentProgramSceneName
    inputs = @((Invoke-Obs $ws "GetInputList" $null).inputs | ForEach-Object { "$($_.inputName) ($($_.inputKind))" })
    canvas = @($v.baseWidth, $v.baseHeight); fps = $v.fpsNumerator
    recording = (Invoke-Obs $ws "GetRecordStatus" $null).outputActive
    record_dir = (Invoke-Obs $ws "GetRecordDirectory" $null).recordDirectory
  })
}
function Cmd-Raw {
  $ws = Connect-Obs (Get-WsPassword) $Wait
  $d = $Data | ConvertFrom-Json
  $h = @{}; foreach ($p in $d.PSObject.Properties) { $h[$p.Name] = $p.Value }
  Out-Json (Invoke-Obs $ws $Request $h)
}
function Cmd-Quit {
  $p = Get-Process -Name obs64 -ErrorAction SilentlyContinue
  if (-not $p) { Log "OBS is not running"; return }
  $p | ForEach-Object { $_.CloseMainWindow() | Out-Null }
  for ($i = 0; $i -lt 30; $i++) { if (-not (Test-ObsRunning)) { Log "OBS closed"; return }; Start-Sleep -Seconds 1 }
  Fail "OBS did not close (stop the recording first, then close it)"
}
function Cmd-Finish {
  if (Test-ObsRunning) { Fail "OBS is running. Run quit first" }
  Write-WsConfig $false
  Log "setup finished: remote-control WebSocket disabled (run configure again to re-enable)"
}
function Cmd-Status {
  "obs_installed=$(if (Test-Path $Exe) { 'yes' } else { 'no' }) version=$(Get-ObsVersion)"
  "obs_running=$(if (Test-ObsRunning) { 'yes' } else { 'no' })"
  $u = Join-Path $ConfigDir "user.ini"
  "selected_profile=$(Ini-Get $u 'Basic' 'Profile') collection=$(Ini-Get $u 'Basic' 'SceneCollection')"
  $pi = Join-Path $ConfigDir "basic\profiles\$Name\basic.ini"
  if (Test-Path $pi) { "kit_profile=$Name canvas=$(Ini-Get $pi 'Video' 'BaseCX')x$(Ini-Get $pi 'Video' 'BaseCY') fps=$(Ini-Get $pi 'Video' 'FPSCommon') quality=$(Ini-Get $pi 'SimpleOutput' 'RecQuality') save_dir=$(Ini-Get $pi 'SimpleOutput' 'FilePath')" }
  $wsc = Join-Path $ConfigDir "plugin_config\obs-websocket\config.json"
  if (Test-Path $wsc) { "websocket_enabled=$((Get-Content $wsc -Raw | ConvertFrom-Json).server_enabled)" }
  $bkd = Join-Path $KitDir "backup"
  "backups=$(if (Test-Path $bkd) { (Get-ChildItem $bkd -Directory | ForEach-Object { $_.Name }) -join ' ' })"
}
function Cmd-Restore {
  if (Test-ObsRunning) { Fail "OBS is running. Run quit first" }
  $bkd = Join-Path $KitDir "backup"
  $stamp = $Backup; if (-not $stamp) { $stamp = (Get-ChildItem $bkd -Directory | Sort-Object Name | Select-Object -Last 1).Name }
  $bk = Join-Path $bkd $stamp; if (-not (Test-Path $bk)) { Fail "backup not found: $bk" }
  foreach ($f in @("user.ini", "global.ini")) { $p = Join-Path $bk $f; if (Test-Path $p) { Copy-Item $p (Join-Path $ConfigDir $f) -Force } }
  $w = Join-Path $bk "ws_config.json"; if (Test-Path $w) { Copy-Item $w (Join-Path $ConfigDir "plugin_config\obs-websocket\config.json") -Force }
  Log "restored from $bk (the '$Name' profile files are kept; delete them in OBS > Profile > Remove if unwanted)"
}

switch ($Command) {
  "check"     { Cmd-Check }
  "install"   { Cmd-Install }
  "configure" { Cmd-Configure }
  "launch"    { Cmd-Launch }
  "tune"      { Cmd-Tune }
  "test"      { Cmd-Test }
  "shot"      { Cmd-Shot }
  "info"      { Cmd-Info }
  "raw"       { Cmd-Raw }
  "quit"      { Cmd-Quit }
  "finish"    { Cmd-Finish }
  "status"    { Cmd-Status }
  "restore"   { Cmd-Restore }
  default     { Get-Content $PSCommandPath -TotalCount 6 | Select-Object -Skip 1; exit 1 }
}
```
