diff --git a/__init__.py b/__init__.py index 86513ee..2e2e697 100644 --- a/__init__.py +++ b/__init__.py @@ -67,6 +67,10 @@ try: NODE_CLASS_MAPPINGS as HARDCORE_POSITION_NODE_CLASS_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 ( NODE_CLASS_MAPPINGS as FORMATTER_NODE_CLASS_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_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 ( NODE_CLASS_MAPPINGS as FORMATTER_NODE_CLASS_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(CHARACTER_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(INSTA_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(CHARACTER_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(INSTA_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(ROUTE_CONFIG_NODE_DISPLAY_NAME_MAPPINGS) diff --git a/node_choice_board.py b/node_choice_board.py new file mode 100644 index 0000000..e367730 --- /dev/null +++ b/node_choice_board.py @@ -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", +} diff --git a/node_tooltips.py b/node_tooltips.py index 3997490..51e30ce 100644 --- a/node_tooltips.py +++ b/node_tooltips.py @@ -335,6 +335,17 @@ NODE_INPUT_TOOLTIPS = { "allow_anal": "Allow anal 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": { "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.", diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 7f8afd3..1d17f35 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -8956,6 +8956,7 @@ def smoke_node_scene_chain_registration() -> None: "SxCPHardcoreBranchOptions", "SxCPSceneOutput", "SxCPScenePairOutput", + "SxCPChoiceBoard", ] for node_name in required_nodes: _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, "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: diff --git a/web/choice_board.js b/web/choice_board.js new file mode 100644 index 0000000..2692d90 --- /dev/null +++ b/web/choice_board.js @@ -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; + }; + }, +});