@@ -2,6 +2,8 @@
|
||||
|
||||
Some basic custom JavaScript files for the ComfyUI user interface. Features:
|
||||
|
||||
+ Spawner: adds multiple nodes at once, with collision detection to avoid placing over your current nodes
|
||||
+ Miniamp: provides an overview of your workflow and allows for quick navigation to any node by clicking on it in the minimap
|
||||
+ Fontifier: a script that allows you to change fonts and font sizes for ComfyUI nodes
|
||||
+ More to come (maybe)
|
||||
|
||||
@@ -10,9 +12,25 @@ When using the [ComfyUI](https://github.com/comfyanonymous/ComfyUI) interface fo
|
||||
Rightly or wrongly, I was teaching myself a bit of Python back in 2023 to get some nodes up and running to do what I'd like, and I now I am doing the same for JavaScript. Yes, I am using ChatGPT, Copilot, Claude and others, and yes, I am a glutton for punishment. There are no promises that these scripts will work for you or that I will maintain them. Feel free to do with them as you wish, according to the license model.
|
||||
|
||||
***
|
||||
**UPDATE: JUL 25, 2025**
|
||||
|
||||
|
||||
+ Added the Endless 🌊✨ Node Spawner, which accesses your node library and allows you to select nodes to places
|
||||
+ Categorizes your nodes based on the ComfyUI menu system
|
||||
+ Places nodes with intelligent collision avoidance so it will not overlap existing ones
|
||||
+ Real time search and filtering off nodes to add
|
||||
+ Added the Endless 🌊✨ Minimap, which shows the current workflow landscape in a floating map
|
||||
+ Colour coded nodes by category
|
||||
+ Click on any node in the map to jump to it
|
||||
+ Respects window aspect ratio
|
||||
+ Places nodes with intelligent collision avoidance so it will not overlap existing ones
|
||||
+ Real time search and filtering off nodes to add
|
||||
+ Updated the Endless 🌊✨ Fontifier to allow for persistent state saving.
|
||||
|
||||
|
||||
**UPDATE: JUL 20, 2025**
|
||||
|
||||
+ **Cloned the scripts from [Endless Sea of Stars Nodes](https://github.com/tusharbhutt/Endless-Nodes) so you can just download the scripts if you want**
|
||||
+ **Cloned the scripts from [Endless Sea of Stars Nodes](https://github.com/tusharbhutt/Endless-Nodes) so you can just download these scripts alone**
|
||||
+ Added the Endless 🌊✨ Tools button to taskbar. This acts as a centralized repository of all my scripts
|
||||
+ Introduced the Endless 🌊✨ Fontifier, a script that allows you to dynamically change fonts and sizes.
|
||||
+ No need to dive into CSS to change text size
|
||||
@@ -28,11 +46,13 @@ It’s preferable to install from the ComfyUI Node Manager, but for direct insta
|
||||
|
||||
`git clone https://github.com/tusharbhutt/Endless-Buttons`
|
||||
|
||||
**NOTE: The scripts are also available as part of [Endless Sea of Stars Nodes](https://github.com/tusharbhutt/Endless-Nodes), which features a set of useful nodes**
|
||||
|
||||
If installed correctly, you should see a button in the main ComfyUI taskbar that look like this:
|
||||
|
||||
Endless 🌊✨ Tools
|
||||
|
||||
and when you click it, a floating toolbar with available scripts will be enabled. **DON'T SEE THE BUTTON IN THE TOOLBAR? PRESS CTRL-ALT-E TO BRING THE TOOLBAR**
|
||||
and when you click it, a floating toolbar with available scripts will be enabled. Dismiss the floating toolbar with the ESC key. **DON'T SEE THE BUTTON IN THE TOOLBAR? PRESS CTRL-ALT-E TO BRING THE TOOLBAR**
|
||||
|
||||
***
|
||||
## Assistance Requests
|
||||
@@ -48,7 +68,84 @@ If you have issues, ask me **nicely** for help. Your tone matters; I'm too old a
|
||||
|
||||

|
||||
|
||||
**IF YOU DO NOT SEE IT, PRESS CTRL-ALT-E TO BRING UP A FLOATING TOOLBAR THAT CAN BE MOVED AROUND** This is the central toolbar that contains all the scripts I have made for this collection
|
||||
**IF YOU DO NOT SEE IT, PRESS CTRL-ALT-E TO BRING UP A FLOATING TOOLBAR THAT CAN BE MOVED AROUND** This is the central toolbar that contains all the scripts I have made for this collection. Get rid of it by pressing the ESC key.
|
||||
|
||||
|
||||
### Endless 🌊✨ Node Spawner
|
||||
|
||||
I find that sometimes I need to create a few nodes for a workflow and creating them one at a time is painful for me. So, I made the Endless 🌊✨ Node Spawner. The spawner has a searchable, categorized interface that supports batch operations and maintains usage history for improved efficiency. Click the Endless 🌊✨ Tools button to bring up the floating toolbar and you should see a choice for "🌊✨ Node Spawner". Clicking it shows the dialog box below:
|
||||
|
||||

|
||||
|
||||
|
||||
The node spawner has the following features:
|
||||
|
||||
+ Hierarchical categorization of all available nodes
|
||||
+ Real-time search and filtering capabilities
|
||||
+ Search history with dropdown suggestions
|
||||
+ Batch node selection and spawning
|
||||
+ Intelligent collision detection for node placement
|
||||
+ Category-level selection controls
|
||||
+ Persistent usage tracking and search history
|
||||
|
||||
|
||||
Here's a quick overview of how to use the spawner:
|
||||
|
||||
+ Open the Node Loader from the Endless Tools menu
|
||||
+ Browse categories or use the search filter to find specific nodes
|
||||
+ Select nodes individually or use category selection buttons
|
||||
+ Review selections in the counter display
|
||||
+ Click Spawn Nodes to add selected nodes to your workflow
|
||||
+ Recently used nodes appear as clickable chips for quick access
|
||||
|
||||
Once you have made your selections and applied them, all the nodes you created will appear. How fast is it? My system can create 950 nodes in less than two seconds.
|
||||
|
||||

|
||||
|
||||
|
||||
### Endless 🌊✨ Minimap
|
||||
|
||||
When you have large workflows, it can be hard to keep tack of everything on the screen. The ComfyUI web interface does have a button to resize the nodes to your screen, but I thought a minimap would be of use to some people. The minimap displays a scaled overview of all nodes with visual indicators for the current viewport and support for direct navigation. Click the Endless 🌊✨ Tools button to bring up the floating toolbar and you should see a choice for "🌊✨ Minimap". Clicking it shows the dialog box below:
|
||||
|
||||
|
||||

|
||||
|
||||
The minimap has the following features:
|
||||
|
||||
+ Dynamic aspect ratio adjustment based on canvas dimensions
|
||||
+ Real-time viewport highlighting with theme-aware colors
|
||||
+ Interactive click-to-navigate functionality
|
||||
+ Zoom and pan controls for detailed exploration
|
||||
+ Color-coded node types with optional legend display
|
||||
+ Responsive resizing based on window dimensions
|
||||
+ Drag-and-drop repositioning of the minimap window
|
||||
|
||||
|
||||
Drag the box around by clicking and holding the title. To cancel, you can simply click outside the dialog box or press the escape key. With this dialog box, you can do the following:
|
||||
|
||||
+ Use the minimap to understand your workflow's overall structure
|
||||
+ Click anywhere on the minimap to jump to that location
|
||||
+ Click a node to jump to the node
|
||||
+ Use zoom controls (+/-) or mouse wheel for detailed viewing
|
||||
+ Toggle the legend (🎨) to identify node types by color
|
||||
|
||||
|
||||
The drag bar has the following icons:
|
||||
|
||||
|
||||

|
||||
|
||||
The icons correspond to:
|
||||
|
||||
🎨 Toggle legend
|
||||
▫ Zoom out
|
||||
🏠 Reset zoom and pan
|
||||
⬜ Zoom in
|
||||
❌ Close minimap
|
||||
|
||||
When you zoom in to the minimap, you can see the labels of the nodes as well, like shown below. The minimap should also change shape to respect the browser window shape, and works with ComfyUI themes.
|
||||
|
||||

|
||||
|
||||
|
||||
### Endless 🌊✨ Fontifier
|
||||
@@ -65,9 +162,10 @@ Drag the box around by clicking and holding the title. To cancel, you can simpl
|
||||
+ Change the fonts themselves
|
||||
+ Instead of a global change, select various elements to resize
|
||||
+ Adjust the height of the title bar or connectors and other input areas
|
||||
+ Save the current state for persistent use, or revert to ComfyUI defaults
|
||||
|
||||
|
||||
Once you make your changes, you can preview them and then choose to apply or cancel. Changed your mind? Load the box again and press the reset key.
|
||||
Once you make your changes, you can preview them and then choose to apply or cancel. Changed your mind? Load the box again and press the reset key. You can also save your settings for persistent use.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
WEB_DIRECTORY = "./web/"
|
||||
|
||||
# Version info
|
||||
__version__ = "1.0.2"
|
||||
__version__ = "1.1.0
|
||||
|
||||
print("\n===============================")
|
||||
print(f"Endless Sea of Stars Buttons v{__version__} loaded successfully! 🌠")
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
July 25/25, V1.2: Added the Endless Node Spawner and Node minimap. The node spawner crates multiple nodes at once, with collision avoidance for existing nodes. The minimap shows an overview of your workflow and allows for quick navigation of your nodes
|
||||
July 20/25, V1.0.2: Introducing the Endless Fontifier, a JavaScript file that adds allows the user to change font sizes and fonts for various text elements on the ComfyUI interface.
|
||||
|
||||
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 589 KiB |
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "endless-buttons"
|
||||
description = "A small set of JavaScript files I created for myself. The scripts provide Quality of Life enhancements to the ComfyUI interface, such as changing fonts and font sizes."
|
||||
version = "1.0.3"
|
||||
version = "1.2.0"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = ""
|
||||
|
||||
|
||||
@@ -1,442 +1,381 @@
|
||||
// ComfyUI Endless 🌊✨ Fontifier - Fixed Version
|
||||
import { registerEndlessTool } from './endless_ui_helpers.js';
|
||||
// ComfyUI Endless 🌊✨ Fontifier - Fully Fixed Version
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Store original ComfyUI default values for reset functionality
|
||||
(function waitForHelpers() {
|
||||
if (typeof window.EndlessHelpers === 'undefined') {
|
||||
console.warn("⏳ Waiting for EndlessHelpers to be ready...");
|
||||
setTimeout(waitForHelpers, 100); // Retry every 100ms
|
||||
return;
|
||||
}
|
||||
|
||||
// Load helpers from window
|
||||
const {
|
||||
registerEndlessTool,
|
||||
injectEndlessToolsButton,
|
||||
showEndlessToolMenu,
|
||||
onThemeChange,
|
||||
getComfyUIColors,
|
||||
toRGBA,
|
||||
blendColors,
|
||||
addButtonHoverEffects,
|
||||
makeDraggable
|
||||
} = window.EndlessHelpers;
|
||||
|
||||
// === ORIGINAL COMFYUI DEFAULTS ===
|
||||
const originalValues = {
|
||||
NODE_TEXT_SIZE: 14,
|
||||
NODE_SUBTEXT_SIZE: 12,
|
||||
NODE_TITLE_HEIGHT: 30,
|
||||
DEFAULT_GROUP_FONT: 24,
|
||||
NODE_FONT: 'Arial',
|
||||
NODE_SLOT_HEIGHT: 20,
|
||||
NODE_WIDGET_HEIGHT: 20,
|
||||
WIDGET_TEXT_SIZE: 12 // Added widget text size to original values
|
||||
NODE_TEXT_SIZE: LiteGraph.NODE_TEXT_SIZE || 14,
|
||||
NODE_SUBTEXT_SIZE: LiteGraph.NODE_SUBTEXT_SIZE || 12,
|
||||
NODE_TITLE_HEIGHT: LiteGraph.NODE_TITLE_HEIGHT || 30,
|
||||
DEFAULT_GROUP_FONT: LiteGraph.DEFAULT_GROUP_FONT || 24,
|
||||
NODE_FONT: LiteGraph.NODE_FONT || 'Arial',
|
||||
NODE_SLOT_HEIGHT: LiteGraph.NODE_SLOT_HEIGHT || 20,
|
||||
NODE_WIDGET_HEIGHT: LiteGraph.NODE_WIDGET_HEIGHT || 20,
|
||||
WIDGET_TEXT_SIZE: 12,
|
||||
GLOBAL_SCALE: 1
|
||||
};
|
||||
|
||||
// Current values (will be updated as user changes them)
|
||||
let currentValues = { ...originalValues };
|
||||
|
||||
// Values when dialog opens (for cancel/preview rollback)
|
||||
let dialogOpenValues = {};
|
||||
|
||||
// Get ComfyUI theme colors
|
||||
function getComfyUIColors() {
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
background: computedStyle.getPropertyValue('--comfy-menu-bg') || '#353535',
|
||||
backgroundSecondary: computedStyle.getPropertyValue('--comfy-input-bg') || '#222',
|
||||
border: computedStyle.getPropertyValue('--border-color') || '#999',
|
||||
text: computedStyle.getPropertyValue('--input-text') || '#ddd',
|
||||
textSecondary: computedStyle.getPropertyValue('--descrip-text') || '#999',
|
||||
accent: computedStyle.getPropertyValue('--comfy-menu-bg') || '#0f0f0f'
|
||||
};
|
||||
}
|
||||
|
||||
function makeDraggable(dialog) {
|
||||
const header = dialog.querySelector('h2');
|
||||
if (!header) return;
|
||||
const saved = localStorage.getItem("endless_fontifier_defaults");
|
||||
let currentValues = saved ? JSON.parse(saved) : { ...originalValues };
|
||||
let dialogOpenValues = null;
|
||||
let currentDialog = null;
|
||||
let handlersSetup = false;
|
||||
let escHandler = null;
|
||||
let unregisterThemeCallback = null;
|
||||
let isPreviewMode = false;
|
||||
|
||||
let offsetX = 0, offsetY = 0, isDown = false;
|
||||
|
||||
header.style.cursor = 'move';
|
||||
header.style.userSelect = 'none';
|
||||
|
||||
header.onmousedown = (e) => {
|
||||
e.preventDefault();
|
||||
isDown = true;
|
||||
|
||||
const rect = dialog.getBoundingClientRect();
|
||||
offsetX = e.clientX - rect.left;
|
||||
offsetY = e.clientY - rect.top;
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
if (!isDown) return;
|
||||
e.preventDefault();
|
||||
dialog.style.left = `${e.clientX - offsetX}px`;
|
||||
dialog.style.top = `${e.clientY - offsetY}px`;
|
||||
dialog.style.transform = 'none';
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
isDown = false;
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
}
|
||||
|
||||
// Function to capture current ComfyUI settings
|
||||
function captureCurrentSettings() {
|
||||
const settings = {
|
||||
NODE_TEXT_SIZE: (typeof LiteGraph !== 'undefined' && LiteGraph.NODE_TEXT_SIZE) ? LiteGraph.NODE_TEXT_SIZE : originalValues.NODE_TEXT_SIZE,
|
||||
NODE_SUBTEXT_SIZE: (typeof LiteGraph !== 'undefined' && LiteGraph.NODE_SUBTEXT_SIZE) ? LiteGraph.NODE_SUBTEXT_SIZE : originalValues.NODE_SUBTEXT_SIZE,
|
||||
NODE_TITLE_HEIGHT: (typeof LiteGraph !== 'undefined' && LiteGraph.NODE_TITLE_HEIGHT) ? LiteGraph.NODE_TITLE_HEIGHT : originalValues.NODE_TITLE_HEIGHT,
|
||||
DEFAULT_GROUP_FONT: (typeof LiteGraph !== 'undefined' && LiteGraph.DEFAULT_GROUP_FONT) ? LiteGraph.DEFAULT_GROUP_FONT : originalValues.DEFAULT_GROUP_FONT,
|
||||
NODE_FONT: (typeof LiteGraph !== 'undefined' && LiteGraph.NODE_FONT) ? LiteGraph.NODE_FONT : originalValues.NODE_FONT,
|
||||
NODE_SLOT_HEIGHT: (typeof LiteGraph !== 'undefined' && LiteGraph.NODE_SLOT_HEIGHT) ? LiteGraph.NODE_SLOT_HEIGHT : originalValues.NODE_SLOT_HEIGHT,
|
||||
WIDGET_TEXT_SIZE: currentValues.WIDGET_TEXT_SIZE // Get current widget text size
|
||||
};
|
||||
|
||||
console.log('📸 Captured current settings:', settings);
|
||||
return settings;
|
||||
}
|
||||
|
||||
function createFontifierDialog() {
|
||||
// Remove existing dialog if present
|
||||
const existingDialog = document.getElementById('fontifier-dialog');
|
||||
if (existingDialog) {
|
||||
existingDialog.remove();
|
||||
}
|
||||
|
||||
// Capture current settings when dialog opens
|
||||
currentValues = captureCurrentSettings();
|
||||
dialogOpenValues = { ...currentValues };
|
||||
|
||||
if (currentDialog) return;
|
||||
|
||||
const colors = getComfyUIColors();
|
||||
|
||||
// Create dialog container
|
||||
const dialog = document.createElement('div');
|
||||
dialog.id = 'fontifier-dialog';
|
||||
dialog.className = 'comfyui-dialog';
|
||||
|
||||
const dialog = document.createElement("div");
|
||||
dialog.id = "fontifier-dialog";
|
||||
dialog.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: ${colors.background};
|
||||
border: 1px solid ${colors.border};
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
z-index: 10000;
|
||||
width: 520px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
font-family: Arial, sans-serif;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
color: ${colors.text};
|
||||
`;
|
||||
|
||||
// Create backdrop - REMOVE the onclick handler to prevent accidental cancellation
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'comfyui-backdrop';
|
||||
backdrop.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
position: absolute;
|
||||
z-index: 9999;
|
||||
top: 100px;
|
||||
left: 100px;
|
||||
width: 320px;
|
||||
background: ${colors.dialogBg || colors.menu || 'rgba(20, 20, 20, 0.95)'};
|
||||
color: ${colors.inputText || '#fff'};
|
||||
font-family: sans-serif;
|
||||
border: 1px solid ${colors.border};
|
||||
border-radius: 10px;
|
||||
box-shadow: ${colors.shadow || '0 0 20px rgba(0,0,0,0.5)'};
|
||||
padding: 10px;
|
||||
`;
|
||||
// Removed the onclick handler to prevent accidental cancellation
|
||||
|
||||
|
||||
// Clean up any existing style tags
|
||||
const existingStyle = document.getElementById('fontifier-dialog-style');
|
||||
if (existingStyle) existingStyle.remove();
|
||||
|
||||
// Themed style block
|
||||
const style = document.createElement("style");
|
||||
style.id = "fontifier-dialog-style";
|
||||
style.textContent = createStyleCSS(colors);
|
||||
document.head.appendChild(style);
|
||||
|
||||
dialog.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; border-bottom: 1px solid ${colors.border}; padding-bottom: 15px;">
|
||||
<h2 style="color: ${colors.text}; margin: 0; font-size: 16px;">🌊✨ Endless Fontifier</h2>
|
||||
<button id="close-dialog" style="background: ${colors.backgroundSecondary}; border: 1px solid ${colors.border}; color: ${colors.text}; padding: 6px 12px; border-radius: 4px; cursor: pointer;">✕</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 12px; padding: 12px; background: ${colors.backgroundSecondary}; border-radius: 6px; border: 1px solid ${colors.border};">
|
||||
<h3 style="color: ${colors.text}; margin: 0 0 10px 0; font-size: 16px;">Global Scale</h3>
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<label style="color: ${colors.textSecondary}; min-width: 80px; font-size: 12px;">Scale All:</label>
|
||||
<input type="range" id="global-scale" min="0.5" max="3" step="0.1" value="1" style="flex: 1; accent-color: ${colors.accent};">
|
||||
<input type="number" id="global-scale-num" min="0.5" max="3" step="0.1" value="1" style="width: 70px; padding: 6px; background: ${colors.background}; border: 1px solid ${colors.border}; color: ${colors.text}; border-radius: 4px; font-size: 12px;">
|
||||
<div id="drag-bar" style="text-align:center; padding:6px; background:${colors.menuSecondary || '#2a2a2a'}; cursor:move; border-radius:10px 10px 0 0;">Endless 🌊✨ Drag Bar</div>
|
||||
<h2 style="margin: 8px 0; text-align: center;">Fontifier Settings</h2>
|
||||
|
||||
<div class="fontifier-setting">
|
||||
<label>Global Scale</label>
|
||||
<div class="fontifier-row">
|
||||
<input type="range" id="global-scale" min="0.5" max="2" step="0.01" value="1" title="Overall scaling factor for all font sizes">
|
||||
<input type="number" id="global-scale-num" min="0.5" max="2" step="0.01" value="1" title="Overall scaling factor for all font sizes">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 12px; padding: 12px; background: ${colors.backgroundSecondary}; border-radius: 6px; border: 1px solid ${colors.border};">
|
||||
<h3 style="color: ${colors.text}; margin: 0 0 12px 0; font-size: 16px;">Font Family</h3>
|
||||
<select id="font-family" style="width: 100%; padding: 8px; background: ${colors.background}; border: 1px solid ${colors.border}; color: ${colors.text}; border-radius: 4px; font-size: 12px;">
|
||||
|
||||
<div class="fontifier-setting">
|
||||
<label>Text Size</label>
|
||||
<div class="fontifier-row">
|
||||
<input type="range" id="node-text-size" min="8" max="32" value="14" title="Font size for node text content and labels">
|
||||
<input type="number" id="node-text-size-num" min="8" max="32" value="14" title="Font size for node text content and labels">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fontifier-setting">
|
||||
<label>Subtext Size</label>
|
||||
<div class="fontifier-row">
|
||||
<input type="range" id="node-subtext-size" min="8" max="32" value="12" title="Font size for secondary text and descriptions">
|
||||
<input type="number" id="node-subtext-size-num" min="8" max="32" value="12" title="Font size for secondary text and descriptions">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fontifier-setting">
|
||||
<label>Title Height</label>
|
||||
<div class="fontifier-row">
|
||||
<input type="range" id="title-height" min="20" max="60" value="30" title="Height of node title bars">
|
||||
<input type="number" id="title-height-num" min="20" max="60" value="30" title="Height of node title bars">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fontifier-setting">
|
||||
<label>Slot Height</label>
|
||||
<div class="fontifier-row">
|
||||
<input type="range" id="slot-height" min="10" max="40" value="20" title="Height of input/output connection slots">
|
||||
<input type="number" id="slot-height-num" min="10" max="40" value="20" title="Height of input/output connection slots">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fontifier-setting">
|
||||
<label>Group Font Size</label>
|
||||
<div class="fontifier-row">
|
||||
<input type="range" id="group-font-size" min="8" max="32" value="24" title="Font size for group labels and titles">
|
||||
<input type="number" id="group-font-size-num" min="8" max="32" value="24" title="Font size for group labels and titles">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fontifier-setting">
|
||||
<label>Widget Font Size</label>
|
||||
<div class="fontifier-row">
|
||||
<input type="range" id="widget-text-size" min="8" max="32" value="12" title="Font size for input widgets and controls">
|
||||
<input type="number" id="widget-text-size-num" min="8" max="32" value="12" title="Font size for input widgets and controls">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fontifier-setting">
|
||||
<label>Font Family</label>
|
||||
<select id="font-family" style="width: 100%;" title="Choose the font family for all text elements">
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Helvetica">Helvetica</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Comic Sans MS">Comic Sans MS</option>
|
||||
<option value="Impact">Impact</option>
|
||||
<option value="Trebuchet MS">Trebuchet MS</option>
|
||||
<option value="Tahoma">Tahoma</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 12px; padding: 12px; background: ${colors.backgroundSecondary}; border-radius: 6px; border: 1px solid ${colors.border};">
|
||||
<h3 style="color: ${colors.text}; margin: 0 0 12px 0; font-size: 16px;">Text Element Sizes</h3>
|
||||
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label style="color: ${colors.text}; display: block; margin-bottom: 4px; font-size: 12px; font-weight: bold;">Node Title Text</label>
|
||||
<div style="color: ${colors.textSecondary}; font-size: 11px; margin-bottom: 5px;">The main title text at the top of each node (e.g., "KSampler", "VAE Decode")</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<input type="range" id="node-text-size" min="8" max="32" value="${currentValues.NODE_TEXT_SIZE}" style="flex: 1; accent-color: ${colors.accent};">
|
||||
<input type="number" id="node-text-size-num" min="8" max="32" value="${currentValues.NODE_TEXT_SIZE}" style="width: 60px; padding: 5px; background: ${colors.background}; border: 1px solid ${colors.border}; color: ${colors.text}; border-radius: 4px; font-size: 12px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label style="color: ${colors.text}; display: block; margin-bottom: 4px; font-size: 12px; font-weight: bold;">Widget Labels & Values</label>
|
||||
<div style="color: ${colors.textSecondary}; font-size: 11px; margin-bottom: 5px;">Text inside nodes: parameter names and values (e.g., "steps: 20", "cfg: 8.0")</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<input type="range" id="node-subtext-size" min="6" max="24" value="${currentValues.NODE_SUBTEXT_SIZE}" style="flex: 1; accent-color: ${colors.accent};">
|
||||
<input type="number" id="node-subtext-size-num" min="6" max="24" value="${currentValues.NODE_SUBTEXT_SIZE}" style="width: 60px; padding: 5px; background: ${colors.background}; border: 1px solid ${colors.border}; color: ${colors.text}; border-radius: 4px; font-size: 12px;">
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label style="color: ${colors.text}; display: block; margin-bottom: 4px; font-size: 12px; font-weight: bold;">Widget Text Input Size</label>
|
||||
<div style="color: ${colors.textSecondary}; font-size: 11px; margin-bottom: 5px;">Font size for text inside input boxes, dropdowns, and textareas in nodes.</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<input type="range" id="widget-text-size" min="8" max="24" value="${currentValues.WIDGET_TEXT_SIZE}" style="flex: 1; accent-color: ${colors.accent};">
|
||||
<input type="number" id="widget-text-size-num" min="8" max="24" value="${currentValues.WIDGET_TEXT_SIZE}" style="width: 60px; padding: 5px; background: ${colors.background}; border: 1px solid ${colors.border}; color: ${colors.text}; border-radius: 4px; font-size: 12px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label style="color: ${colors.text}; display: block; margin-bottom: 4px; font-size: 12px; font-weight: bold;">Node Title Area Height</label>
|
||||
<div style="color: ${colors.textSecondary}; font-size: 11px; margin-bottom: 5px;">Height of the colored title bar area at the top of nodes</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<input type="range" id="title-height" min="20" max="60" value="${currentValues.NODE_TITLE_HEIGHT}" style="flex: 1; accent-color: ${colors.accent};">
|
||||
<input type="number" id="title-height-num" min="20" max="60" value="${currentValues.NODE_TITLE_HEIGHT}" style="width: 60px; padding: 5px; background: ${colors.background}; border: 1px solid ${colors.border}; color: ${colors.text}; border-radius: 4px; font-size: 12px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label style="color: ${colors.text}; display: block; margin-bottom: 4px; font-size: 12px; font-weight: bold;">Connection Slot Height</label>
|
||||
<div style="color: ${colors.textSecondary}; font-size: 11px; margin-bottom: 5px;">Height of input/output connection points on node sides</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<input type="range" id="slot-height" min="12" max="40" value="${currentValues.NODE_SLOT_HEIGHT}" style="flex: 1; accent-color: ${colors.accent};">
|
||||
<input type="number" id="slot-height-num" min="12" max="40" value="${currentValues.NODE_SLOT_HEIGHT}" style="width: 60px; padding: 5px; background: ${colors.background}; border: 1px solid ${colors.border}; color: ${colors.text}; border-radius: 4px; font-size: 12px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label style="color: ${colors.text}; display: block; margin-bottom: 4px; font-size: 12px; font-weight: bold;">Group Label Size</label>
|
||||
<div style="color: ${colors.textSecondary}; font-size: 11px; margin-bottom: 5px;">Text size for node group labels (when nodes are grouped together)</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<input type="range" id="group-font-size" min="12" max="48" value="${currentValues.DEFAULT_GROUP_FONT}" style="flex: 1; accent-color: ${colors.accent};">
|
||||
<input type="number" id="group-font-size-num" min="12" max="48" value="${currentValues.DEFAULT_GROUP_FONT}" style="width: 60px; padding: 5px; background: ${colors.background}; border: 1px solid ${colors.border}; color: ${colors.text}; border-radius: 4px; font-size: 12px;">
|
||||
</div>
|
||||
</div>
|
||||
<div id="preview-indicator" style="display: none; text-align: center; color: ${colors.accent}; font-size: 12px; margin: 8px 0;">
|
||||
🔍 Preview Mode - Changes not saved
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; justify-content: center; padding-top: 15px; border-top: 1px solid ${colors.border};">
|
||||
<button id="apply-btn" style="padding: 8px 16px; background: ${colors.backgroundSecondary}; border: 1px solid ${colors.border}; color: ${colors.text}; border-radius: 4px; cursor: pointer; font-size: 12px; transition: border-width 0.2s ease; background-image: linear-gradient(rgba(128, 255, 128, 0.08), rgba(128, 255, 128, 0.08));">Apply & Close</button>
|
||||
<button id="preview-btn" style="padding: 8px 16px; background: ${colors.backgroundSecondary}; border: 1px solid ${colors.border}; color: ${colors.text}; border-radius: 4px; cursor: pointer; font-size: 12px; transition: border-width 0.2s ease;">Preview</button>
|
||||
<button id="reset-btn" style="padding: 8px 16px; background: ${colors.backgroundSecondary}; border: 1px solid ${colors.border}; color: ${colors.text}; border-radius: 4px; cursor: pointer; font-size: 12px; transition: border-width 0.2s ease;">Reset to Defaults</button>
|
||||
<button id="cancel-btn" style="padding: 8px 16px; background: ${colors.backgroundSecondary}; border: 1px solid ${colors.border}; color: ${colors.textSecondary}; border-radius: 4px; cursor: pointer; font-size: 12px; transition: border-width 0.2s ease; background-image: linear-gradient(rgba(255, 128, 128, 0.08), rgba(255, 128, 128, 0.08));">Cancel</button>
|
||||
|
||||
<div style="margin-top: 12px; display: flex; flex-wrap: wrap; gap: 6px; justify-content: space-between;">
|
||||
<button id="apply-btn" title="Apply changes permanently and close dialog">Apply</button>
|
||||
<button id="preview-btn" title="Preview changes temporarily without saving">Preview</button>
|
||||
<button id="reset-btn" title="Reset to ComfyUI defaults">Reset</button>
|
||||
<button id="save-defaults-btn" title="Save current settings as defaults">💾 Save as Default</button>
|
||||
<button id="cancel-btn" title="Cancel changes and close dialog">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
// ESC key handler - restore original values on escape
|
||||
function escHandler(e) {
|
||||
if (e.key === 'Escape') {
|
||||
restoreSettings(dialogOpenValues);
|
||||
backdrop.remove();
|
||||
dialog.remove();
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
// Set up event handlers
|
||||
setupDialogHandlers(dialog, backdrop);
|
||||
makeDraggable(dialog, dialog.querySelector('#drag-bar'));
|
||||
setupDialogHandlers(dialog);
|
||||
currentDialog = dialog;
|
||||
|
||||
// Set font family to current value
|
||||
// Store the values when dialog opens for cancel functionality
|
||||
dialogOpenValues = { ...currentValues };
|
||||
|
||||
// Set current values in the dialog
|
||||
updateDialogValues(dialog);
|
||||
|
||||
// Live theme updating without closing dialog
|
||||
unregisterThemeCallback = onThemeChange(() => {
|
||||
if (currentDialog) {
|
||||
updateDialogTheme();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createStyleCSS(colors) {
|
||||
return `
|
||||
.fontifier-setting {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.fontifier-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.fontifier-row input[type="range"] {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.fontifier-row input[type="number"] {
|
||||
width: 50px;
|
||||
background: ${colors.inputBg || '#222'};
|
||||
border: 1px solid ${colors.border || '#999'};
|
||||
color: ${colors.inputText || '#ddd'};
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
}
|
||||
#fontifier-dialog button {
|
||||
flex-grow: 1;
|
||||
padding: 6px;
|
||||
background: ${colors.inputBg || '#222'};
|
||||
border: 1px solid ${colors.border || '#999'};
|
||||
color: ${colors.inputText || '#ddd'};
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#fontifier-dialog button:hover {
|
||||
background: ${colors.buttonHoverBg || colors.hoverBg || blendColors(colors.inputBg || '#222', '#ffffff', 0.1)};
|
||||
}
|
||||
#fontifier-dialog #apply-btn {
|
||||
background: ${toRGBA(colors.accent || '#4CAF50', 0.3)};
|
||||
border-color: ${colors.accent || '#4CAF50'};
|
||||
}
|
||||
#fontifier-dialog #cancel-btn {
|
||||
background: ${toRGBA(colors.errorText || '#f44336', 0.3)};
|
||||
border-color: ${colors.errorText || '#f44336'};
|
||||
}
|
||||
#fontifier-dialog select {
|
||||
background: ${colors.inputBg || '#222'};
|
||||
border: 1px solid ${colors.border || '#999'};
|
||||
color: ${colors.inputText || '#ddd'};
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function updateDialogTheme() {
|
||||
if (!currentDialog) return;
|
||||
|
||||
const newColors = getComfyUIColors();
|
||||
|
||||
// Update main dialog styling
|
||||
currentDialog.style.background = newColors.dialogBg || newColors.menu || 'rgba(20, 20, 20, 0.95)';
|
||||
currentDialog.style.color = newColors.inputText || '#fff';
|
||||
currentDialog.style.borderColor = newColors.border || '#999';
|
||||
currentDialog.style.boxShadow = newColors.shadow || '0 0 20px rgba(0,0,0,0.5)';
|
||||
|
||||
// Update drag bar
|
||||
const dragBar = currentDialog.querySelector('#drag-bar');
|
||||
if (dragBar) {
|
||||
dragBar.style.background = newColors.menuSecondary || '#2a2a2a';
|
||||
}
|
||||
|
||||
// Update preview indicator
|
||||
const previewIndicator = currentDialog.querySelector('#preview-indicator');
|
||||
if (previewIndicator) {
|
||||
previewIndicator.style.color = newColors.accent || '#4CAF50';
|
||||
}
|
||||
|
||||
// Update the style tag with new colors
|
||||
const styleTag = document.getElementById('fontifier-dialog-style');
|
||||
if (styleTag) {
|
||||
styleTag.textContent = createStyleCSS(newColors);
|
||||
}
|
||||
}
|
||||
|
||||
function updateDialogValues(dialog) {
|
||||
dialog.querySelector('#global-scale').value = currentValues.GLOBAL_SCALE || 1;
|
||||
dialog.querySelector('#global-scale-num').value = currentValues.GLOBAL_SCALE || 1;
|
||||
dialog.querySelector('#node-text-size').value = currentValues.NODE_TEXT_SIZE;
|
||||
dialog.querySelector('#node-text-size-num').value = currentValues.NODE_TEXT_SIZE;
|
||||
dialog.querySelector('#node-subtext-size').value = currentValues.NODE_SUBTEXT_SIZE;
|
||||
dialog.querySelector('#node-subtext-size-num').value = currentValues.NODE_SUBTEXT_SIZE;
|
||||
dialog.querySelector('#title-height').value = currentValues.NODE_TITLE_HEIGHT;
|
||||
dialog.querySelector('#title-height-num').value = currentValues.NODE_TITLE_HEIGHT;
|
||||
dialog.querySelector('#slot-height').value = currentValues.NODE_SLOT_HEIGHT;
|
||||
dialog.querySelector('#slot-height-num').value = currentValues.NODE_SLOT_HEIGHT;
|
||||
dialog.querySelector('#group-font-size').value = currentValues.DEFAULT_GROUP_FONT;
|
||||
dialog.querySelector('#group-font-size-num').value = currentValues.DEFAULT_GROUP_FONT;
|
||||
dialog.querySelector('#widget-text-size').value = currentValues.WIDGET_TEXT_SIZE;
|
||||
dialog.querySelector('#widget-text-size-num').value = currentValues.WIDGET_TEXT_SIZE;
|
||||
dialog.querySelector('#font-family').value = currentValues.NODE_FONT;
|
||||
}
|
||||
|
||||
// Function to restore settings
|
||||
function restoreSettings(settings) {
|
||||
applySettingsToComfyUI(settings);
|
||||
console.log('🔄 Settings restored:', settings);
|
||||
}
|
||||
|
||||
// Separated function to apply settings to ComfyUI
|
||||
function applySettingsToComfyUI(settings) {
|
||||
if (typeof LiteGraph !== 'undefined') {
|
||||
LiteGraph.NODE_TEXT_SIZE = settings.NODE_TEXT_SIZE;
|
||||
LiteGraph.NODE_SUBTEXT_SIZE = settings.NODE_SUBTEXT_SIZE;
|
||||
LiteGraph.NODE_TITLE_HEIGHT = settings.NODE_TITLE_HEIGHT;
|
||||
LiteGraph.NODE_SLOT_HEIGHT = settings.NODE_SLOT_HEIGHT;
|
||||
LiteGraph.NODE_WIDGET_HEIGHT = settings.NODE_SLOT_HEIGHT;
|
||||
LiteGraph.DEFAULT_GROUP_FONT = settings.DEFAULT_GROUP_FONT;
|
||||
LiteGraph.DEFAULT_GROUP_FONT_SIZE = settings.DEFAULT_GROUP_FONT;
|
||||
LiteGraph.NODE_FONT = settings.NODE_FONT || settings.FONT_FAMILY;
|
||||
LiteGraph.DEFAULT_FONT = settings.NODE_FONT || settings.FONT_FAMILY;
|
||||
LiteGraph.GROUP_FONT = settings.NODE_FONT || settings.FONT_FAMILY;
|
||||
|
||||
if (typeof app !== 'undefined' && app.canvas) {
|
||||
app.canvas.setDirty(true, true);
|
||||
if (app.canvas.draw) {
|
||||
setTimeout(() => app.canvas.draw(true, true), 100);
|
||||
}
|
||||
}
|
||||
function setupDialogHandlers(dialog) {
|
||||
if (handlersSetup) return;
|
||||
handlersSetup = true;
|
||||
|
||||
const canvases = document.querySelectorAll('canvas');
|
||||
canvases.forEach(canvas => {
|
||||
if (canvas.getContext) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const originalWidth = canvas.width;
|
||||
canvas.width = originalWidth + 1;
|
||||
canvas.width = originalWidth;
|
||||
}
|
||||
});
|
||||
}
|
||||
addButtonHoverEffects(dialog);
|
||||
|
||||
// Apply widget font size to CSS
|
||||
let styleTag = document.getElementById('fontifier-widget-text-style');
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement('style');
|
||||
styleTag.id = 'fontifier-widget-text-style';
|
||||
document.head.appendChild(styleTag);
|
||||
}
|
||||
styleTag.textContent = `
|
||||
canvas ~ * .widget input, canvas ~ * .widget select, canvas ~ * .widget textarea,
|
||||
canvas ~ * .comfy-multiline-input, canvas ~ * .comfy-input,
|
||||
canvas ~ * input.comfy-multiline-input, canvas ~ * textarea.comfy-multiline-input,
|
||||
canvas ~ * [class*="comfy-input"], canvas ~ * [class*="comfy-multiline"],
|
||||
canvas ~ * .comfyui-widget input, canvas ~ * .comfyui-widget select, canvas ~ * .comfyui-widget textarea,
|
||||
canvas ~ * [class*="widget"] input, canvas ~ * [class*="widget"] select, canvas ~ * [class*="widget"] textarea,
|
||||
canvas ~ * .litegraph input, canvas ~ * .litegraph select, canvas ~ * .litegraph textarea,
|
||||
.litegraph input, .litegraph select, .litegraph textarea {
|
||||
font-size: ${settings.WIDGET_TEXT_SIZE}px !important;
|
||||
font-family: ${settings.NODE_FONT || settings.FONT_FAMILY} !important;
|
||||
}
|
||||
|
||||
/* Exclude the fontifier dialog itself */
|
||||
#fontifier-dialog input, #fontifier-dialog select, #fontifier-dialog textarea {
|
||||
font-size: 14px !important;
|
||||
font-family: Arial !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function setupDialogHandlers(dialog, backdrop) {
|
||||
makeDraggable(dialog);
|
||||
|
||||
// Sync sliders with number inputs
|
||||
const elements = [
|
||||
'global-scale',
|
||||
'node-text-size',
|
||||
'node-subtext-size',
|
||||
'title-height',
|
||||
'node-text-size',
|
||||
'node-subtext-size',
|
||||
'title-height',
|
||||
'slot-height',
|
||||
'group-font-size',
|
||||
'widget-text-size'
|
||||
];
|
||||
|
||||
|
||||
elements.forEach(id => {
|
||||
const slider = dialog.querySelector(`#${id}`);
|
||||
const numberInput = dialog.querySelector(`#${id}-num`);
|
||||
|
||||
slider.oninput = () => {
|
||||
numberInput.value = slider.value;
|
||||
if (id === 'global-scale') {
|
||||
const globalScaleNum = dialog.querySelector('#global-scale-num');
|
||||
globalScaleNum.value = slider.value;
|
||||
}
|
||||
};
|
||||
numberInput.oninput = () => {
|
||||
slider.value = numberInput.value;
|
||||
};
|
||||
if (slider && numberInput) {
|
||||
slider.oninput = () => {
|
||||
numberInput.value = slider.value;
|
||||
if (isPreviewMode) showPreviewIndicator();
|
||||
};
|
||||
numberInput.oninput = () => {
|
||||
// Enforce min/max constraints
|
||||
const min = parseFloat(numberInput.min);
|
||||
const max = parseFloat(numberInput.max);
|
||||
let value = parseFloat(numberInput.value);
|
||||
if (value < min) value = min;
|
||||
if (value > max) value = max;
|
||||
numberInput.value = value;
|
||||
slider.value = value;
|
||||
if (isPreviewMode) showPreviewIndicator();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Global scale handler - now includes widget text size
|
||||
const globalScale = dialog.querySelector('#global-scale');
|
||||
const globalScaleNum = dialog.querySelector('#global-scale-num');
|
||||
|
||||
function updateGlobalScale() {
|
||||
const scale = parseFloat(globalScale.value);
|
||||
globalScaleNum.value = scale;
|
||||
|
||||
// Update all individual controls including widget text size
|
||||
const updates = [
|
||||
['node-text-size', originalValues.NODE_TEXT_SIZE],
|
||||
['node-subtext-size', originalValues.NODE_SUBTEXT_SIZE],
|
||||
['title-height', originalValues.NODE_TITLE_HEIGHT],
|
||||
['slot-height', originalValues.NODE_SLOT_HEIGHT],
|
||||
['group-font-size', originalValues.DEFAULT_GROUP_FONT],
|
||||
['widget-text-size', originalValues.WIDGET_TEXT_SIZE] // Added widget text size to global scaling
|
||||
];
|
||||
|
||||
updates.forEach(([id, originalValue]) => {
|
||||
const newValue = Math.round(originalValue * scale);
|
||||
dialog.querySelector(`#${id}`).value = newValue;
|
||||
dialog.querySelector(`#${id}-num`).value = newValue;
|
||||
});
|
||||
|
||||
const saveBtn = dialog.querySelector('#save-defaults-btn');
|
||||
if (saveBtn) {
|
||||
saveBtn.onclick = () => {
|
||||
localStorage.setItem("endless_fontifier_defaults", JSON.stringify(currentValues));
|
||||
alert("🌊 Fontifier defaults saved! They'll auto-load next time.");
|
||||
};
|
||||
}
|
||||
|
||||
globalScale.oninput = updateGlobalScale;
|
||||
globalScaleNum.oninput = () => {
|
||||
globalScale.value = globalScaleNum.value;
|
||||
updateGlobalScale();
|
||||
|
||||
dialog.querySelector('#apply-btn').onclick = () => {
|
||||
applyChanges(dialog, true);
|
||||
hidePreviewIndicator();
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
// Button handlers
|
||||
dialog.querySelector('#close-dialog').onclick = () => {
|
||||
restoreSettings(dialogOpenValues);
|
||||
backdrop.remove();
|
||||
dialog.remove();
|
||||
dialog.querySelector('#preview-btn').onclick = () => {
|
||||
applyChanges(dialog, false);
|
||||
showPreviewIndicator();
|
||||
};
|
||||
|
||||
dialog.querySelector('#reset-btn').onclick = () => {
|
||||
dialog.querySelector('#global-scale').value = 1;
|
||||
dialog.querySelector('#global-scale-num').value = 1;
|
||||
dialog.querySelector('#node-text-size').value = originalValues.NODE_TEXT_SIZE;
|
||||
dialog.querySelector('#node-text-size-num').value = originalValues.NODE_TEXT_SIZE;
|
||||
dialog.querySelector('#node-subtext-size').value = originalValues.NODE_SUBTEXT_SIZE;
|
||||
dialog.querySelector('#node-subtext-size-num').value = originalValues.NODE_SUBTEXT_SIZE;
|
||||
dialog.querySelector('#title-height').value = originalValues.NODE_TITLE_HEIGHT;
|
||||
dialog.querySelector('#title-height-num').value = originalValues.NODE_TITLE_HEIGHT;
|
||||
dialog.querySelector('#slot-height').value = originalValues.NODE_SLOT_HEIGHT;
|
||||
dialog.querySelector('#slot-height-num').value = originalValues.NODE_SLOT_HEIGHT;
|
||||
dialog.querySelector('#group-font-size').value = originalValues.DEFAULT_GROUP_FONT;
|
||||
dialog.querySelector('#group-font-size-num').value = originalValues.DEFAULT_GROUP_FONT;
|
||||
dialog.querySelector('#widget-text-size').value = originalValues.WIDGET_TEXT_SIZE;
|
||||
dialog.querySelector('#widget-text-size-num').value = originalValues.WIDGET_TEXT_SIZE;
|
||||
dialog.querySelector('#font-family').value = 'Arial';
|
||||
};
|
||||
|
||||
dialog.querySelector('#preview-btn').onclick = () => applyChanges(dialog, false);
|
||||
|
||||
dialog.querySelector('#apply-btn').onclick = () => {
|
||||
applyChanges(dialog, true);
|
||||
backdrop.remove();
|
||||
dialog.remove();
|
||||
localStorage.removeItem("endless_fontifier_defaults");
|
||||
currentValues = { ...originalValues };
|
||||
applySettingsToComfyUI(originalValues);
|
||||
updateDialogValues(dialog);
|
||||
hidePreviewIndicator();
|
||||
alert("🔁 Fontifier reset to ComfyUI defaults.");
|
||||
};
|
||||
|
||||
dialog.querySelector('#cancel-btn').onclick = () => {
|
||||
restoreSettings(dialogOpenValues);
|
||||
backdrop.remove();
|
||||
dialog.remove();
|
||||
applySettingsToComfyUI(dialogOpenValues);
|
||||
hidePreviewIndicator();
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
// Add hover effects to buttons
|
||||
const buttons = dialog.querySelectorAll('button');
|
||||
buttons.forEach(button => {
|
||||
button.style.boxSizing = 'border-box';
|
||||
button.style.minWidth = button.offsetWidth + 'px';
|
||||
button.addEventListener('mouseenter', () => {
|
||||
button.style.borderWidth = '2px';
|
||||
button.style.padding = '7px 15px';
|
||||
});
|
||||
button.addEventListener('mouseleave', () => {
|
||||
button.style.borderWidth = '1px';
|
||||
button.style.padding = '8px 16px';
|
||||
});
|
||||
});
|
||||
escHandler = e => {
|
||||
if (e.key === 'Escape') {
|
||||
applySettingsToComfyUI(dialogOpenValues);
|
||||
hidePreviewIndicator();
|
||||
closeDialog();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
}
|
||||
|
||||
|
||||
function showPreviewIndicator() {
|
||||
if (!currentDialog) return;
|
||||
isPreviewMode = true;
|
||||
const indicator = currentDialog.querySelector('#preview-indicator');
|
||||
if (indicator) indicator.style.display = 'block';
|
||||
}
|
||||
|
||||
function hidePreviewIndicator() {
|
||||
if (!currentDialog) return;
|
||||
isPreviewMode = false;
|
||||
const indicator = currentDialog.querySelector('#preview-indicator');
|
||||
if (indicator) indicator.style.display = 'none';
|
||||
}
|
||||
|
||||
function applyChanges(dialog, permanent = false) {
|
||||
const newValues = {
|
||||
const globalScale = parseFloat(dialog.querySelector('#global-scale').value);
|
||||
|
||||
const baseValues = {
|
||||
NODE_TEXT_SIZE: parseInt(dialog.querySelector('#node-text-size').value),
|
||||
NODE_SUBTEXT_SIZE: parseInt(dialog.querySelector('#node-subtext-size').value),
|
||||
NODE_TITLE_HEIGHT: parseInt(dialog.querySelector('#title-height').value),
|
||||
@@ -444,17 +383,87 @@ import { registerEndlessTool } from './endless_ui_helpers.js';
|
||||
DEFAULT_GROUP_FONT: parseInt(dialog.querySelector('#group-font-size').value),
|
||||
FONT_FAMILY: dialog.querySelector('#font-family').value,
|
||||
NODE_FONT: dialog.querySelector('#font-family').value,
|
||||
WIDGET_TEXT_SIZE: parseInt(dialog.querySelector('#widget-text-size').value)
|
||||
WIDGET_TEXT_SIZE: parseInt(dialog.querySelector('#widget-text-size').value),
|
||||
GLOBAL_SCALE: globalScale
|
||||
};
|
||||
|
||||
applySettingsToComfyUI(newValues);
|
||||
console.log('🌊✨ Fontifier applied:', newValues);
|
||||
// Apply global scaling to font sizes
|
||||
const scaledValues = {
|
||||
...baseValues,
|
||||
NODE_TEXT_SIZE: Math.round(baseValues.NODE_TEXT_SIZE * globalScale),
|
||||
NODE_SUBTEXT_SIZE: Math.round(baseValues.NODE_SUBTEXT_SIZE * globalScale),
|
||||
DEFAULT_GROUP_FONT: Math.round(baseValues.DEFAULT_GROUP_FONT * globalScale),
|
||||
WIDGET_TEXT_SIZE: Math.round(baseValues.WIDGET_TEXT_SIZE * globalScale)
|
||||
};
|
||||
|
||||
applySettingsToComfyUI(scaledValues);
|
||||
if (permanent) {
|
||||
currentValues = { ...newValues };
|
||||
console.log('🌊✨ Fontifier changes applied permanently (until page refresh)');
|
||||
currentValues = { ...baseValues }; // Store unscaled values
|
||||
isPreviewMode = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applySettingsToComfyUI(settings) {
|
||||
LiteGraph.NODE_TEXT_SIZE = settings.NODE_TEXT_SIZE;
|
||||
LiteGraph.NODE_SUBTEXT_SIZE = settings.NODE_SUBTEXT_SIZE;
|
||||
LiteGraph.NODE_TITLE_HEIGHT = settings.NODE_TITLE_HEIGHT;
|
||||
LiteGraph.NODE_SLOT_HEIGHT = settings.NODE_SLOT_HEIGHT;
|
||||
LiteGraph.DEFAULT_GROUP_FONT = settings.DEFAULT_GROUP_FONT;
|
||||
LiteGraph.DEFAULT_GROUP_FONT_SIZE = settings.DEFAULT_GROUP_FONT;
|
||||
LiteGraph.NODE_FONT = settings.NODE_FONT;
|
||||
LiteGraph.DEFAULT_FONT = settings.NODE_FONT;
|
||||
LiteGraph.GROUP_FONT = settings.NODE_FONT;
|
||||
|
||||
if (window.app?.canvas) {
|
||||
window.app.canvas.setDirty(true, true);
|
||||
setTimeout(() => window.app.canvas.draw(true, true), 100);
|
||||
}
|
||||
|
||||
const styleId = "fontifier-widget-text-style";
|
||||
let styleTag = document.getElementById(styleId);
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement('style');
|
||||
styleTag.id = styleId;
|
||||
document.head.appendChild(styleTag);
|
||||
}
|
||||
styleTag.textContent = `
|
||||
.litegraph input, .litegraph select, .litegraph textarea {
|
||||
font-size: ${settings.WIDGET_TEXT_SIZE}px !important;
|
||||
font-family: ${settings.NODE_FONT} !important;
|
||||
}
|
||||
#fontifier-dialog input, #fontifier-dialog select, #fontifier-dialog textarea {
|
||||
font-size: 14px !important;
|
||||
font-family: Arial !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
if (currentDialog) currentDialog.remove();
|
||||
if (escHandler) document.removeEventListener('keydown', escHandler);
|
||||
if (unregisterThemeCallback) unregisterThemeCallback();
|
||||
// Clean up the style tag
|
||||
const styleTag = document.getElementById('fontifier-dialog-style');
|
||||
if (styleTag) styleTag.remove();
|
||||
currentDialog = null;
|
||||
handlersSetup = false;
|
||||
escHandler = null;
|
||||
unregisterThemeCallback = null;
|
||||
isPreviewMode = false;
|
||||
}
|
||||
|
||||
// Wait for app to be ready before initializing
|
||||
function waitForApp() {
|
||||
if (typeof window.app !== 'undefined' && window.app?.canvas) {
|
||||
// Initialize with saved defaults once app is ready
|
||||
applySettingsToComfyUI(currentValues);
|
||||
return;
|
||||
}
|
||||
setTimeout(waitForApp, 100);
|
||||
}
|
||||
|
||||
waitForApp();
|
||||
|
||||
// Register into Endless Tools menu
|
||||
registerEndlessTool("Fontifier", createFontifierDialog);
|
||||
})();
|
||||
@@ -0,0 +1,764 @@
|
||||
// ComfyUI Endless 🌊✨ Minimap - Optimized Version
|
||||
|
||||
(function waitForHelpers() {
|
||||
if (typeof window.EndlessHelpers === 'undefined') {
|
||||
console.warn("⏳ Waiting for EndlessHelpers to be ready...");
|
||||
setTimeout(waitForHelpers, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
registerEndlessTool,
|
||||
onThemeChange,
|
||||
getComfyUIColors,
|
||||
toRGBA,
|
||||
makeDraggable
|
||||
} = window.EndlessHelpers;
|
||||
|
||||
console.log("✅ Endless Minimap loaded.");
|
||||
|
||||
// State variables
|
||||
let currentDialog = null;
|
||||
let animationId = null;
|
||||
let unregisterThemeCallback = null;
|
||||
let resizeObserver = null;
|
||||
|
||||
// Canvas state
|
||||
let panX = 0, panY = 0, zoom = 1;
|
||||
let isDragging = false;
|
||||
let dragStartTime = 0, dragStartX = 0, dragStartY = 0;
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
// Size constants
|
||||
const BASE_WIDTH = 300, BASE_HEIGHT = 400;
|
||||
const MAX_WIDTH = 500, MAX_HEIGHT = 600;
|
||||
const MIN_WIDTH = 200, MIN_HEIGHT = 150;
|
||||
|
||||
// Node type colors - comprehensive mapping
|
||||
const NODE_COLORS = {
|
||||
// Image Processing (Blue family)
|
||||
'LoadImage': '#5DADE2', 'SaveImage': '#3498DB', 'PreviewImage': '#2E86AB',
|
||||
'ImageScale': '#85C1E9', 'ImageCrop': '#7FB3D3', 'ImageBlend': '#6BB6FF',
|
||||
|
||||
// Latent Processing (Purple family)
|
||||
'KSampler': '#8E44AD', 'KSamplerAdvanced': '#9B59B6', 'EmptyLatentImage': '#A569BD',
|
||||
'LatentUpscale': '#BB8FCE', 'LatentBlend': '#D2B4DE',
|
||||
|
||||
// VAE (Green family)
|
||||
'VAEDecode': '#27AE60', 'VAEEncode': '#2ECC71', 'VAELoader': '#58D68D',
|
||||
|
||||
// Model/Checkpoint (Teal family)
|
||||
'CheckpointLoaderSimple': '#17A2B8', 'CheckpointLoader': '#148A99',
|
||||
'ModelMergeSimple': '#1ABC9C', 'UNETLoader': '#5DADE2',
|
||||
|
||||
// CLIP/Text (Orange family)
|
||||
'CLIPTextEncode': '#E67E22', 'CLIPTextEncodeSDXL': '#F39C12',
|
||||
'CLIPLoader': '#F8C471', 'CLIPVisionEncode': '#D68910',
|
||||
|
||||
// LoRA (Yellow family)
|
||||
'LoraLoader': '#F1C40F', 'LoraLoaderModelOnly': '#F4D03F',
|
||||
|
||||
// ControlNet (Pink family)
|
||||
'ControlNetLoader': '#E91E63', 'ControlNetApply': '#F06292',
|
||||
'CannyEdgePreprocessor': '#EC407A', 'OpenposePreprocessor': '#F8BBD9',
|
||||
|
||||
// Conditioning (Coral family)
|
||||
'ConditioningAverage': '#FF6B35', 'ConditioningCombine': '#FF8C42',
|
||||
|
||||
// Utility (Gray family)
|
||||
'PrimitiveNode': '#95A5A6', 'Note': '#BDC3C7', 'Reroute': '#85929E',
|
||||
|
||||
// Upscaling (Lime family)
|
||||
'UpscaleModelLoader': '#7FB069', 'ImageUpscaleWithModel': '#8BC34A',
|
||||
|
||||
// Masks (Red family)
|
||||
'MaskComposite': '#E53935', 'MaskToImage': '#F44336', 'ImageToMask': '#EF5350',
|
||||
|
||||
'default': 'rgba(200, 200, 200, 0.7)'
|
||||
};
|
||||
|
||||
function getApp() {
|
||||
return window.app || window.comfyApp || document.querySelector('#app')?.__vue__?.$root || null;
|
||||
}
|
||||
|
||||
function getCanvasAspectRatio() {
|
||||
const mainCanvas = document.querySelector('canvas') ||
|
||||
document.querySelector('#graph-canvas') ||
|
||||
document.querySelector('.litegraph');
|
||||
|
||||
if (mainCanvas) {
|
||||
const rect = mainCanvas.getBoundingClientRect();
|
||||
return rect.width / rect.height;
|
||||
}
|
||||
|
||||
return window.innerWidth / window.innerHeight;
|
||||
}
|
||||
|
||||
function calculateDimensions() {
|
||||
const aspectRatio = getCanvasAspectRatio();
|
||||
let containerWidth, containerHeight, canvasWidth, canvasHeight;
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
containerWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, BASE_WIDTH * aspectRatio));
|
||||
containerHeight = BASE_HEIGHT;
|
||||
canvasWidth = containerWidth;
|
||||
canvasHeight = BASE_HEIGHT - 50;
|
||||
} else {
|
||||
containerWidth = BASE_WIDTH;
|
||||
containerHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, BASE_HEIGHT / aspectRatio));
|
||||
canvasWidth = containerWidth;
|
||||
canvasHeight = containerHeight - 50;
|
||||
}
|
||||
|
||||
return { containerWidth, containerHeight, canvasWidth, canvasHeight };
|
||||
}
|
||||
|
||||
function getNodeColor(node) {
|
||||
const nodeType = node.type || node.constructor?.name || 'default';
|
||||
|
||||
if (NODE_COLORS[nodeType]) return NODE_COLORS[nodeType];
|
||||
|
||||
// Pattern matching for common types
|
||||
const patterns = [
|
||||
['Sampler', NODE_COLORS['KSampler']],
|
||||
['CLIP', NODE_COLORS['CLIPTextEncode']],
|
||||
['VAE', NODE_COLORS['VAEDecode']],
|
||||
['ControlNet', NODE_COLORS['ControlNetLoader']],
|
||||
['Lora', NODE_COLORS['LoraLoader']],
|
||||
['Image.*Load', NODE_COLORS['LoadImage']],
|
||||
['Image.*Save', NODE_COLORS['SaveImage']],
|
||||
['Checkpoint', NODE_COLORS['CheckpointLoaderSimple']],
|
||||
['Upscale', NODE_COLORS['UpscaleModelLoader']],
|
||||
['Mask', NODE_COLORS['MaskComposite']]
|
||||
];
|
||||
|
||||
for (const [pattern, color] of patterns) {
|
||||
if (new RegExp(pattern, 'i').test(nodeType)) return color;
|
||||
}
|
||||
|
||||
return NODE_COLORS.default;
|
||||
}
|
||||
|
||||
function getNodes() {
|
||||
const app = getApp();
|
||||
if (!app) {
|
||||
console.log("App not found, trying DOM fallback...");
|
||||
const nodeElements = document.querySelectorAll('[class*="node"], .comfy-node, .litegraph-node');
|
||||
if (nodeElements.length > 0) {
|
||||
return Array.from(nodeElements).map((el, i) => ({
|
||||
pos: [i * 150, i * 100],
|
||||
size: [100, 60],
|
||||
type: 'Unknown',
|
||||
title: `Node ${i + 1}`
|
||||
}));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodes = app.graph?._nodes ||
|
||||
app.graph?.nodes ||
|
||||
app.canvas?.graph?._nodes ||
|
||||
app.canvas?.graph?.nodes ||
|
||||
[];
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function createStyleCSS(colors) {
|
||||
return `
|
||||
#endless-minimap button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${colors.inputText};
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-size: 18px;
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
#endless-minimap button:hover {
|
||||
background: ${toRGBA(colors.inputText, 0.1)};
|
||||
}
|
||||
#endless-minimap .drag-bar {
|
||||
padding: 4px 8px;
|
||||
background: ${toRGBA(colors.inputText, 0.05)};
|
||||
cursor: move;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid ${colors.border};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#endless-minimap .legend {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
background: ${colors.menu};
|
||||
color: ${colors.inputText};
|
||||
padding: 8px;
|
||||
border: 1px solid ${colors.border};
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
z-index: 1;
|
||||
}
|
||||
#endless-minimap .pan-info {
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
background: ${colors.menuSecondary};
|
||||
color: ${colors.inputText};
|
||||
border-top: 1px solid ${colors.border};
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function updateTheme() {
|
||||
if (!currentDialog) return;
|
||||
|
||||
const colors = getComfyUIColors();
|
||||
|
||||
// Update container colors only, not size
|
||||
currentDialog.style.background = colors.menu;
|
||||
currentDialog.style.color = colors.inputText;
|
||||
currentDialog.style.borderColor = colors.accent;
|
||||
|
||||
// Update style tag
|
||||
const styleTag = document.getElementById('minimap-style');
|
||||
if (styleTag) {
|
||||
styleTag.textContent = createStyleCSS(colors);
|
||||
}
|
||||
|
||||
drawMinimap();
|
||||
}
|
||||
|
||||
function updateLegend() {
|
||||
const legend = currentDialog.querySelector('.legend');
|
||||
if (!legend || legend.style.display === 'none') return;
|
||||
|
||||
const nodes = getNodes();
|
||||
if (!nodes) return;
|
||||
|
||||
const typeCounts = {};
|
||||
nodes.forEach(n => {
|
||||
const nodeType = n.type || n.constructor?.name || 'default';
|
||||
typeCounts[nodeType] = (typeCounts[nodeType] || 0) + 1;
|
||||
});
|
||||
|
||||
legend.innerHTML = Object.entries(typeCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([type, count]) => {
|
||||
const color = NODE_COLORS[type] || NODE_COLORS.default;
|
||||
return `<div style="margin: 2px 0; display: flex; align-items: center;">
|
||||
<div style="width: 12px; height: 12px; background: ${color}; margin-right: 6px; border-radius: 2px;"></div>
|
||||
<span>${type} (${count})</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Get current transform state for coordinate conversions
|
||||
function getTransformState() {
|
||||
const canvas = currentDialog?.querySelector('canvas');
|
||||
if (!canvas) return null;
|
||||
|
||||
const nodes = getNodes();
|
||||
if (!nodes?.length) return null;
|
||||
|
||||
// Calculate bounds (same as in drawMinimap)
|
||||
const bounds = nodes.reduce((acc, n) => {
|
||||
const x = n.pos?.[0] ?? n.x ?? 0;
|
||||
const y = n.pos?.[1] ?? n.y ?? 0;
|
||||
const w = n.size?.[0] ?? n.width ?? 100;
|
||||
const h = n.size?.[1] ?? n.height ?? 60;
|
||||
|
||||
return {
|
||||
minX: Math.min(acc.minX, x),
|
||||
minY: Math.min(acc.minY, y),
|
||||
maxX: Math.max(acc.maxX, x + w),
|
||||
maxY: Math.max(acc.maxY, y + h)
|
||||
};
|
||||
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
|
||||
|
||||
const width = bounds.maxX - bounds.minX;
|
||||
const height = bounds.maxY - bounds.minY;
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
|
||||
const baseScale = Math.min(canvas.width / Math.max(width, 1000), canvas.height / Math.max(height, 1000));
|
||||
const scale = baseScale * zoom;
|
||||
|
||||
return {
|
||||
bounds,
|
||||
width,
|
||||
height,
|
||||
scale,
|
||||
canvas
|
||||
};
|
||||
}
|
||||
|
||||
// Convert canvas coordinates to world coordinates
|
||||
function canvasToWorld(canvasX, canvasY) {
|
||||
const transform = getTransformState();
|
||||
if (!transform) return null;
|
||||
|
||||
const { bounds, width, height, scale, canvas } = transform;
|
||||
|
||||
// Inverse of the transform used in drawMinimap
|
||||
const worldX = (canvasX - canvas.width / 2 - panX) / scale + (bounds.minX + width / 2);
|
||||
const worldY = (canvasY - canvas.height / 2 - panY) / scale + (bounds.minY + height / 2);
|
||||
|
||||
return { x: worldX, y: worldY };
|
||||
}
|
||||
|
||||
// Convert world coordinates to canvas coordinates
|
||||
function worldToCanvas(worldX, worldY) {
|
||||
const transform = getTransformState();
|
||||
if (!transform) return null;
|
||||
|
||||
const { bounds, width, height, scale, canvas } = transform;
|
||||
|
||||
// Same transform as used in drawMinimap
|
||||
const canvasX = (worldX - (bounds.minX + width / 2)) * scale + canvas.width / 2 + panX;
|
||||
const canvasY = (worldY - (bounds.minY + height / 2)) * scale + canvas.height / 2 + panY;
|
||||
|
||||
return { x: canvasX, y: canvasY };
|
||||
}
|
||||
|
||||
function drawMinimap() {
|
||||
if (!currentDialog) return;
|
||||
|
||||
const canvas = currentDialog.querySelector('canvas');
|
||||
const panInfo = currentDialog.querySelector('.pan-info');
|
||||
if (!canvas || !panInfo) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const colors = getComfyUIColors();
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const nodes = getNodes();
|
||||
if (!nodes || !nodes.length) {
|
||||
ctx.fillStyle = colors.inputText;
|
||||
ctx.font = '12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('No nodes found', canvas.width / 2, canvas.height / 2);
|
||||
ctx.fillText('or graph not loaded', canvas.width / 2, canvas.height / 2 + 15);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate bounds
|
||||
const bounds = nodes.reduce((acc, n) => {
|
||||
const x = n.pos?.[0] ?? n.x ?? 0;
|
||||
const y = n.pos?.[1] ?? n.y ?? 0;
|
||||
const w = n.size?.[0] ?? n.width ?? 100;
|
||||
const h = n.size?.[1] ?? n.height ?? 60;
|
||||
|
||||
return {
|
||||
minX: Math.min(acc.minX, x),
|
||||
minY: Math.min(acc.minY, y),
|
||||
maxX: Math.max(acc.maxX, x + w),
|
||||
maxY: Math.max(acc.maxY, y + h)
|
||||
};
|
||||
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
|
||||
|
||||
const width = bounds.maxX - bounds.minX;
|
||||
const height = bounds.maxY - bounds.minY;
|
||||
if (width <= 0 || height <= 0) return;
|
||||
|
||||
const baseScale = Math.min(canvas.width / Math.max(width, 1000), canvas.height / Math.max(height, 1000));
|
||||
const scale = baseScale * zoom;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(canvas.width / 2 + panX, canvas.height / 2 + panY);
|
||||
ctx.scale(scale, scale);
|
||||
ctx.translate(-(bounds.minX + width/2), -(bounds.minY + height/2));
|
||||
|
||||
// Draw grid
|
||||
ctx.strokeStyle = toRGBA(colors.inputText, 0.1);
|
||||
ctx.lineWidth = 1 / scale;
|
||||
const gridSize = 100;
|
||||
for (let x = Math.floor(bounds.minX / gridSize) * gridSize; x <= bounds.maxX; x += gridSize) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, bounds.minY);
|
||||
ctx.lineTo(x, bounds.maxY);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = Math.floor(bounds.minY / gridSize) * gridSize; y <= bounds.maxY; y += gridSize) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bounds.minX, y);
|
||||
ctx.lineTo(bounds.maxX, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
nodes.forEach((n, index) => {
|
||||
const x = n.pos?.[0] ?? n.x ?? 0;
|
||||
const y = n.pos?.[1] ?? n.y ?? 0;
|
||||
const w = n.size?.[0] ?? n.width ?? 100;
|
||||
const h = n.size?.[1] ?? n.height ?? 60;
|
||||
|
||||
ctx.fillStyle = getNodeColor(n);
|
||||
ctx.fillRect(x, y, w, h);
|
||||
|
||||
ctx.strokeStyle = toRGBA(colors.inputText, 0.8);
|
||||
ctx.lineWidth = 1 / scale;
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
|
||||
if (scale > 0.3) {
|
||||
ctx.fillStyle = colors.inputText;
|
||||
ctx.font = `${Math.max(10, 12 / scale)}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const title = n.title || n.type || `Node ${index + 1}`;
|
||||
ctx.fillText(title.substring(0, 15), x + w / 2, y + h / 2);
|
||||
}
|
||||
});
|
||||
|
||||
// Draw viewport indicator
|
||||
const app = getApp();
|
||||
if (app?.canvas?.ds) {
|
||||
const ds = app.canvas.ds;
|
||||
const mainCanvas = document.querySelector('canvas');
|
||||
|
||||
if (mainCanvas) {
|
||||
const viewportX = -ds.offset[0];
|
||||
const viewportY = -ds.offset[1];
|
||||
const viewportW = mainCanvas.width / ds.scale;
|
||||
const viewportH = mainCanvas.height / ds.scale;
|
||||
|
||||
ctx.fillStyle = toRGBA(colors.accent || '#4a90e2', 0.12);
|
||||
ctx.fillRect(viewportX, viewportY, viewportW, viewportH);
|
||||
|
||||
ctx.strokeStyle = toRGBA(colors.accent || '#4a90e2', 0.8);
|
||||
ctx.lineWidth = 2 / scale;
|
||||
ctx.strokeRect(viewportX, viewportY, viewportW, viewportH);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
panInfo.textContent = `Nodes: ${nodes.length} | Zoom: ${(zoom * 100).toFixed(0)}% | Pan: ${panX.toFixed(0)}, ${panY.toFixed(0)}`;
|
||||
}
|
||||
|
||||
function navigateToPosition(canvasX, canvasY) {
|
||||
const worldPos = canvasToWorld(canvasX, canvasY);
|
||||
if (!worldPos) return;
|
||||
|
||||
const app = getApp();
|
||||
const mainCanvas = document.querySelector('canvas');
|
||||
if (!app?.canvas?.ds || !mainCanvas) return;
|
||||
|
||||
try {
|
||||
// Center the main canvas on the clicked world position
|
||||
app.canvas.ds.offset[0] = -worldPos.x + mainCanvas.width / 2;
|
||||
app.canvas.ds.offset[1] = -worldPos.y + mainCanvas.height / 2;
|
||||
app.canvas.setDirty(true, true);
|
||||
|
||||
// Update minimap to reflect the change
|
||||
setTimeout(() => drawMinimap(), 50);
|
||||
} catch (err) {
|
||||
console.log("❌ Navigation error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function isClickInViewport(canvasX, canvasY) {
|
||||
const app = getApp();
|
||||
if (!app?.canvas?.ds) return false;
|
||||
|
||||
const ds = app.canvas.ds;
|
||||
const mainCanvas = document.querySelector('canvas');
|
||||
if (!mainCanvas) return false;
|
||||
|
||||
const worldPos = canvasToWorld(canvasX, canvasY);
|
||||
if (!worldPos) return false;
|
||||
|
||||
// Check if click is inside viewport rectangle in world coordinates
|
||||
const viewportX = -ds.offset[0];
|
||||
const viewportY = -ds.offset[1];
|
||||
const viewportW = mainCanvas.width / ds.scale;
|
||||
const viewportH = mainCanvas.height / ds.scale;
|
||||
|
||||
return worldPos.x >= viewportX && worldPos.x <= viewportX + viewportW &&
|
||||
worldPos.y >= viewportY && worldPos.y <= viewportY + viewportH;
|
||||
}
|
||||
|
||||
function adjustPanToKeepNodesVisible() {
|
||||
const nodes = getNodes();
|
||||
if (!nodes?.length) return;
|
||||
|
||||
const canvas = currentDialog?.querySelector('canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
// Calculate bounds
|
||||
const bounds = nodes.reduce((acc, n) => {
|
||||
const x = n.pos?.[0] ?? n.x ?? 0;
|
||||
const y = n.pos?.[1] ?? n.y ?? 0;
|
||||
const w = n.size?.[0] ?? n.width ?? 100;
|
||||
const h = n.size?.[1] ?? n.height ?? 60;
|
||||
|
||||
return {
|
||||
minX: Math.min(acc.minX, x),
|
||||
minY: Math.min(acc.minY, y),
|
||||
maxX: Math.max(acc.maxX, x + w),
|
||||
maxY: Math.max(acc.maxY, y + h)
|
||||
};
|
||||
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
|
||||
|
||||
const width = bounds.maxX - bounds.minX;
|
||||
const height = bounds.maxY - bounds.minY;
|
||||
if (width <= 0 || height <= 0) return;
|
||||
|
||||
const baseScale = Math.min(canvas.width / Math.max(width, 1000), canvas.height / Math.max(height, 1000));
|
||||
const scale = baseScale * zoom;
|
||||
|
||||
// If zoomed in too much, adjust pan to keep nodes centered
|
||||
if (zoom > 2) {
|
||||
const maxPanX = canvas.width / 4;
|
||||
const maxPanY = canvas.height / 4;
|
||||
panX = Math.max(-maxPanX, Math.min(maxPanX, panX));
|
||||
panY = Math.max(-maxPanY, Math.min(maxPanY, panY));
|
||||
}
|
||||
}
|
||||
|
||||
function setupEventHandlers() {
|
||||
const canvas = currentDialog.querySelector('canvas');
|
||||
let isViewportDragging = false;
|
||||
|
||||
// Mouse handlers
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
dragStartTime = Date.now();
|
||||
dragStartX = e.clientX;
|
||||
dragStartY = e.clientY;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const canvasX = e.clientX - rect.left;
|
||||
const canvasY = e.clientY - rect.top;
|
||||
|
||||
// Check if clicking inside viewport indicator
|
||||
isViewportDragging = isClickInViewport(canvasX, canvasY);
|
||||
|
||||
if (isViewportDragging) {
|
||||
canvas.style.cursor = 'grab';
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
if (!dragStartTime) return;
|
||||
|
||||
const totalDelta = Math.abs(e.clientX - dragStartX) + Math.abs(e.clientY - dragStartY);
|
||||
|
||||
if (totalDelta > DRAG_THRESHOLD && !isDragging) {
|
||||
isDragging = true;
|
||||
if (isViewportDragging) {
|
||||
canvas.style.cursor = 'grabbing';
|
||||
} else {
|
||||
canvas.style.cursor = 'move';
|
||||
}
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
if (isViewportDragging) {
|
||||
// Move the viewport - convert movement to world coordinates
|
||||
const app = getApp();
|
||||
if (app?.canvas?.ds) {
|
||||
const transform = getTransformState();
|
||||
if (transform) {
|
||||
// Scale movement by the inverse of the minimap scale
|
||||
const movementScale = 1 / transform.scale;
|
||||
app.canvas.ds.offset[0] -= e.movementX * movementScale;
|
||||
app.canvas.ds.offset[1] -= e.movementY * movementScale;
|
||||
app.canvas.setDirty(true, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Pan the minimap view
|
||||
panX += e.movementX;
|
||||
panY += e.movementY;
|
||||
}
|
||||
drawMinimap();
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseup', (e) => {
|
||||
const clickDuration = Date.now() - dragStartTime;
|
||||
const totalMovement = Math.abs(e.clientX - dragStartX) + Math.abs(e.clientY - dragStartY);
|
||||
|
||||
if (!isDragging && clickDuration < 500 && totalMovement < DRAG_THRESHOLD) {
|
||||
if (!isViewportDragging) {
|
||||
// Regular click-to-navigate (only if not clicking viewport)
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
navigateToPosition(e.clientX - rect.left, e.clientY - rect.top);
|
||||
}
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
isViewportDragging = false;
|
||||
dragStartTime = 0;
|
||||
canvas.style.cursor = 'crosshair';
|
||||
});
|
||||
|
||||
canvas.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const oldZoom = zoom;
|
||||
zoom = Math.max(0.1, Math.min(5, zoom * (e.deltaY > 0 ? 0.9 : 1.1)));
|
||||
|
||||
// Adjust pan to keep content centered when zooming
|
||||
if (zoom !== oldZoom) {
|
||||
adjustPanToKeepNodesVisible();
|
||||
}
|
||||
|
||||
drawMinimap();
|
||||
});
|
||||
|
||||
// Button handlers
|
||||
currentDialog.querySelector('#close-btn').onclick = () => closeDialog();
|
||||
currentDialog.querySelector('#legend-btn').onclick = () => {
|
||||
const legend = currentDialog.querySelector('.legend');
|
||||
const isVisible = legend.style.display !== 'none';
|
||||
legend.style.display = isVisible ? 'none' : 'block';
|
||||
if (!isVisible) updateLegend();
|
||||
};
|
||||
currentDialog.querySelector('#zoom-in').onclick = () => {
|
||||
zoom = Math.min(zoom * 1.2, 5);
|
||||
adjustPanToKeepNodesVisible();
|
||||
drawMinimap();
|
||||
};
|
||||
currentDialog.querySelector('#zoom-out').onclick = () => {
|
||||
zoom = Math.max(zoom / 1.2, 0.1);
|
||||
adjustPanToKeepNodesVisible();
|
||||
drawMinimap();
|
||||
};
|
||||
currentDialog.querySelector('#zoom-reset').onclick = () => {
|
||||
zoom = 1;
|
||||
panX = panY = 0;
|
||||
drawMinimap();
|
||||
};
|
||||
|
||||
// ESC key
|
||||
const escHandler = (e) => e.key === 'Escape' && closeDialog();
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
return escHandler;
|
||||
}
|
||||
|
||||
function createMinimapDialog() {
|
||||
if (currentDialog) return;
|
||||
|
||||
const colors = getComfyUIColors();
|
||||
const { containerWidth, containerHeight, canvasWidth, canvasHeight } = calculateDimensions();
|
||||
|
||||
// Clean up existing style
|
||||
document.getElementById('minimap-style')?.remove();
|
||||
|
||||
// Create style tag
|
||||
const style = document.createElement('style');
|
||||
style.id = 'minimap-style';
|
||||
style.textContent = createStyleCSS(colors);
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Create container
|
||||
const container = document.createElement('div');
|
||||
container.id = 'endless-minimap';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 100px;
|
||||
right: 20px;
|
||||
width: ${containerWidth}px;
|
||||
height: ${containerHeight}px;
|
||||
background: ${colors.menu};
|
||||
color: ${colors.inputText};
|
||||
border: 1px solid ${colors.accent};
|
||||
border-radius: 8px;
|
||||
box-shadow: ${colors.shadow || '0 4px 12px rgba(0, 0, 0, 0.25)'};
|
||||
z-index: 99999;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="drag-bar">
|
||||
<span>Endless 🌊✨ Minimap</span>
|
||||
<div style="float: right; margin-top: -2px;">
|
||||
<button id="legend-btn" title="Toggle legend">🎨</button>
|
||||
<button id="zoom-out" title="Zoom out">▫️</button>
|
||||
<button id="zoom-reset" title="Reset zoom and pan">🏠</button>
|
||||
<button id="zoom-in" title="Zoom in">⬜</button>
|
||||
<button id="close-btn" title="Close minimap">❌</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1; position: relative; overflow: hidden;">
|
||||
<canvas width="${canvasWidth}" height="${canvasHeight}" style="display: block; cursor: crosshair; position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></canvas>
|
||||
<div class="legend"></div>
|
||||
</div>
|
||||
<div class="pan-info">Nodes: 0 | Zoom: 100% | Pan: 0, 0</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(container);
|
||||
currentDialog = container;
|
||||
|
||||
// Setup dragging
|
||||
makeDraggable(container, container.querySelector('.drag-bar'));
|
||||
|
||||
// Setup event handlers
|
||||
const escHandler = setupEventHandlers();
|
||||
|
||||
// Setup resize observer
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
const newAspectRatio = getCanvasAspectRatio();
|
||||
const canvas = container.querySelector('canvas');
|
||||
const currentAspectRatio = canvas.width / canvas.height;
|
||||
|
||||
if (Math.abs(newAspectRatio - currentAspectRatio) > 0.1) {
|
||||
const { containerWidth: newContainerWidth, containerHeight: newContainerHeight, canvasWidth: newCanvasWidth, canvasHeight: newCanvasHeight } = calculateDimensions();
|
||||
|
||||
// Update container size
|
||||
container.style.width = `${newContainerWidth}px`;
|
||||
container.style.height = `${newContainerHeight}px`;
|
||||
|
||||
// Update canvas size
|
||||
canvas.width = newCanvasWidth;
|
||||
canvas.height = newCanvasHeight;
|
||||
|
||||
drawMinimap();
|
||||
}
|
||||
});
|
||||
|
||||
const mainCanvas = document.querySelector('canvas');
|
||||
if (mainCanvas) resizeObserver.observe(mainCanvas);
|
||||
resizeObserver.observe(document.body);
|
||||
|
||||
// Setup theme updates
|
||||
unregisterThemeCallback = onThemeChange(updateTheme);
|
||||
|
||||
// Start animation loop
|
||||
function updateLoop() {
|
||||
drawMinimap();
|
||||
animationId = setTimeout(updateLoop, 1000);
|
||||
}
|
||||
updateLoop();
|
||||
|
||||
// Setup cleanup
|
||||
const originalRemove = container.remove.bind(container);
|
||||
container.remove = function() {
|
||||
clearTimeout(animationId);
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
resizeObserver?.disconnect();
|
||||
unregisterThemeCallback?.();
|
||||
document.getElementById('minimap-style')?.remove();
|
||||
currentDialog = null;
|
||||
animationId = null;
|
||||
unregisterThemeCallback = null;
|
||||
resizeObserver = null;
|
||||
originalRemove();
|
||||
};
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
currentDialog?.remove();
|
||||
}
|
||||
|
||||
// Register tool
|
||||
registerEndlessTool("Minimap", createMinimapDialog);
|
||||
})();
|
||||
@@ -1,4 +1,5 @@
|
||||
// === Endless 🌊✨ Tools UI Helper ===
|
||||
|
||||
const endlessToolsRegistry = [];
|
||||
|
||||
export function registerEndlessTool(name, callback) {
|
||||
@@ -29,23 +30,23 @@ export function showEndlessToolMenu() {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: ${colors.background};
|
||||
color: ${colors.text};
|
||||
background: ${colors.menu};
|
||||
color: ${colors.inputText};
|
||||
padding: 12px;
|
||||
border: 1px solid ${colors.accent};
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
z-index: 99999;
|
||||
transition: opacity 0.2s ease;
|
||||
opacity: 1;
|
||||
min-width: 180px;
|
||||
width: fit-content;
|
||||
transition: opacity 0.2s ease;
|
||||
`;
|
||||
|
||||
const dragBar = document.createElement("div");
|
||||
dragBar.textContent = "→ Drag Me With This ←";
|
||||
dragBar.textContent = "Endless 🌊✨ Tools Drag Bar";
|
||||
dragBar.style.cssText = `
|
||||
padding: 4px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: ${toRGBA(colors.inputText, 0.05)};
|
||||
cursor: move;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
@@ -54,7 +55,7 @@ export function showEndlessToolMenu() {
|
||||
`;
|
||||
menu.appendChild(dragBar);
|
||||
|
||||
endlessToolsRegistry.forEach(tool => {
|
||||
endlessToolsRegistry.sort((a, b) => a.name.localeCompare(b.name)).forEach(tool => {
|
||||
const btn = document.createElement("div");
|
||||
btn.textContent = `🌊✨ ${tool.name}`;
|
||||
btn.style.cssText = `
|
||||
@@ -63,7 +64,7 @@ export function showEndlessToolMenu() {
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
`;
|
||||
btn.onmouseover = () => btn.style.background = "rgba(255, 255, 255, 0.1)";
|
||||
btn.onmouseover = () => btn.style.background = toRGBA(colors.inputText, 0.1);
|
||||
btn.onmouseout = () => btn.style.background = "transparent";
|
||||
btn.onclick = () => {
|
||||
tool.callback();
|
||||
@@ -73,9 +74,27 @@ export function showEndlessToolMenu() {
|
||||
});
|
||||
|
||||
makeDraggable(menu, dragBar);
|
||||
|
||||
// Live theme updater
|
||||
function updateMenuTheme(newColors = getComfyUIColors()) {
|
||||
menu.style.background = newColors.menu;
|
||||
menu.style.color = newColors.inputText;
|
||||
menu.style.borderColor = newColors.accent;
|
||||
menu.style.boxShadow = newColors.shadow;
|
||||
dragBar.style.background = toRGBA(newColors.inputText, 0.05);
|
||||
dragBar.style.borderBottomColor = newColors.border;
|
||||
}
|
||||
|
||||
const unregister = onThemeChange(updateMenuTheme);
|
||||
menu.remove = ((orig => function () {
|
||||
unregister();
|
||||
orig.call(this);
|
||||
})(menu.remove));
|
||||
|
||||
document.body.appendChild(menu);
|
||||
}
|
||||
|
||||
// === Hotkeys ===
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.altKey && e.key.toLowerCase() === 'e') {
|
||||
showEndlessToolMenu();
|
||||
@@ -104,49 +123,130 @@ function waitForToolbarAndInject() {
|
||||
waitForToolbarAndInject();
|
||||
|
||||
function findToolbar() {
|
||||
let toolbar = document.querySelector('.comfyui-menu, .comfy-menu, [class*="menu"], [class*="toolbar"]');
|
||||
if (!toolbar) {
|
||||
const buttonGroups = document.querySelectorAll('[class*="button-group"], [class*="btn-group"], .comfyui-button-group');
|
||||
toolbar = Array.from(buttonGroups).find(group => group.querySelectorAll('button').length > 0);
|
||||
}
|
||||
if (!toolbar) {
|
||||
const allElements = document.querySelectorAll('*');
|
||||
toolbar = Array.from(allElements).find(el => {
|
||||
const buttons = el.querySelectorAll('button');
|
||||
return buttons.length >= 2 && buttons.length <= 10;
|
||||
});
|
||||
}
|
||||
if (!toolbar) {
|
||||
toolbar = Array.from(document.querySelectorAll(".comfyui-button-group")).find(div =>
|
||||
Array.from(div.querySelectorAll("button")).some(btn => btn.title === "Share")
|
||||
);
|
||||
}
|
||||
return toolbar;
|
||||
return (
|
||||
document.querySelector('.comfyui-menu, .comfy-menu, [class*="menu"], [class*="toolbar"]') ||
|
||||
Array.from(document.querySelectorAll('[class*="button-group"], [class*="btn-group"], .comfyui-button-group'))
|
||||
.find(g => g.querySelectorAll('button').length > 0) ||
|
||||
Array.from(document.querySelectorAll('*'))
|
||||
.find(el => {
|
||||
const buttons = el.querySelectorAll('button');
|
||||
return buttons.length >= 2 && buttons.length <= 10;
|
||||
}) ||
|
||||
Array.from(document.querySelectorAll(".comfyui-button-group"))
|
||||
.find(div => Array.from(div.querySelectorAll("button")).some(btn => btn.title === "Share"))
|
||||
);
|
||||
}
|
||||
|
||||
function getComfyUIColors() {
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
background: computedStyle.getPropertyValue('--comfy-menu-bg') || '#353535',
|
||||
backgroundSecondary: computedStyle.getPropertyValue('--comfy-input-bg') || '#222',
|
||||
border: computedStyle.getPropertyValue('--border-color') || '#999',
|
||||
text: computedStyle.getPropertyValue('--input-text') || '#ddd',
|
||||
accent: computedStyle.getPropertyValue('--comfy-accent-color') || '#5fc0e9' // fallback soft blue
|
||||
// === Live Theme Monitoring ===
|
||||
let themeObserver = null;
|
||||
const themeCallbacks = new Set();
|
||||
|
||||
export function onThemeChange(callback) {
|
||||
themeCallbacks.add(callback);
|
||||
if (themeCallbacks.size === 1) startThemeObserver();
|
||||
return () => {
|
||||
themeCallbacks.delete(callback);
|
||||
if (themeCallbacks.size === 0) stopThemeObserver();
|
||||
};
|
||||
}
|
||||
|
||||
function makeDraggable(element, handle = element) {
|
||||
let offsetX = 0, offsetY = 0, isDown = false;
|
||||
function startThemeObserver() {
|
||||
if (themeObserver) return;
|
||||
themeObserver = new MutationObserver(() => {
|
||||
clearTimeout(window.themeChangeTimeout);
|
||||
window.themeChangeTimeout = setTimeout(() => {
|
||||
const newColors = getComfyUIColors();
|
||||
themeCallbacks.forEach(cb => cb(newColors));
|
||||
}, 100);
|
||||
});
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
|
||||
themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
|
||||
}
|
||||
|
||||
function stopThemeObserver() {
|
||||
if (themeObserver) {
|
||||
themeObserver.disconnect();
|
||||
themeObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getComfyUIColors() {
|
||||
const computed = getComputedStyle(document.documentElement);
|
||||
const getVar = name => computed.getPropertyValue(name).trim() || null;
|
||||
return {
|
||||
fg: getVar("--fg-color") || "#ddd",
|
||||
bg: getVar("--bg-color") || "#353535",
|
||||
menu: getVar("--comfy-menu-bg") || "#353535",
|
||||
menuSecondary: getVar("--comfy-menu-secondary-bg") || "#222",
|
||||
inputBg: getVar("--comfy-input-bg") || "#222",
|
||||
inputText: getVar("--input-text") || "#ddd",
|
||||
descriptionText: getVar("--descrip-text") || "#999",
|
||||
dragText: getVar("--drag-text") || "#ddd",
|
||||
errorText: getVar("--error-text") || "#f44336",
|
||||
border: getVar("--border-color") || "#999",
|
||||
accent: getVar("--comfy-accent") || getVar("--comfy-accent-color") || "#4a90e2",
|
||||
hoverBg: getVar("--content-hover-bg") || "rgba(255,255,255,0.1)",
|
||||
hoverFg: getVar("--content-hover-fg") || "#fff",
|
||||
shadow: getVar("--bar-shadow") || "0 2px 10px rgba(0,0,0,0.3)",
|
||||
dialogBg: getVar("--comfy-menu-bg") || getVar("--bg-color") || "#353535",
|
||||
buttonHoverBg: getVar("--content-hover-bg") || "rgba(255,255,255,0.1)"
|
||||
};
|
||||
}
|
||||
|
||||
export function toRGBA(color, alpha = 0.2) {
|
||||
if (!color) return `rgba(128,128,128,${alpha})`;
|
||||
color = color.trim();
|
||||
if (color.startsWith('#')) {
|
||||
const hex = color.slice(1);
|
||||
const fullHex = hex.length === 3 ? hex.split('').map(c => c + c).join('') : hex;
|
||||
const bigint = parseInt(fullHex, 16);
|
||||
const r = (bigint >> 16) & 255, g = (bigint >> 8) & 255, b = bigint & 255;
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
if (color.startsWith('rgb')) {
|
||||
const rgb = color.match(/\d+/g);
|
||||
if (rgb?.length >= 3) return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`;
|
||||
}
|
||||
return `rgba(128,128,128,${alpha})`;
|
||||
}
|
||||
|
||||
export function blendColors(color1, color2, ratio) {
|
||||
const c1 = toRGBA(color1, 1).match(/\d+/g);
|
||||
const c2 = toRGBA(color2, 1).match(/\d+/g);
|
||||
if (!c1 || !c2) return color1;
|
||||
const r = Math.round(c1[0] * (1 - ratio) + c2[0] * ratio);
|
||||
const g = Math.round(c1[1] * (1 - ratio) + c2[1] * ratio);
|
||||
const b = Math.round(c1[2] * (1 - ratio) + c2[2] * ratio);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
export function addButtonHoverEffects(container) {
|
||||
container?.querySelectorAll('button').forEach(button => {
|
||||
button.addEventListener('mouseenter', () => {
|
||||
button.style.boxShadow = '0 0 0 1px currentColor';
|
||||
button.style.filter = 'brightness(1.1)';
|
||||
button.style.transform = 'translateY(-1px)';
|
||||
});
|
||||
button.addEventListener('mouseleave', () => {
|
||||
button.style.boxShadow = 'none';
|
||||
button.style.filter = 'brightness(1)';
|
||||
button.style.transform = 'translateY(0px)';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function makeDraggable(element, handle = element) {
|
||||
let offsetX = 0, offsetY = 0, isDown = false;
|
||||
handle.onmousedown = (e) => {
|
||||
isDown = true;
|
||||
if (element.style.position !== 'fixed') {
|
||||
element.style.position = 'fixed';
|
||||
element.style.right = 'auto';
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
offsetX = e.clientX - rect.left;
|
||||
offsetY = e.clientY - rect.top;
|
||||
element.style.cursor = 'move';
|
||||
element.style.position = 'fixed';
|
||||
element.style.right = 'auto';
|
||||
|
||||
document.onmousemove = (e) => {
|
||||
if (!isDown) return;
|
||||
element.style.left = `${e.clientX - offsetX}px`;
|
||||
@@ -162,3 +262,15 @@ function makeDraggable(element, handle = element) {
|
||||
};
|
||||
}
|
||||
|
||||
// === Global exposure for F12 ===
|
||||
window.EndlessHelpers = {
|
||||
registerEndlessTool,
|
||||
injectEndlessToolsButton,
|
||||
showEndlessToolMenu,
|
||||
onThemeChange,
|
||||
getComfyUIColors,
|
||||
toRGBA,
|
||||
blendColors,
|
||||
addButtonHoverEffects,
|
||||
makeDraggable
|
||||
};
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from PIL import Image, PngImagePlugin
|
||||
import numpy as np
|
||||
import torch
|
||||
import folder_paths
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
import platform
|
||||
|
||||
class EndlessNode_Imagesaver:
|
||||
"""
|
||||
Enhanced batch image saver with comprehensive metadata support
|
||||
Saves batched images with individual prompt names in filenames
|
||||
Automatically handles multiple images from batch processing
|
||||
Enhanced with workflow embedding, JSON export, and robust filename handling
|
||||
"""
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.type = "output"
|
||||
self.compress_level = 4
|
||||
# OS-specific filename length limits
|
||||
self.max_filename_length = self._get_max_filename_length()
|
||||
|
||||
def _get_max_filename_length(self):
|
||||
"""Get maximum filename length based on OS"""
|
||||
system = platform.system().lower()
|
||||
if system == 'windows':
|
||||
return 255 # NTFS limit
|
||||
elif system in ['linux', 'darwin']: # Linux and macOS
|
||||
return 255 # ext4/APFS limit
|
||||
else:
|
||||
return 200 # Conservative fallback
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required":
|
||||
{"images": ("IMAGE", ),
|
||||
"prompt_list": ("STRING", {"forceInput": True}),
|
||||
"include_timestamp": ("BOOLEAN", {"default": True}),
|
||||
"timestamp_format": ("STRING", {"default": "%Y-%m-%d_%H-%M-%S", "description": "Use Python strftime format.\nExample: %Y-%m-%d %H-%M-%S\nSee: strftime.org for full options."}),
|
||||
"image_format": (["PNG", "JPEG", "WEBP"], {"default": "PNG"}),
|
||||
"jpeg_quality": ("INT", {"default": 95, "min": 1, "max": 100, "step": 1}),
|
||||
"delimiter": ("STRING", {"default": "_"}),
|
||||
"prompt_words_limit": ("INT", {"default": 8, "min": 1, "max": 16, "step": 1}),
|
||||
"embed_workflow": ("BOOLEAN", {"default": True}),
|
||||
"save_json_metadata": ("BOOLEAN", {"default": False}),
|
||||
# ITEM #2: Enable/disable number padding
|
||||
"enable_filename_numbering": ("BOOLEAN", {"default": True}),
|
||||
# ITEM #1: Filename Number Padding Control
|
||||
"filename_number_padding": ("INT", {"default": 2, "min": 1, "max": 9, "step": 1}),
|
||||
"filename_number_start": ("BOOLEAN", {"default": False}),
|
||||
# ITEM #3: Conditional PNG Metadata Embedding
|
||||
"embed_png_metadata": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional":
|
||||
{"output_path": ("STRING", {"default": ""}),
|
||||
"filename_prefix": ("STRING", {"default": "Batch"}),
|
||||
"negative_prompt_list": ("STRING", {"default": ""}),
|
||||
"json_folder": ("STRING", {"default": ""}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO"
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("saved_paths",)
|
||||
FUNCTION = "save_batch_images"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "Endless 🌊✨/IO"
|
||||
|
||||
def encode_emoji(self, obj):
|
||||
"""Properly encode emojis and special characters"""
|
||||
if isinstance(obj, str):
|
||||
return obj.encode('utf-8', 'surrogatepass').decode('utf-8')
|
||||
return obj
|
||||
|
||||
def clean_filename(self, text, max_words=8, delimiter="_"):
|
||||
"""Clean text for use in filenames with word limit and emoji support"""
|
||||
# Limit to specified number of words
|
||||
words = text.split()[:max_words]
|
||||
text = ' '.join(words)
|
||||
|
||||
# Handle emojis by encoding them properly
|
||||
text = self.encode_emoji(text)
|
||||
|
||||
# Replace illegal characters with delimiter, then clean up spaces
|
||||
illegal_chars = r'[<>:"/\\|?*]'
|
||||
clean_text = re.sub(illegal_chars, delimiter, text)
|
||||
clean_text = re.sub(r'\s+', delimiter, clean_text) # Replace spaces with delimiter
|
||||
clean_text = re.sub(r'[^\w\-_.{}]'.format(re.escape(delimiter)), '', clean_text) # Keep only safe chars
|
||||
|
||||
return clean_text
|
||||
|
||||
def format_timestamp(self, dt, format_string, delimiter='_'):
|
||||
try:
|
||||
formatted = dt.strftime(format_string)
|
||||
# Replace colons first
|
||||
formatted = formatted.replace(':', '-')
|
||||
# Then replace all whitespace with the user's delimiter
|
||||
if delimiter:
|
||||
formatted = re.sub(r'\s+', delimiter, formatted)
|
||||
return formatted
|
||||
except Exception as e:
|
||||
print(f"Invalid timestamp format: {e}")
|
||||
return dt.strftime("%Y-%m-%d_%H-%M-%S")
|
||||
|
||||
def validate_and_process_path(self, path, delimiter="_"):
|
||||
if not path or path.strip() == "":
|
||||
return path
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
# Normalize path separators
|
||||
path = path.replace("/", os.sep).replace("\\", os.sep)
|
||||
|
||||
# Handle UNC or drive prefix
|
||||
unc_prefix = ""
|
||||
parts = path.split(os.sep)
|
||||
|
||||
if path.startswith("\\\\"): # UNC path
|
||||
if len(parts) >= 4:
|
||||
unc_prefix = os.sep.join(parts[:4]) # \\server\share
|
||||
parts = parts[4:]
|
||||
else:
|
||||
raise ValueError(f"Invalid UNC path: {path}")
|
||||
elif re.match(r"^[A-Za-z]:$", parts[0]): # Drive letter
|
||||
unc_prefix = parts[0]
|
||||
parts = parts[1:]
|
||||
|
||||
# Process the remaining subfolders
|
||||
processed_parts = []
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
if "%" in part:
|
||||
# Format date placeholders
|
||||
formatted = self.format_timestamp(now, part, delimiter)
|
||||
else:
|
||||
# Sanitize folder names
|
||||
formatted = re.sub(r'[<>:"/\\|?*]', delimiter, part)
|
||||
processed_parts.append(formatted)
|
||||
|
||||
# Reconstruct full path
|
||||
full_path = os.path.join(unc_prefix, *processed_parts)
|
||||
return full_path
|
||||
|
||||
def ensure_filename_length(self, full_path, base_name, extension):
|
||||
"""Ensure the full filename doesn't exceed OS limits"""
|
||||
directory = os.path.dirname(full_path)
|
||||
|
||||
# Calculate available space for filename
|
||||
dir_length = len(directory) + 1 # +1 for path separator
|
||||
available_length = self.max_filename_length - len(extension)
|
||||
max_base_length = available_length - dir_length
|
||||
|
||||
if len(base_name) > max_base_length:
|
||||
# Truncate base name to fit
|
||||
base_name = base_name[:max_base_length-3] + "..." # -3 for ellipsis
|
||||
|
||||
return os.path.join(directory, base_name + extension)
|
||||
|
||||
def get_unique_filename(self, file_path):
|
||||
"""Generate unique filename by adding incremental numbers if file exists"""
|
||||
if not os.path.exists(file_path):
|
||||
return file_path
|
||||
|
||||
directory = os.path.dirname(file_path)
|
||||
filename = os.path.basename(file_path)
|
||||
name, ext = os.path.splitext(filename)
|
||||
|
||||
counter = 1
|
||||
while True:
|
||||
new_name = f"{name}_{counter:03d}{ext}"
|
||||
new_path = os.path.join(directory, new_name)
|
||||
|
||||
# Check length constraints
|
||||
if len(new_name) > self.max_filename_length:
|
||||
# Truncate original name to make room for counter
|
||||
available = self.max_filename_length - len(f"_{counter:03d}{ext}")
|
||||
truncated_name = name[:available-3] + "..."
|
||||
new_name = f"{truncated_name}_{counter:03d}{ext}"
|
||||
new_path = os.path.join(directory, new_name)
|
||||
|
||||
if not os.path.exists(new_path):
|
||||
return new_path
|
||||
counter += 1
|
||||
|
||||
def save_json_metadata(self, json_path, prompt_text, negative_text,
|
||||
batch_index, creation_time, prompt=None, extra_pnginfo=None):
|
||||
"""Save JSON metadata file with ComfyUI-compatible format"""
|
||||
|
||||
# Create the metadata structure that ComfyUI expects
|
||||
metadata = {
|
||||
"prompt": prompt_text,
|
||||
"negative_prompt": negative_text,
|
||||
"batch_index": batch_index,
|
||||
"creation_time": creation_time,
|
||||
}
|
||||
|
||||
# BUG FIX #1: Save workflow and extra_pnginfo in the same format as PNG metadata
|
||||
# ComfyUI expects these to be at the root level, not nested under other keys
|
||||
if prompt is not None:
|
||||
# The 'prompt' parameter contains the workflow data
|
||||
metadata["workflow"] = prompt
|
||||
|
||||
if extra_pnginfo is not None:
|
||||
# extra_pnginfo contains additional workflow metadata
|
||||
# Add each key-value pair from extra_pnginfo directly to metadata
|
||||
for key, value in extra_pnginfo.items():
|
||||
metadata[key] = value
|
||||
|
||||
# BUG FIX #2: Use the same JSON serialization as PNG metadata
|
||||
# Don't double-encode the workflow data
|
||||
try:
|
||||
with open(json_path, 'w', encoding='utf-8', newline='\n') as f:
|
||||
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to save JSON metadata: {e}")
|
||||
return False
|
||||
|
||||
def generate_numbered_filename(self, filename_prefix, delimiter, counter,
|
||||
filename_number_padding, filename_number_start,
|
||||
enable_filename_numbering, date_str, clean_prompt, ext):
|
||||
"""Generate filename with configurable number positioning and padding"""
|
||||
# ITEM #3: Build filename parts in the correct order based on settings
|
||||
filename_parts = []
|
||||
|
||||
# Always add timestamp first if provided
|
||||
if date_str:
|
||||
filename_parts.append(date_str)
|
||||
|
||||
# Add number after timestamp if number_start is True AND numbering is enabled
|
||||
if enable_filename_numbering and filename_number_start:
|
||||
counter_str = f"{counter:0{filename_number_padding}}"
|
||||
filename_parts.append(counter_str)
|
||||
|
||||
# Add filename prefix if provided
|
||||
if filename_prefix:
|
||||
filename_parts.append(filename_prefix)
|
||||
|
||||
# Add cleaned prompt
|
||||
filename_parts.append(clean_prompt)
|
||||
|
||||
# Add number at the end if number_start is False AND numbering is enabled
|
||||
if enable_filename_numbering and not filename_number_start:
|
||||
counter_str = f"{counter:0{filename_number_padding}}"
|
||||
filename_parts.append(counter_str)
|
||||
|
||||
# Join all parts with delimiter
|
||||
filename = delimiter.join(filename_parts) + ext
|
||||
|
||||
return filename
|
||||
|
||||
def save_batch_images(self, images, prompt_list, include_timestamp=True,
|
||||
timestamp_format="%Y-%m-%d_%H-%M-%S", image_format="PNG",
|
||||
jpeg_quality=95, delimiter="_",
|
||||
prompt_words_limit=8, embed_workflow=True, save_json_metadata=False,
|
||||
enable_filename_numbering=True, filename_number_padding=2,
|
||||
filename_number_start=False, embed_png_metadata=True,
|
||||
output_path="", filename_prefix="batch",
|
||||
negative_prompt_list="", json_folder="", prompt=None, extra_pnginfo=None):
|
||||
|
||||
# Debug: Print tensor information
|
||||
print(f"DEBUG: Images tensor shape: {images.shape}")
|
||||
print(f"DEBUG: Images tensor type: {type(images)}")
|
||||
|
||||
# BUG FIX #3: Debug workflow data structure
|
||||
if save_json_metadata and prompt is not None:
|
||||
print(f"DEBUG: Prompt type: {type(prompt)}")
|
||||
print(f"DEBUG: Prompt keys: {list(prompt.keys()) if isinstance(prompt, dict) else 'Not a dict'}")
|
||||
|
||||
if save_json_metadata and extra_pnginfo is not None:
|
||||
print(f"DEBUG: Extra PNG info type: {type(extra_pnginfo)}")
|
||||
print(f"DEBUG: Extra PNG info keys: {list(extra_pnginfo.keys()) if isinstance(extra_pnginfo, dict) else 'Not a dict'}")
|
||||
|
||||
# Process output path with date/time validation (always process regardless of timestamp toggle)
|
||||
processed_output_path = self.validate_and_process_path(output_path, delimiter)
|
||||
|
||||
# Set output directory
|
||||
if processed_output_path.strip() != "":
|
||||
if not os.path.isabs(processed_output_path):
|
||||
output_dir = os.path.join(self.output_dir, processed_output_path)
|
||||
else:
|
||||
output_dir = processed_output_path
|
||||
else:
|
||||
output_dir = self.output_dir
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Could not create output directory {output_dir}: {e}")
|
||||
|
||||
# Set up JSON directory
|
||||
if save_json_metadata:
|
||||
if json_folder.strip():
|
||||
processed_json_folder = self.validate_and_process_path(json_folder, delimiter)
|
||||
if not os.path.isabs(processed_json_folder):
|
||||
json_dir = os.path.join(self.output_dir, processed_json_folder)
|
||||
else:
|
||||
json_dir = processed_json_folder
|
||||
else:
|
||||
json_dir = output_dir
|
||||
|
||||
try:
|
||||
os.makedirs(json_dir, exist_ok=True)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not create JSON directory {json_dir}: {e}")
|
||||
json_dir = output_dir
|
||||
|
||||
# Generate datetime string if timestamp is enabled
|
||||
now = datetime.now()
|
||||
if include_timestamp:
|
||||
date_str = self.format_timestamp(now, timestamp_format, delimiter)
|
||||
else:
|
||||
date_str = None
|
||||
|
||||
# Parse individual prompts from the prompt list
|
||||
individual_prompts = prompt_list.split('|')
|
||||
individual_negatives = negative_prompt_list.split('|') if negative_prompt_list else []
|
||||
|
||||
# Set file extension
|
||||
if image_format == "PNG":
|
||||
ext = ".png"
|
||||
elif image_format == "JPEG":
|
||||
ext = ".jpg"
|
||||
elif image_format == "WEBP":
|
||||
ext = ".webp"
|
||||
else:
|
||||
ext = ".png"
|
||||
|
||||
saved_paths = []
|
||||
|
||||
# Handle different tensor formats
|
||||
if isinstance(images, torch.Tensor):
|
||||
# Convert to numpy for easier handling
|
||||
images_np = images.cpu().numpy()
|
||||
print(f"DEBUG: Converted to numpy shape: {images_np.shape}")
|
||||
|
||||
# Check if we have a batch dimension
|
||||
if len(images_np.shape) == 4: # Batch format: [B, H, W, C] or [B, C, H, W]
|
||||
batch_size = images_np.shape[0]
|
||||
print(f"DEBUG: Found batch of {batch_size} images")
|
||||
|
||||
for i in range(batch_size):
|
||||
try:
|
||||
# Extract single image from batch
|
||||
img_array = images_np[i]
|
||||
|
||||
# Validate and process the image array
|
||||
if len(img_array.shape) != 3:
|
||||
raise ValueError(f"Expected 3D tensor for image {i+1}, got shape {img_array.shape}")
|
||||
|
||||
# Convert to 0-255 range if needed
|
||||
if img_array.max() <= 1.0:
|
||||
img_array = img_array * 255.0
|
||||
|
||||
img_array = np.clip(img_array, 0, 255).astype(np.uint8)
|
||||
|
||||
# Handle different channel orders (HWC vs CHW)
|
||||
if img_array.shape[0] == 3 or img_array.shape[0] == 4: # CHW format
|
||||
img_array = np.transpose(img_array, (1, 2, 0)) # Convert to HWC
|
||||
|
||||
img = Image.fromarray(img_array)
|
||||
|
||||
# Get the corresponding prompt for this image
|
||||
if i < len(individual_prompts):
|
||||
prompt_text = individual_prompts[i].strip()
|
||||
else:
|
||||
# Cycle through prompts if we have more images than prompts
|
||||
prompt_text = individual_prompts[i % len(individual_prompts)].strip()
|
||||
print(f"Note: Cycling prompt for image {i+1} (using prompt {(i % len(individual_prompts)) + 1})")
|
||||
|
||||
# Get corresponding negative prompt
|
||||
negative_text = ""
|
||||
if individual_negatives:
|
||||
if i < len(individual_negatives):
|
||||
negative_text = individual_negatives[i].strip()
|
||||
else:
|
||||
negative_text = individual_negatives[i % len(individual_negatives)].strip()
|
||||
|
||||
# Clean the prompt for filename use
|
||||
clean_prompt = self.clean_filename(prompt_text, prompt_words_limit, delimiter)
|
||||
|
||||
# Generate filename using the new method
|
||||
filename = self.generate_numbered_filename(
|
||||
filename_prefix, delimiter, i+1,
|
||||
filename_number_padding, filename_number_start,
|
||||
enable_filename_numbering, date_str, clean_prompt, ext
|
||||
)
|
||||
|
||||
# Create full file path and ensure length constraints
|
||||
base_filename = os.path.splitext(filename)[0]
|
||||
temp_path = os.path.join(output_dir, filename)
|
||||
file_path = self.ensure_filename_length(temp_path, base_filename, ext)
|
||||
|
||||
# Ensure unique filename
|
||||
file_path = self.get_unique_filename(file_path)
|
||||
|
||||
# Create JSON path if needed
|
||||
if save_json_metadata:
|
||||
json_base = os.path.splitext(os.path.basename(file_path))[0]
|
||||
json_path = os.path.join(json_dir, json_base + ".json")
|
||||
json_path = self.get_unique_filename(json_path)
|
||||
|
||||
# Save image based on format
|
||||
if image_format == "PNG":
|
||||
# ITEM #3: Conditional PNG metadata embedding
|
||||
if embed_png_metadata:
|
||||
# Prepare PNG metadata
|
||||
metadata = PngImagePlugin.PngInfo()
|
||||
metadata.add_text("prompt", prompt_text)
|
||||
metadata.add_text("negative_prompt", negative_text)
|
||||
metadata.add_text("batch_index", str(i+1))
|
||||
metadata.add_text("creation_time", now.isoformat())
|
||||
|
||||
# Add workflow data if requested
|
||||
if embed_workflow:
|
||||
# BUG FIX #4: Save workflow data in the same format as expected by ComfyUI
|
||||
if prompt is not None:
|
||||
# Convert prompt to JSON string for PNG metadata
|
||||
metadata.add_text("workflow", json.dumps(prompt, ensure_ascii=False))
|
||||
if extra_pnginfo is not None:
|
||||
for key, value in extra_pnginfo.items():
|
||||
# Convert each value to JSON string for PNG metadata
|
||||
metadata.add_text(key, json.dumps(value, ensure_ascii=False))
|
||||
|
||||
img.save(file_path, format="PNG", optimize=True,
|
||||
compress_level=self.compress_level, pnginfo=metadata)
|
||||
else:
|
||||
# ITEM #3: Save clean PNG without metadata
|
||||
img.save(file_path, format="PNG", optimize=True,
|
||||
compress_level=self.compress_level)
|
||||
|
||||
elif image_format == "JPEG":
|
||||
# Convert RGBA to RGB for JPEG
|
||||
if img.mode == 'RGBA':
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
background.paste(img, mask=img.split()[-1])
|
||||
img = background
|
||||
img.save(file_path, format="JPEG", quality=jpeg_quality, optimize=True)
|
||||
|
||||
elif image_format == "WEBP":
|
||||
img.save(file_path, format="WEBP", quality=jpeg_quality, method=6)
|
||||
|
||||
# Save JSON metadata if requested
|
||||
if save_json_metadata:
|
||||
self.save_json_metadata(json_path, prompt_text, negative_text,
|
||||
i+1, now.isoformat(), prompt, extra_pnginfo)
|
||||
|
||||
saved_paths.append(file_path)
|
||||
print(f"Saved: {os.path.basename(file_path)}")
|
||||
print(f" Prompt: {prompt_text}")
|
||||
if negative_text:
|
||||
print(f" Negative: {negative_text}")
|
||||
if save_json_metadata:
|
||||
print(f" JSON: {os.path.basename(json_path)}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to save image {i+1}: {e}"
|
||||
print(error_msg)
|
||||
# Continue with other images rather than failing completely
|
||||
saved_paths.append(f"ERROR: {error_msg}")
|
||||
|
||||
elif len(images_np.shape) == 3: # Single image format: [H, W, C]
|
||||
print("DEBUG: Single image detected, processing as batch of 1")
|
||||
# Process as single image
|
||||
img_array = images_np
|
||||
|
||||
# Convert to 0-255 range if needed
|
||||
if img_array.max() <= 1.0:
|
||||
img_array = img_array * 255.0
|
||||
|
||||
img_array = np.clip(img_array, 0, 255).astype(np.uint8)
|
||||
img = Image.fromarray(img_array)
|
||||
|
||||
prompt_text = individual_prompts[0].strip() if individual_prompts else "no_prompt"
|
||||
negative_text = individual_negatives[0].strip() if individual_negatives else ""
|
||||
clean_prompt = self.clean_filename(prompt_text, prompt_words_limit, delimiter)
|
||||
|
||||
# Generate filename using the new method
|
||||
filename = self.generate_numbered_filename(
|
||||
filename_prefix, delimiter, 1,
|
||||
filename_number_padding, filename_number_start,
|
||||
enable_filename_numbering, date_str, clean_prompt, ext
|
||||
)
|
||||
|
||||
base_filename = os.path.splitext(filename)[0]
|
||||
temp_path = os.path.join(output_dir, filename)
|
||||
file_path = self.ensure_filename_length(temp_path, base_filename, ext)
|
||||
file_path = self.get_unique_filename(file_path)
|
||||
|
||||
if save_json_metadata:
|
||||
json_base = os.path.splitext(os.path.basename(file_path))[0]
|
||||
json_path = os.path.join(json_dir, json_base + ".json")
|
||||
json_path = self.get_unique_filename(json_path)
|
||||
|
||||
if image_format == "PNG":
|
||||
# ITEM #3: Conditional PNG metadata embedding
|
||||
if embed_png_metadata:
|
||||
metadata = PngImagePlugin.PngInfo()
|
||||
metadata.add_text("prompt", prompt_text)
|
||||
metadata.add_text("negative_prompt", negative_text)
|
||||
metadata.add_text("batch_index", "1")
|
||||
metadata.add_text("creation_time", now.isoformat())
|
||||
|
||||
if embed_workflow:
|
||||
if prompt is not None:
|
||||
metadata.add_text("workflow", json.dumps(prompt, ensure_ascii=False))
|
||||
if extra_pnginfo is not None:
|
||||
for key, value in extra_pnginfo.items():
|
||||
metadata.add_text(key, json.dumps(value, ensure_ascii=False))
|
||||
|
||||
img.save(file_path, format="PNG", optimize=True,
|
||||
compress_level=self.compress_level, pnginfo=metadata)
|
||||
else:
|
||||
# ITEM #3: Save clean PNG without metadata
|
||||
img.save(file_path, format="PNG", optimize=True,
|
||||
compress_level=self.compress_level)
|
||||
elif image_format == "JPEG":
|
||||
if img.mode == 'RGBA':
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
background.paste(img, mask=img.split()[-1])
|
||||
img = background
|
||||
img.save(file_path, format="JPEG", quality=jpeg_quality, optimize=True)
|
||||
elif image_format == "WEBP":
|
||||
img.save(file_path, format="WEBP", quality=jpeg_quality, method=6)
|
||||
|
||||
if save_json_metadata:
|
||||
self.save_json_metadata(json_path, prompt_text, negative_text,
|
||||
1, now.isoformat(), prompt, extra_pnginfo)
|
||||
|
||||
saved_paths.append(file_path)
|
||||
print(f"Saved: {os.path.basename(file_path)}")
|
||||
print(f" Prompt: {prompt_text}")
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unexpected image tensor shape: {images_np.shape}")
|
||||
|
||||
else:
|
||||
# Handle case where images might be a list
|
||||
print(f"DEBUG: Images is not a tensor, type: {type(images)}")
|
||||
for i, image in enumerate(images):
|
||||
try:
|
||||
if isinstance(image, torch.Tensor):
|
||||
img_array = image.cpu().numpy()
|
||||
else:
|
||||
img_array = np.array(image)
|
||||
|
||||
# Process similar to above...
|
||||
if img_array.max() <= 1.0:
|
||||
img_array = img_array * 255.0
|
||||
|
||||
img_array = np.clip(img_array, 0, 255).astype(np.uint8)
|
||||
|
||||
if len(img_array.shape) == 3 and (img_array.shape[0] == 3 or img_array.shape[0] == 4):
|
||||
img_array = np.transpose(img_array, (1, 2, 0))
|
||||
|
||||
img = Image.fromarray(img_array)
|
||||
|
||||
prompt_text = individual_prompts[i % len(individual_prompts)].strip() if individual_prompts else "no_prompt"
|
||||
negative_text = individual_negatives[i % len(individual_negatives)].strip() if individual_negatives else ""
|
||||
clean_prompt = self.clean_filename(prompt_text, prompt_words_limit, delimiter)
|
||||
|
||||
# Generate filename using the new method
|
||||
filename = self.generate_numbered_filename(
|
||||
filename_prefix, delimiter, i+1,
|
||||
filename_number_padding, filename_number_start,
|
||||
enable_filename_numbering, date_str, clean_prompt, ext
|
||||
)
|
||||
|
||||
base_filename = os.path.splitext(filename)[0]
|
||||
temp_path = os.path.join(output_dir, filename)
|
||||
file_path = self.ensure_filename_length(temp_path, base_filename, ext)
|
||||
file_path = self.get_unique_filename(file_path)
|
||||
|
||||
if save_json_metadata:
|
||||
json_base = os.path.splitext(os.path.basename(file_path))[0]
|
||||
json_path = os.path.join(json_dir, json_base + ".json")
|
||||
json_path = self.get_unique_filename(json_path)
|
||||
|
||||
# ITEM #3: Apply conditional PNG metadata for all image formats logic
|
||||
if image_format == "PNG" and embed_png_metadata:
|
||||
metadata = PngImagePlugin.PngInfo()
|
||||
metadata.add_text("prompt", prompt_text)
|
||||
metadata.add_text("negative_prompt", negative_text)
|
||||
metadata.add_text("batch_index", str(i+1))
|
||||
metadata.add_text("creation_time", now.isoformat())
|
||||
|
||||
if embed_workflow:
|
||||
if prompt is not None:
|
||||
metadata.add_text("workflow", json.dumps(prompt, ensure_ascii=False))
|
||||
if extra_pnginfo is not None:
|
||||
for key, value in extra_pnginfo.items():
|
||||
metadata.add_text(key, json.dumps(value, ensure_ascii=False))
|
||||
|
||||
img.save(file_path, format="PNG", optimize=True,
|
||||
compress_level=self.compress_level, pnginfo=metadata)
|
||||
else:
|
||||
img.save(file_path, format=image_format.upper())
|
||||
|
||||
if save_json_metadata:
|
||||
self.save_json_metadata(json_path, prompt_text, negative_text,
|
||||
i+1, now.isoformat(), prompt, extra_pnginfo)
|
||||
|
||||
saved_paths.append(file_path)
|
||||
print(f"Saved: {os.path.basename(file_path)}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to save image {i+1}: {e}"
|
||||
print(error_msg)
|
||||
saved_paths.append(f"ERROR: {error_msg}")
|
||||
|
||||
# Return all saved paths joined with newlines
|
||||
return ("\n".join(saved_paths),)
|
||||