Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f3d8581df | ||
|
|
309aaf216b | ||
|
|
59ebc14c5c | ||
|
|
aef1cc5968 | ||
|
|
d3e8e6d470 | ||
|
|
fd8808fbbb |
@@ -2,7 +2,32 @@
|
||||
|
||||

|
||||
|
||||
A collection of custom ComfyUI nodes developed by [Stillfront](https://www.stillfront.com). All nodes are prefixed with **SF** for easy searching in ComfyUI.
|
||||
**63 custom ComfyUI nodes** from [Stillfront](https://www.stillfront.com), covering AI image and
|
||||
video generation, LLM integration, and tooling for working with large game asset libraries. Every
|
||||
node is prefixed **SF** so they're easy to find in the node search.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [**Stillfront/WaveSpeed**](#stillfrontwavespeed) | 45 nodes — image, video and 3D generation through [WaveSpeed AI](https://wavespeed.ai): VEO 3.1, Sora 2, Nano Banana, Qwen, Flux Kontext, Seedream, WAN 2.5, InfiniteTalk, Tripo P2, upscalers |
|
||||
| [**Stillfront/VertexAI**](#stillfrontvertexai) | 7 nodes — Imagen 3/4, VEO 3.1 and Nano Banana Pro via Google Cloud Vertex AI |
|
||||
| [**Stillfront/Utils**](#stillfrontutils) | 7 nodes — Google Sheets integration, prompt batching, resolution presets |
|
||||
| [**Stillfront/LLM**](#stillfrontllm) | 4 nodes — Claude, Gemini and GPT in your graph, plus the asset library tooling below |
|
||||
|
||||
### Worth a look
|
||||
|
||||
**[The asset library workflow](#the-asset-library-workflow)** — point Claude at a folder of game
|
||||
assets and it writes a searchable manifest describing every one. After that, any workflow can ask
|
||||
for *"the character art that suits a boss-fight poster"* and get back the right reference images
|
||||
**plus a generated prompt that already matches your project's art style** — without a single image
|
||||
being opened at generation time.
|
||||
|
||||
Two nodes do this: **SF Claude Library Builder** catalogues once, **SF Claude Asset Director**
|
||||
searches on every run. Both run on your existing Claude Code login, so there's no API key and no
|
||||
per-token billing.
|
||||
|
||||
**[SF Google Sheet Prompt Queue](#using-sf-google-sheet-prompt-queue)** — drive a batch of
|
||||
generations straight from a Google Sheet, marking rows done as you go and resuming where you left
|
||||
off.
|
||||
|
||||
---
|
||||
|
||||
@@ -61,6 +86,91 @@ Chat with large language models directly inside your ComfyUI workflows, with opt
|
||||
| **SF Text Analyzer** | Analyze and process text strings within workflows. |
|
||||
| **SF Claude Code** | Send text and images to Claude through the Claude Code CLI on your own machine, using your existing Claude login instead of an API key. Image slots appear as you fill them. |
|
||||
| **SF Claude Asset Director** | Point Claude at an asset library and it chooses which references suit the brief and writes the image prompt to go with them. Outputs an IMAGE batch plus the prompt, ready to wire into any generation node. |
|
||||
| **SF Claude Library Builder** | Point Claude at a folder of game assets and it writes a searchable manifest describing each one. Can also reorganise and rename the files. Feeds **SF Claude Asset Director**. |
|
||||
|
||||
#### The asset library workflow
|
||||
|
||||
These two nodes are a pair. **Library Builder** catalogues your assets once; **Asset Director**
|
||||
then searches that catalogue on every run without ever opening an image.
|
||||
|
||||
```
|
||||
one time, when assets change every generation
|
||||
┌──────────────────────────────┐ ┌──────────────────────────────────┐
|
||||
│ SF Claude Library Builder │ │ SF Claude Asset Director │
|
||||
│ opens every image, │──►│ searches the manifest, │──► images + prompt
|
||||
│ writes manifest.json │ │ opens nothing │
|
||||
└──────────────────────────────┘ └──────────────────────────────────┘
|
||||
```
|
||||
|
||||
That split is the point: cataloguing is slow (~5s per asset) but happens rarely, while choosing
|
||||
is instant and happens constantly.
|
||||
|
||||
**The manifest schema** (`sf-asset-library/1`):
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "sf-asset-library/1",
|
||||
"project": "Fantasy RPG",
|
||||
"art_style": "Hand-painted 2D, saturated palette, thick outlines, 3/4 view",
|
||||
"generated": "2026-10-08",
|
||||
"assets": [
|
||||
{
|
||||
"file": "characters/heroes/knight_aldric.png",
|
||||
"name": "Aldric the Knight",
|
||||
"category": "character",
|
||||
"subcategory": "hero",
|
||||
"archetype": "tank",
|
||||
"tags": ["armoured", "sword", "front view", "heroic"],
|
||||
"desc": "Armoured human knight in silver plate with a red tabard, standing front-on."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
It's shaped for fast searching rather than for humans: one **flat list** (cheaper to scan than a
|
||||
tree), the **same keys on every entry**, faceted fields (`category` / `subcategory` /
|
||||
`archetype` / `tags`) to narrow by, and a one-sentence `desc` carrying the meaning. The
|
||||
top-level **`art_style`** matters more than it looks — Asset Director reads it and keeps every
|
||||
prompt it writes consistent with it, so a library generates in one coherent style.
|
||||
|
||||
**Library Builder modes:**
|
||||
|
||||
| Mode | What it touches |
|
||||
|---|---|
|
||||
| **manifest only** (default) | Nothing. Reads the images, returns the JSON as text |
|
||||
| **organise into a copy** | Writes a tidy, renamed copy to `output_path`; originals untouched |
|
||||
| **organise in place** | Renames and moves your actual files |
|
||||
|
||||
`write_manifest` is **off** by default, so the first run shows you the JSON without writing
|
||||
anything. Turn it on once you're happy, or paste the text in yourself.
|
||||
|
||||
Start in **manifest only**. Reorganising is genuinely useful on a folder of `IMG_0021.png` and
|
||||
`final_v2 copy.png`, but it moves real files — try it on a copy first.
|
||||
|
||||
**Large libraries are processed in batches, not one session.** `batch_size` (default 15) caps
|
||||
how many assets Claude handles per call — asking it to hold a whole large library open, compose
|
||||
one giant manifest, and write the file all in a single uninterrupted session is what used to
|
||||
blow up in turns and cost on libraries of 100+ assets, with nothing produced at the end. Each
|
||||
batch now only reads its own assets and replies with JSON; the node assembles and writes the
|
||||
manifest itself, so no file-writing tool is ever handed to Claude for this.
|
||||
|
||||
**Reruns are incremental.** With `update_existing` on (the default), the node reads the manifest
|
||||
already in the library and only catalogues files it doesn't yet describe. So the normal workflow
|
||||
is: drop new assets into the folder, run the same node against the same path, and only the new
|
||||
ones cost anything. Entries whose file has since been deleted are dropped automatically. Turn
|
||||
`update_existing` off to re-describe everything from scratch.
|
||||
|
||||
*(Not available in `organise into a copy` mode, where the manifest holds post-rename paths that
|
||||
can't be matched against the untouched source folder.)*
|
||||
|
||||
Separately, a hidden checkpoint (`.sf_library_builder_checkpoint.json`, removed automatically on
|
||||
success) covers a run that dies partway. Rerunning **resumes** — finished batches are skipped,
|
||||
not re-paid for.
|
||||
|
||||
If a batch's reply comes back **cut off** — the model ran past its reply length limit partway
|
||||
through describing a wordy set of assets — that batch is automatically **retried as two smaller
|
||||
halves**, repeatedly if needed, rather than failing the run. You'll see it in the log as
|
||||
`Batch 7/7: the reply was cut off… Retrying as two batches of 5 and 6`.
|
||||
|
||||
#### Using SF Claude Asset Director
|
||||
|
||||
@@ -201,6 +311,20 @@ Connect an **SF WaveSpeed Client** node (enter your WaveSpeed API key) to any Wa
|
||||
| **SF WaveSpeed Flux Kontext Max** | Flux Kontext Max — highest quality variant. |
|
||||
| **SF WaveSpeed Flux ControlNet Union Pro 2** | Flux image generation with ControlNet guidance. |
|
||||
|
||||
#### 3D Generation
|
||||
|
||||
| Node | Description |
|
||||
|---|---|
|
||||
| **SF WaveSpeed Tripo P2 Image to 3D** | Tripo's P2 model — the same one behind ComfyUI's *Tripo P2: Image to Model* partner node — billed to your WaveSpeed account instead of ComfyUI credits. Quad or triangle topology, a target face count, texture quality from none to extreme, PBR, delighting, UVs, and seeds. |
|
||||
|
||||
Outputs mirror the partner node, so they plug into **Preview 3D** and **Save 3D** directly: Tripo
|
||||
returns **FBX when `quad` is on and GLB when it's off**, so the matching output is populated and
|
||||
the other is empty. The file is also saved to `output/3d/`, with its path and CDN URL as strings.
|
||||
|
||||
`face_limit` runs 48–50,000 for triangles or 48–25,000 for quads; `-1` lets Tripo decide. It's a
|
||||
target, not a hard cap. Leave `export_orientation` unset if the model is headed for rigging or
|
||||
retexturing — Tripo warns those steps can come out mis-rotated without reporting an error.
|
||||
|
||||
#### Upscalers
|
||||
|
||||
| Node | Description |
|
||||
|
||||
@@ -56,7 +56,9 @@ def build_request(system_prompt, instruction, files, manifest_text, max_images,
|
||||
|
||||
if manifest_text:
|
||||
parts.append(
|
||||
"Here is the asset library manifest describing what each file contains:\n\n"
|
||||
"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()}"
|
||||
)
|
||||
|
||||
@@ -171,10 +173,11 @@ class SFClaudeAssetDirector:
|
||||
"manifest": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "assets.json",
|
||||
"default": "manifest.json",
|
||||
"tooltip": (
|
||||
"File inside the library describing each asset, used to choose "
|
||||
"between them. Any text or JSON format. Ignored if absent."
|
||||
"between them. Written by SF Claude Library Builder, though any "
|
||||
"text or JSON works. Ignored if absent."
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
# ABOUTME: Claude surveys a folder of game assets and writes a searchable manifest for it
|
||||
# ABOUTME: Processes assets in small batches with a resumable checkpoint, so a failure costs little
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from .sf_claude_code import MODEL_CHOICES, find_claude_binary
|
||||
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tga"}
|
||||
|
||||
MODE_MANIFEST = "manifest only (read-only)"
|
||||
MODE_COPY = "organise into a copy"
|
||||
MODE_IN_PLACE = "organise in place"
|
||||
MODES = [MODE_MANIFEST, MODE_COPY, MODE_IN_PLACE]
|
||||
|
||||
SCHEMA_VERSION = "sf-asset-library/1"
|
||||
CHECKPOINT_NAME = ".sf_library_builder_checkpoint.json"
|
||||
|
||||
ASSET_SCHEMA_SPEC = """{
|
||||
"file": "<path relative to the library root, exactly as it exists on disk>",
|
||||
"name": "<short human name>",
|
||||
"category": "<character|environment|prop|ui|vfx|other>",
|
||||
"subcategory": "<hero|enemy|npc|location|background|weapon|icon|...>",
|
||||
"archetype": "<for characters: tank|healer|rogue|mage|boss|minion|... else omit>",
|
||||
"tags": ["<lowercase keyword>", "..."],
|
||||
"desc": "<one sentence: what it depicts, pose/view, dominant colours>"
|
||||
}"""
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = """You are cataloguing a game asset library.
|
||||
|
||||
Judge each asset on what it depicts and what role it would play in a game.
|
||||
Keep `desc` to one sentence that would let someone pick this asset without
|
||||
seeing it. Make `tags` lowercase, specific and useful for searching — subject,
|
||||
colour, pose, view angle, mood. Prefer a small shared vocabulary across the
|
||||
library over inventing a new word per asset."""
|
||||
|
||||
|
||||
def scan_assets(root):
|
||||
"""Image files under `root`, relative to it, in a stable sorted order."""
|
||||
found = []
|
||||
for folder, dirs, names in os.walk(root):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
for name in names:
|
||||
if os.path.splitext(name)[1].lower() in IMAGE_EXTS:
|
||||
found.append(os.path.relpath(os.path.join(folder, name), root))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def chunk(items, size):
|
||||
for i in range(0, len(items), size):
|
||||
yield items[i : i + size]
|
||||
|
||||
|
||||
class BatchParseError(RuntimeError):
|
||||
"""Claude's reply was not usable JSON.
|
||||
|
||||
Nearly always a reply that ran past its length limit and stopped partway,
|
||||
which a smaller batch fixes — so the caller retries by splitting rather than
|
||||
giving up.
|
||||
"""
|
||||
|
||||
|
||||
def extract_json(text):
|
||||
"""The JSON object out of a reply that may be fenced or prefaced."""
|
||||
start, end = text.find("{"), text.rfind("}")
|
||||
if start == -1 or end <= start:
|
||||
raise BatchParseError("the reply stopped before any complete JSON object")
|
||||
body = text[start : end + 1]
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError as e:
|
||||
raise BatchParseError(f"the reply was cut off or malformed ({e})")
|
||||
|
||||
|
||||
def canonicalize(claude_path, expected):
|
||||
"""Map Claude's returned file path onto one of the paths we actually asked
|
||||
about, tolerating a leading './' or a basename-only answer."""
|
||||
cleaned = (claude_path or "").strip().lstrip("./")
|
||||
if cleaned in expected:
|
||||
return cleaned
|
||||
base = os.path.basename(cleaned).lower()
|
||||
for candidate in expected:
|
||||
if os.path.basename(candidate).lower() == base:
|
||||
return candidate
|
||||
return cleaned
|
||||
|
||||
|
||||
def checkpoint_key(mode, manifest_name, system_prompt, project_notes):
|
||||
"""A fingerprint of this job's settings, so a checkpoint only resumes a
|
||||
matching rerun.
|
||||
|
||||
Deliberately excludes the file list: adding or removing an asset should not
|
||||
throw away a half-finished run, and entries are keyed by path anyway, so
|
||||
stale ones are filtered out when the manifest is assembled.
|
||||
"""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"mode": mode,
|
||||
"manifest_name": manifest_name,
|
||||
"system_prompt": system_prompt,
|
||||
"project_notes": project_notes,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def load_existing_manifest(path):
|
||||
"""A previously written manifest, as (entries by file, project, art_style).
|
||||
|
||||
This is what makes a rerun incremental: anything already described here is
|
||||
not sent to Claude again.
|
||||
"""
|
||||
if not os.path.isfile(path):
|
||||
return {}, None, None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}, None, None
|
||||
|
||||
entries = {}
|
||||
for entry in data.get("assets") or []:
|
||||
if isinstance(entry, dict) and entry.get("file"):
|
||||
entries[entry["file"]] = entry
|
||||
return entries, data.get("project"), data.get("art_style")
|
||||
|
||||
|
||||
def load_checkpoint(root, key):
|
||||
path = os.path.join(root, CHECKPOINT_NAME)
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if data.get("key") == key else None
|
||||
|
||||
|
||||
def save_checkpoint(root, key, project, art_style, done_assets):
|
||||
"""Atomic write, so a crash mid-save can't corrupt the checkpoint."""
|
||||
path = os.path.join(root, CHECKPOINT_NAME)
|
||||
data = {"key": key, "project": project, "art_style": art_style, "done_assets": done_assets}
|
||||
fd, tmp = tempfile.mkstemp(dir=root, prefix=".sf_checkpoint_")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f)
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def clear_checkpoint(root):
|
||||
path = os.path.join(root, CHECKPOINT_NAME)
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
|
||||
|
||||
def build_batch_instruction(mode, batch_files, total_count, project_notes, established, out_dir):
|
||||
"""The task description for ONE batch call — scoped to just its own files."""
|
||||
parts = [
|
||||
f"You are cataloguing {len(batch_files)} file(s) from one batch of a "
|
||||
f"{total_count}-file game asset library. You only need to handle the "
|
||||
f"files listed below — nothing else exists from your point of view."
|
||||
]
|
||||
|
||||
if project_notes.strip():
|
||||
parts.append(f"About this project:\n{project_notes.strip()}")
|
||||
|
||||
listing = "\n".join(f" {name}" for name in batch_files)
|
||||
parts.append(f"Your files for this batch:\n{listing}")
|
||||
|
||||
parts.append(
|
||||
"Open each one with the Read tool before describing it. Work through them "
|
||||
"steadily; do not guess from a filename."
|
||||
)
|
||||
|
||||
if mode == MODE_MANIFEST:
|
||||
parts.append("Do NOT move, rename, copy or delete anything. This is a survey only.")
|
||||
else:
|
||||
where = (
|
||||
f"a new folder at `{out_dir}`, leaving the originals untouched"
|
||||
if mode == MODE_COPY
|
||||
else "the working directory itself"
|
||||
)
|
||||
parts.append(
|
||||
f"Then reorganise ONLY these files into {where}:\n"
|
||||
f"- Group by category, e.g. characters/heroes, characters/enemies, "
|
||||
f"environments, props, ui, vfx.\n"
|
||||
f"- Rename to lowercase_with_underscores, descriptive of the subject, "
|
||||
f"no spaces, no version noise. Keep the original extension.\n"
|
||||
f"- Never discard a file.\n"
|
||||
f"- Use Bash (cp for a copy, mv in place) to do the moving.\n"
|
||||
f"The `file` field of each asset must be its path AFTER reorganising, "
|
||||
f"relative to the library root."
|
||||
)
|
||||
|
||||
if established:
|
||||
project, art_style = established
|
||||
parts.append(
|
||||
f"This library's project name and art style are already settled by an "
|
||||
f"earlier batch — reuse them exactly, do not invent new ones:\n"
|
||||
f' project: "{project}"\n art_style: "{art_style}"'
|
||||
)
|
||||
parts.append(
|
||||
f"Reply with ONLY a JSON object of this shape — no commentary, no code "
|
||||
f"fence, no other top-level keys:\n"
|
||||
f'{{"assets": [{ASSET_SCHEMA_SPEC}]}}\n'
|
||||
f"The assets array must contain exactly one entry per file listed above."
|
||||
)
|
||||
else:
|
||||
parts.append(
|
||||
f"You are establishing this library's project name and art style for "
|
||||
f"the first time — later batches will reuse whatever you choose here, "
|
||||
f"so make `art_style` a precise sentence covering the whole library's "
|
||||
f"visual approach, not just these files.\n"
|
||||
f"Reply with ONLY a JSON object of this shape — no commentary, no code "
|
||||
f"fence:\n"
|
||||
f'{{"project": "<short project name>", "art_style": "<one sentence>", '
|
||||
f'"assets": [{ASSET_SCHEMA_SPEC}]}}\n'
|
||||
f"The assets array must contain exactly one entry per file listed above."
|
||||
)
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
class SFClaudeLibraryBuilder:
|
||||
"""
|
||||
Surveys a folder of game assets and writes a manifest describing each one,
|
||||
so SF Claude Asset Director can choose between them without opening images.
|
||||
|
||||
Processes assets in small batches rather than one session, because a single
|
||||
session covering a large library can blow up in turns and cost with nothing
|
||||
to show for it. A checkpoint records finished batches, so a failed or
|
||||
interrupted run resumes instead of re-paying for work already done.
|
||||
|
||||
Read-only by default. It can also reorganise and rename the files, into a
|
||||
copy or in place, when you ask it to.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"library_path": (
|
||||
"STRING",
|
||||
{"multiline": False, "default": "", "tooltip": "Folder holding the assets to catalogue."},
|
||||
),
|
||||
"mode": (
|
||||
MODES,
|
||||
{
|
||||
"default": MODE_MANIFEST,
|
||||
"tooltip": (
|
||||
"manifest only: nothing is moved or renamed. "
|
||||
"organise into a copy: originals untouched, tidy copy written "
|
||||
"to output_path. organise in place: renames and moves your "
|
||||
"actual files."
|
||||
),
|
||||
},
|
||||
),
|
||||
"output_path": (
|
||||
"STRING",
|
||||
{"multiline": False, "default": "", "tooltip": "Destination folder, for 'organise into a copy' only."},
|
||||
),
|
||||
"project_notes": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"tooltip": (
|
||||
"What the game is, and anything Claude could not infer from "
|
||||
"the images — genre, art direction, naming you prefer."
|
||||
),
|
||||
},
|
||||
),
|
||||
"system_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": DEFAULT_SYSTEM_PROMPT,
|
||||
"tooltip": "How to categorise and describe. Edit to suit your project.",
|
||||
},
|
||||
),
|
||||
"manifest_name": (
|
||||
"STRING",
|
||||
{"default": "manifest.json", "tooltip": "Filename for the manifest, written at the library root."},
|
||||
),
|
||||
"write_manifest": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
"Off: the manifest is only returned as text for you to read "
|
||||
"and save yourself. On: it is written into the library."
|
||||
),
|
||||
},
|
||||
),
|
||||
"update_existing": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": (
|
||||
"On: reuse descriptions already in the manifest and only "
|
||||
"catalogue files that are new, so adding assets costs only "
|
||||
"those assets. Entries whose file has since been deleted are "
|
||||
"dropped. Off: describe every asset again from scratch."
|
||||
),
|
||||
},
|
||||
),
|
||||
"batch_size": (
|
||||
"INT",
|
||||
{
|
||||
"default": 15,
|
||||
"min": 3,
|
||||
"max": 50,
|
||||
"step": 1,
|
||||
"tooltip": (
|
||||
"Assets per Claude call. Keeps each session small enough to "
|
||||
"finish reliably — a failed batch only costs that batch, not "
|
||||
"the whole library, and a rerun resumes rather than restarts."
|
||||
),
|
||||
},
|
||||
),
|
||||
"model": (
|
||||
MODEL_CHOICES,
|
||||
{"default": "default", "tooltip": "Which Claude model does the cataloguing."},
|
||||
),
|
||||
"timeout_seconds": (
|
||||
"INT",
|
||||
{
|
||||
"default": 300,
|
||||
"min": 60,
|
||||
"max": 1800,
|
||||
"step": 30,
|
||||
"tooltip": "Per BATCH, not per library. Budget roughly 5s per asset in the batch.",
|
||||
},
|
||||
),
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFF,
|
||||
"control_after_generate": True,
|
||||
"tooltip": "Not sent to Claude. Change it to force a fresh run.",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("manifest", "report")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "Stillfront/LLM"
|
||||
|
||||
DESCRIPTION = """
|
||||
Claude looks at every asset in a folder and writes a manifest describing each —
|
||||
category, archetype, tags and a one-line description — plus a project-level art
|
||||
style. **SF Claude Asset Director** then searches that manifest instead of
|
||||
opening images.
|
||||
|
||||
Processes **batch_size** assets per Claude call rather than the whole library at
|
||||
once, and tracks progress in a hidden checkpoint file. If a run fails partway,
|
||||
rerunning with the same settings resumes from where it stopped instead of
|
||||
re-paying for batches already done.
|
||||
|
||||
With **update_existing** on (the default), rerunning against a library that
|
||||
already has a manifest only catalogues files that are new — so adding assets
|
||||
costs just those assets. Entries whose file has since been deleted are dropped.
|
||||
|
||||
Defaults to **manifest only**, which touches nothing: you get the JSON as text to
|
||||
read and save yourself. Switch `write_manifest` on to have it saved into the
|
||||
library, or change `mode` to let Claude reorganise and rename the files too.
|
||||
|
||||
Budget about 5 seconds per asset — it opens every image.
|
||||
"""
|
||||
|
||||
def build(
|
||||
self,
|
||||
library_path,
|
||||
mode,
|
||||
output_path,
|
||||
project_notes,
|
||||
system_prompt,
|
||||
manifest_name,
|
||||
write_manifest,
|
||||
update_existing,
|
||||
batch_size,
|
||||
model,
|
||||
timeout_seconds,
|
||||
seed,
|
||||
):
|
||||
root = os.path.expanduser(library_path.strip())
|
||||
if not root or not os.path.isdir(root):
|
||||
raise ValueError(f"library_path is not a folder: {library_path!r}")
|
||||
|
||||
out_dir = os.path.expanduser(output_path.strip())
|
||||
if mode == MODE_COPY:
|
||||
if not out_dir:
|
||||
raise ValueError("output_path is required for 'organise into a copy'.")
|
||||
if os.path.abspath(out_dir).startswith(os.path.abspath(root) + os.sep):
|
||||
raise ValueError("output_path must sit outside library_path.")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
binary = find_claude_binary()
|
||||
if not binary:
|
||||
raise RuntimeError(
|
||||
"Claude Code CLI not found. Install it from https://claude.com/claude-code "
|
||||
"and sign in, then restart ComfyUI."
|
||||
)
|
||||
|
||||
assets = scan_assets(root)
|
||||
if not assets:
|
||||
raise ValueError(f"No image files found in {root}")
|
||||
|
||||
manifest_root = out_dir if mode == MODE_COPY else root
|
||||
manifest_path = os.path.join(manifest_root, manifest_name)
|
||||
|
||||
done_by_file = {}
|
||||
project = art_style = None
|
||||
|
||||
# An existing manifest is the durable record of what is already described,
|
||||
# so a rerun only pays for assets that are genuinely new. Skipped for the
|
||||
# copy mode, where the manifest holds post-rename paths that cannot be
|
||||
# matched against a scan of the untouched source folder.
|
||||
reused = 0
|
||||
if update_existing and mode != MODE_COPY:
|
||||
existing, prev_project, prev_style = load_existing_manifest(manifest_path)
|
||||
if existing:
|
||||
done_by_file.update(existing)
|
||||
project, art_style = prev_project, prev_style
|
||||
reused = sum(1 for f in assets if f in existing)
|
||||
gone = len(existing) - reused
|
||||
print(
|
||||
f"[SF Claude Library Builder] Found {manifest_name}: reusing "
|
||||
f"{reused} existing description(s)"
|
||||
+ (f", dropping {gone} for deleted file(s)" if gone else "")
|
||||
+ "."
|
||||
)
|
||||
|
||||
# A checkpoint from an interrupted run is newer than the manifest, so it wins.
|
||||
key = checkpoint_key(mode, manifest_name, system_prompt, project_notes)
|
||||
checkpoint = load_checkpoint(root, key)
|
||||
if checkpoint:
|
||||
for entry in checkpoint.get("done_assets", []):
|
||||
if entry.get("file"):
|
||||
done_by_file[entry["file"]] = entry
|
||||
project = checkpoint.get("project") or project
|
||||
art_style = checkpoint.get("art_style") or art_style
|
||||
print(
|
||||
f"[SF Claude Library Builder] Resuming an interrupted run — "
|
||||
f"{len(done_by_file)}/{len(assets)} asset(s) already catalogued."
|
||||
)
|
||||
|
||||
todo = sum(1 for f in assets if f not in done_by_file)
|
||||
if todo == 0:
|
||||
print("[SF Claude Library Builder] Nothing new to catalogue.")
|
||||
else:
|
||||
print(f"[SF Claude Library Builder] {todo} asset(s) to catalogue.")
|
||||
|
||||
# Claude only ever reads images and replies with JSON; the node writes the
|
||||
# manifest itself, and the Write tool is never offered. Bash is needed
|
||||
# only to move/rename files in the organise modes.
|
||||
tools = "Read" if mode == MODE_MANIFEST else "Read,Bash"
|
||||
batches = list(chunk(assets, max(1, batch_size)))
|
||||
|
||||
total_cost = 0.0
|
||||
started = time.time()
|
||||
|
||||
def fail(message):
|
||||
"""Stop the run, leaving finished batches safely checkpointed."""
|
||||
save_checkpoint(root, key, project, art_style, list(done_by_file.values()))
|
||||
raise RuntimeError(
|
||||
f"{message} {len(done_by_file)}/{len(assets)} asset(s) are saved in a "
|
||||
f"checkpoint — rerun with the same settings to resume rather than "
|
||||
f"start over."
|
||||
)
|
||||
|
||||
def run_batch(files, label):
|
||||
"""One Claude call. Returns (parsed reply, cost, turns)."""
|
||||
established = (project, art_style) if project and art_style else None
|
||||
command = [
|
||||
binary,
|
||||
"-p",
|
||||
build_batch_instruction(
|
||||
mode, files, len(assets), project_notes, established, out_dir
|
||||
),
|
||||
"--output-format",
|
||||
"json",
|
||||
"--allowedTools",
|
||||
tools,
|
||||
]
|
||||
if system_prompt.strip():
|
||||
command += ["--append-system-prompt", system_prompt.strip()]
|
||||
if model != "default":
|
||||
command += ["--model", model]
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
fail(f"Batch {label} did not finish within {timeout_seconds}s.")
|
||||
|
||||
if completed.returncode != 0:
|
||||
detail = (completed.stderr or completed.stdout or "").strip()[:500]
|
||||
fail(f"Batch {label} failed (exit {completed.returncode}). {detail}")
|
||||
|
||||
try:
|
||||
payload = json.loads(completed.stdout)
|
||||
except json.JSONDecodeError:
|
||||
fail(f"Batch {label}: unreadable response: {completed.stdout.strip()[:300]}")
|
||||
|
||||
if payload.get("is_error"):
|
||||
fail(f"Batch {label} reported an error: {payload.get('result', 'unknown')}.")
|
||||
|
||||
# May raise BatchParseError, which catalogue() retries by splitting.
|
||||
data = extract_json(payload.get("result", ""))
|
||||
return data, payload.get("total_cost_usd") or 0.0, payload.get("num_turns", "?")
|
||||
|
||||
def catalogue(files, label):
|
||||
"""Catalogue `files`, halving and retrying if the reply comes back cut off.
|
||||
|
||||
Each piece that succeeds is merged and checkpointed immediately, so a
|
||||
later failure never costs work already paid for.
|
||||
"""
|
||||
nonlocal project, art_style, total_cost
|
||||
|
||||
try:
|
||||
data, cost, turns = run_batch(files, label)
|
||||
except BatchParseError as exc:
|
||||
if len(files) == 1:
|
||||
fail(
|
||||
f"Batch {label}: {exc}. This is a single asset "
|
||||
f"({files[0]}), so it cannot be split any further."
|
||||
)
|
||||
half = len(files) // 2
|
||||
print(
|
||||
f"[SF Claude Library Builder] Batch {label}: {exc}. "
|
||||
f"Retrying as two batches of {half} and {len(files) - half}."
|
||||
)
|
||||
catalogue(files[:half], f"{label}a")
|
||||
catalogue(files[half:], f"{label}b")
|
||||
return
|
||||
|
||||
total_cost += cost
|
||||
if project is None:
|
||||
project = data.get("project", "Untitled project")
|
||||
art_style = data.get("art_style", "")
|
||||
|
||||
expected = set(files)
|
||||
for entry in data.get("assets") or []:
|
||||
raw = entry.get("file")
|
||||
if not raw:
|
||||
continue
|
||||
entry["file"] = canonicalize(raw, expected)
|
||||
done_by_file[entry["file"]] = entry
|
||||
|
||||
save_checkpoint(root, key, project, art_style, list(done_by_file.values()))
|
||||
|
||||
print(
|
||||
f"[SF Claude Library Builder] Batch {label} done: {turns} turns, "
|
||||
f"${cost:.2f}. Running total: {len(done_by_file)}/{len(assets)} "
|
||||
f"asset(s), ${total_cost:.2f}."
|
||||
)
|
||||
|
||||
skipped = [f for f in files if f not in done_by_file]
|
||||
if skipped:
|
||||
print(
|
||||
f"[SF Claude Library Builder] Batch {label}: {len(skipped)} file(s) "
|
||||
f"came back undescribed; a rerun will pick them up."
|
||||
)
|
||||
|
||||
for batch_num, batch_files in enumerate(batches, start=1):
|
||||
remaining = [f for f in batch_files if f not in done_by_file]
|
||||
if not remaining:
|
||||
continue # fully covered by the checkpoint already
|
||||
|
||||
print(
|
||||
f"[SF Claude Library Builder] Batch {batch_num}/{len(batches)} "
|
||||
f"({len(remaining)} asset(s))..."
|
||||
)
|
||||
catalogue(remaining, f"{batch_num}/{len(batches)}")
|
||||
|
||||
elapsed = time.time() - started
|
||||
|
||||
# Assemble the final manifest in the library's original scan order.
|
||||
final_assets = [done_by_file[f] for f in assets if f in done_by_file]
|
||||
manifest_data = {
|
||||
"schema": SCHEMA_VERSION,
|
||||
"project": project or "Untitled project",
|
||||
"art_style": art_style or "",
|
||||
"generated": time.strftime("%Y-%m-%d"),
|
||||
"assets": final_assets,
|
||||
}
|
||||
manifest_text = json.dumps(manifest_data, indent=2)
|
||||
|
||||
if write_manifest:
|
||||
os.makedirs(manifest_root, exist_ok=True)
|
||||
with open(manifest_path, "w", encoding="utf-8") as f:
|
||||
f.write(manifest_text)
|
||||
|
||||
clear_checkpoint(root)
|
||||
|
||||
counts = {}
|
||||
for entry in final_assets:
|
||||
counts[entry.get("category", "?")] = counts.get(entry.get("category", "?"), 0) + 1
|
||||
|
||||
newly_done = max(0, len(final_assets) - reused)
|
||||
report = "\n".join(
|
||||
[
|
||||
f"mode: {mode}",
|
||||
f"scanned: {len(assets)} file(s) in {root}",
|
||||
f"in manifest: {len(final_assets)} asset(s)",
|
||||
f"reused: {reused} already described, {newly_done} newly catalogued",
|
||||
"categories: " + (", ".join(f"{k} x{v}" for k, v in sorted(counts.items())) or "none"),
|
||||
f"art style: {manifest_data['art_style'] or '(not set)'}",
|
||||
"manifest: " + (manifest_path if write_manifest else "returned as text, not saved"),
|
||||
f"took: {elapsed:.0f}s, ${total_cost:.2f} total",
|
||||
]
|
||||
)
|
||||
print(
|
||||
f"[SF Claude Library Builder] Done: {len(final_assets)}/{len(assets)} asset(s), "
|
||||
f"{elapsed:.0f}s, ${total_cost:.2f}."
|
||||
)
|
||||
|
||||
if len(final_assets) < len(assets):
|
||||
report += f"\nWARNING: {len(assets) - len(final_assets)} file(s) were not catalogued."
|
||||
|
||||
return (manifest_text, report)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SFClaudeLibraryBuilder": SFClaudeLibraryBuilder}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFClaudeLibraryBuilder": "SF Claude Library Builder"}
|
||||
@@ -0,0 +1,235 @@
|
||||
# ABOUTME: Tripo P2 image-to-3D through the WaveSpeed API, billed to a WaveSpeed account
|
||||
# ABOUTME: Emits GLB/FBX File3D outputs compatible with ComfyUI's Preview 3D and Save nodes
|
||||
|
||||
import os
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from .wavespeed_api.client import WaveSpeedClient
|
||||
from .wavespeed_api.utils import tensor2images
|
||||
|
||||
try:
|
||||
from comfy_api.latest import Types
|
||||
|
||||
File3D = Types.File3D
|
||||
except ImportError: # older ComfyUI without 3D file types
|
||||
File3D = None
|
||||
|
||||
ENDPOINT = "/api/v3/tripo3d/p2/image-to-3d"
|
||||
RESULT_PATH = "/api/v3/predictions/{id}/result"
|
||||
|
||||
TEXTURE_LEVELS = ["standard", "fast", "detailed", "extreme", "none"]
|
||||
TEXTURE_VERSIONS = ["v3.5-20260815", "v3.0-20250812", "v2.5-20250123"]
|
||||
ORIENTATION_UNSET = "unset (recommended)"
|
||||
ORIENTATIONS = [ORIENTATION_UNSET, "+x", "-x", "+y", "-y"]
|
||||
TERMINAL_FAILURES = {"failed", "cancelled", "timeout", "deleted"}
|
||||
|
||||
|
||||
def build_payload(image_url, quad, face_limit, texture, pbr, texture_version,
|
||||
delight, auto_size, export_uv, export_orientation, model_seed, texture_seed):
|
||||
"""The request body. Unused options are omitted rather than sent as null,
|
||||
as WaveSpeed's documentation asks."""
|
||||
if face_limit != -1:
|
||||
ceiling = 25000 if quad else 50000
|
||||
if not 48 <= face_limit <= ceiling:
|
||||
raise ValueError(
|
||||
f"face_limit must be -1 (adaptive) or between 48 and {ceiling}"
|
||||
f"{' — quad meshes cap at 25,000' if quad else ''}."
|
||||
)
|
||||
if texture == "fast" and texture_version != "v3.5-20260815":
|
||||
raise ValueError("texture 'fast' requires texture_version v3.5-20260815.")
|
||||
|
||||
textured = texture != "none"
|
||||
payload = {
|
||||
"image": image_url,
|
||||
"quad": quad,
|
||||
"texture": textured,
|
||||
# PBR implies textures, so it is switched off along with them.
|
||||
"pbr": bool(pbr and textured),
|
||||
"auto_size": auto_size,
|
||||
"export_uv": export_uv,
|
||||
}
|
||||
if textured:
|
||||
payload["texture_quality"] = texture
|
||||
payload["texture_version"] = texture_version
|
||||
payload["delight"] = delight
|
||||
if face_limit != -1:
|
||||
payload["face_limit"] = face_limit
|
||||
if export_orientation != ORIENTATION_UNSET:
|
||||
payload["export_orientation"] = export_orientation
|
||||
if model_seed != -1:
|
||||
payload["model_seed"] = model_seed
|
||||
if texture_seed != -1 and textured:
|
||||
payload["texture_seed"] = texture_seed
|
||||
return payload
|
||||
|
||||
|
||||
def pick_model_url(outputs):
|
||||
"""The model file URL out of a result's `outputs`, which may be a list or a string."""
|
||||
candidates = outputs if isinstance(outputs, list) else [outputs]
|
||||
urls = [u for u in candidates if isinstance(u, str) and u.startswith("http")]
|
||||
for url in urls:
|
||||
if urlparse(url).path.lower().endswith((".glb", ".fbx")):
|
||||
return url
|
||||
if urls:
|
||||
return urls[0]
|
||||
raise RuntimeError(f"No model URL in the result: {outputs!r}")
|
||||
|
||||
|
||||
def check_interrupted():
|
||||
try:
|
||||
import comfy.model_management as mm
|
||||
except ImportError:
|
||||
return
|
||||
mm.throw_exception_if_processing_interrupted()
|
||||
|
||||
|
||||
def output_dir():
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
base = folder_paths.get_output_directory()
|
||||
except ImportError:
|
||||
base = os.getcwd()
|
||||
path = os.path.join(base, "3d")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
class SFWaveSpeedTripoP2ImageTo3D:
|
||||
"""
|
||||
Tripo P2 image-to-3D via WaveSpeed — the same model as ComfyUI's partner
|
||||
node, billed to your WaveSpeed account instead of ComfyUI credits.
|
||||
|
||||
Tripo returns FBX when quad topology is on and GLB when it is off, so the
|
||||
matching output is populated and the other is empty.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"client": ("WAVESPEED_AI_API_CLIENT",),
|
||||
"image": ("IMAGE", {"tooltip": "Reference image. Only the first frame of a batch is used."}),
|
||||
"quad": (
|
||||
"BOOLEAN",
|
||||
{"default": False, "tooltip": "Quad topology instead of triangles. Output becomes FBX."},
|
||||
),
|
||||
"face_limit": (
|
||||
"INT",
|
||||
{
|
||||
"default": -1,
|
||||
"min": -1,
|
||||
"max": 50000,
|
||||
"step": 100,
|
||||
"tooltip": "Target face count. -1 lets Tripo choose. 48-50,000, or up to 25,000 with quad.",
|
||||
},
|
||||
),
|
||||
"texture": (
|
||||
TEXTURE_LEVELS,
|
||||
{"default": "standard", "tooltip": "'none' gives untextured geometry. Higher levels cost more."},
|
||||
),
|
||||
"pbr": ("BOOLEAN", {"default": True, "tooltip": "PBR material maps. Ignored when texture is 'none'."}),
|
||||
},
|
||||
"optional": {
|
||||
"texture_version": (
|
||||
TEXTURE_VERSIONS,
|
||||
{"default": TEXTURE_VERSIONS[0], "tooltip": "'fast' texture requires v3.5."},
|
||||
),
|
||||
"delight": (
|
||||
"BOOLEAN",
|
||||
{"default": True, "tooltip": "Strip lighting baked into the source image before texturing."},
|
||||
),
|
||||
"auto_size": ("BOOLEAN", {"default": False, "tooltip": "Scale the model to real-world metres."}),
|
||||
"export_uv": ("BOOLEAN", {"default": True, "tooltip": "Generate UVs. Off is faster and smaller."}),
|
||||
"export_orientation": (
|
||||
ORIENTATIONS,
|
||||
{
|
||||
"default": ORIENTATION_UNSET,
|
||||
"tooltip": (
|
||||
"Forward axis. Leave unset if you will rig, retexture or convert the "
|
||||
"model — Tripo warns those steps can silently come out mis-rotated."
|
||||
),
|
||||
},
|
||||
),
|
||||
"model_seed": (
|
||||
"INT",
|
||||
{"default": -1, "min": -1, "max": 2147483647, "tooltip": "-1 for random geometry."},
|
||||
),
|
||||
"texture_seed": (
|
||||
"INT",
|
||||
{"default": -1, "min": -1, "max": 2147483647, "tooltip": "-1 for random texturing."},
|
||||
),
|
||||
"timeout_seconds": (
|
||||
"INT",
|
||||
{"default": 900, "min": 60, "max": 3600, "step": 30},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("FILE_3D_GLB", "FILE_3D_FBX", "STRING", "STRING")
|
||||
RETURN_NAMES = ("GLB", "FBX", "model_file", "model_url")
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"Populated when quad is off.",
|
||||
"Populated when quad is on.",
|
||||
"Absolute path of the downloaded model in ComfyUI's output/3d folder.",
|
||||
"WaveSpeed CDN link to the model.",
|
||||
)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "Stillfront/WaveSpeed"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def generate(self, client, image, quad, face_limit, texture, pbr,
|
||||
texture_version=TEXTURE_VERSIONS[0], delight=True, auto_size=False,
|
||||
export_uv=True, export_orientation=ORIENTATION_UNSET,
|
||||
model_seed=-1, texture_seed=-1, timeout_seconds=900):
|
||||
api = WaveSpeedClient(api_key=client["api_key"])
|
||||
|
||||
image_url = api.upload_file(tensor2images(image)[0])
|
||||
payload = build_payload(
|
||||
image_url, quad, face_limit, texture, pbr, texture_version, delight,
|
||||
auto_size, export_uv, export_orientation, model_seed, texture_seed,
|
||||
)
|
||||
|
||||
task = api.post(ENDPOINT, payload)
|
||||
task_id = task.get("id")
|
||||
if not task_id:
|
||||
raise RuntimeError(f"WaveSpeed did not return a task id: {task}")
|
||||
print(f"[SF Tripo P2] Submitted {task_id} (quad={quad}, faces={face_limit}, texture={texture}).")
|
||||
|
||||
started = time.time()
|
||||
while True:
|
||||
check_interrupted()
|
||||
result = api.get(RESULT_PATH.format(id=task_id))
|
||||
status = result.get("status")
|
||||
if status == "completed":
|
||||
break
|
||||
if status in TERMINAL_FAILURES:
|
||||
raise RuntimeError(f"Tripo P2 task {status}: {result.get('error') or result}")
|
||||
if time.time() - started > timeout_seconds:
|
||||
raise RuntimeError(f"Tripo P2 task {task_id} still '{status}' after {timeout_seconds}s.")
|
||||
time.sleep(3)
|
||||
|
||||
url = pick_model_url(result.get("outputs"))
|
||||
ext = os.path.splitext(urlparse(url).path)[1].lower() or (".fbx" if quad else ".glb")
|
||||
path = os.path.join(output_dir(), f"sf_tripo_p2_{task_id}{ext}")
|
||||
|
||||
response = requests.get(url, timeout=300)
|
||||
response.raise_for_status()
|
||||
with open(path, "wb") as f:
|
||||
f.write(response.content)
|
||||
|
||||
print(f"[SF Tripo P2] Done in {time.time() - started:.0f}s -> {path}")
|
||||
|
||||
glb = fbx = None
|
||||
if File3D is not None:
|
||||
model = File3D(path)
|
||||
glb = model if ext == ".glb" else None
|
||||
fbx = model if ext == ".fbx" else None
|
||||
return (glb, fbx, path, url)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SFWaveSpeedTripoP2ImageTo3D": SFWaveSpeedTripoP2ImageTo3D}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedTripoP2ImageTo3D": "SF WaveSpeed Tripo P2 Image to 3D"}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-sf-nodes"
|
||||
description = "Custom nodes for ComfyUI including LLM Chat with support for Claude, Gemini, and OpenAI models, WaveSpeed AI image/video generation, VertexAI integration, and workflow utilities"
|
||||
version = "1.2.0"
|
||||
version = "1.5.0"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"requests>=2.31.0",
|
||||
|
||||
Reference in New Issue
Block a user