Add scene choice board node
This commit is contained in:
+10
@@ -67,6 +67,10 @@ try:
|
|||||||
NODE_CLASS_MAPPINGS as HARDCORE_POSITION_NODE_CLASS_MAPPINGS,
|
NODE_CLASS_MAPPINGS as HARDCORE_POSITION_NODE_CLASS_MAPPINGS,
|
||||||
NODE_DISPLAY_NAME_MAPPINGS as HARDCORE_POSITION_NODE_DISPLAY_NAME_MAPPINGS,
|
NODE_DISPLAY_NAME_MAPPINGS as HARDCORE_POSITION_NODE_DISPLAY_NAME_MAPPINGS,
|
||||||
)
|
)
|
||||||
|
from .node_choice_board import (
|
||||||
|
NODE_CLASS_MAPPINGS as CHOICE_BOARD_NODE_CLASS_MAPPINGS,
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS as CHOICE_BOARD_NODE_DISPLAY_NAME_MAPPINGS,
|
||||||
|
)
|
||||||
from .node_formatter import (
|
from .node_formatter import (
|
||||||
NODE_CLASS_MAPPINGS as FORMATTER_NODE_CLASS_MAPPINGS,
|
NODE_CLASS_MAPPINGS as FORMATTER_NODE_CLASS_MAPPINGS,
|
||||||
NODE_DISPLAY_NAME_MAPPINGS as FORMATTER_NODE_DISPLAY_NAME_MAPPINGS,
|
NODE_DISPLAY_NAME_MAPPINGS as FORMATTER_NODE_DISPLAY_NAME_MAPPINGS,
|
||||||
@@ -119,6 +123,10 @@ except ImportError:
|
|||||||
NODE_CLASS_MAPPINGS as HARDCORE_POSITION_NODE_CLASS_MAPPINGS,
|
NODE_CLASS_MAPPINGS as HARDCORE_POSITION_NODE_CLASS_MAPPINGS,
|
||||||
NODE_DISPLAY_NAME_MAPPINGS as HARDCORE_POSITION_NODE_DISPLAY_NAME_MAPPINGS,
|
NODE_DISPLAY_NAME_MAPPINGS as HARDCORE_POSITION_NODE_DISPLAY_NAME_MAPPINGS,
|
||||||
)
|
)
|
||||||
|
from node_choice_board import (
|
||||||
|
NODE_CLASS_MAPPINGS as CHOICE_BOARD_NODE_CLASS_MAPPINGS,
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS as CHOICE_BOARD_NODE_DISPLAY_NAME_MAPPINGS,
|
||||||
|
)
|
||||||
from node_formatter import (
|
from node_formatter import (
|
||||||
NODE_CLASS_MAPPINGS as FORMATTER_NODE_CLASS_MAPPINGS,
|
NODE_CLASS_MAPPINGS as FORMATTER_NODE_CLASS_MAPPINGS,
|
||||||
NODE_DISPLAY_NAME_MAPPINGS as FORMATTER_NODE_DISPLAY_NAME_MAPPINGS,
|
NODE_DISPLAY_NAME_MAPPINGS as FORMATTER_NODE_DISPLAY_NAME_MAPPINGS,
|
||||||
@@ -205,6 +213,7 @@ NODE_CLASS_MAPPINGS.update(SEED_RESOLUTION_NODE_CLASS_MAPPINGS)
|
|||||||
NODE_CLASS_MAPPINGS.update(CAMERA_NODE_CLASS_MAPPINGS)
|
NODE_CLASS_MAPPINGS.update(CAMERA_NODE_CLASS_MAPPINGS)
|
||||||
NODE_CLASS_MAPPINGS.update(CHARACTER_NODE_CLASS_MAPPINGS)
|
NODE_CLASS_MAPPINGS.update(CHARACTER_NODE_CLASS_MAPPINGS)
|
||||||
NODE_CLASS_MAPPINGS.update(HARDCORE_POSITION_NODE_CLASS_MAPPINGS)
|
NODE_CLASS_MAPPINGS.update(HARDCORE_POSITION_NODE_CLASS_MAPPINGS)
|
||||||
|
NODE_CLASS_MAPPINGS.update(CHOICE_BOARD_NODE_CLASS_MAPPINGS)
|
||||||
NODE_CLASS_MAPPINGS.update(FORMATTER_NODE_CLASS_MAPPINGS)
|
NODE_CLASS_MAPPINGS.update(FORMATTER_NODE_CLASS_MAPPINGS)
|
||||||
NODE_CLASS_MAPPINGS.update(INSTA_NODE_CLASS_MAPPINGS)
|
NODE_CLASS_MAPPINGS.update(INSTA_NODE_CLASS_MAPPINGS)
|
||||||
NODE_CLASS_MAPPINGS.update(ROUTE_CONFIG_NODE_CLASS_MAPPINGS)
|
NODE_CLASS_MAPPINGS.update(ROUTE_CONFIG_NODE_CLASS_MAPPINGS)
|
||||||
@@ -219,6 +228,7 @@ 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(CAMERA_NODE_DISPLAY_NAME_MAPPINGS)
|
||||||
NODE_DISPLAY_NAME_MAPPINGS.update(CHARACTER_NODE_DISPLAY_NAME_MAPPINGS)
|
NODE_DISPLAY_NAME_MAPPINGS.update(CHARACTER_NODE_DISPLAY_NAME_MAPPINGS)
|
||||||
NODE_DISPLAY_NAME_MAPPINGS.update(HARDCORE_POSITION_NODE_DISPLAY_NAME_MAPPINGS)
|
NODE_DISPLAY_NAME_MAPPINGS.update(HARDCORE_POSITION_NODE_DISPLAY_NAME_MAPPINGS)
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS.update(CHOICE_BOARD_NODE_DISPLAY_NAME_MAPPINGS)
|
||||||
NODE_DISPLAY_NAME_MAPPINGS.update(FORMATTER_NODE_DISPLAY_NAME_MAPPINGS)
|
NODE_DISPLAY_NAME_MAPPINGS.update(FORMATTER_NODE_DISPLAY_NAME_MAPPINGS)
|
||||||
NODE_DISPLAY_NAME_MAPPINGS.update(INSTA_NODE_DISPLAY_NAME_MAPPINGS)
|
NODE_DISPLAY_NAME_MAPPINGS.update(INSTA_NODE_DISPLAY_NAME_MAPPINGS)
|
||||||
NODE_DISPLAY_NAME_MAPPINGS.update(ROUTE_CONFIG_NODE_DISPLAY_NAME_MAPPINGS)
|
NODE_DISPLAY_NAME_MAPPINGS.update(ROUTE_CONFIG_NODE_DISPLAY_NAME_MAPPINGS)
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .character_slot import parse_character_cast
|
||||||
|
from .hardcore_position_config import (
|
||||||
|
build_hardcore_position_pool_json,
|
||||||
|
hardcore_position_family_choices,
|
||||||
|
hardcore_position_key_choices,
|
||||||
|
normalize_hardcore_position_family,
|
||||||
|
normalize_hardcore_position_values,
|
||||||
|
)
|
||||||
|
from .location_config import build_composition_pool_json, build_location_pool_json
|
||||||
|
except ImportError: # Allows local smoke tests from the repository root.
|
||||||
|
from character_slot import parse_character_cast
|
||||||
|
from hardcore_position_config import (
|
||||||
|
build_hardcore_position_pool_json,
|
||||||
|
hardcore_position_family_choices,
|
||||||
|
hardcore_position_key_choices,
|
||||||
|
normalize_hardcore_position_family,
|
||||||
|
normalize_hardcore_position_values,
|
||||||
|
)
|
||||||
|
from location_config import build_composition_pool_json, build_location_pool_json
|
||||||
|
|
||||||
|
|
||||||
|
SXCP_LOCATION_CONFIG = "SXCP_LOCATION_CONFIG"
|
||||||
|
SXCP_COMPOSITION_CONFIG = "SXCP_COMPOSITION_CONFIG"
|
||||||
|
SXCP_HARDCORE_POSITION_CONFIG = "SXCP_HARDCORE_POSITION_CONFIG"
|
||||||
|
SXCP_CHARACTER_CAST = "SXCP_CHARACTER_CAST"
|
||||||
|
|
||||||
|
LOCK_CHOICES = [
|
||||||
|
"none",
|
||||||
|
"location_from_softcore",
|
||||||
|
"location_from_hardcore",
|
||||||
|
"composition_from_softcore",
|
||||||
|
"composition_from_hardcore",
|
||||||
|
"hardcore_position_current",
|
||||||
|
"softcore_outfit_current",
|
||||||
|
]
|
||||||
|
WARDROBE_SUBJECTS = ["Woman A", "Man A", "all women", "all men", "all"]
|
||||||
|
|
||||||
|
|
||||||
|
def _loads(value: Any) -> dict[str, Any]:
|
||||||
|
if not value:
|
||||||
|
return {}
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
loaded = json.loads(str(value))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
return loaded if isinstance(loaded, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _dump(value: Any) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=True, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _row(meta: dict[str, Any], key: str) -> dict[str, Any]:
|
||||||
|
value = meta.get(key)
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _first_text(*values: Any) -> str:
|
||||||
|
for value in values:
|
||||||
|
text = _text(value)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _first_position_key(row: dict[str, Any], meta: dict[str, Any]) -> str:
|
||||||
|
values: list[Any] = []
|
||||||
|
if row.get("position_key") is not None:
|
||||||
|
values.append(row.get("position_key"))
|
||||||
|
if row.get("position_keys") is not None:
|
||||||
|
position_keys = row.get("position_keys")
|
||||||
|
values.extend(position_keys if isinstance(position_keys, list) else [position_keys])
|
||||||
|
config = meta.get("hardcore_position_config")
|
||||||
|
if isinstance(config, dict) and config.get("positions") is not None:
|
||||||
|
positions = config.get("positions")
|
||||||
|
values.extend(positions if isinstance(positions, list) else [positions])
|
||||||
|
selected = normalize_hardcore_position_values(values)
|
||||||
|
return selected[0] if selected else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _position_family(row: dict[str, Any], meta: dict[str, Any]) -> str:
|
||||||
|
config = meta.get("hardcore_position_config")
|
||||||
|
raw_config_family = config.get("family") if isinstance(config, dict) else ""
|
||||||
|
return normalize_hardcore_position_family(
|
||||||
|
_first_text(row.get("position_family"), row.get("source_position_family"), raw_config_family),
|
||||||
|
"any",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _choice_rows(meta: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
soft = _row(meta, "softcore_row")
|
||||||
|
hard = _row(meta, "hardcore_row")
|
||||||
|
options = meta.get("options") if isinstance(meta.get("options"), dict) else {}
|
||||||
|
rows = [
|
||||||
|
("softcore.level", "softcore", "Softcore Level", options.get("softcore_level")),
|
||||||
|
("softcore.cast", "softcore", "Softcore Cast", options.get("softcore_cast")),
|
||||||
|
("softcore.outfit", "softcore", "Softcore Outfit", soft.get("item")),
|
||||||
|
("softcore.pose", "softcore", "Softcore Pose", soft.get("pose")),
|
||||||
|
("softcore.expression", "softcore", "Softcore Expression", soft.get("expression")),
|
||||||
|
("hardcore.level", "hardcore", "Hardcore Level", options.get("hardcore_level")),
|
||||||
|
("hardcore.cast", "hardcore", "Hardcore Cast", options.get("hardcore_cast")),
|
||||||
|
("hardcore.position_family", "hardcore", "Position Family", _position_family(hard, meta)),
|
||||||
|
("hardcore.position_key", "hardcore", "Position Key", _first_position_key(hard, meta)),
|
||||||
|
("hardcore.role_graph", "hardcore", "Role Graph", hard.get("role_graph")),
|
||||||
|
("hardcore.action", "hardcore", "Action Text", hard.get("item")),
|
||||||
|
("hardcore.expression", "hardcore", "Hardcore Expression", hard.get("expression")),
|
||||||
|
("scene.softcore", "scene", "Softcore Location", soft.get("scene_text")),
|
||||||
|
("scene.hardcore", "scene", "Hardcore Location", hard.get("scene_text")),
|
||||||
|
("composition.softcore", "composition", "Softcore Composition", soft.get("composition")),
|
||||||
|
("composition.hardcore", "composition", "Hardcore Composition", hard.get("composition")),
|
||||||
|
("camera.softcore", "camera", "Softcore Camera", meta.get("softcore_camera_directive")),
|
||||||
|
("camera.hardcore", "camera", "Hardcore Camera", meta.get("hardcore_camera_directive")),
|
||||||
|
("style.softcore", "style", "Softcore Style", soft.get("positive_suffix")),
|
||||||
|
("style.hardcore", "style", "Hardcore Style", hard.get("positive_suffix")),
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"axis": axis,
|
||||||
|
"branch": branch,
|
||||||
|
"label": label,
|
||||||
|
"value": _text(value),
|
||||||
|
"active": bool(_text(value)),
|
||||||
|
}
|
||||||
|
for axis, branch, label, value in rows
|
||||||
|
if _text(value)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_slots(meta: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
slots = meta.get("character_cast_slots")
|
||||||
|
if isinstance(slots, list):
|
||||||
|
return parse_character_cast(slots)
|
||||||
|
chain = meta.get("scene_chain") if isinstance(meta.get("scene_chain"), dict) else {}
|
||||||
|
for branch in ("hardcore", "softcore"):
|
||||||
|
branch_scene = chain.get(branch) if isinstance(chain.get(branch), dict) else {}
|
||||||
|
configs = branch_scene.get("configs") if isinstance(branch_scene.get("configs"), dict) else {}
|
||||||
|
parsed = parse_character_cast(configs.get("character_cast"))
|
||||||
|
if parsed:
|
||||||
|
return parsed
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_matches_subject(slot: dict[str, Any], subject: str) -> bool:
|
||||||
|
subject = _text(subject).lower()
|
||||||
|
subject_type = _text(slot.get("subject_type")).lower()
|
||||||
|
label = _text(slot.get("label")).upper()
|
||||||
|
if subject == "all":
|
||||||
|
return True
|
||||||
|
if subject == "all women":
|
||||||
|
return subject_type == "woman"
|
||||||
|
if subject == "all men":
|
||||||
|
return subject_type == "man"
|
||||||
|
if subject.startswith("woman "):
|
||||||
|
return subject_type == "woman" and label == subject.split(" ", 1)[1].upper()
|
||||||
|
if subject.startswith("man "):
|
||||||
|
return subject_type == "man" and label == subject.split(" ", 1)[1].upper()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _wardrobe_character_cast(
|
||||||
|
meta: dict[str, Any],
|
||||||
|
subject: str,
|
||||||
|
softcore_outfit: str,
|
||||||
|
hardcore_clothing: str,
|
||||||
|
) -> tuple[str, int]:
|
||||||
|
slots = _metadata_slots(meta)
|
||||||
|
if not slots:
|
||||||
|
return "", 0
|
||||||
|
changed = 0
|
||||||
|
for slot in slots:
|
||||||
|
if not _slot_matches_subject(slot, subject):
|
||||||
|
continue
|
||||||
|
if softcore_outfit:
|
||||||
|
slot["softcore_outfit"] = softcore_outfit
|
||||||
|
changed += 1
|
||||||
|
if hardcore_clothing:
|
||||||
|
slot["hardcore_clothing"] = hardcore_clothing
|
||||||
|
changed += 1
|
||||||
|
if not changed:
|
||||||
|
return _dump({"profile_type": "character_cast", "version": 1, "slots": slots}), 0
|
||||||
|
return _dump({"profile_type": "character_cast", "version": 1, "slots": slots}), changed
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_location(meta: dict[str, Any], lock_choice: str) -> str:
|
||||||
|
soft = _row(meta, "softcore_row")
|
||||||
|
hard = _row(meta, "hardcore_row")
|
||||||
|
if lock_choice == "location_from_softcore":
|
||||||
|
return _text(soft.get("scene_text"))
|
||||||
|
if lock_choice == "location_from_hardcore":
|
||||||
|
return _text(hard.get("scene_text"))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_composition(meta: dict[str, Any], lock_choice: str) -> str:
|
||||||
|
soft = _row(meta, "softcore_row")
|
||||||
|
hard = _row(meta, "hardcore_row")
|
||||||
|
if lock_choice == "composition_from_softcore":
|
||||||
|
return _text(soft.get("composition"))
|
||||||
|
if lock_choice == "composition_from_hardcore":
|
||||||
|
return _text(hard.get("composition"))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_parts(*parts: str) -> str:
|
||||||
|
clean = [part for part in (_text(part) for part in parts) if part]
|
||||||
|
return "; ".join(clean) if clean else "no overrides active"
|
||||||
|
|
||||||
|
|
||||||
|
class SxCPChoiceBoard:
|
||||||
|
OUTPUT_NODE = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"metadata_json": ("STRING", {"default": "", "multiline": True}),
|
||||||
|
"lock_choice": (LOCK_CHOICES, {"default": "none"}),
|
||||||
|
"location_override": ("STRING", {"default": "", "multiline": True}),
|
||||||
|
"composition_override": ("STRING", {"default": "", "multiline": True}),
|
||||||
|
"hardcore_position_family": (["auto"] + hardcore_position_family_choices(), {"default": "auto"}),
|
||||||
|
"hardcore_position_key": (["auto"] + hardcore_position_key_choices(), {"default": "auto"}),
|
||||||
|
"wardrobe_subject": (WARDROBE_SUBJECTS, {"default": "Woman A"}),
|
||||||
|
"softcore_outfit_override": ("STRING", {"default": "", "multiline": True}),
|
||||||
|
"hardcore_clothing_override": ("STRING", {"default": "", "multiline": True}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = (
|
||||||
|
SXCP_LOCATION_CONFIG,
|
||||||
|
SXCP_COMPOSITION_CONFIG,
|
||||||
|
SXCP_HARDCORE_POSITION_CONFIG,
|
||||||
|
SXCP_CHARACTER_CAST,
|
||||||
|
"STRING",
|
||||||
|
"STRING",
|
||||||
|
)
|
||||||
|
RETURN_NAMES = (
|
||||||
|
"location_config",
|
||||||
|
"composition_config",
|
||||||
|
"hardcore_position_config",
|
||||||
|
"character_cast",
|
||||||
|
"choice_board_json",
|
||||||
|
"summary",
|
||||||
|
)
|
||||||
|
FUNCTION = "build"
|
||||||
|
CATEGORY = "prompt_builder/v2_scene"
|
||||||
|
|
||||||
|
def build(
|
||||||
|
self,
|
||||||
|
metadata_json,
|
||||||
|
lock_choice,
|
||||||
|
location_override,
|
||||||
|
composition_override,
|
||||||
|
hardcore_position_family,
|
||||||
|
hardcore_position_key,
|
||||||
|
wardrobe_subject,
|
||||||
|
softcore_outfit_override,
|
||||||
|
hardcore_clothing_override,
|
||||||
|
):
|
||||||
|
meta = _loads(metadata_json)
|
||||||
|
soft = _row(meta, "softcore_row")
|
||||||
|
hard = _row(meta, "hardcore_row")
|
||||||
|
|
||||||
|
location_text = _text(location_override) or _lock_location(meta, lock_choice)
|
||||||
|
composition_text = _text(composition_override) or _lock_composition(meta, lock_choice)
|
||||||
|
|
||||||
|
family = _text(hardcore_position_family)
|
||||||
|
key = _text(hardcore_position_key)
|
||||||
|
if lock_choice == "hardcore_position_current":
|
||||||
|
if family == "auto":
|
||||||
|
family = _position_family(hard, meta)
|
||||||
|
if key == "auto":
|
||||||
|
key = _first_position_key(hard, meta)
|
||||||
|
if family == "auto":
|
||||||
|
family = "any"
|
||||||
|
if key == "auto":
|
||||||
|
key = ""
|
||||||
|
|
||||||
|
softcore_outfit = _text(softcore_outfit_override)
|
||||||
|
if lock_choice == "softcore_outfit_current" and not softcore_outfit:
|
||||||
|
softcore_outfit = _text(soft.get("item"))
|
||||||
|
hardcore_clothing = _text(hardcore_clothing_override)
|
||||||
|
|
||||||
|
location_config = (
|
||||||
|
build_location_pool_json(
|
||||||
|
enabled=True,
|
||||||
|
combine_mode="replace",
|
||||||
|
preset="custom_only",
|
||||||
|
custom_locations=location_text,
|
||||||
|
)
|
||||||
|
if location_text
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
composition_config = (
|
||||||
|
build_composition_pool_json(
|
||||||
|
enabled=True,
|
||||||
|
combine_mode="replace",
|
||||||
|
preset="custom_only",
|
||||||
|
custom_compositions=composition_text,
|
||||||
|
)
|
||||||
|
if composition_text
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
selected_positions = [key] if key else []
|
||||||
|
position_active = bool(selected_positions) or family != "any"
|
||||||
|
hardcore_position_config = (
|
||||||
|
build_hardcore_position_pool_json(
|
||||||
|
combine_mode="replace",
|
||||||
|
family=family,
|
||||||
|
selected_positions=selected_positions,
|
||||||
|
)
|
||||||
|
if position_active
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
character_cast, wardrobe_changes = _wardrobe_character_cast(
|
||||||
|
meta,
|
||||||
|
wardrobe_subject,
|
||||||
|
softcore_outfit,
|
||||||
|
hardcore_clothing,
|
||||||
|
) if softcore_outfit or hardcore_clothing else ("", 0)
|
||||||
|
|
||||||
|
board = {
|
||||||
|
"version": 1,
|
||||||
|
"choices": _choice_rows(meta),
|
||||||
|
"overrides": {
|
||||||
|
"lock_choice": lock_choice,
|
||||||
|
"location": location_text,
|
||||||
|
"composition": composition_text,
|
||||||
|
"hardcore_position_family": family if position_active else "",
|
||||||
|
"hardcore_position_key": key,
|
||||||
|
"wardrobe_subject": wardrobe_subject,
|
||||||
|
"softcore_outfit": softcore_outfit,
|
||||||
|
"hardcore_clothing": hardcore_clothing,
|
||||||
|
},
|
||||||
|
"outputs": {
|
||||||
|
"location_config": bool(location_config),
|
||||||
|
"composition_config": bool(composition_config),
|
||||||
|
"hardcore_position_config": bool(hardcore_position_config),
|
||||||
|
"character_cast": bool(character_cast),
|
||||||
|
"wardrobe_changes": wardrobe_changes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
summary = _summary_parts(
|
||||||
|
f"location locked" if location_config else "",
|
||||||
|
f"composition locked" if composition_config else "",
|
||||||
|
f"position locked ({family}{':' + key if key else ''})" if hardcore_position_config else "",
|
||||||
|
f"wardrobe updated x{wardrobe_changes}" if wardrobe_changes else "",
|
||||||
|
)
|
||||||
|
board_json = _dump(board)
|
||||||
|
return {
|
||||||
|
"ui": {
|
||||||
|
"choice_board_json": [board_json],
|
||||||
|
"summary": [summary],
|
||||||
|
},
|
||||||
|
"result": (
|
||||||
|
location_config,
|
||||||
|
composition_config,
|
||||||
|
hardcore_position_config,
|
||||||
|
character_cast,
|
||||||
|
board_json,
|
||||||
|
summary,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
"SxCPChoiceBoard": SxCPChoiceBoard,
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"SxCPChoiceBoard": "SxCP Choice Board",
|
||||||
|
}
|
||||||
@@ -335,6 +335,17 @@ NODE_INPUT_TOOLTIPS = {
|
|||||||
"allow_anal": "Allow anal subcategories.",
|
"allow_anal": "Allow anal subcategories.",
|
||||||
"allow_climax": "Allow cumshot/climax aftermath subcategories.",
|
"allow_climax": "Allow cumshot/climax aftermath subcategories.",
|
||||||
},
|
},
|
||||||
|
"SxCPChoiceBoard": {
|
||||||
|
"metadata_json": "Pair metadata from Scene Pair Output or Insta/OF Prompt Pair. The board reads the resolved choices just before prompt output.",
|
||||||
|
"lock_choice": "Use one current resolved choice as an override source, such as current location, current composition, current position, or current outfit.",
|
||||||
|
"location_override": "Manual exact location replacement. When set, it overrides the lock_choice location.",
|
||||||
|
"composition_override": "Manual exact composition replacement. When set, it overrides the lock_choice composition.",
|
||||||
|
"hardcore_position_family": "Optional hardcore position family override. auto uses the current metadata when lock_choice is hardcore_position_current.",
|
||||||
|
"hardcore_position_key": "Optional exact hardcore position key override. auto uses the current metadata when lock_choice is hardcore_position_current.",
|
||||||
|
"wardrobe_subject": "Character slot target for outfit/clothing overrides.",
|
||||||
|
"softcore_outfit_override": "Manual softcore outfit for the selected character slot. Empty keeps the current slot value unless lock_choice uses the current outfit.",
|
||||||
|
"hardcore_clothing_override": "Manual hardcore clothing/body exposure text for the selected character slot.",
|
||||||
|
},
|
||||||
"SxCPInstaOFOptions": {
|
"SxCPInstaOFOptions": {
|
||||||
"softcore_cast": "solo keeps softcore focused on Woman A; same_as_hardcore includes the same cast as the hardcore prompt.",
|
"softcore_cast": "solo keeps softcore focused on Woman A; same_as_hardcore includes the same cast as the hardcore prompt.",
|
||||||
"hardcore_cast": "use_counts reads hardcore_women_count/hardcore_men_count; presets set the counts automatically.",
|
"hardcore_cast": "use_counts reads hardcore_women_count/hardcore_men_count; presets set the counts automatically.",
|
||||||
|
|||||||
@@ -8956,6 +8956,7 @@ def smoke_node_scene_chain_registration() -> None:
|
|||||||
"SxCPHardcoreBranchOptions",
|
"SxCPHardcoreBranchOptions",
|
||||||
"SxCPSceneOutput",
|
"SxCPSceneOutput",
|
||||||
"SxCPScenePairOutput",
|
"SxCPScenePairOutput",
|
||||||
|
"SxCPChoiceBoard",
|
||||||
]
|
]
|
||||||
for node_name in required_nodes:
|
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_CLASS_MAPPINGS, f"{node_name} missing from node registry")
|
||||||
@@ -9245,6 +9246,39 @@ def smoke_node_scene_chain_registration() -> None:
|
|||||||
content_hard_seed_config.get("pose_seed") == 8899 and content_hard_seed_config.get("role_seed") == 8899,
|
content_hard_seed_config.get("pose_seed") == 8899 and content_hard_seed_config.get("role_seed") == 8899,
|
||||||
"Hardcore branch content_pose reroll did not reach hardcore pose and role seeds",
|
"Hardcore branch content_pose reroll did not reach hardcore pose and role seeds",
|
||||||
)
|
)
|
||||||
|
choice_board_node = nodes["SxCPChoiceBoard"]()
|
||||||
|
choice_board_output = choice_board_node.build(
|
||||||
|
json.dumps(content_pair),
|
||||||
|
"hardcore_position_current",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"auto",
|
||||||
|
"auto",
|
||||||
|
"Woman A",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
choice_result = choice_board_output["result"]
|
||||||
|
position_config = json.loads(choice_result[2])
|
||||||
|
board_json = json.loads(choice_result[4])
|
||||||
|
_expect(position_config.get("enabled"), "Choice Board did not emit active hardcore position config")
|
||||||
|
_expect(board_json.get("choices"), "Choice Board did not expose resolved choice rows")
|
||||||
|
location_board_output = choice_board_node.build(
|
||||||
|
json.dumps(content_pair),
|
||||||
|
"none",
|
||||||
|
"fixed private test location with repeated room anchors",
|
||||||
|
"",
|
||||||
|
"auto",
|
||||||
|
"auto",
|
||||||
|
"Woman A",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
location_config = json.loads(location_board_output["result"][0])
|
||||||
|
_expect(
|
||||||
|
location_config.get("scene_entries", [{}])[0].get("prompt") == "fixed private test location with repeated room anchors",
|
||||||
|
"Choice Board did not convert location override into a replace location config",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def smoke_node_builder_registration() -> None:
|
def smoke_node_builder_registration() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
|
||||||
|
const EXTENSION = "ethanfel.prompt_builder.choice_board";
|
||||||
|
const NODE_NAME = "SxCPChoiceBoard";
|
||||||
|
const STYLE_ID = "sxcp-choice-board-styles";
|
||||||
|
const MIN_W = 600;
|
||||||
|
const BOARD_H = 360;
|
||||||
|
|
||||||
|
function isChoiceBoardNode(node) {
|
||||||
|
return node?.comfyClass === NODE_NAME || node?.type === NODE_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeById(id) {
|
||||||
|
return app.graph?.getNodeById?.(Number(id)) || app.graph?._nodes_by_id?.[id] || app.graph?._nodes_by_id?.[Number(id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function widget(node, name) {
|
||||||
|
return node.widgets?.find((item) => item.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWidgetValue(node, name, value) {
|
||||||
|
const w = widget(node, name);
|
||||||
|
if (!w) return false;
|
||||||
|
w.value = value;
|
||||||
|
if (w.inputEl) w.inputEl.value = value;
|
||||||
|
w.callback?.(value, app.canvas, node);
|
||||||
|
node.setDirtyCanvas?.(true, true);
|
||||||
|
app.graph?.setDirtyCanvas?.(true, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputValue(output, name) {
|
||||||
|
const value = output?.[name];
|
||||||
|
if (Array.isArray(value)) return value[0] ?? "";
|
||||||
|
return value ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(value) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(String(value || ""));
|
||||||
|
return parsed && typeof parsed === "object" ? parsed : {};
|
||||||
|
} catch (_err) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueText(value, max = 180) {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (text.length <= max) return text;
|
||||||
|
return `${text.slice(0, max - 1)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockForChoice(choice) {
|
||||||
|
switch (choice?.axis) {
|
||||||
|
case "scene.softcore":
|
||||||
|
return "location_from_softcore";
|
||||||
|
case "scene.hardcore":
|
||||||
|
return "location_from_hardcore";
|
||||||
|
case "composition.softcore":
|
||||||
|
return "composition_from_softcore";
|
||||||
|
case "composition.hardcore":
|
||||||
|
return "composition_from_hardcore";
|
||||||
|
case "hardcore.position_family":
|
||||||
|
case "hardcore.position_key":
|
||||||
|
return "hardcore_position_current";
|
||||||
|
case "softcore.outfit":
|
||||||
|
return "softcore_outfit_current";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyChoice(node, choice) {
|
||||||
|
const lock = lockForChoice(choice);
|
||||||
|
if (!lock) return;
|
||||||
|
setWidgetValue(node, "lock_choice", lock);
|
||||||
|
if (choice.axis === "hardcore.position_family") {
|
||||||
|
setWidgetValue(node, "hardcore_position_family", choice.value || "auto");
|
||||||
|
} else if (choice.axis === "hardcore.position_key") {
|
||||||
|
setWidgetValue(node, "hardcore_position_key", choice.value || "auto");
|
||||||
|
}
|
||||||
|
renderBoard(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentLock(node) {
|
||||||
|
return String(widget(node, "lock_choice")?.value || "none");
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectStyles() {
|
||||||
|
if (document.getElementById(STYLE_ID)) return;
|
||||||
|
const css = `
|
||||||
|
.sxcb-wrap { display:flex; flex-direction:column; gap:6px; box-sizing:border-box; height:100%; padding:2px; }
|
||||||
|
.sxcb-toolbar { display:flex; align-items:center; gap:6px; min-height:24px; }
|
||||||
|
.sxcb-toolbar button { font-size:11px; padding:2px 8px; cursor:pointer; }
|
||||||
|
.sxcb-status { margin-left:auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; opacity:.75; }
|
||||||
|
.sxcb-board { flex:1 1 auto; min-height:0; overflow:auto; border-radius:4px; background:rgba(0,0,0,.16); padding:6px; box-sizing:border-box; }
|
||||||
|
.sxcb-empty { padding:12px; font-size:12px; opacity:.65; text-align:center; }
|
||||||
|
.sxcb-section { display:flex; flex-direction:column; gap:4px; margin:0 0 8px; }
|
||||||
|
.sxcb-section-title { font-size:11px; text-transform:uppercase; letter-spacing:.04em; opacity:.62; padding:0 2px; }
|
||||||
|
.sxcb-row { display:grid; grid-template-columns:128px minmax(0,1fr); gap:8px; align-items:start;
|
||||||
|
padding:5px 6px; border:1px solid rgba(255,255,255,.08); border-radius:4px;
|
||||||
|
background:rgba(255,255,255,.035); color:#ddd; }
|
||||||
|
.sxcb-row.sxcb-clickable { cursor:pointer; }
|
||||||
|
.sxcb-row.sxcb-clickable:hover { border-color:rgba(120,190,255,.55); background:rgba(90,150,220,.12); }
|
||||||
|
.sxcb-row.sxcb-locked { border-color:rgba(100,220,160,.72); background:rgba(60,150,100,.14); }
|
||||||
|
.sxcb-label { font-size:11px; color:#9eb0c0; line-height:1.35; }
|
||||||
|
.sxcb-value { font-size:12px; line-height:1.35; color:#f2f2f2; word-break:break-word; }
|
||||||
|
`;
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = STYLE_ID;
|
||||||
|
style.textContent = css;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupedChoices(node) {
|
||||||
|
const board = node._sxcbBoard || parseJson(widget(node, "choice_board_json")?.value);
|
||||||
|
const choices = Array.isArray(board?.choices) ? board.choices : [];
|
||||||
|
const groups = new Map();
|
||||||
|
for (const choice of choices) {
|
||||||
|
const branch = String(choice?.branch || "other");
|
||||||
|
if (!groups.has(branch)) groups.set(branch, []);
|
||||||
|
groups.get(branch).push(choice);
|
||||||
|
}
|
||||||
|
return {board, groups};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoard(node) {
|
||||||
|
const root = node._sxcbBoardEl;
|
||||||
|
if (!root) return;
|
||||||
|
root.replaceChildren();
|
||||||
|
const {board, groups} = groupedChoices(node);
|
||||||
|
const lock = currentLock(node);
|
||||||
|
const count = Array.isArray(board?.choices) ? board.choices.length : 0;
|
||||||
|
if (node._sxcbStatusEl) {
|
||||||
|
node._sxcbStatusEl.textContent = count ? `${count} choices; lock=${lock}` : "no executed choices";
|
||||||
|
node._sxcbStatusEl.title = node._sxcbStatusEl.textContent;
|
||||||
|
}
|
||||||
|
if (!count) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "sxcb-empty";
|
||||||
|
empty.textContent = "Run the node once to inspect choices.";
|
||||||
|
root.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [branch, choices] of groups.entries()) {
|
||||||
|
const section = document.createElement("div");
|
||||||
|
section.className = "sxcb-section";
|
||||||
|
const title = document.createElement("div");
|
||||||
|
title.className = "sxcb-section-title";
|
||||||
|
title.textContent = branch;
|
||||||
|
section.appendChild(title);
|
||||||
|
|
||||||
|
for (const choice of choices) {
|
||||||
|
const choiceLock = lockForChoice(choice);
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sxcb-row";
|
||||||
|
if (choiceLock) row.classList.add("sxcb-clickable");
|
||||||
|
if (choiceLock && choiceLock === lock) row.classList.add("sxcb-locked");
|
||||||
|
row.title = choiceLock ? `Click to set ${choiceLock}` : choice.axis;
|
||||||
|
row.onclick = () => applyChoice(node, choice);
|
||||||
|
|
||||||
|
const label = document.createElement("div");
|
||||||
|
label.className = "sxcb-label";
|
||||||
|
label.textContent = choice.label || choice.axis || "";
|
||||||
|
|
||||||
|
const value = document.createElement("div");
|
||||||
|
value.className = "sxcb-value";
|
||||||
|
value.textContent = valueText(choice.value, 260);
|
||||||
|
value.title = choice.value || "";
|
||||||
|
|
||||||
|
row.append(label, value);
|
||||||
|
section.appendChild(row);
|
||||||
|
}
|
||||||
|
root.appendChild(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOverrides(node) {
|
||||||
|
setWidgetValue(node, "lock_choice", "none");
|
||||||
|
setWidgetValue(node, "location_override", "");
|
||||||
|
setWidgetValue(node, "composition_override", "");
|
||||||
|
setWidgetValue(node, "hardcore_position_family", "auto");
|
||||||
|
setWidgetValue(node, "hardcore_position_key", "auto");
|
||||||
|
setWidgetValue(node, "softcore_outfit_override", "");
|
||||||
|
setWidgetValue(node, "hardcore_clothing_override", "");
|
||||||
|
renderBoard(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupNode(node) {
|
||||||
|
injectStyles();
|
||||||
|
if (!node._sxcbBoardEl && typeof node.addDOMWidget === "function") {
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "sxcb-wrap";
|
||||||
|
|
||||||
|
const board = document.createElement("div");
|
||||||
|
board.className = "sxcb-board";
|
||||||
|
|
||||||
|
const toolbar = document.createElement("div");
|
||||||
|
toolbar.className = "sxcb-toolbar";
|
||||||
|
|
||||||
|
const clear = document.createElement("button");
|
||||||
|
clear.textContent = "Clear";
|
||||||
|
clear.title = "Clear board override widgets";
|
||||||
|
clear.onclick = () => clearOverrides(node);
|
||||||
|
|
||||||
|
const refresh = document.createElement("button");
|
||||||
|
refresh.textContent = "Refresh";
|
||||||
|
refresh.title = "Refresh from current board JSON";
|
||||||
|
refresh.onclick = () => renderBoard(node);
|
||||||
|
|
||||||
|
const status = document.createElement("span");
|
||||||
|
status.className = "sxcb-status";
|
||||||
|
|
||||||
|
toolbar.append(clear, refresh, status);
|
||||||
|
wrap.append(board, toolbar);
|
||||||
|
|
||||||
|
node._sxcbBoardEl = board;
|
||||||
|
node._sxcbStatusEl = status;
|
||||||
|
node._sxcbWidget = node.addDOMWidget("choice_board", "div", wrap, {
|
||||||
|
serialize: false,
|
||||||
|
getMinHeight: () => BOARD_H,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const onResize = node.onResize;
|
||||||
|
if (!node._sxcbResizeWrapped) {
|
||||||
|
node.onResize = function () {
|
||||||
|
const result = onResize?.apply(this, arguments);
|
||||||
|
if (node._sxcbWidget) node._sxcbWidget.width = node.size?.[0] || MIN_W;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
node._sxcbResizeWrapped = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node._sxcbInitialSizeSet) {
|
||||||
|
node._sxcbInitialSizeSet = true;
|
||||||
|
node.setSize?.([Math.max(node.size?.[0] || 0, MIN_W), Math.max(node.size?.[1] || 0, 520)]);
|
||||||
|
}
|
||||||
|
if (node._sxcbWidget) node._sxcbWidget.width = node.size?.[0] || MIN_W;
|
||||||
|
renderBoard(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: EXTENSION,
|
||||||
|
|
||||||
|
async setup() {
|
||||||
|
api.addEventListener("executed", ({detail}) => {
|
||||||
|
const node = getNodeById(detail?.display_node ?? detail?.node);
|
||||||
|
if (!isChoiceBoardNode(node)) return;
|
||||||
|
const boardJson = outputValue(detail?.output || {}, "choice_board_json");
|
||||||
|
if (boardJson) {
|
||||||
|
node._sxcbBoard = parseJson(boardJson);
|
||||||
|
const w = widget(node, "choice_board_json");
|
||||||
|
if (w) w.value = boardJson;
|
||||||
|
}
|
||||||
|
renderBoard(node);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||||
|
if (nodeData.name !== NODE_NAME) return;
|
||||||
|
|
||||||
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
|
const result = onNodeCreated?.apply(this, arguments);
|
||||||
|
setupNode(this);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConfigure = nodeType.prototype.onConfigure;
|
||||||
|
nodeType.prototype.onConfigure = function () {
|
||||||
|
const result = onConfigure?.apply(this, arguments);
|
||||||
|
queueMicrotask(() => setupNode(this));
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user