Compare commits
@@ -1,20 +0,0 @@
|
||||
name: Publish to Comfy registry
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
@@ -36,6 +36,9 @@ env/
|
||||
# Config with secrets
|
||||
config.ini
|
||||
|
||||
# Publish artifact created by `comfy node publish`
|
||||
node.zip
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
# SF ComfyUI Nodes
|
||||
|
||||
A collection of custom nodes for ComfyUI developed by Stillfront.
|
||||

|
||||
|
||||
## Features
|
||||
**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.
|
||||
|
||||
- **LLM Integration** — Chat with Claude, Gemini, and OpenAI GPT models, with optional image input
|
||||
- **WaveSpeed AI** — Image and video generation (VEO, Sora, Qwen, ByteDance, Kling, VIDU, WAN, and more)
|
||||
- **VertexAI** — Google Imagen3 and VEO video generation via Vertex AI
|
||||
- **Utility Nodes** — Resolution presets, dynamic prompt lists, text analysis
|
||||
| | |
|
||||
|---|---|
|
||||
| [**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.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Via ComfyUI Manager (recommended)
|
||||
|
||||
Search for **"SF ComfyUI Nodes"** in ComfyUI Manager and click Install.
|
||||
Search for **"SF ComfyUI Nodes"** in the ComfyUI Nodes Manager and click **Install**.
|
||||
|
||||
### Manual
|
||||
|
||||
@@ -24,47 +46,421 @@ cd comfyui-sf-nodes
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Copy `config.ini.tmp` to `config.ini`
|
||||
2. Add your API keys:
|
||||
- `WAVESPEED_API_KEY`
|
||||
- `ANTHROPIC_API_KEY` (for Claude)
|
||||
- `GEMINI_API_KEY` (for Gemini)
|
||||
- Google Cloud credentials (for VertexAI nodes)
|
||||
Copy `config.ini.tmp` to `config.ini` and fill in your API keys:
|
||||
|
||||
```ini
|
||||
[API]
|
||||
wavespeed_api_key = YOUR_WAVESPEED_API_KEY
|
||||
```
|
||||
|
||||
Alternatively, set keys via environment variables:
|
||||
|
||||
| Variable | Used by |
|
||||
|---|---|
|
||||
| `WAVESPEED_API_KEY` | All WaveSpeed nodes |
|
||||
| `ANTHROPIC_API_KEY` | SF LLM Chat (Claude) |
|
||||
| `GEMINI_API_KEY` | SF LLM Chat (Gemini) |
|
||||
| `OPENAI_API_KEY` | SF LLM Chat (GPT) |
|
||||
| Google Application Default Credentials | All VertexAI nodes |
|
||||
|
||||
For VertexAI nodes, authenticate via the [Google Cloud CLI](https://cloud.google.com/sdk/docs/install):
|
||||
```bash
|
||||
gcloud auth application-default login
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Node Categories
|
||||
|
||||
All nodes are prefixed with **SF** for easy searching in ComfyUI.
|
||||
|
||||
### Stillfront/LLM
|
||||
- **SF LLM Chat** — Multi-model chat (Claude, Gemini, GPT) with image support and token usage reporting
|
||||
- **SF Text Analyzer** — Text analysis utilities
|
||||
|
||||
### Stillfront/WaveSpeed
|
||||
- **SF WaveSpeed Client** — API client configuration
|
||||
- **SF VEO 3.1 Text to Video** — Google VEO video generation
|
||||
- **SF Sora 2 Text to Video** — OpenAI Sora video generation
|
||||
- **SF Qwen Image** — Qwen image generation and editing
|
||||
- **SF ByteDance Seedream** — ByteDance image generation
|
||||
- **SF Kling** — Kling video generation
|
||||
- **SF VIDU** — VIDU video generation
|
||||
- And many more...
|
||||
Chat with large language models directly inside your ComfyUI workflows, with optional image input.
|
||||
|
||||
| Node | Description |
|
||||
|---|---|
|
||||
| **SF LLM Chat** | Multi-model chat supporting Claude (Opus, Sonnet, Haiku), Gemini 3 (Pro, Flash), and OpenAI GPT-5 series. Supports image input and reports token usage. |
|
||||
| **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
|
||||
|
||||
These two nodes are a pair. **Library Builder** catalogues your assets once; **Asset Director**
|
||||
then searches that catalogue on every run without ever opening an image.
|
||||
|
||||
```
|
||||
one time, when assets change every generation
|
||||
┌──────────────────────────────┐ ┌──────────────────────────────────┐
|
||||
│ SF Claude Library Builder │ │ SF Claude Asset Director │
|
||||
│ opens every image, │──►│ searches the manifest, │──► images + prompt
|
||||
│ writes manifest.json │ │ opens nothing │
|
||||
└──────────────────────────────┘ └──────────────────────────────────┘
|
||||
```
|
||||
|
||||
That split is the point: cataloguing is slow (~5s per asset) but happens rarely, while choosing
|
||||
is instant and happens constantly.
|
||||
|
||||
**The manifest schema** (`sf-asset-library/1`):
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "sf-asset-library/1",
|
||||
"project": "Fantasy RPG",
|
||||
"art_style": "Hand-painted 2D, saturated palette, thick outlines, 3/4 view",
|
||||
"generated": "2026-10-08",
|
||||
"assets": [
|
||||
{
|
||||
"file": "characters/heroes/knight_aldric.png",
|
||||
"name": "Aldric the Knight",
|
||||
"category": "character",
|
||||
"subcategory": "hero",
|
||||
"archetype": "tank",
|
||||
"tags": ["armoured", "sword", "front view", "heroic"],
|
||||
"desc": "Armoured human knight in silver plate with a red tabard, standing front-on."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
It's shaped for fast searching rather than for humans: one **flat list** (cheaper to scan than a
|
||||
tree), the **same keys on every entry**, faceted fields (`category` / `subcategory` /
|
||||
`archetype` / `tags`) to narrow by, and a one-sentence `desc` carrying the meaning. The
|
||||
top-level **`art_style`** matters more than it looks — Asset Director reads it and keeps every
|
||||
prompt it writes consistent with it, so a library generates in one coherent style.
|
||||
|
||||
**Library Builder modes:**
|
||||
|
||||
| Mode | What it touches |
|
||||
|---|---|
|
||||
| **manifest only** (default) | Nothing. Reads the images, returns the JSON as text |
|
||||
| **organise into a copy** | Writes a tidy, renamed copy to `output_path`; originals untouched |
|
||||
| **organise in place** | Renames and moves your actual files |
|
||||
|
||||
`write_manifest` is **off** by default, so the first run shows you the JSON without writing
|
||||
anything. Turn it on once you're happy, or paste the text in yourself.
|
||||
|
||||
Start in **manifest only**. Reorganising is genuinely useful on a folder of `IMG_0021.png` and
|
||||
`final_v2 copy.png`, but it moves real files — try it on a copy first.
|
||||
|
||||
**Large libraries are processed in batches, not one session.** `batch_size` (default 15) caps
|
||||
how many assets Claude handles per call — asking it to hold a whole large library open, compose
|
||||
one giant manifest, and write the file all in a single uninterrupted session is what used to
|
||||
blow up in turns and cost on libraries of 100+ assets, with nothing produced at the end. Each
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
Give it a folder of assets and a brief; it returns the references Claude chose and the prompt
|
||||
it wrote for them. Wire `images` and `prompt` straight into Nano Banana, Seedream, or anything
|
||||
else that takes an image batch and a text prompt.
|
||||
|
||||
**It decides from a manifest, not from the pixels.** Put a file in the library — `assets.json`
|
||||
by default, though any text or JSON works — describing what each asset is:
|
||||
|
||||
```json
|
||||
{"assets": [
|
||||
{"file": "knight.png", "desc": "Armoured knight character, heroic, front view"},
|
||||
{"file": "castle.png", "desc": "Stone castle environment, background location"}
|
||||
]}
|
||||
```
|
||||
|
||||
That keeps a large library cheap to work with: no image is opened unless you ask for it.
|
||||
|
||||
| Input | Purpose |
|
||||
|---|---|
|
||||
| `library_path` | The asset folder. Point it at a Google Drive for Desktop sync folder to work off Drive |
|
||||
| `manifest` | Descriptions file inside the library (default `assets.json`); falls back to filenames if absent |
|
||||
| `system_prompt` | Standing rules — e.g. *"Choose only character assets unless a background is asked for"* |
|
||||
| `instruction` | The brief for this run. Type it or wire a string in |
|
||||
| `max_images` | Ceiling on how many references Claude may pick |
|
||||
| `pad_to_fit` | **On** (default): scale each image onto a shared canvas, keeping aspect ratios. **Off**: stretch them all to one size |
|
||||
| `look_at_images` | **Off** (default). On, Claude opens the files as well — better picks on an unlabelled library, but ~5s per image |
|
||||
| `seed` | Not sent to Claude; change it to force a fresh choice rather than ComfyUI's cached one |
|
||||
|
||||
Outputs are `images` (batch), `prompt`, and `picked` (the filenames chosen, for checking its work).
|
||||
|
||||
**Why `pad_to_fit` exists:** a ComfyUI image batch is one tensor, so every frame must share
|
||||
dimensions — and an asset library won't. Padding preserves aspect ratios at the cost of black
|
||||
bars; stretching avoids bars at the cost of distortion. Which is better depends on the model
|
||||
you're feeding.
|
||||
|
||||
Requires [Claude Code](https://claude.com/claude-code) installed and signed in, same as **SF Claude Code**.
|
||||
|
||||
#### Using SF Claude Code
|
||||
|
||||
This node shells out to the **Claude Code CLI**, so it bills against your Claude
|
||||
subscription rather than an Anthropic API key.
|
||||
|
||||
**Requirements:** [Claude Code](https://claude.com/claude-code) must be installed *and signed
|
||||
in* on the machine running ComfyUI. The node searches your `PATH` plus the usual install
|
||||
locations, and gives a clear error if it can't find it.
|
||||
|
||||
| Input | Purpose |
|
||||
|---|---|
|
||||
| `prompt` | What you want Claude to do |
|
||||
| `system_prompt` | Shapes the reply, e.g. *"Reply with only the prompt text, no preamble."* Leave blank to skip |
|
||||
| `model` | `default`, `opus`, `sonnet`, or `haiku` |
|
||||
| `timeout_seconds` | Give up after this long (default 300) |
|
||||
| `seed` | Not sent to Claude — change it to force a fresh reply instead of ComfyUI's cached one |
|
||||
| `image_1…` | Connect an image and the next slot appears, up to 16 |
|
||||
|
||||
Worth knowing before you build a big batch around it:
|
||||
|
||||
- **Roughly 5–6 seconds per call minimum.** Claude Code is an agent that reads each image as a
|
||||
tool call, so it's slower than a direct vision API request.
|
||||
- **Subscription rate limits apply.** Heavy batch use will hit them; `SF LLM Chat` with an API
|
||||
key is the pay-per-token alternative that won't.
|
||||
- **Not portable.** Anyone else installing this pack needs their own Claude Code install and
|
||||
login for this particular node. Every other node in the pack is unaffected.
|
||||
|
||||
---
|
||||
|
||||
### Stillfront/VertexAI
|
||||
- **SF Imagen3** — Google Imagen3 image generation
|
||||
- **SF VEO Video** — Video generation via Vertex AI
|
||||
|
||||
Generate images and videos using Google Cloud Vertex AI. Requires a Google Cloud project and Application Default Credentials.
|
||||
|
||||
| Node | Description |
|
||||
|---|---|
|
||||
| **SF VertexAI Imagen 3 Text to Image** | Generate images from text using Google Imagen 3.0. Supports multiple aspect ratios, safety settings, and up to 4 images per run. |
|
||||
| **SF VertexAI Imagen 4 Text to Image** | Generate images using the latest Imagen 4.0 model. |
|
||||
| **SF VertexAI Imagen 4 Upscale** | Upscale images using Imagen 4.0. |
|
||||
| **SF VertexAI Nano Banana Pro** | Image generation via the Nano Banana Pro model on Vertex AI. |
|
||||
| **SF VertexAI Nano Banana Pro Edit** | Edit existing images using Nano Banana Pro on Vertex AI. |
|
||||
| **SF VertexAI Veo 3.1 Text to Video** | Generate videos from text prompts using Google VEO 3.1 via Vertex AI. |
|
||||
| **SF VertexAI Veo 3.1 Image to Video** | Animate a still image into a video using Google VEO 3.1 via Vertex AI. |
|
||||
|
||||
---
|
||||
|
||||
### Stillfront/WaveSpeed
|
||||
|
||||
Image and video generation nodes powered by the [WaveSpeed AI](https://wavespeed.ai) API. All nodes require the **SF WaveSpeed Client** node to authenticate.
|
||||
|
||||
#### Setup
|
||||
Connect an **SF WaveSpeed Client** node (enter your WaveSpeed API key) to any WaveSpeed generation node.
|
||||
|
||||
#### Video Generation
|
||||
|
||||
| Node | Description |
|
||||
|---|---|
|
||||
| **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. |
|
||||
| **SF WaveSpeed VEO 3.1 Fast Text to Video** | Faster VEO 3.1 text-to-video variant. |
|
||||
| **SF WaveSpeed VEO 3.1 Fast Image to Video** | Faster VEO 3.1 image-to-video variant. |
|
||||
| **SF WaveSpeed Sora 2 Text to Video** | OpenAI Sora 2 text-to-video generation. |
|
||||
| **SF WaveSpeed Sora 2 Text to Video Pro** | Sora 2 Pro — higher quality text-to-video. |
|
||||
| **SF WaveSpeed Sora 2 Image to Video** | OpenAI Sora 2 image-to-video animation. |
|
||||
| **SF WaveSpeed Sora 2 Image to Video Pro** | Sora 2 Pro — higher quality image-to-video. |
|
||||
| **SF WaveSpeed Wan 2.5 Text to Video** | WAN 2.5 text-to-video generation. |
|
||||
| **SF WaveSpeed Wan 2.5 Text to Video Fast** | WAN 2.5 faster text-to-video variant. |
|
||||
| **SF WaveSpeed Wan 2.5 Image to Video** | WAN 2.5 image-to-video animation. |
|
||||
| **SF WaveSpeed Wan 2.5 Image to Video Fast** | WAN 2.5 faster image-to-video variant. |
|
||||
| **SF WaveSpeed Wan 2.2 Animate** | WAN 2.2 image animation. |
|
||||
| **SF WaveSpeed InfiniteTalk** | Animate a portrait image with audio to create a talking-head video. |
|
||||
| **SF WaveSpeed InfiniteTalk Multi** | InfiniteTalk with multiple reference inputs. |
|
||||
|
||||
#### Image Generation
|
||||
|
||||
| Node | Description |
|
||||
|---|---|
|
||||
| **SF WaveSpeed GPT Image 2.5** | OpenAI's GPT Image models through WaveSpeed, built to match ComfyUI's *OpenAI GPT Image 2.5* partner node — see below. |
|
||||
| **SF WaveSpeed Nano Banana Pro** | Google's Nano Banana Pro and Nano Banana 2 through WaveSpeed, built to match ComfyUI's *Nano Banana Pro (Google Gemini Image)* partner node — see below. |
|
||||
| **SF WaveSpeed Nano Banana Text to Image** | Nano Banana text-to-image generation. |
|
||||
| **SF WaveSpeed Nano Banana Edit** | Edit images using the Nano Banana model. |
|
||||
| **SF WaveSpeed Nano Banana Pro Text to Image** | Nano Banana Pro text-to-image. |
|
||||
| **SF WaveSpeed Nano Banana Pro Text to Image Multi** | Nano Banana Pro with multiple inputs. |
|
||||
| **SF WaveSpeed Nano Banana Pro Text to Image Ultra** | Nano Banana Pro Ultra quality variant. |
|
||||
| **SF WaveSpeed Nano Banana Pro Edit** | Edit images with Nano Banana Pro. |
|
||||
| **SF WaveSpeed Nano Banana Pro Edit Multi** | Nano Banana Pro edit with multiple inputs. |
|
||||
| **SF WaveSpeed Nano Banana Pro Edit Ultra** | Nano Banana Pro edit Ultra quality variant. |
|
||||
| **SF WaveSpeed 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. |
|
||||
| **SF WaveSpeed Qwen Edit LoRA** | Qwen edit with LoRA support. |
|
||||
| **SF WaveSpeed Qwen Edit Plus LoRA** | Qwen Edit Plus with LoRA support. |
|
||||
| **SF WaveSpeed Seedream V4** | ByteDance Seedream V4 image generation. |
|
||||
| **SF WaveSpeed Seedream V4 Sequential** | Seedream V4 sequential generation. |
|
||||
| **SF WaveSpeed Seedream V4 Edit** | Edit images with Seedream V4. |
|
||||
| **SF WaveSpeed Seedream V4 Edit Sequential** | Seedream V4 sequential editing. |
|
||||
| **SF WaveSpeed Wan 2.5 Text to Image** | WAN 2.5 text-to-image generation. |
|
||||
| **SF WaveSpeed Wan 2.5 Image Edit** | Edit images using WAN 2.5. |
|
||||
| **SF WaveSpeed Flux Kontext Dev** | Flux Kontext Dev image generation. |
|
||||
| **SF WaveSpeed Flux Kontext Pro** | Flux Kontext Pro image generation. |
|
||||
| **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 |
|
||||
|---|---|
|
||||
| **SF WaveSpeed Image Upscaler** | Upscale images via WaveSpeed AI. |
|
||||
| **SF WaveSpeed Runway Upscale** | Upscale images using RunwayML via WaveSpeed. |
|
||||
|
||||
---
|
||||
|
||||
### Stillfront/Utils
|
||||
- **SF Qwen Resolution** — Resolution presets with visual preview
|
||||
- **SF Prompt List** — Dynamic multi-input text combiner (2–50 inputs)
|
||||
|
||||
Utility nodes for building and enhancing workflows.
|
||||
|
||||
| Node | Description |
|
||||
|---|---|
|
||||
| **SF Prompt List** | Combine 2–50 text inputs into a single comma-separated string. Add or remove inputs using the `inputcount` control. |
|
||||
| **SF Qwen Resolution** | Visual resolution preset selector with aspect ratio preview. |
|
||||
| **SF Text Analyzer** | Analyze text content within a workflow. |
|
||||
| **SF Google Sheet Cell** | Read a single cell from a publicly shared Google Sheet. Specify the sheet tab by GID, row by number (1-indexed), and column by letter (A, B, C, ...). |
|
||||
| **SF Google Sheet Range** | Read a range of cells from a publicly shared Google Sheet and join them into a single string with a configurable delimiter. Supports multi-row, multi-column ranges. |
|
||||
| **SF Google Sheet Prompt Queue** | Run a batch of prompts straight from a Google Sheet. Reads prompts from one column, skips any row you've marked as finished in a second column, and sends the rest downstream — the graph runs once per prompt. |
|
||||
|
||||
**Google Sheet nodes require the sheet to be shared as "Anyone with the link can view."**
|
||||
The sheet tab GID is found in the browser URL after `#gid=` when you have that tab open.
|
||||
|
||||
#### Using SF Google Sheet Prompt Queue
|
||||
|
||||
Set your sheet up with prompts in one column and an empty column beside it for tracking:
|
||||
|
||||
| | A | B |
|
||||
|---|---|---|
|
||||
| **1** | Prompt | Status |
|
||||
| **2** | a red cat on a windowsill | done |
|
||||
| **3** | a blue dog in the snow | |
|
||||
| **4** | a green bird mid-flight | |
|
||||
|
||||
With the defaults (`prompt_col` A, `status_col` B, `skip_first_row` on), a run sends rows 3 and 4
|
||||
and skips row 2. Each prompt triggers a separate pass through the downstream graph.
|
||||
|
||||
- **`max_rows`** caps how many prompts go out per run — 10 by default.
|
||||
- **Marking is manual.** Type anything into the status column — `done`, `x`, `executed` — and that
|
||||
row is skipped next time. Clear the cell and it gets queued again.
|
||||
- **Scanning stops at the first empty prompt cell**, so anything below a gap is ignored.
|
||||
- **The sheet is re-read on every run**, so edits you make in the browser are picked up
|
||||
automatically. Add rows or clear status cells and just hit Run again.
|
||||
- **Refresh sheet** is optional — it reports how many prompts are pending without running
|
||||
the graph.
|
||||
|
||||
---
|
||||
|
||||
## 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+
|
||||
- ComfyUI
|
||||
- See `requirements.txt` for Python dependencies
|
||||
- See `requirements.txt` for full Python dependency list
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
MIT License — see [LICENSE](LICENSE) for details.
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
# ABOUTME: Claude picks reference images from an asset library and writes the prompt for them
|
||||
# ABOUTME: Reads a manifest describing the assets; looking at the images themselves is optional
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from .sf_claude_code import MODEL_CHOICES, find_claude_binary
|
||||
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"}
|
||||
MAX_LISTED = 2000
|
||||
|
||||
|
||||
def scan_library(root, recursive):
|
||||
"""Image files under `root`, as paths relative to it, in sorted order."""
|
||||
found = []
|
||||
if recursive:
|
||||
for folder, _, names in os.walk(root):
|
||||
for name in names:
|
||||
if os.path.splitext(name)[1].lower() in IMAGE_EXTS:
|
||||
full = os.path.join(folder, name)
|
||||
found.append(os.path.relpath(full, root))
|
||||
else:
|
||||
for name in os.listdir(root):
|
||||
if os.path.splitext(name)[1].lower() in IMAGE_EXTS:
|
||||
found.append(name)
|
||||
return sorted(found)[:MAX_LISTED]
|
||||
|
||||
|
||||
def read_manifest(root, manifest):
|
||||
"""The asset descriptions file, or None when there isn't one."""
|
||||
name = (manifest or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
path = os.path.join(root, name)
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="ignore") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def build_request(system_prompt, instruction, files, manifest_text, max_images, may_look):
|
||||
"""The single prompt sent to Claude."""
|
||||
parts = []
|
||||
if system_prompt.strip():
|
||||
parts.append(system_prompt.strip())
|
||||
|
||||
if manifest_text:
|
||||
parts.append(
|
||||
"Here is the asset library manifest describing what each file contains. "
|
||||
"Search it to choose; if it declares an `art_style`, the prompt you write "
|
||||
"must stay consistent with it.\n\n"
|
||||
f"{manifest_text.strip()}"
|
||||
)
|
||||
|
||||
listing = "\n".join(f" {name}" for name in files)
|
||||
parts.append(f"These are the image files available ({len(files)} total):\n{listing}")
|
||||
|
||||
if may_look:
|
||||
parts.append(
|
||||
"You may use the Read tool on any of these files to look at them before "
|
||||
"deciding. They are in your current working directory."
|
||||
)
|
||||
|
||||
parts.append(f"Request:\n{instruction.strip()}")
|
||||
parts.append(
|
||||
f"Choose at most {max_images} file(s). Reply with ONLY a JSON object and nothing "
|
||||
f"else — no explanation, no code fence:\n"
|
||||
f'{{"files": ["exact/path/as/listed.png"], "prompt": "the image generation prompt"}}\n'
|
||||
f"Use file paths exactly as they appear in the listing. The prompt should refer to "
|
||||
f"the chosen images in the order you list them."
|
||||
)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def parse_reply(text):
|
||||
"""The JSON object out of Claude's reply, tolerating a code fence around it."""
|
||||
stripped = text.strip()
|
||||
start, end = stripped.find("{"), stripped.rfind("}")
|
||||
if start == -1 or end <= start:
|
||||
raise RuntimeError(f"Claude did not return JSON. It said: {stripped[:300]}")
|
||||
try:
|
||||
payload = json.loads(stripped[start : end + 1])
|
||||
except json.JSONDecodeError as e:
|
||||
raise RuntimeError(f"Claude's JSON could not be parsed ({e}): {stripped[:300]}")
|
||||
|
||||
files = payload.get("files") or []
|
||||
prompt = str(payload.get("prompt") or "").strip()
|
||||
if not isinstance(files, list):
|
||||
raise RuntimeError("Claude's 'files' field was not a list.")
|
||||
return [str(f).strip() for f in files if str(f).strip()], prompt
|
||||
|
||||
|
||||
def resolve_picks(picked, available, root):
|
||||
"""Map Claude's filenames onto real paths, matching on basename as a fallback."""
|
||||
by_rel = {name: name for name in available}
|
||||
by_base = {}
|
||||
for name in available:
|
||||
by_base.setdefault(os.path.basename(name).lower(), name)
|
||||
|
||||
resolved, missing = [], []
|
||||
for want in picked:
|
||||
cleaned = want.strip().lstrip("./")
|
||||
match = by_rel.get(cleaned) or by_base.get(os.path.basename(cleaned).lower())
|
||||
if match:
|
||||
resolved.append(os.path.join(root, match))
|
||||
else:
|
||||
missing.append(want)
|
||||
return resolved, missing
|
||||
|
||||
|
||||
def to_image_batch(paths, pad_to_fit):
|
||||
"""Load the images into one ComfyUI IMAGE tensor.
|
||||
|
||||
A batch is a single tensor, so every frame must share dimensions. With
|
||||
`pad_to_fit` each image is scaled to fit and centred on a common canvas,
|
||||
which keeps its aspect ratio; without it they are stretched to match.
|
||||
"""
|
||||
images = [Image.open(p).convert("RGB") for p in paths]
|
||||
width = max(im.width for im in images)
|
||||
height = max(im.height for im in images)
|
||||
|
||||
frames = []
|
||||
for im in images:
|
||||
if pad_to_fit:
|
||||
scale = min(width / im.width, height / im.height)
|
||||
size = (max(1, round(im.width * scale)), max(1, round(im.height * scale)))
|
||||
canvas = Image.new("RGB", (width, height), (0, 0, 0))
|
||||
canvas.paste(im.resize(size, Image.LANCZOS),
|
||||
((width - size[0]) // 2, (height - size[1]) // 2))
|
||||
im = canvas
|
||||
else:
|
||||
im = im.resize((width, height), Image.LANCZOS)
|
||||
frames.append(np.asarray(im, dtype=np.float32) / 255.0)
|
||||
|
||||
return torch.from_numpy(np.stack(frames))
|
||||
|
||||
|
||||
class SFClaudeAssetDirector:
|
||||
"""
|
||||
Claude picks reference images out of an asset library and writes the prompt
|
||||
that goes with them.
|
||||
|
||||
Decisions are made from a manifest describing the assets, so the library can
|
||||
be large without every run costing a pile of image reads. Outputs an IMAGE
|
||||
batch and a STRING, which wire straight into an image generation node.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"library_path": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"default": "",
|
||||
"tooltip": (
|
||||
"Folder holding the assets. For Google Drive, point this at "
|
||||
"your Drive for Desktop sync folder."
|
||||
),
|
||||
},
|
||||
),
|
||||
"manifest": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "manifest.json",
|
||||
"tooltip": (
|
||||
"File inside the library describing each asset, used to choose "
|
||||
"between them. Written by SF Claude Library Builder, though any "
|
||||
"text or JSON works. Ignored if absent."
|
||||
),
|
||||
},
|
||||
),
|
||||
"system_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"tooltip": "Standing instructions — how to choose, how to write the prompt.",
|
||||
},
|
||||
),
|
||||
"instruction": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"tooltip": (
|
||||
"What you want this run. Type it here or wire a string in, "
|
||||
"e.g. 'Make a poster. Pick the characters you think work best.'"
|
||||
),
|
||||
},
|
||||
),
|
||||
"max_images": (
|
||||
"INT",
|
||||
{
|
||||
"default": 5,
|
||||
"min": 1,
|
||||
"max": 20,
|
||||
"step": 1,
|
||||
"tooltip": "Most reference images Claude may choose.",
|
||||
},
|
||||
),
|
||||
"pad_to_fit": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": (
|
||||
"On: scale each image to fit a shared canvas and centre it, "
|
||||
"keeping aspect ratios. Off: stretch them all to one size."
|
||||
),
|
||||
},
|
||||
),
|
||||
"look_at_images": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
"Let Claude open the image files as well as the manifest. "
|
||||
"Better choices on an unlabelled library, but roughly 5 "
|
||||
"seconds per image it opens."
|
||||
),
|
||||
},
|
||||
),
|
||||
"recursive": (
|
||||
"BOOLEAN",
|
||||
{"default": True, "tooltip": "Include images in subfolders."},
|
||||
),
|
||||
"model": (
|
||||
MODEL_CHOICES,
|
||||
{"default": "default", "tooltip": "Which Claude model decides."},
|
||||
),
|
||||
"timeout_seconds": (
|
||||
"INT",
|
||||
{"default": 300, "min": 30, "max": 3600, "step": 30,
|
||||
"tooltip": "Give up if Claude has not replied within this long."},
|
||||
),
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFF,
|
||||
"control_after_generate": True,
|
||||
"tooltip": (
|
||||
"Not sent to Claude. Change it to force a fresh choice instead "
|
||||
"of reusing ComfyUI's cached result."
|
||||
),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "STRING", "STRING")
|
||||
RETURN_NAMES = ("images", "prompt", "picked")
|
||||
FUNCTION = "direct"
|
||||
CATEGORY = "Stillfront/LLM"
|
||||
|
||||
DESCRIPTION = """
|
||||
Claude reads your asset library's **manifest**, chooses which references suit the
|
||||
request, and writes the image prompt to go with them.
|
||||
|
||||
Outputs an IMAGE batch and the prompt, ready to wire into Nano Banana or any
|
||||
other generation node. Point **library_path** at a Google Drive for Desktop sync
|
||||
folder to work straight off Drive.
|
||||
|
||||
**look_at_images** is off by default: choices come from the manifest, so a large
|
||||
library costs nothing extra. Turn it on to have Claude open the files too.
|
||||
"""
|
||||
|
||||
def direct(self, library_path, manifest, system_prompt, instruction, max_images,
|
||||
pad_to_fit, look_at_images, recursive, model, timeout_seconds, seed):
|
||||
root = os.path.expanduser(library_path.strip())
|
||||
if not root or not os.path.isdir(root):
|
||||
raise ValueError(f"library_path is not a folder: {library_path!r}")
|
||||
if not instruction.strip():
|
||||
raise ValueError("instruction is empty — tell Claude what this run is for.")
|
||||
|
||||
binary = find_claude_binary()
|
||||
if not binary:
|
||||
raise RuntimeError(
|
||||
"Claude Code CLI not found. Install it from https://claude.com/claude-code "
|
||||
"and sign in, then restart ComfyUI."
|
||||
)
|
||||
|
||||
files = scan_library(root, recursive)
|
||||
if not files:
|
||||
raise ValueError(f"No image files found in {root}")
|
||||
|
||||
manifest_text = read_manifest(root, manifest)
|
||||
if manifest_text is None and manifest.strip():
|
||||
print(
|
||||
f"[SF Claude Asset Director] No manifest '{manifest.strip()}' in the library; "
|
||||
f"choosing from filenames alone."
|
||||
)
|
||||
|
||||
request = build_request(
|
||||
system_prompt, instruction, files, manifest_text, max_images, look_at_images
|
||||
)
|
||||
|
||||
command = [binary, "-p", request, "--output-format", "json"]
|
||||
if model != "default":
|
||||
command += ["--model", model]
|
||||
if look_at_images:
|
||||
command += ["--allowedTools", "Read"]
|
||||
|
||||
print(
|
||||
f"[SF Claude Asset Director] {len(files)} asset(s), "
|
||||
f"manifest={'yes' if manifest_text else 'no'}, vision={'on' if look_at_images else 'off'}."
|
||||
)
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=root,
|
||||
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, "
|
||||
f"or turn look_at_images off."
|
||||
)
|
||||
|
||||
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"Unreadable response: {completed.stdout.strip()[:400]}")
|
||||
|
||||
if payload.get("is_error"):
|
||||
raise RuntimeError(f"Claude reported an error: {payload.get('result', 'unknown')}")
|
||||
|
||||
picked, prompt = parse_reply(payload.get("result", ""))
|
||||
if not picked:
|
||||
raise RuntimeError("Claude chose no images. Check the manifest describes the assets.")
|
||||
if not prompt:
|
||||
raise RuntimeError("Claude returned no prompt.")
|
||||
|
||||
paths, missing = resolve_picks(picked[:max_images], files, root)
|
||||
if missing:
|
||||
print(f"[SF Claude Asset Director] Ignoring names not in the library: {missing}")
|
||||
if not paths:
|
||||
raise RuntimeError(
|
||||
f"None of Claude's choices matched a real file. It asked for: {picked}"
|
||||
)
|
||||
|
||||
batch = to_image_batch(paths, pad_to_fit)
|
||||
names = [os.path.relpath(p, root) for p in paths]
|
||||
print(f"[SF Claude Asset Director] Chose {len(names)}: {', '.join(names)}")
|
||||
|
||||
return (batch, prompt, "\n".join(names))
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SFClaudeAssetDirector": SFClaudeAssetDirector}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFClaudeAssetDirector": "SF Claude Asset Director"}
|
||||
@@ -0,0 +1,241 @@
|
||||
# ABOUTME: Runs a prompt through the locally installed Claude Code CLI, using its own login
|
||||
# ABOUTME: Accepts text plus any number of images and returns Claude's reply as a string
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from .vertexai_utils import tensor_to_pil
|
||||
|
||||
MAX_IMAGES = 16
|
||||
|
||||
# Aliases rather than pinned model IDs, so this keeps working as models are updated.
|
||||
MODEL_CHOICES = ["default", "opus", "sonnet", "haiku"]
|
||||
|
||||
BINARY_CANDIDATES = [
|
||||
"~/.local/bin/claude",
|
||||
"~/.claude/local/claude",
|
||||
"/opt/homebrew/bin/claude",
|
||||
"/usr/local/bin/claude",
|
||||
]
|
||||
|
||||
|
||||
def find_claude_binary():
|
||||
"""Locates the Claude Code CLI, which may not be on ComfyUI's PATH."""
|
||||
found = shutil.which("claude")
|
||||
if found:
|
||||
return found
|
||||
for candidate in BINARY_CANDIDATES:
|
||||
path = os.path.expanduser(candidate)
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def save_images(image_inputs, directory):
|
||||
"""Writes every frame of every connected IMAGE input to disk. Returns filenames."""
|
||||
names = []
|
||||
for slot, tensor in image_inputs:
|
||||
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))
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def build_prompt(prompt, image_names):
|
||||
"""Prefixes the request with instructions to read the images off disk."""
|
||||
if not image_names:
|
||||
return prompt
|
||||
|
||||
listing = ", ".join(image_names)
|
||||
plural = "image" if len(image_names) == 1 else "images"
|
||||
return (
|
||||
f"The following {plural} are in your current working directory: {listing}\n"
|
||||
f"Use the Read tool on each of them first, then respond to this request:\n\n"
|
||||
f"{prompt}"
|
||||
)
|
||||
|
||||
|
||||
class SFClaudeCode:
|
||||
"""
|
||||
Sends a prompt (and any attached images) to Claude through the Claude Code
|
||||
CLI installed on this machine, so it runs on your existing Claude login
|
||||
rather than a separate API key.
|
||||
|
||||
Connect an image and another image slot appears automatically.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
optional = {
|
||||
f"image_{i}": ("IMAGE",) for i in range(1, MAX_IMAGES + 1)
|
||||
}
|
||||
return {
|
||||
"required": {
|
||||
"prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"tooltip": "What you want Claude to do.",
|
||||
},
|
||||
),
|
||||
"system_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"tooltip": (
|
||||
"Extra instructions shaping how Claude responds, e.g. "
|
||||
"'Reply with only the prompt text, no preamble.' Leave blank to skip."
|
||||
),
|
||||
},
|
||||
),
|
||||
"model": (
|
||||
MODEL_CHOICES,
|
||||
{
|
||||
"default": "default",
|
||||
"tooltip": "Which Claude model to use. 'default' uses whatever Claude Code is set to.",
|
||||
},
|
||||
),
|
||||
"timeout_seconds": (
|
||||
"INT",
|
||||
{
|
||||
"default": 300,
|
||||
"min": 30,
|
||||
"max": 3600,
|
||||
"step": 30,
|
||||
"tooltip": "Give up if Claude has not replied within this long.",
|
||||
},
|
||||
),
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFF,
|
||||
"control_after_generate": True,
|
||||
"tooltip": (
|
||||
"Not sent to Claude. Change it to force a fresh reply instead "
|
||||
"of reusing ComfyUI's cached result."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": optional,
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("text",)
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "Stillfront/LLM"
|
||||
|
||||
DESCRIPTION = """
|
||||
Sends text and images to Claude via the **Claude Code CLI** on this machine,
|
||||
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.
|
||||
|
||||
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.")
|
||||
|
||||
binary = find_claude_binary()
|
||||
if not binary:
|
||||
raise RuntimeError(
|
||||
"Claude Code CLI not found. Install it from "
|
||||
"https://claude.com/claude-code and sign in, then restart ComfyUI."
|
||||
)
|
||||
|
||||
image_inputs = [
|
||||
(i, kwargs.get(f"image_{i}"))
|
||||
for i in range(1, MAX_IMAGES + 1)
|
||||
if kwargs.get(f"image_{i}") is not None
|
||||
]
|
||||
|
||||
workdir = tempfile.mkdtemp(prefix="sf_claude_")
|
||||
try:
|
||||
image_names = save_images(image_inputs, workdir)
|
||||
|
||||
command = [
|
||||
binary,
|
||||
"-p",
|
||||
build_prompt(prompt, image_names),
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
if system_prompt.strip():
|
||||
command += ["--append-system-prompt", system_prompt.strip()]
|
||||
if model != "default":
|
||||
command += ["--model", model]
|
||||
if image_names:
|
||||
command += ["--allowedTools", "Read"]
|
||||
|
||||
print(
|
||||
f"[SF Claude Code] Running {model} with {len(image_names)} image(s), "
|
||||
f"timeout {timeout_seconds}s."
|
||||
)
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=workdir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
# Without this the CLI spends 3s waiting on stdin it will never get.
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(
|
||||
f"Claude did not reply within {timeout_seconds}s. Raise timeout_seconds "
|
||||
f"or simplify the request."
|
||||
)
|
||||
|
||||
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')}"
|
||||
)
|
||||
|
||||
text = payload.get("result", "")
|
||||
if not text:
|
||||
raise RuntimeError("Claude returned an empty response.")
|
||||
|
||||
print(
|
||||
f"[SF Claude Code] Got {len(text)} characters in "
|
||||
f"{payload.get('duration_ms', '?')}ms."
|
||||
)
|
||||
return (text,)
|
||||
|
||||
finally:
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SFClaudeCode": SFClaudeCode}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFClaudeCode": "SF Claude Code"}
|
||||
@@ -0,0 +1,646 @@
|
||||
# ABOUTME: Claude surveys a folder of game assets and writes a searchable manifest for it
|
||||
# ABOUTME: Processes assets in small batches with a resumable checkpoint, so a failure costs little
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from .sf_claude_code import MODEL_CHOICES, find_claude_binary
|
||||
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tga"}
|
||||
|
||||
MODE_MANIFEST = "manifest only (read-only)"
|
||||
MODE_COPY = "organise into a copy"
|
||||
MODE_IN_PLACE = "organise in place"
|
||||
MODES = [MODE_MANIFEST, MODE_COPY, MODE_IN_PLACE]
|
||||
|
||||
SCHEMA_VERSION = "sf-asset-library/1"
|
||||
CHECKPOINT_NAME = ".sf_library_builder_checkpoint.json"
|
||||
|
||||
ASSET_SCHEMA_SPEC = """{
|
||||
"file": "<path relative to the library root, exactly as it exists on disk>",
|
||||
"name": "<short human name>",
|
||||
"category": "<character|environment|prop|ui|vfx|other>",
|
||||
"subcategory": "<hero|enemy|npc|location|background|weapon|icon|...>",
|
||||
"archetype": "<for characters: tank|healer|rogue|mage|boss|minion|... else omit>",
|
||||
"tags": ["<lowercase keyword>", "..."],
|
||||
"desc": "<one sentence: what it depicts, pose/view, dominant colours>"
|
||||
}"""
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = """You are cataloguing a game asset library.
|
||||
|
||||
Judge each asset on what it depicts and what role it would play in a game.
|
||||
Keep `desc` to one sentence that would let someone pick this asset without
|
||||
seeing it. Make `tags` lowercase, specific and useful for searching — subject,
|
||||
colour, pose, view angle, mood. Prefer a small shared vocabulary across the
|
||||
library over inventing a new word per asset."""
|
||||
|
||||
|
||||
def scan_assets(root):
|
||||
"""Image files under `root`, relative to it, in a stable sorted order."""
|
||||
found = []
|
||||
for folder, dirs, names in os.walk(root):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
for name in names:
|
||||
if os.path.splitext(name)[1].lower() in IMAGE_EXTS:
|
||||
found.append(os.path.relpath(os.path.join(folder, name), root))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def chunk(items, size):
|
||||
for i in range(0, len(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 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 BatchParseError(f"the reply was cut off or malformed ({e})")
|
||||
|
||||
|
||||
def canonicalize(claude_path, expected):
|
||||
"""Map Claude's returned file path onto one of the paths we actually asked
|
||||
about, tolerating a leading './' or a basename-only answer."""
|
||||
cleaned = (claude_path or "").strip().lstrip("./")
|
||||
if cleaned in expected:
|
||||
return cleaned
|
||||
base = os.path.basename(cleaned).lower()
|
||||
for candidate in expected:
|
||||
if os.path.basename(candidate).lower() == base:
|
||||
return candidate
|
||||
return cleaned
|
||||
|
||||
|
||||
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,
|
||||
},
|
||||
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):
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if data.get("key") == key else None
|
||||
|
||||
|
||||
def save_checkpoint(root, key, project, art_style, done_assets):
|
||||
"""Atomic write, so a crash mid-save can't corrupt the checkpoint."""
|
||||
path = os.path.join(root, CHECKPOINT_NAME)
|
||||
data = {"key": key, "project": project, "art_style": art_style, "done_assets": done_assets}
|
||||
fd, tmp = tempfile.mkstemp(dir=root, prefix=".sf_checkpoint_")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f)
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def clear_checkpoint(root):
|
||||
path = os.path.join(root, CHECKPOINT_NAME)
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
|
||||
|
||||
def build_batch_instruction(mode, batch_files, total_count, project_notes, established, out_dir):
|
||||
"""The task description for ONE batch call — scoped to just its own files."""
|
||||
parts = [
|
||||
f"You are cataloguing {len(batch_files)} file(s) from one batch of a "
|
||||
f"{total_count}-file game asset library. You only need to handle the "
|
||||
f"files listed below — nothing else exists from your point of view."
|
||||
]
|
||||
|
||||
if project_notes.strip():
|
||||
parts.append(f"About this project:\n{project_notes.strip()}")
|
||||
|
||||
listing = "\n".join(f" {name}" for name in batch_files)
|
||||
parts.append(f"Your files for this batch:\n{listing}")
|
||||
|
||||
parts.append(
|
||||
"Open each one with the Read tool before describing it. Work through them "
|
||||
"steadily; do not guess from a filename."
|
||||
)
|
||||
|
||||
if mode == MODE_MANIFEST:
|
||||
parts.append("Do NOT move, rename, copy or delete anything. This is a survey only.")
|
||||
else:
|
||||
where = (
|
||||
f"a new folder at `{out_dir}`, leaving the originals untouched"
|
||||
if mode == MODE_COPY
|
||||
else "the working directory itself"
|
||||
)
|
||||
parts.append(
|
||||
f"Then reorganise ONLY these files into {where}:\n"
|
||||
f"- Group by category, e.g. characters/heroes, characters/enemies, "
|
||||
f"environments, props, ui, vfx.\n"
|
||||
f"- Rename to lowercase_with_underscores, descriptive of the subject, "
|
||||
f"no spaces, no version noise. Keep the original extension.\n"
|
||||
f"- Never discard a file.\n"
|
||||
f"- Use Bash (cp for a copy, mv in place) to do the moving.\n"
|
||||
f"The `file` field of each asset must be its path AFTER reorganising, "
|
||||
f"relative to the library root."
|
||||
)
|
||||
|
||||
if established:
|
||||
project, art_style = established
|
||||
parts.append(
|
||||
f"This library's project name and art style are already settled by an "
|
||||
f"earlier batch — reuse them exactly, do not invent new ones:\n"
|
||||
f' project: "{project}"\n art_style: "{art_style}"'
|
||||
)
|
||||
parts.append(
|
||||
f"Reply with ONLY a JSON object of this shape — no commentary, no code "
|
||||
f"fence, no other top-level keys:\n"
|
||||
f'{{"assets": [{ASSET_SCHEMA_SPEC}]}}\n'
|
||||
f"The assets array must contain exactly one entry per file listed above."
|
||||
)
|
||||
else:
|
||||
parts.append(
|
||||
f"You are establishing this library's project name and art style for "
|
||||
f"the first time — later batches will reuse whatever you choose here, "
|
||||
f"so make `art_style` a precise sentence covering the whole library's "
|
||||
f"visual approach, not just these files.\n"
|
||||
f"Reply with ONLY a JSON object of this shape — no commentary, no code "
|
||||
f"fence:\n"
|
||||
f'{{"project": "<short project name>", "art_style": "<one sentence>", '
|
||||
f'"assets": [{ASSET_SCHEMA_SPEC}]}}\n'
|
||||
f"The assets array must contain exactly one entry per file listed above."
|
||||
)
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
class SFClaudeLibraryBuilder:
|
||||
"""
|
||||
Surveys a folder of game assets and writes a manifest describing each one,
|
||||
so SF Claude Asset Director can choose between them without opening images.
|
||||
|
||||
Processes assets in small batches rather than one session, because a single
|
||||
session covering a large library can blow up in turns and cost with nothing
|
||||
to show for it. A checkpoint records finished batches, so a failed or
|
||||
interrupted run resumes instead of re-paying for work already done.
|
||||
|
||||
Read-only by default. It can also reorganise and rename the files, into a
|
||||
copy or in place, when you ask it to.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"library_path": (
|
||||
"STRING",
|
||||
{"multiline": False, "default": "", "tooltip": "Folder holding the assets to catalogue."},
|
||||
),
|
||||
"mode": (
|
||||
MODES,
|
||||
{
|
||||
"default": MODE_MANIFEST,
|
||||
"tooltip": (
|
||||
"manifest only: nothing is moved or renamed. "
|
||||
"organise into a copy: originals untouched, tidy copy written "
|
||||
"to output_path. organise in place: renames and moves your "
|
||||
"actual files."
|
||||
),
|
||||
},
|
||||
),
|
||||
"output_path": (
|
||||
"STRING",
|
||||
{"multiline": False, "default": "", "tooltip": "Destination folder, for 'organise into a copy' only."},
|
||||
),
|
||||
"project_notes": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"tooltip": (
|
||||
"What the game is, and anything Claude could not infer from "
|
||||
"the images — genre, art direction, naming you prefer."
|
||||
),
|
||||
},
|
||||
),
|
||||
"system_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": DEFAULT_SYSTEM_PROMPT,
|
||||
"tooltip": "How to categorise and describe. Edit to suit your project.",
|
||||
},
|
||||
),
|
||||
"manifest_name": (
|
||||
"STRING",
|
||||
{"default": "manifest.json", "tooltip": "Filename for the manifest, written at the library root."},
|
||||
),
|
||||
"write_manifest": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
"Off: the manifest is only returned as text for you to read "
|
||||
"and save yourself. On: it is written into the library."
|
||||
),
|
||||
},
|
||||
),
|
||||
"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",
|
||||
{
|
||||
"default": 15,
|
||||
"min": 3,
|
||||
"max": 50,
|
||||
"step": 1,
|
||||
"tooltip": (
|
||||
"Assets per Claude call. Keeps each session small enough to "
|
||||
"finish reliably — a failed batch only costs that batch, not "
|
||||
"the whole library, and a rerun resumes rather than restarts."
|
||||
),
|
||||
},
|
||||
),
|
||||
"model": (
|
||||
MODEL_CHOICES,
|
||||
{"default": "default", "tooltip": "Which Claude model does the cataloguing."},
|
||||
),
|
||||
"timeout_seconds": (
|
||||
"INT",
|
||||
{
|
||||
"default": 300,
|
||||
"min": 60,
|
||||
"max": 1800,
|
||||
"step": 30,
|
||||
"tooltip": "Per BATCH, not per library. Budget roughly 5s per asset in the batch.",
|
||||
},
|
||||
),
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFF,
|
||||
"control_after_generate": True,
|
||||
"tooltip": "Not sent to Claude. Change it to force a fresh run.",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("manifest", "report")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "Stillfront/LLM"
|
||||
|
||||
DESCRIPTION = """
|
||||
Claude looks at every asset in a folder and writes a manifest describing each —
|
||||
category, archetype, tags and a one-line description — plus a project-level art
|
||||
style. **SF Claude Asset Director** then searches that manifest instead of
|
||||
opening images.
|
||||
|
||||
Processes **batch_size** assets per Claude call rather than the whole library at
|
||||
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.
|
||||
|
||||
Budget about 5 seconds per asset — it opens every image.
|
||||
"""
|
||||
|
||||
def build(
|
||||
self,
|
||||
library_path,
|
||||
mode,
|
||||
output_path,
|
||||
project_notes,
|
||||
system_prompt,
|
||||
manifest_name,
|
||||
write_manifest,
|
||||
update_existing,
|
||||
batch_size,
|
||||
model,
|
||||
timeout_seconds,
|
||||
seed,
|
||||
):
|
||||
root = os.path.expanduser(library_path.strip())
|
||||
if not root or not os.path.isdir(root):
|
||||
raise ValueError(f"library_path is not a folder: {library_path!r}")
|
||||
|
||||
out_dir = os.path.expanduser(output_path.strip())
|
||||
if mode == MODE_COPY:
|
||||
if not out_dir:
|
||||
raise ValueError("output_path is required for 'organise into a copy'.")
|
||||
if os.path.abspath(out_dir).startswith(os.path.abspath(root) + os.sep):
|
||||
raise ValueError("output_path must sit outside library_path.")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
binary = find_claude_binary()
|
||||
if not binary:
|
||||
raise RuntimeError(
|
||||
"Claude Code CLI not found. Install it from https://claude.com/claude-code "
|
||||
"and sign in, then restart ComfyUI."
|
||||
)
|
||||
|
||||
assets = scan_assets(root)
|
||||
if not assets:
|
||||
raise ValueError(f"No image files found in {root}")
|
||||
|
||||
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:
|
||||
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 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.
|
||||
tools = "Read" if mode == MODE_MANIFEST else "Read,Bash"
|
||||
batches = list(chunk(assets, max(1, batch_size)))
|
||||
|
||||
total_cost = 0.0
|
||||
started = time.time()
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
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]
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
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 {label} failed (exit {completed.returncode}). {detail}")
|
||||
|
||||
try:
|
||||
payload = json.loads(completed.stdout)
|
||||
except json.JSONDecodeError:
|
||||
fail(f"Batch {label}: unreadable response: {completed.stdout.strip()[:300]}")
|
||||
|
||||
if payload.get("is_error"):
|
||||
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(files)
|
||||
for entry in data.get("assets") or []:
|
||||
raw = entry.get("file")
|
||||
if not raw:
|
||||
continue
|
||||
entry["file"] = canonicalize(raw, expected)
|
||||
done_by_file[entry["file"]] = entry
|
||||
|
||||
save_checkpoint(root, key, project, art_style, list(done_by_file.values()))
|
||||
|
||||
print(
|
||||
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.
|
||||
final_assets = [done_by_file[f] for f in assets if f in done_by_file]
|
||||
manifest_data = {
|
||||
"schema": SCHEMA_VERSION,
|
||||
"project": project or "Untitled project",
|
||||
"art_style": art_style or "",
|
||||
"generated": time.strftime("%Y-%m-%d"),
|
||||
"assets": final_assets,
|
||||
}
|
||||
manifest_text = json.dumps(manifest_data, indent=2)
|
||||
|
||||
if write_manifest:
|
||||
os.makedirs(manifest_root, exist_ok=True)
|
||||
with open(manifest_path, "w", encoding="utf-8") as f:
|
||||
f.write(manifest_text)
|
||||
|
||||
clear_checkpoint(root)
|
||||
|
||||
counts = {}
|
||||
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"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"),
|
||||
f"took: {elapsed:.0f}s, ${total_cost:.2f} total",
|
||||
]
|
||||
)
|
||||
print(
|
||||
f"[SF Claude Library Builder] Done: {len(final_assets)}/{len(assets)} asset(s), "
|
||||
f"{elapsed:.0f}s, ${total_cost:.2f}."
|
||||
)
|
||||
|
||||
if len(final_assets) < len(assets):
|
||||
report += f"\nWARNING: {len(assets) - len(final_assets)} file(s) were not catalogued."
|
||||
|
||||
return (manifest_text, report)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SFClaudeLibraryBuilder": SFClaudeLibraryBuilder}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFClaudeLibraryBuilder": "SF Claude Library Builder"}
|
||||
@@ -0,0 +1,227 @@
|
||||
# ABOUTME: Dispatches prompts from a Google Sheet column, skipping rows already marked done
|
||||
# ABOUTME: Status marks are written manually in the sheet; this node only reads
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from .sf_google_sheet_cell import col_letter_to_index
|
||||
|
||||
|
||||
def extract_spreadsheet_id(url: str) -> str:
|
||||
match = re.search(r"/d/([a-zA-Z0-9-_]+)", url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
if re.match(r"^[a-zA-Z0-9-_]+$", url.strip()):
|
||||
return url.strip()
|
||||
raise ValueError(
|
||||
"Could not extract spreadsheet ID. Please provide a full Google Sheets URL."
|
||||
)
|
||||
|
||||
|
||||
def fetch_rows(url: str, gid: str) -> list:
|
||||
"""Reads a publicly shared sheet tab as a list of rows."""
|
||||
spreadsheet_id = extract_spreadsheet_id(url)
|
||||
gid = (gid or "0").strip() or "0"
|
||||
|
||||
csv_url = (
|
||||
f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}"
|
||||
f"/gviz/tq?tqx=out:csv&gid={gid}"
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.get(csv_url, timeout=15)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
raise RuntimeError(
|
||||
f"Could not access Google Sheet (HTTP {response.status_code}). "
|
||||
f"Make sure it is shared as 'Anyone with the link can view'. Error: {e}"
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise RuntimeError(f"Failed to fetch Google Sheet: {e}")
|
||||
|
||||
return list(csv.reader(io.StringIO(response.content.decode("utf-8"))))
|
||||
|
||||
|
||||
def scan_rows(rows, prompt_idx, status_idx, skip_first_row, max_rows=None):
|
||||
"""Walks the sheet and returns (pending, total).
|
||||
|
||||
`pending` is [(sheet_row_number, prompt), ...] for rows whose status cell is
|
||||
empty, capped at `max_rows` when given. `total` counts every prompt found.
|
||||
Scanning stops at the first row with an empty prompt cell.
|
||||
"""
|
||||
pending = []
|
||||
total = 0
|
||||
start = 1 if skip_first_row else 0
|
||||
|
||||
for offset in range(start, len(rows)):
|
||||
row = rows[offset]
|
||||
prompt = row[prompt_idx].strip() if prompt_idx < len(row) else ""
|
||||
if not prompt:
|
||||
break
|
||||
|
||||
total += 1
|
||||
status = row[status_idx].strip() if status_idx < len(row) else ""
|
||||
if status:
|
||||
continue
|
||||
|
||||
if max_rows is None or len(pending) < max_rows:
|
||||
pending.append((offset + 1, prompt))
|
||||
|
||||
return pending, total
|
||||
|
||||
|
||||
class SFGoogleSheetPromptQueue:
|
||||
"""
|
||||
Sends unfinished prompts from a Google Sheet downstream as a batch.
|
||||
|
||||
Reads prompts from one column and checks a second column for a completion
|
||||
mark. Rows with anything written in the status column are skipped, so each
|
||||
run picks up where the last one left off. Clearing a status cell in the
|
||||
browser queues that row again.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"url": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"default": "https://docs.google.com/spreadsheets/d/.../edit",
|
||||
"tooltip": (
|
||||
"Full Google Sheets URL. "
|
||||
"The sheet must be shared: Share → Anyone with the link → Viewer."
|
||||
),
|
||||
},
|
||||
),
|
||||
"sheet_gid": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "0",
|
||||
"tooltip": (
|
||||
"Sheet tab GID — the number after '#gid=' in the URL. "
|
||||
"Use 0 for the first tab."
|
||||
),
|
||||
},
|
||||
),
|
||||
"prompt_col": (
|
||||
"STRING",
|
||||
{"default": "A", "tooltip": "Column holding the prompts (e.g. A)."},
|
||||
),
|
||||
"status_col": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "B",
|
||||
"tooltip": (
|
||||
"Column you mark by hand when a prompt is finished. "
|
||||
"Any text at all (done, x, executed) means skip that row."
|
||||
),
|
||||
},
|
||||
),
|
||||
"skip_first_row": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Ignore row 1, which is usually a header.",
|
||||
},
|
||||
),
|
||||
"max_rows": (
|
||||
"INT",
|
||||
{
|
||||
"default": 10,
|
||||
"min": 1,
|
||||
"max": 500,
|
||||
"step": 1,
|
||||
"tooltip": "Most prompts to send in a single run.",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("prompt",)
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
FUNCTION = "dispatch"
|
||||
CATEGORY = "Stillfront/Utils"
|
||||
|
||||
DESCRIPTION = """
|
||||
Sends every unfinished prompt from a Google Sheet column downstream — the graph
|
||||
runs once per prompt, up to **max_rows** prompts per run.
|
||||
|
||||
A row is treated as finished when its **status_col** cell contains any text at
|
||||
all. Write `done` next to a prompt in the sheet and the next run skips it; clear
|
||||
the cell and it gets queued again. Scanning stops at the first empty prompt cell.
|
||||
|
||||
The sheet is re-read fresh on every run, so edits you make in the browser are
|
||||
always picked up. **Refresh sheet** is optional — it reports how many prompts
|
||||
are pending without having to run the graph.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
# The sheet changes outside ComfyUI, so never serve a cached result.
|
||||
return float("nan")
|
||||
|
||||
def dispatch(self, url, sheet_gid, prompt_col, status_col, skip_first_row, max_rows):
|
||||
prompt_idx = col_letter_to_index(prompt_col)
|
||||
status_idx = col_letter_to_index(status_col)
|
||||
if prompt_idx == status_idx:
|
||||
raise ValueError(
|
||||
f"prompt_col and status_col are both '{prompt_col.upper()}'. They must differ."
|
||||
)
|
||||
|
||||
rows = fetch_rows(url, sheet_gid)
|
||||
pending, total = scan_rows(rows, prompt_idx, status_idx, skip_first_row, max_rows)
|
||||
|
||||
if not pending:
|
||||
if total == 0:
|
||||
raise ValueError(
|
||||
f"No prompts found in column {prompt_col.upper()}. Check the column "
|
||||
f"letter, the sheet GID, and the skip_first_row setting."
|
||||
)
|
||||
raise ValueError(
|
||||
f"Nothing left to run — all {total} prompt(s) in column "
|
||||
f"{prompt_col.upper()} are marked in column {status_col.upper()}. "
|
||||
f"Clear those cells in the sheet to queue them again."
|
||||
)
|
||||
|
||||
print(
|
||||
f"[SF Google Sheet Prompt Queue] Sending {len(pending)} of {total} prompt(s), "
|
||||
f"rows {pending[0][0]}-{pending[-1][0]}."
|
||||
)
|
||||
return ([prompt for _, prompt in pending],)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SFGoogleSheetPromptQueue": SFGoogleSheetPromptQueue}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFGoogleSheetPromptQueue": "SF Google Sheet Prompt Queue"}
|
||||
|
||||
|
||||
# --- Server route backing the node's Refresh button ---------------------------
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
@PromptServer.instance.routes.post("/sf_gsheet/preview")
|
||||
async def _preview(request):
|
||||
body = await request.json()
|
||||
try:
|
||||
rows = fetch_rows(body.get("url", ""), body.get("sheet_gid", "0"))
|
||||
pending, total = scan_rows(
|
||||
rows,
|
||||
col_letter_to_index(body.get("prompt_col", "A")),
|
||||
col_letter_to_index(body.get("status_col", "B")),
|
||||
bool(body.get("skip_first_row", True)),
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": True, "total": total, "pending": len(pending)}
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)})
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -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
@@ -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,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"}
|
||||
@@ -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.0.6"
|
||||
version = "1.9.0"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"requests>=2.31.0",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* SF Claude Code — grows the image input list on demand.
|
||||
*
|
||||
* The Python side declares image_1..image_16 as optional inputs. This keeps
|
||||
* only as many slots visible as are in use, plus one spare to connect into.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
const NODE_NAME = "SFClaudeCode";
|
||||
const MAX_IMAGES = 16;
|
||||
|
||||
function imageSlotIndex(node, n) {
|
||||
return (node.inputs ?? []).findIndex((input) => input.name === `image_${n}`);
|
||||
}
|
||||
|
||||
function syncImageSlots(node) {
|
||||
if (!node.inputs) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Highest numbered image slot that currently has something plugged in.
|
||||
let lastUsed = 0;
|
||||
for (const input of node.inputs) {
|
||||
const match = input.name?.match(/^image_(\d+)$/);
|
||||
if (match && input.link != null) {
|
||||
lastUsed = Math.max(lastUsed, parseInt(match[1], 10));
|
||||
}
|
||||
}
|
||||
|
||||
// Show every used slot, plus one empty spare to drag into.
|
||||
const wanted = Math.min(lastUsed + 1, MAX_IMAGES);
|
||||
|
||||
for (let n = 1; n <= wanted; n++) {
|
||||
if (imageSlotIndex(node, n) === -1) {
|
||||
node.addInput(`image_${n}`, "IMAGE");
|
||||
}
|
||||
}
|
||||
|
||||
// Drop trailing spares, but never one that is still connected.
|
||||
for (let n = MAX_IMAGES; n > wanted; n--) {
|
||||
const index = imageSlotIndex(node, n);
|
||||
if (index !== -1 && node.inputs[index].link == null) {
|
||||
node.removeInput(index);
|
||||
}
|
||||
}
|
||||
|
||||
node.setSize(node.computeSize());
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Stillfront.ClaudeCode",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== NODE_NAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
syncImageSlots(this);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Re-sync after a saved workflow restores its connections.
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const result = onConfigure ? onConfigure.apply(this, arguments) : undefined;
|
||||
requestAnimationFrame(() => syncImageSlots(this));
|
||||
return result;
|
||||
};
|
||||
|
||||
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
|
||||
nodeType.prototype.onConnectionsChange = function (type, index, connected, linkInfo, ioSlot) {
|
||||
const result = onConnectionsChange
|
||||
? onConnectionsChange.apply(this, arguments)
|
||||
: undefined;
|
||||
// Let litegraph finish updating link state before recounting.
|
||||
requestAnimationFrame(() => syncImageSlots(this));
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* SF Google Sheet Prompt Queue — adds a Refresh button that re-reads the sheet
|
||||
* and reports how many prompts are still waiting to run.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
const NODE_NAME = "SFGoogleSheetPromptQueue";
|
||||
|
||||
function widgetValue(node, name, fallback) {
|
||||
const widget = node.widgets?.find((w) => w.name === name);
|
||||
return widget ? widget.value : fallback;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Stillfront.GoogleSheetPromptQueue",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== NODE_NAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
const node = this;
|
||||
|
||||
const status = node.addWidget("text", "", "Press Refresh to read the sheet", () => {});
|
||||
status.disabled = true;
|
||||
status.serialize = false;
|
||||
|
||||
const setStatus = (text) => {
|
||||
status.value = text;
|
||||
node.setDirtyCanvas(true, false);
|
||||
};
|
||||
|
||||
const refreshButton = node.addWidget("button", "Refresh sheet", null, async () => {
|
||||
setStatus("Reading sheet…");
|
||||
try {
|
||||
const response = await api.fetchApi("/sf_gsheet/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: widgetValue(node, "url", ""),
|
||||
sheet_gid: widgetValue(node, "sheet_gid", "0"),
|
||||
prompt_col: widgetValue(node, "prompt_col", "A"),
|
||||
status_col: widgetValue(node, "status_col", "B"),
|
||||
skip_first_row: widgetValue(node, "skip_first_row", true),
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.ok) {
|
||||
setStatus(`Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const cap = widgetValue(node, "max_rows", 10);
|
||||
const willSend = Math.min(data.pending, cap);
|
||||
setStatus(
|
||||
data.pending === 0
|
||||
? `Nothing pending — all ${data.total} prompt(s) marked`
|
||||
: `${data.pending} pending of ${data.total} — next run sends ${willSend}`
|
||||
);
|
||||
} catch (e) {
|
||||
setStatus("Could not reach ComfyUI server");
|
||||
}
|
||||
});
|
||||
refreshButton.serialize = false;
|
||||
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user