Compare commits

...
1 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
5 changed files with 241 additions and 76 deletions
+12 -4
View File
@@ -2,13 +2,13 @@
![Stillfront](assets/logo.png)
**65 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) | 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/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 |
@@ -315,8 +315,8 @@ Connect an **SF WaveSpeed Client** node (enter your WaveSpeed API key) to any Wa
#### 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
**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.
@@ -369,11 +369,19 @@ Transparent backgrounds keep their alpha channel; opaque results are ordinary RG
| 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.
+79
View File
@@ -3,6 +3,9 @@
import asyncio
import io
import os
import time
from urllib.parse import urlparse
import numpy as np
import requests
@@ -11,6 +14,13 @@ 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"}
@@ -133,3 +143,72 @@ def to_image_batch(frames):
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)
+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"}
+9 -71
View File
@@ -1,23 +1,8 @@
# 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
import requests
from .wavespeed_api.client import WaveSpeedClient
from .wavespeed_api.utils import tensor2images
from .wavespeed_api.v3 import call, wait_for_result
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"
@@ -66,28 +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 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:
@@ -179,43 +144,16 @@ class SFWaveSpeedTripoP2ImageTo3D:
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 = 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,
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 = 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 = 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}")
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}")
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.8.0"
version = "1.9.0"
license = { file = "LICENSE" }
dependencies = [
"requests>=2.31.0",