From 029ece173e4b788f2e1befbf1e66a9aeb3598164 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 26 Jun 2026 22:32:10 +0200 Subject: [PATCH] Extract seed resolution nodes --- __init__.py | 522 +------------------ docs/prompt-architecture-improvement-plan.md | 17 +- docs/prompt-pool-routing-map.md | 7 +- node_seed_resolution.py | 522 +++++++++++++++++++ tools/prompt_smoke.py | 46 ++ 5 files changed, 599 insertions(+), 515 deletions(-) create mode 100644 node_seed_resolution.py diff --git a/__init__.py b/__init__.py index ab0c768..d07f938 100644 --- a/__init__.py +++ b/__init__.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import math import random import re @@ -30,40 +29,6 @@ SXCP_CHARACTER_CAST = "SXCP_CHARACTER_CAST" SXCP_CHARACTER_SLOT = "SXCP_CHARACTER_SLOT" SXCP_CHARACTER_PROFILE = "SXCP_CHARACTER_PROFILE" -SDXL_BUCKET_RESOLUTIONS = [ - {"orientation": "portrait", "width": 896, "height": 1792, "aspect": 0.50, "mp": 1.61}, - {"orientation": "portrait", "width": 960, "height": 1664, "aspect": 0.58, "mp": 1.60}, - {"orientation": "portrait", "width": 1024, "height": 1600, "aspect": 0.64, "mp": 1.64}, - {"orientation": "portrait", "width": 1088, "height": 1472, "aspect": 0.74, "mp": 1.60}, - {"orientation": "portrait", "width": 1152, "height": 1408, "aspect": 0.82, "mp": 1.62}, - {"orientation": "portrait", "width": 1216, "height": 1344, "aspect": 0.90, "mp": 1.63}, - {"orientation": "square", "width": 1280, "height": 1280, "aspect": 1.00, "mp": 1.64}, - {"orientation": "landscape", "width": 1344, "height": 1216, "aspect": 1.11, "mp": 1.63}, - {"orientation": "landscape", "width": 1408, "height": 1152, "aspect": 1.22, "mp": 1.62}, - {"orientation": "landscape", "width": 1472, "height": 1088, "aspect": 1.35, "mp": 1.60}, - {"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 + ["8:9", "21:9", "9:21", "3:1", "1:3"] -KREA2_MEGAPIXEL_PRESETS = [ - "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", - "max_for_aspect", -] - - COMMON_INPUT_TOOLTIPS = { "row_number": "Generation row to use. Changing it advances the deterministic selection without changing the main seed.", "start_index": "Metadata/output index offset only. It does not limit category pools or random choices.", @@ -436,6 +401,10 @@ try: accumulator_move_entry, accumulator_save_entries, ) + from .node_seed_resolution import ( + NODE_CLASS_MAPPINGS as SEED_RESOLUTION_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as SEED_RESOLUTION_NODE_DISPLAY_NAME_MAPPINGS, + ) from .prompt_builder import ( build_camera_config_json, build_camera_orbit_config_json, @@ -459,8 +428,6 @@ try: build_insta_of_pair, build_prompt, build_prompt_from_configs, - build_seed_config_json, - build_seed_lock_config_json, camera_angle_choices, camera_detail_choices, camera_distance_choices, @@ -504,7 +471,6 @@ try: location_theme_choices, location_pool_preset_choices, save_character_profile_payload, - seed_mode_choices, subcategory_choices, ) from .caption_naturalizer import naturalize_caption @@ -520,6 +486,10 @@ except ImportError: accumulator_move_entry, accumulator_save_entries, ) + from node_seed_resolution import ( + NODE_CLASS_MAPPINGS as SEED_RESOLUTION_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as SEED_RESOLUTION_NODE_DISPLAY_NAME_MAPPINGS, + ) from prompt_builder import ( build_camera_config_json, build_camera_orbit_config_json, @@ -543,8 +513,6 @@ except ImportError: build_insta_of_pair, build_prompt, build_prompt_from_configs, - build_seed_config_json, - build_seed_lock_config_json, camera_angle_choices, camera_detail_choices, camera_distance_choices, @@ -588,7 +556,6 @@ except ImportError: location_theme_choices, location_pool_preset_choices, save_character_profile_payload, - seed_mode_choices, subcategory_choices, ) from caption_naturalizer import naturalize_caption @@ -787,459 +754,6 @@ class SxCPPromptBuilder: ) -class SxCPSeedControl: - SEED_AXES = ( - "category", - "subcategory", - "content", - "person", - "scene", - "pose", - "role", - "expression", - "composition", - ) - - @classmethod - def INPUT_TYPES(cls): - seed_spec = {"default": -1, "min": -1, "max": 0xFFFFFFFF, "step": 1} - required = {} - for axis in cls.SEED_AXES: - required[f"{axis}_seed_mode"] = (seed_mode_choices(), {"default": "auto"}) - required[f"{axis}_seed"] = ("INT", seed_spec) - return { - "required": required - } - - RETURN_TYPES = (SXCP_SEED_CONFIG,) - RETURN_NAMES = ("seed_config",) - FUNCTION = "build" - CATEGORY = "prompt_builder" - - @classmethod - def IS_CHANGED(cls, *args, **kwargs): - values = list(args) + list(kwargs.values()) - if "random" in values: - return random.random() - return tuple(args), tuple(sorted(kwargs.items())) - - def build( - self, - category_seed_mode, - category_seed, - subcategory_seed_mode, - subcategory_seed, - content_seed_mode, - content_seed, - person_seed_mode, - person_seed, - scene_seed_mode, - scene_seed, - pose_seed_mode, - pose_seed, - role_seed_mode, - role_seed, - expression_seed_mode, - expression_seed, - composition_seed_mode, - composition_seed, - ): - return ( - build_seed_config_json( - category_seed=category_seed, - subcategory_seed=subcategory_seed, - content_seed=content_seed, - person_seed=person_seed, - scene_seed=scene_seed, - pose_seed=pose_seed, - role_seed=role_seed, - expression_seed=expression_seed, - composition_seed=composition_seed, - category_seed_mode=category_seed_mode, - subcategory_seed_mode=subcategory_seed_mode, - content_seed_mode=content_seed_mode, - person_seed_mode=person_seed_mode, - scene_seed_mode=scene_seed_mode, - pose_seed_mode=pose_seed_mode, - role_seed_mode=role_seed_mode, - expression_seed_mode=expression_seed_mode, - composition_seed_mode=composition_seed_mode, - ), - ) - - -class SxCPGlobalSeed: - @classmethod - def INPUT_TYPES(cls): - seed_spec = {"default": 20260614, "min": 0, "max": 0xFFFFFFFF, "step": 1} - return { - "required": { - "global_seed": ("INT", seed_spec), - } - } - - RETURN_TYPES = ("INT", SXCP_SEED_CONFIG, "STRING") - RETURN_NAMES = ("seed", "seed_config", "summary") - FUNCTION = "build" - CATEGORY = "prompt_builder" - - def build(self, global_seed): - seed = max(0, min(0xFFFFFFFF, int(global_seed))) - config = build_seed_lock_config_json(base_seed=seed, reroll_axis="none", reroll_seed=-1) - return seed, config, f"global seed {seed}; all axes locked" - - -class SxCPSeedLocker: - @classmethod - def INPUT_TYPES(cls): - seed_spec = {"default": 20260614, "min": 0, "max": 0xFFFFFFFF, "step": 1} - reroll_seed_spec = {"default": -1, "min": -1, "max": 0xFFFFFFFF, "step": 1} - return { - "required": { - "base_seed": ("INT", seed_spec), - "reroll_axis": ( - [ - "none", - "category", - "subcategory", - "content", - "person", - "scene", - "pose", - "role", - "expression", - "composition", - "content_pose", - "scene_pose", - ], - {"default": "none"}, - ), - "reroll_seed": ("INT", reroll_seed_spec), - } - } - - RETURN_TYPES = (SXCP_SEED_CONFIG, "STRING") - RETURN_NAMES = ("seed_config", "summary") - FUNCTION = "build" - CATEGORY = "prompt_builder" - - def build(self, base_seed, reroll_axis, reroll_seed): - config = build_seed_lock_config_json(base_seed=base_seed, reroll_axis=reroll_axis, reroll_seed=reroll_seed) - summary = f"base {base_seed}; reroll {reroll_axis} with {'main seed' if int(reroll_seed) < 0 else reroll_seed}" - return config, summary - - -class SxCPSDXLBucketSize: - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "orientation": (["any", "portrait", "square", "landscape"], {"default": "any"}), - "seed": ("INT", {"default": -1, "min": -1, "max": 0xFFFFFFFF, "step": 1}), - "row_number": ("INT", {"default": 1, "min": 1, "max": 1000000, "step": 1}), - "bucket_index": ("INT", {"default": 0, "min": 0, "max": len(SDXL_BUCKET_RESOLUTIONS), "step": 1}), - }, - "optional": { - "seed_config": (SXCP_SEED_CONFIG,), - }, - } - - RETURN_TYPES = ("INT", "INT", "STRING", "STRING", "FLOAT", "FLOAT", "INT", "STRING") - RETURN_NAMES = ("width", "height", "resolution", "orientation", "aspect", "megapixels", "bucket_index", "summary") - FUNCTION = "build" - CATEGORY = "prompt_builder/util" - - @staticmethod - def _configured_bucket_seed(seed_config): - if not seed_config: - return None - if isinstance(seed_config, dict): - raw = seed_config - else: - try: - raw = json.loads(str(seed_config)) - except (TypeError, ValueError, json.JSONDecodeError): - return None - if not isinstance(raw, dict): - return None - for key in ("composition_seed", "content_seed", "seed", "global_seed"): - try: - value = int(raw.get(key)) - except (TypeError, ValueError): - continue - if value >= 0: - return value - return None - - @classmethod - def IS_CHANGED(cls, *args, **kwargs): - seed_value = kwargs.get("seed") - if seed_value is None and len(args) > 1: - seed_value = args[1] - bucket_index = kwargs.get("bucket_index") - if bucket_index is None and len(args) > 3: - bucket_index = args[3] - seed_config = kwargs.get("seed_config", "") - if not seed_config and len(args) > 4: - seed_config = args[4] - try: - seed = int(seed_value) - except (TypeError, ValueError): - seed = -1 - try: - index = int(bucket_index) - except (TypeError, ValueError): - index = 0 - if index <= 0 and seed < 0 and cls._configured_bucket_seed(seed_config) is None: - return random.random() - return tuple(args), tuple(sorted(kwargs.items())) - - def build(self, orientation, seed, row_number, bucket_index, seed_config=""): - orientation = str(orientation or "any").strip().lower() - pool = [ - (index + 1, bucket) - for index, bucket in enumerate(SDXL_BUCKET_RESOLUTIONS) - if orientation == "any" or bucket["orientation"] == orientation - ] - if not pool: - pool = list(enumerate(SDXL_BUCKET_RESOLUTIONS, start=1)) - if int(bucket_index) > 0: - pool_position = max(1, min(len(pool), int(bucket_index))) - 1 - else: - configured_seed = self._configured_bucket_seed(seed_config) - if configured_seed is None and int(seed) < 0: - rng = random.Random(random.getrandbits(64)) - else: - bucket_seed = configured_seed if configured_seed is not None else int(seed) - rng = random.Random(f"sdxl_bucket:{bucket_seed}:{int(row_number)}:{orientation}") - pool_position = rng.randrange(len(pool)) - selected_index, selected = pool[pool_position] - width = int(selected["width"]) - height = int(selected["height"]) - selected_orientation = str(selected["orientation"]) - aspect = float(selected["aspect"]) - mp = float(selected["mp"]) - resolution = f"{width}x{height}" - summary = ( - f"{selected_orientation} bucket {pool_position + 1}/{len(pool)} " - f"(table {selected_index}): {resolution}, aspect {aspect:.2f}, {mp:.2f} MP" - ) - return width, height, resolution, selected_orientation, aspect, mp, selected_index, summary - - -class SxCPKrea2ResolutionSelector: - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "megapixels": (KREA2_MEGAPIXEL_PRESETS, {"default": "1.0MP"}), - "aspect_ratio": (KREA2_ASPECT_RATIOS, {"default": "1:1"}), - }, - } - - 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 _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 - - def select(self, megapixels, aspect_ratio): - multiple = 16 - profile = "turbo_local_2k" - max_long_edge, max_profile_mp, _profile_label = self._profile_limits(profile, 2048, 4.20) - resolved_aspect, ratio = self._aspect_value(aspect_ratio, 1.0, 1.0, random.Random("krea2_resolution")) - 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(megapixels or "1.0MP").strip() - target_mp = self._preset_megapixels(preset) - if preset == "max_for_aspect": - target_mp = max_actual_mp - 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"{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"Krea2 Turbo 2K", - f"API equivalent {api_aspect_ratio} {api_resolution}", - ] - if clamped: - summary_parts.append(f"target {target_mp:.2f} MP clamped to aspect/profile limit") - 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): @@ -3155,11 +2669,9 @@ class SxCPInstaOFPromptPair: NODE_CLASS_MAPPINGS = { "SxCPPromptBuilder": SxCPPromptBuilder, - "SxCPGlobalSeed": SxCPGlobalSeed, - "SxCPSeedControl": SxCPSeedControl, - "SxCPSeedLocker": SxCPSeedLocker, - "SxCPSDXLBucketSize": SxCPSDXLBucketSize, - "SxCPKrea2ResolutionSelector": SxCPKrea2ResolutionSelector, +} +NODE_CLASS_MAPPINGS.update(SEED_RESOLUTION_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update({ "SxCPCameraControl": SxCPCameraControl, "SxCPCameraOrbitControl": SxCPCameraOrbitControl, "SxCPQwenCameraTranslator": SxCPQwenCameraTranslator, @@ -3195,17 +2707,15 @@ NODE_CLASS_MAPPINGS = { "SxCPSDXLFormatter": SxCPSDXLFormatter, "SxCPInstaOFOptions": SxCPInstaOFOptions, "SxCPInstaOFPromptPair": SxCPInstaOFPromptPair, -} +}) NODE_CLASS_MAPPINGS.update(LOOP_NODE_CLASS_MAPPINGS) _install_input_tooltips(NODE_CLASS_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS = { "SxCPPromptBuilder": "SxCP Prompt Builder", - "SxCPGlobalSeed": "SxCP Global Seed", - "SxCPSeedControl": "SxCP Seed Control", - "SxCPSeedLocker": "SxCP Seed Locker", - "SxCPSDXLBucketSize": "SxCP SDXL Bucket Size", - "SxCPKrea2ResolutionSelector": "SxCP Krea2 Resolution Selector", +} +NODE_DISPLAY_NAME_MAPPINGS.update(SEED_RESOLUTION_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update({ "SxCPCameraControl": "SxCP Camera Control", "SxCPCameraOrbitControl": "SxCP Camera Orbit Control", "SxCPQwenCameraTranslator": "SxCP Qwen Camera Translator", @@ -3241,7 +2751,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SxCPSDXLFormatter": "SxCP SDXL Formatter", "SxCPInstaOFOptions": "SxCP Insta/OF Options", "SxCPInstaOFPromptPair": "SxCP Insta/OF Prompt Pair", -} +}) NODE_DISPLAY_NAME_MAPPINGS.update(LOOP_NODE_DISPLAY_NAME_MAPPINGS) WEB_DIRECTORY = "./web" diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 5fe32ef..2ef932c 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -7,7 +7,8 @@ routing map in `docs/prompt-pool-routing-map.md`. The current branch adds two major surfaces: -- `SxCP Krea2 Resolution Selector` in `__init__.py`, with README notes. +- `SxCP Krea2 Resolution Selector` in `node_seed_resolution.py`, with README + notes. - Expanded hardcore interaction/manual/action pools in `categories/sexual_poses.json`, `categories/expression_composition_pools.json`, `prompt_builder.py`, and @@ -271,7 +272,7 @@ Improve later: ### Node / UI Path -Owner: `__init__.py`, `loop_nodes.py`, `web/*.js`. +Owner: `__init__.py`, `node_seed_resolution.py`, `loop_nodes.py`, `web/*.js`. Keep here: @@ -279,10 +280,16 @@ Keep here: - widget behavior; - button actions; - dynamic input slots. +- seed and resolution utility node declarations in `node_seed_resolution.py`. + +Already isolated: + +- seed/global-seed/seed-locker and SDXL/Krea2 resolution utility nodes live in + `node_seed_resolution.py`, with registration maps imported by `__init__.py`. Improve later: -- split large node classes into files by family; +- split remaining large node classes into files by family; - keep node display names, return names, and docs in sync through the audit helper; - add small endpoint tests for profile/accumulator/index-switch routes. @@ -400,8 +407,8 @@ Medium-term: ## Recommended Next Passes -1. Split `__init__.py` node classes by family after behavior is covered by smoke - checks. +1. Continue splitting remaining `__init__.py` node classes by family after + behavior is covered by smoke checks. 2. Continue splitting the internals of `hardcore_role_graphs.py` by action family once generated edge cases are covered by smoke fixtures. 3. Add more route-level smoke fixtures for generated edge cases that are not diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index bb32cfc..ad8030b 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -24,8 +24,8 @@ When a result is wrong, first identify which layer owns the bad text: - Raw builder prompt acceptable, Krea2 output wrong: edit `krea_formatter.py`. - Raw builder prompt acceptable, SDXL tags wrong: edit `sdxl_formatter.py`. - Natural caption/training caption wrong: edit `caption_naturalizer.py`. -- UI/preview/loop behavior wrong: edit `__init__.py`, `loop_nodes.py`, or - `web/*.js`. +- UI/preview/loop behavior wrong: edit `__init__.py`, node family modules such + as `node_seed_resolution.py`, `loop_nodes.py`, or `web/*.js`. ## High-Level Routes @@ -691,8 +691,7 @@ These do not own prompt pool wording, but they affect execution and review: | Index switch | `loop_nodes.py`, `web/index_switch_slots.js` | Multi-input to selected output, and selected input to multi-output routing. | | Accumulator | `loop_nodes.py`, `web/accumulator_preview.js` | Stores generated values/images during workflow execution and previews/reorders/deletes them. | | Persistent text preview | `loop_nodes.py`, `web/preview_any_text.js` | Stores any value as text and keeps it after workflow reload. | -| SDXL bucket size | `SxCPSDXLBucketSize` in `__init__.py` | Random/fixed SDXL bucket width and height selection. | -| Krea2 resolution selector | `SxCPKrea2ResolutionSelector` in `__init__.py` | Krea-compatible width/height and API aspect/resolution helper. | +| Seed and resolution utility nodes | `node_seed_resolution.py`, imported by `__init__.py` | Global/per-axis seed configs plus SDXL/Krea width/height helpers. | ## Drift Audit Helper diff --git a/node_seed_resolution.py b/node_seed_resolution.py new file mode 100644 index 0000000..3dae606 --- /dev/null +++ b/node_seed_resolution.py @@ -0,0 +1,522 @@ +from __future__ import annotations + +import json +import math +import random + +try: + from .prompt_builder import ( + build_seed_config_json, + build_seed_lock_config_json, + seed_mode_choices, + ) +except ImportError: # Allows local smoke tests from the repository root. + from prompt_builder import ( + build_seed_config_json, + build_seed_lock_config_json, + seed_mode_choices, + ) + + +SXCP_SEED_CONFIG = "SXCP_SEED_CONFIG" + +SDXL_BUCKET_RESOLUTIONS = [ + {"orientation": "portrait", "width": 896, "height": 1792, "aspect": 0.50, "mp": 1.61}, + {"orientation": "portrait", "width": 960, "height": 1664, "aspect": 0.58, "mp": 1.60}, + {"orientation": "portrait", "width": 1024, "height": 1600, "aspect": 0.64, "mp": 1.64}, + {"orientation": "portrait", "width": 1088, "height": 1472, "aspect": 0.74, "mp": 1.60}, + {"orientation": "portrait", "width": 1152, "height": 1408, "aspect": 0.82, "mp": 1.62}, + {"orientation": "portrait", "width": 1216, "height": 1344, "aspect": 0.90, "mp": 1.63}, + {"orientation": "square", "width": 1280, "height": 1280, "aspect": 1.00, "mp": 1.64}, + {"orientation": "landscape", "width": 1344, "height": 1216, "aspect": 1.11, "mp": 1.63}, + {"orientation": "landscape", "width": 1408, "height": 1152, "aspect": 1.22, "mp": 1.62}, + {"orientation": "landscape", "width": 1472, "height": 1088, "aspect": 1.35, "mp": 1.60}, + {"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 + ["8:9", "21:9", "9:21", "3:1", "1:3"] +KREA2_MEGAPIXEL_PRESETS = [ + "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", + "max_for_aspect", +] + + +class SxCPSeedControl: + SEED_AXES = ( + "category", + "subcategory", + "content", + "person", + "scene", + "pose", + "role", + "expression", + "composition", + ) + + @classmethod + def INPUT_TYPES(cls): + seed_spec = {"default": -1, "min": -1, "max": 0xFFFFFFFF, "step": 1} + required = {} + for axis in cls.SEED_AXES: + required[f"{axis}_seed_mode"] = (seed_mode_choices(), {"default": "auto"}) + required[f"{axis}_seed"] = ("INT", seed_spec) + return {"required": required} + + RETURN_TYPES = (SXCP_SEED_CONFIG,) + RETURN_NAMES = ("seed_config",) + FUNCTION = "build" + CATEGORY = "prompt_builder" + + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + values = list(args) + list(kwargs.values()) + if "random" in values: + return random.random() + return tuple(args), tuple(sorted(kwargs.items())) + + def build( + self, + category_seed_mode, + category_seed, + subcategory_seed_mode, + subcategory_seed, + content_seed_mode, + content_seed, + person_seed_mode, + person_seed, + scene_seed_mode, + scene_seed, + pose_seed_mode, + pose_seed, + role_seed_mode, + role_seed, + expression_seed_mode, + expression_seed, + composition_seed_mode, + composition_seed, + ): + return ( + build_seed_config_json( + category_seed=category_seed, + subcategory_seed=subcategory_seed, + content_seed=content_seed, + person_seed=person_seed, + scene_seed=scene_seed, + pose_seed=pose_seed, + role_seed=role_seed, + expression_seed=expression_seed, + composition_seed=composition_seed, + category_seed_mode=category_seed_mode, + subcategory_seed_mode=subcategory_seed_mode, + content_seed_mode=content_seed_mode, + person_seed_mode=person_seed_mode, + scene_seed_mode=scene_seed_mode, + pose_seed_mode=pose_seed_mode, + role_seed_mode=role_seed_mode, + expression_seed_mode=expression_seed_mode, + composition_seed_mode=composition_seed_mode, + ), + ) + + +class SxCPGlobalSeed: + @classmethod + def INPUT_TYPES(cls): + seed_spec = {"default": 20260614, "min": 0, "max": 0xFFFFFFFF, "step": 1} + return { + "required": { + "global_seed": ("INT", seed_spec), + } + } + + RETURN_TYPES = ("INT", SXCP_SEED_CONFIG, "STRING") + RETURN_NAMES = ("seed", "seed_config", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, global_seed): + seed = max(0, min(0xFFFFFFFF, int(global_seed))) + config = build_seed_lock_config_json(base_seed=seed, reroll_axis="none", reroll_seed=-1) + return seed, config, f"global seed {seed}; all axes locked" + + +class SxCPSeedLocker: + @classmethod + def INPUT_TYPES(cls): + seed_spec = {"default": 20260614, "min": 0, "max": 0xFFFFFFFF, "step": 1} + reroll_seed_spec = {"default": -1, "min": -1, "max": 0xFFFFFFFF, "step": 1} + return { + "required": { + "base_seed": ("INT", seed_spec), + "reroll_axis": ( + [ + "none", + "category", + "subcategory", + "content", + "person", + "scene", + "pose", + "role", + "expression", + "composition", + "content_pose", + "scene_pose", + ], + {"default": "none"}, + ), + "reroll_seed": ("INT", reroll_seed_spec), + } + } + + RETURN_TYPES = (SXCP_SEED_CONFIG, "STRING") + RETURN_NAMES = ("seed_config", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, base_seed, reroll_axis, reroll_seed): + config = build_seed_lock_config_json(base_seed=base_seed, reroll_axis=reroll_axis, reroll_seed=reroll_seed) + summary = f"base {base_seed}; reroll {reroll_axis} with {'main seed' if int(reroll_seed) < 0 else reroll_seed}" + return config, summary + + +class SxCPSDXLBucketSize: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "orientation": (["any", "portrait", "square", "landscape"], {"default": "any"}), + "seed": ("INT", {"default": -1, "min": -1, "max": 0xFFFFFFFF, "step": 1}), + "row_number": ("INT", {"default": 1, "min": 1, "max": 1000000, "step": 1}), + "bucket_index": ("INT", {"default": 0, "min": 0, "max": len(SDXL_BUCKET_RESOLUTIONS), "step": 1}), + }, + "optional": { + "seed_config": (SXCP_SEED_CONFIG,), + }, + } + + RETURN_TYPES = ("INT", "INT", "STRING", "STRING", "FLOAT", "FLOAT", "INT", "STRING") + RETURN_NAMES = ("width", "height", "resolution", "orientation", "aspect", "megapixels", "bucket_index", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder/util" + + @staticmethod + def _configured_bucket_seed(seed_config): + if not seed_config: + return None + if isinstance(seed_config, dict): + raw = seed_config + else: + try: + raw = json.loads(str(seed_config)) + except (TypeError, ValueError, json.JSONDecodeError): + return None + if not isinstance(raw, dict): + return None + for key in ("composition_seed", "content_seed", "seed", "global_seed"): + try: + value = int(raw.get(key)) + except (TypeError, ValueError): + continue + if value >= 0: + return value + return None + + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + seed_value = kwargs.get("seed") + if seed_value is None and len(args) > 1: + seed_value = args[1] + bucket_index = kwargs.get("bucket_index") + if bucket_index is None and len(args) > 3: + bucket_index = args[3] + seed_config = kwargs.get("seed_config", "") + if not seed_config and len(args) > 4: + seed_config = args[4] + try: + seed = int(seed_value) + except (TypeError, ValueError): + seed = -1 + try: + index = int(bucket_index) + except (TypeError, ValueError): + index = 0 + if index <= 0 and seed < 0 and cls._configured_bucket_seed(seed_config) is None: + return random.random() + return tuple(args), tuple(sorted(kwargs.items())) + + def build(self, orientation, seed, row_number, bucket_index, seed_config=""): + orientation = str(orientation or "any").strip().lower() + pool = [ + (index + 1, bucket) + for index, bucket in enumerate(SDXL_BUCKET_RESOLUTIONS) + if orientation == "any" or bucket["orientation"] == orientation + ] + if not pool: + pool = list(enumerate(SDXL_BUCKET_RESOLUTIONS, start=1)) + if int(bucket_index) > 0: + pool_position = max(1, min(len(pool), int(bucket_index))) - 1 + else: + configured_seed = self._configured_bucket_seed(seed_config) + if configured_seed is None and int(seed) < 0: + rng = random.Random(random.getrandbits(64)) + else: + bucket_seed = configured_seed if configured_seed is not None else int(seed) + rng = random.Random(f"sdxl_bucket:{bucket_seed}:{int(row_number)}:{orientation}") + pool_position = rng.randrange(len(pool)) + selected_index, selected = pool[pool_position] + width = int(selected["width"]) + height = int(selected["height"]) + selected_orientation = str(selected["orientation"]) + aspect = float(selected["aspect"]) + mp = float(selected["mp"]) + resolution = f"{width}x{height}" + summary = ( + f"{selected_orientation} bucket {pool_position + 1}/{len(pool)} " + f"(table {selected_index}): {resolution}, aspect {aspect:.2f}, {mp:.2f} MP" + ) + return width, height, resolution, selected_orientation, aspect, mp, selected_index, summary + + +class SxCPKrea2ResolutionSelector: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "megapixels": (KREA2_MEGAPIXEL_PRESETS, {"default": "1.0MP"}), + "aspect_ratio": (KREA2_ASPECT_RATIOS, {"default": "1:1"}), + }, + } + + 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 _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 + + def select(self, megapixels, aspect_ratio): + multiple = 16 + profile = "turbo_local_2k" + max_long_edge, max_profile_mp, _profile_label = self._profile_limits(profile, 2048, 4.20) + resolved_aspect, ratio = self._aspect_value(aspect_ratio, 1.0, 1.0, random.Random("krea2_resolution")) + 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(megapixels or "1.0MP").strip() + target_mp = self._preset_megapixels(preset) + if preset == "max_for_aspect": + target_mp = max_actual_mp + 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"{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", + "Krea2 Turbo 2K", + f"API equivalent {api_aspect_ratio} {api_resolution}", + ] + if clamped: + summary_parts.append(f"target {target_mp:.2f} MP clamped to aspect/profile limit") + 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), + ) + + +NODE_CLASS_MAPPINGS = { + "SxCPGlobalSeed": SxCPGlobalSeed, + "SxCPSeedControl": SxCPSeedControl, + "SxCPSeedLocker": SxCPSeedLocker, + "SxCPSDXLBucketSize": SxCPSDXLBucketSize, + "SxCPKrea2ResolutionSelector": SxCPKrea2ResolutionSelector, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SxCPGlobalSeed": "SxCP Global Seed", + "SxCPSeedControl": "SxCP Seed Control", + "SxCPSeedLocker": "SxCP Seed Locker", + "SxCPSDXLBucketSize": "SxCP SDXL Bucket Size", + "SxCPKrea2ResolutionSelector": "SxCP Krea2 Resolution Selector", +} diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index c2e7162..ddcbc75 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -25,6 +25,7 @@ if str(ROOT) not in sys.path: import caption_naturalizer # noqa: E402 import category_library # noqa: E402 +import __init__ as sxcp_nodes # noqa: E402 import krea_formatter # noqa: E402 import prompt_builder as pb # noqa: E402 import sdxl_formatter # noqa: E402 @@ -1661,6 +1662,50 @@ def smoke_formatter_metadata_fixtures() -> None: _expect(term in caption_text, f"{name}.caption missing {term!r}") +def smoke_node_utility_registration() -> None: + required_nodes = [ + "SxCPGlobalSeed", + "SxCPSeedControl", + "SxCPSeedLocker", + "SxCPSDXLBucketSize", + "SxCPKrea2ResolutionSelector", + ] + for node_name in required_nodes: + _expect(node_name in sxcp_nodes.NODE_CLASS_MAPPINGS, f"{node_name} missing from node registry") + _expect(node_name in sxcp_nodes.NODE_DISPLAY_NAME_MAPPINGS, f"{node_name} missing from display registry") + + seed_control = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPSeedControl"] + seed_inputs = seed_control.INPUT_TYPES().get("required") or {} + _expect("category_seed_mode" in seed_inputs, "Seed Control lost category seed mode input") + _expect("tooltip" in seed_inputs["category_seed_mode"][1], "Seed Control tooltip injection missing") + + seed, seed_config, summary = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPGlobalSeed"]().build(12345) + parsed_seed = json.loads(seed_config) + _expect(seed == 12345, "Global Seed did not return the clamped seed") + _expect(parsed_seed, "Global Seed config should not be empty") + _expect(all(int(value) == 12345 for value in parsed_seed.values()), "Global Seed config did not lock every axis") + _expect("all axes locked" in summary, "Global Seed summary changed unexpectedly") + + locker_config, locker_summary = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPSeedLocker"]().build(12345, "pose", 999) + parsed_locker = json.loads(locker_config) + _expect(parsed_locker.get("pose_seed") == 999, "Seed Locker did not apply pose reroll seed") + _expect("reroll pose" in locker_summary, "Seed Locker summary lost reroll axis") + + bucket_node = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPSDXLBucketSize"]() + bucket_a = bucket_node.build("portrait", 77, 3, 0) + bucket_b = bucket_node.build("portrait", 77, 3, 0) + _expect(bucket_a == bucket_b, "SDXL bucket should be deterministic for fixed seed and row") + _expect(bucket_a[3] == "portrait", "SDXL bucket ignored orientation filter") + + krea_node = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPKrea2ResolutionSelector"]() + krea_width, krea_height, _resolution, aspect_ratio, api_aspect, _api_resolution, *_rest = krea_node.select("1.0MP", "9:16") + krea_config = json.loads(_rest[-1]) + _expect(krea_height > krea_width, "Krea2 9:16 selector should return portrait dimensions") + _expect(aspect_ratio == "9:16", "Krea2 selector lost requested aspect ratio") + _expect(api_aspect == "9:16", "Krea2 selector lost API aspect mapping") + _expect(krea_config.get("width") == krea_width and krea_config.get("height") == krea_height, "Krea2 config_json dimensions mismatch") + + SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("builtin_single_woman", smoke_builtin_single), ("camera_scene_single", smoke_camera_scene_single), @@ -1684,6 +1729,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("fallback_role_graph_routes", smoke_fallback_role_graph_routes), ("expression_disabled", smoke_no_expression_fallback), ("formatter_metadata_fixtures", smoke_formatter_metadata_fixtures), + ("node_utility_registration", smoke_node_utility_registration), ]