diff --git a/README.md b/README.md index 200bb71..539eb4a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ The node is registered as: - `prompt_builder / SxCP Category Preset` - `prompt_builder / SxCP Cast Control` - `prompt_builder / SxCP Generation Profile` +- `prompt_builder / SxCP Ethnicity List` - `prompt_builder / SxCP Advanced Filters` - `prompt_builder / SxCP Prompt Builder From Configs` - `prompt_builder / SxCP Woman Slot` @@ -51,6 +52,12 @@ node. For cleaner workflows, use the split nodes: - `SxCP Generation Profile` outputs `generation_profile` for common behavior presets such as casual-clean, evocative-softcore, hardcore-intense, Krea2-friendly, or Flux-original. +- `SxCP Ethnicity List` outputs a reusable ethnicity filter from checkboxes. + It includes broad groups and narrower European/Mediterranean groups such as + `french_european`, `germanic_european`, `nordic_european`, + `slavic_european`, `italian_mediterranean`, and `iberian_mediterranean`. + Connect `ethnicity` to a prompt or slot `ethnicity_list` input, or connect + `filter_config` to generator-level `filter_config`. - `SxCP Advanced Filters` outputs `filter_config` for appearance include checkboxes, figure, and plus-size inclusion. - `SxCP Prompt Builder From Configs` consumes those config outputs and produces @@ -125,6 +132,11 @@ as one stable character across scene, pose, outfit, or row rerolls. This seed is shared by that slot's random age/body/appearance choices, so you can keep the same participant while changing other generation axes. +Connect `SxCP Ethnicity List.ethnicity` to a slot's `ethnicity_list` input when +that character should randomize inside a selected heritage list. This is useful +for narrowing broad groups, for example choosing French/Germanic/Nordic/Slavic +European entries instead of the entire `european` pool. + Use `Woman Slot` for women because it exposes woman-focused body choices and a `figure_bias` selector. Use `Man Slot` for men because it exposes man-focused body choices and omits figure bias. The older generic `SxCP Character Slot` diff --git a/__init__.py b/__init__.py index 5e85bbd..c50edc6 100644 --- a/__init__.py +++ b/__init__.py @@ -20,6 +20,7 @@ try: build_category_config_json, build_character_slot_json, build_character_profile_json, + build_ethnicity_list_json, build_filter_config_json, build_generation_profile_json, build_insta_of_options_json, @@ -72,6 +73,7 @@ except ImportError: build_category_config_json, build_character_slot_json, build_character_profile_json, + build_ethnicity_list_json, build_filter_config_json, build_generation_profile_json, build_insta_of_options_json, @@ -155,6 +157,7 @@ class SxCPPromptBuilder: "prepend_trigger_to_prompt": ("BOOLEAN", {"default": True}), }, "optional": { + "ethnicity_list": ("STRING", {"default": "", "multiline": True}), "seed_config": ("STRING", {"default": "", "multiline": True}), "camera_config": ("STRING", {"default": "", "multiline": True}), "character_profile": ("STRING", {"default": "", "multiline": True}), @@ -197,6 +200,7 @@ class SxCPPromptBuilder: extra_negative="", no_plus_women=False, no_black=False, + ethnicity_list="", ): row = build_prompt( category=category, @@ -205,7 +209,7 @@ class SxCPPromptBuilder: start_index=start_index, seed=seed, clothing=clothing, - ethnicity=ethnicity, + ethnicity=ethnicity_list or ethnicity, poses=poses, expression_enabled=expression_enabled, expression_intensity=expression_intensity, @@ -700,6 +704,99 @@ class SxCPAdvancedFilters: ) +class SxCPEthnicityList: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "include_european": ("BOOLEAN", {"default": False}), + "include_mediterranean_mena": ("BOOLEAN", {"default": False}), + "include_latina": ("BOOLEAN", {"default": False}), + "include_east_asian": ("BOOLEAN", {"default": False}), + "include_southeast_asian": ("BOOLEAN", {"default": False}), + "include_south_asian": ("BOOLEAN", {"default": False}), + "include_black_african": ("BOOLEAN", {"default": False}), + "include_indigenous": ("BOOLEAN", {"default": False}), + "include_mixed": ("BOOLEAN", {"default": False}), + "include_asian": ("BOOLEAN", {"default": False}), + "include_white_asian": ("BOOLEAN", {"default": False}), + "include_western_european": ("BOOLEAN", {"default": False}), + "include_french_european": ("BOOLEAN", {"default": False}), + "include_germanic_european": ("BOOLEAN", {"default": False}), + "include_nordic_european": ("BOOLEAN", {"default": False}), + "include_celtic_european": ("BOOLEAN", {"default": False}), + "include_slavic_european": ("BOOLEAN", {"default": False}), + "include_baltic_european": ("BOOLEAN", {"default": False}), + "include_alpine_european": ("BOOLEAN", {"default": False}), + "include_balkan_european": ("BOOLEAN", {"default": False}), + "include_greek_mediterranean": ("BOOLEAN", {"default": False}), + "include_italian_mediterranean": ("BOOLEAN", {"default": False}), + "include_iberian_mediterranean": ("BOOLEAN", {"default": False}), + "strict_excludes": ("BOOLEAN", {"default": True}), + } + } + + RETURN_TYPES = ("STRING", "STRING", "STRING") + RETURN_NAMES = ("ethnicity", "filter_config", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + include_european, + include_mediterranean_mena, + include_latina, + include_east_asian, + include_southeast_asian, + include_south_asian, + include_black_african, + include_indigenous, + include_mixed, + include_asian, + include_white_asian, + include_western_european, + include_french_european, + include_germanic_european, + include_nordic_european, + include_celtic_european, + include_slavic_european, + include_baltic_european, + include_alpine_european, + include_balkan_european, + include_greek_mediterranean, + include_italian_mediterranean, + include_iberian_mediterranean, + strict_excludes, + ): + result = build_ethnicity_list_json( + include_european=include_european, + include_mediterranean_mena=include_mediterranean_mena, + include_latina=include_latina, + include_east_asian=include_east_asian, + include_southeast_asian=include_southeast_asian, + include_south_asian=include_south_asian, + include_black_african=include_black_african, + include_indigenous=include_indigenous, + include_mixed=include_mixed, + include_asian=include_asian, + include_white_asian=include_white_asian, + include_western_european=include_western_european, + include_french_european=include_french_european, + include_germanic_european=include_germanic_european, + include_nordic_european=include_nordic_european, + include_celtic_european=include_celtic_european, + include_slavic_european=include_slavic_european, + include_baltic_european=include_baltic_european, + include_alpine_european=include_alpine_european, + include_balkan_european=include_balkan_european, + include_greek_mediterranean=include_greek_mediterranean, + include_italian_mediterranean=include_italian_mediterranean, + include_iberian_mediterranean=include_iberian_mediterranean, + strict_excludes=strict_excludes, + ) + return result["ethnicity"], result["filter_config"], result["summary"] + + class SxCPPromptBuilderFromConfigs: @classmethod def INPUT_TYPES(cls): @@ -714,6 +811,7 @@ class SxCPPromptBuilderFromConfigs: "cast_config": ("STRING", {"default": "", "multiline": True}), "generation_profile": ("STRING", {"default": "", "multiline": True}), "filter_config": ("STRING", {"default": "", "multiline": True}), + "ethnicity_list": ("STRING", {"default": "", "multiline": True}), "seed_config": ("STRING", {"default": "", "multiline": True}), "camera_config": ("STRING", {"default": "", "multiline": True}), "character_profile": ("STRING", {"default": "", "multiline": True}), @@ -737,6 +835,7 @@ class SxCPPromptBuilderFromConfigs: cast_config="", generation_profile="", filter_config="", + ethnicity_list="", seed_config="", camera_config="", character_profile="", @@ -751,7 +850,7 @@ class SxCPPromptBuilderFromConfigs: category_config=category_config or "", cast_config=cast_config or "", generation_profile=generation_profile or "", - filter_config=filter_config or "", + filter_config=ethnicity_list or filter_config or "", seed_config=seed_config or "", camera_config=camera_config or "", character_profile=character_profile or "", @@ -798,6 +897,7 @@ class SxCPCharacterSlot: "hardcore_clothing": ("STRING", {"default": ""}), }, "optional": { + "ethnicity_list": ("STRING", {"default": "", "multiline": True}), "character_cast": ("STRING", {"default": "", "multiline": True}), }, } @@ -832,6 +932,7 @@ class SxCPCharacterSlot: softcore_outfit="", hardcore_clothing="", character_cast="", + ethnicity_list="", ): result = build_character_slot_json( subject_type=subject_type, @@ -839,7 +940,7 @@ class SxCPCharacterSlot: slot_seed=slot_seed, age=age, manual_age=manual_age, - ethnicity=ethnicity, + ethnicity=ethnicity_list or ethnicity, figure=figure, body=body, manual_body=manual_body, @@ -888,6 +989,7 @@ class SxCPWomanSlot: "hardcore_clothing": ("STRING", {"default": ""}), }, "optional": { + "ethnicity_list": ("STRING", {"default": "", "multiline": True}), "character_cast": ("STRING", {"default": "", "multiline": True}), }, } @@ -920,6 +1022,7 @@ class SxCPWomanSlot: softcore_outfit="", hardcore_clothing="", character_cast="", + ethnicity_list="", ): result = build_character_slot_json( subject_type="woman", @@ -927,7 +1030,7 @@ class SxCPWomanSlot: slot_seed=slot_seed, age=age, manual_age=manual_age, - ethnicity=ethnicity, + ethnicity=ethnicity_list or ethnicity, figure=figure_bias, body=body, manual_body=manual_body, @@ -975,6 +1078,7 @@ class SxCPManSlot: "hardcore_clothing": ("STRING", {"default": ""}), }, "optional": { + "ethnicity_list": ("STRING", {"default": "", "multiline": True}), "character_cast": ("STRING", {"default": "", "multiline": True}), }, } @@ -1007,6 +1111,7 @@ class SxCPManSlot: softcore_outfit="", hardcore_clothing="", character_cast="", + ethnicity_list="", ): result = build_character_slot_json( subject_type="man", @@ -1014,7 +1119,7 @@ class SxCPManSlot: slot_seed=slot_seed, age=age, manual_age=manual_age, - ethnicity=ethnicity, + ethnicity=ethnicity_list or ethnicity, figure="random", body=body, manual_body=manual_body, @@ -1397,6 +1502,7 @@ class SxCPInstaOFPromptPair: "seed_config": ("STRING", {"default": "", "multiline": True}), "options_json": ("STRING", {"default": "", "multiline": True}), "filter_config": ("STRING", {"default": "", "multiline": True}), + "ethnicity_list": ("STRING", {"default": "", "multiline": True}), "camera_config": ("STRING", {"default": "", "multiline": True}), "softcore_camera_config": ("STRING", {"default": "", "multiline": True}), "hardcore_camera_config": ("STRING", {"default": "", "multiline": True}), @@ -1433,6 +1539,7 @@ class SxCPInstaOFPromptPair: seed_config="", options_json="", filter_config="", + ethnicity_list="", camera_config="", softcore_camera_config="", hardcore_camera_config="", @@ -1455,7 +1562,7 @@ class SxCPInstaOFPromptPair: prepend_trigger_to_prompt=prepend_trigger_to_prompt, seed_config=seed_config or "", options_json=options_json or "", - filter_config=filter_config or "", + filter_config=ethnicity_list or filter_config or "", camera_config=camera_config or "", softcore_camera_config=softcore_camera_config or "", hardcore_camera_config=hardcore_camera_config or "", @@ -1487,6 +1594,7 @@ NODE_CLASS_MAPPINGS = { "SxCPCategoryPreset": SxCPCategoryPreset, "SxCPCastControl": SxCPCastControl, "SxCPGenerationProfile": SxCPGenerationProfile, + "SxCPEthnicityList": SxCPEthnicityList, "SxCPAdvancedFilters": SxCPAdvancedFilters, "SxCPPromptBuilderFromConfigs": SxCPPromptBuilderFromConfigs, "SxCPWomanSlot": SxCPWomanSlot, @@ -1512,6 +1620,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SxCPCategoryPreset": "SxCP Category Preset", "SxCPCastControl": "SxCP Cast Control", "SxCPGenerationProfile": "SxCP Generation Profile", + "SxCPEthnicityList": "SxCP Ethnicity List", "SxCPAdvancedFilters": "SxCP Advanced Filters", "SxCPPromptBuilderFromConfigs": "SxCP Prompt Builder From Configs", "SxCPWomanSlot": "SxCP Woman Slot", diff --git a/generate_prompt_batches.py b/generate_prompt_batches.py index a1f88ba..274f9e7 100755 --- a/generate_prompt_batches.py +++ b/generate_prompt_batches.py @@ -2782,6 +2782,18 @@ WHITE_KEYWORDS = ( "french", "mediterranean", ) +WESTERN_EUROPEAN_KEYWORDS = ("western european", "french", "germanic", "german", "dutch") +FRENCH_EUROPEAN_KEYWORDS = ("french",) +GERMANIC_EUROPEAN_KEYWORDS = ("germanic", "german") +NORDIC_EUROPEAN_KEYWORDS = ("nordic", "swedish", "norwegian", "danish", "scandinavian") +CELTIC_EUROPEAN_KEYWORDS = ("celtic", "irish", "scottish") +SLAVIC_EUROPEAN_KEYWORDS = ("slavic", "polish", "ukrainian") +BALTIC_EUROPEAN_KEYWORDS = ("baltic",) +ALPINE_EUROPEAN_KEYWORDS = ("alpine",) +BALKAN_EUROPEAN_KEYWORDS = ("balkan",) +GREEK_MEDITERRANEAN_KEYWORDS = ("greek",) +ITALIAN_MEDITERRANEAN_KEYWORDS = ("italian",) +IBERIAN_MEDITERRANEAN_KEYWORDS = ("spanish", "portuguese", "iberian") EAST_ASIAN_KEYWORDS = ( "east asian", "japanese", @@ -2865,6 +2877,18 @@ ETHNICITY_KEYWORD_GROUPS = { "asian": ASIAN_KEYWORDS, "white_asian": WHITE_KEYWORDS + ASIAN_KEYWORDS, "european": WHITE_KEYWORDS, + "western_european": WESTERN_EUROPEAN_KEYWORDS, + "french_european": FRENCH_EUROPEAN_KEYWORDS, + "germanic_european": GERMANIC_EUROPEAN_KEYWORDS, + "nordic_european": NORDIC_EUROPEAN_KEYWORDS, + "celtic_european": CELTIC_EUROPEAN_KEYWORDS, + "slavic_european": SLAVIC_EUROPEAN_KEYWORDS, + "baltic_european": BALTIC_EUROPEAN_KEYWORDS, + "alpine_european": ALPINE_EUROPEAN_KEYWORDS, + "balkan_european": BALKAN_EUROPEAN_KEYWORDS, + "greek_mediterranean": GREEK_MEDITERRANEAN_KEYWORDS, + "italian_mediterranean": ITALIAN_MEDITERRANEAN_KEYWORDS, + "iberian_mediterranean": IBERIAN_MEDITERRANEAN_KEYWORDS, "mediterranean_mena": MEDITERRANEAN_MENA_KEYWORDS, "latina": LATINA_KEYWORDS, "east_asian": EAST_ASIAN_KEYWORDS, diff --git a/prompt_builder.py b/prompt_builder.py index e055015..c37239e 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -76,7 +76,47 @@ ETHNICITY_FILTER_CHOICES = [ "mixed", "asian", "white_asian", + "western_european", + "french_european", + "germanic_european", + "nordic_european", + "celtic_european", + "slavic_european", + "baltic_european", + "alpine_european", + "balkan_european", + "greek_mediterranean", + "italian_mediterranean", + "iberian_mediterranean", ] +ETHNICITY_LIST_KEYS = tuple(choice for choice in ETHNICITY_FILTER_CHOICES if choice != "any") +ETHNICITY_BASE_LIST_KEYS = ( + "european", + "mediterranean_mena", + "latina", + "east_asian", + "southeast_asian", + "south_asian", + "black_african", + "indigenous", + "mixed", +) +EUROPEAN_REGIONAL_LIST_KEYS = ( + "western_european", + "french_european", + "germanic_european", + "nordic_european", + "celtic_european", + "slavic_european", + "baltic_european", + "alpine_european", + "balkan_european", +) +MEDITERRANEAN_REGIONAL_LIST_KEYS = ( + "greek_mediterranean", + "italian_mediterranean", + "iberian_mediterranean", +) CHARACTER_LABEL_CHOICES = [ "auto_chain", @@ -1217,7 +1257,7 @@ def build_filter_config_json( enabled_ethnicities.extend(f"exclude_{key}" for key in disabled_ethnicities) if 0 < len(selected_ethnicities) < len(include_flags): ethnicity = "+".join(enabled_ethnicities) - elif ethnicity not in ETHNICITY_FILTER_CHOICES: + elif not _is_valid_ethnicity_filter(ethnicity): ethnicity = "any" return json.dumps( { @@ -1234,6 +1274,120 @@ def build_filter_config_json( ) +def _ethnicity_text_from_value(value: Any) -> str: + if isinstance(value, dict): + return str(value.get("ethnicity") or "").strip() + text = str(value or "").strip() + if not text: + return "" + if text.startswith("{"): + try: + raw = json.loads(text) + except json.JSONDecodeError: + return text + if isinstance(raw, dict): + return str(raw.get("ethnicity") or "").strip() + return text + + +def _is_valid_ethnicity_filter(value: Any) -> bool: + text = _ethnicity_text_from_value(value) + return text == "any" or text in ETHNICITY_FILTER_CHOICES or "+" in text + + +def normalize_ethnicity_filter(value: Any, default: str = "any", allow_random: bool = False) -> str: + text = _ethnicity_text_from_value(value) + if text.lower() in CHARACTER_RANDOM_TOKENS: + return "random" if allow_random else default + return text if _is_valid_ethnicity_filter(text) else default + + +def build_ethnicity_list_json( + include_european: bool = False, + include_mediterranean_mena: bool = False, + include_latina: bool = False, + include_east_asian: bool = False, + include_southeast_asian: bool = False, + include_south_asian: bool = False, + include_black_african: bool = False, + include_indigenous: bool = False, + include_mixed: bool = False, + include_asian: bool = False, + include_white_asian: bool = False, + include_western_european: bool = False, + include_french_european: bool = False, + include_germanic_european: bool = False, + include_nordic_european: bool = False, + include_celtic_european: bool = False, + include_slavic_european: bool = False, + include_baltic_european: bool = False, + include_alpine_european: bool = False, + include_balkan_european: bool = False, + include_greek_mediterranean: bool = False, + include_italian_mediterranean: bool = False, + include_iberian_mediterranean: bool = False, + strict_excludes: bool = True, +) -> dict[str, str]: + include_flags = { + "european": include_european, + "mediterranean_mena": include_mediterranean_mena, + "latina": include_latina, + "east_asian": include_east_asian, + "southeast_asian": include_southeast_asian, + "south_asian": include_south_asian, + "black_african": include_black_african, + "indigenous": include_indigenous, + "mixed": include_mixed, + "asian": include_asian, + "white_asian": include_white_asian, + "western_european": include_western_european, + "french_european": include_french_european, + "germanic_european": include_germanic_european, + "nordic_european": include_nordic_european, + "celtic_european": include_celtic_european, + "slavic_european": include_slavic_european, + "baltic_european": include_baltic_european, + "alpine_european": include_alpine_european, + "balkan_european": include_balkan_european, + "greek_mediterranean": include_greek_mediterranean, + "italian_mediterranean": include_italian_mediterranean, + "iberian_mediterranean": include_iberian_mediterranean, + } + selected = [key for key in ETHNICITY_LIST_KEYS if include_flags.get(key)] + if not selected or set(selected) == set(ETHNICITY_LIST_KEYS): + ethnicity = "any" + else: + tokens = list(selected) + if strict_excludes: + protected: set[str] = set() + if "asian" in selected: + protected.update(("east_asian", "southeast_asian", "south_asian")) + if "white_asian" in selected: + protected.update(("european", "east_asian", "southeast_asian", "south_asian", "mixed")) + if any(key in selected for key in EUROPEAN_REGIONAL_LIST_KEYS): + protected.add("european") + if any(key in selected for key in MEDITERRANEAN_REGIONAL_LIST_KEYS): + protected.add("mediterranean_mena") + if "mixed" in selected: + protected.update(ETHNICITY_BASE_LIST_KEYS) + tokens.extend( + f"exclude_{key}" + for key in ETHNICITY_BASE_LIST_KEYS + if key not in selected and key not in protected + ) + ethnicity = "+".join(tokens) + filter_config = { + "ethnicity": ethnicity, + "ethnicity_includes": selected, + } + summary = "any ethnicity" if ethnicity == "any" else "ethnicity list: " + ", ".join(selected) + return { + "ethnicity": ethnicity, + "filter_config": json.dumps(filter_config, ensure_ascii=True, sort_keys=True), + "summary": summary, + } + + def _parse_filter_config(filter_config: str | dict[str, Any] | None) -> dict[str, Any]: defaults = { "ethnicity": "any", @@ -1248,15 +1402,18 @@ def _parse_filter_config(filter_config: str | dict[str, Any] | None) -> dict[str if isinstance(filter_config, dict): raw = filter_config else: - try: - raw = json.loads(str(filter_config)) - except json.JSONDecodeError as exc: - raise ValueError(f"Invalid filter_config JSON: {exc}") from exc + text = str(filter_config).strip() + if not text.startswith("{"): + raw = {"ethnicity": text} + else: + try: + raw = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid filter_config JSON: {exc}") from exc if not isinstance(raw, dict): raise ValueError("filter_config must be a JSON object") parsed = {**defaults, **raw} - ethnicity = str(parsed.get("ethnicity") or "any") - parsed["ethnicity"] = ethnicity if ethnicity == "any" or ethnicity in ETHNICITY_FILTER_CHOICES or "+" in ethnicity else "any" + parsed["ethnicity"] = normalize_ethnicity_filter(parsed.get("ethnicity"), "any") parsed["figure"] = parsed["figure"] if parsed.get("figure") in ("curvy", "balanced", "bombshell") else "curvy" parsed["include_plus_size"] = bool(parsed.get("include_plus_size")) parsed["include_black_african"] = bool(parsed.get("include_black_african")) @@ -2481,12 +2638,7 @@ def _slot_manual_or_choice(choice: str, manual_value: str) -> str: def _normalize_slot_ethnicity(value: Any) -> str: - text = str(value or "").strip() - if text.lower() in CHARACTER_RANDOM_TOKENS: - return "random" - if text == "any" or text in ETHNICITY_FILTER_CHOICES or "+" in text: - return text - return "random" + return normalize_ethnicity_filter(value, "random", allow_random=True) def _normalize_character_slot(slot: dict[str, Any]) -> dict[str, Any]: @@ -4308,7 +4460,7 @@ def build_prompt( start_index = max(1, int(start_index)) seed = int(seed) clothing = clothing if clothing in ("full", "minimal") else "full" - ethnicity = ethnicity if ethnicity == "any" or ethnicity in ETHNICITY_FILTER_CHOICES or "+" in str(ethnicity) else "any" + ethnicity = normalize_ethnicity_filter(ethnicity, "any") poses = poses if poses in ("standard", "evocative") else "standard" figure = figure if figure in ("curvy", "balanced", "bombshell") else "curvy" expression_enabled = not _is_false(expression_enabled)