前編ではComfyUIの基礎構造から、カスタムノードの概念、さらにはフロントエンド(クライアント)とバックエンド(サーバー)の通信モデルを中心に詳しく説明しました。本後編では、そこから一歩進んで「実際に動くカスタムノードを作る」具体的なチュートリアルを行います。
まず最小限のノードを作って動かすまでの流れを追いかけ、続いて複数の入力やオプションを追加してノードを拡張する方法を見ていきます。その後、フロントエンドとのメッセージ連携に触れていきましょう。
https://note.com/aicu/n/ne425c8759bef
前編をまだご覧でない方は、ComfyUIの内部構造やNodeの仕組みを把握した上で読み進めるのがおすすめです。
「早速手を動かしてみたい!」という方は、最低限のPython環境とComfyUIの基本的な使い方だけ分かっていれば、とりあえず本後編から着手しても大丈夫です。
それでは、本格的に“コードを書く”チュートリアルを始めましょう。
カスタムノードの配布と共有のためにGitHubでリポジトリを作成します。そして、GitHubの開発環境でリポジトリ内にカスタムノードを作成していきます。
まず、以下のリンクよりGitHubにアクセスします。
以下のような画面が開きましたら、右上のSign upをクリックします。
アカウント情報の入力画面に移ります。まずは、メールアドレスを入力します。
次にパスワードを入力します。
最後にユーザー名を入力します。
「Continue」をクリックします。
CAPTCHAで人間かどうかの確認が行われます。指示通りに進めてください。
ユーザーの検証が終わると、確認コードが登録したメールアドレスに送信されるので、その確認コードを入力します。
これでアカウント作成完了です。サインイン画面が表示されるので、先ほど登録したメールアドレスまたはユーザーメールと、パスワードを入力し、ログインします。
GitHubからの質問への回答を求められます。質問に順次回答してください。
質問への回答が終わると、プラン選択画面に移ります。ここでは、無料版を使用するので、「Continue for free」をクリックします。
これでアカウントの作成完了です。
左側の「Create repository」をクリックします。
新規リポジトリを作成する画面が開きます。ここで必要な項目を設定していきましょう。各項目の内容と、今回の設定内容は、以下の通りです。
Repository name: リポジトリ名を入力します。今回は「comfyui-invert_image」と入力します。
Public/Private: Publicにすると、リポジトリが誰でも見れるようになり、Privateにすると、自分や招待した人のみが確認できるようになります。ComfyUI Managerに登録したい場合は、Publicを選択してください。それ以外の場合は、お好みでいずれかを選択してください。今回は、Publicを選択します。
Add a README file: 最初から「README.md」ファイルを作成するかどうかを選択できます。今回はチェックを入れ、README.mdファイルを用意します。
Add .gitignore: リポジトリに登録しないファイルを指定する「.gitignore」ファイルを追加するかを決定します。今回は、Python用の.gitignoreファイルを作成します。
Choose a license: このリポジトリのライセンスを決定します。今回は、「MIT License」を選択し、自由に使用できるようなライセンスにしました。
設定が完了したら、「Create repository」をクリックします。
これでリポジトリが作成されました。
GitHubには、「github.dev エディター」というブラウザ上でリポジトリ内のファイルを編集できる機能があります。今回は、この機能を使い、リポジトリでのファイルの追加・編集を行います。
https://docs.github.com/ja/codespaces/the-githubdev-web-based-editor
まず、対象のリポジトリを開き、適当なファイルを開きます。ここでは、README.mdファイルを開きます。
ファイルを開いたら、右側の「▼」ボタンを押し、表示されたメニューの中から「github.dev」をクリックします。
github.devエディターが開きます。これでリポジトリ上のファイルを編集できるようになります。
もう一つの方法としては、URLを変更する方法があります。通常のGitHubのURLは「github.com」になっていますが、これを「github.dev」に変更します。そうすると、先ほどと同様にエディターの画面を開くことができます。
ここで試しにREADME.mdに「テスト」と入力し保存してみます。そうすると、左側のアイコンに①と表示されます。このアイコンをクリックします。
ここでは、コミットとプッシュを行います。コミットとプッシュを行うことで、リポジトリに変更を反映し、自分以外のユーザーにも変更を確認できるようにします。メッセージに変更点を入力し、「コミットとプッシュ」をクリックします。
GitHubリポジトリのトップページに戻ります。戻り方としては、URLを「github.dev」から「github.com」に変更します。そうすると、エディタの画面から、リポジトリのビューワーに戻ります。
リポジトリのトップページを確認すると、変更した内容が反映されていることが確認できます。変更点を記したメッセージも表示されます。
今回作成するカスタムノードのディレクトリ構成は以下になります。
comfyui-invert-text/
 ├─ .gitignore
 ├─ LICENSE
 ├─ README.md
 ├─ __init__.py
 └─ invert_image_node.py
__init__.py : NODE_CLASS_MAPPINGSやWEB_DIRECTORYを定義しておく重要ファイル
invert_image_node.py : カスタムノードのPythonクラス本体
この状態で、ひとまず__init__.pyとinvert_image_node.pyを空ファイルとして追加してください。次のセクションで実際にノードを書くコード例を示します。
ここでは“Invert Image Node”のような超シンプルなノードを例にしながら、カスタムノードを定義 → ComfyUIに認識させ → 実際にUI上で動かす一連の手順を確認します。
まず、invert_image_node.pyに以下のようなサンプルを書いてみましょう。これは「受け取ったIMAGEを反転して返す」非常に簡単な例です。
import torch
class InvertImageNode:
    # カテゴリ名。UIの「Add Node」メニューでの配置先。
    CATEGORY = "example"
    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "image_in": ("IMAGE", {})
            }
        }
    # 出力はIMAGE型を1つ返す
    RETURN_TYPES = ("IMAGE", )
    RETURN_NAMES = ("image_out", )  # 出力スロットに付けるラベル(省略可)
    FUNCTION = "invert"
    def invert(self, image_in):
        # image_inはtorch.Tensor ([B,H,W,C]) で来る
        # 0~1で正規化されているので、1 - values でピクセル反転可能
        image_out = 1.0 - image_in
        return (image_out,)
CATEGORY: "example"というカテゴリ名。
INPUT_TYPES: "required"パラメータとして"image_in": ("IMAGE", {})を1つ準備。
FUNCTION: このクラスのinvertメソッドを実行メソッドに指定。
invertメソッド: 第1引数にself、第2引数にINPUT_TYPESで定義したimage_inを受け取り、処理結果をタプル(image_out,)で返す。
次に、同じフォルダ内の__init__.pyで上記クラスを登録します。例としては以下のようになります。
from .invert_image_node import InvertImageNode
NODE_CLASS_MAPPINGS = {
    "Invert Image Node Sample": InvertImageNode
}
__all__ = ["NODE_CLASS_MAPPINGS"]
ここでは、キーとなる文字列"Invert Image Node Sample"がComfyUI内部でのノード名として扱われます。後でUIの「Add Node」メニューにこの名前が現れるわけです。InvertImageNodeクラスは、レジストリを辿って読み込まれていきます。これで最低限の設定は完了です。
ここまでの変更をコミットし、GitHubリポジトリにプッシュで反映させます。「InvertImageNodeの追加」とコミットメッセージを入力し、「コミットとプッシュ」ボタンを押して、リポジトリに反映させます。
ここまでに作成したカスタムノードをComfyUIにインストールしましょう。インストール方法としては、「ComfyUI/custom_nodes」フォルダに直接このリポジトリをクローンする方法と、ComfyUI Managerからインストールする方法があります。ComfyUI Managerからインストールするには、Comfy Registryに追加する必要があり、この方法は別途解説します。ここでは、リポジトリをクローンする方法を説明します。
Comfy Registry
Comfy Registryは、Comfy Orgが提供する、ComfyUI用のカスタムノードを管理するためのプラットフォームです。このレジストリは、開発者が自作のカスタムノードを発見、インストール、管理できるパブリックな集まりとして機能します。
ComfyUIをローカルPCやサーバー上で起動させており、コマンドラインでアクセスできる場合は、コマンドラインで「ComfyUI/custom_nodes」フォルダに移動し、「git clone」コマンドで本リポジトリをフォルダ内にクローンします。
cd ComfyUI/custom_nodes
git clone https://github.com/your-account-name/comfyui-invert-image.git
ここで使用するリポジトリのURLは、リポジトリのトップページから「Code」をクリックし、表示されたダイアログからHTTPSタブを選択し、そこに表示されるURLをコピーしてください。
Google Colabを使用している場合は、リポジトリのコードをダウンロードし、Google Driveの「ComfyUI/custom_nodes」フォルダに格納してください。リポジトリのダウンロードは、リポジトリのトップページから「Code」をクリックし、表示されたダイアログから「Download ZIP」をクリックすることでzipファイルとしてダウンロード可能です。
ComfyUIを再起動、あるいはまだ起動していないなら起動してください。起動時のコンソール(Pythonターミナル上)で、
0.0 seconds: [your path]\ComfyUI\custom_nodes\comfyui-invert-image
などと出ていれば、ComfyUIがこのカスタムノードフォルダを読み込みに行っていることを示唆しています。エラーがなければ成功です。
ブラウザでComfyUIのUIを開き、「Add Node」メニューの中にexampleカテゴリやInvert Image Node Sampleが追加されているか探してみましょう。
ノードが見つかれば成功です。ノードを置いて、テストとしてLoad ImageノードやImage Previewノードと組み合わせ、実行できるか確認してみてください。画像がすべて反転された結果が得られれば問題ありません。
もしUIにノードが表示されない、あるいはPython側でImportErrorやSyntaxErrorが出ている場合は、ファイル名やクラス名などのミス、依存ライブラリが足りない、などが考えられます。まずはエラーメッセージの内容を落ち着いてチェックしましょう。
前章では最小限の機能を持つカスタムノードを作成しましたが、実際の開発では、より実用的な機能が求められます。そこで本章では、前章で作成した基本的なInvert Image Nodeにプレビュー機能を追加していきます。本章では、まずバックエンド(Python)側の実装について詳しく解説します。
プレビュー機能を実装するには、まず以下の3つの要素が必要です。
プレビュー用の画像データの生成
一時ファイルとしての保存
フロントエンドへの情報伝達
これらを実現するために、まずPythonコードに必要なライブラリをインポートします。
import torch            # PyTorch:テンソル操作用
import numpy as np      # NumPy:数値計算用
from PIL import Image   # Pillow:画像処理用
import json            # JSON:メタデータ処理用
import random          # random:ランダム文字列生成用
import os              # os:ファイルパス操作用
import folder_paths    # ComfyUI:パス管理用
from PIL.PngImagePlugin import PngInfo  # PNG画像のメタデータ操作用
各ライブラリの役割を詳しく見ていきましょう。
torch: 画像データをテンソルとして扱うためのライブラリ。ComfyUIは内部でPyTorchのテンソルを使用しています。
numpy: 数値計算ライブラリ。画像データの変換や操作に使用します。
PIL (Python Imaging Library): 画像ファイルの読み書きや処理を行うライブラリ。
json: メタデータをJSON形式で扱うためのライブラリ。
random: 一時ファイル名を重複しないようにするためのランダム文字列生成に使用。
os: ファイルパスの操作に使用。
folder_paths: ComfyUIが提供するパス管理ユーティリティ。
PngInfo: PNG画像にメタデータを付加するためのクラス。
既存のInvertImageNodeクラスを以下のように拡張します。
class InvertImageNode:
    CATEGORY = "example"
    OUTPUT_NODE = True  # プレビュー機能に必要なフラグ
    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "image_in": ("IMAGE", {
                    "tooltip": "入力画像です。この画像が反転されます。"
                })
            },
            "hidden": {
                "prompt": "PROMPT",           # 生成プロンプト情報
                "extra_pnginfo": "EXTRA_PNGINFO"  # 追加のメタデータ
            }
        }
    RETURN_TYPES = ("IMAGE",)
    RETURN_NAMES = ("image_out",)
    FUNCTION = "invert"
重要な変更点を解説します。
OUTPUT_NODE = True
このフラグをTrueにすることで、ノードがプレビュー機能を持つことをComfyUIに通知します。
プレビュー用の一時ファイル生成などの処理が有効になります。
INPUT_TYPESの拡張
「tooltip」を追加し、UIでマウスオーバー時に説明が表示されるようになります。
「hidden」パラメータを追加し、プロンプト情報やメタデータを受け取れるようになります。
クラスに初期化メソッドを追加します:
def __init__(self):
    # 一時ファイル保存用の設定
    self.output_dir = folder_paths.get_temp_directory()
    self.type = "temp"
    
    # 一時ファイル名の重複を防ぐためのランダムな接尾辞を生成
    self.prefix_append = "_temp_" + ''.join(
        random.choice("abcdefghijklmnopqrstupvxyz") 
        for x in range(5)
    )
    
    # プレビュー用画像の圧縮レベル(1=最速、9=最高圧縮)
    self.compress_level = 1
各設定の詳細を解説します。
output_dirの設定
「folder_paths.get_temp_directory()」で一時ファイル用ディレクトリのパスを取得
ComfyUIが管理する一時ファイル用の適切な場所が自動的に選択されます
typeの設定
「temp」は一時ファイルであることを示す識別子
ComfyUIはこの識別子を見て、適切なタイミングでファイルを削除します
prefix_appendの生成
ランダムな5文字の文字列を生成
これにより、同時に複数のノードが動作しても、ファイル名が重複しません
compress_levelの設定
プレビュー用なので、処理速度優先で低圧縮(レベル1)に設定
必要に応じて1-9の間で調整可能
invertメソッドを拡張して、プレビュー機能を追加します。
def invert(self, image_in, prompt=None, extra_pnginfo=None):
    # 画像反転処理
    image_out = 1.0 - image_in
    
    # プレビュー用の一時ファイル保存の準備
    results = []
    filename_prefix = "inverted" + self.prefix_append
    
    # 保存先パスの取得
    full_output_folder, filename, counter, subfolder, filename_prefix = \
        folder_paths.get_save_image_path(
            filename_prefix,
            self.output_dir,
            image_out.shape[1],  # width
            image_out.shape[0]   # height
        )
    # バッチ内の各画像を処理
    for batch_number, image in enumerate(image_out):
        # PyTorchテンソルからPIL Imageへの変換
        i = 255. * image.cpu().numpy()  # 0-1の値を0-255にスケーリング
        img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
        
        # メタデータの設定
        metadata = None
        if prompt is not None or extra_pnginfo is not None:
            metadata = PngInfo()
            if prompt is not None:
                metadata.add_text("prompt", json.dumps(prompt))
            if extra_pnginfo is not None:
                for x in extra_pnginfo:
                    metadata.add_text(x, json.dumps(extra_pnginfo[x]))
        # ファイル名の生成と保存
        filename_with_batch = filename.replace("%batch_num%", str(batch_number))
        file = f"{filename_with_batch}_{counter:05}_.png"
        
        # 画像の保存
        img.save(
            os.path.join(full_output_folder, file),
            pnginfo=metadata,
            compress_level=self.compress_level
        )
        
        # 結果を記録
        results.append({
            "filename": file,
            "subfolder": subfolder,
            "type": self.type
        })
        counter += 1
    # UIと処理結果を返す
    return {
        "ui": {"images": results},  # プレビュー用情報
        "result": (image_out,)      # 次のノードへの出力
    }
実装の重要ポイントを詳しく解説します。
1. 画像データの変換プロセス
PyTorchテンソル(0-1の範囲)から8ビット整数(0-255の範囲)への変換
NumPyを経由してPIL Imageフォーマットに変換
「np.clip」で値を0-255の範囲に収める
2. メタデータの処理
プロンプト情報や追加メタデータをPNG画像に埋め込み
JSON形式でシリアライズして保存
後で画像を読み込んだ時に情報を復元可能
3. ファイル管理
一意なファイル名の生成
適切なディレクトリへの保存
バッチ処理への対応
4. 戻り値の形式
「ui」キーにプレビュー情報を格納
`result`キーに次のノードへの出力を格納
この形式はComfyUIの規約に従ったもの
実際の運用では、以下のようなエラーハンドリングを追加することを推奨します。
def invert(self, image_in, prompt=None, extra_pnginfo=None):
    try:
        # 画像反転処理
        image_out = 1.0 - image_in
        
        # 入力チェック
        if not isinstance(image_out, torch.Tensor):
            raise ValueError("Invalid image format")
            
        if len(image_out.shape) != 4:
            raise ValueError("Expected 4D tensor (batch, height, width, channels)")
        
        # 以下、前述の処理を続ける...
        
    except Exception as e:
        print(f"Error in InvertImageNode: {str(e)}")
        # エラー時は元の画像をそのまま返す
        return {
            "ui": {"error": str(e)},
            "result": (image_in,)
        }
これにより、以下が実現できます。
無効な入力データの検出
エラーメッセージのログ出力
エラー時の適切なフォールバック
ここまでの実装により、以下の機能が実現されました。
画像の反転処理
プレビュー用の一時ファイル生成
メタデータの保存
バッチ処理対応
エラーハンドリング
最終的なinvert_image_node.pyのコードは以下になります。
import torch
import numpy as np
from PIL import Image
import json
import random
import os
import folder_paths
from PIL.PngImagePlugin import PngInfo
class InvertImageNode:
    """画像を反転させるノードクラス
    
    このノードは入力画像の色を反転(ネガポジ反転)させます。
    また、ComfyUIの標準的なプレビュー機能を実装しています。
    """
    
    # ノードの基本設定
    CATEGORY = "example"  # UIメニューでの表示カテゴリ
    OUTPUT_NODE = True    # このノードが出力を持つことを示すフラグ(プレビュー機能に必要)
    @classmethod
    def INPUT_TYPES(cls):
        """ノードの入力定義
        
        Returns:
            dict: 必須入力とhidden入力を含む入力定義
        """
        return {
            "required": {
                "image_in": ("IMAGE", {
                    "tooltip": "入力画像です。この画像が反転されます。"
                })
            },
            # プロンプト情報とメタデータ用のhidden入力
            "hidden": {
                "prompt": "PROMPT",           # 生成時のプロンプト情報
                "extra_pnginfo": "EXTRA_PNGINFO"  # 追加のメタデータ
            }
        }
    # 出力の定義
    RETURN_TYPES = ("IMAGE",)     # 出力型の定義(画像型)
    RETURN_NAMES = ("image_out",) # 出力スロットのラベル名
    FUNCTION = "invert"           # 実行時に呼び出される関数名
    def __init__(self):
        """初期化メソッド
        
        一時ファイル保存用の設定を初期化します。
        """
        # 一時ファイル保存先ディレクトリの設定
        self.output_dir = folder_paths.get_temp_directory()
        self.type = "temp"  # ファイルタイプを一時ファイルとして指定
        
        # 一時ファイル名の重複を防ぐためのランダムな接尾辞を生成
        self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5))
        
        # PNG圧縮レベル(1=最速、9=最高圧縮)
        self.compress_level = 1  # プレビュー用なので高速化優先
    def invert(self, image_in, prompt=None, extra_pnginfo=None):
        """画像反転処理の主関数
        
        Args:
            image_in (torch.Tensor): 入力画像テンソル [B,H,W,C]
            prompt (dict, optional): 生成プロンプト情報
            extra_pnginfo (dict, optional): 追加のメタデータ
        
        Returns:
            dict: UI更新情報と処理結果を含む辞書
        """
        # 画像の反転処理(1から引くことで色を反転)
        image_out = 1.0 - image_in
        
        # プレビュー用の一時ファイル保存処理の開始
        results = []  # 保存した画像の情報を格納するリスト
        filename_prefix = "inverted"  # 基本ファイル名
        filename_prefix += self.prefix_append  # ランダムな接尾辞を追加
        
        # 保存先のパス情報を取得
        full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
            filename_prefix,
            self.output_dir,
            image_out.shape[1],  # 画像の幅
            image_out.shape[0]   # 画像の高さ
        )
        # バッチ内の各画像を処理
        for batch_number, image in enumerate(image_out):
            # PyTorchテンソルをPIL Imageに変換
            i = 255. * image.cpu().numpy()  # 0-1の値を0-255にスケーリング
            img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
            
            # メタデータの設定(プロンプトや追加情報)
            metadata = None
            if prompt is not None or extra_pnginfo is not None:
                metadata = PngInfo()
                if prompt is not None:
                    metadata.add_text("prompt", json.dumps(prompt))
                if extra_pnginfo is not None:
                    for x in extra_pnginfo:
                        metadata.add_text(x, json.dumps(extra_pnginfo[x]))
            # ファイル名を生成して画像を保存
            filename_with_batch = filename.replace("%batch_num%", str(batch_number))
            file = f"{filename_with_batch}_{counter:05}_.png"
            img.save(
                os.path.join(full_output_folder, file),
                pnginfo=metadata,
                compress_level=self.compress_level
            )
            
            # 保存した画像の情報を記録
            results.append({
                "filename": file,       # ファイル名
                "subfolder": subfolder, # サブフォルダ名
                "type": self.type       # ファイルタイプ(temp)
            })
            counter += 1
        # 処理結果を返す
        return {
            "ui": {"images": results},  # UI更新用の画像情報
            "result": (image_out,)      # ノードの出力値(次のノードへの入力として使用)
        }
次章では、これらのバックエンド機能と連携するフロントエンド(JavaScript)側の実装について解説します。
前章で実装したバックエンド側の機能と連携して、実際にユーザーが目にするプレビュー画面を作成していきます。
まず、必要なファイルとディレクトリを作成します。
comfyui-invert-image/
  └─ js/
      └─ invert_preview.js
jsディレクトリ内に「invert_preview.js」を作成し、以下の基本的なインポート文から始めます。
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
ここでインポートしている要素について説明します。
app: ComfyUIのメインアプリケーションオブジェクト。ノードの登録や管理を行います。
api: ComfyUIのAPIクライアント。サーバーとの通信を担当します。
ComfyUIの拡張機能として実装するための基本構造を作成します。
app.registerExtension({
    name: "Comfy.InvertImagePreview",
    
    async beforeRegisterNodeDef(nodeType, nodeData, app) {
        // InvertImageノードの場合のみ処理
        if (nodeType.comfyClass !== "InvertImage") return;
        
        // ここに実装を追加していきます
    }
});
この構造について解説します。
registerExtension
ComfyUIに拡張機能を登録するメソッド
一意な名前(ここではComfy.InvertImagePreview)を付ける必要があります
beforeRegisterNodeDef
ノードが登録される前に呼び出されるフック関数
引数:
nodeType: ノードの型定義
nodeData: ノードのメタデータ
app: ComfyUIアプリケーションインスタンス
comfyClass確認
自分のノード(InvertImage)の場合のみ処理を行うための条件分岐
プレビュー表示用のコンテナを作成する関数を実装します。
function createPreviewHost(node) {
    // コンテナ要素の作成
    const container = document.createElement("div");
    container.className = "comfy-img-preview";
    
    // スタイルの設定
    container.style.minHeight = "200px";
    container.style.minWidth = "100px";
    container.style.maxHeight = "300px";
    container.style.backgroundColor = "#1e1e1e";
    container.style.display = "flex";
    container.style.justifyContent = "center";
    container.style.alignItems = "center";
    container.style.marginTop = "10px";
    container.style.overflow = "hidden";
    container.style.borderRadius = "4px";
    // 状態管理用の変数
    let currentImages = null;
    let firstUpdate = true;
    // プレビューのサイズ更新処理
    function updatePreviewSize() {
        if (!currentImages) return;
        const nodeWidth = node.size[0];
        const containerHeight = container.clientHeight;
        // 初回更新時の処理
        if (firstUpdate) {
            firstUpdate = false;
            if (containerHeight < 200) {
                container.style.minHeight = "200px";
            }
        }
        // アスペクト比を保持したサイズ計算
        const { naturalWidth, naturalHeight } = currentImages[0];
        const scale = Math.min(
            (nodeWidth - 20) / naturalWidth,    // 横幅に合わせたスケール
            containerHeight / naturalHeight,     // 高さに合わせたスケール
            1                                   // 最大スケール(等倍)
        );
        // 計算したサイズを適用
        const width = Math.floor(naturalWidth * scale);
        const height = Math.floor(naturalHeight * scale);
        currentImages.forEach(img => {
            img.style.width = width + "px";
            img.style.height = height + "px";
            img.style.objectFit = "contain";
        });
    }
    // プレビューホストのインターフェース
    return {
        element: container,
        
        // 画像更新メソッド
        updateImages(imgs) {
            if (imgs !== currentImages) {
                container.replaceChildren(...imgs);
                currentImages = imgs;
                requestAnimationFrame(() => {
                    updatePreviewSize();
                });
                node.onResize?.(node.size);
            }
        },
        
        updateSize: updatePreviewSize
    };
}
実装の重要なポイントを解説します。
1. コンテナ要素の構造
「div」要素をベースに作成
Flexboxレイアウトを使用して中央配置
最小/最大サイズの制限による適切な表示領域の確保
2. サイズ計算ロジック
ノードの幅とコンテナの高さを考慮
アスペクト比を保持したスケーリング
画像が大きすぎる場合の縮小処理
3. 状態管理
「currentImages」で現在表示中の画像を管理
「firstUpdate」フラグで初期化処理を制御
作成したプレビューホストをノードに統合します。
// ノード作成時の処理
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
    const result = onNodeCreated?.apply(this, arguments);
    
    // プレビューホストを作成してノードに追加
    this.previewHost = createPreviewHost(this);
    this.widgets?.length && 
        this.widgets[0].element.appendChild(this.previewHost.element);
    return result;
};
// リサイズ時の処理
const onResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function(size) {
    const result = onResize?.apply(this, arguments);
    this.previewHost?.updateSize();
    return result;
};
// 実行完了時の処理
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = async function(message) {
    const result = onExecuted?.apply(this, arguments);
    
    // プレビュー更新
    if (message?.ui?.images) {
        try {
            // 画像の非同期読み込み
            const images = await Promise.all(
                message.ui.images.map(async (img) => {
                    // 画像URLの生成
                    const url = api.apiURL(
                        `/view?filename=${encodeURIComponent(img.filename)}` +
                        `&type=${img.type}`
                    );
                    
                    // 画像要素の作成と読み込み
                    const imgElement = new Image();
                    imgElement.src = url;
                    
                    // 読み込み完了を待機
                    return new Promise((resolve, reject) => {
                        imgElement.onload = () => resolve(imgElement);
                        imgElement.onerror = reject;
                    });
                })
            );
            
            // プレビューを更新
            this.previewHost.updateImages(images);
        } catch (error) {
            console.error("Failed to update preview:", error);
        }
    }
    return result;
};
実装された3つの主要なイベントハンドラを解説します。
1. onNodeCreated
ノードが作成された時に呼ばれる
プレビューホストを初期化
ノードのUI要素にプレビュー領域を追加
2. onResize
ノードのサイズが変更された時に呼ばれる
プレビュー画像のサイズを再計算
レスポンシブな表示を実現
3. onExecuted
ノードの処理が完了した時に呼ばれる
バックエンドから受け取った画像情報を処理
非同期での画像読み込みとエラーハンドリング
フロントエンドのコードを有効にするためには、__init__.pyでWEB_DIRECTORYの指定が必要になります。改修後の__init__.pyは以下になります。
from .invert_image_node import InvertImageNode
NODE_CLASS_MAPPINGS = {
    "Invert Image Node Sample": InvertImageNode
}
WEB_DIRECTORY = "./js"
__all__ = ["NODE_CLASS_MAPPINGS", "WEB_DIRECTORY"]
この章で実装した主な機能は以下の通りです。
プレビュー表示用のUI作成
画像の動的サイズ調整
非同期での画像読み込み
__init__.pyにWEB_DIRECTORYの指定
最終的なinvert_preview.jsのコードは以下になります。
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
/**
 * 画像反転ノードのプレビュー機能を実装する拡張機能
 */
app.registerExtension({
    name: "Comfy.InvertImagePreview",
    
    /**
     * ノード定義の登録前に呼び出される処理
     * @param {Object} nodeType - ノードの型定義
     * @param {Object} nodeData - ノードのデータ
     * @param {Object} app - ComfyUIのアプリケーションインスタンス
     */
    async beforeRegisterNodeDef(nodeType, nodeData, app) {
        // InvertImageノードの場合のみ処理を行う
        if (nodeType.comfyClass !== "InvertImage") return;
        /**
         * プレビューホストを作成する関数
         * @param {Object} node - ノードインスタンス
         * @returns {Object} プレビューホストのインターフェース
         */
        function createPreviewHost(node) {
            // プレビュー用のコンテナ要素を作成
            const container = document.createElement("div");
            container.className = "comfy-img-preview";
            // コンテナのスタイル設定
            container.style.minHeight = "200px";
            container.style.minWidth = "100px";
            container.style.maxHeight = "300px";
            container.style.backgroundColor = "#1e1e1e";
            container.style.display = "flex";
            container.style.justifyContent = "center";
            container.style.alignItems = "center";
            container.style.marginTop = "10px";
            container.style.overflow = "hidden";
            // 現在表示中の画像の参照を保持
            let currentImages = null;
            let firstUpdate = true;
            /**
             * プレビュー画像のサイズを更新する関数
             * ノードのサイズに合わせて画像を適切にスケーリングします
             */
            function updatePreviewSize() {
                if (!currentImages) return;
                const nodeWidth = node.size[0];
                const containerHeight = container.clientHeight;
                // 最初の更新時は最小高さを設定
                if (firstUpdate) {
                    firstUpdate = false;
                    if (containerHeight < 200) {
                        container.style.minHeight = "200px";
                    }
                }
                // アスペクト比を保持しながら、プレビュー画像のサイズを計算
                const { naturalWidth, naturalHeight } = currentImages[0];
                const scale = Math.min(
                    (nodeWidth - 20) / naturalWidth,    // 横幅に合わせたスケール
                    containerHeight / naturalHeight,     // 高さに合わせたスケール
                    1                                   // 最大スケール(等倍)
                );
                // 計算したサイズを適用
                const width = Math.floor(naturalWidth * scale);
                const height = Math.floor(naturalHeight * scale);
                // バッチ内の全画像にサイズを適用
                currentImages.forEach(img => {
                    img.style.width = width + "px";
                    img.style.height = height + "px";
                    img.style.objectFit = "contain";  // アスペクト比を保持
                });
            }
            // プレビューホストのインターフェースを返す
            return {
                element: container,  // DOM要素
                
                /**
                 * プレビュー画像を更新する関数
                 * @param {Array<HTMLImageElement>} imgs - 新しい画像要素の配列
                 */
                updateImages(imgs) {
                    if (imgs !== currentImages) {
                        // 画像を更新
                        container.replaceChildren(...imgs);
                        currentImages = imgs;
                        // サイズ更新を次のフレームで実行
                        requestAnimationFrame(() => {
                            updatePreviewSize();
                        });
                        // ノードのリサイズイベントを発火
                        node.onResize?.(node.size);
                    }
                },
                
                // サイズ更新関数を外部に公開
                updateSize: updatePreviewSize
            };
        }
        // ノード作成時の処理をカスタマイズ
        const onNodeCreated = nodeType.prototype.onNodeCreated;
        nodeType.prototype.onNodeCreated = function() {
            const result = onNodeCreated?.apply(this, arguments);
            
            // プレビューホストを作成してノードに追加
            this.previewHost = createPreviewHost(this);
            // 最初のウィジェットの要素にプレビューを追加
            this.widgets?.length && this.widgets[0].element.appendChild(this.previewHost.element);
            return result;
        };
        // ノードのサイズ変更時の処理をカスタマイズ
        const onResize = nodeType.prototype.onResize;
        nodeType.prototype.onResize = function(size) {
            const result = onResize?.apply(this, arguments);
            // プレビューのサイズを更新
            this.previewHost?.updateSize();
            return result;
        };
        // ノードの実行完了時の処理をカスタマイズ
        const onExecuted = nodeType.prototype.onExecuted;
        nodeType.prototype.onExecuted = async function(message) {
            const result = onExecuted?.apply(this, arguments);
            
            // UIメッセージに画像情報が含まれている場合
            if (message?.ui?.images) {
                try {
                    // 全ての画像を非同期で読み込む
                    const images = await Promise.all(
                        message.ui.images.map(async (img) => {
                            // 画像URLを生成
                            const url = api.apiURL(`/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}`);
                            const imgElement = new Image();
                            imgElement.src = url;
                            // 画像の読み込み完了を待つ
                            return new Promise((resolve, reject) => {
                                imgElement.onload = () => resolve(imgElement);
                                imgElement.onerror = reject;
                            });
                        })
                    );
                    // プレビューを更新
                    this.previewHost.updateImages(images);
                } catch (error) {
                    console.error("Failed to update preview:", error);
                }
            }
            return result;
        };
    }
});
次章では、これらの実装を実際に動作させる手順と、動作確認方法について解説します。
まず、ここまでの変更をGitHubリポジトリに反映させましょう。最終的なプロジェクト構成は、以下の通りです。これでリポジトリにプッシュしてください。
comfyui-invert-text/
 ├─ .gitignore
 ├─ LICENSE
 ├─ README.md
 ├─ __init__.py
 ├─ invert_image_node.py
 └─ js
    └─ invert_preview.js
リポジトリに反映できたら、「3.4 ComfyUIにインストールする」で説明した通りにComfyUIで使用できるようにしましょう。
それでは、作成したカスタムノードを使用したワークフローを実行してみましょう。以下は、読み込んだ画像をInvert Imageノードで反転させた結果をプレビューするという、簡単なワークフローの実行結果です。プレビュー機能の搭載前は、Invert Imageノードに画像は表示されませんでしたが、今回の実装で処理後の画像が表示されるようになりました。このように、フロントエンドを拡張することが可能です。
ここではありがちな問題と、その対処法をまとめます。
__init__.pyにNODE_CLASS_MAPPINGSを書き忘れている
→ NODE_CLASS_MAPPINGS = { "MyClassName": MyClassObj } が正しく書かれているか確認。
フォルダ名やファイル名のスペルミス、Pythonエラーで読み込みが失敗している
→ PythonのコンソールにImportErrorやSyntaxErrorが出ていないか。
ComfyUIを再起動していない
→ ノードを追加したら必ず再起動しましょう。
ComfyUIはノード同士の接続時に型の整合性を図ります。IMAGE同士をつなぐなら("IMAGE" → "IMAGE")でなければいけません。プリミティブ型("FLOAT", "INT", 等)も合っているか再チェックしましょう。新たに定義した型("CHEESE"など)を勝手に使う場合はforceInputを指定する必要があります。
WEB_DIRECTORYの指定が誤っている
→ WEB_DIRECTORY = "./js" のパスが正しいか、jsフォルダが存在するか再確認。
JavaScriptファイルにエラーがあり読み込まれていない
→ ブラウザのDevToolsコンソールでエラーが出ていないかをチェック。
api.addEventListenerのイベント名が間違っている
→ サーバー側の send_sync で指定する文字列と一致しているか確認。
こうしたトラブル時はまずPythonコンソールとブラウザのコンソールログをよく見るのが鉄則です。エラー内容が長文で表示される場合もあるので、丁寧に読むと原因が分かるケースがほとんどです。
長いボリュームの記事となりましたが、これでカスタムノード作成の一連の流れ――ディレクトリ準備から最終的な配布まで――を網羅的に体験できたはずです。前編の「ComfyUIの仕組みやノードの構造的理解」をベースに、この後編で「動くノードを作る」実践ステップをしっかり踏むことで、必要十分な知識はかなり得られたことでしょう。
現段階で触れなかった細やかなテーマ(VAEやCLIPなどのモデル系ノードの内部実装、ComfyUIによるメモリ管理やキャッシュ機能の詳細、JavaScriptフックの全貌など)も、今後の学習と試行錯誤で確実に身につくはずです。少なくとも「ComfyUIのカスタムノード記述方式」と「フロントエンド連携のあらまし」を理解できていれば、大半の要望は実装可能になるでしょう。
もし思い通りに動作しなかったら、前編・後編の内容を改めて振り返り、Python側・JavaScript側のログを丁寧に検証する癖をつけてみてください。エラー文言は長いですが必ずヒントが埋まっています。また、コミュニティのDiscordやGitHub Issuesなどで質問してみるのも有効です。
最後に、本記事によってあなたがComfyUIカスタムノード開発のスタートラインに立てたなら幸いです。ぜひオリジナルのアイデアを形にして、ComfyUIのエコシステムをさらに盛り上げていきましょう。いずれ、あなたの作ったノードが多くのユーザーに使われる日が来るかもしれません。ここまでお読みいただき、ありがとうございました。あなたの創意工夫あふれるカスタムノード作りを応援しています。
AICUは「つくる人をつくる」をビジョンに掲げ、最新の生成AI、クリエイティブAI技術を皆さまにお届けしています。
📝noteでは、生成AIに関する最新情報や使い方の解説を発信しています。ぜひフォローして、最新情報をチェックしてください!
https://note.com/aicu
📩メールマガジン「週刊AICU通信」では、AI関連のニュースやトレンド、イベント情報などを毎週お届けしています。購読登録はこちらから!
https://corp.aicu.ai/ja
【月刊AICUマガジン】「つくる人をつくる」クリエイティブAI専門誌、毎月魅力的な特集や深掘りお送りします[Kindle Unlimitedで無料]
【書籍化】「ComfyUIマスターガイド」
https://www.aicu.jp/pricing-plans/list
★3/31までβテスト中につき無料参加可能です★
【月刊AICUマガジン】「つくる人をつくる」クリエイティブAI専門誌、毎月魅力的な特集や深掘りお送りします[Kindle Unlimitedで無料]
Xでも情報発信中!フォローしてね😉
https://x.com/AICUai
※noteのComfyUIマスタープランと、上記の「共有ComfyUI」はしばらく並列に運営していきます。他にもたくさんサービスを開発していきますので応援よろしくお願いいたします!
—
この記事の続きはこちらから https://note.com/aicu/n/nbd6c10819996
Originally published at https://note.com on Feb 24, 2025.