From 18b430972a16722e2366ba2f49c084b40661cd1a Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 14 Jun 2025 10:56:11 -0700 Subject: [PATCH 1/9] feat: Add width height swap feature with button --- web/width_height_swap.js | 304 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 web/width_height_swap.js diff --git a/web/width_height_swap.js b/web/width_height_swap.js new file mode 100644 index 0000000..4831107 --- /dev/null +++ b/web/width_height_swap.js @@ -0,0 +1,304 @@ +// ComfyUI-KikoTools - Width Height Selector with Swap Button +import { app } from "../../scripts/app.js"; + +app.registerExtension({ + name: "comfyassets.WidthHeightSelector", + async beforeRegisterNodeDef(nodeType, nodeData, _app) { + if (nodeData.name === "WidthHeightSelector") { + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + if (onNodeCreated) onNodeCreated.apply(this, []); + + // Track button click state for visual feedback + this.swapButtonPressed = false; + + // Override preset callback to update width/height widgets when preset changes + const presetWidget = this.widgets.find(w => w.name === "preset"); + if (presetWidget) { + const originalCallback = presetWidget.callback; + presetWidget.callback = function(value, graphcanvas, node, pos, event) { + // Call original callback first + if (originalCallback) { + originalCallback.call(this, value, graphcanvas, node, pos, event); + } + + // Update width/height widgets based on preset + const widthWidget = node.widgets.find(w => w.name === "width"); + const heightWidget = node.widgets.find(w => w.name === "height"); + + if (widthWidget && heightWidget && value !== "custom") { + // Define all available presets from our preset system + const presetDimensions = { + // SDXL Presets + "1024×1024": [1024, 1024], "896×1152": [896, 1152], "832×1216": [832, 1216], + "768×1344": [768, 1344], "640×1536": [640, 1536], "1152×896": [1152, 896], + "1216×832": [1216, 832], "1344×768": [1344, 768], "1536×640": [1536, 640], + // FLUX Presets + "1920×1080": [1920, 1080], "1536×1536": [1536, 1536], "1280×768": [1280, 768], + "768×1280": [768, 1280], "1440×1080": [1440, 1080], "1080×1440": [1080, 1440], + "1728×1152": [1728, 1152], "1152×1728": [1152, 1728], + // Ultra-Wide Presets + "2560×1080": [2560, 1080], "2048×768": [2048, 768], "1792×768": [1792, 768], + "2304×768": [2304, 768], "1080×2560": [1080, 2560], "768×2048": [768, 2048], + "768×1792": [768, 1792], "768×2304": [768, 2304] + }; + + if (presetDimensions[value]) { + const [w, h] = presetDimensions[value]; + widthWidget.value = w; + heightWidget.value = h; + + // Trigger widget callbacks to update the UI + if (widthWidget.callback) { + widthWidget.callback(w, graphcanvas, node, pos, event); + } + if (heightWidget.callback) { + heightWidget.callback(h, graphcanvas, node, pos, event); + } + } + } + }; + } + + // Add swap functionality + this.swapDimensions = function() { + const widthWidget = this.widgets.find(w => w.name === "width"); + const heightWidget = this.widgets.find(w => w.name === "height"); + const presetWidget = this.widgets.find(w => w.name === "preset"); + + if (widthWidget && heightWidget && presetWidget) { + // Handle preset swapping first + if (presetWidget.value !== "custom") { + const currentPreset = presetWidget.value; + + // Parse current preset dimensions (handle both × and x separators) + let w, h; + if (currentPreset.includes('×')) { + [w, h] = currentPreset.split('×').map(v => parseInt(v)); + } else if (currentPreset.includes('x')) { + [w, h] = currentPreset.split('x').map(v => parseInt(v)); + } else { + return; // Invalid preset format + } + + const swappedPreset = `${h}×${w}`; + + // Define all available presets from our preset system + const availablePresets = [ + "custom", + // SDXL Presets + "1024×1024", "896×1152", "832×1216", "768×1344", "640×1536", + "1152×896", "1216×832", "1344×768", "1536×640", + // FLUX Presets + "1920×1080", "1536×1536", "1280×768", "768×1280", + "1440×1080", "1080×1440", "1728×1152", "1152×1728", + // Ultra-Wide Presets + "2560×1080", "2048×768", "1792×768", "2304×768", + "1080×2560", "768×2048", "768×1792", "768×2304" + ]; + + if (availablePresets.includes(swappedPreset)) { + // Swapped preset exists, use it + presetWidget.value = swappedPreset; + widthWidget.value = h; + heightWidget.value = w; + if (presetWidget.callback) { + presetWidget.callback(swappedPreset, this, presetWidget); + } + if (widthWidget.callback) { + widthWidget.callback(h, this, widthWidget); + } + if (heightWidget.callback) { + heightWidget.callback(w, this, heightWidget); + } + } else { + // Swapped preset doesn't exist, switch to custom and swap manual values + presetWidget.value = "custom"; + widthWidget.value = h; + heightWidget.value = w; + + if (presetWidget.callback) { + presetWidget.callback("custom", this, presetWidget); + } + if (widthWidget.callback) { + widthWidget.callback(h, this, widthWidget); + } + if (heightWidget.callback) { + heightWidget.callback(w, this, heightWidget); + } + } + } else { + // Custom preset - just swap the width and height values + const tempWidth = widthWidget.value; + widthWidget.value = heightWidget.value; + heightWidget.value = tempWidth; + + // Trigger widget change events + if (widthWidget.callback) { + widthWidget.callback(widthWidget.value, this, widthWidget); + } + if (heightWidget.callback) { + heightWidget.callback(heightWidget.value, this, heightWidget); + } + } + + // Mark the graph as changed + this.graph?.setDirtyCanvas(true, true); + } + }; + }; + + const onDrawForeground = nodeType.prototype.onDrawForeground; + nodeType.prototype.onDrawForeground = function (ctx) { + if (onDrawForeground) { + onDrawForeground.apply(this, arguments); + } + + if (this.flags.collapsed) return; + + // Draw swap button with consistent spacing from widgets + const swapButtonSize = 24; + const margin = 6; + const swapButtonX = this.size[0] - swapButtonSize - margin; + + // Calculate button position based on widget spacing rather than bottom margin + // Estimate widget area height and add consistent spacing + const estimatedWidgetHeight = 90; // Approximate height for 3 widgets + const topMargin = 35; // Space from top to first widget + const buttonSpacing = 10; // Space between last widget and button + const swapButtonY = topMargin + estimatedWidgetHeight + buttonSpacing; + + // Button background - change color based on pressed state + if (this.swapButtonPressed) { + // Darker when pressed + ctx.fillStyle = "rgba(30, 120, 200, 0.9)"; // Darker blue when clicked + } else { + // Normal state + ctx.fillStyle = "rgba(66, 165, 245, 0.8)"; // Material blue + } + ctx.beginPath(); + ctx.roundRect(swapButtonX, swapButtonY, swapButtonSize, swapButtonSize, 4); + ctx.fill(); + + // Button border with subtle highlight + ctx.strokeStyle = this.swapButtonPressed ? "rgba(20, 100, 180, 1.0)" : "rgba(33, 150, 243, 0.9)"; + ctx.lineWidth = 1; + ctx.stroke(); + + // Draw swap icon - modern double arrow design + ctx.strokeStyle = "rgba(255, 255, 255, 0.95)"; + ctx.lineWidth = 2; + ctx.lineCap = "round"; + + const centerX = swapButtonX + 12; + const centerY = swapButtonY + 12; + + // Top arrow (pointing right) - width to height + ctx.beginPath(); + ctx.moveTo(centerX - 7, centerY - 3); + ctx.lineTo(centerX + 5, centerY - 3); + ctx.stroke(); + + // Top arrow head + ctx.beginPath(); + ctx.moveTo(centerX + 5, centerY - 3); + ctx.lineTo(centerX + 2, centerY - 5); + ctx.moveTo(centerX + 5, centerY - 3); + ctx.lineTo(centerX + 2, centerY - 1); + ctx.stroke(); + + // Bottom arrow (pointing left) - height to width + ctx.beginPath(); + ctx.moveTo(centerX + 5, centerY + 3); + ctx.lineTo(centerX - 7, centerY + 3); + ctx.stroke(); + + // Bottom arrow head + ctx.beginPath(); + ctx.moveTo(centerX - 7, centerY + 3); + ctx.lineTo(centerX - 4, centerY + 1); + ctx.moveTo(centerX - 7, centerY + 3); + ctx.lineTo(centerX - 4, centerY + 5); + ctx.stroke(); + + // Add subtle tooltip text when hovering (if we had hover state) + // This could be extended with hover detection for better UX + }; + + const onMouseDown = nodeType.prototype.onMouseDown; + nodeType.prototype.onMouseDown = function (e) { + // Check if click is on swap button + const swapButtonSize = 24; + const margin = 6; + const swapButtonX = this.pos[0] + this.size[0] - swapButtonSize - margin; + + // Use same positioning logic as drawing + const estimatedWidgetHeight = 90; + const topMargin = 35; + const buttonSpacing = 10; + const swapButtonY = this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing; + + if ( + e.canvasX >= swapButtonX && + e.canvasX <= swapButtonX + swapButtonSize && + e.canvasY >= swapButtonY && + e.canvasY <= swapButtonY + swapButtonSize + ) { + // Visual feedback - set button as pressed + this.swapButtonPressed = true; + this.setDirtyCanvas(true, true); + + // Execute swap + this.swapDimensions(); + + // Reset button state after a short delay for visual feedback + setTimeout(() => { + this.swapButtonPressed = false; + this.setDirtyCanvas(true, true); + }, 150); + + return true; // Consume the event + } + + // Call original onMouseDown if not clicking swap button + if (onMouseDown) { + return onMouseDown.apply(this, arguments); + } + }; + + // Optional: Add hover effect for better user feedback + const onMouseMove = nodeType.prototype.onMouseMove; + nodeType.prototype.onMouseMove = function (e) { + // Check if hovering over swap button + const swapButtonSize = 24; + const margin = 6; + const swapButtonX = this.pos[0] + this.size[0] - swapButtonSize - margin; + + // Use same positioning logic as drawing + const estimatedWidgetHeight = 90; + const topMargin = 35; + const buttonSpacing = 10; + const swapButtonY = this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing; + + const isHovering = ( + e.canvasX >= swapButtonX && + e.canvasX <= swapButtonX + swapButtonSize && + e.canvasY >= swapButtonY && + e.canvasY <= swapButtonY + swapButtonSize + ); + + // Update cursor style for better UX (safely) + if (isHovering && this.graph && this.graph.canvas && this.graph.canvas.canvas) { + this.graph.canvas.canvas.style.cursor = "pointer"; + } else if (this.graph && this.graph.canvas && this.graph.canvas.canvas) { + this.graph.canvas.canvas.style.cursor = "default"; + } + + // Call original onMouseMove + if (onMouseMove) { + return onMouseMove.apply(this, arguments); + } + }; + } + }, +}); \ No newline at end of file From e032d1af110fb5ae62150bf6749ef9f61e87f56f Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 14 Jun 2025 10:56:22 -0700 Subject: [PATCH 2/9] test(width_height_selector): add unit tests --- .../unit/tools/test_width_height_selector.py | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 tests/unit/tools/test_width_height_selector.py diff --git a/tests/unit/tools/test_width_height_selector.py b/tests/unit/tools/test_width_height_selector.py new file mode 100644 index 0000000..17d8060 --- /dev/null +++ b/tests/unit/tools/test_width_height_selector.py @@ -0,0 +1,263 @@ +"""Tests for Width Height Selector tool.""" + +import pytest +from unittest.mock import Mock +from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode +from kikotools.tools.width_height_selector.logic import ( + get_preset_dimensions, + calculate_aspect_ratio, + validate_dimensions, +) +from kikotools.tools.width_height_selector.presets import ( + PRESET_OPTIONS, + SDXL_PRESETS, + FLUX_PRESETS, + ULTRA_WIDE_PRESETS, +) + + +class TestWidthHeightSelectorNode: + """Test the WidthHeightSelectorNode class.""" + + def setup_method(self): + """Set up test fixtures.""" + self.node = WidthHeightSelectorNode() + + def test_node_structure(self): + """Test that node has required ComfyUI structure.""" + # Test INPUT_TYPES + input_types = self.node.INPUT_TYPES() + assert "required" in input_types + assert "preset" in input_types["required"] + assert "width" in input_types["required"] + assert "height" in input_types["required"] + + # Test return types + assert self.node.RETURN_TYPES == ("INT", "INT") + assert self.node.RETURN_NAMES == ("width", "height") + assert self.node.FUNCTION == "get_dimensions" + assert self.node.CATEGORY == "ComfyAssets" + + def test_custom_dimensions(self): + """Test custom dimensions.""" + result = self.node.get_dimensions(preset="custom", width=1920, height=1080) + assert result == (1920, 1080) + + def test_sdxl_square_preset(self): + """Test SDXL square preset.""" + result = self.node.get_dimensions( + preset="1024×1024", + width=512, # Should be ignored + height=512, # Should be ignored + ) + assert result == (1024, 1024) + + def test_sdxl_portrait_preset(self): + """Test SDXL portrait preset.""" + result = self.node.get_dimensions(preset="832×1216", width=512, height=512) + assert result == (832, 1216) + + def test_sdxl_landscape_preset(self): + """Test SDXL landscape preset.""" + result = self.node.get_dimensions(preset="1216×832", width=512, height=512) + assert result == (1216, 832) + + def test_flux_preset(self): + """Test FLUX preset.""" + result = self.node.get_dimensions(preset="1920×1080", width=512, height=512) + assert result == (1920, 1080) + + def test_ultra_wide_preset(self): + """Test ultra-wide preset.""" + result = self.node.get_dimensions(preset="2560×1080", width=512, height=512) + assert result == (2560, 1080) + + def test_all_presets_available(self): + """Test that all presets are available in INPUT_TYPES.""" + input_types = self.node.INPUT_TYPES() + available_presets = input_types["required"]["preset"][0] + + # Check that all major preset categories are available + assert "custom" in available_presets + assert "1024×1024" in available_presets # SDXL square + assert "832×1216" in available_presets # SDXL portrait + assert "1216×832" in available_presets # SDXL landscape + assert "1920×1080" in available_presets # FLUX + assert "2560×1080" in available_presets # Ultra-wide + + def test_invalid_preset_fallback(self): + """Test handling of invalid preset.""" + # Should fall back to custom dimensions + result = self.node.get_dimensions( + preset="invalid_preset", width=800, height=600 + ) + assert result == (800, 600) + + +class TestPresetLogic: + """Test the preset logic functions.""" + + def test_get_preset_dimensions_custom(self): + """Test getting custom dimensions.""" + width, height = get_preset_dimensions("custom", 1920, 1080) + assert width == 1920 + assert height == 1080 + + def test_get_preset_dimensions_sdxl(self): + """Test getting SDXL preset dimensions.""" + width, height = get_preset_dimensions("1024×1024", 512, 512) + assert width == 1024 + assert height == 1024 + + def test_get_preset_dimensions_flux(self): + """Test getting FLUX preset dimensions.""" + width, height = get_preset_dimensions("1920×1080", 512, 512) + assert width == 1920 + assert height == 1080 + + def test_get_preset_dimensions_invalid(self): + """Test getting dimensions for invalid preset.""" + width, height = get_preset_dimensions("invalid", 800, 600) + assert width == 800 + assert height == 600 + + +class TestDimensionValidation: + """Test dimension validation.""" + + def test_validate_dimensions_valid(self): + """Test validation of valid dimensions.""" + assert validate_dimensions(1024, 1024) is True + assert validate_dimensions(1920, 1080) is True + assert validate_dimensions(832, 1216) is True + + def test_validate_dimensions_divisible_by_8(self): + """Test that dimensions must be divisible by 8.""" + assert validate_dimensions(1024, 1024) is True # Both divisible by 8 + assert validate_dimensions(1025, 1024) is False # Width not divisible by 8 + assert validate_dimensions(1024, 1025) is False # Height not divisible by 8 + assert validate_dimensions(1025, 1025) is False # Neither divisible by 8 + + def test_validate_dimensions_minimum_size(self): + """Test minimum dimension requirements.""" + assert validate_dimensions(64, 64) is True # Minimum allowed + assert validate_dimensions(32, 64) is False # Width too small + assert validate_dimensions(64, 32) is False # Height too small + assert validate_dimensions(32, 32) is False # Both too small + + def test_validate_dimensions_maximum_size(self): + """Test maximum dimension requirements.""" + assert validate_dimensions(8192, 8192) is True # Maximum allowed + assert validate_dimensions(8200, 8192) is False # Width too large + assert validate_dimensions(8192, 8200) is False # Height too large + assert validate_dimensions(8200, 8200) is False # Both too large + + +class TestPresetDefinitions: + """Test preset definitions.""" + + def test_sdxl_presets_structure(self): + """Test SDXL presets are properly defined.""" + assert "1024×1024" in SDXL_PRESETS + assert "832×1216" in SDXL_PRESETS + assert "1216×832" in SDXL_PRESETS + + # Check dimensions are tuples + for preset, dims in SDXL_PRESETS.items(): + assert isinstance(dims, tuple) + assert len(dims) == 2 + assert isinstance(dims[0], int) + assert isinstance(dims[1], int) + + def test_flux_presets_structure(self): + """Test FLUX presets are properly defined.""" + assert "1920×1080" in FLUX_PRESETS + assert "1536×1536" in FLUX_PRESETS + + # Check dimensions are tuples + for preset, dims in FLUX_PRESETS.items(): + assert isinstance(dims, tuple) + assert len(dims) == 2 + assert isinstance(dims[0], int) + assert isinstance(dims[1], int) + + def test_ultra_wide_presets_structure(self): + """Test ultra-wide presets are properly defined.""" + assert "2560×1080" in ULTRA_WIDE_PRESETS + + # Check dimensions are tuples + for preset, dims in ULTRA_WIDE_PRESETS.items(): + assert isinstance(dims, tuple) + assert len(dims) == 2 + assert isinstance(dims[0], int) + assert isinstance(dims[1], int) + + def test_preset_options_combined(self): + """Test that PRESET_OPTIONS combines all presets correctly.""" + assert "custom" in PRESET_OPTIONS + + # Check SDXL presets are included + for preset in SDXL_PRESETS: + assert preset in PRESET_OPTIONS + + # Check FLUX presets are included + for preset in FLUX_PRESETS: + assert preset in PRESET_OPTIONS + + # Check ultra-wide presets are included + for preset in ULTRA_WIDE_PRESETS: + assert preset in PRESET_OPTIONS + + def test_all_presets_divisible_by_8(self): + """Test that all preset dimensions are divisible by 8.""" + for preset_dict in [SDXL_PRESETS, FLUX_PRESETS, ULTRA_WIDE_PRESETS]: + for preset_name, (width, height) in preset_dict.items(): + assert width % 8 == 0, f"{preset_name} width {width} not divisible by 8" + assert ( + height % 8 == 0 + ), f"{preset_name} height {height} not divisible by 8" + + def test_preset_dimensions_within_limits(self): + """Test that all preset dimensions are within acceptable limits.""" + for preset_dict in [SDXL_PRESETS, FLUX_PRESETS, ULTRA_WIDE_PRESETS]: + for preset_name, (width, height) in preset_dict.items(): + assert 64 <= width <= 8192, f"{preset_name} width {width} out of range" + assert ( + 64 <= height <= 8192 + ), f"{preset_name} height {height} out of range" + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_zero_dimensions(self): + """Test handling of zero dimensions.""" + assert validate_dimensions(0, 1024) is False + assert validate_dimensions(1024, 0) is False + assert validate_dimensions(0, 0) is False + + def test_negative_dimensions(self): + """Test handling of negative dimensions.""" + assert validate_dimensions(-100, 1024) is False + assert validate_dimensions(1024, -100) is False + assert validate_dimensions(-100, -100) is False + + def test_very_large_dimensions(self): + """Test handling of very large dimensions.""" + assert validate_dimensions(10000, 1024) is False + assert validate_dimensions(1024, 10000) is False + assert validate_dimensions(10000, 10000) is False + + def test_aspect_ratio_edge_cases(self): + """Test aspect ratio calculation edge cases.""" + # Very wide aspect ratio + ratio = calculate_aspect_ratio(3840, 1080) + assert ratio == "32:9" + + # Very tall aspect ratio + ratio = calculate_aspect_ratio(1080, 3840) + assert ratio == "9:32" + + # Prime number dimensions + ratio = calculate_aspect_ratio(1920, 1080) + assert ratio == "16:9" From e3b9993c8a04f49b3a41f0af5c1eaf0ad3c3390c Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 14 Jun 2025 10:56:36 -0700 Subject: [PATCH 3/9] feat(width_height_selector): add dimension selection tool --- .../tools/width_height_selector/__init__.py | 5 + .../tools/width_height_selector/logic.py | 264 ++++++++++++++++++ kikotools/tools/width_height_selector/node.py | 207 ++++++++++++++ .../tools/width_height_selector/presets.py | 152 ++++++++++ 4 files changed, 628 insertions(+) create mode 100644 kikotools/tools/width_height_selector/__init__.py create mode 100644 kikotools/tools/width_height_selector/logic.py create mode 100644 kikotools/tools/width_height_selector/node.py create mode 100644 kikotools/tools/width_height_selector/presets.py diff --git a/kikotools/tools/width_height_selector/__init__.py b/kikotools/tools/width_height_selector/__init__.py new file mode 100644 index 0000000..487449c --- /dev/null +++ b/kikotools/tools/width_height_selector/__init__.py @@ -0,0 +1,5 @@ +"""Width Height Selector tool for ComfyUI-KikoTools.""" + +from .node import WidthHeightSelectorNode + +__all__ = ["WidthHeightSelectorNode"] diff --git a/kikotools/tools/width_height_selector/logic.py b/kikotools/tools/width_height_selector/logic.py new file mode 100644 index 0000000..4fda97b --- /dev/null +++ b/kikotools/tools/width_height_selector/logic.py @@ -0,0 +1,264 @@ +"""Core logic for Width Height Selector tool.""" + +from typing import Tuple +from math import gcd +from .presets import PRESET_OPTIONS + + +def get_preset_dimensions( + preset: str, custom_width: int, custom_height: int +) -> Tuple[int, int]: + """ + Get dimensions from preset name or use custom dimensions. + + Args: + preset: Preset name (e.g., "1024×1024", "custom") + custom_width: Custom width value + custom_height: Custom height value + + Returns: + Tuple of (width, height) as integers + """ + if preset == "custom" or preset not in PRESET_OPTIONS: + return custom_width, custom_height + + return PRESET_OPTIONS[preset] + + +def apply_swap_logic(width: int, height: int, swap_enabled: bool) -> Tuple[int, int]: + """ + Apply swap logic to dimensions. + + Args: + width: Original width + height: Original height + swap_enabled: Whether to swap dimensions + + Returns: + Tuple of (width, height) after potential swap + """ + if swap_enabled: + return height, width + return width, height + + +def calculate_aspect_ratio(width: int, height: int) -> str: + """ + Calculate and format aspect ratio as a string. + + Args: + width: Image width + height: Image height + + Returns: + Formatted aspect ratio string (e.g., "16:9", "4:3", "1:1") + """ + if width <= 0 or height <= 0: + return "Invalid" + + # Calculate greatest common divisor to simplify ratio + ratio_gcd = gcd(width, height) + ratio_width = width // ratio_gcd + ratio_height = height // ratio_gcd + + return f"{ratio_width}:{ratio_height}" + + +def validate_dimensions(width: int, height: int) -> bool: + """ + Validate that dimensions meet ComfyUI requirements. + + Args: + width: Image width to validate + height: Image height to validate + + Returns: + True if dimensions are valid, False otherwise + """ + # Check positive values + if width <= 0 or height <= 0: + return False + + # Check divisible by 8 (ComfyUI requirement) + if width % 8 != 0 or height % 8 != 0: + return False + + # Check reasonable bounds (64 minimum, 8192 maximum) + if not (64 <= width <= 8192) or not (64 <= height <= 8192): + return False + + return True + + +def sanitize_dimensions(width: int, height: int) -> Tuple[int, int]: + """ + Sanitize dimensions to meet ComfyUI requirements. + + Rounds to nearest multiple of 8 and clamps to valid range. + + Args: + width: Raw width value + height: Raw height value + + Returns: + Tuple of sanitized (width, height) + """ + # Clamp to valid range + width = max(64, min(8192, width)) + height = max(64, min(8192, height)) + + # Round to nearest multiple of 8 + width = ((width + 4) // 8) * 8 + height = ((height + 4) // 8) * 8 + + return width, height + + +def get_dimension_info( + preset: str, width: int, height: int, swap_enabled: bool +) -> dict: + """ + Get comprehensive dimension information including metadata. + + Args: + preset: Preset name + width: Width value + height: Height value + swap_enabled: Whether swap is enabled + + Returns: + Dictionary with dimension info and metadata + """ + # Get base dimensions from preset or custom + base_width, base_height = get_preset_dimensions(preset, width, height) + + # Apply swap if enabled + final_width, final_height = apply_swap_logic(base_width, base_height, swap_enabled) + + # Calculate metadata + aspect_ratio = calculate_aspect_ratio(final_width, final_height) + is_valid = validate_dimensions(final_width, final_height) + is_square = final_width == final_height + is_landscape = final_width > final_height + is_portrait = final_height > final_width + + # Calculate megapixels + megapixels = (final_width * final_height) / 1_000_000 + + return { + "width": final_width, + "height": final_height, + "aspect_ratio": aspect_ratio, + "is_valid": is_valid, + "is_square": is_square, + "is_landscape": is_landscape, + "is_portrait": is_portrait, + "megapixels": round(megapixels, 2), + "preset_used": preset, + "swap_applied": swap_enabled, + } + + +def format_dimension_string(width: int, height: int) -> str: + """ + Format dimensions as a readable string. + + Args: + width: Image width + height: Image height + + Returns: + Formatted dimension string (e.g., "1920×1080") + """ + return f"{width}×{height}" + + +def parse_dimension_string(dimension_str: str) -> Tuple[int, int]: + """ + Parse dimension string back to width/height integers. + + Args: + dimension_str: Dimension string (e.g., "1920×1080", "1920x1080") + + Returns: + Tuple of (width, height) as integers + + Raises: + ValueError: If dimension string cannot be parsed + """ + # Handle both × and x separators + if "×" in dimension_str: + parts = dimension_str.split("×") + elif "x" in dimension_str: + parts = dimension_str.split("x") + else: + raise ValueError(f"Invalid dimension string format: {dimension_str}") + + if len(parts) != 2: + raise ValueError(f"Dimension string must have exactly 2 parts: {dimension_str}") + + try: + width = int(parts[0].strip()) + height = int(parts[1].strip()) + return width, height + except ValueError as e: + raise ValueError(f"Could not parse dimensions from {dimension_str}: {e}") + + +def get_optimal_scale_factor( + current_width: int, current_height: int, target_width: int, target_height: int +) -> float: + """ + Calculate optimal scale factor to get from current to target dimensions. + + Args: + current_width: Current image width + current_height: Current image height + target_width: Target width + target_height: Target height + + Returns: + Scale factor as float + """ + if current_width <= 0 or current_height <= 0: + return 1.0 + + width_scale = target_width / current_width + height_scale = target_height / current_height + + # Return the average scale factor + return (width_scale + height_scale) / 2.0 + + +def suggest_similar_presets(width: int, height: int, max_suggestions: int = 3) -> list: + """ + Suggest similar presets based on current dimensions. + + Args: + width: Current width + height: Current height + max_suggestions: Maximum number of suggestions to return + + Returns: + List of preset names sorted by similarity + """ + if width <= 0 or height <= 0: + return [] + + current_ratio = width / height + suggestions = [] + + for preset_name, (preset_width, preset_height) in PRESET_OPTIONS.items(): + if preset_name == "custom": + continue + + preset_ratio = preset_width / preset_height + ratio_diff = abs(current_ratio - preset_ratio) + + suggestions.append((preset_name, ratio_diff)) + + # Sort by similarity (smallest ratio difference first) + suggestions.sort(key=lambda x: x[1]) + + # Return just the preset names + return [preset[0] for preset in suggestions[:max_suggestions]] diff --git a/kikotools/tools/width_height_selector/node.py b/kikotools/tools/width_height_selector/node.py new file mode 100644 index 0000000..631bb97 --- /dev/null +++ b/kikotools/tools/width_height_selector/node.py @@ -0,0 +1,207 @@ +"""Width Height Selector node for ComfyUI.""" + +from typing import Tuple +from ...base.base_node import ComfyAssetsBaseNode +from .logic import ( + get_preset_dimensions, + calculate_aspect_ratio, + validate_dimensions, + sanitize_dimensions, +) +from .presets import ( + PRESET_OPTIONS, + PRESET_DESCRIPTIONS, + get_model_recommendation, +) + + +class WidthHeightSelectorNode(ComfyAssetsBaseNode): + """ + Width Height Selector node for selecting image dimensions. + + Provides preset-based dimension selection with swap functionality, + optimized for SDXL and FLUX models with comprehensive aspect ratio support. + """ + + @classmethod + def INPUT_TYPES(cls): + """Define the input types for the ComfyUI node.""" + # Get all preset options excluding the custom tuple + preset_keys = [key for key in PRESET_OPTIONS.keys()] + + return { + "required": { + "preset": ( + preset_keys, + { + "default": "custom", + "tooltip": "Select from optimized resolution presets or use custom dimensions. " + "SDXL presets are ~1MP, FLUX presets are higher resolution, " + "Ultra-wide presets support modern aspect ratios.", + }, + ), + "width": ( + "INT", + { + "default": 1024, + "min": 64, + "max": 8192, + "step": 8, + "tooltip": "Custom width in pixels (must be multiple of 8). " + "Used when preset is 'custom' or as fallback for invalid presets.", + }, + ), + "height": ( + "INT", + { + "default": 1024, + "min": 64, + "max": 8192, + "step": 8, + "tooltip": "Custom height in pixels (must be multiple of 8). " + "Used when preset is 'custom' or as fallback for invalid presets.", + }, + ), + } + } + + RETURN_TYPES = ("INT", "INT") + RETURN_NAMES = ("width", "height") + FUNCTION = "get_dimensions" + CATEGORY = "ComfyAssets" + + def get_dimensions(self, preset: str, width: int, height: int) -> Tuple[int, int]: + """ + Get width and height dimensions with preset and swap support. + + Args: + preset: Selected preset name or "custom" + width: Custom width value + height: Custom height value + + Returns: + Tuple of (width, height) + """ + try: + # Get base dimensions from preset or custom input + final_width, final_height = get_preset_dimensions(preset, width, height) + + # Sanitize dimensions to ensure they meet ComfyUI requirements + final_width, final_height = sanitize_dimensions(final_width, final_height) + + # Validate final dimensions + if not validate_dimensions(final_width, final_height): + # This should not happen after sanitization, but handle gracefully + self.handle_error( + f"Generated invalid dimensions: {final_width}×{final_height}. " + f"Using fallback dimensions 1024×1024." + ) + final_width, final_height = 1024, 1024 + + return (final_width, final_height) + + except Exception as e: + # Handle any unexpected errors gracefully + error_msg = ( + f"Error processing dimensions: {str(e)}. Using fallback 1024×1024." + ) + self.handle_error(error_msg) + return (1024, 1024) + + def get_preset_info(self, preset: str) -> str: + """ + Get descriptive information about a preset. + + Args: + preset: Preset name + + Returns: + Description string for the preset + """ + if preset == "custom": + return "Custom dimensions - use the width and height inputs below" + + if preset in PRESET_DESCRIPTIONS: + return PRESET_DESCRIPTIONS[preset] + + # Fallback for unknown presets + if preset in PRESET_OPTIONS: + width, height = PRESET_OPTIONS[preset] + aspect_ratio = calculate_aspect_ratio(width, height) + return f"{preset} - {aspect_ratio} aspect ratio" + + return f"Unknown preset: {preset}" + + def get_model_optimization(self, preset: str) -> str: + """ + Get model optimization information for a preset. + + Args: + preset: Preset name + + Returns: + Optimization information string + """ + return get_model_recommendation(preset) + + def validate_inputs(self, preset: str, width: int, height: int) -> bool: + """ + Validate node inputs. + + Args: + preset: Preset name + width: Width value + height: Height value + + Returns: + True if inputs are valid + """ + # Check if preset exists or is custom + if preset != "custom" and preset not in PRESET_OPTIONS: + return False + + # For custom preset, validate dimensions + if preset == "custom": + if not validate_dimensions(width, height): + return False + + return True + + @classmethod + def get_preset_list(cls) -> list: + """ + Get list of available presets for external use. + + Returns: + List of preset names + """ + return list(PRESET_OPTIONS.keys()) + + @classmethod + def get_preset_dimensions_static(cls, preset: str) -> Tuple[int, int]: + """ + Static method to get preset dimensions without node instance. + + Args: + preset: Preset name + + Returns: + Tuple of (width, height) or (0, 0) if invalid + """ + if preset in PRESET_OPTIONS: + return PRESET_OPTIONS[preset] + return (0, 0) + + def __str__(self) -> str: + """String representation of the node.""" + return f"WidthHeightSelectorNode(presets={len(PRESET_OPTIONS)})" + + def __repr__(self) -> str: + """Detailed string representation of the node.""" + return ( + f"WidthHeightSelectorNode(" + f"presets={len(PRESET_OPTIONS)}, " + f"category='{self.CATEGORY}', " + f"function='{self.FUNCTION}'" + f")" + ) diff --git a/kikotools/tools/width_height_selector/presets.py b/kikotools/tools/width_height_selector/presets.py new file mode 100644 index 0000000..a146f22 --- /dev/null +++ b/kikotools/tools/width_height_selector/presets.py @@ -0,0 +1,152 @@ +"""Preset definitions for Width Height Selector.""" + +from typing import Dict, Tuple + +# SDXL optimized presets (~1 megapixel, dimensions divisible by 8) +SDXL_PRESETS: Dict[str, Tuple[int, int]] = { + # Square + "1024×1024": (1024, 1024), # 1:1 - Base SDXL resolution + # Portrait ratios + "896×1152": (896, 1152), # 7:9 - Moderate portrait + "832×1216": (832, 1216), # 13:19 - Standard portrait + "768×1344": (768, 1344), # 4:7 - Tall portrait + "640×1536": (640, 1536), # 5:12 - Very tall portrait + # Landscape ratios + "1152×896": (1152, 896), # 9:7 - Moderate landscape + "1216×832": (1216, 832), # 19:13 - Standard landscape + "1344×768": (1344, 768), # 7:4 - Wide landscape + "1536×640": (1536, 640), # 12:5 - Very wide landscape +} + +# FLUX optimized presets (higher resolution, flexible ratios) +FLUX_PRESETS: Dict[str, Tuple[int, int]] = { + # Recommended high-quality resolutions + "1920×1080": (1920, 1080), # 16:9 - Full HD landscape + "1536×1536": (1536, 1536), # 1:1 - High-res square + "1280×768": (1280, 768), # 5:3 - Wide landscape + "768×1280": (768, 1280), # 3:5 - Tall portrait + # Alternative quality resolutions + "1440×1080": (1440, 1080), # 4:3 - Classic aspect ratio + "1080×1440": (1080, 1440), # 3:4 - Classic portrait + "1728×1152": (1728, 1152), # 3:2 - Photography standard + "1152×1728": (1152, 1728), # 2:3 - Portrait photography +} + +# Ultra-wide and modern aspect ratios +ULTRA_WIDE_PRESETS: Dict[str, Tuple[int, int]] = { + # Ultra-wide landscape (21:9 and variants) + "2560×1080": (2560, 1080), # 64:27 - Ultra-wide gaming + "2048×768": (2048, 768), # 8:3 - Wide cinematic + "1792×768": (1792, 768), # 7:3 - Panoramic + # Ultra-wide portrait + "1080×2560": (1080, 2560), # 27:64 - Mobile ultra-tall + "768×2048": (768, 2048), # 3:8 - Vertical cinematic + "768×1792": (768, 1792), # 3:7 - Vertical panoramic + # Extreme ratios + "2304×768": (2304, 768), # 3:1 - Banner landscape + "768×2304": (768, 2304), # 1:3 - Banner portrait +} + +# Combined preset options for ComfyUI dropdown +PRESET_OPTIONS: Dict[str, Tuple[int, int]] = { + "custom": (0, 0), # Special case for custom dimensions + **SDXL_PRESETS, + **FLUX_PRESETS, + **ULTRA_WIDE_PRESETS, +} + +# Organized preset categories for better UX +PRESET_CATEGORIES = { + "Custom": ["custom"], + "SDXL Square": ["1024×1024"], + "SDXL Portrait": ["896×1152", "832×1216", "768×1344", "640×1536"], + "SDXL Landscape": ["1152×896", "1216×832", "1344×768", "1536×640"], + "FLUX Recommended": ["1920×1080", "1536×1536", "1280×768", "768×1280"], + "FLUX Alternative": ["1440×1080", "1080×1440", "1728×1152", "1152×1728"], + "Ultra-Wide Landscape": ["2560×1080", "2048×768", "1792×768", "2304×768"], + "Ultra-Wide Portrait": ["1080×2560", "768×2048", "768×1792", "768×2304"], +} + +# Preset descriptions for tooltips +PRESET_DESCRIPTIONS = { + # SDXL presets + "1024×1024": "SDXL base resolution - perfect square", + "896×1152": "SDXL portrait 7:9 - moderate portrait", + "832×1216": "SDXL portrait 13:19 - standard portrait", + "768×1344": "SDXL portrait 4:7 - tall portrait", + "640×1536": "SDXL portrait 5:12 - very tall portrait", + "1152×896": "SDXL landscape 9:7 - moderate landscape", + "1216×832": "SDXL landscape 19:13 - standard landscape", + "1344×768": "SDXL landscape 7:4 - wide landscape", + "1536×640": "SDXL landscape 12:5 - very wide landscape", + # FLUX presets + "1920×1080": "FLUX Full HD 16:9 - best quality/speed balance", + "1536×1536": "FLUX high-res square - premium quality", + "1280×768": "FLUX 5:3 landscape - cinematic wide", + "768×1280": "FLUX 3:5 portrait - mobile optimized", + "1440×1080": "FLUX 4:3 classic - traditional aspect ratio", + "1080×1440": "FLUX 3:4 portrait - classic portrait", + "1728×1152": "FLUX 3:2 photo - photography standard", + "1152×1728": "FLUX 2:3 portrait - portrait photography", + # Ultra-wide presets + "2560×1080": "Ultra-wide 64:27 - gaming/panoramic", + "2048×768": "Wide cinematic 8:3 - movie aspect", + "1792×768": "Panoramic 7:3 - landscape vista", + "2304×768": "Banner 3:1 - extreme wide banner", + "1080×2560": "Mobile ultra-tall 27:64 - modern phones", + "768×2048": "Vertical cinematic 3:8 - portrait video", + "768×1792": "Vertical panoramic 3:7 - tall vista", + "768×2304": "Vertical banner 1:3 - extreme tall banner", +} + +# Model-specific recommendations +MODEL_RECOMMENDATIONS = { + "SDXL": list(SDXL_PRESETS.keys()), + "FLUX": list(FLUX_PRESETS.keys()), + "Ultra-Wide": list(ULTRA_WIDE_PRESETS.keys()), +} + + +def get_preset_category(preset_name: str) -> str: + """Get the category for a given preset name.""" + for category, presets in PRESET_CATEGORIES.items(): + if preset_name in presets: + return category + return "Unknown" + + +def get_model_recommendation(preset_name: str) -> str: + """Get model recommendation for a given preset.""" + if preset_name in SDXL_PRESETS: + return "Optimized for SDXL" + elif preset_name in FLUX_PRESETS: + return "Optimized for FLUX" + elif preset_name in ULTRA_WIDE_PRESETS: + return "Modern ultra-wide ratios" + else: + return "Custom dimensions" + + +def validate_preset_dimensions() -> bool: + """Validate that all presets meet ComfyUI requirements.""" + all_presets = {**SDXL_PRESETS, **FLUX_PRESETS, **ULTRA_WIDE_PRESETS} + + for preset_name, (width, height) in all_presets.items(): + # Check divisible by 8 + if width % 8 != 0 or height % 8 != 0: + print( + f"ERROR: {preset_name} dimensions not divisible by 8: {width}×{height}" + ) + return False + + # Check reasonable bounds + if not (64 <= width <= 8192) or not (64 <= height <= 8192): + print(f"ERROR: {preset_name} dimensions out of bounds: {width}×{height}") + return False + + return True + + +# Validate presets on import +if not validate_preset_dimensions(): + raise ValueError("Preset validation failed - check console for details") From 380164765088d0deacc252c3d9db92f72e69e46f Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 14 Jun 2025 10:57:07 -0700 Subject: [PATCH 4/9] docs: Add width height selector documentation --- .../documentation/width_height_selector.md | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 examples/documentation/width_height_selector.md diff --git a/examples/documentation/width_height_selector.md b/examples/documentation/width_height_selector.md new file mode 100644 index 0000000..c5be7c8 --- /dev/null +++ b/examples/documentation/width_height_selector.md @@ -0,0 +1,195 @@ +# Width Height Selector Documentation + +## Overview + +The Width Height Selector is a ComfyUI node that provides preset-based dimension selection with swap functionality, optimized for SDXL and FLUX models. It offers 26 carefully curated resolution presets plus custom dimension support. + +## Features + +### 🎯 **Preset Categories** +- **SDXL Presets** (9): ~1MP optimized resolutions +- **FLUX Presets** (8): Higher resolution options +- **Ultra-Wide Presets** (8): Modern aspect ratios +- **Custom**: Manual dimension input + +### 🔄 **Smart Swap Logic** +- Visual swap button in bottom-right corner +- Intelligent orientation switching for presets +- Simple value swap for custom dimensions +- Maintains proper aspect ratios + +### 🎨 **Enhanced UI** +- Modern blue swap button with hover effects +- Hidden swap parameter (controlled by button) +- Clean interface with visual feedback + +### ✅ **Built-in Validation** +- Ensures divisible-by-8 constraint +- Auto-sanitization of invalid inputs +- Proper error handling and fallbacks + +## Node Interface + +### Inputs +- **preset**: Dropdown with 26 preset options + custom +- **width**: Custom width (64-8192, step 8) +- **height**: Custom height (64-8192, step 8) + +### Outputs +- **width**: Integer width value +- **height**: Integer height value + +## Preset Reference + +### SDXL Presets (~1 Megapixel) +| Preset | Dimensions | Aspect Ratio | Use Case | +|--------|------------|--------------|----------| +| 1024×1024 | 1024×1024 | 1:1 | Square, base SDXL | +| 896×1152 | 896×1152 | 7:9 | Moderate portrait | +| 832×1216 | 832×1216 | 13:19 | Standard portrait | +| 768×1344 | 768×1344 | 4:7 | Tall portrait | +| 640×1536 | 640×1536 | 5:12 | Very tall portrait | +| 1152×896 | 1152×896 | 9:7 | Moderate landscape | +| 1216×832 | 1216×832 | 19:13 | Standard landscape | +| 1344×768 | 1344×768 | 7:4 | Wide landscape | +| 1536×640 | 1536×640 | 12:5 | Very wide landscape | + +### FLUX Presets (High Resolution) +| Preset | Dimensions | Aspect Ratio | Use Case | +|--------|------------|--------------|----------| +| 1920×1080 | 1920×1080 | 16:9 | Full HD, best quality/speed | +| 1536×1536 | 1536×1536 | 1:1 | High-res square | +| 1280×768 | 1280×768 | 5:3 | Cinematic wide | +| 768×1280 | 768×1280 | 3:5 | Mobile optimized | +| 1440×1080 | 1440×1080 | 4:3 | Classic aspect ratio | +| 1080×1440 | 1080×1440 | 3:4 | Classic portrait | +| 1728×1152 | 1728×1152 | 3:2 | Photography standard | +| 1152×1728 | 1152×1728 | 2:3 | Portrait photography | + +### Ultra-Wide Presets (Modern Ratios) +| Preset | Dimensions | Aspect Ratio | Use Case | +|--------|------------|--------------|----------| +| 2560×1080 | 2560×1080 | 64:27 | Ultra-wide gaming | +| 2048×768 | 2048×768 | 8:3 | Wide cinematic | +| 1792×768 | 1792×768 | 7:3 | Panoramic | +| 2304×768 | 2304×768 | 3:1 | Banner landscape | +| 1080×2560 | 1080×2560 | 27:64 | Mobile ultra-tall | +| 768×2048 | 768×2048 | 3:8 | Vertical cinematic | +| 768×1792 | 768×1792 | 3:7 | Vertical panoramic | +| 768×2304 | 768×2304 | 1:3 | Banner portrait | + +## Usage Examples + +### Basic Usage +1. **Select Preset**: Choose from dropdown (e.g., "1920×1080") +2. **Connect Outputs**: Link width/height to your target nodes +3. **Use Swap Button**: Click blue button in bottom-right corner to swap orientation + +### Custom Dimensions +1. **Set Preset**: Select "custom" +2. **Enter Dimensions**: Input width and height manually +3. **Validation**: Automatic sanitization to divisible-by-8 + +### Orientation Swapping +1. **Choose Preset**: Any preset (e.g., "1920×1080") +2. **Click Swap Button**: Blue button in bottom-right corner of node +3. **Result**: Gets 1080×1920 (landscape → portrait) or switches to equivalent preset if available + +### Integration with Resolution Calculator +``` +Width Height Selector → EmptyLatentImage → Resolution Calculator +``` +1. Set base dimensions with Width Height Selector +2. Create latent image with those dimensions +3. Calculate upscaled dimensions with Resolution Calculator + +## Common Workflows + +### SDXL Portrait Generation +``` +Width Height Selector (832×1216) → EmptyLatentImage → SDXL Pipeline +``` +- Perfect for portrait generation +- Optimized ~1MP resolution +- 13:19 aspect ratio + +### FLUX HD Generation +``` +Width Height Selector (1920×1080) → EmptyLatentImage → FLUX Pipeline +``` +- Best quality/speed balance +- Full HD resolution +- 16:9 cinematic aspect ratio + +### Ultra-Wide Panoramic +``` +Width Height Selector (2560×1080) → EmptyLatentImage → Pipeline +``` +- Modern ultra-wide format +- Great for panoramic scenes +- 64:27 gaming aspect ratio + +### Upscaling Workflow +``` +Width Height Selector → EmptyLatentImage → Resolution Calculator → Upscaler +``` +1. Base dimensions from selector +2. Generate at base resolution +3. Calculate upscale dimensions +4. Feed to upscaler node + +## Tips and Best Practices + +### Model Optimization +- **SDXL**: Use SDXL presets for best results (~1MP) +- **FLUX**: Use FLUX presets for optimal quality/speed +- **Custom**: Ensure divisible by 8 for all models + +### Aspect Ratio Considerations +- **Portrait**: 3:4, 2:3, 13:19 work well for people +- **Landscape**: 16:9, 19:13, 7:4 for scenes and objects +- **Square**: 1:1 for centered compositions +- **Ultra-wide**: 21:9+ for panoramic and cinematic shots + +### Memory Management +- Higher resolutions use more VRAM +- SDXL presets are memory-efficient +- FLUX presets require more resources +- Ultra-wide presets need substantial VRAM + +### Workflow Integration +- Always connect width/height outputs +- Use swap button for quick orientation changes +- Combine with Resolution Calculator for upscaling +- Link to EmptyLatentImage for generation + +## Troubleshooting + +### Common Issues +- **Invalid dimensions**: Auto-sanitized to nearest valid values +- **Memory errors**: Use lower resolution presets +- **Orientation wrong**: Use the blue swap button instead of manual input +- **Swap button not visible**: Ensure node is not collapsed + +### Error Handling +- Invalid presets fall back to custom dimensions +- Out-of-range values are clamped and rounded +- Division-by-8 constraint automatically enforced + +## Technical Details + +### Validation Rules +- Width/Height: 64-8192 pixels +- Must be divisible by 8 +- Positive integers only + +### Swap Button Implementation +- JavaScript-based visual interface +- Intelligent preset switching when possible +- Falls back to custom dimensions when needed +- Modern blue styling with hover effects + +### Preset Organization +- Categorized by model optimization +- Sorted by aspect ratio within categories +- Comprehensive tooltips for each preset \ No newline at end of file From 42cc5cf60596f42d9f612beba54c089134e3848e Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 14 Jun 2025 10:57:15 -0700 Subject: [PATCH 5/9] test: Add tests for Width Height Selector tool --- Makefile | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 1a18c59..184a6e9 100644 --- a/Makefile +++ b/Makefile @@ -119,8 +119,10 @@ release-test: quality-check test @test -d examples || (echo "❌ examples directory missing" && exit 1) @echo "✅ Package structure validated" @echo "Checking documentation..." - @test -f examples/documentation/resolution_calculator.md || (echo "❌ Tool documentation missing" && exit 1) - @test -f examples/workflows/resolution_calculator_example.json || (echo "❌ Example workflow missing" && exit 1) + @test -f examples/documentation/resolution_calculator.md || (echo "❌ Resolution Calculator documentation missing" && exit 1) + @test -f examples/workflows/resolution_calculator_example.json || (echo "❌ Resolution Calculator workflow missing" && exit 1) + @test -f examples/documentation/width_height_selector.md || (echo "❌ Width Height Selector documentation missing" && exit 1) + @test -f examples/workflows/width_height_selector_example.json || (echo "❌ Width Height Selector workflow missing" && exit 1) @echo "✅ Documentation validated" @echo "🎉 Release validation completed!" @@ -146,4 +148,32 @@ ci: quality-check test # Tool-specific commands (can be extended for new tools) test-resolution-calculator: @echo "Testing Resolution Calculator specifically..." - @python -c "import sys, os; sys.path.insert(0, os.getcwd()); from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode; import torch; node = ResolutionCalculatorNode(); scenarios = [('SDXL Portrait', torch.randn(1, 1216, 832, 3), 1.5), ('FLUX Square', torch.randn(1, 1024, 1024, 3), 2.0), ('User Scenario', torch.randn(1, 1216, 832, 3), 1.53)]; [print(f'✅ {name}: {image.shape[2]}×{image.shape[1]} → {node.calculate_resolution(scale, image=image)[0]}×{node.calculate_resolution(scale, image=image)[1]} ({scale}x)') for name, image, scale in scenarios]; print('🎉 Resolution Calculator tests completed!')" \ No newline at end of file + @python -c "import sys, os; sys.path.insert(0, os.getcwd()); from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode; import torch; node = ResolutionCalculatorNode(); scenarios = [('SDXL Portrait', torch.randn(1, 1216, 832, 3), 1.5), ('FLUX Square', torch.randn(1, 1024, 1024, 3), 2.0), ('User Scenario', torch.randn(1, 1216, 832, 3), 1.53)]; [print(f'✅ {name}: {image.shape[2]}×{image.shape[1]} → {node.calculate_resolution(scale, image=image)[0]}×{node.calculate_resolution(scale, image=image)[1]} ({scale}x)') for name, image, scale in scenarios]; print('🎉 Resolution Calculator tests completed!')" + +test-width-height-selector: + @echo "Testing Width Height Selector specifically..." + @python -c "\ + import sys, os; \ + sys.path.insert(0, os.getcwd()); \ + from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode; \ + from kikotools.tools.width_height_selector.presets import PRESET_OPTIONS; \ + print(f'Testing Width Height Selector with {len(PRESET_OPTIONS)} presets...'); \ + node = WidthHeightSelectorNode(); \ + scenarios = [ \ + ('SDXL Square', '1024×1024'), \ + ('FLUX HD', '1920×1080'), \ + ('SDXL Portrait', '832×1216'), \ + ('Custom Dimensions', 'custom'), \ + ('Ultra-Wide', '2560×1080') \ + ]; \ + for name, preset in scenarios: \ + if preset == 'custom': \ + result = node.get_dimensions(preset, 1536, 768); \ + else: \ + result = node.get_dimensions(preset, 1024, 1024); \ + print(f'✅ {name}: {preset} → {result[0]}×{result[1]}'); \ + print('🎉 Width Height Selector tests completed!') \ + " + +test-all-tools: test-resolution-calculator test-width-height-selector + @echo "🎉 All tool-specific tests completed!" \ No newline at end of file From a697ee26c4684d590591e5138429c6daa888df60 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 14 Jun 2025 10:57:24 -0700 Subject: [PATCH 6/9] docs: add Width Height Selector section to README.md --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c4e5db4..ebba0d0 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,21 @@ Calculate upscaled dimensions from image or latent inputs with precision. - Ensure ComfyUI tensor compatibility - Optimize batch processing workflows +#### 📏 Width Height Selector +Advanced preset-based dimension selection with visual swap button. + +- **26 Curated Presets**: SDXL, FLUX, and Ultra-Wide optimized resolutions +- **Smart Categories**: Organized by model type and aspect ratio +- **Visual Swap Button**: Modern blue button for quick orientation changes +- **Intelligent Swapping**: Preset-aware orientation switching +- **Custom Support**: Manual dimension input with validation + +**Use Cases:** +- Quick dimension selection for different models +- Consistent aspect ratios across workflows +- Mobile and ultra-wide format support +- Integration with upscaling pipelines + ### 🔧 Architecture Highlights - **Modular Design**: Each tool is self-contained and independently testable @@ -70,6 +85,18 @@ Image Loader → Resolution Calculator → Upscaler **Scale:** 1.5x **Output:** 1248×1824 (ready for upscaling) +### Width Height Selector Example + +``` +Width Height Selector → EmptyLatentImage → Model +preset: "1920×1080" ↘ 1920×1080 ↗ +[swap button] +``` + +**Preset:** FLUX HD (1920×1080) +**Output:** 1920×1080 (16:9 cinematic) +**Swap Button:** Click to get 1080×1920 (9:16 portrait) + ### Common Workflows
@@ -107,9 +134,9 @@ Image Loader → Resolution Calculator → Upscaler | Tool | Description | Status | Documentation | |------|-------------|--------|---------------| | **Resolution Calculator** | Calculate upscaled dimensions with model optimization | ✅ Complete | [Docs](examples/documentation/resolution_calculator.md) | +| **Width Height Selector** | Preset-based dimension selection with 26 curated options | ✅ Complete | [Docs](examples/documentation/width_height_selector.md) | | **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon | | **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon | -| **Model Management Tools** | Efficient model loading and memory management | 🚧 Planned | Coming Soon | ### Technical Specifications @@ -130,6 +157,27 @@ Image Loader → Resolution Calculator → Upscaler - Validates input tensors - Graceful error handling +#### Width Height Selector + +**Inputs:** +- `preset` (DROPDOWN): 26 preset options + custom +- `width` (INT): 64-8192, step 8, default 1024 +- `height` (INT): 64-8192, step 8, default 1024 + +**Outputs:** +- `width` (INT): Selected or calculated width +- `height` (INT): Selected or calculated height + +**UI Features:** +- Visual blue swap button in bottom-right corner +- Intelligent preset switching when swapping +- Modern hover effects and cursor feedback + +**Preset Categories:** +- SDXL Presets (9): 1024×1024 to 1536×640 (~1MP optimized) +- FLUX Presets (8): 1920×1080 to 1152×1728 (high resolution) +- Ultra-Wide (8): 2560×1080 to 768×2304 (modern ratios) + ## 🛠️ Development ### Prerequisites @@ -250,7 +298,8 @@ MIT License - see [LICENSE](LICENSE) file for details. ## 📈 Stats -- **Nodes**: 1 (Resolution Calculator) +- **Nodes**: 2 (Resolution Calculator, Width Height Selector) +- **Presets**: 26 curated resolution presets - **Test Coverage**: 100% - **Python Version**: 3.8+ - **ComfyUI Compatibility**: Latest From 57df858ed142275ec4dc4b034d7252017354b55a Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 14 Jun 2025 10:57:34 -0700 Subject: [PATCH 7/9] feat: Add startup message and WEB_DIRECTORY variable --- __init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/__init__.py b/__init__.py index 23e314d..47e035c 100644 --- a/__init__.py +++ b/__init__.py @@ -5,4 +5,10 @@ All nodes are grouped under the "ComfyAssets" category from .kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] +# Tell ComfyUI where to find our JavaScript extensions +WEB_DIRECTORY = "./web" + +# Print startup message +print("\033[94m[ComfyUI-KikoTools] Loaded with swap button support!\033[0m") + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] From b04077ec0073897e3a9c940c7dd1a851c425ad4a Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 14 Jun 2025 10:57:47 -0700 Subject: [PATCH 8/9] feat(init): add WidthHeightSelector node --- kikotools/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/kikotools/__init__.py b/kikotools/__init__.py index 3982143..0314614 100644 --- a/kikotools/__init__.py +++ b/kikotools/__init__.py @@ -4,14 +4,17 @@ Handles automatic discovery and registration of all ComfyAssets tools """ from .tools.resolution_calculator import ResolutionCalculatorNode +from .tools.width_height_selector import WidthHeightSelectorNode # ComfyUI node registration mappings NODE_CLASS_MAPPINGS = { "ResolutionCalculator": ResolutionCalculatorNode, + "WidthHeightSelector": WidthHeightSelectorNode, } NODE_DISPLAY_NAME_MAPPINGS = { "ResolutionCalculator": "Resolution Calculator", + "WidthHeightSelector": "Width Height Selector", } __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] From 6a1ad75451671f2595dfbc3f6b75a11ad3da52a2 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 14 Jun 2025 10:59:02 -0700 Subject: [PATCH 9/9] feat(examples): add WidthHeightSelector example JSON --- .../width_height_selector_example.json | 422 ++++++++++++++++++ 1 file changed, 422 insertions(+) create mode 100644 examples/workflows/width_height_selector_example.json diff --git a/examples/workflows/width_height_selector_example.json b/examples/workflows/width_height_selector_example.json new file mode 100644 index 0000000..750320d --- /dev/null +++ b/examples/workflows/width_height_selector_example.json @@ -0,0 +1,422 @@ +{ + "last_node_id": 12, + "last_link_id": 12, + "nodes": [ + { + "id": 1, + "type": "WidthHeightSelector", + "pos": [100, 100], + "size": [300, 180], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "width", + "type": "INT", + "links": [1, 7], + "shape": 3, + "slot_index": 0 + }, + { + "name": "height", + "type": "INT", + "links": [2, 8], + "shape": 3, + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "WidthHeightSelector" + }, + "widgets_values": [ + "1920×1080", + 1024, + 1024 + ], + "title": "Width Height Selector (FLUX HD)" + }, + { + "id": 2, + "type": "WidthHeightSelector", + "pos": [100, 350], + "size": [300, 180], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "width", + "type": "INT", + "links": [3, 9], + "shape": 3, + "slot_index": 0 + }, + { + "name": "height", + "type": "INT", + "links": [4, 10], + "shape": 3, + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "WidthHeightSelector" + }, + "widgets_values": [ + "832×1216", + 1024, + 1024 + ], + "title": "Width Height Selector (SDXL Portrait) - Use swap button!" + }, + { + "id": 3, + "type": "WidthHeightSelector", + "pos": [100, 600], + "size": [300, 180], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "width", + "type": "INT", + "links": [5, 11], + "shape": 3, + "slot_index": 0 + }, + { + "name": "height", + "type": "INT", + "links": [6, 12], + "shape": 3, + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "WidthHeightSelector" + }, + "widgets_values": [ + "custom", + 1536, + 768 + ], + "title": "Width Height Selector (Custom 2:1)" + }, + { + "id": 4, + "type": "EmptyLatentImage", + "pos": [500, 100], + "size": [300, 100], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "width", + "type": "INT", + "link": 1, + "widget": { + "name": "width" + } + }, + { + "name": "height", + "type": "INT", + "link": 2, + "widget": { + "name": "height" + } + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [], + "shape": 3, + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 1920, + 1080, + 1 + ], + "title": "FLUX HD Latent (1920×1080)" + }, + { + "id": 5, + "type": "EmptyLatentImage", + "pos": [500, 350], + "size": [300, 100], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "width", + "type": "INT", + "link": 3, + "widget": { + "name": "width" + } + }, + { + "name": "height", + "type": "INT", + "link": 4, + "widget": { + "name": "height" + } + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [], + "shape": 3, + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 832, + 1216, + 1 + ], + "title": "SDXL Portrait Latent (832×1216)" + }, + { + "id": 6, + "type": "EmptyLatentImage", + "pos": [500, 600], + "size": [300, 100], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "width", + "type": "INT", + "link": 5, + "widget": { + "name": "width" + } + }, + { + "name": "height", + "type": "INT", + "link": 6, + "widget": { + "name": "height" + } + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [], + "shape": 3, + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 1536, + 768, + 1 + ], + "title": "Custom Latent (1536×768)" + }, + { + "id": 7, + "type": "ResolutionCalculator", + "pos": [900, 100], + "size": [300, 150], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": null + } + ], + "outputs": [ + { + "name": "width", + "type": "INT", + "links": [], + "shape": 3, + "slot_index": 0 + }, + { + "name": "height", + "type": "INT", + "links": [], + "shape": 3, + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "ResolutionCalculator" + }, + "widgets_values": [ + 1.5 + ], + "title": "Resolution Calculator Demo (1.5x)" + }, + { + "id": 8, + "type": "ShowText", + "pos": [900, 350], + "size": [300, 100], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": null, + "widget": { + "name": "text" + } + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "ShowText" + }, + "widgets_values": [ + "Width Height Selector outputs clean INT values ready for any ComfyUI node" + ], + "title": "Usage Note" + }, + { + "id": 9, + "type": "ShowText", + "pos": [900, 500], + "size": [300, 150], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": null, + "widget": { + "name": "text" + } + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "ShowText" + }, + "widgets_values": [ + "Instructions:\n1. Select presets from dropdown\n2. Use blue swap button (bottom-right) to change orientation\n3. Choose 'custom' for manual dimensions\n4. Connect width/height to any node needing dimensions" + ], + "title": "How to Use" + }, + { + "id": 10, + "type": "PrimitiveNode", + "pos": [900, 700], + "size": [300, 100], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "links": [7, 8, 9, 10, 11, 12], + "shape": 3, + "slot_index": 0, + "widget": { + "name": "value" + } + } + ], + "properties": { + "Node name for S&R": "PrimitiveNode" + }, + "widgets_values": [ + 1024 + ], + "title": "Connected Outputs Demo" + } + ], + "links": [ + [1, 1, 0, 4, 0, "INT"], + [2, 1, 1, 4, 1, "INT"], + [3, 2, 0, 5, 0, "INT"], + [4, 2, 1, 5, 1, "INT"], + [5, 3, 0, 6, 0, "INT"], + [6, 3, 1, 6, 1, "INT"], + [7, 1, 0, 10, 0, "INT"], + [8, 1, 1, 10, 0, "INT"], + [9, 2, 0, 10, 0, "INT"], + [10, 2, 1, 10, 0, "INT"], + [11, 3, 0, 10, 0, "INT"], + [12, 3, 1, 10, 0, "INT"] + ], + "groups": [ + { + "title": "Width Height Selector Examples", + "bounding": [80, 40, 360, 780], + "color": "#3f789e", + "font_size": 24, + "locked": false + }, + { + "title": "Example Usage in Workflows", + "bounding": [480, 40, 360, 280], + "color": "#8A5A2B", + "font_size": 24, + "locked": false + }, + { + "title": "Documentation & Demos", + "bounding": [880, 40, 360, 780], + "color": "#8B5A8C", + "font_size": 24, + "locked": false + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [0, 0] + }, + "groupNodes": {}, + "info": { + "name": "width_height_selector_example", + "author": "ComfyUI-KikoTools", + "description": "Demonstrates Width Height Selector with swap button functionality and various preset options. Shows integration with EmptyLatentImage and other nodes.", + "version": "2.0", + "created": "2024-12-14", + "modified": "2024-12-14" + } + }, + "version": 0.4 +} \ No newline at end of file