update to support comfyui native web file handling
This commit is contained in:
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.
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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 })]
|
||||
),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
static unescapeXml(safe) {
|
||||
return safe.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user