ComfyUI × PopNow

Run cloud AI models inside ComfyUI

One small custom node puts every PopNow image, video and audio model into your ComfyUI graph. No GPU and no model downloads: it sends the job, waits, and hands the result to the next node.

Install in four steps

  1. Save the code below as custom_nodes/popnow_api.py inside your ComfyUI folder.
  2. Set POPNOW_API_KEY in the environment you start ComfyUI from, then restart ComfyUI.
  3. Double-click the canvas, search for PopNow and add the PopNow Generate node.
  4. Type a model name and a prompt, then queue. Images come out as an IMAGE; videos and audio are saved to the output folder.

The node

Plain Python that only uses requests, Pillow, NumPy and PyTorch, which ComfyUI already ships. Read it before you run it: it is short on purpose.

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"}

Inputs and outputs

model
Model name, for example google/nano-banana-2.1/text-to-image. Every model API page shows it.
prompt
What to make. Leave it empty for models that take no prompt.
options
Other fields as JSON, such as resolution or duration_seconds. The model’s API page lists them all.
image
Optional IMAGE input. It is uploaded for you and placed in the field named by image_field.
image_field
Which input field gets the image: image_url for most video models, image_urls for image editing.
image · url · path
IMAGE for image models, plus the file URL and the local path for video and audio.

Why run cloud models from ComfyUI

  • No GPU needed

    Video models that need 24 GB and more run on our side. Your laptop only sends the prompt.

  • Mix with your local nodes

    Make a start frame locally, animate it with Kling or Seedance, then upscale it locally again.

  • Known price per run

    Every model lists its credits up front, and failed runs are refunded.

Good models to start with

ComfyUI questions

Is this an official package?

It is a single file we publish on this page, not a registry package. Copy it, read it, and update it from here when the API changes.

Does it work with ComfyUI Desktop and Comfy Cloud?

It works anywhere you can add a custom node and set an environment variable: ComfyUI portable, Desktop and self-hosted servers.

How do I get video frames back?

The node saves the video to ComfyUI’s output folder and returns its path. Load it with a video loader node to keep working on the frames.

What does a run cost?

The same credits as on the website, shown on each model’s API page. Set a daily cap on the key in the console while you experiment.