Compare commits

..
3 Commits
Author SHA1 Message Date
Ivor StrelarandClaude Opus 5.5 d392931a43 feat: add SF WaveSpeed Tripo H3.1 Image to 3D node
Tripo's high-detail H3.1 model through WaveSpeed, for comparing against P2 on
the same image. About a fifth of P2's price there, with quad or triangle
topology, a 1,000-2,000,000 face target, standard or detailed geometry and
textures, texture alignment, orientation and seeds. A face_limit below
H3.1's 1,000 floor is rejected before submission, pointing at P2.

The upload/submit/wait/download/File3D sequence the two 3D nodes share now
lives in wavespeed_api/v3.generate_3d_model, along with pick_model_url and
output_dir, so P2 and H3.1 differ only in their inputs and payloads. Both are
async and run in parallel with each other and the image nodes.

Verified with a stubbed API: P2 and H3.1 on the same image take 8.6s in
sequence and 4.2s side by side, P2's request body is unchanged by the
refactor, and both return FBX with quad on and GLB with it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:01:46 +02:00
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
Ivor StrelarandClaude Opus 5.5 9212fa484e feat: add SF WaveSpeed GPT Image 2.5 node
Mirrors ComfyUI's "OpenAI GPT Image 2.5" partner node but bills a WaveSpeed
key via SF WaveSpeed Client. Built on the same V3 components the partner node
uses: a DynamicCombo model picker whose options carry their own controls, and
Autogrow reference-image slots, so connecting images switches to WaveSpeed's
/edit endpoint and leaving them empty uses /text-to-image.

Follows WaveSpeed's actual schemas rather than OpenAI's: gpt-image-1 is
dropped (not offered), 2 and 2.5 take aspect_ratio plus a 1k/2k/4k resolution
instead of pixel sizes, background exists only on 1.5, and 1.5 caps
references at 10. There is no mask input, as no WaveSpeed edit endpoint
accepts one. WaveSpeed has no `n`, so n requests are submitted together and
returned as one batch.

Copies the partner node's behaviour where it applies: references are
downscaled to 2048x2048 pixels before upload, batches are flattened, and
outputs keep alpha when a transparent background was produced.

Also moves v3 result polling and the 401 key-source message into a shared
wavespeed_api/v3.py, and switches the Tripo P2 node to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:10:38 +02:00
7 changed files with 725 additions and 103 deletions
+63 -2
View File
@@ -2,13 +2,13 @@
![Stillfront](assets/logo.png)
**63 custom ComfyUI nodes** from [Stillfront](https://www.stillfront.com), covering AI image and
**66 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) | 45 nodes — image, video and 3D generation through [WaveSpeed AI](https://wavespeed.ai): VEO 3.1, Sora 2, Nano Banana, Qwen, Flux Kontext, Seedream, WAN 2.5, InfiniteTalk, Tripo P2, upscalers |
| [**Stillfront/WaveSpeed**](#stillfrontwavespeed) | 48 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 and H3.1, 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 |
@@ -287,6 +287,8 @@ 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. |
@@ -311,16 +313,75 @@ 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**, **SF WaveSpeed Tripo P2 Image
to 3D** and **SF WaveSpeed Tripo H3.1 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
that model supports; connect reference images and it switches from generating to editing, with
image slots appearing as you fill them. The difference is billing — it runs on your WaveSpeed key
through **SF WaveSpeed Client** instead of ComfyUI credits.
Where it differs from the partner node, it's because WaveSpeed's API differs:
| | Partner node | This node |
|---|---|---|
| Models | 2.5 Flare, 2.5 Sunburst, 2, 1.5, 1 | Same minus **gpt-image-1**, which WaveSpeed doesn't offer |
| Size (2 / 2.5) | Exact pixels, e.g. `1536x1024`, or Custom | **Aspect ratio** (15 options) + **resolution** `1k` / `2k` / `4k` |
| Background | All models | gpt-image-1.5 only — the only one WaveSpeed accepts it for |
| `n` (1–8) | One request | **n parallel requests**, returned as one batch; each billed separately |
| Inpainting mask | Yes | **No** — WaveSpeed's edit endpoints have no mask input |
| Reference images | Up to 16 | Up to 16; **10** for gpt-image-1.5 |
| Extras | — | `output_format`; `input_fidelity` when editing with 1.5 |
Like the partner node, `seed` isn't sent (the API has none) — change it to force a re-run.
Transparent backgrounds keep their alpha channel; opaque results are ordinary RGB images.
#### 3D Generation
| Node | Description |
|---|---|
| **SF WaveSpeed Tripo P2 Image to 3D** | Tripo's P2 model — the same one behind ComfyUI's *Tripo P2: Image to Model* partner node — billed to your WaveSpeed account instead of ComfyUI credits. Quad or triangle topology, a target face count, texture quality from none to extreme, PBR, delighting, UVs, and seeds. |
| **SF WaveSpeed Tripo H3.1 Image to 3D** | Tripo's high-detail H3.1 model — about a fifth of P2's price on WaveSpeed. Quad or triangle topology, 1,000–2,000,000 faces, standard or detailed geometry and textures, PBR, and seeds. Same outputs as the P2 node. |
Outputs mirror the partner node, so they plug into **Preview 3D** and **Save 3D** directly: Tripo
returns **FBX when `quad` is on and GLB when it's off**, so the matching output is populated and
the other is empty. The file is also saved to `output/3d/`, with its path and CDN URL as strings.
**P2 or H3.1?** They're two different Tripo families. **P2** is the low-poly line: it builds
game-ready geometry at the face count you ask for, down to 48 faces, with clean quads — Tripo still
labels it a *preview* release, so results can shift. **H3.1** is the high-detail model, up to
2,000,000 faces, cheaper on WaveSpeed (≈$0.24 vs ≈$1.20 per model), but with a 1,000-face floor and
a mesh reduced from a dense one rather than built low-poly. Put both nodes on the same image to
compare — they run in parallel.
`face_limit` runs 48–50,000 for triangles or 48–25,000 for quads; `-1` lets Tripo decide. It's a
target, not a hard cap. Leave `export_orientation` unset if the model is headed for rigging or
retexturing — Tripo warns those steps can come out mis-rotated without reporting an error.
+214
View File
@@ -0,0 +1,214 @@
# 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 asyncio
import io
import os
import time
from urllib.parse import urlparse
import numpy as np
import requests
import torch
from PIL import Image
from .utils import tensor2images
try:
from comfy_api.latest import Types
File3D = Types.File3D
except ImportError: # older ComfyUI without 3D file types
File3D = None
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."""
try:
import comfy.model_management as mm
except ImportError:
return
mm.throw_exception_if_processing_interrupted()
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 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")
raise RuntimeError(
f"WaveSpeed rejected the API key, which came from {source}. "
f"Check it on wavespeed.ai under API Keys, or clear the api_key field "
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)
# --- 3D models -----------------------------------------------------------------
def pick_model_url(outputs):
"""The model file URL out of a result's `outputs`, which may be a list or a string."""
candidates = outputs if isinstance(outputs, list) else [outputs]
urls = [u for u in candidates if isinstance(u, str) and u.startswith("http")]
for url in urls:
if urlparse(url).path.lower().endswith((".glb", ".fbx")):
return url
if urls:
return urls[0]
raise RuntimeError(f"No model URL in the result: {outputs!r}")
def output_dir():
"""ComfyUI's output/3d folder, created if missing."""
try:
import folder_paths
base = folder_paths.get_output_directory()
except ImportError:
base = os.getcwd()
path = os.path.join(base, "3d")
os.makedirs(path, exist_ok=True)
return path
async def generate_3d_model(api, client, image, endpoint, build_payload, quad, file_prefix,
timeout_seconds, label):
"""Image-to-3D from upload to File3D: uploads the first frame, submits the
job, waits for it, saves the model to output/3d and returns
(glb, fbx, path, url). `build_payload` receives the uploaded image URL.
Tripo returns FBX when quad topology is requested and GLB otherwise, so only
the output matching the returned file is populated, as on ComfyUI's own
Tripo nodes.
"""
image_url = await call(client, api.upload_file, tensor2images(image)[0])
task = await call(client, api.post, endpoint, build_payload(image_url))
task_id = task.get("id")
if not task_id:
raise RuntimeError(f"WaveSpeed did not return a task id: {task}")
print(f"[SF {label}] Submitted {task_id}.")
started = time.time()
result = await wait_for_result(api, task_id, timeout_seconds, label, 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"{file_prefix}_{task_id}{ext}")
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 {label}] Done in {time.time() - started:.0f}s -> {path}")
glb = fbx = None
if File3D is not None:
model = File3D(path)
glb = model if ext == ".glb" else None
fbx = model if ext == ".fbx" else None
return (glb, fbx, path, url)
+194
View File
@@ -0,0 +1,194 @@
# 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; 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
# 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.
MODELS = {
"gpt-image-2.5-flare": "openai/gpt-image-2.5-flare",
"gpt-image-2.5-sunburst": "openai/gpt-image-2.5-sunburst",
"gpt-image-2": "openai/gpt-image-2",
"gpt-image-1.5": "openai/gpt-image-1.5",
}
QUALITIES = ("low", "medium", "high")
QUALITIES_25 = ("low", "medium", "high", "xhigh", "max")
ASPECT_AUTO = "auto"
ASPECT_RATIOS = (ASPECT_AUTO, "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9",
"1:2", "2:1", "1:3", "3:1", "9:21", "21:9")
LEGACY_SIZES = ("auto", "1024x1024", "1024x1536", "1536x1024")
def _reference_images(max_images):
return IO.Autogrow.Input(
"images",
template=IO.Autogrow.TemplateNames(
IO.Image.Input("image"),
names=[f"image_{i}" for i in range(1, max_images + 1)],
min=0,
),
tooltip=f"Optional reference image(s). Connecting any switches to editing. Up to {max_images}.",
)
def _quality(qualities):
return IO.Combo.Input(
"quality",
default="low",
options=list(qualities),
tooltip="Image quality, affects cost and generation time.",
)
def _gpt_image_2_inputs(qualities):
"""gpt-image-2 and 2.5: WaveSpeed takes an aspect ratio plus a resolution tier
instead of OpenAI's exact pixel sizes."""
return [
IO.Combo.Input(
"aspect_ratio",
default=ASPECT_AUTO,
options=list(ASPECT_RATIOS),
tooltip="'auto' follows the reference image when editing, or the model's default otherwise.",
),
IO.Combo.Input("resolution", default="1k", options=["1k", "2k", "4k"], tooltip="Output resolution tier."),
_quality(qualities),
IO.Combo.Input("output_format", default="png", options=["png", "jpeg", "webp"]),
_reference_images(16),
]
def _gpt_image_15_inputs():
return [
IO.Combo.Input("size", default="auto", options=list(LEGACY_SIZES), tooltip="Image size."),
IO.Combo.Input(
"background",
default="auto",
options=["auto", "opaque", "transparent"],
tooltip="Return image with or without background. Transparent needs png output.",
),
_quality(QUALITIES),
IO.Combo.Input("output_format", default="png", options=["png", "jpeg"]),
IO.Combo.Input(
"input_fidelity",
default="high",
options=["low", "high"],
tooltip="How closely an edit sticks to the reference images. Only used when editing.",
),
_reference_images(10),
]
def build_payload(model_name, prompt, options, image_urls):
"""The request body for one generation. Options the user left on 'auto' are
omitted so WaveSpeed applies its own default."""
payload = {"prompt": prompt, "output_format": options["output_format"], "quality": options["quality"]}
if model_name == "gpt-image-1.5":
if options["background"] == "transparent" and options["output_format"] != "png":
raise ValueError("A transparent background needs output_format 'png'.")
payload["background"] = options["background"]
if options["size"] != "auto":
payload["size"] = options["size"].replace("x", "*")
if image_urls:
payload["input_fidelity"] = options["input_fidelity"]
else:
payload["resolution"] = options["resolution"]
if options["aspect_ratio"] != ASPECT_AUTO:
payload["aspect_ratio"] = options["aspect_ratio"]
if image_urls:
payload["images"] = image_urls
return payload
class SFWaveSpeedGPTImage(IO.ComfyNode):
"""GPT Image via WaveSpeed. Mirrors the partner node's interface; the
differences come from what WaveSpeed's API actually accepts."""
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SFWaveSpeedGPTImage",
display_name="SF WaveSpeed GPT Image 2.5",
category="Stillfront/WaveSpeed",
description=(
"Generates or edits images with OpenAI's GPT Image models through WaveSpeed. "
"Connect reference images to edit; leave them empty to generate."
),
inputs=[
IO.Custom("WAVESPEED_AI_API_CLIENT").Input("client"),
IO.String.Input("prompt", default="", multiline=True, tooltip="Text prompt for GPT Image"),
IO.DynamicCombo.Input(
"model",
options=[
IO.DynamicCombo.Option("gpt-image-2.5-flare", _gpt_image_2_inputs(QUALITIES_25)),
IO.DynamicCombo.Option("gpt-image-2.5-sunburst", _gpt_image_2_inputs(QUALITIES_25)),
IO.DynamicCombo.Option("gpt-image-2", _gpt_image_2_inputs(QUALITIES)),
IO.DynamicCombo.Option("gpt-image-1.5", _gpt_image_15_inputs()),
],
),
IO.Int.Input(
"n",
default=1,
min=1,
max=8,
step=1,
tooltip="How many images to generate. Sent as parallel requests, each billed separately.",
display_mode=IO.NumberDisplay.number,
),
IO.Int.Input(
"seed",
default=0,
min=0,
max=2147483647,
step=1,
display_mode=IO.NumberDisplay.number,
control_after_generate=True,
tooltip="Not sent to WaveSpeed, which has no seed for these models. Change it to force a re-run.",
),
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, n, seed, timeout_seconds=600):
if not prompt.strip():
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_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 = 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 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}
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedGPTImage": "SF WaveSpeed GPT Image 2.5"}
+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"}
+140
View File
@@ -0,0 +1,140 @@
# ABOUTME: Tripo H3.1 image-to-3D through the WaveSpeed API — Tripo's high-detail model
# ABOUTME: Same outputs and parallel behaviour as the P2 node, for side-by-side comparison
from .wavespeed_api.client import WaveSpeedClient
from .wavespeed_api.v3 import generate_3d_model
ENDPOINT = "/api/v3/tripo3d/h3.1/image-to-3d"
TEXTURE_LEVELS = ["standard", "detailed", "none"]
QUALITIES = ["standard", "detailed"]
FACE_MIN, FACE_MAX = 1000, 2000000
def build_payload(image_url, quad, face_limit, texture, pbr, geometry_quality,
texture_alignment, orientation, auto_size, model_seed, texture_seed):
"""The request body. Unused options are omitted rather than sent as null."""
if face_limit != -1 and not FACE_MIN <= face_limit <= FACE_MAX:
raise ValueError(
f"face_limit must be -1 (adaptive) or between {FACE_MIN:,} and {FACE_MAX:,} — "
f"H3.1 does not go below {FACE_MIN:,} faces. Use the P2 node for lower counts."
)
textured = texture != "none"
payload = {
"image": image_url,
"quad": quad,
"texture": textured,
# PBR implies textures, so it is switched off along with them.
"pbr": bool(pbr and textured),
"geometry_quality": geometry_quality,
"orientation": orientation,
"auto_size": auto_size,
}
if textured:
payload["texture_quality"] = texture
payload["texture_alignment"] = texture_alignment
if face_limit != -1:
payload["face_limit"] = face_limit
if model_seed != -1:
payload["model_seed"] = model_seed
if texture_seed != -1 and textured:
payload["texture_seed"] = texture_seed
return payload
class SFWaveSpeedTripoH31ImageTo3D:
"""
Tripo H3.1 image-to-3D via WaveSpeed: Tripo's high-detail model, at roughly
a fifth of P2's price. Meshes from 1,000 to 2,000,000 faces.
Tripo returns FBX when quad topology is on and GLB when it is off, so the
matching output is populated and the other is empty.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"client": ("WAVESPEED_AI_API_CLIENT",),
"image": ("IMAGE", {"tooltip": "Reference image. Only the first frame of a batch is used."}),
"quad": (
"BOOLEAN",
{"default": False, "tooltip": "Quad topology instead of triangles. Output becomes FBX."},
),
"face_limit": (
"INT",
{
"default": -1,
"min": -1,
"max": FACE_MAX,
"step": 100,
"tooltip": f"Target face count. -1 lets Tripo choose. {FACE_MIN:,}-{FACE_MAX:,}.",
},
),
"texture": (
TEXTURE_LEVELS,
{"default": "standard", "tooltip": "'none' gives untextured geometry."},
),
"pbr": ("BOOLEAN", {"default": True, "tooltip": "PBR material maps. Ignored when texture is 'none'."}),
},
"optional": {
"geometry_quality": (
QUALITIES,
{"default": "standard", "tooltip": "'detailed' gives finer geometry and may cost more."},
),
"texture_alignment": (
["original_image", "geometry"],
{
"default": "original_image",
"tooltip": "Align textures to the input image, or to the generated geometry.",
},
),
"orientation": (
["default", "align_image"],
{"default": "default", "tooltip": "'align_image' rotates the model to match the input image."},
),
"auto_size": ("BOOLEAN", {"default": False, "tooltip": "Scale the model to real-world metres."}),
"model_seed": (
"INT",
{"default": -1, "min": -1, "max": 2147483647, "tooltip": "-1 for random geometry."},
),
"texture_seed": (
"INT",
{"default": -1, "min": -1, "max": 2147483647, "tooltip": "-1 for random texturing."},
),
"timeout_seconds": ("INT", {"default": 900, "min": 60, "max": 3600, "step": 30}),
},
}
RETURN_TYPES = ("FILE_3D_GLB", "FILE_3D_FBX", "STRING", "STRING")
RETURN_NAMES = ("GLB", "FBX", "model_file", "model_url")
OUTPUT_TOOLTIPS = (
"Populated when quad is off.",
"Populated when quad is on.",
"Absolute path of the downloaded model in ComfyUI's output/3d folder.",
"WaveSpeed CDN link to the model.",
)
FUNCTION = "generate"
CATEGORY = "Stillfront/WaveSpeed"
OUTPUT_NODE = True
# 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,
geometry_quality="standard", texture_alignment="original_image",
orientation="default", auto_size=False,
model_seed=-1, texture_seed=-1, timeout_seconds=900):
api = WaveSpeedClient(api_key=client["api_key"])
print(f"[SF Tripo H3.1] quad={quad}, faces={face_limit}, texture={texture}, geometry={geometry_quality}.")
return await generate_3d_model(
api, client, image, ENDPOINT,
lambda image_url: build_payload(
image_url, quad, face_limit, texture, pbr, geometry_quality,
texture_alignment, orientation, auto_size, model_seed, texture_seed,
),
quad, "sf_tripo_h31", timeout_seconds, "Tripo H3.1",
)
NODE_CLASS_MAPPINGS = {"SFWaveSpeedTripoH31ImageTo3D": SFWaveSpeedTripoH31ImageTo3D}
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedTripoH31ImageTo3D": "SF WaveSpeed Tripo H3.1 Image to 3D"}
+14 -100
View File
@@ -1,30 +1,15 @@
# 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 os
import time
from urllib.parse import urlparse
import requests
from .wavespeed_api.client import WaveSpeedClient
from .wavespeed_api.utils import tensor2images
try:
from comfy_api.latest import Types
File3D = Types.File3D
except ImportError: # older ComfyUI without 3D file types
File3D = None
from .wavespeed_api.v3 import generate_3d_model
ENDPOINT = "/api/v3/tripo3d/p2/image-to-3d"
RESULT_PATH = "/api/v3/predictions/{id}/result"
TEXTURE_LEVELS = ["standard", "fast", "detailed", "extreme", "none"]
TEXTURE_VERSIONS = ["v3.5-20260815", "v3.0-20250812", "v2.5-20250123"]
ORIENTATION_UNSET = "unset (recommended)"
ORIENTATIONS = [ORIENTATION_UNSET, "+x", "-x", "+y", "-y"]
TERMINAL_FAILURES = {"failed", "cancelled", "timeout", "deleted"}
def build_payload(image_url, quad, face_limit, texture, pbr, texture_version,
@@ -66,36 +51,8 @@ def build_payload(image_url, quad, face_limit, texture, pbr, texture_version,
return payload
def pick_model_url(outputs):
"""The model file URL out of a result's `outputs`, which may be a list or a string."""
candidates = outputs if isinstance(outputs, list) else [outputs]
urls = [u for u in candidates if isinstance(u, str) and u.startswith("http")]
for url in urls:
if urlparse(url).path.lower().endswith((".glb", ".fbx")):
return url
if urls:
return urls[0]
raise RuntimeError(f"No model URL in the result: {outputs!r}")
def check_interrupted():
try:
import comfy.model_management as mm
except ImportError:
return
mm.throw_exception_if_processing_interrupted()
def output_dir():
try:
import folder_paths
base = folder_paths.get_output_directory()
except ImportError:
base = os.getcwd()
path = os.path.join(base, "3d")
os.makedirs(path, exist_ok=True)
return path
class SFWaveSpeedTripoP2ImageTo3D:
@@ -181,65 +138,22 @@ 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"])
try:
image_url = api.upload_file(tensor2images(image)[0])
except Exception as e:
if "401" in str(e):
source = client.get("source", "the SF WaveSpeed Client node")
raise RuntimeError(
f"WaveSpeed rejected the API key, which came from {source}. "
f"Check it on wavespeed.ai under API Keys, or clear the api_key field "
f"on SF WaveSpeed Client to fall back to a key saved elsewhere."
) from e
raise
payload = build_payload(
image_url, quad, face_limit, texture, pbr, texture_version, delight,
auto_size, export_uv, export_orientation, model_seed, texture_seed,
print(f"[SF Tripo P2] quad={quad}, faces={face_limit}, texture={texture}.")
return await generate_3d_model(
api, client, image, ENDPOINT,
lambda image_url: build_payload(
image_url, quad, face_limit, texture, pbr, texture_version, delight,
auto_size, export_uv, export_orientation, model_seed, texture_seed,
),
quad, "sf_tripo_p2", timeout_seconds, "Tripo P2",
)
task = 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()
while True:
check_interrupted()
result = api.get(RESULT_PATH.format(id=task_id))
status = result.get("status")
if status == "completed":
break
if status in TERMINAL_FAILURES:
raise RuntimeError(f"Tripo P2 task {status}: {result.get('error') or result}")
if time.time() - started > timeout_seconds:
raise RuntimeError(f"Tripo P2 task {task_id} still '{status}' after {timeout_seconds}s.")
time.sleep(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)
print(f"[SF Tripo P2] Done in {time.time() - started:.0f}s -> {path}")
glb = fbx = None
if File3D is not None:
model = File3D(path)
glb = model if ext == ".glb" else None
fbx = model if ext == ".fbx" else None
return (glb, fbx, path, url)
NODE_CLASS_MAPPINGS = {"SFWaveSpeedTripoP2ImageTo3D": SFWaveSpeedTripoP2ImageTo3D}
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedTripoP2ImageTo3D": "SF WaveSpeed Tripo P2 Image to 3D"}
+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.6.1"
version = "1.9.0"
license = { file = "LICENSE" }
dependencies = [
"requests>=2.31.0",