From 49fe509aa7602ecde3920c467e01e1964a213184 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 26 Jun 2026 22:44:33 +0200 Subject: [PATCH] Extract route config nodes --- __init__.py | 314 +----------------- docs/prompt-architecture-improvement-plan.md | 7 +- docs/prompt-pool-routing-map.md | 5 +- node_route_config.py | 331 +++++++++++++++++++ tools/prompt_smoke.py | 71 ++++ 5 files changed, 421 insertions(+), 307 deletions(-) create mode 100644 node_route_config.py diff --git a/__init__.py b/__init__.py index 57bef8f..f89d987 100644 --- a/__init__.py +++ b/__init__.py @@ -404,18 +404,19 @@ try: NODE_CLASS_MAPPINGS as CAMERA_NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as CAMERA_NODE_DISPLAY_NAME_MAPPINGS, ) + from .node_route_config import ( + NODE_CLASS_MAPPINGS as ROUTE_CONFIG_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as ROUTE_CONFIG_NODE_DISPLAY_NAME_MAPPINGS, + ) 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_cast_config_json, - build_category_config_json, build_character_slot_json, build_character_manual_config_json, build_character_profile_json, build_characteristics_config_json, - build_composition_pool_json, build_ethnicity_list_json, build_filter_config_json, build_generation_profile_json, @@ -423,15 +424,11 @@ try: build_hardcore_action_filter_json, build_hardcore_position_pool_json, build_insta_of_options_json, - build_location_pool_json, - build_thematic_location_json, build_insta_of_pair, build_prompt, build_prompt_from_configs, camera_detail_choices, camera_mode_choices, - cast_preset_choices, - category_preset_choices, category_choices, character_age_choices, character_body_choices, @@ -451,7 +448,6 @@ try: character_softcore_outfit_source_choices, character_softcore_outfit_values, character_woman_body_choices, - composition_pool_preset_choices, ethnicity_choices, generation_profile_choices, hardcore_position_family_choices, @@ -459,8 +455,6 @@ try: hardcore_position_key_choices, hardcore_detail_density_choices, load_character_profile_json, - location_theme_choices, - location_pool_preset_choices, save_character_profile_payload, subcategory_choices, ) @@ -480,18 +474,19 @@ except ImportError: NODE_CLASS_MAPPINGS as CAMERA_NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as CAMERA_NODE_DISPLAY_NAME_MAPPINGS, ) + from node_route_config import ( + NODE_CLASS_MAPPINGS as ROUTE_CONFIG_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as ROUTE_CONFIG_NODE_DISPLAY_NAME_MAPPINGS, + ) 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_cast_config_json, - build_category_config_json, build_character_slot_json, build_character_manual_config_json, build_character_profile_json, build_characteristics_config_json, - build_composition_pool_json, build_ethnicity_list_json, build_filter_config_json, build_generation_profile_json, @@ -499,15 +494,11 @@ except ImportError: build_hardcore_action_filter_json, build_hardcore_position_pool_json, build_insta_of_options_json, - build_location_pool_json, - build_thematic_location_json, build_insta_of_pair, build_prompt, build_prompt_from_configs, camera_detail_choices, camera_mode_choices, - cast_preset_choices, - category_preset_choices, category_choices, character_age_choices, character_body_choices, @@ -527,7 +518,6 @@ except ImportError: character_softcore_outfit_source_choices, character_softcore_outfit_values, character_woman_body_choices, - composition_pool_preset_choices, ethnicity_choices, generation_profile_choices, hardcore_position_family_choices, @@ -535,8 +525,6 @@ except ImportError: hardcore_position_key_choices, hardcore_detail_density_choices, load_character_profile_json, - location_theme_choices, - location_pool_preset_choices, save_character_profile_payload, subcategory_choices, ) @@ -736,278 +724,6 @@ class SxCPPromptBuilder: ) -class SxCPCategoryPreset: - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "preset": (category_preset_choices(), {"default": "auto_weighted"}), - "subcategory": (subcategory_choices(), {"default": "random"}), - } - } - - RETURN_TYPES = (SXCP_CATEGORY_CONFIG, "STRING", "STRING") - RETURN_NAMES = ("category_config", "category", "subcategory") - FUNCTION = "build" - CATEGORY = "prompt_builder" - - def build(self, preset, subcategory): - config = build_category_config_json(preset=preset, subcategory=subcategory) - parsed = json.loads(config) - return config, parsed["category"], parsed["subcategory"] - - -class SxCPLocationPool: - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "enabled": ("BOOLEAN", {"default": True}), - "combine_mode": (["replace", "add"], {"default": "replace"}), - "preset": (location_pool_preset_choices(), {"default": "custom_only"}), - "custom_locations": ("STRING", {"default": "", "multiline": True}), - }, - "optional": { - "location_config": (SXCP_LOCATION_CONFIG,), - }, - } - - RETURN_TYPES = (SXCP_LOCATION_CONFIG, "STRING") - RETURN_NAMES = ("location_config", "summary") - FUNCTION = "build" - CATEGORY = "prompt_builder" - - def build(self, enabled, combine_mode, preset, custom_locations, location_config=""): - config = build_location_pool_json( - enabled=enabled, - combine_mode=combine_mode, - preset=preset, - custom_locations=custom_locations or "", - location_config=location_config or "", - ) - parsed = json.loads(config) - return config, parsed.get("summary", "") - - -class SxCPCompositionPool: - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "enabled": ("BOOLEAN", {"default": True}), - "combine_mode": (["replace", "add"], {"default": "replace"}), - "preset": (composition_pool_preset_choices(), {"default": "no_outfit_check"}), - "custom_compositions": ("STRING", {"default": "", "multiline": True}), - }, - "optional": { - "composition_config": (SXCP_COMPOSITION_CONFIG,), - }, - } - - RETURN_TYPES = (SXCP_COMPOSITION_CONFIG, "STRING") - RETURN_NAMES = ("composition_config", "summary") - FUNCTION = "build" - CATEGORY = "prompt_builder" - - def build(self, enabled, combine_mode, preset, custom_compositions, composition_config=""): - config = build_composition_pool_json( - enabled=enabled, - combine_mode=combine_mode, - preset=preset, - custom_compositions=custom_compositions or "", - composition_config=composition_config or "", - ) - parsed = json.loads(config) - return config, parsed.get("summary", "") - - -class SxCPLocationTheme: - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "enabled": ("BOOLEAN", {"default": True}), - "combine_mode": (["replace", "add"], {"default": "replace"}), - "theme": (location_theme_choices(), {"default": "semi_public_affair"}), - "custom_locations": ("STRING", {"default": "", "multiline": True}), - "custom_compositions": ("STRING", {"default": "", "multiline": True}), - }, - "optional": { - "location_config": (SXCP_LOCATION_CONFIG,), - "composition_config": (SXCP_COMPOSITION_CONFIG,), - }, - } - - RETURN_TYPES = (SXCP_LOCATION_CONFIG, SXCP_COMPOSITION_CONFIG, "STRING") - RETURN_NAMES = ("location_config", "composition_config", "summary") - FUNCTION = "build" - CATEGORY = "prompt_builder" - - def build( - self, - enabled, - combine_mode, - theme, - custom_locations, - custom_compositions, - location_config="", - composition_config="", - ): - return build_thematic_location_json( - enabled=enabled, - combine_mode=combine_mode, - theme=theme, - custom_locations=custom_locations or "", - custom_compositions=custom_compositions or "", - location_config=location_config or "", - composition_config=composition_config or "", - ) - - -class SxCPCastControl: - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "cast_mode": (cast_preset_choices(), {"default": "mixed_couple"}), - "women_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}), - "men_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}), - } - } - - RETURN_TYPES = (SXCP_CAST_CONFIG, "INT", "INT", "STRING") - RETURN_NAMES = ("cast_config", "women_count", "men_count", "cast_summary") - FUNCTION = "build" - CATEGORY = "prompt_builder" - - def build(self, cast_mode, women_count, men_count): - config = build_cast_config_json(cast_mode=cast_mode, women_count=women_count, men_count=men_count) - parsed = json.loads(config) - summary = f"{parsed['women_count']} women, {parsed['men_count']} men" - return config, parsed["women_count"], parsed["men_count"], summary - - -class SxCPCastBias: - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "seed": ("INT", {"default": -1, "min": -1, "max": 0xFFFFFFFF, "step": 1}), - "row_number": ("INT", {"default": 1, "min": 1, "max": 1000000, "step": 1}), - "women_weights": ("STRING", {"default": "0.60,0.25,0.10,0.05"}), - "women_start_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}), - "men_weights": ("STRING", {"default": "0.45,0.40,0.10,0.05"}), - "men_start_count": ("INT", {"default": 0, "min": 0, "max": 12, "step": 1}), - "empty_behavior": (["force_one_woman", "force_one_man", "allow_empty"], {"default": "force_one_woman"}), - }, - "optional": { - "seed_config": (SXCP_SEED_CONFIG,), - }, - } - - RETURN_TYPES = (SXCP_CAST_CONFIG, "INT", "INT", "STRING") - RETURN_NAMES = ("cast_config", "women_count", "men_count", "cast_summary") - FUNCTION = "build" - CATEGORY = "prompt_builder" - - @staticmethod - def _configured_cast_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 ("category_seed", "content_seed", "role_seed", "seed", "global_seed"): - try: - value = int(raw.get(key)) - except (TypeError, ValueError): - continue - if value >= 0: - return value - return None - - @staticmethod - def _weight_pairs(weights_text, start_count): - pairs = [] - start = max(0, min(12, int(start_count))) - parts = str(weights_text or "").replace("\n", ",").split(",") - for offset, raw in enumerate(parts): - count = start + offset - if count > 12: - break - try: - weight = float(raw.strip()) - except (TypeError, ValueError): - continue - if weight > 0: - pairs.append((count, weight)) - return pairs or [(start, 1.0)] - - @staticmethod - def _weighted_count(rng, pairs): - total = sum(weight for _count, weight in pairs) - point = rng.random() * total - upto = 0.0 - for count, weight in pairs: - upto += weight - if point <= upto: - return int(count) - return int(pairs[-1][0]) - - @classmethod - def IS_CHANGED(cls, *args, **kwargs): - seed_value = kwargs.get("seed") - if seed_value is None and args: - seed_value = args[0] - seed_config = kwargs.get("seed_config", "") - if not seed_config and len(args) > 7: - seed_config = args[7] - try: - seed = int(seed_value) - except (TypeError, ValueError): - seed = -1 - if seed < 0 and cls._configured_cast_seed(seed_config) is None: - return random.random() - return tuple(args), tuple(sorted(kwargs.items())) - - def build( - self, - seed, - row_number, - women_weights, - women_start_count, - men_weights, - men_start_count, - empty_behavior, - seed_config="", - ): - configured_seed = self._configured_cast_seed(seed_config) - if configured_seed is None and int(seed) < 0: - rng = random.Random(random.getrandbits(64)) - else: - cast_seed = configured_seed if configured_seed is not None else int(seed) - rng = random.Random(f"sxcp_cast_bias:{cast_seed}:{int(row_number)}") - women_pairs = self._weight_pairs(women_weights, women_start_count) - men_pairs = self._weight_pairs(men_weights, men_start_count) - women_count = self._weighted_count(rng, women_pairs) - men_count = self._weighted_count(rng, men_pairs) - if women_count + men_count == 0: - if empty_behavior == "force_one_man": - men_count = 1 - elif empty_behavior != "allow_empty": - women_count = 1 - config = build_cast_config_json(cast_mode="custom_counts", women_count=women_count, men_count=men_count) - parsed = json.loads(config) - summary = f"weighted cast: {parsed['women_count']} women, {parsed['men_count']} men" - return config, parsed["women_count"], parsed["men_count"], summary - - class SxCPGenerationProfile: @classmethod def INPUT_TYPES(cls): @@ -2482,13 +2198,8 @@ NODE_CLASS_MAPPINGS = { } NODE_CLASS_MAPPINGS.update(SEED_RESOLUTION_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(CAMERA_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(ROUTE_CONFIG_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update({ - "SxCPCategoryPreset": SxCPCategoryPreset, - "SxCPLocationPool": SxCPLocationPool, - "SxCPCompositionPool": SxCPCompositionPool, - "SxCPLocationTheme": SxCPLocationTheme, - "SxCPCastControl": SxCPCastControl, - "SxCPCastBias": SxCPCastBias, "SxCPGenerationProfile": SxCPGenerationProfile, "SxCPEthnicityList": SxCPEthnicityList, "SxCPHairLength": SxCPHairLength, @@ -2524,13 +2235,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { } NODE_DISPLAY_NAME_MAPPINGS.update(SEED_RESOLUTION_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(CAMERA_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(ROUTE_CONFIG_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update({ - "SxCPCategoryPreset": "SxCP Category Preset", - "SxCPLocationPool": "SxCP Location Pool", - "SxCPCompositionPool": "SxCP Composition Pool", - "SxCPLocationTheme": "SxCP Location Theme", - "SxCPCastControl": "SxCP Cast Control", - "SxCPCastBias": "SxCP Cast Bias", "SxCPGenerationProfile": "SxCP Generation Profile", "SxCPEthnicityList": "SxCP Ethnicity List", "SxCPHairLength": "SxCP Hair Length", diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index d327728..a0fa82f 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -273,7 +273,7 @@ Improve later: ### Node / UI Path Owner: `__init__.py`, `node_seed_resolution.py`, `node_camera.py`, -`loop_nodes.py`, `web/*.js`. +`node_route_config.py`, `loop_nodes.py`, `web/*.js`. Keep here: @@ -283,6 +283,8 @@ Keep here: - dynamic input slots. - seed and resolution utility node declarations in `node_seed_resolution.py`. - camera utility node declarations in `node_camera.py`. +- route/category/location/composition/cast config node declarations in + `node_route_config.py`. Already isolated: @@ -290,6 +292,9 @@ Already isolated: `node_seed_resolution.py`, with registration maps imported by `__init__.py`. - camera/orbit/Qwen translator utility nodes live in `node_camera.py`, with registration maps imported by `__init__.py`. +- category preset, location/composition pool, location theme, and cast config + utility nodes live in `node_route_config.py`, with registration maps imported + by `__init__.py`. Improve later: diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index d5b33b3..25a66b7 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -25,8 +25,8 @@ When a result is wrong, first identify which layer owns the bad text: - 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`, node family modules such - as `node_seed_resolution.py` or `node_camera.py`, `loop_nodes.py`, or - `web/*.js`. + as `node_seed_resolution.py`, `node_camera.py`, or `node_route_config.py`, + `loop_nodes.py`, or `web/*.js`. ## High-Level Routes @@ -694,6 +694,7 @@ These do not own prompt pool wording, but they affect execution and review: | Persistent text preview | `loop_nodes.py`, `web/preview_any_text.js` | Stores any value as text and keeps it after workflow reload. | | Seed and resolution utility nodes | `node_seed_resolution.py`, imported by `__init__.py` | Global/per-axis seed configs plus SDXL/Krea width/height helpers. | | Camera utility nodes | `node_camera.py`, imported by `__init__.py` | Direct camera config, orbit-to-camera config, and Qwen MultiAngle camera translation. | +| Route config utility nodes | `node_route_config.py`, imported by `__init__.py` | Category preset, location/composition pool, location theme, and cast config helpers. | ## Drift Audit Helper diff --git a/node_route_config.py b/node_route_config.py new file mode 100644 index 0000000..748c809 --- /dev/null +++ b/node_route_config.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import json +import random + +try: + from .prompt_builder import ( + build_cast_config_json, + build_category_config_json, + build_composition_pool_json, + build_location_pool_json, + build_thematic_location_json, + cast_preset_choices, + category_preset_choices, + composition_pool_preset_choices, + location_pool_preset_choices, + location_theme_choices, + subcategory_choices, + ) +except ImportError: # Allows local smoke tests from the repository root. + from prompt_builder import ( + build_cast_config_json, + build_category_config_json, + build_composition_pool_json, + build_location_pool_json, + build_thematic_location_json, + cast_preset_choices, + category_preset_choices, + composition_pool_preset_choices, + location_pool_preset_choices, + location_theme_choices, + subcategory_choices, + ) + + +SXCP_CATEGORY_CONFIG = "SXCP_CATEGORY_CONFIG" +SXCP_LOCATION_CONFIG = "SXCP_LOCATION_CONFIG" +SXCP_COMPOSITION_CONFIG = "SXCP_COMPOSITION_CONFIG" +SXCP_CAST_CONFIG = "SXCP_CAST_CONFIG" +SXCP_SEED_CONFIG = "SXCP_SEED_CONFIG" + + +class SxCPCategoryPreset: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "preset": (category_preset_choices(), {"default": "auto_weighted"}), + "subcategory": (subcategory_choices(), {"default": "random"}), + } + } + + RETURN_TYPES = (SXCP_CATEGORY_CONFIG, "STRING", "STRING") + RETURN_NAMES = ("category_config", "category", "subcategory") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, preset, subcategory): + config = build_category_config_json(preset=preset, subcategory=subcategory) + parsed = json.loads(config) + return config, parsed["category"], parsed["subcategory"] + + +class SxCPLocationPool: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "enabled": ("BOOLEAN", {"default": True}), + "combine_mode": (["replace", "add"], {"default": "replace"}), + "preset": (location_pool_preset_choices(), {"default": "custom_only"}), + "custom_locations": ("STRING", {"default": "", "multiline": True}), + }, + "optional": { + "location_config": (SXCP_LOCATION_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_LOCATION_CONFIG, "STRING") + RETURN_NAMES = ("location_config", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, enabled, combine_mode, preset, custom_locations, location_config=""): + config = build_location_pool_json( + enabled=enabled, + combine_mode=combine_mode, + preset=preset, + custom_locations=custom_locations or "", + location_config=location_config or "", + ) + parsed = json.loads(config) + return config, parsed.get("summary", "") + + +class SxCPCompositionPool: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "enabled": ("BOOLEAN", {"default": True}), + "combine_mode": (["replace", "add"], {"default": "replace"}), + "preset": (composition_pool_preset_choices(), {"default": "no_outfit_check"}), + "custom_compositions": ("STRING", {"default": "", "multiline": True}), + }, + "optional": { + "composition_config": (SXCP_COMPOSITION_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_COMPOSITION_CONFIG, "STRING") + RETURN_NAMES = ("composition_config", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, enabled, combine_mode, preset, custom_compositions, composition_config=""): + config = build_composition_pool_json( + enabled=enabled, + combine_mode=combine_mode, + preset=preset, + custom_compositions=custom_compositions or "", + composition_config=composition_config or "", + ) + parsed = json.loads(config) + return config, parsed.get("summary", "") + + +class SxCPLocationTheme: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "enabled": ("BOOLEAN", {"default": True}), + "combine_mode": (["replace", "add"], {"default": "replace"}), + "theme": (location_theme_choices(), {"default": "semi_public_affair"}), + "custom_locations": ("STRING", {"default": "", "multiline": True}), + "custom_compositions": ("STRING", {"default": "", "multiline": True}), + }, + "optional": { + "location_config": (SXCP_LOCATION_CONFIG,), + "composition_config": (SXCP_COMPOSITION_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_LOCATION_CONFIG, SXCP_COMPOSITION_CONFIG, "STRING") + RETURN_NAMES = ("location_config", "composition_config", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + enabled, + combine_mode, + theme, + custom_locations, + custom_compositions, + location_config="", + composition_config="", + ): + return build_thematic_location_json( + enabled=enabled, + combine_mode=combine_mode, + theme=theme, + custom_locations=custom_locations or "", + custom_compositions=custom_compositions or "", + location_config=location_config or "", + composition_config=composition_config or "", + ) + + +class SxCPCastControl: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "cast_mode": (cast_preset_choices(), {"default": "mixed_couple"}), + "women_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}), + "men_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}), + } + } + + RETURN_TYPES = (SXCP_CAST_CONFIG, "INT", "INT", "STRING") + RETURN_NAMES = ("cast_config", "women_count", "men_count", "cast_summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, cast_mode, women_count, men_count): + config = build_cast_config_json(cast_mode=cast_mode, women_count=women_count, men_count=men_count) + parsed = json.loads(config) + summary = f"{parsed['women_count']} women, {parsed['men_count']} men" + return config, parsed["women_count"], parsed["men_count"], summary + + +class SxCPCastBias: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "seed": ("INT", {"default": -1, "min": -1, "max": 0xFFFFFFFF, "step": 1}), + "row_number": ("INT", {"default": 1, "min": 1, "max": 1000000, "step": 1}), + "women_weights": ("STRING", {"default": "0.60,0.25,0.10,0.05"}), + "women_start_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}), + "men_weights": ("STRING", {"default": "0.45,0.40,0.10,0.05"}), + "men_start_count": ("INT", {"default": 0, "min": 0, "max": 12, "step": 1}), + "empty_behavior": (["force_one_woman", "force_one_man", "allow_empty"], {"default": "force_one_woman"}), + }, + "optional": { + "seed_config": (SXCP_SEED_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_CAST_CONFIG, "INT", "INT", "STRING") + RETURN_NAMES = ("cast_config", "women_count", "men_count", "cast_summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + @staticmethod + def _configured_cast_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 ("category_seed", "content_seed", "role_seed", "seed", "global_seed"): + try: + value = int(raw.get(key)) + except (TypeError, ValueError): + continue + if value >= 0: + return value + return None + + @staticmethod + def _weight_pairs(weights_text, start_count): + pairs = [] + start = max(0, min(12, int(start_count))) + parts = str(weights_text or "").replace("\n", ",").split(",") + for offset, raw in enumerate(parts): + count = start + offset + if count > 12: + break + try: + weight = float(raw.strip()) + except (TypeError, ValueError): + continue + if weight > 0: + pairs.append((count, weight)) + return pairs or [(start, 1.0)] + + @staticmethod + def _weighted_count(rng, pairs): + total = sum(weight for _count, weight in pairs) + point = rng.random() * total + upto = 0.0 + for count, weight in pairs: + upto += weight + if point <= upto: + return int(count) + return int(pairs[-1][0]) + + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + seed_value = kwargs.get("seed") + if seed_value is None and args: + seed_value = args[0] + seed_config = kwargs.get("seed_config", "") + if not seed_config and len(args) > 7: + seed_config = args[7] + try: + seed = int(seed_value) + except (TypeError, ValueError): + seed = -1 + if seed < 0 and cls._configured_cast_seed(seed_config) is None: + return random.random() + return tuple(args), tuple(sorted(kwargs.items())) + + def build( + self, + seed, + row_number, + women_weights, + women_start_count, + men_weights, + men_start_count, + empty_behavior, + seed_config="", + ): + configured_seed = self._configured_cast_seed(seed_config) + if configured_seed is None and int(seed) < 0: + rng = random.Random(random.getrandbits(64)) + else: + cast_seed = configured_seed if configured_seed is not None else int(seed) + rng = random.Random(f"sxcp_cast_bias:{cast_seed}:{int(row_number)}") + women_pairs = self._weight_pairs(women_weights, women_start_count) + men_pairs = self._weight_pairs(men_weights, men_start_count) + women_count = self._weighted_count(rng, women_pairs) + men_count = self._weighted_count(rng, men_pairs) + if women_count + men_count == 0: + if empty_behavior == "force_one_man": + men_count = 1 + elif empty_behavior != "allow_empty": + women_count = 1 + config = build_cast_config_json(cast_mode="custom_counts", women_count=women_count, men_count=men_count) + parsed = json.loads(config) + summary = f"weighted cast: {parsed['women_count']} women, {parsed['men_count']} men" + return config, parsed["women_count"], parsed["men_count"], summary + + +NODE_CLASS_MAPPINGS = { + "SxCPCategoryPreset": SxCPCategoryPreset, + "SxCPLocationPool": SxCPLocationPool, + "SxCPCompositionPool": SxCPCompositionPool, + "SxCPLocationTheme": SxCPLocationTheme, + "SxCPCastControl": SxCPCastControl, + "SxCPCastBias": SxCPCastBias, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SxCPCategoryPreset": "SxCP Category Preset", + "SxCPLocationPool": "SxCP Location Pool", + "SxCPCompositionPool": "SxCP Composition Pool", + "SxCPLocationTheme": "SxCP Location Theme", + "SxCPCastControl": "SxCP Cast Control", + "SxCPCastBias": "SxCP Cast Bias", +} diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 75a7b83..7c5baf6 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -1778,6 +1778,76 @@ def smoke_node_camera_registration() -> None: _expect(json.loads(qwen_info).get("qwen_prompt", "").startswith(""), "Qwen info JSON lost original prompt") +def smoke_node_route_config_registration() -> None: + required_nodes = [ + "SxCPCategoryPreset", + "SxCPLocationPool", + "SxCPCompositionPool", + "SxCPLocationTheme", + "SxCPCastControl", + "SxCPCastBias", + ] + 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") + + category_node = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPCategoryPreset"] + category_inputs = category_node.INPUT_TYPES().get("required") or {} + _expect("preset" in category_inputs, "Category Preset lost preset input") + _expect("tooltip" in category_inputs["preset"][1], "Category Preset tooltip injection missing") + category_config, category, subcategory = category_node().build("auto_weighted", "random") + parsed_category = json.loads(category_config) + _expect(category == parsed_category.get("category") == "auto_weighted", "Category Preset output category mismatch") + _expect(subcategory == "random", "Category Preset output subcategory mismatch") + + location_config, location_summary = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPLocationPool"]().build( + True, + "replace", + "custom_only", + "classical library stacks with brass lamps", + ) + parsed_location = json.loads(location_config) + _expect(parsed_location.get("scene_entries"), "Location Pool did not keep custom location") + _expect("locations=1" in location_summary, "Location Pool summary lost custom count") + + composition_config, composition_summary = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPCompositionPool"]().build( + True, + "replace", + "no_outfit_check", + "long aisle composition with shelves repeating behind the subject", + ) + parsed_composition = json.loads(composition_config) + _expect(parsed_composition.get("composition_entries"), "Composition Pool did not keep composition entries") + _expect("compositions=" in composition_summary, "Composition Pool summary lost composition count") + + theme_location, theme_composition, theme_summary = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPLocationTheme"]().build( + True, + "replace", + "semi_public_affair", + "", + "", + ) + _expect(json.loads(theme_location).get("scene_entries"), "Location Theme did not output locations") + _expect(json.loads(theme_composition).get("composition_entries"), "Location Theme did not output compositions") + _expect("semi_public_affair" in theme_summary, "Location Theme summary lost theme name") + + cast_config, women_count, men_count, cast_summary = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPCastControl"]().build( + "mixed_couple", + 1, + 1, + ) + parsed_cast = json.loads(cast_config) + _expect((women_count, men_count) == (parsed_cast.get("women_count"), parsed_cast.get("men_count")), "Cast Control count outputs mismatch") + _expect("1 women, 1 men" in cast_summary, "Cast Control summary changed unexpectedly") + + cast_bias = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPCastBias"]() + bias_a = cast_bias.build(123, 2, "0.7,0.3", 1, "0.4,0.6", 0, "force_one_woman") + bias_b = cast_bias.build(123, 2, "0.7,0.3", 1, "0.4,0.6", 0, "force_one_woman") + _expect(bias_a == bias_b, "Cast Bias should be deterministic for fixed seed and row") + _expect(bias_a[1] + bias_a[2] >= 1, "Cast Bias empty behavior allowed empty cast") + _expect("weighted cast:" in bias_a[3], "Cast Bias summary lost weighted cast label") + + SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("builtin_single_woman", smoke_builtin_single), ("camera_scene_single", smoke_camera_scene_single), @@ -1803,6 +1873,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("formatter_metadata_fixtures", smoke_formatter_metadata_fixtures), ("node_utility_registration", smoke_node_utility_registration), ("node_camera_registration", smoke_node_camera_registration), + ("node_route_config_registration", smoke_node_route_config_registration), ]