diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 441a756..a1bc898 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -304,8 +304,12 @@ Already isolated: - direct and config-driven prompt builder nodes live in `node_builder.py`, with registration maps imported by `__init__.py`. -- seed/global-seed/seed-locker and SDXL/Krea2 resolution utility nodes live in - `node_seed_resolution.py`, with registration maps imported by `__init__.py`. +- seed axis salts/aliases, seed mode choices, lock builders, seed config + parsing, row seed math, and deterministic axis RNG live in `seed_config.py`; + seed/global-seed/seed-locker nodes live in `node_seed_resolution.py`, with + registration maps imported by `__init__.py`. +- SDXL/Krea2 resolution utility nodes live in `node_seed_resolution.py`, with + registration maps imported by `__init__.py`. - camera/orbit/Qwen translator utility nodes live in `node_camera.py`, using `camera_config.py` for option lists and JSON builders, with registration maps imported by `__init__.py`. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 6eeb404..1434b43 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -69,6 +69,7 @@ Core helper ownership: | --- | --- | | `category_library.py` | JSON category loading, subcategory normalization, named scene/expression/composition pool loading, cast compatibility filtering, exact subcategory lookup, and inheritance-based pool merging. | | `camera_config.py` | Camera option schema, direct/orbit/Qwen camera JSON builders, camera config parsing, plain camera directive text, and camera caption labels. | +| `seed_config.py` | Seed axis salts/aliases, seed mode choices, global/axis lock JSON builders, seed config parsing, row seed math, and deterministic axis RNG construction. | | `pair_options.py` | Insta/OF option schema/defaults, softcore category/outfit/pose pools, partner outfit pools, clothing-continuity labels, negatives, and hardcore cast count policy. | | `pair_rows.py` | Insta/OF soft/hard row creation, softcore expression override resolution, Woman A slot context application, soft outfit/pose overrides, and POV row fields. | | `pair_camera.py` | Insta/OF soft/hard camera route resolution, same-as-softcore camera mode, camera-detail override, camera-aware composition mutation, POV camera suppression, and synchronized row/root camera metadata. | @@ -124,8 +125,8 @@ These recipes identify the intended road before editing prompt text. ## Seed Axes -Seed routing is centralized around `SEED_AXIS_SALTS`, `SEED_AXIS_ALIASES`, and -`_axis_rng` in `prompt_builder.py`. +Seed routing is centralized in `seed_config.py` around `SEED_AXIS_SALTS`, +`SEED_AXIS_ALIASES`, and `axis_rng`. | Axis | Controls | | --- | --- | @@ -161,8 +162,9 @@ axes change. | Same soft/hard pair but different hardcore action | In pair mode, keep `person_seed`, `scene_seed`, `content_seed` if clothing must stay; change `pose_seed`/`role_seed`. | | Debug expression only | Fix everything except `expression_seed` or expression intensity. | -Common trap: `row_number` participates in `_axis_rng`. If two workflows have the -same seeds but different `row_number`, they are not expected to match. +Common trap: `row_number` participates in `seed_config.axis_rng`. If two +workflows have the same seeds but different `row_number`, they are not expected +to match. ## Category Sources @@ -699,7 +701,7 @@ These do not own prompt pool wording, but they affect execution and review: | 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. | | Builder node wrappers | `node_builder.py`, imported by `__init__.py` | Direct prompt builder and config-driven prompt builder ComfyUI declarations. | -| Seed and resolution utility nodes | `node_seed_resolution.py`, imported by `__init__.py` | Global/per-axis seed configs plus SDXL/Krea width/height helpers. | +| Seed and resolution utility nodes | `node_seed_resolution.py`, imported by `__init__.py` | UI wrappers for global/per-axis seed configs via `seed_config.py`, plus SDXL/Krea width/height helpers. | | Camera utility nodes | `node_camera.py`, imported by `__init__.py` | UI wrappers for direct camera config, orbit-to-camera config, and Qwen MultiAngle camera translation via `camera_config.py`. | | Character utility nodes | `node_character.py`, imported by `__init__.py` | Hair, age/body/eyes/clothing pools, manual details, character slots, and profile save/load nodes. | | Hardcore position utility nodes | `node_hardcore_position.py`, imported by `__init__.py` | Position-family pool and action/filter gates for hardcore routes. | diff --git a/node_seed_resolution.py b/node_seed_resolution.py index 3dae606..8808634 100644 --- a/node_seed_resolution.py +++ b/node_seed_resolution.py @@ -5,13 +5,13 @@ import math import random try: - from .prompt_builder import ( + from .seed_config 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 ( + from seed_config import ( build_seed_config_json, build_seed_lock_config_json, seed_mode_choices, diff --git a/prompt_builder.py b/prompt_builder.py index 5244b7b..105264d 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -32,6 +32,7 @@ try: from . import pair_rows from . import pair_options from . import scene_camera_adapters + from . import seed_config as seed_policy from .hardcore_text_cleanup import ( sanitize_hardcore_axis_values as _sanitize_hardcore_axis_values, sanitize_hardcore_environment_anchors as _sanitize_hardcore_environment_anchors, @@ -68,6 +69,7 @@ except ImportError: # Allows local smoke tests with `python -c`. import pair_rows import pair_options import scene_camera_adapters + import seed_config as seed_policy from hardcore_text_cleanup import ( sanitize_hardcore_axis_values as _sanitize_hardcore_axis_values, sanitize_hardcore_environment_anchors as _sanitize_hardcore_environment_anchors, @@ -94,41 +96,10 @@ BUILTIN_CATEGORIES = [ "custom_random", ] RANDOM_SUBCATEGORY = "random" -SEED_AXIS_SALTS = { - "category": 31, - "subcategory": 37, - "content": 41, - "person": 43, - "scene": 47, - "pose": 53, - "role": 57, - "expression": 59, - "composition": 61, -} -SEED_AXIS_ALIASES = { - "category": ("category_seed", "category"), - "subcategory": ("subcategory_seed", "subcategory"), - "content": ("content_seed", "item_seed", "outfit_seed", "sexual_pose_seed", "content"), - "person": ("person_seed", "appearance_seed", "cast_seed", "person"), - "scene": ("scene_seed", "scene"), - "pose": ("pose_seed", "sexual_pose_seed", "pose"), - "role": ("role_seed", "role", "pose_seed", "sexual_pose_seed"), - "expression": ("expression_seed", "face_seed", "expression"), - "composition": ("composition_seed", "camera_seed", "composition"), -} - -SEED_LOCK_AXES = ( - "category", - "subcategory", - "content", - "person", - "scene", - "pose", - "role", - "expression", - "composition", -) -SEED_MODE_CHOICES = ["auto", "follow_main", "fixed", "random"] +SEED_AXIS_SALTS = seed_policy.SEED_AXIS_SALTS +SEED_AXIS_ALIASES = seed_policy.SEED_AXIS_ALIASES +SEED_LOCK_AXES = seed_policy.SEED_LOCK_AXES +SEED_MODE_CHOICES = seed_policy.SEED_MODE_CHOICES ETHNICITY_FILTER_CHOICES = [ "any", @@ -1266,7 +1237,7 @@ def subcategory_choices() -> list[str]: def seed_mode_choices() -> list[str]: - return list(SEED_MODE_CHOICES) + return seed_policy.seed_mode_choices() CATEGORY_PRESETS = { @@ -2510,32 +2481,25 @@ def build_seed_config_json( expression_seed_mode: str = "auto", composition_seed_mode: str = "auto", ) -> str: - rng = random.SystemRandom() - - def axis_seed(value: int, mode: str) -> int: - mode = mode if mode in SEED_MODE_CHOICES else "auto" - if mode == "auto": - return int(value) - if mode == "random": - return rng.randint(0, 0xFFFFFFFF) - if mode == "fixed": - return max(0, int(value)) - return -1 - - return json.dumps( - { - "category_seed": axis_seed(category_seed, category_seed_mode), - "subcategory_seed": axis_seed(subcategory_seed, subcategory_seed_mode), - "content_seed": axis_seed(content_seed, content_seed_mode), - "person_seed": axis_seed(person_seed, person_seed_mode), - "scene_seed": axis_seed(scene_seed, scene_seed_mode), - "pose_seed": axis_seed(pose_seed, pose_seed_mode), - "role_seed": axis_seed(role_seed, role_seed_mode), - "expression_seed": axis_seed(expression_seed, expression_seed_mode), - "composition_seed": axis_seed(composition_seed, composition_seed_mode), - }, - ensure_ascii=True, - sort_keys=True, + return seed_policy.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, ) @@ -2544,64 +2508,23 @@ def build_seed_lock_config_json( reroll_axis: str = "none", reroll_seed: int = -1, ) -> str: - base_seed = int(base_seed) - reroll_seed = int(reroll_seed) - reroll_groups = { - "none": (), - "category": ("category",), - "subcategory": ("subcategory",), - "content": ("content",), - "person": ("person",), - "scene": ("scene",), - "pose": ("pose", "role"), - "role": ("role",), - "expression": ("expression",), - "composition": ("composition",), - "content_pose": ("content", "pose", "role"), - "scene_pose": ("scene", "pose", "role"), - } - reroll = set(reroll_groups.get(str(reroll_axis or "none"), ())) - config: dict[str, int] = {} - for axis in SEED_LOCK_AXES: - config[f"{axis}_seed"] = reroll_seed if axis in reroll else base_seed - return json.dumps(config, ensure_ascii=True, sort_keys=True) + return seed_policy.build_seed_lock_config_json( + base_seed=base_seed, + reroll_axis=reroll_axis, + reroll_seed=reroll_seed, + ) def _parse_seed_config(seed_config: str | dict[str, Any] | None) -> dict[str, int]: - if not seed_config: - return {} - if isinstance(seed_config, dict): - raw = seed_config - else: - try: - raw = json.loads(str(seed_config)) - except json.JSONDecodeError as exc: - raise ValueError(f"Invalid seed_config JSON: {exc}") from exc - if not isinstance(raw, dict): - raise ValueError("seed_config must be a JSON object") - parsed: dict[str, int] = {} - for key, value in raw.items(): - try: - parsed[str(key)] = int(value) - except (TypeError, ValueError): - continue - return parsed + return seed_policy.parse_seed_config(seed_config) def _configured_axis_seed(seed_config: dict[str, int], axis: str) -> int | None: - for key in SEED_AXIS_ALIASES.get(axis, (axis,)): - value = seed_config.get(key) - if value is not None and value >= 0: - return value - return None + return seed_policy.configured_axis_seed(seed_config, axis) def _axis_rng(seed_config: dict[str, int], axis: str, base_seed: int, row_number: int) -> random.Random: - configured = _configured_axis_seed(seed_config, axis) - salt = SEED_AXIS_SALTS.get(axis, 0) - if configured is None: - return random.Random(_row_seed(base_seed, row_number, salt)) - return random.Random(_row_seed(configured, row_number, salt)) + return seed_policy.axis_rng(seed_config, axis, base_seed, row_number) def _is_pose_content_category(category: dict[str, Any], subcategory: dict[str, Any]) -> bool: @@ -3085,7 +3008,7 @@ def _apply_camera_config(row: dict[str, Any], camera_config: str | dict[str, Any def _row_seed(seed: int, row_number: int, salt: int = 0) -> int: - return int(seed) + int(row_number) * 1009 + salt * 9176 + return seed_policy.row_seed(seed, row_number, salt) def _pick_clothing_mode(rng: random.Random, clothing: str, minimal_ratio: float | None) -> str: diff --git a/seed_config.py b/seed_config.py new file mode 100644 index 0000000..42a3d22 --- /dev/null +++ b/seed_config.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import json +import random +from typing import Any + + +SEED_AXIS_SALTS = { + "category": 31, + "subcategory": 37, + "content": 41, + "person": 43, + "scene": 47, + "pose": 53, + "role": 57, + "expression": 59, + "composition": 61, +} + +SEED_AXIS_ALIASES = { + "category": ("category_seed", "category"), + "subcategory": ("subcategory_seed", "subcategory"), + "content": ("content_seed", "item_seed", "outfit_seed", "sexual_pose_seed", "content"), + "person": ("person_seed", "appearance_seed", "cast_seed", "person"), + "scene": ("scene_seed", "scene"), + "pose": ("pose_seed", "sexual_pose_seed", "pose"), + "role": ("role_seed", "role", "pose_seed", "sexual_pose_seed"), + "expression": ("expression_seed", "face_seed", "expression"), + "composition": ("composition_seed", "camera_seed", "composition"), +} + +SEED_LOCK_AXES = ( + "category", + "subcategory", + "content", + "person", + "scene", + "pose", + "role", + "expression", + "composition", +) +SEED_MODE_CHOICES = ["auto", "follow_main", "fixed", "random"] + + +def seed_mode_choices() -> list[str]: + return list(SEED_MODE_CHOICES) + + +def row_seed(seed: int, row_number: int, salt: int = 0) -> int: + return int(seed) + int(row_number) * 1009 + salt * 9176 + + +def build_seed_config_json( + category_seed: int = -1, + subcategory_seed: int = -1, + content_seed: int = -1, + person_seed: int = -1, + scene_seed: int = -1, + pose_seed: int = -1, + role_seed: int = -1, + expression_seed: int = -1, + composition_seed: int = -1, + category_seed_mode: str = "auto", + subcategory_seed_mode: str = "auto", + content_seed_mode: str = "auto", + person_seed_mode: str = "auto", + scene_seed_mode: str = "auto", + pose_seed_mode: str = "auto", + role_seed_mode: str = "auto", + expression_seed_mode: str = "auto", + composition_seed_mode: str = "auto", +) -> str: + rng = random.SystemRandom() + + def axis_seed(value: int, mode: str) -> int: + mode = mode if mode in SEED_MODE_CHOICES else "auto" + if mode == "auto": + return int(value) + if mode == "random": + return rng.randint(0, 0xFFFFFFFF) + if mode == "fixed": + return max(0, int(value)) + return -1 + + return json.dumps( + { + "category_seed": axis_seed(category_seed, category_seed_mode), + "subcategory_seed": axis_seed(subcategory_seed, subcategory_seed_mode), + "content_seed": axis_seed(content_seed, content_seed_mode), + "person_seed": axis_seed(person_seed, person_seed_mode), + "scene_seed": axis_seed(scene_seed, scene_seed_mode), + "pose_seed": axis_seed(pose_seed, pose_seed_mode), + "role_seed": axis_seed(role_seed, role_seed_mode), + "expression_seed": axis_seed(expression_seed, expression_seed_mode), + "composition_seed": axis_seed(composition_seed, composition_seed_mode), + }, + ensure_ascii=True, + sort_keys=True, + ) + + +def build_seed_lock_config_json( + base_seed: int = 20260614, + reroll_axis: str = "none", + reroll_seed: int = -1, +) -> str: + base_seed = int(base_seed) + reroll_seed = int(reroll_seed) + reroll_groups = { + "none": (), + "category": ("category",), + "subcategory": ("subcategory",), + "content": ("content",), + "person": ("person",), + "scene": ("scene",), + "pose": ("pose", "role"), + "role": ("role",), + "expression": ("expression",), + "composition": ("composition",), + "content_pose": ("content", "pose", "role"), + "scene_pose": ("scene", "pose", "role"), + } + reroll = set(reroll_groups.get(str(reroll_axis or "none"), ())) + config: dict[str, int] = {} + for axis in SEED_LOCK_AXES: + config[f"{axis}_seed"] = reroll_seed if axis in reroll else base_seed + return json.dumps(config, ensure_ascii=True, sort_keys=True) + + +def parse_seed_config(seed_config: str | dict[str, Any] | None) -> dict[str, int]: + if not seed_config: + return {} + if isinstance(seed_config, dict): + raw = seed_config + else: + try: + raw = json.loads(str(seed_config)) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid seed_config JSON: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError("seed_config must be a JSON object") + parsed: dict[str, int] = {} + for key, value in raw.items(): + try: + parsed[str(key)] = int(value) + except (TypeError, ValueError): + continue + return parsed + + +def configured_axis_seed(seed_config: dict[str, int], axis: str) -> int | None: + for key in SEED_AXIS_ALIASES.get(axis, (axis,)): + value = seed_config.get(key) + if value is not None and value >= 0: + return value + return None + + +def axis_rng(seed_config: dict[str, int], axis: str, base_seed: int, row_number: int) -> random.Random: + configured = configured_axis_seed(seed_config, axis) + salt = SEED_AXIS_SALTS.get(axis, 0) + if configured is None: + return random.Random(row_seed(base_seed, row_number, salt)) + return random.Random(row_seed(configured, row_number, salt)) diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 5f96ab9..793092f 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -29,6 +29,7 @@ import __init__ as sxcp_nodes # noqa: E402 import krea_formatter # noqa: E402 import prompt_builder as pb # noqa: E402 import sdxl_formatter # noqa: E402 +import seed_config # noqa: E402 Trigger = "sxcppnl7" @@ -1761,6 +1762,43 @@ def smoke_node_utility_registration() -> None: _expect(krea_config.get("width") == krea_width and krea_config.get("height") == krea_height, "Krea2 config_json dimensions mismatch") +def smoke_seed_config_policy() -> None: + _expect(pb.SEED_AXIS_SALTS is seed_config.SEED_AXIS_SALTS, "prompt_builder seed salts should delegate to seed_config") + _expect(pb.seed_mode_choices() == seed_config.seed_mode_choices(), "seed mode choices drifted from seed_config") + + fixed_config = json.loads( + pb.build_seed_config_json( + category_seed=-1, + content_seed=123, + pose_seed=456, + role_seed=789, + category_seed_mode="fixed", + content_seed_mode="fixed", + pose_seed_mode="follow_main", + role_seed_mode="auto", + ) + ) + _expect(fixed_config["category_seed"] == 0, "fixed seed mode should clamp negative seeds to zero") + _expect(fixed_config["content_seed"] == 123, "fixed seed mode should preserve positive seed") + _expect(fixed_config["pose_seed"] == -1, "follow_main seed mode should emit unlocked axis") + _expect(fixed_config["role_seed"] == 789, "auto seed mode should preserve numeric seed") + + parsed = pb._parse_seed_config({"item_seed": "44", "pose_seed": "55", "bad": "nope"}) + _expect(parsed == {"item_seed": 44, "pose_seed": 55}, "seed parser should keep integer-like values only") + _expect(pb._configured_axis_seed(parsed, "content") == 44, "content axis should honor item_seed alias") + _expect(pb._configured_axis_seed(parsed, "role") == 55, "role axis should honor pose seed alias") + + locked = json.loads(pb.build_seed_lock_config_json(base_seed=100, reroll_axis="content_pose", reroll_seed=999)) + _expect(locked["content_seed"] == 999, "content_pose reroll should alter content seed") + _expect(locked["pose_seed"] == 999 and locked["role_seed"] == 999, "content_pose reroll should alter pose and role seeds") + _expect(locked["scene_seed"] == 100, "content_pose reroll should leave scene locked") + + rng_a = pb._axis_rng({"content_seed": 123}, "content", 999, 7) + rng_b = seed_config.axis_rng({"content_seed": 123}, "content", 999, 7) + _expect(rng_a.random() == rng_b.random(), "prompt_builder axis RNG should delegate to seed_config") + _expect(pb._row_seed(123, 7, 41) == seed_config.row_seed(123, 7, 41), "row seed wrapper drifted from seed_config") + + def smoke_node_camera_registration() -> None: required_nodes = [ "SxCPCameraControl", @@ -2387,6 +2425,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("expression_disabled", smoke_no_expression_fallback), ("formatter_metadata_fixtures", smoke_formatter_metadata_fixtures), ("node_utility_registration", smoke_node_utility_registration), + ("seed_config_policy", smoke_seed_config_policy), ("node_camera_registration", smoke_node_camera_registration), ("node_route_config_registration", smoke_node_route_config_registration), ("node_character_registration", smoke_node_character_registration),