diff --git a/README.md b/README.md
index 08121e0..83cd42e 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# SATA Utility Node Suite for ComfyUI
**Author:** Satadal
-**Version:** 1.5.0
+**Version:** 2.0
**License:** MIT
**Repository:** https://github.com/SatadalAI/SATA_UtilityNode
@@ -13,7 +13,7 @@ SATA Utility Node Suite is a collection of custom nodes for [ComfyUI](https://gi
It includes:
- **Upscale Machine:** High-quality model-based image upscaling with Frequency-Split SR and Blue Noise injection.
-- **Prompt Machine (Six-Slot):** Professional structured prompting with JSON output support.
+- **Prompt Machine (Eight-Category Framework):** Professional structured prompting with JSON output support.
- **Prompt Style Machine:** Easy prompt selection from a CSV for positive/negative prompt workflows.
- **Touchpad Pan & Zoom:** Seamless navigation for large images in the ComfyUI preview panel.
- **Resolution Machine:** Quick selection of model-specific image resolutions with intelligent Megapixel scaling.
@@ -35,12 +35,12 @@ It includes:
- **CPU-based Rescaling:** Efficient resizing using OpenCV.
- **Rounding Modulus:** Ensures output dimensions seamlessly align with UNet architectural constraints.
-### 💬 Prompt Machine (Six-Slot Framework)
+### 💬 Prompt Machine (Eight-Category Framework)
-- **Structured Prompting:** Dedicated input boxes for `subject`, `style`, `lighting`, `composition`, `mood`, and `technical` details to prevent "word salad".
+- **Structured Prompting:** Dedicated input boxes for `subject`, `pose_and_expression`, `secondary_subject`, `background_and_environment`, `lighting`, `style`, `creative_hook`, and `technical` details following an inside-out spatial sequence.
- **JSON Output Mode:** Toggle `json_output` to compile the slots into a strict JSON string, perfect for piping into LLM rewriting nodes or API pipelines.
- **Autocomplete Ready:** Fully integrated with the autocomplete system—just type `#` in any slot to trigger the smart tag popup!
-- **Natural Language & Comma Separated:** Dynamically formats outputs for FLUX (Natural Language) or SDXL (Comma Separated).
+- **Universal Model Compatibility:** Formats naturally for FLUX, SD3.5, SDXL, SD 1.5, PixArt, and LLM expansion pipelines.
### 💬 Prompt Style Machine (Legacy)
diff --git a/example_workflows/Sample_Workflow.json b/example_workflows/Sample_Workflow.json
index 5d2391d..99454b7 100644
--- a/example_workflows/Sample_Workflow.json
+++ b/example_workflows/Sample_Workflow.json
@@ -665,16 +665,7 @@
"flags": {},
"order": 7,
"mode": 0,
- "inputs": [
- {
- "name": "technical",
- "type": "STRING",
- "widget": {
- "name": "technical"
- },
- "link": 117
- }
- ],
+ "inputs": [],
"outputs": [
{
"name": "prompt",
@@ -694,11 +685,13 @@
},
"widgets_values": [
"a lady walking down the street with her little daughter in a rainy day. both are wearing multicolored dress.",
+ "mother has smiling expression on her face and daughter has sprakling eyes with a cute smile.",
+ "the little girl has jumped to a stagnent water. mother is holding one hand of her daughter. mother has a side bag on her shoulder and a closed umbrella holding in her hand. ",
"a modern photograph of a rainy day.",
"gloomy daylight.",
- "the little girl has jumped to a stagnent water. mother is holding one hand of her daughter. mother has a side bag on her shoulder and a closed umbrella holding in her hand. ",
- "mother has smiling expression on her face and daughter has sprakling eyes with a cute smile.",
- "",
+ "Cinematic 35mm film photography, fine grain, shallow depth of field. Deep navy blues and warm amber tones.",
+ "puddles reflecting neon streetlights and rainy sky.",
+ "Shot on 85mm f/1.4 lens, eye-level medium close-up, sharp focus on subject, soft background bokeh.",
false
]
},
@@ -721,9 +714,7 @@
{
"name": "prompt",
"type": "STRING",
- "links": [
- 117
- ]
+ "links": []
}
],
"properties": {
@@ -916,14 +907,6 @@
11,
0,
"STRING"
- ],
- [
- 117,
- 68,
- 0,
- 67,
- 0,
- "STRING"
]
],
"groups": [
diff --git a/js/preview_machine.js b/js/preview_machine.js
index 778a1ad..0570cf9 100644
--- a/js/preview_machine.js
+++ b/js/preview_machine.js
@@ -8,6 +8,96 @@ const SAVE_MACHINE_CLASS = "Save_Machine";
// Track hover state
const hoveredNodeIds = new Set();
+// Inject stylesheet for Nodes 2.0 (Vue DOM) and Legacy V1 DOM preview masking
+function injectHiderCSS() {
+ if (document.getElementById("sata-preview-hider-styles")) return;
+ const style = document.createElement("style");
+ style.id = "sata-preview-hider-styles";
+ style.textContent = `
+ /* Mask only the image container itself in-place */
+ .sata-image-masked {
+ position: relative !important;
+ min-height: 100px !important;
+ background-color: #1a1a1a !important;
+ border-radius: 6px !important;
+ border: 1px dashed #444 !important;
+ box-sizing: border-box !important;
+ overflow: hidden !important;
+ transition: background-color 0.15s ease;
+ }
+
+ /* Hide all img/canvas/video children inside the masked image container */
+ .sata-image-masked img,
+ .sata-image-masked canvas,
+ .sata-image-masked video {
+ opacity: 0 !important;
+ visibility: hidden !important;
+ pointer-events: none !important;
+ transition: opacity 0.15s ease;
+ }
+
+ /* Center placeholder text cleanly over the masked image container */
+ .sata-image-masked::after {
+ content: "Hidden (Hover to Reveal)" !important;
+ position: absolute !important;
+ top: 50% !important;
+ left: 50% !important;
+ transform: translate(-50%, -50%) !important;
+ color: #888 !important;
+ font-size: 13px !important;
+ font-weight: bold !important;
+ font-family: sans-serif !important;
+ text-align: center !important;
+ white-space: nowrap !important;
+ pointer-events: none !important;
+ opacity: 1 !important;
+ visibility: visible !important;
+ z-index: 10 !important;
+ }
+
+ /* NATIVE CSS HOVER REVEAL: Works across Nodes 2.0 Vue Nodes & Legacy LiteGraph DOM */
+ .sata-image-masked:hover img,
+ .sata-image-masked:hover canvas,
+ .sata-image-masked:hover video,
+ [data-node-id]:hover .sata-image-masked img,
+ [data-node-id]:hover .sata-image-masked canvas,
+ [data-node-id]:hover .sata-image-masked video,
+ .comfy-vue-node:hover .sata-image-masked img,
+ .comfy-vue-node:hover .sata-image-masked canvas,
+ .comfy-vue-node:hover .sata-image-masked video,
+ .node:hover .sata-image-masked img,
+ .node:hover .sata-image-masked canvas,
+ .node:hover .sata-image-masked video,
+ .lgraphnode:hover .sata-image-masked img,
+ .lgraphnode:hover .sata-image-masked canvas,
+ .lgraphnode:hover .sata-image-masked video {
+ opacity: 1 !important;
+ visibility: visible !important;
+ pointer-events: auto !important;
+ }
+
+ .sata-image-masked:hover::after,
+ [data-node-id]:hover .sata-image-masked::after,
+ .comfy-vue-node:hover .sata-image-masked::after,
+ .node:hover .sata-image-masked::after,
+ .lgraphnode:hover .sata-image-masked::after {
+ display: none !important;
+ opacity: 0 !important;
+ visibility: hidden !important;
+ }
+
+ .sata-image-masked:hover,
+ [data-node-id]:hover .sata-image-masked,
+ .comfy-vue-node:hover .sata-image-masked,
+ .node:hover .sata-image-masked,
+ .lgraphnode:hover .sata-image-masked {
+ background-color: transparent !important;
+ border: none !important;
+ }
+ `;
+ document.head.appendChild(style);
+}
+
// Helper to get settings for both ComfyUI V1 and V2
function getSettingValue(id, defaultValue) {
if (app.extensionManager && app.extensionManager.setting) {
@@ -35,6 +125,7 @@ app.registerExtension({
],
async setup() {
+ injectHiderCSS();
setupGlobalMouseTracker();
},
@@ -52,6 +143,7 @@ app.registerExtension({
});
function isTargetNode(node) {
+ if (!node || !node.comfyClass) return false;
return node.comfyClass === "PreviewImage" ||
node.comfyClass === "SaveImage" ||
node.comfyClass === PREVIEW_MACHINE_CLASS ||
@@ -60,26 +152,25 @@ function isTargetNode(node) {
function shouldHideNode(node) {
const globalHide = getSettingValue(GLOBAL_HIDE_SETTING_ID, false);
+ if (globalHide) return true;
- if (node.comfyClass === PREVIEW_MACHINE_CLASS) {
+ const hideWidget = node.widgets?.find(w => w.name === "hide_preview");
+ if (hideWidget) {
+ return !!hideWidget.value;
+ }
+
+ if (node.comfyClass === PREVIEW_MACHINE_CLASS || node.comfyClass === SAVE_MACHINE_CLASS) {
return true;
}
- if (node.comfyClass === SAVE_MACHINE_CLASS) {
- const w = node.widgets?.find(w => w.name === "hide_preview");
- if (w) {
- if (w.value === true) return true;
- if (w.value === false && !globalHide) return false;
- }
- return true;
- }
-
- return globalHide;
+ return false;
}
function isMouseOverNode(node) {
- if (!app.canvas || !app.canvas.graph_mouse) return false;
+ if (!node || !node.pos || !node.size) return false;
+ if (app.canvas && app.canvas.node_over === node) return true;
+ if (!app.canvas || !app.canvas.graph_mouse) return false;
const mouse = app.canvas.graph_mouse;
const x = mouse[0];
const y = mouse[1];
@@ -90,6 +181,89 @@ function isMouseOverNode(node) {
y <= (node.pos[1] + node.size[1]);
}
+function isNodeHovered(node) {
+ if (!node) return false;
+ if (app.canvas && app.canvas.node_over === node) return true;
+ if (hoveredNodeIds.has(node.id)) return true;
+ return isMouseOverNode(node);
+}
+
+function shouldHideNow(node) {
+ const shouldHide = shouldHideNode(node);
+ const hovered = isNodeHovered(node);
+ return shouldHide && !hovered;
+}
+
+function getNodeDOMElement(node) {
+ if (!node) return null;
+ if (node.DOMelement) return node.DOMelement;
+ if (node.element) return node.element;
+
+ const id = String(node.id);
+ return document.querySelector(`[data-node-id="${id}"]`) ||
+ document.querySelector(`[data-id="${id}"]`) ||
+ document.getElementById(`node-${id}`) ||
+ document.querySelector(`.node-${id}`) ||
+ document.querySelector(`.comfy-vue-node[data-id="${id}"]`);
+}
+
+function hasImages(node) {
+ if (!node) return false;
+ if (node.imgs && node.imgs.length > 0) return true;
+
+ const el = getNodeDOMElement(node);
+ if (el) {
+ const img = el.querySelector("img, canvas.comfy-img-preview, .comfy-img-preview, .node-image-preview, .comfy-vue-image-preview");
+ if (img) return true;
+ }
+
+ if (node.widgets) {
+ for (const w of node.widgets) {
+ if (w.type === "image" || w.name === "preview" || w.imageEl || w.element?.querySelector?.("img")) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+function getImageContainers(node) {
+ const containers = new Set();
+ const el = getNodeDOMElement(node);
+
+ if (el) {
+ // Find existing image preview containers inside the node DOM
+ const previewEls = el.querySelectorAll(
+ ".comfy-img-preview, .node-image-preview, .comfy-vue-image-preview, .node-widget-image, [class*='ImagePreview'], [class*='image-preview'], [class*='node-image']"
+ );
+ previewEls.forEach(p => containers.add(p));
+
+ // Direct img elements parent wrappers
+ const imgs = el.querySelectorAll("img, canvas.comfy-img-preview");
+ imgs.forEach(img => {
+ if (img.parentElement && img.parentElement !== el) {
+ containers.add(img.parentElement);
+ } else {
+ containers.add(img);
+ }
+ });
+ }
+
+ if (node.widgets) {
+ for (const widget of node.widgets) {
+ if (widget.element && (widget.type === "image" || widget.name === "preview" || widget.element.querySelector("img"))) {
+ containers.add(widget.element);
+ }
+ if (widget.imageEl) {
+ containers.add(widget.imageEl.parentElement || widget.imageEl);
+ }
+ }
+ }
+
+ return Array.from(containers);
+}
+
function setupGlobalMouseTracker() {
let rafPending = false;
@@ -112,9 +286,11 @@ function setupGlobalMouseTracker() {
}
changed = true;
}
+
+ syncNodeDOMVisibility(node);
}
- if (changed) {
+ if (changed && app.graph) {
app.graph.setDirtyCanvas(true, true);
}
};
@@ -137,112 +313,159 @@ function setupGlobalMouseTracker() {
});
}
+function syncNodeDOMVisibility(node) {
+ if (!node) return;
+
+ const el = getNodeDOMElement(node);
+ if (el && !el._sata_mouse_bound) {
+ el._sata_mouse_bound = true;
+ el.addEventListener("mouseenter", () => {
+ hoveredNodeIds.add(node.id);
+ syncNodeDOMVisibility(node);
+ if (app.graph) app.graph.setDirtyCanvas(true, true);
+ });
+ el.addEventListener("mouseleave", () => {
+ hoveredNodeIds.delete(node.id);
+ syncNodeDOMVisibility(node);
+ if (app.graph) app.graph.setDirtyCanvas(true, true);
+ });
+ }
+
+ // Apply DOM masking when images exist AND hide_preview is active
+ const nodeHasImages = hasImages(node);
+ const hide = nodeHasImages && shouldHideNode(node);
+
+ const containers = getImageContainers(node);
+ containers.forEach(container => {
+ if (hide) {
+ container.classList.add("sata-image-masked");
+ } else {
+ container.classList.remove("sata-image-masked");
+ }
+ });
+}
+
function setupNodeHider(node) {
- if (node._sata_hider_v6) return;
- node._sata_hider_v6 = true;
+ if (node._sata_hider_v12) return;
+ node._sata_hider_v12 = true;
- // Function to check if node should be hidden right now
- const shouldHideNow = () => {
- const shouldHide = shouldHideNode(node);
- const hovered = hoveredNodeIds.has(node.id);
- return shouldHide && !hovered;
- };
+ injectHiderCSS();
- // Find and patch all image-related widgets on the node
const patchWidgets = () => {
if (!node.widgets) return;
for (const widget of node.widgets) {
- // Check if this is an image widget (they typically have draw/drawWidget methods)
if (widget._sata_patched) continue;
- // Patch the draw method if it exists
- if (widget.draw && typeof widget.draw === 'function') {
- const origDraw = widget.draw.bind(widget);
- widget.draw = function (ctx, node, widgetWidth, y, widgetHeight) {
- if (shouldHideNow()) {
- // Draw hidden placeholder instead
- ctx.save();
- ctx.fillStyle = "#1a1a1a";
- ctx.fillRect(0, y, widgetWidth, widgetHeight);
+ if (widget.type === "image" || widget.name === "preview") {
+ if (widget.draw && typeof widget.draw === 'function') {
+ const origDraw = widget.draw.bind(widget);
+ widget.draw = function (ctx, nodeRef, widgetWidth, y, widgetHeight) {
+ const targetNode = nodeRef || node;
+ if (hasImages(targetNode) && shouldHideNow(targetNode)) {
+ ctx.save();
+ ctx.fillStyle = "#1a1a1a";
+ ctx.fillRect(0, y, widgetWidth, widgetHeight);
+ ctx.restore();
+ return;
+ }
+ return origDraw(ctx, targetNode, widgetWidth, y, widgetHeight);
+ };
+ widget._sata_patched = true;
+ }
- ctx.fillStyle = "#888";
- ctx.font = "12px Arial";
- ctx.textAlign = "center";
- ctx.textBaseline = "middle";
- ctx.fillText("Hidden", widgetWidth / 2, y + widgetHeight / 2 - 8);
- ctx.font = "9px Arial";
- ctx.fillStyle = "#666";
- ctx.fillText("(Hover to Reveal)", widgetWidth / 2, y + widgetHeight / 2 + 8);
- ctx.restore();
- return;
- }
- return origDraw(ctx, node, widgetWidth, y, widgetHeight);
- };
- widget._sata_patched = true;
- }
-
- // Also patch drawWidget if it exists (some widgets use this)
- if (widget.drawWidget && typeof widget.drawWidget === 'function') {
- const origDrawWidget = widget.drawWidget.bind(widget);
- widget.drawWidget = function (...args) {
- if (shouldHideNow()) {
- // Skip drawing - the onDrawForeground will handle the overlay
- return;
- }
- return origDrawWidget(...args);
- };
- widget._sata_patched = true;
+ if (widget.drawWidget && typeof widget.drawWidget === 'function') {
+ const origDrawWidget = widget.drawWidget.bind(widget);
+ widget.drawWidget = function (...args) {
+ if (hasImages(node) && shouldHideNow(node)) {
+ return;
+ }
+ return origDrawWidget(...args);
+ };
+ widget._sata_patched = true;
+ }
}
}
+ syncNodeDOMVisibility(node);
};
- // Patch widgets initially and on any update
patchWidgets();
- // Also intercept onDrawBackground to catch widget additions
const origOnDrawBackground = node.onDrawBackground;
node.onDrawBackground = function (ctx) {
- patchWidgets(); // Re-check for new widgets
+ patchWidgets();
+ syncNodeDOMVisibility(node);
if (origOnDrawBackground) {
origOnDrawBackground.apply(this, arguments);
}
};
- // Draw overlay in onDrawForeground as backup
const origOnDrawForeground = node.onDrawForeground;
node.onDrawForeground = function (ctx) {
if (origOnDrawForeground) {
origOnDrawForeground.apply(this, arguments);
}
- if (shouldHideNow()) {
- ctx.save();
- ctx.fillStyle = "#1a1a1a";
- ctx.fillRect(0, 0, this.size[0], this.size[1]);
+ syncNodeDOMVisibility(this);
- ctx.fillStyle = "#888";
- ctx.font = "bold 14px Arial";
- ctx.textAlign = "center";
- ctx.textBaseline = "middle";
- ctx.fillText("Hidden", this.size[0] / 2, this.size[1] / 2 - 10);
+ if (hasImages(this) && shouldHideNow(this)) {
+ // Find where settings widgets end so we only mask the image area below widgets
+ let startY = 30;
+ if (this.widgets && this.widgets.length > 0) {
+ const lastWidget = this.widgets[this.widgets.length - 1];
+ if (lastWidget.last_y !== undefined) {
+ const h = (lastWidget.computeSize ? lastWidget.computeSize(this.size[0])[1] : 20) || 20;
+ startY = lastWidget.last_y + h + 6;
+ } else {
+ startY = 30 + this.widgets.length * 24;
+ }
+ }
- ctx.font = "10px Arial";
- ctx.fillStyle = "#666";
- ctx.fillText("(Hover to Reveal)", this.size[0] / 2, this.size[1] / 2 + 10);
- ctx.restore();
+ const maskHeight = Math.max(0, this.size[1] - startY);
+ if (maskHeight > 10) {
+ ctx.save();
+ ctx.fillStyle = "#1a1a1a";
+ ctx.fillRect(4, startY, this.size[0] - 8, maskHeight - 4);
+
+ // Fallback for pure LiteGraph 2D Canvas rendering (when no DOM element exists)
+ const el = getNodeDOMElement(this);
+ if (!el || !el.querySelector(".sata-image-masked")) {
+ ctx.fillStyle = "#888";
+ ctx.font = "bold 13px Arial";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText("Hidden", this.size[0] / 2, startY + maskHeight / 2 - 8);
+
+ ctx.font = "10px Arial";
+ ctx.fillStyle = "#666";
+ ctx.fillText("(Hover to Reveal)", this.size[0] / 2, startY + maskHeight / 2 + 10);
+ }
+
+ ctx.restore();
+ }
}
};
- // Widget callback for Save_Machine
- if (node.comfyClass === SAVE_MACHINE_CLASS) {
- const hideWidget = node.widgets?.find(w => w.name === "hide_preview");
- if (hideWidget) {
- const origCallback = hideWidget.callback;
- hideWidget.callback = function (value) {
- if (origCallback) origCallback.call(this, value);
- if (app.graph) app.graph.setDirtyCanvas(true, true);
- };
+ const origOnExecuted = node.onExecuted;
+ node.onExecuted = function (message) {
+ if (origOnExecuted) {
+ origOnExecuted.apply(this, arguments);
}
+ patchWidgets();
+ syncNodeDOMVisibility(node);
+ setTimeout(() => syncNodeDOMVisibility(node), 20);
+ setTimeout(() => syncNodeDOMVisibility(node), 100);
+ setTimeout(() => syncNodeDOMVisibility(node), 300);
+ if (app.graph) app.graph.setDirtyCanvas(true, true);
+ };
+
+ const hideWidget = node.widgets?.find(w => w.name === "hide_preview");
+ if (hideWidget) {
+ const origCallback = hideWidget.callback;
+ hideWidget.callback = function (value) {
+ if (origCallback) origCallback.call(this, value);
+ syncNodeDOMVisibility(node);
+ if (app.graph) app.graph.setDirtyCanvas(true, true);
+ };
}
}
diff --git a/js/prompt_autocomplete.js b/js/prompt_autocomplete.js
index 6f38e2a..9b28407 100644
--- a/js/prompt_autocomplete.js
+++ b/js/prompt_autocomplete.js
@@ -55,6 +55,21 @@ app.registerExtension({
}
],
+ async setup() {
+ const self = this;
+ document.addEventListener("focusin", (e) => {
+ if (e.target && (e.target.tagName === "TEXTAREA" || e.target.tagName === "INPUT")) {
+ if (e.target.dataset.hasAutocomplete) return;
+ if (!self.snippetsCache) {
+ self.loadSnippets();
+ }
+ e.target.dataset.hasAutocomplete = "true";
+ e.target.addEventListener("keydown", (evt) => self.handleKeydown(evt, e.target, null));
+ e.target.addEventListener("input", (evt) => self.handleInput(evt, e.target, null));
+ }
+ });
+ },
+
async initAutocompleteForNode(node) {
const isTargetNode = node.comfyClass === "PromptAutocomplete" || node.comfyClass === "Prompt_Machine";
const isGlobal = getSettingValue("SATA_UtilityNode.PromptAutocomplete.Global", false);
diff --git a/js/prompt_machine.js b/js/prompt_machine.js
index b430333..e4431af 100644
--- a/js/prompt_machine.js
+++ b/js/prompt_machine.js
@@ -1,76 +1,128 @@
// prompt_machine_frontend.js
import { app } from "../../../scripts/app.js";
-app.registerExtension({
- name: "SATA_UtilityNode.PromptMachineFrontend",
- async nodeCreated(node) {
- if (node.comfyClass !== "Prompt_Style_Machine") return;
+const PROMPT_MACHINE_TOOLTIPS = {
+ subject: "Core hero or subject and physical details (age, hair, eyes, outfit, accessories).\nExample: 'A young female astronomer with messy raven hair and grey eyes, wearing a fitted leather jacket.'",
+ pose_and_expression: "Body posture, facial expression, action, and primary gestures.\nExample: 'Leaning over an ancient star map with a subtle smile, holding a brass compass in her right hand.'",
+ secondary_subject: "Supporting characters, companion animals, or secondary focal objects.\nExample: 'A sleek white robotic cat sitting on the desk beside her, watching the holograms.'",
+ background_and_environment: "Immediate physical surroundings, wider environment, and background setting.\nExample: 'Inside a high-tech glass dome observatory with scattered parchment maps, overlooking a star-filled galaxy.'",
+ lighting: "Lighting sources, direction, intensity, reflections, shadows, and mood.\nExample: 'Soft cyan holographic glow illuminating her face, warm golden sunlight streaming through the side window.'",
+ style: "Visual medium, photographic style, aesthetic textures, and dominant color palette.\nExample: 'Cinematic 35mm film photography, fine grain, shallow depth of field. Deep navy blues and warm amber tones.'",
+ creative_hook: "One surreal, impossible, or striking visual concept.\nExample: 'Constellations floating like tiny glowing fireflies in the air inside the room.'",
+ technical: "Camera equipment, lens specs, shot type, and framing (Optional).\nExample: 'Shot on 85mm f/1.4 lens, eye-level medium close-up, sharp focus on subject, soft background bokeh.'",
+ json_output: "Output structured JSON mapping instead of a formatted text paragraph."
+};
- // Widgets
- const csvWidget = node.widgets.find(w => w.name === "csv_file");
- const oldNameWidget = node.widgets.find(w => w.name === "name");
-
- if (!csvWidget || !oldNameWidget) {
- console.warn("[PromptMachineFrontend] Missing widgets, check backend definition.");
- return;
- }
-
- // Remove the old text input for 'name'
- const index = node.widgets.indexOf(oldNameWidget);
- node.widgets.splice(index, 1);
-
- // Add proper dropdown (this appends to the end)
- const nameWidget = node.addWidget("combo", "name", "None", () => { }, {
- values: ["None"]
- });
-
- // Move the new widget back to the original position
- // Remove from the end (where addWidget put it)
- node.widgets.splice(node.widgets.indexOf(nameWidget), 1);
- // Insert at the correct index
- node.widgets.splice(index, 0, nameWidget);
-
- // Store available names
- let namesConfig = [];
-
- // Fetch names from backend for a given CSV
- async function loadNames(csvValue) {
- if (!csvValue) return;
- try {
- const resp = await fetch(`/sata/prompt_machine/names?csv=${encodeURIComponent(csvValue)}`);
- const data = await resp.json();
- namesConfig = data.names || ["None"];
-
- refreshNames();
- } catch (err) {
- console.error("[PromptMachineFrontend] Failed to load names:", err);
+function setupPromptMachine(node) {
+ if (!node.widgets) return;
+ node.widgets.forEach(w => {
+ if (PROMPT_MACHINE_TOOLTIPS[w.name]) {
+ w.tooltip = PROMPT_MACHINE_TOOLTIPS[w.name];
+ if (w.options) {
+ w.options.tooltip = PROMPT_MACHINE_TOOLTIPS[w.name];
+ }
+ const updateInputEl = () => {
+ if (w.inputEl) {
+ w.inputEl.title = PROMPT_MACHINE_TOOLTIPS[w.name];
+ const firstLine = PROMPT_MACHINE_TOOLTIPS[w.name].split('\n')[0];
+ if (!w.inputEl.placeholder) {
+ w.inputEl.placeholder = firstLine;
+ }
+ w.inputEl.readOnly = false;
+ w.inputEl.disabled = false;
+ }
+ };
+ updateInputEl();
+ // Retry if inputEl creates asynchronously
+ if (!w.inputEl) {
+ setTimeout(updateInputEl, 100);
+ setTimeout(updateInputEl, 500);
}
}
+ });
+}
- // Refresh dropdown with available names
- function refreshNames() {
- const available = namesConfig.length > 0 ? namesConfig : ["None"];
- nameWidget.options.values = available;
+function setupPromptStyleMachine(node) {
+ if (node._sata_style_initialized) return;
- if (!available.includes(nameWidget.value)) {
- nameWidget.value = available[0]; // safe fallback
+ const csvWidget = node.widgets?.find(w => w.name === "csv_file");
+ let nameWidget = node.widgets?.find(w => w.name === "name");
+
+ if (!csvWidget || !nameWidget) {
+ return;
+ }
+
+ node._sata_style_initialized = true;
+ const initialSavedName = nameWidget.value || "None";
+
+ // Convert widget to combo dropdown gracefully if it's not already
+ if (nameWidget.type !== "combo") {
+ const index = node.widgets.indexOf(nameWidget);
+ if (index !== -1) {
+ node.widgets.splice(index, 1);
+ nameWidget = node.addWidget("combo", "name", initialSavedName, () => { }, {
+ values: [initialSavedName !== "None" ? initialSavedName : "None"]
+ });
+ const newIdx = node.widgets.indexOf(nameWidget);
+ if (newIdx !== -1 && newIdx !== index) {
+ node.widgets.splice(newIdx, 1);
+ node.widgets.splice(index, 0, nameWidget);
}
+ }
+ }
- // Force update previews for the new name
- updatePreviews(nameWidget.value);
+ if (!nameWidget.options) {
+ nameWidget.options = { values: [initialSavedName] };
+ }
- app.graph.change();
+ let namesConfig = [];
+
+ // Fetch names from backend for a given CSV
+ async function loadNames(csvValue, preferredName) {
+ if (!csvValue || csvValue === "None") return;
+ try {
+ const resp = await fetch(`/sata/prompt_machine/names?csv=${encodeURIComponent(csvValue)}`);
+ const data = await resp.json();
+ namesConfig = (data.names && data.names.length > 0) ? data.names : ["None"];
+
+ refreshNames(preferredName);
+ } catch (err) {
+ console.error("[PromptMachineFrontend] Failed to load names:", err);
+ }
+ }
+
+ // Refresh dropdown with available names while preserving target selection
+ function refreshNames(preferredName) {
+ const available = namesConfig.length > 0 ? namesConfig : ["None"];
+ nameWidget.options.values = available;
+
+ const targetVal = preferredName !== undefined ? preferredName : nameWidget.value;
+ if (available.includes(targetVal)) {
+ nameWidget.value = targetVal;
+ } else {
+ nameWidget.value = available[0] || "None";
}
- // Hook CSV change
- const oldCsvCallback = csvWidget.callback;
- csvWidget.callback = function (value) {
- if (oldCsvCallback) oldCsvCallback(value);
- loadNames(value); // refresh dynamically
- };
+ // Force update previews for current name
+ updatePreviews(nameWidget.value);
- // --- Preview Logic ---
- const previewEl = document.createElement("div");
+ if (app.graph) {
+ if (app.graph.setDirtyCanvas) app.graph.setDirtyCanvas(true, true);
+ if (app.graph.change) app.graph.change();
+ }
+ }
+
+ // Hook CSV change (user triggered)
+ const oldCsvCallback = csvWidget.callback;
+ csvWidget.callback = function (value) {
+ if (oldCsvCallback) oldCsvCallback.apply(this, arguments);
+ loadNames(value); // refresh without preferred name (defaults to first available)
+ };
+
+ // --- Preview Logic ---
+ let previewEl = node.widgets?.find(w => w.name === "preview_text")?.element;
+ if (!previewEl) {
+ previewEl = document.createElement("div");
Object.assign(previewEl.style, {
marginTop: "10px",
padding: "8px",
@@ -83,43 +135,67 @@ app.registerExtension({
maxHeight: "400px",
overflowY: "auto"
});
-
+
node.addDOMWidget("preview_text", "html", previewEl, {
getValue: () => previewEl.innerHTML,
- setValue: (v) => { previewEl.innerHTML = v; }
- });
-
- 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();
- let html = "";
- if (data.positive) html += `${data.positive}`;
-
- previewEl.innerHTML = html || "No preview available";
-
- // Adjust node size to fit content
- node.setSize(node.computeSize());
-
- 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).then(() => {
- updatePreviews(nameWidget.value);
+ setValue: (v) => { previewEl.innerHTML = v; },
+ serialize: false
});
}
+
+ async function updatePreviews(name) {
+ const csv = csvWidget.value;
+ if (!csv || !name || name === "None") {
+ previewEl.innerHTML = "No preview available";
+ return;
+ }
+
+ try {
+ const resp = await fetch(`/sata/prompt_machine/get?csv=${encodeURIComponent(csv)}&name=${encodeURIComponent(name)}`);
+ const data = await resp.json();
+ let html = "";
+ if (data.positive) html += `${data.positive}`;
+
+ previewEl.innerHTML = html || "No preview available";
+
+ if (node.computeSize && node.setSize) {
+ node.setSize(node.computeSize());
+ }
+
+ if (app.graph) {
+ if (app.graph.setDirtyCanvas) app.graph.setDirtyCanvas(true, true);
+ if (app.graph.change) 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.apply(this, arguments);
+ updatePreviews(value);
+ };
+
+ // Initial sync preserving saved value
+ loadNames(csvWidget.value, initialSavedName);
+}
+
+app.registerExtension({
+ name: "SATA_UtilityNode.PromptMachineFrontend",
+ async nodeCreated(node) {
+ if (node.comfyClass === "Prompt_Machine") {
+ setupPromptMachine(node);
+ } else if (node.comfyClass === "Prompt_Style_Machine") {
+ setupPromptStyleMachine(node);
+ }
+ },
+ async loadedGraphNode(node) {
+ if (node.comfyClass === "Prompt_Machine") {
+ setupPromptMachine(node);
+ } else if (node.comfyClass === "Prompt_Style_Machine") {
+ setupPromptStyleMachine(node);
+ }
+ }
});
\ No newline at end of file
diff --git a/js/resolution_machine.js b/js/resolution_machine.js
index 8b10ea6..b6b1409 100644
--- a/js/resolution_machine.js
+++ b/js/resolution_machine.js
@@ -1,165 +1,173 @@
import { app } from "../../../scripts/app.js";
+
+function setupResolutionNode(node) {
+ if (node._sata_res_initialized) return;
+ if (node.comfyClass !== "Resolution_Machine" && node.comfyClass !== "Latent_Machine") return;
+
+ // Find widgets
+ const modelWidget = node.widgets?.find(w => w.name === "model");
+ const dimensionWidget = node.widgets?.find(w => w.name === "dimension");
+ const resolutionWidget = node.widgets?.find(w => w.name === "resolution");
+ const widthWidget = node.widgets?.find(w => w.name === "width") || node.widgets?.find(w => w.name === "custom_width");
+ const heightWidget = node.widgets?.find(w => w.name === "height") || node.widgets?.find(w => w.name === "custom_height");
+
+ if (!modelWidget || !dimensionWidget || !resolutionWidget || !widthWidget || !heightWidget) {
+ return;
+ }
+
+ node._sata_res_initialized = true;
+
+ // Store resolution config
+ let resolutionsConfig = {};
+
+ // Fetch resolution config from backend
+ async function loadConfig() {
+ try {
+ const resp = await fetch("/SATA_UtilityNode/resolutions_config");
+ resolutionsConfig = await resp.json();
+ refreshResolutions(false);
+ } catch (err) {
+ console.error("[Resolution_Machine] Failed to load config:", err);
+ }
+ }
+
+ // --- Utility: refresh resolution dropdown when model/dimension changes ---
+ function refreshResolutions(resetSelection = true) {
+ const selectedModel = modelWidget.value;
+ const selectedDimension = dimensionWidget.value;
+
+ let available = [];
+
+ if (resolutionsConfig.models && resolutionsConfig.models[selectedModel]) {
+ const buckets = resolutionsConfig.models[selectedModel];
+ buckets.forEach(bucket => {
+ if (resolutionsConfig.resolutions && resolutionsConfig.resolutions[bucket] && resolutionsConfig.resolutions[bucket][selectedDimension]) {
+ available.push(...Object.keys(resolutionsConfig.resolutions[bucket][selectedDimension]));
+ }
+ });
+ }
+
+ // Always include "Custom" option
+ if (!available.includes("Custom")) {
+ available.push("Custom");
+ }
+
+ resolutionWidget.options.values = available;
+
+ if (resetSelection && !available.includes(resolutionWidget.value)) {
+ resolutionWidget.value = available[0] || "Custom";
+ }
+
+ checkCustomMode();
+ }
+
+ function checkCustomMode() {
+ const isCustom = resolutionWidget.value === "Custom";
+ if (isCustom) {
+ widthWidget.type = "INT";
+ heightWidget.type = "INT";
+ delete widthWidget.computeSize;
+ delete heightWidget.computeSize;
+ widthWidget.disabled = false;
+ heightWidget.disabled = false;
+ } else {
+ widthWidget.type = "hidden";
+ heightWidget.type = "hidden";
+ widthWidget.computeSize = () => [0, -4];
+ heightWidget.computeSize = () => [0, -4];
+ applyResolutionPreset();
+ }
+
+ if (node.computeSize && node.setSize) {
+ node.setSize(node.computeSize());
+ }
+ if (app.graph) {
+ if (app.graph.setDirtyCanvas) app.graph.setDirtyCanvas(true, true);
+ if (app.graph.change) app.graph.change();
+ }
+ }
+
+ // --- Utility: apply resolution preset to width/height ---
+ function applyResolutionPreset() {
+ const selectedModel = modelWidget.value;
+ const selectedDimension = dimensionWidget.value;
+ const selectedResolution = resolutionWidget.value;
+
+ if (selectedResolution === "Custom") {
+ return;
+ }
+
+ let resolutionData = null;
+
+ if (resolutionsConfig.models && resolutionsConfig.models[selectedModel]) {
+ const buckets = resolutionsConfig.models[selectedModel];
+ for (const bucket of buckets) {
+ if (resolutionsConfig.resolutions &&
+ resolutionsConfig.resolutions[bucket] &&
+ resolutionsConfig.resolutions[bucket][selectedDimension] &&
+ resolutionsConfig.resolutions[bucket][selectedDimension][selectedResolution]) {
+
+ resolutionData = resolutionsConfig.resolutions[bucket][selectedDimension][selectedResolution];
+ break;
+ }
+ }
+ }
+
+ if (resolutionData) {
+ widthWidget.value = resolutionData.width;
+ heightWidget.value = resolutionData.height;
+ }
+ }
+
+ // --- Hook model change ---
+ const oldModelCallback = modelWidget.callback;
+ modelWidget.callback = function (value) {
+ if (oldModelCallback) oldModelCallback.apply(this, arguments);
+ refreshResolutions(true);
+ };
+
+ // --- Hook dimension change ---
+ const oldDimensionCallback = dimensionWidget.callback;
+ dimensionWidget.callback = function (value) {
+ if (oldDimensionCallback) oldDimensionCallback.apply(this, arguments);
+ refreshResolutions(true);
+ };
+
+ // --- Hook resolution change ---
+ const oldResolutionCallback = resolutionWidget.callback;
+ resolutionWidget.callback = function (value) {
+ if (oldResolutionCallback) oldResolutionCallback.apply(this, arguments);
+ checkCustomMode();
+ };
+
+ // --- Add Custom Button (once) ---
+ if (!node.widgets.some(w => w.name === "Custom")) {
+ node.addWidget("button", "Custom", null, () => {
+ widthWidget.disabled = false;
+ heightWidget.disabled = false;
+
+ if (resolutionWidget.options && resolutionWidget.options.values) {
+ if (!resolutionWidget.options.values.includes("Custom")) {
+ resolutionWidget.options.values.push("Custom");
+ }
+ }
+ resolutionWidget.value = "Custom";
+
+ checkCustomMode();
+ });
+ }
+
+ loadConfig();
+}
app.registerExtension({
name: "SATA_UtilityNode.Resolution_Machine",
async nodeCreated(node) {
- if (node.comfyClass !== "Resolution_Machine" && node.comfyClass !== "Latent_Machine") return;
+ setupResolutionNode(node);
+ },
- // Find widgets
- const modelWidget = node.widgets.find(w => w.name === "model");
- const dimensionWidget = node.widgets.find(w => w.name === "dimension");
- const resolutionWidget = node.widgets.find(w => w.name === "resolution");
- const widthWidget = node.widgets.find(w => w.name === "width") || node.widgets.find(w => w.name === "custom_width");
- const heightWidget = node.widgets.find(w => w.name === "height") || node.widgets.find(w => w.name === "custom_height");
-
- if (!modelWidget || !dimensionWidget || !resolutionWidget || !widthWidget || !heightWidget) {
- console.warn("[Resolution_Machine] Missing widgets, check backend definition.");
- return;
- }
-
- // Store resolution config
- let resolutionsConfig = {};
-
- // Fetch resolution config from backend
- async function loadConfig() {
- try {
- const resp = await fetch("/SATA_UtilityNode/resolutions_config");
- resolutionsConfig = await resp.json();
-
- } catch (err) {
- console.error("[Resolution_Machine] Failed to load config:", err);
- }
- }
-
- await loadConfig();
-
- // --- Utility: refresh resolution dropdown when model/dimension changes ---
- function refreshResolutions() {
- const selectedModel = modelWidget.value;
- const selectedDimension = dimensionWidget.value;
-
- let available = [];
-
- if (resolutionsConfig.models && resolutionsConfig.models[selectedModel]) {
- const buckets = resolutionsConfig.models[selectedModel];
- buckets.forEach(bucket => {
- // Check if bucket exists and has the selected dimension
- if (resolutionsConfig.resolutions && resolutionsConfig.resolutions[bucket] && resolutionsConfig.resolutions[bucket][selectedDimension]) {
- available.push(...Object.keys(resolutionsConfig.resolutions[bucket][selectedDimension]));
- }
- });
- }
-
- // Always include "Custom" option so that switching model/dimension dropdowns doesn't kick the user out of custom mode
- if (!available.includes("Custom")) {
- available.push("Custom");
- }
-
- resolutionWidget.options.values = available;
-
- // Pick safe default if current value is not valid
- if (!available.includes(resolutionWidget.value)) {
- resolutionWidget.value = available[0];
- }
-
- // If we switched to a preset (not Custom), lock fields
- // If we defaulted to Custom (e.g. empty available), unlock
- checkCustomMode();
- }
-
- function checkCustomMode() {
- if (resolutionWidget.value === "Custom") {
- widthWidget.type = "INT";
- heightWidget.type = "INT";
- delete widthWidget.computeSize;
- delete heightWidget.computeSize;
- widthWidget.disabled = false;
- heightWidget.disabled = false;
- } else {
- widthWidget.type = "hidden";
- heightWidget.type = "hidden";
- widthWidget.computeSize = () => [0, -4];
- heightWidget.computeSize = () => [0, -4];
- applyResolutionPreset();
- }
- node.setSize(node.computeSize());
- app.graph.change();
- }
-
- // --- Utility: apply resolution preset to width/height ---
- function applyResolutionPreset() {
- const selectedModel = modelWidget.value;
- const selectedDimension = dimensionWidget.value;
- const selectedResolution = resolutionWidget.value;
-
- if (selectedResolution === "Custom") {
- return;
- }
-
- let resolutionData = null;
-
- // Lookup
- if (resolutionsConfig.models && resolutionsConfig.models[selectedModel]) {
- const buckets = resolutionsConfig.models[selectedModel];
- for (const bucket of buckets) {
- if (resolutionsConfig.resolutions[bucket] &&
- resolutionsConfig.resolutions[bucket][selectedDimension] &&
- resolutionsConfig.resolutions[bucket][selectedDimension][selectedResolution]) {
-
- resolutionData = resolutionsConfig.resolutions[bucket][selectedDimension][selectedResolution];
- break;
- }
- }
- }
-
- if (resolutionData) {
- widthWidget.value = resolutionData.width;
- heightWidget.value = resolutionData.height;
- }
- }
-
- // --- Hook model change ---
- const oldModelCallback = modelWidget.callback;
- modelWidget.callback = function (value) {
- if (oldModelCallback) oldModelCallback(value);
- refreshResolutions();
- };
-
- // --- Hook dimension change ---
- const oldDimensionCallback = dimensionWidget.callback;
- dimensionWidget.callback = function (value) {
- if (oldDimensionCallback) oldDimensionCallback(value);
- refreshResolutions();
- };
-
- // --- Hook resolution change ---
- const oldResolutionCallback = resolutionWidget.callback;
- resolutionWidget.callback = function (value) {
- if (oldResolutionCallback) oldResolutionCallback(value);
- checkCustomMode();
- };
-
- // --- Add Custom Button ---
- // Rename "Swap Dimensions" -> "Custom"
- // Note: Node logic adds this button. Previous JS added it. We are replacing previous JS logic.
-
- node.addWidget("button", "Custom", null, () => {
- // Unlock widgets
- widthWidget.disabled = false;
- heightWidget.disabled = false;
-
- // Ensure "Custom" option exists and is selected
- if (!resolutionWidget.options.values.includes("Custom")) {
- resolutionWidget.options.values.push("Custom");
- }
- resolutionWidget.value = "Custom";
-
- checkCustomMode();
- app.graph.change();
- });
-
- // --- Initial sync ---
- refreshResolutions();
+ async loadedGraphNode(node) {
+ setupResolutionNode(node);
}
});
\ No newline at end of file
diff --git a/js/touchpad_pan_zoom.js b/js/touchpad_pan_zoom.js
index e508bdb..16cadee 100644
--- a/js/touchpad_pan_zoom.js
+++ b/js/touchpad_pan_zoom.js
@@ -20,8 +20,8 @@ app.registerExtension({
}
],
async setup() {
- // Create a toggle button in the ComfyUI menu
- const menu = document.querySelector(".comfy-menu");
+ // Create a toggle button in the ComfyUI menu (Legacy V1)
+ const menu = document.querySelector(".comfy-menu") || document.querySelector(".comfyui-menu");
if (menu) {
const toggleBtn = document.createElement("button");
toggleBtn.id = "sata-touchpad-toggle-btn";
@@ -74,62 +74,71 @@ app.registerExtension({
/**
* Enhanced smooth scrolling and zooming for touchpad gestures
- * Overrides LGraphCanvas.prototype.processMouseWheel
+ * Overrides LGraphCanvas.prototype.processMouseWheel if available
*/
-const originalProcessMouseWheel = LGraphCanvas.prototype.processMouseWheel;
+if (typeof LGraphCanvas !== "undefined" && LGraphCanvas.prototype) {
+ const originalProcessMouseWheel = LGraphCanvas.prototype.processMouseWheel;
-LGraphCanvas.prototype.processMouseWheel = function (/** @type {WheelEvent} */ event) {
- let isEnabled = true;
-
- if (app.extensionManager && app.extensionManager.setting) {
- const val = app.extensionManager.setting.get(SETTING_ID);
- if (val !== undefined) isEnabled = val;
- } else if (app.ui && app.ui.settings) {
- isEnabled = app.ui.settings.getSettingValue(SETTING_ID, true);
- }
+ LGraphCanvas.prototype.processMouseWheel = function (/** @type {WheelEvent} */ event) {
+ let isEnabled = true;
+
+ if (app.extensionManager && app.extensionManager.setting) {
+ const val = app.extensionManager.setting.get(SETTING_ID);
+ if (val !== undefined) isEnabled = val;
+ } else if (app.ui && app.ui.settings) {
+ isEnabled = app.ui.settings.getSettingValue(SETTING_ID, true);
+ }
- if (isEnabled === false) {
- if (originalProcessMouseWheel) {
- return originalProcessMouseWheel.apply(this, arguments);
+ if (isEnabled === false) {
+ if (originalProcessMouseWheel) {
+ return originalProcessMouseWheel.apply(this, arguments);
+ }
+ return false;
+ }
+
+ if (!this.graph || !this.allow_dragcanvas || !this.ds) return;
+
+ const rect = this.canvas ? this.canvas.getBoundingClientRect() : null;
+ if (!rect) return;
+ const x = event.clientX - rect.left;
+ const y = event.clientY - rect.top;
+ const { deltaX, deltaY, ctrlKey, metaKey } = event;
+
+ // Check if the event occurred inside the canvas viewport
+ if (this.viewport) {
+ const [vx, vy, vw, vh] = this.viewport;
+ const insideX = x >= vx && x < vx + vw;
+ const insideY = y >= vy && y < vy + vh;
+ if (!(insideX && insideY)) return;
+ }
+
+ const currentScale = this.ds.scale;
+
+ // Configurable sensitivity
+ const ZOOM_SENSITIVITY = this.zoomSensitivity || 0.005;
+ const PAN_SENSITIVITY = this.panSensitivity || 1.0;
+ const MIN_SCALE = 0.1;
+ const MAX_SCALE = 4.0;
+
+ if (ctrlKey || metaKey) {
+ // Zoom mode
+ const zoomDirection = Math.sign(deltaY);
+ const zoomAmount = zoomDirection * Math.abs(deltaY) * ZOOM_SENSITIVITY;
+ const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, currentScale - zoomAmount));
+ if (this.ds.changeScale) {
+ this.ds.changeScale(newScale, [x, y]);
}
- return false;
- }
+ } else {
+ // Pan mode
+ if (this.ds.mouseDrag) {
+ this.ds.mouseDrag(-deltaX * PAN_SENSITIVITY, -deltaY * PAN_SENSITIVITY);
+ }
+ }
- if (!this.graph || !this.allow_dragcanvas) return;
-
- const rect = this.canvas.getBoundingClientRect();
- const x = event.clientX - rect.left;
- const y = event.clientY - rect.top;
- const { deltaX, deltaY, ctrlKey, metaKey } = event;
-
- // Check if the event occurred inside the canvas viewport
- if (this.viewport) {
- const [vx, vy, vw, vh] = this.viewport;
- const insideX = x >= vx && x < vx + vw;
- const insideY = y >= vy && y < vy + vh;
- if (!(insideX && insideY)) return;
- }
-
- const currentScale = this.ds.scale;
-
- // Configurable sensitivity
- const ZOOM_SENSITIVITY = this.zoomSensitivity || 0.005;
- const PAN_SENSITIVITY = this.panSensitivity || 1.0;
- const MIN_SCALE = 0.1;
- const MAX_SCALE = 4.0;
-
- if (ctrlKey || metaKey) {
- // Zoom mode
- const zoomDirection = Math.sign(deltaY);
- const zoomAmount = zoomDirection * Math.abs(deltaY) * ZOOM_SENSITIVITY;
- const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, currentScale - zoomAmount));
- this.ds.changeScale(newScale, [x, y]);
- } else {
- // Pan mode
- this.ds.mouseDrag(-deltaX * PAN_SENSITIVITY, -deltaY * PAN_SENSITIVITY);
- }
-
- this.graph.change();
- event.preventDefault();
- return false;
-};
\ No newline at end of file
+ if (this.graph.change) {
+ this.graph.change();
+ }
+ event.preventDefault();
+ return false;
+ };
+}
\ No newline at end of file
diff --git a/nodes/preview_machine.py b/nodes/preview_machine.py
index ef8654f..91f7d44 100644
--- a/nodes/preview_machine.py
+++ b/nodes/preview_machine.py
@@ -6,6 +6,7 @@ class Preview_Machine(PreviewImage):
return {
"required": {
"images": ("IMAGE",),
+ "hide_preview": ("BOOLEAN", {"default": True, "tooltip": "Hide image preview until mouse hovers over the node."}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
@@ -15,5 +16,5 @@ class Preview_Machine(PreviewImage):
CATEGORY = "SATA_UtilityNode"
FUNCTION = "save_images"
- def save_images(self, images, prompt=None, extra_pnginfo=None):
+ def save_images(self, images, hide_preview=True, prompt=None, extra_pnginfo=None):
return super().save_images(images, filename_prefix="Preview_Machine", prompt=prompt, extra_pnginfo=extra_pnginfo)
diff --git a/nodes/prompt_machine.py b/nodes/prompt_machine.py
index 6989b03..fa52c2a 100644
--- a/nodes/prompt_machine.py
+++ b/nodes/prompt_machine.py
@@ -18,7 +18,8 @@ def read_names_from_csv(filename):
"""Return list of names (stripped) from CSV's 'name' column."""
if not filename:
return []
- path = os.path.join(CSV_DIR, filename)
+ safe_filename = os.path.basename(str(filename).strip())
+ path = os.path.join(CSV_DIR, safe_filename)
if not os.path.exists(path):
print(f"[PromptMachine] CSV not found: {path}")
return []
@@ -26,11 +27,11 @@ def read_names_from_csv(filename):
with open(path, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
if not reader.fieldnames:
- print(f"[PromptMachine] CSV has no header: {filename}")
+ print(f"[PromptMachine] CSV has no header: {safe_filename}")
return []
field_map = {fn.strip().lower(): fn for fn in reader.fieldnames}
if "name" not in field_map:
- print(f"[PromptMachine] CSV missing 'name' column: {filename}")
+ print(f"[PromptMachine] CSV missing 'name' column: {safe_filename}")
return []
name_field = field_map["name"]
names = []
@@ -38,12 +39,12 @@ def read_names_from_csv(filename):
raw = row.get(name_field)
if raw is None:
continue
- val = raw.strip()
+ val = str(raw).strip()
if val:
names.append(val)
return names
except Exception as e:
- print(f"[PromptMachine] read_names_from_csv error for {filename}: {e}")
+ print(f"[PromptMachine] read_names_from_csv error for {safe_filename}: {e}")
return []
def read_prompt_row(csv_file, name):
@@ -53,7 +54,8 @@ def read_prompt_row(csv_file, name):
return ("", "", "")
if not name or not isinstance(name, str) or name == "None":
return ("", "", "")
- path = os.path.join(CSV_DIR, csv_file)
+ safe_csv_file = os.path.basename(str(csv_file).strip())
+ path = os.path.join(CSV_DIR, safe_csv_file)
if not os.path.exists(path):
print(f"[PromptMachine] CSV file not found: {path}")
return ("", "", "")
@@ -61,11 +63,11 @@ def read_prompt_row(csv_file, name):
with open(path, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
if not reader.fieldnames:
- print(f"[PromptMachine] CSV has no header: {csv_file}")
+ print(f"[PromptMachine] CSV has no header: {safe_csv_file}")
return ("", "", "")
field_map = {fn.strip().lower(): fn for fn in reader.fieldnames}
if "name" not in field_map:
- print(f"[PromptMachine] 'name' column missing in CSV: {csv_file}")
+ print(f"[PromptMachine] 'name' column missing in CSV: {safe_csv_file}")
return ("", "", "")
name_field = field_map["name"]
pos_field = field_map.get("positive", "positive")
@@ -77,13 +79,13 @@ def read_prompt_row(csv_file, name):
cell = row.get(name_field)
if cell is None:
continue
- if cell.strip() == target:
- pos = (row.get(pos_field) or "").strip()
- neg = (row.get(neg_field) or "").strip()
- note = (row.get(note_field) or "").strip()
+ if str(cell).strip() == target:
+ pos = str(row.get(pos_field) or "").strip()
+ neg = str(row.get(neg_field) or "").strip()
+ note = str(row.get(note_field) or "").strip()
return (pos, neg, note)
except Exception as e:
- print(f"[PromptMachine] read_prompt_row error for {csv_file}, {name}: {e}")
+ print(f"[PromptMachine] read_prompt_row error for {safe_csv_file}, {name}: {e}")
return ("", "", "")
@@ -126,17 +128,66 @@ class Prompt_Style_Machine:
class Prompt_Machine:
+ """
+ Prompt Machine (8-Category Framework):
+ Assembles information-dense scene descriptions in natural prose following an inside-out spatial sequence.
+ """
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
- "subject": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}),
- "style": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}),
- "lighting": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}),
- "composition": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}),
- "mood": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}),
- "technical": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}),
- "json_output": ("BOOLEAN", {"default": False}),
+ "subject": ("STRING", {
+ "multiline": True,
+ "default": "",
+ "dynamicPrompts": True,
+ "tooltip": "Core hero or subject and physical details (age, hair, eyes, outfit, accessories).\nExample: 'A young female astronomer with messy raven hair and grey eyes, wearing a fitted leather jacket.'"
+ }),
+ "pose_and_expression": ("STRING", {
+ "multiline": True,
+ "default": "",
+ "dynamicPrompts": True,
+ "tooltip": "Body posture, facial expression, action, and primary gestures.\nExample: 'Leaning over an ancient star map with a subtle smile, holding a brass compass in her right hand.'"
+ }),
+ "secondary_subject": ("STRING", {
+ "multiline": True,
+ "default": "",
+ "dynamicPrompts": True,
+ "tooltip": "Supporting characters, companion animals, or secondary focal objects.\nExample: 'A sleek white robotic cat sitting on the desk beside her, watching the holograms.'"
+ }),
+ "background_and_environment": ("STRING", {
+ "multiline": True,
+ "default": "",
+ "dynamicPrompts": True,
+ "tooltip": "Immediate physical surroundings, wider environment, and background setting.\nExample: 'Inside a high-tech glass dome observatory with scattered parchment maps, overlooking a star-filled galaxy.'"
+ }),
+ "lighting": ("STRING", {
+ "multiline": True,
+ "default": "",
+ "dynamicPrompts": True,
+ "tooltip": "Lighting sources, direction, intensity, reflections, shadows, and mood.\nExample: 'Soft cyan holographic glow illuminating her face, warm golden sunlight streaming through the side window.'"
+ }),
+ "style": ("STRING", {
+ "multiline": True,
+ "default": "",
+ "dynamicPrompts": True,
+ "tooltip": "Visual medium, photographic style, aesthetic textures, and dominant color palette.\nExample: 'Cinematic 35mm film photography, fine grain, shallow depth of field. Deep navy blues and warm amber tones.'"
+ }),
+ "creative_hook": ("STRING", {
+ "multiline": True,
+ "default": "",
+ "dynamicPrompts": True,
+ "tooltip": "One surreal, impossible, or striking visual concept.\nExample: 'Constellations floating like tiny glowing fireflies in the air inside the room.'"
+ }),
+ "technical": ("STRING", {
+ "multiline": True,
+ "default": "",
+ "dynamicPrompts": True,
+ "tooltip": "Camera equipment, lens specs, shot type, and framing (Optional).\nExample: 'Shot on 85mm f/1.4 lens, eye-level medium close-up, sharp focus on subject, soft background bokeh.'"
+ }),
+ "json_output": ("BOOLEAN", {
+ "default": False,
+ "tooltip": "Output structured JSON mapping instead of a formatted text paragraph."
+ }),
}
}
@@ -145,29 +196,63 @@ class Prompt_Machine:
FUNCTION = "get_prompt"
CATEGORY = "SATA_UtilityNode"
- def get_prompt(self, subject, style, lighting, composition, mood, technical, json_output):
+ def get_prompt(self, subject="", pose_and_expression="", secondary_subject="",
+ background_and_environment="", lighting="", style="",
+ creative_hook="", technical="", json_output=False, **kwargs):
+
+ # Support fallback for legacy parameter names if older workflows pass them
+ if not subject and kwargs.get("appearance"):
+ subject = kwargs["appearance"]
+ if not secondary_subject and kwargs.get("secondary_subjects"):
+ secondary_subject = kwargs["secondary_subjects"]
+ if not background_and_environment:
+ env_parts = [kwargs.get("surroundings", ""), kwargs.get("environment_and_background", "")]
+ valid_parts = [" ".join(str(p).strip().split()) for p in env_parts if p is not None and str(p).strip()]
+ if valid_parts:
+ background_and_environment = " ".join(valid_parts)
+ if not lighting and kwargs.get("lighting_and_atmosphere"):
+ lighting = kwargs["lighting_and_atmosphere"]
+ if kwargs.get("materials_and_colors"):
+ mat = " ".join(str(kwargs["materials_and_colors"]).strip().split())
+ if mat:
+ style_str = " ".join(str(style).strip().split()) if style else ""
+ style = f"{style_str} {mat}".strip() if style_str else mat
+
+ raw_fields = [
+ ("subject", subject),
+ ("pose_and_expression", pose_and_expression),
+ ("secondary_subject", secondary_subject),
+ ("background_and_environment", background_and_environment),
+ ("lighting", lighting),
+ ("style", style),
+ ("creative_hook", creative_hook),
+ ("technical", technical),
+ ]
+
+ cleaned_fields = []
+ for name, val in raw_fields:
+ if val is None:
+ continue
+ normalized = " ".join(str(val).strip().split())
+ if normalized:
+ cleaned_fields.append((name, normalized))
+
if json_output:
import json
- data = {
- "subject": subject.strip(),
- "style": style.strip(),
- "lighting": lighting.strip(),
- "composition": composition.strip(),
- "mood": mood.strip(),
- "technical": technical.strip()
- }
- # Remove empty fields to keep JSON clean
- data = {k: v for k, v in data.items() if v}
+ data = {k: v for k, v in cleaned_fields}
return (json.dumps(data, indent=2),)
- else:
- parts = []
- if subject.strip(): parts.append(subject.strip())
- if style.strip(): parts.append(style.strip())
- if lighting.strip(): parts.append(lighting.strip())
- if composition.strip(): parts.append(composition.strip())
- if mood.strip(): parts.append(mood.strip())
- if technical.strip(): parts.append(technical.strip())
- return (", ".join(parts),)
+
+ sentences = []
+ for name, text in cleaned_fields:
+ # Strip trailing separators like commas, semicolons, colons, or dashes before period
+ trimmed = text.rstrip(" ,;:-")
+ if trimmed:
+ if trimmed[-1] not in ".!?":
+ trimmed += "."
+ sentences.append(trimmed)
+
+ paragraph = " ".join(sentences)
+ return (paragraph,)
# ---------------- REST API ----------------
@@ -182,7 +267,8 @@ async def list_csvs(request):
@PromptServer.instance.routes.get("/sata/prompt_machine/names")
async def list_names(request):
"""Return names from a selected CSV"""
- csv_file = request.query.get("csv")
+ raw_csv = request.query.get("csv", "")
+ csv_file = os.path.basename(raw_csv.strip()) if raw_csv else ""
names = read_names_from_csv(csv_file) if csv_file else []
return web.json_response({"names": names})
@@ -193,7 +279,8 @@ async def get_prompt(request):
Return the prompt triple for immediate frontend consumption.
Example: /sata/prompt_machine/get?csv=characters.csv&name=Bob
"""
- csv_file = request.query.get("csv")
- name = request.query.get("name")
+ raw_csv = request.query.get("csv", "")
+ csv_file = os.path.basename(raw_csv.strip()) if raw_csv else ""
+ name = request.query.get("name", "")
pos, neg, note = read_prompt_row(csv_file, name)
return web.json_response({"positive": pos, "negative": neg, "note": note})
\ No newline at end of file
diff --git a/nodes/save_machine.py b/nodes/save_machine.py
index d76367e..f96a9b6 100644
--- a/nodes/save_machine.py
+++ b/nodes/save_machine.py
@@ -134,6 +134,7 @@ class Save_Machine:
"images": ("IMAGE", ),
"path_and_filename": ("STRING", {"default": "%time", "tooltip": "Supports %date, %time, and custom prompt %placeholders%"}),
"extension": ((['png', 'jpeg', 'webp']),),
+ "hide_preview": ("BOOLEAN", {"default": True, "tooltip": "Hide image preview until mouse hovers over the node."}),
},
"hidden": {
"prompt": "PROMPT",
@@ -147,7 +148,7 @@ class Save_Machine:
OUTPUT_NODE = True
CATEGORY = "SATA_UtilityNode"
- def save_files(self, images, path_and_filename, extension, prompt=None, extra_pnginfo=None):
+ def save_files(self, images, path_and_filename, extension, hide_preview=True, prompt=None, extra_pnginfo=None):
# Resolve placeholders first (uses prompt)
try:
resolved = resolve_placeholders(str(path_and_filename), prompt, extra_pnginfo)
diff --git a/pyproject.toml b/pyproject.toml
index 0be26df..8cd228d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "sata_utilitynode"
description = "A collection of utility nodes including image upscale,touchpad support and CSV-driven prompt machine for ComfyUI."
-version = "1.5.0"
+version = "2.0.0"
license = {file = "LICENSE"}
dependencies = ["opencv-python>=4.5.0", "spandrel", "piexif"]