Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c3830097b | ||
|
|
b0952e0fb3 | ||
|
|
292abd93d8 | ||
|
|
10f6c59d48 | ||
|
|
e17a70b5cb | ||
|
|
0c9d4b98cb |
@@ -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,16 @@
|
||||
# SF ComfyUI Nodes
|
||||
|
||||
A collection of custom nodes for ComfyUI developed by Stillfront.
|
||||

|
||||
|
||||
## Features
|
||||
A collection of custom ComfyUI nodes developed by [Stillfront](https://www.stillfront.com). All nodes are prefixed with **SF** for easy searching in ComfyUI.
|
||||
|
||||
- **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
|
||||
---
|
||||
|
||||
## 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 +21,203 @@ 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. |
|
||||
|
||||
#### 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 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. |
|
||||
|
||||
#### 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
@@ -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,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
|
||||
+2
-2
@@ -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.5"
|
||||
version = "1.1.0"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"requests>=2.31.0",
|
||||
@@ -19,4 +19,4 @@ Repository = "https://github.com/Stillfront/comfyui-sf-nodes"
|
||||
[tool.comfy]
|
||||
PublisherId = "sf"
|
||||
DisplayName = "SF ComfyUI Nodes"
|
||||
Icon = ""
|
||||
Icon = "https://raw.githubusercontent.com/Stillfront/comfyui-sf-nodes/main/assets/logo.png"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user