1 Commits
11 changed files with 869 additions and 466 deletions
+5 -5
View File
@@ -1,7 +1,7 @@
# SATA Utility Node Suite for ComfyUI # SATA Utility Node Suite for ComfyUI
**Author:** Satadal **Author:** Satadal
**Version:** 1.5.0 **Version:** 2.0
**License:** MIT **License:** MIT
**Repository:** https://github.com/SatadalAI/SATA_UtilityNode **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: It includes:
- **Upscale Machine:** High-quality model-based image upscaling with Frequency-Split SR and Blue Noise injection. - **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. - **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. - **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. - **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. - **CPU-based Rescaling:** Efficient resizing using OpenCV.
- **Rounding Modulus:** Ensures output dimensions seamlessly align with UNet architectural constraints. - **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. - **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! - **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) ### 💬 Prompt Style Machine (Legacy)
+7 -24
View File
@@ -665,16 +665,7 @@
"flags": {}, "flags": {},
"order": 7, "order": 7,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [],
{
"name": "technical",
"type": "STRING",
"widget": {
"name": "technical"
},
"link": 117
}
],
"outputs": [ "outputs": [
{ {
"name": "prompt", "name": "prompt",
@@ -694,11 +685,13 @@
}, },
"widgets_values": [ "widgets_values": [
"a lady walking down the street with her little daughter in a rainy day. both are wearing multicolored dress.", "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.", "a modern photograph of a rainy day.",
"gloomy daylight.", "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. ", "Cinematic 35mm film photography, fine grain, shallow depth of field. Deep navy blues and warm amber tones.",
"mother has smiling expression on her face and daughter has sprakling eyes with a cute smile.", "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 false
] ]
}, },
@@ -721,9 +714,7 @@
{ {
"name": "prompt", "name": "prompt",
"type": "STRING", "type": "STRING",
"links": [ "links": []
117
]
} }
], ],
"properties": { "properties": {
@@ -916,14 +907,6 @@
11, 11,
0, 0,
"STRING" "STRING"
],
[
117,
68,
0,
67,
0,
"STRING"
] ]
], ],
"groups": [ "groups": [
+275 -52
View File
@@ -8,6 +8,96 @@ const SAVE_MACHINE_CLASS = "Save_Machine";
// Track hover state // Track hover state
const hoveredNodeIds = new Set(); 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 // Helper to get settings for both ComfyUI V1 and V2
function getSettingValue(id, defaultValue) { function getSettingValue(id, defaultValue) {
if (app.extensionManager && app.extensionManager.setting) { if (app.extensionManager && app.extensionManager.setting) {
@@ -35,6 +125,7 @@ app.registerExtension({
], ],
async setup() { async setup() {
injectHiderCSS();
setupGlobalMouseTracker(); setupGlobalMouseTracker();
}, },
@@ -52,6 +143,7 @@ app.registerExtension({
}); });
function isTargetNode(node) { function isTargetNode(node) {
if (!node || !node.comfyClass) return false;
return node.comfyClass === "PreviewImage" || return node.comfyClass === "PreviewImage" ||
node.comfyClass === "SaveImage" || node.comfyClass === "SaveImage" ||
node.comfyClass === PREVIEW_MACHINE_CLASS || node.comfyClass === PREVIEW_MACHINE_CLASS ||
@@ -60,26 +152,25 @@ function isTargetNode(node) {
function shouldHideNode(node) { function shouldHideNode(node) {
const globalHide = getSettingValue(GLOBAL_HIDE_SETTING_ID, false); 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; return true;
} }
if (node.comfyClass === SAVE_MACHINE_CLASS) { return false;
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;
} }
function isMouseOverNode(node) { 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 mouse = app.canvas.graph_mouse;
const x = mouse[0]; const x = mouse[0];
const y = mouse[1]; const y = mouse[1];
@@ -90,6 +181,89 @@ function isMouseOverNode(node) {
y <= (node.pos[1] + node.size[1]); 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() { function setupGlobalMouseTracker() {
let rafPending = false; let rafPending = false;
@@ -112,9 +286,11 @@ function setupGlobalMouseTracker() {
} }
changed = true; changed = true;
} }
syncNodeDOMVisibility(node);
} }
if (changed) { if (changed && app.graph) {
app.graph.setDirtyCanvas(true, true); app.graph.setDirtyCanvas(true, true);
} }
}; };
@@ -137,57 +313,71 @@ 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) { function setupNodeHider(node) {
if (node._sata_hider_v6) return; if (node._sata_hider_v12) return;
node._sata_hider_v6 = true; node._sata_hider_v12 = true;
// Function to check if node should be hidden right now injectHiderCSS();
const shouldHideNow = () => {
const shouldHide = shouldHideNode(node);
const hovered = hoveredNodeIds.has(node.id);
return shouldHide && !hovered;
};
// Find and patch all image-related widgets on the node
const patchWidgets = () => { const patchWidgets = () => {
if (!node.widgets) return; if (!node.widgets) return;
for (const widget of node.widgets) { for (const widget of node.widgets) {
// Check if this is an image widget (they typically have draw/drawWidget methods)
if (widget._sata_patched) continue; if (widget._sata_patched) continue;
// Patch the draw method if it exists if (widget.type === "image" || widget.name === "preview") {
if (widget.draw && typeof widget.draw === 'function') { if (widget.draw && typeof widget.draw === 'function') {
const origDraw = widget.draw.bind(widget); const origDraw = widget.draw.bind(widget);
widget.draw = function (ctx, node, widgetWidth, y, widgetHeight) { widget.draw = function (ctx, nodeRef, widgetWidth, y, widgetHeight) {
if (shouldHideNow()) { const targetNode = nodeRef || node;
// Draw hidden placeholder instead if (hasImages(targetNode) && shouldHideNow(targetNode)) {
ctx.save(); ctx.save();
ctx.fillStyle = "#1a1a1a"; ctx.fillStyle = "#1a1a1a";
ctx.fillRect(0, y, widgetWidth, widgetHeight); ctx.fillRect(0, y, widgetWidth, widgetHeight);
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(); ctx.restore();
return; return;
} }
return origDraw(ctx, node, widgetWidth, y, widgetHeight); return origDraw(ctx, targetNode, widgetWidth, y, widgetHeight);
}; };
widget._sata_patched = true; widget._sata_patched = true;
} }
// Also patch drawWidget if it exists (some widgets use this)
if (widget.drawWidget && typeof widget.drawWidget === 'function') { if (widget.drawWidget && typeof widget.drawWidget === 'function') {
const origDrawWidget = widget.drawWidget.bind(widget); const origDrawWidget = widget.drawWidget.bind(widget);
widget.drawWidget = function (...args) { widget.drawWidget = function (...args) {
if (shouldHideNow()) { if (hasImages(node) && shouldHideNow(node)) {
// Skip drawing - the onDrawForeground will handle the overlay
return; return;
} }
return origDrawWidget(...args); return origDrawWidget(...args);
@@ -195,54 +385,87 @@ function setupNodeHider(node) {
widget._sata_patched = true; widget._sata_patched = true;
} }
} }
}
syncNodeDOMVisibility(node);
}; };
// Patch widgets initially and on any update
patchWidgets(); patchWidgets();
// Also intercept onDrawBackground to catch widget additions
const origOnDrawBackground = node.onDrawBackground; const origOnDrawBackground = node.onDrawBackground;
node.onDrawBackground = function (ctx) { node.onDrawBackground = function (ctx) {
patchWidgets(); // Re-check for new widgets patchWidgets();
syncNodeDOMVisibility(node);
if (origOnDrawBackground) { if (origOnDrawBackground) {
origOnDrawBackground.apply(this, arguments); origOnDrawBackground.apply(this, arguments);
} }
}; };
// Draw overlay in onDrawForeground as backup
const origOnDrawForeground = node.onDrawForeground; const origOnDrawForeground = node.onDrawForeground;
node.onDrawForeground = function (ctx) { node.onDrawForeground = function (ctx) {
if (origOnDrawForeground) { if (origOnDrawForeground) {
origOnDrawForeground.apply(this, arguments); origOnDrawForeground.apply(this, arguments);
} }
if (shouldHideNow()) { syncNodeDOMVisibility(this);
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;
}
}
const maskHeight = Math.max(0, this.size[1] - startY);
if (maskHeight > 10) {
ctx.save(); ctx.save();
ctx.fillStyle = "#1a1a1a"; ctx.fillStyle = "#1a1a1a";
ctx.fillRect(0, 0, this.size[0], this.size[1]); 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.fillStyle = "#888";
ctx.font = "bold 14px Arial"; ctx.font = "bold 13px Arial";
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.fillText("Hidden", this.size[0] / 2, this.size[1] / 2 - 10); ctx.fillText("Hidden", this.size[0] / 2, startY + maskHeight / 2 - 8);
ctx.font = "10px Arial"; ctx.font = "10px Arial";
ctx.fillStyle = "#666"; ctx.fillStyle = "#666";
ctx.fillText("(Hover to Reveal)", this.size[0] / 2, this.size[1] / 2 + 10); ctx.fillText("(Hover to Reveal)", this.size[0] / 2, startY + maskHeight / 2 + 10);
}
ctx.restore(); ctx.restore();
} }
}
};
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);
}; };
// Widget callback for Save_Machine
if (node.comfyClass === SAVE_MACHINE_CLASS) {
const hideWidget = node.widgets?.find(w => w.name === "hide_preview"); const hideWidget = node.widgets?.find(w => w.name === "hide_preview");
if (hideWidget) { if (hideWidget) {
const origCallback = hideWidget.callback; const origCallback = hideWidget.callback;
hideWidget.callback = function (value) { hideWidget.callback = function (value) {
if (origCallback) origCallback.call(this, value); if (origCallback) origCallback.call(this, value);
syncNodeDOMVisibility(node);
if (app.graph) app.graph.setDirtyCanvas(true, true); 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) { async initAutocompleteForNode(node) {
const isTargetNode = node.comfyClass === "PromptAutocomplete" || node.comfyClass === "Prompt_Machine"; const isTargetNode = node.comfyClass === "PromptAutocomplete" || node.comfyClass === "Prompt_Machine";
const isGlobal = getSettingValue("SATA_UtilityNode.PromptAutocomplete.Global", false); const isGlobal = getSettingValue("SATA_UtilityNode.PromptAutocomplete.Global", false);
+120 -44
View File
@@ -1,76 +1,128 @@
// prompt_machine_frontend.js // prompt_machine_frontend.js
import { app } from "../../../scripts/app.js"; import { app } from "../../../scripts/app.js";
app.registerExtension({ const PROMPT_MACHINE_TOOLTIPS = {
name: "SATA_UtilityNode.PromptMachineFrontend", 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.'",
async nodeCreated(node) { 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.'",
if (node.comfyClass !== "Prompt_Style_Machine") return; 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 function setupPromptMachine(node) {
const csvWidget = node.widgets.find(w => w.name === "csv_file"); if (!node.widgets) return;
const oldNameWidget = node.widgets.find(w => w.name === "name"); 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);
}
}
});
}
if (!csvWidget || !oldNameWidget) { function setupPromptStyleMachine(node) {
console.warn("[PromptMachineFrontend] Missing widgets, check backend definition."); if (node._sata_style_initialized) return;
const csvWidget = node.widgets?.find(w => w.name === "csv_file");
let nameWidget = node.widgets?.find(w => w.name === "name");
if (!csvWidget || !nameWidget) {
return; return;
} }
// Remove the old text input for 'name' node._sata_style_initialized = true;
const index = node.widgets.indexOf(oldNameWidget); 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); node.widgets.splice(index, 1);
nameWidget = node.addWidget("combo", "name", initialSavedName, () => { }, {
// Add proper dropdown (this appends to the end) values: [initialSavedName !== "None" ? initialSavedName : "None"]
const nameWidget = node.addWidget("combo", "name", "None", () => { }, {
values: ["None"]
}); });
const newIdx = node.widgets.indexOf(nameWidget);
// Move the new widget back to the original position if (newIdx !== -1 && newIdx !== index) {
// Remove from the end (where addWidget put it) node.widgets.splice(newIdx, 1);
node.widgets.splice(node.widgets.indexOf(nameWidget), 1);
// Insert at the correct index
node.widgets.splice(index, 0, nameWidget); node.widgets.splice(index, 0, nameWidget);
}
}
}
if (!nameWidget.options) {
nameWidget.options = { values: [initialSavedName] };
}
// Store available names
let namesConfig = []; let namesConfig = [];
// Fetch names from backend for a given CSV // Fetch names from backend for a given CSV
async function loadNames(csvValue) { async function loadNames(csvValue, preferredName) {
if (!csvValue) return; if (!csvValue || csvValue === "None") return;
try { try {
const resp = await fetch(`/sata/prompt_machine/names?csv=${encodeURIComponent(csvValue)}`); const resp = await fetch(`/sata/prompt_machine/names?csv=${encodeURIComponent(csvValue)}`);
const data = await resp.json(); const data = await resp.json();
namesConfig = data.names || ["None"]; namesConfig = (data.names && data.names.length > 0) ? data.names : ["None"];
refreshNames(); refreshNames(preferredName);
} catch (err) { } catch (err) {
console.error("[PromptMachineFrontend] Failed to load names:", err); console.error("[PromptMachineFrontend] Failed to load names:", err);
} }
} }
// Refresh dropdown with available names // Refresh dropdown with available names while preserving target selection
function refreshNames() { function refreshNames(preferredName) {
const available = namesConfig.length > 0 ? namesConfig : ["None"]; const available = namesConfig.length > 0 ? namesConfig : ["None"];
nameWidget.options.values = available; nameWidget.options.values = available;
if (!available.includes(nameWidget.value)) { const targetVal = preferredName !== undefined ? preferredName : nameWidget.value;
nameWidget.value = available[0]; // safe fallback if (available.includes(targetVal)) {
nameWidget.value = targetVal;
} else {
nameWidget.value = available[0] || "None";
} }
// Force update previews for the new name // Force update previews for current name
updatePreviews(nameWidget.value); updatePreviews(nameWidget.value);
app.graph.change(); if (app.graph) {
if (app.graph.setDirtyCanvas) app.graph.setDirtyCanvas(true, true);
if (app.graph.change) app.graph.change();
}
} }
// Hook CSV change // Hook CSV change (user triggered)
const oldCsvCallback = csvWidget.callback; const oldCsvCallback = csvWidget.callback;
csvWidget.callback = function (value) { csvWidget.callback = function (value) {
if (oldCsvCallback) oldCsvCallback(value); if (oldCsvCallback) oldCsvCallback.apply(this, arguments);
loadNames(value); // refresh dynamically loadNames(value); // refresh without preferred name (defaults to first available)
}; };
// --- Preview Logic --- // --- Preview Logic ---
const previewEl = document.createElement("div"); let previewEl = node.widgets?.find(w => w.name === "preview_text")?.element;
if (!previewEl) {
previewEl = document.createElement("div");
Object.assign(previewEl.style, { Object.assign(previewEl.style, {
marginTop: "10px", marginTop: "10px",
padding: "8px", padding: "8px",
@@ -86,12 +138,17 @@ app.registerExtension({
node.addDOMWidget("preview_text", "html", previewEl, { node.addDOMWidget("preview_text", "html", previewEl, {
getValue: () => previewEl.innerHTML, getValue: () => previewEl.innerHTML,
setValue: (v) => { previewEl.innerHTML = v; } setValue: (v) => { previewEl.innerHTML = v; },
serialize: false
}); });
}
async function updatePreviews(name) { async function updatePreviews(name) {
const csv = csvWidget.value; const csv = csvWidget.value;
if (!csv || !name || name === "None") return; if (!csv || !name || name === "None") {
previewEl.innerHTML = "<i>No preview available</i>";
return;
}
try { try {
const resp = await fetch(`/sata/prompt_machine/get?csv=${encodeURIComponent(csv)}&name=${encodeURIComponent(name)}`); const resp = await fetch(`/sata/prompt_machine/get?csv=${encodeURIComponent(csv)}&name=${encodeURIComponent(name)}`);
@@ -101,10 +158,14 @@ app.registerExtension({
previewEl.innerHTML = html || "<i>No preview available</i>"; previewEl.innerHTML = html || "<i>No preview available</i>";
// Adjust node size to fit content if (node.computeSize && node.setSize) {
node.setSize(node.computeSize()); node.setSize(node.computeSize());
}
app.graph.change(); if (app.graph) {
if (app.graph.setDirtyCanvas) app.graph.setDirtyCanvas(true, true);
if (app.graph.change) app.graph.change();
}
} catch (err) { } catch (err) {
console.error("[PromptMachineFrontend] Failed to load preview:", err); console.error("[PromptMachineFrontend] Failed to load preview:", err);
} }
@@ -113,13 +174,28 @@ app.registerExtension({
// Hook Name change // Hook Name change
const oldNameCallback = nameWidget.callback; const oldNameCallback = nameWidget.callback;
nameWidget.callback = function (value) { nameWidget.callback = function (value) {
if (oldNameCallback) oldNameCallback(value); if (oldNameCallback) oldNameCallback.apply(this, arguments);
updatePreviews(value); updatePreviews(value);
}; };
// Initial sync // Initial sync preserving saved value
loadNames(csvWidget.value).then(() => { loadNames(csvWidget.value, initialSavedName);
updatePreviews(nameWidget.value); }
});
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);
}
} }
}); });
+47 -39
View File
@@ -1,23 +1,22 @@
import { app } from "../../../scripts/app.js"; import { app } from "../../../scripts/app.js";
app.registerExtension({ function setupResolutionNode(node) {
name: "SATA_UtilityNode.Resolution_Machine", if (node._sata_res_initialized) return;
async nodeCreated(node) {
if (node.comfyClass !== "Resolution_Machine" && node.comfyClass !== "Latent_Machine") return; if (node.comfyClass !== "Resolution_Machine" && node.comfyClass !== "Latent_Machine") return;
// Find widgets // Find widgets
const modelWidget = node.widgets.find(w => w.name === "model"); const modelWidget = node.widgets?.find(w => w.name === "model");
const dimensionWidget = node.widgets.find(w => w.name === "dimension"); const dimensionWidget = node.widgets?.find(w => w.name === "dimension");
const resolutionWidget = node.widgets.find(w => w.name === "resolution"); 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 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"); const heightWidget = node.widgets?.find(w => w.name === "height") || node.widgets?.find(w => w.name === "custom_height");
if (!modelWidget || !dimensionWidget || !resolutionWidget || !widthWidget || !heightWidget) { if (!modelWidget || !dimensionWidget || !resolutionWidget || !widthWidget || !heightWidget) {
console.warn("[Resolution_Machine] Missing widgets, check backend definition.");
return; return;
} }
node._sata_res_initialized = true;
// Store resolution config // Store resolution config
let resolutionsConfig = {}; let resolutionsConfig = {};
@@ -26,16 +25,14 @@ app.registerExtension({
try { try {
const resp = await fetch("/SATA_UtilityNode/resolutions_config"); const resp = await fetch("/SATA_UtilityNode/resolutions_config");
resolutionsConfig = await resp.json(); resolutionsConfig = await resp.json();
refreshResolutions(false);
} catch (err) { } catch (err) {
console.error("[Resolution_Machine] Failed to load config:", err); console.error("[Resolution_Machine] Failed to load config:", err);
} }
} }
await loadConfig();
// --- Utility: refresh resolution dropdown when model/dimension changes --- // --- Utility: refresh resolution dropdown when model/dimension changes ---
function refreshResolutions() { function refreshResolutions(resetSelection = true) {
const selectedModel = modelWidget.value; const selectedModel = modelWidget.value;
const selectedDimension = dimensionWidget.value; const selectedDimension = dimensionWidget.value;
@@ -44,32 +41,29 @@ app.registerExtension({
if (resolutionsConfig.models && resolutionsConfig.models[selectedModel]) { if (resolutionsConfig.models && resolutionsConfig.models[selectedModel]) {
const buckets = resolutionsConfig.models[selectedModel]; const buckets = resolutionsConfig.models[selectedModel];
buckets.forEach(bucket => { buckets.forEach(bucket => {
// Check if bucket exists and has the selected dimension
if (resolutionsConfig.resolutions && resolutionsConfig.resolutions[bucket] && resolutionsConfig.resolutions[bucket][selectedDimension]) { if (resolutionsConfig.resolutions && resolutionsConfig.resolutions[bucket] && resolutionsConfig.resolutions[bucket][selectedDimension]) {
available.push(...Object.keys(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 // Always include "Custom" option
if (!available.includes("Custom")) { if (!available.includes("Custom")) {
available.push("Custom"); available.push("Custom");
} }
resolutionWidget.options.values = available; resolutionWidget.options.values = available;
// Pick safe default if current value is not valid if (resetSelection && !available.includes(resolutionWidget.value)) {
if (!available.includes(resolutionWidget.value)) { resolutionWidget.value = available[0] || "Custom";
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(); checkCustomMode();
} }
function checkCustomMode() { function checkCustomMode() {
if (resolutionWidget.value === "Custom") { const isCustom = resolutionWidget.value === "Custom";
if (isCustom) {
widthWidget.type = "INT"; widthWidget.type = "INT";
heightWidget.type = "INT"; heightWidget.type = "INT";
delete widthWidget.computeSize; delete widthWidget.computeSize;
@@ -83,8 +77,14 @@ app.registerExtension({
heightWidget.computeSize = () => [0, -4]; heightWidget.computeSize = () => [0, -4];
applyResolutionPreset(); applyResolutionPreset();
} }
if (node.computeSize && node.setSize) {
node.setSize(node.computeSize()); node.setSize(node.computeSize());
app.graph.change(); }
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 --- // --- Utility: apply resolution preset to width/height ---
@@ -99,11 +99,11 @@ app.registerExtension({
let resolutionData = null; let resolutionData = null;
// Lookup
if (resolutionsConfig.models && resolutionsConfig.models[selectedModel]) { if (resolutionsConfig.models && resolutionsConfig.models[selectedModel]) {
const buckets = resolutionsConfig.models[selectedModel]; const buckets = resolutionsConfig.models[selectedModel];
for (const bucket of buckets) { for (const bucket of buckets) {
if (resolutionsConfig.resolutions[bucket] && if (resolutionsConfig.resolutions &&
resolutionsConfig.resolutions[bucket] &&
resolutionsConfig.resolutions[bucket][selectedDimension] && resolutionsConfig.resolutions[bucket][selectedDimension] &&
resolutionsConfig.resolutions[bucket][selectedDimension][selectedResolution]) { resolutionsConfig.resolutions[bucket][selectedDimension][selectedResolution]) {
@@ -122,44 +122,52 @@ app.registerExtension({
// --- Hook model change --- // --- Hook model change ---
const oldModelCallback = modelWidget.callback; const oldModelCallback = modelWidget.callback;
modelWidget.callback = function (value) { modelWidget.callback = function (value) {
if (oldModelCallback) oldModelCallback(value); if (oldModelCallback) oldModelCallback.apply(this, arguments);
refreshResolutions(); refreshResolutions(true);
}; };
// --- Hook dimension change --- // --- Hook dimension change ---
const oldDimensionCallback = dimensionWidget.callback; const oldDimensionCallback = dimensionWidget.callback;
dimensionWidget.callback = function (value) { dimensionWidget.callback = function (value) {
if (oldDimensionCallback) oldDimensionCallback(value); if (oldDimensionCallback) oldDimensionCallback.apply(this, arguments);
refreshResolutions(); refreshResolutions(true);
}; };
// --- Hook resolution change --- // --- Hook resolution change ---
const oldResolutionCallback = resolutionWidget.callback; const oldResolutionCallback = resolutionWidget.callback;
resolutionWidget.callback = function (value) { resolutionWidget.callback = function (value) {
if (oldResolutionCallback) oldResolutionCallback(value); if (oldResolutionCallback) oldResolutionCallback.apply(this, arguments);
checkCustomMode(); checkCustomMode();
}; };
// --- Add Custom Button --- // --- Add Custom Button (once) ---
// Rename "Swap Dimensions" -> "Custom" if (!node.widgets.some(w => w.name === "Custom")) {
// Note: Node logic adds this button. Previous JS added it. We are replacing previous JS logic.
node.addWidget("button", "Custom", null, () => { node.addWidget("button", "Custom", null, () => {
// Unlock widgets
widthWidget.disabled = false; widthWidget.disabled = false;
heightWidget.disabled = false; heightWidget.disabled = false;
// Ensure "Custom" option exists and is selected if (resolutionWidget.options && resolutionWidget.options.values) {
if (!resolutionWidget.options.values.includes("Custom")) { if (!resolutionWidget.options.values.includes("Custom")) {
resolutionWidget.options.values.push("Custom"); resolutionWidget.options.values.push("Custom");
} }
}
resolutionWidget.value = "Custom"; resolutionWidget.value = "Custom";
checkCustomMode(); checkCustomMode();
app.graph.change();
}); });
}
// --- Initial sync --- loadConfig();
refreshResolutions(); }
app.registerExtension({
name: "SATA_UtilityNode.Resolution_Machine",
async nodeCreated(node) {
setupResolutionNode(node);
},
async loadedGraphNode(node) {
setupResolutionNode(node);
} }
}); });
+14 -5
View File
@@ -20,8 +20,8 @@ app.registerExtension({
} }
], ],
async setup() { async setup() {
// Create a toggle button in the ComfyUI menu // Create a toggle button in the ComfyUI menu (Legacy V1)
const menu = document.querySelector(".comfy-menu"); const menu = document.querySelector(".comfy-menu") || document.querySelector(".comfyui-menu");
if (menu) { if (menu) {
const toggleBtn = document.createElement("button"); const toggleBtn = document.createElement("button");
toggleBtn.id = "sata-touchpad-toggle-btn"; toggleBtn.id = "sata-touchpad-toggle-btn";
@@ -74,8 +74,9 @@ app.registerExtension({
/** /**
* Enhanced smooth scrolling and zooming for touchpad gestures * Enhanced smooth scrolling and zooming for touchpad gestures
* Overrides LGraphCanvas.prototype.processMouseWheel * Overrides LGraphCanvas.prototype.processMouseWheel if available
*/ */
if (typeof LGraphCanvas !== "undefined" && LGraphCanvas.prototype) {
const originalProcessMouseWheel = LGraphCanvas.prototype.processMouseWheel; const originalProcessMouseWheel = LGraphCanvas.prototype.processMouseWheel;
LGraphCanvas.prototype.processMouseWheel = function (/** @type {WheelEvent} */ event) { LGraphCanvas.prototype.processMouseWheel = function (/** @type {WheelEvent} */ event) {
@@ -95,9 +96,10 @@ LGraphCanvas.prototype.processMouseWheel = function (/** @type {WheelEvent} */ e
return false; return false;
} }
if (!this.graph || !this.allow_dragcanvas) return; if (!this.graph || !this.allow_dragcanvas || !this.ds) return;
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas ? this.canvas.getBoundingClientRect() : null;
if (!rect) return;
const x = event.clientX - rect.left; const x = event.clientX - rect.left;
const y = event.clientY - rect.top; const y = event.clientY - rect.top;
const { deltaX, deltaY, ctrlKey, metaKey } = event; const { deltaX, deltaY, ctrlKey, metaKey } = event;
@@ -123,13 +125,20 @@ LGraphCanvas.prototype.processMouseWheel = function (/** @type {WheelEvent} */ e
const zoomDirection = Math.sign(deltaY); const zoomDirection = Math.sign(deltaY);
const zoomAmount = zoomDirection * Math.abs(deltaY) * ZOOM_SENSITIVITY; const zoomAmount = zoomDirection * Math.abs(deltaY) * ZOOM_SENSITIVITY;
const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, currentScale - zoomAmount)); const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, currentScale - zoomAmount));
if (this.ds.changeScale) {
this.ds.changeScale(newScale, [x, y]); this.ds.changeScale(newScale, [x, y]);
}
} else { } else {
// Pan mode // Pan mode
if (this.ds.mouseDrag) {
this.ds.mouseDrag(-deltaX * PAN_SENSITIVITY, -deltaY * PAN_SENSITIVITY); this.ds.mouseDrag(-deltaX * PAN_SENSITIVITY, -deltaY * PAN_SENSITIVITY);
} }
}
if (this.graph.change) {
this.graph.change(); this.graph.change();
}
event.preventDefault(); event.preventDefault();
return false; return false;
}; };
}
+2 -1
View File
@@ -6,6 +6,7 @@ class Preview_Machine(PreviewImage):
return { return {
"required": { "required": {
"images": ("IMAGE",), "images": ("IMAGE",),
"hide_preview": ("BOOLEAN", {"default": True, "tooltip": "Hide image preview until mouse hovers over the node."}),
}, },
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
} }
@@ -15,5 +16,5 @@ class Preview_Machine(PreviewImage):
CATEGORY = "SATA_UtilityNode" CATEGORY = "SATA_UtilityNode"
FUNCTION = "save_images" 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) 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.""" """Return list of names (stripped) from CSV's 'name' column."""
if not filename: if not filename:
return [] 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): if not os.path.exists(path):
print(f"[PromptMachine] CSV not found: {path}") print(f"[PromptMachine] CSV not found: {path}")
return [] return []
@@ -26,11 +27,11 @@ def read_names_from_csv(filename):
with open(path, "r", encoding="utf-8-sig") as f: with open(path, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f) reader = csv.DictReader(f)
if not reader.fieldnames: if not reader.fieldnames:
print(f"[PromptMachine] CSV has no header: {filename}") print(f"[PromptMachine] CSV has no header: {safe_filename}")
return [] return []
field_map = {fn.strip().lower(): fn for fn in reader.fieldnames} field_map = {fn.strip().lower(): fn for fn in reader.fieldnames}
if "name" not in field_map: if "name" not in field_map:
print(f"[PromptMachine] CSV missing 'name' column: {filename}") print(f"[PromptMachine] CSV missing 'name' column: {safe_filename}")
return [] return []
name_field = field_map["name"] name_field = field_map["name"]
names = [] names = []
@@ -38,12 +39,12 @@ def read_names_from_csv(filename):
raw = row.get(name_field) raw = row.get(name_field)
if raw is None: if raw is None:
continue continue
val = raw.strip() val = str(raw).strip()
if val: if val:
names.append(val) names.append(val)
return names return names
except Exception as e: 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 [] return []
def read_prompt_row(csv_file, name): def read_prompt_row(csv_file, name):
@@ -53,7 +54,8 @@ def read_prompt_row(csv_file, name):
return ("", "", "") return ("", "", "")
if not name or not isinstance(name, str) or name == "None": if not name or not isinstance(name, str) or name == "None":
return ("", "", "") 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): if not os.path.exists(path):
print(f"[PromptMachine] CSV file not found: {path}") print(f"[PromptMachine] CSV file not found: {path}")
return ("", "", "") return ("", "", "")
@@ -61,11 +63,11 @@ def read_prompt_row(csv_file, name):
with open(path, "r", encoding="utf-8-sig") as f: with open(path, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f) reader = csv.DictReader(f)
if not reader.fieldnames: if not reader.fieldnames:
print(f"[PromptMachine] CSV has no header: {csv_file}") print(f"[PromptMachine] CSV has no header: {safe_csv_file}")
return ("", "", "") return ("", "", "")
field_map = {fn.strip().lower(): fn for fn in reader.fieldnames} field_map = {fn.strip().lower(): fn for fn in reader.fieldnames}
if "name" not in field_map: 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 ("", "", "") return ("", "", "")
name_field = field_map["name"] name_field = field_map["name"]
pos_field = field_map.get("positive", "positive") pos_field = field_map.get("positive", "positive")
@@ -77,13 +79,13 @@ def read_prompt_row(csv_file, name):
cell = row.get(name_field) cell = row.get(name_field)
if cell is None: if cell is None:
continue continue
if cell.strip() == target: if str(cell).strip() == target:
pos = (row.get(pos_field) or "").strip() pos = str(row.get(pos_field) or "").strip()
neg = (row.get(neg_field) or "").strip() neg = str(row.get(neg_field) or "").strip()
note = (row.get(note_field) or "").strip() note = str(row.get(note_field) or "").strip()
return (pos, neg, note) return (pos, neg, note)
except Exception as e: 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 ("", "", "") return ("", "", "")
@@ -126,17 +128,66 @@ class Prompt_Style_Machine:
class Prompt_Machine: class Prompt_Machine:
"""
Prompt Machine (8-Category Framework):
Assembles information-dense scene descriptions in natural prose following an inside-out spatial sequence.
"""
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
"required": { "required": {
"subject": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}), "subject": ("STRING", {
"style": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}), "multiline": True,
"lighting": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}), "default": "",
"composition": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}), "dynamicPrompts": True,
"mood": ("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.'"
"technical": ("STRING", {"multiline": True, "default": "", "dynamicPrompts": True}), }),
"json_output": ("BOOLEAN", {"default": False}), "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" FUNCTION = "get_prompt"
CATEGORY = "SATA_UtilityNode" 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: if json_output:
import json import json
data = { data = {k: v for k, v in cleaned_fields}
"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}
return (json.dumps(data, indent=2),) return (json.dumps(data, indent=2),)
else:
parts = [] sentences = []
if subject.strip(): parts.append(subject.strip()) for name, text in cleaned_fields:
if style.strip(): parts.append(style.strip()) # Strip trailing separators like commas, semicolons, colons, or dashes before period
if lighting.strip(): parts.append(lighting.strip()) trimmed = text.rstrip(" ,;:-")
if composition.strip(): parts.append(composition.strip()) if trimmed:
if mood.strip(): parts.append(mood.strip()) if trimmed[-1] not in ".!?":
if technical.strip(): parts.append(technical.strip()) trimmed += "."
return (", ".join(parts),) sentences.append(trimmed)
paragraph = " ".join(sentences)
return (paragraph,)
# ---------------- REST API ---------------- # ---------------- REST API ----------------
@@ -182,7 +267,8 @@ async def list_csvs(request):
@PromptServer.instance.routes.get("/sata/prompt_machine/names") @PromptServer.instance.routes.get("/sata/prompt_machine/names")
async def list_names(request): async def list_names(request):
"""Return names from a selected CSV""" """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 [] names = read_names_from_csv(csv_file) if csv_file else []
return web.json_response({"names": names}) return web.json_response({"names": names})
@@ -193,7 +279,8 @@ async def get_prompt(request):
Return the prompt triple for immediate frontend consumption. Return the prompt triple for immediate frontend consumption.
Example: /sata/prompt_machine/get?csv=characters.csv&name=Bob Example: /sata/prompt_machine/get?csv=characters.csv&name=Bob
""" """
csv_file = request.query.get("csv") raw_csv = request.query.get("csv", "")
name = request.query.get("name") 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) pos, neg, note = read_prompt_row(csv_file, name)
return web.json_response({"positive": pos, "negative": neg, "note": note}) return web.json_response({"positive": pos, "negative": neg, "note": note})
+2 -1
View File
@@ -134,6 +134,7 @@ class Save_Machine:
"images": ("IMAGE", ), "images": ("IMAGE", ),
"path_and_filename": ("STRING", {"default": "%time", "tooltip": "Supports %date, %time, and custom prompt %placeholders%"}), "path_and_filename": ("STRING", {"default": "%time", "tooltip": "Supports %date, %time, and custom prompt %placeholders%"}),
"extension": ((['png', 'jpeg', 'webp']),), "extension": ((['png', 'jpeg', 'webp']),),
"hide_preview": ("BOOLEAN", {"default": True, "tooltip": "Hide image preview until mouse hovers over the node."}),
}, },
"hidden": { "hidden": {
"prompt": "PROMPT", "prompt": "PROMPT",
@@ -147,7 +148,7 @@ class Save_Machine:
OUTPUT_NODE = True OUTPUT_NODE = True
CATEGORY = "SATA_UtilityNode" 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) # Resolve placeholders first (uses prompt)
try: try:
resolved = resolve_placeholders(str(path_and_filename), prompt, extra_pnginfo) resolved = resolve_placeholders(str(path_and_filename), prompt, extra_pnginfo)
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "sata_utilitynode" name = "sata_utilitynode"
description = "A collection of utility nodes including image upscale,touchpad support and CSV-driven prompt machine for ComfyUI." 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"} license = {file = "LICENSE"}
dependencies = ["opencv-python>=4.5.0", "spandrel", "piexif"] dependencies = ["opencv-python>=4.5.0", "spandrel", "piexif"]