Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98b7a853cf | ||
|
|
703711f07c | ||
|
|
3f3d8581df | ||
|
|
309aaf216b |
@@ -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,7 +86,6 @@ 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
|
||||
@@ -287,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 |
|
||||
@@ -337,6 +375,25 @@ and skips row 2. Each prompt triggers a separate pass through the downstream gra
|
||||
|
||||
---
|
||||
|
||||
## Interface improvements
|
||||
|
||||
Not nodes — small fixes to ComfyUI's own interface that come with the pack.
|
||||
|
||||
### Folder picker in the Save workflow dialog
|
||||
|
||||
ComfyUI's **Save** / **Save As** dialog normally wants the whole path typed by hand —
|
||||
`Stillfront/NewMoon/My Workflow` — to save into a subfolder. With this pack installed, the dialog
|
||||
also shows your workflow folders as a tree: click a folder, type just the name, confirm.
|
||||
|
||||
- **+ New folder** adds a folder at the selected level; it's created on disk when you save into it
|
||||
- The dialog opens on the folder of the workflow you're editing, already selected
|
||||
- Only the Save dialogs get the picker — Rename and Export, which reuse the same prompt, are left alone
|
||||
- ComfyUI still does the actual saving, so tab tracking and overwrite warnings behave exactly as before
|
||||
|
||||
Turn it off under **Settings → Stillfront → Workflows**.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
|
||||
+65
-34
@@ -1,14 +1,54 @@
|
||||
import configparser
|
||||
import json
|
||||
import os
|
||||
|
||||
from .wavespeed_api.client import WaveSpeedClient
|
||||
|
||||
PACK_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CUSTOM_NODES_DIR = os.path.dirname(PACK_DIR)
|
||||
|
||||
|
||||
def _from_ini(path):
|
||||
if not os.path.isfile(path):
|
||||
return ""
|
||||
config = configparser.ConfigParser()
|
||||
config.read(path)
|
||||
return config.get("API", "api_key", fallback="").strip()
|
||||
|
||||
|
||||
def _from_official_pack():
|
||||
"""The key saved by WaveSpeed's own ComfyUI pack, so one key serves both."""
|
||||
path = os.path.join(CUSTOM_NODES_DIR, "wavespeed-comfyui", "config.json")
|
||||
if not os.path.isfile(path):
|
||||
return ""
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return str(json.load(f).get("api_key") or "").strip()
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_api_key(widget_value):
|
||||
"""Returns (key, where it came from). First non-empty source wins."""
|
||||
sources = [
|
||||
("the api_key field on this node", lambda: (widget_value or "").strip()),
|
||||
("comfyui-sf-nodes/config.ini", lambda: _from_ini(os.path.join(PACK_DIR, "config.ini"))),
|
||||
("the WAVESPEED_API_KEY environment variable", lambda: os.environ.get("WAVESPEED_API_KEY", "").strip()),
|
||||
("the official WaveSpeed pack's config.json", _from_official_pack),
|
||||
]
|
||||
for label, read in sources:
|
||||
key = read()
|
||||
if key:
|
||||
return key, label
|
||||
return "", None
|
||||
|
||||
|
||||
class SFWaveSpeedClient:
|
||||
"""
|
||||
WaveSpeed AI API Client Node
|
||||
|
||||
This node creates a client for connecting to the WaveSpeed AI API.
|
||||
Leave api_key empty to reuse a key saved elsewhere — see the tooltip.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -18,7 +58,19 @@ class SFWaveSpeedClient:
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"api_key": ("STRING", {"multiline": False, "default": ""}),
|
||||
"api_key": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"default": "",
|
||||
"tooltip": (
|
||||
"Leave empty (recommended) to use, in order: comfyui-sf-nodes/config.ini, "
|
||||
"the WAVESPEED_API_KEY environment variable, or the key already saved by "
|
||||
"WaveSpeed's official ComfyUI pack. A key typed here is saved into the "
|
||||
"workflow file, so it travels with any workflow you share."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -34,44 +86,23 @@ class SFWaveSpeedClient:
|
||||
Create a WaveSpeed AI API client
|
||||
|
||||
Args:
|
||||
api_key: WaveSpeed AI API key
|
||||
api_key: WaveSpeed AI API key; empty to look it up elsewhere
|
||||
|
||||
Returns:
|
||||
WaveSpeedAPI: WaveSpeed AI API client
|
||||
dict holding the resolved key and where it came from
|
||||
"""
|
||||
wavespeed_api_key = ""
|
||||
|
||||
if api_key == "":
|
||||
# Try to read from config.ini
|
||||
try:
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.dirname(current_dir)
|
||||
config_path = os.path.join(parent_dir, "config.ini")
|
||||
|
||||
if os.path.exists(config_path):
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
wavespeed_api_key = config.get("API", "api_key", fallback="")
|
||||
|
||||
if not wavespeed_api_key:
|
||||
# Try environment variable
|
||||
wavespeed_api_key = os.environ.get("WAVESPEED_API_KEY", "")
|
||||
|
||||
if not wavespeed_api_key:
|
||||
raise ValueError(
|
||||
"API_KEY is empty. Please provide an API key or set it in config.ini"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Unable to find API_KEY: {str(e)}")
|
||||
|
||||
else:
|
||||
wavespeed_api_key = api_key
|
||||
|
||||
return ({"api_key": wavespeed_api_key},)
|
||||
key, source = resolve_api_key(api_key)
|
||||
if not key:
|
||||
raise ValueError(
|
||||
"No WaveSpeed API key found. Enter one on this node, add it to "
|
||||
"comfyui-sf-nodes/config.ini, set WAVESPEED_API_KEY, or save it in "
|
||||
"WaveSpeed's official ComfyUI pack."
|
||||
)
|
||||
# Never log the key itself — only where it was found.
|
||||
print(f"[SF WaveSpeed Client] Using the API key from {source}.")
|
||||
return ({"api_key": key, "source": source},)
|
||||
|
||||
|
||||
# Node registration
|
||||
NODE_CLASS_MAPPINGS = {"SFWaveSpeedClient": SFWaveSpeedClient}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedClient": "SF WaveSpeed Client"}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
# 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"])
|
||||
|
||||
try:
|
||||
image_url = api.upload_file(tensor2images(image)[0])
|
||||
except Exception as e:
|
||||
if "401" in str(e):
|
||||
source = client.get("source", "the SF WaveSpeed Client node")
|
||||
raise RuntimeError(
|
||||
f"WaveSpeed rejected the API key, which came from {source}. "
|
||||
f"Check it on wavespeed.ai under API Keys, or clear the api_key field "
|
||||
f"on SF WaveSpeed Client to fall back to a key saved elsewhere."
|
||||
) from e
|
||||
raise
|
||||
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.4.0"
|
||||
version = "1.6.1"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"requests>=2.31.0",
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* SF Save Folder Picker — adds a folder browser to ComfyUI's "Save workflow"
|
||||
* dialog, so a workflow can be saved into a subfolder by clicking the folder
|
||||
* and typing only the name, instead of typing the whole path.
|
||||
*
|
||||
* It augments the native dialog rather than replacing it: ComfyUI still does
|
||||
* the actual saving, so tab tracking and overwrite confirmation are unchanged.
|
||||
* Picking a folder only rewrites the path in the dialog's own input.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
const SETTING_ID = "Stillfront.SaveFolderPicker";
|
||||
const SAVE_COMMANDS = ["Comfy.SaveWorkflow", "Comfy.SaveWorkflowAs"];
|
||||
const ROOT = "workflows";
|
||||
|
||||
// True only while a Save command is running, so the same prompt dialog used
|
||||
// for renaming or exporting is left alone.
|
||||
let savePending = false;
|
||||
|
||||
function enabled() {
|
||||
try {
|
||||
return app.extensionManager?.setting?.get(SETTING_ID) !== false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFolders() {
|
||||
const res = await api.fetchApi(`/v2/userdata?path=${encodeURIComponent(ROOT)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`folder list failed (${res.status})`);
|
||||
}
|
||||
const entries = await res.json();
|
||||
return entries
|
||||
.filter((e) => e.type === "directory")
|
||||
.map((e) => e.path.replace(/^workflows\/?/, ""))
|
||||
.filter((p) => p && !p.split("/").some((part) => part.startsWith(".")))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
||||
}
|
||||
|
||||
function el(tag, style = "", text = "") {
|
||||
const node = document.createElement(tag);
|
||||
if (style) node.style.cssText = style;
|
||||
if (text) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function splitPath(value) {
|
||||
const i = value.lastIndexOf("/");
|
||||
return i === -1 ? { folder: "", name: value } : { folder: value.slice(0, i), name: value.slice(i + 1) };
|
||||
}
|
||||
|
||||
function setInput(input, folder) {
|
||||
const { name } = splitPath(input.value);
|
||||
input.value = folder ? `${folder}/${name}` : name;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.focus();
|
||||
// Select just the name, ready to be typed over.
|
||||
const start = input.value.length - name.length;
|
||||
input.setSelectionRange(start, input.value.length);
|
||||
}
|
||||
|
||||
function buildPicker(input, folders) {
|
||||
const expanded = new Set();
|
||||
let selected = splitPath(input.value).folder;
|
||||
|
||||
// Open the branch leading to the folder the dialog started in.
|
||||
selected.split("/").reduce((acc, part) => {
|
||||
const path = acc ? `${acc}/${part}` : part;
|
||||
if (part) expanded.add(path);
|
||||
return path;
|
||||
}, "");
|
||||
|
||||
const box = el(
|
||||
"div",
|
||||
"border:1px solid rgba(128,128,128,.35);border-radius:6px;margin:4px 0 8px;"
|
||||
);
|
||||
const header = el(
|
||||
"div",
|
||||
"display:flex;align-items:center;justify-content:space-between;padding:6px 8px;" +
|
||||
"font-size:12px;opacity:.75;border-bottom:1px solid rgba(128,128,128,.25);"
|
||||
);
|
||||
header.appendChild(el("span", "", "Save into folder"));
|
||||
const newBtn = el(
|
||||
"button",
|
||||
"background:none;border:1px solid rgba(128,128,128,.4);border-radius:4px;" +
|
||||
"padding:2px 8px;font-size:12px;color:inherit;cursor:pointer;",
|
||||
"+ New folder"
|
||||
);
|
||||
newBtn.type = "button";
|
||||
header.appendChild(newBtn);
|
||||
box.appendChild(header);
|
||||
|
||||
const list = el("div", "max-height:260px;overflow-y:auto;padding:4px 0;font-size:13px;");
|
||||
box.appendChild(list);
|
||||
|
||||
const hasChildren = (path) => folders.some((f) => f.startsWith(`${path}/`));
|
||||
const childrenOf = (parent) =>
|
||||
folders.filter((f) => {
|
||||
const { folder } = splitPath(f);
|
||||
return folder === parent;
|
||||
});
|
||||
|
||||
function row(path, depth) {
|
||||
const label = path ? splitPath(path).name : `${ROOT} (top level)`;
|
||||
const r = el(
|
||||
"div",
|
||||
`display:flex;align-items:center;gap:4px;padding:3px 8px 3px ${8 + depth * 16}px;` +
|
||||
"cursor:pointer;border-radius:4px;user-select:none;" +
|
||||
(path === selected ? "background:rgba(66,133,244,.28);" : "")
|
||||
);
|
||||
const caret = el("span", "width:14px;display:inline-block;text-align:center;opacity:.7;");
|
||||
if (path && hasChildren(path)) {
|
||||
caret.textContent = expanded.has(path) ? "▾" : "▸";
|
||||
caret.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
expanded.has(path) ? expanded.delete(path) : expanded.add(path);
|
||||
render();
|
||||
});
|
||||
}
|
||||
r.appendChild(caret);
|
||||
r.appendChild(el("span", "", `📁 ${label}`));
|
||||
r.addEventListener("mouseenter", () => {
|
||||
if (path !== selected) r.style.background = "rgba(128,128,128,.15)";
|
||||
});
|
||||
r.addEventListener("mouseleave", () => {
|
||||
if (path !== selected) r.style.background = "";
|
||||
});
|
||||
r.addEventListener("click", () => {
|
||||
selected = path;
|
||||
setInput(input, path);
|
||||
render();
|
||||
});
|
||||
return r;
|
||||
}
|
||||
|
||||
function addBranch(parent, depth) {
|
||||
for (const child of childrenOf(parent)) {
|
||||
list.appendChild(row(child, depth));
|
||||
if (expanded.has(child)) addBranch(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
list.replaceChildren(row("", 0));
|
||||
addBranch("", 1);
|
||||
}
|
||||
|
||||
newBtn.addEventListener("click", () => {
|
||||
const where = selected ? `inside "${selected}"` : "at the top level";
|
||||
const name = window.prompt(`New folder name (${where}):`);
|
||||
const clean = (name || "").trim().replace(/[\\/]+/g, "-");
|
||||
if (!clean) return;
|
||||
const path = selected ? `${selected}/${clean}` : clean;
|
||||
// The folder is created on disk when the workflow is saved into it.
|
||||
if (!folders.includes(path)) folders.push(path);
|
||||
folders.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
||||
if (selected) expanded.add(selected);
|
||||
selected = path;
|
||||
setInput(input, path);
|
||||
render();
|
||||
});
|
||||
|
||||
render();
|
||||
return box;
|
||||
}
|
||||
|
||||
async function inject(dialog) {
|
||||
if (dialog.dataset.sfFolderPicker) return;
|
||||
dialog.dataset.sfFolderPicker = "1";
|
||||
|
||||
const input = dialog.querySelector("input");
|
||||
if (!input) return;
|
||||
|
||||
let picker;
|
||||
try {
|
||||
picker = buildPicker(input, await fetchFolders());
|
||||
} catch (e) {
|
||||
picker = el("div", "font-size:12px;opacity:.7;margin:4px 0;", `Folder list unavailable: ${e.message}`);
|
||||
}
|
||||
input.parentElement.insertBefore(picker, input);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Stillfront.SaveFolderPicker",
|
||||
|
||||
settings: [
|
||||
{
|
||||
id: SETTING_ID,
|
||||
category: ["Stillfront", "Workflows", "Folder picker in Save dialog"],
|
||||
name: "Show a folder browser in the Save workflow dialog",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
|
||||
async setup() {
|
||||
const commands = app.extensionManager?.command?.commands || [];
|
||||
for (const id of SAVE_COMMANDS) {
|
||||
const command = commands.find((c) => c.id === id);
|
||||
if (!command) continue;
|
||||
const original = command.function;
|
||||
command.function = async function () {
|
||||
savePending = true;
|
||||
try {
|
||||
return await original.apply(this, arguments);
|
||||
} finally {
|
||||
savePending = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
new MutationObserver(() => {
|
||||
if (!savePending || !enabled()) return;
|
||||
const dialog = document.querySelector(".prompt-dialog-content:not([data-sf-folder-picker])");
|
||||
if (dialog) inject(dialog);
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user