Spaces:
Running
on
Zero
Running
on
Zero
import { app } from "../../../scripts/app.js"; | |
import { ComfyWidgets } from "../../../scripts/widgets.js"; | |
import { $el } from "../../../scripts/ui.js"; | |
import { api } from "../../../scripts/api.js"; | |
const CHECKPOINT_LOADER = "CheckpointLoader|pysssss"; | |
const LORA_LOADER = "LoraLoader|pysssss"; | |
function getType(node) { | |
if (node.comfyClass === CHECKPOINT_LOADER) { | |
return "checkpoints"; | |
} | |
return "loras"; | |
} | |
app.registerExtension({ | |
name: "pysssss.Combo++", | |
init() { | |
$el("style", { | |
textContent: ` | |
.litemenu-entry:hover .pysssss-combo-image { | |
display: block; | |
} | |
.pysssss-combo-image { | |
display: none; | |
position: absolute; | |
left: 0; | |
top: 0; | |
transform: translate(-100%, 0); | |
width: 384px; | |
height: 384px; | |
background-size: contain; | |
background-position: top right; | |
background-repeat: no-repeat; | |
filter: brightness(65%); | |
} | |
`, | |
parent: document.body, | |
}); | |
const submenuSetting = app.ui.settings.addSetting({ | |
id: "pysssss.Combo++.Submenu", | |
name: "🐍 Enable submenu in custom nodes", | |
defaultValue: true, | |
type: "boolean", | |
}); | |
// Ensure hook callbacks are available | |
const getOrSet = (target, name, create) => { | |
if (name in target) return target[name]; | |
return (target[name] = create()); | |
}; | |
const symbol = getOrSet(window, "__pysssss__", () => Symbol("__pysssss__")); | |
const store = getOrSet(window, symbol, () => ({})); | |
const contextMenuHook = getOrSet(store, "contextMenuHook", () => ({})); | |
for (const e of ["ctor", "preAddItem", "addItem"]) { | |
if (!contextMenuHook[e]) { | |
contextMenuHook[e] = []; | |
} | |
} | |
// // Checks if this is a custom combo item | |
const isCustomItem = (value) => value && typeof value === "object" && "image" in value && value.content; | |
// Simple check for what separator to split by | |
const splitBy = (navigator.platform || navigator.userAgent).includes("Win") ? /\/|\\/ : /\//; | |
contextMenuHook["ctor"].push(function (values, options) { | |
// Copy the class from the parent so if we are dark we are also dark | |
// this enables the filter box | |
if (options.parentMenu?.options?.className === "dark") { | |
options.className = "dark"; | |
} | |
}); | |
function encodeRFC3986URIComponent(str) { | |
return encodeURIComponent(str).replace( | |
/[!'()*]/g, | |
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, | |
); | |
} | |
// After an element is created for an item, add an image if it has one | |
contextMenuHook["addItem"].push(function (el, menu, [name, value, options]) { | |
if (el && isCustomItem(value) && value?.image && !value.submenu) { | |
el.textContent += " *"; | |
$el("div.pysssss-combo-image", { | |
parent: el, | |
style: { | |
backgroundImage: `url(/pysssss/view/${encodeRFC3986URIComponent(value.image)})`, | |
}, | |
}); | |
} | |
}); | |
function buildMenu(widget, values) { | |
const lookup = { | |
"": { options: [] }, | |
}; | |
// Split paths into menu structure | |
for (const value of values) { | |
const split = value.content.split(splitBy); | |
let path = ""; | |
for (let i = 0; i < split.length; i++) { | |
const s = split[i]; | |
const last = i === split.length - 1; | |
if (last) { | |
// Leaf node, manually add handler that sets the lora | |
lookup[path].options.push({ | |
...value, | |
title: s, | |
callback: () => { | |
widget.value = value; | |
widget.callback(value); | |
app.graph.setDirtyCanvas(true); | |
}, | |
}); | |
} else { | |
const prevPath = path; | |
path += s + splitBy; | |
if (!lookup[path]) { | |
const sub = { | |
title: s, | |
submenu: { | |
options: [], | |
title: s, | |
}, | |
}; | |
// Add to tree | |
lookup[path] = sub.submenu; | |
lookup[prevPath].options.push(sub); | |
} | |
} | |
} | |
} | |
return lookup[""].options; | |
} | |
// Override COMBO widgets to patch their values | |
const combo = ComfyWidgets["COMBO"]; | |
ComfyWidgets["COMBO"] = function (node, inputName, inputData) { | |
const type = inputData[0]; | |
const res = combo.apply(this, arguments); | |
if (isCustomItem(type[0])) { | |
let value = res.widget.value; | |
let values = res.widget.options.values; | |
let menu = null; | |
// Override the option values to check if we should render a menu structure | |
Object.defineProperty(res.widget.options, "values", { | |
get() { | |
let v = values; | |
if (submenuSetting.value) { | |
if (!menu) { | |
// Only build the menu once | |
menu = buildMenu(res.widget, values); | |
} | |
v = menu; | |
} | |
const valuesIncludes = v.includes; | |
v.includes = function (searchElement) { | |
const includesFromMenuItems = function (items) { | |
for (const item of items) { | |
if (includesFromMenuItem(item)) { | |
return true; | |
} | |
} | |
return false; | |
} | |
const includesFromMenuItem = function (item) { | |
if (item.submenu) { | |
return includesFromMenuItems(item.submenu.options) | |
} else { | |
return item.content === searchElement.content; | |
} | |
} | |
const includes = valuesIncludes.apply(this, arguments) || includesFromMenuItems(this); | |
return includes; | |
} | |
return v; | |
}, | |
set(v) { | |
// Options are changing (refresh) so reset the menu so it can be rebuilt if required | |
values = v; | |
menu = null; | |
}, | |
}); | |
Object.defineProperty(res.widget, "value", { | |
get() { | |
// HACK: litegraph supports rendering items with "content" in the menu, but not on the widget | |
// This detects when its being called by the widget drawing and just returns the text | |
// Also uses the content for the same image replacement value | |
if (res.widget) { | |
const stack = new Error().stack; | |
if (stack.includes("drawNodeWidgets") || stack.includes("saveImageExtraOutput")) { | |
return (value || type[0]).content; | |
} | |
} | |
return value; | |
}, | |
set(v) { | |
if (v?.submenu) { | |
// Dont allow selection of submenus | |
return; | |
} | |
value = v; | |
}, | |
}); | |
} | |
return res; | |
}; | |
}, | |
async beforeRegisterNodeDef(nodeType, nodeData, app) { | |
const isCkpt = nodeType.comfyClass === CHECKPOINT_LOADER; | |
const isLora = nodeType.comfyClass === LORA_LOADER; | |
if (isCkpt || isLora) { | |
const onAdded = nodeType.prototype.onAdded; | |
nodeType.prototype.onAdded = function () { | |
onAdded?.apply(this, arguments); | |
const { widget: exampleList } = ComfyWidgets["COMBO"](this, "example", [[""]], app); | |
let exampleWidget; | |
const get = async (route, suffix) => { | |
const url = encodeURIComponent(`${getType(nodeType)}${suffix || ""}`); | |
return await api.fetchApi(`/pysssss/${route}/${url}`); | |
}; | |
const getExample = async () => { | |
if (exampleList.value === "[none]") { | |
if (exampleWidget) { | |
exampleWidget.inputEl.remove(); | |
exampleWidget = null; | |
this.widgets.length -= 1; | |
} | |
return; | |
} | |
const v = this.widgets[0].value.content; | |
const pos = v.lastIndexOf("."); | |
const name = v.substr(0, pos); | |
let exampleName = exampleList.value; | |
let viewPath = `/${name}`; | |
if (exampleName === "notes") { | |
viewPath += ".txt"; | |
} else { | |
viewPath += `/${exampleName}`; | |
} | |
const example = await (await get("view", viewPath)).text(); | |
if (!exampleWidget) { | |
exampleWidget = ComfyWidgets["STRING"](this, "prompt", ["STRING", { multiline: true }], app).widget; | |
exampleWidget.inputEl.readOnly = true; | |
exampleWidget.inputEl.style.opacity = 0.6; | |
} | |
exampleWidget.value = example; | |
}; | |
const exampleCb = exampleList.callback; | |
exampleList.callback = function () { | |
getExample(); | |
return exampleCb?.apply(this, arguments) ?? exampleList.value; | |
}; | |
const listExamples = async () => { | |
exampleList.disabled = true; | |
exampleList.options.values = ["[none]"]; | |
exampleList.value = "[none]"; | |
let examples = []; | |
if (this.widgets[0].value?.content) { | |
try { | |
examples = await (await get("examples", `/${this.widgets[0].value.content}`)).json(); | |
} catch (error) {} | |
} | |
exampleList.options.values = ["[none]", ...examples]; | |
exampleList.value = exampleList.options.values[+!!examples.length]; | |
exampleList.callback(); | |
exampleList.disabled = !examples.length; | |
app.graph.setDirtyCanvas(true, true); | |
}; | |
// Expose function to update examples | |
nodeType.prototype["pysssss.updateExamples"] = listExamples; | |
const modelWidget = this.widgets[0]; | |
const modelCb = modelWidget.callback; | |
let prev = undefined; | |
modelWidget.callback = function () { | |
const ret = modelCb?.apply(this, arguments) ?? modelWidget.value; | |
let v = ret; | |
if (ret?.content) { | |
v = ret.content; | |
} | |
if (prev !== v) { | |
listExamples(); | |
prev = v; | |
} | |
return ret; | |
}; | |
setTimeout(() => { | |
modelWidget.callback(); | |
}, 30); | |
}; | |
// Prevent adding HIDDEN inputs | |
const addInput = nodeType.prototype.addInput ?? LGraphNode.prototype.addInput; | |
nodeType.prototype.addInput = function (_, type) { | |
if (type === "HIDDEN") return; | |
return addInput.apply(this, arguments); | |
}; | |
} | |
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; | |
nodeType.prototype.getExtraMenuOptions = function (_, options) { | |
if (this.imgs) { | |
// If this node has images then we add an open in new tab item | |
let img; | |
if (this.imageIndex != null) { | |
// An image is selected so select that | |
img = this.imgs[this.imageIndex]; | |
} else if (this.overIndex != null) { | |
// No image is selected but one is hovered | |
img = this.imgs[this.overIndex]; | |
} | |
if (img) { | |
const nodes = app.graph._nodes.filter( | |
(n) => n.comfyClass === LORA_LOADER || n.comfyClass === CHECKPOINT_LOADER | |
); | |
if (nodes.length) { | |
options.unshift({ | |
content: "Save as Preview", | |
submenu: { | |
options: nodes.map((n) => ({ | |
content: n.widgets[0].value.content, | |
callback: async () => { | |
const url = new URL(img.src); | |
const { image } = await api.fetchApi( | |
"/pysssss/save/" + encodeURIComponent(`${getType(n)}/${n.widgets[0].value.content}`), | |
{ | |
method: "POST", | |
body: JSON.stringify({ | |
filename: url.searchParams.get("filename"), | |
subfolder: url.searchParams.get("subfolder"), | |
type: url.searchParams.get("type"), | |
}), | |
headers: { | |
"content-type": "application/json", | |
}, | |
} | |
); | |
n.widgets[0].value.image = image; | |
app.refreshComboInNodes(); | |
}, | |
})), | |
}, | |
}); | |
} | |
} | |
} | |
return getExtraMenuOptions?.apply(this, arguments); | |
}; | |
}, | |
}); | |