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

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