diff --git a/.gitignore b/.gitignore index a35ca56..6ecd235 100644 --- a/.gitignore +++ b/.gitignore @@ -119,3 +119,6 @@ venv.bak/ Thumbs.db ehthumbs.db Desktop.ini + +# Claude Code files +.claude/ diff --git a/README.md b/README.md index 8c4378e..5064c80 100755 --- a/README.md +++ b/README.md @@ -1,43 +1,72 @@ -# ResolutionSelector for ComfyUI +# Resolution Selector for ComfyUI -A custom node for Stable Diffusion [ComfyUI](https://github.com/comfyanonymous/ComfyUI) to enable easy selection of image resolutions for SDXL SD15 SD21 +Custom node for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) providing easy resolution and empty latent selection for image generation workflows. -- Select base SDXL resolution, width and height are returned as `INT` values which can be connected to latent image inputs or other inputs such as the `CLIPTextEncodeSDXL` `width, height, target_width, target_height`. -- Resolution list based off what is currently being used in the [Fooocus SDXL Web UI](https://github.com/lllyasviel/Fooocus). -- If using older models such as SD 1.5 or SD 2.1 use the `base_adjustment` dropdown. This will reduce the returned width and height values to suit the selected model whilst maintaining the image aspect ratio. +![Node Preview](node_preview.png) -```terminal -# Example -SDXL base_resolution 1024x1024 -SD21 adjustment returns 768x768 -SD15 adjustment returns 512x512 -``` +## Features +### Resolution Selector +- **Model-optimised resolution presets** (portrait, landscape, square) +- **Empty latent output** for direct KSampler connection +- **Custom width/height inputs** with **independent multiplier and latent** -### Installation +## Installation -``` +```bash # Change to the directory you installed ComfyUI cd pathTo/ComfyUI -# Change to the custom_nodes directory ie. +# Change to the custom_nodes directory cd custom_nodes -``` -```terminal # Clone the repo into custom_nodes git clone https://github.com/bradsec/ComfyUI_ResolutionSelector.git # Restart ComfyUI ``` -### Usage after install -`Add Node > utils > Resolution Selector` - -![node_example](resolution_selector_node.png) +## Usage -#### SDXL hookup example -![sdxl_hookup](sdxl_hookup.png) +### Resolution Selector + +**Add Node:** `Add Node > utils > Resolution Selector` + +**Inputs:** +- `model` (dropdown) - Select your image generation model or "All" for all resolutions +- `resolution` (dropdown) - Choose from model-optimized presets with aspect ratios (e.g., "1920x1080 (16:9 Landscape)") +- `resolution_multiplier` (dropdown) - Multiply preset resolution (1x, 2x, 3x, 4x) +- `custom_width` (optional) - Override with custom width (0-4096, step 8) +- `custom_height` (optional) - Override with custom height (0-4096, step 8) +- `custom_multiplier` (optional) - Independent multiplier for custom dimensions (1x, 2x, 3x, 4x) + +**Outputs:** +- `width` (INT) - Preset resolution width in pixels +- `height` (INT) - Preset resolution height in pixels +- `latent` (LATENT) - Empty latent tensor for preset resolution +- `custom_width` (INT) - Custom width (0 if not set) +- `custom_height` (INT) - Custom height (0 if not set) +- `custom_latent` (LATENT) - Empty latent tensor for custom resolution + +**Example Workflows:** + +**Basic preset with multiplier:** +``` +Resolution Selector (model: SDXL, resolution: "1024x1024 (1:1 Square)", multiplier: 2x) + → width: 2048, height: 2048 + → latent → KSampler +``` + +**Custom dimensions with independent multiplier:** +``` +Resolution Selector (custom_width: 1024, custom_height: 768, custom_multiplier: 2x) + → custom_width: 2048, custom_height: 1536 + → custom_latent → KSampler +``` + +**Using "All" model option:** +``` +Resolution Selector (model: All, resolution: "1920x1080 (16:9 Landscape)") + → Shows all 34 unique resolutions from all models +``` -#### SD15 hookup example -![sd15_hookup](sd15_hookup.png) \ No newline at end of file diff --git a/__init__.py b/__init__.py index f5a9097..1210d0b 100644 --- a/__init__.py +++ b/__init__.py @@ -1,3 +1,5 @@ from .resolution_selector import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS -__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] \ No newline at end of file +WEB_DIRECTORY = "./web" + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] \ No newline at end of file diff --git a/node_preview.png b/node_preview.png new file mode 100644 index 0000000..155d13b Binary files /dev/null and b/node_preview.png differ diff --git a/pyproject.toml b/pyproject.toml index 466093c..b9c9795 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "comfyui_resolutionselector" -description = "A custom node for Stable Diffusion ComfyUI to enable easy selection of image resolutions for SDXL SD15 SD21" -version = "1.0.0" +name = "comfyui_resolutionselector"" +description = "Custom node for ComfyUI providing easy resolution and empty latent selection for image generation workflows." +version = "2.0.0" license = "MIT" [project.urls] diff --git a/resolution_selector.py b/resolution_selector.py index adf40ce..3fc3dd0 100755 --- a/resolution_selector.py +++ b/resolution_selector.py @@ -1,91 +1,431 @@ -# Resolution presets -BASE_RESOLUTIONS = [ - (1024, 1024), - (704, 1408), - (704, 1344), - (768, 1344), - (768, 1280), - (832, 1216), - (832, 1152), - (896, 1152), - (896, 1088), - (960, 1088), - (960, 1024), - (1024, 960), - (1088, 960), - (1088, 896), - (1152, 896), - (1152, 832), - (1216, 832), - (1280, 768), - (1344, 768), - (1344, 704), - (1408, 704), - (1472, 704), - (1536, 640), - (1600, 640), - (1664, 576), - (1728, 576) -] +import torch + +try: + import comfy.model_management + COMFY_AVAILABLE = True +except ImportError: + COMFY_AVAILABLE = False + + +# Model-specific resolution presets with constraints +MODEL_RESOLUTIONS = { + "Flux": { + "square": [(512, 512), (768, 768), (1024, 1024), (1280, 1280), (1536, 1536), (1920, 1920), (2048, 2048)], + "portrait": [(768, 1344), (832, 1216), (896, 1152), (928, 1664), (1024, 1536), (1024, 1792), (1080, 1920), (1152, 2048)], + "landscape": [(1344, 768), (1216, 832), (1152, 896), (1536, 1024), (1664, 928), (1792, 1024), (1920, 1080), (2048, 1152)], + "constraints": {"divisible_by": 16, "min": 256, "max": 2048} + }, + "Qwen Image": { + "square": [(1024, 1024), (1280, 1280), (1328, 1328), (1536, 1536), (1920, 1920), (2048, 2048)], + "portrait": [(928, 1664), (1024, 1536), (1080, 1920), (1140, 1472), (1152, 2048)], + "landscape": [(1472, 1140), (1536, 1024), (1664, 928), (1920, 1080), (2048, 1152)], + "constraints": {"divisible_by": 8, "min": 256, "max": 2048} + }, + "Z-Image": { + "square": [(512, 512), (768, 768), (1024, 1024), (1280, 1280), (1536, 1536), (1920, 1920), (2048, 2048)], + "portrait": [(720, 1280), (768, 1024), (1080, 1920), (1152, 2048)], + "landscape": [(1024, 768), (1280, 720), (1920, 1080), (2048, 1152)], + "constraints": {"divisible_by": 8, "min": 256, "max": 2048} + }, + "SD 1.5": { + "square": [(512, 512), (768, 768), (1024, 1024), (1280, 1280), (1536, 1536)], + "portrait": [(512, 768), (512, 682), (512, 1024), (768, 1024), (768, 1344), (1080, 1920)], + "landscape": [(768, 512), (1024, 512), (1024, 768), (1344, 768), (1536, 512), (1920, 1080)], + "constraints": {"divisible_by": 8, "min": 256, "max": 2048} + }, + "SDXL": { + "square": [(1024, 1024), (1280, 1280), (1536, 1536), (1920, 1920), (2048, 2048)], + "portrait": [(640, 1536), (768, 1344), (832, 1216), (896, 1152), (1024, 1536), (1080, 1920), (1152, 2048)], + "landscape": [(1152, 896), (1216, 832), (1344, 768), (1536, 640), (1536, 1024), (1920, 1080), (2048, 1152)], + "constraints": {"divisible_by": 8, "min": 256, "max": 2048} + } +} + + +def gcd(a, b): + """ + Calculate greatest common divisor using Euclidean algorithm. + + Args: + a (int): First number + b (int): Second number + + Returns: + int: Greatest common divisor + """ + while b != 0: + a, b = b, a % b + return a + + +def calculate_aspect_ratio(width, height): + """ + Calculate simplified aspect ratio from dimensions, rounding to common ratios. + + Args: + width (int): Width in pixels + height (int): Height in pixels + + Returns: + str: Aspect ratio like "16:9" or "1:1" + """ + # Calculate actual ratio as decimal + actual_ratio = width / height + + # Common aspect ratios (ratio_value, "width:height" string) + common_ratios = [ + (1.0, "1:1"), # Square + (1.25, "5:4"), # 1.25 + (1.33, "4:3"), # 1.333... + (1.5, "3:2"), # 1.5 + (1.6, "16:10"), # 1.6 + (1.78, "16:9"), # 1.777... + (2.0, "2:1"), # 2.0 + (2.35, "21:9"), # 2.333... (ultrawide) + (2.4, "12:5"), # 2.4 + (3.0, "3:1"), # 3.0 + # Portrait ratios + (0.75, "3:4"), # 0.75 + (0.67, "2:3"), # 0.666... + (0.625, "5:8"), # 0.625 + (0.56, "9:16"), # 0.5625 + (0.5, "1:2"), # 0.5 + (0.42, "5:12"), # 0.4166... + ] + + # Find closest common ratio (within 5% tolerance) + tolerance = 0.05 + for ratio_value, ratio_str in common_ratios: + if abs(actual_ratio - ratio_value) / ratio_value < tolerance: + return ratio_str + + # If no close match, calculate GCD-based ratio + divisor = gcd(width, height) + ratio_width = width // divisor + ratio_height = height // divisor + return f"{ratio_width}:{ratio_height}" + + +def format_resolution(width, height): + """ + Format resolution tuple into display string with aspect ratio and orientation. + Uses fixed-width formatting for better alignment in dropdowns. + + Args: + width (int): Width in pixels + height (int): Height in pixels + + Returns: + str: Formatted string like "1920x1080 (16:9 Landscape)" with consistent spacing + """ + aspect_ratio = calculate_aspect_ratio(width, height) + + if width == height: + orientation = "Square" + elif width < height: + orientation = "Portrait" + else: + orientation = "Landscape" + + # Format with fixed width for better alignment (e.g., "1920x1080 ") + # Most resolutions are 4 digits, so we pad to 9 characters (4x4 + 'x') + resolution_str = f"{width}x{height}" + padded_resolution = resolution_str.ljust(13) # Pad to 13 chars for alignment + + return f"{padded_resolution}({aspect_ratio} {orientation})" + + +def get_resolution_list(model_name): + """ + Generate ordered list of resolution strings for a specific model. + + Args: + model_name (str): Name of the model (or "All" for all unique resolutions) + + Returns: + list: Formatted resolution strings in order: square, portrait, landscape + """ + if model_name == "All": + return get_all_resolutions() + + if model_name not in MODEL_RESOLUTIONS: + return [] + + model_data = MODEL_RESOLUTIONS[model_name] + resolutions = [] + + # Order: square first, then portrait, then landscape + for category in ["square", "portrait", "landscape"]: + for width, height in model_data[category]: + resolutions.append(format_resolution(width, height)) + + return resolutions + + +def get_default_resolution(model_name): + """ + Get the default/native resolution for a specific model. + + Args: + model_name (str): Name of the model + + Returns: + str: Formatted resolution string for the model's native resolution + """ + # Model-specific native/optimal resolutions + default_resolutions = { + "Flux": (1024, 1024), # Flux native + "Qwen Image": (1328, 1328), # Qwen native + "Z-Image": (1024, 1024), # Z-Image native + "SD 1.5": (512, 512), # SD 1.5 native + "SDXL": (1024, 1024), # SDXL native + "All": (1024, 1024), # Default for "All" + } + + if model_name in default_resolutions: + width, height = default_resolutions[model_name] + return format_resolution(width, height) + + # Fallback to 1024x1024 + return format_resolution(1024, 1024) + + +def get_all_resolutions(): + """ + Get all unique resolution strings across all models, sorted by dimensions. + + Returns: + list: All unique resolution strings sorted by total pixels + """ + unique_resolutions = {} + + # Collect all unique width×height pairs + for model_name in MODEL_RESOLUTIONS.keys(): + model_data = MODEL_RESOLUTIONS[model_name] + for category in ["square", "portrait", "landscape"]: + for width, height in model_data[category]: + key = (width, height) + if key not in unique_resolutions: + unique_resolutions[key] = format_resolution(width, height) + + # Sort by total pixels, then by width + sorted_resolutions = sorted( + unique_resolutions.items(), + key=lambda item: (item[0][0] * item[0][1], item[0][0]) + ) + + return [res_str for _, res_str in sorted_resolutions] + + +def parse_resolution_string(resolution_str): + """ + Parse formatted resolution string back to width, height integers. + + Args: + resolution_str (str): Format "1920x1080 (16:9 Landscape)" with possible padding + + Returns: + tuple: (width, height) + + Raises: + ValueError: If string format is invalid + """ + try: + # Format: "1920x1080 (16:9 Landscape)" - may have padding spaces + # Extract the dimension part (before the opening parenthesis) + dimension_part = resolution_str.split("(")[0].strip() + + if not dimension_part or 'x' not in dimension_part: + raise ValueError(f"No dimension part found in: {resolution_str}") + + # Split on 'x' and convert to integers + width, height = map(int, dimension_part.split("x")) + + return (width, height) + except (ValueError, IndexError) as e: + raise ValueError(f"Invalid resolution format: {resolution_str}") class ResolutionSelector: """ - A node to provide a drop-down list of resolutions and returns two int values (width and height). + Enhanced resolution selector supporting multiple image generation models. + Provides model-specific resolution presets, custom dimension inputs, and empty latent output. """ def __init__(self): - pass + """Initialize device for latent tensor generation.""" + if COMFY_AVAILABLE: + self.device = comfy.model_management.intermediate_device() + else: + self.device = torch.device("cpu") @classmethod def INPUT_TYPES(cls): """ Return a dictionary which contains config for all input fields. - """ - # Create a list of resolution strings for the drop-down menu - resolution_strings = [ - f"{width} x {height}" for width, height in BASE_RESOLUTIONS] + Returns: + dict: Input configuration with required and optional fields + """ + model_list = ["All"] + list(MODEL_RESOLUTIONS.keys()) + + # Get all possible resolutions across all models (JavaScript will filter dynamically) + all_resolutions = get_all_resolutions() return { "required": { - "base_resolution": (resolution_strings,), - "base_adjustment": (["SDXL (None)", "SD21 (75%)", "SD15 (50%)"],), + "model": (model_list, { + "default": "SDXL" + }), + "resolution": (all_resolutions, { + "default": "1024x1024 (1:1 Square)" + }), + "resolution_multiplier": (["1x", "2x", "3x", "4x"], { + "default": "1x" + }), + }, + "optional": { + "custom_width": ("INT", { + "default": 0, + "min": 0, + "max": 4096, + "step": 8, + "display": "number" + }), + "custom_height": ("INT", { + "default": 0, + "min": 0, + "max": 4096, + "step": 8, + "display": "number" + }), + "custom_multiplier": (["1x", "2x", "3x", "4x"], { + "default": "1x" + }), } } - RETURN_TYPES = ("INT", "INT") - RETURN_NAMES = ("width", "height") + RETURN_TYPES = ("INT", "INT", "LATENT", "INT", "INT", "LATENT") + RETURN_NAMES = ("width", "height", "latent", "custom_width", "custom_height", "custom_latent") FUNCTION = "select_resolution" - CATEGORY = 'utils' + CATEGORY = "utils" - def select_resolution(self, base_resolution, base_adjustment): + def select_resolution(self, model, resolution, resolution_multiplier="1x", custom_width=0, custom_height=0, custom_multiplier="1x"): """ - Returns the width and height based on the selected resolution and adjustment. + Select and validate resolution, generate outputs. Args: - base_resolution (str): Selected resolution in the format "width x height". - base_adjustment (str): Selected adjustment (resolution value reduction) based on SD version. + model (str): Selected model name + resolution (str): Selected preset resolution string + resolution_multiplier (str): Multiplier for resolution (1x-4x) + custom_width (int, optional): Custom width override + custom_height (int, optional): Custom height override + custom_multiplier (str, optional): Multiplier for custom dimensions (1x-4x) Returns: - Tuple[int, int]: Adjusted width and height. + tuple: (width: int, height: int, latent: dict, custom_width: int, custom_height: int, custom_latent: dict) """ - try: - width, height = map(int, base_resolution.split(' x ')) - except ValueError: - raise ValueError("Invalid base_resolution format.") + # Parse multipliers (e.g., "2x" -> 2) + multiplier = int(resolution_multiplier.replace("x", "")) + custom_mult = int(custom_multiplier.replace("x", "")) - adjustment_factors = { - "SDXL (None)": 1, "SD21 (75%)": 0.75, "SD15 (50%)": 0.5} - factor = adjustment_factors.get(base_adjustment) + # Parse preset resolution + width, height = parse_resolution_string(resolution) - if factor is None: - raise ValueError("Invalid base_adjustment value.") + # Apply multiplier to preset resolution + width *= multiplier + height *= multiplier - width = int(width * factor) - height = int(height * factor) + # Generate latent for preset resolution + latent = self._generate_empty_latent(width, height) - return width, height + # Determine final custom dimensions + if custom_width > 0 and custom_height > 0: + # Use custom dimensions with custom multiplier + final_custom_width = custom_width * custom_mult + final_custom_height = custom_height * custom_mult + + # Validate against model constraints (if not "All" model) + if model != "All": + self._validate_dimensions(model, final_custom_width, final_custom_height) + + # Generate custom latent + custom_latent = self._generate_empty_latent(final_custom_width, final_custom_height) + + return (width, height, latent, final_custom_width, final_custom_height, custom_latent) + else: + # No custom dimensions, return zeros and minimal empty custom latent + custom_latent = self._generate_empty_latent(1, 1) + + return (width, height, latent, 0, 0, custom_latent) + + def _validate_dimensions(self, model, width, height): + """ + Validate dimensions against model-specific constraints. + + Args: + model (str): Model name + width (int): Width in pixels + height (int): Height in pixels + + Raises: + ValueError: If dimensions violate model constraints + """ + if model not in MODEL_RESOLUTIONS: + return + + constraints = MODEL_RESOLUTIONS[model]["constraints"] + divisible_by = constraints.get("divisible_by", 8) + min_dim = constraints.get("min", 64) + max_dim = constraints.get("max", 4096) + + # Check divisibility + if width % divisible_by != 0: + raise ValueError( + f"{model} requires width divisible by {divisible_by}. " + f"Got {width} (remainder: {width % divisible_by})" + ) + + if height % divisible_by != 0: + raise ValueError( + f"{model} requires height divisible by {divisible_by}. " + f"Got {height} (remainder: {height % divisible_by})" + ) + + # Check bounds + if width < min_dim or width > max_dim: + raise ValueError( + f"{model} requires width between {min_dim} and {max_dim}. Got {width}" + ) + + if height < min_dim or height > max_dim: + raise ValueError( + f"{model} requires height between {min_dim} and {max_dim}. Got {height}" + ) + + def _generate_empty_latent(self, width, height, batch_size=1): + """ + Generate empty latent tensor for VAE input. + + Args: + width (int): Image width in pixels + height (int): Image height in pixels + batch_size (int): Number of latent samples (default: 1) + + Returns: + dict: LATENT dict with 'samples' key containing empty tensor + """ + # Latent space is 1/8 the image dimensions for SD-based models + latent_width = width // 8 + latent_height = height // 8 + + # Shape: [batch_size, channels=4, height//8, width//8] + latent_tensor = torch.zeros( + [batch_size, 4, latent_height, latent_width], + device=self.device + ) + + return {"samples": latent_tensor} NODE_CLASS_MAPPINGS = { diff --git a/resolution_selector_node.png b/resolution_selector_node.png deleted file mode 100644 index eacb43a..0000000 Binary files a/resolution_selector_node.png and /dev/null differ diff --git a/sd15_hookup.png b/sd15_hookup.png deleted file mode 100644 index 81a542f..0000000 Binary files a/sd15_hookup.png and /dev/null differ diff --git a/sdxl_hookup.png b/sdxl_hookup.png deleted file mode 100644 index 1d18eda..0000000 Binary files a/sdxl_hookup.png and /dev/null differ diff --git a/test_resolution_selector.py b/test_resolution_selector.py new file mode 100644 index 0000000..78a08b9 --- /dev/null +++ b/test_resolution_selector.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +""" +Test script for ResolutionSelector enhancements +""" + +import sys +sys.path.insert(0, '.') + +# Mock torch module for testing without ComfyUI environment +class MockTorch: + class device: + def __init__(self, name): + self.name = name + +sys.modules['torch'] = MockTorch() +sys.modules['comfy'] = type('module', (), {'model_management': None})() + +from resolution_selector import ( + gcd, + calculate_aspect_ratio, + format_resolution, + get_resolution_list, + get_all_resolutions, + parse_resolution_string, + MODEL_RESOLUTIONS +) + +def test_gcd(): + """Test GCD function""" + print("Testing GCD function:") + assert gcd(1920, 1080) == 120, "GCD of 1920, 1080 should be 120" + assert gcd(1024, 1024) == 1024, "GCD of 1024, 1024 should be 1024" + assert gcd(16, 9) == 1, "GCD of 16, 9 should be 1" + print(" ✓ GCD tests passed") + +def test_aspect_ratio(): + """Test aspect ratio calculation""" + print("\nTesting aspect ratio calculation:") + assert calculate_aspect_ratio(1920, 1080) == "16:9", "1920x1080 should be 16:9" + assert calculate_aspect_ratio(1024, 1024) == "1:1", "1024x1024 should be 1:1" + assert calculate_aspect_ratio(1280, 720) == "16:9", "1280x720 should be 16:9" + assert calculate_aspect_ratio(1536, 1024) == "3:2", "1536x1024 should be 3:2" + print(" ✓ Aspect ratio tests passed") + +def test_format_resolution(): + """Test resolution formatting""" + print("\nTesting resolution formatting:") + result = format_resolution(1920, 1080) + print(f" 1920x1080 → '{result}'") + # With padding, should be "1920x1080 (16:9 Landscape)" - 13 chars total for resolution part + assert "(16:9 Landscape)" in result, f"Should contain aspect ratio and orientation" + assert result.startswith("1920x1080"), f"Should start with resolution" + + result = format_resolution(1024, 1024) + print(f" 1024x1024 → '{result}'") + assert "(1:1 Square)" in result, f"Should contain aspect ratio and orientation" + assert result.startswith("1024x1024"), f"Should start with resolution" + + result = format_resolution(1080, 1920) + print(f" 1080x1920 → '{result}'") + assert "(9:16 Portrait)" in result, f"Should contain aspect ratio and orientation" + assert result.startswith("1080x1920"), f"Should start with resolution" + print(" ✓ Format resolution tests passed") + +def test_parse_resolution(): + """Test resolution string parsing""" + print("\nTesting resolution string parsing:") + # Test with padding (as it will be in the actual dropdown) + width, height = parse_resolution_string("1920x1080 (16:9 Landscape)") + assert width == 1920 and height == 1080, "Should parse 1920x1080 with padding" + + width, height = parse_resolution_string("1024x1024 (1:1 Square)") + assert width == 1024 and height == 1024, "Should parse 1024x1024 with padding" + + # Test without padding (backward compatibility) + width, height = parse_resolution_string("1920x1080 (16:9 Landscape)") + assert width == 1920 and height == 1080, "Should parse 1920x1080 without padding" + print(" ✓ Parse resolution tests passed") + +def test_model_resolutions(): + """Test model resolution lists""" + print("\nTesting model resolution lists:") + + # Test individual model + flux_res = get_resolution_list("Flux") + print(f" Flux has {len(flux_res)} resolutions") + assert len(flux_res) > 0, "Flux should have resolutions" + assert any("1920x1080" in r for r in flux_res), "Flux should have 1920x1080" + + # Test All model + all_res = get_resolution_list("All") + print(f" 'All' has {len(all_res)} unique resolutions") + assert len(all_res) > len(flux_res), "'All' should have more resolutions than individual models" + + # Verify no duplicates in All + assert len(all_res) == len(set(all_res)), "'All' should have no duplicates" + print(" ✓ Model resolution tests passed") + +def test_new_resolutions(): + """Test that new resolutions were added""" + print("\nTesting new resolutions up to 1920x1080:") + + for model_name, model_data in MODEL_RESOLUTIONS.items(): + resolutions = get_resolution_list(model_name) + has_1080p = any("1920x1080" in r or "1080x1920" in r for r in resolutions) + print(f" {model_name}: {len(resolutions)} resolutions, has 1080p: {has_1080p}") + + print(" ✓ New resolutions verified") + +def test_all_resolutions_unique(): + """Test that 'All' model returns unique resolutions""" + print("\nTesting 'All' model uniqueness:") + all_res = get_all_resolutions() + + # Count occurrences + dimensions = [] + for res in all_res: + width, height = parse_resolution_string(res) + dimensions.append((width, height)) + + # Check for duplicates + unique_dimensions = set(dimensions) + assert len(dimensions) == len(unique_dimensions), "Should have no duplicate dimensions" + print(f" ✓ All {len(all_res)} resolutions are unique") + +if __name__ == "__main__": + print("=" * 60) + print("ResolutionSelector Enhancement Tests") + print("=" * 60) + + try: + test_gcd() + test_aspect_ratio() + test_format_resolution() + test_parse_resolution() + test_model_resolutions() + test_new_resolutions() + test_all_resolutions_unique() + + print("\n" + "=" * 60) + print("✓ ALL TESTS PASSED!") + print("=" * 60) + + except AssertionError as e: + print(f"\n✗ TEST FAILED: {e}") + sys.exit(1) + except Exception as e: + print(f"\n✗ ERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/web/js/resolution_selector.js b/web/js/resolution_selector.js new file mode 100644 index 0000000..374beb0 --- /dev/null +++ b/web/js/resolution_selector.js @@ -0,0 +1,236 @@ +import { app } from "/scripts/app.js"; + +const MODEL_RESOLUTIONS = { + "Flux": { + "square": [[512, 512], [768, 768], [1024, 1024], [1280, 1280], [1536, 1536], [1920, 1920], [2048, 2048]], + "portrait": [[768, 1344], [832, 1216], [896, 1152], [928, 1664], [1024, 1536], [1024, 1792], [1080, 1920], [1152, 2048]], + "landscape": [[1344, 768], [1216, 832], [1152, 896], [1536, 1024], [1664, 928], [1792, 1024], [1920, 1080], [2048, 1152]], + }, + "Qwen Image": { + "square": [[1024, 1024], [1280, 1280], [1328, 1328], [1536, 1536], [1920, 1920], [2048, 2048]], + "portrait": [[928, 1664], [1024, 1536], [1080, 1920], [1140, 1472], [1152, 2048]], + "landscape": [[1472, 1140], [1536, 1024], [1664, 928], [1920, 1080], [2048, 1152]], + }, + "Z-Image": { + "square": [[512, 512], [768, 768], [1024, 1024], [1280, 1280], [1536, 1536], [1920, 1920], [2048, 2048]], + "portrait": [[720, 1280], [768, 1024], [1080, 1920], [1152, 2048]], + "landscape": [[1024, 768], [1280, 720], [1920, 1080], [2048, 1152]], + }, + "SD 1.5": { + "square": [[512, 512], [768, 768], [1024, 1024], [1280, 1280], [1536, 1536]], + "portrait": [[512, 768], [512, 682], [512, 1024], [768, 1024], [768, 1344], [1080, 1920]], + "landscape": [[768, 512], [1024, 512], [1024, 768], [1344, 768], [1536, 512], [1920, 1080]], + }, + "SDXL": { + "square": [[1024, 1024], [1280, 1280], [1536, 1536], [1920, 1920], [2048, 2048]], + "portrait": [[640, 1536], [768, 1344], [832, 1216], [896, 1152], [1024, 1536], [1080, 1920], [1152, 2048]], + "landscape": [[1152, 896], [1216, 832], [1344, 768], [1536, 640], [1536, 1024], [1920, 1080], [2048, 1152]], + } +}; + +function gcd(a, b) { + while (b !== 0) { + const temp = b; + b = a % b; + a = temp; + } + return a; +} + +function calculateAspectRatio(width, height) { + // Calculate actual ratio as decimal + const actualRatio = width / height; + + // Common aspect ratios [ratio_value, "width:height" string] + const commonRatios = [ + [1.0, "1:1"], // Square + [1.25, "5:4"], // 1.25 + [1.33, "4:3"], // 1.333... + [1.5, "3:2"], // 1.5 + [1.6, "16:10"], // 1.6 + [1.78, "16:9"], // 1.777... + [2.0, "2:1"], // 2.0 + [2.35, "21:9"], // 2.333... (ultrawide) + [2.4, "12:5"], // 2.4 + [3.0, "3:1"], // 3.0 + // Portrait ratios + [0.75, "3:4"], // 0.75 + [0.67, "2:3"], // 0.666... + [0.625, "5:8"], // 0.625 + [0.56, "9:16"], // 0.5625 + [0.5, "1:2"], // 0.5 + [0.42, "5:12"], // 0.4166... + ]; + + // Find closest common ratio (within 5% tolerance) + const tolerance = 0.05; + for (const [ratioValue, ratioStr] of commonRatios) { + if (Math.abs(actualRatio - ratioValue) / ratioValue < tolerance) { + return ratioStr; + } + } + + // If no close match, calculate GCD-based ratio + const divisor = gcd(width, height); + const ratioWidth = width / divisor; + const ratioHeight = height / divisor; + return `${ratioWidth}:${ratioHeight}`; +} + +function formatResolution(width, height) { + const aspectRatio = calculateAspectRatio(width, height); + let orientation; + + if (width === height) { + orientation = "Square"; + } else if (width < height) { + orientation = "Portrait"; + } else { + orientation = "Landscape"; + } + + // Format with fixed width for better alignment + const resolutionStr = `${width}x${height}`; + const paddedResolution = resolutionStr.padEnd(13, ' '); // Pad to 13 chars for alignment + + return `${paddedResolution}(${aspectRatio} ${orientation})`; +} + +function getAllResolutions() { + const uniqueResolutions = new Map(); + + // Collect all unique width×height pairs + for (const modelName in MODEL_RESOLUTIONS) { + const modelData = MODEL_RESOLUTIONS[modelName]; + for (const category of ["square", "portrait", "landscape"]) { + if (modelData[category]) { + for (const [w, h] of modelData[category]) { + const key = `${w}x${h}`; + if (!uniqueResolutions.has(key)) { + uniqueResolutions.set(key, { width: w, height: h, pixels: w * h }); + } + } + } + } + } + + // Sort by total pixels, then by width + const sorted = Array.from(uniqueResolutions.values()) + .sort((a, b) => { + if (a.pixels !== b.pixels) return a.pixels - b.pixels; + return a.width - b.width; + }); + + return sorted.map(({ width, height }) => formatResolution(width, height)); +} + +function getResolutionsForModel(model) { + if (model === "All") { + return getAllResolutions(); + } + + const modelData = MODEL_RESOLUTIONS[model]; + if (!modelData) return []; + + const resolutions = []; + for (const category of ["square", "portrait", "landscape"]) { + if (modelData[category]) { + for (const [w, h] of modelData[category]) { + resolutions.push(formatResolution(w, h)); + } + } + } + return resolutions; +} + +function getDefaultResolution(model) { + // Model-specific native/optimal resolutions + const defaultResolutions = { + "Flux": [1024, 1024], + "Qwen Image": [1328, 1328], + "Z-Image": [1024, 1024], + "SD 1.5": [512, 512], + "SDXL": [1024, 1024], + "All": [1024, 1024] + }; + + if (defaultResolutions[model]) { + const [w, h] = defaultResolutions[model]; + return formatResolution(w, h); + } + + // Fallback + return formatResolution(1024, 1024); +} + +app.registerExtension({ + name: "ResolutionSelector.DynamicDropdown", + + async nodeCreated(node) { + if (node.comfyClass !== "ResolutionSelector") return; + + // Set wider default node width to prevent text cutoff + node.setSize([400, node.size[1]]); + + const modelWidget = node.widgets.find(w => w.name === "model"); + const resolutionWidget = node.widgets.find(w => w.name === "resolution"); + + if (!modelWidget || !resolutionWidget) { + console.error("ResolutionSelector: Required widgets not found"); + return; + } + + const origCallback = modelWidget.callback; + + const updateResolutions = (modelValue) => { + const resolutions = getResolutionsForModel(modelValue); + + if (resolutions.length === 0) { + console.warn(`ResolutionSelector: No resolutions found for model ${modelValue}`); + return; + } + + resolutionWidget.options.values = resolutions; + + // Set to model-specific default resolution + const defaultRes = getDefaultResolution(modelValue); + if (resolutions.includes(defaultRes)) { + resolutionWidget.value = defaultRes; + } else if (!resolutions.includes(resolutionWidget.value)) { + // Fallback to first resolution if default not found + resolutionWidget.value = resolutions[0]; + } + + node.setDirtyCanvas(true, true); + }; + + modelWidget.callback = function(value) { + if (origCallback) { + origCallback.apply(this, arguments); + } + updateResolutions(value); + }; + + setTimeout(() => { + updateResolutions(modelWidget.value); + }, 10); + }, + + async loadedGraphNode(node) { + if (node.comfyClass !== "ResolutionSelector") return; + + const modelWidget = node.widgets.find(w => w.name === "model"); + if (modelWidget) { + const resolutions = getResolutionsForModel(modelWidget.value); + const resolutionWidget = node.widgets.find(w => w.name === "resolution"); + + if (resolutionWidget && resolutions.length > 0) { + resolutionWidget.options.values = resolutions; + + if (!resolutions.includes(resolutionWidget.value)) { + resolutionWidget.value = resolutions[0]; + } + } + } + } +});