diff --git a/README.md b/README.md index 9a5d628..17295fe 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,16 @@ resolutions and outputs `width`, `height`, and a `resolution` string. Use position inside the filtered pool. Connect `SxCP Global Seed` or Seed Locker's `seed_config` when the bucket choice must be reproducible. +`SxCP Krea2 Resolution Selector` outputs model-friendly `width`, `height`, +`resolution`, aspect ratio, megapixel, and hosted API fields for Krea2. Use +`profile=turbo_local_2k` for local Turbo generation up to a 2K long edge, or +`profile=raw_local_1k` for RAW/local 1K limits. `profile=api_hosted_1k` keeps +the official API fields visible: `api_aspect_ratio` and `api_resolution=1K`. +The node searches for the best multiple-of-16 size for the selected aspect ratio +and megapixel preset, then clamps to the selected profile's limit. Use +`max_for_aspect` to get the highest valid size for that aspect ratio, or +`random_1_to_limit` with a seed config for reproducible random sizes. + `SxCP Camera Control` and `SxCP Camera Orbit Control` output `camera_config`, which can be connected to the prompt builder or the Insta/OF pair node. They make camera/framing first-class instead of relying on a weak phrase inside the diff --git a/__init__.py b/__init__.py index 535d339..1615aef 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import math import random import re @@ -43,6 +44,27 @@ SDXL_BUCKET_RESOLUTIONS = [ {"orientation": "landscape", "width": 1536, "height": 1024, "aspect": 1.50, "mp": 1.57}, ] +KREA2_API_ASPECT_RATIOS = ["1:1", "4:3", "3:2", "16:9", "2.35:1", "4:5", "2:3", "9:16"] +KREA2_ASPECT_RATIOS = KREA2_API_ASPECT_RATIOS + ["21:9", "9:21", "3:1", "1:3", "custom", "random_api"] +KREA2_MEGAPIXEL_PRESETS = [ + "max_for_aspect", + "random_1_to_limit", + "1.0MP", + "1.25MP", + "1.5MP", + "1.75MP", + "2.0MP", + "2.25MP", + "2.5MP", + "2.75MP", + "3.0MP", + "3.25MP", + "3.5MP", + "3.75MP", + "4.0MP", + "custom", +] + COMMON_INPUT_TOOLTIPS = { "row_number": "Generation row to use. Changing it advances the deterministic selection without changing the main seed.", @@ -236,6 +258,17 @@ NODE_INPUT_TOOLTIPS = { "bucket_index": "0=random. 1+ selects that bucket position inside the selected orientation pool and ignores seed.", "seed_config": "Optional seed config. The composition seed controls bucket choice, so Seed Locker can keep sizes fixed while rerolling pose/person.", }, + "SxCPKrea2ResolutionSelector": { + "profile": "api_hosted_1k returns official API fields; raw_local_1k and turbo_local_2k return explicit ComfyUI width/height under known local limits.", + "aspect_ratio": "Official Krea API ratios are listed first. custom uses custom_aspect_width/custom_aspect_height; random_api chooses from official ratios.", + "megapixel_preset": "Pick a target megapixel count, max_for_aspect, random_1_to_limit, or custom_megapixels.", + "round_to": "Krea2 local inference pads to multiples of 16; 32/64 are stricter bucket-friendly choices.", + "custom_max_long_edge": "Only used by custom_limit. Local Turbo documentation uses 2048 as the 2K upper edge.", + "custom_max_megapixels": "Only used by custom_limit. The selector still respects custom_max_long_edge.", + "seed": "Used only for random_api or random_1_to_limit. Connect Seed Config for deterministic workflow-wide randomness.", + "row_number": "Deterministic row offset for random ratio/megapixel choices.", + "seed_config": "Optional seed config. The composition seed controls resolution choices when connected.", + }, "SxCPCameraControl": { "camera_mode": "Camera style preset. Use from_camera_config in Insta/OF options to consume this.", "priority": "locked makes the camera wording strict; soft_hint allows the model more freedom.", @@ -1001,6 +1034,294 @@ class SxCPSDXLBucketSize: return width, height, resolution, selected_orientation, aspect, mp, selected_index, summary +class SxCPKrea2ResolutionSelector: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "profile": ( + ["turbo_local_2k", "raw_local_1k", "api_hosted_1k", "custom_limit"], + {"default": "turbo_local_2k"}, + ), + "aspect_ratio": (KREA2_ASPECT_RATIOS, {"default": "1:1"}), + "megapixel_preset": (KREA2_MEGAPIXEL_PRESETS, {"default": "1.0MP"}), + "custom_megapixels": ("FLOAT", {"default": 1.0, "min": 0.10, "max": 16.0, "step": 0.05}), + "round_to": (["16", "32", "64"], {"default": "16"}), + "custom_aspect_width": ("FLOAT", {"default": 1.0, "min": 0.10, "max": 100.0, "step": 0.05}), + "custom_aspect_height": ("FLOAT", {"default": 1.0, "min": 0.10, "max": 100.0, "step": 0.05}), + "custom_max_long_edge": ("INT", {"default": 2048, "min": 256, "max": 8192, "step": 16}), + "custom_max_megapixels": ("FLOAT", {"default": 4.20, "min": 0.10, "max": 64.0, "step": 0.05}), + "seed": ("INT", {"default": -1, "min": -1, "max": 0xFFFFFFFF, "step": 1}), + "row_number": ("INT", {"default": 1, "min": 1, "max": 1000000, "step": 1}), + }, + "optional": { + "seed_config": (SXCP_SEED_CONFIG,), + }, + } + + RETURN_TYPES = ("INT", "INT", "STRING", "STRING", "STRING", "STRING", "FLOAT", "FLOAT", "STRING", "STRING", "STRING") + RETURN_NAMES = ( + "width", + "height", + "resolution", + "aspect_ratio", + "api_aspect_ratio", + "api_resolution", + "megapixels", + "max_megapixels_for_aspect", + "orientation", + "summary", + "config_json", + ) + FUNCTION = "select" + CATEGORY = "prompt_builder/util" + + @staticmethod + def _configured_seed(seed_config): + return SxCPSDXLBucketSize._configured_bucket_seed(seed_config) + + @staticmethod + def _aspect_value(aspect_ratio, custom_aspect_width, custom_aspect_height, rng): + selected = str(aspect_ratio or "1:1").strip() + if selected == "random_api": + selected = rng.choice(KREA2_API_ASPECT_RATIOS) + if selected == "custom": + width = max(0.1, float(custom_aspect_width)) + height = max(0.1, float(custom_aspect_height)) + return selected, width / height + try: + left, right = selected.split(":", 1) + return selected, max(0.01, float(left) / float(right)) + except (TypeError, ValueError): + return "1:1", 1.0 + + @staticmethod + def _closest_api_aspect(ratio): + def parse(value): + left, right = value.split(":", 1) + return float(left) / float(right) + + return min(KREA2_API_ASPECT_RATIOS, key=lambda item: abs(math.log(parse(item) / max(0.01, ratio)))) + + @staticmethod + def _continuous_limit_mp(ratio, max_long_edge, max_megapixels): + ratio = max(0.01, float(ratio)) + max_long = max(16.0, float(max_long_edge)) + if ratio >= 1.0: + exact_width = max_long + exact_height = max_long / ratio + else: + exact_width = max_long * ratio + exact_height = max_long + exact_mp = (exact_width * exact_height) / 1_000_000.0 + return max(0.01, min(float(max_megapixels), exact_mp)) + + @staticmethod + def _nearby_multiples(value, multiple): + scaled = float(value) / float(multiple) + values = { + int(math.floor(scaled)) * multiple, + int(round(scaled)) * multiple, + int(math.ceil(scaled)) * multiple, + } + return {int(v) for v in values if int(v) > 0} + + @classmethod + def _candidate_sizes(cls, ratio, max_long_edge, max_megapixels, multiple): + max_long = max(multiple, int(max_long_edge) // multiple * multiple) + max_pixels = float(max_megapixels) * 1_000_000.0 + candidates = set() + for width in range(multiple, max_long + 1, multiple): + for height in cls._nearby_multiples(float(width) / ratio, multiple): + candidates.add((width, height)) + for height in range(multiple, max_long + 1, multiple): + for width in cls._nearby_multiples(float(height) * ratio, multiple): + candidates.add((width, height)) + valid = [] + for width, height in candidates: + if width < multiple or height < multiple: + continue + if max(width, height) > max_long: + continue + if width * height > max_pixels + 1: + continue + valid.append((width, height)) + return valid + + @classmethod + def _best_size(cls, ratio, target_megapixels, max_long_edge, max_megapixels, multiple): + candidates = cls._candidate_sizes(ratio, max_long_edge, max_megapixels, multiple) + if not candidates: + fallback = max(multiple, int(max_long_edge) // multiple * multiple) + return fallback, fallback, (fallback * fallback) / 1_000_000.0, 1.0 + target = max((multiple * multiple) / 1_000_000.0, float(target_megapixels)) + best = None + best_score = None + for width, height in candidates: + actual_mp = (width * height) / 1_000_000.0 + actual_ratio = float(width) / float(height) + ratio_error = abs(math.log(actual_ratio / max(0.01, ratio))) + mp_error = abs(actual_mp - target) / max(target, 0.01) + score = ratio_error * 4.0 + mp_error + if best_score is None or score < best_score: + best = (width, height, actual_mp, actual_ratio) + best_score = score + return best + + @staticmethod + def _profile_limits(profile, custom_max_long_edge, custom_max_megapixels): + profile = str(profile or "turbo_local_2k").strip() + if profile == "raw_local_1k": + return 1024, 1.05, "Krea2 RAW local explicit size, up to 1K" + if profile == "api_hosted_1k": + return 1024, 1.05, "Krea hosted API fields, 1K only" + if profile == "custom_limit": + return max(256, int(custom_max_long_edge)), max(0.10, float(custom_max_megapixels)), "custom explicit size limit" + return 2048, 4.20, "Krea2 Turbo local explicit size, up to 2K" + + @staticmethod + def _preset_megapixels(megapixel_preset): + value = str(megapixel_preset or "1.0MP").strip() + if value.endswith("MP"): + try: + return float(value[:-2]) + except ValueError: + return 1.0 + return None + + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + aspect_ratio = kwargs.get("aspect_ratio") + if aspect_ratio is None and len(args) > 1: + aspect_ratio = args[1] + megapixel_preset = kwargs.get("megapixel_preset") + if megapixel_preset is None and len(args) > 2: + megapixel_preset = args[2] + seed_value = kwargs.get("seed") + if seed_value is None and len(args) > 9: + seed_value = args[9] + seed_config = kwargs.get("seed_config", "") + if not seed_config and len(args) > 11: + seed_config = args[11] + try: + seed = int(seed_value) + except (TypeError, ValueError): + seed = -1 + uses_random = str(aspect_ratio) == "random_api" or str(megapixel_preset) == "random_1_to_limit" + if uses_random and seed < 0 and cls._configured_seed(seed_config) is None: + return random.random() + return tuple(args), tuple(sorted(kwargs.items())) + + def select( + self, + profile, + aspect_ratio, + megapixel_preset, + custom_megapixels, + round_to, + custom_aspect_width, + custom_aspect_height, + custom_max_long_edge, + custom_max_megapixels, + seed, + row_number, + seed_config="", + ): + configured_seed = self._configured_seed(seed_config) + if configured_seed is None and int(seed) < 0: + rng = random.Random(random.getrandbits(64)) + seed_label = "fresh" + else: + selected_seed = configured_seed if configured_seed is not None else int(seed) + rng = random.Random(f"krea2_resolution:{selected_seed}:{int(row_number)}") + seed_label = str(selected_seed) + + multiple = int(round_to) if str(round_to).isdigit() else 16 + max_long_edge, max_profile_mp, profile_label = self._profile_limits(profile, custom_max_long_edge, custom_max_megapixels) + resolved_aspect, ratio = self._aspect_value(aspect_ratio, custom_aspect_width, custom_aspect_height, rng) + api_aspect_ratio = resolved_aspect if resolved_aspect in KREA2_API_ASPECT_RATIOS else self._closest_api_aspect(ratio) + + continuous_max_mp = self._continuous_limit_mp(ratio, max_long_edge, max_profile_mp) + max_width, max_height, max_actual_mp, max_actual_ratio = self._best_size( + ratio, continuous_max_mp, max_long_edge, max_profile_mp, multiple + ) + + preset = str(megapixel_preset or "1.0MP").strip() + target_mp = self._preset_megapixels(preset) + if preset == "custom": + target_mp = max(0.10, float(custom_megapixels)) + elif preset == "max_for_aspect": + target_mp = max_actual_mp + elif preset == "random_1_to_limit": + available = [self._preset_megapixels(value) for value in KREA2_MEGAPIXEL_PRESETS if value.endswith("MP")] + available = [value for value in available if value is not None and 1.0 <= value <= max_actual_mp + 0.001] + if not available: + target_mp = max_actual_mp + else: + target_mp = rng.choice(available) + if target_mp is None: + target_mp = 1.0 + + clamped = target_mp > max_actual_mp + 0.001 + effective_target_mp = min(float(target_mp), max_actual_mp) + width, height, actual_mp, actual_ratio = self._best_size( + ratio, effective_target_mp, max_long_edge, max_profile_mp, multiple + ) + orientation = "square" + if width > height: + orientation = "landscape" + elif height > width: + orientation = "portrait" + + resolution = f"{width}x{height}" + api_resolution = "1K" + summary_parts = [ + f"{profile_label}", + f"{resolution}", + f"{actual_mp:.2f} MP", + f"aspect {resolved_aspect} ({actual_ratio:.3f})", + f"max for aspect {max_width}x{max_height} / {max_actual_mp:.2f} MP", + f"API {api_aspect_ratio} {api_resolution}", + ] + if clamped: + summary_parts.append(f"target {target_mp:.2f} MP clamped to aspect/profile limit") + if preset.startswith("random") or resolved_aspect == "random_api": + summary_parts.append(f"seed {seed_label}, row {int(row_number)}") + summary = "; ".join(summary_parts) + + config = { + "profile": profile, + "width": width, + "height": height, + "resolution": resolution, + "aspect_ratio": resolved_aspect, + "aspect_ratio_value": actual_ratio, + "target_megapixels": round(float(target_mp), 4), + "megapixels": round(actual_mp, 4), + "max_width_for_aspect": max_width, + "max_height_for_aspect": max_height, + "max_megapixels_for_aspect": round(max_actual_mp, 4), + "api_aspect_ratio": api_aspect_ratio, + "api_resolution": api_resolution, + "orientation": orientation, + "round_to": multiple, + "clamped": clamped, + } + return ( + width, + height, + resolution, + resolved_aspect, + api_aspect_ratio, + api_resolution, + round(actual_mp, 4), + round(max_actual_mp, 4), + orientation, + summary, + json.dumps(config, ensure_ascii=True, sort_keys=True), + ) + + class SxCPCameraControl: @classmethod def INPUT_TYPES(cls): @@ -2914,6 +3235,7 @@ NODE_CLASS_MAPPINGS = { "SxCPSeedControl": SxCPSeedControl, "SxCPSeedLocker": SxCPSeedLocker, "SxCPSDXLBucketSize": SxCPSDXLBucketSize, + "SxCPKrea2ResolutionSelector": SxCPKrea2ResolutionSelector, "SxCPCameraControl": SxCPCameraControl, "SxCPCameraOrbitControl": SxCPCameraOrbitControl, "SxCPQwenCameraTranslator": SxCPQwenCameraTranslator, @@ -2959,6 +3281,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SxCPSeedControl": "SxCP Seed Control", "SxCPSeedLocker": "SxCP Seed Locker", "SxCPSDXLBucketSize": "SxCP SDXL Bucket Size", + "SxCPKrea2ResolutionSelector": "SxCP Krea2 Resolution Selector", "SxCPCameraControl": "SxCP Camera Control", "SxCPCameraOrbitControl": "SxCP Camera Orbit Control", "SxCPQwenCameraTranslator": "SxCP Qwen Camera Translator",