Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98b7a853cf | ||
|
|
703711f07c |
@@ -375,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"}
|
||||
|
||||
@@ -187,7 +187,17 @@ class SFWaveSpeedTripoP2ImageTo3D:
|
||||
model_seed=-1, texture_seed=-1, timeout_seconds=900):
|
||||
api = WaveSpeedClient(api_key=client["api_key"])
|
||||
|
||||
image_url = api.upload_file(tensor2images(image)[0])
|
||||
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,
|
||||
|
||||
+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.5.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