Files
BRADSEC 02f84ccb23 Fix invalid presets, validate preset path, sync JS resolution table (review 1, 2)
Qwen Image 1140x1472 (and mirror) and SD 1.5 512x682 were not divisible
by 8, so the generated latent did not match the reported width/height;
removed the stray Qwen pair (official list has 1104x1472) and corrected
682 to 680. The preset branch now runs _validate_dimensions before the
multiplier, so API-built workflows cannot pair a model with another
model's incompatible preset. Regenerated the hand-copied JS table from
nodes.py (it had drifted by 14 entries) and added tests that fail on
future drift or non-conforming presets. Bump to 2.1.6.
2026-07-03 10:56:23 +10:00

559 lines
24 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import torch
try:
import comfy.model_management
COMFY_AVAILABLE = True
except ImportError:
COMFY_AVAILABLE = False
# Model-specific resolution presets with constraints
# Includes: model-optimized sizes + photo print (4x6, 5x7, 8x10) + digital/social + canvas art ratios
MODEL_RESOLUTIONS = {
"Flux": {
"square": [(512, 512), (768, 768), (1024, 1024), (1088, 1088), (1280, 1280), (1328, 1328), (1536, 1536), (1920, 1920), (2048, 2048)],
"portrait": [(688, 2048), (768, 1344), (832, 1216), (896, 1152), (928, 1664), (1024, 1536), (1024, 1792), (1024, 2048), (1056, 1584), (1088, 1920), (1104, 1472), (1152, 2048), (1200, 1792), (1360, 2048), (1456, 2048), (1536, 2048), (1616, 2048), (1632, 2048), (1712, 2048)],
"landscape": [(1280, 720), (1344, 768), (1216, 832), (1152, 896), (1472, 1104), (1536, 1024), (1584, 1056), (1664, 928), (1792, 1024), (1792, 1200), (1920, 1088), (2048, 688), (2048, 1024), (2048, 1152), (2048, 1360), (2048, 1456), (2048, 1536), (2048, 1616), (2048, 1632), (2048, 1712)],
"constraints": {"divisible_by": 16, "min": 256, "max": 2048, "latent_channels": 16}
},
"Qwen Image": {
"square": [(1024, 1024), (1080, 1080), (1280, 1280), (1328, 1328), (1536, 1536), (1920, 1920), (2048, 2048)],
"portrait": [(680, 2048), (928, 1664), (1024, 1536), (1024, 2048), (1056, 1584), (1080, 1920), (1104, 1472), (1152, 2048), (1200, 1800), (1368, 2048), (1464, 2048), (1536, 2048), (1608, 2048), (1640, 2048), (1704, 2048)],
"landscape": [(1280, 720), (1472, 1104), (1536, 1024), (1584, 1056), (1664, 928), (1800, 1200), (1920, 1080), (2048, 680), (2048, 1024), (2048, 1152), (2048, 1368), (2048, 1464), (2048, 1536), (2048, 1608), (2048, 1640), (2048, 1704)],
"constraints": {"divisible_by": 8, "min": 256, "max": 2048, "latent_channels": 16}
},
"Z-Image": {
"square": [(512, 512), (768, 768), (1024, 1024), (1080, 1080), (1280, 1280), (1328, 1328), (1536, 1536), (1920, 1920), (2048, 2048)],
"portrait": [(680, 2048), (720, 1280), (768, 1024), (928, 1664), (1024, 2048), (1056, 1584), (1080, 1920), (1104, 1472), (1152, 2048), (1200, 1800), (1368, 2048), (1464, 2048), (1536, 2048), (1608, 2048), (1640, 2048), (1704, 2048)],
"landscape": [(1024, 768), (1280, 720), (1472, 1104), (1584, 1056), (1664, 928), (1800, 1200), (1920, 1080), (2048, 680), (2048, 1024), (2048, 1152), (2048, 1368), (2048, 1464), (2048, 1536), (2048, 1608), (2048, 1640), (2048, 1704)],
"constraints": {"divisible_by": 8, "min": 256, "max": 2048, "latent_channels": 16}
},
"SD 1.5": {
"square": [(512, 512), (768, 768), (1024, 1024), (1080, 1080), (1280, 1280), (1536, 1536)],
"portrait": [(512, 768), (512, 680), (512, 1024), (680, 2048), (768, 1024), (768, 1344), (1024, 2048), (1080, 1920), (1200, 1800), (1368, 2048), (1464, 2048), (1536, 2048), (1608, 2048), (1640, 2048), (1704, 2048)],
"landscape": [(768, 512), (1024, 512), (1024, 768), (1280, 720), (1344, 768), (1536, 512), (1800, 1200), (1920, 1080), (2048, 680), (2048, 1024), (2048, 1368), (2048, 1464), (2048, 1536), (2048, 1608), (2048, 1640), (2048, 1704)],
"constraints": {"divisible_by": 8, "min": 256, "max": 2048, "latent_channels": 4}
},
"SDXL": {
"square": [(1024, 1024), (1080, 1080), (1280, 1280), (1536, 1536), (1920, 1920), (2048, 2048)],
"portrait": [(640, 1536), (680, 2048), (768, 1344), (832, 1216), (896, 1152), (1024, 1536), (1024, 2048), (1080, 1920), (1152, 2048), (1200, 1800), (1368, 2048), (1464, 2048), (1536, 2048), (1608, 2048), (1640, 2048), (1704, 2048)],
"landscape": [(1152, 896), (1216, 832), (1280, 720), (1344, 768), (1536, 640), (1536, 1024), (1800, 1200), (1920, 1080), (2048, 680), (2048, 1024), (2048, 1152), (2048, 1368), (2048, 1464), (2048, 1536), (2048, 1608), (2048, 1640), (2048, 1704)],
"constraints": {"divisible_by": 8, "min": 256, "max": 2048, "latent_channels": 4}
}
}
def calculate_aspect_ratio(width, height):
"""
Calculate simplified aspect ratio from dimensions, using nearest common ratio.
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...
(0.33, "1:3"), # 0.333... (panoramic)
]
# Find closest common ratio by absolute difference
closest_ratio = min(common_ratios, key=lambda r: abs(actual_ratio - r[0]))
return closest_ratio[1]
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_latent_channels(model_name):
"""
Return the latent channel count for a model.
SD 1.5 and SDXL use 4-channel latents. Flux, Qwen Image, and Z-Image use
16-channel latents. A wrong channel count makes the LATENT output unusable
with that model's sampler.
For "All" (no specific model) this defaults to 4 for backward compatibility
with SD-based workflows; pick the specific model to get a 16-channel latent.
Args:
model_name (str): Model name, or "All".
Returns:
int: Latent channel count (4 or 16).
"""
model_data = MODEL_RESOLUTIONS.get(model_name)
if model_data:
return model_data["constraints"].get("latent_channels", 4)
return 4
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}")
# Tooltips (shared by both API wrappers).
TIP_MODEL = "Image model. Filters the resolution presets to that model's recommended sizes. 'All' shows every preset."
TIP_RESOLUTION = "Preset resolution. The list filters to the selected model."
TIP_MULT = "Multiplies the preset width and height (1x to 4x)."
TIP_BATCH = "Number of latent samples in the preset latent batch."
TIP_CW = "Custom width in pixels. 0 disables the custom outputs. Must satisfy the model's divisibility and bounds."
TIP_CH = "Custom height in pixels. 0 disables the custom outputs. Must satisfy the model's divisibility and bounds."
TIP_CMULT = "Multiplies the custom width and height (1x to 4x)."
TIP_CBATCH = "Number of latent samples in the custom latent batch."
OUTPUT_TIPS = {
"width": "Preset width in pixels after multiplier.",
"height": "Preset height in pixels after multiplier.",
"latent": "Empty latent for the preset resolution.",
"custom_width": "Custom width after multiplier, or 0 when custom is disabled.",
"custom_height": "Custom height after multiplier, or 0 when custom is disabled.",
"custom_latent": "Empty latent for the custom resolution.",
}
class ResolutionSelector:
"""
Enhanced resolution selector supporting multiple image generation models.
Provides model-specific resolution presets, custom dimension inputs, and empty latent output.
"""
def __init__(self):
"""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.
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": {
"model": (model_list, {
"default": "SDXL",
"tooltip": TIP_MODEL,
}),
"resolution": (all_resolutions, {
"default": "1024x1024 (1:1 Square)",
"tooltip": TIP_RESOLUTION,
}),
"resolution_multiplier": (["1x", "2x", "3x", "4x"], {
"default": "1x",
"tooltip": TIP_MULT,
}),
"batch_size": ("INT", {
"default": 1,
"min": 1,
"max": 64,
"step": 1,
"display": "number",
"tooltip": TIP_BATCH,
}),
},
"optional": {
"custom_width": ("INT", {
"default": 0,
"min": 0,
"max": 4096,
"step": 8,
"display": "number",
"tooltip": TIP_CW,
}),
"custom_height": ("INT", {
"default": 0,
"min": 0,
"max": 4096,
"step": 8,
"display": "number",
"tooltip": TIP_CH,
}),
"custom_multiplier": (["1x", "2x", "3x", "4x"], {
"default": "1x",
"tooltip": TIP_CMULT,
}),
"custom_batch": ("INT", {
"default": 1,
"min": 1,
"max": 64,
"step": 1,
"display": "number",
"tooltip": TIP_CBATCH,
}),
}
}
RETURN_TYPES = ("INT", "INT", "LATENT", "INT", "INT", "LATENT")
RETURN_NAMES = ("width", "height", "latent", "custom_width", "custom_height", "custom_latent")
FUNCTION = "select_resolution"
CATEGORY = "utils"
def select_resolution(self, model, resolution, resolution_multiplier="1x", batch_size=1, custom_width=0, custom_height=0, custom_multiplier="1x", custom_batch=1):
"""
Select and validate resolution, generate outputs.
Args:
model (str): Selected model name
resolution (str): Selected preset resolution string
resolution_multiplier (str): Multiplier for resolution (1x-4x)
batch_size (int): Number of latent samples for preset resolution (default: 1)
custom_width (int, optional): Custom width override
custom_height (int, optional): Custom height override
custom_multiplier (str, optional): Multiplier for custom dimensions (1x-4x)
custom_batch (int, optional): Number of latent samples for custom resolution (default: 1)
Returns:
tuple: (width: int, height: int, latent: dict, custom_width: int, custom_height: int, custom_latent: dict)
"""
# Parse multipliers (e.g., "2x" -> 2)
multiplier = int(resolution_multiplier.replace("x", ""))
custom_mult = int(custom_multiplier.replace("x", ""))
# Parse preset resolution
width, height = parse_resolution_string(resolution)
# The resolution combo lists every model's presets (the JS extension
# filters client-side), so API-built workflows can pair a model with a
# preset that violates its constraints. Validate before the multiplier:
# multiplied presets intentionally exceed the max constraint, and an
# integer multiplier preserves divisibility.
self._validate_dimensions(model, width, height)
# Apply multiplier to preset resolution
width *= multiplier
height *= multiplier
# Channel count depends on the model (4 for SD1.5/SDXL, 16 for Flux/Qwen/Z-Image)
channels = get_latent_channels(model)
# Generate latent for preset resolution with batch size
latent = self._generate_empty_latent(width, height, batch_size, channels)
# 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. "All" still gets the generic
# 8-divisible / bounds check so the latent dimensions are not silently
# truncated by the //8 below.
self._validate_dimensions(model, final_custom_width, final_custom_height)
# Generate custom latent with custom batch size
custom_latent = self._generate_empty_latent(final_custom_width, final_custom_height, custom_batch, channels)
return (width, height, latent, final_custom_width, final_custom_height, custom_latent)
else:
# No custom dimensions: return zeros and a minimal VALID latent (8x8 -> 1x1
# in latent space), never a zero-size [B,C,0,0] tensor.
custom_latent = self._generate_empty_latent(8, 8, 1, channels)
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 in MODEL_RESOLUTIONS:
constraints = MODEL_RESOLUTIONS[model]["constraints"]
else:
# "All" or unknown model: enforce the generic latent requirement so
# dimensions divide cleanly by 8 and stay within sane bounds.
constraints = {"divisible_by": 8, "min": 64, "max": 4096}
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, channels=4):
"""
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)
channels (int): Latent channel count (4 for SD1.5/SDXL, 16 for Flux/Qwen/Z-Image)
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, height//8, width//8]
latent_tensor = torch.zeros(
[batch_size, channels, latent_height, latent_width],
device=self.device
)
return {"samples": latent_tensor}
# node_id must be unique: ComfyUI core ships a built-in "ResolutionSelector"
# (comfy_extras/nodes_resolution.py), so this pack registers under a distinct id.
NODE_CLASS_MAPPINGS = {
"ResolutionSelectorPlus": ResolutionSelector,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ResolutionSelectorPlus": "Resolution Selector Plus",
}
# -- V3 node (import-guarded comfy_api schema) ---------------------------------
# Thin adapter: execute delegates to the V1 class so the logic is never forked.
try:
from comfy_api.v0_0_2 import io, ComfyExtension
class ResolutionSelectorPlusV3(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
model_list = ["All"] + list(MODEL_RESOLUTIONS.keys())
all_resolutions = get_all_resolutions()
mult = ["1x", "2x", "3x", "4x"]
return io.Schema(
node_id="ResolutionSelectorPlus",
display_name="Resolution Selector Plus",
category="utils",
description="Model-aware resolution presets, custom dimensions, and empty latent output.",
inputs=[
io.Combo.Input("model", options=model_list, default="SDXL", tooltip=TIP_MODEL),
io.Combo.Input("resolution", options=all_resolutions,
default="1024x1024 (1:1 Square)", tooltip=TIP_RESOLUTION),
io.Combo.Input("resolution_multiplier", options=mult, default="1x", tooltip=TIP_MULT),
io.Int.Input("batch_size", default=1, min=1, max=64, step=1, tooltip=TIP_BATCH),
io.Int.Input("custom_width", default=0, min=0, max=4096, step=8, optional=True, tooltip=TIP_CW),
io.Int.Input("custom_height", default=0, min=0, max=4096, step=8, optional=True, tooltip=TIP_CH),
io.Combo.Input("custom_multiplier", options=mult, default="1x", optional=True, tooltip=TIP_CMULT),
io.Int.Input("custom_batch", default=1, min=1, max=64, step=1, optional=True, tooltip=TIP_CBATCH),
],
outputs=[
io.Int.Output(id="width", display_name="width", tooltip=OUTPUT_TIPS["width"]),
io.Int.Output(id="height", display_name="height", tooltip=OUTPUT_TIPS["height"]),
io.Latent.Output(id="latent", display_name="latent", tooltip=OUTPUT_TIPS["latent"]),
io.Int.Output(id="custom_width", display_name="custom_width", tooltip=OUTPUT_TIPS["custom_width"]),
io.Int.Output(id="custom_height", display_name="custom_height", tooltip=OUTPUT_TIPS["custom_height"]),
io.Latent.Output(id="custom_latent", display_name="custom_latent", tooltip=OUTPUT_TIPS["custom_latent"]),
],
)
@classmethod
def execute(cls, model, resolution, resolution_multiplier="1x", batch_size=1,
custom_width=0, custom_height=0, custom_multiplier="1x", custom_batch=1) -> io.NodeOutput:
result = ResolutionSelector().select_resolution(
model, resolution, resolution_multiplier, batch_size,
custom_width, custom_height, custom_multiplier, custom_batch)
return io.NodeOutput(*result)
class ResolutionSelectorPlusExtension(ComfyExtension):
async def get_node_list(self):
return [ResolutionSelectorPlusV3]
async def comfy_entrypoint() -> "ResolutionSelectorPlusExtension":
return ResolutionSelectorPlusExtension()
except ImportError:
# Older ComfyUI without comfy_api: V1 mappings above are the only path.
pass