diff --git a/src/nodes/outfit_builder.py b/src/nodes/outfit_builder.py new file mode 100644 index 0000000..938e3ec --- /dev/null +++ b/src/nodes/outfit_builder.py @@ -0,0 +1,231 @@ +from ..config import NODE_CATEGORY +from ..utils import clean_prompt, override +from ..utils_loader import clean_val + +SOURCE_MODES = ["both", "outfit", "character"] + + +def _merge_source(mode, character_val, outfit_val, join=", "): + """ + mode: "both" | "outfit" | "character" + """ + cv = clean_val(character_val) + ov = clean_val(outfit_val) + + if mode == "character": + return cv + if mode == "outfit": + return ov + + if cv and ov: + return clean_prompt(cv + join + ov) + + return cv or ov + + +class OutfitBuilder: + CATEGORY = NODE_CATEGORY + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "preset_data": ("STRING", {"default": "{}"}), + "hat_override": ("STRING", {"default": ""}), + "hat_override_mode": ("BOOLEAN", {"default": False}), + "facewear_override": ("STRING", {"default": ""}), + "facewear_override_mode": ("BOOLEAN", {"default": False}), + "facewear_source": (SOURCE_MODES,), + "neckwear_override": ("STRING", {"default": ""}), + "neckwear_override_mode": ("BOOLEAN", {"default": False}), + "neckwear_source": (SOURCE_MODES,), + "coat_override": ("STRING", {"default": ""}), + "coat_override_mode": ("BOOLEAN", {"default": False}), + "midlayer_override": ("STRING", {"default": ""}), + "midlayer_override_mode": ("BOOLEAN", {"default": False}), + "top_override": ("STRING", {"default": ""}), + "top_override_mode": ("BOOLEAN", {"default": False}), + "armwear_override": ("STRING", {"default": ""}), + "armwear_override_mode": ("BOOLEAN", {"default": False}), + "armwear_source": (SOURCE_MODES,), + "accessories_override": ("STRING", {"default": ""}), + "accessories_override_mode": ("BOOLEAN", {"default": False}), + "bottom_override": ("STRING", {"default": ""}), + "bottom_override_mode": ("BOOLEAN", {"default": False}), + "legwear_override": ("STRING", {"default": ""}), + "legwear_override_mode": ("BOOLEAN", {"default": False}), + "footwear_override": ("STRING", {"default": ""}), + "footwear_override_mode": ("BOOLEAN", {"default": False}), + "use_bypass": ("BOOLEAN", {"default": False}), + "bypass_options": ( + "STRING", + {"multiline": True, "default": ""}, + ), + "bypass_selected": ("STRING", {"default": ""}), + }, + "optional": { + "hat": ("STRING", {"forceInput": True}), + "facewear": ("STRING", {"forceInput": True}), + "neckwear": ("STRING", {"forceInput": True}), + "coat": ("STRING", {"forceInput": True}), + "midlayer": ("STRING", {"forceInput": True}), + "top": ("STRING", {"forceInput": True}), + "armwear": ("STRING", {"forceInput": True}), + "accessories": ("STRING", {"forceInput": True}), + "bottom": ("STRING", {"forceInput": True}), + "legwear": ("STRING", {"forceInput": True}), + "footwear": ("STRING", {"forceInput": True}), + "facewear_modifiers": ("STRING", {"forceInput": True}), + "neckwear_modifiers": ("STRING", {"forceInput": True}), + "armwear_modifiers": ("STRING", {"forceInput": True}), + "top_modifiers": ("STRING", {"forceInput": True}), + "bottom_modifiers": ("STRING", {"forceInput": True}), + "outfit_modifiers": ("STRING", {"forceInput": True}), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("outfit",) + FUNCTION = "build" + + def build( + self, + preset_data, + hat_override, + hat_override_mode, + facewear_override, + facewear_override_mode, + facewear_source, + neckwear_override, + neckwear_override_mode, + neckwear_source, + coat_override, + coat_override_mode, + midlayer_override, + midlayer_override_mode, + top_override, + top_override_mode, + armwear_override, + armwear_override_mode, + armwear_source, + accessories_override, + accessories_override_mode, + bottom_override, + bottom_override_mode, + legwear_override, + legwear_override_mode, + footwear_override, + footwear_override_mode, + use_bypass, + bypass_options, + bypass_selected, + # Optional + hat="", + facewear="", + neckwear="", + coat="", + midlayer="", + top="", + armwear="", + accessories="", + bottom="", + legwear="", + footwear="", + facewear_modifiers="", + neckwear_modifiers="", + armwear_modifiers="", + top_modifiers="", + bottom_modifiers="", + outfit_modifiers="", + ): + if use_bypass: + return (clean_val(bypass_selected),) + + hat_out = override(hat_override, hat_override_mode, clean_val(hat)) + + facewear_base = _merge_source(facewear_source, facewear_modifiers, facewear) + facewear_out = override( + facewear_override, facewear_override_mode, facewear_base + ) + + neckwear_base = _merge_source(neckwear_source, neckwear_modifiers, neckwear) + neckwear_out = override( + neckwear_override, neckwear_override_mode, neckwear_base + ) + + coat_out = override(coat_override, coat_override_mode, clean_val(coat)) + + midlayer_out = override( + midlayer_override, midlayer_override_mode, clean_val(midlayer) + ) + + top_out = override(top_override, top_override_mode, clean_val(top)) + + armwear_base = _merge_source(armwear_source, armwear_modifiers, armwear) + armwear_out = override(armwear_override, armwear_override_mode, armwear_base) + + accessories_out = override( + accessories_override, accessories_override_mode, clean_val(accessories) + ) + + bottom_out = override(bottom_override, bottom_override_mode, clean_val(bottom)) + + legwear_out = override( + legwear_override, legwear_override_mode, clean_val(legwear) + ) + + footwear_out = override( + footwear_override, footwear_override_mode, clean_val(footwear) + ) + + # --- Output assembly --- + groups = [] + + if v := clean_val(hat_out): + groups.append(f"({v})") + + facewear_neckwear = [p for p in [facewear_out, neckwear_out] if p] + if facewear_neckwear: + groups.append(f"({', '.join(facewear_neckwear)})") + + if v := clean_val(coat_out): + groups.append(f"({v})") + + if v := clean_val(midlayer_out): + groups.append(f"({v})") + + top_group = [p for p in [top_out, clean_val(top_modifiers)] if p] + if top_group: + groups.append(f"({', '.join(top_group)})") + + if v := clean_val(armwear_out): + groups.append(f"({v})") + + if v := clean_val(accessories_out): + groups.append(f"({v})") + + bottom_group = [p for p in [bottom_out, clean_val(bottom_modifiers)] if p] + if bottom_group: + groups.append(f"({', '.join(bottom_group)})") + + if v := clean_val(legwear_out): + groups.append(f"({v})") + + if v := clean_val(footwear_out): + groups.append(f"({v})") + + if v := clean_val(outfit_modifiers): + groups.append(f"({v})") + + outfit_out = clean_prompt(", ".join(groups)) + + return (outfit_out,) + + +NODE_CLASS_MAPPINGS = { + "OutfitBuilder": OutfitBuilder, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "OutfitBuilder": "Outfit Builder", +} diff --git a/web/outfit_builder.js b/web/outfit_builder.js new file mode 100644 index 0000000..106929e --- /dev/null +++ b/web/outfit_builder.js @@ -0,0 +1,191 @@ +import { COLORS, DISABLED_OPACITY } from "./constants.js"; +import { attachInlineSelector } from "./inline_selector.js"; +import { createPresetManager } from "./preset_manager.js"; +import { + drawGroupBorder, + findWidget, + hideWidget, + hookWidget, + registerNode, + waitForWidgets, +} from "./utils.js"; +import { replaceWithInlineTextToggle } from "./widgets/inline_text_toggle_widget.js"; + +// --- Constants --- + +const NODE_NAME = "OutfitBuilder"; + +const OVERRIDE_FIELDS = [ + "hat_override", + "facewear_override", + "neckwear_override", + "coat_override", + "midlayer_override", + "top_override", + "armwear_override", + "accessories_override", + "bottom_override", + "legwear_override", + "footwear_override", +]; + +const SOURCE_FIELDS = ["facewear_source", "neckwear_source", "armwear_source"]; + +const BOOL_FIELDS = ["use_bypass", ...OVERRIDE_FIELDS.map((f) => `${f}_mode`)]; + +const setInlineSelectorDisabled = (selectorInstance, disabled) => { + if (!selectorInstance?.editor) return; + selectorInstance.editor.style.opacity = disabled + ? `${DISABLED_OPACITY}` + : "1"; + selectorInstance.editor.style.pointerEvents = disabled ? "none" : "auto"; +}; + +const applyQuickOverrideVisibility = ( + node, + toggleWidgets, + quickSelectorInstance, +) => { + const useQuick = findWidget(node, "use_bypass")?.value ?? false; + + for (const field of OVERRIDE_FIELDS) { + const helper = toggleWidgets[field]; + if (helper) helper.disabled = useQuick; + } + for (const field of SOURCE_FIELDS) { + const w = findWidget(node, field); + if (w) w.disabled = useQuick; + } + + setInlineSelectorDisabled(quickSelectorInstance, !useQuick); + + if (node.graph) node.graph.setDirtyCanvas(true, true); +}; + +// --- Capture / Recall --- + +const captureState = (node) => { + const state = {}; + for (const field of BOOL_FIELDS) { + const w = findWidget(node, field); + if (w) state[field] = w.value ?? false; + } + for (const field of OVERRIDE_FIELDS) { + const w = findWidget(node, field); + if (w) state[field] = w.value ?? ""; + } + for (const field of SOURCE_FIELDS) { + const w = findWidget(node, field); + if (w) state[field] = w.value ?? "outfit"; + } + const optionsW = findWidget(node, "bypass_options"); + const selectedW = findWidget(node, "bypass_selected"); + if (optionsW) state.bypass_options = optionsW.value ?? ""; + if (selectedW) state.bypass_selected = selectedW.value ?? ""; + return state; +}; + +const recallState = (node, state) => { + const fields = [ + ...BOOL_FIELDS, + ...OVERRIDE_FIELDS, + ...SOURCE_FIELDS, + "bypass_options", + "bypass_selected", + ]; + for (const field of fields) { + if (!(field in state)) continue; + const w = findWidget(node, field); + if (!w) continue; + w.value = state[field]; + w.callback?.(state[field]); + } + if (node.graph) node.graph.setDirtyCanvas(true, true); +}; + +// --- Attach --- + +const attachOutfitBuilder = (node) => { + const toggleWidgets = {}; + for (const field of OVERRIDE_FIELDS) { + const textWidget = findWidget(node, field); + const modeWidget = findWidget(node, `${field}_mode`); + if (!textWidget || !modeWidget) continue; + toggleWidgets[field] = replaceWithInlineTextToggle( + node, + textWidget, + modeWidget, + ); + } + + const quickOptions = findWidget(node, "bypass_options"); + const quickSelected = findWidget(node, "bypass_selected"); + let quickSelectorInstance = null; + if (quickOptions && quickSelected) { + hideWidget(quickSelected); + attachInlineSelector(node, quickOptions, quickSelected, { + placeholder: "quick override", + }); + } + + const useQuickWidget = findWidget(node, "use_bypass"); + if (useQuickWidget) { + hookWidget(useQuickWidget, () => + applyQuickOverrideVisibility( + node, + toggleWidgets, + quickSelectorInstance, + ), + ); + } + applyQuickOverrideVisibility(node, toggleWidgets, quickSelectorInstance); + + const groups = [ + { from: "hat_override", to: "hat_override", color: COLORS.blue }, + { from: "facewear_override", to: "facewear_source" }, + { from: "neckwear_override", to: "neckwear_source" }, + { from: "top_override", to: "top_override", color: COLORS.blue }, + { from: "armwear_override", to: "armwear_source" }, + { from: "bottom_override", to: "bottom_override", color: COLORS.blue }, + { + from: "footwear_override", + to: "footwear_override", + color: COLORS.blue, + }, + ]; + const original = node.onDrawForeground; + node.onDrawForeground = function (ctx) { + if (original) original.call(this, ctx); + + for (const group of groups) { + drawGroupBorder( + ctx, + node, + group.from, + group.to, + group.color ?? COLORS.highlight, + ); + } + }; + + // --- Preset manager --- + const { presetRow, exportImportRow } = createPresetManager(node, { + nodeLabel: NODE_NAME, + onCapture: () => captureState(node), + onRecall: (state) => { + recallState(node, state); + applyQuickOverrideVisibility( + node, + toggleWidgets, + quickSelectorInstance, + ); + }, + }); + + node.widgets.push(presetRow); + node.widgets.push(exportImportRow); +}; + +// --- Registration --- + +registerNode(NODE_NAME, (node) => waitForWidgets(node, attachOutfitBuilder));