ComfyUIのプラグインを作る!
はじめに
ComfyUIはStableDiffusion等の画像生成AIをブラウザ上のノードベースUIで操作できるようにしたツールです。ComfyUIについては以前書いた記事もあるので良ければご覧ください。
今回はかなりマニアックですが、ComfyUI自体の裏側の仕組みやそのプラグイン作成に焦点を当てた解説があまり無かったので、そういったところを解説しています。また、ソースコードを眺めていて学びがあったのでそういった知見の共有として書いています。何かの参考になれば幸いです。
ComfyUIの大まかな構成
ComfyUIの構成はPythonで記述されたサーバー側とJavascriptで書かれたクライアント側に分けられます。サーバー側(py)では主にノードで組まれたワークフローの実行やノードの定義などを担っています。一方、クライアント側(js)ではブラウザに描画されるUIの部分を担っています。
(ちなみにノードUIの実装はlitegraph.jsというフレームワークを使っているみたいです。)
ComfyUIではサーバーとクライアントをインタラクティブに通信させるため、通常のHttp通信に加え、WebSocket通信を採用しています。それぞれの通信方法をどのように活用しているのか、具体例を見てみましょう。
Http通信
Http通信はクライアント側からサーバー側へリクエストを送信し、その返答を受信する一問一答のような形式でやり取りを行う通信手法です。
ComfyUIではワークフローがサーバー側で実行される故、各ノードのインプットやアウトプット、処理の内容などはサーバー側(nodes.py)で定義されています[1]。このノード定義をノードUIとしてブラウザで描画するために、Http通信を利用してクライアント起動時にその情報をサーバー側にリクエストしています。
コード上の処理の流れ:1 -> 2 -> 3 -> 4
上図のように、単に何かの情報を取得したい等のケースではHttp通信のみを使えます。ただ、先述の通り、クライアントからサーバーへの単方向の一問一答形式なので、サーバーからクライアントに逐一何かを報告するみたいな使い方はできません。そこでWebSocket通信を使います。
Http + WebSocket通信
WebSocket通信はサーバーとクライアントが接続している間、双方向のやり取りを可能にする通信手法です。(よくチャットツールとかで使われている)
ComfyUIではHttp通信でPOSTリクエストを行った後、サーバー側で行われている処理をリアルタイムでUIに反映させるため、WebSocket通信で逐一報告するようになっています。下図はQueue Promptボタンを押したときの処理を簡易的に表しています。
コード上の処理の流れ(ノードの実行まで):1 -> 2 -> 3 -> 4 --> 5 -> 6
サーバー側からクライアントに通知を送るにはserver.py
で定義されたsend_sync
関数を使います。例えばKSamplerノードの進捗(プログレスバーに使う)を通知しているのはここで行っています。サーバーからの通知があった際にはapi.jsのここの処理が実行されます。メッセージのタイプに応じて、予め設定しておいたイベントを発火させてることがわかると思います。
下のgifはQueue Promptを押した後のサーバーからのメッセージをそのままコンソールに表示しています。サーバー側からクライアントに通知が逐次渡っているのがわかります。
さて、ここで勘がいい方は気付くかと思いますが、クライアントはなにもComfyUIで用意されているWebUIでなくとも良いのです。つまり、サーバーに送るデータさえ適切に作ることが出来るのであれば、別のアプリケーションからでも画像生成できます。
既にそういったプラグインは存在していて、ComfyUI-BlenderAI-nodeではBlender側でノードを繋げてワークフローを作れるようにしているみたいです。また、Unreal EngineのプラグインComfyTexturesではあらかじめワークフローを作っておいて、入力する文字列や画像を変数化しているっぽいです。
ここまでComfyUIのざっくりとした仕組みについて語ってきましたが、ここからは本題で実際にプラグインを作ってみます。
Pythonでカスタムノードをつくる
まずは簡単なカスタムノードを作ってみましょう。
ComfyUIで特定の機能を持ったカスタムノードは、python側で定義してやるだけで作成できます。今回は文字列を入力する"TextInput"ノードとそれをコンソールにprintする"TextOutput"ノードを作ってみます。
1. ディレクトリ構成
まずはComfyUI/custom_nodes
下に以下のようなフォルダ・ファイルを作成します。
MyCustomNodes
├── __init__.py
└── nodes.py
nodes.py
に書く
2. nodes.pyにカスタムノードの定義をクラスとして記述します。このクラスの書き方は本家の方のnodes.pyが参考になると思います。
class TextInput:
@classmethod
def INPUT_TYPES(s):
return {"required": {"text": ("STRING", {"multiline": True})}}
RETURN_TYPES = ("STRING",)
FUNCTION = "run"
CATEGORY = "MyCustomNodes"
def run(self, text, seed = None):
return (text,)
class TextOutput:
@classmethod
def INPUT_TYPES(s):
return {"required": {"text": ("STRING", {"forceInput": True})}}
OUTPUT_NODE = True
RETURN_TYPES = ()
FUNCTION = "run"
CATEGORY = "MyCustomNodes"
def run(self, text):
print(text)
return ()
NODE_CLASS_MAPPINGS = {
"TextInput": TextInput,
"TextOutput": TextOutput,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"TextInput": "TextInput",
"TextOutput": "TextOutput",
}
__init__.py
に書く
3. NODE_CLASS_MAPPINGS
とNODE_DISPLAY_NAME_MAPPINGS
を明示的に公開させるため、__init__.py
の方で以下のように記述します。
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
4. 動作確認
ComfyUIを起動し、"TextInput"、"TextOutput"ノードが追加されているのを確認します。
下図のようにQueuePromptを押しコンソールにテキストがprintされればokです。
nodes.py
を少し修正
5. しかし、このままでは一つ問題があって、テキストを編集した後の最初の一回は正常にprintされるのですが、連続で実行するとなにもprintされません。これはComfyUIの仕様で、入力が以前実行したときと同じ場合、そのノードは実行しないようになっています。
これを解決するには、実行時に毎回値が変化するシード値を"TextInput"の入力に設けます。この入力タイプはINT:seed
という名前としてデフォルトで用意されてます。
class TextInput:
@classmethod
def INPUT_TYPES(s):
return {"required": {
"text": ("STRING", {"multiline": True}),
+ "seed": ("INT:seed", {}),
}}
Javascriptでノードの見た目を変える
ただ、新しく追加されたseedに関する入力は直接ユーザーが触る必要はないので、UIとして表示させない方が良さそうです。そこでJavascriptの方でノードの見た目を変えてみます。
web.js
を追加
1. Javascriptを記述するファイルとして、web.js
を所定のディレクトリ下に配置します。
MyCustomNodes
├── js
│ └── web.js <-これを追加
├── __init__.py
└── nodes.py
__init__.py
を修正
2. 追加したjsファイルを認識できるように__init__.py
を以下のように修正します。
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
+ WEB_DIRECTORY = "./js"
- __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
+ __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
web.js
で特定のインプットを非表示にする
3. ここでは"TextInput"ノードのseedとそれに関連するインプット(widget)を非表示にしています。
beforeRegisterNodeDef
はノード定義が登録される前に呼び出される関数です。
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
app.registerExtension({
name: "MyCustomNodes",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "TextInput") {
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = origOnNodeCreated ? origOnNodeCreated.apply(this) : undefined;
for (const w of this.widgets) {
if (w.name === "seed") {
w.type = "converted-widget";
if (!w.linkedWidgets) continue;
for (const lw of w.linkedWidgets) {
lw.type = "converted-widget";
}
}
}
return r;
}
}
}
})
ComfyUIを再起動してseed関連のUIが消えていればokです。
web.js
でノードの色を変える
4. 同じような具合で、ノードの色も変えてみます。
app.registerExtension({
name: "MyCustomNodes",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "TextInput") {
//中略...
+ nodeType.prototype.color = LGraphCanvas.node_colors.green.color;
+ nodeType.prototype.bgcolor = LGraphCanvas.node_colors.green.bgcolor;
}
+ else if (nodeData.name === "TextOutput") {
+ nodeType.prototype.color = LGraphCanvas.node_colors.green.color;
+ nodeType.prototype.bgcolor = LGraphCanvas.node_colors.green.bgcolor;
+ }
}
})
外部アプリと連携させてみる
ここでは簡単な例として、C#のコンソールアプリを新しいクライアントとしてサーバーと接続してみます。「コンソールアプリからサーバーにテキストを送信 → jsのクライアントにテキストを送信 → TextInputノードにセット → ワークフローを実行 → 結果をコンソールアプリに送信」という一連の流れを実装します。
1. ディレクトリ構成
新しいプラグインとしてMyCustomClient
というフォルダを作ります。
そのディレクトリ構成は以下のようにします。
MyCustomClient
├── console
│ ├── console.csproj
│ └── Program.cs
├── js
│ └── web.js
├── __init__.py
└── nodes.py
__init__.py
に書く
2. 前半の部分は先ほどと同じです。
後半にコンソールアプリからHttpリクエストできるエンドポイントを新たに追加します。このエンドポイントでは、WebSocket通信で接続しているjs側のクライアントに"TextInput"ノードのテキストを更新する&queuePromptを実行するためのメッセージを送っています。
import os
import server
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
from aiohttp import web
WEB_DIRECTORY = "./js"
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
# テキストを更新し、queuePromptを実行するエンドポイントを追加
@server.PromptServer.instance.routes.post('/my_custom_client/update_text')
async def update_text(request):
data = await request.json()
text = data["text"]
# クライアントでテキストを更新するメッセージを送信
server.PromptServer.instance.send_sync("update_text", data)
# クライアントでqueuePromptを呼び出すメッセージを送信
server.PromptServer.instance.send_sync("run_workflow", {})
return web.Response()
nodes.py
に書く
3. 新たに"TextSend"というカスタムノードを追加します。
このノードは入力されたテキストをWebSocket接続しているコンソールアプリに送信します。
import server
class TextSend:
@classmethod
def INPUT_TYPES(s):
return {"required": {"text": ("STRING", {"forceInput": True})}}
OUTPUT_NODE = True
RETURN_TYPES = ()
FUNCTION = "run"
CATEGORY = "MyCustomClient"
def run(self, text):
# テキストをクライアントに送信する(コンソールアプリの方で受け取る)
text = text + " (from TextSend node)"
server.PromptServer.instance.send_sync("send_text", {"text": text})
return ()
NODE_CLASS_MAPPINGS = {
"TextSend": TextSend,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"TextSend": "TextSend",
}
web.js
に書く
4. jsのクライアント側にWebSocket通信のメッセージが送られてきたときのイベントリスナーを登録します。
apiインスタンスのaddEventListener
関数で第一引数にメッセージのタイプと第二引数にイベント発火時の関数を記述します。
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
app.registerExtension({
name: "MyCustomClient",
async setup() {
// queuePromptを呼び出すイベントリスナーを追加
api.addEventListener("run_workflow", ({ detail }) => {
app.queuePrompt(0);
});
// TextInputノードのテキストを更新するイベントリスナーを追加
api.addEventListener("update_text", ({ detail }) => {
const nodes = app.graph.findNodesByType("TextInput");
for (const node of nodes) {
const text = detail["text"];
node.widgets[0].value = text;
}
});
}
})
Program.cs
に書く
5. コンソールアプリの実装です。
WebSocket通信でサーバーと接続し、Http通信でテキストを送信します。特定のタイプのメッセージが受信するまでWebSocket接続を維持しています。
コードを書いたらcsprojがある階層でdotnet build
します。
using System;
using System.Net.WebSockets;
using System.Text;
using Newtonsoft.Json;
using RestSharp;
namespace MyCustomClient
{
class Program
{
// サーバーアドレスを静的変数で定義
private static readonly string SERVER_ADDRESS = "127.0.0.1:8188";
static async Task Main(string[] args)
{
// ユーザー入力を受け取る
Console.WriteLine("Enter message: ");
string? input = Console.ReadLine();
if (string.IsNullOrEmpty(input))
{
Console.WriteLine("Input is empty. Exiting...");
return;
}
// メッセージ送信を試みる
try
{
await SendMessage(input);
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
}
// サーバーにメッセージを送信するメソッド
static async Task SendMessage(string message)
{
// 送信するメッセージを表示
Console.WriteLine("Sending message: " + message);
using (ClientWebSocket client = new ClientWebSocket())
{
// WebSocketサーバーへ接続
Uri serverUri = new Uri($"ws://{SERVER_ADDRESS}/ws");
await client.ConnectAsync(serverUri, CancellationToken.None);
// RESTクライアントを作成
RestClient restClient = new RestClient($"http://{SERVER_ADDRESS}");
// 送信メッセージを格納
var dic = new Dictionary<string, string>
{
{ "text", message }
};
// REST APIを通じてメッセージ送信
RestRequest restRequest = new RestRequest("/my_custom_client/update_text", Method.Post);
string jsonData = JsonConvert.SerializeObject(dic);
restRequest.AddParameter("application/json", jsonData, ParameterType.RequestBody);
await restClient.ExecuteAsync(restRequest);
// サーバーからの応答を待ち続ける
while (client.State == WebSocketState.Open)
{
var receiveBuffer = new byte[1024];
var result = await client.ReceiveAsync(new ArraySegment<byte>(receiveBuffer), CancellationToken.None);
// 受信データをJSONに変換
var json = Encoding.UTF8.GetString(receiveBuffer, 0, result.Count);
var comfyReceiveObject = JsonConvert.DeserializeObject<ComfyReceivedObject>(json);
bool isClose = false;
// 応答タイプによって処理を分岐
switch (comfyReceiveObject?.Type)
{
case "send_text":
Console.WriteLine("Received send_text: " + comfyReceiveObject.Data?["text"]);
isClose = true;
break;
}
// 応答処理後に接続を閉じる
if (isClose)
{
break;
}
}
}
}
}
// サーバーからの応答を格納するクラス
public class ComfyReceivedObject
{
[JsonProperty("type")]
public string? Type { get; set; }
[JsonProperty("data")]
public Dictionary<string, object>? Data { get; set; }
}
}
6. 動作確認
ComfyUIを起動し、"TextInput"と"TextSend"を繋げます。
その後、ビルドしたコンソールアプリ(console.exe)を実行します。任意の文字列を送信後、QueuePromptが実行されコンソール側にテキストが返ってきたらokです。
今回は非常に地味ですが、これを応用することで、任意のアプリケーションとComfyUIを連携させることが可能になります。
(以前作成したGrasshopperと連携するプラグインComfyGHはこれとほぼ同じ原理です)
まとめ
今回はComfyUIの裏側の仕組みとプラグインの作成というだいぶマニアックな解説をしました。
個人的な感想としては、ComfyUIは外部からカスタマイズし易いように設計されていて、そういった要素も人気の要因の一つになっているのだと感じました。また、任意のアプリケーション上でAIを使いたいとなったとき、そのアプリ上で実行する環境を一から作成するのではなく、こういったカスタマイズ性に富んだフレームワークに乗っからせる方が色々と恩恵が得られそうだなと思いました。
今回作成したコードは以下のgithubリポジトリに置いてあります。
-
PrimitiveやNoteなどの「値をインプットにセットする」、「UIで表示するだけ」みたいなノードはjs側のみで作られています ↩︎
Discussion