Files
Gerald LonlasandClaude Opus 4.8 479b021d14 feat: ratio-first resolution picker with image-driven aspect ratio
Redesign the profile editor's dimension controls into a two-step flow:
pick a shape (orientation + aspect-ratio chips), then a resolution tier on
a fast-mock -> high-res scale. Width/height are derived by fitting the ratio
into the tier's pixel budget and snapping to a multiple of 8, replacing the
33-entry preset dropdown and free-text custom field.

Add an optional IMAGE input: when connected, the node adopts the image's
aspect ratio at runtime while the selected profile still sets the resolution
(pixel budget) and step count. A banner and form note surface this in the UI.

Tests: +8 Python cases for image-dimension reading and ratio-to-area fitting
(91 total); existing 65 JS pure-helper tests unchanged and passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:32:06 +08:00

258 lines
7.8 KiB
Python

"""Node definitions for ComfyUI-Image-profile."""
from __future__ import annotations
import json
import math
from typing import Any
import torch
import comfy.model_management
DIM_MULTIPLE = 8
DIM_MIN = 8
DIM_MAX = 16384
STEPS_MIN = 1
STEPS_MAX = 150
DEFAULT_PROFILES: list[dict[str, Any]] = [
{
"id": "default-landscape-low",
"name": "Landscape Low resolution",
"width": 404,
"height": 204,
"steps": 5,
},
{
"id": "default-portrait-low",
"name": "Portrait Low resolution",
"width": 204,
"height": 404,
"steps": 5,
},
{
"id": "default-landscape-high",
"name": "Landscape High resolution",
"width": 1152,
"height": 864,
"steps": 8,
},
{
"id": "default-portrait-high",
"name": "Portrait High resolution",
"width": 864,
"height": 1152,
"steps": 8,
},
]
class ComfyUIImageProfile:
"""Emit a latent and step count from a selected image profile."""
def __init__(self) -> None:
self.device = comfy.model_management.intermediate_device()
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"profiles_json": ("STRING", {"default": "[]", "multiline": False}),
"selected_profile_id": ("STRING", {"default": ""}),
"selected_width": ("INT", {"default": 1152, "min": DIM_MIN, "max": DIM_MAX, "step": DIM_MULTIPLE}),
"selected_height": ("INT", {"default": 864, "min": DIM_MIN, "max": DIM_MAX, "step": DIM_MULTIPLE}),
"selected_steps": ("INT", {"default": 8, "min": STEPS_MIN, "max": STEPS_MAX, "step": 1}),
},
"optional": {
# When connected, the image's aspect ratio overrides the
# selected profile's ratio; the profile still sets the
# resolution (pixel budget) and step count.
"image": ("IMAGE",),
},
}
RETURN_TYPES = ("LATENT", "INT")
RETURN_NAMES = ("Latent", "Steps")
FUNCTION = "generate"
CATEGORY = "Latent/Profile"
@staticmethod
def _clamp(value: int, minimum: int, maximum: int) -> int:
return max(minimum, min(maximum, value))
@classmethod
def _sanitize_dimension(cls, value: Any) -> int:
try:
integer = int(value)
except (TypeError, ValueError):
integer = DIM_MIN
integer = cls._clamp(integer, DIM_MIN, DIM_MAX)
lower = (integer // DIM_MULTIPLE) * DIM_MULTIPLE
upper = lower + DIM_MULTIPLE
if lower < DIM_MIN:
lower = DIM_MIN
if upper > DIM_MAX:
upper = DIM_MAX
if integer - lower >= upper - integer:
return upper
return lower
@classmethod
def _sanitize_steps(cls, value: Any) -> int:
try:
integer = int(value)
except (TypeError, ValueError):
integer = STEPS_MIN
return cls._clamp(integer, STEPS_MIN, STEPS_MAX)
@classmethod
def _sanitize_profile(cls, profile: dict[str, Any]) -> dict[str, Any] | None:
profile_id = str(profile.get("id", "")).strip()
name = str(profile.get("name", "")).strip()
if not profile_id or not name:
return None
return {
"id": profile_id,
"name": name,
"width": cls._sanitize_dimension(profile.get("width")),
"height": cls._sanitize_dimension(profile.get("height")),
"steps": cls._sanitize_steps(profile.get("steps")),
}
@classmethod
def _load_profiles(cls, profiles_json: str) -> list[dict[str, Any]]:
try:
parsed = json.loads(profiles_json)
except (TypeError, ValueError, json.JSONDecodeError):
parsed = []
if not isinstance(parsed, list):
parsed = []
valid_profiles: list[dict[str, Any]] = []
for candidate in parsed:
if not isinstance(candidate, dict):
continue
profile = cls._sanitize_profile(candidate)
if profile is not None:
valid_profiles.append(profile)
if valid_profiles:
return valid_profiles
return [p for profile in DEFAULT_PROFILES if (p := cls._sanitize_profile(profile)) is not None]
@staticmethod
def _image_dimensions(image: Any) -> tuple[int, int] | None:
"""Return (width, height) from a ComfyUI IMAGE tensor, or None.
ComfyUI images are tensors shaped [batch, height, width, channels]
(a bare [height, width, channels] is also tolerated).
"""
shape = getattr(image, "shape", None)
if shape is None:
return None
try:
dims = tuple(int(value) for value in shape)
except (TypeError, ValueError):
return None
if len(dims) >= 4:
height, width = dims[1], dims[2]
elif len(dims) == 3:
height, width = dims[0], dims[1]
else:
return None
if width <= 0 or height <= 0:
return None
return width, height
@classmethod
def _fit_ratio_to_area(
cls,
image: Any,
target_width: int,
target_height: int,
) -> tuple[int, int]:
"""Scale the image's aspect ratio to the target pixel budget.
Keeps roughly the same number of pixels as ``target_width x
target_height`` (the selected profile's resolution) while adopting the
image's ratio. Falls back to the target dimensions when the image has
no usable shape.
"""
dimensions = cls._image_dimensions(image)
if dimensions is None:
return target_width, target_height
image_width, image_height = dimensions
area = max(1, target_width * target_height)
ratio = image_width / image_height
raw_width = math.sqrt(area * ratio)
raw_height = math.sqrt(area / ratio)
return cls._sanitize_dimension(raw_width), cls._sanitize_dimension(raw_height)
def _resolve_selected_profile(
self,
profiles: list[dict[str, Any]],
selected_profile_id: str,
selected_width: int,
selected_height: int,
selected_steps: int,
) -> dict[str, Any]:
selected = next((p for p in profiles if p["id"] == selected_profile_id), None)
if selected is not None:
return selected
return {
"id": profiles[0]["id"],
"name": "Selected",
"width": self._sanitize_dimension(selected_width),
"height": self._sanitize_dimension(selected_height),
"steps": self._sanitize_steps(selected_steps),
}
def generate(
self,
profiles_json: str,
selected_profile_id: str,
selected_width: int,
selected_height: int,
selected_steps: int,
image: Any = None,
):
profiles = self._load_profiles(profiles_json)
selected = self._resolve_selected_profile(
profiles,
selected_profile_id=selected_profile_id,
selected_width=selected_width,
selected_height=selected_height,
selected_steps=selected_steps,
)
width = selected["width"]
height = selected["height"]
steps = selected["steps"]
if image is not None:
# Adopt the connected image's aspect ratio while keeping the
# profile's resolution (pixel budget) and step count.
width, height = self._fit_ratio_to_area(image, width, height)
latent = torch.zeros([1, 4, height // 8, width // 8], device=self.device)
return ({"samples": latent}, steps)
NODE_CLASS_MAPPINGS = {
"ComfyUIImageProfile": ComfyUIImageProfile,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ComfyUIImageProfile": "Image Profile",
}