From 6368582e1b4994199652051d8c2fdb30e51572a7 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 17 Dec 2025 06:59:11 -0800 Subject: [PATCH 01/18] feat: Add WidthHeightToVec2Node with tests --- .../tools/width_height_to_vec2/__init__.py | 8 ++ kikotools/tools/width_height_to_vec2/node.py | 128 ++++++++++++++++++ tests/unit/tools/test_width_height_to_vec2.py | 81 +++++++++++ 3 files changed, 217 insertions(+) create mode 100644 kikotools/tools/width_height_to_vec2/__init__.py create mode 100644 kikotools/tools/width_height_to_vec2/node.py create mode 100644 tests/unit/tools/test_width_height_to_vec2.py diff --git a/kikotools/tools/width_height_to_vec2/__init__.py b/kikotools/tools/width_height_to_vec2/__init__.py new file mode 100644 index 0000000..165cf91 --- /dev/null +++ b/kikotools/tools/width_height_to_vec2/__init__.py @@ -0,0 +1,8 @@ +""" +Width Height to VEC2 converter node +Converts width and height inputs to VEC2 tuple for jovi_glsl and similar nodes +""" + +from .node import WidthHeightToVec2Node + +__all__ = ["WidthHeightToVec2Node"] diff --git a/kikotools/tools/width_height_to_vec2/node.py b/kikotools/tools/width_height_to_vec2/node.py new file mode 100644 index 0000000..bdc5f3d --- /dev/null +++ b/kikotools/tools/width_height_to_vec2/node.py @@ -0,0 +1,128 @@ +""" +Width Height to VEC2 Node + +Converts width and height inputs to a VEC2 tuple for use with +nodes like jovi_glsl that expect vector inputs. +""" + +from typing import Any, Dict, Tuple, Union + +from ...base import ComfyAssetsBaseNode + + +class WidthHeightToVec2Node(ComfyAssetsBaseNode): + """ + Convert width and height values to VEC2 format. + + Accepts INT, FLOAT, STRING, or ANY types and outputs a VEC2 tuple + suitable for nodes expecting vector inputs. + """ + + @classmethod + def INPUT_TYPES(cls) -> Dict[str, Any]: + """Define ComfyUI input interface.""" + return { + "required": { + "width": ( + "INT", + { + "default": 1024, + "min": 1, + "max": 8192, + "step": 1, + "tooltip": "Width value (x component of VEC2)", + }, + ), + "height": ( + "INT", + { + "default": 1024, + "min": 1, + "max": 8192, + "step": 1, + "tooltip": "Height value (y component of VEC2)", + }, + ), + }, + } + + RETURN_TYPES = ("VEC2",) + RETURN_NAMES = ("vec2",) + FUNCTION = "convert_to_vec2" + CATEGORY = "๐Ÿซถ ComfyAssets/๐Ÿ–ผ๏ธ Resolution" + + def convert_to_vec2( + self, + width: Union[int, float, str, Any], + height: Union[int, float, str, Any], + ) -> Tuple[Tuple[int, int]]: + """ + Convert width and height to VEC2 tuple. + + Args: + width: Width value (will be converted to int) + height: Height value (will be converted to int) + + Returns: + Tuple containing the VEC2 tuple (width, height) + """ + try: + # Convert to integers, handling various input types + w = self._to_int(width, "width") + h = self._to_int(height, "height") + + # Clamp values to valid range + w = max(1, min(8192, w)) + h = max(1, min(8192, h)) + + self.log_info(f"Converted to VEC2: ({w}, {h})") + + # Return as tuple wrapped in tuple (ComfyUI return format) + return ((w, h),) + + except Exception as e: + error_msg = f"Failed to convert to VEC2: {str(e)}" + self.handle_error(error_msg, e) + + def _to_int(self, value: Any, name: str) -> int: + """ + Convert a value to integer. + + Args: + value: Value to convert (int, float, str, or any) + name: Parameter name for error messages + + Returns: + Integer value + + Raises: + ValueError: If conversion fails + """ + if isinstance(value, int): + return value + elif isinstance(value, float): + return int(value) + elif isinstance(value, str): + try: + # Try parsing as float first (handles "1024.0") + return int(float(value.strip())) + except ValueError: + raise ValueError(f"Cannot convert {name} string '{value}' to integer") + else: + # Try generic conversion for ANY type + try: + return int(value) + except (ValueError, TypeError): + raise ValueError( + f"Cannot convert {name} of type {type(value).__name__} to integer" + ) + + +# Node registration +NODE_CLASS_MAPPINGS = { + "WidthHeightToVec2": WidthHeightToVec2Node, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "WidthHeightToVec2": "Width Height to VEC2", +} diff --git a/tests/unit/tools/test_width_height_to_vec2.py b/tests/unit/tools/test_width_height_to_vec2.py new file mode 100644 index 0000000..9161771 --- /dev/null +++ b/tests/unit/tools/test_width_height_to_vec2.py @@ -0,0 +1,81 @@ +"""Unit tests for Width Height to VEC2 node.""" + +import pytest + +from kikotools.tools.width_height_to_vec2 import WidthHeightToVec2Node + + +class TestWidthHeightToVec2Node: + """Test cases for WidthHeightToVec2Node.""" + + def setup_method(self): + """Set up test fixtures.""" + self.node = WidthHeightToVec2Node() + + def test_basic_int_conversion(self): + """Test basic integer inputs.""" + result = self.node.convert_to_vec2(512, 768) + assert result == ((512, 768),) + + def test_float_conversion(self): + """Test float inputs are converted to int.""" + result = self.node.convert_to_vec2(512.7, 768.3) + assert result == ((512, 768),) + + def test_string_conversion(self): + """Test string inputs are parsed correctly.""" + result = self.node.convert_to_vec2("1024", "768") + assert result == ((1024, 768),) + + def test_string_with_decimal(self): + """Test string with decimal point.""" + result = self.node.convert_to_vec2("1024.5", "768.0") + assert result == ((1024, 768),) + + def test_clamp_max_values(self): + """Test values are clamped to maximum.""" + result = self.node.convert_to_vec2(10000, 9999) + assert result == ((8192, 8192),) + + def test_clamp_min_values(self): + """Test values are clamped to minimum.""" + result = self.node.convert_to_vec2(0, -5) + assert result == ((1, 1),) + + def test_return_type_is_tuple_of_tuple(self): + """Test return type is correct for ComfyUI.""" + result = self.node.convert_to_vec2(512, 512) + assert isinstance(result, tuple) + assert len(result) == 1 + assert isinstance(result[0], tuple) + assert len(result[0]) == 2 + + def test_input_types_defined(self): + """Test INPUT_TYPES is properly defined.""" + input_types = WidthHeightToVec2Node.INPUT_TYPES() + assert "required" in input_types + assert "width" in input_types["required"] + assert "height" in input_types["required"] + + def test_return_types_defined(self): + """Test RETURN_TYPES is properly defined.""" + assert WidthHeightToVec2Node.RETURN_TYPES == ("VEC2",) + assert WidthHeightToVec2Node.RETURN_NAMES == ("vec2",) + + def test_category_set(self): + """Test node category is set.""" + assert "ComfyAssets" in WidthHeightToVec2Node.CATEGORY + + +class TestWidthHeightToVec2Errors: + """Test error handling for WidthHeightToVec2Node.""" + + def setup_method(self): + """Set up test fixtures.""" + self.node = WidthHeightToVec2Node() + + def test_invalid_string_raises_error(self): + """Test invalid string input raises error.""" + with pytest.raises(ValueError) as excinfo: + self.node.convert_to_vec2("not_a_number", 512) + assert "Cannot convert width" in str(excinfo.value) From 9a05f207904408ce5a6d10c20061339fd5a0faf6 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 17 Dec 2025 07:00:31 -0800 Subject: [PATCH 02/18] style: Remove unnecessary whitespace for consistency --- web/display_any.js | 20 +- web/display_text.js | 32 +- web/empty_latent_batch_swap.js | 2 +- web/gemini_prompt_ui.js | 72 +-- web/kikoEmbeddingAutocomplete.js | 2 +- web/kiko_custom_colors.js | 102 ++-- web/kiko_follow_execution.js | 10 +- web/kiko_local_image_loader.js | 863 ++++++++++++++++++++----------- web/kikotools_extensions.js | 60 +-- 9 files changed, 721 insertions(+), 442 deletions(-) diff --git a/web/display_any.js b/web/display_any.js index cc0ad3e..bcfea50 100644 --- a/web/display_any.js +++ b/web/display_any.js @@ -1,5 +1,5 @@ -import { app } from "../../../scripts/app.js"; -import { ComfyWidgets } from "../../../scripts/widgets.js"; +import { app } from "../../scripts/app.js"; +import { ComfyWidgets } from "../../scripts/widgets.js"; app.registerExtension({ name: "ComfyAssets.DisplayAny", @@ -25,11 +25,11 @@ app.registerExtension({ const lines = text ? text.split('\n') : ['']; const maxLinesPerWidget = 20; const chunks = []; - + for (let i = 0; i < lines.length; i += maxLinesPerWidget) { chunks.push(lines.slice(i, i + maxLinesPerWidget).join('\n')); } - + // Create a widget for each chunk chunks.forEach((chunk, index) => { const w = ComfyWidgets["STRING"](this, `display_${index}`, ["STRING", { multiline: true }], app).widget; @@ -38,7 +38,7 @@ app.registerExtension({ w.inputEl.style.fontFamily = "monospace"; w.value = chunk; }); - + // Add copy button widget const copyWidget = { type: "button", @@ -76,9 +76,9 @@ app.registerExtension({ const onExecuted = nodeType.prototype.onExecuted; nodeType.prototype.onExecuted = function (message) { onExecuted?.apply(this, arguments); - + console.log("DisplayAny onExecuted message:", message); - + if (message?.text && message.text.length > 0) { const displayText = message.text[0]; console.log("DisplayAny displayText:", displayText); @@ -113,14 +113,14 @@ app.registerExtension({ const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function() { onNodeCreated?.apply(this, arguments); - + // Set minimum size - make it wider for better JSON display this.size[0] = Math.max(this.size[0], 450); this.size[1] = Math.max(this.size[1], 250); - + // Add placeholder text populate.call(this, "Value will appear here..."); }; } } -}); \ No newline at end of file +}); diff --git a/web/display_text.js b/web/display_text.js index b0c07f1..a672ef5 100644 --- a/web/display_text.js +++ b/web/display_text.js @@ -1,5 +1,5 @@ -import { app } from "../../../scripts/app.js"; -import { ComfyWidgets } from "../../../scripts/widgets.js"; +import { app } from "../../scripts/app.js"; +import { ComfyWidgets } from "../../scripts/widgets.js"; app.registerExtension({ name: "ComfyAssets.DisplayText", @@ -24,23 +24,23 @@ app.registerExtension({ // Parse the text to detect positive/negative prompt format const posMatch = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i); const negMatch = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i); - + if (posMatch && negMatch) { // Extract prompt content let positiveText = posMatch[1].trim(); let negativeText = negMatch[1].trim(); - + // Remove any trailing ** markers const posEndIndex = positiveText.indexOf('**'); if (posEndIndex > 0) { positiveText = positiveText.substring(0, posEndIndex).trim(); } - + const negEndIndex = negativeText.indexOf('**'); if (negEndIndex > 0) { negativeText = negativeText.substring(0, negEndIndex).trim(); } - + // Create header widget for positive prompt const posHeader = ComfyWidgets["STRING"](this, "text_pos_header", ["STRING", { multiline: false }], app).widget; posHeader.inputEl.readOnly = true; @@ -49,13 +49,13 @@ app.registerExtension({ posHeader.inputEl.style.color = "#8f8"; posHeader.inputEl.style.fontWeight = "bold"; posHeader.value = "โœ“ Positive Prompt"; - + // Create positive prompt widget const posWidget = ComfyWidgets["STRING"](this, "text_positive", ["STRING", { multiline: true }], app).widget; posWidget.inputEl.readOnly = true; posWidget.inputEl.style.opacity = 0.9; posWidget.value = positiveText; - + // Create copy button widget for positive prompt const posCopyWidget = { type: "button", @@ -75,7 +75,7 @@ app.registerExtension({ } }; this.addCustomWidget(posCopyWidget); - + // Create header widget for negative prompt const negHeader = ComfyWidgets["STRING"](this, "text_neg_header", ["STRING", { multiline: false }], app).widget; negHeader.inputEl.readOnly = true; @@ -84,13 +84,13 @@ app.registerExtension({ negHeader.inputEl.style.color = "#f88"; negHeader.inputEl.style.fontWeight = "bold"; negHeader.value = "โœ— Negative Prompt"; - + // Create negative prompt widget const negWidget = ComfyWidgets["STRING"](this, "text_negative", ["STRING", { multiline: true }], app).widget; negWidget.inputEl.readOnly = true; negWidget.inputEl.style.opacity = 0.9; negWidget.value = negativeText; - + // Create copy button widget for negative prompt const negCopyWidget = { type: "button", @@ -110,14 +110,14 @@ app.registerExtension({ } }; this.addCustomWidget(negCopyWidget); - + } else { // Single text display with ComfyUI's standard STRING widget const w = ComfyWidgets["STRING"](this, "text_display", ["STRING", { multiline: true }], app).widget; w.inputEl.readOnly = true; w.inputEl.style.opacity = 0.9; w.value = text; - + // Create copy button widget const copyWidget = { type: "button", @@ -186,14 +186,14 @@ app.registerExtension({ const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function() { onNodeCreated?.apply(this, arguments); - + // Set default size this.size[0] = Math.max(this.size[0], 400); this.size[1] = Math.max(this.size[1], 300); - + // Add placeholder text populate.call(this, "Text will appear here after execution..."); }; } } -}); \ No newline at end of file +}); diff --git a/web/empty_latent_batch_swap.js b/web/empty_latent_batch_swap.js index 636da04..d21f607 100644 --- a/web/empty_latent_batch_swap.js +++ b/web/empty_latent_batch_swap.js @@ -269,7 +269,7 @@ app.registerExtension({ // Add as DOM widget this.swapButtonWidget = this.addDOMWidget( "swap_button", - "div", + "div", buttonContainer ); }; diff --git a/web/gemini_prompt_ui.js b/web/gemini_prompt_ui.js index 9ef8f5e..444dac0 100644 --- a/web/gemini_prompt_ui.js +++ b/web/gemini_prompt_ui.js @@ -1,43 +1,43 @@ -import { app } from "../../../scripts/app.js"; -import { api } from "../../../scripts/api.js"; +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; app.registerExtension({ name: "ComfyAssets.GeminiPrompt", - + async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name === "GeminiPrompt") { // Add visual enhancements to the node const onNodeCreated = nodeType.prototype.onNodeCreated; - + nodeType.prototype.onNodeCreated = function() { const result = onNodeCreated?.apply(this, arguments); - + // Store reference to widgets this.promptTypeWidget = this.widgets.find(w => w.name === "prompt_type"); this.modelWidget = this.widgets.find(w => w.name === "model"); this.apiKeyWidget = this.widgets.find(w => w.name === "api_key"); this.customPromptWidget = this.widgets.find(w => w.name === "custom_prompt"); - + // Add helper text button const helpButton = this.addWidget("button", "Help / API Setup", null, () => { this.showHelpDialog(); }); - + // Style the button helpButton.serialize = false; - + // Add refresh models button const refreshButton = this.addWidget("button", "Refresh Model List", null, () => { this.refreshModelList(); }); refreshButton.serialize = false; - + // Add status indicator this.status = this.addWidget("text", "status", "Ready", () => {}, { serialize: false }); this.status.disabled = true; - + // Update custom prompt visibility based on selection if (this.promptTypeWidget && this.customPromptWidget) { const originalCallback = this.promptTypeWidget.callback; @@ -46,19 +46,19 @@ app.registerExtension({ this.updateCustomPromptVisibility(); }; } - + return result; }; - + // Add method to show help dialog nodeType.prototype.showHelpDialog = function() { const helpContent = `

Gemini Prompt Engineer Setup

- +

1. Get API Key

Get your free API key from: Google AI Studio

- +

2. Set API Key

Choose one of these methods:

    @@ -66,10 +66,10 @@ app.registerExtension({
  • Config File: Create gemini_config.json in ComfyUI root with {"api_key": "your-key"}
  • Node Input: Enter directly in the api_key field
- +

3. Install Dependencies

pip install google-generativeai - +

Prompt Types

  • FLUX: Detailed artistic prompts with quality markers
  • @@ -77,7 +77,7 @@ app.registerExtension({
  • Danbooru: Anime-style booru tags
  • Video: Motion and temporal descriptions
- +

Gemini Models

  • gemini-1.5-flash: Fast and efficient (recommended for most uses)
  • @@ -85,43 +85,43 @@ app.registerExtension({
  • gemini-1.5-pro: Most capable, best quality results
  • gemini-1.0-pro: Previous generation, stable option
- +

Custom Prompts

You can override any template by entering your own system prompt in the custom_prompt field.

`; - + app.ui.dialog.show(helpContent); }; - + // Add method to update custom prompt visibility nodeType.prototype.updateCustomPromptVisibility = function() { // You could implement logic here to show/hide custom prompt based on selection // For now, it's always visible but this method provides extensibility }; - + // Add method to refresh model list nodeType.prototype.refreshModelList = function() { if (this.status) { this.status.value = "Refreshing models..."; } - + // Set the refresh_models flag const refreshWidget = this.widgets.find(w => w.name === "refresh_models"); if (refreshWidget) { refreshWidget.value = true; } - + // Show message alert("Model list will refresh on next execution. Make sure API key is set and run the node."); - + if (this.status) { setTimeout(() => { this.status.value = "Ready - Run node to refresh"; }, 2000); } }; - + // Override execute to show status const onExecute = nodeType.prototype.onExecute; nodeType.prototype.onExecute = function() { @@ -131,12 +131,12 @@ app.registerExtension({ const result = onExecute?.apply(this, arguments); return result; }; - + // Handle execution feedback const onExecuted = nodeType.prototype.onExecuted; nodeType.prototype.onExecuted = function(message) { const result = onExecuted?.apply(this, arguments); - + if (this.status) { // Check if there was an error in the output const outputs = message.output; @@ -147,7 +147,7 @@ app.registerExtension({ this.status.value = "Success!"; this.bgcolor = "#225522"; } - + // Reset color after delay setTimeout(() => { this.bgcolor = ""; @@ -156,12 +156,12 @@ app.registerExtension({ } }, 3000); } - + return result; }; } }, - + // Add custom styling async setup() { const style = document.createElement("style"); @@ -172,33 +172,33 @@ app.registerExtension({ border-radius: 8px; color: #fff; } - + .gemini-prompt-help h2 { color: #4285f4; margin-top: 0; } - + .gemini-prompt-help h3 { color: #8ab4f8; margin-top: 20px; } - + .gemini-prompt-help code { background: #333; padding: 2px 6px; border-radius: 4px; font-family: monospace; } - + .gemini-prompt-help a { color: #8ab4f8; text-decoration: none; } - + .gemini-prompt-help a:hover { text-decoration: underline; } `; document.head.appendChild(style); } -}); \ No newline at end of file +}); diff --git a/web/kikoEmbeddingAutocomplete.js b/web/kikoEmbeddingAutocomplete.js index 220edf8..03bf766 100644 --- a/web/kikoEmbeddingAutocomplete.js +++ b/web/kikoEmbeddingAutocomplete.js @@ -1221,4 +1221,4 @@ app.registerExtension({ autocompleteInstance.cleanup(); } } -}); \ No newline at end of file +}); diff --git a/web/kiko_custom_colors.js b/web/kiko_custom_colors.js index 8e19af2..23d9434 100644 --- a/web/kiko_custom_colors.js +++ b/web/kiko_custom_colors.js @@ -30,8 +30,8 @@ app.registerExtension({ id: "kikotools.custom_colors.enabled", name: "๐Ÿซถ Custom Colors: Enable", type: "boolean", - defaultValue: false, - tooltip: "Enable custom color picker options in node context menu", + defaultValue: true, + tooltip: "Enable custom color picker options in node context menu (Full/Title/BG)", }); app.ui.settings.addSetting({ @@ -70,11 +70,11 @@ app.registerExtension({ setup() { let pickerFull, pickerTitle, pickerBG; let activeNode; - + // Check if feature is enabled const isEnabled = () => { const setting = app.ui.settings.getSettingValue("kikotools.custom_colors.enabled"); - return setting !== undefined ? setting : false; + return setting !== undefined ? setting : true; }; const getSettings = () => ({ @@ -85,37 +85,49 @@ app.registerExtension({ }); // Helper function to apply color to node(s) + // Uses setColorOption like the built-in colors when possible const applyColorToNodes = (colorValue, colorType, node) => { - const settings = getSettings(); + if (!colorValue) return; + const graphcanvas = LGraphCanvas.active_canvas; - const nodes = (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) - ? [node] + const nodes = (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) + ? [node] : Object.values(graphcanvas.selected_nodes); - nodes.forEach(n => { - if (colorValue && colorValue !== "" && colorValue.startsWith("#")) { - if (n.constructor === LiteGraph.LGraphGroup) { - // For groups, only set the main color - if (colorType === 'full' || colorType === 'bg') { + for (const n of nodes) { + if (n.constructor === LiteGraph.LGraphGroup) { + // For groups, use setColorOption if available + if (colorType === 'full' || colorType === 'bg') { + if (n.setColorOption) { + n.setColorOption({ groupcolor: colorValue }); + } else { n.color = colorValue; } - } else { - // For regular nodes - switch(colorType) { - case 'full': - n.color = settings.autoShade ? colorShade(colorValue, 20) : colorValue; + } + } else { + // For regular nodes + switch(colorType) { + case 'full': + // Use setColorOption if available (like built-in colors) + if (n.setColorOption) { + n.setColorOption({ + color: colorShade(colorValue, 20), + bgcolor: colorValue + }); + } else { + n.color = colorShade(colorValue, 20); n.bgcolor = colorValue; - break; - case 'title': - n.color = colorValue; - break; - case 'bg': - n.bgcolor = colorValue; - break; - } + } + break; + case 'title': + n.color = colorValue; + break; + case 'bg': + n.bgcolor = colorValue; + break; } } - }); + } node.setDirtyCanvas(true, true); }; @@ -129,13 +141,13 @@ app.registerExtension({ display: "none", }, }); - + picker.onchange = () => { if (activeNode) { applyColorToNodes(picker.value, type, activeNode); } }; - + return picker; }; @@ -143,24 +155,36 @@ app.registerExtension({ const onMenuNodeColors = LGraphCanvas.onMenuNodeColors; LGraphCanvas.onMenuNodeColors = function (value, options, e, menu, node) { const r = onMenuNodeColors.apply(this, arguments); - + // Only add custom options if enabled if (!isEnabled()) return r; - + const settings = getSettings(); - + requestAnimationFrame(() => { const menus = document.querySelectorAll(".litecontextmenu"); for (let i = menus.length - 1; i >= 0; i--) { - if (menus[i].firstElementChild.textContent.includes("No color") || - menus[i].firstElementChild.value?.content?.includes("No color")) { - + const menu = menus[i]; + if (!menu || !menu.firstElementChild) continue; + + // Check for "No color" in various ways to be compatible with different frontend versions + const firstChild = menu.firstElementChild; + const textContent = firstChild.textContent || ''; + const innerHTML = firstChild.innerHTML || ''; + const valueContent = firstChild.value?.content || ''; + + const isColorMenu = textContent.includes("No color") || + innerHTML.includes("No color") || + valueContent.includes("No color"); + + if (isColorMenu) { + // Add Custom Full option if (settings.showFull) { $el( "div.litemenu-entry.submenu", { - parent: menus[i], + parent: menu, $: (el) => { el.onclick = () => { LiteGraph.closeAllContextMenus(); @@ -190,7 +214,7 @@ app.registerExtension({ $el( "div.litemenu-entry.submenu", { - parent: menus[i], + parent: menu, $: (el) => { el.onclick = () => { LiteGraph.closeAllContextMenus(); @@ -220,7 +244,7 @@ app.registerExtension({ $el( "div.litemenu-entry.submenu", { - parent: menus[i], + parent: menu, $: (el) => { el.onclick = () => { LiteGraph.closeAllContextMenus(); @@ -244,7 +268,7 @@ app.registerExtension({ ] ); } - + break; } } @@ -252,4 +276,4 @@ app.registerExtension({ return r; }; }, -}); \ No newline at end of file +}); diff --git a/web/kiko_follow_execution.js b/web/kiko_follow_execution.js index 55881ad..ef92e72 100644 --- a/web/kiko_follow_execution.js +++ b/web/kiko_follow_execution.js @@ -32,7 +32,7 @@ app.registerExtension({ tooltip: "Automatically start following execution when workflow starts", }); }, - + async setup() { let followExecution = false; let isEnabled = false; @@ -41,7 +41,7 @@ app.registerExtension({ const checkEnabled = () => { const setting = app.ui.settings.getSettingValue("kikotools.follow_execution.enabled"); isEnabled = setting !== undefined ? setting : false; - + // If disabled, turn off follow execution if (!isEnabled && followExecution) { followExecution = false; @@ -81,14 +81,14 @@ app.registerExtension({ const orig = LGraphCanvas.prototype.getCanvasMenuOptions; LGraphCanvas.prototype.getCanvasMenuOptions = function () { const options = orig.apply(this, arguments); - + // Check if feature is enabled before adding menu items checkEnabled(); if (!isEnabled) return options; // Add separator options.push(null); - + // Add follow execution toggle options.push({ content: followExecution ? "๐Ÿซถ Stop following execution" : "๐Ÿซถ Follow execution", @@ -154,4 +154,4 @@ app.registerExtension({ return options; }; }, -}); \ No newline at end of file +}); diff --git a/web/kiko_local_image_loader.js b/web/kiko_local_image_loader.js index cdc1287..1259e30 100644 --- a/web/kiko_local_image_loader.js +++ b/web/kiko_local_image_loader.js @@ -4,7 +4,7 @@ import { api } from "/scripts/api.js"; // Setup global lightbox for image preview function setupGlobalLightbox() { if (document.getElementById('kiko-image-lightbox')) return; - + const lightboxId = 'kiko-image-lightbox'; const lightboxHTML = ` `; - + const lightboxCSS = ` - #${lightboxId} { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.85); - display: none; - align-items: center; - justify-content: center; - z-index: 10000; - box-sizing: border-box; - -webkit-user-select: none; - user-select: none; + #${lightboxId} { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.85); + display: none; + align-items: center; + justify-content: center; + z-index: 10000; + box-sizing: border-box; + -webkit-user-select: none; + user-select: none; } - #${lightboxId} .lightbox-content { - position: relative; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; + #${lightboxId} .lightbox-content { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; } - #${lightboxId} img, #${lightboxId} video { - max-width: 95%; - max-height: 95%; - object-fit: contain; - transition: transform 0.1s ease-out; - transform: scale(1) translate(0, 0); + #${lightboxId} img, #${lightboxId} video { + max-width: 95%; + max-height: 95%; + object-fit: contain; + transition: transform 0.1s ease-out; + transform: scale(1) translate(0, 0); } - #${lightboxId} audio { - width: 80%; - max-width: 600px; + #${lightboxId} audio { + width: 80%; + max-width: 600px; } - #${lightboxId} img { - cursor: grab; + #${lightboxId} img { + cursor: grab; } - #${lightboxId} img.panning { - cursor: grabbing; + #${lightboxId} img.panning { + cursor: grabbing; } - #${lightboxId} .lightbox-close { - position: absolute; - top: 15px; - right: 20px; - width: 35px; - height: 35px; - background-color: rgba(0,0,0,0.5); - color: #fff; - border-radius: 50%; - border: 2px solid #fff; - font-size: 24px; - line-height: 30px; - text-align: center; - cursor: pointer; - z-index: 10002; + #${lightboxId} .lightbox-close { + position: absolute; + top: 15px; + right: 20px; + width: 35px; + height: 35px; + background-color: rgba(0,0,0,0.5); + color: #fff; + border-radius: 50%; + border: 2px solid #fff; + font-size: 24px; + line-height: 30px; + text-align: center; + cursor: pointer; + z-index: 10002; } - #${lightboxId} .lightbox-prev, #${lightboxId} .lightbox-next { - position: absolute; - top: 50%; - transform: translateY(-50%); - width: 45px; - height: 60px; - background-color: rgba(0,0,0,0.4); - color: #fff; - border: none; - font-size: 30px; - cursor: pointer; - z-index: 10001; - transition: background-color 0.2s; + #${lightboxId} .lightbox-prev, #${lightboxId} .lightbox-next { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 45px; + height: 60px; + background-color: rgba(0,0,0,0.4); + color: #fff; + border: none; + font-size: 30px; + cursor: pointer; + z-index: 10001; + transition: background-color 0.2s; } - #${lightboxId} .lightbox-prev:hover, #${lightboxId} .lightbox-next:hover { - background-color: rgba(0,0,0,0.7); + #${lightboxId} .lightbox-prev:hover, #${lightboxId} .lightbox-next:hover { + background-color: rgba(0,0,0,0.7); } - #${lightboxId} .lightbox-prev { - left: 15px; + #${lightboxId} .lightbox-prev { + left: 15px; } - #${lightboxId} .lightbox-next { - right: 15px; + #${lightboxId} .lightbox-next { + right: 15px; } - #${lightboxId} [disabled] { - display: none; + #${lightboxId} [disabled] { + display: none; } - #${lightboxId} .lightbox-dimensions { - position: absolute; - bottom: 0px; - left: 50%; - transform: translateX(-50%); - background-color: rgba(0, 0, 0, 0.7); - color: #fff; - padding: 2px 4px; - border-radius: 5px; - font-size: 14px; - z-index: 10001; + #${lightboxId} .lightbox-dimensions { + position: absolute; + bottom: 0px; + left: 50%; + transform: translateX(-50%); + background-color: rgba(0, 0, 0, 0.7); + color: #fff; + padding: 2px 4px; + border-radius: 5px; + font-size: 14px; + z-index: 10001; } `; - + document.body.insertAdjacentHTML('beforeend', lightboxHTML); const styleEl = document.createElement('style'); styleEl.textContent = lightboxCSS; @@ -133,15 +133,15 @@ app.registerExtension({ const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const r = onNodeCreated?.apply(this, arguments); - + const galleryContainer = document.createElement("div"); const uniqueId = `kiko-gallery-${Math.random().toString(36).substring(2, 9)}`; galleryContainer.id = uniqueId; - + const folderSVG = ``; const videoSVG = ``; const audioSVG = ``; - + galleryContainer.innerHTML = ` - +
@@ -326,23 +391,24 @@ app.registerExtension({ +
- +
- +
+ +
+
- +
-
- +
Loading...
- +
`; - + // Add the widget to the node this.galleryWidget = this.addDOMWidget("kiko_local_image_gallery", "div", galleryContainer, { serialize: false, }); - + // Set initial size for the node this.size = [800, 600]; this.setSize(this.size); - + // Initialize gallery functionality const node = this; const container = galleryContainer.querySelector('.kiko-container-wrapper'); const pathInput = container.querySelector('.kiko-path-input'); const pathPresets = container.querySelector('.kiko-path-presets'); const savePathBtn = container.querySelector('.kiko-save-path'); + const deletePathBtn = container.querySelector('.kiko-delete-path'); const browseBtn = container.querySelector('.kiko-browse'); const upFolderBtn = container.querySelector('.kiko-up-folder'); - const showVideos = container.querySelector('.kiko-show-videos'); - const showAudio = container.querySelector('.kiko-show-audio'); + const hideDotFolders = container.querySelector('.kiko-hide-dot-folders'); const sortBy = container.querySelector('.kiko-sort-by'); const sortOrder = container.querySelector('.kiko-sort-order'); const refreshBtn = container.querySelector('.kiko-refresh'); const cardHolder = container.querySelector('.kiko-cardholder'); const pagination = container.querySelector('.kiko-pagination'); const statusMessage = container.querySelector('.kiko-status-message'); - + const autocompleteDropdown = container.querySelector('.kiko-autocomplete-dropdown'); + let currentPage = 1; let totalPages = 1; let currentDirectory = ''; @@ -402,7 +469,7 @@ app.registerExtension({ video: null, audio: null }; - + // Utility: Debounce function const debounce = (func, delay) => { let timeoutId; @@ -411,19 +478,19 @@ app.registerExtension({ timeoutId = setTimeout(() => func.apply(this, args), delay); }; }; - + // Apply responsive masonry layout const applyMasonryLayout = () => { const minCardWidth = 120; const gap = 5; const containerWidth = cardHolder.clientWidth - 10; // Account for padding if (containerWidth <= 0) return; - + const columnCount = Math.max(1, Math.floor(containerWidth / (minCardWidth + gap))); const totalGapSpace = (columnCount - 1) * gap; const actualCardWidth = Math.floor((containerWidth - totalGapSpace) / columnCount); const columnHeights = new Array(columnCount).fill(0); - + const cards = cardHolder.querySelectorAll('.kiko-gallery-card'); cards.forEach(card => { card.style.width = `${actualCardWidth}px`; @@ -433,25 +500,176 @@ app.registerExtension({ card.style.top = `${minHeight}px`; columnHeights[columnIndex] += card.offsetHeight + gap; }); - + // Set container height - let scrollbar handle overflow const maxHeight = Math.max(...columnHeights); if (maxHeight > 0) { cardHolder.style.minHeight = `${Math.min(200, maxHeight)}px`; } }; - + const debouncedLayout = debounce(applyMasonryLayout, 20); - + // Set up ResizeObserver for responsive layout new ResizeObserver(debouncedLayout).observe(cardHolder); - + + // Autocomplete functionality + let autocompleteSelectedIndex = -1; + let autocompleteSuggestions = []; + const smallFolderSVG = ``; + + async function fetchDirectorySuggestions(path) { + try { + const response = await api.fetchApi(`/kiko_local_image_loader/list_directories?path=${encodeURIComponent(path)}`); + const data = await response.json(); + return data.directories || []; + } catch (error) { + console.error('Error fetching directories:', error); + return []; + } + } + + function renderAutocompleteSuggestions(suggestions) { + autocompleteSuggestions = suggestions; + autocompleteSelectedIndex = -1; + + if (suggestions.length === 0) { + autocompleteDropdown.classList.remove('visible'); + autocompleteDropdown.innerHTML = ''; + return; + } + + autocompleteDropdown.innerHTML = suggestions.map((path, index) => { + const parts = path.split('/').filter(p => p); + const folderName = parts.pop() || path; + const parentPath = parts.length > 0 ? '/' + parts.join('/') : ''; + + return ` +
+
${smallFolderSVG}
+
+ ${folderName} + ${parentPath ? ` โ€” ${parentPath}` : ''} +
+
+ `; + }).join(''); + + autocompleteDropdown.classList.add('visible'); + + // Add click handlers to items + autocompleteDropdown.querySelectorAll('.kiko-autocomplete-item').forEach(item => { + item.addEventListener('mousedown', (e) => { + e.preventDefault(); // Prevent blur from hiding dropdown before click registers + const path = item.dataset.path; + selectAutocompleteSuggestion(path); + }); + }); + } + + function hideAutocomplete() { + autocompleteDropdown.classList.remove('visible'); + autocompleteDropdown.innerHTML = ''; + autocompleteSuggestions = []; + autocompleteSelectedIndex = -1; + } + + function selectAutocompleteSuggestion(path) { + pathInput.value = path + '/'; + hideAutocomplete(); + // Trigger another fetch to show contents of selected directory + debouncedAutocomplete(path + '/'); + } + + function updateAutocompleteSelection() { + const items = autocompleteDropdown.querySelectorAll('.kiko-autocomplete-item'); + items.forEach((item, index) => { + if (index === autocompleteSelectedIndex) { + item.classList.add('selected'); + item.scrollIntoView({ block: 'nearest' }); + } else { + item.classList.remove('selected'); + } + }); + } + + const debouncedAutocomplete = debounce(async (value) => { + const suggestions = await fetchDirectorySuggestions(value); + renderAutocompleteSuggestions(suggestions); + }, 150); + + // Path input event handlers for autocomplete + pathInput.addEventListener('input', (e) => { + const value = e.target.value; + if (value.length > 0) { + debouncedAutocomplete(value); + } else { + hideAutocomplete(); + } + }); + + pathInput.addEventListener('focus', () => { + const value = pathInput.value; + if (value.length > 0) { + debouncedAutocomplete(value); + } + }); + + pathInput.addEventListener('blur', () => { + // Delay hiding to allow click events to register + setTimeout(hideAutocomplete, 150); + }); + + pathInput.addEventListener('keydown', (e) => { + if (!autocompleteDropdown.classList.contains('visible')) { + if (e.key === 'Enter') { + loadImages(pathInput.value); + e.preventDefault(); + } + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + autocompleteSelectedIndex = Math.min(autocompleteSelectedIndex + 1, autocompleteSuggestions.length - 1); + updateAutocompleteSelection(); + break; + case 'ArrowUp': + e.preventDefault(); + autocompleteSelectedIndex = Math.max(autocompleteSelectedIndex - 1, -1); + updateAutocompleteSelection(); + break; + case 'Enter': + e.preventDefault(); + if (autocompleteSelectedIndex >= 0 && autocompleteSelectedIndex < autocompleteSuggestions.length) { + selectAutocompleteSuggestion(autocompleteSuggestions[autocompleteSelectedIndex]); + } else { + hideAutocomplete(); + loadImages(pathInput.value); + } + break; + case 'Escape': + hideAutocomplete(); + break; + case 'Tab': + if (autocompleteSelectedIndex >= 0) { + e.preventDefault(); + selectAutocompleteSuggestion(autocompleteSuggestions[autocompleteSelectedIndex]); + } else if (autocompleteSuggestions.length === 1) { + e.preventDefault(); + selectAutocompleteSuggestion(autocompleteSuggestions[0]); + } + break; + } + }); + // Load saved paths async function loadSavedPaths() { try { const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths'); const data = await response.json(); - + pathPresets.innerHTML = ''; data.saved_paths?.forEach(path => { const option = document.createElement('option'); @@ -464,16 +682,16 @@ app.registerExtension({ console.error('Error loading saved paths:', error); } } - + // Save current path savePathBtn.onclick = async () => { if (!currentDirectory) return; - + try { const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths'); const data = await response.json(); const savedPaths = data.saved_paths || []; - + if (!savedPaths.includes(currentDirectory)) { savedPaths.push(currentDirectory); await api.fetchApi('/kiko_local_image_loader/save_paths', { @@ -487,73 +705,117 @@ app.registerExtension({ console.error('Error saving path:', error); } }; - + + // Delete selected saved path + deletePathBtn.onclick = async () => { + const selectedPath = pathPresets.value; + if (!selectedPath) return; + + try { + const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths'); + const data = await response.json(); + const savedPaths = data.saved_paths || []; + + const updatedPaths = savedPaths.filter(p => p !== selectedPath); + if (updatedPaths.length !== savedPaths.length) { + await api.fetchApi('/kiko_local_image_loader/save_paths', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths: updatedPaths }) + }); + await loadSavedPaths(); + } + } catch (error) { + console.error('Error deleting path:', error); + } + }; + // Navigate to parent directory upFolderBtn.onclick = () => { if (parentDirectory) { loadImages(parentDirectory); } }; - + // Load images from directory async function loadImages(directory, page = 1) { if (!directory) return; - + statusMessage.style.display = 'block'; statusMessage.textContent = 'Loading...'; - + const params = new URLSearchParams({ directory: directory, page: page.toString(), per_page: '50', - show_videos: showVideos.checked, - show_audio: showAudio.checked, + show_videos: 'true', + show_audio: 'true', + hide_dot_folders: hideDotFolders.checked, sort_by: sortBy.value, sort_order: sortOrder.value }); - + try { const response = await api.fetchApi(`/kiko_local_image_loader/images?${params}`); const data = await response.json(); - + if (data.error) { statusMessage.textContent = data.error; return; } - + currentDirectory = data.current_directory; parentDirectory = data.parent_directory; currentPage = data.current_page; totalPages = data.total_pages; currentItems = data.items; - + pathInput.value = currentDirectory; upFolderBtn.disabled = !parentDirectory; - + renderGallery(); renderPagination(); - + statusMessage.style.display = 'none'; } catch (error) { console.error('Error loading images:', error); statusMessage.textContent = 'Error loading directory'; } } - + // Render gallery cards function renderGallery() { cardHolder.innerHTML = ''; - - if (currentItems.length === 0) { + + if (currentItems.length === 0 && !parentDirectory) { cardHolder.innerHTML = '
No items found
'; return; } - - currentItems.forEach((item, index) => { + + // Create items array with parent folder at the beginning if it exists + const itemsToRender = parentDirectory + ? [{ type: 'parent_dir', name: '..', path: parentDirectory }, ...currentItems] + : currentItems; + + itemsToRender.forEach((item, index) => { const card = document.createElement('div'); card.className = 'kiko-gallery-card'; - - if (item.type === 'dir') { + + if (item.type === 'parent_dir') { + // Parent directory (..) with white up arrow on blue folder + const upArrowSVG = ``; + card.innerHTML = ` +
+
+
${upArrowSVG}
+
.. (Up)
+
+
+ `; + card.onclick = () => { + loadImages(item.path); + }; + } else if (item.type === 'dir') { card.innerHTML = `
@@ -573,16 +835,16 @@ app.registerExtension({
${item.name}
`; - + // Trigger layout when image loads const img = card.querySelector('img'); if (img) { img.onload = debouncedLayout; } - + // Single click to select card.onclick = () => selectMedia(item, 'image', card); - + // Double click to open in new tab card.ondblclick = (e) => { e.preventDefault(); @@ -599,15 +861,15 @@ app.registerExtension({
${item.name}
`; - + const img = card.querySelector('img'); if (img) { img.onload = debouncedLayout; } - + // Single click to select card.onclick = () => selectMedia(item, 'video', card); - + // Double click to open in new tab card.ondblclick = (e) => { e.preventDefault(); @@ -624,10 +886,10 @@ app.registerExtension({ `; - + // Single click to select card.onclick = () => selectMedia(item, 'audio', card); - + // Double click to open in new tab card.ondblclick = (e) => { e.preventDefault(); @@ -636,30 +898,30 @@ app.registerExtension({ window.open(fullAudioUrl, '_blank'); }; } - + // Check if selected if (selectedPaths[item.type] === item.path) { card.classList.add('kiko-selected'); } - + cardHolder.appendChild(card); }); - + // Apply layout after all cards are added requestAnimationFrame(debouncedLayout); } - + // Select media async function selectMedia(item, type, cardElement) { // Update selection selectedPaths[type] = item.path; - + // Update UI cardHolder.querySelectorAll('.kiko-gallery-card').forEach(card => { card.classList.remove('kiko-selected'); }); cardElement.classList.add('kiko-selected'); - + // Send to backend try { await api.fetchApi('/kiko_local_image_loader/set_node_selection', { @@ -671,20 +933,20 @@ app.registerExtension({ type: type }) }); - + // Trigger node update node.setDirtyCanvas(true); } catch (error) { console.error('Error setting selection:', error); } } - + // Render pagination function renderPagination() { pagination.innerHTML = ''; - + if (totalPages <= 1) return; - + const createButton = (text, page) => { const btn = document.createElement('button'); btn.textContent = text; @@ -695,11 +957,11 @@ app.registerExtension({ btn.onclick = () => loadImages(currentDirectory, page); return btn; }; - + if (currentPage > 1) { pagination.appendChild(createButton('โ—€', currentPage - 1)); } - + for (let i = 1; i <= totalPages; i++) { if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) { pagination.appendChild(createButton(i.toString(), i)); @@ -710,36 +972,29 @@ app.registerExtension({ pagination.appendChild(span); } } - + if (currentPage < totalPages) { pagination.appendChild(createButton('โ–ถ', currentPage + 1)); } } - + // Event handlers browseBtn.onclick = () => loadImages(pathInput.value || currentDirectory); refreshBtn.onclick = () => loadImages(currentDirectory, currentPage); - - pathInput.onkeydown = (e) => { - if (e.key === 'Enter') { - loadImages(pathInput.value); - } - }; - + pathPresets.onchange = () => { if (pathPresets.value) { loadImages(pathPresets.value); } }; - - showVideos.onchange = () => loadImages(currentDirectory, 1); - showAudio.onchange = () => loadImages(currentDirectory, 1); + + hideDotFolders.onchange = () => loadImages(currentDirectory, 1); sortBy.onchange = () => loadImages(currentDirectory, 1); sortOrder.onchange = () => loadImages(currentDirectory, 1); - + // Load initial data loadSavedPaths(); - + // Try to load last path api.fetchApi('/kiko_local_image_loader/get_last_path').then(async response => { const data = await response.json(); @@ -747,9 +1002,9 @@ app.registerExtension({ loadImages(data.last_path); } }); - + return r; }; } } -}); \ No newline at end of file +}); diff --git a/web/kikotools_extensions.js b/web/kikotools_extensions.js index 21e60f3..fe6bbed 100644 --- a/web/kikotools_extensions.js +++ b/web/kikotools_extensions.js @@ -1,23 +1,23 @@ -import { app } from "../../../scripts/app.js"; +import { app } from "../../scripts/app.js"; /** * KikoTools Extensions - Adds utility features to all ComfyAssets nodes */ app.registerExtension({ name: "ComfyAssets.Extensions", - + async setup() { // Wait for the canvas to be ready setTimeout(() => { const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions; - + LGraphCanvas.prototype.getNodeMenuOptions = function (node) { const options = getNodeMenuOptions.apply(this, arguments); - + // Only add our menu items to ComfyAssets nodes if (node.constructor.category && node.constructor.category.includes("ComfyAssets")) { node.setDirtyCanvas(true, true); - + // Find the position before the last separator (usually before "Remove") let insertIndex = options.length - 1; for (let i = options.length - 1; i >= 0; i--) { @@ -26,7 +26,7 @@ app.registerExtension({ break; } } - + // Insert our custom menu items const kikoOptions = [ null, // separator @@ -37,10 +37,10 @@ app.registerExtension({ } } ]; - + options.splice(insertIndex, 0, ...kikoOptions); } - + return options; }; }, 500); @@ -70,9 +70,9 @@ class KikoToolsExtensions { font-family: Arial, sans-serif; box-shadow: 0 4px 20px rgba(0,0,0,0.5); `; - + dialog.innerHTML = htmlContent; - + // Create button container const buttonContainer = document.createElement("div"); buttonContainer.style.cssText = ` @@ -81,7 +81,7 @@ class KikoToolsExtensions { gap: 10px; margin-top: 15px; `; - + // Create OK button const okButton = document.createElement("button"); okButton.textContent = "OK"; @@ -96,7 +96,7 @@ class KikoToolsExtensions { `; okButton.onmouseover = () => okButton.style.background = "#5BA0F2"; okButton.onmouseout = () => okButton.style.background = "#4A90E2"; - + // Create Cancel button const cancelButton = document.createElement("button"); cancelButton.textContent = "Cancel"; @@ -111,21 +111,21 @@ class KikoToolsExtensions { `; cancelButton.onmouseover = () => cancelButton.style.background = "#777"; cancelButton.onmouseout = () => cancelButton.style.background = "#666"; - + buttonContainer.appendChild(cancelButton); buttonContainer.appendChild(okButton); dialog.appendChild(buttonContainer); - + // Dialog close function dialog.close = function() { if (dialog.parentNode) { dialog.parentNode.removeChild(dialog); } }; - + // Get all inputs const inputs = Array.from(dialog.querySelectorAll("input, select")); - + // Handle keyboard events inputs.forEach(input => { input.addEventListener("keydown", function(e) { @@ -139,49 +139,49 @@ class KikoToolsExtensions { e.stopPropagation(); }); }); - + // Button click handlers okButton.onclick = () => { onOK && onOK(dialog, inputs.map(input => input.value)); dialog.close(); }; - + cancelButton.onclick = () => { onCancel && onCancel(); dialog.close(); }; - + // Add to document document.body.appendChild(dialog); - + // Focus first input if (inputs.length > 0) { inputs[0].focus(); inputs[0].select(); } - + return dialog; } - + /** * Show node dimensions dialog */ static showNodeDimensionsDialog(node) { const nodeWidth = Math.round(node.size[0]); const nodeHeight = Math.round(node.size[1]); - + const htmlContent = `

Node Dimensions

-
-
@@ -190,22 +190,22 @@ class KikoToolsExtensions {
`; - + this.createDialog( htmlContent, function(dialog, values) { const widthValue = Number(values[0]) || nodeWidth; const heightValue = Number(values[1]) || nodeHeight; - + // Calculate minimum size based on node content const minSize = node.computeSize(); - + // Apply new size (respecting minimums) node.setSize([ Math.max(minSize[0], widthValue), Math.max(minSize[1], heightValue) ]); - + // Mark canvas as dirty to trigger redraw node.setDirtyCanvas(true, true); }, @@ -215,4 +215,4 @@ class KikoToolsExtensions { } // Export for global access -window.KikoToolsExtensions = KikoToolsExtensions; \ No newline at end of file +window.KikoToolsExtensions = KikoToolsExtensions; From 0e26f0ad36dac5b2c211dca2f8b78a5a14b74b76 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 17 Dec 2025 07:00:53 -0800 Subject: [PATCH 03/18] style: Remove trailing spaces in config files --- .flake8 | 6 +++--- .gitattributes | 2 +- .github/dependabot.yml | 2 +- .pre-commit-config.yaml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.flake8 b/.flake8 index c9aac90..2a57acf 100644 --- a/.flake8 +++ b/.flake8 @@ -1,7 +1,7 @@ [flake8] max-line-length = 127 max-complexity = 10 -exclude = +exclude = .git, __pycache__, .mypy_cache, @@ -12,7 +12,7 @@ exclude = dist, *.egg-info, .tox -ignore = +ignore = # W503: line break before binary operator (conflicts with Black) W503, # E203: whitespace before ':' (conflicts with Black) @@ -32,4 +32,4 @@ per-file-ignores = # Statistics count = True -statistics = True \ No newline at end of file +statistics = True diff --git a/.gitattributes b/.gitattributes index c0d39dd..8a3d456 100644 --- a/.gitattributes +++ b/.gitattributes @@ -38,4 +38,4 @@ *.safetensors binary *.ckpt binary *.pt binary -*.pth binary \ No newline at end of file +*.pth binary diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 36ce860..645c171 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,4 +7,4 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "weekly" \ No newline at end of file + interval: "weekly" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aa18d14..68d93d8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -81,4 +81,4 @@ exclude: | .*\.egg-info/| venv/| env/ - ) \ No newline at end of file + ) From f2093d656798e3b7f0876018aa5ddbc9ca5fc6aa Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 17 Dec 2025 07:01:22 -0800 Subject: [PATCH 04/18] style(docs): Ensure newline at EOF in markdown files --- examples/documentation/batch_prompts.md | 2 +- examples/documentation/display_any.md | 2 +- examples/documentation/display_text.md | 6 +++--- examples/documentation/flux_sampler_params.md | 2 +- examples/documentation/gemini_prompt.md | 2 +- examples/documentation/image_to_multiple_of.md | 2 +- examples/documentation/kiko_film_grain.md | 2 +- examples/documentation/kiko_save_image.md | 4 ++-- examples/documentation/local_image_loader.md | 2 +- examples/documentation/lora_folder_batch.md | 2 +- examples/documentation/plot_parameters.md | 6 +++--- examples/documentation/sampler_select_helper.md | 2 +- examples/documentation/scheduler_select_helper.md | 2 +- examples/documentation/text_encode_sampler_params.md | 4 ++-- examples/test_prompts.txt | 2 +- examples/workflows/display_any_example.json | 2 +- examples/workflows/display_text_example.json | 2 +- examples/workflows/empty_latent_batch_example.json | 2 +- examples/workflows/gemini_prompt_example.json | 2 +- examples/workflows/image_to_multiple_of_example.json | 2 +- examples/workflows/kiko_film_grain_example.json | 2 +- examples/workflows/kiko_save_image_example.json | 2 +- examples/workflows/resolution_calculator_example.json | 2 +- examples/workflows/xyz_helpers_sampler_comparison.json | 2 +- 24 files changed, 30 insertions(+), 30 deletions(-) diff --git a/examples/documentation/batch_prompts.md b/examples/documentation/batch_prompts.md index 9d6af1f..7caa108 100644 --- a/examples/documentation/batch_prompts.md +++ b/examples/documentation/batch_prompts.md @@ -103,4 +103,4 @@ The node provides several ways to track progress: ### State persistence - State is stored in your system's temp directory - Clear `/tmp/comfyui_batch_prompts/` to reset all counters -- Use `reload_file` to reset counter for a specific file \ No newline at end of file +- Use `reload_file` to reset counter for a specific file diff --git a/examples/documentation/display_any.md b/examples/documentation/display_any.md index 14441bd..b94fff0 100644 --- a/examples/documentation/display_any.md +++ b/examples/documentation/display_any.md @@ -117,4 +117,4 @@ Config Node โ†’ Display Any (raw value) โ†’ Processing Node [Text Multiline] โ† [Concatenate] โ† "Image dimensions: " ``` -This creates a text output showing the current image dimensions that can be used elsewhere in your workflow. \ No newline at end of file +This creates a text output showing the current image dimensions that can be used elsewhere in your workflow. diff --git a/examples/documentation/display_text.md b/examples/documentation/display_text.md index fa3e7c5..eb4c957 100644 --- a/examples/documentation/display_text.md +++ b/examples/documentation/display_text.md @@ -85,7 +85,7 @@ Display long text content with scrolling and word wrapping. The node intelligently detects prompt formats: -1. **SDXL Format**: +1. **SDXL Format**: - Looks for "Positive prompt:" and "Negative prompt:" markers - Case-insensitive detection - Handles various formatting styles @@ -98,7 +98,7 @@ The node intelligently detects prompt formats: ## Styling - **Font**: Monospace for consistent alignment -- **Colors**: +- **Colors**: - Text: Light gray (#ddd) on dark background - Background: Semi-transparent dark (#1a1a1a) - Borders: Subtle gray (#333) @@ -144,4 +144,4 @@ The node intelligently detects prompt formats: SDXL Format Split View Display Clean Prompts ``` -This creates a seamless workflow from prompt generation to usage, with the Display Text node providing the visual interface for review and interaction. \ No newline at end of file +This creates a seamless workflow from prompt generation to usage, with the Display Text node providing the visual interface for review and interaction. diff --git a/examples/documentation/flux_sampler_params.md b/examples/documentation/flux_sampler_params.md index 4b64e9c..800373d 100644 --- a/examples/documentation/flux_sampler_params.md +++ b/examples/documentation/flux_sampler_params.md @@ -149,4 +149,4 @@ base_shift: 0.4 - **1.0.3**: Improved UI elements and parameter validation ## Credits -Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team. \ No newline at end of file +Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team. diff --git a/examples/documentation/gemini_prompt.md b/examples/documentation/gemini_prompt.md index 60655c4..551f946 100644 --- a/examples/documentation/gemini_prompt.md +++ b/examples/documentation/gemini_prompt.md @@ -206,4 +206,4 @@ Errors are displayed in the prompt output for easy debugging. **Import error for google-generativeai**: - Run `pip install google-generativeai` in your ComfyUI environment -- Restart ComfyUI after installation \ No newline at end of file +- Restart ComfyUI after installation diff --git a/examples/documentation/image_to_multiple_of.md b/examples/documentation/image_to_multiple_of.md index 4a7539b..9921715 100644 --- a/examples/documentation/image_to_multiple_of.md +++ b/examples/documentation/image_to_multiple_of.md @@ -79,4 +79,4 @@ Load Images โ†’ Image to Multiple Of (multiple_of: 16, method: rescale) โ†’ Batc The node will raise an error if: - The image dimensions are smaller than the specified multiple_of value - Invalid input types are provided -- The resulting dimensions would be 0 or negative \ No newline at end of file +- The resulting dimensions would be 0 or negative diff --git a/examples/documentation/kiko_film_grain.md b/examples/documentation/kiko_film_grain.md index 35c285a..5f058af 100644 --- a/examples/documentation/kiko_film_grain.md +++ b/examples/documentation/kiko_film_grain.md @@ -122,4 +122,4 @@ Creates monochrome grain perfect for black and white photography. - Works with any image format supported by ComfyUI - Preserves image properties (alpha channel, batch size) - Compatible with both RGB and RGBA images -- Efficient batch processing support \ No newline at end of file +- Efficient batch processing support diff --git a/examples/documentation/kiko_save_image.md b/examples/documentation/kiko_save_image.md index 22372ee..1726732 100644 --- a/examples/documentation/kiko_save_image.md +++ b/examples/documentation/kiko_save_image.md @@ -50,7 +50,7 @@ Enhanced image saving node with multiple format support, quality controls, and a ### Image Grid - **Thumbnails**: Click any image to open full-size in new tab - **File Info**: Shows filename and size for each image -- **Quality Indicators**: +- **Quality Indicators**: - PNG: Compression level (0-9) - JPEG/WebP: Quality percentage - **Batch Selection**: Checkboxes for multi-select operations @@ -210,4 +210,4 @@ Batch Generate โ†’ Kiko Save Image โ†’ Popup Viewer **Can't see all images**: - Scroll within the popup grid - Maximize the popup window -- Images are shown newest first \ No newline at end of file +- Images are shown newest first diff --git a/examples/documentation/local_image_loader.md b/examples/documentation/local_image_loader.md index 6f27c66..befb56e 100644 --- a/examples/documentation/local_image_loader.md +++ b/examples/documentation/local_image_loader.md @@ -155,4 +155,4 @@ The node creates a visual widget that runs in the ComfyUI interface and communic - Save user preferences - Handle file selection -All file operations are performed server-side for security, with proper path validation to prevent directory traversal attacks. \ No newline at end of file +All file operations are performed server-side for security, with proper path validation to prevent directory traversal attacks. diff --git a/examples/documentation/lora_folder_batch.md b/examples/documentation/lora_folder_batch.md index fa1799f..df17f9f 100644 --- a/examples/documentation/lora_folder_batch.md +++ b/examples/documentation/lora_folder_batch.md @@ -261,4 +261,4 @@ batch_mode: sequential - **1.0.4**: Added auto-batching for large LoRA collections ## Credits -Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team. \ No newline at end of file +Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team. diff --git a/examples/documentation/plot_parameters.md b/examples/documentation/plot_parameters.md index 5fa3996..60e8d19 100644 --- a/examples/documentation/plot_parameters.md +++ b/examples/documentation/plot_parameters.md @@ -109,12 +109,12 @@ Parameter Grid โ†’ PlotParameters โ†’ Analysis Display x_axis: "guidance" y_axis: "perceived_quality" -# Step efficiency analysis +# Step efficiency analysis x_axis: "steps" y_axis: "generation_time" # LoRA impact assessment -x_axis: "lora_strength" +x_axis: "lora_strength" y_axis: "style_adherence" ``` @@ -231,4 +231,4 @@ Compare multiple generation runs to identify optimal parameters. - **1.0.3**: Improved export capabilities ## Credits -Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team. \ No newline at end of file +Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team. diff --git a/examples/documentation/sampler_select_helper.md b/examples/documentation/sampler_select_helper.md index c1d0f77..27b195f 100644 --- a/examples/documentation/sampler_select_helper.md +++ b/examples/documentation/sampler_select_helper.md @@ -257,4 +257,4 @@ Compare all compatible samplers for specific model/prompt combination. - **1.0.3**: Improved auto-detection ## Credits -Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team. \ No newline at end of file +Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team. diff --git a/examples/documentation/scheduler_select_helper.md b/examples/documentation/scheduler_select_helper.md index 3f9735c..40a4ab8 100644 --- a/examples/documentation/scheduler_select_helper.md +++ b/examples/documentation/scheduler_select_helper.md @@ -297,4 +297,4 @@ Progress through schedulers from fast to quality for different use cases. - **1.0.3**: Improved compatibility matrix ## Credits -Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team. \ No newline at end of file +Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team. diff --git a/examples/documentation/text_encode_sampler_params.md b/examples/documentation/text_encode_sampler_params.md index 485d07b..6cecf35 100644 --- a/examples/documentation/text_encode_sampler_params.md +++ b/examples/documentation/text_encode_sampler_params.md @@ -142,7 +142,7 @@ steps: 30-40 cfg: 7-8 sampler: dpmpp_3m_sde -# Speed over quality +# Speed over quality steps: 10-15 cfg: 5-6 sampler: euler @@ -307,4 +307,4 @@ Very High (50+): Diminishing returns - **1.0.3**: Improved batch processing ## Credits -Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team. \ No newline at end of file +Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team. diff --git a/examples/test_prompts.txt b/examples/test_prompts.txt index 17f5820..e49fbad 100644 --- a/examples/test_prompts.txt +++ b/examples/test_prompts.txt @@ -11,4 +11,4 @@ Enchanted forest with magical glowing mushrooms, fairy lights, mystical atmosphe Negative: desert, urban, modern, realistic, mundane --- Space station orbiting Earth, detailed mechanical structures, astronauts performing spacewalk, realistic sci-fi, NASA photography -Negative: fantasy, medieval, underwater, cartoon style \ No newline at end of file +Negative: fantasy, medieval, underwater, cartoon style diff --git a/examples/workflows/display_any_example.json b/examples/workflows/display_any_example.json index f1c4a8f..165511b 100644 --- a/examples/workflows/display_any_example.json +++ b/examples/workflows/display_any_example.json @@ -376,4 +376,4 @@ "VHS_KeepIntermediate": true }, "version": 0.4 -} \ No newline at end of file +} diff --git a/examples/workflows/display_text_example.json b/examples/workflows/display_text_example.json index 01823e5..3fd07f8 100644 --- a/examples/workflows/display_text_example.json +++ b/examples/workflows/display_text_example.json @@ -141,4 +141,4 @@ "VHS_KeepIntermediate": true }, "version": 0.4 -} \ No newline at end of file +} diff --git a/examples/workflows/empty_latent_batch_example.json b/examples/workflows/empty_latent_batch_example.json index d476bb3..001071c 100644 --- a/examples/workflows/empty_latent_batch_example.json +++ b/examples/workflows/empty_latent_batch_example.json @@ -298,4 +298,4 @@ "VHS_KeepIntermediate": true }, "version": 0.4 -} \ No newline at end of file +} diff --git a/examples/workflows/gemini_prompt_example.json b/examples/workflows/gemini_prompt_example.json index afc3e21..5740052 100644 --- a/examples/workflows/gemini_prompt_example.json +++ b/examples/workflows/gemini_prompt_example.json @@ -204,4 +204,4 @@ "VHS_KeepIntermediate": true }, "version": 0.4 -} \ No newline at end of file +} diff --git a/examples/workflows/image_to_multiple_of_example.json b/examples/workflows/image_to_multiple_of_example.json index 7b14a84..1801fd6 100644 --- a/examples/workflows/image_to_multiple_of_example.json +++ b/examples/workflows/image_to_multiple_of_example.json @@ -549,4 +549,4 @@ "VHS_KeepIntermediate": true }, "version": 0.4 -} \ No newline at end of file +} diff --git a/examples/workflows/kiko_film_grain_example.json b/examples/workflows/kiko_film_grain_example.json index 74dbcdd..35e0ce7 100644 --- a/examples/workflows/kiko_film_grain_example.json +++ b/examples/workflows/kiko_film_grain_example.json @@ -162,4 +162,4 @@ } }, "version": 0.4 -} \ No newline at end of file +} diff --git a/examples/workflows/kiko_save_image_example.json b/examples/workflows/kiko_save_image_example.json index db7d335..2b2f308 100644 --- a/examples/workflows/kiko_save_image_example.json +++ b/examples/workflows/kiko_save_image_example.json @@ -144,4 +144,4 @@ "VHS_KeepIntermediate": true }, "version": 0.4 -} \ No newline at end of file +} diff --git a/examples/workflows/resolution_calculator_example.json b/examples/workflows/resolution_calculator_example.json index 08e50fa..2171619 100644 --- a/examples/workflows/resolution_calculator_example.json +++ b/examples/workflows/resolution_calculator_example.json @@ -352,4 +352,4 @@ "VHS_KeepIntermediate": true }, "version": 0.4 -} \ No newline at end of file +} diff --git a/examples/workflows/xyz_helpers_sampler_comparison.json b/examples/workflows/xyz_helpers_sampler_comparison.json index f2eeb6e..4551ecc 100644 --- a/examples/workflows/xyz_helpers_sampler_comparison.json +++ b/examples/workflows/xyz_helpers_sampler_comparison.json @@ -166,4 +166,4 @@ ], "attribution": "xyz_helpers nodes adapted from comfyui-essentials-nodes" } -} \ No newline at end of file +} From a7805ad587427224cf267f21681991beb1e7acf6 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 17 Dec 2025 07:01:34 -0800 Subject: [PATCH 05/18] feat: add WidthHeightToVec2Node to mappings --- kikotools/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/kikotools/__init__.py b/kikotools/__init__.py index abfed77..0cb2ede 100644 --- a/kikotools/__init__.py +++ b/kikotools/__init__.py @@ -21,6 +21,7 @@ from .tools.sampler_combo import SamplerComboCompactNode, SamplerComboNode from .tools.seed_history import SeedHistoryNode from .tools.text_input import TextInputNode from .tools.width_height_selector import WidthHeightSelectorNode +from .tools.width_height_to_vec2 import WidthHeightToVec2Node from .tools.xyz_helpers import ( FluxSamplerParamsNode, LoRAFolderBatchNode, @@ -56,6 +57,7 @@ NODE_CLASS_MAPPINGS = { "FluxSamplerParams": FluxSamplerParamsNode, "PlotParameters+": PlotParametersNode, "LoRAFolderBatch": LoRAFolderBatchNode, + "WidthHeightToVec2": WidthHeightToVec2Node, # Note: KikoEmbeddingAutocomplete is not registered as a node # It's a settings-only feature accessed through ComfyUI settings menu } @@ -85,6 +87,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "FluxSamplerParams": "Flux Sampler Parameters", "PlotParameters+": "Plot Parameters", "LoRAFolderBatch": "LoRA Folder Batch", + "WidthHeightToVec2": "Width Height to VEC2", # KikoEmbeddingAutocomplete removed - settings only, not a node } From bff1276d0613161c053b06d6272ec7f81c3d07ad Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 17 Dec 2025 07:01:46 -0800 Subject: [PATCH 06/18] style: Add newline at EOF in JSON file --- kikotools/tools/gemini_prompt/.gemini_models_cache.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kikotools/tools/gemini_prompt/.gemini_models_cache.json b/kikotools/tools/gemini_prompt/.gemini_models_cache.json index 4fcfcdc..9e94ff9 100644 --- a/kikotools/tools/gemini_prompt/.gemini_models_cache.json +++ b/kikotools/tools/gemini_prompt/.gemini_models_cache.json @@ -86,4 +86,4 @@ "gemini-2.5-flash-lite": "Gemini 2.5 Flash-Lite" }, "timestamp": 1754568195.1098156 -} \ No newline at end of file +} From 14738b80c543e7f475209c863216097f7af13938 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 17 Dec 2025 07:01:55 -0800 Subject: [PATCH 07/18] feat(image_loader): add directory listing API endpoint --- .../tools/local_image_loader/config.json | 8 +- kikotools/tools/local_image_loader/logic.py | 6 ++ kikotools/tools/local_image_loader/node.py | 96 ++++++++++++++----- .../tools/local_image_loader/selections.json | 55 +++++++++++ .../tools/width_height_selector/presets.py | 20 ++++ 5 files changed, 161 insertions(+), 24 deletions(-) diff --git a/kikotools/tools/local_image_loader/config.json b/kikotools/tools/local_image_loader/config.json index e9b712b..446a5d3 100644 --- a/kikotools/tools/local_image_loader/config.json +++ b/kikotools/tools/local_image_loader/config.json @@ -1,7 +1,11 @@ { - "last_path": "/home/vito/Downloads/vito", + "last_path": "/home/vito/Downloads/images", "saved_paths": [ "/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01", - "/home/vito/ai-apps/ComfyUI-3.12/output/" + "/home/vito/ai-apps/ComfyUI-3.12/output/", + "/home/vito/Downloads/vito", + "/home/vito/ai-apps/ComfyUI/output/2025-06-08", + "/home/vito/ai-apps/ComfyUI/output", + "/home/vito/Downloads" ] } \ No newline at end of file diff --git a/kikotools/tools/local_image_loader/logic.py b/kikotools/tools/local_image_loader/logic.py index 95716aa..96d1220 100644 --- a/kikotools/tools/local_image_loader/logic.py +++ b/kikotools/tools/local_image_loader/logic.py @@ -73,6 +73,7 @@ def scan_directory( show_audio: bool = False, sort_by: str = "name", sort_order: str = "asc", + hide_dot_folders: bool = True, ) -> List[Dict[str, Any]]: """ Scan a directory for supported media files. @@ -83,6 +84,7 @@ def scan_directory( show_audio: Include audio files sort_by: Sort criteria ('name', 'date', 'size') sort_order: Sort order ('asc', 'desc') + hide_dot_folders: Hide folders starting with a dot Returns: List of file information dictionaries @@ -94,6 +96,10 @@ def scan_directory( items = [] for item in os.listdir(directory): + # Skip dot folders/files if hide_dot_folders is enabled + if hide_dot_folders and item.startswith("."): + continue + full_path = os.path.join(directory, item) try: diff --git a/kikotools/tools/local_image_loader/node.py b/kikotools/tools/local_image_loader/node.py index 814c2c7..c17c821 100644 --- a/kikotools/tools/local_image_loader/node.py +++ b/kikotools/tools/local_image_loader/node.py @@ -68,13 +68,9 @@ class LocalImageLoaderNode(ComfyAssetsBaseNode): RETURN_TYPES = ( "IMAGE", "STRING", - "STRING", - "STRING", ) RETURN_NAMES = ( "image", - "video_path", - "audio_path", "info", ) FUNCTION = "load_media" @@ -87,7 +83,7 @@ class LocalImageLoaderNode(ComfyAssetsBaseNode): return os.path.getmtime(SELECTIONS_FILE) return float("inf") - def load_media(self, unique_id: str) -> Tuple[torch.Tensor, str, str, str]: + def load_media(self, unique_id: str) -> Tuple[torch.Tensor, str]: """ Load selected media based on node's unique ID. @@ -95,11 +91,9 @@ class LocalImageLoaderNode(ComfyAssetsBaseNode): unique_id: Unique identifier for this node instance Returns: - Tuple of (image tensor, video path, audio path, info string) + Tuple of (image tensor, info string) """ image_tensor = create_empty_tensor() - video_path = "" - audio_path = "" info_string = "" selections = load_selections() @@ -116,19 +110,7 @@ class LocalImageLoaderNode(ComfyAssetsBaseNode): except Exception as e: print(f"KikoLocalImageLoader: Error loading image: {e}") - # Get video path if selected - video_selection = node_selections.get("video") - if video_selection and video_selection.get("path"): - if os.path.exists(video_selection["path"]): - video_path = video_selection["path"] - - # Get audio path if selected - audio_selection = node_selections.get("audio") - if audio_selection and audio_selection.get("path"): - if os.path.exists(audio_selection["path"]): - audio_path = audio_selection["path"] - - return (image_tensor, video_path, audio_path, info_string) + return (image_tensor, info_string) # Setup API routes @@ -194,6 +176,9 @@ try: if not directory or not os.path.isdir(directory): return web.json_response({"error": "Directory not found."}, status=404) + # Normalize path to remove trailing slashes and resolve relative paths + directory = os.path.normpath(directory) + # Save last path config = load_config() config["last_path"] = directory @@ -201,6 +186,7 @@ try: show_videos = request.query.get("show_videos", "false").lower() == "true" show_audio = request.query.get("show_audio", "false").lower() == "true" + hide_dot_folders = request.query.get("hide_dot_folders", "true").lower() == "true" page = int(request.query.get("page", 1)) per_page = int(request.query.get("per_page", 50)) @@ -209,7 +195,7 @@ try: try: items = scan_directory( - directory, show_videos, show_audio, sort_by, sort_order + directory, show_videos, show_audio, sort_by, sort_order, hide_dot_folders ) # Get parent directory @@ -239,6 +225,72 @@ try: """API endpoint to get last used directory path.""" return web.json_response({"last_path": load_config().get("last_path", "")}) + @prompt_server.routes.get("/kiko_local_image_loader/list_directories") + async def list_directories(request): + """API endpoint to list directories for autocomplete.""" + path = request.query.get("path", "") + + try: + # Handle empty path - show root or common starting points + if not path: + # Return filesystem root + if os.name == 'nt': # Windows + import string + drives = [f"{d}:\\" for d in string.ascii_uppercase + if os.path.exists(f"{d}:\\")] + return web.json_response({"directories": drives}) + else: # Unix/Linux/Mac + return web.json_response({"directories": ["/"]}) + + # Normalize the path + path = os.path.expanduser(path) # Handle ~ for home directory + + # If path ends with separator, list contents of that directory + if path.endswith(os.sep) or (os.name == 'nt' and path.endswith('/')): + if os.path.isdir(path): + try: + entries = os.listdir(path) + dirs = [] + for entry in entries: + full_path = os.path.join(path, entry) + if os.path.isdir(full_path): + dirs.append(full_path) + dirs.sort(key=lambda x: x.lower()) + return web.json_response({"directories": dirs[:50]}) # Limit results + except PermissionError: + return web.json_response({"directories": [], "error": "Permission denied"}) + else: + return web.json_response({"directories": []}) + + # Otherwise, find matching directories in parent + parent_dir = os.path.dirname(path) + basename = os.path.basename(path).lower() + + if not parent_dir: + # Handle root level on Unix + if path.startswith('/'): + parent_dir = '/' + basename = path[1:].lower() + else: + return web.json_response({"directories": []}) + + if os.path.isdir(parent_dir): + try: + entries = os.listdir(parent_dir) + dirs = [] + for entry in entries: + full_path = os.path.join(parent_dir, entry) + if os.path.isdir(full_path) and entry.lower().startswith(basename): + dirs.append(full_path) + dirs.sort(key=lambda x: x.lower()) + return web.json_response({"directories": dirs[:50]}) # Limit results + except PermissionError: + return web.json_response({"directories": [], "error": "Permission denied"}) + + return web.json_response({"directories": []}) + except Exception as e: + return web.json_response({"directories": [], "error": str(e)}) + @prompt_server.routes.get("/kiko_local_image_loader/thumbnail") async def get_thumbnail(request): """API endpoint to get image thumbnail.""" diff --git a/kikotools/tools/local_image_loader/selections.json b/kikotools/tools/local_image_loader/selections.json index 852e172..a5016c6 100644 --- a/kikotools/tools/local_image_loader/selections.json +++ b/kikotools/tools/local_image_loader/selections.json @@ -18,5 +18,60 @@ "image": { "path": "/home/vito/ai-apps/ComfyUI/output/ComfyUI_00002_.png" } + }, + "23": { + "image": { + "path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00098_.png" + } + }, + "69": { + "image": { + "path": "/home/vito/Downloads/KikoSave_00086.png" + } + }, + "52": { + "image": { + "path": "/home/vito/ai-apps/ComfyUI/output/kiko/XXX/images/kiko_00038_.png" + } + }, + "38": { + "image": { + "path": "/home/vito/Downloads/vito/IMG_20160422_163419.jpg" + } + }, + "170": { + "image": { + "path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00326_.png" + } + }, + "214": { + "image": { + "path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00329_.png" + } + }, + "299": { + "image": { + "path": "/home/vito/Downloads/images/KikoSave_00016.png" + } + }, + "527": { + "image": { + "path": "/home/vito/Downloads/ComfyUI_temp_sktzg_00012_.png" + } + }, + "522": { + "image": { + "path": "/home/vito/Downloads/KikoSave_00086.png" + } + }, + "517": { + "image": { + "path": "/home/vito/Downloads/images/KikoSave_00087.png" + } + }, + "144": { + "image": { + "path": "/home/vito/Downloads/images/view.png" + } } } \ No newline at end of file diff --git a/kikotools/tools/width_height_selector/presets.py b/kikotools/tools/width_height_selector/presets.py index af4d082..388e5d8 100644 --- a/kikotools/tools/width_height_selector/presets.py +++ b/kikotools/tools/width_height_selector/presets.py @@ -98,6 +98,16 @@ PRESET_METADATA: Dict[str, PresetMetadata] = { "Portrait", "SDXL near-square portrait - subtle portrait", ), + "720ร—1280": PresetMetadata( + 720, + 1280, + "9:16", + 0.5625, + 0.92, + "SDXL", + "Portrait", + "SDXL portrait 9:16 - vertical video/mobile", + ), # SDXL Presets - Landscape "1024ร—960": PresetMetadata( 1024, @@ -159,6 +169,16 @@ PRESET_METADATA: Dict[str, PresetMetadata] = { "Landscape", "SDXL landscape 3:1 - extreme wide panoramic", ), + "1280ร—720": PresetMetadata( + 1280, + 720, + "16:9", + 1.778, + 0.92, + "SDXL", + "Landscape", + "SDXL landscape 16:9 - HD widescreen video", + ), # FLUX Presets - High Quality "1920ร—1080": PresetMetadata( 1920, From b5276352541714c8e7405479c30ec1ea6fec7ccb Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 17 Dec 2025 07:02:08 -0800 Subject: [PATCH 08/18] test: update return types in test_assertion --- tests/unit/tools/test_sampler_combo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/tools/test_sampler_combo.py b/tests/unit/tools/test_sampler_combo.py index 8258beb..08fb12b 100644 --- a/tests/unit/tools/test_sampler_combo.py +++ b/tests/unit/tools/test_sampler_combo.py @@ -178,7 +178,7 @@ class TestSamplerComboNode: def test_return_types_structure(self): """Test that return types are correctly defined.""" - assert SamplerComboNode.RETURN_TYPES == ("SAMPLER", SCHEDULERS, "INT", "FLOAT") + assert SamplerComboNode.RETURN_TYPES == (SAMPLERS, SCHEDULERS, "INT", "FLOAT") assert SamplerComboNode.RETURN_NAMES == ( "sampler_name", "scheduler", From 8c0b89ff708c6d98295829e5c850716e1ddca09d Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 17 Dec 2025 07:06:16 -0800 Subject: [PATCH 09/18] style(node): Reformat code for readability --- kikotools/tools/local_image_loader/node.py | 47 ++++++++++++++++------ 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/kikotools/tools/local_image_loader/node.py b/kikotools/tools/local_image_loader/node.py index c17c821..90ebedf 100644 --- a/kikotools/tools/local_image_loader/node.py +++ b/kikotools/tools/local_image_loader/node.py @@ -186,7 +186,9 @@ try: show_videos = request.query.get("show_videos", "false").lower() == "true" show_audio = request.query.get("show_audio", "false").lower() == "true" - hide_dot_folders = request.query.get("hide_dot_folders", "true").lower() == "true" + hide_dot_folders = ( + request.query.get("hide_dot_folders", "true").lower() == "true" + ) page = int(request.query.get("page", 1)) per_page = int(request.query.get("per_page", 50)) @@ -195,7 +197,12 @@ try: try: items = scan_directory( - directory, show_videos, show_audio, sort_by, sort_order, hide_dot_folders + directory, + show_videos, + show_audio, + sort_by, + sort_order, + hide_dot_folders, ) # Get parent directory @@ -234,10 +241,14 @@ try: # Handle empty path - show root or common starting points if not path: # Return filesystem root - if os.name == 'nt': # Windows + if os.name == "nt": # Windows import string - drives = [f"{d}:\\" for d in string.ascii_uppercase - if os.path.exists(f"{d}:\\")] + + drives = [ + f"{d}:\\" + for d in string.ascii_uppercase + if os.path.exists(f"{d}:\\") + ] return web.json_response({"directories": drives}) else: # Unix/Linux/Mac return web.json_response({"directories": ["/"]}) @@ -246,7 +257,7 @@ try: path = os.path.expanduser(path) # Handle ~ for home directory # If path ends with separator, list contents of that directory - if path.endswith(os.sep) or (os.name == 'nt' and path.endswith('/')): + if path.endswith(os.sep) or (os.name == "nt" and path.endswith("/")): if os.path.isdir(path): try: entries = os.listdir(path) @@ -256,9 +267,13 @@ try: if os.path.isdir(full_path): dirs.append(full_path) dirs.sort(key=lambda x: x.lower()) - return web.json_response({"directories": dirs[:50]}) # Limit results + return web.json_response( + {"directories": dirs[:50]} + ) # Limit results except PermissionError: - return web.json_response({"directories": [], "error": "Permission denied"}) + return web.json_response( + {"directories": [], "error": "Permission denied"} + ) else: return web.json_response({"directories": []}) @@ -268,8 +283,8 @@ try: if not parent_dir: # Handle root level on Unix - if path.startswith('/'): - parent_dir = '/' + if path.startswith("/"): + parent_dir = "/" basename = path[1:].lower() else: return web.json_response({"directories": []}) @@ -280,12 +295,18 @@ try: dirs = [] for entry in entries: full_path = os.path.join(parent_dir, entry) - if os.path.isdir(full_path) and entry.lower().startswith(basename): + if os.path.isdir(full_path) and entry.lower().startswith( + basename + ): dirs.append(full_path) dirs.sort(key=lambda x: x.lower()) - return web.json_response({"directories": dirs[:50]}) # Limit results + return web.json_response( + {"directories": dirs[:50]} + ) # Limit results except PermissionError: - return web.json_response({"directories": [], "error": "Permission denied"}) + return web.json_response( + {"directories": [], "error": "Permission denied"} + ) return web.json_response({"directories": []}) except Exception as e: From 6916b47a89e288eda2dba0e18ac0c026be225522 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 17 Dec 2025 07:15:01 -0800 Subject: [PATCH 10/18] chore: bump version to 1.0.27 in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ba8a6f0..f98b6b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "kikotools" description = "Simple tools for ComfyUI" -version = "1.0.26" +version = "1.0.27" license = {text = "MIT"} dependencies = [] From 969fd9e25e509dc0c229d05b593246995846ba26 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 21 Dec 2025 06:44:14 -0800 Subject: [PATCH 11/18] docs: Add Workflow Timer section to README.md --- README.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 891ed9f..62e36df 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Iโ€™m sharing them here with the community, and I hope you find them as useful a | [๐Ÿงน Kiko Purge VRAM](#-kiko-purge-vram) | Intelligent VRAM management with detailed reporting | ๐Ÿ› ๏ธ Utils | | [๐Ÿ“‚ Local Image Loader](#-local-image-loader) | Visual gallery browser for local media files | ๐Ÿ’พ Images | | [๐ŸŒ Model Downloader](#-model-downloader) | Download models from CivitAI, HuggingFace, and custom URLs | ๐Ÿ› ๏ธ Utils | +| [โฑ๏ธ Workflow Timer](#๏ธ-workflow-timer) | Real-time execution timer with customizable display | ๐Ÿ› ๏ธ Utils | ### ๐Ÿงฐ xyz-helpers Tools @@ -405,6 +406,26 @@ Download models, LoRAs, and other assets directly from CivitAI, HuggingFace, and ![Model Downloader Example](examples/workflows/model_downloader_example.png) +#### โฑ๏ธ Workflow Timer +Real-time execution timer that displays workflow duration with millisecond precision. + +- **Live Timing**: Updates in real-time during workflow execution (MM:SS:mmm format) +- **Customizable Color**: Choose your preferred display color via KikoTools settings +- **Glow Effect**: Optional pulsing glow animation (can be enabled/disabled in settings) +- **Global Settings**: Color and glow preferences apply to all timer nodes +- **Persistent Display**: Shows final execution time after workflow completes +- **Multi-Node Sync**: All timer nodes stay synchronized during execution + +**Use Cases:** +- Monitor workflow execution performance +- Compare generation times across different settings +- Identify slow nodes by adding timers at different workflow stages +- Track optimization improvements over time + +**Settings (KikoTools Settings Panel):** +- **Workflow Timer: Color** - Custom color picker for timer display +- **Workflow Timer: Enable Glow** - Toggle pulsing glow effect on/off + ### ๐Ÿ”ค Embedding Autocomplete **Intelligent autocomplete for embeddings, LoRAs, and custom tags in text prompts.** @@ -448,7 +469,7 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI **Intelligent GPU memory management with threshold-based triggering and detailed reporting.** **Key Features:** -- **4 Purge Modes**: +- **4 Purge Modes**: - `soft`: Basic garbage collection and cache clearing - `aggressive`: Multiple GC passes with full CUDA cache clearing - `models_only`: Unload all models and clear model cache @@ -728,6 +749,7 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x | **Text Encode Sampler Params** | Combined text encoding and parameter management | โœ… Complete | [Docs](examples/documentation/text_encode_sampler_params.md) | | **Local Image Loader** | Visual gallery browser for local media files | โœ… Complete | [Docs](examples/documentation/local_image_loader.md) | | **Model Downloader** | Download models from CivitAI, HuggingFace, and custom URLs | โœ… Complete | [Docs](examples/documentation/model_downloader.md) | +| **Workflow Timer** | Real-time execution timer with customizable display | โœ… Complete | [Docs](examples/documentation/workflow_timer.md) | | **Batch Image Processor** | Process multiple images with consistent settings | ๐Ÿšง Planned | Coming Soon | | **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | ๐Ÿšง Planned | Coming Soon | @@ -1021,7 +1043,7 @@ MIT License - see [LICENSE](LICENSE) file for details. ## ๐Ÿ“ˆ Stats -- **Nodes**: 20 (14 core tools + 6 xyz-helpers) +- **Nodes**: 21 (15 core tools + 6 xyz-helpers) - **Features**: Embedding Autocomplete (settings-based, not a node) - **Categories**: 9 emoji-based categories for better organization - **Download Platforms**: 3 (CivitAI, HuggingFace, Custom URLs) From 36d37db8a95c407a852061d5bd802d17b4572408 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 21 Dec 2025 06:44:24 -0800 Subject: [PATCH 12/18] feat(init): add KikoWorkflowTimerNode to mappings --- kikotools/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/kikotools/__init__.py b/kikotools/__init__.py index 0cb2ede..df52781 100644 --- a/kikotools/__init__.py +++ b/kikotools/__init__.py @@ -13,6 +13,7 @@ from .tools.image_scale_down_by import ImageScaleDownByNode from .tools.image_to_multiple_of import ImageToMultipleOfNode from .tools.kiko_film_grain import KikoFilmGrainNode from .tools.kiko_purge_vram import KikoPurgeVRAM +from .tools.kiko_workflow_timer import KikoWorkflowTimerNode from .tools.kiko_save_image import KikoSaveImageNode from .tools.local_image_loader import LocalImageLoaderNode from .tools.model_downloader import ModelDownloaderNode @@ -58,6 +59,7 @@ NODE_CLASS_MAPPINGS = { "PlotParameters+": PlotParametersNode, "LoRAFolderBatch": LoRAFolderBatchNode, "WidthHeightToVec2": WidthHeightToVec2Node, + "KikoWorkflowTimer": KikoWorkflowTimerNode, # Note: KikoEmbeddingAutocomplete is not registered as a node # It's a settings-only feature accessed through ComfyUI settings menu } @@ -88,6 +90,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "PlotParameters+": "Plot Parameters", "LoRAFolderBatch": "LoRA Folder Batch", "WidthHeightToVec2": "Width Height to VEC2", + "KikoWorkflowTimer": "Workflow Timer", # KikoEmbeddingAutocomplete removed - settings only, not a node } From c64e835d2e8a65f478e740108853428837e93d0a Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 21 Dec 2025 06:44:38 -0800 Subject: [PATCH 13/18] chore(local_image_loader): update config paths --- kikotools/tools/local_image_loader/config.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/kikotools/tools/local_image_loader/config.json b/kikotools/tools/local_image_loader/config.json index 446a5d3..d06b7b7 100644 --- a/kikotools/tools/local_image_loader/config.json +++ b/kikotools/tools/local_image_loader/config.json @@ -1,11 +1,12 @@ { - "last_path": "/home/vito/Downloads/images", + "last_path": "/home/vito/ai-apps/ComfyUI/output/vids/images", "saved_paths": [ "/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01", "/home/vito/ai-apps/ComfyUI-3.12/output/", "/home/vito/Downloads/vito", "/home/vito/ai-apps/ComfyUI/output/2025-06-08", "/home/vito/ai-apps/ComfyUI/output", - "/home/vito/Downloads" + "/home/vito/Downloads", + "/home/vito/Downloads/images" ] } \ No newline at end of file From f12debe72957270c51f3f88c8e825a01438d95cb Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 21 Dec 2025 06:44:49 -0800 Subject: [PATCH 14/18] chore: update image paths in selections.json --- kikotools/tools/local_image_loader/selections.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kikotools/tools/local_image_loader/selections.json b/kikotools/tools/local_image_loader/selections.json index a5016c6..d27dcf4 100644 --- a/kikotools/tools/local_image_loader/selections.json +++ b/kikotools/tools/local_image_loader/selections.json @@ -66,12 +66,12 @@ }, "517": { "image": { - "path": "/home/vito/Downloads/images/KikoSave_00087.png" + "path": "/home/vito/ai-apps/ComfyUI/output/vids/images/kittybear_00004.png" } }, "144": { "image": { - "path": "/home/vito/Downloads/images/view.png" + "path": "/home/vito/ai-apps/ComfyUI/output/2025-04-24/ComfyUI_00002_.png" } } } \ No newline at end of file From e504f314aa244a1a851b858c1adf8def4edcc611 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 21 Dec 2025 06:45:00 -0800 Subject: [PATCH 15/18] chore: bump version to 1.0.28 in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f98b6b6..0069524 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "kikotools" description = "Simple tools for ComfyUI" -version = "1.0.27" +version = "1.0.28" license = {text = "MIT"} dependencies = [] From cecd1e01e983b7de29b18afecb40068794f4591a Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 21 Dec 2025 06:45:10 -0800 Subject: [PATCH 16/18] feat(timer): add Sketch-style color picker widget --- web/kiko_workflow_timer_ui.js | 539 ++++++++++++++++++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 web/kiko_workflow_timer_ui.js diff --git a/web/kiko_workflow_timer_ui.js b/web/kiko_workflow_timer_ui.js new file mode 100644 index 0000000..8ae736f --- /dev/null +++ b/web/kiko_workflow_timer_ui.js @@ -0,0 +1,539 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; +import { $el } from "../../scripts/ui.js"; + +/** + * Sketch-style Color Picker for Settings + */ +class SketchColorPicker { + constructor(initialColor = '#ff9966', onChange = () => {}) { + this.color = this.hexToHsv(initialColor); + this.hex = initialColor; + this.onChange = onChange; + this.isOpen = false; + + this.presetColors = [ + '#D0021B', '#F5A623', '#F8E71C', '#8B572A', '#7ED321', + '#417505', '#BD10E0', '#9013FE', '#4A90D9', '#50E3C2', + '#B8E986', '#000000', '#4A4A4A', '#9B9B9B', '#FFFFFF', + ]; + + this.createElements(); + } + + createElements() { + // Swatch button + this.swatch = document.createElement('div'); + this.swatch.style.cssText = ` + width: 50px; height: 28px; border-radius: 4px; + background: ${this.hex}; cursor: pointer; + box-shadow: 0 0 0 1px rgba(0,0,0,.2), inset 0 0 0 1px rgba(0,0,0,.1); + display: inline-block; + `; + this.swatch.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggle(); + }); + + // Popup container + this.popup = document.createElement('div'); + this.popup.style.cssText = ` + position: absolute; z-index: 10000; + background: #2a2a2a; border-radius: 6px; + box-shadow: 0 0 0 1px rgba(255,255,255,.1), 0 8px 24px rgba(0,0,0,.4); + padding: 12px; display: none; width: 220px; + bottom: 100%; margin-bottom: 8px; right: 0; + `; + + // Saturation/Brightness picker + this.satBright = document.createElement('div'); + this.satBright.style.cssText = ` + width: 196px; height: 140px; position: relative; + border-radius: 4px; cursor: crosshair; margin-bottom: 12px; + `; + this.updateSatBrightBackground(); + + this.satBrightPointer = document.createElement('div'); + this.satBrightPointer.style.cssText = ` + width: 14px; height: 14px; border-radius: 50%; + border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.3), 0 2px 4px rgba(0,0,0,.3); + position: absolute; transform: translate(-50%, -50%); + pointer-events: none; + `; + this.satBright.appendChild(this.satBrightPointer); + + // Hue slider + this.hueSlider = document.createElement('div'); + this.hueSlider.style.cssText = ` + width: 196px; height: 14px; border-radius: 4px; + background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%); + position: relative; cursor: pointer; margin-bottom: 12px; + `; + this.huePointer = document.createElement('div'); + this.huePointer.style.cssText = ` + width: 8px; height: 18px; border-radius: 3px; + background: #fff; border: 1px solid rgba(0,0,0,.3); + position: absolute; top: -2px; transform: translateX(-50%); + pointer-events: none; box-shadow: 0 1px 3px rgba(0,0,0,.3); + `; + this.hueSlider.appendChild(this.huePointer); + + // Hex input row + this.hexRow = document.createElement('div'); + this.hexRow.style.cssText = 'display: flex; align-items: center; margin-bottom: 12px; gap: 8px;'; + + this.hexInput = document.createElement('input'); + this.hexInput.type = 'text'; + this.hexInput.value = this.hex; + this.hexInput.style.cssText = ` + flex: 1; padding: 6px 8px; border: 1px solid #444; + border-radius: 4px; font-size: 13px; font-family: monospace; + text-transform: uppercase; background: #1a1a1a; color: #eee; + `; + this.hexInput.addEventListener('change', () => this.setHex(this.hexInput.value)); + + const hexLabel = document.createElement('span'); + hexLabel.textContent = 'Hex'; + hexLabel.style.cssText = 'font-size: 12px; color: #888; min-width: 28px;'; + + this.hexRow.appendChild(this.hexInput); + this.hexRow.appendChild(hexLabel); + + // Preset swatches + this.presetsContainer = document.createElement('div'); + this.presetsContainer.style.cssText = ` + display: flex; flex-wrap: wrap; gap: 6px; + border-top: 1px solid #444; padding-top: 12px; + `; + + this.presetColors.forEach(color => { + const preset = document.createElement('div'); + preset.style.cssText = ` + width: 18px; height: 18px; border-radius: 3px; + background: ${color}; cursor: pointer; + box-shadow: inset 0 0 0 1px rgba(0,0,0,.2); + transition: transform 0.1s; + `; + preset.addEventListener('mouseenter', () => preset.style.transform = 'scale(1.15)'); + preset.addEventListener('mouseleave', () => preset.style.transform = 'scale(1)'); + preset.addEventListener('click', () => this.setHex(color)); + this.presetsContainer.appendChild(preset); + }); + + // Assemble popup + this.popup.appendChild(this.satBright); + this.popup.appendChild(this.hueSlider); + this.popup.appendChild(this.hexRow); + this.popup.appendChild(this.presetsContainer); + + // Event handlers + this.setupDrag(this.satBright, this.handleSatBrightChange.bind(this)); + this.setupDrag(this.hueSlider, this.handleHueChange.bind(this)); + + // Close on outside click + this.closeHandler = (e) => { + if (!this.popup.contains(e.target) && e.target !== this.swatch) { + this.close(); + } + }; + } + + setupDrag(element, handler) { + const onMove = (e) => { + e.preventDefault(); + const rect = element.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + handler(x, y); + }; + + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + + // Click and drag behavior + element.addEventListener('mousedown', (e) => { + e.preventDefault(); + onMove(e); + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + + // Hover preview - color follows mouse pointer + element.addEventListener('mousemove', (e) => { + if (e.buttons === 0) { // Only on hover, not during drag + const rect = element.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + handler(x, y); + } + }); + } + + handleSatBrightChange(x, y) { + this.color.s = x * 100; + this.color.v = (1 - y) * 100; + this.updateFromHsv(); + } + + handleHueChange(x) { + this.color.h = x * 360; + this.updateFromHsv(); + } + + updateFromHsv() { + this.hex = this.hsvToHex(this.color.h, this.color.s, this.color.v); + this.updateUI(); + this.onChange(this.hex); + } + + updateUI() { + this.swatch.style.background = this.hex; + this.updateSatBrightBackground(); + this.satBrightPointer.style.left = `${this.color.s}%`; + this.satBrightPointer.style.top = `${100 - this.color.v}%`; + this.huePointer.style.left = `${(this.color.h / 360) * 100}%`; + this.hexInput.value = this.hex.toUpperCase(); + } + + updateSatBrightBackground() { + const hueColor = this.hsvToHex(this.color.h, 100, 100); + this.satBright.style.background = ` + linear-gradient(to top, #000, transparent), + linear-gradient(to right, #fff, ${hueColor}) + `; + } + + setHex(hex) { + if (!/^#?[0-9A-Fa-f]{6}$/.test(hex)) return; + if (!hex.startsWith('#')) hex = '#' + hex; + this.hex = hex; + this.color = this.hexToHsv(hex); + this.updateUI(); + this.onChange(this.hex); + } + + toggle() { this.isOpen ? this.close() : this.open(); } + + open() { + this.isOpen = true; + this.popup.style.display = 'block'; + this.updateUI(); + setTimeout(() => document.addEventListener('click', this.closeHandler), 0); + } + + close() { + this.isOpen = false; + this.popup.style.display = 'none'; + document.removeEventListener('click', this.closeHandler); + } + + getElement() { + const container = document.createElement('div'); + container.style.cssText = 'position: relative; display: inline-block;'; + container.appendChild(this.swatch); + container.appendChild(this.popup); + return container; + } + + hexToHsv(hex) { + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + const v = max * 100, d = max - min; + const s = max === 0 ? 0 : (d / max) * 100; + let h = 0; + if (d !== 0) { + switch (max) { + case r: h = ((g - b) / d + (g < b ? 6 : 0)) * 60; break; + case g: h = ((b - r) / d + 2) * 60; break; + case b: h = ((r - g) / d + 4) * 60; break; + } + } + return { h, s, v }; + } + + hsvToHex(h, s, v) { + s /= 100; v /= 100; + const c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c; + let r = 0, g = 0, b = 0; + if (h < 60) { r = c; g = x; } + else if (h < 120) { r = x; g = c; } + else if (h < 180) { g = c; b = x; } + else if (h < 240) { g = x; b = c; } + else if (h < 300) { r = x; b = c; } + else { r = c; b = x; } + const toHex = (n) => Math.round((n + m) * 255).toString(16).padStart(2, '0'); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + } +} + +/** + * Global Timer Manager + */ +const KikoGlobalTimer = { + startTime: 0, + intervalId: null, + isRunning: false, + activeNodes: new Set(), + + formatTime(ms) { + if (ms < 0) ms = 0; + const minutes = String(Math.floor(ms / 60000)).padStart(2, '0'); + const seconds = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0'); + const milliseconds = String(ms % 1000).padStart(3, '0'); + return `${minutes}:${seconds}:${milliseconds}`; + }, + + getTimerColor() { + return app.ui.settings.getSettingValue("kikotools.workflow_timer.color") || '#ff9966'; + }, + + isGlowEnabled() { + const val = app.ui.settings.getSettingValue("kikotools.workflow_timer.glow"); + return val !== undefined ? val : true; + }, + + start() { + if (this.isRunning) return; + this.isRunning = true; + this.startTime = Date.now(); + + const timerColor = this.getTimerColor(); + this.activeNodes.forEach(node => { + if (node.timerDisplay) { + node.timerDisplay.style.color = timerColor; + node.timerDisplay.classList.add('running'); + } + }); + + this.intervalId = setInterval(() => { + const elapsed = Date.now() - this.startTime; + const timeString = this.formatTime(elapsed); + this.activeNodes.forEach(node => { + if (node.timerDisplay) { + node.timerDisplay.textContent = timeString; + } + }); + }, 33); + }, + + stop() { + if (!this.isRunning) return; + this.isRunning = false; + clearInterval(this.intervalId); + + const finalTime = Date.now() - this.startTime; + const finalTimeString = this.formatTime(finalTime); + const timerColor = this.getTimerColor(); + + this.activeNodes.forEach(node => { + if (node.timerDisplay) { + node.timerDisplay.textContent = finalTimeString; + node.timerDisplay.style.color = timerColor; + node.timerDisplay.classList.remove('running'); + } + node.properties.elapsed_time_str = finalTimeString; + }); + }, + + updateColor(color) { + if (this.isRunning) return; + this.activeNodes.forEach(node => { + if (node.timerDisplay) { + node.timerDisplay.style.color = color; + } + }); + }, + + updateGlow(enabled) { + this.activeNodes.forEach(node => { + if (node.timerDisplay) { + node.timerDisplay.classList.toggle('no-glow', !enabled); + } + }); + }, + + registerNode(node) { this.activeNodes.add(node); }, + unregisterNode(node) { this.activeNodes.delete(node); }, +}; + +/** + * ComfyUI Extension for KikoWorkflow Timer + */ +const KikoWorkflowTimerExtension = { + name: "ComfyAssets.KikoWorkflowTimer", + + async init() { + // Color picker setting with custom render + app.ui.settings.addSetting({ + id: "kikotools.workflow_timer.color", + name: "๐Ÿซถ Workflow Timer: Color", + type: (name, setter, value) => { + const container = $el("div", { + style: { display: "flex", alignItems: "center", gap: "10px" } + }); + + const picker = new SketchColorPicker(value || '#ff9966', (newColor) => { + setter(newColor); + KikoGlobalTimer.updateColor(newColor); + }); + + container.appendChild(picker.getElement()); + + // Also show hex value as text + const hexDisplay = $el("span", { + textContent: value || '#ff9966', + style: { color: "#888", fontFamily: "monospace", fontSize: "12px" } + }); + container.appendChild(hexDisplay); + + // Update hex display when color changes + const origOnChange = picker.onChange; + picker.onChange = (newColor) => { + origOnChange(newColor); + hexDisplay.textContent = newColor.toUpperCase(); + }; + + return container; + }, + defaultValue: "#ff9966", + tooltip: "Color of the timer display when not running", + }); + + // Glow toggle + app.ui.settings.addSetting({ + id: "kikotools.workflow_timer.glow", + name: "๐Ÿซถ Workflow Timer: Enable Glow", + type: "boolean", + defaultValue: true, + tooltip: "Enable pulsing glow effect on the timer", + onChange: (value) => { + KikoGlobalTimer.updateGlow(value); + }, + }); + }, + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "KikoWorkflowTimer") { + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function() { + origOnNodeCreated?.apply(this, arguments); + + this.bgcolor = "#1a1a2e"; + this.color = "#16213e"; + this.title = "Workflow Timer"; + this.properties = this.properties || {}; + this.size = [300, 100]; + + const container = document.createElement("div"); + container.className = "kiko-timer-container"; + + this.timerDisplay = document.createElement("div"); + this.timerDisplay.className = "kiko-timer-display"; + this.timerDisplay.textContent = this.properties.elapsed_time_str || "00:00:000"; + + const timerColor = KikoGlobalTimer.getTimerColor(); + this.timerDisplay.style.color = timerColor; + + if (!KikoGlobalTimer.isGlowEnabled()) { + this.timerDisplay.classList.add('no-glow'); + } + + container.appendChild(this.timerDisplay); + this.addDOMWidget("kikoTimer", "Kiko Timer", container, { serialize: false }); + + KikoGlobalTimer.registerNode(this); + }; + + nodeType.prototype.onRemoved = function() { + KikoGlobalTimer.unregisterNode(this); + }; + + const origOnSerialize = nodeType.prototype.onSerialize; + nodeType.prototype.onSerialize = function(o) { + origOnSerialize?.apply(this, arguments); + o.properties = this.properties; + }; + + const origOnConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function(info) { + origOnConfigure?.apply(this, arguments); + this.properties = info.properties || {}; + + if (this.timerDisplay) { + this.timerDisplay.textContent = this.properties.elapsed_time_str || "00:00:000"; + const timerColor = KikoGlobalTimer.getTimerColor(); + this.timerDisplay.style.color = timerColor; + + if (!KikoGlobalTimer.isGlowEnabled()) { + this.timerDisplay.classList.add('no-glow'); + } + } + }; + } + }, + + setup() { + const style = document.createElement("style"); + style.textContent = ` + @keyframes kiko-timer-pulse { + 0%, 100% { text-shadow: 0 0 15px currentColor; opacity: 0.9; } + 50% { text-shadow: 0 0 25px currentColor; opacity: 1; } + } + + @keyframes kiko-timer-running-pulse { + 0%, 100% { text-shadow: 0 0 15px currentColor; opacity: 0.9; } + 50% { text-shadow: 0 0 25px currentColor; opacity: 1; } + } + + .kiko-timer-container { + width: 100%; height: 100%; + position: relative; display: flex; + align-items: center; justify-content: center; + } + + .kiko-timer-display { + text-align: center; width: 100%; height: 100%; + position: absolute; top: 0; left: 0; + background: transparent; border: none; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', monospace; + box-sizing: border-box; outline: none; margin: 0; + overflow: hidden; display: flex; + justify-content: center; align-items: center; + font-size: 42px; + animation: kiko-timer-pulse 4s infinite ease-in-out; + transition: color 0.3s ease-in-out; + font-variant-numeric: tabular-nums; + letter-spacing: 0.08em; + white-space: nowrap; font-weight: 600; + } + + .kiko-timer-display.no-glow { + animation: none; + text-shadow: none; + } + + .kiko-timer-display.running { + animation: kiko-timer-running-pulse 2s infinite ease-in-out; + } + + .kiko-timer-display.running.no-glow { + animation: none; + text-shadow: none; + } + `; + document.head.appendChild(style); + + api.addEventListener("execution_start", () => KikoGlobalTimer.start()); + api.addEventListener("executing", ({ detail }) => { + if (detail === null) KikoGlobalTimer.stop(); + }); + api.addEventListener("execution_error", () => KikoGlobalTimer.stop()); + api.addEventListener("execution_interrupted", () => KikoGlobalTimer.stop()); + } +}; + +app.registerExtension(KikoWorkflowTimerExtension); From 2467dff704798f0abf87ca92a5bc4a9a8ca03496 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 21 Dec 2025 06:45:24 -0800 Subject: [PATCH 17/18] feat(timer): add real-time workflow execution timer --- .../tools/kiko_workflow_timer/__init__.py | 10 ++++ kikotools/tools/kiko_workflow_timer/node.py | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 kikotools/tools/kiko_workflow_timer/__init__.py create mode 100644 kikotools/tools/kiko_workflow_timer/node.py diff --git a/kikotools/tools/kiko_workflow_timer/__init__.py b/kikotools/tools/kiko_workflow_timer/__init__.py new file mode 100644 index 0000000..fd81c54 --- /dev/null +++ b/kikotools/tools/kiko_workflow_timer/__init__.py @@ -0,0 +1,10 @@ +""" +KikoWorkflow Timer - Display real-time execution timer for ComfyUI workflows. + +Provides a visual timer that tracks workflow execution duration with +millisecond precision. +""" + +from .node import KikoWorkflowTimerNode + +__all__ = ["KikoWorkflowTimerNode"] diff --git a/kikotools/tools/kiko_workflow_timer/node.py b/kikotools/tools/kiko_workflow_timer/node.py new file mode 100644 index 0000000..40e1119 --- /dev/null +++ b/kikotools/tools/kiko_workflow_timer/node.py @@ -0,0 +1,52 @@ +""" +KikoWorkflow Timer Node + +A display-only node that shows real-time execution timing for ComfyUI workflows. +The timer is managed entirely on the frontend via WebSocket events. +""" + +from ...base import ComfyAssetsBaseNode + + +class KikoWorkflowTimerNode(ComfyAssetsBaseNode): + """ + A UI node that displays a real-time timer for workflow execution. + + The timer starts when execution begins and stops when the workflow + completes, showing the total elapsed time in MM:SS:mmm format. + + This is a display-only node with no inputs or outputs - all timing + logic is handled by the JavaScript frontend via WebSocket events. + """ + + DISPLAY_NAME = "Workflow Timer" + CATEGORY = "๐Ÿซถ ComfyAssets/๐Ÿ› ๏ธ Utils" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "hidden": { + "prompt": "PROMPT", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = () + FUNCTION = "execute" + OUTPUT_NODE = True + + def execute(self, **kwargs): + """ + Execute method - returns empty since this is a display-only node. + + The actual timer functionality is handled entirely by the JavaScript + frontend which hooks into ComfyUI's WebSocket events. + + Args: + **kwargs: Hidden parameters (prompt, unique_id) + + Returns: + Empty dict - no outputs + """ + return {} From 20270d9501e1684dfed3206220e8038aec5fd1ea Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 21 Dec 2025 06:45:35 -0800 Subject: [PATCH 18/18] test: Add unit tests for KikoWorkflowTimerNode --- tests/unit/tools/test_kiko_workflow_timer.py | 110 +++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/unit/tools/test_kiko_workflow_timer.py diff --git a/tests/unit/tools/test_kiko_workflow_timer.py b/tests/unit/tools/test_kiko_workflow_timer.py new file mode 100644 index 0000000..36094f3 --- /dev/null +++ b/tests/unit/tools/test_kiko_workflow_timer.py @@ -0,0 +1,110 @@ +""" +Unit tests for KikoWorkflowTimerNode. + +Since this is a display-only node with all logic handled by JavaScript, +these tests focus on validating the node's structure and ComfyUI integration. +""" + + +class TestKikoWorkflowTimerNode: + """Test suite for KikoWorkflowTimerNode.""" + + def test_node_import(self): + """Test that the node can be imported successfully.""" + from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode + + assert KikoWorkflowTimerNode is not None + + def test_node_has_required_attributes(self): + """Test that the node has all required ComfyUI attributes.""" + from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode + + # Check required ComfyUI attributes + assert hasattr(KikoWorkflowTimerNode, "INPUT_TYPES") + assert hasattr(KikoWorkflowTimerNode, "RETURN_TYPES") + assert hasattr(KikoWorkflowTimerNode, "FUNCTION") + assert hasattr(KikoWorkflowTimerNode, "CATEGORY") + + def test_node_input_types(self): + """Test that INPUT_TYPES is correctly defined.""" + from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode + + input_types = KikoWorkflowTimerNode.INPUT_TYPES() + + # Should have required dict (empty) + assert "required" in input_types + assert input_types["required"] == {} + + # Should have hidden inputs for prompt and unique_id + assert "hidden" in input_types + assert "prompt" in input_types["hidden"] + assert "unique_id" in input_types["hidden"] + assert input_types["hidden"]["prompt"] == "PROMPT" + assert input_types["hidden"]["unique_id"] == "UNIQUE_ID" + + def test_node_return_types(self): + """Test that the node has empty return types (display-only).""" + from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode + + assert KikoWorkflowTimerNode.RETURN_TYPES == () + + def test_node_is_output_node(self): + """Test that OUTPUT_NODE is set to True.""" + from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode + + assert KikoWorkflowTimerNode.OUTPUT_NODE is True + + def test_node_category(self): + """Test that the node has correct category.""" + from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode + + assert "ComfyAssets" in KikoWorkflowTimerNode.CATEGORY + + def test_node_display_name(self): + """Test that the node has a display name.""" + from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode + + assert hasattr(KikoWorkflowTimerNode, "DISPLAY_NAME") + assert KikoWorkflowTimerNode.DISPLAY_NAME == "Workflow Timer" + + def test_node_execute_returns_empty(self): + """Test that execute() returns empty dict.""" + from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode + + node = KikoWorkflowTimerNode() + result = node.execute() + + assert result == {} + + def test_node_execute_with_kwargs(self): + """Test that execute() handles kwargs correctly.""" + from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode + + node = KikoWorkflowTimerNode() + result = node.execute(prompt={}, unique_id="test-123") + + assert result == {} + + def test_node_inherits_from_base(self): + """Test that node inherits from ComfyAssetsBaseNode.""" + from kikotools.tools.kiko_workflow_timer.node import KikoWorkflowTimerNode + from kikotools.base import ComfyAssetsBaseNode + + assert issubclass(KikoWorkflowTimerNode, ComfyAssetsBaseNode) + + +class TestKikoWorkflowTimerModuleInit: + """Test the module's __init__.py exports.""" + + def test_module_exports_node(self): + """Test that the module exports the node class.""" + from kikotools.tools.kiko_workflow_timer import KikoWorkflowTimerNode + + assert KikoWorkflowTimerNode is not None + + def test_module_all_exports(self): + """Test that __all__ is correctly defined.""" + from kikotools.tools import kiko_workflow_timer + + assert hasattr(kiko_workflow_timer, "__all__") + assert "KikoWorkflowTimerNode" in kiko_workflow_timer.__all__