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