Compare commits

...
12 Commits
Author SHA1 Message Date
Ivor StrelarandClaude Opus 5 919b822fe0 feat: add SF Claude Asset Director node
Claude reads an asset library's manifest, picks the references that suit the
brief, and writes the image prompt for them. Emits an IMAGE batch plus the
prompt, which wire directly into a generation node's image and prompt inputs.

Choosing from a manifest rather than the images themselves keeps a large
library cheap; look_at_images opts into opening the files and is off by
default. pad_to_fit controls how mixed-size assets are reconciled into a
single batch tensor: letterbox onto a shared canvas (default) or stretch.

Runs on the existing Claude Code login, no API key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 09:46:44 +02:00
Ivor StrelarandClaude Opus 5 eb513e1cc2 chore: remove the auto-publish workflow
Inherited from the original repo. It fires on every pyproject.toml change
and immediately fails, because the Stillfront org blocks third-party GitHub
Actions and `Comfy-Org/publish-node-action` is not on the allowlist. Each
version bump therefore produced a failure email for no benefit.

Publishing is done manually with `comfy node publish`. Recoverable from
history if the action is ever allowlisted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:32:43 +02:00
Ivor StrelarandClaude Opus 5 0c3830097b feat: add SF Claude Code node
Runs prompts through the locally installed Claude Code CLI, so it uses the
user's existing Claude login rather than an Anthropic API key. Accepts a
system prompt and up to 16 images, which are written to a temp working
directory and read back by the agent's Read tool.

Image inputs grow on demand: connecting one reveals the next empty slot, and
unused trailing slots are trimmed.

Closes stdin on the subprocess, which otherwise costs 3s per call while the
CLI waits for input that never arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:50:24 +02:00
Ivor StrelarandClaude Opus 5 b0952e0fb3 Revert "fix: drop the HTTP endpoint that left 1.0.8 pending registry review"
Removing the route did not clear the Pending status: 1.0.9 shipped without
it and sits Pending alongside 1.0.8, so the endpoint was never the cause.
Pending appears to be the normal post-upload state before a scan promotes a
version to Active.

Restores the Refresh button and its preview endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 13:40:42 +02:00
Ivor StrelarandClaude Opus 5 292abd93d8 fix: drop the HTTP endpoint that left 1.0.8 pending registry review
1.0.8 registered a ComfyUI server route to power a Refresh button, which
put that version into NodeVersionStatusPending and blocked installs. The
route only ever supplied a pre-run preview of the pending count; IS_CHANGED
already forces a fresh read of the sheet on every run, so removing it costs
no functionality.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 13:35:40 +02:00
Ivor StrelarandClaude Opus 5 10f6c59d48 feat: add SF Google Sheet Prompt Queue node
Dispatches unfinished prompts from a Google Sheet column as a batch, one
downstream graph run per prompt, capped by max_rows. Rows carrying any text
in the status column are skipped, so reruns continue where the last one
stopped and clearing a cell requeues that row. Scanning stops at the first
empty prompt cell.

Adds a Refresh button that re-reads the sheet and reports pending counts.

Also stops tracking node.zip, which is a `comfy node publish` build artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 13:19:57 +02:00
Ivor Strelar e17a70b5cb docs: rewrite README with full node list and setup guide 2026-07-01 13:44:32 +02:00
Ivor Strelar 0c9d4b98cb feat: add Stillfront logo and set registry icon 2026-06-30 14:19:33 +02:00
Ivor Strelar 1689aa0723 feat: use column letters (A, B, C...) in Google Sheet nodes 2026-06-30 14:15:07 +02:00
Ivor Strelar 61cf275578 feat: add SF Google Sheet Range node (Utils) 2026-06-30 13:23:43 +02:00
Ivor Strelar 78272268a6 feat: add SF Google Sheet Cell node (Utils) 2026-06-30 12:45:55 +02:00
Ivor Strelar 244af068cd feat: add SF Version Check dummy node for v1.0.2 verification 2026-06-30 11:39:45 +02:00
14 changed files with 1526 additions and 54 deletions
-20
View File
@@ -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 }}
+3
View File
@@ -36,6 +36,9 @@ env/
# Config with secrets
config.ini
# Publish artifact created by `comfy node publish`
node.zip
# OS
.DS_Store
Thumbs.db
+224 -32
View File
@@ -1,19 +1,16 @@
# SF ComfyUI Nodes
A collection of custom nodes for ComfyUI developed by Stillfront.
![Stillfront](assets/logo.png)
## 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,242 @@ 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. |
#### 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 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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
View File
Binary file not shown.
+367
View File
@@ -0,0 +1,367 @@
# 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:\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": "assets.json",
"tooltip": (
"File inside the library describing each asset, used to choose "
"between them. Any text or JSON format. 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"}
+241
View File
@@ -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"}
+124
View File
@@ -0,0 +1,124 @@
import csv
import io
import re
import requests
def col_letter_to_index(col_str: str) -> int:
"""Convert a column letter (A, B, C, ... Z, AA, AB, ...) to a 0-based index."""
col_str = col_str.strip().upper()
if not col_str or not col_str.isalpha():
raise ValueError(f"Invalid column label '{col_str}'. Use letters like A, B, C, AA, AB, etc.")
result = 0
for char in col_str:
result = result * 26 + (ord(char) - ord("A") + 1)
return result - 1
class SFGoogleSheetCell:
"""
Reads a single cell from a publicly shared Google Sheet.
Row is 1-indexed. Column uses letter notation (A, B, C, ...) matching Google Sheets.
The sheet must be shared as "Anyone with the link can view."
"""
@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 — found in the URL after '#gid='. "
"Use 0 for the first/default sheet. "
"Example: for ...#gid=1234567890 enter 1234567890."
),
},
),
"row": (
"INT",
{
"default": 1,
"min": 1,
"step": 1,
"tooltip": "Row number starting from 1 (matches the row numbers shown in Google Sheets).",
},
),
"col": (
"STRING",
{
"default": "A",
"tooltip": "Column letter as shown in Google Sheets (A, B, C, ... Z, AA, AB, ...).",
},
),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("cell_value",)
FUNCTION = "get_cell"
CATEGORY = "Stillfront/Utils"
def get_cell(self, url: str, sheet_gid: str, row: int, col: str) -> tuple:
spreadsheet_id = self._extract_id(url)
gid = sheet_gid.strip() or "0"
col_idx = col_letter_to_index(col)
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 the sheet 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}")
content = response.content.decode("utf-8")
table = list(csv.reader(io.StringIO(content)))
row_idx = row - 1
if row_idx >= len(table):
raise ValueError(f"Row {row} is out of range — the sheet has {len(table)} rows.")
row_data = table[row_idx]
if col_idx >= len(row_data):
raise ValueError(
f"Column '{col.upper()}' is out of range — row {row} has {len(row_data)} columns."
)
return (str(row_data[col_idx]),)
def _extract_id(self, 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."
)
NODE_CLASS_MAPPINGS = {"SFGoogleSheetCell": SFGoogleSheetCell}
NODE_DISPLAY_NAME_MAPPINGS = {"SFGoogleSheetCell": "SF Google Sheet Cell"}
+227
View File
@@ -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
+162
View File
@@ -0,0 +1,162 @@
import csv
import io
import re
import requests
from .sf_google_sheet_cell import col_letter_to_index
class SFGoogleSheetRange:
"""
Reads a range of cells from a publicly shared Google Sheet and joins them
into a single string using a configurable delimiter.
Cells are read left to right, top to bottom.
Row numbers are 1-indexed. Columns use letter notation (A, B, C, ...) matching Google Sheets.
The sheet must be shared as "Anyone with the link can view."
"""
@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 — found in the URL after '#gid='. "
"Use 0 for the first/default sheet."
),
},
),
"start_row": (
"INT",
{
"default": 1,
"min": 1,
"step": 1,
"tooltip": "First row of the range (1 = first row in the sheet).",
},
),
"start_col": (
"STRING",
{
"default": "A",
"tooltip": "First column of the range (e.g. A, B, C, AA, ...).",
},
),
"end_row": (
"INT",
{
"default": 1,
"min": 1,
"step": 1,
"tooltip": "Last row of the range (inclusive).",
},
),
"end_col": (
"STRING",
{
"default": "A",
"tooltip": "Last column of the range (inclusive, e.g. A, B, C, AA, ...).",
},
),
"delimiter": (
"STRING",
{
"default": ", ",
"multiline": False,
"tooltip": (
"String placed between each cell value in the output. "
"Can be typed directly or connected from another string node."
),
},
),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "get_range"
CATEGORY = "Stillfront/Utils"
def get_range(
self,
url: str,
sheet_gid: str,
start_row: int,
start_col: str,
end_row: int,
end_col: str,
delimiter: str,
) -> tuple:
start_col_idx = col_letter_to_index(start_col)
end_col_idx = col_letter_to_index(end_col)
if start_row > end_row:
raise ValueError(f"start_row ({start_row}) must be ≤ end_row ({end_row}).")
if start_col_idx > end_col_idx:
raise ValueError(
f"start_col '{start_col.upper()}' must be ≤ end_col '{end_col.upper()}'."
)
table = self._fetch_sheet(url, sheet_gid)
cells = []
for r in range(start_row - 1, end_row):
if r >= len(table):
break
row_data = table[r]
for c in range(start_col_idx, end_col_idx + 1):
value = row_data[c] if c < len(row_data) else ""
cells.append(str(value))
return (delimiter.join(cells),)
def _fetch_sheet(self, url: str, sheet_gid: str) -> list:
spreadsheet_id = self._extract_id(url)
gid = sheet_gid.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 the sheet 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}")
content = response.content.decode("utf-8")
return list(csv.reader(io.StringIO(content)))
def _extract_id(self, 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."
)
NODE_CLASS_MAPPINGS = {"SFGoogleSheetRange": SFGoogleSheetRange}
NODE_DISPLAY_NAME_MAPPINGS = {"SFGoogleSheetRange": "SF Google Sheet Range"}
+16
View File
@@ -0,0 +1,16 @@
class SFVersionCheck:
@classmethod
def INPUT_TYPES(cls):
return {"required": {}}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("version",)
FUNCTION = "get_version"
CATEGORY = "Stillfront/Utils"
def get_version(self):
return ("SF ComfyUI Nodes v1.0.2",)
NODE_CLASS_MAPPINGS = {"SFVersionCheck": SFVersionCheck}
NODE_DISPLAY_NAME_MAPPINGS = {"SFVersionCheck": "SF Version Check"}
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-sf-nodes"
description = "Custom nodes for ComfyUI including LLM Chat with support for Claude, Gemini, and OpenAI models, WaveSpeed AI image/video generation, VertexAI integration, and workflow utilities"
version = "1.0.1"
version = "1.2.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"
+85
View File
@@ -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;
};
},
});
+75
View File
@@ -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;
};
},
});