ComfyUI × PopNow

ComfyUI でクラウドの AI モデルを動かす

小さなカスタムノード1つで、PopNow の画像・動画・音声モデルをすべて ComfyUI のグラフに組み込めます。GPU もモデルのダウンロードも不要。ジョブを送り、待って、結果を次のノードに渡します。

4ステップでインストール

  1. 下のコードを、ComfyUI フォルダー内の custom_nodes/popnow_api.py として保存します。
  2. ComfyUI を起動する環境に POPNOW_API_KEY を設定し、ComfyUI を再起動します。
  3. キャンバスをダブルクリックして PopNow を検索し、PopNow Generate ノードを追加します。
  4. モデル名とプロンプトを入力してキューに入れます。画像は IMAGE として出力され、動画と音声は output フォルダーに保存されます。

ノードのコード

使っているのは requests、Pillow、NumPy、PyTorch だけのシンプルな Python で、どれも ComfyUI に最初から入っています。実行する前に読んでください。あえて短くしてあります。

custom_nodes/popnow_api.py
"""PopNow API node for ComfyUI.

Save as ComfyUI/custom_nodes/popnow_api.py, set POPNOW_API_KEY, restart ComfyUI.
Runs any PopNow image, video or audio model in the cloud and returns the result.
Docs: https://popnow.ai/docs
"""
import io
import json
import os
import time
import uuid

import numpy as np
import requests
import torch
from PIL import Image

import folder_paths

BASE = "https://api.popnow.ai/api/v1"
TERMINAL = ("succeeded", "failed", "canceled", "expired")


def _headers(extra=None):
    key = os.environ.get("POPNOW_API_KEY")
    if not key:
        raise RuntimeError("Set POPNOW_API_KEY before starting ComfyUI")
    return {"Authorization": f"Bearer {key}", **(extra or {})}


def _call(method, path, **kwargs):
    headers = _headers(kwargs.pop("headers", None))
    body = requests.request(method, BASE + path, headers=headers, timeout=60, **kwargs).json()
    if body.get("code") != 200:
        raise RuntimeError(f'PopNow {body.get("code")}: {body.get("msg")}')
    return body["data"]


def _upload(image):
    """IMAGE tensor -> PNG -> upload -> popnow:// reference."""
    arr = (image[0].cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
    buf = io.BytesIO()
    Image.fromarray(arr).save(buf, format="PNG")
    data = buf.getvalue()
    ticket = _call("POST", "/common/upload-url", json={"contentType": "image/png", "bytes": len(data)})
    requests.put(ticket["uploadUrl"], data=data, headers=ticket.get("headers", {}), timeout=120).raise_for_status()
    return _call("POST", f'/files/{ticket["fileId"]}/commit')["uri"]


class PopNowGenerate:
    CATEGORY = "PopNow"
    RETURN_TYPES = ("IMAGE", "STRING", "STRING")
    RETURN_NAMES = ("image", "url", "path")
    FUNCTION = "run"

    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "model": ("STRING", {"default": "google/nano-banana-2.1/text-to-image"}),
                "prompt": ("STRING", {"multiline": True, "default": "A ceramic teapot on a linen cloth, soft window light, muted palette"}),
                "options": ("STRING", {"multiline": True, "default": "{\"aspect_ratio\":\"16:9\",\"resolution\":\"1k\",\"output_format\":\"png\"}"}),
            },
            "optional": {
                "image": ("IMAGE",),
                "image_field": ("STRING", {"default": "image_url"}),
            },
        }

    def run(self, model, prompt, options, image=None, image_field="image_url"):
        task_input = json.loads(options or "{}")
        if prompt.strip():
            task_input.setdefault("prompt", prompt)
        if image is not None:
            ref = _upload(image)
            task_input[image_field] = [ref] if image_field.endswith("s") else ref

        task_id = _call(
            "POST",
            "/jobs/createTask",
            headers={"Idempotency-Key": str(uuid.uuid4())},
            json={"model": model, "input": task_input},
        )["taskId"]

        delay = 2.0
        while True:
            time.sleep(delay)
            task = _call("GET", "/jobs/recordInfo", params={"taskId": task_id})
            assets = (task.get("output") or {}).get("assets") or []
            copying = any(a.get("pending") and not a.get("unavailable") for a in assets)
            if task["state"] in TERMINAL and not copying:
                break
            delay = min(delay * 1.5, 15.0)
        if task["state"] != "succeeded" or not assets:
            raise RuntimeError(f'PopNow {task["state"]}: {task.get("errorMessage", "no detail")}')

        asset = assets[0]
        content = requests.get(asset["url"], timeout=300).content
        mime = asset.get("mime", "")
        if mime.startswith("image/"):
            pil = Image.open(io.BytesIO(content)).convert("RGB")
            tensor = torch.from_numpy(np.asarray(pil).astype(np.float32) / 255.0)[None,]
            return (tensor, asset["url"], "")

        ext = {"video/mp4": ".mp4", "video/webm": ".webm", "audio/mpeg": ".mp3", "audio/wav": ".wav"}.get(mime, ".bin")
        path = os.path.join(folder_paths.get_output_directory(), f"popnow_{task_id}{ext}")
        with open(path, "wb") as f:
            f.write(content)
        # Video and audio: a small blank IMAGE keeps the graph valid; use the path output.
        return (torch.zeros((1, 64, 64, 3)), asset["url"], path)


NODE_CLASS_MAPPINGS = {"PopNowGenerate": PopNowGenerate}
NODE_DISPLAY_NAME_MAPPINGS = {"PopNowGenerate": "PopNow Generate"}

入力と出力

model
モデル名。例:google/nano-banana-2.1/text-to-image。各モデルの API ページに載っています。
prompt
作りたいもの。プロンプトを取らないモデルでは空欄のままで構いません。
options
そのほかのフィールドを JSON で。resolution や duration_seconds など。全フィールドはモデルの API ページにあります。
image
任意の IMAGE 入力。自動でアップロードされ、image_field で指定したフィールドに入ります。
image_field
画像を入れる入力フィールド。たいていの動画モデルは image_url、画像編集は image_urls です。
image · url · path
画像モデルは IMAGE を、動画と音声はファイルの URL とローカルパスも出力します。

ComfyUI からクラウドモデルを使う理由

  • GPU は不要

    24 GB 以上を必要とする動画モデルもこちら側で動きます。手元の PC はプロンプトを送るだけです。

  • ローカルのノードと組み合わせる

    最初のフレームをローカルで作り、Kling や Seedance で動かして、またローカルでアップスケールできます。

  • 1回あたりの料金が明確

    どのモデルもクレジットを事前に表示しており、失敗した実行は返金されます。

最初に使いやすいモデル

ComfyUI についての質問

公式のパッケージですか?

このページで公開している単一のファイルで、レジストリのパッケージではありません。コピーして中身を確認し、API が変わったらここから更新してください。

ComfyUI Desktop や Comfy Cloud でも使えますか?

カスタムノードを追加でき、環境変数を設定できる環境なら使えます。ComfyUI ポータブル版、Desktop、セルフホストのサーバーで動きます。

動画のフレームを取り出すには?

ノードは動画を ComfyUI の output フォルダーに保存し、そのパスを返します。動画読み込みノードで読み込めば、フレームの加工を続けられます。

1回の実行でいくらかかりますか?

サイトと同じクレジットで、料金は各モデルの API ページに載っています。試しているあいだは、コンソールでキーに1日の上限を設定しておくと安心です。