Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d392931a43 | ||
|
|
f28000603c | ||
|
|
9212fa484e |
@@ -2,13 +2,13 @@
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user