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; }; }, });