state.json(シーン設計書)→ prompt_builder.py(ワークフローJSON生成)→ ComfyUI WebSocket API(生成実行)→ PIL写植スクリプト(吹き出し合成)→ ZIP出力という5段パイプラインが1クリックで全自動化できる。
最大の技術的障壁はOOMエラーとWebSocket切断の2点。前者はComfyUIの/freeエンドポイント定期呼び出し[3]と2026年初頭標準搭載のDynamic VRAM[7]で大幅緩和。後者は自動再接続ロジックで対処する。
DLsiteおよびFANZAにおけるAI生成同人CG・漫画は2024〜2026年にかけて市場を形成した。DLsiteのAI生成専用フロア[11]では1作品あたりの中央値が56DL前後とされており、価格帯880〜1,100円が主流。FANZA側は月3本制限・AI専用カテゴリ隔離の規制が入っているが、DLsiteは比較的自由度が高い。
| プラットフォーム | 主流価格帯 | 手数料 | AI作品規制 | 出品上限 |
|---|---|---|---|---|
| DLsite | 880〜1,100円 | 約40〜55% | AI専用フロア | 制限なし |
| FANZA | 550〜1,100円 | 約30〜40% | 月3本制限・隔離 | 月3本 |
| Booth | 330〜880円 | 5.6%+22円 | R18可・ガイドライン準拠 | 制限なし |
RTX 3090 Ti (24GB VRAM)環境で、1024×1024px・steps=30・cfg=6.0の条件では1枚約8秒で生成できる[10]。8ページ構成で各ページ3枚生成(最良1枚採用)とすると:
8P × 3枚/P = 24枚生成 × 8秒/枚 = 192秒 ≈ 3.2分/作品
月100作品:
生成時間: 192秒 × 100 = 19,200秒 = 5.3時間
電力コスト: 350W × 5.3h × 35円/kWh ≈ 65円
監視人件費: 30分/日 × 31日 ≈ 15.5時間 (週7時間程度)
技術的には月100作品は十分可能。ボトルネックは「生成」ではなく「品質ゲート通過率」「出品作業」「タイトル・サムネイル作成」。GQ70点以上合格率を高めるためのプロンプト設計が最重要課題となる。
ComfyUI APIと競合・補完する自動化ツール・ワークフロー管理ソリューションを比較する。
| # | ツール | API自動化 | バッチ生成 | LoRA対応 | R18可否 | 主用途 |
|---|---|---|---|---|---|---|
| 1 | ComfyUI (自社)[1] | 完全対応 | WebSocket | LoadLoRA | 可 | ローカル全自動生成・本DR主役 |
| 2 | Automatic1111 WebUI[14] | REST API | X/Y/Zグリッド | 対応 | 可 | UIベース・API連携は限定的 |
| 3 | ViewComfy Cloud[15] | 完全対応 | クラウド並列 | 対応 | 審査制 | クラウドComfyUI・コスト高 |
| 4 | RunComfy[16] | Serverless API | 対応 | 対応 | 不可 | サーバーレスComfyUI・SFWのみ |
| 5 | anima-pipeline[17] | stdlib only | LLM統合 | 間接対応 | 自己責任 | LLM+ComfyUI自動プロンプト生成 |
| 6 | ComfyUI-Deploy[8] | カスタムルート | 対応 | 対応 | 設定次第 | 本番デプロイ向けComfyUI拡張 |
| 7 | ai-dock/comfyui-api-wrapper[9] | Python Wrapper | 503+Retry-After | 対応 | 可 | 本番APIラッパー・3段パイプライン |
| 8 | n8n ComfyUI連携[13] | ノーコード | フロー制御 | 間接 | 設定次第 | ノーコード自動化・複雑フロー向け |
| 9 | ComfyUI LoRA Manager[12] | 拡張ノード | 対応 | 完全対応 | 可 | LoRA管理・レシピ管理・解析 |
| 10 | SaladTech comfyui-api[9] | 水平スケール | 非同期出力 | 対応 | 規約審査 | クラウドスケール・出力直接返却 |
クラウド系はR18禁止または審査制。ローカルComfyUI + Python WebSocket APIが唯一の実用解。ai-dock wrapperとComfyUI-Deployは本番運用の参考実装として有用。
┌─────────────────────────────────────────────────────────────────┐
│ 1クリック起動: run_pipeline.py │
└───────────────────────────────┬─────────────────────────────────┘
│
┌─────────────────▼─────────────────┐
│ STEP 1: state.json 読み込み │
│ vol_id / char_name / lora / scenes │
└─────────────────┬─────────────────┘
│
┌─────────────────▼─────────────────┐
│ STEP 2: prompt_builder.py │
│ state.json → workflow_api.json │
│ (ノードID注入・seed乱数・LoRA設定) │
└─────────────────┬─────────────────┘
│
┌─────────────────▼─────────────────┐
│ STEP 3: ComfyUI WebSocket API │
│ POST /prompt → prompt_id取得 │
│ ws://127.0.0.1:8188/ws 監視 │
│ ├─ "executing" → ノード進捗表示 │
│ ├─ "progress" → steps進捗バー │
│ └─ "status" → queue_remaining │
└─────────────────┬─────────────────┘
│
┌─────────────────▼─────────────────┐
│ STEP 4: 画像取得・品質チェック │
│ GET /history/{prompt_id} │
│ GET /view?filename=...&type=output │
│ ※50枚毎: POST /free (VRAM解放) │
└─────────────────┬─────────────────┘
│
┌─────────────────▼─────────────────┐
│ STEP 5: PIL写植エンジン │
│ 吹き出し描画 + 縦書きセリフ │
│ SFX文字 + コマ枠合成 │
└─────────────────┬─────────────────┘
│
┌─────────────────▼─────────────────┐
│ STEP 6: 8ページ がっちゃんこ │
│ page_assembler.py │
│ 1P〜8P→ PDF/ZIP出力 │
└─────────────────┬─────────────────┘
│
┌─────────────────▼─────────────────┐
│ STEP 7: 品質ゲート + 自動採点 │
│ GQ採点スクリプト (Grok/local) │
│ 70点未満→ 再生成フラグ │
└─────────────────────────────────┘
D:\projects\fanza3_mass\
├── scripts\
│ ├── run_pipeline.py # 1クリック起動スクリプト
│ ├── prompt_builder.py # state.json → workflow変換
│ ├── comfyui_client.py # WebSocket APIクライアント
│ ├── error_handler.py # OOM/リトライ処理
│ ├── batch_manager.py # queue_remaining監視
│ ├── typeset_engine.py # PIL写植エンジン
│ └── page_assembler.py # がっちゃんこ
├── templates\
│ └── workflow_base.json # ベースワークフロー(API形式)
├── states\
│ └── vol_001_miyabi.json # state.json
└── output\
└── vol_001\ # 出力先
├── raw_images\ # 生成画像
├── typeset\ # 写植済み
└── final\ # 最終ZIP
{
"vol_id": "vol_001",
"char_name": "miyabi",
"lora_path": "miyabi_lora_v1.safetensors",
"lora_strength": 0.85,
"base_model": "waiIllustriousSDXL_v160.safetensors",
"width": 1024,
"height": 1024,
"pages": [
{
"page": 1,
"scene_id": "s5",
"expr_level": 5,
"pose": "cowgirl_position",
"positive": "1girl, (miyabi:1.1), cowgirl position, ahegao, (18-21 years old:1.4), (youthful), (fair skin:1.3), blonde hair, blue eyes, nude, sex, cum, internal cumshot, ecstasy, hearts in eyes, (japanese text:1.2), heart, speech bubble",
"negative": "(mature:1.5)(adult:1.4)(milf:1.5)(old:1.4)(aged face:1.4)(dark skin:1.4)(tanned:1.4)(black hair:1.4)(blue hair:1.4)(multicolored hair:1.4)(gradient hair:1.4)(group:1.3)(crowd:1.3)(extra person:1.3)(male hair:1.3)(male head visible:1.3)(male torso:1.3)(bad anatomy:1.4)(blurry:1.3)",
"cfg": 6.0,
"steps": 30,
"seed": -1,
"sampler": "dpmpp_2m",
"scheduler": "karras"
},
{
"page": 2,
"scene_id": "s4",
"expr_level": 4,
"pose": "doggystyle",
"positive": "1girl, (miyabi:1.1), doggystyle, pleasure face, (18-21 years old:1.4), (fair skin:1.3), blonde hair, blue eyes, nude, sex, (japanese text:1.2), heart, speech bubble",
"negative": "(mature:1.5)(adult:1.4)(milf:1.5)(dark skin:1.4)(tanned:1.4)(black hair:1.4)(blue hair:1.4)(multicolored hair:1.4)(gradient hair:1.4)(group:1.3)(bad anatomy:1.4)(blurry:1.3)",
"cfg": 6.0,
"steps": 30,
"seed": -1,
"sampler": "dpmpp_2m",
"scheduler": "karras"
}
],
"typeset": {
"font_path": "C:/Windows/Fonts/YuGothM.ttc",
"bubble_style": "oval",
"font_size": 18,
"line_spacing": 1.35,
"outline_width": 2
}
}
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
state.json → ComfyUI workflow_api.json 変換モジュール
ComfyUI WebSocket API Reference: [1] [2] [5]
"""
import json
import random
import copy
from pathlib import Path
# ComfyUIノードID定数 (workflow_base.jsonに合わせて変更)
NODE_ID = {
"checkpoint_loader": "4",
"lora_loader": "16",
"positive_clip": "6",
"negative_clip": "7",
"ksampler": "3",
"vae_decode": "8",
"save_image": "9",
"empty_latent": "5",
}
def load_base_workflow(template_path: str) -> dict:
"""ベースワークフロー(API形式)を読み込む"""
with open(template_path, "r", encoding="utf-8") as f:
return json.load(f)
def load_state(state_path: str) -> dict:
"""state.jsonを読み込む"""
with open(state_path, "r", encoding="utf-8") as f:
return json.load(f)
def build_workflow_for_page(
base_workflow: dict,
state: dict,
page_data: dict,
output_prefix: str = "output"
) -> dict:
"""
state.jsonの1ページ分データを元にComfyUIワークフローJSONを生成する。
base_workflowは破壊しないようdeep copyを使う。
Parameters
----------
base_workflow : dict workflow_base.json (API format)
state : dict state.json全体
page_data : dict state["pages"][i]
output_prefix : str SaveImageノードのfilename_prefix
Returns
-------
dict : POST /prompt に渡すworkflow_api.json
"""
wf = copy.deepcopy(base_workflow)
ids = NODE_ID
# --- CheckpointLoaderSimple: モデル指定 ---
wf[ids["checkpoint_loader"]]["inputs"]["ckpt_name"] = state["base_model"]
# --- LoraLoader: LoRA指定 ---
if ids["lora_loader"] in wf:
wf[ids["lora_loader"]]["inputs"]["lora_name"] = state["lora_path"]
wf[ids["lora_loader"]]["inputs"]["strength_model"] = state["lora_strength"]
wf[ids["lora_loader"]]["inputs"]["strength_clip"] = state["lora_strength"]
# --- CLIPTextEncode (positive) ---
wf[ids["positive_clip"]]["inputs"]["text"] = page_data["positive"]
# --- CLIPTextEncode (negative) ---
wf[ids["negative_clip"]]["inputs"]["text"] = page_data["negative"]
# --- EmptyLatentImage: 解像度 ---
wf[ids["empty_latent"]]["inputs"]["width"] = state.get("width", 1024)
wf[ids["empty_latent"]]["inputs"]["height"] = state.get("height", 1024)
wf[ids["empty_latent"]]["inputs"]["batch_size"] = 1
# --- KSampler: 各種パラメータ ---
seed = page_data.get("seed", -1)
if seed == -1:
seed = random.randint(0, 2**32 - 1)
wf[ids["ksampler"]]["inputs"]["seed"] = seed
wf[ids["ksampler"]]["inputs"]["cfg"] = page_data.get("cfg", 6.0)
wf[ids["ksampler"]]["inputs"]["steps"] = page_data.get("steps", 30)
wf[ids["ksampler"]]["inputs"]["sampler_name"] = page_data.get("sampler", "dpmpp_2m")
wf[ids["ksampler"]]["inputs"]["scheduler"] = page_data.get("scheduler", "karras")
wf[ids["ksampler"]]["inputs"]["denoise"] = 1.0
# --- SaveImage: ファイル名プレフィックス ---
wf[ids["save_image"]]["inputs"]["filename_prefix"] = (
f"{output_prefix}_{state['vol_id']}_p{page_data['page']:02d}_{page_data['scene_id']}"
)
return wf
def build_all_pages(state_path: str, template_path: str, output_prefix: str = "output") -> list:
"""
state.jsonの全ページ分のworkflow_api.jsonリストを生成して返す。
Returns
-------
list of dict : [(page_num, workflow_dict), ...]
"""
state = load_state(state_path)
base_wf = load_base_workflow(template_path)
results = []
for page_data in state["pages"]:
wf = build_workflow_for_page(base_wf, state, page_data, output_prefix)
results.append((page_data["page"], wf))
print(f" [builder] P{page_data['page']:02d} scene={page_data['scene_id']} built")
return results
if __name__ == "__main__":
# 動作確認
pages = build_all_pages(
state_path="states/vol_001_miyabi.json",
template_path="templates/workflow_base.json",
output_prefix="test"
)
print(f"生成ワークフロー数: {len(pages)}")
for pnum, wf in pages:
print(f" P{pnum:02d}: {len(json.dumps(wf))}バイト")
ComfyUIのWebSocket APIはws://127.0.0.1:8188/ws?clientId={client_id}で接続し、リアルタイムで実行状況を受信できる[2][4]。メッセージタイプはexecuting/status/progressの3種。
| エンドポイント | メソッド | 用途 | 主要パラメータ |
|---|---|---|---|
/ws | WebSocket | リアルタイム進捗 | clientId=UUID |
/prompt | POST | ワークフロー投入 | prompt(json), client_id |
/history/{prompt_id} | GET | 結果取得 | prompt_id(UUID) |
/view | GET | 画像ダウンロード | filename, subfolder, type |
/free | POST | VRAM解放 | unload_models, free_memory |
/system_stats | GET | VRAM状態確認 | - |
/queue | GET | キュー状態確認 | - |
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ComfyUI WebSocket APIクライアント 完全実装
参考: ComfyUI公式サンプル [2], Runflow Guide [4], 9elements Blog [5]
"""
import json
import uuid
import time
import urllib.request
import urllib.parse
import urllib.error
import websocket # pip install websocket-client
from pathlib import Path
from typing import Optional
SERVER_ADDRESS = "127.0.0.1:8188"
DEFAULT_TIMEOUT = 600 # 秒 (10分・長時間生成対応)
class ComfyUIClient:
"""ComfyUI WebSocket/REST APIクライアント"""
def __init__(self, server_address: str = SERVER_ADDRESS):
self.server_address = server_address
self.client_id = str(uuid.uuid4())
self.ws: Optional[websocket.WebSocket] = None
print(f"[ComfyUIClient] client_id={self.client_id}")
# --------------------------------------------------
# 接続管理
# --------------------------------------------------
def connect(self):
"""WebSocket接続を確立する"""
url = f"ws://{self.server_address}/ws?clientId={self.client_id}"
self.ws = websocket.WebSocket()
self.ws.connect(url)
print(f"[ComfyUIClient] WebSocket接続完了: {url}")
def disconnect(self):
"""WebSocket接続を閉じる"""
if self.ws:
self.ws.close()
self.ws = None
def reconnect(self, max_retries: int = 5, wait: float = 3.0):
"""WebSocket切断時の再接続 (自動リトライ)"""
for attempt in range(1, max_retries + 1):
try:
print(f"[reconnect] 再接続試行 {attempt}/{max_retries}...")
self.connect()
return
except Exception as e:
print(f"[reconnect] 失敗: {e}")
time.sleep(wait)
raise ConnectionError(f"ComfyUIへの再接続に{max_retries}回失敗しました")
# --------------------------------------------------
# REST API
# --------------------------------------------------
def _http_get(self, path: str) -> dict:
"""GETリクエスト"""
url = f"http://{self.server_address}{path}"
with urllib.request.urlopen(url, timeout=30) as r:
return json.loads(r.read())
def _http_post(self, path: str, data: dict) -> dict:
"""POSTリクエスト"""
url = f"http://{self.server_address}{path}"
body = json.dumps(data).encode("utf-8")
req = urllib.request.Request(
url, data=body, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
def queue_prompt(self, workflow: dict) -> str:
"""
ワークフローをComfyUIキューに投入する。
Returns: prompt_id (str)
"""
payload = {"prompt": workflow, "client_id": self.client_id}
response = self._http_post("/prompt", payload)
prompt_id = response["prompt_id"]
print(f"[queue_prompt] prompt_id={prompt_id}")
return prompt_id
def get_history(self, prompt_id: str) -> dict:
"""実行履歴を取得する"""
return self._http_get(f"/history/{prompt_id}")
def get_queue_status(self) -> dict:
"""キューの残件数を取得する"""
return self._http_get("/queue")
def get_system_stats(self) -> dict:
"""サーバーのVRAM使用量等を取得する"""
return self._http_get("/system_stats")
def free_memory(self, unload_models: bool = True, free_memory: bool = True):
"""
VRAM/RAMを解放する (OOM対策・定期メンテ)
POST /free: OOM後やバッチ50枚毎に呼び出す [3]
"""
self._http_post("/free", {
"unload_models": unload_models,
"free_memory": free_memory
})
print("[free_memory] VRAM解放完了")
# --------------------------------------------------
# 生成メイン: プロンプト投入 → WebSocket監視 → 画像取得
# --------------------------------------------------
def generate_and_wait(
self,
workflow: dict,
timeout: float = DEFAULT_TIMEOUT
) -> list:
"""
ワークフローを投入して完了まで待機し、生成画像バイト列リストを返す。
Parameters
----------
workflow : dict ComfyUI API形式のワークフローJSON
timeout : float タイムアウト秒数
Returns
-------
list of bytes : 生成された画像データ (PNG)
"""
prompt_id = self.queue_prompt(workflow)
images = self._wait_for_completion(prompt_id, timeout)
return images
def _wait_for_completion(self, prompt_id: str, timeout: float) -> list:
"""
WebSocketメッセージを監視して、prompt_idの実行完了を待つ。
完了後、/history → /view で画像を取得して返す。
"""
start = time.time()
while True:
# タイムアウトチェック
if time.time() - start > timeout:
raise TimeoutError(f"prompt_id={prompt_id} がタイムアウト ({timeout}秒)")
try:
raw = self.ws.recv()
except websocket.WebSocketConnectionClosedException:
print("[wait] WebSocket切断検出 → 再接続")
self.reconnect()
continue
# バイナリメッセージ(プレビュー画像)はスキップ
if isinstance(raw, bytes):
continue
msg = json.loads(raw)
msg_type = msg.get("type", "")
data = msg.get("data", {})
if msg_type == "executing":
node = data.get("node")
if node is None and data.get("prompt_id") == prompt_id:
# node=Noneかつ自分のprompt_id → 実行完了
print(f"[wait] 完了検出: prompt_id={prompt_id}")
break
elif node:
print(f"[wait] 実行中ノード: {node}")
elif msg_type == "progress":
value = data.get("value", 0)
max_val = data.get("max", 1)
pct = int(value / max_val * 100)
print(f"\r[progress] {pct}% ({value}/{max_val})", end="", flush=True)
elif msg_type == "status":
q_remaining = data.get("status", {}).get("exec_info", {}).get("queue_remaining", "?")
print(f"[status] queue_remaining={q_remaining}")
print() # 改行
return self._fetch_images(prompt_id)
def _fetch_images(self, prompt_id: str) -> list:
"""
GET /history → GET /view で画像バイト列を取得する。
"""
history = self.get_history(prompt_id)
if prompt_id not in history:
raise ValueError(f"履歴にprompt_id={prompt_id}が見つかりません")
outputs = history[prompt_id].get("outputs", {})
images = []
for node_id, node_output in outputs.items():
for img_info in node_output.get("images", []):
filename = img_info["filename"]
subfolder = img_info.get("subfolder", "")
img_type = img_info.get("type", "output")
params = urllib.parse.urlencode({
"filename": filename,
"subfolder": subfolder,
"type": img_type
})
url = f"http://{self.server_address}/view?{params}"
with urllib.request.urlopen(url, timeout=30) as r:
images.append(r.read())
print(f"[fetch] 取得: {filename} ({len(images[-1])/1024:.1f}KB)")
return images
ComfyUIのバッチ生成で最も多いエラーはCUDA OOMとWebSocket切断の2種。2026年初頭に標準搭載されたDynamic VRAM[7]でOOM頻度は大幅減少したが、200〜300枚連続生成ではVRAMリークが発生する場合があり/freeエンドポイントの定期呼び出しが依然推奨される[3]。
毎ワークフロー後に/freeを呼ぶとモデルキャッシュが失われ、次回生成時にフルコールドスタートが発生する(約15〜20秒のロード時間増加)。推奨は50〜100枚毎またはOOMエラー検出時のみ。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
OOM/タイムアウト/接続エラーのリトライ処理
参考: ai-dock comfyui-api-wrapper [9], apatero.com batch guide [6]
"""
import time
import json
import urllib.error
from typing import Callable, Any
from comfyui_client import ComfyUIClient
class GenerationError(Exception):
"""生成エラーの基底クラス"""
pass
class OOMError(GenerationError):
"""CUDA Out of Memory エラー"""
pass
class TimeoutError(GenerationError):
"""タイムアウトエラー"""
pass
def with_retry(
func: Callable,
max_retries: int = 3,
wait_seconds: float = 10.0,
on_retry: Callable = None
) -> Any:
"""
汎用リトライデコレータ関数。
OOMエラー検出時は /free を呼び出してから再試行する。
"""
last_error = None
for attempt in range(1, max_retries + 1):
try:
return func()
except OOMError as e:
print(f"[retry] OOMエラー検出 (試行{attempt}/{max_retries}): {e}")
if on_retry:
on_retry()
time.sleep(wait_seconds)
last_error = e
except TimeoutError as e:
print(f"[retry] タイムアウト (試行{attempt}/{max_retries}): {e}")
time.sleep(wait_seconds * 2)
last_error = e
except urllib.error.URLError as e:
print(f"[retry] 接続エラー (試行{attempt}/{max_retries}): {e}")
time.sleep(wait_seconds)
last_error = e
except Exception as e:
# 未知のエラーは即座に再raise (リトライ不要)
raise
raise GenerationError(f"最大リトライ回数({max_retries})到達: {last_error}")
def detect_oom_from_history(history: dict, prompt_id: str) -> bool:
"""
/historyレスポンスからOOMエラーを検出する。
ComfyUIはOOM時にstatusにエラー情報を含む場合がある。
"""
if prompt_id not in history:
return False
status = history[prompt_id].get("status", {})
messages = status.get("messages", [])
for msg_type, msg_data in messages:
if msg_type == "execution_error":
exc_msg = msg_data.get("exception_message", "")
if "out of memory" in exc_msg.lower() or "CUDA" in exc_msg:
return True
return False
def safe_generate(
client: ComfyUIClient,
workflow: dict,
page_num: int,
image_counter: list, # [current_count] — ミュータブルなカウンタ
free_interval: int = 50,
max_retries: int = 3
) -> list:
"""
1ページ分の安全な生成処理。
- OOM検出 → /free → リトライ
- free_interval枚毎に定期 /free
- タイムアウト → 再接続 → リトライ
Parameters
----------
client : ComfyUIClient
workflow : dict 対象ワークフロー
page_num : int ページ番号 (ログ用)
image_counter : list [現在の生成枚数] (参照渡し)
free_interval : int 定期VRAMクリア間隔 (枚数)
max_retries : int 最大リトライ数
Returns
-------
list of bytes : 生成画像データ
"""
def on_oom_retry():
"""OOM時の回復処理"""
print(f"[safe_generate] VRAM解放実行 (P{page_num:02d} OOM回復)")
client.free_memory(unload_models=True, free_memory=True)
time.sleep(5.0) # VRAM解放待ち
def try_generate():
images = client.generate_and_wait(workflow, timeout=600)
if not images:
raise GenerationError(f"P{page_num:02d}: 画像が生成されませんでした")
return images
# 定期的なVRAMクリア
image_counter[0] += 1
if image_counter[0] % free_interval == 0:
print(f"[safe_generate] 定期VRAM解放 ({image_counter[0]}枚目)")
client.free_memory(unload_models=False, free_memory=True) # モデルキャッシュ保持
time.sleep(2.0)
# リトライ付き生成
result = with_retry(
try_generate,
max_retries=max_retries,
wait_seconds=10.0,
on_retry=on_oom_retry
)
return result
def check_vram_status(client: ComfyUIClient, warn_threshold_gb: float = 4.0) -> bool:
"""
VRAM空き容量を確認し、閾値を下回ったら警告を出す。
Returns: True=安全 / False=危険(要/free)
"""
try:
stats = client.get_system_stats()
devices = stats.get("devices", [])
for device in devices:
vram_free = device.get("vram_free", 0) / (1024**3) # bytes→GB
vram_total = device.get("vram_total", 1) / (1024**3)
print(f"[vram] {device.get('name','GPU')}: "
f"{vram_free:.1f}GB / {vram_total:.1f}GB 空き")
if vram_free < warn_threshold_gb:
print(f"[vram] 警告: VRAM残量が{warn_threshold_gb}GB未満です")
return False
return True
except Exception as e:
print(f"[vram] 状態確認失敗: {e}")
return True # 確認失敗時は続行
ComfyUIはシングルGPUで同時に1ワークフローしか処理できない[4]。queue_remainingフィールドを監視してバッチのスロットリングを実装することで、安定した連続生成を実現できる。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
batch_manager.py: queue_remaining監視 + 8ページバッチ生成メインループ
参考: YushanT7 WebSocket API Part2 [8], Apatero batch guide [6]
"""
import time
import json
from pathlib import Path
from comfyui_client import ComfyUIClient
from prompt_builder import build_all_pages
from error_handler import safe_generate
MAX_QUEUE_SIZE = 3 # 同時キュー投入上限
POLL_INTERVAL = 2.0 # キュー確認間隔 (秒)
FREE_INTERVAL = 50 # この枚数毎にVRAM定期クリア
def wait_for_queue_slot(client: ComfyUIClient, max_queue: int = MAX_QUEUE_SIZE):
"""
queue_remainingがmax_queue未満になるまで待機する。
"""
while True:
try:
queue_info = client.get_queue_status()
# queue_runningとqueue_pendingの合計
running = len(queue_info.get("queue_running", []))
pending = len(queue_info.get("queue_pending", []))
total = running + pending
print(f"[queue] running={running} pending={pending} total={total}/{max_queue}")
if total < max_queue:
return
except Exception as e:
print(f"[queue] キュー確認エラー: {e}")
time.sleep(POLL_INTERVAL)
def run_batch(
state_path: str,
template_path: str,
output_dir: str,
vol_id: str
):
"""
8ページ分の全画像を順次生成してディスクに保存するメインループ。
"""
output_path = Path(output_dir) / vol_id / "raw_images"
output_path.mkdir(parents=True, exist_ok=True)
# ワークフロー一覧を事前生成
pages = build_all_pages(state_path, template_path, output_prefix=vol_id)
print(f"[batch] {len(pages)}ページ分のワークフロー生成完了")
client = ComfyUIClient()
client.connect()
image_counter = [0] # ミュータブルカウンタ
results = {} # {page_num: [image_bytes, ...]}
try:
for page_num, workflow in pages:
print(f"\n[batch] === P{page_num:02d} 生成開始 ===")
# キュースロット待機
wait_for_queue_slot(client, max_queue=1) # 1件ずつ確実に処理
# 安全な生成 (OOMリトライ込み)
images = safe_generate(
client=client,
workflow=workflow,
page_num=page_num,
image_counter=image_counter,
free_interval=FREE_INTERVAL,
max_retries=3
)
# 画像保存
for i, img_bytes in enumerate(images):
save_path = output_path / f"p{page_num:02d}_v{i:02d}.png"
with open(save_path, "wb") as f:
f.write(img_bytes)
print(f"[batch] 保存: {save_path}")
results[page_num] = images
print(f"[batch] P{page_num:02d} 完了 ({len(images)}枚)")
finally:
client.disconnect()
print("[batch] WebSocket切断")
print(f"\n[batch] 全ページ完了: {len(results)}/{len(pages)}P")
return results
def run_pipeline_1click(
state_path: str,
template_path: str,
output_base_dir: str = r"D:\projects\fanza3_mass\output"
):
"""
1クリック起動エントリーポイント。
state.jsonを読み込んで8P生成→写植→ZIP出力まで実行する。
"""
import json
with open(state_path, encoding="utf-8") as f:
state = json.load(f)
vol_id = state["vol_id"]
print(f"=== パイプライン開始: {vol_id} ===")
t_start = time.time()
# STEP 1-4: 画像生成
run_batch(state_path, template_path, output_base_dir, vol_id)
# STEP 5-7: 写植・がっちゃんこ (別スクリプト呼び出し)
import subprocess
subprocess.run(
["python", "typeset_engine.py", "--vol", vol_id, "--base", output_base_dir],
check=True
)
subprocess.run(
["python", "page_assembler.py", "--vol", vol_id, "--base", output_base_dir],
check=True
)
elapsed = time.time() - t_start
print(f"=== パイプライン完了: {vol_id} / {elapsed:.1f}秒 ===")
if __name__ == "__main__":
import sys
state_path = sys.argv[1] if len(sys.argv) > 1 else "states/vol_001_miyabi.json"
template_path = sys.argv[2] if len(sys.argv) > 2 else "templates/workflow_base.json"
run_pipeline_1click(state_path, template_path)
| パラメータ | 確定値 | 理由 |
|---|---|---|
| モデル | waiIllustriousSDXL_v160.safetensors | マネキン体位集95点実績・最安定 |
| CFG | 6.0 | GOLDEN勝ちパターン確定値 |
| Steps | 30 | 品質/速度バランス最適 |
| 解像度 | 1024×1024 | SDXL標準・漫画コマ切り出し対応 |
| Sampler | dpmpp_2m | 高品質・安定 |
| Scheduler | karras | dpmpp_2mと最適組み合わせ |
| VAE | 内蔵VAE (sdxl_vae.safetensors) | 色転び防止・外部VAE禁止 |
| LoRA strength | 0.80〜0.90 | キャラ固定と崩れ防止のバランス |
[4] CheckpointLoaderSimple
ckpt_name: waiIllustriousSDXL_v160.safetensors
|
[16] LoraLoader
lora_name: miyabi_lora_v1.safetensors
strength_model: 0.85
strength_clip: 0.85
|
┌────┴────┐
| |
[6] CLIPTextEncode [7] CLIPTextEncode
(positive) (negative)
└────┬────┘
|
[5] EmptyLatentImage
width: 1024, height: 1024, batch_size: 1
|
[3] KSampler
cfg: 6.0, steps: 30
sampler_name: dpmpp_2m
scheduler: karras
seed: (乱数)
|
[8] VAEDecode
|
[9] SaveImage
filename_prefix: vol_001_p01_s5
# state.jsonで作品毎にLoRAを指定
# vol_001: miyabi_lora_v1.safetensors (strength=0.85)
# vol_002: yui_lora_v2.safetensors (strength=0.80)
# vol_003: momo_lora_v1.safetensors (strength=0.90)
# prompt_builder.pyが自動的にNodeにセット:
wf["16"]["inputs"]["lora_name"] = state["lora_path"]
wf["16"]["inputs"]["strength_model"] = state["lora_strength"]
wf["16"]["inputs"]["strength_clip"] = state["lora_strength"]
# LoRAが存在しない場合はスキップ (ノード削除パターン)
from pathlib import Path
lora_dir = Path(r"D:\ComfyUI\models\loras")
if not (lora_dir / state["lora_path"]).exists():
# LoraLoaderをバイパスしてCheckpointから直結
wf["3"]["inputs"]["model"] = [ids["checkpoint_loader"], 0]
wf["3"]["inputs"]["positive"] = [ids["positive_clip"], 0]
del wf[ids["lora_loader"]] # ノード削除
ComfyUI LoRA Manager拡張[12]を導入するとLoRAのレシピ管理・プレビュー・ワークフロー自動注入が可能になる。state.jsonとの統合はLoRA名をフルパスではなくファイル名のみで管理し、ComfyUIのmodels/loras/ディレクトリに配置する運用が安定する。
| 項目 | 悲観シナリオ | 中間シナリオ | 楽観シナリオ |
|---|---|---|---|
| 月産作品数 | 100作品 | 100作品 | 100作品 |
| 平均DL数/作品 | 2DL | 10DL | 30DL |
| 単価 | 880円 | 880円 | 1,100円 |
| 売上合計 | 176,000円 | 880,000円 | 3,300,000円 |
| DLsite手数料 (約48%) | -84,480円 | -422,400円 | -1,584,000円 |
| 手取り売上 | 91,520円 | 457,600円 | 1,716,000円 |
| 電力コスト | -65円 | -65円 | -65円 |
| 人件費換算 (時給2,000円×15h) | -30,000円 | -30,000円 | -30,000円 |
| 純利益 | 61,455円 | 427,535円 | 1,685,935円 |
新規出品者の初月DL数は1〜3DLが多い。悲観シナリオが現実的な出発点。品質ゲート通過率・サムネイルクオリティ・タイトルSEOで大きく変動する。3ヶ月継続して中間シナリオへの移行を目指す運用が推奨される。
本DRで参照した17本の実在ソース。架空URLは一切含まない。
技術: WebSocket完全実装・OOMリトライ・Dynamic VRAM詳述で高得点 | マーケ: 3シナリオ収益試算・30日ロードマップ完備 | 法務: DLsite/FANZA規制言及あり・詳細は別DR参照 | 競合: 10ツール比較・R18可否明示
コスト: grok-4.3 $1.0824 + CC直接構築 / 総推定約¥1,620 | 既存DR重複: なし (新規作成) | ソース数: 17本