Compare commits

..
1 Commits
Author SHA1 Message Date
Ivor StrelarandClaude Opus 5.5 f28000603c feat: run WaveSpeed nodes in parallel and add SF WaveSpeed Nano Banana Pro
GPT Image 2.5 and Tripo P2 waited on WaveSpeed with blocking requests and
time.sleep, so ComfyUI ran them strictly one after another. Partner nodes
overlap because their execute is a coroutine, which ComfyUI's executor
starts as a task and schedules other ready nodes around. Both nodes are now
async (V3 execute, and an async FUNCTION method for the V1 Tripo node).

Async alone is not enough: WaveSpeedClient uses blocking requests, which
would still stall the event loop. wavespeed_api/v3.py now runs every call
and download through asyncio.to_thread, polls with asyncio.sleep, uploads
references and downloads outputs concurrently, and submits a node's n
requests together. Image flattening, downscaling and alpha-aware batching
moved there from the GPT Image node so the new node can share them.

New SF WaveSpeed Nano Banana Pro mirrors the "Nano Banana Pro (Google Gemini
Image)" partner node: the same two models (mapped to google/nano-banana-pro
and google/nano-banana-2), aspect ratios, 1K/2K/4K, and up to 14 batched
reference images switching it to /edit. WaveSpeed has no text output,
response_modalities, system prompt or file inputs, so those are left out.

Verified with a stubbed API whose calls block like real requests: the three
nodes take 10.8s run in sequence and 4.2s run together, and an event-loop
heartbeat keeps ticking throughout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:39:16 +02:00
6 changed files with 268 additions and 108 deletions
+32 -2
View File
@@ -2,13 +2,13 @@
![Stillfront](assets/logo.png)
**64 custom ComfyUI nodes** from [Stillfront](https://www.stillfront.com), covering AI image and
**65 custom ComfyUI nodes** from [Stillfront](https://www.stillfront.com), covering AI image and
video generation, LLM integration, and tooling for working with large game asset libraries. Every
node is prefixed **SF** so they're easy to find in the node search.
| | |
|---|---|
| [**Stillfront/WaveSpeed**](#stillfrontwavespeed) | 46 nodes — image, video and 3D generation through [WaveSpeed AI](https://wavespeed.ai): VEO 3.1, Sora 2, GPT Image 2.5, Nano Banana, Qwen, Flux Kontext, Seedream, WAN 2.5, InfiniteTalk, Tripo P2, upscalers |
| [**Stillfront/WaveSpeed**](#stillfrontwavespeed) | 47 nodes — image, video and 3D generation through [WaveSpeed AI](https://wavespeed.ai): VEO 3.1, Sora 2, GPT Image 2.5, Nano Banana Pro, Qwen, Flux Kontext, Seedream, WAN 2.5, InfiniteTalk, Tripo P2, upscalers |
| [**Stillfront/VertexAI**](#stillfrontvertexai) | 7 nodes — Imagen 3/4, VEO 3.1 and Nano Banana Pro via Google Cloud Vertex AI |
| [**Stillfront/Utils**](#stillfrontutils) | 7 nodes — Google Sheets integration, prompt batching, resolution presets |
| [**Stillfront/LLM**](#stillfrontllm) | 4 nodes — Claude, Gemini and GPT in your graph, plus the asset library tooling below |
@@ -288,6 +288,7 @@ Connect an **SF WaveSpeed Client** node (enter your WaveSpeed API key) to any Wa
| Node | Description |
|---|---|
| **SF WaveSpeed GPT Image 2.5** | OpenAI's GPT Image models through WaveSpeed, built to match ComfyUI's *OpenAI GPT Image 2.5* partner node — see below. |
| **SF WaveSpeed Nano Banana Pro** | Google's Nano Banana Pro and Nano Banana 2 through WaveSpeed, built to match ComfyUI's *Nano Banana Pro (Google Gemini Image)* partner node — see below. |
| **SF WaveSpeed Nano Banana Text to Image** | Nano Banana text-to-image generation. |
| **SF WaveSpeed Nano Banana Edit** | Edit images using the Nano Banana model. |
| **SF WaveSpeed Nano Banana Pro Text to Image** | Nano Banana Pro text-to-image. |
@@ -312,6 +313,35 @@ Connect an **SF WaveSpeed Client** node (enter your WaveSpeed API key) to any Wa
| **SF WaveSpeed Flux Kontext Max** | Flux Kontext Max — highest quality variant. |
| **SF WaveSpeed Flux ControlNet Union Pro 2** | Flux image generation with ControlNet guidance. |
#### These run in parallel
**SF WaveSpeed GPT Image 2.5**, **SF WaveSpeed Nano Banana Pro** and **SF WaveSpeed Tripo P2
Image to 3D** run concurrently, the way ComfyUI's partner nodes do. Put several of them side by
side and press Run: they all submit and wait together, so the run takes about as long as the
slowest one rather than the sum of all of them.
They're asynchronous nodes, which ComfyUI starts as background tasks and keeps scheduling other
work around — no change to ComfyUI is involved, so updates don't affect it. Only nodes that don't
depend on each other overlap; WaveSpeed may also cap how many jobs one account runs at once.
The older SF WaveSpeed nodes still run one at a time.
#### Using SF WaveSpeed Nano Banana Pro
Same controls as the partner node — prompt, the same two models, aspect ratio, resolution, and an
optional `images` input that switches it to editing (batch up to 14 references with **Batch
Images**). It bills your WaveSpeed key instead of ComfyUI credits.
| Partner model | WaveSpeed model |
|---|---|
| `gemini-3-pro-image-preview` | `google/nano-banana-pro` |
| `Nano Banana 2 (Gemini 3.1 Flash Image)` | `google/nano-banana-2` |
What WaveSpeed's API doesn't offer, and so the node leaves out rather than fakes: the **text
output** (the partner's second output, a written response from the model), **`response_modalities`**,
the **system prompt**, and the **`files`** input. As on the partner node, `seed` isn't used by the
model — change it to force a re-run.
#### Using SF WaveSpeed GPT Image 2.5
Same interface as ComfyUI's partner node: pick a model and the controls below it change to what
+110 -22
View File
@@ -1,11 +1,27 @@
# ABOUTME: Shared helpers for nodes that call WaveSpeed's v3 prediction API directly
# ABOUTME: Result polling with interrupt support, and readable errors for a rejected API key
# ABOUTME: Shared async helpers for nodes that call WaveSpeed's v3 prediction API directly
# ABOUTME: Lets those nodes run in parallel in ComfyUI: every blocking call runs off the event loop
import time
import asyncio
import io
import numpy as np
import requests
import torch
from PIL import Image
from .utils import tensor2images
RESULT_PATH = "/api/v3/predictions/{id}/result"
TERMINAL_FAILURES = {"failed", "cancelled", "timeout", "deleted"}
# The partner image nodes downscale references to this many pixels before upload.
MAX_UPLOAD_PIXELS = 2048 * 2048
# ComfyUI runs an `async def` node as a background task and moves on to other
# ready nodes while it waits, which is how partner nodes overlap. That only
# helps if the coroutine actually yields: WaveSpeedClient uses blocking
# `requests`, so every call below goes through asyncio.to_thread.
def check_interrupted():
"""Raise if the user pressed Cancel in ComfyUI; a no-op outside ComfyUI."""
@@ -16,26 +32,11 @@ def check_interrupted():
mm.throw_exception_if_processing_interrupted()
def wait_for_result(api, task_id, timeout_seconds, label, interval=2):
"""Poll a prediction until it completes. Returns the result dict."""
started = time.time()
while True:
check_interrupted()
result = api.get(RESULT_PATH.format(id=task_id))
status = result.get("status")
if status == "completed":
return result
if status in TERMINAL_FAILURES:
raise RuntimeError(f"{label} task {status}: {result.get('error') or result}")
if time.time() - started > timeout_seconds:
raise RuntimeError(f"{label} task {task_id} still '{status}' after {timeout_seconds}s.")
time.sleep(interval)
def call(client, action):
"""Run an API call, turning a 401 into an error that says which key was used."""
async def call(client, fn, *args):
"""Run a blocking API call in a worker thread, turning a 401 into an error
that says which key was used."""
try:
return action()
return await asyncio.to_thread(fn, *args)
except Exception as e:
if "401" in str(e) or "Unauthorized" in str(e):
source = client.get("source", "the SF WaveSpeed Client node")
@@ -45,3 +46,90 @@ def call(client, action):
f"on SF WaveSpeed Client to fall back to a key saved elsewhere."
) from e
raise
async def wait_for_result(api, task_id, timeout_seconds, label, interval=2):
"""Poll a prediction until it completes, yielding between polls. Returns the result."""
loop = asyncio.get_running_loop()
started = loop.time()
while True:
check_interrupted()
result = await asyncio.to_thread(api.get, RESULT_PATH.format(id=task_id))
status = result.get("status")
if status == "completed":
return result
if status in TERMINAL_FAILURES:
raise RuntimeError(f"{label} task {status}: {result.get('error') or result}")
if loop.time() - started > timeout_seconds:
raise RuntimeError(f"{label} task {task_id} still '{status}' after {timeout_seconds}s.")
await asyncio.sleep(interval)
async def submit_and_collect(api, client, endpoint, payload, count, timeout_seconds, label):
"""Submit `count` identical predictions at once and wait for all of them.
Returns every output URL, in submission order."""
async def one():
task = await call(client, api.post, endpoint, payload)
if not task.get("id"):
raise RuntimeError(f"WaveSpeed did not return a task id: {task}")
result = await wait_for_result(api, task["id"], timeout_seconds, label)
outputs = result.get("outputs") or []
return outputs if isinstance(outputs, list) else [outputs]
batches = await asyncio.gather(*(one() for _ in range(count)))
return [url for batch in batches for url in batch]
def flatten_images(tensors):
"""Every frame of every IMAGE tensor, as PIL images."""
frames = []
for tensor in tensors:
if tensor is None:
continue
batch = tensor if tensor.dim() == 4 else tensor.unsqueeze(0)
frames.extend(tensor2images(batch))
return frames
def downscale(image, max_pixels=MAX_UPLOAD_PIXELS):
w, h = image.size
if w * h <= max_pixels:
return image
scale = (max_pixels / (w * h)) ** 0.5
return image.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
async def upload_images(api, client, images):
"""Upload references concurrently, downscaled first. Returns their URLs in order."""
return list(await asyncio.gather(*(call(client, api.upload_file, downscale(img)) for img in images)))
async def fetch_images(urls, timeout=120):
"""Download output images concurrently."""
def get(url):
response = requests.get(url, timeout=timeout)
response.raise_for_status()
return Image.open(io.BytesIO(response.content))
return list(await asyncio.gather(*(asyncio.to_thread(get, url) for url in urls)))
def _has_alpha(image):
return image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info)
def to_image_batch(frames):
"""One IMAGE tensor from PIL frames. Keeps alpha when any frame is transparent;
resizes stragglers to the first frame, since a batch must share one size."""
if not frames:
raise RuntimeError("WaveSpeed returned no images.")
mode = "RGBA" if any(_has_alpha(f) for f in frames) else "RGB"
width, height = frames[0].size
tensors = []
for frame in frames:
if frame.size != (width, height):
frame = frame.resize((width, height), Image.LANCZOS)
tensors.append(torch.from_numpy(np.asarray(frame.convert(mode), dtype=np.float32) / 255.0)[None,])
return torch.cat(tensors, dim=0)
+10 -72
View File
@@ -1,18 +1,10 @@
# ABOUTME: OpenAI GPT Image through WaveSpeed, modelled on ComfyUI's "OpenAI GPT Image 2.5" partner node
# ABOUTME: Same model picker, per-model controls and growing image slots, billed to a WaveSpeed account
import io
import numpy as np
import requests
import torch
from PIL import Image
# ABOUTME: Same model picker, per-model controls and growing image slots; async, so it runs in parallel
from comfy_api.latest import IO
from .wavespeed_api.client import WaveSpeedClient
from .wavespeed_api.utils import tensor2images
from .wavespeed_api.v3 import call, wait_for_result
from .wavespeed_api.v3 import fetch_images, flatten_images, submit_and_collect, to_image_batch, upload_images
# Display name -> WaveSpeed slug. WaveSpeed does not offer gpt-image-1, so of
# the partner node's five models this carries the four that exist there.
@@ -30,9 +22,6 @@ ASPECT_RATIOS = (ASPECT_AUTO, "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "
"1:2", "2:1", "1:3", "3:1", "9:21", "21:9")
LEGACY_SIZES = ("auto", "1024x1024", "1024x1536", "1536x1024")
# The partner node downscales references to this many pixels before upload.
MAX_UPLOAD_PIXELS = 2048 * 2048
def _reference_images(max_images):
return IO.Autogrow.Input(
@@ -116,31 +105,6 @@ def build_payload(model_name, prompt, options, image_urls):
return payload
def _flatten(tensors):
"""Every frame of every connected IMAGE input, as PIL images."""
frames = []
for tensor in tensors:
batch = tensor if tensor.dim() == 4 else tensor.unsqueeze(0)
frames.extend(tensor2images(batch))
return frames
def _downscale(image):
w, h = image.size
if w * h <= MAX_UPLOAD_PIXELS:
return image
scale = (MAX_UPLOAD_PIXELS / (w * h)) ** 0.5
return image.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
def _has_alpha(image):
return image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info)
def _to_tensor(image, mode):
return torch.from_numpy(np.asarray(image.convert(mode), dtype=np.float32) / 255.0)[None,]
class SFWaveSpeedGPTImage(IO.ComfyNode):
"""GPT Image via WaveSpeed. Mirrors the partner node's interface; the
differences come from what WaveSpeed's API actually accepts."""
@@ -199,57 +163,31 @@ class SFWaveSpeedGPTImage(IO.ComfyNode):
)
@classmethod
def execute(cls, client, prompt, model, n, seed, timeout_seconds=600):
async def execute(cls, client, prompt, model, n, seed, timeout_seconds=600):
if not prompt.strip():
raise ValueError("prompt is empty.")
raise ValueError("The prompt box is empty — GPT Image needs a text instruction, even when editing.")
model_name = model["model"]
if model_name not in MODELS:
raise ValueError(f"Unknown model: {model_name}")
references = _flatten([t for t in (model.get("images") or {}).values() if t is not None])
references = flatten_images((model.get("images") or {}).values())
limit = 10 if model_name == "gpt-image-1.5" else 16
if len(references) > limit:
raise ValueError(f"{model_name} accepts at most {limit} reference images, got {len(references)}.")
api = WaveSpeedClient(api_key=client["api_key"])
image_urls = [call(client, lambda img=img: api.upload_file(_downscale(img))) for img in references]
image_urls = await upload_images(api, client, references)
endpoint = f"/api/v3/{MODELS[model_name]}/{'edit' if image_urls else 'text-to-image'}"
payload = build_payload(model_name, prompt, model, image_urls)
mode = f"editing {len(image_urls)} image(s)" if image_urls else "text-to-image"
print(f"[SF GPT Image] {model_name}, {mode}, n={n}.")
# WaveSpeed has no `n`, so submit n requests up front and collect them together.
task_ids = []
for _ in range(n):
task = call(client, lambda: api.post(endpoint, payload))
if not task.get("id"):
raise RuntimeError(f"WaveSpeed did not return a task id: {task}")
task_ids.append(task["id"])
frames = []
for task_id in task_ids:
result = wait_for_result(api, task_id, timeout_seconds, f"GPT Image ({model_name})")
outputs = result.get("outputs") or []
for url in outputs if isinstance(outputs, list) else [outputs]:
response = requests.get(url, timeout=120)
response.raise_for_status()
frames.append(Image.open(io.BytesIO(response.content)))
if not frames:
raise RuntimeError("WaveSpeed returned no images.")
# Keep alpha when a transparent background was produced, as the partner
# node does; otherwise return ordinary 3-channel images.
mode = "RGBA" if any(_has_alpha(f) for f in frames) else "RGB"
# A batch is one tensor, so frames must share a size; identical requests
# should agree, but match them to the first rather than fail if not.
width, height = frames[0].size
tensors = [_to_tensor(f if f.size == (width, height) else f.resize((width, height), Image.LANCZOS), mode)
for f in frames]
return IO.NodeOutput(torch.cat(tensors, dim=0))
# WaveSpeed has no `n`, so n requests are submitted together.
urls = await submit_and_collect(api, client, endpoint, payload, n, timeout_seconds,
f"GPT Image ({model_name})")
return IO.NodeOutput(to_image_batch(await fetch_images(urls)))
NODE_CLASS_MAPPINGS = {"SFWaveSpeedGPTImage": SFWaveSpeedGPTImage}
+99
View File
@@ -0,0 +1,99 @@
# ABOUTME: Nano Banana Pro through WaveSpeed, modelled on ComfyUI's "Nano Banana Pro (Google Gemini Image)" node
# ABOUTME: Same controls and model choices as the partner node; async, so it runs in parallel
from comfy_api.latest import IO
from .wavespeed_api.client import WaveSpeedClient
from .wavespeed_api.v3 import fetch_images, flatten_images, submit_and_collect, to_image_batch, upload_images
# The partner node's model names, mapped to their WaveSpeed equivalents.
MODELS = {
"gemini-3-pro-image-preview": "google/nano-banana-pro",
"Nano Banana 2 (Gemini 3.1 Flash Image)": "google/nano-banana-2",
}
ASPECT_RATIOS = ["auto", "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]
MAX_REFERENCES = 14
def build_payload(prompt, aspect_ratio, resolution, image_urls):
payload = {"prompt": prompt, "resolution": resolution.lower(), "output_format": "png"}
if aspect_ratio != "auto":
payload["aspect_ratio"] = aspect_ratio
if image_urls:
payload["images"] = image_urls
return payload
class SFWaveSpeedNanoBananaPro(IO.ComfyNode):
"""Nano Banana Pro via WaveSpeed, with the partner node's interface."""
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SFWaveSpeedNanoBananaPro",
display_name="SF WaveSpeed Nano Banana Pro",
category="Stillfront/WaveSpeed",
description="Generate or edit images with Google's Nano Banana Pro through WaveSpeed.",
inputs=[
IO.Custom("WAVESPEED_AI_API_CLIENT").Input("client"),
IO.String.Input(
"prompt",
multiline=True,
default="",
tooltip="Text prompt describing the image to generate or the edits to apply. "
"Include any constraints, styles, or details the model should follow.",
),
IO.Combo.Input("model", options=list(MODELS)),
IO.Int.Input(
"seed",
default=42,
min=0,
max=0xFFFFFFFFFFFFFFFF,
control_after_generate=True,
tooltip="Not sent to WaveSpeed, which has no seed for these models. "
"Change it to force a re-run.",
),
IO.Combo.Input(
"aspect_ratio",
options=ASPECT_RATIOS,
default="auto",
tooltip="If set to 'auto', matches your input image's aspect ratio; "
"with no image, the model chooses.",
),
IO.Combo.Input("resolution", options=["1K", "2K", "4K"], tooltip="Target output resolution."),
IO.Image.Input(
"images",
optional=True,
tooltip="Optional reference image(s). "
f"To include multiple images, use the Batch Images node (up to {MAX_REFERENCES}).",
),
IO.Int.Input("timeout_seconds", default=600, min=60, max=3600, step=30, advanced=True),
],
outputs=[IO.Image.Output()],
)
@classmethod
async def execute(cls, client, prompt, model, seed, aspect_ratio, resolution, images=None, timeout_seconds=600):
if not prompt.strip():
raise ValueError("The prompt box is empty — Nano Banana needs a text instruction, even when editing.")
if model not in MODELS:
raise ValueError(f"Unknown model: {model}")
references = flatten_images([images])
if len(references) > MAX_REFERENCES:
raise ValueError(f"The current maximum number of supported images is {MAX_REFERENCES}.")
api = WaveSpeedClient(api_key=client["api_key"])
image_urls = await upload_images(api, client, references)
endpoint = f"/api/v3/{MODELS[model]}/{'edit' if image_urls else 'text-to-image'}"
payload = build_payload(prompt, aspect_ratio, resolution, image_urls)
print(f"[SF Nano Banana Pro] {MODELS[model]}, "
f"{f'editing {len(image_urls)} image(s)' if image_urls else 'text-to-image'}, {resolution}.")
urls = await submit_and_collect(api, client, endpoint, payload, 1, timeout_seconds, "Nano Banana Pro")
return IO.NodeOutput(to_image_batch(await fetch_images(urls)))
NODE_CLASS_MAPPINGS = {"SFWaveSpeedNanoBananaPro": SFWaveSpeedNanoBananaPro}
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedNanoBananaPro": "SF WaveSpeed Nano Banana Pro"}
+16 -11
View File
@@ -1,6 +1,7 @@
# ABOUTME: Tripo P2 image-to-3D through the WaveSpeed API, billed to a WaveSpeed account
# ABOUTME: Emits GLB/FBX File3D outputs compatible with ComfyUI's Preview 3D and Save nodes
import asyncio
import os
import time
from urllib.parse import urlparse
@@ -172,35 +173,39 @@ class SFWaveSpeedTripoP2ImageTo3D:
CATEGORY = "Stillfront/WaveSpeed"
OUTPUT_NODE = True
def generate(self, client, image, quad, face_limit, texture, pbr,
texture_version=TEXTURE_VERSIONS[0], delight=True, auto_size=False,
export_uv=True, export_orientation=ORIENTATION_UNSET,
model_seed=-1, texture_seed=-1, timeout_seconds=900):
# async so ComfyUI can run several of these, and other async nodes, at once.
async def generate(self, client, image, quad, face_limit, texture, pbr,
texture_version=TEXTURE_VERSIONS[0], delight=True, auto_size=False,
export_uv=True, export_orientation=ORIENTATION_UNSET,
model_seed=-1, texture_seed=-1, timeout_seconds=900):
api = WaveSpeedClient(api_key=client["api_key"])
image_url = call(client, lambda: api.upload_file(tensor2images(image)[0]))
image_url = await call(client, api.upload_file, tensor2images(image)[0])
payload = build_payload(
image_url, quad, face_limit, texture, pbr, texture_version, delight,
auto_size, export_uv, export_orientation, model_seed, texture_seed,
)
task = call(client, lambda: api.post(ENDPOINT, payload))
task = await call(client, api.post, ENDPOINT, payload)
task_id = task.get("id")
if not task_id:
raise RuntimeError(f"WaveSpeed did not return a task id: {task}")
print(f"[SF Tripo P2] Submitted {task_id} (quad={quad}, faces={face_limit}, texture={texture}).")
started = time.time()
result = wait_for_result(api, task_id, timeout_seconds, "Tripo P2", interval=3)
result = await wait_for_result(api, task_id, timeout_seconds, "Tripo P2", interval=3)
url = pick_model_url(result.get("outputs"))
ext = os.path.splitext(urlparse(url).path)[1].lower() or (".fbx" if quad else ".glb")
path = os.path.join(output_dir(), f"sf_tripo_p2_{task_id}{ext}")
response = requests.get(url, timeout=300)
response.raise_for_status()
with open(path, "wb") as f:
f.write(response.content)
def download():
response = requests.get(url, timeout=300)
response.raise_for_status()
with open(path, "wb") as f:
f.write(response.content)
await asyncio.to_thread(download)
print(f"[SF Tripo P2] Done in {time.time() - started:.0f}s -> {path}")
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-sf-nodes"
description = "Custom nodes for ComfyUI including LLM Chat with support for Claude, Gemini, and OpenAI models, WaveSpeed AI image/video generation, VertexAI integration, and workflow utilities"
version = "1.7.0"
version = "1.8.0"
license = { file = "LICENSE" }
dependencies = [
"requests>=2.31.0",