Release v2.0: Fix Prompt Machine technical input box, connector alignment, and preview masking

This commit is contained in:
Satadal Dhara
2026-08-21 16:57:35 +05:30
parent 5eda61901a
commit addd2f9995
11 changed files with 869 additions and 466 deletions
+5 -5
View File
@@ -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)
+7 -24
View File
@@ -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": [
+308 -85
View File
@@ -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);
};
}
}
+15
View File
@@ -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);
+170 -94
View File
@@ -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 || "<i>No preview available</i>";
// 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 = "<i>No preview available</i>";
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 || "<i>No preview available</i>";
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);
}
}
});
+164 -156
View File
@@ -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);
}
});
+65 -56
View File
@@ -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;
};
if (this.graph.change) {
this.graph.change();
}
event.preventDefault();
return false;
};
}
+2 -1
View File
@@ -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)
+130 -43
View File
@@ -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})
+2 -1
View File
@@ -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)
+1 -1
View File
@@ -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"]