Compare commits

...
11 Commits
Author SHA1 Message Date
Ivor StrelarandClaude Opus 5.5 2f76ee6fe3 chore: release 1.10.0
Publishes everything since 1.9.0 to the Comfy Registry: Seedream 5.0, Rodin
2.5, Meshy 7.1 and Seedance 2.5 (with reference images), Nano Banana 2.1 and
the Legacy labels, the dollar cost badges, SF Claude Prompt Coach, network
retries, the optional SF Claude Code prompt and the Save As folder fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:07:40 +01:00
Ivor StrelarandClaude Opus 5.5 42fdf2d3fe feat: add numbered reference images to SF WaveSpeed Seedance 2.5
Connecting images to the new image_1, image_2... slots switches the node to
Seedance 2.5 reference-to-video (up to 30 references). The prompt can say
"the character from image 1 enters the bar"; mentions such as "image 1",
"@Image 1" or "<Image 1>" are rewritten to Seedance's @Image N tags, numbered
by send order so a gap between slots still maps correctly. Mentioning an
empty slot, or combining references with a start/end frame (which the
reference endpoint cannot take), errors before anything is billed.

The start and end frame inputs now display as start_frame and end_frame. The
reference slots are sockets appended last, so saved workflows keep their
widget values (verified by loading one). The image upload also uses the new
retrying upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:39:21 +01:00
Ivor StrelarandClaude Opus 5.5 e4cf49e216 fix: save into the picked folder when Save As starts from a workflow in a subfolder
ComfyUI resolves the Save As name against the open workflow's folder, while
the folder picker writes paths from the top-level workflows folder. Picking
"Stillfront" for a workflow opened from X/deep saved to
X/deep/Stillfront/<name>.

During Save As the active workflow's directory now reads as the top level,
and the input starts with the workflow's current folder, so an untouched
save still lands next to the original. The directory's original descriptor
is restored afterwards (it is an own value in this frontend, not a getter).
Plain Save is untouched. Verified in a browser: nested, Stillfront and top-
level targets, plus the overwrite confirmation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:39:21 +01:00
Ivor StrelarandClaude Opus 5.5 112ea747e0 feat: let SF Claude Code run with an empty prompt when a system prompt is set
When the system prompt and images already describe the task, the prompt box
had to hold a dummy character. An empty prompt now sends "Carry out the task
described in your system prompt."; the node only errors when both are empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:39:21 +01:00
Ivor StrelarandClaude Opus 5.5 271f313fd2 feat: give SF Claude Prompt Coach a researched default prompt and a clearer input order
The built-in coach_system_prompt is rewritten from the most-starred prompt-
engineering and image-to-prompt skills on GitHub plus real coaching rounds:
it diagnoses which layer caused a problem (system prompt, prompt writer,
reference choice, image model), makes 1-3 general, explained changes per
round, checks the log before repeating a fix, and treats image 1 as the
result and image 2+ as references unless image_notes say otherwise - earlier
rounds guessed those roles the wrong way round.

The prompt now sits in a visible text box that a connected text node
overrides, and the inputs run top to bottom as images, system_prompt,
generated_prompt, feedback, image_notes, brief, coach_system_prompt,
session_name, model, timeout_seconds, seed, image_model. Coach nodes saved
before this change should be re-added, as widget values restore by position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:39:21 +01:00
Ivor StrelarandClaude Opus 5.5 17964f3017 fix: retry dropped connections when polling, uploading and downloading on WaveSpeed
A GPT Image run failed with "Connection reset by peer" while polling an
already-submitted task, losing an image that WaveSpeed went on to bill. One
transient network error was enough to abort the node.

Polling, image uploads and result downloads are safe to repeat, so they now
retry connection errors and timeouts up to five times (2, 4, 8, 15, 30 s
apart) before failing with a message that the task may still finish on
WaveSpeed. Submitting a task is deliberately not retried: an unclear failure
there could otherwise create a second billed task.

Applies to every node on the shared v3 helpers (GPT Image, Nano Banana,
Seedream, Seedance, Tripo, Rodin, Meshy). Verified with a stubbed API that
drops connections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:39:21 +01:00
Ivor StrelarandClaude Opus 5.5 878bfc2392 feat: add SF Claude Prompt Coach for tuning prompt-writer system prompts
Takes the system prompt being tuned, the prompt it generated, numbered
reference and result images with notes on what each is, and the artist's
feedback, and returns a revised system prompt plus Claude's analysis.

Runs with the same session_name resume one Claude Code session, so Claude
remembers earlier rounds. Each session also keeps output/sf_prompt_coach/
<name>/ with a log of every round, each system prompt version and each
round's images; Claude can read the log when older detail has faded from the
session, and a new session starts from it if the old one is missing.

The node's own instructions are the coach_system_prompt input, sent as
Claude's whole system prompt, so nothing it is told is hidden.

Verified with two real rounds: the second resumed the session and built on
the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:11:01 +01:00
Ivor StrelarandClaude Opus 5.5 2711b9a59b feat: show the estimated WaveSpeed cost in dollars in node headers
The newer WaveSpeed nodes (GPT Image 2.5, Nano Banana, Seedream 5.0,
Seedance 2.5, Tripo P2/H3.1, Rodin 2.5, Meshy 7.1) now show "~$0.04/run" in
their header, updating as the model, resolution, quality, duration, add-ons,
connected references and n change. Prices come from WaveSpeed's model pages
(2026-10-30) and live in sf_wavespeed_pricing.js.

ComfyUI's own price_badge only renders on api_node partner nodes, and marking
these as such would make ComfyUI treat them as Comfy-billed. So the classic
canvas gets an ordinary node badge, and Vue nodes, whose header only renders
credit badges, get a pill injected into the header.

README documents the badge, the Legacy labels and the new nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:54:54 +01:00
Ivor StrelarandClaude Opus 5.5 1bcb63c420 feat: add Seedream 5.0, Rodin 2.5, Meshy 7.1 and Seedance 2.5 WaveSpeed nodes
- SF WaveSpeed Seedream 5.0: Pro or Flash, generate or edit (up to 10
  references), 1K-2K, n parallel requests. Flash is a flat $0.027.
- SF WaveSpeed Rodin 2.5 Image to 3D: 1-5 views, quad or triangle face
  budgets, PBR/shaded materials, delighting, T/A-pose, HighPack. Defaults to
  18K quads in FBX, since WaveSpeed's default is 500K triangles and GLB
  triangulates quads.
- SF WaveSpeed Meshy 7.1 Image to 3D: standard, low-poly or smart topology,
  polycount target, PBR, texture image/prompt, and optional humanoid
  auto-rig (with walk and run cycles) and animation preset.
- SF WaveSpeed Seedance 2.5: text-to-video, or image-to-video from a start
  and optional end frame; 480p-4k, 4-30 s, native audio; outputs a VIDEO.

All are async V3 nodes, so they run in parallel. generate_3d_model is split
into submit_and_wait and save_3d_model so Rodin and Meshy can reuse them;
pick_model_url can prefer a format, and fetch_video returns a ComfyUI VIDEO.

Verified against a stubbed WaveSpeed API (endpoints, payloads, outputs and
error paths) and by loading them in a separate ComfyUI instance. Not yet
verified with real paid requests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:54:53 +01:00
Ivor StrelarandClaude Opus 5.5 7e08e9a90a feat: add Nano Banana 2.1 to SF WaveSpeed Nano Banana and mark old nodes Legacy
Nano Banana 2.1 ranks above Pro on the public image leaderboards at about a
third of the price, so it joins the existing node as the default model rather
than getting a node of its own: the API has the same shape. The node now
displays as "SF WaveSpeed Nano Banana"; its id is unchanged so saved
workflows keep loading.

To let it replace the eight older Nano Banana nodes it gains n (parallel
requests, replacing the Multi nodes), 8K for Pro via WaveSpeed's ultra
endpoints (replacing the Ultra nodes), 2.1's 1:4/4:1/1:8/8:1 aspect ratios,
and web/image search for 2 and 2.1. Combinations a model doesn't support
raise before any request is sent.

New inputs are appended after timeout_seconds because saved workflows restore
widget values by position; inserted earlier, an old timeout of 600 would load
into n. Verified by loading a workflow saved with the previous version.

The eight older nodes keep their ids and gain " (Legacy)" in their display
names so search shows which node to use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:54:53 +01:00
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
23 changed files with 1720 additions and 124 deletions
+82 -19
View File
@@ -2,16 +2,16 @@
![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) | 52 nodes — image, video and 3D generation through [WaveSpeed AI](https://wavespeed.ai): VEO 3.1, Sora 2, Seedance 2.5, GPT Image 2.5, Nano Banana 2.1/Pro, Seedream 5.0, Qwen, Flux Kontext, WAN 2.5, InfiniteTalk, Tripo P2 and H3.1, Rodin 2.5, Meshy 7.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 |
| [**Stillfront/LLM**](#stillfrontllm) | 6 nodes — Claude, Gemini and GPT in your graph, plus the asset library tooling and a system-prompt coach below |
### Worth a look
@@ -87,6 +87,7 @@ Chat with large language models directly inside your ComfyUI workflows, with opt
| **SF Claude Code** | Send text and images to Claude through the Claude Code CLI on your own machine, using your existing Claude login instead of an API key. Image slots appear as you fill them. |
| **SF Claude Asset Director** | Point Claude at an asset library and it chooses which references suit the brief and writes the image prompt to go with them. Outputs an IMAGE batch plus the prompt, ready to wire into any generation node. |
| **SF Claude Library Builder** | Point Claude at a folder of game assets and it writes a searchable manifest describing each one. Can also reorganise and rename the files. Feeds **SF Claude Asset Director**. |
| **SF Claude Prompt Coach** | Improves the system prompt behind a prompt-writing node. Give it that system prompt, the prompt it wrote, the references and the result, plus your feedback, and it returns a revised system prompt. Remembers earlier rounds — see below. |
#### The asset library workflow
@@ -210,6 +211,37 @@ you're feeding.
Requires [Claude Code](https://claude.com/claude-code) installed and signed in, same as **SF Claude Code**.
#### Using SF Claude Prompt Coach
For tuning the system prompt of a prompt-writing node such as **SF Claude Asset Director** or
**SF Claude Code**. Each run is one round:
1. Connect the **system prompt** you're tuning and the **generated prompt** it produced.
2. Connect the **result** and the **references** to the `image_N` slots (a new slot appears as you
fill one) and say what each is in **image_notes** — `image 1: the result from Nano Banana`,
`image 2: style reference`. Claude refers to images by the same numbers, so you can too in
**feedback**: *"image 1 lost the thick outlines from image 2"*.
3. Write your **feedback**. Claude treats it as the most important input.
4. Run. **system_prompt** is the revised version; **analysis** says what Claude saw, the root
causes, and what it changed. Feed the revised prompt back into your prompt writer, generate
again, and run the coach again with the new result.
**Memory.** Runs with the same **session_name** share one Claude Code session, so Claude remembers
earlier rounds — what it changed, what you said, what got better or worse. Change the name to
start fresh. Everything is also kept in `output/sf_prompt_coach/<session_name>/`: `log.md` (every
round's inputs, analysis and revised prompt), `system_prompt_v1.md`, `v2`… and each round's images.
The log never fades, unlike a long session, and Claude reads it when it needs older detail. To keep
talking to it outside ComfyUI, run `claude --resume <session_id>` from that folder.
**Nothing hidden.** The node's own instructions are the **coach_system_prompt** text box, sent as
Claude's whole system prompt; everything else Claude sees is the message built from your inputs,
which is also written to the log. Edit the text in place, or connect your own text node to that
input to override it, but keep the `<analysis>` / `<system_prompt>` reply format the node reads.
Unless **image_notes** say otherwise, the built-in instructions treat image 1 as the generated
result and images 2 and up as the references.
Runs on your Claude Code login like **SF Claude Code**; `model` defaults to Opus.
#### Using SF Claude Code
This node shells out to the **Claude Code CLI**, so it bills against your Claude
@@ -262,10 +294,23 @@ Image and video generation nodes powered by the [WaveSpeed AI](https://wavespeed
#### Setup
Connect an **SF WaveSpeed Client** node (enter your WaveSpeed API key) to any WaveSpeed generation node.
#### Cost badge
The newer nodes show an estimated cost per run in dollars in their header, e.g. `~$0.04/run`,
and update it as you change the settings — model, resolution, quality, duration, add-ons, the
number of connected reference images and `n`. The prices come from WaveSpeed's model pages
(`web/js/sf_wavespeed_pricing.js`); WaveSpeed notes those can lag, and the charge on the task is
what you actually pay. A reference input carrying a batch counts as one image, so multi-image
edits can be slightly underestimated.
Nodes marked **(Legacy)** are older nodes kept so existing workflows still load; use the node
named without it for new work.
#### Video Generation
| Node | Description |
|---|---|
| **SF WaveSpeed Seedance 2.5** | ByteDance Seedance 2.5 with native audio: text-to-video, image-to-video from a start frame and optional end frame, or reference-to-video from up to 30 reference images. In the prompt, *"the character from image 1 enters the bar"* means the image in slot `image_1` — the node turns those mentions into Seedance's `@Image N` tags. 480p–4k, 4–30 s, billed per second. Outputs a `VIDEO` plus its URL. |
| **SF WaveSpeed VEO 3.1 Text to Video** | Google VEO 3.1 text-to-video generation. |
| **SF WaveSpeed VEO 3.1 Image to Video** | Google VEO 3.1 image-to-video animation. |
| **SF WaveSpeed VEO 3.1 Reference to Video** | VEO 3.1 video generation using a reference image. |
@@ -288,15 +333,16 @@ 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. |
| **SF WaveSpeed Nano Banana Pro Text to Image Multi** | Nano Banana Pro with multiple inputs. |
| **SF WaveSpeed Nano Banana Pro Text to Image Ultra** | Nano Banana Pro Ultra quality variant. |
| **SF WaveSpeed Nano Banana Pro Edit** | Edit images with Nano Banana Pro. |
| **SF WaveSpeed Nano Banana Pro Edit Multi** | Nano Banana Pro edit with multiple inputs. |
| **SF WaveSpeed Nano Banana Pro Edit Ultra** | Nano Banana Pro edit Ultra quality variant. |
| **SF WaveSpeed Nano Banana** | Google's Nano Banana 2.1, Nano Banana 2 and Nano Banana Pro through WaveSpeed, generate and edit in one node, built to match ComfyUI's *Nano Banana Pro (Google Gemini Image)* partner node — see below. |
| **SF WaveSpeed Seedream 5.0** | ByteDance Seedream 5.0 Pro or Flash, generate and edit in one node. Up to 10 references, up to 2K. Flash is a flat $0.027 at any resolution. |
| **SF WaveSpeed Nano Banana Text to Image (Legacy)** | Nano Banana text-to-image generation. |
| **SF WaveSpeed Nano Banana Edit (Legacy)** | Edit images using the Nano Banana model. |
| **SF WaveSpeed Nano Banana Pro Text to Image (Legacy)** | Nano Banana Pro text-to-image. |
| **SF WaveSpeed Nano Banana Pro Text to Image Multi (Legacy)** | Nano Banana Pro with multiple inputs. |
| **SF WaveSpeed Nano Banana Pro Text to Image Ultra (Legacy)** | Nano Banana Pro Ultra quality variant. |
| **SF WaveSpeed Nano Banana Pro Edit (Legacy)** | Edit images with Nano Banana Pro. |
| **SF WaveSpeed Nano Banana Pro Edit Multi (Legacy)** | Nano Banana Pro edit with multiple inputs. |
| **SF WaveSpeed Nano Banana Pro Edit Ultra (Legacy)** | Nano Banana Pro edit Ultra quality variant. |
| **SF WaveSpeed Qwen Text to Image** | Qwen text-to-image generation. |
| **SF WaveSpeed Qwen Edit** | Edit images using Qwen. |
| **SF WaveSpeed Qwen Edit Plus** | Qwen Edit with extended capabilities. |
@@ -315,8 +361,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**, **SF WaveSpeed Seedream 5.0**,
**SF WaveSpeed Seedance 2.5**, and the Tripo P2, Tripo H3.1, Rodin 2.5 and Meshy 7.1 3D nodes 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.
@@ -326,17 +372,24 @@ depend on each other overlap; WaveSpeed may also cap how many jobs one account r
The older SF WaveSpeed nodes still run one at a time.
#### Using SF WaveSpeed Nano Banana Pro
#### Using SF WaveSpeed Nano Banana
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.
Same controls as the partner node — prompt, model, aspect ratio, resolution, and an optional
`images` input that switches it to editing (batch up to 14 references with **Batch Images**) —
plus Nano Banana 2.1 and a few WaveSpeed extras. It bills your WaveSpeed key instead of ComfyUI
credits. (Its internal id is still `SFWaveSpeedNanoBananaPro`, so older workflows keep loading.)
| Partner model | WaveSpeed model |
| Model | WaveSpeed model |
|---|---|
| `Nano Banana 2.1` | `google/nano-banana-2.1` — the default: ranks above Pro on the public leaderboards at about a third of the price |
| `gemini-3-pro-image-preview` | `google/nano-banana-pro` |
| `Nano Banana 2 (Gemini 3.1 Flash Image)` | `google/nano-banana-2` |
Extras: `n` sends several requests at once (replacing the old *Multi* nodes); **8K** on Pro runs
on WaveSpeed's *ultra* endpoint (replacing the old *Ultra* nodes); the 1:4, 4:1, 1:8 and 8:1 aspect
ratios work with 2.1 only; and **web / image search** (advanced inputs, paid add-ons) work with 2
and 2.1. Combinations a model doesn't support stop with an error instead of being sent.
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
@@ -369,11 +422,21 @@ 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. |
| **SF WaveSpeed Rodin 2.5 Image to 3D** | Hyper3D Rodin Gen-2.5 from 1–5 views. Quad budgets of 4K/8K/18K/50K faces or triangle budgets up to 500K, PBR or shaded materials, delighting, T/A-pose, and the HighPack add-on for 4K textures. Defaults to 18K quads in FBX. Same outputs as the Tripo nodes. |
| **SF WaveSpeed Meshy 7.1 Image to 3D** | Meshy 7.1 with standard, low-poly or smart-topology meshes, quad or triangle, a 100–300,000 polygon target, PBR, an optional texture image or prompt — and humanoid auto-rigging, which also returns walk and run cycles, plus an optional animation preset. |
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.
+13 -3
View File
@@ -51,8 +51,13 @@ def save_images(image_inputs, directory):
return names
# Sent when the prompt box is empty: the system prompt and images carry the request.
EMPTY_PROMPT_REQUEST = "Carry out the task described in your system prompt."
def build_prompt(prompt, image_names):
"""Prefixes the request with instructions to read the images off disk."""
prompt = prompt.strip() or EMPTY_PROMPT_REQUEST
if not image_names:
return prompt
@@ -86,7 +91,10 @@ class SFClaudeCode:
{
"multiline": True,
"default": "",
"tooltip": "What you want Claude to do.",
"tooltip": (
"What you want Claude to do. Optional when the system prompt already "
"describes the task."
),
},
),
"system_prompt": (
@@ -146,12 +154,14 @@ using your existing Claude login — no API key needed.
Requires Claude Code to be installed and signed in on whichever computer runs
ComfyUI. Connect an image and a further image slot appears automatically.
**prompt** can stay empty when **system_prompt** already describes the task.
Change **seed** to force a fresh reply; otherwise ComfyUI reuses the cached one.
"""
def run(self, prompt, system_prompt, model, timeout_seconds, seed, **kwargs):
if not prompt.strip():
raise ValueError("prompt is empty — give Claude something to do.")
if not prompt.strip() and not system_prompt.strip():
raise ValueError("prompt and system_prompt are both empty — give Claude something to do.")
binary = find_claude_binary()
if not binary:
+328
View File
@@ -0,0 +1,328 @@
# ABOUTME: Claude reviews a system prompt against what it produced (prompt, references, result, feedback)
# ABOUTME: and returns an improved system prompt; remembers earlier rounds through a Claude Code session plus a log
import asyncio
import datetime
import json
import os
import re
import shutil
import subprocess
from comfy_api.latest import IO
from .sf_claude_code import MODEL_CHOICES, find_claude_binary
from .vertexai_utils import tensor_to_pil
MAX_IMAGES = 16
COACH_DIR = "sf_prompt_coach"
LOG_NAME = "log.md"
SESSION_FILE = "session.json"
DEFAULT_COACH_PROMPT = """You are a prompt coach for a game-art image pipeline. You improve one thing: the system prompt of the LLM that writes image prompts (the "prompt writer"). That system prompt is the only lever the artist edits between runs, so every finding you make ends either as a change to it or as a clear statement that the cause lies elsewhere.
## The pipeline you are reviewing
The prompt writer receives its system prompt plus a brief, and sometimes an asset library. It writes a prompt for an image model (GPT Image, Nano Banana, Seedream, Flux or similar) and, in some workflows, picks reference images from the library to send along with it. The image model then produces the result. The artist wants the result to match the references: their art style always, the character's likeness when a character is involved, and whatever the brief asks for. In replication workflows the "reference" is a single source image the result should copy as closely as possible.
## What each message contains
Each message is one iteration: the system prompt under test, the brief, the prompt the writer generated, the image model used, numbered images, the artist's notes on those images, and the artist's feedback. Images are files in your working directory; read every listed image with the Read tool before forming any opinion, because the whole review rests on what is actually in them.
Unless the artist's notes say otherwise, image 1 is the generated result and images 2 and up are the references or source images the prompt writer used. The number of references varies from run to run. Refer to images by these numbers so the artist can follow you. If the notes assign different roles, follow the notes.
## How to look at the images
Compare the result with the references along the same axes, in this order, because earlier axes dominate how an image reads and a later fix cannot rescue an earlier miss:
1. Medium and rendering: 2D painted, cel-shaded, vector, pixel art, 3D render, photo. A wrong medium is the most damaging miss.
2. Structure and composition: overall silhouette and massing, relative sizes of the main parts, camera angle, crop, framing, aspect ratio, how much of the frame the subject fills.
3. Style decomposed into measurable parts: line quality (weight, variation, open or closed, clean or wobbly), shape language, shading method (flat, cel bands, soft airbrush, painterly), texture, level of detail, edge treatment, and background treatment.
4. Colour: dominant palette, accents, saturation, value range, temperature.
5. Likeness, when a character is involved: face shape, eyes, hair (colour, style, length), build and proportions in head-heights, costume and its palette, distinguishing features, accessories.
6. Subject details: counts, named parts, small features, any text.
Separate what you can see from what you infer, and mark inferences as such. Be concrete about position and direction ("outline weight roughly halves", "light from frame left", "subject fills 60% of the width rather than 90%"); vague words like "style is off" give the prompt writer nothing to act on. Note what the result gets right as well, so a later revision does not break it.
## How to diagnose
Read the generated prompt next to the images and decide which layer each problem belongs to:
- The system prompt under test: a missing rule, an ambiguous or conflicting instruction, wrong priorities, a vocabulary gap (it never asks the writer to name line quality, say), an example the writer copies too literally, or a length and emphasis problem that buries important details.
- The prompt writer ignoring a rule that is already clear. Consider whether the rule is buried, contradicted elsewhere, or stated without a reason.
- Reference selection or delivery: the wrong references picked, the right ones not sent, or the prompt not telling the image model what each reference is for.
- The image model's own limits or defaults (for example a pull towards clean vector outlines or towards 3D gloss). These can often be countered with explicit wording, but sometimes no system prompt change will fix them; say so plainly when that is the case.
Trace each problem from the result back through the generated prompt to the system prompt. "The result has thin uniform lines" is a symptom. "The system prompt asks for 'crisp' line work and never asks the writer to describe line weight variation" is a root cause.
The artist's feedback is the most important input. Short or blunt feedback still carries a precise intent; work out what they mean. If what you see disagrees with the feedback, say so briefly, then follow the artist.
## How to revise the system prompt
Fix root causes with general principles. The system prompt has to work for every future brief and asset, so turn this run's problem into a rule about the class of problem ("describe line weight and how it varies") rather than this asset ("the boots need thick outlines"). Many narrow special cases turn a prompt into a maze the writer navigates badly; if the prompt has already become one, consolidate it into a few clear principles.
Make the smallest set of changes that addresses the main causes, usually one to three per iteration, so the next result shows which change helped. Keep everything that works, and keep the prompt's structure and voice.
Give each rule its reason in a few words; a model follows a rule it understands far more reliably than one shouted at it, so prefer explained instructions over capitals and "NEVER". State the desired outcome in positive terms, and keep a prohibition only for a failure that has actually appeared. Say each thing once. Write the revised prompt as if it had always been this way: no "now", "instead" or "no longer", since the prompt writer never saw the earlier version.
Good image prompts usually lead with the medium and style, then structure and composition, then subject details, then lighting and colour, and close with a short line of identity-critical features that must survive. They match the reference's level of detail rather than adding to it, and they include nothing that is not in the references. GPT Image and Nano Banana respond best to clear natural-language descriptions with explicit constraints and positive phrasing, and when references are attached the prompt should say what each one is for ("use image 2 for the art style, image 3 for the character's face"). Where a model cannot take references, the prompt must carry the full style and likeness description in words. Encode such guidance in the system prompt only where this run shows it is missing.
This conversation continues across iterations, and log.md in your working directory holds every earlier round. Before changing anything, check whether a similar change was tried before and what happened: keep changes that helped, roll back or soften changes that made results worse (re-add a removed rule in its simplest form rather than restoring a long version), and do not repeat a fix that already failed. If the result is good and the feedback raises nothing the system prompt can fix, return it unchanged and say why; an unchanged prompt is a valid answer.
## Reply format
Reply in exactly this format and nothing else:
<analysis>
Images: what you took each image to be, by number.
Matches: what the result gets right.
Problems: each mismatch, ordered by impact, with the image numbers and concrete evidence.
Root causes: for each problem, the layer it belongs to and the exact cause, quoting the system prompt where relevant.
Changes: each edit to the system prompt, with what was wrong, what you changed, why, and your confidence (high, medium or low). Note anything you deliberately left alone, and any cause that lies outside the system prompt.
Check next time: the specific things the artist should look at in the next result to tell whether the changes worked.
</analysis>
<system_prompt>
The complete revised system prompt, ready to paste. No commentary inside this block.
</system_prompt>"""
def slugify(name):
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", name.strip()).strip("-.")
return slug or "prompt-coach"
def coach_root():
try:
import folder_paths
base = folder_paths.get_output_directory()
except ImportError:
base = os.getcwd()
return os.path.join(base, COACH_DIR)
def save_images(images, directory, prefix):
"""Saves every frame of every connected slot. Returns (slot number, relative path) pairs.
Slot N becomes image_N.png; a batch on slot N becomes image_N_1.png, image_N_2.png..."""
saved = []
for slot in range(1, MAX_IMAGES + 1):
tensor = images.get(f"image_{slot}")
if tensor is None:
continue
batch = tensor if tensor.dim() == 4 else tensor.unsqueeze(0)
for frame in range(batch.shape[0]):
pil = tensor_to_pil(batch[frame])
if pil is None:
continue
name = f"image_{slot}.png" if batch.shape[0] == 1 else f"image_{slot}_{frame + 1}.png"
pil.convert("RGB").save(os.path.join(directory, name))
saved.append((slot, f"{prefix}/{name}"))
return saved
def build_message(iteration, system_prompt, brief, generated_prompt, image_model, saved, image_notes,
feedback, has_history):
"""The user message for one iteration. Everything Claude sees is in here or in the coach prompt."""
parts = [f"# Iteration {iteration}"]
parts.append(f"## System prompt under test\n\n{system_prompt.strip()}")
if brief.strip():
parts.append(f"## Brief / inputs given to the prompt writer\n\n{brief.strip()}")
parts.append(f"## Prompt it generated\n\n{generated_prompt.strip() or '(not provided)'}")
if image_model.strip():
parts.append(f"## Image model the prompt was sent to\n\n{image_model.strip()}")
if saved:
listing = "\n".join(f"- image {slot}: `{path}`" for slot, path in saved)
parts.append(
"## Images\n\nRead each of these files before answering. The number is the image's "
f"slot on the node, which is how the artist refers to it.\n\n{listing}"
)
parts.append(
"## The artist's notes on the images\n\n"
+ (image_notes.strip() or "(none given; work out from the content which image is the result "
"and which are references, and say what you assumed)")
)
else:
parts.append("## Images\n\nNo images were connected this iteration.")
parts.append(f"## The artist's feedback\n\n{feedback.strip() or '(no feedback given this time)'}")
if has_history:
parts.append(f"Earlier iterations are recorded in `{LOG_NAME}`.")
return "\n\n".join(parts)
def parse_reply(text):
def block(tag):
match = re.search(rf"<{tag}>\s*(.*?)\s*</{tag}>", text, re.DOTALL)
return match.group(1).strip() if match else None
new_prompt = block("system_prompt")
if not new_prompt:
raise RuntimeError(
"Claude's reply had no <system_prompt> block, so there is no revised prompt to output. "
f"Check that coach_system_prompt still asks for that format. Reply began: {text[:400]}"
)
return new_prompt, block("analysis") or ""
def append_log(path, iteration, session_name, message, analysis, new_prompt):
stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
if not os.path.exists(path):
with open(path, "w", encoding="utf-8") as f:
f.write(f"# Prompt coach log: {session_name}\n\n")
with open(path, "a", encoding="utf-8") as f:
f.write(
f"\n---\n\n<!-- iteration {iteration}, {stamp} -->\n\n{message}\n\n"
f"## Claude's analysis\n\n{analysis}\n\n"
f"## Revised system prompt (saved as system_prompt_v{iteration}.md)\n\n{new_prompt}\n"
)
def run_claude(command, workdir, timeout_seconds):
try:
return subprocess.run(command, cwd=workdir, capture_output=True, text=True,
timeout=timeout_seconds, stdin=subprocess.DEVNULL)
except subprocess.TimeoutExpired:
raise RuntimeError(f"Claude did not reply within {timeout_seconds}s. Raise timeout_seconds.")
class SFClaudePromptCoach(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SFClaudePromptCoach",
display_name="SF Claude Prompt Coach",
category="Stillfront/LLM",
description=(
"Shows Claude a system prompt together with what it produced — the generated prompt, the "
"reference images, the result and your feedback — and returns an improved system prompt. "
"Runs on your Claude Code login and remembers earlier rounds of the same session."
),
# Optional inputs render after required ones, so everything is required (with empty
# defaults) to keep this top-to-bottom order.
inputs=[
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="image 1: the generated result. image 2 and up: the references. A batch on one "
"slot becomes image_N_1, image_N_2, ...",
),
IO.String.Input("system_prompt", multiline=True, default="",
tooltip="The system prompt you are tuning (the one the prompt-writing node used)."),
IO.String.Input("generated_prompt", multiline=True, default="",
tooltip="The prompt that system prompt produced and sent to the image model."),
IO.String.Input("feedback", multiline=True, default="",
placeholder="What's wrong with the result, in your words. Refer to images by "
"number, e.g. 'image 1 lost the thick outlines from image 2'.",
tooltip="Your feedback. Claude treats it as the most important input."),
IO.String.Input("image_notes", multiline=True, default="",
placeholder="Optional. By default image 1 is the result and image 2+ are references.\n"
"image 2: style reference\nimage 3: character likeness reference",
tooltip="What each connected image is. The numbers match the image_N slots."),
IO.String.Input("brief", multiline=True, default="",
placeholder="Optional: the brief the prompt writer was given (not instructions "
"for the coach).",
tooltip="The brief or other text inputs the prompt writer was given."),
IO.String.Input("coach_system_prompt", multiline=True, default=DEFAULT_COACH_PROMPT,
tooltip="This node's own instructions to Claude, in full. Edit it here, or connect "
"your own text to override it; keep the <analysis>/<system_prompt> reply format."),
IO.String.Input("session_name", default="prompt-coach",
tooltip="Rounds with the same name share Claude's memory and one log. "
"Change the name to start a fresh session."),
IO.Combo.Input("model", options=MODEL_CHOICES, default="opus",
tooltip="Claude model. 'default' uses whatever Claude Code is set to."),
IO.Int.Input("timeout_seconds", default=900, min=60, max=3600, step=30),
IO.Int.Input("seed", default=0, min=0, max=0xFFFFFFFF, control_after_generate=True,
tooltip="Not sent to Claude. Change it to force a new round with identical inputs."),
IO.String.Input("image_model", default="",
tooltip="Optional: which image model got the prompt, e.g. 'Nano Banana 2.1'."),
],
outputs=[
IO.String.Output("system_prompt", tooltip="The revised system prompt."),
IO.String.Output("analysis", tooltip="What Claude saw, the root causes, and what it changed."),
IO.String.Output("session_id", tooltip="Claude Code session id; `claude --resume <id>` "
"continues it interactively from the log folder."),
IO.String.Output("log_file", tooltip="Path of this session's log."),
],
)
@classmethod
async def execute(cls, system_prompt, generated_prompt, feedback, image_notes, images, session_name,
brief="", image_model="", model="opus", coach_system_prompt=DEFAULT_COACH_PROMPT,
timeout_seconds=900, seed=0):
if not system_prompt.strip():
raise ValueError("system_prompt is empty — connect the system prompt you want to improve.")
if not coach_system_prompt.strip():
raise ValueError("coach_system_prompt is empty — it holds this node's instructions to Claude.")
binary = find_claude_binary()
if not binary:
raise RuntimeError("Claude Code CLI not found. Install it from https://claude.com/claude-code, "
"sign in, then restart ComfyUI.")
session_name = slugify(session_name)
workdir = os.path.join(coach_root(), session_name)
os.makedirs(workdir, exist_ok=True)
session_path = os.path.join(workdir, SESSION_FILE)
log_path = os.path.join(workdir, LOG_NAME)
state = {}
if os.path.exists(session_path):
with open(session_path, encoding="utf-8") as f:
state = json.load(f)
iteration = state.get("iterations", 0) + 1
iteration_dir = f"iteration_{iteration:03d}"
os.makedirs(os.path.join(workdir, iteration_dir), exist_ok=True)
saved = save_images(images or {}, os.path.join(workdir, iteration_dir), iteration_dir)
message = build_message(iteration, system_prompt, brief, generated_prompt, image_model, saved,
image_notes, feedback, os.path.exists(log_path))
def command(resume_id):
cmd = [binary, "-p", message, "--output-format", "json",
"--system-prompt", coach_system_prompt, "--allowedTools", "Read"]
if model != "default":
cmd += ["--model", model]
if resume_id:
cmd += ["--resume", resume_id]
return cmd
resume_id = state.get("session_id")
print(f"[SF Prompt Coach] '{session_name}' iteration {iteration}, {len(saved)} image(s), "
f"{'resuming ' + resume_id if resume_id else 'new session'}.")
completed = await asyncio.to_thread(run_claude, command(resume_id), workdir, timeout_seconds)
note = ""
if resume_id and completed.returncode != 0 and "No conversation found" in (completed.stderr + completed.stdout):
# The Claude Code session was deleted; start a new one, which can still read the log.
note = "(The previous Claude session could not be found, so this round started a new one.)\n\n"
print("[SF Prompt Coach] Previous session not found; starting a new one.")
completed = await asyncio.to_thread(run_claude, command(None), workdir, timeout_seconds)
if completed.returncode != 0:
detail = (completed.stderr or completed.stdout or "").strip()[:500]
raise RuntimeError(f"Claude Code exited with code {completed.returncode}. {detail}")
try:
payload = json.loads(completed.stdout)
except json.JSONDecodeError:
raise RuntimeError(f"Could not parse Claude's response: {completed.stdout.strip()[:500]}")
if payload.get("is_error"):
raise RuntimeError(f"Claude reported an error: {payload.get('result', 'unknown')}")
new_prompt, analysis = parse_reply(payload.get("result", ""))
analysis = note + analysis
session_id = payload.get("session_id", "")
with open(os.path.join(workdir, f"system_prompt_v{iteration}.md"), "w", encoding="utf-8") as f:
f.write(new_prompt + "\n")
append_log(log_path, iteration, session_name, message, analysis, new_prompt)
with open(session_path, "w", encoding="utf-8") as f:
json.dump({"session_id": session_id, "iterations": iteration}, f, indent=2)
print(f"[SF Prompt Coach] Iteration {iteration} done in {payload.get('duration_ms', '?')}ms -> {log_path}")
return IO.NodeOutput(new_prompt, analysis, session_id, log_path)
NODE_CLASS_MAPPINGS = {"SFClaudePromptCoach": SFClaudePromptCoach}
NODE_DISPLAY_NAME_MAPPINGS = {"SFClaudePromptCoach": "SF Claude Prompt Coach"}
+144 -5
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"}
@@ -32,10 +42,39 @@ def check_interrupted():
mm.throw_exception_if_processing_interrupted()
async def call(client, fn, *args):
# Network failures worth retrying: a dropped or reset connection, or a request
# that timed out. Only used for requests that are safe to repeat (polling,
# uploads, downloads), never for submitting a task, which WaveSpeed bills.
TRANSIENT_ERRORS = (requests.exceptions.ConnectionError, requests.exceptions.Timeout,
requests.exceptions.ChunkedEncodingError)
RETRY_DELAYS = (2, 4, 8, 15, 30)
async def with_retries(fn, *args, label="WaveSpeed"):
"""Run a blocking, repeatable request in a worker thread, retrying network
hiccups with growing pauses before giving up."""
for attempt, delay in enumerate(RETRY_DELAYS + (None,), start=1):
try:
return await asyncio.to_thread(fn, *args)
except TRANSIENT_ERRORS as e:
if delay is None:
raise RuntimeError(
f"{label}: lost the connection to WaveSpeed {attempt} times in a row ({e}). "
f"Check your internet connection; any task already submitted may still finish "
f"on WaveSpeed's side."
) from e
print(f"[SF {label}] Network error ({type(e).__name__}); retrying in {delay}s "
f"({attempt}/{len(RETRY_DELAYS)}).")
check_interrupted()
await asyncio.sleep(delay)
async def call(client, fn, *args, retry=False):
"""Run a blocking API call in a worker thread, turning a 401 into an error
that says which key was used."""
that says which key was used. `retry` is only for calls that are safe to repeat."""
try:
if retry:
return await with_retries(fn, *args)
return await asyncio.to_thread(fn, *args)
except Exception as e:
if "401" in str(e) or "Unauthorized" in str(e):
@@ -54,7 +93,7 @@ async def wait_for_result(api, task_id, timeout_seconds, label, interval=2):
started = loop.time()
while True:
check_interrupted()
result = await asyncio.to_thread(api.get, RESULT_PATH.format(id=task_id))
result = await with_retries(api.get, RESULT_PATH.format(id=task_id), label=label)
status = result.get("status")
if status == "completed":
return result
@@ -102,7 +141,8 @@ def downscale(image, max_pixels=MAX_UPLOAD_PIXELS):
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)))
return list(await asyncio.gather(*(call(client, api.upload_file, downscale(img), retry=True)
for img in images)))
async def fetch_images(urls, timeout=120):
@@ -113,7 +153,7 @@ async def fetch_images(urls, timeout=120):
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)))
return list(await asyncio.gather(*(with_retries(get, url, label="download") for url in urls)))
def _has_alpha(image):
@@ -133,3 +173,102 @@ 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, prefer=None):
"""The model file URL out of a result's `outputs`, which may be a list or a string.
`prefer` (e.g. ".fbx") wins when a result carries several model files."""
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 prefer and urlparse(url).path.lower().endswith(prefer):
return url
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 submit_and_wait(api, client, endpoint, payload, timeout_seconds, label, interval=3):
"""Submit one prediction and wait for it. Returns (task_id, result)."""
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 {label}] Submitted {task_id}.")
return task_id, await wait_for_result(api, task_id, timeout_seconds, label, interval=interval)
async def save_3d_model(url, name, default_ext=".glb"):
"""Download a model into output/3d as `name` plus the URL's extension.
Returns (glb, fbx, path); the File3D goes in whichever slot matches the file."""
ext = os.path.splitext(urlparse(url).path)[1].lower() or default_ext
path = os.path.join(output_dir(), f"{name}{ext}")
def download():
response = requests.get(url, timeout=300)
response.raise_for_status()
with open(path, "wb") as f:
f.write(response.content)
await with_retries(download, label="download")
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
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], retry=True)
started = time.time()
task_id, result = await submit_and_wait(api, client, endpoint, build_payload(image_url), timeout_seconds, label)
url = pick_model_url(result.get("outputs"))
glb, fbx, path = await save_3d_model(url, f"{file_prefix}_{task_id}", ".fbx" if quad else ".glb")
print(f"[SF {label}] Done in {time.time() - started:.0f}s -> {path}")
return (glb, fbx, path, url)
# --- Video ---------------------------------------------------------------------
async def fetch_video(url, timeout=600):
"""Download an output video into memory as a ComfyUI VIDEO."""
from comfy_api.latest import InputImpl
def get():
response = requests.get(url, timeout=timeout)
response.raise_for_status()
return io.BytesIO(response.content)
return InputImpl.VideoFromFile(await with_retries(get, label="download"))
+147
View File
@@ -0,0 +1,147 @@
# ABOUTME: Meshy 7.1 image-to-3D through WaveSpeed: topology control plus optional humanoid auto-rig and animation
# ABOUTME: Returns the model and, when rigged, the rigged/walk/run/preset GLBs; async, so it runs in parallel
import asyncio
import time
from comfy_api.latest import IO
from .wavespeed_api.client import WaveSpeedClient
from .wavespeed_api.utils import tensor2images
from .wavespeed_api.v3 import call, save_3d_model, submit_and_wait
ENDPOINT = "/api/v3/meshy/v7.1/image-to-3d"
POSE_NONE = "none"
def build_payload(image_url, model_type, topology, target_polycount, symmetry_mode, should_remesh, should_texture,
enable_pbr, geometry_resolution, pose_mode, texture_prompt, texture_image_url, enable_rigging,
rigging_height_meters, enable_animation, animation_action_id):
if enable_animation and not enable_rigging:
raise ValueError("enable_animation needs enable_rigging on.")
payload = {
"image": image_url,
"model_type": model_type,
"topology": topology,
"target_polycount": target_polycount,
"symmetry_mode": symmetry_mode,
"should_remesh": should_remesh,
"should_texture": should_texture,
"enable_pbr": bool(enable_pbr and should_texture),
"enable_rigging": enable_rigging,
"enable_animation": enable_animation,
}
# Meshy ignores geometry_resolution for lowpoly and smart-topology.
if model_type == "standard":
payload["geometry_resolution"] = geometry_resolution
if pose_mode != POSE_NONE:
payload["pose_mode"] = pose_mode
if should_texture and texture_prompt.strip():
payload["texture_prompt"] = texture_prompt.strip()
if should_texture and texture_image_url:
payload["texture_image"] = texture_image_url
if enable_rigging:
payload["rigging_height_meters"] = rigging_height_meters
if enable_animation:
payload["animation_action_id"] = animation_action_id
return payload
class SFWaveSpeedMeshyV71ImageTo3D(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SFWaveSpeedMeshyV71ImageTo3D",
display_name="SF WaveSpeed Meshy 7.1 Image to 3D",
category="Stillfront/WaveSpeed",
description=(
"Image-to-3D with Meshy 7.1 through WaveSpeed. Smart topology or low-poly meshes, "
"and optional auto-rigging and animation for humanoid characters."
),
is_output_node=True,
inputs=[
IO.Custom("WAVESPEED_AI_API_CLIENT").Input("client"),
IO.Image.Input("image", tooltip="Reference image. Only the first frame of a batch is used."),
IO.Combo.Input("model_type", options=["standard", "lowpoly", "smart-topology"], default="standard",
tooltip="smart-topology: clean, artist-style edge flow. lowpoly: stylised low-poly."),
IO.Combo.Input("topology", options=["triangle", "quad"], default="triangle"),
IO.Int.Input("target_polycount", default=30000, min=100, max=300000, step=100,
tooltip="Target polygon count."),
IO.Boolean.Input("should_texture", default=True,
tooltip="Off gives an untextured mesh for $0.96 instead of $1.44."),
IO.Boolean.Input("enable_pbr", default=True, tooltip="Metallic, roughness and normal maps."),
IO.Combo.Input("pose_mode", options=[POSE_NONE, "a-pose", "t-pose"], default=POSE_NONE,
tooltip="Force a humanoid pose. Leave 'none' for props."),
IO.Boolean.Input("enable_rigging", default=False,
tooltip="Auto-rig a humanoid character (+$0.24). Also returns walk and run cycles."),
IO.Boolean.Input("enable_animation", default=False,
tooltip="Apply the animation preset below (+$0.144). Needs rigging."),
IO.Image.Input("texture_image", optional=True, tooltip="Optional image to guide the texture."),
IO.String.Input("texture_prompt", multiline=True, default="", advanced=True,
tooltip="Optional texture description, up to 600 characters."),
IO.Combo.Input("geometry_resolution", options=["standard", "2k", "4k"], default="standard",
advanced=True, tooltip="Standard model type only. 2k +$0.24, 4k +$0.48."),
IO.Combo.Input("symmetry_mode", options=["auto", "on", "off"], default="auto", advanced=True),
IO.Boolean.Input("should_remesh", default=True, advanced=True,
tooltip="Remesh for cleaner topology."),
IO.Float.Input("rigging_height_meters", default=1.7, min=0.1, max=20.0, step=0.05, advanced=True,
tooltip="Approximate character height, used for rigging."),
IO.Int.Input("animation_action_id", default=92, min=0, max=696, advanced=True,
tooltip="Meshy animation preset id (0-696)."),
IO.Int.Input("seed", default=0, min=0, max=2147483647, control_after_generate=True, advanced=True,
tooltip="Not sent to WaveSpeed. Change it to force a re-run."),
IO.Int.Input("timeout_seconds", default=1200, min=60, max=3600, step=30, advanced=True),
],
outputs=[
IO.File3DGLB.Output("GLB", tooltip="The generated model."),
IO.File3DGLB.Output("rigged", tooltip="Rigged character, when rigging is on."),
IO.File3DGLB.Output("walking", tooltip="Walk cycle, when rigging is on."),
IO.File3DGLB.Output("running", tooltip="Run cycle, when rigging is on."),
IO.File3DGLB.Output("animation", tooltip="The animation preset, when animation is on."),
IO.String.Output("model_file", tooltip="Absolute path of the model in output/3d."),
IO.String.Output("model_url", tooltip="WaveSpeed CDN link to the model."),
],
)
@classmethod
async def execute(cls, client, image, model_type, topology, target_polycount, should_texture, enable_pbr,
pose_mode, enable_rigging, enable_animation, texture_image=None, texture_prompt="",
geometry_resolution="standard", symmetry_mode="auto", should_remesh=True,
rigging_height_meters=1.7, animation_action_id=92, seed=0, timeout_seconds=1200):
if len(texture_prompt) > 600:
raise ValueError(f"texture_prompt is {len(texture_prompt)} characters; Meshy allows 600.")
api = WaveSpeedClient(api_key=client["api_key"])
uploads = [call(client, api.upload_file, tensor2images(image)[0], retry=True)]
if texture_image is not None and should_texture:
uploads.append(call(client, api.upload_file, tensor2images(texture_image)[0], retry=True))
urls = await asyncio.gather(*uploads)
payload = build_payload(
urls[0], model_type, topology, target_polycount, symmetry_mode, should_remesh, should_texture,
enable_pbr, geometry_resolution, pose_mode, texture_prompt, urls[1] if len(urls) > 1 else None,
enable_rigging, rigging_height_meters, enable_animation, animation_action_id,
)
print(f"[SF Meshy 7.1] {model_type}, {topology}, {target_polycount} polys, texture={should_texture}, "
f"rig={enable_rigging}, animation={enable_animation}.")
started = time.time()
task_id, result = await submit_and_wait(api, client, ENDPOINT, payload, timeout_seconds, "Meshy 7.1")
outputs = result.get("outputs") or []
outputs = [u for u in (outputs if isinstance(outputs, list) else [outputs]) if isinstance(u, str)]
if not outputs:
raise RuntimeError(f"Meshy returned no model: {result!r}")
# Documented order: model, then rigged/walking/running with rigging, then the preset with animation.
names = ["model", "rigged", "walking", "running", "animation"]
saved = await asyncio.gather(*(
save_3d_model(url, f"sf_meshy71_{task_id}_{name}") for name, url in zip(names, outputs)
))
models = [glb for glb, _fbx, _path in saved] + [None] * (len(names) - len(saved))
path = saved[0][2]
print(f"[SF Meshy 7.1] Done in {time.time() - started:.0f}s -> {len(saved)} file(s), {path}")
return IO.NodeOutput(*models, path, outputs[0])
NODE_CLASS_MAPPINGS = {"SFWaveSpeedMeshyV71ImageTo3D": SFWaveSpeedMeshyV71ImageTo3D}
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedMeshyV71ImageTo3D": "SF WaveSpeed Meshy 7.1 Image to 3D"}
+1 -1
View File
@@ -145,5 +145,5 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SFWaveSpeedNanoBananaEdit": "SF WaveSpeed Nano Banana Edit"
"SFWaveSpeedNanoBananaEdit": "SF WaveSpeed Nano Banana Edit (Legacy)"
}
+79 -17
View File
@@ -1,39 +1,74 @@
# 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
# ABOUTME: Nano Banana 2.1, 2 and Pro through WaveSpeed, modelled on ComfyUI's "Nano Banana Pro (Google Gemini Image)" node
# ABOUTME: One node for generate and edit across models; 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.
# Display name -> WaveSpeed slug. The node id still says "Pro" because saved
# workflows reference it; the display name does not.
MODELS = {
"Nano Banana 2.1": "google/nano-banana-2.1",
"gemini-3-pro-image-preview": "google/nano-banana-pro",
"Nano Banana 2 (Gemini 3.1 Flash Image)": "google/nano-banana-2",
}
NANO_BANANA_21 = "Nano Banana 2.1"
NANO_BANANA_PRO = "gemini-3-pro-image-preview"
ASPECT_RATIOS = ["auto", "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]
# Extreme ratios only Nano Banana 2.1 accepts.
WIDE_ASPECT_RATIOS = ["1:4", "4:1", "1:8", "8:1"]
# 8K exists only for Pro, through WaveSpeed's separate "ultra" endpoints.
RESOLUTIONS = ["1K", "2K", "4K", "8K"]
MAX_REFERENCES = 14
def build_payload(prompt, aspect_ratio, resolution, image_urls):
def endpoint_for(model, resolution, editing):
mode = "edit" if editing else "text-to-image"
if resolution == "8K":
mode += "-ultra"
return f"/api/v3/{MODELS[model]}/{mode}"
def validate(model, aspect_ratio, resolution, enable_web_search, enable_image_search):
if aspect_ratio in WIDE_ASPECT_RATIOS and model != NANO_BANANA_21:
raise ValueError(f"Aspect ratio {aspect_ratio} is only available with {NANO_BANANA_21}.")
if resolution == "8K" and model != NANO_BANANA_PRO:
raise ValueError(f"8K is only available with {NANO_BANANA_PRO}.")
if (enable_web_search or enable_image_search) and model == NANO_BANANA_PRO:
raise ValueError("Web and image search are only available with Nano Banana 2 and 2.1.")
def build_payload(prompt, aspect_ratio, resolution, image_urls, enable_web_search=False, enable_image_search=False):
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
# Both are paid add-ons, so they are only sent when switched on.
if enable_web_search:
payload["enable_web_search"] = True
if enable_image_search:
payload["enable_image_search"] = True
return payload
class SFWaveSpeedNanoBananaPro(IO.ComfyNode):
"""Nano Banana Pro via WaveSpeed, with the partner node's interface."""
"""Nano Banana 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",
display_name="SF WaveSpeed Nano Banana",
category="Stillfront/WaveSpeed",
description="Generate or edit images with Google's Nano Banana Pro through WaveSpeed.",
description=(
"Generate or edit images with Google's Nano Banana 2.1, 2 or Pro through WaveSpeed. "
"Connect reference images to edit; leave them empty to generate."
),
# New inputs go at the end: saved workflows restore widget values by
# position, so inserting one earlier would shift every value after it.
inputs=[
IO.Custom("WAVESPEED_AI_API_CLIENT").Input("client"),
IO.String.Input(
@@ -55,12 +90,16 @@ class SFWaveSpeedNanoBananaPro(IO.ComfyNode):
),
IO.Combo.Input(
"aspect_ratio",
options=ASPECT_RATIOS,
options=ASPECT_RATIOS + WIDE_ASPECT_RATIOS,
default="auto",
tooltip="If set to 'auto', matches your input image's aspect ratio; "
"with no image, the model chooses.",
f"with no image, the model chooses. {', '.join(WIDE_ASPECT_RATIOS)} need {NANO_BANANA_21}.",
),
IO.Combo.Input(
"resolution",
options=RESOLUTIONS,
tooltip=f"Target output resolution. 8K needs {NANO_BANANA_PRO}.",
),
IO.Combo.Input("resolution", options=["1K", "2K", "4K"], tooltip="Target output resolution."),
IO.Image.Input(
"images",
optional=True,
@@ -68,16 +107,39 @@ class SFWaveSpeedNanoBananaPro(IO.ComfyNode):
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),
IO.Int.Input(
"n",
default=1,
min=1,
max=8,
display_mode=IO.NumberDisplay.number,
tooltip="How many images to generate. Sent as parallel requests, each billed separately.",
),
IO.Boolean.Input(
"enable_web_search",
default=False,
advanced=True,
tooltip="Ground the image in live web results. Paid add-on; Nano Banana 2 and 2.1 only.",
),
IO.Boolean.Input(
"enable_image_search",
default=False,
advanced=True,
tooltip="Ground the image in real reference photos found online. "
"Paid add-on; Nano Banana 2 and 2.1 only.",
),
],
outputs=[IO.Image.Output()],
)
@classmethod
async def execute(cls, client, prompt, model, seed, aspect_ratio, resolution, images=None, timeout_seconds=600):
async def execute(cls, client, prompt, model, seed, aspect_ratio, resolution, images=None,
timeout_seconds=600, n=1, enable_web_search=False, enable_image_search=False):
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}")
validate(model, aspect_ratio, resolution, enable_web_search, enable_image_search)
references = flatten_images([images])
if len(references) > MAX_REFERENCES:
@@ -86,14 +148,14 @@ class SFWaveSpeedNanoBananaPro(IO.ComfyNode):
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}.")
endpoint = endpoint_for(model, resolution, bool(image_urls))
payload = build_payload(prompt, aspect_ratio, resolution, image_urls, enable_web_search, enable_image_search)
print(f"[SF Nano Banana] {MODELS[model]}, "
f"{f'editing {len(image_urls)} image(s)' if image_urls else 'text-to-image'}, {resolution}, n={n}.")
urls = await submit_and_collect(api, client, endpoint, payload, 1, timeout_seconds, "Nano Banana Pro")
urls = await submit_and_collect(api, client, endpoint, payload, n, timeout_seconds, f"Nano Banana ({model})")
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"}
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedNanoBananaPro": "SF WaveSpeed Nano Banana"}
+1 -1
View File
@@ -208,5 +208,5 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SFWaveSpeedNanoBananaProEdit": "SF WaveSpeed Nano Banana Pro Edit"
"SFWaveSpeedNanoBananaProEdit": "SF WaveSpeed Nano Banana Pro Edit (Legacy)"
}
+1 -1
View File
@@ -201,5 +201,5 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SFWaveSpeedNanoBananaProEditMulti": "SF WaveSpeed Nano Banana Pro Edit Multi"
"SFWaveSpeedNanoBananaProEditMulti": "SF WaveSpeed Nano Banana Pro Edit Multi (Legacy)"
}
+1 -1
View File
@@ -208,5 +208,5 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SFWaveSpeedNanoBananaProEditUltra": "SF WaveSpeed Nano Banana Pro Edit Ultra"
"SFWaveSpeedNanoBananaProEditUltra": "SF WaveSpeed Nano Banana Pro Edit Ultra (Legacy)"
}
@@ -137,5 +137,5 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SFWaveSpeedNanoBananaProTextToImage": "SF WaveSpeed Nano Banana Pro Text to Image"
"SFWaveSpeedNanoBananaProTextToImage": "SF WaveSpeed Nano Banana Pro Text to Image (Legacy)"
}
@@ -133,5 +133,5 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SFWaveSpeedNanoBananaProTextToImageMulti": "SF WaveSpeed Nano Banana Pro Text to Image Multi"
"SFWaveSpeedNanoBananaProTextToImageMulti": "SF WaveSpeed Nano Banana Pro Text to Image Multi (Legacy)"
}
@@ -140,5 +140,5 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SFWaveSpeedNanoBananaProTextToImageUltra": "SF WaveSpeed Nano Banana Pro Text to Image Ultra"
"SFWaveSpeedNanoBananaProTextToImageUltra": "SF WaveSpeed Nano Banana Pro Text to Image Ultra (Legacy)"
}
+1 -1
View File
@@ -87,5 +87,5 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SFWaveSpeedNanoBananaTextToImage": "SF WaveSpeed Nano Banana Text to Image"
"SFWaveSpeedNanoBananaTextToImage": "SF WaveSpeed Nano Banana Text to Image (Legacy)"
}
+125
View File
@@ -0,0 +1,125 @@
# ABOUTME: Hyper3D Rodin Gen-2.5 image-to-3D through WaveSpeed, with quad/triangle budgets for game assets
# ABOUTME: Same GLB/FBX/path/url outputs as the Tripo nodes; async, so it runs in parallel
import time
from comfy_api.latest import IO
from .wavespeed_api.client import WaveSpeedClient
from .wavespeed_api.v3 import (
flatten_images,
pick_model_url,
save_3d_model,
submit_and_wait,
upload_images,
)
ENDPOINT = "/api/v3/hyper3d/rodin-v2.5/image-to-3d"
TIERS = ["Gen-2.5-Medium", "Gen-2.5-Extreme-Low", "Gen-2.5-Low", "Gen-2.5-High", "Gen-2.5-Extreme-High"]
MESHES = ["18K Quad", "4K Quad", "8K Quad", "50K Quad",
"2K Triangle", "20K Triangle", "150K Triangle", "500K Triangle"]
MATERIALS = ["PBR", "Shaded", "All", "None"]
TEXTURE_MODE_DEFAULT = "default"
TEXTURE_MODES = [TEXTURE_MODE_DEFAULT, "legacy", "extreme-low", "low", "medium", "high"]
MAX_IMAGES = 5
def build_payload(image_urls, prompt, tier, quality_and_mesh, material, file_format, texture_delight,
hd_texture, ta_pose, high_pack, geometry_instruct_mode, texture_mode, use_original_alpha):
payload = {
"images": image_urls,
"tier": tier,
"quality_and_mesh": quality_and_mesh,
"material": material,
"geometry_file_format": file_format,
"texture_delight": texture_delight,
"hd_texture": hd_texture,
"ta_pose": ta_pose,
"geometry_instruct_mode": geometry_instruct_mode,
"use_original_alpha": use_original_alpha,
}
if prompt.strip():
payload["prompt"] = prompt
if high_pack:
payload["addons"] = "HighPack"
if texture_mode != TEXTURE_MODE_DEFAULT:
payload["texture_mode"] = texture_mode
return payload
class SFWaveSpeedRodinV25ImageTo3D(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SFWaveSpeedRodinV25ImageTo3D",
display_name="SF WaveSpeed Rodin 2.5 Image to 3D",
category="Stillfront/WaveSpeed",
description="Image-to-3D with Hyper3D Rodin Gen-2.5 through WaveSpeed. Takes 1-5 views of one object.",
is_output_node=True,
inputs=[
IO.Custom("WAVESPEED_AI_API_CLIENT").Input("client"),
IO.Image.Input("images", tooltip=f"1-{MAX_IMAGES} views of the same object. "
"Use Batch Images to send several; the first is the main view."),
IO.String.Input("prompt", multiline=True, default="",
tooltip="Optional. Leave empty and Rodin writes its own from the images."),
IO.Combo.Input("quality_and_mesh", options=MESHES, default="18K Quad",
tooltip="Face budget and topology. Quad budgets suit game assets; "
"WaveSpeed's own default is 500K Triangle."),
IO.Combo.Input("geometry_file_format", options=["fbx", "glb"], default="fbx",
tooltip="FBX keeps quads; GLB is always triangulated."),
IO.Combo.Input("material", options=MATERIALS, default="PBR",
tooltip="PBR maps, baked Shaded colour, both (All), or geometry only (None)."),
IO.Combo.Input("tier", options=TIERS, default="Gen-2.5-Medium",
tooltip="Extreme-High doubles the price; every other tier costs the same."),
IO.Boolean.Input("texture_delight", default=True,
tooltip="Remove lighting and highlights baked into the source image."),
IO.Boolean.Input("ta_pose", default=False, tooltip="Put characters in a T- or A-pose for rigging."),
IO.Boolean.Input("high_pack", default=False,
tooltip="HighPack add-on: 4K textures and denser geometry. Adds $0.96."),
IO.Boolean.Input("hd_texture", default=False, advanced=True,
tooltip="Extra texture post-processing. No extra charge."),
IO.Combo.Input("geometry_instruct_mode", options=["faithful", "creative"], default="faithful",
advanced=True, tooltip="How freely Rodin interprets the shape."),
IO.Combo.Input("texture_mode", options=TEXTURE_MODES, default=TEXTURE_MODE_DEFAULT, advanced=True,
tooltip="'default' leaves it to Rodin."),
IO.Boolean.Input("use_original_alpha", default=False, advanced=True,
tooltip="Keep the input image's transparency instead of cutting out the background."),
IO.Int.Input("seed", default=0, min=0, max=2147483647, control_after_generate=True, advanced=True,
tooltip="Not sent to WaveSpeed. Change it to force a re-run."),
IO.Int.Input("timeout_seconds", default=900, min=60, max=3600, step=30, advanced=True),
],
outputs=[
IO.File3DGLB.Output("GLB", tooltip="Populated when geometry_file_format is glb."),
IO.File3DFBX.Output("FBX", tooltip="Populated when geometry_file_format is fbx."),
IO.String.Output("model_file", tooltip="Absolute path of the downloaded model in output/3d."),
IO.String.Output("model_url", tooltip="WaveSpeed CDN link to the model."),
],
)
@classmethod
async def execute(cls, client, images, prompt, quality_and_mesh, geometry_file_format, material, tier,
texture_delight, ta_pose, high_pack, hd_texture=False, geometry_instruct_mode="faithful",
texture_mode=TEXTURE_MODE_DEFAULT, use_original_alpha=False, seed=0, timeout_seconds=900):
frames = flatten_images([images])
if not 1 <= len(frames) <= MAX_IMAGES:
raise ValueError(f"Rodin takes 1-{MAX_IMAGES} images, got {len(frames)}.")
api = WaveSpeedClient(api_key=client["api_key"])
image_urls = await upload_images(api, client, frames)
payload = build_payload(image_urls, prompt, tier, quality_and_mesh, material, geometry_file_format,
texture_delight, hd_texture, ta_pose, high_pack, geometry_instruct_mode,
texture_mode, use_original_alpha)
print(f"[SF Rodin 2.5] {len(image_urls)} image(s), {quality_and_mesh}, {material}, {tier}"
f"{', HighPack' if high_pack else ''}.")
started = time.time()
task_id, result = await submit_and_wait(api, client, ENDPOINT, payload, timeout_seconds, "Rodin 2.5")
url = pick_model_url(result.get("outputs"), prefer=f".{geometry_file_format}")
glb, fbx, path = await save_3d_model(url, f"sf_rodin25_{task_id}", f".{geometry_file_format}")
print(f"[SF Rodin 2.5] Done in {time.time() - started:.0f}s -> {path}")
return IO.NodeOutput(glb, fbx, path, url)
NODE_CLASS_MAPPINGS = {"SFWaveSpeedRodinV25ImageTo3D": SFWaveSpeedRodinV25ImageTo3D}
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedRodinV25ImageTo3D": "SF WaveSpeed Rodin 2.5 Image to 3D"}
+163
View File
@@ -0,0 +1,163 @@
# ABOUTME: ByteDance Seedance 2.5 video through WaveSpeed: text-to-video, image-to-video from a start (and end) frame,
# ABOUTME: or reference-to-video from numbered reference images; returns a VIDEO plus its URL, async so it runs in parallel
import re
from comfy_api.latest import IO
from .wavespeed_api.client import WaveSpeedClient
from .wavespeed_api.utils import tensor2images
from .wavespeed_api.v3 import call, fetch_video, submit_and_wait, upload_images
TEXT_TO_VIDEO = "/api/v3/bytedance/seedance-2.5/text-to-video"
IMAGE_TO_VIDEO = "/api/v3/bytedance/seedance-2.5/image-to-video"
REFERENCE_TO_VIDEO = "/api/v3/bytedance/seedance-2.5/reference-to-video"
ASPECT_RATIOS = ["16:9", "9:16", "4:3", "3:4", "1:1", "21:9"]
RESOLUTIONS = ["480p", "720p", "1080p", "4k"]
MAX_REFERENCES = 30
# "image 2", "Image 2", "image2", "@Image 2" or "<Image 2>" in the prompt.
IMAGE_MENTION = re.compile(r"(?:@\s*|<\s*)?\b[Ii]mage\s*(\d+)\b(?:\s*>)?")
def tag_references(prompt, slots):
"""Rewrites mentions of reference slots into Seedance's @Image N tags.
`slots` are the connected slot numbers in order; they are sent in that order,
so the tag number is the slot's position in the list. That keeps "image 3"
pointing at slot 3 even when slot 2 is empty."""
position = {slot: i + 1 for i, slot in enumerate(slots)}
missing = []
def replace(match):
slot = int(match.group(1))
if slot not in position:
missing.append(slot)
return match.group(0)
return f"@Image {position[slot]}"
tagged = IMAGE_MENTION.sub(replace, prompt)
if missing:
names = ", ".join(f"image {n}" for n in sorted(set(missing)))
connected = ", ".join(f"image_{n}" for n in slots) or "none"
raise ValueError(f"The prompt mentions {names}, but that reference slot is empty "
f"(connected: {connected}).")
return tagged
def build_payload(prompt, aspect_ratio, resolution, duration, generate_audio, image_url=None, last_image_url=None,
reference_urls=None):
payload = {"prompt": prompt, "resolution": resolution, "duration": duration, "generate_audio": generate_audio}
if reference_urls:
payload["reference_images"] = reference_urls
payload["aspect_ratio"] = aspect_ratio
elif image_url:
# Image-to-video takes its aspect ratio from the start frame.
payload["image"] = image_url
if last_image_url:
payload["last_image"] = last_image_url
else:
payload["aspect_ratio"] = aspect_ratio
return payload
class SFWaveSpeedSeedanceV25(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SFWaveSpeedSeedanceV25",
display_name="SF WaveSpeed Seedance 2.5",
category="Stillfront/WaveSpeed",
description=(
"Generate video with native audio using ByteDance Seedance 2.5 through WaveSpeed. "
"Connect a start frame for image-to-video, or reference images for reference-to-video "
"(refer to them in the prompt as 'image 1', 'image 2'...); leave both empty for text-to-video."
),
inputs=[
IO.Custom("WAVESPEED_AI_API_CLIENT").Input("client"),
IO.String.Input("prompt", multiline=True, default="",
tooltip="Scene, action, camera movement and mood. With references connected, "
"write 'image 1', 'image 2'... to mean the image in that reference slot."),
IO.Combo.Input("resolution", options=RESOLUTIONS, default="720p",
tooltip="Billed per second: 480p $0.18, 720p $0.36, 1080p $0.90, 4k $1.80."),
IO.Int.Input("duration", default=5, min=4, max=30, display_mode=IO.NumberDisplay.number,
tooltip="Length in seconds (4-30). Cost scales with it."),
IO.Combo.Input("aspect_ratio", options=ASPECT_RATIOS, default="16:9",
tooltip="Text-to-video only; image-to-video follows the start frame."),
IO.Boolean.Input("generate_audio", default=True, tooltip="Native audio synced to the video."),
IO.Int.Input("seed", default=0, min=0, max=2147483647, control_after_generate=True,
display_mode=IO.NumberDisplay.number,
tooltip="Not sent to WaveSpeed. Change it to force a re-run."),
IO.Image.Input("image", display_name="start_frame", optional=True,
tooltip="Optional start frame; connecting it switches to image-to-video."),
IO.Image.Input("last_image", display_name="end_frame", optional=True,
tooltip="Optional end frame. Needs a start frame."),
IO.Int.Input("timeout_seconds", default=1800, min=60, max=7200, step=60, advanced=True),
# Sockets only, so appending them does not shift saved widget values.
IO.Autogrow.Input(
"references",
template=IO.Autogrow.TemplateNames(
IO.Image.Input("image"), names=[f"image_{i}" for i in range(1, MAX_REFERENCES + 1)], min=0,
),
tooltip="Reference images (characters, objects, places). Connecting any switches to "
"reference-to-video. The prompt's 'image 1' means slot image_1, and so on. "
"Only the first frame of a batch is used.",
),
],
outputs=[
IO.Video.Output("video"),
IO.String.Output("video_url", tooltip="WaveSpeed CDN link to the video."),
],
)
@classmethod
async def execute(cls, client, prompt, resolution, duration, aspect_ratio, generate_audio, seed,
image=None, last_image=None, timeout_seconds=1800, references=None):
if not prompt.strip():
raise ValueError("The prompt box is empty — Seedance needs a description of the video.")
if last_image is not None and image is None:
raise ValueError("end_frame needs a start_frame connected as well.")
slots = sorted(int(name.rsplit("_", 1)[1]) for name, tensor in (references or {}).items()
if tensor is not None)
if slots and (image is not None or last_image is not None):
raise ValueError("Seedance's reference-to-video has no start or end frame. Disconnect start_frame/"
"end_frame, or the reference images — or add the start frame as a reference.")
if slots:
prompt = tag_references(prompt, slots)
api = WaveSpeedClient(api_key=client["api_key"])
image_url = last_image_url = None
if image is not None:
image_url = await call(client, api.upload_file, tensor2images(image)[0], retry=True)
if last_image is not None:
last_image_url = await call(client, api.upload_file, tensor2images(last_image)[0], retry=True)
reference_urls = []
if slots:
frames = [tensor2images(references[f"image_{slot}"])[0] for slot in slots]
reference_urls = await upload_images(api, client, frames)
if reference_urls:
endpoint, mode = REFERENCE_TO_VIDEO, f"reference-to-video ({len(reference_urls)} refs)"
elif image_url:
endpoint, mode = IMAGE_TO_VIDEO, "image-to-video"
else:
endpoint, mode = TEXT_TO_VIDEO, "text-to-video"
payload = build_payload(prompt, aspect_ratio, resolution, duration, generate_audio, image_url, last_image_url,
reference_urls)
print(f"[SF Seedance 2.5] {mode}, {resolution}, {duration}s, audio={generate_audio}.")
if reference_urls:
print(f"[SF Seedance 2.5] Prompt sent: {prompt}")
_task_id, result = await submit_and_wait(api, client, endpoint, payload, timeout_seconds, "Seedance 2.5",
interval=5)
outputs = result.get("outputs") or []
urls = [u for u in (outputs if isinstance(outputs, list) else [outputs]) if isinstance(u, str)]
if not urls:
raise RuntimeError(f"Seedance returned no video: {result!r}")
return IO.NodeOutput(await fetch_video(urls[0]), urls[0])
NODE_CLASS_MAPPINGS = {"SFWaveSpeedSeedanceV25": SFWaveSpeedSeedanceV25}
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedSeedanceV25": "SF WaveSpeed Seedance 2.5"}
+93
View File
@@ -0,0 +1,93 @@
# ABOUTME: ByteDance Seedream 5.0 Pro and Flash through WaveSpeed, generate and edit in one node
# ABOUTME: Connecting reference images switches to edit; 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. Text-to-image is the bare slug; edit adds /edit.
MODELS = {
"Seedream 5.0 Pro": "bytedance/seedream-v5.0-pro",
"Seedream 5.0 Flash": "bytedance/seedream-v5.0-flash",
}
ASPECT_RATIOS = ["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"]
RESOLUTIONS = ["1K", "1.5K", "2K"]
MAX_REFERENCES = 10
def build_payload(model, prompt, aspect_ratio, resolution, prompt_optimization, image_urls):
payload = {"prompt": prompt, "resolution": resolution.lower(), "output_format": "png"}
if aspect_ratio != "auto":
payload["aspect_ratio"] = aspect_ratio
# Only Pro documents prompt optimisation.
if model == "Seedream 5.0 Pro":
payload["prompt_optimization_mode"] = prompt_optimization
if image_urls:
payload["images"] = image_urls
return payload
class SFWaveSpeedSeedreamV5(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SFWaveSpeedSeedreamV5",
display_name="SF WaveSpeed Seedream 5.0",
category="Stillfront/WaveSpeed",
description=(
"Generate or edit images with ByteDance Seedream 5.0 Pro or Flash 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", multiline=True, default="",
tooltip="For edits, say what should change and what should stay the same."),
IO.Combo.Input("model", options=list(MODELS),
tooltip="Pro: higher quality. Flash: cheaper and faster, a flat price at any resolution."),
IO.Int.Input("seed", default=0, min=0, max=2147483647, control_after_generate=True,
display_mode=IO.NumberDisplay.number,
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="'auto' follows the first reference image when editing, or the model's default otherwise."),
IO.Combo.Input("resolution", options=RESOLUTIONS, default="1K",
tooltip="Seedream tops out at 2K. On Pro, 1K and 1.5K cost the same."),
IO.Int.Input("n", default=1, min=1, max=8, display_mode=IO.NumberDisplay.number,
tooltip="How many images to generate. Sent as parallel requests, each billed separately."),
IO.Image.Input("images", optional=True,
tooltip=f"Optional reference image(s); connecting any switches to edit. "
f"Use Batch Images for several (up to {MAX_REFERENCES}); the first is the main input."),
IO.Combo.Input("prompt_optimization", options=["standard", "fast"], default="standard", advanced=True,
tooltip="Pro only. 'fast' is about twice as quick but follows long prompts less closely."),
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, n, images=None,
prompt_optimization="standard", timeout_seconds=600):
if not prompt.strip():
raise ValueError("The prompt box is empty — Seedream 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"Seedream accepts at most {MAX_REFERENCES} 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]}{'/edit' if image_urls else ''}"
payload = build_payload(model, prompt, aspect_ratio, resolution, prompt_optimization, image_urls)
print(f"[SF Seedream 5.0] {MODELS[model]}, "
f"{f'editing {len(image_urls)} image(s)' if image_urls else 'text-to-image'}, {resolution}, n={n}.")
urls = await submit_and_collect(api, client, endpoint, payload, n, timeout_seconds, f"Seedream ({model})")
return IO.NodeOutput(to_image_batch(await fetch_images(urls)))
NODE_CLASS_MAPPINGS = {"SFWaveSpeedSeedreamV5": SFWaveSpeedSeedreamV5}
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedSeedreamV5": "SF WaveSpeed Seedream 5.0"}
+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.10.0"
license = { file = "LICENSE" }
dependencies = [
"requests>=2.31.0",
+51
View File
@@ -6,6 +6,13 @@
* It augments the native dialog rather than replacing it: ComfyUI still does
* the actual saving, so tab tracking and overwrite confirmation are unchanged.
* Picking a folder only rewrites the path in the dialog's own input.
*
* ComfyUI resolves the typed name against the open workflow's own folder
* (path = workflow.directory + "/" + input). The picker works in paths from
* the top-level workflows folder, so while the dialog is open the active
* workflow's directory reads as that top level, and the input starts out
* holding the workflow's current folder so an unchanged save lands where it
* always did.
*/
import { app } from "../../../scripts/app.js";
@@ -18,6 +25,21 @@ const ROOT = "workflows";
// True only while a Save command is running, so the same prompt dialog used
// for renaming or exporting is left alone.
let savePending = false;
// The active workflow's folder relative to ROOT ("" at the top level), captured
// when the Save command starts.
let currentFolder = "";
function activeWorkflow() {
try {
return app.extensionManager?.workflow?.activeWorkflow ?? null;
} catch {
return null;
}
}
function relativeFolder(directory) {
return (directory || ROOT).replace(new RegExp(`^${ROOT}/?`), "");
}
function enabled() {
try {
@@ -174,6 +196,13 @@ async function inject(dialog) {
const input = dialog.querySelector("input");
if (!input) return;
// The dialog offers just the file name; show it in its real folder, since the
// name is now resolved from the top level.
if (currentFolder && !input.value.includes("/")) {
input.value = `${currentFolder}/${input.value}`;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
let picker;
try {
picker = buildPicker(input, await fetchFolders());
@@ -203,11 +232,33 @@ app.registerExtension({
if (!command) continue;
const original = command.function;
command.function = async function () {
const workflow = enabled() ? activeWorkflow() : null;
let overridden = false;
let ownDirectory;
if (workflow) {
currentFolder = relativeFolder(workflow.directory);
// Plain Save on a saved workflow never prompts, so it is left untouched.
if (currentFolder && id === "Comfy.SaveWorkflowAs") {
// Override directory on this instance only, for the length of the save. Depending
// on the frontend version it is an own value or an inherited getter, so the
// original own descriptor (if any) is kept and put back afterwards.
ownDirectory = Object.getOwnPropertyDescriptor(workflow, "directory");
Object.defineProperty(workflow, "directory", {
value: ROOT, configurable: true, writable: true, enumerable: true,
});
overridden = true;
}
}
savePending = true;
try {
return await original.apply(this, arguments);
} finally {
savePending = false;
currentFolder = "";
if (overridden) {
if (ownDirectory) Object.defineProperty(workflow, "directory", ownDirectory);
else delete workflow.directory;
}
}
};
}
+206
View File
@@ -0,0 +1,206 @@
/**
* SF WaveSpeed price badge — shows the estimated WaveSpeed cost in dollars in
* the node header, updating as the settings change.
*
* ComfyUI's own price badge only renders on Comfy partner (api_node) nodes, and
* marking ours as such would make ComfyUI treat them as Comfy-billed. So the
* classic canvas gets an ordinary extension badge, and Vue nodes get a pill
* added to their header.
*/
import { app } from "../../../scripts/app.js";
import {
formatUsd,
gptImagePrice,
meshyPrice,
nanoBananaPrice,
rodinPrice,
seedancePrice,
seedreamPrice,
tripoH31Price,
tripoP2Price,
} from "./sf_wavespeed_pricing.js";
/** Widget value by name. V3 dynamic combos prefix their child widgets, e.g. "model.quality". */
function widgetValue(node, name) {
const widget = node.widgets?.find((w) => w.name === name || w.name?.endsWith(`.${name}`));
return widget?.value;
}
function connectedImages(node) {
return (node.inputs ?? []).filter((input) => input.type === "IMAGE" && input.link != null).length;
}
// node type -> () => { usd, count } for one run of the node
const PRICERS = {
SFWaveSpeedGPTImage: (node) => ({
usd: gptImagePrice({
model: widgetValue(node, "model"),
quality: widgetValue(node, "quality"),
resolution: widgetValue(node, "resolution"),
size: widgetValue(node, "size"),
input_fidelity: widgetValue(node, "input_fidelity"),
references: connectedImages(node),
}),
count: Number(widgetValue(node, "n")) || 1,
}),
SFWaveSpeedNanoBananaPro: (node) => ({
usd: nanoBananaPrice({
model: widgetValue(node, "model"),
resolution: widgetValue(node, "resolution"),
references: connectedImages(node),
enable_web_search: widgetValue(node, "enable_web_search"),
enable_image_search: widgetValue(node, "enable_image_search"),
}),
count: Number(widgetValue(node, "n")) || 1,
}),
SFWaveSpeedSeedreamV5: (node) => ({
usd: seedreamPrice({
model: widgetValue(node, "model"),
resolution: widgetValue(node, "resolution"),
references: connectedImages(node),
}),
count: Number(widgetValue(node, "n")) || 1,
}),
SFWaveSpeedSeedanceV25: (node) => ({
usd: seedancePrice({
resolution: widgetValue(node, "resolution"),
duration: widgetValue(node, "duration"),
}),
count: 1,
}),
SFWaveSpeedRodinV25ImageTo3D: (node) => ({
usd: rodinPrice({ tier: widgetValue(node, "tier"), high_pack: widgetValue(node, "high_pack") }),
count: 1,
}),
SFWaveSpeedMeshyV71ImageTo3D: (node) => ({
usd: meshyPrice({
should_texture: widgetValue(node, "should_texture"),
model_type: widgetValue(node, "model_type"),
geometry_resolution: widgetValue(node, "geometry_resolution"),
enable_rigging: widgetValue(node, "enable_rigging"),
enable_animation: widgetValue(node, "enable_animation"),
}),
count: 1,
}),
SFWaveSpeedTripoP2ImageTo3D: (node) => ({
usd: tripoP2Price({ texture: widgetValue(node, "texture") }),
count: 1,
}),
SFWaveSpeedTripoH31ImageTo3D: (node) => ({
usd: tripoH31Price({
texture: widgetValue(node, "texture"),
geometry_quality: widgetValue(node, "geometry_quality"),
quad: widgetValue(node, "quad"),
}),
count: 1,
}),
};
function badgeText(node, pricer) {
const { usd, count } = pricer(node);
if (usd == null) return "";
const total = `~${formatUsd(usd * count)}/run`;
return count > 1 ? `${total} (${count} × ${formatUsd(usd)})` : total;
}
/**
* Duck-types LiteGraph's LGraphBadge (text, height, getWidth, draw) so the
* classic canvas can draw it without depending on an internal class.
*/
class PriceBadge {
constructor(getText) {
this.getText = getText;
this.height = 20;
this.fgColor = "#f5f5f5";
this.bgColor = "#2b6a3f";
}
get text() {
return this.getText();
}
getWidth(ctx) {
const text = this.text;
if (!text) return 0;
ctx.save();
ctx.font = "12px sans-serif";
const width = ctx.measureText(text).width + 12;
ctx.restore();
return width;
}
draw(ctx, x, y) {
const text = this.text;
if (!text) return;
const width = this.getWidth(ctx);
ctx.save();
ctx.fillStyle = this.bgColor;
ctx.beginPath();
ctx.roundRect(x, y, width, this.height, 5);
ctx.fill();
ctx.fillStyle = this.fgColor;
ctx.font = "12px sans-serif";
ctx.textBaseline = "middle";
ctx.fillText(text, x + 6, y + this.height / 2 + 1);
ctx.restore();
}
}
/**
* The Vue node header only renders ComfyUI's own credit badges, so on Vue
* nodes the price is added to the header element directly. Polled rather than
* event-driven: Vue can rebuild a header at any time, and widget edits do not
* reach extensions through one reliable hook.
*/
function syncVueHeaders() {
for (const node of app.canvas?.graph?.nodes ?? []) {
const pricer = PRICERS[node.type];
if (!pricer) continue;
const header = document.querySelector(`[data-testid="node-header-${node.id}"]`);
const row = header?.firstElementChild;
if (!row) continue;
let pill = row.querySelector(":scope > [data-sf-price]");
if (!pill) {
pill = document.createElement("span");
pill.dataset.sfPrice = "";
pill.title = "Estimated WaveSpeed cost from wavespeed.ai list prices";
Object.assign(pill.style, {
flexShrink: "0",
whiteSpace: "nowrap",
fontSize: "11px",
lineHeight: "16px",
padding: "1px 6px",
marginRight: "4px",
borderRadius: "6px",
color: "#d7f5df",
background: "rgba(43, 106, 63, 0.85)",
});
row.appendChild(pill);
}
const text = badgeText(node, pricer);
if (pill.textContent !== text) pill.textContent = text;
pill.style.display = text ? "" : "none";
}
}
app.registerExtension({
name: "SF.WaveSpeedPriceBadge",
setup() {
setInterval(syncVueHeaders, 250);
},
async beforeRegisterNodeDef(nodeType, nodeData) {
const pricer = PRICERS[nodeData.name];
if (!pricer) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
const badge = new PriceBadge(() => badgeText(this, pricer));
// A getter, so the text is recomputed whenever the header redraws.
this.badges = [...(this.badges ?? []), () => badge];
return result;
};
},
});
+131
View File
@@ -0,0 +1,131 @@
/**
* WaveSpeed list prices (USD per run) for the SF WaveSpeed nodes that show a
* cost badge. Taken from each model's wavespeed.ai page on 2026-10-30; WaveSpeed
* says those pages can lag, and the task charge is what you actually pay.
*
* No imports, so the price maths can be checked outside ComfyUI.
*/
// quality -> [1k, 2k, 4k]
const GPT_25_T2I = {
low: [0.01, 0.02, 0.03],
medium: [0.024, 0.04, 0.07],
high: [0.09, 0.15, 0.27],
xhigh: [0.16, 0.27, 0.48],
max: [0.36, 0.6, 1.0],
};
// Includes the first reference image.
const GPT_25_EDIT = {
low: [0.025, 0.035, 0.045],
medium: [0.039, 0.055, 0.085],
high: [0.105, 0.165, 0.285],
xhigh: [0.175, 0.285, 0.495],
max: [0.375, 0.615, 1.015],
};
const GPT_2_T2I = {
low: [0.01, 0.02, 0.03],
medium: [0.06, 0.1, 0.18],
high: [0.22, 0.4, 0.72],
};
const GPT_2_EDIT = {
low: [0.02, 0.03, 0.04],
medium: [0.07, 0.11, 0.19],
high: [0.23, 0.41, 0.73],
};
// quality -> [1024x1024, any other size]
const GPT_15_T2I = { low: [0.01, 0.02], medium: [0.04, 0.06], high: [0.14, 0.21] };
const GPT_15_EDIT = { low: [0.02, 0.03], medium: [0.05, 0.07], high: [0.15, 0.21] };
const RESOLUTION_INDEX = { "1k": 0, "2k": 1, "4k": 2 };
/** One GPT Image request. `references` is the number of connected image inputs. */
export function gptImagePrice({ model, quality, resolution, size, input_fidelity, references }) {
const editing = references > 0;
const extra = Math.max(references - 1, 0);
if (model === "gpt-image-1.5") {
const col = size === "1024x1024" ? 0 : 1;
if (!editing) return GPT_15_T2I[quality]?.[col];
const base = GPT_15_EDIT[quality]?.[col];
if (base == null) return undefined;
return base + 0.01 * extra + (input_fidelity === "high" ? 0.05 * references : 0);
}
const col = RESOLUTION_INDEX[String(resolution).toLowerCase()];
if (model === "gpt-image-2") {
const base = (editing ? GPT_2_EDIT : GPT_2_T2I)[quality]?.[col];
return base == null ? undefined : base + (editing ? 0.012 * extra : 0);
}
// gpt-image-2.5-flare and -sunburst share one price list.
const base = (editing ? GPT_25_EDIT : GPT_25_T2I)[quality]?.[col];
return base == null ? undefined : base + (editing ? 0.015 * extra : 0);
}
// Same price for text-to-image and edit. Pro's 8K runs on WaveSpeed's "ultra" endpoint.
const NANO_BANANA = {
"Nano Banana 2.1": { "1k": 0.04, "2k": 0.06, "4k": 0.135 },
"gemini-3-pro-image-preview": { "1k": 0.14, "2k": 0.14, "4k": 0.24, "8k": 0.18 },
"Nano Banana 2 (Gemini 3.1 Flash Image)": { "1k": 0.07, "2k": 0.105, "4k": 0.14 },
};
const NANO_BANANA_SEARCH = 0.014; // each of web and image search
const NANO_BANANA_21_PER_REFERENCE = 0.002;
/** `references` counts connected image inputs, not the frames inside a batch. */
export function nanoBananaPrice({ model, resolution, references, enable_web_search, enable_image_search }) {
const base = NANO_BANANA[model]?.[String(resolution).toLowerCase()];
if (base == null) return undefined;
const searches = (enable_web_search ? 1 : 0) + (enable_image_search ? 1 : 0);
const perReference = model === "Nano Banana 2.1" ? NANO_BANANA_21_PER_REFERENCE : 0;
return base + NANO_BANANA_SEARCH * searches + perReference * references;
}
// The node switches PBR off along with textures, so 'none' is the untextured price.
const TRIPO_P2 = { none: 1.2, fast: 1.32, standard: 1.32, detailed: 1.44, extreme: 1.56 };
export function tripoP2Price({ texture }) {
return TRIPO_P2[texture];
}
const TRIPO_H31_TEXTURE = { none: 1, standard: 1.5, detailed: 2 };
export function tripoH31Price({ texture, geometry_quality, quad }) {
const multiplier = TRIPO_H31_TEXTURE[texture];
if (multiplier == null) return undefined;
return 0.24 * multiplier + (geometry_quality === "detailed" ? 0.24 : 0) + (quad ? 0.06 : 0);
}
/** "$0.024", "$0.04", "$1.32": cents always shown, a third decimal only when it matters. */
export function formatUsd(usd) {
const rounded = Math.round(usd * 1000) / 1000;
const digits = Math.abs(rounded * 100 - Math.round(rounded * 100)) < 1e-9 ? 2 : 3;
return `$${rounded.toFixed(digits)}`;
}
// Pro: 1K and 1.5K cost the same; each reference after the first adds $0.003 when editing.
const SEEDREAM_PRO = { "1k": 0.045, "1.5k": 0.045, "2k": 0.09 };
const SEEDREAM_FLASH = 0.027; // any resolution, any number of references
export function seedreamPrice({ model, resolution, references }) {
if (model === "Seedream 5.0 Flash") return SEEDREAM_FLASH;
const base = SEEDREAM_PRO[String(resolution).toLowerCase()];
return base == null ? undefined : base + 0.003 * Math.max(references - 1, 0);
}
// Per second of output video.
const SEEDANCE_PER_SECOND = { "480p": 0.18, "720p": 0.36, "1080p": 0.9, "4k": 1.8 };
export function seedancePrice({ resolution, duration }) {
const rate = SEEDANCE_PER_SECOND[resolution];
return rate == null ? undefined : rate * Number(duration);
}
export function rodinPrice({ tier, high_pack }) {
return (tier === "Gen-2.5-Extreme-High" ? 0.96 : 0.48) + (high_pack ? 0.96 : 0);
}
export function meshyPrice({ should_texture, model_type, geometry_resolution, enable_rigging, enable_animation }) {
let usd = should_texture ? 1.44 : 0.96;
if (model_type === "standard") usd += { "2k": 0.24, "4k": 0.48 }[geometry_resolution] ?? 0;
if (enable_rigging) usd += 0.24 + (enable_animation ? 0.144 : 0);
return usd;
}