Compare commits

...
3 Commits
Author SHA1 Message Date
Ivor StrelarandClaude Opus 5 fd8808fbbb feat: add SF Claude Library Builder and sync it with Asset Director
Library Builder opens every asset in a folder once and writes a manifest
describing each — category, subcategory, archetype, tags, a one-line
description, and a project-level art_style. Asset Director then searches that
manifest on every run without opening an image, which is what keeps a large
library cheap to work with.

Modes: manifest only (read-only, the default), organise into a copy, or
organise in place. write_manifest is off by default so a first run shows the
JSON without touching anything.

Also aligns Asset Director's manifest default to manifest.json and teaches it
to honour the manifest's art_style when writing prompts, so a library
generates in one consistent style.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 14:00:09 +02:00
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
5 changed files with 848 additions and 21 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 }}
+100
View File
@@ -60,6 +60,106 @@ Chat with large language models directly inside your ComfyUI workflows, with opt
| **SF LLM Chat** | Multi-model chat supporting Claude (Opus, Sonnet, Haiku), Gemini 3 (Pro, Flash), and OpenAI GPT-5 series. Supports image input and reports token usage. |
| **SF Text Analyzer** | Analyze and process text strings within workflows. |
| **SF Claude Code** | Send text and images to Claude through the Claude Code CLI on your own machine, using your existing Claude login instead of an API key. Image slots appear as you fill them. |
| **SF Claude Asset Director** | Point Claude at an asset library and it chooses which references suit the brief and writes the image prompt to go with them. Outputs an IMAGE batch plus the prompt, ready to wire into any generation node. |
| **SF Claude Library Builder** | Point Claude at a folder of game assets and it writes a searchable manifest describing each one. Can also reorganise and rename the files. Feeds **SF Claude Asset Director**. |
#### The asset library workflow
These two nodes are a pair. **Library Builder** catalogues your assets once; **Asset Director**
then searches that catalogue on every run without ever opening an image.
```
one time, when assets change every generation
┌──────────────────────────────┐ ┌──────────────────────────────────┐
│ SF Claude Library Builder │ │ SF Claude Asset Director │
│ opens every image, │──►│ searches the manifest, │──► images + prompt
│ writes manifest.json │ │ opens nothing │
└──────────────────────────────┘ └──────────────────────────────────┘
```
That split is the point: cataloguing is slow (~5s per asset) but happens rarely, while choosing
is instant and happens constantly.
**The manifest schema** (`sf-asset-library/1`):
```json
{
"schema": "sf-asset-library/1",
"project": "Fantasy RPG",
"art_style": "Hand-painted 2D, saturated palette, thick outlines, 3/4 view",
"generated": "2026-10-08",
"assets": [
{
"file": "characters/heroes/knight_aldric.png",
"name": "Aldric the Knight",
"category": "character",
"subcategory": "hero",
"archetype": "tank",
"tags": ["armoured", "sword", "front view", "heroic"],
"desc": "Armoured human knight in silver plate with a red tabard, standing front-on."
}
]
}
```
It's shaped for fast searching rather than for humans: one **flat list** (cheaper to scan than a
tree), the **same keys on every entry**, faceted fields (`category` / `subcategory` /
`archetype` / `tags`) to narrow by, and a one-sentence `desc` carrying the meaning. The
top-level **`art_style`** matters more than it looks — Asset Director reads it and keeps every
prompt it writes consistent with it, so a library generates in one coherent style.
**Library Builder modes:**
| Mode | What it touches |
|---|---|
| **manifest only** (default) | Nothing. Reads the images, returns the JSON as text |
| **organise into a copy** | Writes a tidy, renamed copy to `output_path`; originals untouched |
| **organise in place** | Renames and moves your actual files |
`write_manifest` is **off** by default, so the first run shows you the JSON without writing
anything. Turn it on once you're happy, or paste the text in yourself.
Start in **manifest only**. Reorganising is genuinely useful on a folder of `IMG_0021.png` and
`final_v2 copy.png`, but it moves real files — try it on a copy first.
#### 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
+370
View File
@@ -0,0 +1,370 @@
# ABOUTME: Claude picks reference images from an asset library and writes the prompt for them
# ABOUTME: Reads a manifest describing the assets; looking at the images themselves is optional
import json
import os
import re
import subprocess
import tempfile
import numpy as np
import torch
from PIL import Image
from .sf_claude_code import MODEL_CHOICES, find_claude_binary
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"}
MAX_LISTED = 2000
def scan_library(root, recursive):
"""Image files under `root`, as paths relative to it, in sorted order."""
found = []
if recursive:
for folder, _, names in os.walk(root):
for name in names:
if os.path.splitext(name)[1].lower() in IMAGE_EXTS:
full = os.path.join(folder, name)
found.append(os.path.relpath(full, root))
else:
for name in os.listdir(root):
if os.path.splitext(name)[1].lower() in IMAGE_EXTS:
found.append(name)
return sorted(found)[:MAX_LISTED]
def read_manifest(root, manifest):
"""The asset descriptions file, or None when there isn't one."""
name = (manifest or "").strip()
if not name:
return None
path = os.path.join(root, name)
if not os.path.isfile(path):
return None
try:
with open(path, encoding="utf-8", errors="ignore") as f:
return f.read()
except OSError:
return None
def build_request(system_prompt, instruction, files, manifest_text, max_images, may_look):
"""The single prompt sent to Claude."""
parts = []
if system_prompt.strip():
parts.append(system_prompt.strip())
if manifest_text:
parts.append(
"Here is the asset library manifest describing what each file contains. "
"Search it to choose; if it declares an `art_style`, the prompt you write "
"must stay consistent with it.\n\n"
f"{manifest_text.strip()}"
)
listing = "\n".join(f" {name}" for name in files)
parts.append(f"These are the image files available ({len(files)} total):\n{listing}")
if may_look:
parts.append(
"You may use the Read tool on any of these files to look at them before "
"deciding. They are in your current working directory."
)
parts.append(f"Request:\n{instruction.strip()}")
parts.append(
f"Choose at most {max_images} file(s). Reply with ONLY a JSON object and nothing "
f"else — no explanation, no code fence:\n"
f'{{"files": ["exact/path/as/listed.png"], "prompt": "the image generation prompt"}}\n'
f"Use file paths exactly as they appear in the listing. The prompt should refer to "
f"the chosen images in the order you list them."
)
return "\n\n".join(parts)
def parse_reply(text):
"""The JSON object out of Claude's reply, tolerating a code fence around it."""
stripped = text.strip()
start, end = stripped.find("{"), stripped.rfind("}")
if start == -1 or end <= start:
raise RuntimeError(f"Claude did not return JSON. It said: {stripped[:300]}")
try:
payload = json.loads(stripped[start : end + 1])
except json.JSONDecodeError as e:
raise RuntimeError(f"Claude's JSON could not be parsed ({e}): {stripped[:300]}")
files = payload.get("files") or []
prompt = str(payload.get("prompt") or "").strip()
if not isinstance(files, list):
raise RuntimeError("Claude's 'files' field was not a list.")
return [str(f).strip() for f in files if str(f).strip()], prompt
def resolve_picks(picked, available, root):
"""Map Claude's filenames onto real paths, matching on basename as a fallback."""
by_rel = {name: name for name in available}
by_base = {}
for name in available:
by_base.setdefault(os.path.basename(name).lower(), name)
resolved, missing = [], []
for want in picked:
cleaned = want.strip().lstrip("./")
match = by_rel.get(cleaned) or by_base.get(os.path.basename(cleaned).lower())
if match:
resolved.append(os.path.join(root, match))
else:
missing.append(want)
return resolved, missing
def to_image_batch(paths, pad_to_fit):
"""Load the images into one ComfyUI IMAGE tensor.
A batch is a single tensor, so every frame must share dimensions. With
`pad_to_fit` each image is scaled to fit and centred on a common canvas,
which keeps its aspect ratio; without it they are stretched to match.
"""
images = [Image.open(p).convert("RGB") for p in paths]
width = max(im.width for im in images)
height = max(im.height for im in images)
frames = []
for im in images:
if pad_to_fit:
scale = min(width / im.width, height / im.height)
size = (max(1, round(im.width * scale)), max(1, round(im.height * scale)))
canvas = Image.new("RGB", (width, height), (0, 0, 0))
canvas.paste(im.resize(size, Image.LANCZOS),
((width - size[0]) // 2, (height - size[1]) // 2))
im = canvas
else:
im = im.resize((width, height), Image.LANCZOS)
frames.append(np.asarray(im, dtype=np.float32) / 255.0)
return torch.from_numpy(np.stack(frames))
class SFClaudeAssetDirector:
"""
Claude picks reference images out of an asset library and writes the prompt
that goes with them.
Decisions are made from a manifest describing the assets, so the library can
be large without every run costing a pile of image reads. Outputs an IMAGE
batch and a STRING, which wire straight into an image generation node.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"library_path": (
"STRING",
{
"multiline": False,
"default": "",
"tooltip": (
"Folder holding the assets. For Google Drive, point this at "
"your Drive for Desktop sync folder."
),
},
),
"manifest": (
"STRING",
{
"default": "manifest.json",
"tooltip": (
"File inside the library describing each asset, used to choose "
"between them. Written by SF Claude Library Builder, though any "
"text or JSON works. Ignored if absent."
),
},
),
"system_prompt": (
"STRING",
{
"multiline": True,
"default": "",
"tooltip": "Standing instructions — how to choose, how to write the prompt.",
},
),
"instruction": (
"STRING",
{
"multiline": True,
"default": "",
"tooltip": (
"What you want this run. Type it here or wire a string in, "
"e.g. 'Make a poster. Pick the characters you think work best.'"
),
},
),
"max_images": (
"INT",
{
"default": 5,
"min": 1,
"max": 20,
"step": 1,
"tooltip": "Most reference images Claude may choose.",
},
),
"pad_to_fit": (
"BOOLEAN",
{
"default": True,
"tooltip": (
"On: scale each image to fit a shared canvas and centre it, "
"keeping aspect ratios. Off: stretch them all to one size."
),
},
),
"look_at_images": (
"BOOLEAN",
{
"default": False,
"tooltip": (
"Let Claude open the image files as well as the manifest. "
"Better choices on an unlabelled library, but roughly 5 "
"seconds per image it opens."
),
},
),
"recursive": (
"BOOLEAN",
{"default": True, "tooltip": "Include images in subfolders."},
),
"model": (
MODEL_CHOICES,
{"default": "default", "tooltip": "Which Claude model decides."},
),
"timeout_seconds": (
"INT",
{"default": 300, "min": 30, "max": 3600, "step": 30,
"tooltip": "Give up if Claude has not replied within this long."},
),
"seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 0xFFFFFFFF,
"control_after_generate": True,
"tooltip": (
"Not sent to Claude. Change it to force a fresh choice instead "
"of reusing ComfyUI's cached result."
),
},
),
}
}
RETURN_TYPES = ("IMAGE", "STRING", "STRING")
RETURN_NAMES = ("images", "prompt", "picked")
FUNCTION = "direct"
CATEGORY = "Stillfront/LLM"
DESCRIPTION = """
Claude reads your asset library's **manifest**, chooses which references suit the
request, and writes the image prompt to go with them.
Outputs an IMAGE batch and the prompt, ready to wire into Nano Banana or any
other generation node. Point **library_path** at a Google Drive for Desktop sync
folder to work straight off Drive.
**look_at_images** is off by default: choices come from the manifest, so a large
library costs nothing extra. Turn it on to have Claude open the files too.
"""
def direct(self, library_path, manifest, system_prompt, instruction, max_images,
pad_to_fit, look_at_images, recursive, model, timeout_seconds, seed):
root = os.path.expanduser(library_path.strip())
if not root or not os.path.isdir(root):
raise ValueError(f"library_path is not a folder: {library_path!r}")
if not instruction.strip():
raise ValueError("instruction is empty — tell Claude what this run is for.")
binary = find_claude_binary()
if not binary:
raise RuntimeError(
"Claude Code CLI not found. Install it from https://claude.com/claude-code "
"and sign in, then restart ComfyUI."
)
files = scan_library(root, recursive)
if not files:
raise ValueError(f"No image files found in {root}")
manifest_text = read_manifest(root, manifest)
if manifest_text is None and manifest.strip():
print(
f"[SF Claude Asset Director] No manifest '{manifest.strip()}' in the library; "
f"choosing from filenames alone."
)
request = build_request(
system_prompt, instruction, files, manifest_text, max_images, look_at_images
)
command = [binary, "-p", request, "--output-format", "json"]
if model != "default":
command += ["--model", model]
if look_at_images:
command += ["--allowedTools", "Read"]
print(
f"[SF Claude Asset Director] {len(files)} asset(s), "
f"manifest={'yes' if manifest_text else 'no'}, vision={'on' if look_at_images else 'off'}."
)
try:
completed = subprocess.run(
command,
cwd=root,
capture_output=True,
text=True,
timeout=timeout_seconds,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
raise RuntimeError(
f"Claude did not reply within {timeout_seconds}s. Raise timeout_seconds, "
f"or turn look_at_images off."
)
if completed.returncode != 0:
detail = (completed.stderr or completed.stdout or "").strip()[:500]
raise RuntimeError(f"Claude Code exited with code {completed.returncode}. {detail}")
try:
payload = json.loads(completed.stdout)
except json.JSONDecodeError:
raise RuntimeError(f"Unreadable response: {completed.stdout.strip()[:400]}")
if payload.get("is_error"):
raise RuntimeError(f"Claude reported an error: {payload.get('result', 'unknown')}")
picked, prompt = parse_reply(payload.get("result", ""))
if not picked:
raise RuntimeError("Claude chose no images. Check the manifest describes the assets.")
if not prompt:
raise RuntimeError("Claude returned no prompt.")
paths, missing = resolve_picks(picked[:max_images], files, root)
if missing:
print(f"[SF Claude Asset Director] Ignoring names not in the library: {missing}")
if not paths:
raise RuntimeError(
f"None of Claude's choices matched a real file. It asked for: {picked}"
)
batch = to_image_batch(paths, pad_to_fit)
names = [os.path.relpath(p, root) for p in paths]
print(f"[SF Claude Asset Director] Chose {len(names)}: {', '.join(names)}")
return (batch, prompt, "\n".join(names))
NODE_CLASS_MAPPINGS = {"SFClaudeAssetDirector": SFClaudeAssetDirector}
NODE_DISPLAY_NAME_MAPPINGS = {"SFClaudeAssetDirector": "SF Claude Asset Director"}
+377
View File
@@ -0,0 +1,377 @@
# ABOUTME: Claude surveys a folder of game assets and writes a searchable manifest for it
# ABOUTME: Can optionally reorganise and rename the files; read-only by default
import json
import os
import re
import shutil
import subprocess
import time
from .sf_claude_code import MODEL_CHOICES, find_claude_binary
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tga"}
MODE_MANIFEST = "manifest only (read-only)"
MODE_COPY = "organise into a copy"
MODE_IN_PLACE = "organise in place"
MODES = [MODE_MANIFEST, MODE_COPY, MODE_IN_PLACE]
SCHEMA_VERSION = "sf-asset-library/1"
# What the manifest has to contain for SF Claude Asset Director to search it well:
# one flat list (cheaper to scan than a tree), the same keys on every entry, a
# one-sentence description carrying the meaning, and a project-level art style so
# generated prompts stay consistent across the whole library.
SCHEMA_SPEC = """{
"schema": "sf-asset-library/1",
"project": "<short project name>",
"art_style": "<one sentence describing the shared visual style of the whole library>",
"generated": "<YYYY-MM-DD>",
"assets": [
{
"file": "<path relative to the library root, exactly as it exists on disk>",
"name": "<short human name>",
"category": "<character|environment|prop|ui|vfx|other>",
"subcategory": "<hero|enemy|npc|location|background|weapon|icon|...>",
"archetype": "<for characters: tank|healer|rogue|mage|boss|minion|... else omit>",
"tags": ["<lowercase keyword>", "..."],
"desc": "<one sentence: what it depicts, pose/view, dominant colours>"
}
]
}"""
DEFAULT_SYSTEM_PROMPT = """You are cataloguing a game asset library.
Judge each asset on what it depicts and what role it would play in a game.
Keep `desc` to one sentence that would let someone pick this asset without
seeing it. Make `tags` lowercase, specific and useful for searching — subject,
colour, pose, view angle, mood. Prefer a small shared vocabulary across the
library over inventing a new word per asset."""
def scan_assets(root):
"""Image files under `root`, relative to it."""
found = []
for folder, dirs, names in os.walk(root):
dirs[:] = [d for d in dirs if not d.startswith(".")]
for name in names:
if os.path.splitext(name)[1].lower() in IMAGE_EXTS:
found.append(os.path.relpath(os.path.join(folder, name), root))
return sorted(found)
def extract_json(text):
"""The JSON object out of a reply that may be fenced or prefaced."""
start, end = text.find("{"), text.rfind("}")
if start == -1 or end <= start:
raise RuntimeError(f"Claude did not return JSON. It said: {text.strip()[:300]}")
body = text[start : end + 1]
try:
return json.loads(body)
except json.JSONDecodeError as e:
raise RuntimeError(f"Claude's JSON could not be parsed ({e}): {body[:300]}")
def build_instruction(mode, assets, project_notes, manifest_name, write_manifest, out_dir):
"""The task description handed to Claude."""
parts = [
f"You are building an asset library manifest for {len(assets)} image file(s)."
]
if project_notes.strip():
parts.append(f"About this project:\n{project_notes.strip()}")
listing = "\n".join(f" {name}" for name in assets)
parts.append(f"The files, relative to your working directory:\n{listing}")
parts.append(
"Open each image with the Read tool before describing it. Work through them "
"steadily; do not guess from a filename."
)
if mode == MODE_MANIFEST:
parts.append(
"Do NOT move, rename, copy or delete anything. This is a survey only."
)
if write_manifest:
parts.append(
f"When you have seen them all, write the manifest to `{manifest_name}` "
f"in the working directory using the Write tool, then reply with the "
f"word DONE and nothing else."
)
else:
parts.append(
"When you have seen them all, reply with ONLY the manifest JSON — no "
"commentary, no code fence."
)
else:
where = (
f"a new folder at `{out_dir}`, leaving the originals untouched"
if mode == MODE_COPY
else "the working directory itself"
)
parts.append(
f"Then reorganise the library into {where}:\n"
f"- Group files into folders by category, e.g. characters/heroes, "
f"characters/enemies, environments, props, ui, vfx.\n"
f"- Rename to lowercase_with_underscores, descriptive of the subject, no "
f"spaces and no version noise. Keep the original extension.\n"
f"- Never discard a file. Every input must end up somewhere.\n"
f"- Use Bash (cp for a copy, mv in place) to do the moving."
)
parts.append(
f"The `file` field of every asset must be the path AFTER reorganising, "
f"relative to the library root. Write the manifest to `{manifest_name}` at "
f"the root of the reorganised library, then reply with the word DONE and "
f"nothing else."
)
parts.append(f"The manifest must match this shape exactly:\n{SCHEMA_SPEC}")
return "\n\n".join(parts)
class SFClaudeLibraryBuilder:
"""
Surveys a folder of game assets and writes a manifest describing each one,
so SF Claude Asset Director can choose between them without opening images.
Read-only by default. It can also reorganise and rename the files, into a
copy or in place, when you ask it to.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"library_path": (
"STRING",
{
"multiline": False,
"default": "",
"tooltip": "Folder holding the assets to catalogue.",
},
),
"mode": (
MODES,
{
"default": MODE_MANIFEST,
"tooltip": (
"manifest only: nothing is moved or renamed. "
"organise into a copy: originals untouched, tidy copy written "
"to output_path. organise in place: renames and moves your "
"actual files."
),
},
),
"output_path": (
"STRING",
{
"multiline": False,
"default": "",
"tooltip": "Destination folder, for 'organise into a copy' only.",
},
),
"project_notes": (
"STRING",
{
"multiline": True,
"default": "",
"tooltip": (
"What the game is, and anything Claude could not infer from "
"the images — genre, art direction, naming you prefer."
),
},
),
"system_prompt": (
"STRING",
{
"multiline": True,
"default": DEFAULT_SYSTEM_PROMPT,
"tooltip": "How to categorise and describe. Edit to suit your project.",
},
),
"manifest_name": (
"STRING",
{
"default": "manifest.json",
"tooltip": "Filename for the manifest, written at the library root.",
},
),
"write_manifest": (
"BOOLEAN",
{
"default": False,
"tooltip": (
"Off: the manifest is only returned as text for you to read "
"and save yourself. On: it is written into the library."
),
},
),
"model": (
MODEL_CHOICES,
{"default": "default", "tooltip": "Which Claude model does the cataloguing."},
),
"timeout_seconds": (
"INT",
{
"default": 1800,
"min": 60,
"max": 7200,
"step": 60,
"tooltip": (
"Allow roughly 5 seconds per asset. 100 assets needs about "
"10 minutes."
),
},
),
"seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 0xFFFFFFFF,
"control_after_generate": True,
"tooltip": "Not sent to Claude. Change it to force a fresh run.",
},
),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("manifest", "report")
FUNCTION = "build"
CATEGORY = "Stillfront/LLM"
DESCRIPTION = """
Claude looks at every asset in a folder and writes a manifest describing each —
category, archetype, tags and a one-line description — plus a project-level art
style. **SF Claude Asset Director** then searches that manifest instead of
opening images.
Defaults to **manifest only**, which touches nothing: you get the JSON as text to
read and save yourself. Switch `write_manifest` on to have it saved into the
library, or change `mode` to let Claude reorganise and rename the files too.
Budget about 5 seconds per asset — it opens every image.
"""
def build(self, library_path, mode, output_path, project_notes, system_prompt,
manifest_name, write_manifest, model, timeout_seconds, seed):
root = os.path.expanduser(library_path.strip())
if not root or not os.path.isdir(root):
raise ValueError(f"library_path is not a folder: {library_path!r}")
out_dir = os.path.expanduser(output_path.strip())
if mode == MODE_COPY:
if not out_dir:
raise ValueError("output_path is required for 'organise into a copy'.")
if os.path.abspath(out_dir).startswith(os.path.abspath(root) + os.sep):
raise ValueError("output_path must sit outside library_path.")
os.makedirs(out_dir, exist_ok=True)
binary = find_claude_binary()
if not binary:
raise RuntimeError(
"Claude Code CLI not found. Install it from https://claude.com/claude-code "
"and sign in, then restart ComfyUI."
)
assets = scan_assets(root)
if not assets:
raise ValueError(f"No image files found in {root}")
writes_file = write_manifest or mode != MODE_MANIFEST
tools = "Read" if mode == MODE_MANIFEST and not write_manifest else "Read,Write,Bash"
command = [
binary,
"-p",
build_instruction(mode, assets, project_notes, manifest_name,
writes_file, out_dir),
"--output-format",
"json",
"--allowedTools",
tools,
]
if system_prompt.strip():
command += ["--append-system-prompt", system_prompt.strip()]
if model != "default":
command += ["--model", model]
print(
f"[SF Claude Library Builder] Cataloguing {len(assets)} asset(s) — mode='{mode}', "
f"tools={tools}. This opens every image, so allow time."
)
started = time.time()
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 finish within {timeout_seconds}s. Raise timeout_seconds "
f"(about 5s per asset) or catalogue a smaller folder."
)
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')}")
elapsed = time.time() - started
reply = payload.get("result", "")
# When Claude wrote the file, read it back; otherwise its reply IS the manifest.
manifest_root = out_dir if mode == MODE_COPY else root
manifest_path = os.path.join(manifest_root, manifest_name)
if writes_file:
if not os.path.isfile(manifest_path):
raise RuntimeError(
f"Claude was asked to write {manifest_name} but it is not there. "
f"It replied: {reply.strip()[:300]}"
)
with open(manifest_path, encoding="utf-8") as f:
manifest_text = f.read()
else:
manifest_text = json.dumps(extract_json(reply), indent=2)
data = extract_json(manifest_text)
entries = data.get("assets") or []
counts = {}
for entry in entries:
counts[entry.get("category", "?")] = counts.get(entry.get("category", "?"), 0) + 1
report = "\n".join([
f"mode: {mode}",
f"scanned: {len(assets)} file(s) in {root}",
f"catalogued: {len(entries)} asset(s)",
f"categories: " + (", ".join(f"{k} x{v}" for k, v in sorted(counts.items())) or "none"),
f"art style: {data.get('art_style', '(not set)')}",
f"manifest: " + (manifest_path if writes_file else "returned as text, not saved"),
f"took: {elapsed:.0f}s",
])
print(f"[SF Claude Library Builder] {len(entries)} asset(s) catalogued in {elapsed:.0f}s.")
if len(entries) < len(assets):
report += f"\nWARNING: {len(assets) - len(entries)} file(s) were not catalogued."
return (manifest_text, report)
NODE_CLASS_MAPPINGS = {"SFClaudeLibraryBuilder": SFClaudeLibraryBuilder}
NODE_DISPLAY_NAME_MAPPINGS = {"SFClaudeLibraryBuilder": "SF Claude Library Builder"}
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-sf-nodes"
description = "Custom nodes for ComfyUI including LLM Chat with support for Claude, Gemini, and OpenAI models, WaveSpeed AI image/video generation, VertexAI integration, and workflow utilities"
version = "1.1.0"
version = "1.3.0"
license = { file = "LICENSE" }
dependencies = [
"requests>=2.31.0",