# Windows 11: ComfyUI (SDXL / Anima) 導入と Muse 連携手順 前提 (最低要件): - OS: Windows 11 64bit (関連ソフトウェア未導入の状態から始める想定) - GPU: NVIDIA製GPU、VRAM 12GB以上 (パートEの Hires fix + FaceDetailer 構成まで含める場合は 16GB を推奨) - RAM: 16GB以上 (32GBを推奨) - 方式: ComfyUI Portable (Windows公式配布)。システムPython / Git / CUDA Toolkitは導入不要 (禁止ではない。本手順では入れない) - 用途: アニメ調・2次元・静止画の生成 ## 全体構成 本手順は段階的に積み上げる構成になっている。**パートAの完了条件は「PC単体でローカル生成ができること」であり、Museとの連携は一切含まない。** 連携はパートA完了後にパートBで追加し、以降のパートで機能を拡張していく。 - パートA: ローカル導入と単体検証 (SDXL base → NoobAI → WAI の順に追加し、各モデルでローカル生成を確認する) - パートB: Muse 連携 (中継API方式)。ComfyUIは `127.0.0.1` でのみ待ち受け、外部に公開するのは限定機能の中継APIだけにする - パートC: LoRA組み込み (常駐LoRAチェーン) - パートD: キャラLoRAの許可リスト制パラメータ - パートE: Hires fix + FaceDetailer の標準化 - パートF: Anima 系ワークフローの追加 ## パスと表記ルール インストール先は以下で固定する。以降のコマンドはこのパス前提。 ```text C:\AI\ComfyUI_windows_portable\ <- ComfyUI Portable展開先 C:\AI\models\ <- ダウンロード直後の保管場所 (staging) C:\AI\workflows\ <- ワークフローJSONの保管場所 (承認済みワークフローの置き場) C:\AI\relay\ <- 中継APIの配置場所 (パートB) C:\AI\setup-record.txt <- 導入したリリース・同梱環境の記録 ``` 空き容量: 最低 60GB、モデル追加を見込むなら 100GB以上のSSDを推奨。 各手順には以下を明記する。 - **実行場所**: PowerShell (通常) / PowerShell (管理者) / エクスプローラー / ComfyUI画面 のいずれか - **管理者権限**: 要 / 不要 - **完了条件**: その工程の成功判定。生成速度やVRAM表示値などの数値は参考値として扱い、厳密な数値を合格条件にしない ダウンロードについて: 本手順で取得するファイルは全て公式ページからのみ取得する (巻末の一覧参照)。まとめサイト、ファイルアップローダ、再配布ミラーからは取得しない。これを真正性の担保とし、ファイルごとのハッシュ突合は必須工程にしない (整合性確認は任意工程として残す)。 --- # パートA: ローカル導入と単体検証 ## A0: Windows側の事前処理とフォルダー作成 **実行場所**: Windows設定 / PowerShell (通常) / **管理者権限**: 不要 1. Windows Updateを完了し、再起動する。 2. `設定 > システム > 電源` で、AC接続時のスリープを「なし」にしておく (後段でサーバとして使うため。不要なら後で戻す)。 3. フォルダーを作成する。OneDrive配下・Program Files配下・日本語パスは使わない。 ```powershell New-Item -ItemType Directory -Force C:\AI, C:\AI\models, C:\AI\workflows, C:\AI\relay ``` 完了条件: - 再起動後、保留中の更新が無いこと - `Test-Path C:\AI\models`、`Test-Path C:\AI\workflows`、`Test-Path C:\AI\relay` がいずれも `True` であること ## A1: NVIDIAドライバ Portable版はPyTorch同梱だが、ドライバは別途必要。同梱PyTorchが起動しない場合の対処として公式ドキュメントでもドライバ更新が指定されている。 **実行場所**: ブラウザ / NVIDIA App / PowerShell (通常) / **管理者権限**: ドライバのインストール時のみ要 (インストーラが要求する) 1. NVIDIA公式から NVIDIA App をインストールする。 - 公式ページ: https://www.nvidia.com/en-us/software/nvidia-app/ - ドライバ本体も NVIDIA App 内から取得する。このページ以外の配布サイトからは取得しない 2. NVIDIA Appで最新の Studio Driver をインストールする (Game Readyでも動作はする。本用途ではStudioを推奨)。 - オプションで「クリーンインストール」を有効にする。 3. 再起動する。 4. 確認する: ```powershell nvidia-smi ``` 完了条件: 出力のデバイス一覧に自分のGPU名が表示され、エラーにならないこと。VRAM総量などの数値表示は参考値であり、合格条件ではない。 注意: **CUDA Toolkitは不要 (インストール禁止ではない)。** PyTorchのwheel内にCUDA Runtime (cudart / cuBLAS / cuDNN等) が同梱されるため、推論用途ではToolkit側は参照されない。Toolkitが問題になるのは、CUDA拡張をソースからビルドする際に `nvcc` やPATH経由で別バージョンが選択される場合に限る。なお、ドライバ同梱のPhysXシステムソフトウェアは物理演算用ランタイムであり、CUDA Toolkitとは別物。ComfyUIには無関係で、ドライバ導入時にデフォルトのまま入れてよい (CUDA Driver APIの `nvcuda.dll` はPhysXではなくグラフィックスドライバ本体に含まれる)。 ## A2: 7-Zip ComfyUI Portableの配布形式 `.7z` を展開するために 7-Zip (64bit) を公式サイトからインストールする。 **実行場所**: ブラウザ / **管理者権限**: インストール時に要 (インストーラが要求する) - 公式ページ: https://7-zip.org/download.html - このページで「64-bit Windows x64」の `.exe` インストーラを選ぶ。類似ドメインのサイトからは取得しない 完了条件: `.7z` ファイルの右クリックメニューに7-Zipの項目が表示されること。 ## A3: SDXL checkpointのダウンロード (検証用・この段階では1本だけ) ここで重要なのは、ComfyUIの `CheckpointLoaderSimple` が読むのは **単一ファイルの `.safetensors`** であり、Hugging Face上のdiffusers形式 (フォルダ + `model_index.json` + `unet/` 等) ではないこと。最初から派生モデルを入れると「環境の問題かモデルの仕様か」を分離できないため、検証用は配布元・ファイル名・前提パラメータが明確なSDXL baseに固定する。 **実行場所**: ブラウザ / **管理者権限**: 不要 - 配布元: Hugging Face `stabilityai/stable-diffusion-xl-base-1.0` - 公式ファイルページ: https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/blob/main/sd_xl_base_1.0.safetensors - ファイル: `sd_xl_base_1.0.safetensors` (約6.9GB) - 保存先: `C:\AI\models\sd_xl_base_1.0.safetensors` 完了条件: 保存先にファイルが存在し、サイズが数GB規模であること (正確な値は条件としない)。 整合性確認 (任意): 公式ファイルページから直接ダウンロードした場合はハッシュ突合を省略してよい。確認したい場合のみ、以下で算出したSHA256がファイルページ表示と一致することを確認する。 ```powershell Get-FileHash -Algorithm SHA256 C:\AI\models\sd_xl_base_1.0.safetensors ``` ## A4: ComfyUI Portableの導入とリリースの記録 補足 (Comfy Desktopとの関係): Comfy Desktopは、同じComfyUIコアをインストーラ形式のランチャーアプリで包んだ別パッケージであり、エンジンやワークフロー形式が異なるわけではない。本手順では採用しない。理由は、(a) サーバとして起動引数とフォルダ構成を明示的に固定する必要がある、(b) Desktopは標準でstableリリース追従とアプリ側の更新管理が入るため、動作前提とするコアのバージョンを自分で固定しにくい、の2点。 **実行場所**: ブラウザ / エクスプローラー (7-Zip) / PowerShell (通常) / **管理者権限**: 不要 1. ComfyUI公式GitHub (Comfy-Org/ComfyUI) のReleasesページを開き、最新リリースのタグ (例: `v0.x.x`) を確認する。 - 公式Releasesページ: https://github.com/comfyanonymous/ComfyUI/releases - 最新版の直接リンク: https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia.7z - ファイル名: `ComfyUI_windows_portable_nvidia.7z` - **自分のGPU世代に対応した版を選ぶ。** 例えば `_cu126` 版は旧世代GPU用 (Python 3.12 + CUDA 12.6) で、最新世代のGPUでは使用できない。 - 標準版の同梱環境は Python 3.13 + PyTorch CUDA 13.0 (A6で実測値を記録する)。 2. 確認したリリースタグを記録する: ```powershell Set-Content C:\AI\setup-record.txt "ComfyUI Portable release: " ``` 3. 7-Zipで `C:\AI\` 配下に展開する。結果が以下になること: ```text C:\AI\ComfyUI_windows_portable\ ComfyUI\ python_embeded\ run_nvidia_gpu.bat run_cpu.bat update\ ``` 注意: 展開方法によっては、`ComfyUI_windows_portable` フォルダーがもう一段内側に入る場合がある (例: `C:\AI\ComfyUI_windows_portable\ComfyUI_windows_portable\...`)。以降の手順はフォルダー名ではなく **`python_embeded` の実在パス** を基準とするため、展開後に次で実際の場所を確認し、異なる場合は読み替える。 ```powershell Get-ChildItem C:\AI -Recurse -Directory -Filter python_embeded | Select-Object FullName ``` 完了条件: 上記構成が存在し、`python_embeded` の実在パスが確認でき、`C:\AI\setup-record.txt` にリリースタグが記録されていること。`run_cpu.bat` は使わない。 ## A5: SDXL checkpointの配置 **実行場所**: PowerShell (通常) / **管理者権限**: 不要 ```powershell Copy-Item C:\AI\models\sd_xl_base_1.0.safetensors C:\AI\ComfyUI_windows_portable\ComfyUI\models\checkpoints\ Get-Item C:\AI\ComfyUI_windows_portable\ComfyUI\models\checkpoints\sd_xl_base_1.0.safetensors ``` 完了条件: 配置先にファイルが存在すること。この時点ではVAE / LoRA / ControlNetの追加ファイルは不要 (checkpoint内にVAEとCLIPを含む)。 ## A6: PyTorch / CUDA動作確認と同梱環境の記録 (起動前) システムのpythonではなく、同梱の `python_embeded` を使うこと。 **実行場所**: PowerShell (通常) / **管理者権限**: 不要 ### A6-1. バージョン確認と記録 ```powershell cd C:\AI\ComfyUI_windows_portable .\python_embeded\python.exe -c "import sys, torch; print(sys.version); print(torch.__version__); print(torch.version.cuda); print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0)); print(torch.cuda.get_device_capability(0))" | Tee-Object -Append C:\AI\setup-record.txt .\python_embeded\python.exe -m pip list --format=freeze | Select-String -Pattern "^(torch|torchvision|torchaudio)==" | Tee-Object -Append C:\AI\setup-record.txt ``` 2つ目のコマンドで、torchだけでなく **torchvision / torchaudio のバージョンも記録する** (修復時に必要な組合せを、正常なうちに確定させておくため)。 ### A6-2. 実際のCUDA計算の確認 GPUが「認識される」だけでは不十分なため、カーネル実行を伴う計算を行う。 ```powershell .\python_embeded\python.exe -c "import torch; x = torch.randn(1024, 1024, device='cuda'); y = x @ x; torch.cuda.synchronize(); print('cuda matmul ok:', y.shape, float(y[0, 0])); print(torch.cuda.get_device_name(0))" ``` 完了条件 (A6-1 / A6-2 合わせて全て満たすこと): - `torch.cuda.is_available()` が `True` - デバイス名が自分のGPU名と一致すること (capability はGPUのアーキテクチャに対応する値になる) - A6-2が例外なく完了し、数値が出力されること (乱数由来の値そのものは条件としない) - A6-1の出力が `C:\AI\setup-record.txt` に記録されていること (この記録が、後日の修復時に使う「同梱環境の正」になる) `torch.__version__` の `+cuXXX` 表記で、同梱PyTorchが自分のGPUアーキテクチャに対応したCUDA版であることを確認する (新しい世代のGPUでは、対応するcu版でないと A6-3 の `no kernel image` になる)。厳密なバージョン番号自体は合格条件にしない。 ### A6-3. 異常時の対処 (順序固定) 1. `is_available() == False` の場合: まずドライバ未導入でないか A1 を確認する。 2. `CUDA error: no kernel image is available for execution on the device` 等の場合は、**pip修復の前に、正しいPortableを別フォルダーへ新規展開して確認する**。展開先を分けることで「同梱環境の破損」と「PC側の問題」を分離する。 - 展開先フォルダーの中にも `ComfyUI_windows_portable` が一段入る場合があるため、まず `python_embeded` の実在パスを確認する (A4の注意と同じ)。 ```powershell # 7-Zipで ComfyUI_windows_portable_nvidia.7z を C:\AI\ComfyUI_portable_check\ に展開した後 Get-ChildItem C:\AI\ComfyUI_portable_check -Recurse -Directory -Filter python_embeded | Select-Object FullName # 表示された python_embeded の親フォルダーへ移動する (以下は一例) cd C:\AI\ComfyUI_portable_check\ComfyUI_windows_portable ``` - 別フォルダー側では、GPU認識だけで「正常」と判定せず、**A6-1 (バージョン確認) と A6-2 (実際のCUDA計算) の両方** を実施する。 - 別フォルダー側が両方とも正常なら、同梱環境側の問題と確定する。そのフォルダーを正として `C:\AI\ComfyUI_windows_portable\` に置き換え、A5からやり直す。**この方法で解決する場合はpip修復は行わない。** - 別フォルダー側でも異常な場合は、次項の切り分けに進む。**この分岐では、同じバージョンの自動再インストールは行わない** (両環境で再現する異常は、同梱環境の破損ではなくPC側・ドライバ側・Portable選定側の問題である可能性が高いため)。 3. 両環境で異常な場合の切り分け (再インストールの前に実施する): - `nvidia-smi` を再実行し、ドライバがGPUを認識しているか、ドライババージョンはいくつかを確認する - A1に戻り、Studio Driverの再インストール (クリーンインストール) を検討する - エラーの全文、`nvidia-smi` の出力、`setup-record.txt` の内容を控え、状況をClaudeに報告して判断する (Portableの別リリースへの切り替え等を含む) 4. pip修復 (最終手段。上記の切り分けで「同梱パッケージ側の問題」と判断できた場合のみ)。**バージョン無指定の `pip install torch ...` は使わない** (既存版との混在・残留の原因になる)。手順: 1. 復元できる状態を残す: 現在の環境を記録し、対象フォルダーを退避する。**退避 (Rename-Item) は、対象フォルダーの中ではなく `C:\AI` に移動してから実行する** (フォルダー内にいる状態ではリネームできない)。 ```powershell cd C:\AI .\ComfyUI_windows_portable\python_embeded\python.exe -m pip list --format=freeze | Set-Content C:\AI\pip-freeze-before-repair.txt Rename-Item C:\AI\ComfyUI_windows_portable C:\AI\ComfyUI_windows_portable_bak # A4の手順で Portable を C:\AI\ へ再展開し (ComfyUI_windows_portable\ が新しくできる)、A5の配置をやり直す # 以後の手順は、再展開した新しいフォルダーへ移動し直してから実行する (退避側 _bak ではない) cd C:\AI\ComfyUI_windows_portable ``` 2. 対応する組合せの配布ファイルが公式インデックスから取得できることを、**3パッケージ全てについて、アンインストールの前に** 確認する。バージョンは A6-1 で正常時に記録した組合せ (torch / torchvision / torchaudio) を使い、実際にwheelをダウンロードして確認する。 ```powershell .\python_embeded\python.exe -m pip download torch==<記録したtorchのバージョン> torchvision==<記録したtorchvisionのバージョン> torchaudio==<記録したtorchaudioのバージョン> --index-url https://download.pytorch.org/whl/cu130 --no-deps -d C:\AI\wheel-check ``` 完了条件: `C:\AI\wheel-check` に torch / torchvision / torchaudio の3つの `.whl` が揃っていること。1つでも取得できない場合は、アンインストールに進まず、3項の切り分けに戻る。 3. 3つ全ての取得可能性が確認できた場合のみ、既存を除去してから明示指定で入れる。 ```powershell .\python_embeded\python.exe -m pip uninstall -y torch torchvision torchaudio .\python_embeded\python.exe -m pip install torch==<記録したtorchのバージョン> torchvision==<記録したtorchvisionのバージョン> torchaudio==<記録したtorchaudioのバージョン> --index-url https://download.pytorch.org/whl/cu130 ``` - `--index-url` の `cu130` は、記録した `torch.version.cuda` に対応させる (13.0 → `cu130`、12.8 → `cu128`)。取得元は公式インデックス `https://download.pytorch.org/whl/` 配下のみ。 - 修復後は A6-1 / A6-2 を再実行し、完了条件を満たすことを確認する。解消しない場合は退避した `ComfyUI_windows_portable_bak` に戻し、3項の切り分けに戻る。 ## A7: ローカル生成の検証 (SDXL base) ### A7-1. 起動 (localhostのみ) **実行場所**: エクスプローラー / PowerShell (通常) / **管理者権限**: 不要 ```powershell cd C:\AI\ComfyUI_windows_portable .\run_nvidia_gpu.bat ``` 完了条件: コンソールにエラーなく起動が完了し、ブラウザで `http://127.0.0.1:8188` が開くこと。使用デバイスとしてGPUが表示されること (CPUになっていないこと)。コンソールに出るVRAM総量などの数値は参考値であり合格条件ではない。この段階では `--listen` を付けない。外部からの到達経路は作らない。 ### A7-2. デフォルトワークフローで生成 初回表示されるデフォルトのtxt2imgワークフローを使用し、以下だけ変更する: 注: サイドバーの「ワークフロー」一覧は保存済みファイルの一覧であり、初回は空である (異常ではない)。キャンバスも空で、テンプレート一覧にモデル名の付いていない素の「テキストから画像へ」が無い場合は、テンプレート探しを続けず、配布ファイル **`sdxl_base_test.json`** (下表の値を設定済みのGUI形式ワークフロー) を使うこと。ダウンロードしたファイルをキャンバスへドラッグ&ドロップするか、「Workflow (ワークフロー)」メニューの「開く (Open)」(Ctrl+O) で読み込む。この場合は下表への変更は不要で、そのまま実行に進める。このファイルを `C:\AI\workflows\sdxl_base_test.json` に置いてから開けば、A7-3 の保存も兼ねる。 | 対象 | 設定値 | |---|---| | Load Checkpoint > ckpt_name | `sd_xl_base_1.0.safetensors` | | CLIP Text Encode (Positive) | `a city street in Tokyo at night, neon signs, rain, reflections, photorealistic, detailed` | | CLIP Text Encode (Negative) | `blurry, low quality, distorted, deformed` | | Empty Latent Image | width 1024 / height 1024 / batch_size 1 | | KSampler > seed | 42 (固定。再現性確認のため) | | KSampler > steps | 30 | | KSampler > cfg | 7.0 | | KSampler > sampler_name | `euler` | | KSampler > scheduler | `normal` | | KSampler > denoise | 1.0 | `Queue` (Run) を実行する。 完了条件: 1. エラーなく完了し、Save Imageノードに画像が表示される 2. `C:\AI\ComfyUI_windows_portable\ComfyUI\output\` にPNGが保存されている 3. 同じseed/設定で再実行すると同一画像になる 4. 2回目以降の生成が、初回のモデルロード時間を含まずに完了すること (所要時間の絶対値は条件としない) ### A7-3. ワークフローの保存 GUI形式で保存する: `C:\AI\workflows\sdxl_base_test.json` これはローカル確認用であり、パートBの中継APIには登録しない (中継APIに登録できるのは承認済みワークフローのみ。A8 / A9参照)。 ## A8: NoobAI-XL V-Pred 1.0 の追加 (本命モデル) ### A8-1. 取得と配置 **実行場所**: ブラウザ / PowerShell (通常) / **管理者権限**: 不要 - 配布元: Laxhar Lab (以下の公式ページからのみ取得する) - Hugging Face: https://huggingface.co/Laxhar/noobai-XL-Vpred-1.0 (ファイル名: `NoobAI-XL-Vpred-v1.0.safetensors`) - Civitai: https://civitai.com/models/929685 - 取得はHugging Faceを主とする (repo自体がV-Pred 1.0専用で、バージョンの取り違えが起きないため)。Civitai側は推奨設定とバージョン情報の確認用 - 保存先: `C:\AI\models\` に置いた後、A5と同様に `ComfyUI\models\checkpoints\` へコピーする 選定理由 (配布元の説明と、本手順の採用判断の区別): 配布元資料では、Danbooru + e621 の全データで学習され、ネイティブタグへの追従性が高いこと、v-prediction版はプロンプト追従と色域・光の表現を特徴とすることが説明されている。本手順では、利用者の「品質優先・初期設定の煩雑さは許容・運用時の手間は最小化」という方針に基づき、このモデルを本命として採用する (優劣の断定ではなく採用判断)。 ### A8-2. 設定の根拠 (配布元の指定と本手順の採用値の区別) 参照した配布元の情報 (対象バージョン: **V-Pred 1.0**): - Hugging Faceモデルカード: https://huggingface.co/Laxhar/noobai-XL-Vpred-1.0 - NoobAI公式ガイド: https://betterwaifu.com/blog/noobai-guide (パラメータ表・解像度表・Diffusers実装例) - Civitaiモデルページ: https://civitai.com/models/929685 配布元の指定・推奨と、本手順の採用値を分けて明記する。 **必須設定 (外すと v-prediction として正しく計算されず、色抜け・破綻する):** | 項目 | 値 | 根拠 | |---|---|---| | ModelSamplingDiscrete の挿入 | CheckpointLoaderSimple → ModelSamplingDiscrete → (RescaleCFG) → KSampler の順に接続 | 配布元のComfyUI向け指示 (追加ノードで対応) | | ModelSamplingDiscrete > sampling | `v_prediction` | モデル自体の予測方式。Diffusers例の `prediction_type: "v_prediction"` に対応 | | ModelSamplingDiscrete > zsnr | `true` | Diffusers例の `rescale_betas_zero_snr: True` に対応 (Zero Terminal SNR) | | KSampler > sampler_name | `euler` | 配布元指定。他のsamplerは正常に動作しない旨がモデルカード系資料に明記されている | **schedulerについての確認結果:** 配布元のモデルカードおよび公式ガイドには、ComfyUIのKSamplerにおける scheduler 名の指定は **存在しない** (Diffusers例はEulerDiscreteSchedulerであり、上記の v_prediction + ZSNR 指定がその中核)。したがって本手順では scheduler = `normal` を「配布元指定」ではなく **本手順の採用値** とする (ModelSamplingDiscreteがモデルのサンプリングを差し替える構成での標準値)。A8-4の生成確認で妥当性を検証し、破綻する場合はこの値を切り分け対象とする。 **推奨設定 (品質のための推奨。必須ではない):** | 項目 | 採用値 | 配布元の推奨範囲 | |---|---|---| | RescaleCFG > multiplier | `0.7` | 公式ガイドが強く推奨する選択肢の1つ (約0.7)。代替案はEuler Ancestral CFG++ + CFG 1–1.8だが本手順では採用しない | | KSampler > cfg | `4.5` | Euler時 3.5–5.5 (公式ガイド) / 4–5 (モデルカード系資料) | | KSampler > steps | `32` | Euler時 32–40 (公式ガイド) / 28–35 (モデルカード系資料) の重なる範囲 | | KSampler > denoise | `1.0` | txt2imgの標準 | | 解像度プリセット | 後述の7種から選択 | 公式ガイドの解像度表: 768x1344 / **832x1216** / 896x1152 / 1024x1024 / 1152x896 / **1216x832** / 1344x768 | | Positiveの先頭タグ | `masterpiece, best quality, very awa` を基本形とする | 公式ガイドの推奨 (品質タグ `masterpiece` + 美学タグ `very awa`) | | Negativeの基本形 | `nsfw, worst quality, old, early, low quality, lowres, signature, username, logo, bad hands, mutated hands, mammal, anthro, furry, ambiguous form, feral, semi-anthro` | 配布元のDiffusers実装例の negative_prompt | 構成上の制約: - 使用ノードは全てComfyUI本体の標準ノードのみ (CheckpointLoaderSimple / ModelSamplingDiscrete / RescaleCFG / CLIPTextEncode / EmptyLatentImage / KSampler / VAEDecode / SaveImage)。**カスタムノードは追加しない。** - VAEはcheckpoint内蔵のものを使う (別途VAEファイルは不要。配布元資料でも外部VAE不要とされている)。CLIP skipは設定しない。 - LoRAはIllustrious系のものが使える。Pony系LoRAとは混用しない。 ### A8-3. ワークフローファイルの配置 作成済みのワークフローJSON (本手順書と一緒に配布されるファイル) を配置する。 **実行場所**: エクスプローラー / テキストエディタ / **管理者権限**: 不要 | ファイル | 形式 | 配置先 | |---|---|---| | `noobai_vpred.json` | GUI形式 | `C:\AI\workflows\noobai_vpred.json` | | `noobai_vpred_api.json` | API形式 | `C:\AI\workflows\noobai_vpred_api.json` | 両ファイルとも `ckpt_name` はプレースホルダ (`REPLACE_WITH_DOWNLOADED_NOOBAI_VPRED_FILENAME.safetensors`) になっている。配置後に実ファイル名へ置き換える。 - GUI形式: ComfyUIに読み込んだ後、Load Checkpointノードで実ファイルを選択し直して上書き保存する - API形式: テキストエディタでプレースホルダ文字列を実ファイル名に置換して保存する 完了条件: 両ファイルが配置され、`ckpt_name` がダウンロードした実ファイル名と一致していること。 今後の変更時のルール: GUI形式 (`noobai_vpred.json`) のノード構成やパラメータを変更した場合は、その変更済みワークフローをComfyUIの `Export (API)` で再出力してAPI形式を作り直し、**API形式でもローカル生成の確認を行ってから**、承認済みワークフローとして差し替える。GUI形式だけを変更してAPI形式を古いまま運用しない。 ### A8-4. ローカル生成の確認 (NoobAI) **実行場所**: ComfyUI画面 / **管理者権限**: 不要 1. A7-1と同様にComfyUIを起動し、GUI形式 `noobai_vpred.json` を読み込む。 2. Positiveのシーンタグ部分は初期値のままでよい。`Queue` を実行する。 完了条件: エラーなくPNGが生成され、目視で全面ノイズ・著しい色抜け・露出破綻が無いこと。生成されたら、このワークフローが「承認済みワークフロー (noobai_vpred)」としてパートBの登録対象になる。 ## A9: WAI-NSFW-illustrious-SDXL の追加 (予備 / フォールバック) 役割: epsモデルのため標準設定で使える予備モデル。本運用開始後は、NoobAIで再生成しても崩れるシーンのフォールバック先になる。 **実行場所**: ブラウザ / PowerShell (通常) / ComfyUI画面 / **管理者権限**: 不要 1. 取得: Civitaiのモデルページから最新安定版を取得する。 - 公式ページ: https://civitai.com/models/827184/wai-nsfw-illustrious-sdxl - Civitaiはモデルページの下に複数のバージョンがぶら下がる構造のため、ダウンロード時に「最新安定版のバージョン」を選択していることを確認する (バージョンごとにファイルと推奨設定が異なる)。取得したファイル名を正確に記録する 2. 配置: `C:\AI\models\` に置いた後、`ComfyUI\models\checkpoints\` へコピーする。 3. ワークフロー: 作成済みの `wai_nsfw_api.json` (API形式) を `C:\AI\workflows\` に配置し、`ckpt_name` のプレースホルダを実ファイル名に置換する。設定値はモデルページ推奨 (sampler `euler_ancestral` / CFG 5–7 / steps 30前後) に基づき、初期値は euler_ancestral / CFG 6 / steps 30 とする。 4. ローカル生成の確認: `sdxl_base_test.json` を読み込み、checkpointをWAIのファイルに、KSamplerを euler_ancestral / CFG 6 / steps 30 に変更して生成する。確認できたらGUI形式でも保存する (`C:\AI\workflows\wai_nsfw.json`)。 完了条件: エラーなくPNGが生成されること。このワークフローが「承認済みワークフロー (wai_nsfw)」としてパートBの登録対象になる。 パートA完了の定義: A7 (base)、A8 (NoobAI)、A9 (WAI) の3モデル全てでローカル生成が確認できていること。 ### 採用しないもの (参考) - Illustrious XL v2.0 公式checkpoint: マージ/学習のベース用に調整された「untuned base」であり、そのまま生成用途に使う前提ではない - Animagine XL 系: SFW指向であり、本用途の前提に合わない - Pony Diffusion V6 XL: 使いたいLoRAがPony系にしか存在する場合のみ追加候補とする (タグ体系が別系統のため) --- # パートB: Muse 連携 (中継API方式) 前提: パートAが完了していること。本パートの作業は、提示された内容をユーザーが確認してから進める。Tailscale参加承認やPC設定の変更を先行して行わない。 **設定の順序に関する原則**: 中継APIを先に起動してから制限を絞る、という順序は取らない。制限 (ACL / Firewall) を先に準備し、Museの参加 → 端末確認 → タグ付与 → Firewall確定が全て完了してから、最後に中継APIを起動する。制限が完成する前に到達可能になる時間を作らない。 ## B0: 設計 ```text Muse VM (デバイス名: <Muse VMのデバイス名>) | Tailscale (WireGuard)。ACLで「tag:muse -> このPCのTailscale IP : 8190 (tcp)」のみ許可 v 中継API (relay_api.py) <- このPCのTailscale IP : 8190 で待ち受け。これだけが外部に公開される | http://127.0.0.1:8188 (同一PC内のみ) v ComfyUI <- 127.0.0.1 : 8188 で待ち受け。外部からは到達不能 ``` ComfyUIのHTTP APIには認証機能が無く、到達できる主体は誰でも生成ジョブの投入と生成結果の取得ができる。そのため **ComfyUI自体は外部に公開せず**、以下の制約を持つ中継APIだけを公開する。 中継APIの仕様 (実装: `relay_api.py`、Python標準ライブラリのみ): - 使用できるワークフローは、PC側に保存された承認済みワークフローだけ (登録ID: `noobai_vpred` / `wai_nsfw`。実体は `C:\AI\workflows\` のAPI形式JSON) - 呼び出し側が変更できるのは次の4項目だけ: `prompt` / `negative_prompt` (任意) / `seed` / `resolution` (許可済みプリセット7種のみ)。それ以外の項目を含むリクエストは 400 で拒否する。入力は型検証を先に行い、不正な型 (配列・辞書・浮動小数点のseed等) も全て規定のJSON形式で 400 を返す - 任意のワークフローJSON、ノード指定、ファイルパス、接続先URL、コマンドは受け付けない (リクエスト形式にその欄自体が存在しない。未知のキーは拒否) - 同時実行は1件。待ち行列は上限付きキュー (既定4件) で管理し、受理判定は `put_nowait` で行うため、同時リクエストが競合しても上限を超えて受理しない。拒否したジョブは記録に残さない。超過時は 429 - 状態管理: ComfyUI側の処理状態が不明になったジョブ (生成タイムアウト後に自ジョブの取消を確認できない場合、`/prompt` の応答喪失、通信断) がある間は、**新規投入を停止する** (503 / `submit_suspended`)。復旧条件は次のいずれか: - 対象ジョブが自ジョブであることが prompt_id で照合でき、終了または取消を確認できた場合は、失敗として確定し自動で再開する。取消手段は「待機中ジョブへの prompt_id 指定 delete」だけに限定する。**実行中ジョブへの自動割り込みは行わない**: ComfyUI の `/interrupt` は対象を指定できない全体割り込みであり、直前のID確認だけでは、確認と実行の間に実行ジョブが別ジョブへ交代していた場合の誤停止を防げないためである。実行中ジョブがタイムアウトした場合は状態不明として投入を止める。無関係なジョブの一括停止 (clear等) も行わない - 照合できない場合は、PC側コンソールで運用者がComfyUIのキューを確認し、`resume` コマンドで再開する (B4-3) - ゲート停止中は、新規受付だけでなく **ワーカーによる待機済みジョブの投入も行わない** (待機ジョブは queued のまま保持され、再開後に処理される) - 中継APIの起動時にComfyUIのキューに残件がある場合も、前インスタンスの残りかGUIのジョブか区別できないため、同じく停止状態で起動する。起動時にComfyUIの状態を確認できない場合 (未起動・通信不可) も、開放状態にはせず停止を維持する - 生成エラーの判定: ComfyUIは生成失敗時に `completed=false, status_str="error"` を返すため、エラー判定を完了判定より先に行う - 接続資源の制限: 本文サイズ上限64KB、接続の読み書きタイムアウト (既定15秒)、同時接続数の上限 (既定32)。応答後は接続を閉じ、未読の本文を次のリクエストに持ち込ませない - 保持の上限: ジョブ記録は保持期限 (既定3600秒) と件数上限 (既定100件) で削除する。完了・失敗・状態不明のいずれでも、プロンプトの内容は終端状態への遷移時に記録から除去する。生成画像ファイルはComfyUIの output フォルダに残る (ComfyUIの標準APIにはファイル削除機能が無いため、中継APIはファイルを削除しない。容量管理は利用者側で行い、中継API経由の生成物はファイル名の接頭辞 `relay_` で識別できる) - 画像取得は、この中継APIが発行したジョブIDに対してのみ可能。ジョブIDはUUIDであり、ComfyUI側の実ファイル名は外部に返さない - ComfyUIの任意APIへの転送はしない。内部で使うのは `/prompt` (投入)、`/history/{id}` (状態確認)、`/view` (自ジョブの画像取得)、`/system_stats` (死活確認)、`/queue` (状態確認と、待機中自ジョブのID指定delete専用) の固定用途のみで、パスや宛先を呼び出し側が指定する余地はない。`/interrupt` は使用しない - `/queue` の応答は形式を検証する (両キーの存在・配列型・各要素と prompt_id の形式)。不正な応答は「キューが空」と解釈せず、状態確認失敗として扱う。この判定は起動時・resume・取消確認で共通の処理を使う - 投入結果は区別する: HTTP 400 で、既知の error.type (`prompt_outputs_failed_validation` / `prompt_no_outputs` / `invalid_prompt`) と node_errors オブジェクト (空でも可) を持つ検証エラー応答を確認できた場合は「明確な投入拒否」として当該ジョブを failed にし、後続ジョブの処理を継続する。HTTP 200 で有効な prompt_id が返った場合は、node_errors が併記されていても受理済みとして ID を保持し完了まで追跡する (一部の出力系統だけが検証失敗でも、正常な系統の生成は走るため)。通信断・応答喪失・成否を判断できない応答は unknown として投入を停止する (HTTPエラーを一律に failed へは変えない) - 画像取得の失敗は規定のJSONエラーで返す: 画像の消失 (404) は `image_not_found`、ComfyUIへの接続失敗は `comfy_unreachable` とし、呼び出し側から接続断に見える形にはしない。画像取得に失敗しても生成ジョブの再投入はしない - 待ち受けアドレスはTailscale帯域 (100.64.0.0/10) のIPだけを許可し、`0.0.0.0` やLANのIPを指定すると起動を拒否する。接続先ComfyUIも `127.0.0.1` 以外を指定すると起動を拒否する 起動時の「構造検証」の範囲 (正確な記載): 起動時に検査するのは、JSONとして読み込めること、KSamplerがちょうど1つ存在すること、その positive / negative の接続先が CLIPTextEncode で latent_image の接続先が EmptyLatentImage であること、CheckpointLoaderSimple が存在し ckpt_name がプレースホルダではなく `.safetensors` で終わること、SaveImage が存在すること、のみである。**任意のワークフローの安全性や生成結果の正しさを判定する機能ではない。** 承認済みかどうかの担保は、この検証ではなく「どのファイルを `C:\AI\workflows\` に配置したか」という配置管理側にある。 実現上の制約 (直接公開への切り替えはしない): - 中継API自体には認証が無い。認証境界は Tailscale のACL (B2/B3) と Windows Firewall (B2/B3) による到達制御が担う。これは設計上の割り切りであり、ACL/Firewallの限定が崩れた場合は中継APIも無防備になる。だからこそ「広い許可を残さない」確認が必須工程になっている - 中継APIとComfyUIは、同一ユーザーセッションで通常権限のプロセスとして手動起動する。サービス化 (管理者権限での常時実行) はしない。PCを再起動した場合は B4 の起動操作が必要になる (自動化したい場合はタスクスケジューラの「ログオン時・通常権限」起動で代替できるが、初期導入では手動とする) - Windows Firewallのプログラム指定は `python_embeded\python.exe` 単位であり、同じ同梱pythonで動くComfyUIと中継APIをプログラム名だけでは分離できない。分離は「バインド先 (127.0.0.1 か Tailscale IPか) + ポート + 送信元IP」の組合せで行い、加えて 8188 への外部着信を明示的にブロックするルールを併用する (ループバック通信はFirewallの処理対象外のため、ローカル動作には影響しない) 禁止事項: ルーターのポート開放、Tailscale Funnel、公開リバースプロキシ、その他インターネットへの公開は行わない。 ## B1: 中継APIの配置 (この段階では起動しない) **実行場所**: エクスプローラー / PowerShell (通常) / **管理者権限**: 不要 1. `relay_api.py` を `C:\AI\relay\relay_api.py` に配置する。 2. 承認済みワークフローが `C:\AI\workflows\` に配置され、`ckpt_name` が実ファイル名に置換済みであることを確認する (A8-3 / A9)。 ```powershell Select-String -Path C:\AI\workflows\noobai_vpred_api.json, C:\AI\workflows\wai_nsfw_api.json -Pattern "REPLACE_WITH" ``` 完了条件: 上記コマンドで何も出力されないこと (プレースホルダが残っていないこと)。**この時点では中継APIを起動しない。** ## B2: アクセス制限の準備 (中継APIは停止したまま) ### B2-1. Tailscaleの導入と自機IPの確認 **実行場所**: ブラウザ / PowerShell (通常) / **管理者権限**: インストール時に要 (インストーラが要求する) 1. Tailscale公式からWindows用クライアントをインストールし、サインインする。 - 公式ページ: https://tailscale.com/download/windows 2. 自機のTailscale IPv4を取得し、`C:\AI\setup-record.txt` に記録する。 ```powershell tailscale ip -4 # 出力例: 100.x.y.z (以下、このPCのIPを <あなたのPCのIPアドレス> と表記する) ``` 3. Windows起動時にTailscaleサービスが自動起動する状態であることを確認する。 注: この段階では **Muse (VM) の参加はまだ行わない**。参加は B3 で、制限の準備が終わってから行う。 ### B2-2. 既存ポリシーの確認とACLの差分準備 **実行場所**: ブラウザ (Tailscale管理コンソール > Access controls) / **管理者権限**: 不要 (管理コンソールの管理者アカウントで操作) 前提知識: - 識別について: Tailscaleのデバイス名 `muse` は表示名であり、識別子としては使わない。識別は (a) 承認時に管理コンソールに表示されるデバイスIDの記録、(b) 管理者だけが付与できるタグ、(c) 割り当てられたTailscale IP、の組合せで行う - **`autogroup:member` は「利用者本人だけ」を意味しない。** tailnetの全メンバー (の端末) を指す。複数メンバーがいるtailnetでは、自分の端末だけに絞る場合は src に自分のユーザー (メールアドレス) または自端末用のグループを明示すること - タグを付けた端末は、タグがその端末のidentityになる。タグ付き端末には `autogroup:member` 系のルールは適用されなくなる。**したがって、このPC自体に `tag:pc` を付けると、member宛ての既存ルールでは自分の端末からこのPCへアクセスできなくなる。** 本手順ではPCにはタグを付けず、宛先はPCのTailscale IPで限定する構成を第一案とする - タグは複数端末に付けることができる。`tag:muse` を付けた端末は全て同じ許可を受けるため、付与は対象の1台だけに限定し、付与状況を管理コンソールで監査できる状態にすること。タグの付与権限は `tagOwners` で管理者に限定される 手順: 1. 現在のポリシー全体をコピーし、バックアップとして手元に保存する。**既存ポリシーを確認せずに、例示JSONで全置換しないこと。** 2. 既定のポリシーは tailnet内全デバイス間の全ポート通信を許可する (`src: ["*"]` → `dst: ["*:*"]` 相当)。このような広い許可が残っているかを確認する。残っている場合、そのままでは Museデバイスにも広い許可が適用され得るため、限定したことにならない。 3. 広い許可がある場合は、自分の端末の必要な通信を維持する形に **差分として** 書き換える。考え方: - 自分の端末 → このPC: 宛先を `<あなたのPCのIPアドレス>` に限定したルールで維持する。src は、単独メンバーのtailnetなら `autogroup:member` でもよいが、その意味 (上記) を理解した上で使うこと。厳密に自分だけに絞る場合は自分のユーザーを明示する - 自分の端末同士 (PC以外): 既存のルールを保持する - Museデバイス向け: B3でタグ付与後に有効になる1条だけを先に追加しておく 4. 差分の例 (自分の環境の既存ルールを保持した上で、広い許可を置き換える場合の形): ```json { "tagOwners": { "tag:muse": ["autogroup:admin"] }, "acls": [ { "action": "accept", "src": ["<自分のユーザー (メールアドレス)>"], "dst": ["<あなたのPCのIPアドレス>:*"] }, { "action": "accept", "src": ["tag:muse"], "dst": ["<あなたのPCのIPアドレス>:8190"], "proto": "tcp" } ] } ``` - Muse向けルールには `proto: "tcp"` を明示する - 許可は src → dst の一方向のみ。PCからMuseデバイス方向への許可は不要 (Muse側クライアントは着信接続を受け付けない) - 既存ポリシーに exit node や subnet router 等のルールがある場合は、それらを削除せず保持すること。grants形式で管理している場合は、同等の内容 (src `tag:muse` → dst `<あなたのPCのIPアドレス>`、`ip: ["tcp:8190"]`) に読み替える - 作業中の注意: 後述の通り、参加直後・タグ付与前のClaudeデバイスは、承認したユーザーのidentityで評価され得る。その間に広い権限を得ないよう、**この時点で「自分のユーザー → PC」方向の許可も含め、PC宛ての広い許可 (<あなたのPCのIPアドレス>:* のような全ポート許可) がClaudeデバイスに及ばない状態** にしておく。自分のPCアクセス用ルールの復帰は、タグ付与後 (B3) に行う。導入作業中はPCを直接操作している前提のため、この一時的な制約は実作業に影響しない - **確認範囲は対象PCだけではない。** 参加直後のデバイスが既存許可で到達できる範囲は、ポリシーの全ルールに依存する。参加を承認する前に、ポリシー全体を src の観点で棚卸しし、参加デバイスに適用され得るルールの宛先を全て列挙して確認すること。具体的には次のような宛先が意図せず開いていないかを見る: - このPC以外の自分の端末 (他PC、NAS、スマホ等) への許可 - subnet route 経由の到達 (ルールを宛先CIDRで書いている場合、そのサブネット全体 = 自宅LAN等への到達になり得る) - exit node の利用可否、共有 (shared-in) されている他者の端末への到達 棚卸しの結果、参加デバイスに及ぶ許可は「<あなたのPCのIPアドレス>:8190 の1条 (タグ付与後)」だけになるよう、この段階で差分を確定させる 完了条件: ポリシーがエラーなく保存でき、広い許可 (`*` 宛て・全ポート) が残っていないこと。自分の他端末からこのPCへのアクセスを維持する必要がある場合は、その分のルールが宛先IP限定で残っていること。 ### B2-3. Windows Firewallの実効ルール確認 狭いルールを追加するだけでは、既存の広い許可が残っている限り制限にならない。またルール名 (DisplayName) は重複し得るため、変更対象の特定は一意の `Name` で行う。 注意: `Get-NetFirewallRule` の既定の確認対象は PersistentStore (ローカルに保存されたルール) であり、グループポリシー等で配布されたものを含む実効設定とは一致しない場合がある。本手順の確認は全て **`-PolicyStore ActiveStore`** を指定し、実際に効いている設定 (実効ルール) を対象にする。 **実行場所**: PowerShell (管理者) / **管理者権限**: 要 1. 有効な受信許可ルールの実効内容 (プログラム、プロトコル、ポート、送信元・宛先、プロファイル、インターフェース) を確認する。特に「ポート8190/8188を許可し得るルール」と「python_embededを対象にするルール」を洗い出す。 ```powershell # 全ての有効な受信許可ルールの実効内容を一覧化する Get-NetFirewallRule -PolicyStore ActiveStore -Direction Inbound -Enabled True -Action Allow | ForEach-Object { $app = $_ | Get-NetFirewallApplicationFilter $port = $_ | Get-NetFirewallPortFilter $addr = $_ | Get-NetFirewallAddressFilter $if = $_ | Get-NetFirewallInterfaceTypeFilter [PSCustomObject]@{ Name = $_.Name DisplayName = $_.DisplayName Profile = $_.Profile Program = $app.Program Protocol = $port.Protocol LocalPort = $port.LocalPort LocalAddress = ($addr.LocalAddress -join ",") RemoteAddress = ($addr.RemoteAddress -join ",") InterfaceType = $if.InterfaceType } } | Format-List | Out-File C:\AI\firewall-inbound-allow.txt ``` 2. Firewallプロファイルの有効状態と、実効的な受信既定動作を確認する。既定動作が `Allow` のプロファイルでは、許可ルールが無くても着信が通ってしまうため、その場合はルールの有無だけでは制限にならない。 ```powershell Get-NetFirewallProfile -PolicyStore ActiveStore | Format-Table Name, Enabled, DefaultInboundAction, DefaultOutboundAction ``` 完了条件: Tailscaleのインターフェースが属するプロファイル (通常は Private。導入時に実機で確認する) が `Enabled: True` で、`DefaultInboundAction` が `Block` であること。`Allow` の場合はその旨を記録し、既定動作の変更は他用途への影響を確認してから判断する (この場で勝手に変更しない)。 3. `C:\AI\firewall-inbound-allow.txt` の中で、次に該当するルールを特定する: - Program が `Any` で LocalPort が `Any` または 8190 を含むもの (プログラム指定なしで8190を許可してしまうルール) - Program が `*python_embeded*` に一致するもの (初回起動ダイアログで許可したルールを含む) - RemoteAddress が `Any` のまま対象ポートを開いているもの 4. 該当ルールが「広い許可」にあたる場合は、そのルールの **`Name`** を控えた上で、無効化または削除する。他用途のルールを誤って変更しないよう、DisplayNameではなくNameで指定すること。 ```powershell Disable-NetFirewallRule -Name "<確認したルールのName>" # 完全に削除する場合: Remove-NetFirewallRule -Name "<確認したルールのName>" ``` 判断に迷うルールは変更せず、Nameと内容を控えてClaudeに報告する。 5. ComfyUI (8188) への外部着信の明示ブロックは、送信元に依存しないためこの段階で先に作成する (一意のNameを付ける): ```powershell New-NetFirewallRule -Name "ComfyUI8188BlockExternal" -DisplayName "ComfyUI 8188 Block (external)" -Direction Inbound -Action Block -Protocol TCP -LocalPort 8188 ``` 完了条件: 8190を許可し得る広いルールが残っていないこと。8188ブロックルールが実効設定として `Enabled: True` であること。 ```powershell Get-NetFirewallRule -PolicyStore ActiveStore -Name "ComfyUI8188BlockExternal" ``` ## B3: Muse の参加と許可の確定 この工程で初めてClaude (VM) をtailnetに参加させる。**中継APIはまだ起動しない。** 手順 (順序を入れ替えない): 1. Claude側が参加リンクを提示する。ユーザーはリンク先が自分のtailnetであることを確認して承認する。 2. 管理コンソール > Machines で、参加したデバイスの **デバイスID** と **Tailscale IP** (以下 `<Muse VMのIPアドレス>`) を確認し、`C:\AI\setup-record.txt` に記録する。デバイス名が `muse` であることは確認項目の一つに過ぎず、識別はこのデバイスIDとIPで行う。 3. **直ちに** そのデバイスへ `tag:muse` を付与する (Edit tags)。タグ付与により、そのデバイスのidentityはタグに置き換わり、B2-2で用意した Muse向けルール (tag:muse → <あなたのPCのIPアドレス>:8190/tcp) だけが適用される状態になる。タグ付与前のデバイスは承認ユーザーのidentityで評価され得るため、この間はPCを操作し続け、放置しない。 4. 自分の端末からこのPCへのアクセス用ルールを (B2-2で一時的に外していた場合は) ここで戻す。タグ付与後のClaudeデバイスには member 系・ユーザー系のルールは適用されないため、安全に戻せる。 5. Windows Firewallに中継API用の許可ルールを作成する (一意のNameを付ける)。送信元は `<Muse VMのIPアドレス>` に固定する。 **実行場所**: PowerShell (管理者) / **管理者権限**: 要 (本手順で管理者権限が必要なのはこの工程とドライバ導入時、B2-3だけ) ```powershell New-NetFirewallRule -Name "ClaudeRelay8190Tailscale" -DisplayName "Claude Relay 8190 (Tailscale)" -Direction Inbound -Action Allow -Program "C:\AI\ComfyUI_windows_portable\python_embeded\python.exe" -Protocol TCP -LocalAddress <あなたのPCのIPアドレス> -LocalPort 8190 -RemoteAddress <Muse VMのIPアドレス> ``` 完了条件: - `Get-NetFirewallRule -PolicyStore ActiveStore -Name "ClaudeRelay8190Tailscale"` で実効設定として `Enabled: True` であること - 管理コンソールで、Claudeデバイスに `tag:muse` が付いており、適用される許可が `<あなたのPCのIPアドレス>:8190` の1条だけであること - デバイスIDと `<Muse VMのIPアドレス>` が `setup-record.txt` に記録されていること ## B4: 起動手順 (制限の確定後に初めて起動する) ### B4-1. ComfyUIを 127.0.0.1 で起動する `run_nvidia_gpu.bat` は編集せず、連携用の起動ファイルを新規作成する: `C:\AI\ComfyUI_windows_portable\run_comfyui_local.bat` ```bat @echo off cd /d %~dp0 .\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build --listen 127.0.0.1 --port 8188 pause ``` **実行場所**: エクスプローラー (ダブルクリック) / **管理者権限**: 不要 完了条件: ブラウザで `http://127.0.0.1:8188` が開くこと。A7の `run_nvidia_gpu.bat` と同時には起動しない (ポートが競合する)。 ### B4-2. 中継APIを起動する 起動ファイルを新規作成する: `C:\AI\relay\run_relay.bat` (`<あなたのPCのIPアドレス>` は B2-1 で取得した値に置換する) ```bat @echo off cd /d C:\AI\relay C:\AI\ComfyUI_windows_portable\python_embeded\python.exe relay_api.py --host <あなたのPCのIPアドレス> --port 8190 --workflows-dir C:\AI\workflows pause ``` **実行場所**: エクスプローラー (ダブルクリック) / **管理者権限**: 不要 完了条件: コンソールに次が表示されること。 - ワークフロー2件の登録ログ (`noobai_vpred` / `wai_nsfw`) - `listening on http://<あなたのPCのIPアドレス>:8190` - 投入停止の警告が出ていないこと (起動時にComfyUIのキューに残件がある場合、またはComfyUIの状態を確認できない場合は停止状態で起動する。その場合はB4-3で対処する) `ckpt_name` 未設定等の問題がある場合は、エラーメッセージを表示して起動が止まる (その場合はA8-3 / A9の置換漏れを確認する)。 ### B4-3. 状態不明時の再開手順 (resume) 中継APIが「投入を停止しました」と表示した場合 (ComfyUI側の状態が不明なジョブがある、起動時にキュー残件があった、または起動時にComfyUIの状態を確認できなかった場合): 1. ComfyUIの画面 (http://127.0.0.1:8188) でキューを確認し、中継API経由のジョブが残っていないこと、または残っているジョブの扱い (完了を待つ / GUIから当該ジョブだけを止める) を決める。**無関係なジョブの一括停止はしない。** 実行中の対象ジョブを止める必要がある場合は、この画面から運用者が行う (中継APIは自動割り込みをしない)。 2. 中継APIのコンソールで `resume` と入力する。再開には次の条件がある: - ComfyUIのキュー状態を確認できること。**確認できない場合は、`resume force` でも再開せず、停止を維持する** (ComfyUIの起動を先に確認する) - キューが空であること。残件がある場合は内容が表示され、停止が維持される。内容を確認済みで再開する場合のみ `resume force` と入力する 3. 状態確認は同じコンソールで `status` と入力する。 ### B4-4. PC単体での死活確認 **実行場所**: PowerShell (通常) / **管理者権限**: 不要 ```powershell Invoke-RestMethod http://<あなたのPCのIPアドレス>:8190/health ``` 完了条件: `status` が `ok`、`comfy` が `ok`、`submit_gate` が `open` と返ること。ComfyUIを停止した状態では `comfy` が `unreachable` (HTTP 503) になることも確認する。 ## B5: 検証 (正常系・拒否系) B1–B4が完了した後に、Museと共同で実施する。実施した項目は、表の「状態」欄に結果を記録していく (未実施の項目は未検証のまま扱う)。 | ID | 種別 | 内容 | 期待結果 | 状態 | |---|---|---|---|---| | T1 | 正常系 | Claudeから中継APIへ `POST /generate` (承認済みワークフロー、許可済み解像度) を実行し、`GET /jobs/{id}` で完了を確認、`GET /jobs/{id}/image` で画像を取得する | ジョブが succeeded になり、生成画像 (PNG) が取得できる | 未検証 | | T2 | 静的確認 | Tailscale管理コンソールのポリシーテストで、src `tag:muse` から (a) `<あなたのPCのIPアドレス>:8190` (b) `<あなたのPCのIPアドレス>:8188` (c) 他端末の任意ポート、への可否を確認する | (a) のみ許可、(b)(c) は拒否と表示される | 未検証 | | T3 | 拒否系 (実通信) | PCのTailscale IPで待ち受けるテスト用リスナーを別ポート (例: 8191) に一時的に立てた上で、Claudeから 8190 と 8191 の両方へ接続を試みる | 8190 は到達できる、8191 は (待ち受けているにもかかわらず) 到達できない。**この試験は到達制限全体 (ACL + Firewall の合算) の確認であり、どちらが遮断したかは判別できない。** ACL単独の許可・拒否の切り分けは T2 のポリシーテストの範囲とする | 未検証 | | T4 | 拒否系 | ClaudeからこのPCの `8188` へ直接接続する (`http://<あなたのPCのIPアドレス>:8188/system_stats`) | 接続できない (ComfyUIはloopbackのみで待ち受けており、Firewallのブロックも併用されている) | 未検証 | | T5 | 拒否系 | 中継APIへ未許可のワークフローIDを指定する (例: 登録されていないID、任意の文字列) | 400 (`unknown_workflow`) で拒否される | 未検証 | | T6 | 拒否系 | 中継APIへ不正な入力を送る (未許可の解像度 `512x512`、未知のキー `url` の追加、2000文字超のプロンプト、範囲外・浮動小数点・文字列のseed、配列の workflow_id、辞書の resolution) | いずれも規定のJSON形式で 400 が返る (500や接続断にならない) | 未検証 | | T7 | 拒否系 | 中継APIの未定義パスへアクセスする (例: `GET /system_stats`、`POST /prompt`) | 404 になり、ComfyUIへ転送されない | 未検証 | | T8 | 制限 | 待ち行列の上限を超えてジョブを同時投入する (実行中1件 + キュー上限を超える分を並行して送る) | 上限分だけ受理され、超過分が 429 (`queue_full`) で拒否される。拒否されたジョブの記録は残らない | 未検証 | | T9 | 拒否系 | 中継APIが発行していないジョブIDで `GET /jobs/{id}` / `GET /jobs/{id}/image` を呼ぶ | 404 (`job_not_found`) になり、他ジョブの画像は取得できない | 未検証 | | T10 | 状態管理 | 生成が長時間終わらないジョブを作り、中継API側のタイムアウト後に、自ジョブの取消確認と後続ジョブの扱いを確認する | 待機中ジョブはID指定deleteで取消が確認できれば失敗として確定し後続が継続する。実行中ジョブは自動割り込みされず、状態不明となり新規投入が 503 で停止する | 未検証 | | T11 | 状態管理 | 状態不明でゲートが閉じた時点で待機済みのジョブが、ゲート再開までComfyUIへ投入されないこと、再開後に処理されることを確認する | ゲート停止中は待機ジョブが queued のまま保持され、ComfyUI側に新規投入が発生しない。再開後に順に処理される | 未検証 | | T12 | 状態管理 | ComfyUIを停止した状態で中継APIを起動し、そのまま `resume` / `resume force` を試みる | 状態を確認できない間はゲートが閉じたままになる。ComfyUI起動後に `resume` で再開できる | 未検証 | | T13 | 状態管理 | ComfyUIの /queue 応答が不正な形式を返す状況 (実機では再現が難しいため、モックでの確認を主とする) で、起動・`resume`・`resume force` を試みる | 形式不正は「キューが空」と解釈されず、状態確認失敗として停止が維持される | 未検証 | | T14 | 状態管理 | ComfyUIが検証エラーを返すワークフローを投入する (ノード単位の失敗と、存在しないノード種別など node_errors が空になる全体検証エラーの両方) | いずれも当該ジョブは failed になり、後続ジョブは継続して処理される。通信断など成否を判断できない応答の場合は unknown になり投入が停止する。また HTTP 200 + prompt_id + node_errors (部分検証エラー) の場合は受理済みとして生成完了まで追跡される | 未検証 | | T15 | エラー処理 | 生成成功後に、出力画像を削除した場合と、ComfyUIを停止した場合に、それぞれ画像取得を試みる | 404 は `image_not_found`、接続失敗は `comfy_unreachable` の規定JSONエラーが返り、接続断としては見えない。生成ジョブの再投入は発生しない | 未検証 | 補足: 中継APIのロジック自体は、モックのComfyUIを使った回帰テスト (`relay_regression_test.py`) で別途確認できる。ただしモックでの確認をもって実機検証の代替とはしない。 ## B6: 停止・解除手順 ### B6-1. 一時停止 (生成経路の遮断) - 中継APIのコンソールウィンドウで `Ctrl+C` を押す (またはウィンドウを閉じる)。これで外部からの生成経路は消える - ComfyUIも同様にコンソールで停止できる。中継APIだけ止めれば外部からは到達不能になる - 注: B4-3の `resume` は「状態不明による投入停止」の解除であり、アクセス許可の解除とは別物である ### B6-2. Firewallルールの削除 **実行場所**: PowerShell (管理者) / **管理者権限**: 要。ルールは一意の `Name` で特定する。 ```powershell Remove-NetFirewallRule -Name "ClaudeRelay8190Tailscale" Remove-NetFirewallRule -Name "ComfyUI8188BlockExternal" ``` B2-3で無効化した既存ルールを元に戻す場合は `Enable-NetFirewallRule -Name "<控えたName>"`。 ### B6-3. Tailscaleのアクセス許可の撤回 (2つの操作の区別) 撤回には性質の異なる2つの操作があり、混同しないこと。 1. **Muse側の `down` (VM側の切断とidentity消去)** - Muse側の内蔵クライアント <Muse内蔵のtailscaleコマンド> down を実行すると、接続が切断され、VM側のTailscale identityが消去される (これは上流の `tailscale down` = 切断のみ、とは異なるMuse独自の動作。Muse側の仕様文書に明記) - ただし **管理コンソール側のデバイス登録はこれでは消えない** 2. **管理コンソールでのデバイス削除 (完全な失効)** - Tailscale管理コンソール > Machines から対象デバイスを削除する。これで登録自体が消え、完全な失効になる - 併せて、B2-2のACLから `tag:muse` の許可行を削除すると、再参加しても許可が復活しない状態になる ### B6-4. 再参加時の再確認 Museが再参加した場合、新しい node key が発行され、**デバイスの識別情報 (デバイスID、Tailscale IP) が変わる可能性がある**。その場合は次を必ず再確認・更新する。 - 管理コンソールで新しいデバイスIDとTailscale IPを控え直す - B3のタグ付与 (`tag:muse`) が新しいデバイスに行われていること - 防火壁ルール `ClaudeRelay8190Tailscale` の `RemoteAddress` が新しいIPになっていること (古いIPのままでは接続できず、新しいIPが未登録の間は許可されていない状態が維持される)。更新は `Set-NetFirewallRule -Name "ClaudeRelay8190Tailscale"` にアドレスフィルタを併用するか、ルールを削除して再作成する ## B7: 連携完了時にClaudeへ渡す情報 - [ ] `C:\AI\setup-record.txt` の内容 (Portableのリリースタグ、A6-1の出力、PCのTailscale IP、MuseデバイスのデバイスIDとTailscale IP) - [ ] 配置したcheckpointの正確なファイル名 (base / NoobAI / WAI) - [ ] A7 / A8-4 / A9 のローカル生成確認が完了したか - [ ] B4-4 の `/health` の応答 - [ ] B5 の検証結果 (実施した項目と結果) --- # パートC: LoRA組み込み ここでは採用LoRAの中継への組み込みだけを扱う (候補の精査と試験は別途行う)。 ## C0: 採用セットと方式 採用LoRA (いずれも重み 1.0、strength_model / strength_clip 同値): | 役割 | ファイル名 | 配置先 | |---|---|---| | ディテール | `add-detail-xl.safetensors` (Detail Tweaker XL) | `ComfyUI\models\loras\` | | 画風 | `Outlined_IL.safetensors` (Outlined) | 同上 | 方式は **ワークフローへの焼き込み**: - 既定ワークフロー2本 (`noobai_vpred` / `wai_nsfw`) の API JSON に LoraLoader 連鎖 (Detail Tweaker → Outlined) を焼き込み、同名ファイルで差し替える。workflow_id は変わらない。 - 中継APIの変更は `WORKFLOW_FILES` への2行追加のみ (ロジック変更なし)。 - リクエストで変更できる範囲 (prompt / negative_prompt / seed / resolution) は従来通り。LoRA の有無と重みはワークフロー側で固定される。 ## C1: ローカルGUIでの事前確認 1. 配布された GUI 版 (`noobai_vpred_lora.json` / `wai_nsfw_lora.json`) を `C:\AI\workflows\` にコピーする。LoRA 名と重み、`ckpt_name` (NoobAI: `NoobAI-XL-Vpred-v1.0.safetensors` / WAI: `waiIllustriousSDXL_v170.safetensors`) は設定済み。 2. ComfyUI (ローカル) で読み込み、`ckpt_name` が上記の値になっていることを確認して、各1枚生成する。 3. 試験時 (lora-trial-procedure のシーンA/C) と絵が一致すること、エラーが出ないことを確認する。 ## C2: 承認ワークフローの差し替え 1. ComfyUI と中継API を停止する。 2. `C:\AI\workflows\` の既存 `noobai_vpred_api.json` / `wai_nsfw_api.json` を `*_pre_lora.bak` として退避する。 3. 新しい API 版2ファイルを `C:\AI\workflows\` に配置する (`ckpt_name` は設定済み。万一プレースホルダのままのファイルが混じっていたら中継APIが起動時に拒否するので、その場合はファイルを取り違えていないか確認する)。 4. 中継APIの変更は不要 (LoRAはワークフロー側に焼き込むため、登録ワークフローは従来の2件のまま)。 5. B4 の手順で ComfyUI → 中継API の順に起動する。 ## C3: 確認 - `GET /health` の応答で、登録ワークフローが2件 (`noobai_vpred`, `wai_nsfw`) になっていること。 - Claude側から2ワークフローそれぞれ1ジョブずつ生成し、画像が返ること (スモークテスト)。 - 備考: ジョブ状態のフィールド名は `state` (`status` ではない)。ポーリング時は `state` を見ること。 ## C4: ロールバック 問題があれば、ComfyUI と中継API を停止し、`C:\AI\workflows\` の `*_pre_lora.bak` を元のファイル名に戻し、`relay_api.py` を退避した旧版に戻して、B4 の順で再起動する。LoRA ファイル自体は `models\loras` に残しても、旧ワークフローからは参照されない。 --- # パートD: キャラLoRAの許可リスト制パラメータ ## D0: 方式 キャラごとにワークフローを増やす方式 (A案) ではなく、`/generate` に任意項目 `lora` を1つ追加する方式 (B案) に改修する。改修は relay_api.py のみで、ワークフローJSONは変更しない。 - リクエスト例: `"lora": {"name": "<キャラLoRAのファイル名>.safetensors", "strength": 0.8}` - `name` は許可リスト (下記 `lora_allowlist.json`) のキーと完全一致した場合のみ受理。一致しなければ 400 (`unknown_lora`)。 - `strength` は任意。省略時はリストの `default`、範囲外は `min`/`max` にクランプされる。1リクエストにつきLoRAは1つまで。 - 挿入位置はサーバー側固定: 常駐チェーン (Detail Tweaker → Outlined) の末尾、つまりキャラLoRAは常駐LoRAの後ろに積まれる。呼び出し側で並び替えや複数積みはできない。 - 許可リストは `C:\AI\workflows\lora_allowlist.json` に置く。中継APIは起動時に読み込み、形式が不正なら起動を中止する (黙って無効化しない)。ファイルが無ければ `lora` 指定は全て拒否され、従来動作になる。 - 許可リストの形式 (ファイル名 → 範囲と既定値)。妥当性条件: `0 <= min <= default <= max <= 2.0`。 ```json { "<キャラLoRAのファイル名>.safetensors": {"min": 0.0, "max": 1.2, "default": 1.0} } ``` ## D1: 最初の登録 (例) - 使いたいキャラLoRAのファイルを `ComfyUI\models\loras\` に配置する。 - 許可リスト `lora_allowlist.json` に D0 の形式でそのファイル名を登録し、`C:\AI\workflows\` に置くこと。 ## D2: 入れ替え手順 1. ComfyUI と中継API を停止する (B6)。 2. 現在の `C:\AI\relay\relay_api.py` を `relay_api_pre_lora_param.py.bak` に退避する。 3. 改修版 `relay_api.py` を `C:\AI\relay\` に配置する。 4. `lora_allowlist.json` を `C:\AI\workflows\` に配置する。 5. B4 の順で再起動する。起動ログに「LoRAを許可リストに登録しました: <キャラLoRAのファイル名>.safetensors (min=0.0, max=1.2, default=1.0)」が出ること。 ## D3: 確認 - `GET /health` の応答に `"loras": ["<キャラLoRAのファイル名>.safetensors"]` が含まれること。 - Claude側からスモークテストを行う: 1. `wai_nsfw` + `lora` 指定 (strength 省略 = 1.0) で登録したキャラのシーンを1枚生成し、常駐チェーン込みで似ているか判定する (事前のローカル判定との比較)。 2. `strength: 0.8` でもう1枚生成し、強さの効きを比較する。 3. 許可リストに無い名前を指定して 400 (`unknown_lora`) が返ることを確認する。 ## D4: 今後のキャラ追加 新しいキャラLoRAを使いたくなったら、次の3つで済む (ワークフロー追加や中継APIの改修は不要)。 1. LoRAファイルを `ComfyUI\models\loras\` に置く。 2. `C:\AI\workflows\lora_allowlist.json` に、そのファイル名で1項目追加する (範囲と既定値は D0 の形式で)。 3. 中継API を再起動する (起動ログに登録が出る)。 ## D5: ロールバック 問題があれば、中継API を停止し、`relay_api.py` を `relay_api_pre_lora_param.py.bak` に戻して再起動する。`lora_allowlist.json` は旧版からは読まれないので、残しても動作に影響しない。 --- # パートE: Hires fix + FaceDetailer の標準化 ## E0: 方式 生成の1発通過率を上げるため、Hires fix (第2パス) と FaceDetailer (顔の検出→再描画) を、全2ワークフローの標準構成として組み込む。HQ版ワークフローとの使い分けはしない。 変更点は3つ。(1) ワークフロー2本のグラフ拡張、(2) relay_api.py の検証・LoRA挿入の一般化、(3) PC側への Impact Pack / Impact Subpack の導入 (新規依存)。**中継APIの外形は無変更**: リクエスト形式、解像度プリセット、`lora` パラメータ、ジョブタイムアウト (既定900秒) は従来通り。生成時間は1枚 約14秒 → 約40秒の見込み。 グラフ (各ワークフロー共通の末尾構造): ``` KSampler (メイン) → LatentUpscaleBy (1.5倍) → KSampler (Hires) → VAEDecode → FaceDetailer → SaveImage ``` - Hires段: `LatentUpscaleBy` (scale_by 1.5 / nearest-exact) → 第2 KSampler (steps 20、denoise は NoobAI 0.5 / WAI 0.4、cfg・sampler・scheduler はメインと同一、seed は固定値 — 入力 latent がメイン seed 由来なので再現性は保たれる)。出力解像度はプリセットの1.5倍になる (例: 832x1216 → 1248x1824)。 - FaceDetailer段 (Impact Pack): 顔を検出 (検出器 `bbox/face_yolov8m.pt`) し、切り出して再描画する。denoise 0.4、guide_size 512、max_size 1024。検出漏れの顔はそのまま素通りする (エラーにはならない)。 - **プロンプトとLoRAの伝播が設計の要**: FaceDetailer と第2 KSampler の model / clip は、常駐チェーン末尾の共有リンクに接続し、positive / negative はメインの両エンコーダ出力を共用する。したがってメインプロンプト (目の色タグを含む) と `lora` パラメータのキャラLoRAが、第2パスと顔の再描画にも届く。NoobAI の V-Pred 用パッチ (ModelSamplingDiscrete / RescaleCFG) はチェーン前段にあるため、両段に自動で効く。 relay_api.py の変更内容: - 起動時検証を「メイン KSampler + 任意の Hires 第2パス + 任意の FaceDetailer」の構造に一般化した。第2パスや FaceDetailer が共有リンクから外れた配線は、起動時に拒否する (fail closed は従来通り)。 - キャラLoRAの挿入を「共有リンクを参照する全入力の付け替え」に一般化した (従来は KSampler と両エンコーダの3点のみ)。 ## E1: Impact Pack / Impact Subpack の導入 (新規依存) 取得先 (公式ページのみ): | 対象 | 公式ページ | |---|---| | ComfyUI-Impact-Pack (FaceDetailer 本体) | https://github.com/ltdrdata/ComfyUI-Impact-Pack | | ComfyUI-Impact-Subpack (検出器プロバイダ) | https://github.com/ltdrdata/ComfyUI-Impact-Subpack | | 顔検出モデル `face_yolov8m.pt` | https://huggingface.co/Bingsu/adetailer/blob/main/face_yolov8m.pt | 注意: 検出器ノード `UltralyticsDetectorProvider` は Impact Pack 本体ではなく **Subpack 側**にある (V8.0 で分離)。両方入れること。 手順 (ComfyUI と中継API は停止した状態で): 1. 両リポジトリを `ComfyUI\custom_nodes\` に配置する。git がある場合: ``` cd C:\AI\ComfyUI_windows_portable\ComfyUI\custom_nodes git clone https://github.com/ltdrdata/ComfyUI-Impact-Pack git clone https://github.com/ltdrdata/ComfyUI-Impact-Subpack ``` git が無い場合は、各公式ページの Code → Download ZIP を取得して展開し、フォルダ名を `ComfyUI-Impact-Pack` / `ComfyUI-Impact-Subpack` に揃える。 2. 両方の依存パッケージを同梱 Python でインストールし、install.py を実行する: ``` cd ComfyUI-Impact-Pack C:\AI\ComfyUI_windows_portable\python_embeded\python.exe -m pip install -r requirements.txt C:\AI\ComfyUI_windows_portable\python_embeded\python.exe install.py cd ..\ComfyUI-Impact-Subpack C:\AI\ComfyUI_windows_portable\python_embeded\python.exe -m pip install -r requirements.txt C:\AI\ComfyUI_windows_portable\python_embeded\python.exe install.py ``` 注: パックのフォルダーはポータブルルート (`ComfyUI_windows_portable`) の3階層下 (`ComfyUI\custom_nodes\<パック名>`) にあり、`python_embeded` はルート直下なので、相対パスでは `..\..\..\python_embeded\...` になる。数え間違いを避けるため、ここでは絶対パスで統一する (`python_embeded` の実在パスが異なる場合は A4 の確認結果に読み替える)。 - Subpack の install.py は、検出モデル (顔 `face_yolov8m.pt`、手、人物セグメント) を `ComfyUI\models\ultralytics\` へ自動ダウンロードする。Pack 側の install.py は SAM モデル (今回は未使用) のダウンロードと設定ファイル作成を行う。 - Impact Pack の requirements には `git+https://github.com/facebookresearch/sam2` の行がある。git が無い環境ではこの行だけ失敗するが、SAM2 は今回の構成 (bbox検出 + FaceDetailer) では未使用なので、その行を除いて再実行すればよい。 3. 検出モデルの確認: `ComfyUI\models\ultralytics\bbox\face_yolov8m.pt` が存在すること。自動ダウンロードが失敗した場合は、公式ページ (上表) から取得してこの場所に手動配置する。 4. ComfyUI を単独で起動し、コンソールに Impact Pack / Impact Subpack のロードエラーが出ないことを確認する。Impact Pack は **ComfyUI 0.3.63 以上**が必要で、古い場合は起動時に "ComfyUI is an outdated version" のエラーになる。その場合は ComfyUI 本体の更新が別途必要になるので、作業を止めて相談すること。 ## E2: 入れ替え手順 1. ComfyUI と中継API を停止する (B6)。E1-4 の確認起動は先に済ませてから止める。 2. バックアップ: 現在の `C:\AI\relay\relay_api.py` を `relay_api_pre_hires.py.bak` に、`C:\AI\workflows\` のワークフロー2本をそれぞれ `*_pre_hires.bak` に退避する。 3. 改修版 `relay_api.py` を `C:\AI\relay\` に、改修版ワークフロー2本 (`noobai_vpred_api.json` / `wai_nsfw_api.json`) を `C:\AI\workflows\` に配置する。 4. B4 の順で再起動する。relay の起動ログに2ワークフローの登録が出て、エラーで止まらないこと (新しい配線検証を通過した証拠になる)。 ## E3: 確認 Claude側からスモークテストを行う: 1. `noobai_vpred` で1枚生成: 出力サイズが 1248x1824 (832x1216 プリセット時) であること、生成時間の実測、顔・目の品質を確認する。 2. `wai_nsfw` で1枚生成: 同上。 3. `wai_nsfw` + `lora` 指定 (登録済みキャラLoRA) で1枚生成: 顔の再描画にキャラLoRAが効いているか (似せ具合と目の色) を確認する。 4. 許可リストに無い LoRA 名で 400 (`unknown_lora`) が引き続き返ること。 問題があれば E5 でロールバックする。 ## E4: 調整ノブ パラメータ変更はワークフローJSONの数値を書き換え、中継API を再起動すれば反映される (テンプレートは relay の起動時読込。グラフはリクエストごとに送られるため ComfyUI の再起動は不要)。調整は1ノブずつ、同じシード・プロンプトで比較すること。 | ノブ | 場所 | 初期値 | 目安 | |---|---|---|---| | Hires denoise | ノード `21` (第2 KSampler) | NoobAI 0.5 / WAI 0.4 | 強いと構図・表情が変わる。弱いと精細感が出ない | | Detailer denoise | ノード `23` (FaceDetailer) | 0.4 | 強いと顔の同一性が変わる。弱いと目が直らない | | guide_size | ノード `23` | 512 | 再描画の基準サイズ。小さい顔ほど効きが変わる | | bbox_threshold | ノード `23` | 0.5 | 顔の検出漏れが多い場合は 0.4 へ | | scale_by | ノード `20` (LatentUpscaleBy) | 1.5 | 上げると生成時間とVRAMが増える。通常は固定 | ## E5: ロールバック 問題があれば、中継API を停止し、`relay_api.py` とワークフロー2本を `*_pre_hires.bak` に戻して再起動する。Impact Pack / Subpack は旧構成では読み込まれない (ComfyUI 起動時にロードはされるが、旧ワークフローはそれらのノードを使わない) ので、残しても動作に影響しない。完全に消す場合は `custom_nodes` から両フォルダを削除する。 --- # Part F: Anima 系ワークフローの追加 (分割ローダー対応) ## F0: 方式 - 目的: Anima 系モデル (Cosmos-Predict2 系 DiT、分割ローダー構成) を中継の登録ワークフローとして扱えるようにし、SDXL と並行して品質検証できるようにする。検証の結果が良ければ Anima を主軸に昇格し、SDXL はフォールバックに降格する。 - 中継の変更は**起動時検証の一般化のみ**。HTTP API、変更可能項目 (プロンプト / ネガティブ / シード / 解像度プリセット / 許可リスト制 LoRA) は不変で、プロンプト注入と LoRA 挿入の仕組み (build_prompt) は無改造で流用する。 - ローダーの検証は2系統のどちらか一方を要求する。系統S = CheckpointLoaderSimple (SDXL、検証内容は従来と同一)。系統A = UNETLoader / CLIPLoader / VAELoader がちょうど1つずつで、各ファイル名が設定済み、model / clip の共有リンクを LoraLoader 経由でたどると各ローダーに行き着き、VAEDecode の vae が VAELoader の出力であること。両系統の混在は起動拒否する。 - Anima 系ワークフローは**単段 txt2img** (Hires fix / FaceDetailer は付けない)。まず素の品質を測るためで、2段構成の要否は検証結果を見てから判断する。 - 登録ワークフローは計6種になる (SDXL 2種 + 以下): | ID | 本体 | サンプラー初期値 | |---|---|---| | `anima_base` | 公式 Anima-Base v1.0 | euler / simple、30 steps、CFG 4 (公式テンプレート値) | | `anima_aesthetic` | 公式 Anima-Aesthetic v1.1 | 同上 | | `anima_janima` | JANIMA v1.0 (2B) | er_sde / simple、30 steps、CFG 5 (作者推奨) | | `anima_janima29` | JANIMA v1.0 2.9B | 同上 | - 回帰テストには分割ローダー系統の検証が含まれる。系統S のみの構成でも従来通り動作する (後方互換)。 ## F1: モデルファイルの取得と配置 (新規依存) 取得先 (公式ページのみ): | 対象 | 公式ページ | |---|---| | Anima 公式 (Base / Aesthetic 本体、テキストエンコーダ、VAE) | https://huggingface.co/circlestone-labs/Anima | | JANIMA (2B / 2.9B) | https://civitai.com/models/2642932/janima-anima | 配置: | ファイル | 取得元 | 配置先 | |---|---|---| | `anima-base-v1.0.safetensors` (約4.2GB) | HF の `split_files/diffusion_models/` | `ComfyUI\models\diffusion_models\` | | `anima-aesthetic-v1.1.safetensors` (約4.2GB) | 同上 | 同上 | | `JANIMAAnima_v10_pruned_fp16.safetensors` | Civitai の v1.0 (2B) — ダウンロードファイル名そのまま使う | 同上 | | `JANIMAAnima_2.9B.safetensors` (約5.8GB) | Civitai の v1.0 2.9B — ダウンロードファイル名そのまま使う | 同上 | | `qwen_3_06b_base.safetensors` (約1.2GB) | HF の `split_files/text_encoders/` | `ComfyUI\models\text_encoders\` | | `qwen_image_vae.safetensors` (約0.25GB) | HF の `split_files/vae/` | `ComfyUI\models\vae\` | 注意: JANIMA の実際のダウンロードファイル名は、モデルページの Installing 記載と異なる場合がある。配置は上表の実ファイル名で行うこと。 前提確認: ComfyUI 本体が Anima ネイティブ対応版であること。目安はテンプレートライブラリに「Anima Base v1」があること。2.9B の読み込みには **ComfyUI 0.33.1 以上**が必要。満たさない場合は本体の更新が別途必要になるので、作業を止めて相談すること。 ## F2: 入れ替え手順 1. ComfyUI と中継API を停止する (B6)。 2. バックアップ: 現在の `C:\AI\relay\relay_api.py` を `relay_api_pre_anima.py.bak` に退避する。SDXL 側のワークフロー2本は変更しない (Anima 用は新規追加のみ)。 3. 改修版 `relay_api.py` を `C:\AI\relay\` に、Anima 用ワークフロー4本 (`anima_base_api.json` / `anima_aesthetic_api.json` / `anima_janima_api.json` / `anima_janima29_api.json`) を `C:\AI\workflows\` に配置する。 4. F1 のモデルファイルを配置する。 5. B4 の順で再起動する。起動ログに6ワークフローの登録が出て、エラーで止まらないこと (系統A の検証を通過した証拠になる)。 ## F3: 確認 Claude側からスモークテストを行う: 1. `/health` に6ワークフローが表示されること。 2. `anima_base` で1枚生成 (832x1216): 成功すること、生成時間の実測、画質 (目・線・プロンプト追従) を確認する。 3. `anima_aesthetic` で1枚生成: 同上。 4. `anima_janima` / `anima_janima29` で各1枚生成: 同上 (同一プロンプト・同一シードで並べ、2B と 2.9B の差も見る)。 5. SDXL 回帰: `noobai_vpred` で1枚生成し、Part E の2段構成が従来通り動くこと。 6. Anima 系のキャラLoRA を許可リストに追加済みの場合は、`lora` 指定で1枚生成し、挿入が効くことを確認する (任意)。 ## F4: 調整ノブと比較検証の作法 パラメータ変更はワークフローJSONの数値を書き換え、中継API を再起動すれば反映される (E4 と同じ理屈で、ComfyUI の再起動は不要)。調整は1ノブずつ。 | ノブ | 場所 | 初期値 | 目安 | |---|---|---|---| | sampler_name / scheduler | ノード `7` (KSampler) | 公式系 euler / simple、JANIMA 系 er_sde / simple | JANIMA で厚塗り寄りにしたい場合は scheduler `beta57` が候補 (作者系の推奨) | | cfg | ノード `7` | 公式系 4 / JANIMA 系 5 | プロンプトの効き具合で調整 | | steps | ノード `7` | 30 | 各推奨は 24〜30 の範囲 | 比較検証は、同一プロンプト・同一シードで SDXL 側 (Part E 構成) と並べ、目の出来・人体破綻率・既知キャラの再現・プロンプト追従の4点で見る。 ## F5: ロールバック 問題があれば、中継API を停止し、`relay_api.py` を `relay_api_pre_anima.py.bak` に戻し、Anima 用ワークフロー4本を `C:\AI\workflows\` から除いて再起動する。モデルファイル (diffusion_models / text_encoders / vae) は残しても SDXL 側の動作に影響しない。 --- ## トラブルシューティング早見表 | 症状 | 原因の切り分け順 | |---|---| | checkpointがドロップダウンに出ない | 配置パス → 拡張子が `.safetensors` か → ComfyUI再起動 → ファイルがdiffusers形式フォルダでないか | | `Torch not compiled with CUDA enabled` | Portable取り違え、または同梱torch破損。A6の記録で `+cuXXX` の有無を確認し、A6-3の順序で対処 | | `no kernel image is available` | CUDAバージョン不足 (GPUアーキテクチャ未対応)。A6-3 (別フォルダー展開での切り分け → 必要時のみpip修復) | | 生成が極端に遅い | コンソールで使用デバイスを確認。`run_cpu.bat` で起動していないか、GPUが他アプリに占有されていないか | | VRAM不足 (OOM) | batch_size=1、1024x1024であることを確認。他のGPU使用プロセスを終了する | | 出力画像が真っ黒 | seed/設定をA7-2の検証値に戻して再現するか確認。再現する場合はVAEを分離構成にする検討対象として切り分ける | | `http://127.0.0.1:8188` が開かない | コンソールの例外を確認。ポート8188を使用中の他プロセスが無いか `Get-NetTCPConnection -LocalPort 8188` で確認 | | 中継APIが起動時にエラーで止まる | メッセージを確認。`ckpt_name` 未設定の場合はA8-3/A9の置換漏れ。`--host` のエラーはTailscale IPの指定ミス (B2-1で再取得) | | `/health` が 503 になる | ComfyUI (127.0.0.1:8188) が起動していない。B2-2の起動を確認 | | 中継APIが 429 を返す | 待ち行列が上限 (既定4件) に達している。実行中ジョブの完了を待つ | | 中継APIが 503 (submit_suspended) を返す | ComfyUI側の状態が不明なジョブがあるため投入が停止している。B4-3の手順でComfyUIのキューを確認し、コンソールの `resume` で再開する | | Claudeから中継APIに届かない | TailscaleのACL (B3) → Firewallの `RemoteAddress` (B4-2、再参加でIPが変わっていないか) → 中継APIが起動しているか、の順で確認 | | FaceDetailer の検出モデルが見つからない (model file is not found) | Subpack の導入と install.py の実行を確認。`ComfyUI\models\ultralytics\bbox\face_yolov8m.pt` の存在を確認 (E1-3) | | Impact Pack が "ComfyUI is an outdated version" で読み込まれない | ComfyUI が 0.3.63 未満。更新が別途必要なので、作業を止めて相談する (E1-4) | | 中継APIが起動時に Hires / FaceDetailer の接続エラーで止まる | 配置したワークフローJSONの取り違えを確認。改修版2本が揃っているか (E2) | | 中継APIが起動時に UNETLoader / CLIPLoader の接続エラーで止まる | Anima 用JSONの取り違え・改変を確認 (F2)。両系統が混在していないか | | Anima の生成でテキストエンコーダ系のエラーになる | `qwen_3_06b_base.safetensors` の配置先 (text_encoders) と、ComfyUI 本体が Anima 対応版か (F1 の前提確認) | | JANIMA 2.9B だけ読み込みに失敗する | ComfyUI が 0.33.1 未満の可能性 (F1 の前提確認)。ファイル名が `JANIMAAnima_2.9B.safetensors` であることも確認 | ## 公式ダウンロードページ一覧 本手順で取得するファイルは、全て以下の公式ページからのみ取得する。まとめサイト、ファイルアップローダ、再配布ミラーからは取得しない (これを真正性の担保とし、ファイルごとのハッシュ突合は必須工程にしない)。 | 対象 | 公式ページ | |---|---| | NVIDIA App / ドライバ (A1) | https://www.nvidia.com/en-us/software/nvidia-app/ | | 7-Zip 64bit (A2) | https://7-zip.org/download.html | | SDXL base `sd_xl_base_1.0.safetensors` (A3) | https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/blob/main/sd_xl_base_1.0.safetensors | | NoobAI-XL V-Pred 1.0 (A8) | https://huggingface.co/Laxhar/noobai-XL-Vpred-1.0 / https://civitai.com/models/929685 | | WAI-NSFW-illustrious-SDXL (A9) | https://civitai.com/models/827184/wai-nsfw-illustrious-sdxl | | ComfyUI Portable (A4) | https://github.com/comfyanonymous/ComfyUI/releases | | Tailscale for Windows (B2-1) | https://tailscale.com/download/windows | | ComfyUI-Impact-Pack (E1) | https://github.com/ltdrdata/ComfyUI-Impact-Pack | | ComfyUI-Impact-Subpack (E1) | https://github.com/ltdrdata/ComfyUI-Impact-Subpack | | 顔検出モデル `face_yolov8m.pt` (E1) | https://huggingface.co/Bingsu/adetailer/blob/main/face_yolov8m.pt | | Anima 公式 (Base / Aesthetic / テキストエンコーダ / VAE) (F1) | https://huggingface.co/circlestone-labs/Anima | | JANIMA (F1) | https://civitai.com/models/2642932/janima-anima | ## 参考 - ComfyUI公式ドキュメント: Portable for Windows / System Requirements (docs.comfy.org) - ComfyUI公式GitHub: Comfy-Org/ComfyUI (Releases / README) - Hugging Face: stabilityai/stable-diffusion-xl-base-1.0 (`sd_xl_base_1.0.safetensors`) - Hugging Face: Laxhar/noobai-XL-Vpred-1.0 (モデルカード) - NoobAI公式ガイド: https://betterwaifu.com/blog/noobai-guide (パラメータ表・解像度表) - Hugging Face: OnomaAIResearch/Illustrious-XL-v2.0 (派生モデルの素体。生成用途では調整済み派生を使うこと) --- # 付録: 配布ファイル一式 (パート別) 手順書中で「配布する」としているファイルを、パートごとに分けて全文掲載する。各パートの実施時点では、そのパートの付録にあるファイルだけを使うこと (後のパートのファイルで先に進めると、まだ導入していないLoRAファイルの参照などで動かない)。 - 付録B: パートBで使うファイル (中継APIの初期版と、LoRAなしのワークフロー) - 付録C: パートCで使うファイル (LoRA焼き込み版ワークフロー)。中継APIは付録Bと同一で、変更はない - 付録D: パートDで使うファイル (キャラLoRA許可リスト対応の中継APIと許可リスト)。ワークフローは付録Cと同一 - 付録E: パートEで使うファイル (Hires fix + FaceDetailer 版ワークフロー)。中継APIは付録Dと同一 - 付録F: パートFで使うファイル (Anima対応の中継APIと Anima 系ワークフロー)。SDXL ワークフローは付録Eと同一 注記: - `lora_allowlist.json` のファイル名はプレースホルダ表記なので、自分のキャラLoRAの実ファイル名に置き換えること - パートD/E版の `relay_api.py` は、原本ファイルが残っていなかったため、パートF版のコードに D/E 時点の登録ワークフロー (SDXL 2件) を適用した再構成版である。D/E 各パートの手順で投入され得る入力に対しては、原本と同じ振る舞いになることを回帰テスト (パートF版スイートの Phase A〜Q、109項目) で確認済み - `relay_regression_test.py` は、ワークフローJSONと同一フォルダーに置く前提のテストである (起動拒否テストが、同フォルダーのJSONにプレースホルダが残っていることを利用する) ## 付録B: パートBで使うファイル ### relay_api.py ```python #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ relay_api.py - Muse -> ComfyUI 限定中継API (実装案 v2) 設計方針: - ComfyUI本体は 127.0.0.1:8188 で待ち受け、この中継APIだけがPCのTailscale IPで待ち受ける。 - 受け付けるのは、PC側に保存された承認済みワークフロー (API形式JSON) の実行のみ。 - 呼び出し側が変更できるのは prompt / negative_prompt / seed / 解像度プリセットのみ。 - ワークフローJSON、ノード、ファイルパス、URL、コマンド類は一切受け付けない (未知のキーは 400)。 - 画像取得は、この中継APIが発行したジョブの生成物だけに限定する (ジョブIDはUUID、実ファイル名は外部に出さない)。 - ComfyUIの任意APIへの汎用プロキシにはしない (内部で使うのは /prompt, /history, /view, /system_stats, /queue の固定用途のみ。/queue は自ジョブの状態確認と、待機中ジョブの prompt_id指定delete専用)。 - 実行中ジョブの自動割り込みは行わない。ComfyUIの /interrupt は対象を指定できない 全体割り込みであり、直前のID確認だけでは「確認後に別ジョブへ交代していた」場合の 誤停止を防げないため。実行中ジョブがタイムアウトした場合は状態不明として投入を止め、 運用者の確認 (GUIでの停止または完了待ち) と resume による再開に委ねる。 - 同時実行は1件。待ち行列は上限付きキューで管理し、超過は 429 (競合時も上限を超えない)。 - ComfyUI側の処理状態が不明になったジョブがある間は、新規投入を停止する (503)。 復旧は、対象ジョブの終了・取消が確認できた場合に自動で行い、照合できない場合は PC側コンソールでの手動 resume (運用者の確認) が必要。 - ジョブ記録には保持期限と件数上限があり、完了したジョブのプロンプトは記録から即時除去する。 - 生成画像ファイル自体はComfyUIの output フォルダに残る (ComfyUI標準APIにファイル削除機能が無いため、 中継APIはファイルを削除しない。容量管理は利用者側で行い、中継API経由の生成物は filename_prefix = relay_ で識別できる)。 - Python標準ライブラリのみで動作する。ComfyUI Portable同梱の python_embeded で実行する想定。 - 管理者権限は不要。通常ユーザーで手動起動する。 起動時の「構造検証」について: 起動時に検査するのは次の範囲のみであり、任意のワークフローの安全性や生成結果の正しさを 判定するものではない。 - JSONとして読み込めること - KSampler がちょうど1つ存在すること - KSampler の positive / negative の接続先が CLIPTextEncode、latent_image の接続先が EmptyLatentImage であること - CheckpointLoaderSimple が存在し、ckpt_name がプレースホルダではなく .safetensors で終わること - SaveImage が存在すること 承認済みかどうかは、この検証ではなく「どのファイルを C:\\AI\\workflows に配置したか」という 配置管理で担保する。 使い方 (PC側): python relay_api.py --host 100.x.y.z --port 8190 --workflows-dir C:\\AI\\workflows --host は原則としてTailscale帯域 (100.64.0.0/10) のIPのみ許可する。 ローカル単体テスト用に 127.0.0.1 を使う場合だけ --local-test を付ける。 コンソールコマンド (中継APIを起動したコンソールで入力): status : 投入ゲートの状態とジョブ件数を表示する resume : 状態不明により停止中の投入を再開する。ComfyUIのキュー状態を確認でき、 かつキューが空の場合のみ再開する。状態を確認できない場合は停止を維持する resume force : キューに残件があることを運用者が確認済みの場合に再開する (状態確認自体ができない場合は force でも再開しない) """ import argparse import copy import ipaddress import json import os import queue import secrets import socket import sys import threading import time import urllib.error import urllib.parse import urllib.request import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # ---------------------------------------------------------------- 設定 (承認済みリスト) # ここに無いワークフローID・解像度は受け付けない。 WORKFLOW_FILES = { "noobai_vpred": "noobai_vpred_api.json", # 本命: NoobAI-XL V-Pred 1.0 "wai_nsfw": "wai_nsfw_api.json", # 予備: WAI-NSFW-illustrious-SDXL } # NoobAI公式ガイドの推奨解像度 (W x H) のみを許可する。 RESOLUTION_PRESETS = { "768x1344": (768, 1344), "832x1216": (832, 1216), "896x1152": (896, 1152), "1024x1024": (1024, 1024), "1152x896": (1152, 896), "1216x832": (1216, 832), "1344x768": (1344, 768), } MAX_PROMPT_CHARS = 2000 MAX_BODY_BYTES = 64 * 1024 MAX_SEED = 2**64 - 1 ALLOWED_REQUEST_KEYS = {"workflow_id", "prompt", "negative_prompt", "seed", "resolution"} PLACEHOLDER_MARK = "REPLACE_WITH" # ckpt_name がこの文字列を含むテンプレートは未設定として起動時に拒否する TERMINAL_STATES = {"succeeded", "failed", "unknown"} # ---------------------------------------------------------------- グローバル状態 CONFIG = {} TEMPLATES = {} # workflow_id -> (api_json_dict, node_map) JOBS = {} # job_id -> job dict JOBS_LOCK = threading.Lock() JOB_QUEUE = None # main() で上限付きキューとして生成する GATE = {"closed": False, "reason": None} # ComfyUI側の状態不明時に閉じる投入ゲート GATE_LOCK = threading.Lock() GATE_EVENT = threading.Event() # ワーカーの投入可否と連動させる (set = 開放) GATE_EVENT.set() CONN_SEM = None # main() で同時接続数上限のセマフォとして生成する def gate_close(reason): with GATE_LOCK: GATE["closed"] = True GATE["reason"] = reason GATE_EVENT.clear() print(f"[relay] 投入を停止しました: {reason}") print("[relay] ComfyUI側のキューを確認し、対象ジョブの終了・取消を確認してから、コンソールで resume と入力してください") def gate_open(): with GATE_LOCK: GATE["closed"] = False GATE["reason"] = None GATE_EVENT.set() print("[relay] 投入を再開しました") def gate_is_closed(): with GATE_LOCK: return GATE["closed"], GATE["reason"] class ApiError(Exception): def __init__(self, status, code, message): super().__init__(message) self.status = status self.code = code self.message = message class ComfyLostContact(Exception): """ポーリング中にComfyUIとの通信が継続的に失敗した (状態不明の可能性)。""" class ComfyStateError(Exception): """ComfyUIの状態応答 (/queue) の形式が不正で、状態を確認できない。""" class SubmitOutcomeUnknown(Exception): """ComfyUIへの投入の成否を判断できない (通信断・応答喪失・解釈できない応答)。""" class GenerationTimeout(Exception): """制限時間内に完了しなかった (ComfyUI側に自ジョブが残っている可能性)。""" # ---------------------------------------------------------------- テンプレート検証 def load_templates(workflows_dir): """承認済みワークフローを読み込み、冒頭に記載した範囲の構造検証を行う。不正なら起動を中止する。""" for workflow_id, filename in WORKFLOW_FILES.items(): path = os.path.join(workflows_dir, filename) try: with open(path, "r", encoding="utf-8") as f: wf = json.load(f) except FileNotFoundError: raise SystemExit(f"[relay] ワークフローファイルが見つかりません: {path}") except json.JSONDecodeError as e: raise SystemExit(f"[relay] ワークフローJSONが不正です: {path}: {e}") if not isinstance(wf, dict) or not wf: raise SystemExit(f"[relay] ワークフローが空、または形式が不正です: {path}") ksamplers = [nid for nid, n in wf.items() if isinstance(n, dict) and n.get("class_type") == "KSampler"] if len(ksamplers) != 1: raise SystemExit(f"[relay] {filename}: KSampler はちょうど1つ必要です (検出: {len(ksamplers)})") ks_id = ksamplers[0] ks_inputs = wf[ks_id].get("inputs", {}) def linked_node(input_name, expect_class): link = ks_inputs.get(input_name) if not (isinstance(link, list) and len(link) == 2 and str(link[0]) in wf): raise SystemExit(f"[relay] {filename}: KSampler.{input_name} の接続が不正です") nid = str(link[0]) if wf[nid].get("class_type") != expect_class: raise SystemExit(f"[relay] {filename}: KSampler.{input_name} の接続先は {expect_class} である必要があります") return nid node_map = { "ksampler": ks_id, "positive": linked_node("positive", "CLIPTextEncode"), "negative": linked_node("negative", "CLIPTextEncode"), "latent": linked_node("latent_image", "EmptyLatentImage"), } loaders = [n for n in wf.values() if isinstance(n, dict) and n.get("class_type") == "CheckpointLoaderSimple"] if not loaders: raise SystemExit(f"[relay] {filename}: CheckpointLoaderSimple がありません") for ld in loaders: ckpt = str(ld.get("inputs", {}).get("ckpt_name", "")) if PLACEHOLDER_MARK in ckpt or not ckpt.endswith(".safetensors"): raise SystemExit( f"[relay] {filename}: ckpt_name が未設定です ('{ckpt}')。" "ダウンロードした実ファイル名に置き換えてください") if not any(isinstance(n, dict) and n.get("class_type") == "SaveImage" for n in wf.values()): raise SystemExit(f"[relay] {filename}: SaveImage がありません") TEMPLATES[workflow_id] = (wf, node_map) print(f"[relay] ワークフローを登録しました: {workflow_id} ({filename})") def build_prompt(job_id, workflow_id, prompt_text, negative_text, seed, preset_name): """テンプレートに許可されたパラメータだけを注入したワークフローJSONを返す。""" template, node_map = TEMPLATES[workflow_id] wf = copy.deepcopy(template) width, height = RESOLUTION_PRESETS[preset_name] wf[node_map["ksampler"]]["inputs"]["seed"] = seed wf[node_map["positive"]]["inputs"]["text"] = prompt_text if negative_text is not None: wf[node_map["negative"]]["inputs"]["text"] = negative_text latent = wf[node_map["latent"]]["inputs"] latent["width"] = width latent["height"] = height latent["batch_size"] = 1 for node in wf.values(): if isinstance(node, dict) and node.get("class_type") == "SaveImage": node["inputs"]["filename_prefix"] = f"relay_{job_id}" return wf # ---------------------------------------------------------------- ComfyUI連携 (127.0.0.1固定) def comfy_request(method, path, payload=None, timeout=30): url = CONFIG["comfy_base"] + path data = None headers = {} if payload is not None: data = json.dumps(payload).encode("utf-8") headers["Content-Type"] = "application/json" req = urllib.request.Request(url, data=data, headers=headers, method=method) with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.status, resp.headers.get("Content-Type", ""), resp.read() # ComfyUI の POST /prompt が検証失敗時に返す error.type の既知の値。 # ノード単位の検証失敗は "prompt_outputs_failed_validation"、 # 出力なしや存在しないノード種別などプロンプト全体の検証失敗は # "prompt_no_outputs" / "invalid_prompt" で、node_errors は空 {} になり得る。 _KNOWN_REJECTION_ERROR_TYPES = { "prompt_outputs_failed_validation", "prompt_no_outputs", "invalid_prompt", } def _is_explicit_rejection(status, body_bytes): """ComfyUIの既知の検証エラー応答かどうかを判定する。 ComfyUIはワークフローの検証エラー時に HTTP 400 で {"error": {"type": ..., "message": ..., "details": ..., "extra_info": {...}}, "node_errors": {...}} を返す。次の両方を確認できた場合のみ「明確な投入拒否」と判定する: - error が既知の error.type を持つオブジェクトであること - node_errors がオブジェクトとして存在すること (空 {} でもよい。 ノード単位の失敗が無い全体検証エラーでは空になる) それ以外のHTTPエラーや解釈できない応答は、投入の成否を判断できないもの として扱う (一律に失敗へは変えない)。 """ if status != 400: return False try: data = json.loads(body_bytes.decode("utf-8")) except Exception: return False if not isinstance(data, dict): return False error = data.get("error") if not isinstance(error, dict): return False if error.get("type") not in _KNOWN_REJECTION_ERROR_TYPES: return False return isinstance(data.get("node_errors"), dict) def comfy_submit(workflow): """ワークフローを投入し prompt_id を返す。 - HTTP 200 で有効な prompt_id が返れば受理済みとして扱い、IDを保持して 完了まで追跡する。node_errors が同時に返っていても、それは一部の出力系統の 検証失敗を示すだけで、正常な系統はキューに登録され生成が走るため、 未投入・失敗とは扱わない - 明確な検証エラー (HTTP 400 + 既知の error.type + node_errors 構造) は ApiError (comfy_node_error) として送出する -> ジョブは failed になり、後続は継続する。 prompt_id の無い 200 応答で node_errors がある場合も同様に拒否として扱う - 通信断・応答喪失・解釈できない応答は SubmitOutcomeUnknown を送出する -> ジョブは unknown になり、投入ゲートが閉じる """ try: _, _, body = comfy_request( "POST", "/prompt", {"prompt": workflow, "client_id": CONFIG["client_id"]}, timeout=30) except urllib.error.HTTPError as e: try: raw = e.read() except Exception: raw = b"" if _is_explicit_rejection(e.code, raw): raise ApiError(502, "comfy_node_error", "ComfyUIがワークフローの検証エラーで投入を拒否しました") raise SubmitOutcomeUnknown(f"HTTP {e.code} (投入の成否を判断できない応答)") except Exception as e: raise SubmitOutcomeUnknown(type(e).__name__) try: result = json.loads(body.decode("utf-8")) except Exception: raise SubmitOutcomeUnknown("/prompt の応答を解釈できません") if not isinstance(result, dict): raise SubmitOutcomeUnknown("/prompt の応答形式が不正です") prompt_id = result.get("prompt_id") if isinstance(prompt_id, str) and prompt_id: # 受理済み。一部の出力系統の検証失敗 (node_errors) が併記されていても、 # 正常な系統は生成が走るため、prompt_id を保持して完了まで追跡する return prompt_id if result.get("node_errors"): raise ApiError(502, "comfy_node_error", "ComfyUIがワークフローを拒否しました") raise SubmitOutcomeUnknown("/prompt の応答に prompt_id がありません") def comfy_history(prompt_id): _, _, body = comfy_request("GET", f"/history/{prompt_id}", timeout=10) history = json.loads(body.decode("utf-8")) return history.get(prompt_id) def comfy_queue_state(): """GET /queue の応答を検証し、(running_ids, pending_ids) を返す。 検証内容: 応答がJSONオブジェクトであること、queue_running / queue_pending の 両キーが存在し配列であること、各要素が [number, prompt_id, ...] 形式の配列で prompt_id が空でない文字列であること。 形式が不正な場合は「空」と解釈せず ComfyStateError を送出する (状態確認失敗)。 起動時・resume・取消確認で共通の判定として使う。 """ _, _, body = comfy_request("GET", "/queue", timeout=10) try: data = json.loads(body.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): raise ComfyStateError("/queue の応答がJSONではありません") if not isinstance(data, dict): raise ComfyStateError("/queue の応答がオブジェクトではありません") result = [] for key in ("queue_running", "queue_pending"): if key not in data: raise ComfyStateError(f"/queue の応答に {key} がありません") entries = data[key] if not isinstance(entries, list): raise ComfyStateError(f"/queue の {key} が配列ではありません") ids = set() for item in entries: if (not isinstance(item, list) or len(item) < 2 or not isinstance(item[1], str) or not item[1]): raise ComfyStateError(f"/queue の {key} に不正な要素があります") ids.add(item[1]) result.append(ids) return result[0], result[1] def comfy_queue_ids(): """ComfyUIのキューにある prompt_id の集合 (実行中 + 待機中) を返す。""" running, pending = comfy_queue_state() return running | pending def extract_output(entry): for node_out in (entry.get("outputs") or {}).values(): images = node_out.get("images") or [] if images: img = images[0] return {"filename": img["filename"], "subfolder": img.get("subfolder", ""), "type": img.get("type", "output")} return None def comfy_poll(prompt_id, timeout_sec): """完了まで /history をポーリングする。 - status_str == "error" は completed の真偽より先に判定する (ComfyUIは失敗時に completed=false, status_str="error" を返すため)。 - 通信失敗がしきい値以上続いたら ComfyLostContact、制限時間を超えたら GenerationTimeout。 """ deadline = time.time() + timeout_sec failures = 0 while time.time() < deadline: try: entry = comfy_history(prompt_id) failures = 0 except Exception: failures += 1 if failures >= CONFIG["comfy_fail_threshold"]: raise ComfyLostContact(prompt_id) time.sleep(CONFIG["poll_interval"]) continue if entry: status = entry.get("status", {}) if status.get("status_str") == "error": raise ApiError(502, "generation_failed", "ComfyUI側で生成がエラーになりました") if status.get("completed"): output = extract_output(entry) if output is None: raise ApiError(502, "no_output", "生成が完了しましたが画像出力がありません") return output time.sleep(CONFIG["poll_interval"]) raise GenerationTimeout(prompt_id) def try_cancel_and_verify(prompt_id): """自ジョブ (prompt_id) の状態確定を試み、確定したら True を返す。 取消手段は「待機中ジョブへの prompt_id 指定 delete」だけに限定する。 - 待機中 (queue_pending にある) 場合のみ、/queue の delete で当該IDのみ削除する - 実行中 (queue_running にある) 場合は取り消さない。対象を指定できない全体割り込み (/interrupt) は、確認と実行の間に実行ジョブが交代していた場合に無関係なジョブを 止めてしまうため使わない -> False を返し、状態不明として扱う - 無関係なジョブの一括停止 (clear 等) は行わない 確認できない場合は False (状態不明として扱う)。 """ try: running, pending = comfy_queue_state() # 形式不正は状態確認失敗として例外になる if prompt_id in running: return False # 実行中: 安全な取消手段が無いため状態不明にする if prompt_id in pending: comfy_request("POST", "/queue", {"delete": [prompt_id]}, timeout=10) # 確認: キューから消え、履歴が「完了 (成功/失敗)」または「履歴なし (未実行のまま削除)」であれば確定 time.sleep(1.0) if prompt_id in comfy_queue_ids(): return False entry = comfy_history(prompt_id) if entry is None: return True # 未実行のまま取り消された status = entry.get("status", {}) if status.get("status_str") == "error" or status.get("completed"): return True return False except Exception: return False def comfy_fetch_image(output): query = urllib.parse.urlencode( {"filename": output["filename"], "subfolder": output["subfolder"], "type": output["type"]}) _, content_type, body = comfy_request("GET", f"/view?{query}", timeout=60) return content_type or "image/png", body # ---------------------------------------------------------------- ジョブ管理 def finish_job(job, state, error=None, output=None): """終端状態への遷移。プロンプトはこの時点で記録から除去する。""" with JOBS_LOCK: job["state"] = state job["error"] = error if output is not None: job["output"] = output job["finished_at"] = time.time() job["prompt"] = None job["negative_prompt"] = None def mark_unknown(job, code, message): finish_job(job, "unknown", {"code": code, "message": message}) gate_close(f"job {job['job_id']} のComfyUI側状態が不明です ({code})") def process_job(job): """1ジョブの投入・監視・状態確定を行う。task_done は呼び出し側 (worker_loop) だけが行う。""" job_id = job["job_id"] try: workflow = build_prompt(job_id, job["workflow_id"], job["prompt"], job["negative_prompt"], job["seed"], job["resolution"]) try: prompt_id = comfy_submit(workflow) except ApiError: raise # 明確な投入拒否 (検証エラー) は failed として処理する except SubmitOutcomeUnknown: # /prompt の応答が失われた等: 投入されたかどうか自体が不明。 # prompt_id が無いため自動照合はできない -> 状態不明として投入を止める。 mark_unknown(job, "submit_outcome_unknown", "ComfyUIへの投入結果を確認できませんでした") return with JOBS_LOCK: job["prompt_id"] = prompt_id output = comfy_poll(prompt_id, CONFIG["job_timeout"]) finish_job(job, "succeeded", output=output) print(f"[relay] job {job_id} succeeded (workflow={job['workflow_id']}, preset={job['resolution']})") except ApiError as e: finish_job(job, "failed", {"code": e.code, "message": e.message}) print(f"[relay] job {job_id} failed: {e.code}") except GenerationTimeout: # 待機中ジョブの取消 (prompt_id指定delete) が確認できれば失敗として確定し、後続投入は継続する。 # 実行中ジョブは安全な取消手段が無いため、確認できない場合は状態不明にする。 if try_cancel_and_verify(job["prompt_id"]): finish_job(job, "failed", {"code": "generation_timeout", "message": "生成が制限時間内に完了せず、対象ジョブの取消を確認しました"}) print(f"[relay] job {job_id} failed: generation_timeout (cancel verified)") else: mark_unknown(job, "generation_timeout_unresolved", "生成がタイムアウトし、対象ジョブの終了・取消を確認できませんでした") except ComfyLostContact: # 通信が回復した後の照合で確定できなければ状態不明にする。 if job.get("prompt_id") and try_cancel_and_verify(job["prompt_id"]): finish_job(job, "failed", {"code": "comfy_contact_lost", "message": "ComfyUIとの通信が途絶しましたが、対象ジョブの終了・取消を確認しました"}) print(f"[relay] job {job_id} failed: comfy_contact_lost (resolved)") else: mark_unknown(job, "comfy_contact_lost", "ComfyUIとの通信が途絶し、対象ジョブの状態を確認できませんでした") except Exception as e: finish_job(job, "failed", {"code": "internal_error", "message": f"中継API内部でエラーが発生しました ({type(e).__name__})"}) print(f"[relay] job {job_id} failed: internal_error ({type(e).__name__})") def worker_loop(): while True: job_id = JOB_QUEUE.get() try: with JOBS_LOCK: job = JOBS.get(job_id) if job is None: continue # ゲートが閉じている間は、待機済みジョブであってもComfyUIへ投入しない。 # ジョブは queued のまま保持され、ゲート再開後に処理される。 GATE_EVENT.wait() with JOBS_LOCK: job["state"] = "running" process_job(job) finally: JOB_QUEUE.task_done() def cleanup_loop(): """保持期限を過ぎた終端ジョブの削除と、記録件数の上限管理。""" while True: time.sleep(CONFIG["cleanup_interval"]) now = time.time() with JOBS_LOCK: expired = [jid for jid, j in JOBS.items() if j["state"] in TERMINAL_STATES and j["finished_at"] and now - j["finished_at"] > CONFIG["job_retention"]] for jid in expired: del JOBS[jid] overflow = len(JOBS) - CONFIG["max_job_records"] if overflow > 0: finished = sorted( (j for j in JOBS.values() if j["state"] in TERMINAL_STATES and j["finished_at"]), key=lambda j: j["finished_at"]) for j in finished[:overflow]: del JOBS[j["job_id"]] if expired: print(f"[relay] 保持期限を過ぎたジョブ記録を削除しました: {len(expired)}件") # ---------------------------------------------------------------- 入力検証 def validate_text(value, field, required): if value is None: if required: raise ApiError(400, "missing_field", f"{field} が必要です") return None if not isinstance(value, str): raise ApiError(400, "invalid_type", f"{field} は文字列で指定してください") if required and len(value) == 0: raise ApiError(400, "empty_field", f"{field} が空です") if len(value) > MAX_PROMPT_CHARS: raise ApiError(400, "too_long", f"{field} は {MAX_PROMPT_CHARS} 文字以内で指定してください") if any(ord(c) < 32 or ord(c) == 127 for c in value): raise ApiError(400, "invalid_characters", f"{field} に制御文字は使えません") return value def validate_generate_request(obj): """型検証を辞書検索や特殊値判定より前に行う。不正入力は全て ApiError(400) にする。""" if not isinstance(obj, dict): raise ApiError(400, "invalid_body", "JSONオブジェクトで送信してください") unknown = set(obj.keys()) - ALLOWED_REQUEST_KEYS if unknown: raise ApiError(400, "unknown_field", f"許可されていない項目があります: {', '.join(sorted(map(str, unknown)))}") workflow_id = obj.get("workflow_id") if workflow_id is None: raise ApiError(400, "missing_field", "workflow_id が必要です") if not isinstance(workflow_id, str): raise ApiError(400, "invalid_type", "workflow_id は文字列で指定してください") if workflow_id not in TEMPLATES: raise ApiError(400, "unknown_workflow", "許可されていないワークフローです") prompt = validate_text(obj.get("prompt"), "prompt", required=True) negative = validate_text(obj.get("negative_prompt"), "negative_prompt", required=False) seed = obj.get("seed") if seed is None: seed = secrets.randbelow(2**63) elif type(seed) is not int: # bool や float (例: -1.0) は整数として扱わない raise ApiError(400, "invalid_seed", "seed は整数で指定してください") elif seed == -1: seed = secrets.randbelow(2**63) elif seed < 0 or seed > MAX_SEED: raise ApiError(400, "invalid_seed", f"seed は 0 から {MAX_SEED} の範囲で指定してください") resolution = obj.get("resolution", "1024x1024") if not isinstance(resolution, str): raise ApiError(400, "invalid_type", "resolution は文字列で指定してください") if resolution not in RESOLUTION_PRESETS: raise ApiError(400, "unknown_resolution", "許可されていない解像度プリセットです") return workflow_id, prompt, negative, seed, resolution # ---------------------------------------------------------------- HTTPサーバ (接続資源の制限付き) class RelayHTTPServer(ThreadingHTTPServer): daemon_threads = True request_queue_size = 128 def process_request(self, request, client_address): if CONN_SEM is not None and not CONN_SEM.acquire(blocking=False): # 同時接続数の上限超過: 新規接続は受け付けずに閉じる try: request.shutdown(socket.SHUT_RDWR) except OSError: pass request.close() return try: super().process_request(request, client_address) except Exception: if CONN_SEM is not None: CONN_SEM.release() raise def process_request_thread(self, request, client_address): try: super().process_request_thread(request, client_address) finally: if CONN_SEM is not None: CONN_SEM.release() class RelayHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" server_version = "RelayAPI/2.0" def setup(self): super().setup() # 遅い送信・接続放置に対する読み書きタイムアウト self.connection.settimeout(CONFIG["socket_timeout"]) def log_message(self, fmt, *args): # プロンプト等の内容はログに残さない sys.stderr.write("[relay] %s %s\n" % (self.command, self.path.split("?")[0])) # --- 共通応答 (応答後は接続を再利用しない: 未読の本文を次リクエストに持ち込ませない) def send_json(self, status, obj): body = json.dumps(obj, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Connection", "close") self.end_headers() self.wfile.write(body) self.close_connection = True def send_error_json(self, e): self.send_json(e.status, {"error": {"code": e.code, "message": e.message}}) def read_json_body(self): length = self.headers.get("Content-Length") if length is None: raise ApiError(400, "invalid_body", "Content-Length がありません") try: length = int(length) except ValueError: raise ApiError(400, "invalid_body", "Content-Length が不正です") if length <= 0 or length > MAX_BODY_BYTES: raise ApiError(400, "body_too_large", "リクエストボディが大きすぎます") raw = self.rfile.read(length) if len(raw) != length: raise ApiError(400, "invalid_body", "リクエストボディが途中で切断されました") try: return json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): raise ApiError(400, "invalid_json", "JSONとして解釈できません") # --- ルーティング def do_GET(self): try: path = urllib.parse.urlparse(self.path).path if path == "/health": self.handle_health() elif path.startswith("/jobs/") and path.endswith("/image"): self.handle_job_image(path[len("/jobs/"):-len("/image")].strip("/")) elif path.startswith("/jobs/"): self.handle_job_status(path[len("/jobs/"):].strip("/")) else: raise ApiError(404, "not_found", "存在しないパスです") except ApiError as e: self.send_error_json(e) except (BrokenPipeError, ConnectionResetError, socket.timeout): pass def do_POST(self): try: path = urllib.parse.urlparse(self.path).path if path == "/generate": self.handle_generate() else: raise ApiError(404, "not_found", "存在しないパスです") except ApiError as e: self.send_error_json(e) except (BrokenPipeError, ConnectionResetError, socket.timeout): pass def do_PUT(self): self.send_error_json(ApiError(405, "method_not_allowed", "許可されていないメソッドです")) do_DELETE = do_PUT do_PATCH = do_PUT # --- ハンドラ本体 def handle_health(self): closed, reason = gate_is_closed() try: comfy_request("GET", "/system_stats", timeout=3) comfy_state = "ok" except Exception: comfy_state = "unreachable" body = {"status": "suspended" if closed else "ok", "comfy": comfy_state, "submit_gate": "closed" if closed else "open", "gate_reason": reason, "workflows": sorted(TEMPLATES.keys()), "queue_depth": JOB_QUEUE.qsize() if JOB_QUEUE else 0} self.send_json(200 if comfy_state == "ok" else 503, body) def handle_generate(self): closed, _ = gate_is_closed() if closed: raise ApiError(503, "submit_suspended", "ComfyUI側の状態が不明なジョブがあるため、新規投入を停止しています") obj = self.read_json_body() workflow_id, prompt, negative, seed, resolution = validate_generate_request(obj) job_id = uuid.uuid4().hex job = {"job_id": job_id, "workflow_id": workflow_id, "resolution": resolution, "seed": seed, "prompt": prompt, "negative_prompt": negative, "state": "queued", "created_at": time.time(), "finished_at": None, "error": None, "output": None, "prompt_id": None} with JOBS_LOCK: JOBS[job_id] = job try: JOB_QUEUE.put_nowait(job_id) # 上限付きキュー: 競合時も上限を超えて受理しない except queue.Full: with JOBS_LOCK: JOBS.pop(job_id, None) # 拒否したジョブは記録に残さない raise ApiError(429, "queue_full", "待ち行列が上限に達しています") print(f"[relay] job {job_id} accepted (workflow={workflow_id}, preset={resolution})") self.send_json(202, {"job_id": job_id, "state": "queued", "workflow_id": workflow_id, "resolution": resolution, "seed": seed}) def get_job_or_404(self, job_id): with JOBS_LOCK: job = JOBS.get(job_id) if job is None: raise ApiError(404, "job_not_found", "この中継APIが発行したジョブではありません") return job def handle_job_status(self, job_id): job = self.get_job_or_404(job_id) self.send_json(200, {"job_id": job["job_id"], "state": job["state"], "workflow_id": job["workflow_id"], "resolution": job["resolution"], "seed": job["seed"], "created_at": job["created_at"], "finished_at": job["finished_at"], "error": job["error"]}) def handle_job_image(self, job_id): job = self.get_job_or_404(job_id) if job["state"] != "succeeded" or not job["output"]: raise ApiError(409, "job_not_ready", "ジョブが完了していないため画像を取得できません") try: content_type, body = comfy_fetch_image(job["output"]) except urllib.error.HTTPError as e: # 画像の消失と上流の異常を区別する。いずれも生成ジョブの再投入はしない if e.code == 404: raise ApiError(404, "image_not_found", "生成画像が見つかりません (ComfyUI側で削除された可能性があります)") raise ApiError(502, "comfy_error", f"ComfyUIが画像取得にエラーを返しました (HTTP {e.code})") except Exception: raise ApiError(502, "comfy_unreachable", "ComfyUIへ接続できないため画像を取得できません") self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.send_header("Connection", "close") self.end_headers() self.wfile.write(body) self.close_connection = True # ---------------------------------------------------------------- コンソールコマンド def console_loop(): """PC側コンソールからの運用コマンド (status / resume)。""" while True: try: line = input() except (EOFError, KeyboardInterrupt): return cmd = line.strip().lower() if cmd == "status": closed, reason = gate_is_closed() with JOBS_LOCK: counts = {} for j in JOBS.values(): counts[j["state"]] = counts.get(j["state"], 0) + 1 print(f"[relay] submit_gate: {'closed (' + str(reason) + ')' if closed else 'open'} / jobs: {counts}") elif cmd in ("resume", "resume force"): closed, _ = gate_is_closed() if not closed: print("[relay] 投入は停止していません") continue # 状態を確認できない場合は、force でも再開しない (停止を維持する) try: ids = comfy_queue_ids() except Exception: print("[relay] ComfyUIの状態を確認できないため、停止を維持します") continue if ids and cmd != "resume force": print(f"[relay] ComfyUIのキューに残件があります: {sorted(ids)}") print("[relay] 内容を確認済みで再開する場合は resume force と入力してください (停止を維持しています)") continue gate_open() elif cmd: print("[relay] 使用できるコマンド: status / resume / resume force") # ---------------------------------------------------------------- 起動 def parse_args(): p = argparse.ArgumentParser(description="Muse -> ComfyUI 限定中継API") p.add_argument("--host", required=True, help="待ち受けIP (Tailscale帯域 100.64.0.0/10 のみ許可)") p.add_argument("--port", type=int, default=8190, help="待ち受けポート (既定: 8190)") p.add_argument("--comfy", default="http://127.0.0.1:8188", help="ComfyUIのURL (127.0.0.1 のみ許可。既定: http://127.0.0.1:8188)") p.add_argument("--workflows-dir", required=True, help="承認済みワークフローJSONのフォルダー") p.add_argument("--max-queue", type=int, default=4, help="待ち行列の上限 (既定: 4)") p.add_argument("--job-timeout", type=int, default=900, help="1ジョブの生成タイムアウト秒 (既定: 900)") p.add_argument("--poll-interval", type=float, default=2.0, help="状態確認の間隔秒 (既定: 2.0)") p.add_argument("--comfy-fail-threshold", type=int, default=5, help="状態不明と判定する連続通信失敗回数 (既定: 5)") p.add_argument("--job-retention", type=int, default=3600, help="終端ジョブ記録の保持秒数 (既定: 3600)") p.add_argument("--max-job-records", type=int, default=100, help="ジョブ記録の最大件数 (既定: 100)") p.add_argument("--cleanup-interval", type=int, default=60, help="保持期限チェックの間隔秒 (既定: 60)") p.add_argument("--socket-timeout", type=float, default=15.0, help="接続の読み書きタイムアウト秒 (既定: 15)") p.add_argument("--max-connections", type=int, default=32, help="同時接続数の上限 (既定: 32)") p.add_argument("--local-test", action="store_true", help="127.0.0.1 へのバインドを許可する (単体テスト用)") args = p.parse_args() try: ip = ipaddress.ip_address(args.host) except ValueError: p.error(f"--host がIPアドレスではありません: {args.host}") in_tailnet = ip in ipaddress.ip_network("100.64.0.0/10") if not in_tailnet and not (args.local_test and ip.is_loopback): p.error("--host はTailscale帯域 (100.64.0.0/10) のIPを指定してください。" "0.0.0.0 や LAN のIPへのバインドは許可しません") comfy = urllib.parse.urlparse(args.comfy) if comfy.scheme != "http" or comfy.hostname != "127.0.0.1": p.error("--comfy は http://127.0.0.1: の形式のみ許可します") if not (1 <= args.port <= 65535): p.error("--port が範囲外です") if args.max_queue < 1: p.error("--max-queue は1以上にしてください") return args def main(): global JOB_QUEUE, CONN_SEM args = parse_args() CONFIG.update({ "comfy_base": f"http://127.0.0.1:{urllib.parse.urlparse(args.comfy).port or 8188}", "client_id": "claude-relay-" + uuid.uuid4().hex[:12], "job_timeout": args.job_timeout, "poll_interval": args.poll_interval, "comfy_fail_threshold": args.comfy_fail_threshold, "job_retention": args.job_retention, "max_job_records": args.max_job_records, "cleanup_interval": args.cleanup_interval, "socket_timeout": args.socket_timeout, }) JOB_QUEUE = queue.Queue(maxsize=args.max_queue) CONN_SEM = threading.Semaphore(args.max_connections) load_templates(args.workflows_dir) # 起動時にComfyUIのキューに残件がある場合は、前インスタンスの残りかGUIのジョブか # 区別できないため、運用者が確認するまで投入を停止する (自動で消したりはしない)。 # 状態を確認できない場合 (ComfyUI未起動・通信不可) も、開放状態にはせず停止を維持する。 try: existing = comfy_queue_ids() except Exception: existing = None gate_close("起動時にComfyUIの状態を確認できませんでした") if existing: gate_close(f"起動時にComfyUIのキューに残件があります: {sorted(existing)}") threading.Thread(target=worker_loop, daemon=True).start() threading.Thread(target=cleanup_loop, daemon=True).start() threading.Thread(target=console_loop, daemon=True).start() server = RelayHTTPServer((args.host, args.port), RelayHandler) print(f"[relay] listening on http://{args.host}:{args.port} (comfy: {CONFIG['comfy_base']})") print("[relay] 停止するには Ctrl+C を押してください") try: server.serve_forever() except KeyboardInterrupt: print("\n[relay] 停止します") server.shutdown() if __name__ == "__main__": main() ``` ### relay_regression_test.py ```python #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ relay_regression_test.py - relay_api.py の回帰テスト (モックComfyUI使用) 実行方法: python3 relay_regression_test.py [--workflows-dir DIR] 前提: - relay_api.py がこのファイルと同じフォルダーにあること - ワークフローJSON (noobai_vpred_api.json / wai_nsfw_api.json) の ckpt_name を 実ファイル名に置換したコピーを用意すること。--workflows-dir を省略した場合は 一時フォルダーに自動生成する (プレースホルダをテスト用ファイル名に置換)。 注意: これはモックのComfyUIに対する単体・結合テストであり、 Windows + 実ComfyUI + Tailscale での実機検証の代替にはならない。 検証項目: A. 正常系 (生成・画像取得・パラメータ注入) / 型検証 / 従来の入力検証 / 保持期限による記録削除 B. キュー上限の競合 (上限付きキューで同時投入しても上限を超えない) C. 生成エラー (completed=false, status_str="error") の即時検出 D. タイムアウト後の待機中ジョブ取消 (prompt_id指定delete) と後続投入の継続 D2. 実行中ジョブのタイムアウト: 自動割り込みをせず状態不明として投入を止める E. 通信断後の状態不明 -> 投入ゲート停止 F. /prompt 応答喪失 -> 状態不明・投入停止、その後のワーカー生存と resume による再開 I. ゲート停止中に待機済みジョブがComfyUIへ投入されないこと、再開後に処理されること J. 実行ジョブ交代・他ジョブ共存時に、無関係なジョブを止めないこと G. 起動時にComfyUIキュー残件がある場合の投入停止 K. 起動時にComfyUIの状態を確認できない場合の停止維持と、確認後の再開 L. 不正な /queue 応答 (キー欠落・null・型違い・不正要素) を「空」と判断せず停止を維持すること M. 投入拒否 (HTTP 400 + node_errors) は failed、判断できない応答 (HTTP 500) は unknown と区別すること N. 画像取得失敗 (404 / 接続失敗) を規定のJSONエラーで返し、再投入しないこと O. HTTP 200 + prompt_id + node_errors (部分検証エラー) を受理済みとして追跡すること、 node_errors が空の HTTP 400 (既知の error.type) を明確な拒否として扱うこと、 未知の error.type の 400 は unknown のままであること H. CLIの起動拒否 (バインド先 / ckpt未設定) Z. 全体を通して /queue の clear と /interrupt が一度も送られていないこと """ import argparse import json import pathlib import platform import socket import subprocess import sys import tempfile import threading import time import urllib.error import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer HERE = pathlib.Path(__file__).resolve().parent RELAY = HERE / "relay_api.py" PNG_BYTES = b"\x89PNG\r\n\x1a\nMOCKIMAGE" RESULTS = [] def check(name, cond, detail=""): RESULTS.append((name, bool(cond))) print(("PASS" if cond else "FAIL"), name, detail if not cond else "") # ---------------------------------------------------------------- モックComfyUI class MockState: """フェーズごとに reset() する状態と、全フェーズ累積の統計を分けて持つ。""" def __init__(self): self.lock = threading.Lock() # 累積 (resetしない) self.interrupts_total = 0 self.clear_total = 0 self.deleted_ids = [] self.reset() def reset(self): with self.lock: self.counter = 0 self.jobs = {} # prompt_id -> dict(marker, at, deleted, interrupted) self.preloaded = [] # 他人のジョブ想定 (queue_running に現れる) self.completion_delay = 0.6 self.last_prompt = None self.queue_mode = "ok" # ok / missing / null / wrongtype / badelem / badpid self.view_mode = "ok" # ok / 404 def marker_of(self, prompt): texts = [] for node in (prompt or {}).values(): if isinstance(node, dict) and node.get("class_type") == "CLIPTextEncode": texts.append(str(node.get("inputs", {}).get("text", ""))) joined = " ".join(texts) for marker in ("TRIGGER_ERROR", "TRIGGER_HANG_RUNNING", "TRIGGER_HANG", "TRIGGER_DROP", "TRIGGER_LOSE", "TRIGGER_REJECT_EMPTY", "TRIGGER_REJECT", "TRIGGER_500", "TRIGGER_PARTIAL", "TRIGGER_400_UNKNOWN"): if marker in joined: return marker return "" STATE = MockState() class MockHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, *a): pass def _json(self, code, obj): body = json.dumps(obj).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _drop(self): try: self.connection.shutdown(socket.SHUT_RDWR) except OSError: pass self.connection.close() self.close_connection = True def do_POST(self): try: n = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(n) if n else b"{}" if self.path == "/prompt": payload = json.loads(raw or b"{}") prompt = payload.get("prompt") with STATE.lock: STATE.counter += 1 pid = f"mock-{STATE.counter}" marker = STATE.marker_of(prompt) STATE.jobs[pid] = {"marker": marker, "at": time.time(), "deleted": False, "interrupted": False} STATE.last_prompt = prompt if marker == "TRIGGER_LOSE": self._drop() # 投入は受理したが応答が失われた状況を再現 return if marker == "TRIGGER_REJECT": # 実ComfyUIの検証エラー応答 (HTTP 400 + error + node_errors) を再現 self._json(400, { "error": {"type": "prompt_outputs_failed_validation", "message": "Prompt outputs failed validation", "details": "Required input is missing: ckpt_name", "extra_info": {"input_name": "ckpt_name", "exception_type": "Exception", "node_id": "4", "node_type": "CheckpointLoaderSimple"}}, "node_errors": {"4": { "errors": [{"type": "required_input_missing", "message": "Required input is missing", "details": "ckpt_name", "extra_info": {"input_name": "ckpt_name"}}], "dependent_outputs": [], "class_type": "CheckpointLoaderSimple"}}}) return if marker == "TRIGGER_PARTIAL": # 一部の出力系統だけ検証失敗: 正常な系統はキュー登録され、 # HTTP 200 + prompt_id + node_errors が返る状況を再現 self._json(200, {"prompt_id": pid, "number": 1, "node_errors": { "99": {"errors": [{"type": "required_input_missing", "message": "Required input is missing", "details": "ckpt_name", "extra_info": {"input_name": "ckpt_name"}}], "dependent_outputs": [], "class_type": "CheckpointLoaderSimple"}}}) return if marker == "TRIGGER_REJECT_EMPTY": # ノード種別が存在しない等の全体検証エラー: # HTTP 400 + error (既知の type) + node_errors {} を再現 self._json(400, { "error": {"type": "invalid_prompt", "message": "Node type not found: UnknownNode", "details": "", "extra_info": {}}, "node_errors": {}}) return if marker == "TRIGGER_400_UNKNOWN": # 既知でない error.type の 400 は明確な拒否と判定しないことの確認用 self._json(400, { "error": {"type": "some_unrecognized_type", "message": "unknown", "details": "", "extra_info": {}}, "node_errors": {"4": {"errors": [], "dependent_outputs": [], "class_type": "KSampler"}}}) return if marker == "TRIGGER_500": body = b"Internal Server Error" self.send_response(500) self.send_header("Content-Type", "text/plain") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) return self._json(200, {"prompt_id": pid, "number": 1, "node_errors": {}}) elif self.path == "/queue": payload = json.loads(raw or b"{}") with STATE.lock: if "clear" in payload: STATE.clear_total += 1 for pid in payload.get("delete") or []: STATE.deleted_ids.append(pid) if pid in STATE.jobs: STATE.jobs[pid]["deleted"] = True self._json(200, {}) elif self.path == "/interrupt": with STATE.lock: STATE.interrupts_total += 1 for job in STATE.jobs.values(): if job["marker"] in ("TRIGGER_HANG_RUNNING", "TRIGGER_LOSE"): job["interrupted"] = True self._json(200, {}) else: self._json(404, {}) except (BrokenPipeError, ConnectionResetError): pass def do_GET(self): try: if self.path == "/system_stats": self._json(200, {"system": {}}) elif self.path.startswith("/history/"): pid = self.path.rsplit("/", 1)[1] with STATE.lock: job = STATE.jobs.get(pid) if job and job["marker"] == "TRIGGER_DROP": self._drop() return if job and job["marker"] == "TRIGGER_ERROR": self._json(200, {pid: {"status": {"status_str": "error", "completed": False, "messages": [["execution_error", {}]]}, "outputs": {}}}) return if job and job["marker"] in ("TRIGGER_HANG", "TRIGGER_HANG_RUNNING"): self._json(200, {}) return if job and time.time() - job["at"] >= STATE.completion_delay: self._json(200, {pid: {"status": {"status_str": "success", "completed": True}, "outputs": {"9": {"images": [{"filename": "mock.png", "subfolder": "", "type": "output"}]}}}}) return self._json(200, {}) elif self.path == "/queue": with STATE.lock: qmode = STATE.queue_mode if qmode == "missing": self._json(200, {}) return if qmode == "null": self._json(200, {"queue_running": None, "queue_pending": []}) return if qmode == "wrongtype": self._json(200, {"queue_running": "oops", "queue_pending": []}) return if qmode == "badelem": self._json(200, {"queue_running": [[0]], "queue_pending": []}) return if qmode == "badpid": self._json(200, {"queue_running": [[0, 123]], "queue_pending": []}) return running, pending = [], [] with STATE.lock: for extra in STATE.preloaded: running.append([0, extra, {}, "foreign", []]) for pid, job in STATE.jobs.items(): if job["deleted"] or job["interrupted"]: continue if job["marker"] in ("TRIGGER_HANG_RUNNING", "TRIGGER_LOSE"): running.append([0, pid, {}, "relay", []]) elif job["marker"] == "TRIGGER_HANG": pending.append([0, pid, {}, "relay", []]) self._json(200, {"queue_running": running, "queue_pending": pending}) elif self.path.startswith("/view"): with STATE.lock: vmode = STATE.view_mode if vmode == "404": self._json(404, {"error": "file not found"}) return self.send_response(200) self.send_header("Content-Type", "image/png") self.send_header("Content-Length", str(len(PNG_BYTES))) self.end_headers() self.wfile.write(PNG_BYTES) else: self._json(404, {}) except (BrokenPipeError, ConnectionResetError): pass # ---------------------------------------------------------------- テスト用ユーティリティ def http(method, base, path, obj=None): data = json.dumps(obj).encode() if obj is not None else None req = urllib.request.Request(base + path, data=data, method=method, headers={"Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=15) as resp: return resp.status, resp.read() except urllib.error.HTTPError as e: return e.code, e.read() except Exception as e: return -1, str(e).encode() def wait_job(base, job_id, want, timeout=30): deadline = time.time() + timeout last = None while time.time() < deadline: s, b = http("GET", base, f"/jobs/{job_id}") if s == 200: last = json.loads(b) if last["state"] in want: return last time.sleep(0.2) return last def health(base): s, b = http("GET", base, "/health") try: return s, json.loads(b) except Exception: return s, {} class RelayProc: def __init__(self, port, comfy_port, workflows_dir, extra_args): self.base = f"http://127.0.0.1:{port}" cmd = [sys.executable, str(RELAY), "--host", "127.0.0.1", "--port", str(port), "--local-test", "--comfy", f"http://127.0.0.1:{comfy_port}", "--workflows-dir", str(workflows_dir)] + extra_args self.proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) deadline = time.time() + 30 while time.time() < deadline: if self.proc.poll() is not None: raise RuntimeError(f"relayが起動しませんでした:\n{self._log_tail()}") s, _ = http("GET", self.base, "/health") if s in (200, 503): return time.sleep(0.2) # タイムアウト時も子プロセスを確実に終了し、原因確認用のログを残す self.proc.kill() try: self.proc.wait(timeout=5) except subprocess.TimeoutExpired: pass raise RuntimeError("relayの起動待ちがタイムアウトしました" f" (子プロセスは終了済み)。ログ末尾:\n{self._log_tail()}") def _log_tail(self): try: out = self.proc.stdout.read() if self.proc.stdout else "" except Exception: out = "" return "\n".join((out or "").splitlines()[-20:]) def send_cmd(self, cmd): self.proc.stdin.write(cmd + "\n") self.proc.stdin.flush() def stop(self): if self.proc.poll() is None: self.proc.terminate() try: self.proc.wait(timeout=5) except subprocess.TimeoutExpired: self.proc.kill() try: self.proc.wait(timeout=5) except subprocess.TimeoutExpired: pass def prepare_workflows(): tmp = pathlib.Path(tempfile.mkdtemp(prefix="relay-test-wf-")) for name, ckpt in [("noobai_vpred_api.json", "NoobAI-XL-Vpred-v1.0.safetensors"), ("wai_nsfw_api.json", "wai-nsfw-illustrious-sdxl_v17.safetensors")]: d = json.loads((HERE / name).read_text()) for node in d.values(): if isinstance(node, dict) and node.get("class_type") == "CheckpointLoaderSimple": node["inputs"]["ckpt_name"] = ckpt (tmp / name).write_text(json.dumps(d)) return tmp def gen(base, prompt, **kw): payload = {"workflow_id": "wai_nsfw", "prompt": prompt} payload.update(kw) return http("POST", base, "/generate", payload) # ---------------------------------------------------------------- フェーズ def phase_a(mock_port, wf): print("== Phase A: 正常系 / 型検証 / 保持期限 ==") STATE.reset() relay = RelayProc(18400, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "30", "--job-retention", "2", "--cleanup-interval", "1"]) try: s, h = health(relay.base) check("A1 health: gate open", s == 200 and h.get("submit_gate") == "open") s, b = http("POST", relay.base, "/generate", {"workflow_id": "noobai_vpred", "prompt": "masterpiece, best quality, 1girl", "seed": 123, "resolution": "832x1216"}) job = json.loads(b) check("A2 generate accepted", s == 202 and "job_id" in job) jid = job["job_id"] st = wait_job(relay.base, jid, {"succeeded", "failed"}, 20) check("A2 job succeeded", st and st["state"] == "succeeded", str(st)) s, b = http("GET", relay.base, f"/jobs/{jid}/image") check("A2 image bytes", s == 200 and b == PNG_BYTES) with STATE.lock: submitted = STATE.last_prompt ks = next(n for n in submitted.values() if n["class_type"] == "KSampler") latent = next(n for n in submitted.values() if n["class_type"] == "EmptyLatentImage") save = next(n for n in submitted.values() if n["class_type"] == "SaveImage") check("A3 injection: seed", ks["inputs"]["seed"] == 123) check("A3 injection: size", (latent["inputs"]["width"], latent["inputs"]["height"]) == (832, 1216)) check("A3 injection: filename_prefix", save["inputs"]["filename_prefix"] == f"relay_{jid}") cases = [ ("A4 workflow_id list -> 400", {"workflow_id": [], "prompt": "x"}), ("A5 resolution dict -> 400", {"workflow_id": "wai_nsfw", "prompt": "x", "resolution": {}}), ("A6 seed -1.0 -> 400", {"workflow_id": "wai_nsfw", "prompt": "x", "seed": -1.0}), ("A6 seed 1.0 -> 400", {"workflow_id": "wai_nsfw", "prompt": "x", "seed": 1.0}), ("A6 seed str -> 400", {"workflow_id": "wai_nsfw", "prompt": "x", "seed": "42"}), ("A7 negative_prompt int -> 400", {"workflow_id": "wai_nsfw", "prompt": "x", "negative_prompt": 123}), ("A8 unknown field -> 400", {"workflow_id": "wai_nsfw", "prompt": "x", "url": "http://example.com"}), ("A8 unknown workflow -> 400", {"workflow_id": "nope", "prompt": "x"}), ("A8 bad resolution -> 400", {"workflow_id": "wai_nsfw", "prompt": "x", "resolution": "512x512"}), ("A8 overlong prompt -> 400", {"workflow_id": "wai_nsfw", "prompt": "x" * 2001}), ] for name, payload in cases: s, b = http("POST", relay.base, "/generate", payload) check(name, s == 400 and b'"error"' in b, f"status={s} body={b[:120]}") time.sleep(3.5) s, _ = http("GET", relay.base, f"/jobs/{jid}") check("A9 retention: finished job evicted", s == 404, f"status={s}") finally: relay.stop() def phase_b(mock_port, wf): print("== Phase B: キュー上限の競合 ==") STATE.reset() STATE.completion_delay = 2.5 relay = RelayProc(18401, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "60", "--max-queue", "1"]) try: results = [] barrier = threading.Barrier(6) def fire(): barrier.wait() results.append(gen(relay.base, "race test")) threads = [threading.Thread(target=fire) for _ in range(6)] for t in threads: t.start() for t in threads: t.join() accepted = [b for s, b in results if s == 202] rejected = [b for s, b in results if s == 429] check("B1 concurrent: accepted == running(1)+queue(1)", len(accepted) == 2, f"accepted={len(accepted)} codes={[s for s, _ in results]}") check("B2 concurrent: rest are 429", len(rejected) == 4, f"rejected={len(rejected)}") check("B3 rejected responses carry no job_id", all(b"job_id" not in b for b in rejected)) for b in accepted: wait_job(relay.base, json.loads(b)["job_id"], {"succeeded", "failed"}, 30) s, _ = gen(relay.base, "after") check("B4 capacity recovers after completion", s == 202, f"status={s}") finally: relay.stop() def phase_c(mock_port, wf): print("== Phase C: 生成エラー (completed=false) の即時検出 ==") STATE.reset() relay = RelayProc(18402, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "60"]) try: s, b = gen(relay.base, "TRIGGER_ERROR scene") jid = json.loads(b)["job_id"] started = time.time() st = wait_job(relay.base, jid, {"failed", "succeeded", "unknown"}, 20) elapsed = time.time() - started check("C1 error job failed (not timeout)", st and st["state"] == "failed" and st["error"]["code"] == "generation_failed", str(st)) check("C2 error detected quickly", elapsed < 20, f"elapsed={elapsed:.1f}s") finally: relay.stop() def phase_d(mock_port, wf): print("== Phase D: タイムアウト後の待機中ジョブ取消 ==") STATE.reset() relay = RelayProc(18403, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "3"]) try: before = len(STATE.deleted_ids) s, b = gen(relay.base, "TRIGGER_HANG scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"failed", "unknown"}, 30) check("D1 pending job: timeout -> failed (cancel verified)", st and st["state"] == "failed" and st["error"]["code"] == "generation_timeout", str(st)) check("D2 pending job: delete was targeted at own prompt_id", len(STATE.deleted_ids) > before) s, b = gen(relay.base, "normal scene") st = wait_job(relay.base, json.loads(b)["job_id"], {"succeeded", "failed"}, 20) check("D3 gate stays open after verified cancel", st and st["state"] == "succeeded", str(st)) finally: relay.stop() def phase_d2(mock_port, wf): print("== Phase D2: 実行中ジョブのタイムアウト (自動割り込みなし) ==") STATE.reset() relay = RelayProc(18407, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "3"]) try: s, b = gen(relay.base, "TRIGGER_HANG_RUNNING scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"unknown", "failed"}, 30) check("D2-1 running job: timeout -> unknown (no auto interrupt)", st and st["state"] == "unknown" and st["error"]["code"] == "generation_timeout_unresolved", str(st)) s, _ = gen(relay.base, "normal scene") check("D2-2 new submit suspended (503)", s == 503, f"status={s}") check("D2-3 /interrupt never sent", STATE.interrupts_total == 0) finally: relay.stop() def phase_e(mock_port, wf): print("== Phase E: 通信断 -> 状態不明・投入停止 ==") STATE.reset() relay = RelayProc(18404, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "60", "--comfy-fail-threshold", "3"]) try: s, b = gen(relay.base, "TRIGGER_DROP scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"unknown"}, 30) check("E1 dropped job becomes unknown", st and st["state"] == "unknown", str(st)) s, b = gen(relay.base, "normal scene") check("E2 new submit suspended (503)", s == 503 and b"submit_suspended" in b, f"status={s}") _, h = health(relay.base) check("E3 health shows gate closed", h.get("submit_gate") == "closed") finally: relay.stop() def phase_f(mock_port, wf): print("== Phase F: /prompt 応答喪失 -> 状態不明、その後のワーカー生存と再開 ==") STATE.reset() relay = RelayProc(18405, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "60"]) try: s, b = gen(relay.base, "TRIGGER_LOSE scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"unknown"}, 30) check("F1 lost-response job becomes unknown", st and st["state"] == "unknown", str(st)) check("F2 error code submit_outcome_unknown", st and st["error"]["code"] == "submit_outcome_unknown", str(st)) s, _ = gen(relay.base, "normal scene") check("F3 new submit suspended (503)", s == 503, f"status={s}") # キューに残件 (応答喪失した自ジョブ) がある間は resume だけでは開かない relay.send_cmd("resume") time.sleep(1.0) s, _ = gen(relay.base, "normal scene") check("F4 resume with non-empty queue keeps gate closed", s == 503, f"status={s}") # 運用者確認を経た force で再開し、ワーカーが生存していること (後続ジョブが処理される) を確認 relay.send_cmd("resume force") time.sleep(0.5) s, b = gen(relay.base, "worker survival check") check("F5 submit accepted after resume force", s == 202, f"status={s}") st = wait_job(relay.base, json.loads(b)["job_id"], {"succeeded", "failed"}, 20) check("F6 worker alive: subsequent job processed", st and st["state"] == "succeeded", str(st)) finally: relay.stop() def phase_i(mock_port, wf): print("== Phase I: ゲート停止中の待機済みジョブ ==") STATE.reset() relay = RelayProc(18408, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "60", "--comfy-fail-threshold", "3", "--max-queue", "4"]) try: s, b = gen(relay.base, "TRIGGER_DROP scene") jid1 = json.loads(b)["job_id"] # ゲートが閉じる前に2件を待機させる s2, b2 = gen(relay.base, "queued one") s3, b3 = gen(relay.base, "queued two") check("I1 two jobs queued before gate closes", s2 == 202 and s3 == 202, f"s2={s2} s3={s3}") jid2, jid3 = json.loads(b2)["job_id"], json.loads(b3)["job_id"] st = wait_job(relay.base, jid1, {"unknown"}, 30) check("I2 first job becomes unknown", st and st["state"] == "unknown", str(st)) with STATE.lock: submitted = STATE.counter time.sleep(2.5) with STATE.lock: submitted_after = STATE.counter check("I3 queued jobs NOT submitted while gate closed", submitted == 1 and submitted_after == 1, f"submitted={submitted} after={submitted_after}") for jid in (jid2, jid3): s, b = http("GET", relay.base, f"/jobs/{jid}") check("I4 queued job state stays 'queued'", s == 200 and json.loads(b)["state"] == "queued", b[:120]) relay.send_cmd("resume") # モックのキューに残件は無いため通常resumeで開く time.sleep(0.5) st2 = wait_job(relay.base, jid2, {"succeeded", "failed"}, 20) st3 = wait_job(relay.base, jid3, {"succeeded", "failed"}, 20) check("I5 queued jobs processed after resume", st2 and st2["state"] == "succeeded" and st3 and st3["state"] == "succeeded", f"{st2} / {st3}") finally: relay.stop() def phase_j(mock_port, wf): print("== Phase J: 他ジョブ共存・実行ジョブ交代時の誤停止防止 ==") STATE.reset() relay = RelayProc(18409, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "3"]) try: # GUI等で実行中の他ジョブが共存する状況を作る with STATE.lock: STATE.preloaded = ["gui-job-1"] s, b = gen(relay.base, "TRIGGER_HANG_RUNNING scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"unknown", "failed"}, 30) check("J1 own running job unresolved -> unknown", st and st["state"] == "unknown", str(st)) with STATE.lock: deleted = list(STATE.deleted_ids) check("J2 foreign job never deleted", "gui-job-1" not in deleted, str(deleted)) check("J3 /interrupt never sent", STATE.interrupts_total == 0) finally: relay.stop() def phase_g(mock_port, wf): print("== Phase G: 起動時のキュー残件 -> 投入停止 ==") STATE.reset() STATE.preloaded = ["foreign-prompt-1"] relay = RelayProc(18406, mock_port, wf, ["--poll-interval", "0.2"]) try: _, h = health(relay.base) check("G1 health shows gate closed at startup", h.get("submit_gate") == "closed") s, _ = gen(relay.base, "normal scene") check("G2 submit suspended at startup (503)", s == 503, f"status={s}") finally: relay.stop() def phase_k(wf): print("== Phase K: 起動時に状態確認不可 -> 停止維持、確認後に再開 ==") STATE.reset() dead_port = 18499 # この時点では何も待ち受けていない relay = RelayProc(18411, dead_port, wf, ["--poll-interval", "0.2", "--job-timeout", "30"]) mock2 = None try: _, h = health(relay.base) check("K1 gate closed when comfy state unverifiable at startup", h.get("submit_gate") == "closed", str(h)) relay.send_cmd("resume") time.sleep(1.0) _, h = health(relay.base) check("K2 resume keeps gate closed while state unverifiable", h.get("submit_gate") == "closed", str(h)) # ComfyUI (モック) を起動してから resume すると開く mock2 = ThreadingHTTPServer(("127.0.0.1", dead_port), MockHandler) threading.Thread(target=mock2.serve_forever, daemon=True).start() relay.send_cmd("resume") time.sleep(0.5) _, h = health(relay.base) check("K3 gate opens after state verified empty", h.get("submit_gate") == "open", str(h)) s, b = gen(relay.base, "post recovery scene") st = wait_job(relay.base, json.loads(b)["job_id"], {"succeeded", "failed"}, 20) check("K4 generation works after recovery", st and st["state"] == "succeeded", str(st)) finally: relay.stop() if mock2: mock2.shutdown() def phase_l(mock_port, wf): print("== Phase L: 不正な /queue 応答を「空」と判断しない ==") STATE.reset() STATE.queue_mode = "missing" relay = RelayProc(18412, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "3"]) try: _, h = health(relay.base) check("L1 missing keys at startup -> gate closed", h.get("submit_gate") == "closed", str(h)) for mode in ("null", "wrongtype", "badelem", "badpid"): STATE.queue_mode = mode relay.send_cmd("resume") time.sleep(0.7) _, h = health(relay.base) check(f"L2 resume blocked with malformed queue ({mode})", h.get("submit_gate") == "closed", str(h)) STATE.queue_mode = "wrongtype" relay.send_cmd("resume force") time.sleep(0.7) _, h = health(relay.base) check("L3 resume force also blocked when state unverifiable", h.get("submit_gate") == "closed", str(h)) STATE.queue_mode = "ok" relay.send_cmd("resume") time.sleep(0.5) _, h = health(relay.base) check("L4 gate opens once queue response is valid and empty", h.get("submit_gate") == "open", str(h)) finally: relay.stop() STATE.reset() relay = RelayProc(18413, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "3"]) try: s, b = gen(relay.base, "TRIGGER_HANG scene") jid = json.loads(b)["job_id"] STATE.queue_mode = "missing" st = wait_job(relay.base, jid, {"unknown", "failed"}, 30) check("L5 malformed queue during cancel-verify -> unknown", st and st["state"] == "unknown", str(st)) finally: relay.stop() STATE.queue_mode = "ok" def phase_m(mock_port, wf): print("== Phase M: 投入拒否 (400) と判断不能 (500) の区別 ==") STATE.reset() relay = RelayProc(18414, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "30"]) try: s, b = gen(relay.base, "TRIGGER_REJECT scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"failed", "unknown"}, 20) check("M1 explicit 400 rejection -> job failed (comfy_node_error)", st and st["state"] == "failed" and st["error"]["code"] == "comfy_node_error", str(st)) s, b = gen(relay.base, "normal scene") st = wait_job(relay.base, json.loads(b)["job_id"], {"succeeded", "failed"}, 20) check("M2 subsequent job processed after rejection", st and st["state"] == "succeeded", str(st)) s, b = gen(relay.base, "TRIGGER_500 scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"unknown"}, 20) check("M3 ambiguous HTTP 500 -> unknown (submit_outcome_unknown)", st and st["state"] == "unknown" and st["error"]["code"] == "submit_outcome_unknown", str(st)) s, _ = gen(relay.base, "normal scene") check("M4 gate closed after ambiguous submit", s == 503, f"status={s}") finally: relay.stop() def phase_n(wf): print("== Phase N: 画像取得失敗のJSONエラー化 ==") STATE.reset() port = 18501 mock = ThreadingHTTPServer(("127.0.0.1", port), MockHandler) threading.Thread(target=mock.serve_forever, daemon=True).start() relay = RelayProc(18415, port, wf, ["--poll-interval", "0.2", "--job-timeout", "30"]) try: s, b = gen(relay.base, "image error scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"succeeded"}, 20) check("N1 job succeeded", st and st["state"] == "succeeded", str(st)) s, b = http("GET", relay.base, f"/jobs/{jid}/image") check("N2 image ok", s == 200 and b == PNG_BYTES) STATE.view_mode = "404" s, b = http("GET", relay.base, f"/jobs/{jid}/image") code = None try: code = json.loads(b)["error"]["code"] except Exception: pass check("N3 image 404 -> JSON image_not_found", s == 404 and code == "image_not_found", f"status={s} body={b[:120]}") STATE.view_mode = "ok" with STATE.lock: counter_before = STATE.counter mock.shutdown() mock.server_close() s, b = http("GET", relay.base, f"/jobs/{jid}/image") code = None try: code = json.loads(b)["error"]["code"] except Exception: pass check("N4 comfy down -> JSON comfy_unreachable (502)", s == 502 and code == "comfy_unreachable", f"status={s} body={b[:120]}") s, b = http("GET", relay.base, f"/jobs/{jid}") check("N5 job record stays succeeded (no resubmission)", s == 200 and json.loads(b)["state"] == "succeeded") with STATE.lock: counter_after = STATE.counter check("N6 no resubmission happened", counter_before == counter_after) finally: relay.stop() def phase_o(mock_port, wf): print("== Phase O: 部分エラー200の受理継続 / node_errors空の400拒否 / 未知typeの境界 ==") STATE.reset() relay = RelayProc(18416, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "30"]) try: s, b = gen(relay.base, "TRIGGER_PARTIAL scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"succeeded", "failed", "unknown"}, 20) check("O1 200 + prompt_id + node_errors -> accepted, tracked to success", st and st["state"] == "succeeded", str(st)) s, b = http("GET", relay.base, f"/jobs/{jid}/image") check("O2 image retrievable for partial-error job", s == 200 and b == PNG_BYTES) s, b = gen(relay.base, "TRIGGER_REJECT_EMPTY scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"failed", "unknown"}, 20) check("O3 400 + invalid_prompt + empty node_errors -> failed (comfy_node_error)", st and st["state"] == "failed" and st["error"]["code"] == "comfy_node_error", str(st)) s, b = gen(relay.base, "normal scene") st = wait_job(relay.base, json.loads(b)["job_id"], {"succeeded", "failed"}, 20) check("O4 subsequent job processed after empty-node_errors rejection", st and st["state"] == "succeeded", str(st)) s, b = gen(relay.base, "TRIGGER_400_UNKNOWN scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"unknown", "failed"}, 20) check("O5 400 with unknown error.type stays unknown", st and st["state"] == "unknown", str(st)) finally: relay.stop() def phase_h(wf): print("== Phase H: 起動拒否 ==") r = subprocess.run([sys.executable, str(RELAY), "--host", "0.0.0.0", "--port", "18410", "--workflows-dir", str(wf)], capture_output=True, text=True, timeout=30) check("H1 bind 0.0.0.0 rejected", r.returncode != 0) r = subprocess.run([sys.executable, str(RELAY), "--host", "127.0.0.1", "--port", "18410", "--local-test", "--workflows-dir", str(HERE)], capture_output=True, text=True, timeout=30) check("H2 placeholder ckpt rejected at startup", r.returncode != 0 and "ckpt_name" in (r.stdout + r.stderr), (r.stdout + r.stderr)[-200:]) # 到達制限: Tailscale帯域でも 127.0.0.1 (--local-test) でもないアドレスは起動拒否する r = subprocess.run([sys.executable, str(RELAY), "--host", "192.168.1.10", "--port", "18410", "--workflows-dir", str(wf)], capture_output=True, text=True, timeout=30) check("H3 bind LAN address rejected (reachability restriction)", r.returncode != 0 and "Tailscale" in (r.stdout + r.stderr), (r.stdout + r.stderr)[-200:]) def main(): ap = argparse.ArgumentParser() ap.add_argument("--workflows-dir", default=None) args = ap.parse_args() wf = pathlib.Path(args.workflows_dir) if args.workflows_dir else prepare_workflows() print(f"env: python {sys.version.split()[0]} / {platform.platform()}") print(f"relay under test: {RELAY}") mock_port = 18450 server = ThreadingHTTPServer(("127.0.0.1", mock_port), MockHandler) threading.Thread(target=server.serve_forever, daemon=True).start() print(f"mock ComfyUI on 127.0.0.1:{mock_port}; workflows: {wf}") phase_a(mock_port, wf) phase_b(mock_port, wf) phase_c(mock_port, wf) phase_d(mock_port, wf) phase_d2(mock_port, wf) phase_e(mock_port, wf) phase_f(mock_port, wf) phase_i(mock_port, wf) phase_j(mock_port, wf) phase_g(mock_port, wf) phase_k(wf) phase_l(mock_port, wf) phase_m(mock_port, wf) phase_n(wf) phase_o(mock_port, wf) phase_h(wf) check("Z1 mock never received queue clear", STATE.clear_total == 0) check("Z2 mock never received /interrupt", STATE.interrupts_total == 0) failed = [n for n, ok in RESULTS if not ok] print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} passed =====") if failed: print("FAILED:", ", ".join(failed)) return 1 print("ALL PASS (mock environment)") return 0 if __name__ == "__main__": sys.exit(main()) ``` ### sdxl_base_test.json ```json { "id": "c4e5ca48-eb75-4bea-8e4e-d483d6010889", "revision": 0, "last_node_id": 7, "last_link_id": 9, "nodes": [ { "id": 1, "type": "CheckpointLoaderSimple", "pos": [ 40, 300 ], "size": [ 280, 90 ], "flags": {}, "order": 1, "mode": 0, "inputs": [], "outputs": [ { "name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [ 1 ] }, { "name": "CLIP", "type": "CLIP", "slot_index": 0, "links": [ 2, 3 ] }, { "name": "VAE", "type": "VAE", "slot_index": 0, "links": [ 8 ] } ], "properties": { "Node name for S&R": "CheckpointLoaderSimple" }, "widgets_values": [ "sd_xl_base_1.0.safetensors" ] }, { "id": 2, "type": "CLIPTextEncode", "pos": [ 380, 60 ], "size": [ 280, 90 ], "flags": {}, "order": 2, "mode": 0, "inputs": [ { "name": "clip", "type": "CLIP", "link": 2 } ], "outputs": [ { "name": "CONDITIONING", "type": "CONDITIONING", "slot_index": 0, "links": [ 4 ] } ], "properties": { "Node name for S&R": "CLIPTextEncode" }, "widgets_values": [ "a city street in Tokyo at night, neon signs, rain, reflections, photorealistic, detailed" ], "title": "Positive" }, { "id": 3, "type": "CLIPTextEncode", "pos": [ 380, 560 ], "size": [ 280, 90 ], "flags": {}, "order": 3, "mode": 0, "inputs": [ { "name": "clip", "type": "CLIP", "link": 3 } ], "outputs": [ { "name": "CONDITIONING", "type": "CONDITIONING", "slot_index": 0, "links": [ 5 ] } ], "properties": { "Node name for S&R": "CLIPTextEncode" }, "widgets_values": [ "blurry, low quality, distorted, deformed" ], "title": "Negative" }, { "id": 4, "type": "EmptyLatentImage", "pos": [ 700, 560 ], "size": [ 280, 90 ], "flags": {}, "order": 4, "mode": 0, "inputs": [], "outputs": [ { "name": "LATENT", "type": "LATENT", "slot_index": 0, "links": [ 6 ] } ], "properties": { "Node name for S&R": "EmptyLatentImage" }, "widgets_values": [ 1024, 1024, 1 ] }, { "id": 5, "type": "KSampler", "pos": [ 1040, 300 ], "size": [ 280, 90 ], "flags": {}, "order": 5, "mode": 0, "inputs": [ { "name": "model", "type": "MODEL", "link": 1 }, { "name": "positive", "type": "CONDITIONING", "link": 4 }, { "name": "negative", "type": "CONDITIONING", "link": 5 }, { "name": "latent_image", "type": "LATENT", "link": 6 } ], "outputs": [ { "name": "LATENT", "type": "LATENT", "slot_index": 0, "links": [ 7 ] } ], "properties": { "Node name for S&R": "KSampler" }, "widgets_values": [ 42, "fixed", 30, 7.0, "euler", "normal", 1.0 ] }, { "id": 6, "type": "VAEDecode", "pos": [ 1400, 300 ], "size": [ 280, 90 ], "flags": {}, "order": 6, "mode": 0, "inputs": [ { "name": "samples", "type": "LATENT", "link": 7 }, { "name": "vae", "type": "VAE", "link": 8 } ], "outputs": [ { "name": "IMAGE", "type": "IMAGE", "slot_index": 0, "links": [ 9 ] } ], "properties": { "Node name for S&R": "VAEDecode" }, "widgets_values": [] }, { "id": 7, "type": "SaveImage", "pos": [ 1720, 300 ], "size": [ 280, 90 ], "flags": {}, "order": 7, "mode": 0, "inputs": [ { "name": "images", "type": "IMAGE", "link": 9 } ], "outputs": [], "properties": { "Node name for S&R": "SaveImage" }, "widgets_values": [ "ComfyUI" ] } ], "links": [ [ 1, 1, 0, 5, 0, "MODEL" ], [ 2, 1, 1, 2, 0, "CLIP" ], [ 3, 1, 1, 3, 0, "CLIP" ], [ 4, 2, 0, 5, 1, "CONDITIONING" ], [ 5, 3, 0, 5, 2, "CONDITIONING" ], [ 6, 4, 0, 5, 3, "LATENT" ], [ 7, 5, 0, 6, 0, "LATENT" ], [ 8, 1, 2, 6, 1, "VAE" ], [ 9, 6, 0, 7, 0, "IMAGE" ] ], "groups": [], "config": {}, "extra": {}, "version": 0.4 } ``` ### noobai_vpred.json ```json { "id": "368df941-d02c-424b-8afe-7468493f236f", "revision": 0, "last_node_id": 9, "last_link_id": 11, "nodes": [ { "id": 1, "type": "CheckpointLoaderSimple", "pos": [ 40, 320 ], "size": [ 280, 90 ], "flags": {}, "order": 1, "mode": 0, "inputs": [], "outputs": [ { "name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [ 1 ] }, { "name": "CLIP", "type": "CLIP", "slot_index": 0, "links": [ 4, 5 ] }, { "name": "VAE", "type": "VAE", "slot_index": 0, "links": [ 10 ] } ], "properties": { "Node name for S&R": "CheckpointLoaderSimple" }, "widgets_values": [ "REPLACE_WITH_DOWNLOADED_NOOBAI_VPRED_FILENAME.safetensors" ] }, { "id": 2, "type": "ModelSamplingDiscrete", "pos": [ 380, 320 ], "size": [ 280, 90 ], "flags": {}, "order": 2, "mode": 0, "inputs": [ { "name": "model", "type": "MODEL", "link": 1 } ], "outputs": [ { "name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [ 2 ] } ], "properties": { "Node name for S&R": "ModelSamplingDiscrete" }, "widgets_values": [ "v_prediction", true ] }, { "id": 3, "type": "RescaleCFG", "pos": [ 700, 320 ], "size": [ 280, 90 ], "flags": {}, "order": 3, "mode": 0, "inputs": [ { "name": "model", "type": "MODEL", "link": 2 } ], "outputs": [ { "name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [ 3 ] } ], "properties": { "Node name for S&R": "RescaleCFG" }, "widgets_values": [ 0.7 ] }, { "id": 4, "type": "CLIPTextEncode", "pos": [ 380, 60 ], "size": [ 280, 90 ], "flags": {}, "order": 4, "mode": 0, "inputs": [ { "name": "clip", "type": "CLIP", "link": 4 } ], "outputs": [ { "name": "CONDITIONING", "type": "CONDITIONING", "slot_index": 0, "links": [ 6 ] } ], "properties": { "Node name for S&R": "CLIPTextEncode" }, "widgets_values": [ "masterpiece, best quality, very awa, 1girl, solo, long hair, school uniform, classroom" ], "title": "Positive" }, { "id": 5, "type": "CLIPTextEncode", "pos": [ 380, 560 ], "size": [ 280, 90 ], "flags": {}, "order": 5, "mode": 0, "inputs": [ { "name": "clip", "type": "CLIP", "link": 5 } ], "outputs": [ { "name": "CONDITIONING", "type": "CONDITIONING", "slot_index": 0, "links": [ 7 ] } ], "properties": { "Node name for S&R": "CLIPTextEncode" }, "widgets_values": [ "nsfw, worst quality, old, early, low quality, lowres, signature, username, logo, bad hands, mutated hands, mammal, anthro, furry, ambiguous form, feral, semi-anthro" ], "title": "Negative" }, { "id": 6, "type": "EmptyLatentImage", "pos": [ 700, 560 ], "size": [ 280, 90 ], "flags": {}, "order": 6, "mode": 0, "inputs": [], "outputs": [ { "name": "LATENT", "type": "LATENT", "slot_index": 0, "links": [ 8 ] } ], "properties": { "Node name for S&R": "EmptyLatentImage" }, "widgets_values": [ 1024, 1024, 1 ] }, { "id": 7, "type": "KSampler", "pos": [ 1040, 300 ], "size": [ 280, 90 ], "flags": {}, "order": 7, "mode": 0, "inputs": [ { "name": "model", "type": "MODEL", "link": 3 }, { "name": "positive", "type": "CONDITIONING", "link": 6 }, { "name": "negative", "type": "CONDITIONING", "link": 7 }, { "name": "latent_image", "type": "LATENT", "link": 8 } ], "outputs": [ { "name": "LATENT", "type": "LATENT", "slot_index": 0, "links": [ 9 ] } ], "properties": { "Node name for S&R": "KSampler" }, "widgets_values": [ 42, "fixed", 32, 4.5, "euler", "normal", 1.0 ] }, { "id": 8, "type": "VAEDecode", "pos": [ 1400, 300 ], "size": [ 280, 90 ], "flags": {}, "order": 8, "mode": 0, "inputs": [ { "name": "samples", "type": "LATENT", "link": 9 }, { "name": "vae", "type": "VAE", "link": 10 } ], "outputs": [ { "name": "IMAGE", "type": "IMAGE", "slot_index": 0, "links": [ 11 ] } ], "properties": { "Node name for S&R": "VAEDecode" }, "widgets_values": [] }, { "id": 9, "type": "SaveImage", "pos": [ 1720, 300 ], "size": [ 280, 90 ], "flags": {}, "order": 9, "mode": 0, "inputs": [ { "name": "images", "type": "IMAGE", "link": 11 } ], "outputs": [], "properties": { "Node name for S&R": "SaveImage" }, "widgets_values": [ "noobai_vpred" ] } ], "links": [ [ 1, 1, 0, 2, 0, "MODEL" ], [ 2, 2, 0, 3, 0, "MODEL" ], [ 3, 3, 0, 7, 0, "MODEL" ], [ 4, 1, 1, 4, 0, "CLIP" ], [ 5, 1, 1, 5, 0, "CLIP" ], [ 6, 4, 0, 7, 1, "CONDITIONING" ], [ 7, 5, 0, 7, 2, "CONDITIONING" ], [ 8, 6, 0, 7, 3, "LATENT" ], [ 9, 7, 0, 8, 0, "LATENT" ], [ 10, 1, 2, 8, 1, "VAE" ], [ 11, 8, 0, 9, 0, "IMAGE" ] ], "groups": [], "config": {}, "extra": {}, "version": 0.4 } ``` ### noobai_vpred_api.json ```json { "10": { "class_type": "ModelSamplingDiscrete", "inputs": { "model": [ "4", 0 ], "sampling": "v_prediction", "zsnr": true } }, "11": { "class_type": "RescaleCFG", "inputs": { "model": [ "10", 0 ], "multiplier": 0.7 } }, "3": { "class_type": "KSampler", "inputs": { "cfg": 4.5, "denoise": 1.0, "latent_image": [ "5", 0 ], "model": [ "11", 0 ], "negative": [ "7", 0 ], "positive": [ "6", 0 ], "sampler_name": "euler", "scheduler": "normal", "seed": 42, "steps": 32 } }, "4": { "class_type": "CheckpointLoaderSimple", "inputs": { "ckpt_name": "REPLACE_WITH_DOWNLOADED_NOOBAI_VPRED_FILENAME.safetensors" } }, "5": { "class_type": "EmptyLatentImage", "inputs": { "batch_size": 1, "height": 1024, "width": 1024 } }, "6": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "4", 1 ], "text": "masterpiece, best quality, very awa, 1girl, solo, long hair, school uniform, classroom" } }, "7": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "4", 1 ], "text": "nsfw, worst quality, old, early, low quality, lowres, signature, username, logo, bad hands, mutated hands, mammal, anthro, furry, ambiguous form, feral, semi-anthro" } }, "8": { "class_type": "VAEDecode", "inputs": { "samples": [ "3", 0 ], "vae": [ "4", 2 ] } }, "9": { "class_type": "SaveImage", "inputs": { "filename_prefix": "noobai_vpred", "images": [ "8", 0 ] } } } ``` ### wai_nsfw_api.json ```json { "3": { "class_type": "KSampler", "inputs": { "cfg": 6, "denoise": 1.0, "latent_image": [ "5", 0 ], "model": [ "4", 0 ], "negative": [ "7", 0 ], "positive": [ "6", 0 ], "sampler_name": "euler_ancestral", "scheduler": "normal", "seed": 42, "steps": 30 } }, "4": { "class_type": "CheckpointLoaderSimple", "inputs": { "ckpt_name": "REPLACE_WITH_DOWNLOADED_WAI_NSFW_FILENAME.safetensors" } }, "5": { "class_type": "EmptyLatentImage", "inputs": { "batch_size": 1, "height": 1024, "width": 1024 } }, "6": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "4", 1 ], "text": "masterpiece, best quality, [SCENE TAGS HERE]" } }, "7": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "4", 1 ], "text": "lowres, worst quality, low quality, bad anatomy, bad hands, mutated hands, extra digits, fewer digits, watermark, signature, blurry" } }, "8": { "class_type": "VAEDecode", "inputs": { "samples": [ "3", 0 ], "vae": [ "4", 2 ] } }, "9": { "class_type": "SaveImage", "inputs": { "filename_prefix": "wai_nsfw", "images": [ "8", 0 ] } } } ``` ## 付録C: パートCで使うファイル 中継API (`relay_api.py`) は付録Bと同一 (変更なし)。 ### noobai_vpred_api.json ```json { "10": { "class_type": "ModelSamplingDiscrete", "inputs": { "model": [ "4", 0 ], "sampling": "v_prediction", "zsnr": true } }, "11": { "class_type": "RescaleCFG", "inputs": { "model": [ "10", 0 ], "multiplier": 0.7 } }, "3": { "class_type": "KSampler", "inputs": { "cfg": 4.5, "denoise": 1.0, "latent_image": [ "5", 0 ], "model": [ "13", 0 ], "negative": [ "7", 0 ], "positive": [ "6", 0 ], "sampler_name": "euler", "scheduler": "normal", "seed": 42, "steps": 32 } }, "4": { "class_type": "CheckpointLoaderSimple", "inputs": { "ckpt_name": "NoobAI-XL-Vpred-v1.0.safetensors" } }, "5": { "class_type": "EmptyLatentImage", "inputs": { "batch_size": 1, "height": 1024, "width": 1024 } }, "6": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "13", 1 ], "text": "masterpiece, best quality, very awa, 1girl, solo, long hair, school uniform, classroom" } }, "7": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "13", 1 ], "text": "nsfw, worst quality, old, early, low quality, lowres, signature, username, logo, bad hands, mutated hands, mammal, anthro, furry, ambiguous form, feral, semi-anthro" } }, "8": { "class_type": "VAEDecode", "inputs": { "samples": [ "3", 0 ], "vae": [ "4", 2 ] } }, "9": { "class_type": "SaveImage", "inputs": { "filename_prefix": "noobai_vpred", "images": [ "8", 0 ] } }, "12": { "class_type": "LoraLoader", "inputs": { "model": [ "11", 0 ], "clip": [ "4", 1 ], "lora_name": "add-detail-xl.safetensors", "strength_model": 1.0, "strength_clip": 1.0 } }, "13": { "class_type": "LoraLoader", "inputs": { "model": [ "12", 0 ], "clip": [ "12", 1 ], "lora_name": "Outlined_IL.safetensors", "strength_model": 1.0, "strength_clip": 1.0 } } } ``` ### wai_nsfw_api.json ```json { "3": { "class_type": "KSampler", "inputs": { "cfg": 6, "denoise": 1.0, "latent_image": [ "5", 0 ], "model": [ "11", 0 ], "negative": [ "7", 0 ], "positive": [ "6", 0 ], "sampler_name": "euler_ancestral", "scheduler": "normal", "seed": 42, "steps": 30 } }, "4": { "class_type": "CheckpointLoaderSimple", "inputs": { "ckpt_name": "waiIllustriousSDXL_v170.safetensors" } }, "5": { "class_type": "EmptyLatentImage", "inputs": { "batch_size": 1, "height": 1024, "width": 1024 } }, "6": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "11", 1 ], "text": "masterpiece, best quality, [SCENE TAGS HERE]" } }, "7": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "11", 1 ], "text": "lowres, worst quality, low quality, bad anatomy, bad hands, mutated hands, extra digits, fewer digits, watermark, signature, blurry" } }, "8": { "class_type": "VAEDecode", "inputs": { "samples": [ "3", 0 ], "vae": [ "4", 2 ] } }, "9": { "class_type": "SaveImage", "inputs": { "filename_prefix": "wai_nsfw", "images": [ "8", 0 ] } }, "10": { "class_type": "LoraLoader", "inputs": { "model": [ "4", 0 ], "clip": [ "4", 1 ], "lora_name": "add-detail-xl.safetensors", "strength_model": 1.0, "strength_clip": 1.0 } }, "11": { "class_type": "LoraLoader", "inputs": { "model": [ "10", 0 ], "clip": [ "10", 1 ], "lora_name": "Outlined_IL.safetensors", "strength_model": 1.0, "strength_clip": 1.0 } } } ``` ### noobai_vpred_lora.json ```json { "id": "5e8a2c14-6b7d-4f09-9a3c-1d2e3f405162", "revision": 0, "last_node_id": 11, "last_link_id": 15, "nodes": [ { "id": 1, "type": "CheckpointLoaderSimple", "pos": [ 40, 320 ], "size": [ 280, 90 ], "flags": {}, "order": 1, "mode": 0, "inputs": [], "outputs": [ { "name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [ 1 ] }, { "name": "CLIP", "type": "CLIP", "slot_index": 0, "links": [ 4 ] }, { "name": "VAE", "type": "VAE", "slot_index": 0, "links": [ 14 ] } ], "properties": { "Node name for S&R": "CheckpointLoaderSimple" }, "widgets_values": [ "NoobAI-XL-Vpred-v1.0.safetensors" ] }, { "id": 2, "type": "ModelSamplingDiscrete", "pos": [ 380, 320 ], "size": [ 280, 90 ], "flags": {}, "order": 2, "mode": 0, "inputs": [ { "name": "model", "type": "MODEL", "link": 1 } ], "outputs": [ { "name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [ 2 ] } ], "properties": { "Node name for S&R": "ModelSamplingDiscrete" }, "widgets_values": [ "v_prediction", true ] }, { "id": 3, "type": "RescaleCFG", "pos": [ 700, 320 ], "size": [ 280, 90 ], "flags": {}, "order": 3, "mode": 0, "inputs": [ { "name": "model", "type": "MODEL", "link": 2 } ], "outputs": [ { "name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [ 3 ] } ], "properties": { "Node name for S&R": "RescaleCFG" }, "widgets_values": [ 0.7 ] }, { "id": 10, "type": "LoraLoader", "pos": [ 1020, 60 ], "size": [ 280, 130 ], "flags": {}, "order": 4, "mode": 0, "inputs": [ { "name": "model", "type": "MODEL", "link": 3 }, { "name": "clip", "type": "CLIP", "link": 4 } ], "outputs": [ { "name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [ 5 ] }, { "name": "CLIP", "type": "CLIP", "slot_index": 0, "links": [ 6 ] } ], "properties": { "Node name for S&R": "LoraLoader" }, "widgets_values": [ "add-detail-xl.safetensors", 1.0, 1.0 ], "title": "LoRA 1: add-detail-xl.safetensors" }, { "id": 11, "type": "LoraLoader", "pos": [ 1360, 60 ], "size": [ 280, 130 ], "flags": {}, "order": 5, "mode": 0, "inputs": [ { "name": "model", "type": "MODEL", "link": 5 }, { "name": "clip", "type": "CLIP", "link": 6 } ], "outputs": [ { "name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [ 9 ] }, { "name": "CLIP", "type": "CLIP", "slot_index": 0, "links": [ 7, 8 ] } ], "properties": { "Node name for S&R": "LoraLoader" }, "widgets_values": [ "Outlined_IL.safetensors", 1.0, 1.0 ], "title": "LoRA 2: Outlined_IL.safetensors" }, { "id": 4, "type": "CLIPTextEncode", "pos": [ 380, 560 ], "size": [ 280, 90 ], "flags": {}, "order": 6, "mode": 0, "inputs": [ { "name": "clip", "type": "CLIP", "link": 7 } ], "outputs": [ { "name": "CONDITIONING", "type": "CONDITIONING", "slot_index": 0, "links": [ 10 ] } ], "properties": { "Node name for S&R": "CLIPTextEncode" }, "widgets_values": [ "masterpiece, best quality, very awa, 1girl, solo, long hair, school uniform, classroom" ], "title": "Positive" }, { "id": 5, "type": "CLIPTextEncode", "pos": [ 700, 560 ], "size": [ 280, 90 ], "flags": {}, "order": 7, "mode": 0, "inputs": [ { "name": "clip", "type": "CLIP", "link": 8 } ], "outputs": [ { "name": "CONDITIONING", "type": "CONDITIONING", "slot_index": 0, "links": [ 11 ] } ], "properties": { "Node name for S&R": "CLIPTextEncode" }, "widgets_values": [ "nsfw, worst quality, old, early, low quality, lowres, signature, username, logo, bad hands, mutated hands, mammal, anthro, furry, ambiguous form, feral, semi-anthro" ], "title": "Negative" }, { "id": 6, "type": "EmptyLatentImage", "pos": [ 1020, 560 ], "size": [ 280, 90 ], "flags": {}, "order": 8, "mode": 0, "inputs": [], "outputs": [ { "name": "LATENT", "type": "LATENT", "slot_index": 0, "links": [ 12 ] } ], "properties": { "Node name for S&R": "EmptyLatentImage" }, "widgets_values": [ 832, 1216, 1 ] }, { "id": 7, "type": "KSampler", "pos": [ 1500, 300 ], "size": [ 280, 90 ], "flags": {}, "order": 9, "mode": 0, "inputs": [ { "name": "model", "type": "MODEL", "link": 9 }, { "name": "positive", "type": "CONDITIONING", "link": 10 }, { "name": "negative", "type": "CONDITIONING", "link": 11 }, { "name": "latent_image", "type": "LATENT", "link": 12 } ], "outputs": [ { "name": "LATENT", "type": "LATENT", "slot_index": 0, "links": [ 13 ] } ], "properties": { "Node name for S&R": "KSampler" }, "widgets_values": [ 42, "fixed", 32, 4.5, "euler", "normal", 1.0 ] }, { "id": 8, "type": "VAEDecode", "pos": [ 1840, 300 ], "size": [ 280, 90 ], "flags": {}, "order": 10, "mode": 0, "inputs": [ { "name": "samples", "type": "LATENT", "link": 13 }, { "name": "vae", "type": "VAE", "link": 14 } ], "outputs": [ { "name": "IMAGE", "type": "IMAGE", "slot_index": 0, "links": [ 15 ] } ], "properties": { "Node name for S&R": "VAEDecode" }, "widgets_values": [] }, { "id": 9, "type": "SaveImage", "pos": [ 2180, 300 ], "size": [ 280, 90 ], "flags": {}, "order": 11, "mode": 0, "inputs": [ { "name": "images", "type": "IMAGE", "link": 15 } ], "outputs": [], "properties": { "Node name for S&R": "SaveImage" }, "widgets_values": [ "noobai_vpred_lora" ] } ], "links": [ [ 1, 1, 0, 2, 0, "MODEL" ], [ 2, 2, 0, 3, 0, "MODEL" ], [ 3, 3, 0, 10, 0, "MODEL" ], [ 4, 1, 1, 10, 1, "CLIP" ], [ 5, 10, 0, 11, 0, "MODEL" ], [ 6, 10, 1, 11, 1, "CLIP" ], [ 7, 11, 1, 4, 0, "CLIP" ], [ 8, 11, 1, 5, 0, "CLIP" ], [ 9, 11, 0, 7, 0, "MODEL" ], [ 10, 4, 0, 7, 1, "CONDITIONING" ], [ 11, 5, 0, 7, 2, "CONDITIONING" ], [ 12, 6, 0, 7, 3, "LATENT" ], [ 13, 7, 0, 8, 0, "LATENT" ], [ 14, 1, 2, 8, 1, "VAE" ], [ 15, 8, 0, 9, 0, "IMAGE" ] ], "groups": [], "config": {}, "extra": {}, "version": 0.4 } ``` ### wai_nsfw_lora.json ```json { "id": "5e8a2c14-6b7d-4f09-9a3c-1d2e3f405162", "revision": 0, "last_node_id": 11, "last_link_id": 13, "nodes": [ { "id": 1, "type": "CheckpointLoaderSimple", "pos": [ 40, 320 ], "size": [ 280, 90 ], "flags": {}, "order": 1, "mode": 0, "inputs": [], "outputs": [ { "name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [ 1 ] }, { "name": "CLIP", "type": "CLIP", "slot_index": 0, "links": [ 2 ] }, { "name": "VAE", "type": "VAE", "slot_index": 0, "links": [ 12 ] } ], "properties": { "Node name for S&R": "CheckpointLoaderSimple" }, "widgets_values": [ "waiIllustriousSDXL_v170.safetensors" ] }, { "id": 10, "type": "LoraLoader", "pos": [ 380, 60 ], "size": [ 280, 130 ], "flags": {}, "order": 2, "mode": 0, "inputs": [ { "name": "model", "type": "MODEL", "link": 1 }, { "name": "clip", "type": "CLIP", "link": 2 } ], "outputs": [ { "name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [ 3 ] }, { "name": "CLIP", "type": "CLIP", "slot_index": 0, "links": [ 4 ] } ], "properties": { "Node name for S&R": "LoraLoader" }, "widgets_values": [ "add-detail-xl.safetensors", 1.0, 1.0 ], "title": "LoRA 1: add-detail-xl.safetensors" }, { "id": 11, "type": "LoraLoader", "pos": [ 720, 60 ], "size": [ 280, 130 ], "flags": {}, "order": 3, "mode": 0, "inputs": [ { "name": "model", "type": "MODEL", "link": 3 }, { "name": "clip", "type": "CLIP", "link": 4 } ], "outputs": [ { "name": "MODEL", "type": "MODEL", "slot_index": 0, "links": [ 7 ] }, { "name": "CLIP", "type": "CLIP", "slot_index": 0, "links": [ 5, 6 ] } ], "properties": { "Node name for S&R": "LoraLoader" }, "widgets_values": [ "Outlined_IL.safetensors", 1.0, 1.0 ], "title": "LoRA 2: Outlined_IL.safetensors" }, { "id": 4, "type": "CLIPTextEncode", "pos": [ 380, 560 ], "size": [ 280, 90 ], "flags": {}, "order": 4, "mode": 0, "inputs": [ { "name": "clip", "type": "CLIP", "link": 5 } ], "outputs": [ { "name": "CONDITIONING", "type": "CONDITIONING", "slot_index": 0, "links": [ 8 ] } ], "properties": { "Node name for S&R": "CLIPTextEncode" }, "widgets_values": [ "masterpiece, best quality, 1girl, solo, long hair, school uniform, classroom, window light" ], "title": "Positive" }, { "id": 5, "type": "CLIPTextEncode", "pos": [ 700, 560 ], "size": [ 280, 90 ], "flags": {}, "order": 5, "mode": 0, "inputs": [ { "name": "clip", "type": "CLIP", "link": 6 } ], "outputs": [ { "name": "CONDITIONING", "type": "CONDITIONING", "slot_index": 0, "links": [ 9 ] } ], "properties": { "Node name for S&R": "CLIPTextEncode" }, "widgets_values": [ "lowres, worst quality, low quality, bad anatomy, bad hands, mutated hands, extra digits, fewer digits, watermark, signature, blurry" ], "title": "Negative" }, { "id": 6, "type": "EmptyLatentImage", "pos": [ 1020, 560 ], "size": [ 280, 90 ], "flags": {}, "order": 6, "mode": 0, "inputs": [], "outputs": [ { "name": "LATENT", "type": "LATENT", "slot_index": 0, "links": [ 10 ] } ], "properties": { "Node name for S&R": "EmptyLatentImage" }, "widgets_values": [ 832, 1216, 1 ] }, { "id": 7, "type": "KSampler", "pos": [ 1500, 300 ], "size": [ 280, 90 ], "flags": {}, "order": 7, "mode": 0, "inputs": [ { "name": "model", "type": "MODEL", "link": 7 }, { "name": "positive", "type": "CONDITIONING", "link": 8 }, { "name": "negative", "type": "CONDITIONING", "link": 9 }, { "name": "latent_image", "type": "LATENT", "link": 10 } ], "outputs": [ { "name": "LATENT", "type": "LATENT", "slot_index": 0, "links": [ 11 ] } ], "properties": { "Node name for S&R": "KSampler" }, "widgets_values": [ 42, "fixed", 30, 6, "euler_ancestral", "normal", 1.0 ] }, { "id": 8, "type": "VAEDecode", "pos": [ 1840, 300 ], "size": [ 280, 90 ], "flags": {}, "order": 8, "mode": 0, "inputs": [ { "name": "samples", "type": "LATENT", "link": 11 }, { "name": "vae", "type": "VAE", "link": 12 } ], "outputs": [ { "name": "IMAGE", "type": "IMAGE", "slot_index": 0, "links": [ 13 ] } ], "properties": { "Node name for S&R": "VAEDecode" }, "widgets_values": [] }, { "id": 9, "type": "SaveImage", "pos": [ 2180, 300 ], "size": [ 280, 90 ], "flags": {}, "order": 9, "mode": 0, "inputs": [ { "name": "images", "type": "IMAGE", "link": 13 } ], "outputs": [], "properties": { "Node name for S&R": "SaveImage" }, "widgets_values": [ "wai_nsfw_lora" ] } ], "links": [ [ 1, 1, 0, 10, 0, "MODEL" ], [ 2, 1, 1, 10, 1, "CLIP" ], [ 3, 10, 0, 11, 0, "MODEL" ], [ 4, 10, 1, 11, 1, "CLIP" ], [ 5, 11, 1, 4, 0, "CLIP" ], [ 6, 11, 1, 5, 0, "CLIP" ], [ 7, 11, 0, 7, 0, "MODEL" ], [ 8, 4, 0, 7, 1, "CONDITIONING" ], [ 9, 5, 0, 7, 2, "CONDITIONING" ], [ 10, 6, 0, 7, 3, "LATENT" ], [ 11, 7, 0, 8, 0, "LATENT" ], [ 12, 1, 2, 8, 1, "VAE" ], [ 13, 8, 0, 9, 0, "IMAGE" ] ], "groups": [], "config": {}, "extra": {}, "version": 0.4 } ``` ## 付録D: パートDで使うファイル ワークフローJSONは付録Cと同一 (変更なし)。 ### relay_api.py ```python #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ relay_api.py - Muse -> ComfyUI 限定中継API (実装案 v2) 設計方針: - ComfyUI本体は 127.0.0.1:8188 で待ち受け、この中継APIだけがPCのTailscale IPで待ち受ける。 - 受け付けるのは、PC側に保存された承認済みワークフロー (API形式JSON) の実行のみ。 - 呼び出し側が変更できるのは prompt / negative_prompt / seed / 解像度プリセットのみ。 - 追加で、任意項目 lora (キャラLoRA) を1つだけ指定できる。ただし指定できるのは PC側の許可リスト (ワークフローフォルダー内の lora_allowlist.json) に登録された ファイル名と完全一致する場合のみで、強さはリスト側の範囲にクランプされる。 挿入位置はサーバー側で固定する (KSampler直前 = 常駐LoRAチェーンの末尾) ため、 呼び出し側が並び替えたり複数積んだりすることはできない。許可リストに無い名前は 400。 - ワークフローJSON、ノード、ファイルパス、URL、コマンド類は一切受け付けない (未知のキーは 400)。 - 画像取得は、この中継APIが発行したジョブの生成物だけに限定する (ジョブIDはUUID、実ファイル名は外部に出さない)。 - ComfyUIの任意APIへの汎用プロキシにはしない (内部で使うのは /prompt, /history, /view, /system_stats, /queue の固定用途のみ。/queue は自ジョブの状態確認と、待機中ジョブの prompt_id指定delete専用)。 - 実行中ジョブの自動割り込みは行わない。ComfyUIの /interrupt は対象を指定できない 全体割り込みであり、直前のID確認だけでは「確認後に別ジョブへ交代していた」場合の 誤停止を防げないため。実行中ジョブがタイムアウトした場合は状態不明として投入を止め、 運用者の確認 (GUIでの停止または完了待ち) と resume による再開に委ねる。 - 同時実行は1件。待ち行列は上限付きキューで管理し、超過は 429 (競合時も上限を超えない)。 - ComfyUI側の処理状態が不明になったジョブがある間は、新規投入を停止する (503)。 復旧は、対象ジョブの終了・取消が確認できた場合に自動で行い、照合できない場合は PC側コンソールでの手動 resume (運用者の確認) が必要。 - ジョブ記録には保持期限と件数上限があり、完了したジョブのプロンプトは記録から即時除去する。 - 生成画像ファイル自体はComfyUIの output フォルダに残る (ComfyUI標準APIにファイル削除機能が無いため、 中継APIはファイルを削除しない。容量管理は利用者側で行い、中継API経由の生成物は filename_prefix = relay_ で識別できる)。 - Python標準ライブラリのみで動作する。ComfyUI Portable同梱の python_embeded で実行する想定。 - 管理者権限は不要。通常ユーザーで手動起動する。 起動時の「構造検証」について: 起動時に検査するのは次の範囲のみであり、任意のワークフローの安全性や生成結果の正しさを 判定するものではない。 - JSONとして読み込めること - KSampler がちょうど1つ存在すること - KSampler の positive / negative の接続先が CLIPTextEncode、latent_image の接続先が EmptyLatentImage であること - ローダーが次のどちらか一方の系統であること (S) CheckpointLoaderSimple が存在し、ckpt_name がプレースホルダではなく .safetensors で終わること (A) UNETLoader / CLIPLoader / VAELoader がちょうど1つずつ存在し (Anima 系の 分割ローダー構成)、各ファイル名が設定済みで、model / clip の共有リンクを たどると各ローダーに行き着き、VAEDecode の vae が VAELoader の出力であること - SaveImage が存在すること 承認済みかどうかは、この検証ではなく「どのファイルを C:\\AI\\workflows に配置したか」という 配置管理で担保する。 使い方 (PC側): python relay_api.py --host 100.x.y.z --port 8190 --workflows-dir C:\\AI\\workflows --host は原則としてTailscale帯域 (100.64.0.0/10) のIPのみ許可する。 ローカル単体テスト用に 127.0.0.1 を使う場合だけ --local-test を付ける。 コンソールコマンド (中継APIを起動したコンソールで入力): status : 投入ゲートの状態とジョブ件数を表示する resume : 状態不明により停止中の投入を再開する。ComfyUIのキュー状態を確認でき、 かつキューが空の場合のみ再開する。状態を確認できない場合は停止を維持する resume force : キューに残件があることを運用者が確認済みの場合に再開する (状態確認自体ができない場合は force でも再開しない) """ import argparse import copy import ipaddress import json import os import queue import secrets import socket import sys import threading import time import urllib.error import urllib.parse import urllib.request import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # ---------------------------------------------------------------- 設定 (承認済みリスト) # ここに無いワークフローID・解像度は受け付けない。 WORKFLOW_FILES = { "noobai_vpred": "noobai_vpred_api.json", # 本命: NoobAI-XL V-Pred 1.0 "wai_nsfw": "wai_nsfw_api.json", # 予備: WAI-NSFW-illustrious-SDXL # Part F: Anima 系 (分割ローダー構成、単段 txt2img) } # NoobAI公式ガイドの推奨解像度 (W x H) のみを許可する。 RESOLUTION_PRESETS = { "768x1344": (768, 1344), "832x1216": (832, 1216), "896x1152": (896, 1152), "1024x1024": (1024, 1024), "1152x896": (1152, 896), "1216x832": (1216, 832), "1344x768": (1344, 768), } MAX_PROMPT_CHARS = 2000 MAX_BODY_BYTES = 64 * 1024 MAX_SEED = 2**64 - 1 ALLOWED_REQUEST_KEYS = {"workflow_id", "prompt", "negative_prompt", "seed", "resolution", "lora"} ALLOWED_LORA_KEYS = {"name", "strength"} LORA_ALLOWLIST_FILE = "lora_allowlist.json" # ワークフローフォルダー内に置く (任意。無ければ lora 指定は全て拒否) LORA_STRENGTH_HARD_MAX = 2.0 # 許可リスト自体の妥当性上限 PLACEHOLDER_MARK = "REPLACE_WITH" # ckpt_name がこの文字列を含むテンプレートは未設定として起動時に拒否する TERMINAL_STATES = {"succeeded", "failed", "unknown"} # ---------------------------------------------------------------- グローバル状態 CONFIG = {} TEMPLATES = {} # workflow_id -> (api_json_dict, node_map) LORA_ALLOWLIST = {} # loraファイル名 -> {"min": float, "max": float, "default": float} JOBS = {} # job_id -> job dict JOBS_LOCK = threading.Lock() JOB_QUEUE = None # main() で上限付きキューとして生成する GATE = {"closed": False, "reason": None} # ComfyUI側の状態不明時に閉じる投入ゲート GATE_LOCK = threading.Lock() GATE_EVENT = threading.Event() # ワーカーの投入可否と連動させる (set = 開放) GATE_EVENT.set() CONN_SEM = None # main() で同時接続数上限のセマフォとして生成する def gate_close(reason): with GATE_LOCK: GATE["closed"] = True GATE["reason"] = reason GATE_EVENT.clear() print(f"[relay] 投入を停止しました: {reason}") print("[relay] ComfyUI側のキューを確認し、対象ジョブの終了・取消を確認してから、コンソールで resume と入力してください") def gate_open(): with GATE_LOCK: GATE["closed"] = False GATE["reason"] = None GATE_EVENT.set() print("[relay] 投入を再開しました") def gate_is_closed(): with GATE_LOCK: return GATE["closed"], GATE["reason"] class ApiError(Exception): def __init__(self, status, code, message): super().__init__(message) self.status = status self.code = code self.message = message class ComfyLostContact(Exception): """ポーリング中にComfyUIとの通信が継続的に失敗した (状態不明の可能性)。""" class ComfyStateError(Exception): """ComfyUIの状態応答 (/queue) の形式が不正で、状態を確認できない。""" class SubmitOutcomeUnknown(Exception): """ComfyUIへの投入の成否を判断できない (通信断・応答喪失・解釈できない応答)。""" class GenerationTimeout(Exception): """制限時間内に完了しなかった (ComfyUI側に自ジョブが残っている可能性)。""" # ---------------------------------------------------------------- テンプレート検証 def load_templates(workflows_dir): """承認済みワークフローを読み込み、冒頭に記載した範囲の構造検証を行う。不正なら起動を中止する。""" for workflow_id, filename in WORKFLOW_FILES.items(): path = os.path.join(workflows_dir, filename) try: with open(path, "r", encoding="utf-8") as f: wf = json.load(f) except FileNotFoundError: raise SystemExit(f"[relay] ワークフローファイルが見つかりません: {path}") except json.JSONDecodeError as e: raise SystemExit(f"[relay] ワークフローJSONが不正です: {path}: {e}") if not isinstance(wf, dict) or not wf: raise SystemExit(f"[relay] ワークフローが空、または形式が不正です: {path}") def norm(link): """リンクを [str(node_id), output_index] に正規化する。不正な形式は None。""" if isinstance(link, list) and len(link) == 2: return [str(link[0]), link[1]] return None def latent_target(nid): link = wf[nid].get("inputs", {}).get("latent_image") n = norm(link) return n[0] if n and n[0] in wf else None all_ksamplers = [nid for nid, n in wf.items() if isinstance(n, dict) and n.get("class_type") == "KSampler"] # メインの KSampler は latent_image が EmptyLatentImage に接続するもの。 # Hires fix の第2パス (任意・最大1つ) はメインと区別して検証する。 mains = [nid for nid in all_ksamplers if latent_target(nid) is not None and wf[latent_target(nid)].get("class_type") == "EmptyLatentImage"] if len(mains) != 1: raise SystemExit( f"[relay] {filename}: EmptyLatentImage に接続するメインの KSampler が" f"ちょうど1つ必要です (検出: {len(mains)})") ks_id = mains[0] others = [nid for nid in all_ksamplers if nid != ks_id] if len(others) > 1: raise SystemExit( f"[relay] {filename}: KSampler はメイン + Hires の最大2つまでです" f" (検出: {len(all_ksamplers)})") ks_inputs = wf[ks_id].get("inputs", {}) def linked_node(input_name, expect_class): link = ks_inputs.get(input_name) if not (isinstance(link, list) and len(link) == 2 and str(link[0]) in wf): raise SystemExit(f"[relay] {filename}: KSampler.{input_name} の接続が不正です") nid = str(link[0]) if wf[nid].get("class_type") != expect_class: raise SystemExit(f"[relay] {filename}: KSampler.{input_name} の接続先は {expect_class} である必要があります") return nid node_map = { "ksampler": ks_id, "positive": linked_node("positive", "CLIPTextEncode"), "negative": linked_node("negative", "CLIPTextEncode"), "latent": linked_node("latent_image", "EmptyLatentImage"), } # LoRAチェーン末尾の検証 (lora パラメータ指定時の挿入位置を確定させるため)。 # MODEL側は KSampler.model の接続元、CLIP側は positive / negative 両エンコーダの # clip 接続元で、両エンコーダが同じ接続元を共有していることまで確認する # (共有していないワークフローには単一の LoraLoader を正しく挿入できない)。 def any_link(node_id, input_name): link = wf[node_id].get("inputs", {}).get(input_name) if not (isinstance(link, list) and len(link) == 2 and str(link[0]) in wf): raise SystemExit(f"[relay] {filename}: ノード {node_id} の {input_name} 接続が不正です") return link node_map["model_link"] = any_link(ks_id, "model") pos_clip = any_link(node_map["positive"], "clip") neg_clip = any_link(node_map["negative"], "clip") if pos_clip != neg_clip: raise SystemExit( f"[relay] {filename}: positive / negative の clip 接続元が一致しません" " (lora パラメータの挿入位置を確定できません)") node_map["clip_link"] = pos_clip # ローダーは2系統のどちらか一方であること (混在は認めない)。 # 系統S (SDXL): CheckpointLoaderSimple。系統A (Anima 等): UNETLoader / # CLIPLoader / VAELoader の分割構成で、model / clip の共有リンクを # LoraLoader 経由でたどると各ローダーに行き着くことまで確認する # (系統S の常駐チェーンと同じ考え方。lora パラメータの挿入位置を # 確定させるための要件)。 split_ids = {} for kind in ("UNETLoader", "CLIPLoader", "VAELoader"): split_ids[kind] = [nid for nid, n in wf.items() if isinstance(n, dict) and n.get("class_type") == kind] loaders = [n for n in wf.values() if isinstance(n, dict) and n.get("class_type") == "CheckpointLoaderSimple"] if any(split_ids.values()): if loaders: raise SystemExit( f"[relay] {filename}: CheckpointLoaderSimple と分割ローダーが" "混在しています") for kind, ids in split_ids.items(): if len(ids) != 1: raise SystemExit( f"[relay] {filename}: {kind} はちょうど1つ必要です" f" (検出: {len(ids)})") def loader_file(nid, key): v = str(wf[nid].get("inputs", {}).get(key, "")) if PLACEHOLDER_MARK in v or not v.endswith(".safetensors"): raise SystemExit( f"[relay] {filename}: {key} が未設定です ('{v}')。" "ダウンロードした実ファイル名に置き換えてください") return v unet_id = split_ids["UNETLoader"][0] clip_id = split_ids["CLIPLoader"][0] vae_id = split_ids["VAELoader"][0] loader_file(unet_id, "unet_name") loader_file(clip_id, "clip_name") loader_file(vae_id, "vae_name") def chain_root(link, input_key, want_index): """LoraLoader を input_key 側へたどり、終端リンクを返す。不正は None。""" cur = norm(link) for _ in range(len(wf) + 1): if cur is None or cur[0] not in wf: return None if wf[cur[0]].get("class_type") != "LoraLoader": return cur if cur[1] != want_index: return None cur = norm(wf[cur[0]].get("inputs", {}).get(input_key)) return None if chain_root(node_map["model_link"], "model", 0) != [unet_id, 0]: raise SystemExit( f"[relay] {filename}: model の共有リンクは UNETLoader の" "出力に接続してください") if chain_root(node_map["clip_link"], "clip", 1) != [clip_id, 0]: raise SystemExit( f"[relay] {filename}: clip の共有リンクは CLIPLoader の" "出力に接続してください") node_map["vae_link"] = [vae_id, 0] else: if not loaders: raise SystemExit(f"[relay] {filename}: CheckpointLoaderSimple がありません") for ld in loaders: ckpt = str(ld.get("inputs", {}).get("ckpt_name", "")) if PLACEHOLDER_MARK in ckpt or not ckpt.endswith(".safetensors"): raise SystemExit( f"[relay] {filename}: ckpt_name が未設定です ('{ckpt}')。" "ダウンロードした実ファイル名に置き換えてください") # Hires fix の第2パス (任意): メインと model / positive / negative の接続元を # 共有し、latent はメイン出力への LatentUpscale 系からのみ取ることを確認する # (プロンプト注入と lora 挿入が第2パスにも同じ条件で届くための要件)。 sampler_out = ks_id if others: hs_id = others[0] hs_inputs = wf[hs_id].get("inputs", {}) for key in ("model", "positive", "negative"): if norm(hs_inputs.get(key)) != norm(ks_inputs.get(key)): raise SystemExit( f"[relay] {filename}: Hires側 KSampler の {key} 接続が" "メインと一致しません") up_id = latent_target(hs_id) if up_id is None or wf[up_id].get("class_type") not in ( "LatentUpscaleBy", "LatentUpscale"): raise SystemExit( f"[relay] {filename}: Hires側 KSampler の latent_image は" " LatentUpscale 系に接続してください") up_samples = norm(wf[up_id].get("inputs", {}).get("samples")) if up_samples != [ks_id, 0]: raise SystemExit( f"[relay] {filename}: LatentUpscale 系の samples は" "メイン KSampler の出力に接続してください") node_map["hires"] = hs_id sampler_out = hs_id # VAEDecode はちょうど1つで、入力は最終サンプラー (Hires があればその出力)。 decoders = [nid for nid, n in wf.items() if isinstance(n, dict) and n.get("class_type") == "VAEDecode"] if len(decoders) != 1: raise SystemExit( f"[relay] {filename}: VAEDecode はちょうど1つ必要です (検出: {len(decoders)})") dec_id = decoders[0] if norm(wf[dec_id].get("inputs", {}).get("samples")) != [sampler_out, 0]: raise SystemExit( f"[relay] {filename}: VAEDecode の samples は最終サンプラーの" "出力に接続してください") if "vae_link" in node_map: if norm(wf[dec_id].get("inputs", {}).get("vae")) != node_map["vae_link"]: raise SystemExit( f"[relay] {filename}: VAEDecode の vae は VAELoader の" "出力に接続してください") # FaceDetailer (任意・最大1つ): model / clip は共有リンクと一致し、 # positive / negative はメイン両エンコーダの出力、image は VAEDecode の # 出力であること (再描画にもメインプロンプトとキャラLoRAが届くための要件)。 detailers = [nid for nid, n in wf.items() if isinstance(n, dict) and n.get("class_type") == "FaceDetailer"] if len(detailers) > 1: raise SystemExit( f"[relay] {filename}: FaceDetailer は最大1つまでです (検出: {len(detailers)})") final_image = [dec_id, 0] if detailers: fd_id = detailers[0] fd_inputs = wf[fd_id].get("inputs", {}) def fd_require(key, want, label): if norm(fd_inputs.get(key)) != want: raise SystemExit( f"[relay] {filename}: FaceDetailer の {key} 接続が不正です" f" ({label})") fd_require("model", norm(node_map["model_link"]), "model は共有リンクと一致") fd_require("clip", norm(node_map["clip_link"]), "clip は共有リンクと一致") fd_require("positive", [node_map["positive"], 0], "positive はメインのエンコーダ出力") fd_require("negative", [node_map["negative"], 0], "negative はメインのエンコーダ出力") fd_require("image", [dec_id, 0], "image は VAEDecode の出力") det = fd_inputs.get("bbox_detector") det_n = norm(det) if det_n is None or det_n[0] not in wf or \ wf[det_n[0]].get("class_type") != "UltralyticsDetectorProvider": raise SystemExit( f"[relay] {filename}: FaceDetailer の bbox_detector は" " UltralyticsDetectorProvider に接続してください") node_map["facedetailer"] = fd_id final_image = [fd_id, 0] savers = [n for n in wf.values() if isinstance(n, dict) and n.get("class_type") == "SaveImage"] if not savers: raise SystemExit(f"[relay] {filename}: SaveImage がありません") if not any(norm(n.get("inputs", {}).get("images")) == norm(final_image) for n in savers): raise SystemExit( f"[relay] {filename}: SaveImage の images は最終段" " (FaceDetailer または VAEDecode) の出力に接続してください") TEMPLATES[workflow_id] = (wf, node_map) print(f"[relay] ワークフローを登録しました: {workflow_id} ({filename})") def load_lora_allowlist(workflows_dir): """キャラLoRAの許可リスト (lora_allowlist.json) を読み込み、厳密に検証する。 ファイルが無い場合は許可リストは空 (lora 指定は全て unknown_lora で拒否) で起動する。 ファイルがあるのに形式が不正な場合は、黙って無効化せず起動を中止する (fail closed)。 形式: {"<ファイル名.safetensors>": {"min": 0.0, "max": 1.2, "default": 1.0}, ...} """ path = os.path.join(workflows_dir, LORA_ALLOWLIST_FILE) if not os.path.exists(path): print(f"[relay] {LORA_ALLOWLIST_FILE} が無いため、lora 指定は受け付けません") return try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) except json.JSONDecodeError as e: raise SystemExit(f"[relay] {LORA_ALLOWLIST_FILE} のJSONが不正です: {e}") if not isinstance(data, dict): raise SystemExit(f"[relay] {LORA_ALLOWLIST_FILE} はオブジェクト形式で記述してください") for name, entry in data.items(): if (not isinstance(name, str) or not name.endswith(".safetensors") or "/" in name or "\\" in name): raise SystemExit(f"[relay] {LORA_ALLOWLIST_FILE}: 不正なLoRAファイル名です: {name!r}") if not isinstance(entry, dict) or set(entry.keys()) != {"min", "max", "default"}: raise SystemExit( f"[relay] {LORA_ALLOWLIST_FILE}: {name} の項目は min / max / default の3キーで記述してください") vals = {} for k in ("min", "max", "default"): v = entry[k] if type(v) not in (int, float): # bool は数値として扱わない raise SystemExit(f"[relay] {LORA_ALLOWLIST_FILE}: {name}.{k} は数値で指定してください") vals[k] = float(v) if not (0.0 <= vals["min"] <= vals["default"] <= vals["max"] <= LORA_STRENGTH_HARD_MAX): raise SystemExit( f"[relay] {LORA_ALLOWLIST_FILE}: {name} は 0 <= min <= default <= max <= {LORA_STRENGTH_HARD_MAX} を満たしてください") LORA_ALLOWLIST[name] = vals print(f"[relay] LoRAを許可リストに登録しました: {name} " f"(min={vals['min']}, max={vals['max']}, default={vals['default']})") def build_prompt(job_id, workflow_id, prompt_text, negative_text, seed, preset_name, lora_spec=None): """テンプレートに許可されたパラメータだけを注入したワークフローJSONを返す。""" template, node_map = TEMPLATES[workflow_id] wf = copy.deepcopy(template) width, height = RESOLUTION_PRESETS[preset_name] wf[node_map["ksampler"]]["inputs"]["seed"] = seed wf[node_map["positive"]]["inputs"]["text"] = prompt_text if negative_text is not None: wf[node_map["negative"]]["inputs"]["text"] = negative_text latent = wf[node_map["latent"]]["inputs"] latent["width"] = width latent["height"] = height latent["batch_size"] = 1 for node in wf.values(): if isinstance(node, dict) and node.get("class_type") == "SaveImage": node["inputs"]["filename_prefix"] = f"relay_{job_id}" if lora_spec is not None: # キャラLoRAを常駐チェーンの末尾に固定挿入する。共有リンク (model_link / # clip_link) を参照している全ての入力を、新しい LoraLoader 経由に付け替える。 # これにより、メイン KSampler と両 CLIPTextEncode だけでなく、同じ共有 # リンクを使う Hires 側 KSampler や FaceDetailer にもキャラLoRAが伝播する # (起動時検証で、それらの接続が共有リンクと一致することを確認済み)。 # リンクの付け替えだけなので、テンプレート側のチェーン段数には依存しない。 model_link = node_map["model_link"] clip_link = node_map["clip_link"] def is_link(value, target): return (isinstance(value, list) and len(value) == 2 and str(value[0]) == str(target[0]) and value[1] == target[1]) new_id = 1 while str(new_id) in wf: new_id += 1 new_id = str(new_id) for node in wf.values(): if not isinstance(node, dict): continue inputs = node.get("inputs") if not isinstance(inputs, dict): continue for key, value in inputs.items(): if is_link(value, model_link): inputs[key] = [new_id, 0] elif is_link(value, clip_link): inputs[key] = [new_id, 1] wf[new_id] = { "class_type": "LoraLoader", "inputs": { "lora_name": lora_spec["name"], "strength_model": lora_spec["strength"], "strength_clip": lora_spec["strength"], "model": model_link, "clip": clip_link, }, } return wf # ---------------------------------------------------------------- ComfyUI連携 (127.0.0.1固定) def comfy_request(method, path, payload=None, timeout=30): url = CONFIG["comfy_base"] + path data = None headers = {} if payload is not None: data = json.dumps(payload).encode("utf-8") headers["Content-Type"] = "application/json" req = urllib.request.Request(url, data=data, headers=headers, method=method) with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.status, resp.headers.get("Content-Type", ""), resp.read() # ComfyUI の POST /prompt が検証失敗時に返す error.type の既知の値。 # ノード単位の検証失敗は "prompt_outputs_failed_validation"、 # 出力なしや存在しないノード種別などプロンプト全体の検証失敗は # "prompt_no_outputs" / "invalid_prompt" で、node_errors は空 {} になり得る。 _KNOWN_REJECTION_ERROR_TYPES = { "prompt_outputs_failed_validation", "prompt_no_outputs", "invalid_prompt", } def _is_explicit_rejection(status, body_bytes): """ComfyUIの既知の検証エラー応答かどうかを判定する。 ComfyUIはワークフローの検証エラー時に HTTP 400 で {"error": {"type": ..., "message": ..., "details": ..., "extra_info": {...}}, "node_errors": {...}} を返す。次の両方を確認できた場合のみ「明確な投入拒否」と判定する: - error が既知の error.type を持つオブジェクトであること - node_errors がオブジェクトとして存在すること (空 {} でもよい。 ノード単位の失敗が無い全体検証エラーでは空になる) それ以外のHTTPエラーや解釈できない応答は、投入の成否を判断できないもの として扱う (一律に失敗へは変えない)。 """ if status != 400: return False try: data = json.loads(body_bytes.decode("utf-8")) except Exception: return False if not isinstance(data, dict): return False error = data.get("error") if not isinstance(error, dict): return False if error.get("type") not in _KNOWN_REJECTION_ERROR_TYPES: return False return isinstance(data.get("node_errors"), dict) def comfy_submit(workflow): """ワークフローを投入し prompt_id を返す。 - HTTP 200 で有効な prompt_id が返れば受理済みとして扱い、IDを保持して 完了まで追跡する。node_errors が同時に返っていても、それは一部の出力系統の 検証失敗を示すだけで、正常な系統はキューに登録され生成が走るため、 未投入・失敗とは扱わない - 明確な検証エラー (HTTP 400 + 既知の error.type + node_errors 構造) は ApiError (comfy_node_error) として送出する -> ジョブは failed になり、後続は継続する。 prompt_id の無い 200 応答で node_errors がある場合も同様に拒否として扱う - 通信断・応答喪失・解釈できない応答は SubmitOutcomeUnknown を送出する -> ジョブは unknown になり、投入ゲートが閉じる """ try: _, _, body = comfy_request( "POST", "/prompt", {"prompt": workflow, "client_id": CONFIG["client_id"]}, timeout=30) except urllib.error.HTTPError as e: try: raw = e.read() except Exception: raw = b"" if _is_explicit_rejection(e.code, raw): raise ApiError(502, "comfy_node_error", "ComfyUIがワークフローの検証エラーで投入を拒否しました") raise SubmitOutcomeUnknown(f"HTTP {e.code} (投入の成否を判断できない応答)") except Exception as e: raise SubmitOutcomeUnknown(type(e).__name__) try: result = json.loads(body.decode("utf-8")) except Exception: raise SubmitOutcomeUnknown("/prompt の応答を解釈できません") if not isinstance(result, dict): raise SubmitOutcomeUnknown("/prompt の応答形式が不正です") prompt_id = result.get("prompt_id") if isinstance(prompt_id, str) and prompt_id: # 受理済み。一部の出力系統の検証失敗 (node_errors) が併記されていても、 # 正常な系統は生成が走るため、prompt_id を保持して完了まで追跡する return prompt_id if result.get("node_errors"): raise ApiError(502, "comfy_node_error", "ComfyUIがワークフローを拒否しました") raise SubmitOutcomeUnknown("/prompt の応答に prompt_id がありません") def comfy_history(prompt_id): _, _, body = comfy_request("GET", f"/history/{prompt_id}", timeout=10) history = json.loads(body.decode("utf-8")) return history.get(prompt_id) def comfy_queue_state(): """GET /queue の応答を検証し、(running_ids, pending_ids) を返す。 検証内容: 応答がJSONオブジェクトであること、queue_running / queue_pending の 両キーが存在し配列であること、各要素が [number, prompt_id, ...] 形式の配列で prompt_id が空でない文字列であること。 形式が不正な場合は「空」と解釈せず ComfyStateError を送出する (状態確認失敗)。 起動時・resume・取消確認で共通の判定として使う。 """ _, _, body = comfy_request("GET", "/queue", timeout=10) try: data = json.loads(body.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): raise ComfyStateError("/queue の応答がJSONではありません") if not isinstance(data, dict): raise ComfyStateError("/queue の応答がオブジェクトではありません") result = [] for key in ("queue_running", "queue_pending"): if key not in data: raise ComfyStateError(f"/queue の応答に {key} がありません") entries = data[key] if not isinstance(entries, list): raise ComfyStateError(f"/queue の {key} が配列ではありません") ids = set() for item in entries: if (not isinstance(item, list) or len(item) < 2 or not isinstance(item[1], str) or not item[1]): raise ComfyStateError(f"/queue の {key} に不正な要素があります") ids.add(item[1]) result.append(ids) return result[0], result[1] def comfy_queue_ids(): """ComfyUIのキューにある prompt_id の集合 (実行中 + 待機中) を返す。""" running, pending = comfy_queue_state() return running | pending def extract_output(entry): for node_out in (entry.get("outputs") or {}).values(): images = node_out.get("images") or [] if images: img = images[0] return {"filename": img["filename"], "subfolder": img.get("subfolder", ""), "type": img.get("type", "output")} return None def comfy_poll(prompt_id, timeout_sec): """完了まで /history をポーリングする。 - status_str == "error" は completed の真偽より先に判定する (ComfyUIは失敗時に completed=false, status_str="error" を返すため)。 - 通信失敗がしきい値以上続いたら ComfyLostContact、制限時間を超えたら GenerationTimeout。 """ deadline = time.time() + timeout_sec failures = 0 while time.time() < deadline: try: entry = comfy_history(prompt_id) failures = 0 except Exception: failures += 1 if failures >= CONFIG["comfy_fail_threshold"]: raise ComfyLostContact(prompt_id) time.sleep(CONFIG["poll_interval"]) continue if entry: status = entry.get("status", {}) if status.get("status_str") == "error": raise ApiError(502, "generation_failed", "ComfyUI側で生成がエラーになりました") if status.get("completed"): output = extract_output(entry) if output is None: raise ApiError(502, "no_output", "生成が完了しましたが画像出力がありません") return output time.sleep(CONFIG["poll_interval"]) raise GenerationTimeout(prompt_id) def try_cancel_and_verify(prompt_id): """自ジョブ (prompt_id) の状態確定を試み、確定したら True を返す。 取消手段は「待機中ジョブへの prompt_id 指定 delete」だけに限定する。 - 待機中 (queue_pending にある) 場合のみ、/queue の delete で当該IDのみ削除する - 実行中 (queue_running にある) 場合は取り消さない。対象を指定できない全体割り込み (/interrupt) は、確認と実行の間に実行ジョブが交代していた場合に無関係なジョブを 止めてしまうため使わない -> False を返し、状態不明として扱う - 無関係なジョブの一括停止 (clear 等) は行わない 確認できない場合は False (状態不明として扱う)。 """ try: running, pending = comfy_queue_state() # 形式不正は状態確認失敗として例外になる if prompt_id in running: return False # 実行中: 安全な取消手段が無いため状態不明にする if prompt_id in pending: comfy_request("POST", "/queue", {"delete": [prompt_id]}, timeout=10) # 確認: キューから消え、履歴が「完了 (成功/失敗)」または「履歴なし (未実行のまま削除)」であれば確定 time.sleep(1.0) if prompt_id in comfy_queue_ids(): return False entry = comfy_history(prompt_id) if entry is None: return True # 未実行のまま取り消された status = entry.get("status", {}) if status.get("status_str") == "error" or status.get("completed"): return True return False except Exception: return False def comfy_fetch_image(output): query = urllib.parse.urlencode( {"filename": output["filename"], "subfolder": output["subfolder"], "type": output["type"]}) _, content_type, body = comfy_request("GET", f"/view?{query}", timeout=60) return content_type or "image/png", body # ---------------------------------------------------------------- ジョブ管理 def finish_job(job, state, error=None, output=None): """終端状態への遷移。プロンプトはこの時点で記録から除去する。""" with JOBS_LOCK: job["state"] = state job["error"] = error if output is not None: job["output"] = output job["finished_at"] = time.time() job["prompt"] = None job["negative_prompt"] = None def mark_unknown(job, code, message): finish_job(job, "unknown", {"code": code, "message": message}) gate_close(f"job {job['job_id']} のComfyUI側状態が不明です ({code})") def process_job(job): """1ジョブの投入・監視・状態確定を行う。task_done は呼び出し側 (worker_loop) だけが行う。""" job_id = job["job_id"] try: workflow = build_prompt(job_id, job["workflow_id"], job["prompt"], job["negative_prompt"], job["seed"], job["resolution"], job.get("lora")) try: prompt_id = comfy_submit(workflow) except ApiError: raise # 明確な投入拒否 (検証エラー) は failed として処理する except SubmitOutcomeUnknown: # /prompt の応答が失われた等: 投入されたかどうか自体が不明。 # prompt_id が無いため自動照合はできない -> 状態不明として投入を止める。 mark_unknown(job, "submit_outcome_unknown", "ComfyUIへの投入結果を確認できませんでした") return with JOBS_LOCK: job["prompt_id"] = prompt_id output = comfy_poll(prompt_id, CONFIG["job_timeout"]) finish_job(job, "succeeded", output=output) print(f"[relay] job {job_id} succeeded (workflow={job['workflow_id']}, preset={job['resolution']})") except ApiError as e: finish_job(job, "failed", {"code": e.code, "message": e.message}) print(f"[relay] job {job_id} failed: {e.code}") except GenerationTimeout: # 待機中ジョブの取消 (prompt_id指定delete) が確認できれば失敗として確定し、後続投入は継続する。 # 実行中ジョブは安全な取消手段が無いため、確認できない場合は状態不明にする。 if try_cancel_and_verify(job["prompt_id"]): finish_job(job, "failed", {"code": "generation_timeout", "message": "生成が制限時間内に完了せず、対象ジョブの取消を確認しました"}) print(f"[relay] job {job_id} failed: generation_timeout (cancel verified)") else: mark_unknown(job, "generation_timeout_unresolved", "生成がタイムアウトし、対象ジョブの終了・取消を確認できませんでした") except ComfyLostContact: # 通信が回復した後の照合で確定できなければ状態不明にする。 if job.get("prompt_id") and try_cancel_and_verify(job["prompt_id"]): finish_job(job, "failed", {"code": "comfy_contact_lost", "message": "ComfyUIとの通信が途絶しましたが、対象ジョブの終了・取消を確認しました"}) print(f"[relay] job {job_id} failed: comfy_contact_lost (resolved)") else: mark_unknown(job, "comfy_contact_lost", "ComfyUIとの通信が途絶し、対象ジョブの状態を確認できませんでした") except Exception as e: finish_job(job, "failed", {"code": "internal_error", "message": f"中継API内部でエラーが発生しました ({type(e).__name__})"}) print(f"[relay] job {job_id} failed: internal_error ({type(e).__name__})") def worker_loop(): while True: job_id = JOB_QUEUE.get() try: with JOBS_LOCK: job = JOBS.get(job_id) if job is None: continue # ゲートが閉じている間は、待機済みジョブであってもComfyUIへ投入しない。 # ジョブは queued のまま保持され、ゲート再開後に処理される。 GATE_EVENT.wait() with JOBS_LOCK: job["state"] = "running" process_job(job) finally: JOB_QUEUE.task_done() def cleanup_loop(): """保持期限を過ぎた終端ジョブの削除と、記録件数の上限管理。""" while True: time.sleep(CONFIG["cleanup_interval"]) now = time.time() with JOBS_LOCK: expired = [jid for jid, j in JOBS.items() if j["state"] in TERMINAL_STATES and j["finished_at"] and now - j["finished_at"] > CONFIG["job_retention"]] for jid in expired: del JOBS[jid] overflow = len(JOBS) - CONFIG["max_job_records"] if overflow > 0: finished = sorted( (j for j in JOBS.values() if j["state"] in TERMINAL_STATES and j["finished_at"]), key=lambda j: j["finished_at"]) for j in finished[:overflow]: del JOBS[j["job_id"]] if expired: print(f"[relay] 保持期限を過ぎたジョブ記録を削除しました: {len(expired)}件") # ---------------------------------------------------------------- 入力検証 def validate_text(value, field, required): if value is None: if required: raise ApiError(400, "missing_field", f"{field} が必要です") return None if not isinstance(value, str): raise ApiError(400, "invalid_type", f"{field} は文字列で指定してください") if required and len(value) == 0: raise ApiError(400, "empty_field", f"{field} が空です") if len(value) > MAX_PROMPT_CHARS: raise ApiError(400, "too_long", f"{field} は {MAX_PROMPT_CHARS} 文字以内で指定してください") if any(ord(c) < 32 or ord(c) == 127 for c in value): raise ApiError(400, "invalid_characters", f"{field} に制御文字は使えません") return value def validate_generate_request(obj): """型検証を辞書検索や特殊値判定より前に行う。不正入力は全て ApiError(400) にする。""" if not isinstance(obj, dict): raise ApiError(400, "invalid_body", "JSONオブジェクトで送信してください") unknown = set(obj.keys()) - ALLOWED_REQUEST_KEYS if unknown: raise ApiError(400, "unknown_field", f"許可されていない項目があります: {', '.join(sorted(map(str, unknown)))}") workflow_id = obj.get("workflow_id") if workflow_id is None: raise ApiError(400, "missing_field", "workflow_id が必要です") if not isinstance(workflow_id, str): raise ApiError(400, "invalid_type", "workflow_id は文字列で指定してください") if workflow_id not in TEMPLATES: raise ApiError(400, "unknown_workflow", "許可されていないワークフローです") prompt = validate_text(obj.get("prompt"), "prompt", required=True) negative = validate_text(obj.get("negative_prompt"), "negative_prompt", required=False) seed = obj.get("seed") if seed is None: seed = secrets.randbelow(2**63) elif type(seed) is not int: # bool や float (例: -1.0) は整数として扱わない raise ApiError(400, "invalid_seed", "seed は整数で指定してください") elif seed == -1: seed = secrets.randbelow(2**63) elif seed < 0 or seed > MAX_SEED: raise ApiError(400, "invalid_seed", f"seed は 0 から {MAX_SEED} の範囲で指定してください") resolution = obj.get("resolution", "1024x1024") if not isinstance(resolution, str): raise ApiError(400, "invalid_type", "resolution は文字列で指定してください") if resolution not in RESOLUTION_PRESETS: raise ApiError(400, "unknown_resolution", "許可されていない解像度プリセットです") lora_spec = validate_lora(obj.get("lora")) return workflow_id, prompt, negative, seed, resolution, lora_spec def validate_lora(lora): """任意項目 lora {name, strength} を検証し、{name, strength} に正規化して返す。 - name は許可リストのキー (ファイル名) と完全一致した場合のみ受理する - strength 省略時は許可リストの default、範囲外は min/max にクランプする """ if lora is None: return None if not isinstance(lora, dict): raise ApiError(400, "invalid_type", "lora はオブジェクトで指定してください") unknown = set(lora.keys()) - ALLOWED_LORA_KEYS if unknown: raise ApiError(400, "unknown_field", f"lora に許可されていない項目があります: {', '.join(sorted(map(str, unknown)))}") name = lora.get("name") if name is None: raise ApiError(400, "missing_field", "lora.name が必要です") if not isinstance(name, str): raise ApiError(400, "invalid_type", "lora.name は文字列で指定してください") entry = LORA_ALLOWLIST.get(name) if entry is None: raise ApiError(400, "unknown_lora", "許可リストに無いLoRAです") strength = lora.get("strength") if strength is None: strength = entry["default"] elif type(strength) not in (int, float): # bool や文字列は数値として扱わない raise ApiError(400, "invalid_type", "lora.strength は数値で指定してください") strength = min(max(float(strength), entry["min"]), entry["max"]) return {"name": name, "strength": strength} # ---------------------------------------------------------------- HTTPサーバ (接続資源の制限付き) class RelayHTTPServer(ThreadingHTTPServer): daemon_threads = True request_queue_size = 128 def process_request(self, request, client_address): if CONN_SEM is not None and not CONN_SEM.acquire(blocking=False): # 同時接続数の上限超過: 新規接続は受け付けずに閉じる try: request.shutdown(socket.SHUT_RDWR) except OSError: pass request.close() return try: super().process_request(request, client_address) except Exception: if CONN_SEM is not None: CONN_SEM.release() raise def process_request_thread(self, request, client_address): try: super().process_request_thread(request, client_address) finally: if CONN_SEM is not None: CONN_SEM.release() class RelayHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" server_version = "RelayAPI/2.0" def setup(self): super().setup() # 遅い送信・接続放置に対する読み書きタイムアウト self.connection.settimeout(CONFIG["socket_timeout"]) def log_message(self, fmt, *args): # プロンプト等の内容はログに残さない sys.stderr.write("[relay] %s %s\n" % (self.command, self.path.split("?")[0])) # --- 共通応答 (応答後は接続を再利用しない: 未読の本文を次リクエストに持ち込ませない) def send_json(self, status, obj): body = json.dumps(obj, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Connection", "close") self.end_headers() self.wfile.write(body) self.close_connection = True def send_error_json(self, e): self.send_json(e.status, {"error": {"code": e.code, "message": e.message}}) def read_json_body(self): length = self.headers.get("Content-Length") if length is None: raise ApiError(400, "invalid_body", "Content-Length がありません") try: length = int(length) except ValueError: raise ApiError(400, "invalid_body", "Content-Length が不正です") if length <= 0 or length > MAX_BODY_BYTES: raise ApiError(400, "body_too_large", "リクエストボディが大きすぎます") raw = self.rfile.read(length) if len(raw) != length: raise ApiError(400, "invalid_body", "リクエストボディが途中で切断されました") try: return json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): raise ApiError(400, "invalid_json", "JSONとして解釈できません") # --- ルーティング def do_GET(self): try: path = urllib.parse.urlparse(self.path).path if path == "/health": self.handle_health() elif path.startswith("/jobs/") and path.endswith("/image"): self.handle_job_image(path[len("/jobs/"):-len("/image")].strip("/")) elif path.startswith("/jobs/"): self.handle_job_status(path[len("/jobs/"):].strip("/")) else: raise ApiError(404, "not_found", "存在しないパスです") except ApiError as e: self.send_error_json(e) except (BrokenPipeError, ConnectionResetError, socket.timeout): pass def do_POST(self): try: path = urllib.parse.urlparse(self.path).path if path == "/generate": self.handle_generate() else: raise ApiError(404, "not_found", "存在しないパスです") except ApiError as e: self.send_error_json(e) except (BrokenPipeError, ConnectionResetError, socket.timeout): pass def do_PUT(self): self.send_error_json(ApiError(405, "method_not_allowed", "許可されていないメソッドです")) do_DELETE = do_PUT do_PATCH = do_PUT # --- ハンドラ本体 def handle_health(self): closed, reason = gate_is_closed() try: comfy_request("GET", "/system_stats", timeout=3) comfy_state = "ok" except Exception: comfy_state = "unreachable" body = {"status": "suspended" if closed else "ok", "comfy": comfy_state, "submit_gate": "closed" if closed else "open", "gate_reason": reason, "workflows": sorted(TEMPLATES.keys()), "loras": sorted(LORA_ALLOWLIST.keys()), "queue_depth": JOB_QUEUE.qsize() if JOB_QUEUE else 0} self.send_json(200 if comfy_state == "ok" else 503, body) def handle_generate(self): closed, _ = gate_is_closed() if closed: raise ApiError(503, "submit_suspended", "ComfyUI側の状態が不明なジョブがあるため、新規投入を停止しています") obj = self.read_json_body() workflow_id, prompt, negative, seed, resolution, lora_spec = validate_generate_request(obj) job_id = uuid.uuid4().hex job = {"job_id": job_id, "workflow_id": workflow_id, "resolution": resolution, "seed": seed, "prompt": prompt, "negative_prompt": negative, "lora": lora_spec, "state": "queued", "created_at": time.time(), "finished_at": None, "error": None, "output": None, "prompt_id": None} with JOBS_LOCK: JOBS[job_id] = job try: JOB_QUEUE.put_nowait(job_id) # 上限付きキュー: 競合時も上限を超えて受理しない except queue.Full: with JOBS_LOCK: JOBS.pop(job_id, None) # 拒否したジョブは記録に残さない raise ApiError(429, "queue_full", "待ち行列が上限に達しています") print(f"[relay] job {job_id} accepted (workflow={workflow_id}, preset={resolution})") self.send_json(202, {"job_id": job_id, "state": "queued", "workflow_id": workflow_id, "resolution": resolution, "seed": seed}) def get_job_or_404(self, job_id): with JOBS_LOCK: job = JOBS.get(job_id) if job is None: raise ApiError(404, "job_not_found", "この中継APIが発行したジョブではありません") return job def handle_job_status(self, job_id): job = self.get_job_or_404(job_id) self.send_json(200, {"job_id": job["job_id"], "state": job["state"], "workflow_id": job["workflow_id"], "resolution": job["resolution"], "seed": job["seed"], "lora": job.get("lora"), "created_at": job["created_at"], "finished_at": job["finished_at"], "error": job["error"]}) def handle_job_image(self, job_id): job = self.get_job_or_404(job_id) if job["state"] != "succeeded" or not job["output"]: raise ApiError(409, "job_not_ready", "ジョブが完了していないため画像を取得できません") try: content_type, body = comfy_fetch_image(job["output"]) except urllib.error.HTTPError as e: # 画像の消失と上流の異常を区別する。いずれも生成ジョブの再投入はしない if e.code == 404: raise ApiError(404, "image_not_found", "生成画像が見つかりません (ComfyUI側で削除された可能性があります)") raise ApiError(502, "comfy_error", f"ComfyUIが画像取得にエラーを返しました (HTTP {e.code})") except Exception: raise ApiError(502, "comfy_unreachable", "ComfyUIへ接続できないため画像を取得できません") self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.send_header("Connection", "close") self.end_headers() self.wfile.write(body) self.close_connection = True # ---------------------------------------------------------------- コンソールコマンド def console_loop(): """PC側コンソールからの運用コマンド (status / resume)。""" while True: try: line = input() except (EOFError, KeyboardInterrupt): return cmd = line.strip().lower() if cmd == "status": closed, reason = gate_is_closed() with JOBS_LOCK: counts = {} for j in JOBS.values(): counts[j["state"]] = counts.get(j["state"], 0) + 1 print(f"[relay] submit_gate: {'closed (' + str(reason) + ')' if closed else 'open'} / jobs: {counts}") elif cmd in ("resume", "resume force"): closed, _ = gate_is_closed() if not closed: print("[relay] 投入は停止していません") continue # 状態を確認できない場合は、force でも再開しない (停止を維持する) try: ids = comfy_queue_ids() except Exception: print("[relay] ComfyUIの状態を確認できないため、停止を維持します") continue if ids and cmd != "resume force": print(f"[relay] ComfyUIのキューに残件があります: {sorted(ids)}") print("[relay] 内容を確認済みで再開する場合は resume force と入力してください (停止を維持しています)") continue gate_open() elif cmd: print("[relay] 使用できるコマンド: status / resume / resume force") # ---------------------------------------------------------------- 起動 def parse_args(): p = argparse.ArgumentParser(description="Muse -> ComfyUI 限定中継API") p.add_argument("--host", required=True, help="待ち受けIP (Tailscale帯域 100.64.0.0/10 のみ許可)") p.add_argument("--port", type=int, default=8190, help="待ち受けポート (既定: 8190)") p.add_argument("--comfy", default="http://127.0.0.1:8188", help="ComfyUIのURL (127.0.0.1 のみ許可。既定: http://127.0.0.1:8188)") p.add_argument("--workflows-dir", required=True, help="承認済みワークフローJSONのフォルダー") p.add_argument("--max-queue", type=int, default=4, help="待ち行列の上限 (既定: 4)") p.add_argument("--job-timeout", type=int, default=900, help="1ジョブの生成タイムアウト秒 (既定: 900)") p.add_argument("--poll-interval", type=float, default=2.0, help="状態確認の間隔秒 (既定: 2.0)") p.add_argument("--comfy-fail-threshold", type=int, default=5, help="状態不明と判定する連続通信失敗回数 (既定: 5)") p.add_argument("--job-retention", type=int, default=3600, help="終端ジョブ記録の保持秒数 (既定: 3600)") p.add_argument("--max-job-records", type=int, default=100, help="ジョブ記録の最大件数 (既定: 100)") p.add_argument("--cleanup-interval", type=int, default=60, help="保持期限チェックの間隔秒 (既定: 60)") p.add_argument("--socket-timeout", type=float, default=15.0, help="接続の読み書きタイムアウト秒 (既定: 15)") p.add_argument("--max-connections", type=int, default=32, help="同時接続数の上限 (既定: 32)") p.add_argument("--local-test", action="store_true", help="127.0.0.1 へのバインドを許可する (単体テスト用)") args = p.parse_args() try: ip = ipaddress.ip_address(args.host) except ValueError: p.error(f"--host がIPアドレスではありません: {args.host}") in_tailnet = ip in ipaddress.ip_network("100.64.0.0/10") if not in_tailnet and not (args.local_test and ip.is_loopback): p.error("--host はTailscale帯域 (100.64.0.0/10) のIPを指定してください。" "0.0.0.0 や LAN のIPへのバインドは許可しません") comfy = urllib.parse.urlparse(args.comfy) if comfy.scheme != "http" or comfy.hostname != "127.0.0.1": p.error("--comfy は http://127.0.0.1: の形式のみ許可します") if not (1 <= args.port <= 65535): p.error("--port が範囲外です") if args.max_queue < 1: p.error("--max-queue は1以上にしてください") return args def main(): global JOB_QUEUE, CONN_SEM args = parse_args() CONFIG.update({ "comfy_base": f"http://127.0.0.1:{urllib.parse.urlparse(args.comfy).port or 8188}", "client_id": "claude-relay-" + uuid.uuid4().hex[:12], "job_timeout": args.job_timeout, "poll_interval": args.poll_interval, "comfy_fail_threshold": args.comfy_fail_threshold, "job_retention": args.job_retention, "max_job_records": args.max_job_records, "cleanup_interval": args.cleanup_interval, "socket_timeout": args.socket_timeout, }) JOB_QUEUE = queue.Queue(maxsize=args.max_queue) CONN_SEM = threading.Semaphore(args.max_connections) load_templates(args.workflows_dir) load_lora_allowlist(args.workflows_dir) # 起動時にComfyUIのキューに残件がある場合は、前インスタンスの残りかGUIのジョブか # 区別できないため、運用者が確認するまで投入を停止する (自動で消したりはしない)。 # 状態を確認できない場合 (ComfyUI未起動・通信不可) も、開放状態にはせず停止を維持する。 try: existing = comfy_queue_ids() except Exception: existing = None gate_close("起動時にComfyUIの状態を確認できませんでした") if existing: gate_close(f"起動時にComfyUIのキューに残件があります: {sorted(existing)}") threading.Thread(target=worker_loop, daemon=True).start() threading.Thread(target=cleanup_loop, daemon=True).start() threading.Thread(target=console_loop, daemon=True).start() server = RelayHTTPServer((args.host, args.port), RelayHandler) print(f"[relay] listening on http://{args.host}:{args.port} (comfy: {CONFIG['comfy_base']})") print("[relay] 停止するには Ctrl+C を押してください") try: server.serve_forever() except KeyboardInterrupt: print("\n[relay] 停止します") server.shutdown() if __name__ == "__main__": main() ``` ### lora_allowlist.json ```json { "<キャラLoRAのファイル名1>.safetensors": { "default": 1.0, "max": 1.2, "min": 0.0 }, "<キャラLoRAのファイル名2>.safetensors": { "default": 0.85, "max": 1.2, "min": 0.0 } } ``` ## 付録E: パートEで使うファイル 中継API (`relay_api.py`) は付録Dと同一 (変更なし)。 ### noobai_vpred_api.json ```json { "10": { "class_type": "ModelSamplingDiscrete", "inputs": { "model": [ "4", 0 ], "sampling": "v_prediction", "zsnr": true } }, "11": { "class_type": "RescaleCFG", "inputs": { "model": [ "10", 0 ], "multiplier": 0.7 } }, "3": { "class_type": "KSampler", "inputs": { "cfg": 4.5, "denoise": 1.0, "latent_image": [ "5", 0 ], "model": [ "13", 0 ], "negative": [ "7", 0 ], "positive": [ "6", 0 ], "sampler_name": "euler", "scheduler": "normal", "seed": 42, "steps": 32 } }, "4": { "class_type": "CheckpointLoaderSimple", "inputs": { "ckpt_name": "NoobAI-XL-Vpred-v1.0.safetensors" } }, "5": { "class_type": "EmptyLatentImage", "inputs": { "batch_size": 1, "height": 1024, "width": 1024 } }, "6": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "13", 1 ], "text": "masterpiece, best quality, very awa, 1girl, solo, long hair, school uniform, classroom" } }, "7": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "13", 1 ], "text": "nsfw, worst quality, old, early, low quality, lowres, signature, username, logo, bad hands, mutated hands, mammal, anthro, furry, ambiguous form, feral, semi-anthro" } }, "8": { "class_type": "VAEDecode", "inputs": { "samples": [ "21", 0 ], "vae": [ "4", 2 ] } }, "9": { "class_type": "SaveImage", "inputs": { "filename_prefix": "noobai_vpred", "images": [ "23", 0 ] } }, "12": { "class_type": "LoraLoader", "inputs": { "model": [ "11", 0 ], "clip": [ "4", 1 ], "lora_name": "add-detail-xl.safetensors", "strength_model": 1.0, "strength_clip": 1.0 } }, "13": { "class_type": "LoraLoader", "inputs": { "model": [ "12", 0 ], "clip": [ "12", 1 ], "lora_name": "Outlined_IL.safetensors", "strength_model": 1.0, "strength_clip": 1.0 } }, "20": { "class_type": "LatentUpscaleBy", "inputs": { "samples": [ "3", 0 ], "upscale_method": "nearest-exact", "scale_by": 1.5 } }, "21": { "class_type": "KSampler", "inputs": { "model": [ "13", 0 ], "seed": 42, "steps": 20, "cfg": 4.5, "sampler_name": "euler", "scheduler": "normal", "positive": [ "6", 0 ], "negative": [ "7", 0 ], "latent_image": [ "20", 0 ], "denoise": 0.5 } }, "22": { "class_type": "UltralyticsDetectorProvider", "inputs": { "model_name": "bbox/face_yolov8m.pt" } }, "23": { "class_type": "FaceDetailer", "inputs": { "image": [ "8", 0 ], "model": [ "13", 0 ], "clip": [ "13", 1 ], "vae": [ "4", 2 ], "guide_size": 512, "guide_size_for": true, "max_size": 1024, "seed": 42, "steps": 20, "cfg": 4.5, "sampler_name": "euler", "scheduler": "normal", "positive": [ "6", 0 ], "negative": [ "7", 0 ], "denoise": 0.4, "feather": 5, "noise_mask": true, "force_inpaint": true, "bbox_threshold": 0.5, "bbox_dilation": 10, "bbox_crop_factor": 3.0, "sam_detection_hint": "center-1", "sam_dilation": 0, "sam_threshold": 0.93, "sam_bbox_expansion": 0, "sam_mask_hint_threshold": 0.7, "sam_mask_hint_use_negative": "False", "drop_size": 10, "bbox_detector": [ "22", 0 ], "wildcard": "", "cycle": 1, "noise_mask_feather": 20 } } } ``` ### wai_nsfw_api.json ```json { "3": { "class_type": "KSampler", "inputs": { "cfg": 6, "denoise": 1.0, "latent_image": [ "5", 0 ], "model": [ "11", 0 ], "negative": [ "7", 0 ], "positive": [ "6", 0 ], "sampler_name": "euler_ancestral", "scheduler": "normal", "seed": 42, "steps": 30 } }, "4": { "class_type": "CheckpointLoaderSimple", "inputs": { "ckpt_name": "waiIllustriousSDXL_v170.safetensors" } }, "5": { "class_type": "EmptyLatentImage", "inputs": { "batch_size": 1, "height": 1024, "width": 1024 } }, "6": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "11", 1 ], "text": "masterpiece, best quality, [SCENE TAGS HERE]" } }, "7": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "11", 1 ], "text": "lowres, worst quality, low quality, bad anatomy, bad hands, mutated hands, extra digits, fewer digits, watermark, signature, blurry" } }, "8": { "class_type": "VAEDecode", "inputs": { "samples": [ "21", 0 ], "vae": [ "4", 2 ] } }, "9": { "class_type": "SaveImage", "inputs": { "filename_prefix": "wai_nsfw", "images": [ "23", 0 ] } }, "10": { "class_type": "LoraLoader", "inputs": { "model": [ "4", 0 ], "clip": [ "4", 1 ], "lora_name": "add-detail-xl.safetensors", "strength_model": 1.0, "strength_clip": 1.0 } }, "11": { "class_type": "LoraLoader", "inputs": { "model": [ "10", 0 ], "clip": [ "10", 1 ], "lora_name": "Outlined_IL.safetensors", "strength_model": 1.0, "strength_clip": 1.0 } }, "20": { "class_type": "LatentUpscaleBy", "inputs": { "samples": [ "3", 0 ], "upscale_method": "nearest-exact", "scale_by": 1.5 } }, "21": { "class_type": "KSampler", "inputs": { "model": [ "11", 0 ], "seed": 42, "steps": 20, "cfg": 6, "sampler_name": "euler_ancestral", "scheduler": "normal", "positive": [ "6", 0 ], "negative": [ "7", 0 ], "latent_image": [ "20", 0 ], "denoise": 0.4 } }, "22": { "class_type": "UltralyticsDetectorProvider", "inputs": { "model_name": "bbox/face_yolov8m.pt" } }, "23": { "class_type": "FaceDetailer", "inputs": { "image": [ "8", 0 ], "model": [ "11", 0 ], "clip": [ "11", 1 ], "vae": [ "4", 2 ], "guide_size": 512, "guide_size_for": true, "max_size": 1024, "seed": 42, "steps": 20, "cfg": 6, "sampler_name": "euler_ancestral", "scheduler": "normal", "positive": [ "6", 0 ], "negative": [ "7", 0 ], "denoise": 0.4, "feather": 5, "noise_mask": true, "force_inpaint": true, "bbox_threshold": 0.5, "bbox_dilation": 10, "bbox_crop_factor": 3.0, "sam_detection_hint": "center-1", "sam_dilation": 0, "sam_threshold": 0.93, "sam_bbox_expansion": 0, "sam_mask_hint_threshold": 0.7, "sam_mask_hint_use_negative": "False", "drop_size": 10, "bbox_detector": [ "22", 0 ], "wildcard": "", "cycle": 1, "noise_mask_feather": 20 } } } ``` ## 付録F: パートFで使うファイル SDXL ワークフローは付録Eと同一 (変更なし)。 ### relay_api.py ```python #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ relay_api.py - Muse -> ComfyUI 限定中継API (実装案 v2) 設計方針: - ComfyUI本体は 127.0.0.1:8188 で待ち受け、この中継APIだけがPCのTailscale IPで待ち受ける。 - 受け付けるのは、PC側に保存された承認済みワークフロー (API形式JSON) の実行のみ。 - 呼び出し側が変更できるのは prompt / negative_prompt / seed / 解像度プリセットのみ。 - 追加で、任意項目 lora (キャラLoRA) を1つだけ指定できる。ただし指定できるのは PC側の許可リスト (ワークフローフォルダー内の lora_allowlist.json) に登録された ファイル名と完全一致する場合のみで、強さはリスト側の範囲にクランプされる。 挿入位置はサーバー側で固定する (KSampler直前 = 常駐LoRAチェーンの末尾) ため、 呼び出し側が並び替えたり複数積んだりすることはできない。許可リストに無い名前は 400。 - ワークフローJSON、ノード、ファイルパス、URL、コマンド類は一切受け付けない (未知のキーは 400)。 - 画像取得は、この中継APIが発行したジョブの生成物だけに限定する (ジョブIDはUUID、実ファイル名は外部に出さない)。 - ComfyUIの任意APIへの汎用プロキシにはしない (内部で使うのは /prompt, /history, /view, /system_stats, /queue の固定用途のみ。/queue は自ジョブの状態確認と、待機中ジョブの prompt_id指定delete専用)。 - 実行中ジョブの自動割り込みは行わない。ComfyUIの /interrupt は対象を指定できない 全体割り込みであり、直前のID確認だけでは「確認後に別ジョブへ交代していた」場合の 誤停止を防げないため。実行中ジョブがタイムアウトした場合は状態不明として投入を止め、 運用者の確認 (GUIでの停止または完了待ち) と resume による再開に委ねる。 - 同時実行は1件。待ち行列は上限付きキューで管理し、超過は 429 (競合時も上限を超えない)。 - ComfyUI側の処理状態が不明になったジョブがある間は、新規投入を停止する (503)。 復旧は、対象ジョブの終了・取消が確認できた場合に自動で行い、照合できない場合は PC側コンソールでの手動 resume (運用者の確認) が必要。 - ジョブ記録には保持期限と件数上限があり、完了したジョブのプロンプトは記録から即時除去する。 - 生成画像ファイル自体はComfyUIの output フォルダに残る (ComfyUI標準APIにファイル削除機能が無いため、 中継APIはファイルを削除しない。容量管理は利用者側で行い、中継API経由の生成物は filename_prefix = relay_ で識別できる)。 - Python標準ライブラリのみで動作する。ComfyUI Portable同梱の python_embeded で実行する想定。 - 管理者権限は不要。通常ユーザーで手動起動する。 起動時の「構造検証」について: 起動時に検査するのは次の範囲のみであり、任意のワークフローの安全性や生成結果の正しさを 判定するものではない。 - JSONとして読み込めること - KSampler がちょうど1つ存在すること - KSampler の positive / negative の接続先が CLIPTextEncode、latent_image の接続先が EmptyLatentImage であること - ローダーが次のどちらか一方の系統であること (S) CheckpointLoaderSimple が存在し、ckpt_name がプレースホルダではなく .safetensors で終わること (A) UNETLoader / CLIPLoader / VAELoader がちょうど1つずつ存在し (Anima 系の 分割ローダー構成)、各ファイル名が設定済みで、model / clip の共有リンクを たどると各ローダーに行き着き、VAEDecode の vae が VAELoader の出力であること - SaveImage が存在すること 承認済みかどうかは、この検証ではなく「どのファイルを C:\\AI\\workflows に配置したか」という 配置管理で担保する。 使い方 (PC側): python relay_api.py --host 100.x.y.z --port 8190 --workflows-dir C:\\AI\\workflows --host は原則としてTailscale帯域 (100.64.0.0/10) のIPのみ許可する。 ローカル単体テスト用に 127.0.0.1 を使う場合だけ --local-test を付ける。 コンソールコマンド (中継APIを起動したコンソールで入力): status : 投入ゲートの状態とジョブ件数を表示する resume : 状態不明により停止中の投入を再開する。ComfyUIのキュー状態を確認でき、 かつキューが空の場合のみ再開する。状態を確認できない場合は停止を維持する resume force : キューに残件があることを運用者が確認済みの場合に再開する (状態確認自体ができない場合は force でも再開しない) """ import argparse import copy import ipaddress import json import os import queue import secrets import socket import sys import threading import time import urllib.error import urllib.parse import urllib.request import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # ---------------------------------------------------------------- 設定 (承認済みリスト) # ここに無いワークフローID・解像度は受け付けない。 WORKFLOW_FILES = { "noobai_vpred": "noobai_vpred_api.json", # 本命: NoobAI-XL V-Pred 1.0 "wai_nsfw": "wai_nsfw_api.json", # 予備: WAI-NSFW-illustrious-SDXL # Part F: Anima 系 (分割ローダー構成、単段 txt2img) "anima_base": "anima_base_api.json", # 公式 Anima-Base v1.0 "anima_aesthetic": "anima_aesthetic_api.json", # 公式 Anima-Aesthetic v1.1 "anima_janima": "anima_janima_api.json", # JANIMA v1.0 (2B) "anima_janima29": "anima_janima29_api.json", # JANIMA v1.0 2.9B } # NoobAI公式ガイドの推奨解像度 (W x H) のみを許可する。 RESOLUTION_PRESETS = { "768x1344": (768, 1344), "832x1216": (832, 1216), "896x1152": (896, 1152), "1024x1024": (1024, 1024), "1152x896": (1152, 896), "1216x832": (1216, 832), "1344x768": (1344, 768), } MAX_PROMPT_CHARS = 2000 MAX_BODY_BYTES = 64 * 1024 MAX_SEED = 2**64 - 1 ALLOWED_REQUEST_KEYS = {"workflow_id", "prompt", "negative_prompt", "seed", "resolution", "lora"} ALLOWED_LORA_KEYS = {"name", "strength"} LORA_ALLOWLIST_FILE = "lora_allowlist.json" # ワークフローフォルダー内に置く (任意。無ければ lora 指定は全て拒否) LORA_STRENGTH_HARD_MAX = 2.0 # 許可リスト自体の妥当性上限 PLACEHOLDER_MARK = "REPLACE_WITH" # ckpt_name がこの文字列を含むテンプレートは未設定として起動時に拒否する TERMINAL_STATES = {"succeeded", "failed", "unknown"} # ---------------------------------------------------------------- グローバル状態 CONFIG = {} TEMPLATES = {} # workflow_id -> (api_json_dict, node_map) LORA_ALLOWLIST = {} # loraファイル名 -> {"min": float, "max": float, "default": float} JOBS = {} # job_id -> job dict JOBS_LOCK = threading.Lock() JOB_QUEUE = None # main() で上限付きキューとして生成する GATE = {"closed": False, "reason": None} # ComfyUI側の状態不明時に閉じる投入ゲート GATE_LOCK = threading.Lock() GATE_EVENT = threading.Event() # ワーカーの投入可否と連動させる (set = 開放) GATE_EVENT.set() CONN_SEM = None # main() で同時接続数上限のセマフォとして生成する def gate_close(reason): with GATE_LOCK: GATE["closed"] = True GATE["reason"] = reason GATE_EVENT.clear() print(f"[relay] 投入を停止しました: {reason}") print("[relay] ComfyUI側のキューを確認し、対象ジョブの終了・取消を確認してから、コンソールで resume と入力してください") def gate_open(): with GATE_LOCK: GATE["closed"] = False GATE["reason"] = None GATE_EVENT.set() print("[relay] 投入を再開しました") def gate_is_closed(): with GATE_LOCK: return GATE["closed"], GATE["reason"] class ApiError(Exception): def __init__(self, status, code, message): super().__init__(message) self.status = status self.code = code self.message = message class ComfyLostContact(Exception): """ポーリング中にComfyUIとの通信が継続的に失敗した (状態不明の可能性)。""" class ComfyStateError(Exception): """ComfyUIの状態応答 (/queue) の形式が不正で、状態を確認できない。""" class SubmitOutcomeUnknown(Exception): """ComfyUIへの投入の成否を判断できない (通信断・応答喪失・解釈できない応答)。""" class GenerationTimeout(Exception): """制限時間内に完了しなかった (ComfyUI側に自ジョブが残っている可能性)。""" # ---------------------------------------------------------------- テンプレート検証 def load_templates(workflows_dir): """承認済みワークフローを読み込み、冒頭に記載した範囲の構造検証を行う。不正なら起動を中止する。""" for workflow_id, filename in WORKFLOW_FILES.items(): path = os.path.join(workflows_dir, filename) try: with open(path, "r", encoding="utf-8") as f: wf = json.load(f) except FileNotFoundError: raise SystemExit(f"[relay] ワークフローファイルが見つかりません: {path}") except json.JSONDecodeError as e: raise SystemExit(f"[relay] ワークフローJSONが不正です: {path}: {e}") if not isinstance(wf, dict) or not wf: raise SystemExit(f"[relay] ワークフローが空、または形式が不正です: {path}") def norm(link): """リンクを [str(node_id), output_index] に正規化する。不正な形式は None。""" if isinstance(link, list) and len(link) == 2: return [str(link[0]), link[1]] return None def latent_target(nid): link = wf[nid].get("inputs", {}).get("latent_image") n = norm(link) return n[0] if n and n[0] in wf else None all_ksamplers = [nid for nid, n in wf.items() if isinstance(n, dict) and n.get("class_type") == "KSampler"] # メインの KSampler は latent_image が EmptyLatentImage に接続するもの。 # Hires fix の第2パス (任意・最大1つ) はメインと区別して検証する。 mains = [nid for nid in all_ksamplers if latent_target(nid) is not None and wf[latent_target(nid)].get("class_type") == "EmptyLatentImage"] if len(mains) != 1: raise SystemExit( f"[relay] {filename}: EmptyLatentImage に接続するメインの KSampler が" f"ちょうど1つ必要です (検出: {len(mains)})") ks_id = mains[0] others = [nid for nid in all_ksamplers if nid != ks_id] if len(others) > 1: raise SystemExit( f"[relay] {filename}: KSampler はメイン + Hires の最大2つまでです" f" (検出: {len(all_ksamplers)})") ks_inputs = wf[ks_id].get("inputs", {}) def linked_node(input_name, expect_class): link = ks_inputs.get(input_name) if not (isinstance(link, list) and len(link) == 2 and str(link[0]) in wf): raise SystemExit(f"[relay] {filename}: KSampler.{input_name} の接続が不正です") nid = str(link[0]) if wf[nid].get("class_type") != expect_class: raise SystemExit(f"[relay] {filename}: KSampler.{input_name} の接続先は {expect_class} である必要があります") return nid node_map = { "ksampler": ks_id, "positive": linked_node("positive", "CLIPTextEncode"), "negative": linked_node("negative", "CLIPTextEncode"), "latent": linked_node("latent_image", "EmptyLatentImage"), } # LoRAチェーン末尾の検証 (lora パラメータ指定時の挿入位置を確定させるため)。 # MODEL側は KSampler.model の接続元、CLIP側は positive / negative 両エンコーダの # clip 接続元で、両エンコーダが同じ接続元を共有していることまで確認する # (共有していないワークフローには単一の LoraLoader を正しく挿入できない)。 def any_link(node_id, input_name): link = wf[node_id].get("inputs", {}).get(input_name) if not (isinstance(link, list) and len(link) == 2 and str(link[0]) in wf): raise SystemExit(f"[relay] {filename}: ノード {node_id} の {input_name} 接続が不正です") return link node_map["model_link"] = any_link(ks_id, "model") pos_clip = any_link(node_map["positive"], "clip") neg_clip = any_link(node_map["negative"], "clip") if pos_clip != neg_clip: raise SystemExit( f"[relay] {filename}: positive / negative の clip 接続元が一致しません" " (lora パラメータの挿入位置を確定できません)") node_map["clip_link"] = pos_clip # ローダーは2系統のどちらか一方であること (混在は認めない)。 # 系統S (SDXL): CheckpointLoaderSimple。系統A (Anima 等): UNETLoader / # CLIPLoader / VAELoader の分割構成で、model / clip の共有リンクを # LoraLoader 経由でたどると各ローダーに行き着くことまで確認する # (系統S の常駐チェーンと同じ考え方。lora パラメータの挿入位置を # 確定させるための要件)。 split_ids = {} for kind in ("UNETLoader", "CLIPLoader", "VAELoader"): split_ids[kind] = [nid for nid, n in wf.items() if isinstance(n, dict) and n.get("class_type") == kind] loaders = [n for n in wf.values() if isinstance(n, dict) and n.get("class_type") == "CheckpointLoaderSimple"] if any(split_ids.values()): if loaders: raise SystemExit( f"[relay] {filename}: CheckpointLoaderSimple と分割ローダーが" "混在しています") for kind, ids in split_ids.items(): if len(ids) != 1: raise SystemExit( f"[relay] {filename}: {kind} はちょうど1つ必要です" f" (検出: {len(ids)})") def loader_file(nid, key): v = str(wf[nid].get("inputs", {}).get(key, "")) if PLACEHOLDER_MARK in v or not v.endswith(".safetensors"): raise SystemExit( f"[relay] {filename}: {key} が未設定です ('{v}')。" "ダウンロードした実ファイル名に置き換えてください") return v unet_id = split_ids["UNETLoader"][0] clip_id = split_ids["CLIPLoader"][0] vae_id = split_ids["VAELoader"][0] loader_file(unet_id, "unet_name") loader_file(clip_id, "clip_name") loader_file(vae_id, "vae_name") def chain_root(link, input_key, want_index): """LoraLoader を input_key 側へたどり、終端リンクを返す。不正は None。""" cur = norm(link) for _ in range(len(wf) + 1): if cur is None or cur[0] not in wf: return None if wf[cur[0]].get("class_type") != "LoraLoader": return cur if cur[1] != want_index: return None cur = norm(wf[cur[0]].get("inputs", {}).get(input_key)) return None if chain_root(node_map["model_link"], "model", 0) != [unet_id, 0]: raise SystemExit( f"[relay] {filename}: model の共有リンクは UNETLoader の" "出力に接続してください") if chain_root(node_map["clip_link"], "clip", 1) != [clip_id, 0]: raise SystemExit( f"[relay] {filename}: clip の共有リンクは CLIPLoader の" "出力に接続してください") node_map["vae_link"] = [vae_id, 0] else: if not loaders: raise SystemExit(f"[relay] {filename}: CheckpointLoaderSimple がありません") for ld in loaders: ckpt = str(ld.get("inputs", {}).get("ckpt_name", "")) if PLACEHOLDER_MARK in ckpt or not ckpt.endswith(".safetensors"): raise SystemExit( f"[relay] {filename}: ckpt_name が未設定です ('{ckpt}')。" "ダウンロードした実ファイル名に置き換えてください") # Hires fix の第2パス (任意): メインと model / positive / negative の接続元を # 共有し、latent はメイン出力への LatentUpscale 系からのみ取ることを確認する # (プロンプト注入と lora 挿入が第2パスにも同じ条件で届くための要件)。 sampler_out = ks_id if others: hs_id = others[0] hs_inputs = wf[hs_id].get("inputs", {}) for key in ("model", "positive", "negative"): if norm(hs_inputs.get(key)) != norm(ks_inputs.get(key)): raise SystemExit( f"[relay] {filename}: Hires側 KSampler の {key} 接続が" "メインと一致しません") up_id = latent_target(hs_id) if up_id is None or wf[up_id].get("class_type") not in ( "LatentUpscaleBy", "LatentUpscale"): raise SystemExit( f"[relay] {filename}: Hires側 KSampler の latent_image は" " LatentUpscale 系に接続してください") up_samples = norm(wf[up_id].get("inputs", {}).get("samples")) if up_samples != [ks_id, 0]: raise SystemExit( f"[relay] {filename}: LatentUpscale 系の samples は" "メイン KSampler の出力に接続してください") node_map["hires"] = hs_id sampler_out = hs_id # VAEDecode はちょうど1つで、入力は最終サンプラー (Hires があればその出力)。 decoders = [nid for nid, n in wf.items() if isinstance(n, dict) and n.get("class_type") == "VAEDecode"] if len(decoders) != 1: raise SystemExit( f"[relay] {filename}: VAEDecode はちょうど1つ必要です (検出: {len(decoders)})") dec_id = decoders[0] if norm(wf[dec_id].get("inputs", {}).get("samples")) != [sampler_out, 0]: raise SystemExit( f"[relay] {filename}: VAEDecode の samples は最終サンプラーの" "出力に接続してください") if "vae_link" in node_map: if norm(wf[dec_id].get("inputs", {}).get("vae")) != node_map["vae_link"]: raise SystemExit( f"[relay] {filename}: VAEDecode の vae は VAELoader の" "出力に接続してください") # FaceDetailer (任意・最大1つ): model / clip は共有リンクと一致し、 # positive / negative はメイン両エンコーダの出力、image は VAEDecode の # 出力であること (再描画にもメインプロンプトとキャラLoRAが届くための要件)。 detailers = [nid for nid, n in wf.items() if isinstance(n, dict) and n.get("class_type") == "FaceDetailer"] if len(detailers) > 1: raise SystemExit( f"[relay] {filename}: FaceDetailer は最大1つまでです (検出: {len(detailers)})") final_image = [dec_id, 0] if detailers: fd_id = detailers[0] fd_inputs = wf[fd_id].get("inputs", {}) def fd_require(key, want, label): if norm(fd_inputs.get(key)) != want: raise SystemExit( f"[relay] {filename}: FaceDetailer の {key} 接続が不正です" f" ({label})") fd_require("model", norm(node_map["model_link"]), "model は共有リンクと一致") fd_require("clip", norm(node_map["clip_link"]), "clip は共有リンクと一致") fd_require("positive", [node_map["positive"], 0], "positive はメインのエンコーダ出力") fd_require("negative", [node_map["negative"], 0], "negative はメインのエンコーダ出力") fd_require("image", [dec_id, 0], "image は VAEDecode の出力") det = fd_inputs.get("bbox_detector") det_n = norm(det) if det_n is None or det_n[0] not in wf or \ wf[det_n[0]].get("class_type") != "UltralyticsDetectorProvider": raise SystemExit( f"[relay] {filename}: FaceDetailer の bbox_detector は" " UltralyticsDetectorProvider に接続してください") node_map["facedetailer"] = fd_id final_image = [fd_id, 0] savers = [n for n in wf.values() if isinstance(n, dict) and n.get("class_type") == "SaveImage"] if not savers: raise SystemExit(f"[relay] {filename}: SaveImage がありません") if not any(norm(n.get("inputs", {}).get("images")) == norm(final_image) for n in savers): raise SystemExit( f"[relay] {filename}: SaveImage の images は最終段" " (FaceDetailer または VAEDecode) の出力に接続してください") TEMPLATES[workflow_id] = (wf, node_map) print(f"[relay] ワークフローを登録しました: {workflow_id} ({filename})") def load_lora_allowlist(workflows_dir): """キャラLoRAの許可リスト (lora_allowlist.json) を読み込み、厳密に検証する。 ファイルが無い場合は許可リストは空 (lora 指定は全て unknown_lora で拒否) で起動する。 ファイルがあるのに形式が不正な場合は、黙って無効化せず起動を中止する (fail closed)。 形式: {"<ファイル名.safetensors>": {"min": 0.0, "max": 1.2, "default": 1.0}, ...} """ path = os.path.join(workflows_dir, LORA_ALLOWLIST_FILE) if not os.path.exists(path): print(f"[relay] {LORA_ALLOWLIST_FILE} が無いため、lora 指定は受け付けません") return try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) except json.JSONDecodeError as e: raise SystemExit(f"[relay] {LORA_ALLOWLIST_FILE} のJSONが不正です: {e}") if not isinstance(data, dict): raise SystemExit(f"[relay] {LORA_ALLOWLIST_FILE} はオブジェクト形式で記述してください") for name, entry in data.items(): if (not isinstance(name, str) or not name.endswith(".safetensors") or "/" in name or "\\" in name): raise SystemExit(f"[relay] {LORA_ALLOWLIST_FILE}: 不正なLoRAファイル名です: {name!r}") if not isinstance(entry, dict) or set(entry.keys()) != {"min", "max", "default"}: raise SystemExit( f"[relay] {LORA_ALLOWLIST_FILE}: {name} の項目は min / max / default の3キーで記述してください") vals = {} for k in ("min", "max", "default"): v = entry[k] if type(v) not in (int, float): # bool は数値として扱わない raise SystemExit(f"[relay] {LORA_ALLOWLIST_FILE}: {name}.{k} は数値で指定してください") vals[k] = float(v) if not (0.0 <= vals["min"] <= vals["default"] <= vals["max"] <= LORA_STRENGTH_HARD_MAX): raise SystemExit( f"[relay] {LORA_ALLOWLIST_FILE}: {name} は 0 <= min <= default <= max <= {LORA_STRENGTH_HARD_MAX} を満たしてください") LORA_ALLOWLIST[name] = vals print(f"[relay] LoRAを許可リストに登録しました: {name} " f"(min={vals['min']}, max={vals['max']}, default={vals['default']})") def build_prompt(job_id, workflow_id, prompt_text, negative_text, seed, preset_name, lora_spec=None): """テンプレートに許可されたパラメータだけを注入したワークフローJSONを返す。""" template, node_map = TEMPLATES[workflow_id] wf = copy.deepcopy(template) width, height = RESOLUTION_PRESETS[preset_name] wf[node_map["ksampler"]]["inputs"]["seed"] = seed wf[node_map["positive"]]["inputs"]["text"] = prompt_text if negative_text is not None: wf[node_map["negative"]]["inputs"]["text"] = negative_text latent = wf[node_map["latent"]]["inputs"] latent["width"] = width latent["height"] = height latent["batch_size"] = 1 for node in wf.values(): if isinstance(node, dict) and node.get("class_type") == "SaveImage": node["inputs"]["filename_prefix"] = f"relay_{job_id}" if lora_spec is not None: # キャラLoRAを常駐チェーンの末尾に固定挿入する。共有リンク (model_link / # clip_link) を参照している全ての入力を、新しい LoraLoader 経由に付け替える。 # これにより、メイン KSampler と両 CLIPTextEncode だけでなく、同じ共有 # リンクを使う Hires 側 KSampler や FaceDetailer にもキャラLoRAが伝播する # (起動時検証で、それらの接続が共有リンクと一致することを確認済み)。 # リンクの付け替えだけなので、テンプレート側のチェーン段数には依存しない。 model_link = node_map["model_link"] clip_link = node_map["clip_link"] def is_link(value, target): return (isinstance(value, list) and len(value) == 2 and str(value[0]) == str(target[0]) and value[1] == target[1]) new_id = 1 while str(new_id) in wf: new_id += 1 new_id = str(new_id) for node in wf.values(): if not isinstance(node, dict): continue inputs = node.get("inputs") if not isinstance(inputs, dict): continue for key, value in inputs.items(): if is_link(value, model_link): inputs[key] = [new_id, 0] elif is_link(value, clip_link): inputs[key] = [new_id, 1] wf[new_id] = { "class_type": "LoraLoader", "inputs": { "lora_name": lora_spec["name"], "strength_model": lora_spec["strength"], "strength_clip": lora_spec["strength"], "model": model_link, "clip": clip_link, }, } return wf # ---------------------------------------------------------------- ComfyUI連携 (127.0.0.1固定) def comfy_request(method, path, payload=None, timeout=30): url = CONFIG["comfy_base"] + path data = None headers = {} if payload is not None: data = json.dumps(payload).encode("utf-8") headers["Content-Type"] = "application/json" req = urllib.request.Request(url, data=data, headers=headers, method=method) with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.status, resp.headers.get("Content-Type", ""), resp.read() # ComfyUI の POST /prompt が検証失敗時に返す error.type の既知の値。 # ノード単位の検証失敗は "prompt_outputs_failed_validation"、 # 出力なしや存在しないノード種別などプロンプト全体の検証失敗は # "prompt_no_outputs" / "invalid_prompt" で、node_errors は空 {} になり得る。 _KNOWN_REJECTION_ERROR_TYPES = { "prompt_outputs_failed_validation", "prompt_no_outputs", "invalid_prompt", } def _is_explicit_rejection(status, body_bytes): """ComfyUIの既知の検証エラー応答かどうかを判定する。 ComfyUIはワークフローの検証エラー時に HTTP 400 で {"error": {"type": ..., "message": ..., "details": ..., "extra_info": {...}}, "node_errors": {...}} を返す。次の両方を確認できた場合のみ「明確な投入拒否」と判定する: - error が既知の error.type を持つオブジェクトであること - node_errors がオブジェクトとして存在すること (空 {} でもよい。 ノード単位の失敗が無い全体検証エラーでは空になる) それ以外のHTTPエラーや解釈できない応答は、投入の成否を判断できないもの として扱う (一律に失敗へは変えない)。 """ if status != 400: return False try: data = json.loads(body_bytes.decode("utf-8")) except Exception: return False if not isinstance(data, dict): return False error = data.get("error") if not isinstance(error, dict): return False if error.get("type") not in _KNOWN_REJECTION_ERROR_TYPES: return False return isinstance(data.get("node_errors"), dict) def comfy_submit(workflow): """ワークフローを投入し prompt_id を返す。 - HTTP 200 で有効な prompt_id が返れば受理済みとして扱い、IDを保持して 完了まで追跡する。node_errors が同時に返っていても、それは一部の出力系統の 検証失敗を示すだけで、正常な系統はキューに登録され生成が走るため、 未投入・失敗とは扱わない - 明確な検証エラー (HTTP 400 + 既知の error.type + node_errors 構造) は ApiError (comfy_node_error) として送出する -> ジョブは failed になり、後続は継続する。 prompt_id の無い 200 応答で node_errors がある場合も同様に拒否として扱う - 通信断・応答喪失・解釈できない応答は SubmitOutcomeUnknown を送出する -> ジョブは unknown になり、投入ゲートが閉じる """ try: _, _, body = comfy_request( "POST", "/prompt", {"prompt": workflow, "client_id": CONFIG["client_id"]}, timeout=30) except urllib.error.HTTPError as e: try: raw = e.read() except Exception: raw = b"" if _is_explicit_rejection(e.code, raw): raise ApiError(502, "comfy_node_error", "ComfyUIがワークフローの検証エラーで投入を拒否しました") raise SubmitOutcomeUnknown(f"HTTP {e.code} (投入の成否を判断できない応答)") except Exception as e: raise SubmitOutcomeUnknown(type(e).__name__) try: result = json.loads(body.decode("utf-8")) except Exception: raise SubmitOutcomeUnknown("/prompt の応答を解釈できません") if not isinstance(result, dict): raise SubmitOutcomeUnknown("/prompt の応答形式が不正です") prompt_id = result.get("prompt_id") if isinstance(prompt_id, str) and prompt_id: # 受理済み。一部の出力系統の検証失敗 (node_errors) が併記されていても、 # 正常な系統は生成が走るため、prompt_id を保持して完了まで追跡する return prompt_id if result.get("node_errors"): raise ApiError(502, "comfy_node_error", "ComfyUIがワークフローを拒否しました") raise SubmitOutcomeUnknown("/prompt の応答に prompt_id がありません") def comfy_history(prompt_id): _, _, body = comfy_request("GET", f"/history/{prompt_id}", timeout=10) history = json.loads(body.decode("utf-8")) return history.get(prompt_id) def comfy_queue_state(): """GET /queue の応答を検証し、(running_ids, pending_ids) を返す。 検証内容: 応答がJSONオブジェクトであること、queue_running / queue_pending の 両キーが存在し配列であること、各要素が [number, prompt_id, ...] 形式の配列で prompt_id が空でない文字列であること。 形式が不正な場合は「空」と解釈せず ComfyStateError を送出する (状態確認失敗)。 起動時・resume・取消確認で共通の判定として使う。 """ _, _, body = comfy_request("GET", "/queue", timeout=10) try: data = json.loads(body.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): raise ComfyStateError("/queue の応答がJSONではありません") if not isinstance(data, dict): raise ComfyStateError("/queue の応答がオブジェクトではありません") result = [] for key in ("queue_running", "queue_pending"): if key not in data: raise ComfyStateError(f"/queue の応答に {key} がありません") entries = data[key] if not isinstance(entries, list): raise ComfyStateError(f"/queue の {key} が配列ではありません") ids = set() for item in entries: if (not isinstance(item, list) or len(item) < 2 or not isinstance(item[1], str) or not item[1]): raise ComfyStateError(f"/queue の {key} に不正な要素があります") ids.add(item[1]) result.append(ids) return result[0], result[1] def comfy_queue_ids(): """ComfyUIのキューにある prompt_id の集合 (実行中 + 待機中) を返す。""" running, pending = comfy_queue_state() return running | pending def extract_output(entry): for node_out in (entry.get("outputs") or {}).values(): images = node_out.get("images") or [] if images: img = images[0] return {"filename": img["filename"], "subfolder": img.get("subfolder", ""), "type": img.get("type", "output")} return None def comfy_poll(prompt_id, timeout_sec): """完了まで /history をポーリングする。 - status_str == "error" は completed の真偽より先に判定する (ComfyUIは失敗時に completed=false, status_str="error" を返すため)。 - 通信失敗がしきい値以上続いたら ComfyLostContact、制限時間を超えたら GenerationTimeout。 """ deadline = time.time() + timeout_sec failures = 0 while time.time() < deadline: try: entry = comfy_history(prompt_id) failures = 0 except Exception: failures += 1 if failures >= CONFIG["comfy_fail_threshold"]: raise ComfyLostContact(prompt_id) time.sleep(CONFIG["poll_interval"]) continue if entry: status = entry.get("status", {}) if status.get("status_str") == "error": raise ApiError(502, "generation_failed", "ComfyUI側で生成がエラーになりました") if status.get("completed"): output = extract_output(entry) if output is None: raise ApiError(502, "no_output", "生成が完了しましたが画像出力がありません") return output time.sleep(CONFIG["poll_interval"]) raise GenerationTimeout(prompt_id) def try_cancel_and_verify(prompt_id): """自ジョブ (prompt_id) の状態確定を試み、確定したら True を返す。 取消手段は「待機中ジョブへの prompt_id 指定 delete」だけに限定する。 - 待機中 (queue_pending にある) 場合のみ、/queue の delete で当該IDのみ削除する - 実行中 (queue_running にある) 場合は取り消さない。対象を指定できない全体割り込み (/interrupt) は、確認と実行の間に実行ジョブが交代していた場合に無関係なジョブを 止めてしまうため使わない -> False を返し、状態不明として扱う - 無関係なジョブの一括停止 (clear 等) は行わない 確認できない場合は False (状態不明として扱う)。 """ try: running, pending = comfy_queue_state() # 形式不正は状態確認失敗として例外になる if prompt_id in running: return False # 実行中: 安全な取消手段が無いため状態不明にする if prompt_id in pending: comfy_request("POST", "/queue", {"delete": [prompt_id]}, timeout=10) # 確認: キューから消え、履歴が「完了 (成功/失敗)」または「履歴なし (未実行のまま削除)」であれば確定 time.sleep(1.0) if prompt_id in comfy_queue_ids(): return False entry = comfy_history(prompt_id) if entry is None: return True # 未実行のまま取り消された status = entry.get("status", {}) if status.get("status_str") == "error" or status.get("completed"): return True return False except Exception: return False def comfy_fetch_image(output): query = urllib.parse.urlencode( {"filename": output["filename"], "subfolder": output["subfolder"], "type": output["type"]}) _, content_type, body = comfy_request("GET", f"/view?{query}", timeout=60) return content_type or "image/png", body # ---------------------------------------------------------------- ジョブ管理 def finish_job(job, state, error=None, output=None): """終端状態への遷移。プロンプトはこの時点で記録から除去する。""" with JOBS_LOCK: job["state"] = state job["error"] = error if output is not None: job["output"] = output job["finished_at"] = time.time() job["prompt"] = None job["negative_prompt"] = None def mark_unknown(job, code, message): finish_job(job, "unknown", {"code": code, "message": message}) gate_close(f"job {job['job_id']} のComfyUI側状態が不明です ({code})") def process_job(job): """1ジョブの投入・監視・状態確定を行う。task_done は呼び出し側 (worker_loop) だけが行う。""" job_id = job["job_id"] try: workflow = build_prompt(job_id, job["workflow_id"], job["prompt"], job["negative_prompt"], job["seed"], job["resolution"], job.get("lora")) try: prompt_id = comfy_submit(workflow) except ApiError: raise # 明確な投入拒否 (検証エラー) は failed として処理する except SubmitOutcomeUnknown: # /prompt の応答が失われた等: 投入されたかどうか自体が不明。 # prompt_id が無いため自動照合はできない -> 状態不明として投入を止める。 mark_unknown(job, "submit_outcome_unknown", "ComfyUIへの投入結果を確認できませんでした") return with JOBS_LOCK: job["prompt_id"] = prompt_id output = comfy_poll(prompt_id, CONFIG["job_timeout"]) finish_job(job, "succeeded", output=output) print(f"[relay] job {job_id} succeeded (workflow={job['workflow_id']}, preset={job['resolution']})") except ApiError as e: finish_job(job, "failed", {"code": e.code, "message": e.message}) print(f"[relay] job {job_id} failed: {e.code}") except GenerationTimeout: # 待機中ジョブの取消 (prompt_id指定delete) が確認できれば失敗として確定し、後続投入は継続する。 # 実行中ジョブは安全な取消手段が無いため、確認できない場合は状態不明にする。 if try_cancel_and_verify(job["prompt_id"]): finish_job(job, "failed", {"code": "generation_timeout", "message": "生成が制限時間内に完了せず、対象ジョブの取消を確認しました"}) print(f"[relay] job {job_id} failed: generation_timeout (cancel verified)") else: mark_unknown(job, "generation_timeout_unresolved", "生成がタイムアウトし、対象ジョブの終了・取消を確認できませんでした") except ComfyLostContact: # 通信が回復した後の照合で確定できなければ状態不明にする。 if job.get("prompt_id") and try_cancel_and_verify(job["prompt_id"]): finish_job(job, "failed", {"code": "comfy_contact_lost", "message": "ComfyUIとの通信が途絶しましたが、対象ジョブの終了・取消を確認しました"}) print(f"[relay] job {job_id} failed: comfy_contact_lost (resolved)") else: mark_unknown(job, "comfy_contact_lost", "ComfyUIとの通信が途絶し、対象ジョブの状態を確認できませんでした") except Exception as e: finish_job(job, "failed", {"code": "internal_error", "message": f"中継API内部でエラーが発生しました ({type(e).__name__})"}) print(f"[relay] job {job_id} failed: internal_error ({type(e).__name__})") def worker_loop(): while True: job_id = JOB_QUEUE.get() try: with JOBS_LOCK: job = JOBS.get(job_id) if job is None: continue # ゲートが閉じている間は、待機済みジョブであってもComfyUIへ投入しない。 # ジョブは queued のまま保持され、ゲート再開後に処理される。 GATE_EVENT.wait() with JOBS_LOCK: job["state"] = "running" process_job(job) finally: JOB_QUEUE.task_done() def cleanup_loop(): """保持期限を過ぎた終端ジョブの削除と、記録件数の上限管理。""" while True: time.sleep(CONFIG["cleanup_interval"]) now = time.time() with JOBS_LOCK: expired = [jid for jid, j in JOBS.items() if j["state"] in TERMINAL_STATES and j["finished_at"] and now - j["finished_at"] > CONFIG["job_retention"]] for jid in expired: del JOBS[jid] overflow = len(JOBS) - CONFIG["max_job_records"] if overflow > 0: finished = sorted( (j for j in JOBS.values() if j["state"] in TERMINAL_STATES and j["finished_at"]), key=lambda j: j["finished_at"]) for j in finished[:overflow]: del JOBS[j["job_id"]] if expired: print(f"[relay] 保持期限を過ぎたジョブ記録を削除しました: {len(expired)}件") # ---------------------------------------------------------------- 入力検証 def validate_text(value, field, required): if value is None: if required: raise ApiError(400, "missing_field", f"{field} が必要です") return None if not isinstance(value, str): raise ApiError(400, "invalid_type", f"{field} は文字列で指定してください") if required and len(value) == 0: raise ApiError(400, "empty_field", f"{field} が空です") if len(value) > MAX_PROMPT_CHARS: raise ApiError(400, "too_long", f"{field} は {MAX_PROMPT_CHARS} 文字以内で指定してください") if any(ord(c) < 32 or ord(c) == 127 for c in value): raise ApiError(400, "invalid_characters", f"{field} に制御文字は使えません") return value def validate_generate_request(obj): """型検証を辞書検索や特殊値判定より前に行う。不正入力は全て ApiError(400) にする。""" if not isinstance(obj, dict): raise ApiError(400, "invalid_body", "JSONオブジェクトで送信してください") unknown = set(obj.keys()) - ALLOWED_REQUEST_KEYS if unknown: raise ApiError(400, "unknown_field", f"許可されていない項目があります: {', '.join(sorted(map(str, unknown)))}") workflow_id = obj.get("workflow_id") if workflow_id is None: raise ApiError(400, "missing_field", "workflow_id が必要です") if not isinstance(workflow_id, str): raise ApiError(400, "invalid_type", "workflow_id は文字列で指定してください") if workflow_id not in TEMPLATES: raise ApiError(400, "unknown_workflow", "許可されていないワークフローです") prompt = validate_text(obj.get("prompt"), "prompt", required=True) negative = validate_text(obj.get("negative_prompt"), "negative_prompt", required=False) seed = obj.get("seed") if seed is None: seed = secrets.randbelow(2**63) elif type(seed) is not int: # bool や float (例: -1.0) は整数として扱わない raise ApiError(400, "invalid_seed", "seed は整数で指定してください") elif seed == -1: seed = secrets.randbelow(2**63) elif seed < 0 or seed > MAX_SEED: raise ApiError(400, "invalid_seed", f"seed は 0 から {MAX_SEED} の範囲で指定してください") resolution = obj.get("resolution", "1024x1024") if not isinstance(resolution, str): raise ApiError(400, "invalid_type", "resolution は文字列で指定してください") if resolution not in RESOLUTION_PRESETS: raise ApiError(400, "unknown_resolution", "許可されていない解像度プリセットです") lora_spec = validate_lora(obj.get("lora")) return workflow_id, prompt, negative, seed, resolution, lora_spec def validate_lora(lora): """任意項目 lora {name, strength} を検証し、{name, strength} に正規化して返す。 - name は許可リストのキー (ファイル名) と完全一致した場合のみ受理する - strength 省略時は許可リストの default、範囲外は min/max にクランプする """ if lora is None: return None if not isinstance(lora, dict): raise ApiError(400, "invalid_type", "lora はオブジェクトで指定してください") unknown = set(lora.keys()) - ALLOWED_LORA_KEYS if unknown: raise ApiError(400, "unknown_field", f"lora に許可されていない項目があります: {', '.join(sorted(map(str, unknown)))}") name = lora.get("name") if name is None: raise ApiError(400, "missing_field", "lora.name が必要です") if not isinstance(name, str): raise ApiError(400, "invalid_type", "lora.name は文字列で指定してください") entry = LORA_ALLOWLIST.get(name) if entry is None: raise ApiError(400, "unknown_lora", "許可リストに無いLoRAです") strength = lora.get("strength") if strength is None: strength = entry["default"] elif type(strength) not in (int, float): # bool や文字列は数値として扱わない raise ApiError(400, "invalid_type", "lora.strength は数値で指定してください") strength = min(max(float(strength), entry["min"]), entry["max"]) return {"name": name, "strength": strength} # ---------------------------------------------------------------- HTTPサーバ (接続資源の制限付き) class RelayHTTPServer(ThreadingHTTPServer): daemon_threads = True request_queue_size = 128 def process_request(self, request, client_address): if CONN_SEM is not None and not CONN_SEM.acquire(blocking=False): # 同時接続数の上限超過: 新規接続は受け付けずに閉じる try: request.shutdown(socket.SHUT_RDWR) except OSError: pass request.close() return try: super().process_request(request, client_address) except Exception: if CONN_SEM is not None: CONN_SEM.release() raise def process_request_thread(self, request, client_address): try: super().process_request_thread(request, client_address) finally: if CONN_SEM is not None: CONN_SEM.release() class RelayHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" server_version = "RelayAPI/2.0" def setup(self): super().setup() # 遅い送信・接続放置に対する読み書きタイムアウト self.connection.settimeout(CONFIG["socket_timeout"]) def log_message(self, fmt, *args): # プロンプト等の内容はログに残さない sys.stderr.write("[relay] %s %s\n" % (self.command, self.path.split("?")[0])) # --- 共通応答 (応答後は接続を再利用しない: 未読の本文を次リクエストに持ち込ませない) def send_json(self, status, obj): body = json.dumps(obj, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Connection", "close") self.end_headers() self.wfile.write(body) self.close_connection = True def send_error_json(self, e): self.send_json(e.status, {"error": {"code": e.code, "message": e.message}}) def read_json_body(self): length = self.headers.get("Content-Length") if length is None: raise ApiError(400, "invalid_body", "Content-Length がありません") try: length = int(length) except ValueError: raise ApiError(400, "invalid_body", "Content-Length が不正です") if length <= 0 or length > MAX_BODY_BYTES: raise ApiError(400, "body_too_large", "リクエストボディが大きすぎます") raw = self.rfile.read(length) if len(raw) != length: raise ApiError(400, "invalid_body", "リクエストボディが途中で切断されました") try: return json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): raise ApiError(400, "invalid_json", "JSONとして解釈できません") # --- ルーティング def do_GET(self): try: path = urllib.parse.urlparse(self.path).path if path == "/health": self.handle_health() elif path.startswith("/jobs/") and path.endswith("/image"): self.handle_job_image(path[len("/jobs/"):-len("/image")].strip("/")) elif path.startswith("/jobs/"): self.handle_job_status(path[len("/jobs/"):].strip("/")) else: raise ApiError(404, "not_found", "存在しないパスです") except ApiError as e: self.send_error_json(e) except (BrokenPipeError, ConnectionResetError, socket.timeout): pass def do_POST(self): try: path = urllib.parse.urlparse(self.path).path if path == "/generate": self.handle_generate() else: raise ApiError(404, "not_found", "存在しないパスです") except ApiError as e: self.send_error_json(e) except (BrokenPipeError, ConnectionResetError, socket.timeout): pass def do_PUT(self): self.send_error_json(ApiError(405, "method_not_allowed", "許可されていないメソッドです")) do_DELETE = do_PUT do_PATCH = do_PUT # --- ハンドラ本体 def handle_health(self): closed, reason = gate_is_closed() try: comfy_request("GET", "/system_stats", timeout=3) comfy_state = "ok" except Exception: comfy_state = "unreachable" body = {"status": "suspended" if closed else "ok", "comfy": comfy_state, "submit_gate": "closed" if closed else "open", "gate_reason": reason, "workflows": sorted(TEMPLATES.keys()), "loras": sorted(LORA_ALLOWLIST.keys()), "queue_depth": JOB_QUEUE.qsize() if JOB_QUEUE else 0} self.send_json(200 if comfy_state == "ok" else 503, body) def handle_generate(self): closed, _ = gate_is_closed() if closed: raise ApiError(503, "submit_suspended", "ComfyUI側の状態が不明なジョブがあるため、新規投入を停止しています") obj = self.read_json_body() workflow_id, prompt, negative, seed, resolution, lora_spec = validate_generate_request(obj) job_id = uuid.uuid4().hex job = {"job_id": job_id, "workflow_id": workflow_id, "resolution": resolution, "seed": seed, "prompt": prompt, "negative_prompt": negative, "lora": lora_spec, "state": "queued", "created_at": time.time(), "finished_at": None, "error": None, "output": None, "prompt_id": None} with JOBS_LOCK: JOBS[job_id] = job try: JOB_QUEUE.put_nowait(job_id) # 上限付きキュー: 競合時も上限を超えて受理しない except queue.Full: with JOBS_LOCK: JOBS.pop(job_id, None) # 拒否したジョブは記録に残さない raise ApiError(429, "queue_full", "待ち行列が上限に達しています") print(f"[relay] job {job_id} accepted (workflow={workflow_id}, preset={resolution})") self.send_json(202, {"job_id": job_id, "state": "queued", "workflow_id": workflow_id, "resolution": resolution, "seed": seed}) def get_job_or_404(self, job_id): with JOBS_LOCK: job = JOBS.get(job_id) if job is None: raise ApiError(404, "job_not_found", "この中継APIが発行したジョブではありません") return job def handle_job_status(self, job_id): job = self.get_job_or_404(job_id) self.send_json(200, {"job_id": job["job_id"], "state": job["state"], "workflow_id": job["workflow_id"], "resolution": job["resolution"], "seed": job["seed"], "lora": job.get("lora"), "created_at": job["created_at"], "finished_at": job["finished_at"], "error": job["error"]}) def handle_job_image(self, job_id): job = self.get_job_or_404(job_id) if job["state"] != "succeeded" or not job["output"]: raise ApiError(409, "job_not_ready", "ジョブが完了していないため画像を取得できません") try: content_type, body = comfy_fetch_image(job["output"]) except urllib.error.HTTPError as e: # 画像の消失と上流の異常を区別する。いずれも生成ジョブの再投入はしない if e.code == 404: raise ApiError(404, "image_not_found", "生成画像が見つかりません (ComfyUI側で削除された可能性があります)") raise ApiError(502, "comfy_error", f"ComfyUIが画像取得にエラーを返しました (HTTP {e.code})") except Exception: raise ApiError(502, "comfy_unreachable", "ComfyUIへ接続できないため画像を取得できません") self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.send_header("Connection", "close") self.end_headers() self.wfile.write(body) self.close_connection = True # ---------------------------------------------------------------- コンソールコマンド def console_loop(): """PC側コンソールからの運用コマンド (status / resume)。""" while True: try: line = input() except (EOFError, KeyboardInterrupt): return cmd = line.strip().lower() if cmd == "status": closed, reason = gate_is_closed() with JOBS_LOCK: counts = {} for j in JOBS.values(): counts[j["state"]] = counts.get(j["state"], 0) + 1 print(f"[relay] submit_gate: {'closed (' + str(reason) + ')' if closed else 'open'} / jobs: {counts}") elif cmd in ("resume", "resume force"): closed, _ = gate_is_closed() if not closed: print("[relay] 投入は停止していません") continue # 状態を確認できない場合は、force でも再開しない (停止を維持する) try: ids = comfy_queue_ids() except Exception: print("[relay] ComfyUIの状態を確認できないため、停止を維持します") continue if ids and cmd != "resume force": print(f"[relay] ComfyUIのキューに残件があります: {sorted(ids)}") print("[relay] 内容を確認済みで再開する場合は resume force と入力してください (停止を維持しています)") continue gate_open() elif cmd: print("[relay] 使用できるコマンド: status / resume / resume force") # ---------------------------------------------------------------- 起動 def parse_args(): p = argparse.ArgumentParser(description="Muse -> ComfyUI 限定中継API") p.add_argument("--host", required=True, help="待ち受けIP (Tailscale帯域 100.64.0.0/10 のみ許可)") p.add_argument("--port", type=int, default=8190, help="待ち受けポート (既定: 8190)") p.add_argument("--comfy", default="http://127.0.0.1:8188", help="ComfyUIのURL (127.0.0.1 のみ許可。既定: http://127.0.0.1:8188)") p.add_argument("--workflows-dir", required=True, help="承認済みワークフローJSONのフォルダー") p.add_argument("--max-queue", type=int, default=4, help="待ち行列の上限 (既定: 4)") p.add_argument("--job-timeout", type=int, default=900, help="1ジョブの生成タイムアウト秒 (既定: 900)") p.add_argument("--poll-interval", type=float, default=2.0, help="状態確認の間隔秒 (既定: 2.0)") p.add_argument("--comfy-fail-threshold", type=int, default=5, help="状態不明と判定する連続通信失敗回数 (既定: 5)") p.add_argument("--job-retention", type=int, default=3600, help="終端ジョブ記録の保持秒数 (既定: 3600)") p.add_argument("--max-job-records", type=int, default=100, help="ジョブ記録の最大件数 (既定: 100)") p.add_argument("--cleanup-interval", type=int, default=60, help="保持期限チェックの間隔秒 (既定: 60)") p.add_argument("--socket-timeout", type=float, default=15.0, help="接続の読み書きタイムアウト秒 (既定: 15)") p.add_argument("--max-connections", type=int, default=32, help="同時接続数の上限 (既定: 32)") p.add_argument("--local-test", action="store_true", help="127.0.0.1 へのバインドを許可する (単体テスト用)") args = p.parse_args() try: ip = ipaddress.ip_address(args.host) except ValueError: p.error(f"--host がIPアドレスではありません: {args.host}") in_tailnet = ip in ipaddress.ip_network("100.64.0.0/10") if not in_tailnet and not (args.local_test and ip.is_loopback): p.error("--host はTailscale帯域 (100.64.0.0/10) のIPを指定してください。" "0.0.0.0 や LAN のIPへのバインドは許可しません") comfy = urllib.parse.urlparse(args.comfy) if comfy.scheme != "http" or comfy.hostname != "127.0.0.1": p.error("--comfy は http://127.0.0.1: の形式のみ許可します") if not (1 <= args.port <= 65535): p.error("--port が範囲外です") if args.max_queue < 1: p.error("--max-queue は1以上にしてください") return args def main(): global JOB_QUEUE, CONN_SEM args = parse_args() CONFIG.update({ "comfy_base": f"http://127.0.0.1:{urllib.parse.urlparse(args.comfy).port or 8188}", "client_id": "claude-relay-" + uuid.uuid4().hex[:12], "job_timeout": args.job_timeout, "poll_interval": args.poll_interval, "comfy_fail_threshold": args.comfy_fail_threshold, "job_retention": args.job_retention, "max_job_records": args.max_job_records, "cleanup_interval": args.cleanup_interval, "socket_timeout": args.socket_timeout, }) JOB_QUEUE = queue.Queue(maxsize=args.max_queue) CONN_SEM = threading.Semaphore(args.max_connections) load_templates(args.workflows_dir) load_lora_allowlist(args.workflows_dir) # 起動時にComfyUIのキューに残件がある場合は、前インスタンスの残りかGUIのジョブか # 区別できないため、運用者が確認するまで投入を停止する (自動で消したりはしない)。 # 状態を確認できない場合 (ComfyUI未起動・通信不可) も、開放状態にはせず停止を維持する。 try: existing = comfy_queue_ids() except Exception: existing = None gate_close("起動時にComfyUIの状態を確認できませんでした") if existing: gate_close(f"起動時にComfyUIのキューに残件があります: {sorted(existing)}") threading.Thread(target=worker_loop, daemon=True).start() threading.Thread(target=cleanup_loop, daemon=True).start() threading.Thread(target=console_loop, daemon=True).start() server = RelayHTTPServer((args.host, args.port), RelayHandler) print(f"[relay] listening on http://{args.host}:{args.port} (comfy: {CONFIG['comfy_base']})") print("[relay] 停止するには Ctrl+C を押してください") try: server.serve_forever() except KeyboardInterrupt: print("\n[relay] 停止します") server.shutdown() if __name__ == "__main__": main() ``` ### relay_regression_test.py ```python #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ relay_regression_test.py - relay_api.py の回帰テスト (モックComfyUI使用) 実行方法: python3 relay_regression_test.py [--workflows-dir DIR] 前提: - relay_api.py がこのファイルと同じフォルダーにあること - ワークフローJSON (noobai_vpred_api.json / wai_nsfw_api.json) の ckpt_name を 実ファイル名に置換したコピーを用意すること。--workflows-dir を省略した場合は 一時フォルダーに自動生成する (プレースホルダをテスト用ファイル名に置換)。 注意: これはモックのComfyUIに対する単体・結合テストであり、 Windows + 実ComfyUI + Tailscale での実機検証の代替にはならない。 検証項目: A. 正常系 (生成・画像取得・パラメータ注入) / 型検証 / 従来の入力検証 / 保持期限による記録削除 B. キュー上限の競合 (上限付きキューで同時投入しても上限を超えない) C. 生成エラー (completed=false, status_str="error") の即時検出 D. タイムアウト後の待機中ジョブ取消 (prompt_id指定delete) と後続投入の継続 D2. 実行中ジョブのタイムアウト: 自動割り込みをせず状態不明として投入を止める E. 通信断後の状態不明 -> 投入ゲート停止 F. /prompt 応答喪失 -> 状態不明・投入停止、その後のワーカー生存と resume による再開 I. ゲート停止中に待機済みジョブがComfyUIへ投入されないこと、再開後に処理されること J. 実行ジョブ交代・他ジョブ共存時に、無関係なジョブを止めないこと G. 起動時にComfyUIキュー残件がある場合の投入停止 K. 起動時にComfyUIの状態を確認できない場合の停止維持と、確認後の再開 L. 不正な /queue 応答 (キー欠落・null・型違い・不正要素) を「空」と判断せず停止を維持すること M. 投入拒否 (HTTP 400 + node_errors) は failed、判断できない応答 (HTTP 500) は unknown と区別すること N. 画像取得失敗 (404 / 接続失敗) を規定のJSONエラーで返し、再投入しないこと P. 許可リスト制LoRAパラメータ (検証・デフォルト/クランプ・チェーン末尾への挿入・ 未指定時の無変更) と、許可リスト不正時の起動拒否 (H4) O. HTTP 200 + prompt_id + node_errors (部分検証エラー) を受理済みとして追跡すること、 node_errors が空の HTTP 400 (既知の error.type) を明確な拒否として扱うこと、 未知の error.type の 400 は unknown のままであること R. 分割ローダー系統 (Part F): Anima 系テンプレートの起動検証・パラメータ注入・ lora 挿入の伝播と、誤配線 (チェーン逆転 / vae 誤接続 / 系統混在 / 未設定) の起動拒否 H. CLIの起動拒否 (バインド先 / ckpt未設定) Z. 全体を通して /queue の clear と /interrupt が一度も送られていないこと """ import argparse import json import pathlib import platform import socket import subprocess import sys import tempfile import threading import time import urllib.error import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer HERE = pathlib.Path(__file__).resolve().parent RELAY = HERE / "relay_api.py" TEST_LORA = "TestChar_Illustrious.safetensors" PNG_BYTES = b"\x89PNG\r\n\x1a\nMOCKIMAGE" RESULTS = [] def check(name, cond, detail=""): RESULTS.append((name, bool(cond))) print(("PASS" if cond else "FAIL"), name, detail if not cond else "") # ---------------------------------------------------------------- モックComfyUI class MockState: """フェーズごとに reset() する状態と、全フェーズ累積の統計を分けて持つ。""" def __init__(self): self.lock = threading.Lock() # 累積 (resetしない) self.interrupts_total = 0 self.clear_total = 0 self.deleted_ids = [] self.reset() def reset(self): with self.lock: self.counter = 0 self.jobs = {} # prompt_id -> dict(marker, at, deleted, interrupted) self.preloaded = [] # 他人のジョブ想定 (queue_running に現れる) self.completion_delay = 0.6 self.last_prompt = None self.queue_mode = "ok" # ok / missing / null / wrongtype / badelem / badpid self.view_mode = "ok" # ok / 404 def marker_of(self, prompt): texts = [] for node in (prompt or {}).values(): if isinstance(node, dict) and node.get("class_type") == "CLIPTextEncode": texts.append(str(node.get("inputs", {}).get("text", ""))) joined = " ".join(texts) for marker in ("TRIGGER_ERROR", "TRIGGER_HANG_RUNNING", "TRIGGER_HANG", "TRIGGER_DROP", "TRIGGER_LOSE", "TRIGGER_REJECT_EMPTY", "TRIGGER_REJECT", "TRIGGER_500", "TRIGGER_PARTIAL", "TRIGGER_400_UNKNOWN"): if marker in joined: return marker return "" STATE = MockState() class MockHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, *a): pass def _json(self, code, obj): body = json.dumps(obj).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _drop(self): try: self.connection.shutdown(socket.SHUT_RDWR) except OSError: pass self.connection.close() self.close_connection = True def do_POST(self): try: n = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(n) if n else b"{}" if self.path == "/prompt": payload = json.loads(raw or b"{}") prompt = payload.get("prompt") with STATE.lock: STATE.counter += 1 pid = f"mock-{STATE.counter}" marker = STATE.marker_of(prompt) STATE.jobs[pid] = {"marker": marker, "at": time.time(), "deleted": False, "interrupted": False} STATE.last_prompt = prompt if marker == "TRIGGER_LOSE": self._drop() # 投入は受理したが応答が失われた状況を再現 return if marker == "TRIGGER_REJECT": # 実ComfyUIの検証エラー応答 (HTTP 400 + error + node_errors) を再現 self._json(400, { "error": {"type": "prompt_outputs_failed_validation", "message": "Prompt outputs failed validation", "details": "Required input is missing: ckpt_name", "extra_info": {"input_name": "ckpt_name", "exception_type": "Exception", "node_id": "4", "node_type": "CheckpointLoaderSimple"}}, "node_errors": {"4": { "errors": [{"type": "required_input_missing", "message": "Required input is missing", "details": "ckpt_name", "extra_info": {"input_name": "ckpt_name"}}], "dependent_outputs": [], "class_type": "CheckpointLoaderSimple"}}}) return if marker == "TRIGGER_PARTIAL": # 一部の出力系統だけ検証失敗: 正常な系統はキュー登録され、 # HTTP 200 + prompt_id + node_errors が返る状況を再現 self._json(200, {"prompt_id": pid, "number": 1, "node_errors": { "99": {"errors": [{"type": "required_input_missing", "message": "Required input is missing", "details": "ckpt_name", "extra_info": {"input_name": "ckpt_name"}}], "dependent_outputs": [], "class_type": "CheckpointLoaderSimple"}}}) return if marker == "TRIGGER_REJECT_EMPTY": # ノード種別が存在しない等の全体検証エラー: # HTTP 400 + error (既知の type) + node_errors {} を再現 self._json(400, { "error": {"type": "invalid_prompt", "message": "Node type not found: UnknownNode", "details": "", "extra_info": {}}, "node_errors": {}}) return if marker == "TRIGGER_400_UNKNOWN": # 既知でない error.type の 400 は明確な拒否と判定しないことの確認用 self._json(400, { "error": {"type": "some_unrecognized_type", "message": "unknown", "details": "", "extra_info": {}}, "node_errors": {"4": {"errors": [], "dependent_outputs": [], "class_type": "KSampler"}}}) return if marker == "TRIGGER_500": body = b"Internal Server Error" self.send_response(500) self.send_header("Content-Type", "text/plain") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) return self._json(200, {"prompt_id": pid, "number": 1, "node_errors": {}}) elif self.path == "/queue": payload = json.loads(raw or b"{}") with STATE.lock: if "clear" in payload: STATE.clear_total += 1 for pid in payload.get("delete") or []: STATE.deleted_ids.append(pid) if pid in STATE.jobs: STATE.jobs[pid]["deleted"] = True self._json(200, {}) elif self.path == "/interrupt": with STATE.lock: STATE.interrupts_total += 1 for job in STATE.jobs.values(): if job["marker"] in ("TRIGGER_HANG_RUNNING", "TRIGGER_LOSE"): job["interrupted"] = True self._json(200, {}) else: self._json(404, {}) except (BrokenPipeError, ConnectionResetError): pass def do_GET(self): try: if self.path == "/system_stats": self._json(200, {"system": {}}) elif self.path.startswith("/history/"): pid = self.path.rsplit("/", 1)[1] with STATE.lock: job = STATE.jobs.get(pid) if job and job["marker"] == "TRIGGER_DROP": self._drop() return if job and job["marker"] == "TRIGGER_ERROR": self._json(200, {pid: {"status": {"status_str": "error", "completed": False, "messages": [["execution_error", {}]]}, "outputs": {}}}) return if job and job["marker"] in ("TRIGGER_HANG", "TRIGGER_HANG_RUNNING"): self._json(200, {}) return if job and time.time() - job["at"] >= STATE.completion_delay: self._json(200, {pid: {"status": {"status_str": "success", "completed": True}, "outputs": {"9": {"images": [{"filename": "mock.png", "subfolder": "", "type": "output"}]}}}}) return self._json(200, {}) elif self.path == "/queue": with STATE.lock: qmode = STATE.queue_mode if qmode == "missing": self._json(200, {}) return if qmode == "null": self._json(200, {"queue_running": None, "queue_pending": []}) return if qmode == "wrongtype": self._json(200, {"queue_running": "oops", "queue_pending": []}) return if qmode == "badelem": self._json(200, {"queue_running": [[0]], "queue_pending": []}) return if qmode == "badpid": self._json(200, {"queue_running": [[0, 123]], "queue_pending": []}) return running, pending = [], [] with STATE.lock: for extra in STATE.preloaded: running.append([0, extra, {}, "foreign", []]) for pid, job in STATE.jobs.items(): if job["deleted"] or job["interrupted"]: continue if job["marker"] in ("TRIGGER_HANG_RUNNING", "TRIGGER_LOSE"): running.append([0, pid, {}, "relay", []]) elif job["marker"] == "TRIGGER_HANG": pending.append([0, pid, {}, "relay", []]) self._json(200, {"queue_running": running, "queue_pending": pending}) elif self.path.startswith("/view"): with STATE.lock: vmode = STATE.view_mode if vmode == "404": self._json(404, {"error": "file not found"}) return self.send_response(200) self.send_header("Content-Type", "image/png") self.send_header("Content-Length", str(len(PNG_BYTES))) self.end_headers() self.wfile.write(PNG_BYTES) else: self._json(404, {}) except (BrokenPipeError, ConnectionResetError): pass # ---------------------------------------------------------------- テスト用ユーティリティ def http(method, base, path, obj=None): data = json.dumps(obj).encode() if obj is not None else None req = urllib.request.Request(base + path, data=data, method=method, headers={"Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=15) as resp: return resp.status, resp.read() except urllib.error.HTTPError as e: return e.code, e.read() except Exception as e: return -1, str(e).encode() def wait_job(base, job_id, want, timeout=30): deadline = time.time() + timeout last = None while time.time() < deadline: s, b = http("GET", base, f"/jobs/{job_id}") if s == 200: last = json.loads(b) if last["state"] in want: return last time.sleep(0.2) return last def health(base): s, b = http("GET", base, "/health") try: return s, json.loads(b) except Exception: return s, {} class RelayProc: def __init__(self, port, comfy_port, workflows_dir, extra_args): self.base = f"http://127.0.0.1:{port}" cmd = [sys.executable, str(RELAY), "--host", "127.0.0.1", "--port", str(port), "--local-test", "--comfy", f"http://127.0.0.1:{comfy_port}", "--workflows-dir", str(workflows_dir)] + extra_args self.proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) deadline = time.time() + 30 while time.time() < deadline: if self.proc.poll() is not None: raise RuntimeError(f"relayが起動しませんでした:\n{self._log_tail()}") s, _ = http("GET", self.base, "/health") if s in (200, 503): return time.sleep(0.2) # タイムアウト時も子プロセスを確実に終了し、原因確認用のログを残す self.proc.kill() try: self.proc.wait(timeout=5) except subprocess.TimeoutExpired: pass raise RuntimeError("relayの起動待ちがタイムアウトしました" f" (子プロセスは終了済み)。ログ末尾:\n{self._log_tail()}") def _log_tail(self): try: out = self.proc.stdout.read() if self.proc.stdout else "" except Exception: out = "" return "\n".join((out or "").splitlines()[-20:]) def send_cmd(self, cmd): self.proc.stdin.write(cmd + "\n") self.proc.stdin.flush() def stop(self): if self.proc.poll() is None: self.proc.terminate() try: self.proc.wait(timeout=5) except subprocess.TimeoutExpired: self.proc.kill() try: self.proc.wait(timeout=5) except subprocess.TimeoutExpired: pass def prepare_workflows(): tmp = pathlib.Path(tempfile.mkdtemp(prefix="relay-test-wf-")) for name, ckpt in [("noobai_vpred_api.json", "NoobAI-XL-Vpred-v1.0.safetensors"), ("wai_nsfw_api.json", "wai-nsfw-illustrious-sdxl_v17.safetensors")]: d = json.loads((HERE / name).read_text()) for node in d.values(): if isinstance(node, dict) and node.get("class_type") == "CheckpointLoaderSimple": node["inputs"]["ckpt_name"] = ckpt (tmp / name).write_text(json.dumps(d)) for name in ("anima_base_api.json", "anima_aesthetic_api.json", "anima_janima_api.json", "anima_janima29_api.json"): (tmp / name).write_text((HERE / name).read_text()) allow = {} shipped = HERE / "lora_allowlist.json" if shipped.exists(): allow.update(json.loads(shipped.read_text())) allow[TEST_LORA] = {"min": 0.0, "max": 1.2, "default": 1.0} (tmp / "lora_allowlist.json").write_text(json.dumps(allow)) return tmp def gen(base, prompt, **kw): payload = {"workflow_id": "wai_nsfw", "prompt": prompt} payload.update(kw) return http("POST", base, "/generate", payload) # ---------------------------------------------------------------- フェーズ def phase_a(mock_port, wf): print("== Phase A: 正常系 / 型検証 / 保持期限 ==") STATE.reset() relay = RelayProc(18400, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "30", "--job-retention", "2", "--cleanup-interval", "1"]) try: s, h = health(relay.base) check("A1 health: gate open", s == 200 and h.get("submit_gate") == "open") s, b = http("POST", relay.base, "/generate", {"workflow_id": "noobai_vpred", "prompt": "masterpiece, best quality, 1girl", "seed": 123, "resolution": "832x1216"}) job = json.loads(b) check("A2 generate accepted", s == 202 and "job_id" in job) jid = job["job_id"] st = wait_job(relay.base, jid, {"succeeded", "failed"}, 20) check("A2 job succeeded", st and st["state"] == "succeeded", str(st)) s, b = http("GET", relay.base, f"/jobs/{jid}/image") check("A2 image bytes", s == 200 and b == PNG_BYTES) with STATE.lock: submitted = STATE.last_prompt ks = next(n for n in submitted.values() if n["class_type"] == "KSampler") latent = next(n for n in submitted.values() if n["class_type"] == "EmptyLatentImage") save = next(n for n in submitted.values() if n["class_type"] == "SaveImage") check("A3 injection: seed", ks["inputs"]["seed"] == 123) check("A3 injection: size", (latent["inputs"]["width"], latent["inputs"]["height"]) == (832, 1216)) check("A3 injection: filename_prefix", save["inputs"]["filename_prefix"] == f"relay_{jid}") cases = [ ("A4 workflow_id list -> 400", {"workflow_id": [], "prompt": "x"}), ("A5 resolution dict -> 400", {"workflow_id": "wai_nsfw", "prompt": "x", "resolution": {}}), ("A6 seed -1.0 -> 400", {"workflow_id": "wai_nsfw", "prompt": "x", "seed": -1.0}), ("A6 seed 1.0 -> 400", {"workflow_id": "wai_nsfw", "prompt": "x", "seed": 1.0}), ("A6 seed str -> 400", {"workflow_id": "wai_nsfw", "prompt": "x", "seed": "42"}), ("A7 negative_prompt int -> 400", {"workflow_id": "wai_nsfw", "prompt": "x", "negative_prompt": 123}), ("A8 unknown field -> 400", {"workflow_id": "wai_nsfw", "prompt": "x", "url": "http://example.com"}), ("A8 unknown workflow -> 400", {"workflow_id": "nope", "prompt": "x"}), ("A8 bad resolution -> 400", {"workflow_id": "wai_nsfw", "prompt": "x", "resolution": "512x512"}), ("A8 overlong prompt -> 400", {"workflow_id": "wai_nsfw", "prompt": "x" * 2001}), ] for name, payload in cases: s, b = http("POST", relay.base, "/generate", payload) check(name, s == 400 and b'"error"' in b, f"status={s} body={b[:120]}") time.sleep(3.5) s, _ = http("GET", relay.base, f"/jobs/{jid}") check("A9 retention: finished job evicted", s == 404, f"status={s}") finally: relay.stop() def phase_b(mock_port, wf): print("== Phase B: キュー上限の競合 ==") STATE.reset() STATE.completion_delay = 2.5 relay = RelayProc(18401, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "60", "--max-queue", "1"]) try: results = [] barrier = threading.Barrier(6) def fire(): barrier.wait() results.append(gen(relay.base, "race test")) threads = [threading.Thread(target=fire) for _ in range(6)] for t in threads: t.start() for t in threads: t.join() accepted = [b for s, b in results if s == 202] rejected = [b for s, b in results if s == 429] check("B1 concurrent: accepted == running(1)+queue(1)", len(accepted) == 2, f"accepted={len(accepted)} codes={[s for s, _ in results]}") check("B2 concurrent: rest are 429", len(rejected) == 4, f"rejected={len(rejected)}") check("B3 rejected responses carry no job_id", all(b"job_id" not in b for b in rejected)) for b in accepted: wait_job(relay.base, json.loads(b)["job_id"], {"succeeded", "failed"}, 30) s, _ = gen(relay.base, "after") check("B4 capacity recovers after completion", s == 202, f"status={s}") finally: relay.stop() def phase_c(mock_port, wf): print("== Phase C: 生成エラー (completed=false) の即時検出 ==") STATE.reset() relay = RelayProc(18402, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "60"]) try: s, b = gen(relay.base, "TRIGGER_ERROR scene") jid = json.loads(b)["job_id"] started = time.time() st = wait_job(relay.base, jid, {"failed", "succeeded", "unknown"}, 20) elapsed = time.time() - started check("C1 error job failed (not timeout)", st and st["state"] == "failed" and st["error"]["code"] == "generation_failed", str(st)) check("C2 error detected quickly", elapsed < 20, f"elapsed={elapsed:.1f}s") finally: relay.stop() def phase_d(mock_port, wf): print("== Phase D: タイムアウト後の待機中ジョブ取消 ==") STATE.reset() relay = RelayProc(18403, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "3"]) try: before = len(STATE.deleted_ids) s, b = gen(relay.base, "TRIGGER_HANG scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"failed", "unknown"}, 30) check("D1 pending job: timeout -> failed (cancel verified)", st and st["state"] == "failed" and st["error"]["code"] == "generation_timeout", str(st)) check("D2 pending job: delete was targeted at own prompt_id", len(STATE.deleted_ids) > before) s, b = gen(relay.base, "normal scene") st = wait_job(relay.base, json.loads(b)["job_id"], {"succeeded", "failed"}, 20) check("D3 gate stays open after verified cancel", st and st["state"] == "succeeded", str(st)) finally: relay.stop() def phase_d2(mock_port, wf): print("== Phase D2: 実行中ジョブのタイムアウト (自動割り込みなし) ==") STATE.reset() relay = RelayProc(18407, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "3"]) try: s, b = gen(relay.base, "TRIGGER_HANG_RUNNING scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"unknown", "failed"}, 30) check("D2-1 running job: timeout -> unknown (no auto interrupt)", st and st["state"] == "unknown" and st["error"]["code"] == "generation_timeout_unresolved", str(st)) s, _ = gen(relay.base, "normal scene") check("D2-2 new submit suspended (503)", s == 503, f"status={s}") check("D2-3 /interrupt never sent", STATE.interrupts_total == 0) finally: relay.stop() def phase_e(mock_port, wf): print("== Phase E: 通信断 -> 状態不明・投入停止 ==") STATE.reset() relay = RelayProc(18404, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "60", "--comfy-fail-threshold", "3"]) try: s, b = gen(relay.base, "TRIGGER_DROP scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"unknown"}, 30) check("E1 dropped job becomes unknown", st and st["state"] == "unknown", str(st)) s, b = gen(relay.base, "normal scene") check("E2 new submit suspended (503)", s == 503 and b"submit_suspended" in b, f"status={s}") _, h = health(relay.base) check("E3 health shows gate closed", h.get("submit_gate") == "closed") finally: relay.stop() def phase_f(mock_port, wf): print("== Phase F: /prompt 応答喪失 -> 状態不明、その後のワーカー生存と再開 ==") STATE.reset() relay = RelayProc(18405, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "60"]) try: s, b = gen(relay.base, "TRIGGER_LOSE scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"unknown"}, 30) check("F1 lost-response job becomes unknown", st and st["state"] == "unknown", str(st)) check("F2 error code submit_outcome_unknown", st and st["error"]["code"] == "submit_outcome_unknown", str(st)) s, _ = gen(relay.base, "normal scene") check("F3 new submit suspended (503)", s == 503, f"status={s}") # キューに残件 (応答喪失した自ジョブ) がある間は resume だけでは開かない relay.send_cmd("resume") time.sleep(1.0) s, _ = gen(relay.base, "normal scene") check("F4 resume with non-empty queue keeps gate closed", s == 503, f"status={s}") # 運用者確認を経た force で再開し、ワーカーが生存していること (後続ジョブが処理される) を確認 relay.send_cmd("resume force") time.sleep(0.5) s, b = gen(relay.base, "worker survival check") check("F5 submit accepted after resume force", s == 202, f"status={s}") st = wait_job(relay.base, json.loads(b)["job_id"], {"succeeded", "failed"}, 20) check("F6 worker alive: subsequent job processed", st and st["state"] == "succeeded", str(st)) finally: relay.stop() def phase_i(mock_port, wf): print("== Phase I: ゲート停止中の待機済みジョブ ==") STATE.reset() relay = RelayProc(18408, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "60", "--comfy-fail-threshold", "3", "--max-queue", "4"]) try: s, b = gen(relay.base, "TRIGGER_DROP scene") jid1 = json.loads(b)["job_id"] # ゲートが閉じる前に2件を待機させる s2, b2 = gen(relay.base, "queued one") s3, b3 = gen(relay.base, "queued two") check("I1 two jobs queued before gate closes", s2 == 202 and s3 == 202, f"s2={s2} s3={s3}") jid2, jid3 = json.loads(b2)["job_id"], json.loads(b3)["job_id"] st = wait_job(relay.base, jid1, {"unknown"}, 30) check("I2 first job becomes unknown", st and st["state"] == "unknown", str(st)) with STATE.lock: submitted = STATE.counter time.sleep(2.5) with STATE.lock: submitted_after = STATE.counter check("I3 queued jobs NOT submitted while gate closed", submitted == 1 and submitted_after == 1, f"submitted={submitted} after={submitted_after}") for jid in (jid2, jid3): s, b = http("GET", relay.base, f"/jobs/{jid}") check("I4 queued job state stays 'queued'", s == 200 and json.loads(b)["state"] == "queued", b[:120]) relay.send_cmd("resume") # モックのキューに残件は無いため通常resumeで開く time.sleep(0.5) st2 = wait_job(relay.base, jid2, {"succeeded", "failed"}, 20) st3 = wait_job(relay.base, jid3, {"succeeded", "failed"}, 20) check("I5 queued jobs processed after resume", st2 and st2["state"] == "succeeded" and st3 and st3["state"] == "succeeded", f"{st2} / {st3}") finally: relay.stop() def phase_j(mock_port, wf): print("== Phase J: 他ジョブ共存・実行ジョブ交代時の誤停止防止 ==") STATE.reset() relay = RelayProc(18409, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "3"]) try: # GUI等で実行中の他ジョブが共存する状況を作る with STATE.lock: STATE.preloaded = ["gui-job-1"] s, b = gen(relay.base, "TRIGGER_HANG_RUNNING scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"unknown", "failed"}, 30) check("J1 own running job unresolved -> unknown", st and st["state"] == "unknown", str(st)) with STATE.lock: deleted = list(STATE.deleted_ids) check("J2 foreign job never deleted", "gui-job-1" not in deleted, str(deleted)) check("J3 /interrupt never sent", STATE.interrupts_total == 0) finally: relay.stop() def phase_g(mock_port, wf): print("== Phase G: 起動時のキュー残件 -> 投入停止 ==") STATE.reset() STATE.preloaded = ["foreign-prompt-1"] relay = RelayProc(18406, mock_port, wf, ["--poll-interval", "0.2"]) try: _, h = health(relay.base) check("G1 health shows gate closed at startup", h.get("submit_gate") == "closed") s, _ = gen(relay.base, "normal scene") check("G2 submit suspended at startup (503)", s == 503, f"status={s}") finally: relay.stop() def phase_k(wf): print("== Phase K: 起動時に状態確認不可 -> 停止維持、確認後に再開 ==") STATE.reset() dead_port = 18499 # この時点では何も待ち受けていない relay = RelayProc(18411, dead_port, wf, ["--poll-interval", "0.2", "--job-timeout", "30"]) mock2 = None try: _, h = health(relay.base) check("K1 gate closed when comfy state unverifiable at startup", h.get("submit_gate") == "closed", str(h)) relay.send_cmd("resume") time.sleep(1.0) _, h = health(relay.base) check("K2 resume keeps gate closed while state unverifiable", h.get("submit_gate") == "closed", str(h)) # ComfyUI (モック) を起動してから resume すると開く mock2 = ThreadingHTTPServer(("127.0.0.1", dead_port), MockHandler) threading.Thread(target=mock2.serve_forever, daemon=True).start() relay.send_cmd("resume") time.sleep(0.5) _, h = health(relay.base) check("K3 gate opens after state verified empty", h.get("submit_gate") == "open", str(h)) s, b = gen(relay.base, "post recovery scene") st = wait_job(relay.base, json.loads(b)["job_id"], {"succeeded", "failed"}, 20) check("K4 generation works after recovery", st and st["state"] == "succeeded", str(st)) finally: relay.stop() if mock2: mock2.shutdown() def phase_l(mock_port, wf): print("== Phase L: 不正な /queue 応答を「空」と判断しない ==") STATE.reset() STATE.queue_mode = "missing" relay = RelayProc(18412, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "3"]) try: _, h = health(relay.base) check("L1 missing keys at startup -> gate closed", h.get("submit_gate") == "closed", str(h)) for mode in ("null", "wrongtype", "badelem", "badpid"): STATE.queue_mode = mode relay.send_cmd("resume") time.sleep(0.7) _, h = health(relay.base) check(f"L2 resume blocked with malformed queue ({mode})", h.get("submit_gate") == "closed", str(h)) STATE.queue_mode = "wrongtype" relay.send_cmd("resume force") time.sleep(0.7) _, h = health(relay.base) check("L3 resume force also blocked when state unverifiable", h.get("submit_gate") == "closed", str(h)) STATE.queue_mode = "ok" relay.send_cmd("resume") time.sleep(0.5) _, h = health(relay.base) check("L4 gate opens once queue response is valid and empty", h.get("submit_gate") == "open", str(h)) finally: relay.stop() STATE.reset() relay = RelayProc(18413, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "3"]) try: s, b = gen(relay.base, "TRIGGER_HANG scene") jid = json.loads(b)["job_id"] STATE.queue_mode = "missing" st = wait_job(relay.base, jid, {"unknown", "failed"}, 30) check("L5 malformed queue during cancel-verify -> unknown", st and st["state"] == "unknown", str(st)) finally: relay.stop() STATE.queue_mode = "ok" def phase_m(mock_port, wf): print("== Phase M: 投入拒否 (400) と判断不能 (500) の区別 ==") STATE.reset() relay = RelayProc(18414, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "30"]) try: s, b = gen(relay.base, "TRIGGER_REJECT scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"failed", "unknown"}, 20) check("M1 explicit 400 rejection -> job failed (comfy_node_error)", st and st["state"] == "failed" and st["error"]["code"] == "comfy_node_error", str(st)) s, b = gen(relay.base, "normal scene") st = wait_job(relay.base, json.loads(b)["job_id"], {"succeeded", "failed"}, 20) check("M2 subsequent job processed after rejection", st and st["state"] == "succeeded", str(st)) s, b = gen(relay.base, "TRIGGER_500 scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"unknown"}, 20) check("M3 ambiguous HTTP 500 -> unknown (submit_outcome_unknown)", st and st["state"] == "unknown" and st["error"]["code"] == "submit_outcome_unknown", str(st)) s, _ = gen(relay.base, "normal scene") check("M4 gate closed after ambiguous submit", s == 503, f"status={s}") finally: relay.stop() def phase_n(wf): print("== Phase N: 画像取得失敗のJSONエラー化 ==") STATE.reset() port = 18501 mock = ThreadingHTTPServer(("127.0.0.1", port), MockHandler) threading.Thread(target=mock.serve_forever, daemon=True).start() relay = RelayProc(18415, port, wf, ["--poll-interval", "0.2", "--job-timeout", "30"]) try: s, b = gen(relay.base, "image error scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"succeeded"}, 20) check("N1 job succeeded", st and st["state"] == "succeeded", str(st)) s, b = http("GET", relay.base, f"/jobs/{jid}/image") check("N2 image ok", s == 200 and b == PNG_BYTES) STATE.view_mode = "404" s, b = http("GET", relay.base, f"/jobs/{jid}/image") code = None try: code = json.loads(b)["error"]["code"] except Exception: pass check("N3 image 404 -> JSON image_not_found", s == 404 and code == "image_not_found", f"status={s} body={b[:120]}") STATE.view_mode = "ok" with STATE.lock: counter_before = STATE.counter mock.shutdown() mock.server_close() s, b = http("GET", relay.base, f"/jobs/{jid}/image") code = None try: code = json.loads(b)["error"]["code"] except Exception: pass check("N4 comfy down -> JSON comfy_unreachable (502)", s == 502 and code == "comfy_unreachable", f"status={s} body={b[:120]}") s, b = http("GET", relay.base, f"/jobs/{jid}") check("N5 job record stays succeeded (no resubmission)", s == 200 and json.loads(b)["state"] == "succeeded") with STATE.lock: counter_after = STATE.counter check("N6 no resubmission happened", counter_before == counter_after) finally: relay.stop() def phase_o(mock_port, wf): print("== Phase O: 部分エラー200の受理継続 / node_errors空の400拒否 / 未知typeの境界 ==") STATE.reset() relay = RelayProc(18416, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "30"]) try: s, b = gen(relay.base, "TRIGGER_PARTIAL scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"succeeded", "failed", "unknown"}, 20) check("O1 200 + prompt_id + node_errors -> accepted, tracked to success", st and st["state"] == "succeeded", str(st)) s, b = http("GET", relay.base, f"/jobs/{jid}/image") check("O2 image retrievable for partial-error job", s == 200 and b == PNG_BYTES) s, b = gen(relay.base, "TRIGGER_REJECT_EMPTY scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"failed", "unknown"}, 20) check("O3 400 + invalid_prompt + empty node_errors -> failed (comfy_node_error)", st and st["state"] == "failed" and st["error"]["code"] == "comfy_node_error", str(st)) s, b = gen(relay.base, "normal scene") st = wait_job(relay.base, json.loads(b)["job_id"], {"succeeded", "failed"}, 20) check("O4 subsequent job processed after empty-node_errors rejection", st and st["state"] == "succeeded", str(st)) s, b = gen(relay.base, "TRIGGER_400_UNKNOWN scene") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"unknown", "failed"}, 20) check("O5 400 with unknown error.type stays unknown", st and st["state"] == "unknown", str(st)) finally: relay.stop() def phase_p(mock_port, wf): print("== Phase P: 許可リスト制LoRAパラメータ ==") STATE.reset() relay = RelayProc(18417, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "30"]) try: s, h = health(relay.base) check("P1 health lists allowlisted loras", s == 200 and TEST_LORA in (h.get("loras") or []), str(h)) def tail_of(submitted): ks = next(n for n in submitted.values() if n["class_type"] == "KSampler") return ks, submitted[str(ks["inputs"]["model"][0])] s, b = http("POST", relay.base, "/generate", {"workflow_id": "wai_nsfw", "prompt": "1girl", "seed": 7, "lora": {"name": TEST_LORA}}) check("P2 generate with lora accepted", s == 202, f"status={s} body={b[:120]}") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"succeeded", "failed"}, 20) check("P2 job succeeded", st and st["state"] == "succeeded", str(st)) check("P2 job status echoes lora (default strength)", st and (st.get("lora") or {}).get("name") == TEST_LORA and (st.get("lora") or {}).get("strength") == 1.0, str(st)) with STATE.lock: submitted = STATE.last_prompt loras = [n for n in submitted.values() if n["class_type"] == "LoraLoader"] ks, tail = tail_of(submitted) check("P2 chain has 3 LoraLoaders", len(loras) == 3, f"count={len(loras)}") check("P2 MODEL tail is the char lora with default strength", tail["inputs"]["lora_name"] == TEST_LORA and tail["inputs"]["strength_model"] == 1.0 and tail["inputs"]["strength_clip"] == 1.0) prev = submitted[str(tail["inputs"]["model"][0])] check("P2 char lora sits after Outlined (model side)", prev["class_type"] == "LoraLoader" and prev["inputs"]["lora_name"] == "Outlined_IL.safetensors") encs = [n for n in submitted.values() if n["class_type"] == "CLIPTextEncode"] check("P2 both encoders take clip from the char lora", len(encs) == 2 and all(str(e["inputs"]["clip"][0]) == str(ks["inputs"]["model"][0]) and e["inputs"]["clip"][1] == 1 for e in encs)) for raw, want in ((5.0, 1.2), (-0.5, 0.0), (0.8, 0.8)): s, b = http("POST", relay.base, "/generate", {"workflow_id": "wai_nsfw", "prompt": "1girl", "lora": {"name": TEST_LORA, "strength": raw}}) jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"succeeded", "failed"}, 20) with STATE.lock: submitted = STATE.last_prompt _, tail = tail_of(submitted) check(f"P3 strength {raw} -> clamped {want}", st and st["state"] == "succeeded" and tail["inputs"]["strength_model"] == want, f"tail={tail['inputs'].get('strength_model')}") cases = [ ("P4 unknown lora name -> 400 unknown_lora", {"name": "Nope.safetensors"}, "unknown_lora"), ("P5 lora as string -> 400", "x", None), ("P5 lora as list -> 400", [], None), ("P5 name int -> 400", {"name": 42}, None), ("P5 name missing -> 400", {"strength": 1.0}, None), ("P5 strength string -> 400", {"name": TEST_LORA, "strength": "1.0"}, None), ("P5 strength bool -> 400", {"name": TEST_LORA, "strength": True}, None), ("P5 extra key in lora -> 400", {"name": TEST_LORA, "file": "x"}, None), ] for name, lora_val, want_code in cases: s, b = http("POST", relay.base, "/generate", {"workflow_id": "wai_nsfw", "prompt": "x", "lora": lora_val}) ok = s == 400 and b'"error"' in b if want_code: try: ok = ok and json.loads(b)["error"]["code"] == want_code except Exception: ok = False check(name, ok, f"status={s} body={b[:120]}") s, b = http("POST", relay.base, "/generate", {"workflow_id": "wai_nsfw", "prompt": "1girl"}) jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"succeeded", "failed"}, 20) with STATE.lock: submitted = STATE.last_prompt loras = [n for n in submitted.values() if n["class_type"] == "LoraLoader"] check("P7 no lora -> chain unchanged (2 loaders)", st and st["state"] == "succeeded" and len(loras) == 2, f"count={len(loras)}") finally: relay.stop() def phase_q(mock_port, wf): print("== Phase Q: Hires第2パス + FaceDetailer (Part E) ==") STATE.reset() relay = RelayProc(18418, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "30"]) try: def graph(submitted): by_class = {} for nid, n in submitted.items(): by_class.setdefault(n["class_type"], []).append((nid, n)) kss = by_class["KSampler"] main = next((nid, n) for nid, n in kss if submitted[str(n["inputs"]["latent_image"][0])]["class_type"] == "EmptyLatentImage") hires = next((nid, n) for nid, n in kss if nid != main[0]) fd = by_class["FaceDetailer"][0] dec = by_class["VAEDecode"][0] up = by_class["LatentUpscaleBy"][0] save = by_class["SaveImage"][0] encs = {n["inputs"]["text"]: (nid, n) for nid, n in by_class["CLIPTextEncode"]} return main, hires, fd, dec, up, save, encs def norm(link): return [str(link[0]), link[1]] s, b = gen(relay.base, "1girl", workflow_id="wai_nsfw", seed=7) jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"succeeded", "failed"}, 20) check("Q1 job succeeded", st and st["state"] == "succeeded", str(st)) with STATE.lock: submitted = STATE.last_prompt main, hires, fd, dec, up, save, encs = graph(submitted) check("Q1 hires shares model/positive/negative with main", norm(hires[1]["inputs"]["model"]) == norm(main[1]["inputs"]["model"]) and norm(hires[1]["inputs"]["positive"]) == norm(main[1]["inputs"]["positive"]) and norm(hires[1]["inputs"]["negative"]) == norm(main[1]["inputs"]["negative"])) check("Q1 upscale takes main sampler output", norm(up[1]["inputs"]["samples"]) == [main[0], 0]) check("Q1 VAEDecode takes hires output", norm(dec[1]["inputs"]["samples"]) == [hires[0], 0]) check("Q1 FaceDetailer wired to shared links and encoders", norm(fd[1]["inputs"]["model"]) == norm(main[1]["inputs"]["model"]) and norm(fd[1]["inputs"]["clip"]) == norm(list(encs.values())[0][1]["inputs"]["clip"]) and norm(fd[1]["inputs"]["positive"]) == norm(main[1]["inputs"]["positive"]) and norm(fd[1]["inputs"]["negative"]) == norm(main[1]["inputs"]["negative"]) and norm(fd[1]["inputs"]["image"]) == [dec[0], 0]) check("Q1 SaveImage takes FaceDetailer output", norm(save[1]["inputs"]["images"]) == [fd[0], 0]) s, b = http("POST", relay.base, "/generate", {"workflow_id": "wai_nsfw", "prompt": "1girl", "seed": 7, "lora": {"name": TEST_LORA}}) jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"succeeded", "failed"}, 20) check("Q2 job with lora succeeded", st and st["state"] == "succeeded", str(st)) with STATE.lock: submitted = STATE.last_prompt main, hires, fd, dec, up, save, encs = graph(submitted) char_id = str(main[1]["inputs"]["model"][0]) char = submitted[char_id] check("Q2 char lora is the shared tail for main + hires + FaceDetailer (model)", char["class_type"] == "LoraLoader" and char["inputs"]["lora_name"] == TEST_LORA and str(hires[1]["inputs"]["model"][0]) == char_id and str(fd[1]["inputs"]["model"][0]) == char_id) check("Q2 char lora feeds FaceDetailer clip and both encoders (clip)", str(fd[1]["inputs"]["clip"][0]) == char_id and fd[1]["inputs"]["clip"][1] == 1 and all(str(n["inputs"]["clip"][0]) == char_id for _, n in encs.values())) prev = submitted[str(char["inputs"]["model"][0])] check("Q2 char lora sits after Outlined (resident tail)", prev["class_type"] == "LoraLoader" and prev["inputs"]["lora_name"] == "Outlined_IL.safetensors") finally: relay.stop() def startup_outcome(mutate): d = pathlib.Path(tempfile.mkdtemp(prefix="relay-test-pq-")) for f in pathlib.Path(wf).glob("*.json"): (d / f.name).write_text(f.read_text()) target = d / "wai_nsfw_api.json" wfdata = json.loads(target.read_text()) mutate(wfdata) target.write_text(json.dumps(wfdata)) r = subprocess.run([sys.executable, str(RELAY), "--host", "127.0.0.1", "--port", "18419", "--local-test", "--workflows-dir", str(d)], capture_output=True, text=True, timeout=30) return r.returncode, r.stdout + r.stderr def break_hires(wfdata): kss = [n for n in wfdata.values() if n["class_type"] == "KSampler"] main = next(n for n in kss if wfdata[str(n["inputs"]["latent_image"][0])]["class_type"] == "EmptyLatentImage") hires = next(n for n in kss if n is not main) hires["inputs"]["positive"] = hires["inputs"]["negative"] rc, out = startup_outcome(break_hires) check("Q3 hires sampler with mismatched positive rejected at startup", rc != 0 and "Hires" in out, out[-200:]) def break_fd(wfdata): fd = next(n for n in wfdata.values() if n["class_type"] == "FaceDetailer") ckpt_id = next(k for k, n in wfdata.items() if n["class_type"] == "CheckpointLoaderSimple") fd["inputs"]["model"] = [ckpt_id, 0] rc, out = startup_outcome(break_fd) check("Q4 FaceDetailer with non-shared model rejected at startup", rc != 0 and "FaceDetailer" in out, out[-200:]) def phase_r(mock_port, wf): print("== Phase R: 分割ローダー系統 (Part F / Anima) ==") STATE.reset() relay = RelayProc(18421, mock_port, wf, ["--poll-interval", "0.2", "--job-timeout", "30"]) try: def norm(link): return [str(link[0]), link[1]] s, b = gen(relay.base, "1girl", workflow_id="anima_base", seed=7, resolution="832x1216") jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"succeeded", "failed"}, 20) check("R1 anima_base job succeeded", st and st["state"] == "succeeded", str(st)) with STATE.lock: submitted = STATE.last_prompt unet = next(n for n in submitted.values() if n["class_type"] == "UNETLoader") check("R1 unet filename kept", unet["inputs"]["unet_name"] == "anima-base-v1.0.safetensors") ks = next(n for n in submitted.values() if n["class_type"] == "KSampler") check("R1 seed injected", ks["inputs"]["seed"] == 7) lat = next(n for n in submitted.values() if n["class_type"] == "EmptyLatentImage") check("R1 resolution injected", (lat["inputs"]["width"], lat["inputs"]["height"]) == (832, 1216)) s, b = gen(relay.base, "1girl", workflow_id="anima_janima29", seed=7, lora={"name": TEST_LORA}) jid = json.loads(b)["job_id"] st = wait_job(relay.base, jid, {"succeeded", "failed"}, 20) check("R2 anima_janima29 job with lora succeeded", st and st["state"] == "succeeded", str(st)) with STATE.lock: submitted = STATE.last_prompt ks = next(n for n in submitted.values() if n["class_type"] == "KSampler") char_id = str(ks["inputs"]["model"][0]) char = submitted[char_id] unet_id = next(k for k, n in submitted.items() if n["class_type"] == "UNETLoader") clip_id = next(k for k, n in submitted.items() if n["class_type"] == "CLIPLoader") encs = [n for n in submitted.values() if n["class_type"] == "CLIPTextEncode"] check("R2 char lora rooted at UNETLoader / CLIPLoader", char["class_type"] == "LoraLoader" and char["inputs"]["lora_name"] == TEST_LORA and norm(char["inputs"]["model"]) == [unet_id, 0] and norm(char["inputs"]["clip"]) == [clip_id, 0] and all(str(n["inputs"]["clip"][0]) == char_id for n in encs)) finally: relay.stop() def startup_outcome(mutate): d = pathlib.Path(tempfile.mkdtemp(prefix="relay-test-pr-")) for f in pathlib.Path(wf).glob("*.json"): (d / f.name).write_text(f.read_text()) target = d / "anima_base_api.json" wfdata = json.loads(target.read_text()) mutate(wfdata) target.write_text(json.dumps(wfdata)) r = subprocess.run([sys.executable, str(RELAY), "--host", "127.0.0.1", "--port", "18422", "--local-test", "--workflows-dir", str(d)], capture_output=True, text=True, timeout=30) return r.returncode, r.stdout + r.stderr def break_chain(wfdata): ks = next(n for n in wfdata.values() if n["class_type"] == "KSampler") clip_id = next(k for k, n in wfdata.items() if n["class_type"] == "CLIPLoader") ks["inputs"]["model"] = [clip_id, 0] rc, out = startup_outcome(break_chain) check("R3 model link rooted at CLIPLoader rejected at startup", rc != 0 and "UNETLoader" in out, out[-200:]) def break_vae(wfdata): dec = next(n for n in wfdata.values() if n["class_type"] == "VAEDecode") unet_id = next(k for k, n in wfdata.items() if n["class_type"] == "UNETLoader") dec["inputs"]["vae"] = [unet_id, 0] rc, out = startup_outcome(break_vae) check("R4 VAEDecode.vae not from VAELoader rejected at startup", rc != 0 and "VAELoader" in out, out[-200:]) def mix_family(wfdata): wfdata["99"] = {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "something.safetensors"}} rc, out = startup_outcome(mix_family) check("R5 mixed loader families rejected at startup", rc != 0 and "混在" in out, out[-200:]) def placeholder_unet(wfdata): unet = next(n for n in wfdata.values() if n["class_type"] == "UNETLoader") unet["inputs"]["unet_name"] = "REPLACE_WITH_ACTUAL_FILENAME.safetensors" rc, out = startup_outcome(placeholder_unet) check("R6 placeholder unet_name rejected at startup", rc != 0 and "unet_name" in out, out[-200:]) def phase_h(wf): print("== Phase H: 起動拒否 ==") r = subprocess.run([sys.executable, str(RELAY), "--host", "0.0.0.0", "--port", "18410", "--workflows-dir", str(wf)], capture_output=True, text=True, timeout=30) check("H1 bind 0.0.0.0 rejected", r.returncode != 0) ph = pathlib.Path(tempfile.mkdtemp(prefix="relay-test-ph-")) for f in pathlib.Path(wf).glob("*.json"): if f.name == "lora_allowlist.json": continue d = json.loads(f.read_text()) for node in d.values(): if isinstance(node, dict) and node.get("class_type") == "CheckpointLoaderSimple": node["inputs"]["ckpt_name"] = "REPLACE_WITH_ACTUAL_FILENAME.safetensors" (ph / f.name).write_text(json.dumps(d)) r = subprocess.run([sys.executable, str(RELAY), "--host", "127.0.0.1", "--port", "18410", "--local-test", "--workflows-dir", str(ph)], capture_output=True, text=True, timeout=30) check("H2 placeholder ckpt rejected at startup", r.returncode != 0 and "ckpt_name" in (r.stdout + r.stderr), (r.stdout + r.stderr)[-200:]) # 到達制限: Tailscale帯域でも 127.0.0.1 (--local-test) でもないアドレスは起動拒否する r = subprocess.run([sys.executable, str(RELAY), "--host", "192.168.1.10", "--port", "18410", "--workflows-dir", str(wf)], capture_output=True, text=True, timeout=30) check("H3 bind LAN address rejected (reachability restriction)", r.returncode != 0 and "Tailscale" in (r.stdout + r.stderr), (r.stdout + r.stderr)[-200:]) bad = pathlib.Path(tempfile.mkdtemp(prefix="relay-test-bad-")) for f in pathlib.Path(wf).glob("*.json"): if f.name != "lora_allowlist.json": (bad / f.name).write_text(f.read_text()) (bad / "lora_allowlist.json").write_text( json.dumps({"Bad.safetensors": {"min": 0.0, "max": 1.0, "default": 1.5}})) r = subprocess.run([sys.executable, str(RELAY), "--host", "127.0.0.1", "--port", "18411", "--local-test", "--workflows-dir", str(bad)], capture_output=True, text=True, timeout=30) check("H4 malformed lora allowlist rejected at startup", r.returncode != 0 and "lora_allowlist" in (r.stdout + r.stderr), (r.stdout + r.stderr)[-200:]) def main(): ap = argparse.ArgumentParser() ap.add_argument("--workflows-dir", default=None) args = ap.parse_args() wf = pathlib.Path(args.workflows_dir) if args.workflows_dir else prepare_workflows() print(f"env: python {sys.version.split()[0]} / {platform.platform()}") print(f"relay under test: {RELAY}") mock_port = 18450 server = ThreadingHTTPServer(("127.0.0.1", mock_port), MockHandler) threading.Thread(target=server.serve_forever, daemon=True).start() print(f"mock ComfyUI on 127.0.0.1:{mock_port}; workflows: {wf}") phase_a(mock_port, wf) phase_b(mock_port, wf) phase_c(mock_port, wf) phase_d(mock_port, wf) phase_d2(mock_port, wf) phase_e(mock_port, wf) phase_f(mock_port, wf) phase_i(mock_port, wf) phase_j(mock_port, wf) phase_g(mock_port, wf) phase_k(wf) phase_l(mock_port, wf) phase_m(mock_port, wf) phase_n(wf) phase_o(mock_port, wf) phase_p(mock_port, wf) phase_q(mock_port, wf) phase_r(mock_port, wf) phase_h(wf) check("Z1 mock never received queue clear", STATE.clear_total == 0) check("Z2 mock never received /interrupt", STATE.interrupts_total == 0) failed = [n for n, ok in RESULTS if not ok] print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} passed =====") if failed: print("FAILED:", ", ".join(failed)) return 1 print("ALL PASS (mock environment)") return 0 if __name__ == "__main__": sys.exit(main()) ``` ### anima_base_api.json ```json { "1": { "class_type": "UNETLoader", "inputs": { "unet_name": "anima-base-v1.0.safetensors", "weight_dtype": "default" } }, "2": { "class_type": "CLIPLoader", "inputs": { "clip_name": "qwen_3_06b_base.safetensors", "type": "stable_diffusion", "device": "default" } }, "3": { "class_type": "VAELoader", "inputs": { "vae_name": "qwen_image_vae.safetensors" } }, "4": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "2", 0 ], "text": "masterpiece, best quality, score_7, 1girl, solo" } }, "5": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "2", 0 ], "text": "worst quality, low quality, lowres, score_1, score_2, score_3, blurry, jpeg artifacts, bad anatomy, missing fingers, watermark, artist name" } }, "6": { "class_type": "EmptyLatentImage", "inputs": { "width": 832, "height": 1216, "batch_size": 1 } }, "7": { "class_type": "KSampler", "inputs": { "model": [ "1", 0 ], "seed": 42, "steps": 30, "cfg": 4.0, "sampler_name": "euler", "scheduler": "simple", "positive": [ "4", 0 ], "negative": [ "5", 0 ], "latent_image": [ "6", 0 ], "denoise": 1.0 } }, "8": { "class_type": "VAEDecode", "inputs": { "samples": [ "7", 0 ], "vae": [ "3", 0 ] } }, "9": { "class_type": "SaveImage", "inputs": { "images": [ "8", 0 ], "filename_prefix": "anima" } } } ``` ### anima_aesthetic_api.json ```json { "1": { "class_type": "UNETLoader", "inputs": { "unet_name": "anima-aesthetic-v1.1.safetensors", "weight_dtype": "default" } }, "2": { "class_type": "CLIPLoader", "inputs": { "clip_name": "qwen_3_06b_base.safetensors", "type": "stable_diffusion", "device": "default" } }, "3": { "class_type": "VAELoader", "inputs": { "vae_name": "qwen_image_vae.safetensors" } }, "4": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "2", 0 ], "text": "masterpiece, best quality, score_7, 1girl, solo" } }, "5": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "2", 0 ], "text": "worst quality, low quality, lowres, score_1, score_2, score_3, blurry, jpeg artifacts, bad anatomy, missing fingers, watermark, artist name" } }, "6": { "class_type": "EmptyLatentImage", "inputs": { "width": 832, "height": 1216, "batch_size": 1 } }, "7": { "class_type": "KSampler", "inputs": { "model": [ "1", 0 ], "seed": 42, "steps": 30, "cfg": 4.0, "sampler_name": "euler", "scheduler": "simple", "positive": [ "4", 0 ], "negative": [ "5", 0 ], "latent_image": [ "6", 0 ], "denoise": 1.0 } }, "8": { "class_type": "VAEDecode", "inputs": { "samples": [ "7", 0 ], "vae": [ "3", 0 ] } }, "9": { "class_type": "SaveImage", "inputs": { "images": [ "8", 0 ], "filename_prefix": "anima" } } } ``` ### anima_janima_api.json ```json { "1": { "class_type": "UNETLoader", "inputs": { "unet_name": "JANIMAAnima_v10_pruned_fp16.safetensors", "weight_dtype": "default" } }, "2": { "class_type": "CLIPLoader", "inputs": { "clip_name": "qwen_3_06b_base.safetensors", "type": "stable_diffusion", "device": "default" } }, "3": { "class_type": "VAELoader", "inputs": { "vae_name": "qwen_image_vae.safetensors" } }, "4": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "2", 0 ], "text": "masterpiece, best quality, score_7, 1girl, solo" } }, "5": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "2", 0 ], "text": "worst quality, low quality, lowres, score_1, score_2, score_3, blurry, jpeg artifacts, bad anatomy, missing fingers, watermark, artist name" } }, "6": { "class_type": "EmptyLatentImage", "inputs": { "width": 832, "height": 1216, "batch_size": 1 } }, "7": { "class_type": "KSampler", "inputs": { "model": [ "1", 0 ], "seed": 42, "steps": 30, "cfg": 5.0, "sampler_name": "er_sde", "scheduler": "simple", "positive": [ "4", 0 ], "negative": [ "5", 0 ], "latent_image": [ "6", 0 ], "denoise": 1.0 } }, "8": { "class_type": "VAEDecode", "inputs": { "samples": [ "7", 0 ], "vae": [ "3", 0 ] } }, "9": { "class_type": "SaveImage", "inputs": { "images": [ "8", 0 ], "filename_prefix": "anima" } } } ``` ### anima_janima29_api.json ```json { "1": { "class_type": "UNETLoader", "inputs": { "unet_name": "JANIMAAnima_2.9B.safetensors", "weight_dtype": "default" } }, "2": { "class_type": "CLIPLoader", "inputs": { "clip_name": "qwen_3_06b_base.safetensors", "type": "stable_diffusion", "device": "default" } }, "3": { "class_type": "VAELoader", "inputs": { "vae_name": "qwen_image_vae.safetensors" } }, "4": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "2", 0 ], "text": "masterpiece, best quality, score_7, 1girl, solo" } }, "5": { "class_type": "CLIPTextEncode", "inputs": { "clip": [ "2", 0 ], "text": "worst quality, low quality, lowres, score_1, score_2, score_3, blurry, jpeg artifacts, bad anatomy, missing fingers, watermark, artist name" } }, "6": { "class_type": "EmptyLatentImage", "inputs": { "width": 832, "height": 1216, "batch_size": 1 } }, "7": { "class_type": "KSampler", "inputs": { "model": [ "1", 0 ], "seed": 42, "steps": 30, "cfg": 5.0, "sampler_name": "er_sde", "scheduler": "simple", "positive": [ "4", 0 ], "negative": [ "5", 0 ], "latent_image": [ "6", 0 ], "denoise": 1.0 } }, "8": { "class_type": "VAEDecode", "inputs": { "samples": [ "7", 0 ], "vae": [ "3", 0 ] } }, "9": { "class_type": "SaveImage", "inputs": { "images": [ "8", 0 ], "filename_prefix": "anima" } } } ```