Compare commits

...
9 Commits
Author SHA1 Message Date
Ivor StrelarandClaude Opus 5.5 d392931a43 feat: add SF WaveSpeed Tripo H3.1 Image to 3D node
Tripo's high-detail H3.1 model through WaveSpeed, for comparing against P2 on
the same image. About a fifth of P2's price there, with quad or triangle
topology, a 1,000-2,000,000 face target, standard or detailed geometry and
textures, texture alignment, orientation and seeds. A face_limit below
H3.1's 1,000 floor is rejected before submission, pointing at P2.

The upload/submit/wait/download/File3D sequence the two 3D nodes share now
lives in wavespeed_api/v3.generate_3d_model, along with pick_model_url and
output_dir, so P2 and H3.1 differ only in their inputs and payloads. Both are
async and run in parallel with each other and the image nodes.

Verified with a stubbed API: P2 and H3.1 on the same image take 8.6s in
sequence and 4.2s side by side, P2's request body is unchanged by the
refactor, and both return FBX with quad on and GLB with it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:01:46 +02:00
Ivor StrelarandClaude Opus 5.5 f28000603c feat: run WaveSpeed nodes in parallel and add SF WaveSpeed Nano Banana Pro
GPT Image 2.5 and Tripo P2 waited on WaveSpeed with blocking requests and
time.sleep, so ComfyUI ran them strictly one after another. Partner nodes
overlap because their execute is a coroutine, which ComfyUI's executor
starts as a task and schedules other ready nodes around. Both nodes are now
async (V3 execute, and an async FUNCTION method for the V1 Tripo node).

Async alone is not enough: WaveSpeedClient uses blocking requests, which
would still stall the event loop. wavespeed_api/v3.py now runs every call
and download through asyncio.to_thread, polls with asyncio.sleep, uploads
references and downloads outputs concurrently, and submits a node's n
requests together. Image flattening, downscaling and alpha-aware batching
moved there from the GPT Image node so the new node can share them.

New SF WaveSpeed Nano Banana Pro mirrors the "Nano Banana Pro (Google Gemini
Image)" partner node: the same two models (mapped to google/nano-banana-pro
and google/nano-banana-2), aspect ratios, 1K/2K/4K, and up to 14 batched
reference images switching it to /edit. WaveSpeed has no text output,
response_modalities, system prompt or file inputs, so those are left out.

Verified with a stubbed API whose calls block like real requests: the three
nodes take 10.8s run in sequence and 4.2s run together, and an event-loop
heartbeat keeps ticking throughout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:39:16 +02:00
Ivor StrelarandClaude Opus 5.5 9212fa484e feat: add SF WaveSpeed GPT Image 2.5 node
Mirrors ComfyUI's "OpenAI GPT Image 2.5" partner node but bills a WaveSpeed
key via SF WaveSpeed Client. Built on the same V3 components the partner node
uses: a DynamicCombo model picker whose options carry their own controls, and
Autogrow reference-image slots, so connecting images switches to WaveSpeed's
/edit endpoint and leaving them empty uses /text-to-image.

Follows WaveSpeed's actual schemas rather than OpenAI's: gpt-image-1 is
dropped (not offered), 2 and 2.5 take aspect_ratio plus a 1k/2k/4k resolution
instead of pixel sizes, background exists only on 1.5, and 1.5 caps
references at 10. There is no mask input, as no WaveSpeed edit endpoint
accepts one. WaveSpeed has no `n`, so n requests are submitted together and
returned as one batch.

Copies the partner node's behaviour where it applies: references are
downscaled to 2048x2048 pixels before upload, batches are flattened, and
outputs keep alpha when a transparent background was produced.

Also moves v3 result polling and the 401 key-source message into a shared
wavespeed_api/v3.py, and switches the Tripo P2 node to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:10:38 +02:00
Ivor StrelarandClaude Opus 5.5 98b7a853cf fix: let SF WaveSpeed Client reuse the official pack's saved key
SF WaveSpeed Client only looked at its own api_key field, its own config.ini
and WAVESPEED_API_KEY. Users who had configured WaveSpeed through the official
wavespeed-comfyui pack therefore had to type a second copy into the node, and
a stale copy there produced a bare 401 from the upload call with no hint of
which key was at fault.

With the field left empty it now falls back, in order, to config.ini, the
environment variable, and the official pack's config.json. The resolved
source (never the key) is logged and passed along, and the Tripo P2 node
names that source when WaveSpeed rejects the key. The field's tooltip warns
that a key typed there is saved into the workflow file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:38:42 +02:00
Ivor StrelarandClaude Opus 5.5 703711f07c feat: add a folder picker to the Save workflow dialog
ComfyUI's Save and Save As dialogs are a single text prompt, so saving into a
subfolder means typing the full path relative to the workflows directory.

A frontend extension now injects a collapsible tree of the workflow folders
into that dialog. Clicking a folder rewrites the path in the dialog's own
input, keeping the typed name and selecting it for overtyping; "+ New folder"
adds a level that the backend creates on save. The tree opens on the current
workflow's folder.

The native dialog is augmented rather than replaced, so ComfyUI still performs
the save and its tab tracking and overwrite confirmation are unchanged. The
picker appears only while Comfy.SaveWorkflow or Comfy.SaveWorkflowAs is
running, which keeps it out of the Rename and Export prompts that share the
same dialog component. Folders come from /api/v2/userdata, which unlike the
v1 listing includes empty directories. Can be switched off in Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:33:49 +02:00
Ivor StrelarandClaude Opus 5.5 3f3d8581df feat: add SF WaveSpeed Tripo P2 Image to 3D node
Tripo P2 image-to-3D through WaveSpeed's API, for generating on a WaveSpeed
account rather than ComfyUI partner credits. The official WaveSpeed pack does
not surface it: the installed 2.0.0 fetches its model list from a catalog
endpoint that now returns 404.

Exposes quad topology, face_limit, texture quality (none/fast/standard/
detailed/extreme), PBR, texture version, delight, auto_size, export_uv,
export_orientation and seeds. Validates face_limit against Tripo's ranges
(48-50,000 triangles, 48-25,000 quads) and that 'fast' texture is paired with
v3.5, so bad combinations fail before they are billed.

Outputs mirror ComfyUI's own Tripo node: FILE_3D_GLB and FILE_3D_FBX, with
the one matching the returned format populated (FBX for quad, GLB otherwise),
so it feeds Preview 3D and Save 3D directly. The model is also downloaded to
output/3d/ and its path and URL returned as strings. Unused options are
omitted from the request rather than sent as null, per WaveSpeed's docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:17:05 +02:00
Ivor StrelarandClaude Opus 5 309aaf216b docs: give the README a front page that explains the pack
The README had grown by appending a section per node and opened with little
more than a one-line description, so a visitor landing on the repo could not
tell what the pack was for without scrolling through 44 WaveSpeed rows.

Adds an overview table of the four categories with node counts, and surfaces
the two things worth finding — the asset library workflow and the Google Sheet
prompt queue — above the fold with links into their sections.

Also fixes a broken table in Stillfront/LLM, where a blank line had orphaned
the SF Claude Library Builder row into a separate fragment that rendered as
stray text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 13:55:45 +02:00
Ivor StrelarandClaude Opus 5 59ebc14c5c feat: make library builder reruns incremental
Adding assets to a catalogued library used to mean re-describing every asset:
the checkpoint is deleted on success, and its key hashed the file list, so any
change invalidated it. Cataloguing five new assets cost the same as the whole
library.

The manifest itself is now the durable record of what is described. With the
new update_existing input (default on), a rerun loads the existing manifest,
reuses those descriptions, and only sends genuinely new files to Claude.
Entries whose file no longer exists are dropped. Turning it off re-describes
everything.

The checkpoint key no longer hashes the file list either, so adding an asset
mid-recovery does not discard an interrupted run's progress.

Not applied to organise-into-a-copy, where manifest paths are post-rename and
cannot be matched against a scan of the untouched source.

Verified with a stubbed CLI across four reruns: fresh library describes all 6,
an unchanged rerun describes 0, adding 3 describes only those 3, and deleting
2 describes 0 while dropping both from the manifest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 11:22:52 +02:00
Ivor StrelarandClaude Opus 5 aef1cc5968 fix: retry a cut-off batch as two smaller halves instead of failing
A batch whose reply ran past the model's length limit came back as truncated
JSON and aborted the whole run — hit in practice on batch 7 of 7, after the
first 90 of 101 assets had already been catalogued successfully.

Parse failures now raise BatchParseError, which the batch runner handles by
splitting the file list in half and retrying each side, recursively. Normal
batches are unaffected; only a batch that actually fails to parse pays for a
retry, and each half that succeeds is merged and checkpointed immediately.

A single asset that still cannot be parsed stops the run with a message naming
that file, so the recursion always terminates.

Verified with a stubbed CLI: a reply truncating above 4 files descends
12 -> 6 -> 3+3 per side and catalogues all 12, and the worst case where every
reply truncates stops after 3 calls with the checkpoint intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 11:03:06 +02:00
10 changed files with 1402 additions and 97 deletions
+137 -5
View File
@@ -2,7 +2,32 @@
![Stillfront](assets/logo.png)
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) | 48 nodes — image, video and 3D generation through [WaveSpeed AI](https://wavespeed.ai): VEO 3.1, Sora 2, GPT Image 2.5, Nano Banana Pro, Qwen, Flux Kontext, Seedream, WAN 2.5, InfiniteTalk, Tripo P2 and H3.1, upscalers |
| [**Stillfront/VertexAI**](#stillfrontvertexai) | 7 nodes — Imagen 3/4, VEO 3.1 and Nano Banana Pro via Google Cloud Vertex AI |
| [**Stillfront/Utils**](#stillfrontutils) | 7 nodes — Google Sheets integration, prompt batching, resolution presets |
| [**Stillfront/LLM**](#stillfrontllm) | 4 nodes — Claude, Gemini and GPT in your graph, plus the asset library tooling below |
### 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,7 +86,6 @@ 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**. |
#### The asset library workflow
@@ -130,9 +154,23 @@ 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
halves**, repeatedly if needed, rather than failing the run. You'll see it in the log as
`Batch 7/7: the reply was cut off… Retrying as two batches of 5 and 6`.
#### Using SF Claude Asset Director
@@ -249,6 +287,8 @@ Connect an **SF WaveSpeed Client** node (enter your WaveSpeed API key) to any Wa
| Node | Description |
|---|---|
| **SF WaveSpeed GPT Image 2.5** | OpenAI's GPT Image models through WaveSpeed, built to match ComfyUI's *OpenAI GPT Image 2.5* partner node — see below. |
| **SF WaveSpeed Nano Banana Pro** | Google's Nano Banana Pro and Nano Banana 2 through WaveSpeed, built to match ComfyUI's *Nano Banana Pro (Google Gemini Image)* partner node — see below. |
| **SF WaveSpeed Nano Banana Text to Image** | Nano Banana text-to-image generation. |
| **SF WaveSpeed Nano Banana Edit** | Edit images using the Nano Banana model. |
| **SF WaveSpeed Nano Banana Pro Text to Image** | Nano Banana Pro text-to-image. |
@@ -273,6 +313,79 @@ Connect an **SF WaveSpeed Client** node (enter your WaveSpeed API key) to any Wa
| **SF WaveSpeed Flux Kontext Max** | Flux Kontext Max — highest quality variant. |
| **SF WaveSpeed Flux ControlNet Union Pro 2** | Flux image generation with ControlNet guidance. |
#### These run in parallel
**SF WaveSpeed GPT Image 2.5**, **SF WaveSpeed Nano Banana Pro**, **SF WaveSpeed Tripo P2 Image
to 3D** and **SF WaveSpeed Tripo H3.1 Image to 3D** run concurrently, the way ComfyUI's partner nodes do. Put several of them side by
side and press Run: they all submit and wait together, so the run takes about as long as the
slowest one rather than the sum of all of them.
They're asynchronous nodes, which ComfyUI starts as background tasks and keeps scheduling other
work around — no change to ComfyUI is involved, so updates don't affect it. Only nodes that don't
depend on each other overlap; WaveSpeed may also cap how many jobs one account runs at once.
The older SF WaveSpeed nodes still run one at a time.
#### Using SF WaveSpeed Nano Banana Pro
Same controls as the partner node — prompt, the same two models, aspect ratio, resolution, and an
optional `images` input that switches it to editing (batch up to 14 references with **Batch
Images**). It bills your WaveSpeed key instead of ComfyUI credits.
| Partner model | WaveSpeed model |
|---|---|
| `gemini-3-pro-image-preview` | `google/nano-banana-pro` |
| `Nano Banana 2 (Gemini 3.1 Flash Image)` | `google/nano-banana-2` |
What WaveSpeed's API doesn't offer, and so the node leaves out rather than fakes: the **text
output** (the partner's second output, a written response from the model), **`response_modalities`**,
the **system prompt**, and the **`files`** input. As on the partner node, `seed` isn't used by the
model — change it to force a re-run.
#### Using SF WaveSpeed GPT Image 2.5
Same interface as ComfyUI's partner node: pick a model and the controls below it change to what
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. |
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 |
@@ -323,6 +436,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+
+172 -57
View File
@@ -55,16 +55,25 @@ def chunk(items, size):
yield items[i : i + size]
class BatchParseError(RuntimeError):
"""Claude's reply was not usable JSON.
Nearly always a reply that ran past its length limit and stopped partway,
which a smaller batch fixes — so the caller retries by splitting rather than
giving up.
"""
def extract_json(text):
"""The JSON object out of a reply that may be fenced or prefaced."""
start, end = text.find("{"), text.rfind("}")
if start == -1 or end <= start:
raise RuntimeError(f"Claude did not return JSON. It said: {text.strip()[:300]}")
raise BatchParseError("the reply stopped before any complete JSON object")
body = text[start : end + 1]
try:
return json.loads(body)
except json.JSONDecodeError as e:
raise RuntimeError(f"Claude's JSON could not be parsed ({e}): {body[:300]}")
raise BatchParseError(f"the reply was cut off or malformed ({e})")
def canonicalize(claude_path, expected):
@@ -80,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):
@@ -267,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",
{
@@ -324,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.
@@ -340,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,
@@ -368,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.
@@ -392,35 +474,34 @@ Budget about 5 seconds per asset — it opens every image.
total_cost = 0.0
started = time.time()
for batch_num, batch_files in enumerate(batches, start=1):
remaining = [f for f in batch_files if f not in done_by_file]
if not remaining:
continue # fully covered by the checkpoint already
established = (project, art_style) if project and art_style else None
instruction = build_batch_instruction(
mode, remaining, len(assets), project_notes, established, out_dir
def fail(message):
"""Stop the run, leaving finished batches safely checkpointed."""
save_checkpoint(root, key, project, art_style, list(done_by_file.values()))
raise RuntimeError(
f"{message} {len(done_by_file)}/{len(assets)} asset(s) are saved in a "
f"checkpoint — rerun with the same settings to resume rather than "
f"start over."
)
command = [binary, "-p", instruction, "--output-format", "json", "--allowedTools", tools]
def run_batch(files, label):
"""One Claude call. Returns (parsed reply, cost, turns)."""
established = (project, art_style) if project and art_style else None
command = [
binary,
"-p",
build_batch_instruction(
mode, files, len(assets), project_notes, established, out_dir
),
"--output-format",
"json",
"--allowedTools",
tools,
]
if system_prompt.strip():
command += ["--append-system-prompt", system_prompt.strip()]
if model != "default":
command += ["--model", model]
print(
f"[SF Claude Library Builder] Batch {batch_num}/{len(batches)} "
f"({len(remaining)} asset(s))..."
)
def _fail(message):
save_checkpoint(root, key, project, art_style, list(done_by_file.values()))
raise RuntimeError(
f"{message} {len(done_by_file)}/{len(assets)} asset(s) are saved in a "
f"checkpoint — rerun with the same settings to resume rather than "
f"start over."
)
try:
completed = subprocess.run(
command,
@@ -431,39 +512,55 @@ Budget about 5 seconds per asset — it opens every image.
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
_fail(
f"Batch {batch_num}/{len(batches)} did not finish within "
f"{timeout_seconds}s."
)
fail(f"Batch {label} did not finish within {timeout_seconds}s.")
if completed.returncode != 0:
detail = (completed.stderr or completed.stdout or "").strip()[:500]
_fail(f"Batch {batch_num}/{len(batches)} failed (exit {completed.returncode}). {detail}")
fail(f"Batch {label} failed (exit {completed.returncode}). {detail}")
try:
payload = json.loads(completed.stdout)
except json.JSONDecodeError:
_fail(
f"Batch {batch_num}/{len(batches)}: unreadable response: "
f"{completed.stdout.strip()[:300]}"
)
fail(f"Batch {label}: unreadable response: {completed.stdout.strip()[:300]}")
if payload.get("is_error"):
_fail(
f"Batch {batch_num}/{len(batches)} reported an error: "
f"{payload.get('result', 'unknown')}."
)
cost = payload.get("total_cost_usd") or 0.0
total_cost += cost
turns = payload.get("num_turns", "?")
fail(f"Batch {label} reported an error: {payload.get('result', 'unknown')}.")
# May raise BatchParseError, which catalogue() retries by splitting.
data = extract_json(payload.get("result", ""))
return data, payload.get("total_cost_usd") or 0.0, payload.get("num_turns", "?")
def catalogue(files, label):
"""Catalogue `files`, halving and retrying if the reply comes back cut off.
Each piece that succeeds is merged and checkpointed immediately, so a
later failure never costs work already paid for.
"""
nonlocal project, art_style, total_cost
try:
data, cost, turns = run_batch(files, label)
except BatchParseError as exc:
if len(files) == 1:
fail(
f"Batch {label}: {exc}. This is a single asset "
f"({files[0]}), so it cannot be split any further."
)
half = len(files) // 2
print(
f"[SF Claude Library Builder] Batch {label}: {exc}. "
f"Retrying as two batches of {half} and {len(files) - half}."
)
catalogue(files[:half], f"{label}a")
catalogue(files[half:], f"{label}b")
return
total_cost += cost
if project is None:
project = data.get("project", "Untitled project")
art_style = data.get("art_style", "")
expected = set(remaining)
expected = set(files)
for entry in data.get("assets") or []:
raw = entry.get("file")
if not raw:
@@ -474,11 +571,29 @@ Budget about 5 seconds per asset — it opens every image.
save_checkpoint(root, key, project, art_style, list(done_by_file.values()))
print(
f"[SF Claude Library Builder] Batch {batch_num}/{len(batches)} done: "
f"{turns} turns, ${cost:.2f}. Running total: "
f"{len(done_by_file)}/{len(assets)} asset(s), ${total_cost:.2f}."
f"[SF Claude Library Builder] Batch {label} done: {turns} turns, "
f"${cost:.2f}. Running total: {len(done_by_file)}/{len(assets)} "
f"asset(s), ${total_cost:.2f}."
)
skipped = [f for f in files if f not in done_by_file]
if skipped:
print(
f"[SF Claude Library Builder] Batch {label}: {len(skipped)} file(s) "
f"came back undescribed; a rerun will pick them up."
)
for batch_num, batch_files in enumerate(batches, start=1):
remaining = [f for f in batch_files if f not in done_by_file]
if not remaining:
continue # fully covered by the checkpoint already
print(
f"[SF Claude Library Builder] Batch {batch_num}/{len(batches)} "
f"({len(remaining)} asset(s))..."
)
catalogue(remaining, f"{batch_num}/{len(batches)}")
elapsed = time.time() - started
# Assemble the final manifest in the library's original scan order.
@@ -492,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:
@@ -505,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"),
+214
View File
@@ -0,0 +1,214 @@
# 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()
async def call(client, fn, *args):
"""Run a blocking API call in a worker thread, turning a 401 into an error
that says which key was used."""
try:
return 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 asyncio.to_thread(api.get, RESULT_PATH.format(id=task_id))
status = result.get("status")
if status == "completed":
return result
if status in TERMINAL_FAILURES:
raise RuntimeError(f"{label} task {status}: {result.get('error') or result}")
if loop.time() - started > timeout_seconds:
raise RuntimeError(f"{label} task {task_id} still '{status}' after {timeout_seconds}s.")
await asyncio.sleep(interval)
async def submit_and_collect(api, client, endpoint, payload, count, timeout_seconds, label):
"""Submit `count` identical predictions at once and wait for all of them.
Returns every output URL, in submission order."""
async def one():
task = await call(client, api.post, endpoint, payload)
if not task.get("id"):
raise RuntimeError(f"WaveSpeed did not return a task id: {task}")
result = await wait_for_result(api, task["id"], timeout_seconds, label)
outputs = result.get("outputs") or []
return outputs if isinstance(outputs, list) else [outputs]
batches = await asyncio.gather(*(one() for _ in range(count)))
return [url for batch in batches for url in batch]
def flatten_images(tensors):
"""Every frame of every IMAGE tensor, as PIL images."""
frames = []
for tensor in tensors:
if tensor is None:
continue
batch = tensor if tensor.dim() == 4 else tensor.unsqueeze(0)
frames.extend(tensor2images(batch))
return frames
def downscale(image, max_pixels=MAX_UPLOAD_PIXELS):
w, h = image.size
if w * h <= max_pixels:
return image
scale = (max_pixels / (w * h)) ** 0.5
return image.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
async def upload_images(api, client, images):
"""Upload references concurrently, downscaled first. Returns their URLs in order."""
return list(await asyncio.gather(*(call(client, api.upload_file, downscale(img)) for img in images)))
async def fetch_images(urls, timeout=120):
"""Download output images concurrently."""
def get(url):
response = requests.get(url, timeout=timeout)
response.raise_for_status()
return Image.open(io.BytesIO(response.content))
return list(await asyncio.gather(*(asyncio.to_thread(get, url) for url in urls)))
def _has_alpha(image):
return image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info)
def to_image_batch(frames):
"""One IMAGE tensor from PIL frames. Keeps alpha when any frame is transparent;
resizes stragglers to the first frame, since a batch must share one size."""
if not frames:
raise RuntimeError("WaveSpeed returned no images.")
mode = "RGBA" if any(_has_alpha(f) for f in frames) else "RGB"
width, height = frames[0].size
tensors = []
for frame in frames:
if frame.size != (width, height):
frame = frame.resize((width, height), Image.LANCZOS)
tensors.append(torch.from_numpy(np.asarray(frame.convert(mode), dtype=np.float32) / 255.0)[None,])
return torch.cat(tensors, dim=0)
# --- 3D models -----------------------------------------------------------------
def pick_model_url(outputs):
"""The model file URL out of a result's `outputs`, which may be a list or a string."""
candidates = outputs if isinstance(outputs, list) else [outputs]
urls = [u for u in candidates if isinstance(u, str) and u.startswith("http")]
for url in urls:
if urlparse(url).path.lower().endswith((".glb", ".fbx")):
return url
if urls:
return urls[0]
raise RuntimeError(f"No model URL in the result: {outputs!r}")
def output_dir():
"""ComfyUI's output/3d folder, created if missing."""
try:
import folder_paths
base = folder_paths.get_output_directory()
except ImportError:
base = os.getcwd()
path = os.path.join(base, "3d")
os.makedirs(path, exist_ok=True)
return path
async def generate_3d_model(api, client, image, endpoint, build_payload, quad, file_prefix,
timeout_seconds, label):
"""Image-to-3D from upload to File3D: uploads the first frame, submits the
job, waits for it, saves the model to output/3d and returns
(glb, fbx, path, url). `build_payload` receives the uploaded image URL.
Tripo returns FBX when quad topology is requested and GLB otherwise, so only
the output matching the returned file is populated, as on ComfyUI's own
Tripo nodes.
"""
image_url = await call(client, api.upload_file, tensor2images(image)[0])
task = await call(client, api.post, endpoint, build_payload(image_url))
task_id = task.get("id")
if not task_id:
raise RuntimeError(f"WaveSpeed did not return a task id: {task}")
print(f"[SF {label}] Submitted {task_id}.")
started = time.time()
result = await wait_for_result(api, task_id, timeout_seconds, label, interval=3)
url = pick_model_url(result.get("outputs"))
ext = os.path.splitext(urlparse(url).path)[1].lower() or (".fbx" if quad else ".glb")
path = os.path.join(output_dir(), f"{file_prefix}_{task_id}{ext}")
def download():
response = requests.get(url, timeout=300)
response.raise_for_status()
with open(path, "wb") as f:
f.write(response.content)
await asyncio.to_thread(download)
print(f"[SF {label}] Done in {time.time() - started:.0f}s -> {path}")
glb = fbx = None
if File3D is not None:
model = File3D(path)
glb = model if ext == ".glb" else None
fbx = model if ext == ".fbx" else None
return (glb, fbx, path, url)
+65 -34
View File
@@ -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"}
+194
View File
@@ -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"}
+99
View File
@@ -0,0 +1,99 @@
# ABOUTME: Nano Banana Pro through WaveSpeed, modelled on ComfyUI's "Nano Banana Pro (Google Gemini Image)" node
# ABOUTME: Same controls and model choices as the partner node; async, so it runs in parallel
from comfy_api.latest import IO
from .wavespeed_api.client import WaveSpeedClient
from .wavespeed_api.v3 import fetch_images, flatten_images, submit_and_collect, to_image_batch, upload_images
# The partner node's model names, mapped to their WaveSpeed equivalents.
MODELS = {
"gemini-3-pro-image-preview": "google/nano-banana-pro",
"Nano Banana 2 (Gemini 3.1 Flash Image)": "google/nano-banana-2",
}
ASPECT_RATIOS = ["auto", "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]
MAX_REFERENCES = 14
def build_payload(prompt, aspect_ratio, resolution, image_urls):
payload = {"prompt": prompt, "resolution": resolution.lower(), "output_format": "png"}
if aspect_ratio != "auto":
payload["aspect_ratio"] = aspect_ratio
if image_urls:
payload["images"] = image_urls
return payload
class SFWaveSpeedNanoBananaPro(IO.ComfyNode):
"""Nano Banana Pro via WaveSpeed, with the partner node's interface."""
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SFWaveSpeedNanoBananaPro",
display_name="SF WaveSpeed Nano Banana Pro",
category="Stillfront/WaveSpeed",
description="Generate or edit images with Google's Nano Banana Pro through WaveSpeed.",
inputs=[
IO.Custom("WAVESPEED_AI_API_CLIENT").Input("client"),
IO.String.Input(
"prompt",
multiline=True,
default="",
tooltip="Text prompt describing the image to generate or the edits to apply. "
"Include any constraints, styles, or details the model should follow.",
),
IO.Combo.Input("model", options=list(MODELS)),
IO.Int.Input(
"seed",
default=42,
min=0,
max=0xFFFFFFFFFFFFFFFF,
control_after_generate=True,
tooltip="Not sent to WaveSpeed, which has no seed for these models. "
"Change it to force a re-run.",
),
IO.Combo.Input(
"aspect_ratio",
options=ASPECT_RATIOS,
default="auto",
tooltip="If set to 'auto', matches your input image's aspect ratio; "
"with no image, the model chooses.",
),
IO.Combo.Input("resolution", options=["1K", "2K", "4K"], tooltip="Target output resolution."),
IO.Image.Input(
"images",
optional=True,
tooltip="Optional reference image(s). "
f"To include multiple images, use the Batch Images node (up to {MAX_REFERENCES}).",
),
IO.Int.Input("timeout_seconds", default=600, min=60, max=3600, step=30, advanced=True),
],
outputs=[IO.Image.Output()],
)
@classmethod
async def execute(cls, client, prompt, model, seed, aspect_ratio, resolution, images=None, timeout_seconds=600):
if not prompt.strip():
raise ValueError("The prompt box is empty — Nano Banana needs a text instruction, even when editing.")
if model not in MODELS:
raise ValueError(f"Unknown model: {model}")
references = flatten_images([images])
if len(references) > MAX_REFERENCES:
raise ValueError(f"The current maximum number of supported images is {MAX_REFERENCES}.")
api = WaveSpeedClient(api_key=client["api_key"])
image_urls = await upload_images(api, client, references)
endpoint = f"/api/v3/{MODELS[model]}/{'edit' if image_urls else 'text-to-image'}"
payload = build_payload(prompt, aspect_ratio, resolution, image_urls)
print(f"[SF Nano Banana Pro] {MODELS[model]}, "
f"{f'editing {len(image_urls)} image(s)' if image_urls else 'text-to-image'}, {resolution}.")
urls = await submit_and_collect(api, client, endpoint, payload, 1, timeout_seconds, "Nano Banana Pro")
return IO.NodeOutput(to_image_batch(await fetch_images(urls)))
NODE_CLASS_MAPPINGS = {"SFWaveSpeedNanoBananaPro": SFWaveSpeedNanoBananaPro}
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedNanoBananaPro": "SF WaveSpeed Nano Banana Pro"}
+140
View File
@@ -0,0 +1,140 @@
# ABOUTME: Tripo H3.1 image-to-3D through the WaveSpeed API — Tripo's high-detail model
# ABOUTME: Same outputs and parallel behaviour as the P2 node, for side-by-side comparison
from .wavespeed_api.client import WaveSpeedClient
from .wavespeed_api.v3 import generate_3d_model
ENDPOINT = "/api/v3/tripo3d/h3.1/image-to-3d"
TEXTURE_LEVELS = ["standard", "detailed", "none"]
QUALITIES = ["standard", "detailed"]
FACE_MIN, FACE_MAX = 1000, 2000000
def build_payload(image_url, quad, face_limit, texture, pbr, geometry_quality,
texture_alignment, orientation, auto_size, model_seed, texture_seed):
"""The request body. Unused options are omitted rather than sent as null."""
if face_limit != -1 and not FACE_MIN <= face_limit <= FACE_MAX:
raise ValueError(
f"face_limit must be -1 (adaptive) or between {FACE_MIN:,} and {FACE_MAX:,} — "
f"H3.1 does not go below {FACE_MIN:,} faces. Use the P2 node for lower counts."
)
textured = texture != "none"
payload = {
"image": image_url,
"quad": quad,
"texture": textured,
# PBR implies textures, so it is switched off along with them.
"pbr": bool(pbr and textured),
"geometry_quality": geometry_quality,
"orientation": orientation,
"auto_size": auto_size,
}
if textured:
payload["texture_quality"] = texture
payload["texture_alignment"] = texture_alignment
if face_limit != -1:
payload["face_limit"] = face_limit
if model_seed != -1:
payload["model_seed"] = model_seed
if texture_seed != -1 and textured:
payload["texture_seed"] = texture_seed
return payload
class SFWaveSpeedTripoH31ImageTo3D:
"""
Tripo H3.1 image-to-3D via WaveSpeed: Tripo's high-detail model, at roughly
a fifth of P2's price. Meshes from 1,000 to 2,000,000 faces.
Tripo returns FBX when quad topology is on and GLB when it is off, so the
matching output is populated and the other is empty.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"client": ("WAVESPEED_AI_API_CLIENT",),
"image": ("IMAGE", {"tooltip": "Reference image. Only the first frame of a batch is used."}),
"quad": (
"BOOLEAN",
{"default": False, "tooltip": "Quad topology instead of triangles. Output becomes FBX."},
),
"face_limit": (
"INT",
{
"default": -1,
"min": -1,
"max": FACE_MAX,
"step": 100,
"tooltip": f"Target face count. -1 lets Tripo choose. {FACE_MIN:,}-{FACE_MAX:,}.",
},
),
"texture": (
TEXTURE_LEVELS,
{"default": "standard", "tooltip": "'none' gives untextured geometry."},
),
"pbr": ("BOOLEAN", {"default": True, "tooltip": "PBR material maps. Ignored when texture is 'none'."}),
},
"optional": {
"geometry_quality": (
QUALITIES,
{"default": "standard", "tooltip": "'detailed' gives finer geometry and may cost more."},
),
"texture_alignment": (
["original_image", "geometry"],
{
"default": "original_image",
"tooltip": "Align textures to the input image, or to the generated geometry.",
},
),
"orientation": (
["default", "align_image"],
{"default": "default", "tooltip": "'align_image' rotates the model to match the input image."},
),
"auto_size": ("BOOLEAN", {"default": False, "tooltip": "Scale the model to real-world metres."}),
"model_seed": (
"INT",
{"default": -1, "min": -1, "max": 2147483647, "tooltip": "-1 for random geometry."},
),
"texture_seed": (
"INT",
{"default": -1, "min": -1, "max": 2147483647, "tooltip": "-1 for random texturing."},
),
"timeout_seconds": ("INT", {"default": 900, "min": 60, "max": 3600, "step": 30}),
},
}
RETURN_TYPES = ("FILE_3D_GLB", "FILE_3D_FBX", "STRING", "STRING")
RETURN_NAMES = ("GLB", "FBX", "model_file", "model_url")
OUTPUT_TOOLTIPS = (
"Populated when quad is off.",
"Populated when quad is on.",
"Absolute path of the downloaded model in ComfyUI's output/3d folder.",
"WaveSpeed CDN link to the model.",
)
FUNCTION = "generate"
CATEGORY = "Stillfront/WaveSpeed"
OUTPUT_NODE = True
# async so ComfyUI can run several of these, and other async nodes, at once.
async def generate(self, client, image, quad, face_limit, texture, pbr,
geometry_quality="standard", texture_alignment="original_image",
orientation="default", auto_size=False,
model_seed=-1, texture_seed=-1, timeout_seconds=900):
api = WaveSpeedClient(api_key=client["api_key"])
print(f"[SF Tripo H3.1] quad={quad}, faces={face_limit}, texture={texture}, geometry={geometry_quality}.")
return await generate_3d_model(
api, client, image, ENDPOINT,
lambda image_url: build_payload(
image_url, quad, face_limit, texture, pbr, geometry_quality,
texture_alignment, orientation, auto_size, model_seed, texture_seed,
),
quad, "sf_tripo_h31", timeout_seconds, "Tripo H3.1",
)
NODE_CLASS_MAPPINGS = {"SFWaveSpeedTripoH31ImageTo3D": SFWaveSpeedTripoH31ImageTo3D}
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedTripoH31ImageTo3D": "SF WaveSpeed Tripo H3.1 Image to 3D"}
+159
View File
@@ -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
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-sf-nodes"
description = "Custom nodes for ComfyUI including LLM Chat with support for Claude, Gemini, and OpenAI models, WaveSpeed AI image/video generation, VertexAI integration, and workflow utilities"
version = "1.3.1"
version = "1.9.0"
license = { file = "LICENSE" }
dependencies = [
"requests>=2.31.0",
+221
View File
@@ -0,0 +1,221 @@
/**
* 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.
*/
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;
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;
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 () {
savePending = true;
try {
return await original.apply(this, arguments);
} finally {
savePending = false;
}
};
}
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 });
},
});