ComfyUI × PopNow

ComfyUI에서 클라우드 AI 모델 실행하기

작은 커스텀 노드 하나로 PopNow의 모든 이미지·영상·오디오 모델을 ComfyUI 그래프에 넣을 수 있습니다. GPU도, 모델 다운로드도 필요 없습니다. 작업을 보내고 기다렸다가 결과를 다음 노드로 넘깁니다.

네 단계로 설치하기

  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 이상이 필요한 영상 모델도 저희 쪽에서 실행됩니다. 노트북은 프롬프트만 보냅니다.

  • 로컬 노드와 함께

    첫 프레임은 로컬에서 만들고, Kling이나 Seedance로 움직이게 한 뒤, 다시 로컬에서 업스케일하세요.

  • 실행당 가격이 분명함

    모든 모델은 크레딧을 미리 표시하며, 실패한 실행은 환불됩니다.

처음 쓰기 좋은 모델

ComfyUI 질문

공식 패키지인가요?

이 페이지에 공개한 파일 하나이며, 레지스트리 패키지가 아닙니다. 복사해서 읽어 보고, API가 바뀌면 여기서 업데이트하세요.

ComfyUI Desktop과 Comfy Cloud에서도 되나요?

커스텀 노드를 추가하고 환경 변수를 설정할 수 있는 곳이면 어디서든 됩니다. ComfyUI 포터블, Desktop, 자체 호스팅 서버에서 동작합니다.

영상 프레임은 어떻게 받나요?

노드가 영상을 ComfyUI output 폴더에 저장하고 경로를 돌려줍니다. 영상 불러오기 노드로 읽어 들이면 프레임 작업을 이어 갈 수 있습니다.

한 번 실행하는 데 얼마인가요?

웹사이트와 같은 크레딧이며, 각 모델의 API 페이지에 가격이 나와 있습니다. 실험하는 동안에는 콘솔에서 키에 일일 한도를 정해 두세요.