Compare commits
@@ -2,7 +2,32 @@
|
||||
|
||||

|
||||
|
||||
A collection of custom ComfyUI nodes developed by [Stillfront](https://www.stillfront.com). All nodes are prefixed with **SF** for easy searching in ComfyUI.
|
||||
**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) | 54 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) | 6 nodes — Claude, Gemini and GPT in your graph, plus the asset library tooling and a system-prompt coach below |
|
||||
|
||||
### Worth a look
|
||||
|
||||
**[The asset library workflow](#the-asset-library-workflow)** — point Claude at a folder of game
|
||||
assets and it writes a searchable manifest describing every one. After that, any workflow can ask
|
||||
for *"the character art that suits a boss-fight poster"* and get back the right reference images
|
||||
**plus a generated prompt that already matches your project's art style** — without a single image
|
||||
being opened at generation time.
|
||||
|
||||
Two nodes do this: **SF Claude Library Builder** catalogues once, **SF Claude Asset Director**
|
||||
searches on every run. Both run on your existing Claude Code login, so there's no API key and no
|
||||
per-token billing.
|
||||
|
||||
**[SF Google Sheet Prompt Queue](#using-sf-google-sheet-prompt-queue)** — drive a batch of
|
||||
generations straight from a Google Sheet, marking rows done as you go and resuming where you left
|
||||
off.
|
||||
|
||||
---
|
||||
|
||||
@@ -61,8 +86,8 @@ Chat with large language models directly inside your ComfyUI workflows, with opt
|
||||
| **SF Text Analyzer** | Analyze and process text strings within workflows. |
|
||||
| **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
|
||||
|
||||
@@ -130,9 +155,18 @@ blow up in turns and cost on libraries of 100+ assets, with nothing produced at
|
||||
batch now only reads its own assets and replies with JSON; the node assembles and writes the
|
||||
manifest itself, so no file-writing tool is ever handed to Claude for this.
|
||||
|
||||
A hidden checkpoint (`.sf_library_builder_checkpoint.json`, removed automatically on success)
|
||||
tracks which assets are already catalogued. If a run fails or times out partway, rerunning with
|
||||
the same settings **resumes** — finished batches are skipped, not re-paid for.
|
||||
**Reruns are incremental.** With `update_existing` on (the default), the node reads the manifest
|
||||
already in the library and only catalogues files it doesn't yet describe. So the normal workflow
|
||||
is: drop new assets into the folder, run the same node against the same path, and only the new
|
||||
ones cost anything. Entries whose file has since been deleted are dropped automatically. Turn
|
||||
`update_existing` off to re-describe everything from scratch.
|
||||
|
||||
*(Not available in `organise into a copy` mode, where the manifest holds post-rename paths that
|
||||
can't be matched against the untouched source folder.)*
|
||||
|
||||
Separately, a hidden checkpoint (`.sf_library_builder_checkpoint.json`, removed automatically on
|
||||
success) covers a run that dies partway. Rerunning **resumes** — finished batches are skipped,
|
||||
not re-paid for.
|
||||
|
||||
If a batch's reply comes back **cut off** — the model ran past its reply length limit partway
|
||||
through describing a wordy set of assets — that batch is automatically **retried as two smaller
|
||||
@@ -177,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
|
||||
@@ -229,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. |
|
||||
@@ -254,14 +332,19 @@ Connect an **SF WaveSpeed Client** node (enter your WaveSpeed API key) to any Wa
|
||||
|
||||
| Node | Description |
|
||||
|---|---|
|
||||
| **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 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** | 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 Outpaint Resizer** | Turns one image into several resolutions without redrawing it: crop where that is enough, otherwise FLUX Fill, Bria GenFill or Bria Expand paints only the new strips. Optional torn-edge mask on every output — see below. |
|
||||
| **SF Banner Resizer** | Reframes one finished image into several resolutions — crop where that is enough, otherwise GPT Image 2.5 recomposes it, as in the Art Pipeline Hub — see below. |
|
||||
| **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. |
|
||||
@@ -278,6 +361,129 @@ Connect an **SF WaveSpeed Client** node (enter your WaveSpeed API key) to any Wa
|
||||
| **SF WaveSpeed Flux Kontext Max** | Flux Kontext Max — highest quality variant. |
|
||||
| **SF WaveSpeed Flux ControlNet Union Pro 2** | Flux image generation with ControlNet guidance. |
|
||||
|
||||
#### These run in parallel
|
||||
|
||||
**SF WaveSpeed GPT Image 2.5**, **SF WaveSpeed Nano Banana**, **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.
|
||||
|
||||
They're asynchronous nodes, which ComfyUI starts as background tasks and keeps scheduling other
|
||||
work around — no change to ComfyUI is involved, so updates don't affect it. Only nodes that don't
|
||||
depend on each other overlap; WaveSpeed may also cap how many jobs one account runs at once.
|
||||
|
||||
The older SF WaveSpeed nodes still run one at a time.
|
||||
|
||||
#### Using SF WaveSpeed Nano Banana
|
||||
|
||||
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.)
|
||||
|
||||
| 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
|
||||
model — change it to force a re-run.
|
||||
|
||||
#### Using SF WaveSpeed Outpaint Resizer
|
||||
|
||||
Turns one finished image into up to 8 sizes, edited in the **Output sizes** list on the node (width,
|
||||
height and a mode per row). Each row adds an output labelled with its size.
|
||||
|
||||
- **auto** (default) crops when that cuts away no more than **auto_crop_limit** (15%) of the picture,
|
||||
and outpaints otherwise. **crop** always scales and centre-crops; **outpaint** always paints new
|
||||
area. A size with the image's own shape is just resized. Crops and resizes are free.
|
||||
- **Outpainting** never redraws the artwork: the chosen model — **flux-fill** (cleanest joins, best for
|
||||
strips above/below; **clean_corners** repaints FLUX's occasional fake signature), **bria-genfill**
|
||||
(best for strips left/right) or **bria-expand** (strong on squares) — paints only the new strips,
|
||||
the original pixels are pasted back with a soft seam, and each size is scaled from that. Sizes that
|
||||
share a shape share one paid generation.
|
||||
- **prompt** is the whole prompt every model receives, sent as written; emptying it restores the
|
||||
default. bria-expand also has a visible **negative_prompt**.
|
||||
- **edge_mask** (optional): connect a mask of a shaped edge, such as a torn-paper right edge, and pick
|
||||
**mask_side**. The whole shape is scaled to the full length of that side of every output and cut out,
|
||||
so the outputs carry transparency and Save Image writes transparent PNGs. The masked area of the input
|
||||
is filled in first, so no old edge pixels leak into the results. **invert_mask** flips it.
|
||||
|
||||
The **plan** output lists what happened to each size, the paid calls and the estimated cost; the badge
|
||||
shows the worst case, since auto rows may turn out free.
|
||||
|
||||
#### Using SF Banner Resizer
|
||||
|
||||
Turns one finished image into as many sizes as you list, one per line in **resolutions**
|
||||
(`1920x1080`, `800x450`, …, up to 8). Each line adds an output labelled with its size. It works the
|
||||
way the Art Pipeline Hub's marketing pipeline makes its final sizes:
|
||||
|
||||
- **Sizes within crop_threshold** (default 8%) of the image's shape are cover-resized and centre-
|
||||
cropped. No AI, no cost.
|
||||
- **Every other size is recomposed by GPT Image 2.5**: the image goes to GPT with **reflow_prompt**
|
||||
(the Hub's own, editable) at the nearest shape GPT supports, and GPT redraws it in that shape —
|
||||
extending the existing background and repositioning what is there rather than inventing new
|
||||
elements. The result is then cover-resized and centre-cropped to the exact pixels. Sizes that
|
||||
share a shape share one generation.
|
||||
|
||||
Because GPT redraws the whole picture there are no seams, but fine detail can shift slightly from
|
||||
the original. The **plan** output lists what was done for every size. The badge shows the worst
|
||||
case: one generation per distinct shape.
|
||||
|
||||
#### Using SF WaveSpeed GPT Image 2.5
|
||||
|
||||
Same interface as ComfyUI's partner node: pick a model and the controls below it change to what
|
||||
that model supports; connect reference images and it switches from generating to editing, with
|
||||
image slots appearing as you fill them. The difference is billing — it runs on your WaveSpeed key
|
||||
through **SF WaveSpeed Client** instead of ComfyUI credits.
|
||||
|
||||
Where it differs from the partner node, it's because WaveSpeed's API differs:
|
||||
|
||||
| | Partner node | This node |
|
||||
|---|---|---|
|
||||
| Models | 2.5 Flare, 2.5 Sunburst, 2, 1.5, 1 | Same minus **gpt-image-1**, which WaveSpeed doesn't offer |
|
||||
| Size (2 / 2.5) | Exact pixels, e.g. `1536x1024`, or Custom | **Aspect ratio** (15 options) + **resolution** `1k` / `2k` / `4k` |
|
||||
| Background | All models | gpt-image-1.5 only — the only one WaveSpeed accepts it for |
|
||||
| `n` (1–8) | One request | **n parallel requests**, returned as one batch; each billed separately |
|
||||
| Inpainting mask | Yes | **No** — WaveSpeed's edit endpoints have no mask input |
|
||||
| Reference images | Up to 16 | Up to 16; **10** for gpt-image-1.5 |
|
||||
| Extras | — | `output_format`; `input_fidelity` when editing with 1.5 |
|
||||
|
||||
Like the partner node, `seed` isn't sent (the API has none) — change it to force a re-run.
|
||||
Transparent backgrounds keep their alpha channel; opaque results are ordinary RGB images.
|
||||
|
||||
#### 3D Generation
|
||||
|
||||
| Node | Description |
|
||||
|---|---|
|
||||
| **SF WaveSpeed Tripo P2 Image to 3D** | Tripo's P2 model — the same one behind ComfyUI's *Tripo P2: Image to Model* partner node — billed to your WaveSpeed account instead of ComfyUI credits. Quad or triangle topology, a target face count, texture quality from none to extreme, PBR, delighting, UVs, and seeds. |
|
||||
| **SF WaveSpeed Tripo H3.1 Image to 3D** | Tripo's high-detail H3.1 model — about a fifth of P2's price on WaveSpeed. Quad or triangle topology, 1,000–2,000,000 faces, standard or detailed geometry and textures, PBR, and seeds. Same outputs as the P2 node. |
|
||||
| **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.
|
||||
|
||||
#### Upscalers
|
||||
|
||||
| Node | Description |
|
||||
@@ -328,6 +534,25 @@ and skips row 2. Each prompt triggers a separate pass through the downstream gra
|
||||
|
||||
---
|
||||
|
||||
## Interface improvements
|
||||
|
||||
Not nodes — small fixes to ComfyUI's own interface that come with the pack.
|
||||
|
||||
### Folder picker in the Save workflow dialog
|
||||
|
||||
ComfyUI's **Save** / **Save As** dialog normally wants the whole path typed by hand —
|
||||
`Stillfront/NewMoon/My Workflow` — to save into a subfolder. With this pack installed, the dialog
|
||||
also shows your workflow folders as a tree: click a folder, type just the name, confirm.
|
||||
|
||||
- **+ New folder** adds a folder at the selected level; it's created on disk when you save into it
|
||||
- The dialog opens on the folder of the workflow you're editing, already selected
|
||||
- Only the Save dialogs get the picker — Rename and Export, which reuse the same prompt, are left alone
|
||||
- ComfyUI still does the actual saving, so tab tracking and overwrite warnings behave exactly as before
|
||||
|
||||
Turn it off under **Settings → Stillfront → Workflows**.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# ABOUTME: Reframes one image into several resolutions the way the Art Pipeline Hub's marketing pipeline does:
|
||||
# ABOUTME: crop when the shape barely changes, otherwise GPT Image 2.5 (WaveSpeed) reflows it, then cover-resize and crop
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from comfy_api.latest import IO
|
||||
|
||||
from .wavespeed_api.client import WaveSpeedClient
|
||||
from .wavespeed_api.utils import tensor2images
|
||||
from .wavespeed_api.v3 import fetch_images, submit_and_collect, upload_images
|
||||
|
||||
MAX_OUTPUTS = 8
|
||||
GPT_MODELS = {
|
||||
"gpt-image-2.5-sunburst": "openai/gpt-image-2.5-sunburst",
|
||||
"gpt-image-2.5-flare": "openai/gpt-image-2.5-flare",
|
||||
}
|
||||
# Aspect ratios GPT Image 2.5 can generate on WaveSpeed.
|
||||
GPT_RATIOS = ["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"]
|
||||
|
||||
# The Art Pipeline Hub's REFLOW_PROMPT (marketing-creative, ported from GGS2), worded for any image.
|
||||
# {width} and {height} are filled in per generation.
|
||||
DEFAULT_REFLOW_PROMPT = """Adapt this exact image to fit a {width}x{height} canvas. This is a professional resize task, not a redesign: preserve every element exactly as shown -- the character(s), all text, all UI panels and values, the logo -- do not crop out, remove, invent, or change any of them. Rearrange and reflow the composition and layout, the way a professional graphic designer would when adapting a creative to a new aspect ratio, repositioning elements so everything stays fully visible and well-composed in the new shape. Do not invent new characters, do not change any numbers, labels, or text, do not alter the art style.
|
||||
|
||||
Two hard requirements this needs to get right:
|
||||
1. Never stretch, squash, or distort any element's proportions to fit the new shape -- every character, panel, icon, and piece of text must keep its own natural width-to-height ratio. Resize elements uniformly (same scale on both axes) or reposition them; never scale an element differently on one axis than the other.
|
||||
2. Use the entire canvas meaningfully -- do not just pad extra space with empty sky, floor, or blank background. If the new shape is much taller or wider than the original, fill the added space primarily by repositioning and resizing the EXISTING elements (the same background, the same UI panels) to occupy more of the canvas, or by extending the same existing background texture/silhouette a bit further in the same style and level of detail. Do not invent new distinct scene elements (extra buildings, new set pieces, new landmarks) that aren't already present, even partially, in the source -- this image is one of several ratio variants of the same campaign, and every variant needs to look like the same scene, not a fresh reinterpretation of it.
|
||||
|
||||
Do not add a border, caption, watermark, or logo of your own -- any logo is composited separately, afterward."""
|
||||
|
||||
|
||||
def parse_resolutions(text):
|
||||
sizes = []
|
||||
for token in re.split(r"[,\n;]+", text):
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
match = re.fullmatch(r"(\d+)\s*[xX×*]\s*(\d+)", token)
|
||||
if not match:
|
||||
raise ValueError(f"Can't read resolution '{token}'. Write each as WIDTHxHEIGHT, e.g. 1920x1080.")
|
||||
w, h = int(match.group(1)), int(match.group(2))
|
||||
if not (16 <= w <= 8192 and 16 <= h <= 8192):
|
||||
raise ValueError(f"Resolution {w}x{h} is outside 16-8192 pixels.")
|
||||
sizes.append((w, h))
|
||||
if not sizes:
|
||||
raise ValueError("Add at least one resolution, e.g. 1920x1080.")
|
||||
if len(sizes) > MAX_OUTPUTS:
|
||||
raise ValueError(f"At most {MAX_OUTPUTS} resolutions per node; got {len(sizes)}.")
|
||||
return sizes
|
||||
|
||||
|
||||
def needs_reflow(w, h, W, H, threshold):
|
||||
"""True when a plain crop would take more than `threshold` of the image (the Hub's 8% rule)."""
|
||||
a, A = w / h, W / H
|
||||
return 1 - min(a, A) / max(a, A) > threshold + 1e-9
|
||||
|
||||
|
||||
def nearest_ratio(W, H):
|
||||
"""The supported ratio closest to WxH, compared in log space so 16:9 and 9:16 are
|
||||
equally far from square."""
|
||||
wanted = math.log(W / H)
|
||||
return min(GPT_RATIOS, key=lambda r: abs(math.log(int(r.split(":")[0]) / int(r.split(":")[1])) - wanted))
|
||||
|
||||
|
||||
def generation_tier(W, H, setting):
|
||||
if setting != "auto":
|
||||
return setting
|
||||
side = max(W, H)
|
||||
return "1k" if side <= 1100 else "2k" if side <= 2200 else "4k"
|
||||
|
||||
|
||||
def fill_crop(image, W, H):
|
||||
"""Scale to cover WxH, then centre-crop to exactly that: fill the frame, never letterbox or squash."""
|
||||
return ImageOps.fit(image, (W, H), method=Image.LANCZOS, centering=(0.5, 0.5))
|
||||
|
||||
|
||||
def to_tensor(image):
|
||||
return torch.from_numpy(np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0)[None,]
|
||||
|
||||
|
||||
class SFBannerResizer(IO.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="SFBannerResizer",
|
||||
display_name="SF Banner Resizer",
|
||||
category="Stillfront/WaveSpeed",
|
||||
description=(
|
||||
"Reframes one image into several resolutions, as the Art Pipeline Hub's marketing pipeline does. "
|
||||
"Sizes within crop_threshold of the image's shape are cropped; for the rest GPT Image 2.5 "
|
||||
"recomposes the image in the nearest shape it supports, then it is cover-resized and cropped "
|
||||
"to the exact pixels. One output per resolution."
|
||||
),
|
||||
inputs=[
|
||||
IO.Custom("WAVESPEED_AI_API_CLIENT").Input("client"),
|
||||
IO.Image.Input("image", tooltip="The finished artwork. Only the first frame of a batch is used."),
|
||||
IO.String.Input("resolutions", multiline=True, default="1920x1080\n800x450",
|
||||
tooltip=f"One WIDTHxHEIGHT per line (up to {MAX_OUTPUTS}). Each adds an output, "
|
||||
"in the same order."),
|
||||
IO.String.Input("reflow_prompt", multiline=True, default=DEFAULT_REFLOW_PROMPT,
|
||||
tooltip="Sent to GPT Image with the image for every size that is recomposed. "
|
||||
"{width} and {height} are replaced with the size. Connect your own text to override."),
|
||||
IO.Combo.Input("gpt_model", options=list(GPT_MODELS), default="gpt-image-2.5-sunburst"),
|
||||
IO.Combo.Input("quality", options=["low", "medium", "high", "xhigh", "max"], default="high",
|
||||
tooltip="GPT Image quality. Affects cost."),
|
||||
IO.Combo.Input("generation_resolution", options=["auto", "1k", "2k", "4k"], default="auto",
|
||||
tooltip="GPT output tier. 'auto' picks by the largest size sharing that shape."),
|
||||
IO.Float.Input("crop_threshold", default=0.08, min=0.0, max=0.5, step=0.01,
|
||||
tooltip="Sizes a crop of at most this fraction of the image can reach are only "
|
||||
"cropped and resized: no AI, no cost. The Hub uses 0.08."),
|
||||
IO.Int.Input("seed", default=0, min=0, max=2147483647, control_after_generate=True,
|
||||
tooltip="Not sent anywhere. Change it to force a re-run."),
|
||||
IO.Int.Input("timeout_seconds", default=900, min=60, max=3600, step=30, advanced=True),
|
||||
],
|
||||
outputs=[IO.String.Output("plan", tooltip="What was done for each resolution.")]
|
||||
+ [IO.Image.Output(f"image_{i}") for i in range(1, MAX_OUTPUTS + 1)],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(cls, client, image, resolutions, reflow_prompt, gpt_model, quality, generation_resolution,
|
||||
crop_threshold, seed=0, timeout_seconds=900):
|
||||
sizes = parse_resolutions(resolutions)
|
||||
original = tensor2images(image)[0].convert("RGB")
|
||||
w, h = original.size
|
||||
|
||||
# Sizes that need recomposing share one generation per GPT ratio, made for the largest of them.
|
||||
groups = {}
|
||||
for W, H in sizes:
|
||||
if needs_reflow(w, h, W, H, crop_threshold):
|
||||
groups.setdefault(nearest_ratio(W, H), []).append((W, H))
|
||||
|
||||
api = WaveSpeedClient(api_key=client["api_key"])
|
||||
bases = {}
|
||||
if groups:
|
||||
[url] = await upload_images(api, client, [original])
|
||||
|
||||
async def reflow(ratio, members):
|
||||
W, H = max(members, key=lambda s: s[0] * s[1])
|
||||
tier = generation_tier(W, H, generation_resolution)
|
||||
prompt = reflow_prompt.replace("{width}", str(W)).replace("{height}", str(H))
|
||||
payload = {"prompt": prompt, "images": [url], "aspect_ratio": ratio, "resolution": tier,
|
||||
"quality": quality, "output_format": "png"}
|
||||
print(f"[SF Banner Resizer] Reflowing to {ratio} at {tier}/{quality} for "
|
||||
f"{', '.join(f'{a}x{b}' for a, b in members)}.")
|
||||
urls = await submit_and_collect(api, client, f"/api/v3/{GPT_MODELS[gpt_model]}/edit", payload, 1,
|
||||
timeout_seconds, f"Banner Resizer {ratio}")
|
||||
[generated] = await fetch_images(urls)
|
||||
return ratio, generated.convert("RGB")
|
||||
|
||||
bases = dict(await asyncio.gather(*(reflow(r, m) for r, m in groups.items())))
|
||||
|
||||
results, report = [], []
|
||||
for W, H in sizes:
|
||||
if needs_reflow(w, h, W, H, crop_threshold):
|
||||
ratio = nearest_ratio(W, H)
|
||||
final = fill_crop(bases[ratio], W, H)
|
||||
report.append({"size": f"{W}x{H}", "mode": "reflowed by GPT Image", "gpt_ratio": ratio})
|
||||
else:
|
||||
final = fill_crop(original, W, H)
|
||||
report.append({"size": f"{W}x{H}", "mode": "crop and resize"})
|
||||
results.append(to_tensor(final))
|
||||
|
||||
print(f"[SF Banner Resizer] Done: {len(results)} image(s), {len(groups)} GPT generation(s).")
|
||||
plan = json.dumps({"source": f"{w}x{h}", "outputs": report}, indent=2)
|
||||
return IO.NodeOutput(plan, *results, *([None] * (MAX_OUTPUTS - len(results))))
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SFBannerResizer": SFBannerResizer}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFBannerResizer": "SF Banner Resizer"}
|
||||
+13
-3
@@ -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:
|
||||
|
||||
@@ -89,21 +89,47 @@ def canonicalize(claude_path, expected):
|
||||
return cleaned
|
||||
|
||||
|
||||
def checkpoint_key(mode, manifest_name, system_prompt, project_notes, all_files):
|
||||
"""A fingerprint of this exact job, so a checkpoint only resumes a matching rerun."""
|
||||
def checkpoint_key(mode, manifest_name, system_prompt, project_notes):
|
||||
"""A fingerprint of this job's settings, so a checkpoint only resumes a
|
||||
matching rerun.
|
||||
|
||||
Deliberately excludes the file list: adding or removing an asset should not
|
||||
throw away a half-finished run, and entries are keyed by path anyway, so
|
||||
stale ones are filtered out when the manifest is assembled.
|
||||
"""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"mode": mode,
|
||||
"manifest_name": manifest_name,
|
||||
"system_prompt": system_prompt,
|
||||
"project_notes": project_notes,
|
||||
"files": all_files,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def load_existing_manifest(path):
|
||||
"""A previously written manifest, as (entries by file, project, art_style).
|
||||
|
||||
This is what makes a rerun incremental: anything already described here is
|
||||
not sent to Claude again.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
return {}, None, None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}, None, None
|
||||
|
||||
entries = {}
|
||||
for entry in data.get("assets") or []:
|
||||
if isinstance(entry, dict) and entry.get("file"):
|
||||
entries[entry["file"]] = entry
|
||||
return entries, data.get("project"), data.get("art_style")
|
||||
|
||||
|
||||
def load_checkpoint(root, key):
|
||||
path = os.path.join(root, CHECKPOINT_NAME)
|
||||
if not os.path.isfile(path):
|
||||
@@ -276,6 +302,18 @@ class SFClaudeLibraryBuilder:
|
||||
),
|
||||
},
|
||||
),
|
||||
"update_existing": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": (
|
||||
"On: reuse descriptions already in the manifest and only "
|
||||
"catalogue files that are new, so adding assets costs only "
|
||||
"those assets. Entries whose file has since been deleted are "
|
||||
"dropped. Off: describe every asset again from scratch."
|
||||
),
|
||||
},
|
||||
),
|
||||
"batch_size": (
|
||||
"INT",
|
||||
{
|
||||
@@ -333,6 +371,10 @@ once, and tracks progress in a hidden checkpoint file. If a run fails partway,
|
||||
rerunning with the same settings resumes from where it stopped instead of
|
||||
re-paying for batches already done.
|
||||
|
||||
With **update_existing** on (the default), rerunning against a library that
|
||||
already has a manifest only catalogues files that are new — so adding assets
|
||||
costs just those assets. Entries whose file has since been deleted are dropped.
|
||||
|
||||
Defaults to **manifest only**, which touches nothing: you get the JSON as text to
|
||||
read and save yourself. Switch `write_manifest` on to have it saved into the
|
||||
library, or change `mode` to let Claude reorganise and rename the files too.
|
||||
@@ -349,6 +391,7 @@ Budget about 5 seconds per asset — it opens every image.
|
||||
system_prompt,
|
||||
manifest_name,
|
||||
write_manifest,
|
||||
update_existing,
|
||||
batch_size,
|
||||
model,
|
||||
timeout_seconds,
|
||||
@@ -377,21 +420,51 @@ Budget about 5 seconds per asset — it opens every image.
|
||||
if not assets:
|
||||
raise ValueError(f"No image files found in {root}")
|
||||
|
||||
key = checkpoint_key(mode, manifest_name, system_prompt, project_notes, assets)
|
||||
checkpoint = load_checkpoint(root, key)
|
||||
manifest_root = out_dir if mode == MODE_COPY else root
|
||||
manifest_path = os.path.join(manifest_root, manifest_name)
|
||||
|
||||
done_by_file = {}
|
||||
project = art_style = None
|
||||
|
||||
# An existing manifest is the durable record of what is already described,
|
||||
# so a rerun only pays for assets that are genuinely new. Skipped for the
|
||||
# copy mode, where the manifest holds post-rename paths that cannot be
|
||||
# matched against a scan of the untouched source folder.
|
||||
reused = 0
|
||||
if update_existing and mode != MODE_COPY:
|
||||
existing, prev_project, prev_style = load_existing_manifest(manifest_path)
|
||||
if existing:
|
||||
done_by_file.update(existing)
|
||||
project, art_style = prev_project, prev_style
|
||||
reused = sum(1 for f in assets if f in existing)
|
||||
gone = len(existing) - reused
|
||||
print(
|
||||
f"[SF Claude Library Builder] Found {manifest_name}: reusing "
|
||||
f"{reused} existing description(s)"
|
||||
+ (f", dropping {gone} for deleted file(s)" if gone else "")
|
||||
+ "."
|
||||
)
|
||||
|
||||
# A checkpoint from an interrupted run is newer than the manifest, so it wins.
|
||||
key = checkpoint_key(mode, manifest_name, system_prompt, project_notes)
|
||||
checkpoint = load_checkpoint(root, key)
|
||||
if checkpoint:
|
||||
project = checkpoint.get("project")
|
||||
art_style = checkpoint.get("art_style")
|
||||
for entry in checkpoint.get("done_assets", []):
|
||||
if entry.get("file"):
|
||||
done_by_file[entry["file"]] = entry
|
||||
project = checkpoint.get("project") or project
|
||||
art_style = checkpoint.get("art_style") or art_style
|
||||
print(
|
||||
f"[SF Claude Library Builder] Resuming — {len(done_by_file)}/{len(assets)} "
|
||||
f"asset(s) already catalogued from a previous run."
|
||||
f"[SF Claude Library Builder] Resuming an interrupted run — "
|
||||
f"{len(done_by_file)}/{len(assets)} asset(s) already catalogued."
|
||||
)
|
||||
|
||||
todo = sum(1 for f in assets if f not in done_by_file)
|
||||
if todo == 0:
|
||||
print("[SF Claude Library Builder] Nothing new to catalogue.")
|
||||
else:
|
||||
print(f"[SF Claude Library Builder] {todo} asset(s) to catalogue.")
|
||||
|
||||
# Claude only ever reads images and replies with JSON; the node writes the
|
||||
# manifest itself, and the Write tool is never offered. Bash is needed
|
||||
# only to move/rename files in the organise modes.
|
||||
@@ -534,8 +607,6 @@ Budget about 5 seconds per asset — it opens every image.
|
||||
}
|
||||
manifest_text = json.dumps(manifest_data, indent=2)
|
||||
|
||||
manifest_root = out_dir if mode == MODE_COPY else root
|
||||
manifest_path = os.path.join(manifest_root, manifest_name)
|
||||
if write_manifest:
|
||||
os.makedirs(manifest_root, exist_ok=True)
|
||||
with open(manifest_path, "w", encoding="utf-8") as f:
|
||||
@@ -547,11 +618,13 @@ Budget about 5 seconds per asset — it opens every image.
|
||||
for entry in final_assets:
|
||||
counts[entry.get("category", "?")] = counts.get(entry.get("category", "?"), 0) + 1
|
||||
|
||||
newly_done = max(0, len(final_assets) - reused)
|
||||
report = "\n".join(
|
||||
[
|
||||
f"mode: {mode}",
|
||||
f"scanned: {len(assets)} file(s) in {root}",
|
||||
f"catalogued: {len(final_assets)} asset(s) across {len(batches)} batch(es)",
|
||||
f"in manifest: {len(final_assets)} asset(s)",
|
||||
f"reused: {reused} already described, {newly_done} newly catalogued",
|
||||
"categories: " + (", ".join(f"{k} x{v}" for k, v in sorted(counts.items())) or "none"),
|
||||
f"art style: {manifest_data['art_style'] or '(not set)'}",
|
||||
"manifest: " + (manifest_path if write_manifest else "returned as text, not saved"),
|
||||
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,417 @@
|
||||
# ABOUTME: Reframes one image into several resolutions WITHOUT redrawing it: a WaveSpeed fill model paints only the
|
||||
# ABOUTME: new strips (FLUX Fill, Bria GenFill, Bria Expand), the original is pasted back, then each size is scaled
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageFilter, ImageOps
|
||||
|
||||
from comfy_api.latest import IO
|
||||
|
||||
from .wavespeed_api.client import WaveSpeedClient
|
||||
from .wavespeed_api.outpaint import (
|
||||
canvas_and_mask,
|
||||
crop_around_original,
|
||||
fit_source_to_target,
|
||||
geometry,
|
||||
locate_original,
|
||||
model_size,
|
||||
paste_back,
|
||||
pick_expand_ratio,
|
||||
same_shape,
|
||||
)
|
||||
from .wavespeed_api.utils import tensor2images
|
||||
from .wavespeed_api.v3 import fetch_images, submit_and_wait, upload_images
|
||||
|
||||
MAX_OUTPUTS = 8
|
||||
LABEL = "Outpaint Resizer"
|
||||
|
||||
# method -> (endpoint, price per generation in USD, read from wavespeed.ai 2026-11-09)
|
||||
METHODS = {
|
||||
"flux-fill": ("/api/v3/wavespeed-ai/flux-fill-dev", 0.035),
|
||||
"bria-genfill": ("/api/v3/bria/fibo-edit/genfill", 0.04),
|
||||
"bria-expand": ("/api/v3/bria/expand", 0.04),
|
||||
}
|
||||
EXPAND_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9"]
|
||||
|
||||
# The whole prompt, shown in the node's prompt box; nothing is added to it. The last sentence is said
|
||||
# positively on purpose: FLUX's text encoder handles "no X" poorly, and naming "signature" in the prompt
|
||||
# can make one MORE likely (an earlier "No text, no signature..." wording did not stop it).
|
||||
DEFAULT_PROMPT = (
|
||||
"Continue the surrounding scene seamlessly into the empty areas, in exactly the same art style, "
|
||||
"lighting, colour palette and level of detail as the rest of the image. Plain, unmarked painted "
|
||||
"surface right up to every edge and corner of the picture."
|
||||
)
|
||||
DEFAULT_NEGATIVE = "text, signature, watermark, logo, letters, initials, border, frame"
|
||||
|
||||
# Where FLUX tends to sign: the bottom corners of the whole picture. Fraction of the canvas per corner.
|
||||
CORNER_W, CORNER_H = 0.22, 0.18
|
||||
|
||||
|
||||
MODES = ("auto", "crop", "outpaint")
|
||||
|
||||
|
||||
def parse_resolutions(text):
|
||||
"""The size list as [(W, H, mode)]. The list editor stores JSON rows; plain text (one WIDTHxHEIGHT
|
||||
per line, optionally followed by a mode) still works, so older workflows keep their sizes."""
|
||||
rows = []
|
||||
raw = (text or "").strip()
|
||||
if raw.startswith("["):
|
||||
try:
|
||||
entries = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"The resolution list could not be read ({e}).")
|
||||
for e in entries:
|
||||
rows.append((int(e.get("w", 0)), int(e.get("h", 0)), str(e.get("mode", "auto")).lower()))
|
||||
else:
|
||||
for token in re.split(r"[,\n;]+", raw):
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
match = re.fullmatch(r"(\d+)\s*[xX×*]\s*(\d+)(?:\s+(\w+))?", token)
|
||||
if not match:
|
||||
raise ValueError(f"Can't read resolution '{token}'. Write each as WIDTHxHEIGHT, e.g. 1920x1080.")
|
||||
rows.append((int(match.group(1)), int(match.group(2)), (match.group(3) or "auto").lower()))
|
||||
for w, h, mode in rows:
|
||||
if not (16 <= w <= 8192 and 16 <= h <= 8192):
|
||||
raise ValueError(f"Resolution {w}x{h} is outside 16-8192 pixels.")
|
||||
if mode not in MODES:
|
||||
raise ValueError(f"Unknown mode '{mode}' for {w}x{h}; use auto, crop or outpaint.")
|
||||
if not rows:
|
||||
raise ValueError("Add at least one resolution.")
|
||||
if len(rows) > MAX_OUTPUTS:
|
||||
raise ValueError(f"At most {MAX_OUTPUTS} resolutions per node; got {len(rows)}.")
|
||||
return rows
|
||||
|
||||
|
||||
def crop_fraction(sw, sh, W, H):
|
||||
"""How much of the image a scale-and-centre-crop to WxH cuts away (0..1)."""
|
||||
a, A = sw / sh, W / H
|
||||
return 1 - min(a, A) / max(a, A)
|
||||
|
||||
|
||||
def decide(sw, sh, W, H, mode, auto_limit):
|
||||
"""'resize' (same shape), 'crop' or 'outpaint' for one size."""
|
||||
if same_shape(sw, sh, W, H):
|
||||
return "resize"
|
||||
if mode == "crop" or (mode == "auto" and crop_fraction(sw, sh, W, H) <= auto_limit + 1e-9):
|
||||
return "crop"
|
||||
return "outpaint"
|
||||
|
||||
|
||||
def shape_key(w, h):
|
||||
"""Sizes whose shapes differ by under 0.5% share one generation (1920x1080 and 1280x720 do)."""
|
||||
return round(math.log(w / h), 2)
|
||||
|
||||
|
||||
def to_tensor(image):
|
||||
return torch.from_numpy(np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0)[None,]
|
||||
|
||||
|
||||
def exact(image, W, H):
|
||||
"""Scale to WxH; the canvas already has that shape, so at most a pixel of rounding is cropped."""
|
||||
return ImageOps.fit(image, (W, H), method=Image.LANCZOS, centering=(0.5, 0.5))
|
||||
|
||||
|
||||
MASK_SIDES = ["right", "left", "top", "bottom"]
|
||||
|
||||
|
||||
def fit_edge_mask(mask, side, W, H, threshold=0.02):
|
||||
"""Carries a shaped edge (e.g. a torn-paper right edge) over to a WxH output.
|
||||
|
||||
`mask` is HxW in 0..1, 1 = cut away. The band of the mask that holds the shape is taken from
|
||||
`side` (from that edge in to the furthest masked pixel), scaled uniformly so the whole shape spans
|
||||
exactly the length of the output's edge on that side, and placed flush against it. Never tiled,
|
||||
mirrored or stretched. Everything else is kept (0)."""
|
||||
mh, mw = mask.shape
|
||||
out = np.zeros((H, W), dtype=np.float32)
|
||||
horizontal = side in ("left", "right")
|
||||
profile = mask.max(axis=0 if horizontal else 1) > threshold
|
||||
if not profile.any():
|
||||
return out
|
||||
idx = np.nonzero(profile)[0]
|
||||
if side == "right":
|
||||
band = mask[:, idx.min():]
|
||||
elif side == "left":
|
||||
band = mask[:, :idx.max() + 1]
|
||||
elif side == "bottom":
|
||||
band = mask[idx.min():, :]
|
||||
else:
|
||||
band = mask[:idx.max() + 1, :]
|
||||
bh, bw = band.shape
|
||||
scale = H / mh if horizontal else W / mw
|
||||
nw, nh = (max(1, round(bw * scale)), H) if horizontal else (W, max(1, round(bh * scale)))
|
||||
resized = np.asarray(Image.fromarray(band.astype(np.float32), mode="F").resize((nw, nh), Image.BILINEAR))
|
||||
if side == "right":
|
||||
take = min(nw, W)
|
||||
out[:, W - take:] = resized[:, nw - take:]
|
||||
elif side == "left":
|
||||
take = min(nw, W)
|
||||
out[:, :take] = resized[:, :take]
|
||||
elif side == "bottom":
|
||||
take = min(nh, H)
|
||||
out[H - take:, :] = resized[nh - take:, :]
|
||||
else:
|
||||
take = min(nh, H)
|
||||
out[:take, :] = resized[:take, :]
|
||||
return np.clip(out, 0.0, 1.0)
|
||||
|
||||
|
||||
def clean_masked_area(image, mask):
|
||||
"""Fills the cut-away area of the input from its surroundings, so the model never sees (and
|
||||
extends) whatever sits under a torn edge."""
|
||||
import cv2
|
||||
|
||||
hole = (np.asarray(Image.fromarray((mask * 255).astype(np.uint8)).resize(image.size, Image.BILINEAR)) > 127)
|
||||
if not hole.any():
|
||||
return image
|
||||
hole = cv2.dilate(hole.astype(np.uint8) * 255, np.ones((5, 5), np.uint8))
|
||||
filled = cv2.inpaint(np.asarray(image.convert("RGB")), hole, 7, cv2.INPAINT_TELEA)
|
||||
return Image.fromarray(filled)
|
||||
|
||||
|
||||
def with_alpha(image_tensor, cut):
|
||||
"""RGB tensor (1,H,W,3) plus a cut-away mask (H,W) -> RGBA tensor, transparent where cut is 1."""
|
||||
alpha = torch.from_numpy(1.0 - cut)[None, :, :, None].to(image_tensor)
|
||||
return torch.cat((image_tensor[..., :3], alpha), dim=-1)
|
||||
|
||||
|
||||
def corner_mask(cw, ch, ox, oy, sw, sh):
|
||||
"""White over the bottom-left and bottom-right corners of the canvas, but only where the canvas is new:
|
||||
the original is never part of it. None when neither corner touches new canvas."""
|
||||
mask = Image.new("L", (cw, ch), 0)
|
||||
w, h = max(48, round(cw * CORNER_W)), max(48, round(ch * CORNER_H))
|
||||
mask.paste(255, (0, ch - h, w, ch))
|
||||
mask.paste(255, (cw - w, ch - h, cw, ch))
|
||||
mask.paste(0, (ox, oy, ox + sw, oy + sh))
|
||||
return mask if mask.getbbox() else None
|
||||
|
||||
|
||||
class SFWaveSpeedOutpaintResizer(IO.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="SFWaveSpeedOutpaintResizer",
|
||||
display_name="SF WaveSpeed Outpaint Resizer",
|
||||
category="Stillfront/WaveSpeed",
|
||||
description=(
|
||||
"Turns one image into several resolutions without redrawing it. For each new shape, a WaveSpeed "
|
||||
"fill model paints only the strips that need adding (FLUX Fill, Bria GenFill or Bria Expand); "
|
||||
"the original's own pixels are pasted back on top, and the whole picture is scaled to each "
|
||||
"exact size so old and new areas share one level of detail. Sizes with the image's own shape are "
|
||||
"just resized, for free. One output per resolution."
|
||||
),
|
||||
inputs=[
|
||||
IO.Custom("WAVESPEED_AI_API_CLIENT").Input("client"),
|
||||
IO.Image.Input("image", tooltip="The finished artwork. Only the first frame of a batch is used."),
|
||||
IO.String.Input("resolutions", multiline=True,
|
||||
default='[{"w": 380, "h": 380, "mode": "auto"}, {"w": 998, "h": 432, "mode": "auto"}]',
|
||||
tooltip=f"The output sizes (up to {MAX_OUTPUTS}), edited in the list on the node. "
|
||||
"Each adds an output, in the same order. Per size: auto crops when that cuts "
|
||||
"away no more than auto_crop_limit of the picture and outpaints otherwise; crop "
|
||||
"always crops; outpaint always paints new area."),
|
||||
IO.String.Input("prompt", multiline=True, default=DEFAULT_PROMPT,
|
||||
tooltip="The whole prompt every model receives, sent exactly as written. Naming "
|
||||
"the scene helps a lot, e.g. 'World War I trench at night, sandbags, barbed wire, "
|
||||
"smoke, painterly'. Empty the box to go back to the default."),
|
||||
IO.DynamicCombo.Input(
|
||||
"method",
|
||||
tooltip="Price is per distinct shape. flux-fill: cleanest joins, best when strips go "
|
||||
"above/below. bria-genfill: best when strips go left/right. bria-expand: strong on squares.",
|
||||
options=[
|
||||
IO.DynamicCombo.Option("flux-fill", [
|
||||
IO.Int.Input("steps", default=28, min=1, max=50,
|
||||
tooltip="Inference steps. WaveSpeed's default is 28."),
|
||||
IO.Float.Input("guidance", default=30.0, min=28.0, max=35.0, step=0.5,
|
||||
tooltip="How closely it follows the prompt (28-35)."),
|
||||
IO.Boolean.Input("clean_corners", default=True,
|
||||
tooltip="FLUX often paints a fake artist signature in a bottom "
|
||||
"corner. When on, Bria GenFill repaints the bottom corners of the "
|
||||
"NEW areas afterwards (never the original). +$0.04 per shape."),
|
||||
]),
|
||||
IO.DynamicCombo.Option("bria-genfill", []),
|
||||
IO.DynamicCombo.Option("bria-expand", [
|
||||
IO.String.Input("negative_prompt", multiline=True, default=DEFAULT_NEGATIVE,
|
||||
tooltip="What Bria Expand should avoid painting."),
|
||||
IO.Combo.Input("if_shape_impossible", options=["use bria-genfill", "stop with error"],
|
||||
default="use bria-genfill",
|
||||
tooltip="Bria Expand only makes 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, "
|
||||
"9:16 and 16:9 canvases, so it can't, for example, widen a 16:9 "
|
||||
"image. Those sizes are made with Bria GenFill instead, or stop."),
|
||||
]),
|
||||
],
|
||||
),
|
||||
IO.Int.Input("seed", default=0, min=0, max=2147483647, control_after_generate=True,
|
||||
tooltip="Sent to FLUX Fill and Bria Expand; Bria GenFill has no seed, so there "
|
||||
"it only forces a re-run."),
|
||||
IO.Int.Input("timeout_seconds", default=600, min=60, max=3600, step=30, advanced=True),
|
||||
# Appended last: saved workflows restore widget values by position.
|
||||
IO.Mask.Input("edge_mask", optional=True,
|
||||
tooltip="Optional. A mask of a shaped edge (torn paper, brush stroke) to cut out of "
|
||||
"every output. White = cut away. Leave unconnected for plain images."),
|
||||
IO.Combo.Input("mask_side", options=MASK_SIDES, default="right",
|
||||
tooltip="Which side of the image the edge_mask shape belongs to. The whole shape is "
|
||||
"scaled to the full length of that side of each output (never stretched or "
|
||||
"tiled) and placed flush against it."),
|
||||
IO.Boolean.Input("invert_mask", default=False,
|
||||
tooltip="Turn on if the wrong part comes out transparent."),
|
||||
IO.Float.Input("auto_crop_limit", default=0.15, min=0.0, max=0.5, step=0.01,
|
||||
tooltip="For sizes set to auto: crop (free) when a crop cuts away at most this "
|
||||
"fraction of the picture, otherwise outpaint."),
|
||||
],
|
||||
outputs=[IO.String.Output("plan", tooltip="What was done for each resolution."),
|
||||
IO.Image.Output("mask_preview", tooltip="The first generation's mask: white is what the "
|
||||
"model was allowed to paint.")]
|
||||
+ [IO.Image.Output(f"image_{i}") for i in range(1, MAX_OUTPUTS + 1)],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(cls, client, image, resolutions, prompt, method, seed=0, timeout_seconds=600,
|
||||
edge_mask=None, mask_side="right", invert_mask=False, auto_crop_limit=0.15):
|
||||
chosen = method["method"]
|
||||
if chosen not in METHODS:
|
||||
raise ValueError(f"Unknown method '{chosen}'. Pick one of: {', '.join(METHODS)}.")
|
||||
sizes = parse_resolutions(resolutions)
|
||||
original = tensor2images(image)[0].convert("RGB")
|
||||
sw, sh = original.size
|
||||
cut = None
|
||||
if edge_mask is not None:
|
||||
cut = edge_mask[0] if edge_mask.dim() == 3 else edge_mask
|
||||
cut = cut.detach().cpu().numpy().astype(np.float32)
|
||||
if invert_mask:
|
||||
cut = 1.0 - cut
|
||||
original = clean_masked_area(original, cut)
|
||||
|
||||
actions = [decide(sw, sh, W, H, mode, auto_crop_limit) for W, H, mode in sizes]
|
||||
sizes = [(W, H) for W, H, _ in sizes]
|
||||
|
||||
# One generation per distinct shape that is outpainted, sized for the largest resolution of that shape.
|
||||
groups = {}
|
||||
for (W, H), action in zip(sizes, actions):
|
||||
if action == "outpaint":
|
||||
groups.setdefault(shape_key(W, H), []).append((W, H))
|
||||
largest = {k: max(m, key=lambda s: s[0] * s[1]) for k, m in groups.items()}
|
||||
|
||||
# Which model makes each shape. Bria Expand can't make every shape; decided before anything is spent.
|
||||
made_with = {k: chosen for k in groups}
|
||||
if chosen == "bria-expand":
|
||||
impossible = [k for k, (W, H) in largest.items()
|
||||
if pick_expand_ratio(sw, sh, W, H, EXPAND_RATIOS) is None]
|
||||
if impossible and method.get("if_shape_impossible") == "stop with error":
|
||||
names = ", ".join("x".join(map(str, largest[k])) for k in impossible)
|
||||
raise ValueError(
|
||||
f"Bria Expand can't make {names} from a {sw}x{sh} image: it only builds "
|
||||
f"{', '.join(EXPAND_RATIOS)} canvases, none of which contains that shape. Set "
|
||||
"if_shape_impossible to 'use bria-genfill', or use another method.")
|
||||
for k in impossible:
|
||||
made_with[k] = "bria-genfill"
|
||||
|
||||
# Sent exactly as written; an empty box falls back to the default.
|
||||
text = prompt.strip() or DEFAULT_PROMPT
|
||||
api = WaveSpeedClient(api_key=client["api_key"])
|
||||
masks, notes, spent = {}, {}, []
|
||||
|
||||
async def masked_fill(model, canvas, mask, cw, ch, label):
|
||||
"""One FLUX Fill or Bria GenFill call on `canvas`, painting where `mask` is white. Returns the
|
||||
result at canvas size."""
|
||||
mw, mh = model_size(cw, ch)
|
||||
canvas_url, mask_url = await upload_images(
|
||||
api, client, [canvas.resize((mw, mh), Image.LANCZOS), mask.resize((mw, mh), Image.LANCZOS)])
|
||||
if model == "flux-fill":
|
||||
payload = {"prompt": text, "image": canvas_url, "mask_image": mask_url, "size": f"{mw}*{mh}",
|
||||
"num_inference_steps": method.get("steps", 28),
|
||||
"guidance_scale": method.get("guidance", 30.0), "seed": seed}
|
||||
else:
|
||||
payload = {"prompt": text, "image": canvas_url, "mask_image": mask_url}
|
||||
endpoint, price = METHODS[model]
|
||||
_, result = await submit_and_wait(api, client, endpoint, payload, timeout_seconds, label)
|
||||
spent.append(price)
|
||||
[generated] = await fetch_images(result.get("outputs") or [])
|
||||
return generated.convert("RGB").resize((cw, ch), Image.LANCZOS)
|
||||
|
||||
async def fill(key):
|
||||
W, H = largest[key]
|
||||
model = made_with[key]
|
||||
src = fit_source_to_target(original, W, H)
|
||||
if model == "bria-expand":
|
||||
return key, await expand(src, W, H)
|
||||
cw, ch, ox, oy = geometry(src.width, src.height, W, H)
|
||||
canvas, mask = canvas_and_mask(src, cw, ch, ox, oy)
|
||||
masks[key] = mask
|
||||
print(f"[SF {LABEL}] {model}: {src.width}x{src.height} -> canvas {cw}x{ch} for {W}x{H}.")
|
||||
result = paste_back(await masked_fill(model, canvas, mask, cw, ch, f"{LABEL} {W}x{H}"), src, ox, oy)
|
||||
|
||||
if model == "flux-fill" and method.get("clean_corners", True):
|
||||
corners = corner_mask(cw, ch, ox, oy, src.width, src.height)
|
||||
if corners is not None:
|
||||
print(f"[SF {LABEL}] Repainting the new bottom corners of {W}x{H} with Bria GenFill.")
|
||||
redone = await masked_fill("bria-genfill", result, corners, cw, ch, f"{LABEL} corners {W}x{H}")
|
||||
soft = corners.filter(ImageFilter.GaussianBlur(max(2, round(max(cw, ch) / 200))))
|
||||
result = paste_back(Image.composite(redone, result, soft), src, ox, oy)
|
||||
notes[key] = "bottom corners repainted with bria-genfill"
|
||||
return key, result
|
||||
|
||||
async def expand(src, W, H):
|
||||
ratio = pick_expand_ratio(src.width, src.height, W, H, EXPAND_RATIOS)
|
||||
[url] = await upload_images(api, client, [src])
|
||||
payload = {"image": url, "aspect_ratio": ratio, "seed": seed, "prompt": text,
|
||||
"negative_prompt": method.get("negative_prompt") or DEFAULT_NEGATIVE}
|
||||
print(f"[SF {LABEL}] bria-expand: {src.width}x{src.height} at {ratio} for {W}x{H}.")
|
||||
endpoint, price = METHODS["bria-expand"]
|
||||
_, result = await submit_and_wait(api, client, endpoint, payload, timeout_seconds, f"{LABEL} {W}x{H}")
|
||||
spent.append(price)
|
||||
[out] = await fetch_images(result.get("outputs") or [])
|
||||
out = out.convert("RGB")
|
||||
found = locate_original(out, src)
|
||||
if found is None:
|
||||
raise RuntimeError(
|
||||
f"Bria Expand returned a {out.width}x{out.height} image, but the original could not be found "
|
||||
"in it, so it can't be pasted back safely. Try another seed, or flux-fill/bria-genfill.")
|
||||
scale, x, y = found
|
||||
# Bring the result to the original's own scale, paste the original back, then cut only new margin.
|
||||
out = out.resize((round(out.width / scale), round(out.height / scale)), Image.LANCZOS)
|
||||
ox, oy = min(round(x / scale), out.width - src.width), min(round(y / scale), out.height - src.height)
|
||||
out = paste_back(out, src, ox, oy)
|
||||
return crop_around_original(out, (ox, oy, ox + src.width, oy + src.height), W, H)
|
||||
|
||||
bases = dict(await asyncio.gather(*(fill(k) for k in groups)))
|
||||
|
||||
results, report = [], []
|
||||
for (W, H), action in zip(sizes, actions):
|
||||
if action == "resize":
|
||||
results.append(to_tensor(exact(original, W, H)))
|
||||
report.append({"size": f"{W}x{H}", "mode": "resized (same shape as the image), free"})
|
||||
elif action == "crop":
|
||||
results.append(to_tensor(exact(original, W, H)))
|
||||
report.append({"size": f"{W}x{H}", "mode": "scaled and centre-cropped, free",
|
||||
"cropped_away": f"{crop_fraction(sw, sh, W, H) * 100:.1f}%"})
|
||||
else:
|
||||
key = shape_key(W, H)
|
||||
results.append(to_tensor(exact(bases[key], W, H)))
|
||||
entry = {"size": f"{W}x{H}", "mode": f"outpainted with {made_with[key]}",
|
||||
"generated_at": "x".join(map(str, largest[key]))}
|
||||
if made_with[key] != chosen:
|
||||
entry["note"] = f"{chosen} can't make this shape, so {made_with[key]} was used"
|
||||
if key in notes:
|
||||
entry["note"] = notes[key]
|
||||
report.append(entry)
|
||||
|
||||
if cut is not None:
|
||||
for i, (W, H) in enumerate(sizes):
|
||||
results[i] = with_alpha(results[i], fit_edge_mask(cut, mask_side, W, H))
|
||||
report[i]["edge_mask"] = f"{mask_side} edge cut out (transparent)"
|
||||
|
||||
mask_out = to_tensor(next(iter(masks.values())).convert("RGB")) if masks else None
|
||||
cost = round(sum(spent), 3)
|
||||
print(f"[SF {LABEL}] Done: {len(results)} image(s), {len(spent)} paid call(s), ~${cost:.3f}.")
|
||||
plan = json.dumps({"source": f"{sw}x{sh}", "method": chosen, "paid_calls": len(spent),
|
||||
"estimated_cost_usd": cost, "outputs": report}, indent=2)
|
||||
return IO.NodeOutput(plan, mask_out, *results, *([None] * (MAX_OUTPUTS - len(results))))
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SFWaveSpeedOutpaintResizer": SFWaveSpeedOutpaintResizer}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedOutpaintResizer": "SF WaveSpeed Outpaint Resizer"}
|
||||
@@ -0,0 +1,295 @@
|
||||
# ABOUTME: Geometry for keep-the-original outpainting: canvas, mask, paste-back, Bria Expand placement
|
||||
# ABOUTME: Pure Pillow + numpy, ported from the Art Pipeline Hub resolution-variant-generator (tested there)
|
||||
|
||||
"""Geometry for "keep the original" reframing. Pure Pillow and numpy.
|
||||
|
||||
Nothing here talks to a network or imports the runner library, on purpose:
|
||||
every function takes pictures and numbers and returns pictures and numbers, so
|
||||
the tests can check the part that decides whether the artist's original
|
||||
survives (where it sits, what the mask covers, what the paste-back may touch)
|
||||
without any model in the loop.
|
||||
|
||||
THE IDEA. A whole-image edit model (GPT Image, Nano Banana) redraws the
|
||||
picture and moves the subject. A fill model paints only what it is told to
|
||||
paint. So:
|
||||
|
||||
1. build a canvas at the TARGET shape that holds the source at its own pixels
|
||||
(geometry, canvas_and_mask);
|
||||
2. mask only the new strips, reaching a little into the original so the model
|
||||
blends the seam (OVERLAP);
|
||||
3. after the model answers, paste the original back over the result with a
|
||||
soft edge on the grown sides only (paste_back);
|
||||
4. scale the whole canvas to the target together, so old and new areas share
|
||||
one level of detail.
|
||||
|
||||
Ported from the 2026-11-09 bake-off prototype; the prototype's detail-matching
|
||||
blur is gone because the winning results did not need it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
# Same-shape tolerance: a target within 0.5% of the source's aspect ratio is a
|
||||
# plain resize, not a generation.
|
||||
SAME_SHAPE_TOLERANCE = 0.005
|
||||
|
||||
MODEL_MIN_EDGE = 512
|
||||
MODEL_MAX_EDGE = 1536
|
||||
MODEL_SHORT_MIN = 256 # flux-fill-dev refuses anything under 256 on a side
|
||||
MODEL_STEP = 16
|
||||
|
||||
|
||||
def same_shape(sw: int, sh: int, tw: int, th: int, tolerance: float = SAME_SHAPE_TOLERANCE) -> bool:
|
||||
return abs(math.log(tw / th) - math.log(sw / sh)) <= math.log(1 + tolerance)
|
||||
|
||||
|
||||
def overlap_feather(canvas_longest: int) -> tuple[int, int]:
|
||||
"""OVERLAP 24 / FEATHER 16 at ~800 px, scaled linearly, clamped to 8..64."""
|
||||
scale = canvas_longest / 800
|
||||
return (
|
||||
int(min(64, max(8, round(24 * scale)))),
|
||||
int(min(64, max(8, round(16 * scale)))),
|
||||
)
|
||||
|
||||
|
||||
def geometry(sw: int, sh: int, tw: int, th: int) -> tuple[int, int, int, int]:
|
||||
"""Smallest canvas at the target's shape that holds the source at its own
|
||||
pixels. Returns (canvas_w, canvas_h, offset_x, offset_y), the source
|
||||
centred on the axis that grew."""
|
||||
if tw / th >= sw / sh:
|
||||
cw, ch = round(sh * tw / th), sh
|
||||
else:
|
||||
cw, ch = sw, round(sw * th / tw)
|
||||
cw, ch = max(cw, sw), max(ch, sh)
|
||||
return cw, ch, (cw - sw) // 2, (ch - sh) // 2
|
||||
|
||||
|
||||
def fit_source_to_target(src: Image.Image, largest_tw: int, largest_th: int) -> Image.Image:
|
||||
"""The downscale rule: if the canvas would be bigger than the largest
|
||||
target in the group needs, scale the source down first so the model works
|
||||
at the size that will be delivered. Never scales UP — the original's own
|
||||
pixels are the point."""
|
||||
cw, ch, _, _ = geometry(src.width, src.height, largest_tw, largest_th)
|
||||
scale = min(1.0, largest_tw / cw, largest_th / ch)
|
||||
if scale >= 1.0:
|
||||
return src
|
||||
size = (max(1, round(src.width * scale)), max(1, round(src.height * scale)))
|
||||
return src.resize(size, Image.LANCZOS)
|
||||
|
||||
|
||||
def _grown(cw: int, ch: int, ox: int, oy: int, sw: int, sh: int) -> tuple[bool, bool, bool, bool]:
|
||||
"""Which sides of the source have new canvas beside them: left, top, right, bottom."""
|
||||
return ox > 0, oy > 0, cw - ox - sw > 0, ch - oy - sh > 0
|
||||
|
||||
|
||||
def canvas_and_mask(
|
||||
src: Image.Image, cw: int, ch: int, ox: int, oy: int
|
||||
) -> tuple[Image.Image, Image.Image]:
|
||||
"""The canvas the model is given and the mask over it (255 = paint here).
|
||||
|
||||
The new area is prefilled with the source stretched and blurred so the
|
||||
model starts from the right colours. The mask is 255 over everything new
|
||||
and 0 over the original, EXCEPT an OVERLAP band reaching into the original
|
||||
on the sides that grew — a side that did not grow keeps its 0 right to the
|
||||
edge."""
|
||||
sw, sh = src.size
|
||||
overlap, _ = overlap_feather(max(cw, ch))
|
||||
overlap = max(1, min(overlap, sw // 4, sh // 4))
|
||||
blur = min(80, max(8, round(40 * max(cw, ch) / 800)))
|
||||
canvas = src.resize((cw, ch), Image.BICUBIC).filter(ImageFilter.GaussianBlur(blur))
|
||||
canvas.paste(src, (ox, oy))
|
||||
|
||||
left, top, right, bottom = _grown(cw, ch, ox, oy, sw, sh)
|
||||
mask = Image.new("L", (cw, ch), 255)
|
||||
inner = (
|
||||
ox + (overlap if left else 0),
|
||||
oy + (overlap if top else 0),
|
||||
ox + sw - (overlap if right else 0),
|
||||
oy + sh - (overlap if bottom else 0),
|
||||
)
|
||||
mask.paste(0, inner)
|
||||
return canvas, mask
|
||||
|
||||
|
||||
def model_size(cw: int, ch: int) -> tuple[int, int]:
|
||||
"""Both sides multiples of 16; longest edge clamp(canvas longest, 512,
|
||||
1536), raised only as far as needed to keep the short side at 256+ (a
|
||||
very wide canvas would otherwise fall under the model's minimum)."""
|
||||
longest = min(MODEL_MAX_EDGE, max(MODEL_MIN_EDGE, max(cw, ch)))
|
||||
shortest = min(cw, ch)
|
||||
longest = min(MODEL_MAX_EDGE, max(longest, math.ceil(MODEL_SHORT_MIN * max(cw, ch) / shortest)))
|
||||
scale = longest / max(cw, ch)
|
||||
step = MODEL_STEP
|
||||
return (
|
||||
max(MODEL_SHORT_MIN, round(cw * scale / step) * step),
|
||||
max(MODEL_SHORT_MIN, round(ch * scale / step) * step),
|
||||
)
|
||||
|
||||
|
||||
def paste_back(gen: Image.Image, src: Image.Image, ox: int, oy: int) -> Image.Image:
|
||||
"""The original's own pixels win everywhere except a soft band at the seam
|
||||
on the sides that grew. On a side that did not grow the original is kept
|
||||
to the very edge."""
|
||||
sw, sh = src.size
|
||||
_, feather = overlap_feather(max(gen.width, gen.height))
|
||||
feather = max(1, min(feather, sw // 4, sh // 4))
|
||||
left, top, right, bottom = _grown(gen.width, gen.height, ox, oy, sw, sh)
|
||||
|
||||
# Built on a padded sheet and cropped, so the blur cannot fade the edges
|
||||
# of a side that did not grow: there the 255 runs on into the padding.
|
||||
pad = feather * 4
|
||||
sheet = Image.new("L", (sw + 2 * pad, sh + 2 * pad), 0)
|
||||
sheet.paste(255, (
|
||||
pad + (feather if left else -pad),
|
||||
pad + (feather if top else -pad),
|
||||
pad + sw - (feather if right else -pad),
|
||||
pad + sh - (feather if bottom else -pad),
|
||||
))
|
||||
alpha = sheet.filter(ImageFilter.GaussianBlur(feather / 2)).crop((pad, pad, pad + sw, pad + sh))
|
||||
out = gen.copy()
|
||||
out.paste(src, (ox, oy), alpha)
|
||||
return out
|
||||
|
||||
|
||||
def new_boxes(cw: int, ch: int, ox: int, oy: int, sw: int, sh: int) -> list[tuple[int, int, int, int]]:
|
||||
"""The strips of new canvas, one box per side that grew."""
|
||||
left, top, right, bottom = _grown(cw, ch, ox, oy, sw, sh)
|
||||
boxes = []
|
||||
if left:
|
||||
boxes.append((0, 0, ox, ch))
|
||||
if right:
|
||||
boxes.append((ox + sw, 0, cw, ch))
|
||||
if top:
|
||||
boxes.append((0, 0, cw, oy))
|
||||
if bottom:
|
||||
boxes.append((0, oy + sh, cw, ch))
|
||||
return boxes
|
||||
|
||||
|
||||
# ---- Bria Expand: it takes only a ratio from a short list ----
|
||||
|
||||
def _ratio(label: str) -> float:
|
||||
left, _, right = str(label).partition(":")
|
||||
return float(left) / float(right)
|
||||
|
||||
|
||||
def pick_expand_ratio(
|
||||
sw: int, sh: int, tw: int, th: int, enum_labels: list[str] | tuple[str, ...]
|
||||
) -> str | None:
|
||||
"""The enum ratio whose expanded canvas still contains the target's
|
||||
minimal canvas, nearest to the target. None when no ratio can.
|
||||
|
||||
The expanded canvas holds the source at its own scale, centred. A wide
|
||||
target (wider than the source) is contained by a canvas at least as wide;
|
||||
a narrow one by a canvas at least as narrow; a canvas on the other side of
|
||||
the source's own shape never contains it. So a 16:9 source cannot be made
|
||||
wider than 16:9 here."""
|
||||
target = tw / th
|
||||
source = sw / sh
|
||||
slack = 1 + SAME_SHAPE_TOLERANCE
|
||||
best: tuple[float, str] | None = None
|
||||
for label in enum_labels:
|
||||
r = _ratio(label)
|
||||
if target >= source:
|
||||
ok = r >= target / slack and r >= source / slack
|
||||
distance = r - target
|
||||
else:
|
||||
ok = r <= target * slack and r <= source * slack
|
||||
distance = target - r
|
||||
if ok and (best is None or abs(distance) < best[0]):
|
||||
best = (abs(distance), label)
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def locate_original(
|
||||
out: Image.Image,
|
||||
src: Image.Image,
|
||||
max_mean_diff: float = 20.0,
|
||||
) -> tuple[float, int, int] | None:
|
||||
"""Where did the model put the original? Returns (scale, x, y) with the
|
||||
source scaled by `scale` landing at (x, y) in `out`, or None when nothing
|
||||
in `out` looks like the source — a refusal beats a guess, because a guess
|
||||
would paste the original over the wrong place.
|
||||
|
||||
The scale starts from the axis that filled (the one that did not grow) and
|
||||
is tried at 0.97-1.03 of that. A coarse search on a small grayscale copy
|
||||
finds the neighbourhood; a finer one at full resolution settles the pixel."""
|
||||
sw, sh = src.size
|
||||
base = min(out.width / sw, out.height / sh)
|
||||
candidates = [round(base * f, 4) for f in np.linspace(0.97, 1.03, 7)]
|
||||
|
||||
out_gray = np.asarray(out.convert("L"), dtype=np.float32)
|
||||
src_img = src.convert("L")
|
||||
|
||||
coarse_f = min(1.0, 128 / max(out.size))
|
||||
out_small = np.asarray(out.convert("L").resize(
|
||||
(max(1, round(out.width * coarse_f)), max(1, round(out.height * coarse_f))), Image.BILINEAR
|
||||
), dtype=np.float32)
|
||||
|
||||
per_scale: list[tuple[float, float, int, int]] = [] # coarse diff, scale, x, y (full res)
|
||||
for scale in candidates:
|
||||
w, h = round(sw * scale), round(sh * scale)
|
||||
if w < 8 or h < 8 or w > out.width or h > out.height:
|
||||
continue
|
||||
sw_s, sh_s = max(1, round(w * coarse_f)), max(1, round(h * coarse_f))
|
||||
if sw_s > out_small.shape[1] or sh_s > out_small.shape[0]:
|
||||
continue
|
||||
template = np.asarray(src_img.resize((sw_s, sh_s), Image.BILINEAR), dtype=np.float32)
|
||||
here: tuple[float, int, int] | None = None
|
||||
for y in range(out_small.shape[0] - sh_s + 1):
|
||||
for x in range(out_small.shape[1] - sw_s + 1):
|
||||
diff = float(np.abs(out_small[y:y + sh_s, x:x + sw_s] - template).mean())
|
||||
if here is None or diff < here[0]:
|
||||
here = (diff, x, y)
|
||||
if here is not None:
|
||||
per_scale.append((here[0], float(scale), round(here[1] / coarse_f), round(here[2] / coarse_f)))
|
||||
if not per_scale:
|
||||
return None
|
||||
|
||||
# The coarse pass cannot tell 0.99 from 1.00 on smooth pictures, so the
|
||||
# few best scales are all settled at full resolution and the best of those wins.
|
||||
reach = int(math.ceil(1 / coarse_f)) + 4
|
||||
best: tuple[float, float, int, int] | None = None # diff, scale, x, y
|
||||
for _, scale, cx, cy in sorted(per_scale)[:3]:
|
||||
w, h = round(sw * scale), round(sh * scale)
|
||||
template = np.asarray(src_img.resize((w, h), Image.LANCZOS), dtype=np.float32)[::4, ::4]
|
||||
for y in range(max(0, cy - reach), min(out.height - h, cy + reach) + 1):
|
||||
for x in range(max(0, cx - reach), min(out.width - w, cx + reach) + 1):
|
||||
window = out_gray[y:y + h:4, x:x + w:4][:template.shape[0], :template.shape[1]]
|
||||
diff = float(np.abs(window - template).mean())
|
||||
if best is None or diff < best[0]:
|
||||
best = (diff, scale, x, y)
|
||||
if best is None or best[0] > max_mean_diff:
|
||||
return None
|
||||
return best[1], best[2], best[3]
|
||||
|
||||
|
||||
def crop_box_around_original(
|
||||
image_size: tuple[int, int], box: tuple[int, int, int, int], tw: int, th: int
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""The smallest window at the target's ratio that contains `box`, centred
|
||||
on it and slid (never shrunk) to stay inside the image. Only generated
|
||||
margin is ever cut away."""
|
||||
iw, ih = image_size
|
||||
x0, y0, x1, y1 = box
|
||||
bw, bh = x1 - x0, y1 - y0
|
||||
target = tw / th
|
||||
if target >= bw / bh:
|
||||
w, h = math.ceil(bh * target), bh
|
||||
else:
|
||||
w, h = bw, math.ceil(bw / target)
|
||||
w, h = min(w, iw), min(h, ih)
|
||||
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
||||
left = int(round(min(max(cx - w / 2, 0), iw - w)))
|
||||
top = int(round(min(max(cy - h / 2, 0), ih - h)))
|
||||
return left, top, left + w, top + h
|
||||
|
||||
|
||||
def crop_around_original(
|
||||
img: Image.Image, box: tuple[int, int, int, int], tw: int, th: int
|
||||
) -> Image.Image:
|
||||
return img.crop(crop_box_around_original(img.size, box, tw, th))
|
||||
@@ -0,0 +1,274 @@
|
||||
# ABOUTME: Shared async helpers for nodes that call WaveSpeed's v3 prediction API directly
|
||||
# ABOUTME: Lets those nodes run in parallel in ComfyUI: every blocking call runs off the event loop
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from .utils import tensor2images
|
||||
|
||||
try:
|
||||
from comfy_api.latest import Types
|
||||
|
||||
File3D = Types.File3D
|
||||
except ImportError: # older ComfyUI without 3D file types
|
||||
File3D = None
|
||||
|
||||
RESULT_PATH = "/api/v3/predictions/{id}/result"
|
||||
TERMINAL_FAILURES = {"failed", "cancelled", "timeout", "deleted"}
|
||||
|
||||
# The partner image nodes downscale references to this many pixels before upload.
|
||||
MAX_UPLOAD_PIXELS = 2048 * 2048
|
||||
|
||||
# ComfyUI runs an `async def` node as a background task and moves on to other
|
||||
# ready nodes while it waits, which is how partner nodes overlap. That only
|
||||
# helps if the coroutine actually yields: WaveSpeedClient uses blocking
|
||||
# `requests`, so every call below goes through asyncio.to_thread.
|
||||
|
||||
|
||||
def check_interrupted():
|
||||
"""Raise if the user pressed Cancel in ComfyUI; a no-op outside ComfyUI."""
|
||||
try:
|
||||
import comfy.model_management as mm
|
||||
except ImportError:
|
||||
return
|
||||
mm.throw_exception_if_processing_interrupted()
|
||||
|
||||
|
||||
# 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. `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):
|
||||
source = client.get("source", "the SF WaveSpeed Client node")
|
||||
raise RuntimeError(
|
||||
f"WaveSpeed rejected the API key, which came from {source}. "
|
||||
f"Check it on wavespeed.ai under API Keys, or clear the api_key field "
|
||||
f"on SF WaveSpeed Client to fall back to a key saved elsewhere."
|
||||
) from e
|
||||
raise
|
||||
|
||||
|
||||
async def wait_for_result(api, task_id, timeout_seconds, label, interval=2):
|
||||
"""Poll a prediction until it completes, yielding between polls. Returns the result."""
|
||||
loop = asyncio.get_running_loop()
|
||||
started = loop.time()
|
||||
while True:
|
||||
check_interrupted()
|
||||
result = await with_retries(api.get, RESULT_PATH.format(id=task_id), label=label)
|
||||
status = result.get("status")
|
||||
if status == "completed":
|
||||
return result
|
||||
if status in TERMINAL_FAILURES:
|
||||
raise RuntimeError(f"{label} task {status}: {result.get('error') or result}")
|
||||
if loop.time() - started > timeout_seconds:
|
||||
raise RuntimeError(f"{label} task {task_id} still '{status}' after {timeout_seconds}s.")
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
async def submit_and_collect(api, client, endpoint, payload, count, timeout_seconds, label):
|
||||
"""Submit `count` identical predictions at once and wait for all of them.
|
||||
Returns every output URL, in submission order."""
|
||||
|
||||
async def one():
|
||||
task = await call(client, api.post, endpoint, payload)
|
||||
if not task.get("id"):
|
||||
raise RuntimeError(f"WaveSpeed did not return a task id: {task}")
|
||||
result = await wait_for_result(api, task["id"], timeout_seconds, label)
|
||||
outputs = result.get("outputs") or []
|
||||
return outputs if isinstance(outputs, list) else [outputs]
|
||||
|
||||
batches = await asyncio.gather(*(one() for _ in range(count)))
|
||||
return [url for batch in batches for url in batch]
|
||||
|
||||
|
||||
def flatten_images(tensors):
|
||||
"""Every frame of every IMAGE tensor, as PIL images."""
|
||||
frames = []
|
||||
for tensor in tensors:
|
||||
if tensor is None:
|
||||
continue
|
||||
batch = tensor if tensor.dim() == 4 else tensor.unsqueeze(0)
|
||||
frames.extend(tensor2images(batch))
|
||||
return frames
|
||||
|
||||
|
||||
def downscale(image, max_pixels=MAX_UPLOAD_PIXELS):
|
||||
w, h = image.size
|
||||
if w * h <= max_pixels:
|
||||
return image
|
||||
scale = (max_pixels / (w * h)) ** 0.5
|
||||
return image.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
|
||||
|
||||
|
||||
async def upload_images(api, client, images):
|
||||
"""Upload references concurrently, downscaled first. Returns their URLs in order."""
|
||||
return list(await asyncio.gather(*(call(client, api.upload_file, downscale(img), retry=True)
|
||||
for img in images)))
|
||||
|
||||
|
||||
async def fetch_images(urls, timeout=120):
|
||||
"""Download output images concurrently."""
|
||||
|
||||
def get(url):
|
||||
response = requests.get(url, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
return Image.open(io.BytesIO(response.content))
|
||||
|
||||
return list(await asyncio.gather(*(with_retries(get, url, label="download") for url in urls)))
|
||||
|
||||
|
||||
def _has_alpha(image):
|
||||
return image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info)
|
||||
|
||||
|
||||
def to_image_batch(frames):
|
||||
"""One IMAGE tensor from PIL frames. Keeps alpha when any frame is transparent;
|
||||
resizes stragglers to the first frame, since a batch must share one size."""
|
||||
if not frames:
|
||||
raise RuntimeError("WaveSpeed returned no images.")
|
||||
mode = "RGBA" if any(_has_alpha(f) for f in frames) else "RGB"
|
||||
width, height = frames[0].size
|
||||
tensors = []
|
||||
for frame in frames:
|
||||
if frame.size != (width, height):
|
||||
frame = frame.resize((width, height), Image.LANCZOS)
|
||||
tensors.append(torch.from_numpy(np.asarray(frame.convert(mode), dtype=np.float32) / 255.0)[None,])
|
||||
return torch.cat(tensors, dim=0)
|
||||
|
||||
|
||||
# --- 3D models -----------------------------------------------------------------
|
||||
|
||||
|
||||
def pick_model_url(outputs, 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"))
|
||||
+65
-34
@@ -1,14 +1,54 @@
|
||||
import configparser
|
||||
import json
|
||||
import os
|
||||
|
||||
from .wavespeed_api.client import WaveSpeedClient
|
||||
|
||||
PACK_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CUSTOM_NODES_DIR = os.path.dirname(PACK_DIR)
|
||||
|
||||
|
||||
def _from_ini(path):
|
||||
if not os.path.isfile(path):
|
||||
return ""
|
||||
config = configparser.ConfigParser()
|
||||
config.read(path)
|
||||
return config.get("API", "api_key", fallback="").strip()
|
||||
|
||||
|
||||
def _from_official_pack():
|
||||
"""The key saved by WaveSpeed's own ComfyUI pack, so one key serves both."""
|
||||
path = os.path.join(CUSTOM_NODES_DIR, "wavespeed-comfyui", "config.json")
|
||||
if not os.path.isfile(path):
|
||||
return ""
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return str(json.load(f).get("api_key") or "").strip()
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_api_key(widget_value):
|
||||
"""Returns (key, where it came from). First non-empty source wins."""
|
||||
sources = [
|
||||
("the api_key field on this node", lambda: (widget_value or "").strip()),
|
||||
("comfyui-sf-nodes/config.ini", lambda: _from_ini(os.path.join(PACK_DIR, "config.ini"))),
|
||||
("the WAVESPEED_API_KEY environment variable", lambda: os.environ.get("WAVESPEED_API_KEY", "").strip()),
|
||||
("the official WaveSpeed pack's config.json", _from_official_pack),
|
||||
]
|
||||
for label, read in sources:
|
||||
key = read()
|
||||
if key:
|
||||
return key, label
|
||||
return "", None
|
||||
|
||||
|
||||
class SFWaveSpeedClient:
|
||||
"""
|
||||
WaveSpeed AI API Client Node
|
||||
|
||||
This node creates a client for connecting to the WaveSpeed AI API.
|
||||
Leave api_key empty to reuse a key saved elsewhere — see the tooltip.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -18,7 +58,19 @@ class SFWaveSpeedClient:
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"api_key": ("STRING", {"multiline": False, "default": ""}),
|
||||
"api_key": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"default": "",
|
||||
"tooltip": (
|
||||
"Leave empty (recommended) to use, in order: comfyui-sf-nodes/config.ini, "
|
||||
"the WAVESPEED_API_KEY environment variable, or the key already saved by "
|
||||
"WaveSpeed's official ComfyUI pack. A key typed here is saved into the "
|
||||
"workflow file, so it travels with any workflow you share."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -34,44 +86,23 @@ class SFWaveSpeedClient:
|
||||
Create a WaveSpeed AI API client
|
||||
|
||||
Args:
|
||||
api_key: WaveSpeed AI API key
|
||||
api_key: WaveSpeed AI API key; empty to look it up elsewhere
|
||||
|
||||
Returns:
|
||||
WaveSpeedAPI: WaveSpeed AI API client
|
||||
dict holding the resolved key and where it came from
|
||||
"""
|
||||
wavespeed_api_key = ""
|
||||
|
||||
if api_key == "":
|
||||
# Try to read from config.ini
|
||||
try:
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.dirname(current_dir)
|
||||
config_path = os.path.join(parent_dir, "config.ini")
|
||||
|
||||
if os.path.exists(config_path):
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
wavespeed_api_key = config.get("API", "api_key", fallback="")
|
||||
|
||||
if not wavespeed_api_key:
|
||||
# Try environment variable
|
||||
wavespeed_api_key = os.environ.get("WAVESPEED_API_KEY", "")
|
||||
|
||||
if not wavespeed_api_key:
|
||||
raise ValueError(
|
||||
"API_KEY is empty. Please provide an API key or set it in config.ini"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Unable to find API_KEY: {str(e)}")
|
||||
|
||||
else:
|
||||
wavespeed_api_key = api_key
|
||||
|
||||
return ({"api_key": wavespeed_api_key},)
|
||||
key, source = resolve_api_key(api_key)
|
||||
if not key:
|
||||
raise ValueError(
|
||||
"No WaveSpeed API key found. Enter one on this node, add it to "
|
||||
"comfyui-sf-nodes/config.ini, set WAVESPEED_API_KEY, or save it in "
|
||||
"WaveSpeed's official ComfyUI pack."
|
||||
)
|
||||
# Never log the key itself — only where it was found.
|
||||
print(f"[SF WaveSpeed Client] Using the API key from {source}.")
|
||||
return ({"api_key": key, "source": source},)
|
||||
|
||||
|
||||
# Node registration
|
||||
NODE_CLASS_MAPPINGS = {"SFWaveSpeedClient": SFWaveSpeedClient}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedClient": "SF WaveSpeed Client"}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# ABOUTME: OpenAI GPT Image through WaveSpeed, modelled on ComfyUI's "OpenAI GPT Image 2.5" partner node
|
||||
# ABOUTME: Same model picker, per-model controls and growing image slots; async, so it runs in parallel
|
||||
|
||||
from comfy_api.latest import IO
|
||||
|
||||
from .wavespeed_api.client import WaveSpeedClient
|
||||
from .wavespeed_api.v3 import fetch_images, flatten_images, submit_and_collect, to_image_batch, upload_images
|
||||
|
||||
# Display name -> WaveSpeed slug. WaveSpeed does not offer gpt-image-1, so of
|
||||
# the partner node's five models this carries the four that exist there.
|
||||
MODELS = {
|
||||
"gpt-image-2.5-flare": "openai/gpt-image-2.5-flare",
|
||||
"gpt-image-2.5-sunburst": "openai/gpt-image-2.5-sunburst",
|
||||
"gpt-image-2": "openai/gpt-image-2",
|
||||
"gpt-image-1.5": "openai/gpt-image-1.5",
|
||||
}
|
||||
|
||||
QUALITIES = ("low", "medium", "high")
|
||||
QUALITIES_25 = ("low", "medium", "high", "xhigh", "max")
|
||||
ASPECT_AUTO = "auto"
|
||||
ASPECT_RATIOS = (ASPECT_AUTO, "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9",
|
||||
"1:2", "2:1", "1:3", "3:1", "9:21", "21:9")
|
||||
LEGACY_SIZES = ("auto", "1024x1024", "1024x1536", "1536x1024")
|
||||
|
||||
|
||||
def _reference_images(max_images):
|
||||
return IO.Autogrow.Input(
|
||||
"images",
|
||||
template=IO.Autogrow.TemplateNames(
|
||||
IO.Image.Input("image"),
|
||||
names=[f"image_{i}" for i in range(1, max_images + 1)],
|
||||
min=0,
|
||||
),
|
||||
tooltip=f"Optional reference image(s). Connecting any switches to editing. Up to {max_images}.",
|
||||
)
|
||||
|
||||
|
||||
def _quality(qualities):
|
||||
return IO.Combo.Input(
|
||||
"quality",
|
||||
default="low",
|
||||
options=list(qualities),
|
||||
tooltip="Image quality, affects cost and generation time.",
|
||||
)
|
||||
|
||||
|
||||
def _gpt_image_2_inputs(qualities):
|
||||
"""gpt-image-2 and 2.5: WaveSpeed takes an aspect ratio plus a resolution tier
|
||||
instead of OpenAI's exact pixel sizes."""
|
||||
return [
|
||||
IO.Combo.Input(
|
||||
"aspect_ratio",
|
||||
default=ASPECT_AUTO,
|
||||
options=list(ASPECT_RATIOS),
|
||||
tooltip="'auto' follows the reference image when editing, or the model's default otherwise.",
|
||||
),
|
||||
IO.Combo.Input("resolution", default="1k", options=["1k", "2k", "4k"], tooltip="Output resolution tier."),
|
||||
_quality(qualities),
|
||||
IO.Combo.Input("output_format", default="png", options=["png", "jpeg", "webp"]),
|
||||
_reference_images(16),
|
||||
]
|
||||
|
||||
|
||||
def _gpt_image_15_inputs():
|
||||
return [
|
||||
IO.Combo.Input("size", default="auto", options=list(LEGACY_SIZES), tooltip="Image size."),
|
||||
IO.Combo.Input(
|
||||
"background",
|
||||
default="auto",
|
||||
options=["auto", "opaque", "transparent"],
|
||||
tooltip="Return image with or without background. Transparent needs png output.",
|
||||
),
|
||||
_quality(QUALITIES),
|
||||
IO.Combo.Input("output_format", default="png", options=["png", "jpeg"]),
|
||||
IO.Combo.Input(
|
||||
"input_fidelity",
|
||||
default="high",
|
||||
options=["low", "high"],
|
||||
tooltip="How closely an edit sticks to the reference images. Only used when editing.",
|
||||
),
|
||||
_reference_images(10),
|
||||
]
|
||||
|
||||
|
||||
def build_payload(model_name, prompt, options, image_urls):
|
||||
"""The request body for one generation. Options the user left on 'auto' are
|
||||
omitted so WaveSpeed applies its own default."""
|
||||
payload = {"prompt": prompt, "output_format": options["output_format"], "quality": options["quality"]}
|
||||
|
||||
if model_name == "gpt-image-1.5":
|
||||
if options["background"] == "transparent" and options["output_format"] != "png":
|
||||
raise ValueError("A transparent background needs output_format 'png'.")
|
||||
payload["background"] = options["background"]
|
||||
if options["size"] != "auto":
|
||||
payload["size"] = options["size"].replace("x", "*")
|
||||
if image_urls:
|
||||
payload["input_fidelity"] = options["input_fidelity"]
|
||||
else:
|
||||
payload["resolution"] = options["resolution"]
|
||||
if options["aspect_ratio"] != ASPECT_AUTO:
|
||||
payload["aspect_ratio"] = options["aspect_ratio"]
|
||||
|
||||
if image_urls:
|
||||
payload["images"] = image_urls
|
||||
return payload
|
||||
|
||||
|
||||
class SFWaveSpeedGPTImage(IO.ComfyNode):
|
||||
"""GPT Image via WaveSpeed. Mirrors the partner node's interface; the
|
||||
differences come from what WaveSpeed's API actually accepts."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="SFWaveSpeedGPTImage",
|
||||
display_name="SF WaveSpeed GPT Image 2.5",
|
||||
category="Stillfront/WaveSpeed",
|
||||
description=(
|
||||
"Generates or edits images with OpenAI's GPT Image models through WaveSpeed. "
|
||||
"Connect reference images to edit; leave them empty to generate."
|
||||
),
|
||||
inputs=[
|
||||
IO.Custom("WAVESPEED_AI_API_CLIENT").Input("client"),
|
||||
IO.String.Input("prompt", default="", multiline=True, tooltip="Text prompt for GPT Image"),
|
||||
IO.DynamicCombo.Input(
|
||||
"model",
|
||||
options=[
|
||||
IO.DynamicCombo.Option("gpt-image-2.5-flare", _gpt_image_2_inputs(QUALITIES_25)),
|
||||
IO.DynamicCombo.Option("gpt-image-2.5-sunburst", _gpt_image_2_inputs(QUALITIES_25)),
|
||||
IO.DynamicCombo.Option("gpt-image-2", _gpt_image_2_inputs(QUALITIES)),
|
||||
IO.DynamicCombo.Option("gpt-image-1.5", _gpt_image_15_inputs()),
|
||||
],
|
||||
),
|
||||
IO.Int.Input(
|
||||
"n",
|
||||
default=1,
|
||||
min=1,
|
||||
max=8,
|
||||
step=1,
|
||||
tooltip="How many images to generate. Sent as parallel requests, each billed separately.",
|
||||
display_mode=IO.NumberDisplay.number,
|
||||
),
|
||||
IO.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=2147483647,
|
||||
step=1,
|
||||
display_mode=IO.NumberDisplay.number,
|
||||
control_after_generate=True,
|
||||
tooltip="Not sent to WaveSpeed, which has no seed for these models. Change it to force a re-run.",
|
||||
),
|
||||
IO.Int.Input(
|
||||
"timeout_seconds",
|
||||
default=600,
|
||||
min=60,
|
||||
max=3600,
|
||||
step=30,
|
||||
advanced=True,
|
||||
),
|
||||
],
|
||||
outputs=[IO.Image.Output()],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(cls, client, prompt, model, n, seed, timeout_seconds=600):
|
||||
if not prompt.strip():
|
||||
raise ValueError("The prompt box is empty — GPT Image needs a text instruction, even when editing.")
|
||||
|
||||
model_name = model["model"]
|
||||
if model_name not in MODELS:
|
||||
raise ValueError(f"Unknown model: {model_name}")
|
||||
|
||||
references = flatten_images((model.get("images") or {}).values())
|
||||
limit = 10 if model_name == "gpt-image-1.5" else 16
|
||||
if len(references) > limit:
|
||||
raise ValueError(f"{model_name} accepts at most {limit} reference images, got {len(references)}.")
|
||||
|
||||
api = WaveSpeedClient(api_key=client["api_key"])
|
||||
image_urls = await upload_images(api, client, references)
|
||||
|
||||
endpoint = f"/api/v3/{MODELS[model_name]}/{'edit' if image_urls else 'text-to-image'}"
|
||||
payload = build_payload(model_name, prompt, model, image_urls)
|
||||
mode = f"editing {len(image_urls)} image(s)" if image_urls else "text-to-image"
|
||||
print(f"[SF GPT Image] {model_name}, {mode}, n={n}.")
|
||||
|
||||
# WaveSpeed has no `n`, so n requests are submitted together.
|
||||
urls = await submit_and_collect(api, client, endpoint, payload, n, timeout_seconds,
|
||||
f"GPT Image ({model_name})")
|
||||
return IO.NodeOutput(to_image_batch(await fetch_images(urls)))
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SFWaveSpeedGPTImage": SFWaveSpeedGPTImage}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedGPTImage": "SF WaveSpeed GPT Image 2.5"}
|
||||
@@ -0,0 +1,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"}
|
||||
@@ -145,5 +145,5 @@ NODE_CLASS_MAPPINGS = {
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SFWaveSpeedNanoBananaEdit": "SF WaveSpeed Nano Banana Edit"
|
||||
"SFWaveSpeedNanoBananaEdit": "SF WaveSpeed Nano Banana Edit (Legacy)"
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
# 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
|
||||
|
||||
# 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 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 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",
|
||||
category="Stillfront/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(
|
||||
"prompt",
|
||||
multiline=True,
|
||||
default="",
|
||||
tooltip="Text prompt describing the image to generate or the edits to apply. "
|
||||
"Include any constraints, styles, or details the model should follow.",
|
||||
),
|
||||
IO.Combo.Input("model", options=list(MODELS)),
|
||||
IO.Int.Input(
|
||||
"seed",
|
||||
default=42,
|
||||
min=0,
|
||||
max=0xFFFFFFFFFFFFFFFF,
|
||||
control_after_generate=True,
|
||||
tooltip="Not sent to WaveSpeed, which has no seed for these models. "
|
||||
"Change it to force a re-run.",
|
||||
),
|
||||
IO.Combo.Input(
|
||||
"aspect_ratio",
|
||||
options=ASPECT_RATIOS + WIDE_ASPECT_RATIOS,
|
||||
default="auto",
|
||||
tooltip="If set to 'auto', matches your input image's aspect ratio; "
|
||||
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.Image.Input(
|
||||
"images",
|
||||
optional=True,
|
||||
tooltip="Optional reference image(s). "
|
||||
f"To include multiple images, use the Batch Images node (up to {MAX_REFERENCES}).",
|
||||
),
|
||||
IO.Int.Input("timeout_seconds", default=600, min=60, max=3600, step=30, advanced=True),
|
||||
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, 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:
|
||||
raise ValueError(f"The current maximum number of supported images is {MAX_REFERENCES}.")
|
||||
|
||||
api = WaveSpeedClient(api_key=client["api_key"])
|
||||
image_urls = await upload_images(api, client, references)
|
||||
|
||||
endpoint = 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, 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"}
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,159 @@
|
||||
# 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
|
||||
|
||||
from .wavespeed_api.client import WaveSpeedClient
|
||||
from .wavespeed_api.v3 import generate_3d_model
|
||||
|
||||
ENDPOINT = "/api/v3/tripo3d/p2/image-to-3d"
|
||||
|
||||
TEXTURE_LEVELS = ["standard", "fast", "detailed", "extreme", "none"]
|
||||
TEXTURE_VERSIONS = ["v3.5-20260815", "v3.0-20250812", "v2.5-20250123"]
|
||||
ORIENTATION_UNSET = "unset (recommended)"
|
||||
ORIENTATIONS = [ORIENTATION_UNSET, "+x", "-x", "+y", "-y"]
|
||||
|
||||
|
||||
def build_payload(image_url, quad, face_limit, texture, pbr, texture_version,
|
||||
delight, auto_size, export_uv, export_orientation, model_seed, texture_seed):
|
||||
"""The request body. Unused options are omitted rather than sent as null,
|
||||
as WaveSpeed's documentation asks."""
|
||||
if face_limit != -1:
|
||||
ceiling = 25000 if quad else 50000
|
||||
if not 48 <= face_limit <= ceiling:
|
||||
raise ValueError(
|
||||
f"face_limit must be -1 (adaptive) or between 48 and {ceiling}"
|
||||
f"{' — quad meshes cap at 25,000' if quad else ''}."
|
||||
)
|
||||
if texture == "fast" and texture_version != "v3.5-20260815":
|
||||
raise ValueError("texture 'fast' requires texture_version v3.5-20260815.")
|
||||
|
||||
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),
|
||||
"auto_size": auto_size,
|
||||
"export_uv": export_uv,
|
||||
}
|
||||
if textured:
|
||||
payload["texture_quality"] = texture
|
||||
payload["texture_version"] = texture_version
|
||||
payload["delight"] = delight
|
||||
if face_limit != -1:
|
||||
payload["face_limit"] = face_limit
|
||||
if export_orientation != ORIENTATION_UNSET:
|
||||
payload["export_orientation"] = export_orientation
|
||||
if model_seed != -1:
|
||||
payload["model_seed"] = model_seed
|
||||
if texture_seed != -1 and textured:
|
||||
payload["texture_seed"] = texture_seed
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class SFWaveSpeedTripoP2ImageTo3D:
|
||||
"""
|
||||
Tripo P2 image-to-3D via WaveSpeed — the same model as ComfyUI's partner
|
||||
node, billed to your WaveSpeed account instead of ComfyUI credits.
|
||||
|
||||
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": 50000,
|
||||
"step": 100,
|
||||
"tooltip": "Target face count. -1 lets Tripo choose. 48-50,000, or up to 25,000 with quad.",
|
||||
},
|
||||
),
|
||||
"texture": (
|
||||
TEXTURE_LEVELS,
|
||||
{"default": "standard", "tooltip": "'none' gives untextured geometry. Higher levels cost more."},
|
||||
),
|
||||
"pbr": ("BOOLEAN", {"default": True, "tooltip": "PBR material maps. Ignored when texture is 'none'."}),
|
||||
},
|
||||
"optional": {
|
||||
"texture_version": (
|
||||
TEXTURE_VERSIONS,
|
||||
{"default": TEXTURE_VERSIONS[0], "tooltip": "'fast' texture requires v3.5."},
|
||||
),
|
||||
"delight": (
|
||||
"BOOLEAN",
|
||||
{"default": True, "tooltip": "Strip lighting baked into the source image before texturing."},
|
||||
),
|
||||
"auto_size": ("BOOLEAN", {"default": False, "tooltip": "Scale the model to real-world metres."}),
|
||||
"export_uv": ("BOOLEAN", {"default": True, "tooltip": "Generate UVs. Off is faster and smaller."}),
|
||||
"export_orientation": (
|
||||
ORIENTATIONS,
|
||||
{
|
||||
"default": ORIENTATION_UNSET,
|
||||
"tooltip": (
|
||||
"Forward axis. Leave unset if you will rig, retexture or convert the "
|
||||
"model — Tripo warns those steps can silently come out mis-rotated."
|
||||
),
|
||||
},
|
||||
),
|
||||
"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,
|
||||
texture_version=TEXTURE_VERSIONS[0], delight=True, auto_size=False,
|
||||
export_uv=True, export_orientation=ORIENTATION_UNSET,
|
||||
model_seed=-1, texture_seed=-1, timeout_seconds=900):
|
||||
api = WaveSpeedClient(api_key=client["api_key"])
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SFWaveSpeedTripoP2ImageTo3D": SFWaveSpeedTripoP2ImageTo3D}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedTripoP2ImageTo3D": "SF WaveSpeed Tripo P2 Image to 3D"}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-sf-nodes"
|
||||
description = "Custom nodes for ComfyUI including LLM Chat with support for Claude, Gemini, and OpenAI models, WaveSpeed AI image/video generation, VertexAI integration, and workflow utilities"
|
||||
version = "1.3.2"
|
||||
version = "1.11.0"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"requests>=2.31.0",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* SF Banner Resizer — shows one image output per line of the "resolutions" box,
|
||||
* each labelled with its size, so they can be wired straight to Save Image nodes.
|
||||
*
|
||||
* The Python node always declares the plan output plus image_1..image_8; this
|
||||
* trims the unused trailing ones from view (never one that is connected) and
|
||||
* puts them back as lines are added.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { parseResolutions } from "./sf_wavespeed_pricing.js";
|
||||
|
||||
const NODE_NAME = "SFBannerResizer";
|
||||
const MAX_OUTPUTS = 8;
|
||||
const FIRST_IMAGE = 1; // output 0 is "plan"
|
||||
|
||||
function syncOutputs(node) {
|
||||
const widget = node.widgets?.find((w) => w.name === "resolutions");
|
||||
if (!widget || !node.outputs) return;
|
||||
const sizes = parseResolutions(widget.value).slice(0, MAX_OUTPUTS);
|
||||
const want = FIRST_IMAGE + Math.max(1, sizes.length);
|
||||
|
||||
let changed = false;
|
||||
while (node.outputs.length > want) {
|
||||
const last = node.outputs[node.outputs.length - 1];
|
||||
if (last.links?.length) break;
|
||||
node.removeOutput(node.outputs.length - 1);
|
||||
changed = true;
|
||||
}
|
||||
while (node.outputs.length < want) {
|
||||
node.addOutput(`image_${node.outputs.length}`, "IMAGE");
|
||||
changed = true;
|
||||
}
|
||||
for (let i = FIRST_IMAGE; i < node.outputs.length; i++) {
|
||||
const size = sizes[i - FIRST_IMAGE];
|
||||
const label = size ? `${size[0]}x${size[1]}` : `image_${i} (unused)`;
|
||||
if (node.outputs[i].label !== label) {
|
||||
node.outputs[i].label = label;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
node.setSize?.([node.size[0], Math.max(node.size[1], node.computeSize()[1])]);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Stillfront.BannerResizer",
|
||||
setup() {
|
||||
// Polled: multiline text widgets don't reliably report each edit.
|
||||
setInterval(() => {
|
||||
for (const node of app.graph?.nodes ?? []) {
|
||||
if (node.type === NODE_NAME) syncOutputs(node);
|
||||
}
|
||||
}, 400);
|
||||
},
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== NODE_NAME) return;
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const result = onConfigure?.apply(this, arguments);
|
||||
syncOutputs(this);
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* SF WaveSpeed Outpaint Resizer — a list editor for the output sizes, and one image output per size.
|
||||
*
|
||||
* Each row is a width, a height and a mode (auto / crop / outpaint). The rows are stored as JSON in the
|
||||
* node's hidden "resolutions" widget, which is what Python reads; older workflows that still hold plain
|
||||
* "WIDTHxHEIGHT" lines are turned into rows when the node loads.
|
||||
*
|
||||
* The Python node always declares the plan and mask_preview outputs plus image_1..image_8; this trims the
|
||||
* unused trailing ones from view (never one that is connected), puts them back as rows are added, and
|
||||
* labels each with its size.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
const NODE_NAME = "SFWaveSpeedOutpaintResizer";
|
||||
const MAX_OUTPUTS = 8;
|
||||
const FIRST_IMAGE = 2; // output 0 is "plan", 1 is "mask_preview"
|
||||
const MODES = ["auto", "crop", "outpaint"];
|
||||
|
||||
/** [{w, h, mode}] from the stored JSON, or from legacy "WIDTHxHEIGHT [mode]" lines. */
|
||||
export function parseRows(value) {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (raw.startsWith("[")) {
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
.map((r) => ({ w: Number(r.w) || 0, h: Number(r.h) || 0, mode: MODES.includes(r.mode) ? r.mode : "auto" }))
|
||||
.slice(0, MAX_OUTPUTS);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return raw
|
||||
.split(/[,\n;]+/)
|
||||
.map((t) => t.trim().match(/^(\d+)\s*[xX×*]\s*(\d+)(?:\s+(\w+))?$/))
|
||||
.filter(Boolean)
|
||||
.map((m) => ({ w: Number(m[1]), h: Number(m[2]), mode: MODES.includes(m[3]) ? m[3] : "auto" }))
|
||||
.slice(0, MAX_OUTPUTS);
|
||||
}
|
||||
|
||||
function sizesWidget(node) {
|
||||
return node.widgets?.find((w) => w.name === "resolutions");
|
||||
}
|
||||
|
||||
function store(node, rows) {
|
||||
const widget = sizesWidget(node);
|
||||
if (!widget) return;
|
||||
widget.value = JSON.stringify(rows);
|
||||
widget.callback?.(widget.value);
|
||||
syncOutputs(node);
|
||||
}
|
||||
|
||||
function syncOutputs(node) {
|
||||
const widget = sizesWidget(node);
|
||||
if (!widget || !node.outputs) return;
|
||||
const rows = parseRows(widget.value);
|
||||
const want = FIRST_IMAGE + Math.max(1, rows.length);
|
||||
|
||||
let changed = false;
|
||||
while (node.outputs.length > want) {
|
||||
const last = node.outputs[node.outputs.length - 1];
|
||||
if (last.links?.length) break;
|
||||
node.removeOutput(node.outputs.length - 1);
|
||||
changed = true;
|
||||
}
|
||||
while (node.outputs.length < want) {
|
||||
node.addOutput(`image_${node.outputs.length}`, "IMAGE");
|
||||
changed = true;
|
||||
}
|
||||
for (let i = FIRST_IMAGE; i < node.outputs.length; i++) {
|
||||
const row = rows[i - FIRST_IMAGE];
|
||||
const label = row ? `${row.w}x${row.h}` : `image_${i} (unused)`;
|
||||
if (node.outputs[i].label !== label) {
|
||||
node.outputs[i].label = label;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
node.setSize?.([node.size[0], Math.max(node.size[1], node.computeSize()[1])]);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
const CSS = `
|
||||
.sf-res-list { display: flex; flex-direction: column; gap: 4px; padding: 4px 2px; font-size: 12px;
|
||||
color: var(--input-text, #ddd); box-sizing: border-box; width: 100%; }
|
||||
.sf-res-title { opacity: .7; font-size: 11px; padding: 0 2px; }
|
||||
.sf-res-row { display: flex; align-items: center; gap: 4px; }
|
||||
.sf-res-row input, .sf-res-row select { background: var(--comfy-input-bg, #222); color: inherit;
|
||||
border: 1px solid var(--border-color, #444); border-radius: 4px; padding: 2px 4px; font-size: 12px; min-width: 0; }
|
||||
.sf-res-row input { width: 64px; }
|
||||
.sf-res-row select { flex: 1; }
|
||||
.sf-res-row button, .sf-res-add { background: none; color: inherit; border: 1px solid var(--border-color, #444);
|
||||
border-radius: 4px; cursor: pointer; font-size: 12px; padding: 2px 6px; }
|
||||
.sf-res-add { align-self: flex-start; margin-top: 2px; }
|
||||
.sf-res-add:disabled { opacity: .4; cursor: default; }
|
||||
`;
|
||||
|
||||
function ensureStyle() {
|
||||
if (document.getElementById("sf-res-list-style")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "sf-res-list-style";
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function buildEditor(node) {
|
||||
ensureStyle();
|
||||
const root = document.createElement("div");
|
||||
root.className = "sf-res-list";
|
||||
// Keep typing and clicks inside the editor from reaching the canvas.
|
||||
for (const type of ["pointerdown", "mousedown", "wheel", "keydown"]) {
|
||||
root.addEventListener(type, (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
function render() {
|
||||
const rows = parseRows(sizesWidget(node)?.value);
|
||||
root.replaceChildren();
|
||||
const title = document.createElement("div");
|
||||
title.className = "sf-res-title";
|
||||
title.textContent = "Output sizes";
|
||||
root.appendChild(title);
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
const line = document.createElement("div");
|
||||
line.className = "sf-res-row";
|
||||
const num = (key) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.min = "16";
|
||||
input.max = "8192";
|
||||
input.value = row[key];
|
||||
input.title = key === "w" ? "Width" : "Height";
|
||||
input.addEventListener("change", () => {
|
||||
const next = parseRows(sizesWidget(node)?.value);
|
||||
next[i][key] = Math.max(16, Math.min(8192, Math.round(Number(input.value) || 16)));
|
||||
input.value = next[i][key];
|
||||
store(node, next);
|
||||
});
|
||||
return input;
|
||||
};
|
||||
const times = document.createElement("span");
|
||||
times.textContent = "×";
|
||||
const mode = document.createElement("select");
|
||||
mode.title = "auto: crop if that cuts away no more than auto_crop_limit, otherwise outpaint";
|
||||
for (const m of MODES) {
|
||||
const option = document.createElement("option");
|
||||
option.value = m;
|
||||
option.textContent = m;
|
||||
mode.appendChild(option);
|
||||
}
|
||||
mode.value = row.mode;
|
||||
mode.addEventListener("change", () => {
|
||||
const next = parseRows(sizesWidget(node)?.value);
|
||||
next[i].mode = mode.value;
|
||||
store(node, next);
|
||||
});
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.textContent = "✕";
|
||||
remove.title = "Remove this size";
|
||||
remove.addEventListener("click", () => {
|
||||
const next = parseRows(sizesWidget(node)?.value);
|
||||
next.splice(i, 1);
|
||||
store(node, next);
|
||||
render();
|
||||
});
|
||||
line.append(num("w"), times, num("h"), mode, remove);
|
||||
root.appendChild(line);
|
||||
});
|
||||
|
||||
const add = document.createElement("button");
|
||||
add.type = "button";
|
||||
add.className = "sf-res-add";
|
||||
add.textContent = "+ Add resolution";
|
||||
add.disabled = rows.length >= MAX_OUTPUTS;
|
||||
add.addEventListener("click", () => {
|
||||
const next = parseRows(sizesWidget(node)?.value);
|
||||
const last = next[next.length - 1] ?? { w: 1920, h: 1080, mode: "auto" };
|
||||
next.push({ ...last });
|
||||
store(node, next);
|
||||
render();
|
||||
});
|
||||
root.appendChild(add);
|
||||
}
|
||||
|
||||
return { root, render };
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Stillfront.OutpaintResizer",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== NODE_NAME) return;
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
const widget = sizesWidget(this);
|
||||
if (widget) {
|
||||
// The JSON stays in this widget (it is what gets saved and sent); the list replaces it on screen.
|
||||
widget.hidden = true;
|
||||
widget.options = { ...(widget.options ?? {}), hidden: true };
|
||||
widget.computeSize = () => [0, -4];
|
||||
const editor = buildEditor(this);
|
||||
this.sfResolutionEditor = editor;
|
||||
const dom = this.addDOMWidget("resolution_list", "sf_resolution_list", editor.root, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getMinHeight: () => 40 + 26 * (parseRows(widget.value).length + 1),
|
||||
});
|
||||
dom.serialize = false;
|
||||
// Show the list where the text box was.
|
||||
const all = this.widgets;
|
||||
all.splice(all.indexOf(dom), 1);
|
||||
all.splice(all.indexOf(widget) + 1, 0, dom);
|
||||
editor.render();
|
||||
syncOutputs(this);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const result = onConfigure?.apply(this, arguments);
|
||||
const widget = sizesWidget(this);
|
||||
// Older workflows hold plain text: turn it into rows once.
|
||||
if (widget && !String(widget.value ?? "").trim().startsWith("[")) {
|
||||
widget.value = JSON.stringify(parseRows(widget.value));
|
||||
}
|
||||
this.sfResolutionEditor?.render();
|
||||
syncOutputs(this);
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* SF Save Folder Picker — adds a folder browser to ComfyUI's "Save workflow"
|
||||
* dialog, so a workflow can be saved into a subfolder by clicking the folder
|
||||
* and typing only the name, instead of typing the whole path.
|
||||
*
|
||||
* 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";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
const SETTING_ID = "Stillfront.SaveFolderPicker";
|
||||
const SAVE_COMMANDS = ["Comfy.SaveWorkflow", "Comfy.SaveWorkflowAs"];
|
||||
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 {
|
||||
return app.extensionManager?.setting?.get(SETTING_ID) !== false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFolders() {
|
||||
const res = await api.fetchApi(`/v2/userdata?path=${encodeURIComponent(ROOT)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`folder list failed (${res.status})`);
|
||||
}
|
||||
const entries = await res.json();
|
||||
return entries
|
||||
.filter((e) => e.type === "directory")
|
||||
.map((e) => e.path.replace(/^workflows\/?/, ""))
|
||||
.filter((p) => p && !p.split("/").some((part) => part.startsWith(".")))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
||||
}
|
||||
|
||||
function el(tag, style = "", text = "") {
|
||||
const node = document.createElement(tag);
|
||||
if (style) node.style.cssText = style;
|
||||
if (text) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function splitPath(value) {
|
||||
const i = value.lastIndexOf("/");
|
||||
return i === -1 ? { folder: "", name: value } : { folder: value.slice(0, i), name: value.slice(i + 1) };
|
||||
}
|
||||
|
||||
function setInput(input, folder) {
|
||||
const { name } = splitPath(input.value);
|
||||
input.value = folder ? `${folder}/${name}` : name;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.focus();
|
||||
// Select just the name, ready to be typed over.
|
||||
const start = input.value.length - name.length;
|
||||
input.setSelectionRange(start, input.value.length);
|
||||
}
|
||||
|
||||
function buildPicker(input, folders) {
|
||||
const expanded = new Set();
|
||||
let selected = splitPath(input.value).folder;
|
||||
|
||||
// Open the branch leading to the folder the dialog started in.
|
||||
selected.split("/").reduce((acc, part) => {
|
||||
const path = acc ? `${acc}/${part}` : part;
|
||||
if (part) expanded.add(path);
|
||||
return path;
|
||||
}, "");
|
||||
|
||||
const box = el(
|
||||
"div",
|
||||
"border:1px solid rgba(128,128,128,.35);border-radius:6px;margin:4px 0 8px;"
|
||||
);
|
||||
const header = el(
|
||||
"div",
|
||||
"display:flex;align-items:center;justify-content:space-between;padding:6px 8px;" +
|
||||
"font-size:12px;opacity:.75;border-bottom:1px solid rgba(128,128,128,.25);"
|
||||
);
|
||||
header.appendChild(el("span", "", "Save into folder"));
|
||||
const newBtn = el(
|
||||
"button",
|
||||
"background:none;border:1px solid rgba(128,128,128,.4);border-radius:4px;" +
|
||||
"padding:2px 8px;font-size:12px;color:inherit;cursor:pointer;",
|
||||
"+ New folder"
|
||||
);
|
||||
newBtn.type = "button";
|
||||
header.appendChild(newBtn);
|
||||
box.appendChild(header);
|
||||
|
||||
const list = el("div", "max-height:260px;overflow-y:auto;padding:4px 0;font-size:13px;");
|
||||
box.appendChild(list);
|
||||
|
||||
const hasChildren = (path) => folders.some((f) => f.startsWith(`${path}/`));
|
||||
const childrenOf = (parent) =>
|
||||
folders.filter((f) => {
|
||||
const { folder } = splitPath(f);
|
||||
return folder === parent;
|
||||
});
|
||||
|
||||
function row(path, depth) {
|
||||
const label = path ? splitPath(path).name : `${ROOT} (top level)`;
|
||||
const r = el(
|
||||
"div",
|
||||
`display:flex;align-items:center;gap:4px;padding:3px 8px 3px ${8 + depth * 16}px;` +
|
||||
"cursor:pointer;border-radius:4px;user-select:none;" +
|
||||
(path === selected ? "background:rgba(66,133,244,.28);" : "")
|
||||
);
|
||||
const caret = el("span", "width:14px;display:inline-block;text-align:center;opacity:.7;");
|
||||
if (path && hasChildren(path)) {
|
||||
caret.textContent = expanded.has(path) ? "▾" : "▸";
|
||||
caret.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
expanded.has(path) ? expanded.delete(path) : expanded.add(path);
|
||||
render();
|
||||
});
|
||||
}
|
||||
r.appendChild(caret);
|
||||
r.appendChild(el("span", "", `📁 ${label}`));
|
||||
r.addEventListener("mouseenter", () => {
|
||||
if (path !== selected) r.style.background = "rgba(128,128,128,.15)";
|
||||
});
|
||||
r.addEventListener("mouseleave", () => {
|
||||
if (path !== selected) r.style.background = "";
|
||||
});
|
||||
r.addEventListener("click", () => {
|
||||
selected = path;
|
||||
setInput(input, path);
|
||||
render();
|
||||
});
|
||||
return r;
|
||||
}
|
||||
|
||||
function addBranch(parent, depth) {
|
||||
for (const child of childrenOf(parent)) {
|
||||
list.appendChild(row(child, depth));
|
||||
if (expanded.has(child)) addBranch(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
list.replaceChildren(row("", 0));
|
||||
addBranch("", 1);
|
||||
}
|
||||
|
||||
newBtn.addEventListener("click", () => {
|
||||
const where = selected ? `inside "${selected}"` : "at the top level";
|
||||
const name = window.prompt(`New folder name (${where}):`);
|
||||
const clean = (name || "").trim().replace(/[\\/]+/g, "-");
|
||||
if (!clean) return;
|
||||
const path = selected ? `${selected}/${clean}` : clean;
|
||||
// The folder is created on disk when the workflow is saved into it.
|
||||
if (!folders.includes(path)) folders.push(path);
|
||||
folders.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
||||
if (selected) expanded.add(selected);
|
||||
selected = path;
|
||||
setInput(input, path);
|
||||
render();
|
||||
});
|
||||
|
||||
render();
|
||||
return box;
|
||||
}
|
||||
|
||||
async function inject(dialog) {
|
||||
if (dialog.dataset.sfFolderPicker) return;
|
||||
dialog.dataset.sfFolderPicker = "1";
|
||||
|
||||
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());
|
||||
} catch (e) {
|
||||
picker = el("div", "font-size:12px;opacity:.7;margin:4px 0;", `Folder list unavailable: ${e.message}`);
|
||||
}
|
||||
input.parentElement.insertBefore(picker, input);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Stillfront.SaveFolderPicker",
|
||||
|
||||
settings: [
|
||||
{
|
||||
id: SETTING_ID,
|
||||
category: ["Stillfront", "Workflows", "Folder picker in Save dialog"],
|
||||
name: "Show a folder browser in the Save workflow dialog",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
|
||||
async setup() {
|
||||
const commands = app.extensionManager?.command?.commands || [];
|
||||
for (const id of SAVE_COMMANDS) {
|
||||
const command = commands.find((c) => c.id === id);
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
new MutationObserver(() => {
|
||||
if (!savePending || !enabled()) return;
|
||||
const dialog = document.querySelector(".prompt-dialog-content:not([data-sf-folder-picker])");
|
||||
if (dialog) inject(dialog);
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 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 {
|
||||
bannerResizerPrice,
|
||||
outpaintResizerPrice,
|
||||
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,
|
||||
}),
|
||||
SFBannerResizer: (node) => ({
|
||||
usd: bannerResizerPrice({
|
||||
resolutions: widgetValue(node, "resolutions"),
|
||||
model: widgetValue(node, "gpt_model"),
|
||||
quality: widgetValue(node, "quality"),
|
||||
generation_resolution: widgetValue(node, "generation_resolution"),
|
||||
}),
|
||||
count: 1,
|
||||
upTo: true,
|
||||
}),
|
||||
SFWaveSpeedOutpaintResizer: (node) => ({
|
||||
usd: outpaintResizerPrice({
|
||||
resolutions: widgetValue(node, "resolutions"),
|
||||
method: widgetValue(node, "method"),
|
||||
clean_corners: widgetValue(node, "clean_corners"),
|
||||
}),
|
||||
count: 1,
|
||||
upTo: true,
|
||||
}),
|
||||
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, upTo } = pricer(node);
|
||||
if (usd == null) return "";
|
||||
const total = `${upTo ? "up to " : "~"}${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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** "1920x1080\n800x450" -> [[1920, 1080], [800, 450]]; unreadable lines are skipped. */
|
||||
export function parseResolutions(text) {
|
||||
return String(text ?? "")
|
||||
.split(/[,\n;]+/)
|
||||
.map((t) => t.trim().match(/^(\d+)\s*[xX×*]\s*(\d+)$/))
|
||||
.filter(Boolean)
|
||||
.map((m) => [Number(m[1]), Number(m[2])]);
|
||||
}
|
||||
|
||||
const GPT_RATIOS = ["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"];
|
||||
|
||||
function nearestRatio(w, h) {
|
||||
const wanted = Math.log(w / h);
|
||||
const dist = (r) => { const [a, b] = r.split(":").map(Number); return Math.abs(Math.log(a / b) - wanted); };
|
||||
return GPT_RATIOS.reduce((best, r) => (dist(r) < dist(best) ? r : best));
|
||||
}
|
||||
|
||||
/** Worst case for SF Banner Resizer: one GPT Image 2.5 edit per distinct shape, sized for the
|
||||
* largest resolution of that shape. Sizes that only need a crop cost nothing, but that depends
|
||||
* on the input image, which the badge can't see. */
|
||||
export function bannerResizerPrice({ resolutions, model, quality, generation_resolution }) {
|
||||
const largest = new Map();
|
||||
for (const [w, h] of parseResolutions(resolutions).slice(0, 8)) {
|
||||
const ratio = nearestRatio(w, h);
|
||||
largest.set(ratio, Math.max(largest.get(ratio) ?? 0, w, h));
|
||||
}
|
||||
let total = 0;
|
||||
for (const side of largest.values()) {
|
||||
const tier = generation_resolution && generation_resolution !== "auto"
|
||||
? generation_resolution
|
||||
: side <= 1100 ? "1k" : side <= 2200 ? "2k" : "4k";
|
||||
const usd = gptImagePrice({ model, quality, resolution: tier, references: 1 });
|
||||
if (usd == null) return undefined;
|
||||
total += usd;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// SF WaveSpeed Outpaint Resizer, per generation. WaveSpeed model pages, read 2026-11-09:
|
||||
// wavespeed-ai/flux-fill-dev $0.035, bria/fibo-edit/genfill $0.04, bria/expand $0.04.
|
||||
const OUTPAINT_PRICES = { "flux-fill": 0.035, "bria-genfill": 0.04, "bria-expand": 0.04 };
|
||||
|
||||
/** Rows of the Outpaint Resizer's size list: its JSON, or older "WIDTHxHEIGHT [mode]" lines. */
|
||||
function outpaintRows(value) {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (raw.startsWith("[")) {
|
||||
try {
|
||||
return JSON.parse(raw).map((r) => ({ w: Number(r.w) || 0, h: Number(r.h) || 0, mode: r.mode || "auto" }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return raw.split(/[,\n;]+/).map((t) => t.trim().match(/^(\d+)\s*[xX×*]\s*(\d+)(?:\s+(\w+))?$/))
|
||||
.filter(Boolean).map((m) => ({ w: Number(m[1]), h: Number(m[2]), mode: m[3] || "auto" }));
|
||||
}
|
||||
|
||||
/** Worst case for SF WaveSpeed Outpaint Resizer: one generation per distinct shape (shapes within
|
||||
* ~0.5% share one, as in the Python node). Sizes set to "crop" are free. Sizes on "auto", or with the
|
||||
* input image's own shape, may also turn out free, but the badge can't see the image, so this is an
|
||||
* upper bound. */
|
||||
export function outpaintResizerPrice({ resolutions, method, clean_corners }) {
|
||||
// FLUX's optional corner clean-up is one Bria GenFill call per shape on top.
|
||||
const corners = method === "flux-fill" && clean_corners !== false ? OUTPAINT_PRICES["bria-genfill"] : 0;
|
||||
const each = OUTPAINT_PRICES[method] == null ? undefined : OUTPAINT_PRICES[method] + corners;
|
||||
if (each == null) return undefined;
|
||||
const shapes = new Set(outpaintRows(resolutions).slice(0, 8).filter((r) => r.mode !== "crop" && r.w && r.h)
|
||||
.map((r) => Math.round(Math.log(r.w / r.h) * 100) / 100));
|
||||
return shapes.size * each;
|
||||
}
|
||||
Reference in New Issue
Block a user