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

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