Add scene choice board node
This commit is contained in:
@@ -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