🧙‍♂️

ComfyUIのAPIを使用して自動化

2024/09/27に公開

ComfyUIは、コマンドラインからコア部分を実行させ、ブラウザでノードをこねくり回して画像を生成させる、というような使い方が普通です。
例えば、こんな風。

でも、思いませんか。
seed値で出てくる画像の一覧が欲しい、と。
……思いませんか。そうですか。

APIを使って自動化

ComfyUIを実行させると、実はAPIの口があって、自動実行させることもできるのです。
参考になるのは4kk11さんのこちら。「ComfyUIのプラグインを作る!

4kk11さんのでは、ノードにエンドポイントを持たせて、そのエンドポイントめがけて実行することで、画面上のノードを操っています。
今回やるのは、ComfyUIのワークフローをAPIで認識できるようにして、バックグラウンドで動かす完全サーバー/クライアント構造になりますね。
なので、動かす段階になるとブラウザは要りません。

やることは、ComfyUIのディレクトリ内、 script-examples/websockets_api_example.py とほとんど同じことをやります。
(違うのは終了を待って次の動作につなげること)

APIで使うようにワークフローを修正

バックグラウンドで動かすので、プレビューとか要らんのです。
というわけで、実際に実行するのはこちら。

ホントにプレビュー除いただけ、あとは出力で画像保存を付け足しました、というもの。
上と比べると一目瞭然ですね。

APIで使う用のワークフロー保存

API用ワークフロー保存には、ひと手間必要です。
まずはサイドバーの上部、歯車マークをぽちっとな。

設定ウインドウが出ますので、 Dev Mode をONにしてください。

すると、サイドメニューに Save (API Format) というボタンが出ます。
これでAPI用のワークフローを保存できるようになりました。
出てきたボタンをぽちっとな、して、ワークフローを保存してください。

クライアントソフトを作る

クライアントのソフトは4kk11さんと同じくC#を使います。
出来上がったものはこちらになります。

https://github.com/yakumo/ComfyUIApiTest

プロジェクト用意

今回はテスト用プログラムを2つ用意します。
一つは画像保存型、もう一つは画像使いまわし型です。

$ dotnet new sln -n ComfyUIApiTest
$ dotnet new console -n ApiTest1
$ dotnet add ApiTest1/ package System.CommandLine --prerelease
$ dotnet sln add ApiTest1/
$ dotnet new console -n ApiTest2
$ dotnet add ApiTest2/ package System.CommandLine --prerelease
$ dotnet sln add ApiTest2/
$ explorer ComfyUIApiTest.sln

System.CommandLine はコマンドラインアプリには必須ですねー。

画像保存型のアプリ解説

ApiTest1のほうです。

ワークフローのクラスを作る

ソース

  1. 上記で保存した workflow-api.json をプロジェクト内に移動
  2. 埋め込みファイルで設定
  3. workflow-api.json 開き、全選択コピー
  4. ApiTest1に新しいクラス Workflow を作り、編集→形式を選択して貼り付け→JSONをクラスとして張り付ける
  5. クラスを整える
  6. staticメソッドを作り、埋め込みリソースにしたjsonを読み込んでクラスオブジェクトとして返すようにする
  7. API呼び出しの際には prompt というパラメータでワークフローを指定するのでそれもクラス化しておく

特にノードになる番号のプロパティはクラス定義とバッティングするので名前変更後 JsonPropertyName Attributeで固定します。

public class Workflow
{
    [JsonPropertyName("3")]
    public _3 n3 { get; set; }
    [JsonPropertyName("4")]
    public _4 n4 { get; set; }
    [JsonPropertyName("5")]
    public _6 n6 { get; set; }

Promptクラスは単純にWorkflowクラスをpromptというパラメータに変換しているだけです。

public class Prompt
{
    [JsonPropertyName("prompt")]
    public Workflow Workflow { get; set; }
}

(わかってる、わかってるんだよ。Newtonsoft.Jsonを使えば楽なのは…)

メインプログラムを作る

ソース

メインの動作としては以下になります。

  1. WebSocketで接続する
  2. ループ開始
  3. WebSocketで受信したデータのうち、 queue_remaining = 0 か判定し、違えばループ開始に戻る
  4. API用データを整える
  5. HTTP接続し、ワークフローを動かす
  6. HTTP終了後、次のAPI用内部データを用意する
  7. ループ開始に戻る

WebSocketで接続する

using (var ws = new ClientWebSocket())
{
    var uri = new Uri($"ws://{serverAddress}/ws");
    await ws.ConnectAsync(uri, tokenSource.Token);

起動終了だけ見たいので、パスは /ws で見ます。

ループ開始

while (ws.State == WebSocketState.Open && !tokenSource.IsCancellationRequested)
{

WebSocketで接続できている間はループします。

queue_remaining = 0 か判定し、違えばループ開始に戻る

var statusObject = JsonSerializer.Deserialize<ComfyReceivedStatusObject>(json);
if (statusObject?.data?.status?.exec_info != null && statusObject.data.status.exec_info.queue_remaining == 0)
{
    if (seed > seedEnd)
    {
        tokenSource.Cancel();
        continue;
    }

ComfyUIが動いているかどうかは、 queue_remaining の値で見ます。
また、seed値が指定以上なら終了にします。

API用データを整える

var workflow = Workflow.Build();
workflow.Seed = seed;
workflow.OutputFilePrefix = $"{outDir}/{seed:00000}";

Workflow クラスに設定の口を追加しておき、簡単に設定できるようにしておきます。
実際には指定のパスに設定しているだけです。
この口はJSONに含まれないようにしないとComfiUI側でエラーが出るので、JsonIgnoreAttributeつけておきます。

[JsonIgnore]
public int Seed
{
    get => n13.inputs.seed;
    set => n13.inputs.seed = value;
}

[JsonIgnore]
public string OutputFilePrefix
{
    get => n28.inputs.filename_prefix;
    set => n28.inputs.filename_prefix = value;
}

HTTP接続し、ワークフローを動かす

var prompt = new Prompt<Workflow>(workflow);
var client = new HttpClient()
{
    Timeout = TimeSpan.FromSeconds(300),
};
var wfRes = await client.PostAsJsonAsync($"http://{serverAddress}/prompt", prompt, tokenSource.Token);
var content = await wfRes.Content.ReadAsStringAsync();

KSampler で時間がかかる(特にFLUX.1にしたら…)ので、念のためタイムアウト値を増やしてあります。
でも実際、HTTP接続はComfyUIがすぐに終了して処理が返ってきます。

今回はseed値を書き換えたかったので、Workflowクラスを作りましたが、ワークフローのデータそのままを繰り返したい場合であれば作らずともJSONを読み込んでpromptに渡してしまうのでも構いません。

これで実行すると、 ComfyUI/output ディレクトリ下に画像が保存されていきます。

画像使いまわし型のアプリ解説

ApiTest2のほうです。

上記画像保存型の場合、保存先はComfyUIディレクトリ内の output ディレクトリ下に保存されます。
これはこれで使い勝手が悪いので、もう少しデータの使いまわしができないものか…。
ということで、画像出力にする場合、WebSocketでの受け渡しができるノードがあるのでそれを使います。

ワークフローのクラス

ソース

ワークフローに関しては、ApiTest1とほとんど変わらないので解説は省きます。

メインプログラムを作る

ソース

保存関連の機能追加のため、ApiTest1からWebSocket周りを強化してあります。
動作は以下になります。

  1. WebSocketで接続する
  2. ループ開始
  3. WebSocketで受信したデータをバイナリとJSONにわける
  4. JSONのデータタイプごとに読み分ける
  5. HTTP接続し、ワークフローを動かす
  6. HTTP終了後、次のAPI用内部データを用意する
  7. データ準備完了したらファイルを用意し、バイナリで受ける準備をする
  8. バイナリデータを保存
  9. バイナリ終了時にファイルを保存
  10. ループ開始に戻る

WebSocketで接続する

var uri = new Uri($"ws://{serverAddress}/ws?clientId={clientId}");
await ws.ConnectAsync(uri, tokenSource.Token);

接続の際に、動作の一貫性のために clientIdを設定します。

JSONのデータタイプごとに読み分ける

var tmpObj = JsonSerializer.Deserialize<ComfyReceivedObject>(json);
var receivedObj = tmpObj.type switch
{
    "status" => JsonSerializer.Deserialize<ComfyReceivedStatusObject>(json),
    "execution_start" => JsonSerializer.Deserialize<ComfyReceivedExecutionStartObject>(json),
    "execution_cached" => JsonSerializer.Deserialize<ComfyReceivedExecutionCachedObject>(json),
    "executing" => JsonSerializer.Deserialize<ComfyReceivedExecutingObject>(json),
    "execution_success" => JsonSerializer.Deserialize<ComfyReceivedExecutionSuccessObject>(json),
    "progress" => JsonSerializer.Deserialize<ComfyReceivedProgressObject>(json),
    _ => tmpObj
};

式になったswitchでそれぞれ読み分けてます。
JSON二度読みになるけどまあいいよね。

switch (receivedObj)
{
    case ComfyReceivedStatusObject status when status.data?.status?.exec_info != null && status.data.status.exec_info.queue_remaining == 0 && !string.IsNullOrEmpty(status?.data?.sid):
    case ComfyReceivedExecutingObject executing when string.IsNullOrEmpty(executing.data?.node):

動かす際の判定をこれも新しく型判定できるswitchに任せます。
以前の書き方だと、typeの文字列判定と変数確認でif文が並ぶことになるのですが、ちょっとすっきりした書き方になりました。

HTTP接続し、ワークフローを動かす

var prompt = new Prompt<Workflow>(workflow)
{
    ClientId = clientId,
};
var wfRes = await client.PostAsJsonAsync($"http://{serverAddress}/prompt", prompt, tokenSource.Token);
var content = await wfRes.Content.ReadAsStringAsync();
var apiRes = JsonSerializer.Deserialize<ComfyHttpResult>(content);
if (!String.IsNullOrEmpty(apiRes.prompt_id))
{
    promptId = apiRes.prompt_id;
}

ここでもclientIdを設定しておきます。
また、戻ってきたJSONデータにpromptIdが含まれているので、それを保存しておきます。
ホントは ReadAsStringAsyncJsonSerializer.DeserializeReadFromJsonAsyncでまとめられるけど、デバッグ用に分けてしまってます…。

データ準備完了したらファイルを用意し、バイナリで受ける準備をする

case ComfyReceivedExecutingObject executing when executing.data?.node == "29" && executing.data?.prompt_id == promptId: // SaveImageWebsocket
    {
        savingFile = new FileStream(Path.Combine(outDir, $"{seed:00000}.png"), FileMode.OpenOrCreate, FileAccess.Write);
        firstSave = true;
    }

type == 'executing' はノードの実行時のコマンドです。
また、 node == "29" つまり、SaveImageWebsocketのノード番号の場合に保存開始です。
ついでに上記のpromptIdが同じならという判定をしています。
本当はpromptIdとseedを関連付けてファイル名用のseed値を保存しておかなければだめなんですが、手を抜いています。
これは、1API = 1seedでシリアルに動かしているからですね。

firstSave は、ComfyUIのデータとして、バイナリの最初の8バイトを省かなければいけないので、判定用です。

バイナリデータを保存

if (savingFile != null)
{
    if (firstSave)
    {
        savingFile.Write(rbuf, 8, res.Count - 8);
    }
    else
    {
        savingFile.Write(rbuf, 0, res.Count);
    }
    firstSave = false;
}

ファイルストリームが作られてれば保存しています。
上記の通り、バイナリの最初の8バイトは読み飛ばしです。

バイナリ終了時にファイルを保存

case ComfyReceivedExecutionSuccessObject execSuccess:
    if (savingFile != null)
    {
        savingFile.Close();
        savingFile.Dispose();
        savingFile = null;
    }

type == 'execution_success' でノード実行終了です。
ファイル保存していれば、閉じておきます。

総括

勢いで作ってあるので、いろいろ穴がありますね。
エラー判定とか使いまわし的なところとか。

あと、 KSamplercontrol_after_generate = increment でいいじゃん、ってのは無しの方向でお願いしますぅ。

Discussion