7 Commits
11 changed files with 375 additions and 58 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main
- master
paths:
- "pyproject.toml"
permissions:
issues: write
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'SatadalAI' }}
steps:
- name: Check out code
uses: actions/checkout@v4
with:
submodules: true
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@v1
with:
## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+14 -2
View File
@@ -1,7 +1,7 @@
# SATA Utility Node Suite for ComfyUI
**Author:** Satadal
**Version:** 1.2.0
**Version:** 1.5.0
**License:** MIT
**Repository:** https://github.com/SatadalAI/SATA_UtilityNode
@@ -18,6 +18,7 @@ It includes:
- **Resolution Machine:** Quick selection of model-specific image resolutions for popular models.
- **Save Image w/Metadata:** saving images with meta data and with different file formats.
- **Prompt Autocomplete:** Text widget with autocompletion, random selection, and preview capabilities.
- **Latent Machine:** Advanced noise generation for creating empty latents initialized with specific noise patterns instead of standard Gaussian noise.
---
@@ -30,7 +31,6 @@ It includes:
- **CPU-based Rescaling:** Efficient resizing using OpenCV.
- **Supersampling:** Optional detail preservation for downscaling/fractional resizing.
- **Rounding Modulus:** Ensures output dimensions align with model/UI constraints.
- **Dual Output:** Returns both the processed image and a help/documentation link.
### 💬 Prompt Machine
@@ -70,6 +70,18 @@ It includes:
- **Global Mode:** Optional setting to enable autocompletion on ALL text widgets in ComfyUI.
- **Custom Data:** Add your own `.csv` or `.json` files to the `prompt` folder to extend the library.
### 🌌 Latent Machine
- **Advanced Noise Generation:** Create empty latents initialized with specific noise patterns instead of standard Gaussian noise.
- **Noise Types:**
- **Gaussian (White):** Standard noise, good for sharp details.
- **Pink (1/f):** Balanced noise, great for photorealism and nature.
- **Brown (1/f²):** Smoother noise, ideal for backgrounds and anime.
- **Perlin:** Cloud-like structure, perfect for landscapes and fluids.
- **Plasma:** Very smooth, abstract blobs for sci-fi and alien terrains.
- **Model Support:** Select between **4-channel** (SD1.5, SDXL, etc.) and **16-channel** (Flux, SD3) latents.
- **Reproducibility:** Full seed support for consistent noise generation.
## 📦 Installation
+3
View File
@@ -6,6 +6,7 @@ from .nodes.resolution_machine import Resolution_Machine
from .nodes.save_image_metadata import ImageSaveWithMetadata
from .nodes.upscale_machine import Upscale_Machine
from .nodes.prompt_autocomplete import PromptAutocomplete
from .nodes.latent_machine import Latent_Machine
# A dictionary that maps class names to class objects
NODE_CLASS_MAPPINGS = {
@@ -14,6 +15,7 @@ NODE_CLASS_MAPPINGS = {
"ImageSaveWithMetadata": ImageSaveWithMetadata,
"Upscale_Machine": Upscale_Machine,
"PromptAutocomplete": PromptAutocomplete,
"Latent_Machine": Latent_Machine,
}
# A dictionary that maps display names to class names
@@ -23,6 +25,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"Save Image w/Metadata": "ImageSaveWithMetadata",
"Upscale Machine": "Upscale_Machine",
"Prompt Autocomplete": "PromptAutocomplete",
"Latent Machine": "Latent_Machine",
}
# A dictionary that contains web directory information
+38 -38
View File
@@ -3,7 +3,7 @@ import { app } from "../../../scripts/app.js";
// Register extension
app.registerExtension({
name: "SATA_UtilityNode.PromptAutocomplete",
async setup() {
// Add settings
app.ui.settings.addSetting({
@@ -59,7 +59,7 @@ app.registerExtension({
const contentData = await contentResp.json();
this.snippetsCache[file] = contentData.items || [];
}
console.log("[PromptAutocomplete] Loaded snippets:", Object.keys(this.snippetsCache));
} catch (err) {
console.error("[PromptAutocomplete] Failed to load snippets:", err);
}
@@ -77,12 +77,12 @@ app.registerExtension({
// Hook into the input element creation/usage
// NOTE: ComfyUI's customtext widget often creates a textarea.
// We can try to find the textarea in the DOM if it's active, or hook the input callback.
// A more robust way for ComfyUI widgets:
// The widget has an `inputEl` property if it's a DOM widget.
// Let's try to hook the `inputEl` if it exists, or wait for it.
// Helper to setup the listener
function setupListener(input) {
if (input.dataset.hasAutocomplete) return;
@@ -99,7 +99,7 @@ app.registerExtension({
// If not, it might be created later. We can hook `onInput` or similar,
// but `inputEl` is usually created when the node is drawn or added.
// Let's poll or hook draw? Hooking draw is safer to catch it.
widget.draw = function(ctx, node, widgetWidth, y, widgetHeight) {
widget.draw = function (ctx, node, widgetWidth, y, widgetHeight) {
const result = originalDraw?.apply(this, arguments);
if (this.inputEl) {
setupListener(this.inputEl);
@@ -110,7 +110,7 @@ app.registerExtension({
},
// --- Autocomplete Logic ---
popup: null,
active: false,
currentCategory: null, // null = showing categories, string = showing items
@@ -127,7 +127,7 @@ app.registerExtension({
// Simple check: is the character before cursor the trigger?
// Or are we in a "trigger session"?
// If we just typed the trigger
if (e.data === trigger) {
this.showPopup(input, cursor);
@@ -144,11 +144,11 @@ app.registerExtension({
}
const query = val.substring(lastTriggerIndex + 1, cursor);
// Check if we have a category selected (format: #category:query)
// But wait, the plan said: Select Category -> Then Items.
// So maybe we just filter categories first.
if (this.currentCategory) {
// We are inside a category
// Check if the query contains the category prefix?
@@ -161,17 +161,17 @@ app.registerExtension({
// But how do we represent that in text?
// Maybe we don't change text, just internal state?
// Standard way: #category:item
// Let's parse the text to see where we are.
// If text is "#cat", we filter categories.
// If text is "#cat:ite", we filter items in "cat".
const parts = query.split(":");
if (parts.length > 1) {
// We have a category selected
const catName = parts[0];
const itemQuery = parts.slice(1).join(":"); // rest is item query
// Check if catName is valid
if (this.snippetsCache[catName + ".csv"] || this.snippetsCache[catName + ".json"] || this.snippetsCache[catName]) {
// It's a valid category (roughly).
@@ -184,7 +184,7 @@ app.registerExtension({
}
}
}
// If no colon, or invalid category, we are filtering categories
this.currentCategory = null;
this.updateCategories(query);
@@ -192,17 +192,17 @@ app.registerExtension({
} else {
// No category selected yet
// Check for colon
const parts = query.split(":");
if (parts.length > 1) {
// Maybe user typed "style:" manually?
const catName = parts[0];
let key = Object.keys(this.snippetsCache).find(k => k.replace(/\.(csv|json)$/, "") === catName);
if (key) {
this.currentCategory = key;
this.updateItems(parts.slice(1).join(":"));
return;
}
}
const parts = query.split(":");
if (parts.length > 1) {
// Maybe user typed "style:" manually?
const catName = parts[0];
let key = Object.keys(this.snippetsCache).find(k => k.replace(/\.(csv|json)$/, "") === catName);
if (key) {
this.currentCategory = key;
this.updateItems(parts.slice(1).join(":"));
return;
}
}
this.updateCategories(query);
}
@@ -289,10 +289,10 @@ app.registerExtension({
updateItems(query) {
if (!this.currentCategory) return;
const items = this.snippetsCache[this.currentCategory] || [];
// Filter
let matches = items.filter(i => i.toLowerCase().includes(query.toLowerCase()));
// Add "Random" option at top
this.filteredItems = [
{ type: "random", value: "RANDOM", display: "🎲 Random" },
@@ -328,7 +328,7 @@ app.registerExtension({
if (idx === this.selectedIndex) {
div.style.backgroundColor = "#444";
div.style.color = "#fff";
// Show preview if it's a long item
if (item.type === "item" && item.value.length > 30) {
this.showPreview(item.value);
@@ -341,10 +341,10 @@ app.registerExtension({
this.selectedIndex = idx;
this.selectItem(this.activeInput); // We need reference to input
};
this.popup.appendChild(div);
});
// Keep active input ref
const activeElement = document.activeElement;
if (activeElement && activeElement.tagName === "TEXTAREA") {
@@ -370,7 +370,7 @@ app.registerExtension({
});
document.body.appendChild(this.previewBox);
}
this.previewBox.textContent = text;
const rect = this.popup.getBoundingClientRect();
this.previewBox.style.left = `${rect.right + 5}px`;
@@ -402,16 +402,16 @@ app.registerExtension({
this.currentCategory = item.value;
// Don't close popup, just update to items
// We need to update the input value first
// Replace everything from trigger to cursor with trigger + textToInsert
// Replace everything from trigger to cursor with trigger + textToInsert
const before = val.substring(0, lastTriggerIndex + 1); // includes trigger
const after = val.substring(cursor);
input.value = before + textToInsert + after;
// Move cursor
const newCursor = lastTriggerIndex + 1 + textToInsert.length;
input.setSelectionRange(newCursor, newCursor);
// Trigger input event manually to update state
this.handleInput({ data: null }, input, null);
return;
@@ -430,13 +430,13 @@ app.registerExtension({
// Replace
const before = val.substring(0, lastTriggerIndex); // remove trigger
const after = val.substring(cursor);
// If we are replacing a category query like #style:something, we need to find the start of #
// Actually, if we are in item mode, the text is "#style:query".
// We want to replace the whole "#style:query" with "textToInsert".
input.value = before + textToInsert + after;
// Move cursor
const newCursor = before.length + textToInsert.length;
input.setSelectionRange(newCursor, newCursor);
+39 -4
View File
@@ -20,7 +20,7 @@ app.registerExtension({
node.widgets.splice(index, 1);
// Add proper dropdown
const nameWidget = node.addWidget("combo", "name", "None", () => {}, {
const nameWidget = node.addWidget("combo", "name", "None", () => { }, {
values: ["None"]
});
@@ -34,7 +34,7 @@ app.registerExtension({
const resp = await fetch(`/sata/prompt_machine/names?csv=${encodeURIComponent(csvValue)}`);
const data = await resp.json();
namesConfig = data.names || ["None"];
console.log("[PromptMachineFrontend] Loaded names:", namesConfig);
refreshNames();
} catch (err) {
console.error("[PromptMachineFrontend] Failed to load names:", err);
@@ -50,17 +50,52 @@ app.registerExtension({
nameWidget.value = available[0]; // safe fallback
}
// Force update previews for the new name
updatePreviews(nameWidget.value);
app.graph.change();
}
// Hook CSV change
const oldCsvCallback = csvWidget.callback;
csvWidget.callback = function(value) {
csvWidget.callback = function (value) {
if (oldCsvCallback) oldCsvCallback(value);
loadNames(value); // refresh dynamically
};
// --- Preview Logic ---
const posPreview = node.widgets.find(w => w.name === "positive_preview");
const negPreview = node.widgets.find(w => w.name === "negative_preview");
const notePreview = node.widgets.find(w => w.name === "note_preview");
async function updatePreviews(name) {
const csv = csvWidget.value;
if (!csv || !name || name === "None") return;
try {
const resp = await fetch(`/sata/prompt_machine/get?csv=${encodeURIComponent(csv)}&name=${encodeURIComponent(name)}`);
const data = await resp.json();
if (posPreview) posPreview.value = data.positive || "";
if (negPreview) negPreview.value = data.negative || "";
if (notePreview) notePreview.value = data.note || "";
app.graph.change();
} catch (err) {
console.error("[PromptMachineFrontend] Failed to load preview:", err);
}
}
// Hook Name change
const oldNameCallback = nameWidget.callback;
nameWidget.callback = function (value) {
if (oldNameCallback) oldNameCallback(value);
updatePreviews(value);
};
// Initial sync
loadNames(csvWidget.value);
loadNames(csvWidget.value).then(() => {
updatePreviews(nameWidget.value);
});
}
});
+53 -4
View File
@@ -25,7 +25,7 @@ app.registerExtension({
try {
const resp = await fetch("/SATA_UtilityNode/resolutions_config");
resolutionsConfig = await resp.json();
console.log("[Resolution_Machine] Loaded config:", resolutionsConfig);
} catch (err) {
console.error("[Resolution_Machine] Failed to load config:", err);
}
@@ -46,7 +46,7 @@ app.registerExtension({
resolutionWidget.value = available[0];
}
console.log(`[Resolution_Machine] Resolutions for ${selectedModel}:`, available);
app.graph.change();
applyResolution(); // auto-apply width/height on refresh
}
@@ -77,21 +77,70 @@ app.registerExtension({
app.graph.change();
}
// --- Preview Logic ---
const previewWidget = node.widgets.find(w => w.name === "dimension_preview");
function updatePreview() {
if (!previewWidget) return;
const w = widthWidget.value;
const h = heightWidget.value;
previewWidget.value = `${w} x ${h}`;
}
// --- Hook model change ---
const oldModelCallback = modelWidget.callback;
modelWidget.callback = function(value) {
modelWidget.callback = function (value) {
if (oldModelCallback) oldModelCallback(value);
refreshResolutions(value);
};
// --- Hook resolution change ---
const oldResolutionCallback = resolutionWidget.callback;
resolutionWidget.callback = function(value) {
resolutionWidget.callback = function (value) {
if (oldResolutionCallback) oldResolutionCallback(value);
applyResolution();
};
// Hook width/height changes for preview
const oldWidthCallback = widthWidget.callback;
widthWidget.callback = function (value) {
if (oldWidthCallback) oldWidthCallback(value);
updatePreview();
};
const oldHeightCallback = heightWidget.callback;
heightWidget.callback = function (value) {
if (oldHeightCallback) oldHeightCallback(value);
updatePreview();
};
// --- Add Swap Button ---
node.addWidget("button", "Swap Dimensions", null, () => {
const w = widthWidget.value;
const h = heightWidget.value;
// Swap values
widthWidget.value = h;
heightWidget.value = w;
// Force "Custom (manual)" to prevent preset overwrite
if (resolutionWidget.value !== "Custom (manual)") {
// Check if "Custom (manual)" exists in options
if (!resolutionWidget.options.values.includes("Custom (manual)")) {
resolutionWidget.options.values.push("Custom (manual)");
}
resolutionWidget.value = "Custom (manual)";
// Unlock widgets since we are now in custom mode
widthWidget.disabled = false;
heightWidget.disabled = false;
}
updatePreview();
app.graph.change();
});
// --- Initial sync ---
refreshResolutions(modelWidget.value);
updatePreview();
}
});
+148
View File
@@ -0,0 +1,148 @@
import torch
import torch.fft
import random
class Latent_Machine:
"""
Creates an empty latent initialized with Power-Law (1/f) noise, Perlin-like noise, or Plasma noise.
Supports 4-channel (SD1.5/SDXL) and 16-channel (Flux/SD3) latents.
"""
def __init__(self):
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"width": ("INT", {"default": 512, "min": 64, "max": 8192, "step": 8}),
"height": ("INT", {"default": 512, "min": 64, "max": 8192, "step": 8}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 64}),
"model_type": ([
"SD1.5/SDXL/PicsArt/Kolors/Auraflow (4ch)",
"Flux/Qwen/SD3/Lumina (16ch)"
],),
"noise_type": ([
"Gaussian (White): Sharp Architecture, Text, intricate mechanics",
"Pink (1/f): Photorealism, Portraits, Nature",
"Brown (1/f²): Anime, Digital Art, Bokeh/Backgrounds",
"Perlin : Fantasy landscapes, Fluids, Hair/Fabric",
"Plasma : Sci-Fi, Abstract, Alien terrain"
],),
"intensity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.1}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
}
}
RETURN_TYPES = ("LATENT",)
FUNCTION = "generate_noise"
CATEGORY = "SATA_UtilityNode"
def generate_noise(self, width, height, batch_size, model_type, noise_type, intensity, seed):
# Set seed for reproducibility
torch.manual_seed(seed)
random.seed(seed)
# Determine channels based on model type
if "16ch" in model_type:
c = 16
else:
c = 4
# Latent dimensions (compressed by 8)
h = height // 8
w = width // 8
# Generate base noise
if "Gaussian" in noise_type:
noise = torch.randn((batch_size, c, h, w), device=self.device)
elif "Perlin" in noise_type:
# Multi-Octave Value Noise Approximation
noise = self.generate_perlin_approx(batch_size, c, h, w)
else:
# FFT-based Power Law Noise (Pink, Brown, Plasma)
if "Pink" in noise_type:
alpha = 1.0
elif "Brown" in noise_type:
alpha = 2.0
elif "Plasma" in noise_type:
alpha = 3.0 # Very smooth
else:
alpha = 0.0 # Fallback to white
noise = self.generate_power_law_noise(batch_size, c, h, w, alpha)
# Normalize standard deviation to match expected latent variance (approx 1.0 for standard Gaussian)
# This ensures intensity works consistently across different noise types
current_std = noise.std()
if current_std > 1e-6:
noise = noise / current_std
# Apply intensity
noise = noise * intensity
return ({"samples": noise},)
def generate_power_law_noise(self, batch_size, c, h, w, alpha):
# Generate White Noise (Standard Gaussian)
white_noise = torch.randn((batch_size, c, h, w), device=self.device)
# Convert to Frequency Domain (FFT)
fft_noise = torch.fft.fft2(white_noise)
# Create Frequency Grid
y = torch.fft.fftfreq(h, device=self.device)
x = torch.fft.fftfreq(w, device=self.device)
dy, dx = torch.meshgrid(y, x, indexing='ij')
# Calculate distance from center (frequency magnitude)
# Add epsilon to avoid division by zero at DC component
frequency_magnitude = torch.sqrt(dy**2 + dx**2) + 1e-8
# Apply Power Law Scaling: Amplitude ~ 1 / f^(alpha/2)
scale = 1.0 / (frequency_magnitude ** (alpha / 2.0))
# Zero out the DC component (mean brightness) to keep it neutral
scale[0, 0] = 0
fft_structured = fft_noise * scale
# Convert back to Spatial Domain (Inverse FFT)
structured_noise = torch.fft.ifft2(fft_structured).real
return structured_noise
def generate_perlin_approx(self, batch_size, c, h, w):
"""
Generates a Perlin-like noise by blending upsampled noise from multiple octaves.
This is faster than calculating actual Perlin noise per pixel on the CPU/GPU.
"""
noise = torch.zeros((batch_size, c, h, w), device=self.device)
# Octaves: scale down, generate noise, scale up
octaves = [2, 4, 8, 16]
weights = [0.5, 0.25, 0.125, 0.0625] # 1/f amplitude falloff
total_weight = 0
for scale, weight in zip(octaves, weights):
# Calculate small dimensions
sh, sw = max(1, h // scale), max(1, w // scale)
# Generate random noise at this scale
small_noise = torch.randn((batch_size, c, sh, sw), device=self.device)
# Upsample to full size using bicubic interpolation for smoothness
upsampled = torch.nn.functional.interpolate(
small_noise, size=(h, w), mode='bicubic', align_corners=False
)
noise += upsampled * weight
total_weight += weight
# Add a bit of fine-grained white noise for texture
noise += torch.randn((batch_size, c, h, w), device=self.device) * 0.05
return noise
+8 -3
View File
@@ -107,6 +107,11 @@ class Prompt_Machine:
}),
# make 'name' a free text field (STRING) — avoids list validation problems
"name": ("STRING", {"default": default_name}),
},
"optional": {
"positive_preview": ("STRING", {"multiline": True, "forceInput": False, "default": ""}),
"negative_preview": ("STRING", {"multiline": True, "forceInput": False, "default": ""}),
"note_preview": ("STRING", {"multiline": True, "forceInput": False, "default": ""}),
}
}
@@ -115,10 +120,10 @@ class Prompt_Machine:
FUNCTION = "get_prompts"
CATEGORY = "SATA_UtilityNode"
def get_prompts(self, csv_file, name):
print(f"[PromptMachine] get_prompts - csv_file={csv_file}, name={name}")
def get_prompts(self, csv_file, name, positive_preview=None, negative_preview=None, note_preview=None):
pos, neg, note = read_prompt_row(csv_file, name)
print(f"[PromptMachine] get_prompts -> pos_len={len(pos)}, neg_len={len(neg)}, note_len={len(note)}")
return (pos, neg, note)
+5 -2
View File
@@ -40,6 +40,9 @@ class Resolution_Machine:
"resolution": (resolutions, {"default": default_resolution}),
"custom_width": ("INT", {"default": 512, "min": 1, "max": 8192}),
"custom_height": ("INT", {"default": 512, "min": 1, "max": 8192}),
},
"optional": {
"dimension_preview": ("STRING", {"multiline": False, "forceInput": False, "default": ""}),
}
}
@@ -48,7 +51,7 @@ class Resolution_Machine:
FUNCTION = "get_resolution"
CATEGORY = "SATA_UtilityNode"
def get_resolution(self, model, resolution, custom_width, custom_height):
def get_resolution(self, model, resolution, custom_width, custom_height, dimension_preview=None):
config = load_config()
if model not in config:
@@ -67,7 +70,7 @@ class Resolution_Machine:
entry = resolutions[resolution]
width, height = entry["width"], entry["height"]
print(f"[{NODE_NAME}] Model={model}, Resolution={resolution}, Output=({width}, {height})")
return (width, height)
+38 -2
View File
@@ -1,7 +1,43 @@
[project]
name = "sata_utilitynode"
description = "A collection of utility nodes including image upscale,touchpad support and CSV-driven prompt machine for ComfyUI."
version = "1.0.0"
license = {file = "LICENSE"}
# classifiers = [
# # For OS-independent nodes (works on all operating systems)
# "Operating System :: OS Independent",
#
# # OR for OS-specific nodes, specify the supported systems:
# "Operating System :: Microsoft :: Windows", # Windows specific
# "Operating System :: POSIX :: Linux", # Linux specific
# "Operating System :: MacOS", # macOS specific
#
# # GPU Accelerator support. Pick the ones that are supported by your extension.
# "Environment :: GPU :: NVIDIA CUDA", # NVIDIA CUDA support
# "Environment :: GPU :: AMD ROCm", # AMD ROCm support
# "Environment :: GPU :: Intel Arc", # Intel Arc support
# "Environment :: NPU :: Huawei Ascend", # Huawei Ascend support
# "Environment :: GPU :: Apple Metal", # Apple Metal support
# ]
dependencies = ["opencv-python>=4.5.0", "spandrel", "nvidia-tensorrt>=8.0", "onnx>=1.12.0", "onnxruntime-gpu>=1.12.0"]
[project.urls]
Repository = "https://github.com/SatadalAI/SATA_UtilityNode"
# Used by Comfy Registry https://registry.comfy.org
Documentation = "https://github.com/SatadalAI/SATA_UtilityNode/wiki"
"Bug Tracker" = "https://github.com/SatadalAI/SATA_UtilityNode/issues"
[tool.comfy]
PublisherId = ""
DisplayName = "SATA_UtilityNode"
Icon = ""
includes = []
# "requires-comfyui" = ">=1.0.0" # ComfyUI version compatibility
name = "comfyui_sata_utilitynode"
description = "ComfyUI utility nodes collection providing features like prompt machine, resolution machine, and upscale machine to enhance workflow efficiency."
version = "1.2.0"
version = "1.5.0"
license = "MIT"
[project.urls]
@@ -11,4 +47,4 @@ Repository = "https://github.com/SatadalAI/SATA_UtilityNode"
[tool.comfy]
PublisherId = "SatadalAI"
DisplayName = "SATA_UtilityNode"
Icon = ""
Icon = ""
+1 -3
View File
@@ -1,5 +1,3 @@
opencv-python>=4.5.0
spandrel
nvidia-tensorrt>=8.0
onnx>=1.12.0
onnxruntime-gpu>=1.12.0
piexif