update to support comfyui native web file handling

This commit is contained in:
pythongosssss
2023-08-23 17:31:11 +01:00
parent 4461d5e29a
commit e98a92ba5d
37 changed files with 31 additions and 9 deletions
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.
+315
View File
@@ -0,0 +1,315 @@
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: 256px;
height: 256px;
background-size: cover;
background-position: center;
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";
}
});
// 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/${encodeURIComponent(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() {
if (submenuSetting.value) {
if (!menu) {
// Only build the menu once
menu = buildMenu(res.widget, values);
}
return menu;
}
return values;
},
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
if (res.widget && new Error().stack.includes("drawNodeWidgets")) {
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);
const example = await (await get("view", `/${name}/${exampleList.value}`)).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.callback();
exampleList.disabled = !examples.length;
app.graph.setDirtyCanvas(true, true);
};
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);
};
}
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);
};
},
});
+99
View File
@@ -0,0 +1,99 @@
.pysssss-lightbox {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
z-index: 999;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
transition: opacity 0.2s;
}
.pysssss-lightbox-prev,
.pysssss-lightbox-next {
height: 60px;
padding: 10px;
display: flex;
align-items: center;
}
.pysssss-lightbox-prev:after,
.pysssss-lightbox-next:after {
border-style: solid;
border-width: 0.25em 0.25em 0 0;
display: inline-block;
height: 0.45em;
left: 0.15em;
position: relative;
top: 0.15em;
transform: rotate(-135deg) scale(0.75);
vertical-align: top;
width: 0.45em;
padding: 10px;
font-size: 20px;
margin: 0 10px 0 20px;
transition: color 0.2s;
flex-shrink: 0;
content: "";
}
.pysssss-lightbox-next:after {
transform: rotate(45deg) scale(0.75);
margin: 0 20px 0 0px;
}
.pysssss-lightbox-main {
flex: auto;
text-align: center;
}
.pysssss-lightbox-link {
display: inline-block;
position: relative;
}
.pysssss-lightbox .lds-ring {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.pysssss-lightbox-img {
max-height: 90vh;
max-width: calc(100vw - 130px);
height: auto;
object-fit: contain;
border: 3px solid white;
border-radius: 4px;
transition: opacity 0.2s;
user-select: none;
}
.pysssss-lightbox-img:hover {
border-color: dodgerblue;
}
.pysssss-lightbox-close {
font-size: 80px;
line-height: 1ch;
height: 1ch;
width: 1ch;
position: absolute;
right: 10px;
top: 10px;
padding: 5px;
}
.pysssss-lightbox-close:after {
content: "\00d7";
}
.pysssss-lightbox-close:hover,
.pysssss-lightbox-prev:hover,
.pysssss-lightbox-next:hover {
color: dodgerblue;
cursor: pointer;
}
+102
View File
@@ -0,0 +1,102 @@
import { $el } from "../../../../scripts/ui.js";
import { addStylesheet, getUrl, loadImage } from "./utils.js";
import { createSpinner } from "./spinner.js";
addStylesheet(getUrl("lightbox.css", import.meta.url));
const $$el = (tag, name, ...args) => {
if (name) name = "-" + name;
return $el(tag + ".pysssss-lightbox" + name, ...args);
};
const ani = async (a, t, b) => {
a();
await new Promise((r) => setTimeout(r, t));
b();
};
export class Lightbox {
constructor() {
this.el = $$el("div", "", {
parent: document.body,
onclick: (e) => {
e.stopImmediatePropagation();
this.close();
},
style: {
display: "none",
opacity: 0,
},
});
this.closeBtn = $$el("div", "close", {
parent: this.el,
});
this.prev = $$el("div", "prev", {
parent: this.el,
onclick: (e) => {
this.update(-1);
e.stopImmediatePropagation();
},
});
this.main = $$el("div", "main", {
parent: this.el,
});
this.next = $$el("div", "next", {
parent: this.el,
onclick: (e) => {
this.update(1);
e.stopImmediatePropagation();
},
});
this.link = $$el("a", "link", {
parent: this.main,
target: "_blank",
});
this.spinner = createSpinner();
this.link.appendChild(this.spinner);
this.img = $$el("img", "img", {
style: {
opacity: 0,
},
parent: this.link,
onclick: (e) => {
e.stopImmediatePropagation();
},
});
}
close() {
ani(
() => (this.el.style.opacity = 0),
200,
() => (this.el.style.display = "none")
);
}
async show(images, index) {
this.images = images;
this.index = index || 0;
await this.update(0);
}
async update(shift) {
this.index += shift;
this.prev.style.visibility = this.index ? "unset" : "hidden";
this.next.style.visibility = this.index === this.images.length - 1 ? "hidden" : "unset";
const img = this.images[this.index];
this.el.style.display = "flex";
this.el.clientWidth; // Force a reflow
this.el.style.opacity = 1;
this.img.style.opacity = 0;
this.spinner.style.display = "inline-block";
await loadImage(img);
this.spinner.style.display = "none";
this.link.href = img;
this.img.src = img;
this.img.style.opacity = 1;
}
}
export const lightbox = new Lightbox();
+35
View File
@@ -0,0 +1,35 @@
.lds-ring {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 64px;
height: 64px;
margin: 8px;
border: 5px solid #fff;
border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #fff transparent transparent transparent;
}
.lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { addStylesheet, getUrl } from "./utils.js";
addStylesheet(getUrl("spinner.css", import.meta.url));
export function createSpinner() {
const div = document.createElement("div");
div.innerHTML = `<div class="lds-ring"><div></div><div></div><div></div><div></div></div>`;
return div.firstElementChild;
}
+27
View File
@@ -0,0 +1,27 @@
import { $el } from "../../../../scripts/ui.js";
export function addStylesheet(url) {
$el("link", {
parent: document.head,
rel: "stylesheet",
type: "text/css",
href: url.startsWith("http") ? url : getUrl(url),
});
}
export function getUrl(path, baseUrl) {
if (baseUrl) {
return new URL(path, baseUrl).toString();
} else {
return new URL("../" + path, import.meta.url).toString();
}
}
export async function loadImage(url) {
return new Promise((res, rej) => {
const img = new Image();
img.onload = res;
img.onerror = rej;
img.src = url;
});
}
+90
View File
@@ -0,0 +1,90 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "pysssss.ContextMenuHook",
init() {
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] = [];
}
}
// Big ol' hack to get allow customizing the context menu
// Replace the addItem function with our own that wraps the context of "this" with a proxy
// That proxy then replaces the constructor with another proxy
// That proxy then calls the custom ContextMenu that supports filters
const ctorProxy = new Proxy(LiteGraph.ContextMenu, {
construct(target, args) {
return new LiteGraph.ContextMenu(...args);
},
});
function triggerCallbacks(name, getArgs, handler) {
const callbacks = contextMenuHook[name];
if (callbacks && callbacks instanceof Array) {
for (const cb of callbacks) {
const r = cb(...getArgs());
handler?.call(this, r);
}
} else {
console.warn("[pysssss 🐍]", `invalid ${name} callbacks`, callbacks, name in contextMenuHook);
}
}
const addItem = LiteGraph.ContextMenu.prototype.addItem;
LiteGraph.ContextMenu.prototype.addItem = function () {
const proxy = new Proxy(this, {
get(target, prop) {
if (prop === "constructor") {
return ctorProxy;
}
return target[prop];
},
});
proxy.__target__ = this;
let el;
let args = arguments;
triggerCallbacks(
"preAddItem",
() => [el, this, args],
(r) => {
if (r !== undefined) el = r;
}
);
if (el === undefined) {
el = addItem.apply(proxy, arguments);
}
triggerCallbacks(
"addItem",
() => [el, this, args],
(r) => {
if (r !== undefined) el = r;
}
);
return el;
};
// We also need to patch the ContextMenu constructor to unwrap the parent else it fails a LiteGraph type check
const ctxMenu = LiteGraph.ContextMenu;
LiteGraph.ContextMenu = function (values, options) {
if (options?.parentMenu) {
if (options.parentMenu.__target__) {
options.parentMenu = options.parentMenu.__target__;
}
}
triggerCallbacks("ctor", () => [values, options]);
ctxMenu.call(this, values, options);
};
LiteGraph.ContextMenu.prototype = ctxMenu.prototype;
},
});
+85
View File
@@ -0,0 +1,85 @@
import { app } from "../../../scripts/app.js";
import { $el } from "../../../scripts/ui.js";
const colorShade = (col, amt) => {
col = col.replace(/^#/, "");
if (col.length === 3) col = col[0] + col[0] + col[1] + col[1] + col[2] + col[2];
let [r, g, b] = col.match(/.{2}/g);
[r, g, b] = [parseInt(r, 16) + amt, parseInt(g, 16) + amt, parseInt(b, 16) + amt];
r = Math.max(Math.min(255, r), 0).toString(16);
g = Math.max(Math.min(255, g), 0).toString(16);
b = Math.max(Math.min(255, b), 0).toString(16);
const rr = (r.length < 2 ? "0" : "") + r;
const gg = (g.length < 2 ? "0" : "") + g;
const bb = (b.length < 2 ? "0" : "") + b;
return `#${rr}${gg}${bb}`;
};
app.registerExtension({
name: "pysssss.CustomColors",
setup() {
let picker;
let activeNode;
const onMenuNodeColors = LGraphCanvas.onMenuNodeColors;
LGraphCanvas.onMenuNodeColors = function (value, options, e, menu, node) {
const r = onMenuNodeColors.apply(this, arguments);
requestAnimationFrame(() => {
const menus = document.querySelectorAll(".litecontextmenu");
for (let i = menus.length - 1; i >= 0; i--) {
if (menus[i].firstElementChild.textContent.includes("No color")) {
$el(
"div.litemenu-entry.submenu",
{
parent: menus[i],
$: (el) => {
el.onclick = () => {
LiteGraph.closeAllContextMenus();
if (!picker) {
picker = $el("input", {
type: "color",
parent: document.body,
style: {
display: "none",
},
});
picker.onchange = () => {
if (activeNode) {
if (activeNode.constructor === LiteGraph.LGraphGroup) {
activeNode.color = picker.value;
} else {
activeNode.color = colorShade(picker.value, 20);
activeNode.bgcolor = picker.value;
}
activeNode.setDirtyCanvas(true, true);
}
};
}
activeNode = null;
picker.value = node.bgcolor;
activeNode = node;
picker.click();
};
},
},
[
$el("span", {
style: {
paddingLeft: "4px",
display: "block",
},
textContent: "🎨 Custom",
}),
]
);
break;
}
}
});
return r;
};
},
});
+29
View File
@@ -0,0 +1,29 @@
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
// Simple script that adds the current queue size to the window title
// Adds a favicon that changes color while active
app.registerExtension({
name: "pysssss.FaviconStatus",
setup() {
api.addEventListener("status", ({ detail }) => {
let title = "ComfyUI";
let favicon = "favicon";
if (detail && detail.exec_info.queue_remaining) {
favicon += "-active";
title = `(${detail.exec_info.queue_remaining}) ${title}`;
}
document.title = title;
let link = document.querySelector("link[rel~='icon']");
if (!link) {
link = document.createElement("link");
link.rel = "icon";
document.head.appendChild(link);
}
link.href = new URL(`assets/${favicon}.ico`, import.meta.url);
});
},
});
+91
View File
@@ -0,0 +1,91 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "pysssss.GraphArrange",
setup(app) {
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig.apply(this, arguments);
options.push({ content: "Arrange (float left)", callback: () => graph.arrange() });
options.push({
content: "Arrange (float right)",
callback: () => {
(function () {
var margin = 50;
var layout;
const nodes = this.computeExecutionOrder(false, true);
const columns = [];
// Find node first use
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i];
let max = null;
for (const out of node.outputs || []) {
if (out.links) {
for (const link of out.links) {
const outNode = app.graph.getNodeById(app.graph.links[link].target_id);
if (!outNode) continue;
var l = outNode._level - 1;
if (max === null) max = l;
else if (l < max) max = l;
}
}
}
if (max != null) node._level = max;
}
for (let i = 0; i < nodes.length; ++i) {
const node = nodes[i];
const col = node._level || 1;
if (!columns[col]) {
columns[col] = [];
}
columns[col].push(node);
}
let x = margin;
for (let i = 0; i < columns.length; ++i) {
const column = columns[i];
if (!column) {
continue;
}
column.sort((a, b) => {
var as = !(a.type === "SaveImage" || a.type === "PreviewImage");
var bs = !(b.type === "SaveImage" || b.type === "PreviewImage");
var r = as - bs;
if (r === 0) r = (a.inputs?.length || 0) - (b.inputs?.length || 0);
if (r === 0) r = (a.outputs?.length || 0) - (b.outputs?.length || 0);
return r;
});
let max_size = 100;
let y = margin + LiteGraph.NODE_TITLE_HEIGHT;
for (let j = 0; j < column.length; ++j) {
const node = column[j];
node.pos[0] = layout == LiteGraph.VERTICAL_LAYOUT ? y : x;
node.pos[1] = layout == LiteGraph.VERTICAL_LAYOUT ? x : y;
const max_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 1 : 0;
if (node.size[max_size_index] > max_size) {
max_size = node.size[max_size_index];
}
const node_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 0 : 1;
y += node.size[node_size_index] + margin + LiteGraph.NODE_TITLE_HEIGHT + j;
}
// Right align in column
for (let j = 0; j < column.length; ++j) {
const node = column[j];
node.pos[0] += max_size - node.size[0];
}
x += max_size + margin;
}
this.setDirtyCanvas(true, true);
}).apply(app.graph);
},
});
return options;
};
},
});
+394
View File
@@ -0,0 +1,394 @@
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { $el } from "../../../scripts/ui.js";
import { lightbox } from "./common/lightbox.js";
$el("style", {
textContent: `
.pysssss-image-feed {
position: absolute;
background: var(--comfy-menu-bg);
color: var(--fg-color);
z-index: 99;
font-family: sans-serif;
font-size: 12px;
display: flex;
flex-direction: column;
}
.pysssss-image-feed--top, .pysssss-image-feed--bottom {
width: 100vw;
min-height: 30px;
max-height: calc(var(--max-size, 20) * 1vh);
}
.pysssss-image-feed--top {
top: 0;
}
.pysssss-image-feed--bottom {
bottom: 0;
flex-direction: column-reverse;
}
.pysssss-image-feed--left, .pysssss-image-feed--right {
top: 0;
height: 100vh;
min-width: 200px;
max-width: calc(var(--max-size, 10) * 1vw);
}
.pysssss-image-feed--left {
left: 0;
}
.pysssss-image-feed--right {
right: 0;
}
.pysssss-image-feed--left .pysssss-image-feed-menu, .pysssss-image-feed--right .pysssss-image-feed-menu {
flex-direction: column;
}
.pysssss-image-feed-menu {
position: relative;
flex: 0 1 min-content;
display: flex;
gap: 5px;
padding: 5px;
justify-content: space-between;
}
.pysssss-image-feed-btn-group {
align-items: stretch;
display: flex;
gap: .5rem;
flex: 0 1 fit-content;
justify-content: flex-end;
}
.pysssss-image-feed-btn {
background-color:var(--comfy-input-bg);
border-radius:5px;
border:2px solid var(--border-color);
color: var(--fg-color);
cursor:pointer;
display:inline-block;
flex: 0 1 fit-content;
text-decoration:none;
}
.pysssss-image-feed-btn.sizing-btn:checked {
filter: invert();
}
.pysssss-image-feed-btn.clear-btn {
padding: 5px 20px;
}
.pysssss-image-feed-btn.hide-btn {
padding: 5px;
aspect-ratio: 1 / 1;
}
.pysssss-image-feed-btn:hover {
filter: brightness(1.2);
}
.pysssss-image-feed-btn:active {
position:relative;
top:1px;
}
.pysssss-image-feed-menu section {
border-radius: 5px;
background: rgba(0,0,0,0.6);
padding: 0 5px;
display: flex;
gap: 5px;
align-items: center;
position: relative;
}
.pysssss-image-feed-menu section span {
white-space: nowrap;
}
.pysssss-image-feed-menu section input {
flex: 1 1 100%;
background: rgba(0,0,0,0.6);
border-radius: 5px;
overflow: hidden;
z-index: 100;
}
.sizing-menu {
position: relative;
}
.size-controls-flyout {
position: absolute;
transform: scaleX(0%);
transition: 200ms ease-out;
transition-delay: 500ms;
z-index: 101;
width: 300px;
}
.sizing-menu:hover .size-controls-flyout {
transform: scale(1, 1);
transition: 200ms linear;
transition-delay: 0;
}
.pysssss-image-feed--bottom .size-controls-flyout {
transform: scale(1,0);
transform-origin: bottom;
bottom: 0;
left: 0;
}
.pysssss-image-feed--top .size-controls-flyout {
transform: scale(1,0);
transform-origin: top;
top: 0;
left: 0;
}
.pysssss-image-feed--left .size-controls-flyout {
transform: scale(0, 1);
transform-origin: left;
top: 0;
left: 0;
}
.pysssss-image-feed--right .size-controls-flyout {
transform: scale(0, 1);
transform-origin: right;
top: 0;
right: 0;
}
.pysssss-image-feed-menu > * {
min-height: 24px;
}
.pysssss-image-feed-list {
flex: 1 1 auto;
overflow-y: auto;
display: grid;
align-items: center;
justify-content: center;
gap: 4px;
grid-auto-rows: min-content;
grid-template-columns: repeat(var(--img-sz, 3), 1fr);
transition: 100ms linear;
}
.pysssss-image-feed-list div {
text-align: center;
}
.pysssss-image-feed-list img {
object-fit: var(--img-fit, contain);
max-width: 100%;
max-height: calc(var(--max-size) * 1vh);
}
.pysssss-image-feed-list img:hover {
filter: brightness(1.2);
}`,
parent: document.body,
});
app.registerExtension({
name: "pysssss.ImageFeed",
setup() {
let visible = true;
const showButton = $el("button.comfy-settings-btn", {
textContent: "🖼️",
style: {
right: "16px",
cursor: "pointer",
display: "none",
},
});
const getVal = (n, d) => {
const v = localStorage.getItem("pysssss.ImageFeed." + n);
if (v && !isNaN(+v)) {
return v;
}
return d;
};
const saveVal = (n, v) => {
localStorage.setItem("pysssss.ImageFeed." + n, v);
};
const imageFeed = $el("div.pysssss-image-feed", {
parent: document.body,
});
const imageList = $el("div.pysssss-image-feed-list");
const feedLocation = app.ui.settings.addSetting({
id: "pysssss.ImageFeed.Location",
name: "🐍 Image Feed Location",
defaultValue: "bottom",
type: () => {
return $el("tr", [
$el("td", [
$el("label", {
textContent: "🐍 Image Feed Location:",
}),
]),
$el("td", [
$el(
"select",
{
style: {
fontSize: "14px",
},
oninput: (e) => {
feedLocation.value = e.target.value;
imageFeed.className = `pysssss-image-feed pysssss-image-feed--${feedLocation.value}`;
},
},
["left", "top", "right", "bottom"].map((m) =>
$el("option", {
value: m,
textContent: m,
selected: feedLocation.value === m,
})
)
),
]),
]);
},
onChange(value) {
imageFeed.className = `pysssss-image-feed pysssss-image-feed--${value}`;
},
});
const feedDirection = app.ui.settings.addSetting({
id: "pysssss.ImageFeed.Direction",
name: "🐍 Image Feed Direction",
defaultValue: "newest first",
type: () => {
return $el("tr", [
$el("td", [
$el("label", {
textContent: "🐍 Image Feed Direction:",
}),
]),
$el("td", [
$el(
"select",
{
style: {
fontSize: "14px",
},
oninput: (e) => {
feedDirection.value = e.target.value;
imageList.replaceChildren(...[...imageList.childNodes].reverse());
},
},
["newest first", "oldest first"].map((m) =>
$el("option", {
value: m,
textContent: m,
selected: feedDirection.value === m,
})
)
),
]),
]);
},
});
const clearButton = $el("button.pysssss-image-feed-btn.clear-btn", {
textContent: "Clear",
onclick: () => imageList.replaceChildren(),
});
const hideButton = $el("button.pysssss-image-feed-btn.hide-btn", {
textContent: "❌",
onclick: () => {
imageFeed.style.display = "none";
showButton.style.display = "unset";
saveVal("Visible", 0);
visible = false;
},
});
imageFeed.append(
$el("div.pysssss-image-feed-menu", [
$el("section.sizing-menu", {}, [
$el("label.size-control-handle", { textContent: "↹ Resize Feed" }),
$el("div.size-controls-flyout", {}, [
$el("section.size-control.feed-size-control", {}, [
$el("span", {
textContent: "Feed Size...",
}),
$el("input", {
type: "range",
min: 10,
max: 80,
oninput: (e) => {
e.target.parentElement.title = `Controls the maximum size of the image feed panel (${e.target.value}vh)`;
imageFeed.style.setProperty("--max-size", e.target.value);
saveVal("FeedSize", e.target.value);
},
$: (el) => {
requestAnimationFrame(() => {
el.value = getVal("FeedSize", 25);
el.oninput({ target: el });
});
},
}),
]),
$el("section.size-control.image-size-control", {}, [
$el("span", { textContent: "Column count..." }),
$el("input", {
type: "range",
min: 1,
max: 10,
step: 1,
oninput: (e) => {
e.target.parentElement.title = `Controls the number of columns in the feed (${e.target.value} columns)`;
imageFeed.style.setProperty("--img-sz", e.target.value);
saveVal("ImageSize", e.target.value);
},
$: (el) => {
requestAnimationFrame(() => {
el.value = getVal("ImageSize", 4);
el.oninput({ target: el });
});
},
}),
]),
]),
]),
$el("div.pysssss-image-feed-btn-group", {}, [clearButton, hideButton]),
]),
imageList
);
showButton.onclick = () => {
imageFeed.style.display = "block";
showButton.style.display = "none";
saveVal("Visible", 1);
visible = true;
};
document.querySelector(".comfy-settings-btn").after(showButton);
if (!+getVal("Visible", 1)) {
hideButton.onclick();
}
api.addEventListener("executed", ({ detail }) => {
if (visible && detail?.output?.images) {
for (const src of detail.output.images) {
const href = `/view?filename=${encodeURIComponent(src.filename)}&type=${
src.type
}&subfolder=${encodeURIComponent(src.subfolder)}&t=${+new Date()}`;
const method = feedDirection.value === "newest first" ? "prepend" : "append";
imageList[method](
$el("div", [
$el(
"a",
{
target: "_blank",
href,
onclick: (e) => {
const imgs = [...imageList.querySelectorAll("img")].map((img) => img.getAttribute("src"));
lightbox.show(imgs, imgs.indexOf(href));
e.preventDefault();
},
},
[$el("img", { src: href })]
),
])
);
}
}
});
},
});
+54
View File
@@ -0,0 +1,54 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "pysssss.KSamplerAdvDenoise",
async beforeRegisterNodeDef(nodeType) {
// Add menu options to conver to/from widgets
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
const r = origGetExtraMenuOptions?.apply?.(this, arguments);
let stepsWidget = null;
let startAtWidget = null;
let endAtWidget = null;
for (const w of this.widgets || []) {
if (w.name === "steps") {
stepsWidget = w;
} else if (w.name === "start_at_step") {
startAtWidget = w;
} else if (w.name === "end_at_step") {
endAtWidget = w;
}
}
if (stepsWidget && startAtWidget && endAtWidget) {
options.push(
{
content: "Set Denoise",
callback: () => {
const steps = +prompt("How many steps do you want?", 15);
if (isNaN(steps)) {
return;
}
const denoise = +prompt("How much denoise? (0-1)", 0.5);
if (isNaN(denoise)) {
return;
}
stepsWidget.value = Math.floor(steps / Math.max(0, Math.min(1, denoise)));
stepsWidget.callback?.(stepsWidget.value);
startAtWidget.value = stepsWidget.value - steps;
startAtWidget.callback?.(startAtWidget.value);
endAtWidget.value = stepsWidget.value;
endAtWidget.callback?.(endAtWidget.value);
},
},
null
);
}
return r;
};
},
});
+57
View File
@@ -0,0 +1,57 @@
import { app } from "../../../scripts/app.js";
import { $el } from "../../../scripts/ui.js";
const id = "pysssss.LinkRenderMode";
const ext = {
name: id,
async setup(app) {
if (app.extensions.find((ext) => ext.name === "Comfy.LinkRenderMode")) {
console.log("%c[🐍 pysssss]", "color: limegreen", "Skipping LinkRenderMode as core extension found");
return;
}
const setting = app.ui.settings.addSetting({
id,
name: "🐍 Link Render Mode",
defaultValue: 2,
type: () => {
return $el("tr", [
$el("td", [
$el("label", {
for: id.replaceAll(".", "-"),
textContent: "🐍 Link Render Mode:",
}),
]),
$el("td", [
$el(
"select",
{
textContent: "Manage",
style: {
fontSize: "14px",
},
oninput: (e) => {
setting.value = e.target.value;
app.canvas.links_render_mode = +e.target.value;
app.graph.setDirtyCanvas(true, true);
},
},
LiteGraph.LINK_RENDER_MODES.map((m, i) =>
$el("option", {
value: i,
textContent: m,
selected: i == app.canvas.links_render_mode,
})
)
),
]),
]);
},
onChange(value) {
app.canvas.links_render_mode = +value;
app.graph.setDirtyCanvas(true);
},
});
},
};
app.registerExtension(ext);
+185
View File
@@ -0,0 +1,185 @@
import { app } from "../../../scripts/app.js";
// Adds lock/unlock menu item for nodes + groups to prevent moving / resizing them
const LOCKED = Symbol();
function lockArray(arr, isLocked) {
const v = [];
for (let i = 0; i < 2; i++) {
v[i] = arr[i];
Object.defineProperty(arr, i, {
get() {
return v[i];
},
set(value) {
if (!isLocked()) {
v[i] = value;
}
},
});
}
}
app.registerExtension({
name: "pysssss.Locking",
init() {
function lockGroup(node) {
node[LOCKED] = true;
}
// Add the locked flag to serialization
const serialize = LGraphGroup.prototype.serialize;
LGraphGroup.prototype.serialize = function () {
const o = serialize.apply(this, arguments);
o.locked = !!this[LOCKED];
return o;
};
// On initial configure lock group if required
const configure = LGraphGroup.prototype.configure;
LGraphGroup.prototype.configure = function (o) {
configure.apply(this, arguments);
if (o.locked) {
lockGroup(this);
}
};
// Allow click through locked groups
const getGroupOnPos = LGraph.prototype.getGroupOnPos;
LGraph.prototype.getGroupOnPos = function () {
const r = getGroupOnPos.apply(this, arguments);
if (r && r[LOCKED] && !new Error().stack.includes("processContextMenu")) return null;
return r;
};
// Add menu options for lock/unlock
const getGroupMenuOptions = LGraphCanvas.prototype.getGroupMenuOptions;
LGraphCanvas.prototype.getGroupMenuOptions = function (node) {
const opts = getGroupMenuOptions.apply(this, arguments);
opts.unshift(
node[LOCKED]
? {
content: "Unlock",
callback: () => {
delete node[LOCKED];
},
}
: {
content: "Lock",
callback: () => lockGroup(node),
},
null
);
return opts;
};
},
setup() {
const drawNodeShape = LGraphCanvas.prototype.drawNodeShape;
LGraphCanvas.prototype.drawNodeShape = function (node, ctx, size, fgcolor, bgcolor, selected, mouse_over) {
const res = drawNodeShape.apply(this, arguments);
if (node[LOCKED]) {
ctx.fillText("🔒", node.size[0] - 20, -10);
}
return res;
};
},
async beforeRegisterNodeDef(nodeType) {
const nodesArray = (nodes) => {
if (nodes) {
if (nodes instanceof Array) {
return nodes;
}
return [nodes];
}
return Object.values(app.canvas.selected_nodes);
};
function unlockNode(nodes) {
nodes = nodesArray(nodes);
for (const node of nodes) {
delete node[LOCKED];
}
app.graph.setDirtyCanvas(true, false);
}
function lockNode(nodes) {
nodes = nodesArray(nodes);
for (const node of nodes) {
if (node[LOCKED]) continue;
node[LOCKED] = true;
// Same hack as above
lockArray(node.pos, () => !!node[LOCKED]);
// Size is set by both replacing the value and setting individual values
// So define a new property that can prevent reassignment
const sz = [node.size[0], node.size[1]];
Object.defineProperty(node, "size", {
get() {
return sz;
},
set(value) {
if (!node[LOCKED]) {
sz[0] = value[0];
sz[1] = value[1];
}
},
});
// And then lock each element if required
lockArray(sz, () => !!node[LOCKED]);
}
app.graph.setDirtyCanvas(true, false);
}
// Add menu options for lock/unlock
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
const r = getExtraMenuOptions ? getExtraMenuOptions.apply(this, arguments) : undefined;
options.splice(
options.findIndex((o) => o?.content === "Properties") + 1,
0,
null,
this[LOCKED]
? {
content: "Unlock",
callback: () => {
unlockNode();
},
}
: {
content: "Lock",
callback: () => lockNode(),
}
);
return r;
};
// Add the locked flag to serialization
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function (o) {
if (onSerialize) {
onSerialize.apply(this, arguments);
}
o.locked = this[LOCKED];
};
// On initial configure lock node if required
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (o) {
if (onConfigure) {
onConfigure.apply(this, arguments);
}
if (o.locked) {
lockNode(this);
}
};
},
});
+81
View File
@@ -0,0 +1,81 @@
.pysssss-lora-info {
color: white;
font-family: sans-serif;
max-width: 90vw;
}
.pysssss-lora-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
.pysssss-lora-info h2 {
text-align: center;
margin: 0 0 10px 0;
}
.pysssss-lora-info p {
margin: 5px 0;
}
.pysssss-lora-info a {
color: dodgerblue;
}
.pysssss-lora-info a:hover {
text-decoration: underline;
}
.pysssss-lora-tags-list {
display: flex;
flex-wrap: wrap;
list-style: none;
gap: 10px;
max-height: 200px;
overflow: auto;
margin: 10px 0;
padding: 0;
}
.pysssss-lora-tag {
background-color: rgb(128, 213, 247);
color: #000;
display: flex;
align-items: center;
gap: 5px;
border-radius: 5px;
padding: 2px 5px;
cursor: pointer;
}
.pysssss-lora-tag--selected span::before {
content: "✅";
position: absolute;
background-color: dodgerblue;
left: 0;
top: 0;
right: 0;
bottom: 0;
text-align: center;
}
.pysssss-lora-tag:hover {
outline: 2px solid dodgerblue;
}
.pysssss-lora-tag p {
margin: 0;
}
.pysssss-lora-tag span {
text-align: center;
border-radius: 5px;
background-color: dodgerblue;
color: #fff;
padding: 2px;
position: relative;
min-width: 20px;
overflow: hidden;
}
.pysssss-lora-metadata .comfy-modal-content {
max-width: 100%;
}
.pysssss-lora-metadata label {
margin-right: 1ch;
color: #ccc;
}
.pysssss-lora-metadata span {
color: dodgerblue;
}
+314
View File
@@ -0,0 +1,314 @@
import { app } from "../../../scripts/app.js";
import { $el, ComfyDialog } from "../../../scripts/ui.js";
import { api } from "../../../scripts/api.js";
import { addStylesheet, getUrl } from "./common/utils.js";
addStylesheet(getUrl("loraInfo.css", import.meta.url));
const MAX_TAGS = 500;
class LoraMetadataDialog extends ComfyDialog {
constructor(name, metadata) {
super();
this.element.classList.add("pysssss-lora-metadata");
}
show(metadata) {
super.show(
$el(
"div",
Object.keys(metadata).map((k) =>
$el("div", [$el("label", { textContent: k }), $el("span", { textContent: metadata[k] })])
)
)
);
}
}
class LoraInfoDialog extends ComfyDialog {
#metadata;
get tagFrequency() {
if (!this.#metadata.ss_tag_frequency) return [];
const datasets = JSON.parse(this.#metadata.ss_tag_frequency);
const tags = {};
for (const setName in datasets) {
const set = datasets[setName];
for (const t in set) {
if (t in tags) {
tags[t] += set[t];
} else {
tags[t] = set[t];
}
}
}
return Object.entries(tags).sort((a, b) => b[1] - a[1]);
}
get resolutions() {
let res = [];
if (this.#metadata.ss_bucket_info) {
const parsed = JSON.parse(this.#metadata.ss_bucket_info);
if (parsed?.buckets) {
for (const { resolution, count } of Object.values(parsed.buckets)) {
res.push([count, `${resolution.join("x")} * ${count}`]);
}
}
}
res = res.sort((a, b) => b[0] - a[0]).map((a) => a[1]);
let r = this.#metadata.ss_resolution;
if (r) {
const s = r.split(",");
const w = s[0].replace("(", "");
const h = s[1].replace(")", "");
res.push(`${w.trim()}x${h.trim()} (Base res)`);
} else if ((r = this.#metadata["modelspec.resolution"])) {
res.push(r + " (Base res");
}
if (!res.length) {
res.push("⚠️ Unknown");
}
return res;
}
get customNotes() {
return this.#metadata["pysssss.notes"];
}
get hash() {
return this.#metadata["pysssss.sha256"];
}
getTagList(tags) {
return tags.map((t) =>
$el(
"li.pysssss-lora-tag",
{
dataset: {
tag: t[0],
},
$: (el) => {
el.onclick = () => {
el.classList.toggle("pysssss-lora-tag--selected");
};
},
},
[
$el("p", {
textContent: t[0],
}),
$el("span", {
textContent: t[1],
}),
]
)
);
}
constructor(name, metadata) {
super();
this.element.classList.add("pysssss-lora-info");
this.#metadata = metadata;
let tags = this.tagFrequency;
let hasMore;
if (tags?.length) {
const c = tags.length;
let list;
if (c > MAX_TAGS) {
tags = tags.slice(0, MAX_TAGS);
hasMore = $el("p", [
$el("span", { textContent: `⚠️ Only showing first ${MAX_TAGS} tags ` }),
$el("a", {
href: "#",
textContent: `Show all ${c}`,
onclick: () => {
list.replaceChildren(...this.getTagList(this.tagFrequency));
hasMore.remove();
},
}),
]);
}
list = $el("ol.pysssss-lora-tags-list", this.getTagList(tags));
this.tags = $el("div", [list]);
} else {
this.tags = $el("p", { textContent: "⚠️ No tag frequency metadata found" });
}
const resolutions = $el("label", { textContent: "Resolution:" }, [
$el(
"select",
this.resolutions.map((r) => $el("option", { textContent: r }))
),
]);
let notes = null;
if (this.customNotes?.startsWith("http")) {
notes = $el("p", { textContent: "Notes: " }, [
$el("a", {
href: this.customNotes,
textContent: this.customNotes,
target: "_blank",
}),
]);
} else {
notes = $el("p", {
textContent: "Notes: " + (this.customNotes ?? `Add custom notes in ${name.split(".")[0] + ".txt"}`),
});
}
this.content = $el(
"div.pysssss-lora-content",
[
$el("h2", { textContent: name }),
$el("p", {
textContent: "Output Name: " + (metadata.ss_output_name || "⚠️ Unknown"),
}),
$el("p", {
textContent: "Base Model: " + (metadata.ss_sd_model_name || "⚠️ Unknown"),
}),
$el("p", {
textContent: "Clip Skip: " + (metadata.ss_clip_skip || "⚠️ Unknown"),
}),
resolutions,
notes,
this.createCivitaiInfo(),
this.tags,
hasMore,
].filter(Boolean)
);
}
createCivitaiInfo() {
if (!this.hash) return;
const info = $el("span", { textContent: "ℹ️ Loading..." });
const el = $el("p", [
$el("img", {
style: {
width: "18px",
position: "relative",
top: "3px",
marginRight: "5px",
},
src: "https://civitai.com/favicon.ico",
}),
$el("span", { textContent: "Civitai: " }),
info,
]);
(async () => {
try {
const req = await fetch("https://civitai.com/api/v1/model-versions/by-hash/" + this.hash);
if (req.status === 200) {
const res = await req.json();
info.replaceChildren(
$el("a", {
href: "https://civitai.com/models/" + res.modelId,
textContent: "View " + res.model.name,
target: "_blank",
})
);
} else if (req.status === 404) {
info.textContent = "⚠️ Model not found";
} else {
info.textContent = `⚠️ Error loading info (${req.status}) ` + req.statusText;
}
} catch (error) {
console.error(error);
info.textContent = "⚠️ Error loading info";
}
})();
return el;
}
createButtons() {
const btns = super.createButtons();
function copyTags(e, tags) {
const textarea = $el("textarea", {
parent: document.body,
style: {
position: "fixed",
},
textContent: tags.map((el) => el.dataset.tag).join(", "),
});
textarea.select();
try {
document.execCommand("copy");
if (!e.target.dataset.text) {
e.target.dataset.text = e.target.textContent;
}
e.target.textContent = "Copied " + tags.length + " tags";
setTimeout(() => {
e.target.textContent = e.target.dataset.text;
}, 1000);
} catch (ex) {
prompt("Copy to clipboard: Ctrl+C, Enter", text);
} finally {
document.body.removeChild(textarea);
}
}
btns.unshift(
$el("button", {
type: "button",
textContent: "Copy Selected",
onclick: (e) => {
copyTags(e, [...this.tags.querySelectorAll(".pysssss-lora-tag--selected")]);
},
}),
$el("button", {
type: "button",
textContent: "Copy All",
onclick: (e) => {
copyTags(e, [...this.tags.querySelectorAll(".pysssss-lora-tag")]);
},
}),
$el("button", {
type: "button",
textContent: "View raw metadata",
onclick: (e) => {
new LoraMetadataDialog().show(this.#metadata);
},
})
);
return btns;
}
show() {
super.show(this.content);
}
}
app.registerExtension({
name: "pysssss.LoraInfo",
beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass === "LoraLoader" || nodeType.comfyClass === "LoraLoader|pysssss") {
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
let value = this.widgets[0].value;
if (!value) {
return;
}
if (value.content) {
value = value.content;
}
options.unshift({
content: "View info...",
callback: async () => {
const meta = await (await api.fetchApi("/pysssss/metadata/" + encodeURIComponent(`loras/${value}`))).json();
new LoraInfoDialog(value, meta).show();
},
});
return getExtraMenuOptions?.apply(this, arguments);
};
}
},
});
+35
View File
@@ -0,0 +1,35 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
app.registerExtension({
name: "pysssss.MathExpression",
init() {
const STRING = ComfyWidgets.STRING;
ComfyWidgets.STRING = function (node, inputName, inputData) {
const r = STRING.apply(this, arguments);
r.widget.dynamicPrompts = inputData?.[1].dynamicPrompts;
return r;
};
},
beforeRegisterNodeDef(nodeType) {
if (nodeType.comfyClass === "MathExpression|pysssss") {
const onDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
const r = onDrawForeground?.apply?.(this, arguments);
const v = app.nodeOutputs?.[this.id + ""];
if (!this.flags.collapsed && v) {
const text = v.value[0] + "";
ctx.save();
ctx.font = "bold 12px sans-serif";
ctx.fillStyle = "dodgerblue";
const sz = ctx.measureText(text);
ctx.fillText(text, this.size[0] - sz.width - 5, LiteGraph.NODE_SLOT_HEIGHT * 3);
ctx.restore();
}
return r;
};
}
},
});
+82
View File
@@ -0,0 +1,82 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
// Adds a menu option to toggle follow the executing node
// Adds a menu option to go to the currently executing node
// Adds a menu option to go to a node by type
app.registerExtension({
name: "pysssss.NodeFinder",
setup() {
let followExecution = false;
const centerNode = (id) => {
if (!followExecution || !id) return;
const node = app.graph.getNodeById(id);
if (!node) return;
app.canvas.centerOnNode(node);
};
api.addEventListener("executing", ({ detail }) => centerNode(detail));
// Add canvas menu options
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig.apply(this, arguments);
options.push(null, {
content: followExecution ? "Stop following execution" : "Follow execution",
callback: () => {
if ((followExecution = !followExecution)) {
centerNode(app.runningNodeId);
}
},
});
if (app.runningNodeId) {
options.push({
content: "Show executing node",
callback: () => {
const node = app.graph.getNodeById(app.runningNodeId);
if (!node) return;
app.canvas.centerOnNode(node);
},
});
}
const nodes = app.graph._nodes;
const types = nodes.reduce((p, n) => {
if (n.type in p) {
p[n.type].push(n);
} else {
p[n.type] = [n];
}
return p;
}, {});
options.push({
content: "Go to node",
has_submenu: true,
submenu: {
options: Object.keys(types)
.sort()
.map((t) => ({
content: t,
has_submenu: true,
submenu: {
options: types[t]
.sort((a, b) => {
return a.pos[0] - b.pos[0];
})
.map((n) => ({
content: `${n.getTitle()} - #${n.id} (${n.pos[0]}, ${n.pos[1]})`,
callback: () => {
app.canvas.centerOnNode(n);
},
})),
},
})),
},
});
return options;
};
},
});
+25
View File
@@ -0,0 +1,25 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "pysssss.PlaySound",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "PlaySound|pysssss") {
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = async function () {
onExecuted?.apply(this, arguments);
if (this.widgets[0].value === "on empty queue") {
if (app.ui.lastQueueSize !== 0) {
await new Promise((r) => setTimeout(r, 500));
}
if (app.ui.lastQueueSize !== 0) {
return;
}
}
const url = new URL(`assets/notify.mp3`, import.meta.url);
const audio = new Audio(url);
audio.volume = this.widgets[1].value;
audio.play();
};
}
},
});
+232
View File
@@ -0,0 +1,232 @@
import { app } from "../../../scripts/app.js";
// Allows you to manage preset tags for e.g. common negative prompt
// Also performs replacements on any text field e.g. allowing you to use preset text in CLIP Text encode fields
let replaceRegex;
const id = "pysssss.PresetText.Presets";
const getPresets = () => {
let items;
try {
items = JSON.parse(localStorage.getItem(id));
} catch (error) {}
if (!items || !items.length) {
items = [{ name: "default negative", value: "worst quality" }];
}
return items;
};
let presets = getPresets();
app.registerExtension({
name: "pysssss.PresetText",
setup() {
app.ui.settings.addSetting({
id: "pysssss.PresetText.ReplacementRegex",
name: "🐍 Preset Text Replacement Regex",
type: "text",
defaultValue: "(?:^|[^\\w])(?<replace>@(?<id>[\\w-]+))",
tooltip:
"The regex should return two named capture groups: id (the name of the preset text to use), replace (the matched text to replace)",
attrs: {
style: {
fontFamily: "monospace",
},
},
onChange(value) {
if (!value) {
replaceRegex = null;
return;
}
try {
replaceRegex = new RegExp(value, "g");
} catch (error) {
alert("Error creating regex for preset text replacement, no replacements will be performed.");
replaceRegex = null;
}
},
});
},
registerCustomNodes() {
class PresetTextNode {
constructor() {
this.isVirtualNode = true;
this.serialize_widgets = true;
this.addOutput("text", "STRING");
const widget = this.addWidget("combo", "value", presets[0].name, () => {}, {
values: presets.map((p) => p.name),
});
this.addWidget("button", "Manage", "Manage", () => {
const container = document.createElement("div");
Object.assign(container.style, {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "10px",
});
const addNew = document.createElement("button");
addNew.textContent = "Add New";
addNew.classList.add("pysssss-presettext-addnew");
Object.assign(addNew.style, {
fontSize: "13px",
gridColumn: "1 / 3",
color: "dodgerblue",
width: "auto",
textAlign: "center",
});
addNew.onclick = () => {
addRow({ name: "", value: "" });
};
container.append(addNew);
function addRow(p) {
const name = document.createElement("input");
const nameLbl = document.createElement("label");
name.value = p.name;
nameLbl.textContent = "Name:";
nameLbl.append(name);
const value = document.createElement("input");
const valueLbl = document.createElement("label");
value.value = p.value;
valueLbl.textContent = "Value:";
valueLbl.append(value);
addNew.before(nameLbl, valueLbl);
}
for (const p of presets) {
addRow(p);
}
const help = document.createElement("span");
help.textContent = "To remove a preset set the name or value to blank";
help.style.gridColumn = "1 / 3";
container.append(help);
dialog.show("");
dialog.textElement.append(container);
});
const dialog = new app.ui.dialog.constructor();
dialog.element.classList.add("comfy-settings");
const closeButton = dialog.element.querySelector("button");
closeButton.textContent = "CANCEL";
const saveButton = document.createElement("button");
saveButton.textContent = "SAVE";
saveButton.onclick = function () {
const inputs = dialog.element.querySelectorAll("input");
const p = [];
for (let i = 0; i < inputs.length; i += 2) {
const n = inputs[i];
const v = inputs[i + 1];
if (!n.value.trim() || !v.value.trim()) {
continue;
}
p.push({ name: n.value, value: v.value });
}
widget.options.values = p.map((p) => p.name);
if (!widget.options.values.includes(widget.value)) {
widget.value = widget.options.values[0];
}
presets = p;
localStorage.setItem(id, JSON.stringify(presets));
dialog.close();
};
closeButton.before(saveButton);
this.applyToGraph = function (workflow) {
// For each output link copy our value over the original widget value
if (this.outputs[0].links && this.outputs[0].links.length) {
for (const l of this.outputs[0].links) {
const link_info = app.graph.links[l];
const outNode = app.graph.getNodeById(link_info.target_id);
const outIn = outNode && outNode.inputs && outNode.inputs[link_info.target_slot];
if (outIn.widget) {
const w = outNode.widgets.find((w) => w.name === outIn.widget.name);
if (!w) continue;
const preset = presets.find((p) => p.name === widget.value);
if (!preset) {
const msg = `Preset text '${widget.value}' not found. Please fix this and queue again.`;
alert(msg);
throw new Error(msg);
}
w.value = preset.value;
}
}
}
};
}
}
LiteGraph.registerNodeType(
"PresetText|pysssss",
Object.assign(PresetTextNode, {
title: "Preset Text 🐍",
})
);
PresetTextNode.category = "utils";
},
nodeCreated(node) {
if (node.widgets) {
// Locate dynamic prompt text widgets
const widgets = node.widgets.filter((n) => n.type === "customtext" || n.type === "text");
for (const widget of widgets) {
const callbacks = [
() => {
let prompt = widget.value;
if (replaceRegex && typeof prompt.replace !== 'undefined') {
prompt = prompt.replace(replaceRegex, (match, p1, p2, index, text, groups) => {
if (!groups.replace || !groups.id) return match; // No match, bad regex?
const preset = presets.find((p) => p.name.replaceAll(/\s/g, "-") === groups.id);
if (!preset) return match; // Invalid name
const pos = match.indexOf(groups.replace);
return match.substring(0, pos) + preset.value;
});
}
return prompt;
},
];
if (widget.serializeValue) {
callbacks.push(widget.serializeValue);
}
let called = false;
const serializeValue = async (workflowNode, widgetIndex) => {
const widgetValue = widget.value;
if (called) return widgetValue;
called = true;
for (const cb of callbacks) {
widget.value = await cb(workflowNode, widgetIndex);
}
const prompt = widget.value;
widget.value = widgetValue;
called = false;
return prompt;
};
Object.defineProperty(widget, "serializeValue", {
get() {
return serializeValue;
},
set(cb) {
callbacks.push(cb);
},
});
}
}
},
});
+196
View File
@@ -0,0 +1,196 @@
import { app } from "../../../scripts/app.js";
// Adds a bunch of context menu entries for quickly adding common steps
function addMenuHandler(nodeType, cb) {
const getOpts = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function () {
const r = getOpts.apply(this, arguments);
cb.apply(this, arguments);
return r;
};
}
function getOrAddVAELoader(node) {
let vaeNode = app.graph._nodes.find((n) => n.type === "VAELoader");
if (!vaeNode) {
vaeNode = addNode("VAELoader", node);
}
return vaeNode;
}
function addNode(name, nextTo, options) {
options = { select: true, shiftY: 0, before: false, ...(options || {}) };
const node = LiteGraph.createNode(name);
app.graph.add(node);
node.pos = [
options.before ? nextTo.pos[0] - node.size[0] - 30 : nextTo.pos[0] + nextTo.size[0] + 30,
nextTo.pos[1] + options.shiftY,
];
if (options.select) {
app.canvas.selectNode(node, false);
}
return node;
}
app.registerExtension({
name: "pysssss.QuickNodes",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.input && nodeData.input.required) {
const keys = Object.keys(nodeData.input.required);
for (let i = 0; i < keys.length; i++) {
if (nodeData.input.required[keys[i]][0] === "VAE") {
addMenuHandler(nodeType, function (_, options) {
options.unshift({
content: "Use VAE",
callback: () => {
getOrAddVAELoader(this).connect(0, this, i);
},
});
});
break;
}
}
}
if (nodeData.name === "KSampler") {
addMenuHandler(nodeType, function (_, options) {
options.unshift(
{
content: "Add Blank Input",
callback: () => {
const imageNode = addNode("EmptyLatentImage", this, { before: true });
imageNode.connect(0, this, 3);
},
},
{
content: "Add Hi-res Fix",
callback: () => {
const upscaleNode = addNode("LatentUpscale", this);
this.connect(0, upscaleNode, 0);
const sampleNode = addNode("KSampler", upscaleNode);
for (let i = 0; i < 3; i++) {
const l = this.getInputLink(i);
if (l) {
app.graph.getNodeById(l.origin_id).connect(l.origin_slot, sampleNode, i);
}
}
upscaleNode.connect(0, sampleNode, 3);
},
},
{
content: "Add 2nd Pass",
callback: () => {
const upscaleNode = addNode("LatentUpscale", this);
this.connect(0, upscaleNode, 0);
const ckptNode = addNode("CheckpointLoaderSimple", this);
const sampleNode = addNode("KSampler", ckptNode);
const positiveLink = this.getInputLink(1);
const negativeLink = this.getInputLink(2);
const positiveNode = positiveLink
? app.graph.add(app.graph.getNodeById(positiveLink.origin_id).clone())
: addNode("CLIPTextEncode");
const negativeNode = negativeLink
? app.graph.add(app.graph.getNodeById(negativeLink.origin_id).clone())
: addNode("CLIPTextEncode");
ckptNode.connect(0, sampleNode, 0);
ckptNode.connect(1, positiveNode, 0);
ckptNode.connect(1, negativeNode, 0);
positiveNode.connect(0, sampleNode, 1);
negativeNode.connect(0, sampleNode, 2);
upscaleNode.connect(0, sampleNode, 3);
},
},
{
content: "Add Save Image",
callback: () => {
const decodeNode = addNode("VAEDecode", this);
this.connect(0, decodeNode, 0);
getOrAddVAELoader(decodeNode).connect(0, decodeNode, 1);
const saveNode = addNode("SaveImage", decodeNode);
decodeNode.connect(0, saveNode, 0);
},
}
);
});
}
if (nodeData.name === "CheckpointLoaderSimple") {
addMenuHandler(nodeType, function (_, options) {
options.unshift({
content: "Add Clip Skip",
callback: () => {
const clipSkipNode = addNode("CLIPSetLastLayer", this);
const clipLinks = this.outputs[1].links ? this.outputs[1].links.map((l) => ({ ...graph.links[l] })) : [];
this.disconnectOutput(1);
this.connect(1, clipSkipNode, 0);
for (const clipLink of clipLinks) {
clipSkipNode.connect(0, clipLink.target_id, clipLink.target_slot);
}
},
});
});
}
if (
nodeData.name === "CheckpointLoaderSimple" ||
nodeData.name === "CheckpointLoader" ||
nodeData.name === "CheckpointLoader|pysssss" ||
nodeData.name === "LoraLoader" ||
nodeData.name === "LoraLoader|pysssss"
) {
addMenuHandler(nodeType, function (_, options) {
function addLora(type) {
const loraNode = addNode(type, this);
const modelLinks = this.outputs[0].links ? this.outputs[0].links.map((l) => ({ ...graph.links[l] })) : [];
const clipLinks = this.outputs[1].links ? this.outputs[1].links.map((l) => ({ ...graph.links[l] })) : [];
this.disconnectOutput(0);
this.disconnectOutput(1);
this.connect(0, loraNode, 0);
this.connect(1, loraNode, 1);
for (const modelLink of modelLinks) {
loraNode.connect(0, modelLink.target_id, modelLink.target_slot);
}
for (const clipLink of clipLinks) {
loraNode.connect(1, clipLink.target_id, clipLink.target_slot);
}
}
options.unshift(
{
content: "Add LoRA",
callback: () => addLora.call(this, "LoraLoader"),
},
{
content: "Add 🐍 LoRA",
callback: () => addLora.call(this, "LoraLoader|pysssss"),
},
{
content: "Add Prompts",
callback: () => {
const positiveNode = addNode("CLIPTextEncode", this);
const negativeNode = addNode("CLIPTextEncode", this, { shiftY: positiveNode.size[1] + 30 });
this.connect(1, positiveNode, 0);
this.connect(1, negativeNode, 0);
},
}
);
});
}
},
});
+123
View File
@@ -0,0 +1,123 @@
import { app } from "../../../scripts/app.js";
const REPEATER = "Repeater|pysssss";
app.registerExtension({
name: "pysssss.Repeater",
init() {
const graphToPrompt = app.graphToPrompt;
app.graphToPrompt = async function () {
const res = await graphToPrompt.apply(this, arguments);
const id = Date.now() + "_";
let u = 0;
let newNodes = {};
const newRepeaters = {};
for (const nodeId in res.output) {
let output = res.output[nodeId];
if (output.class_type === REPEATER) {
const isMulti = output.inputs.output === "multi";
if (output.inputs.node_mode === "create") {
// We need to clone the input for every repeat
const orig = res.output[output.inputs.source[0]];
if (isMulti) {
if (!newRepeaters[nodeId]) {
newRepeaters[nodeId] = [];
newRepeaters[nodeId][output.inputs.repeats - 1] = nodeId;
}
}
for (let i = 0; i < output.inputs.repeats - 1; i++) {
const clonedInputId = id + ++u;
if (isMulti) {
// If multi create we need to clone the repeater too
newNodes[clonedInputId] = structuredClone(orig);
output = structuredClone(output);
const clonedRepeaterId = id + ++u;
newNodes[clonedRepeaterId] = output;
output.inputs["source"][0] = clonedInputId;
newRepeaters[nodeId][i] = clonedRepeaterId;
} else {
newNodes[clonedInputId] = orig;
}
output.inputs[clonedInputId] = [clonedInputId, output.inputs.source[1]];
}
} else if (isMulti) {
newRepeaters[nodeId] = Array(output.inputs.repeats).fill(nodeId);
}
}
}
Object.assign(res.output, newNodes);
newNodes = {};
for (const nodeId in res.output) {
const output = res.output[nodeId];
for (const k in output.inputs) {
const v = output.inputs[k];
if (v instanceof Array) {
const repeaterId = v[0];
const source = newRepeaters[repeaterId];
if (source) {
v[0] = source.pop();
v[1] = 0;
}
}
}
}
// Object.assign(res.output, newNodes);
return res;
};
},
beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === REPEATER) {
const SETUP_OUTPUTS = Symbol();
nodeType.prototype[SETUP_OUTPUTS] = function (repeats) {
if (repeats == null) {
repeats = this.widgets[0].value;
}
while (this.outputs.length > repeats) {
this.removeOutput(repeats);
}
const id = Date.now() + "_";
let u = 0;
while (this.outputs.length < repeats) {
this.addOutput(id + ++u, "*", { label: "*" });
}
};
const onAdded = nodeType.prototype.onAdded;
nodeType.prototype.onAdded = function () {
const self = this;
const repeatsCb = this.widgets[0].callback;
this.widgets[0].callback = async function () {
const v = (await repeatsCb?.apply(this, arguments)) ?? this.value;
if (self.widgets[1].value === "multi") {
self[SETUP_OUTPUTS](v);
}
return v;
};
const outputCb = this.widgets[1].callback;
this.widgets[1].callback = async function () {
const v = (await outputCb?.apply(this, arguments)) ?? this.value;
if (v === "single") {
self.outputs[0].shape = 6;
self[SETUP_OUTPUTS](1);
} else {
delete self.outputs[0].shape;
self[SETUP_OUTPUTS]();
}
return v;
};
return onAdded?.apply(this, arguments);
};
}
},
});
+342
View File
@@ -0,0 +1,342 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
const REROUTE_PRIMITIVE = "ReroutePrimitive|pysssss";
const MULTI_PRIMITIVE = "MultiPrimitive|pysssss";
const LAST_TYPE = Symbol("LastType");
app.registerExtension({
name: "pysssss.ReroutePrimitive",
init() {
// On graph configure, fire onGraphConfigured to create widgets
const graphConfigure = LGraph.prototype.configure;
LGraph.prototype.configure = function () {
const r = graphConfigure.apply(this, arguments);
for (const n of app.graph._nodes) {
if (n.type === REROUTE_PRIMITIVE) {
n.onGraphConfigured();
}
}
return r;
};
const graphToPrompt = app.graphToPrompt;
app.graphToPrompt = async function () {
const res = await graphToPrompt.apply(this, arguments);
const multiOutputs = [];
for (const nodeId in res.output) {
const output = res.output[nodeId];
if (output.class_type === MULTI_PRIMITIVE) {
multiOutputs.push({ id: nodeId, inputs: output.inputs });
}
}
function permute(outputs) {
function generatePermutations(inputs, currentIndex, currentPermutation, result) {
if (currentIndex === inputs.length) {
result.push({ ...currentPermutation });
return;
}
const input = inputs[currentIndex];
for (const k in input) {
currentPermutation[currentIndex] = input[k];
generatePermutations(inputs, currentIndex + 1, currentPermutation, result);
}
}
const inputs = outputs.map((output) => output.inputs);
const result = [];
const current = new Array(inputs.length);
generatePermutations(inputs, 0, current, result);
return outputs.map((output, index) => ({
...output,
inputs: result.reduce((p, permutation) => {
const count = Object.keys(p).length;
p["value" + (count || "")] = permutation[index];
return p;
}, {}),
}));
}
const permutations = permute(multiOutputs);
for (let i = 0; i < permutations.length; i++) {
res.output[multiOutputs[i].id].inputs = permutations[i].inputs;
}
return res;
};
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
function addOutputHandler() {
// Finds the first non reroute output node down the chain
nodeType.prototype.getFirstReroutedOutput = function (slot) {
if (nodeData.name === MULTI_PRIMITIVE) {
slot = 0;
}
const links = this.outputs[slot].links;
if (!links) return null;
const search = [];
for (const l of links) {
const link = app.graph.links[l];
if (!link) continue;
const node = app.graph.getNodeById(link.target_id);
if (node.type !== REROUTE_PRIMITIVE && node.type !== MULTI_PRIMITIVE) {
return { node, link };
}
search.push({ node, link });
}
for (const { link, node } of search) {
const r = node.getFirstReroutedOutput(link.target_slot);
if (r) {
return r;
}
}
};
}
if (nodeData.name === REROUTE_PRIMITIVE) {
const configure = nodeType.prototype.configure || LGraphNode.prototype.configure;
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
const onAdded = nodeType.prototype.onAdded;
nodeType.title_mode = LiteGraph.NO_TITLE;
function hasAnyInput(node) {
for (const input of node.inputs) {
if (input.link) {
return true;
}
}
return false;
}
// Remove input text
nodeType.prototype.onAdded = function () {
onAdded?.apply(this, arguments);
this.inputs[0].label = "";
this.outputs[0].label = "value";
this.setSize(this.computeSize());
};
// Restore any widgets
nodeType.prototype.onGraphConfigured = function () {
if (hasAnyInput(this)) return;
const outputNode = this.getFirstReroutedOutput(0);
if (outputNode) {
this.checkPrimitiveWidget(outputNode);
}
};
// Check if we need to create (or remove) a widget on the node
nodeType.prototype.checkPrimitiveWidget = function ({ node, link }) {
let widgetType = link.type;
let targetLabel = widgetType;
const input = node.inputs[link.target_slot];
if (input.widget?.config?.[0] instanceof Array) {
targetLabel = input.widget.name;
widgetType = "COMBO";
}
if (widgetType in ComfyWidgets) {
if (!this.widgets?.length) {
let v;
if (this.widgets_values?.length) {
v = this.widgets_values[0];
}
let config = [link.type, {}];
if (input.widget) {
config = input.widget.config;
}
const { widget } = ComfyWidgets[widgetType](this, "value", config, app);
if (v !== undefined && (!this[LAST_TYPE] || this[LAST_TYPE] === widgetType)) {
widget.value = v;
}
this[LAST_TYPE] = widgetType;
}
} else if (this.widgets) {
this.widgets.length = 0;
}
return targetLabel;
};
// Finds all input nodes from the current reroute
nodeType.prototype.getReroutedInputs = function (slot) {
let nodes = [{ node: this }];
let node = this;
while (node?.type === REROUTE_PRIMITIVE) {
const input = node.inputs[slot];
if (input.link) {
const link = app.graph.links[input.link];
node = app.graph.getNodeById(link.origin_id);
slot = link.origin_slot;
nodes.push({
node,
link,
});
} else {
node = null;
}
}
return nodes;
};
addOutputHandler();
// Update the type of all reroutes in a chain
nodeType.prototype.changeRerouteType = function (slot, type, label) {
const color = LGraphCanvas.link_type_colors[type];
const output = this.outputs[slot];
this.inputs[slot].label = " ";
output.label = label || (type === "*" ? "value" : type);
output.type = type;
// Process all linked outputs
for (const linkId of output.links || []) {
const link = app.graph.links[linkId];
if (!link) continue;
link.color = color;
const node = app.graph.getNodeById(link.target_id);
if (node.changeRerouteType) {
// Recursively update reroutes
node.changeRerouteType(link.target_slot, type, label);
} else {
// Validate links to 'real' nodes
const theirType = node.inputs[link.target_slot].type;
if (theirType !== type && theirType !== "*") {
node.disconnectInput(link.target_slot);
}
}
}
if (this.inputs[slot].link) {
const link = app.graph.links[this.inputs[slot].link];
if (link) link.color = color;
}
};
// Override configure so we can flag that we are configuring to avoid link validation breaking
let configuring = false;
nodeType.prototype.configure = function () {
configuring = true;
const r = configure?.apply(this, arguments);
configuring = false;
return r;
};
Object.defineProperty(nodeType, "title_mode", {
get() {
return app.canvas.current_node?.widgets?.length ? LiteGraph.NORMAL_TITLE : LiteGraph.NO_TITLE;
},
});
nodeType.prototype.onConnectionsChange = function (type, _, connected, link_info) {
// If configuring treat everything as OK as links may not be set by litegraph yet
if (configuring) return;
const isInput = type === LiteGraph.INPUT;
const slot = isInput ? link_info.target_slot : link_info.origin_slot;
let targetLabel = null;
let targetNode = null;
let targetType = "*";
let targetSlot = slot;
const inputPath = this.getReroutedInputs(slot);
const rootInput = inputPath[inputPath.length - 1];
const outputNode = this.getFirstReroutedOutput(slot);
if (rootInput.node.type === REROUTE_PRIMITIVE) {
// Our input node is a reroute, so see if we have an output
if (outputNode) {
targetType = outputNode.link.type;
} else if (rootInput.node.widgets) {
rootInput.node.widgets.length = 0;
}
targetNode = rootInput;
targetSlot = rootInput.link?.target_slot ?? slot;
} else {
// We have a real input, so we want to use that type
targetNode = inputPath[inputPath.length - 2];
targetType = rootInput.node.outputs[rootInput.link.origin_slot].type;
targetSlot = rootInput.link.target_slot;
}
if (this.widgets && inputPath.length > 1) {
// We have an input node so remove our widget
this.widgets.length = 0;
}
if (outputNode && rootInput.node.checkPrimitiveWidget) {
// We have an output, check if we need to create a widget
targetLabel = rootInput.node.checkPrimitiveWidget(outputNode);
}
// Trigger an update of the type to all child nodes
targetNode.node.changeRerouteType(targetSlot, targetType, targetLabel);
return onConnectionsChange?.apply(this, arguments);
};
// When collapsed fix the size to just the dot
const computeSize = nodeType.prototype.computeSize || LGraphNode.prototype.computeSize;
nodeType.prototype.computeSize = function () {
const r = computeSize.apply(this, arguments);
if (this.flags?.collapsed) {
return [1, 25];
} else if (this.widgets?.length) {
return r;
} else {
let w = 75;
if (this.outputs?.[0]?.label) {
const t = LiteGraph.NODE_TEXT_SIZE * this.outputs[0].label.length * 0.6 + 30;
if (t > w) {
w = t;
}
}
return [w, r[1]];
}
};
// On collapse shrink the node to just a dot
const collapse = nodeType.prototype.collapse || LGraphNode.prototype.collapse;
nodeType.prototype.collapse = function () {
collapse.apply(this, arguments);
this.setSize(this.computeSize());
requestAnimationFrame(() => {
this.setDirtyCanvas(true, true);
});
};
// Shift the bounding area up slightly as LiteGraph miscalculates it for collapsed nodes
nodeType.prototype.onBounding = function (area) {
if (this.flags?.collapsed) {
area[1] -= 15;
}
};
} else if (nodeData.name === MULTI_PRIMITIVE) {
addOutputHandler();
nodeType.prototype.onConnectionsChange = function (type, _, connected, link_info) {
for (let i = 0; i < this.inputs.length - 1; i++) {
if (!this.inputs[i].link) {
this.removeInput(i--);
}
}
if (this.inputs[this.inputs.length - 1].link) {
this.addInput("v" + +new Date(), this.inputs[0].type).label = "value";
}
};
}
},
});
+82
View File
@@ -0,0 +1,82 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { $el } from "../../../scripts/ui.js";
const id = "pysssss.ShowImageOnMenu";
const ext = {
name: id,
async setup(app) {
let enabled = true;
let nodeId = null;
const img = $el("img", {
style: {
width: "100%",
height: "150px",
objectFit: "contain",
},
});
const link = $el(
"a",
{
style: {
position: "absolute",
bottom: 0,
width: "100%",
height: "150px",
},
href: "#",
onclick: (e) => {
e.stopPropagation();
e.preventDefault();
const node = app.graph.getNodeById(nodeId);
if (!node) return;
app.canvas.centerOnNode(node);
app.canvas.setZoom(1);
},
},
[img]
);
const show = (src, node) => {
img.src = src;
nodeId = +node;
if (!link.parentNode) {
app.ui.menuContainer.style.paddingBottom = "150px";
app.ui.menuContainer.append(link);
}
};
api.addEventListener("executed", ({ detail }) => {
if (!enabled) return;
const images = detail?.output?.images;
if (!images) return;
const format = app.getPreviewFormatParam();
const src = `/view?filename=${encodeURIComponent(images[0].filename)}&type=${
images[0].type
}&subfolder=${encodeURIComponent(images[0].subfolder)}&t=${+new Date()}${format}`;
show(src, detail.node);
});
api.addEventListener("b_preview", ({ detail }) => {
show(URL.createObjectURL(detail), app.runningNodeId);
});
app.ui.settings.addSetting({
id,
name: "🐍 Show Image On Menu",
defaultValue: true,
type: "boolean",
onChange(value) {
enabled = value;
if (!value) {
app.ui.menuContainer.style.removeProperty("padding-bottom");
if (link.parentNode) {
link.parentNode.removeChild(link);
}
}
},
});
},
};
app.registerExtension(ext);
+36
View File
@@ -0,0 +1,36 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
// Displays input text on a node
app.registerExtension({
name: "pysssss.ShowText",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "ShowText|pysssss") {
// When the node is executed we will be sent the input text, display this in the widget
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
if (this.widgets) {
const pos = this.widgets.findIndex((w) => w.name === "text");
if (pos !== -1) {
for (let i = pos; i < this.widgets.length; i++) {
this.widgets[i].onRemove?.();
}
this.widgets.length = pos;
}
}
for (const list of message.text) {
const w = ComfyWidgets["STRING"](this, "text", ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.6;
w.value = list;
}
this.onResize?.(this.size);
};
}
},
});
+58
View File
@@ -0,0 +1,58 @@
import { app } from "../../../scripts/app.js";
let setting;
const id = "pysssss.SnapToGrid";
const ext = {
name: id,
init() {
setting = app.ui.settings.addSetting({
id,
name: "🐍 Always snap to grid",
defaultValue: false,
type: "boolean",
onChange(value) {
app.canvas.align_to_grid = value;
},
});
// We need to register our hooks after the core snap to grid extension runs
// Do this from the graph configure function so we still get onNodeAdded calls
const configure = LGraph.prototype.configure;
LGraph.prototype.configure = function () {
// Override drawNode to draw the drop position
const drawNode = LGraphCanvas.prototype.drawNode;
LGraphCanvas.prototype.drawNode = function () {
if (setting?.value) {
const shift = app.shiftDown;
app.shiftDown = true;
const r = drawNode.apply(this, arguments);
app.shiftDown = shift;
return r;
}
return drawNode.apply(this, arguments);
};
// Override node added to add a resize handler to force grid alignment
const onNodeAdded = app.graph.onNodeAdded;
app.graph.onNodeAdded = function (node) {
const r = onNodeAdded?.apply(this, arguments);
const onResize = node.onResize;
node.onResize = function () {
if (setting?.value) {
const shift = app.shiftDown;
app.shiftDown = true;
const r = onResize?.apply(this, arguments);
app.shiftDown = shift;
return r;
}
return onResize?.apply(this, arguments);
};
return r;
};
return configure.apply(this, arguments);
};
},
};
app.registerExtension(ext);
+33
View File
@@ -0,0 +1,33 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
// Displays input text on a node
app.registerExtension({
name: "pysssss.StringFunction",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "StringFunction|pysssss") {
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
if (this.widgets) {
const pos = this.widgets.findIndex((w) => w.name === "result");
if (pos !== -1) {
for (let i = pos; i < this.widgets.length; i++) {
this.widgets[i].onRemove?.();
}
this.widgets.length = pos;
}
}
const w = ComfyWidgets["STRING"](this, "result", ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.6;
w.value = message.text;
this.onResize?.(this.size);
};
}
},
});
+74
View File
@@ -0,0 +1,74 @@
import { app } from "../../../scripts/app.js";
// Adds mapping of touch events to mouse events for mobile. This isnt great but it is somewhat usable
app.registerExtension({
name: "pysssss.TouchEvents",
setup() {
let touchStart = null;
let touchType = 0;
function fireEvent(originalEvent, type) {
const fakeEvent = document.createEvent("MouseEvent");
const touch = originalEvent.changedTouches[0];
fakeEvent.initMouseEvent(
type,
true,
true,
window,
1,
touch.screenX,
touch.screenY,
touch.clientX,
touch.clientY,
false,
false,
false,
false,
0,
null
);
touch.target.dispatchEvent(fakeEvent);
if (fakeEvent.defaultPrevented) {
originalEvent.preventDefault();
}
}
document.addEventListener(
"touchstart",
(e) => {
// Support tap as click if it completes within a delay
if (touchStart) {
clearTimeout(touchStart);
}
touchStart = setTimeout(() => {
touchStart = null;
}, 100);
// Left or right button down
touchType = e.touches.length === 1 ? 0 : 2;
fireEvent(e, "mousedown");
},
true
);
document.addEventListener("touchmove", (e) => fireEvent(e, "mousemove"), true);
document.addEventListener(
"touchend",
(e) => {
const isClick = touchStart;
if (isClick) {
// We are within the touch start delay so fire this as a click
clearTimeout(touchStart);
fireEvent(e, "click");
}
fireEvent(e, "mouseup");
touchType = 0;
},
true
);
},
});
+231
View File
@@ -0,0 +1,231 @@
import { app } from "../../../scripts/app.js";
import { $el, ComfyDialog } from "../../../scripts/ui.js";
// Allows you to specify custom default values for any widget on any node
const id = "pysssss.WidgetDefaults";
const nodeDataKey = Symbol();
app.registerExtension({
name: id,
beforeRegisterNodeDef(nodeType, nodeData) {
nodeType[nodeDataKey] = nodeData;
},
setup() {
let defaults;
let setting;
const applyDefaults = (defaults) => {
for (const node of Object.values(LiteGraph.registered_node_types)) {
const nodeData = node[nodeDataKey];
if (!nodeData) continue;
const nodeDefaults = defaults[node.type];
if (!nodeDefaults) continue;
const inputs = { ...(nodeData.input?.required || {}), ...(nodeData.input?.optional || {}) };
for (const w in nodeDefaults) {
const widgetDef = inputs[w];
if (widgetDef) {
if (widgetDef[1]) {
widgetDef[1].default = nodeDefaults[w];
} else {
widgetDef[1] = { default: nodeDefaults[w] };
}
}
}
}
};
const getDefaults = () => {
let items;
try {
items = JSON.parse(setting.value);
items = items.reduce((p, n) => {
if (!p[n.node]) p[n.node] = {};
p[n.node][n.widget] = n.value;
return p;
}, {});
} catch (error) {}
if (!items) {
items = {};
}
applyDefaults(items);
return items;
};
// app.graph.onNodeAdded = function (node) {
// const nodeDefaults = defaults[node.constructor.type];
// if (nodeDefaults) {
// for (const w of node.widgets || []) {
// let value = nodeDefaults[w.name];
// if (value != null) {
// debugger
// if (typeof w.value === "number") {
// value = +value;
// }
// w.value = value;
// debugger
// w.callback?.(w.value);
// }
// }
// }
// };
class WidgetDefaultsDialog extends ComfyDialog {
constructor() {
super();
this.element.classList.add("comfy-manage-templates");
this.grid = $el(
"div",
{
style: {
display: "grid",
gridTemplateColumns: "1fr auto auto auto",
gap: "5px",
},
className: "pysssss-widget-defaults",
},
[
$el("label", {
textContent: "Node Class",
}),
$el("label", {
textContent: "Widget Name",
}),
$el("label", {
textContent: "Default Value",
}),
$el("label"),
(this.rows = $el("div", {
style: {
display: "contents",
},
})),
]
);
}
createButtons() {
const btns = super.createButtons();
btns[0].textContent = "Cancel";
btns.unshift(
$el("button", {
type: "button",
textContent: "Add New",
onclick: () => this.addRow(),
}),
$el("button", {
type: "button",
textContent: "Save",
onclick: () => this.save(),
})
);
return btns;
}
addRow(node = "", widget = "", value = "") {
let nameInput;
this.rows.append(
$el(
"div",
{
style: {
display: "contents",
},
className: "pysssss-widget-defaults-row",
},
[
$el("input", {
placeholder: "e.g. CheckpointLoaderSimple",
value: node,
}),
$el("input", {
placeholder: "e.g. ckpt_name",
value: widget,
$: (el) => (nameInput = el),
}),
$el("input", {
placeholder: "e.g. myBestModel.safetensors",
value,
}),
$el("button", {
textContent: "Delete",
style: {
fontSize: "12px",
color: "red",
fontWeight: "normal",
},
onclick: (e) => {
nameInput.value = "";
e.target.parentElement.style.display = "none";
},
}),
]
)
);
}
save() {
const rows = this.rows.children;
const items = [];
for (const row of rows) {
const inputs = row.querySelectorAll("input");
const node = inputs[0].value.trim();
const widget = inputs[1].value.trim();
const value = inputs[2].value;
if (node && widget) {
items.push({ node, widget, value });
}
}
setting.value = JSON.stringify(items);
defaults = getDefaults();
this.close();
}
show() {
this.rows.replaceChildren();
for (const nodeName in defaults) {
const node = defaults[nodeName];
for (const widgetName in node) {
this.addRow(nodeName, widgetName, node[widgetName]);
}
}
this.addRow();
super.show(this.grid);
}
}
setting = app.ui.settings.addSetting({
id,
name: "🐍 Widget Defaults",
type: () => {
return $el("tr", [
$el("td", [
$el("label", {
for: id.replaceAll(".", "-"),
textContent: "🐍 Widget Defaults:",
}),
]),
$el("td", [
$el("button", {
textContent: "Manage",
onclick: () => {
app.ui.settings.element.close();
const dialog = new WidgetDefaultsDialog();
dialog.show();
},
style: {
fontSize: "14px",
},
}),
]),
]);
},
});
defaults = getDefaults();
},
});
+626
View File
@@ -0,0 +1,626 @@
import { app } from "../../../scripts/app.js";
import { importA1111 } from "../../../scripts/pnginfo.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
let getDrawTextConfig = null;
let fileInput;
class WorkflowImage {
static accept = "";
getBounds() {
// Calculate the min max bounds for the nodes on the graph
const bounds = app.graph._nodes.reduce(
(p, n) => {
if (n.pos[0] < p[0]) p[0] = n.pos[0];
if (n.pos[1] < p[1]) p[1] = n.pos[1];
const r = n.pos[0] + n.size[0];
const b = n.pos[1] + n.size[1];
if (r > p[2]) p[2] = r;
if (b > p[3]) p[3] = b;
return p;
},
[99999, 99999, -99999, -99999]
);
bounds[0] -= 100;
bounds[1] -= 100;
bounds[2] += 100;
bounds[3] += 100;
return bounds;
}
saveState() {
this.state = {
scale: app.canvas.ds.scale,
width: app.canvas.canvas.width,
height: app.canvas.canvas.height,
offset: app.canvas.ds.offset,
};
}
restoreState() {
app.canvas.ds.scale = this.state.scale;
app.canvas.canvas.width = this.state.width;
app.canvas.canvas.height = this.state.height;
app.canvas.ds.offset = this.state.offset;
}
updateView(bounds) {
app.canvas.ds.scale = 1;
app.canvas.canvas.width = bounds[2] - bounds[0];
app.canvas.canvas.height = bounds[3] - bounds[1];
app.canvas.ds.offset = [-bounds[0], -bounds[1]];
}
getDrawTextConfig(_, widget) {
return {
x: 10,
y: widget.last_y + 10,
resetTransform: false,
};
}
async export(includeWorkflow) {
// Save the current state of the canvas
this.saveState();
// Update to render the whole workflow
this.updateView(this.getBounds());
// Flag that we are saving and render the canvas
getDrawTextConfig = this.getDrawTextConfig;
app.canvas.draw(true, true);
getDrawTextConfig = null;
// Generate a blob of the image containing the workflow
const blob = await this.getBlob(includeWorkflow ? JSON.stringify(app.graph.serialize()) : undefined);
// Restore initial state and redraw
this.restoreState();
app.canvas.draw(true, true);
// Download the generated image
this.download(blob);
}
download(blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
Object.assign(a, {
href: url,
download: "workflow." + this.extension,
style: "display: none",
});
document.body.append(a);
a.click();
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
}
static import() {
if (!fileInput) {
fileInput = document.createElement("input");
Object.assign(fileInput, {
type: "file",
style: "display: none",
onchange: () => {
app.handleFile(fileInput.files[0]);
},
});
document.body.append(fileInput);
}
fileInput.accept = WorkflowImage.accept;
fileInput.click();
}
}
class PngWorkflowImage extends WorkflowImage {
static accept = ".png,image/png";
extension = "png";
n2b(n) {
return new Uint8Array([(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
}
joinArrayBuffer(...bufs) {
const result = new Uint8Array(bufs.reduce((totalSize, buf) => totalSize + buf.byteLength, 0));
bufs.reduce((offset, buf) => {
result.set(buf, offset);
return offset + buf.byteLength;
}, 0);
return result;
}
crc32(data) {
const crcTable =
PngWorkflowImage.crcTable ||
(PngWorkflowImage.crcTable = (() => {
let c;
const crcTable = [];
for (let n = 0; n < 256; n++) {
c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
crcTable[n] = c;
}
return crcTable;
})());
let crc = 0 ^ -1;
for (let i = 0; i < data.byteLength; i++) {
crc = (crc >>> 8) ^ crcTable[(crc ^ data[i]) & 0xff];
}
return (crc ^ -1) >>> 0;
}
async getBlob(workflow) {
return new Promise((r) => {
app.canvasEl.toBlob(async (blob) => {
if (workflow) {
// If we have a workflow embed it in the PNG
const buffer = await blob.arrayBuffer();
const typedArr = new Uint8Array(buffer);
const view = new DataView(buffer);
const data = new TextEncoder().encode(`tEXtworkflow\0${workflow}`);
const chunk = this.joinArrayBuffer(this.n2b(data.byteLength - 4), data, this.n2b(this.crc32(data)));
const sz = view.getUint32(8) + 20;
const result = this.joinArrayBuffer(typedArr.subarray(0, sz), chunk, typedArr.subarray(sz));
blob = new Blob([result], { type: "image/png" });
}
r(blob);
});
});
}
}
class DataReader {
/** @type {DataView} */
view;
/** @type {boolean | undefined} */
littleEndian;
offset = 0;
/**
* @param {DataView} view
*/
constructor(view) {
this.view = view;
}
/**
* Reads N bytes and increments the offset
* @param {1 | 2 | 4 | 8} size
*/
read(size, signed = false, littleEndian = undefined) {
const v = this.peek(size, signed, littleEndian);
this.offset += size;
return v;
}
/**
* Reads N bytes
* @param {1 | 2 | 4 | 8} size
*/
peek(size, signed = false, littleEndian = undefined) {
this.view.getBigInt64;
let m = "";
if (size === 8) m += "Big";
m += signed ? "Int" : "Uint";
m += size * 8;
m = "get" + m;
if (!this.view[m]) {
throw new Error("Method not found: " + m);
}
return this.view[m](this.offset, littleEndian == null ? this.littleEndian : littleEndian);
}
/**
* Seeks to the specified position or by the number of bytes specified relative to the current offset
* @param {number} pos
* @param {boolean} relative
*/
seek(pos, relative = true) {
if (relative) {
this.offset += pos;
} else {
this.offset = pos;
}
}
}
class Tiff {
/** @type {DataReader} */
#reader;
#start;
readExif(reader) {
const TIFF_MARKER = 0x2a;
const EXIF_IFD = 0x8769;
this.#reader = reader;
this.#start = this.#reader.offset;
this.#readEndianness();
if (!this.#reader.read(2) === TIFF_MARKER) {
throw new Error("Invalid TIFF: Marker not found.");
}
const dirOffset = this.#reader.read(4);
this.#reader.seek(this.#start + dirOffset, false);
for (const t of this.#readTags()) {
if (t.id === EXIF_IFD) {
return this.#readExifTag(t);
}
}
throw new Error("No EXIF: TIFF Exif IFD tag not found");
}
#readUserComment(tag) {
this.#reader.seek(this.#start + tag.offset, false);
const encoding = this.#reader.read(8);
if (encoding !== 0x45444f43494e55n) {
throw new Error("Unable to read non-Unicode data");
}
const decoder = new TextDecoder("utf-16be");
return decoder.decode(new DataView(this.#reader.view.buffer, this.#reader.offset, tag.count - 8));
}
#readExifTag(exifTag) {
const EXIF_USER_COMMENT = 0x9286;
this.#reader.seek(this.#start + exifTag.offset, false);
for (const t of this.#readTags()) {
if (t.id === EXIF_USER_COMMENT) {
return this.#readUserComment(t);
}
}
throw new Error("No embedded data: UserComment Exif tag not found");
}
*#readTags() {
const count = this.#reader.read(2);
for (let i = 0; i < count; i++) {
yield {
id: this.#reader.read(2),
type: this.#reader.read(2),
count: this.#reader.read(4),
offset: this.#reader.read(4),
};
}
}
#readEndianness() {
const II = 0x4949;
const MM = 0x4d4d;
const endianness = this.#reader.read(2);
if (endianness === II) {
this.#reader.littleEndian = true;
} else if (endianness === MM) {
this.#reader.littleEndian = false;
} else {
throw new Error("Invalid JPEG: Endianness marker not found.");
}
}
}
class Jpeg {
/** @type {DataReader} */
#reader;
/**
* @param {ArrayBuffer} buffer
*/
readExif(buffer) {
const JPEG_MARKER = 0xffd8;
const EXIF_SIG = 0x45786966;
this.#reader = new DataReader(new DataView(buffer));
if (!this.#reader.read(2) === JPEG_MARKER) {
throw new Error("Invalid JPEG: SOI not found.");
}
const app0 = this.#readAppMarkerId();
if (app0 !== 0) {
throw new Error(`Invalid JPEG: APP0 not found [found: ${app0}].`);
}
this.#consumeAppSegment();
const app1 = this.#readAppMarkerId();
if (app1 !== 1) {
throw new Error(`No EXIF: APP1 not found [found: ${app0}].`);
}
// Skip size
this.#reader.seek(2);
if (this.#reader.read(4) !== EXIF_SIG) {
throw new Error(`No EXIF: Invalid EXIF header signature.`);
}
if (this.#reader.read(2) !== 0) {
throw new Error(`No EXIF: Invalid EXIF header.`);
}
return new Tiff().readExif(this.#reader);
}
#readAppMarkerId() {
const APP0_MARKER = 0xffe0;
return this.#reader.read(2) - APP0_MARKER;
}
#consumeAppSegment() {
this.#reader.seek(this.#reader.read(2) - 2);
}
}
class SvgWorkflowImage extends WorkflowImage {
static accept = ".svg,image/svg+xml";
extension = "svg";
static init() {
// Override file handling to allow drag & drop of SVG
const handleFile = app.handleFile;
app.handleFile = async function (file) {
if (file && (file.type === "image/svg+xml" || file.name?.endsWith(".svg"))) {
const reader = new FileReader();
reader.onload = () => {
// Extract embedded workflow from desc tags
const descEnd = reader.result.lastIndexOf("</desc>");
if (descEnd !== -1) {
const descStart = reader.result.lastIndexOf("<desc>", descEnd);
if (descStart !== -1) {
const json = reader.result.substring(descStart + 6, descEnd);
this.loadGraphData(JSON.parse(SvgWorkflowImage.unescapeXml(json)));
}
}
};
reader.readAsText(file);
return;
} else if (file && (file.type === "image/jpeg" || file.name?.endsWith(".jpg") || file.name?.endsWith(".jpeg"))) {
if (
await new Promise((r) => {
try {
// This shouldnt go in here but it's easier than refactoring handleFile
const reader = new FileReader();
reader.onload = async () => {
try {
const value = new Jpeg().readExif(reader.result);
importA1111(app.graph, value);
resolve(true);
} catch (error) {
resolve(false);
}
};
reader.onerror = () => resolve(false);
reader.readAsArrayBuffer(file);
} catch (error) {
resolve(false);
}
})
) {
return;
}
}
return handleFile.apply(this, arguments);
};
}
static escapeXml(unsafe) {
return unsafe.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
static unescapeXml(safe) {
return safe.replaceAll("&amp;", "&").replaceAll("&lt;", "<").replaceAll("&gt;", ">");
}
getDrawTextConfig(_, widget) {
return {
x: parseInt(widget.inputEl.style.left),
y: parseInt(widget.inputEl.style.top),
resetTransform: true,
};
}
saveState() {
super.saveState();
this.state.ctx = app.canvas.ctx;
}
restoreState() {
super.restoreState();
app.canvas.ctx = this.state.ctx;
}
updateView(bounds) {
super.updateView(bounds);
this.createSvgCtx(bounds);
}
createSvgCtx(bounds) {
const ctx = this.state.ctx;
const svgCtx = (this.svgCtx = new C2S(bounds[2] - bounds[0], bounds[3] - bounds[1]));
svgCtx.canvas.getBoundingClientRect = function () {
return { width: svgCtx.width, height: svgCtx.height };
};
// Override the c2s handling of images to draw images as canvases
const drawImage = svgCtx.drawImage;
svgCtx.drawImage = function (...args) {
const image = args[0];
// If we are an image node and not a datauri then we need to replace with a canvas
// we cant convert to data uri here as it is an async process
if (image.nodeName === "IMG" && !image.src.startsWith("data:image/")) {
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
const imgCtx = canvas.getContext("2d");
imgCtx.drawImage(image, 0, 0);
args[0] = canvas;
}
return drawImage.apply(this, args);
};
// Implement missing required functions
svgCtx.getTransform = function () {
return ctx.getTransform();
};
svgCtx.resetTransform = function () {
return ctx.resetTransform();
};
svgCtx.roundRect = svgCtx.rect;
app.canvas.ctx = svgCtx;
}
getBlob(workflow) {
let svg = this.svgCtx
.getSerializedSvg(true)
.replace("<svg ", `<svg style="background: ${app.canvas.clear_background_color}" `);
if (workflow) {
svg = svg.replace("</svg>", `<desc>${SvgWorkflowImage.escapeXml(workflow)}</desc></svg>`);
}
return new Blob([svg], { type: "image/svg+xml" });
}
}
app.registerExtension({
name: "pysssss.WorkflowImage",
init() {
// https://codepen.io/peterhry/pen/nbMaYg
function wrapText(context, text, x, y, maxWidth, lineHeight) {
var words = text.split(" "),
line = "",
i,
test,
metrics;
for (i = 0; i < words.length; i++) {
test = words[i];
metrics = context.measureText(test);
while (metrics.width > maxWidth) {
// Determine how much of the word will fit
test = test.substring(0, test.length - 1);
metrics = context.measureText(test);
}
if (words[i] != test) {
words.splice(i + 1, 0, words[i].substr(test.length));
words[i] = test;
}
test = line + words[i] + " ";
metrics = context.measureText(test);
if (metrics.width > maxWidth && i > 0) {
context.fillText(line, x, y);
line = words[i] + " ";
y += lineHeight;
} else {
line = test;
}
}
context.fillText(line, x, y);
}
const stringWidget = ComfyWidgets.STRING;
// Override multiline string widgets to draw text using canvas while saving as svg
ComfyWidgets.STRING = function () {
const w = stringWidget.apply(this, arguments);
if (w.widget && w.widget.type === "customtext") {
const draw = w.widget.draw;
w.widget.draw = function (ctx) {
draw.apply(this, arguments);
if (this.inputEl.hidden) return;
if (getDrawTextConfig) {
const config = getDrawTextConfig(ctx, this);
const t = ctx.getTransform();
ctx.save();
if (config.resetTransform) {
ctx.resetTransform();
}
const style = document.defaultView.getComputedStyle(this.inputEl, null);
const x = config.x;
const y = config.y;
const w = parseInt(this.inputEl.style.width);
const h = parseInt(this.inputEl.style.height);
ctx.fillStyle = style.getPropertyValue("background-color");
ctx.fillRect(x, y, w, h);
ctx.fillStyle = style.getPropertyValue("color");
ctx.font = style.getPropertyValue("font");
wrapText(ctx, this.inputEl.value, x, y + t.d * 12, w, t.d * 12);
ctx.restore();
}
};
}
return w;
};
},
setup() {
const script = document.createElement("script");
script.onload = function () {
const formats = [SvgWorkflowImage, PngWorkflowImage];
for (const f of formats) {
f.init?.call();
WorkflowImage.accept += (WorkflowImage.accept ? "," : "") + f.accept;
}
// Add canvas menu options
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig.apply(this, arguments);
options.push(null, {
content: "Workflow Image",
submenu: {
options: [
{
content: "Import",
callback: () => {
WorkflowImage.import();
},
},
{
content: "Export",
submenu: {
options: formats.flatMap((f) => [
{
content: f.name.replace("WorkflowImage", "").toLocaleLowerCase(),
callback: () => {
new f().export(true);
},
},
{
content: f.name.replace("WorkflowImage", "").toLocaleLowerCase() + " (no embedded workflow)",
callback: () => {
new f().export();
},
},
]),
},
},
],
},
});
return options;
};
};
script.src = new URL(`assets/canvas2svg.js`, import.meta.url);
document.body.append(script);
},
});
+228
View File
@@ -0,0 +1,228 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js"
import { $el } from "../../../scripts/ui.js";
// Adds workflow management
// Original implementation by https://github.com/i-h4x
// Thanks for permission to reimplement as an extension
const style = `
#comfy-save-button, #comfy-load-button {
position: relative;
overflow: hidden;
}
.pysssss-workflow-arrow {
position: absolute;
top: 0;
bottom: 0;
right: 0;
font-size: 12px;
display: flex;
align-items: center;
width: 24px;
justify-content: center;
background: rgba(255,255,255,0.1);
}
.pysssss-workflow-arrow:after {
content: "▼";
}
.pysssss-workflow-arrow:hover {
filter: brightness(1.6);
background-color: var(--comfy-menu-bg);
}
.pysssss-workflow-load .litemenu-entry:not(.has_submenu):before,
.pysssss-workflow-load ~ .litecontextmenu .litemenu-entry:not(.has_submenu):before {
content: "🎛️";
padding-right: 5px;
}
.pysssss-workflow-load .litemenu-entry.has_submenu:before,
.pysssss-workflow-load ~ .litecontextmenu .litemenu-entry.has_submenu:before {
content: "📂";
padding-right: 5px;
position: relative;
top: -1px;
}
.pysssss-workflow-popup ~ .litecontextmenu {
transform: scale(1.3);
}
`;
async function getWorkflows() {
const response = await api.fetchApi("/pysssss/workflows", { cache: "no-store" });
return await response.json();
}
async function getWorkflow(name) {
const response = await api.fetchApi(`/pysssss/workflows/${encodeURIComponent(name)}`, { cache: "no-store" });
return await response.json();
}
async function saveWorkflow(name, workflow, overwrite) {
try {
const response = await api.fetchApi("/pysssss/workflows", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name, workflow, overwrite }),
});
if (response.status === 201) {
return true;
}
if (response.status === 409) {
return false;
}
throw new Error(response.statusText);
} catch (error) {
console.error(error);
}
}
class PysssssWorkflows {
async load() {
this.workflows = await getWorkflows();
this.loadMenu.style.display = this.workflows.length ? "flex" : "none";
}
constructor() {
function buildMenu(workflows) {
var menu = [];
var directories = new Map();
for (const workflow of workflows) {
var path = workflow.split("/");
var parent = menu;
var currentPath = "";
for (var i = 0; i < path.length - 1; i++) {
currentPath += "/" + path[i];
var newParent = directories.get(currentPath);
if (!newParent) {
newParent = {
title: path[i],
has_submenu: true,
submenu: {
options: [],
},
};
parent.push(newParent);
newParent = newParent.submenu.options;
directories.set(currentPath, newParent);
}
parent = newParent;
}
parent.push({
title: path[path.length - 1],
callback: async () => {
const json = await getWorkflow(workflow);
app.loadGraphData(json);
},
});
}
return menu;
}
function addWorkflowMenu(type, getOptions) {
return $el("div.pysssss-workflow-arrow", {
parent: document.getElementById(`comfy-${type}-button`),
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
LiteGraph.closeAllContextMenus();
const menu = new LiteGraph.ContextMenu(
getOptions(),
{
event: e,
scale: 1.3,
},
window
);
menu.root.classList.add("pysssss-workflow-popup");
menu.root.classList.add(`pysssss-workflow-${type}`);
},
});
}
this.loadMenu = addWorkflowMenu("load", () => buildMenu(this.workflows || []));
addWorkflowMenu("save", () => {
return [
{
title: "Save as",
callback: () => {
let filename = prompt("Enter filename", this.workflowName || "workflow");
if (filename) {
if (!filename.toLowerCase().endsWith(".json")) {
filename += ".json";
}
this.workflowName = filename;
const json = JSON.stringify(app.graph.serialize(), null, 2); // convert the data to a JSON string
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = $el("a", {
href: url,
download: filename,
style: { display: "none" },
parent: document.body,
});
a.click();
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
}
},
},
{
title: "Save to workflows",
callback: async () => {
const name = prompt("Enter filename", this.workflowName || "workflow");
if (name) {
this.workflowName = name;
const data = app.graph.serialize();
if (!(await saveWorkflow(name, data))) {
if (confirm("A workspace with this name already exists, do you want to overwrite it?")) {
await saveWorkflow(name, app.graph.serialize(), true);
} else {
return;
}
}
await this.load();
}
},
},
];
});
this.load();
const handleFile = app.handleFile;
const self = this;
app.handleFile = function (file) {
if (file?.name?.endsWith(".json")) {
self.workflowName = file.name;
} else {
self.workflowName = null;
}
return handleFile.apply(this, arguments);
};
}
}
app.registerExtension({
name: "pysssss.Workflows",
init() {
$el("style", {
textContent: style,
parent: document.head,
});
},
setup() {
const workflows = new PysssssWorkflows();
const refreshComboInNodes = app.refreshComboInNodes;
app.refreshComboInNodes = function () {
workflows.load();
refreshComboInNodes.apply(this, arguments);
};
},
});