Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0952e0fb3 | ||
|
|
292abd93d8 | ||
|
|
10f6c59d48 | ||
|
|
e17a70b5cb | ||
|
|
0c9d4b98cb | ||
|
|
1689aa0723 | ||
|
|
61cf275578 | ||
|
|
78272268a6 | ||
|
|
244af068cd |
@@ -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,175 @@ 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. |
|
||||
|
||||
---
|
||||
|
||||
### 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,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"}
|
||||
@@ -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,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"}
|
||||
@@ -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
@@ -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.0.10"
|
||||
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,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