Add custom nodes, Civitai loras (LFS), and vast.ai setup script
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
Python Linting / Run Pylint (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Has been cancelled
Execution Tests / test (macos-latest) (push) Has been cancelled
Execution Tests / test (ubuntu-latest) (push) Has been cancelled
Execution Tests / test (windows-latest) (push) Has been cancelled
Test server launches without errors / test (push) Has been cancelled
Unit Tests / test (macos-latest) (push) Has been cancelled
Unit Tests / test (ubuntu-latest) (push) Has been cancelled
Unit Tests / test (windows-2022) (push) Has been cancelled

Includes 30 custom nodes committed directly, 7 Civitai-exclusive
loras stored via Git LFS, and a setup script that installs all
dependencies and downloads HuggingFace-hosted models on vast.ai.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 00:55:26 +00:00
parent 2b70ab9ad0
commit f09734b0ee
2274 changed files with 748556 additions and 3 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,602 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
import { api } from "../../../scripts/api.js";
import { $el, ComfyDialog } from "../../../scripts/ui.js";
import { TextAreaAutoComplete } from "./common/autocomplete.js";
import { ModelInfoDialog } from "./common/modelInfoDialog.js";
import { LoraInfoDialog } from "./modelInfo.js";
function parseCSV(csvText) {
const rows = [];
const delimiter = ",";
const quote = '"';
let currentField = "";
let inQuotedField = false;
function pushField() {
rows[rows.length - 1].push(currentField);
currentField = "";
inQuotedField = false;
}
rows.push([]); // Initialize the first row
for (let i = 0; i < csvText.length; i++) {
const char = csvText[i];
const nextChar = csvText[i + 1];
// Special handling for backslash escaped quotes
if (char === "\\" && nextChar === quote) {
currentField += quote;
i++;
}
if (!inQuotedField) {
if (char === quote) {
inQuotedField = true;
} else if (char === delimiter) {
pushField();
} else if (char === "\r" || char === "\n" || i === csvText.length - 1) {
pushField();
if (nextChar === "\n") {
i++; // Handle Windows line endings (\r\n)
}
rows.push([]); // Start a new row
} else {
currentField += char;
}
} else {
if (char === quote && nextChar === quote) {
currentField += quote;
i++; // Skip the next quote
} else if (char === quote) {
inQuotedField = false;
} else if (char === "\r" || char === "\n" || i === csvText.length - 1) {
// Dont allow new lines in quoted text, assume its wrong
const parsed = parseCSV(currentField);
rows.pop();
rows.push(...parsed);
inQuotedField = false;
currentField = "";
rows.push([]);
} else {
currentField += char;
}
}
}
if (currentField || csvText[csvText.length - 1] === ",") {
pushField();
}
// Remove the last row if it's empty
if (rows[rows.length - 1].length === 0) {
rows.pop();
}
return rows;
}
async function getCustomWords() {
const resp = await api.fetchApi("/pysssss/autocomplete", { cache: "no-store" });
if (resp.status === 200) {
return await resp.text();
}
return undefined;
}
async function addCustomWords(text) {
if (!text) {
text = await getCustomWords();
}
if (text) {
TextAreaAutoComplete.updateWords(
"pysssss.customwords",
parseCSV(text).reduce((p, n) => {
let text;
let priority;
let value;
let num;
switch (n.length) {
case 0:
return;
case 1:
// Single word
text = n[0];
break;
case 2:
// Word,[priority|alias]
num = +n[1];
if (isNaN(num)) {
text = n[0] + "🔄️" + n[1];
value = n[0];
} else {
text = n[0];
priority = num;
}
break;
case 4:
// a1111 csv format?
value = n[0];
priority = +n[2];
const aliases = n[3]?.trim();
if (aliases && aliases !== "null") { // Weird null in an example csv, maybe they are JSON.parsing the last column?
const split = aliases.split(",");
for (const text of split) {
p[text] = { text, priority, value };
}
}
text = value;
break;
default:
// Word,alias,priority
text = n[1];
value = n[0];
priority = +n[2];
break;
}
p[text] = { text, priority, value };
return p;
}, {})
);
}
}
function toggleLoras() {
[TextAreaAutoComplete.globalWords, TextAreaAutoComplete.globalWordsExclLoras] = [
TextAreaAutoComplete.globalWordsExclLoras,
TextAreaAutoComplete.globalWords,
];
}
class EmbeddingInfoDialog extends ModelInfoDialog {
async addInfo() {
super.addInfo();
const info = await this.addCivitaiInfo();
if (info) {
$el("div", {
parent: this.content,
innerHTML: info.description,
style: {
maxHeight: "250px",
overflow: "auto",
},
});
}
}
}
class CustomWordsDialog extends ComfyDialog {
async show() {
const text = await getCustomWords();
this.words = $el("textarea", {
textContent: text,
style: {
width: "70vw",
height: "70vh",
},
});
const input = $el("input", {
style: {
flex: "auto",
},
value:
"https://gist.githubusercontent.com/pythongosssss/1d3efa6050356a08cea975183088159a/raw/a18fb2f94f9156cf4476b0c24a09544d6c0baec6/danbooru-tags.txt",
});
super.show(
$el(
"div",
{
style: {
display: "flex",
flexDirection: "column",
overflow: "hidden",
maxHeight: "100%",
},
},
[
$el("h2", {
textContent: "Custom Autocomplete Words",
style: {
color: "#fff",
marginTop: 0,
textAlign: "center",
fontFamily: "sans-serif",
},
}),
$el(
"div",
{
style: {
color: "#fff",
fontFamily: "sans-serif",
display: "flex",
alignItems: "center",
gap: "5px",
},
},
[
$el("label", { textContent: "Load Custom List: " }),
input,
$el("button", {
textContent: "Load",
onclick: async () => {
try {
const res = await fetch(input.value);
if (res.status !== 200) {
throw new Error("Error loading: " + res.status + " " + res.statusText);
}
this.words.value = await res.text();
} catch (error) {
alert("Error loading custom list, try manually copy + pasting the list");
}
},
}),
]
),
this.words,
]
)
);
}
createButtons() {
const btns = super.createButtons();
const save = $el("button", {
type: "button",
textContent: "Save",
onclick: async (e) => {
try {
const res = await api.fetchApi("/pysssss/autocomplete", { method: "POST", body: this.words.value });
if (res.status !== 200) {
throw new Error("Error saving: " + res.status + " " + res.statusText);
}
save.textContent = "Saved!";
addCustomWords(this.words.value);
setTimeout(() => {
save.textContent = "Save";
}, 500);
} catch (error) {
alert("Error saving word list!");
console.error(error);
}
},
});
btns.unshift(save);
return btns;
}
}
const id = "pysssss.AutoCompleter";
app.registerExtension({
name: id,
init() {
const STRING = ComfyWidgets.STRING;
const SKIP_WIDGETS = new Set(["ttN xyPlot.x_values", "ttN xyPlot.y_values"]);
ComfyWidgets.STRING = function (node, inputName, inputData) {
const r = STRING.apply(this, arguments);
if (inputData[1]?.multiline) {
// Disabled on this input
const config = inputData[1]?.["pysssss.autocomplete"];
if (config === false) return r;
// In list of widgets to skip
const id = `${node.comfyClass}.${inputName}`;
if (SKIP_WIDGETS.has(id)) return r;
let words;
let separator;
if (typeof config === "object") {
separator = config.separator;
words = {};
if (config.words) {
// Custom wordlist, this will have been registered on setup
Object.assign(words, TextAreaAutoComplete.groups[node.comfyClass + "." + inputName] ?? {});
}
for (const item of config.groups ?? []) {
if (item === "*") {
// This widget wants all global words included
Object.assign(words, TextAreaAutoComplete.globalWords);
} else {
// This widget wants a specific group included
Object.assign(words, TextAreaAutoComplete.groups[item] ?? {});
}
}
}
new TextAreaAutoComplete(r.widget.inputEl, words, separator);
}
return r;
};
TextAreaAutoComplete.globalSeparator = localStorage.getItem(id + ".AutoSeparate") ?? ", ";
const enabledSetting = app.ui.settings.addSetting({
id,
name: "🐍 Text Autocomplete",
defaultValue: true,
type: (name, setter, value) => {
return $el("tr", [
$el("td", [
$el("label", {
for: id.replaceAll(".", "-"),
textContent: name,
}),
]),
$el("td", [
$el(
"label",
{
textContent: "Enabled ",
style: {
display: "block",
},
},
[
$el("input", {
id: id.replaceAll(".", "-"),
type: "checkbox",
checked: value,
onchange: (event) => {
const checked = !!event.target.checked;
TextAreaAutoComplete.enabled = checked;
setter(checked);
},
}),
]
),
$el(
"label.comfy-tooltip-indicator",
{
title: "This requires other ComfyUI nodes/extensions that support using LoRAs in the prompt.",
textContent: "Loras enabled ",
style: {
display: "block",
},
},
[
$el("input", {
type: "checkbox",
checked: !!TextAreaAutoComplete.lorasEnabled,
onchange: (event) => {
const checked = !!event.target.checked;
TextAreaAutoComplete.lorasEnabled = checked;
toggleLoras();
localStorage.setItem(id + ".ShowLoras", TextAreaAutoComplete.lorasEnabled);
},
}),
]
),
$el(
"label",
{
textContent: "Auto-insert comma ",
style: {
display: "block",
},
},
[
$el("input", {
type: "checkbox",
checked: !!TextAreaAutoComplete.globalSeparator,
onchange: (event) => {
const checked = !!event.target.checked;
TextAreaAutoComplete.globalSeparator = checked ? ", " : "";
localStorage.setItem(id + ".AutoSeparate", TextAreaAutoComplete.globalSeparator);
},
}),
]
),
$el(
"label",
{
textContent: "Replace _ with space ",
style: {
display: "block",
},
},
[
$el("input", {
type: "checkbox",
checked: !!TextAreaAutoComplete.replacer,
onchange: (event) => {
const checked = !!event.target.checked;
TextAreaAutoComplete.replacer = checked ? (v) => v.replaceAll("_", " ") : undefined;
localStorage.setItem(id + ".ReplaceUnderscore", checked);
},
}),
]
),
$el(
"label",
{
textContent: "Insert suggestion on: ",
style: {
display: "block",
},
},
[
$el(
"label",
{
textContent: "Tab",
style: {
display: "block",
marginLeft: "20px",
},
},
[
$el("input", {
type: "checkbox",
checked: !!TextAreaAutoComplete.insertOnTab,
onchange: (event) => {
const checked = !!event.target.checked;
TextAreaAutoComplete.insertOnTab = checked;
localStorage.setItem(id + ".InsertOnTab", checked);
},
}),
]
),
$el(
"label",
{
textContent: "Enter",
style: {
display: "block",
marginLeft: "20px",
},
},
[
$el("input", {
type: "checkbox",
checked: !!TextAreaAutoComplete.insertOnEnter,
onchange: (event) => {
const checked = !!event.target.checked;
TextAreaAutoComplete.insertOnEnter = checked;
localStorage.setItem(id + ".InsertOnEnter", checked);
},
}),
]
),
]
),
$el(
"label",
{
textContent: "Max suggestions: ",
style: {
display: "block",
},
},
[
$el("input", {
type: "number",
value: +TextAreaAutoComplete.suggestionCount,
style: {
width: "80px"
},
onchange: (event) => {
const value = +event.target.value;
TextAreaAutoComplete.suggestionCount = value;;
localStorage.setItem(id + ".SuggestionCount", TextAreaAutoComplete.suggestionCount);
},
}),
]
),
$el("button", {
textContent: "Manage Custom Words",
onclick: () => {
try {
// Try closing old settings window
if (typeof app.ui.settings.element?.close === "function") {
app.ui.settings.element.close();
}
} catch (error) {
}
try {
// Try closing new vue dialog
document.querySelector(".p-dialog-close-button").click();
} catch (error) {
// Fallback to just hiding the element
app.ui.settings.element.style.display = "none";
}
new CustomWordsDialog().show();
},
style: {
fontSize: "14px",
display: "block",
marginTop: "5px",
},
}),
]),
]);
},
});
TextAreaAutoComplete.enabled = enabledSetting.value;
TextAreaAutoComplete.replacer = localStorage.getItem(id + ".ReplaceUnderscore") === "true" ? (v) => v.replaceAll("_", " ") : undefined;
TextAreaAutoComplete.insertOnTab = localStorage.getItem(id + ".InsertOnTab") !== "false";
TextAreaAutoComplete.insertOnEnter = localStorage.getItem(id + ".InsertOnEnter") !== "false";
TextAreaAutoComplete.lorasEnabled = localStorage.getItem(id + ".ShowLoras") === "true";
TextAreaAutoComplete.suggestionCount = +localStorage.getItem(id + ".SuggestionCount") || 20;
},
setup() {
async function addEmbeddings() {
const embeddings = await api.getEmbeddings();
const words = {};
words["embedding:"] = { text: "embedding:" };
for (const emb of embeddings) {
const v = `embedding:${emb}`;
words[v] = {
text: v,
info: () => new EmbeddingInfoDialog(emb).show("embeddings", emb),
use_replacer: false,
};
}
TextAreaAutoComplete.updateWords("pysssss.embeddings", words);
}
async function addLoras() {
let loras;
try {
loras = LiteGraph.registered_node_types["LoraLoader"]?.nodeData.input.required.lora_name[0];
} catch (error) {}
if (!loras?.length) {
loras = await api.fetchApi("/pysssss/loras", { cache: "no-store" }).then((res) => res.json());
}
const words = {};
words["lora:"] = { text: "lora:" };
for (const lora of loras) {
const v = `<lora:${lora}:1.0>`;
words[v] = {
text: v,
info: () => new LoraInfoDialog(lora).show("loras", lora),
use_replacer: false,
};
}
TextAreaAutoComplete.updateWords("pysssss.loras", words);
}
// store global words with/without loras
Promise.all([addEmbeddings(), addCustomWords()])
.then(() => {
TextAreaAutoComplete.globalWordsExclLoras = Object.assign({}, TextAreaAutoComplete.globalWords);
})
.then(addLoras)
.then(() => {
if (!TextAreaAutoComplete.lorasEnabled) {
toggleLoras(); // off by default
}
});
},
beforeRegisterNodeDef(_, def) {
// Process each input to see if there is a custom word list for
// { input: { required: { something: ["STRING", { "pysssss.autocomplete": ["groupid", ["custom", "words"] ] }] } } }
const inputs = { ...def.input?.required, ...def.input?.optional };
for (const input in inputs) {
const config = inputs[input][1]?.["pysssss.autocomplete"];
if (!config) continue;
if (typeof config === "object" && config.words) {
const words = {};
for (const text of config.words || []) {
const obj = typeof text === "string" ? { text } : text;
words[obj.text] = obj;
}
TextAreaAutoComplete.updateWords(def.name + "." + input, words, false);
}
}
},
});

View File

@@ -0,0 +1,534 @@
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";
const IMAGE_WIDTH = 384;
const IMAGE_HEIGHT = 384;
function getType(node) {
if (node.comfyClass === CHECKPOINT_LOADER) {
return "checkpoints";
}
return "loras";
}
function getWidgetName(type) {
return type === "checkpoints" ? "ckpt_name" : "lora_name";
}
function encodeRFC3986URIComponent(str) {
return encodeURIComponent(str).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
}
const calculateImagePosition = (el, bodyRect) => {
let { top, left, right } = el.getBoundingClientRect();
const { width: bodyWidth, height: bodyHeight } = bodyRect;
const isSpaceRight = right + IMAGE_WIDTH <= bodyWidth;
if (isSpaceRight) {
left = right;
} else {
left -= IMAGE_WIDTH;
}
top = top - IMAGE_HEIGHT / 2;
if (top + IMAGE_HEIGHT > bodyHeight) {
top = bodyHeight - IMAGE_HEIGHT;
}
if (top < 0) {
top = 0;
}
return { left: Math.round(left), top: Math.round(top), isLeft: !isSpaceRight };
};
function showImage(relativeToEl, imageEl) {
const bodyRect = document.body.getBoundingClientRect();
if (!bodyRect) return;
const { left, top, isLeft } = calculateImagePosition(relativeToEl, bodyRect);
imageEl.style.left = `${left}px`;
imageEl.style.top = `${top}px`;
if (isLeft) {
imageEl.classList.add("left");
} else {
imageEl.classList.remove("left");
}
document.body.appendChild(imageEl);
}
let imagesByType = {};
const loadImageList = async (type) => {
imagesByType[type] = await (await api.fetchApi(`/pysssss/images/${type}`)).json();
};
app.registerExtension({
name: "pysssss.Combo++",
init() {
const displayOptions = { "List (normal)": 0, "Tree (subfolders)": 1, "Thumbnails (grid)": 2 };
const displaySetting = app.ui.settings.addSetting({
id: "pysssss.Combo++.Submenu",
name: "🐍 Lora & Checkpoint loader display mode",
defaultValue: 1,
type: "combo",
options: (value) => {
value = +value;
return Object.entries(displayOptions).map(([k, v]) => ({
value: v,
text: k,
selected: k === value,
}));
},
});
$el("style", {
textContent: `
.pysssss-combo-image {
position: absolute;
left: 0;
top: 0;
width: ${IMAGE_WIDTH}px;
height: ${IMAGE_HEIGHT}px;
object-fit: contain;
object-position: top left;
z-index: 9999;
}
.pysssss-combo-image.left {
object-position: top right;
}
.pysssss-combo-folder { opacity: 0.7 }
.pysssss-combo-folder-arrow { display: inline-block; width: 15px; }
.pysssss-combo-folder:hover { background-color: rgba(255, 255, 255, 0.1); }
.pysssss-combo-prefix { display: none }
/* Special handling for when the filter input is populated to revert to normal */
.litecontextmenu:has(input:not(:placeholder-shown)) .pysssss-combo-folder-contents {
display: block !important;
}
.litecontextmenu:has(input:not(:placeholder-shown)) .pysssss-combo-folder {
display: none;
}
.litecontextmenu:has(input:not(:placeholder-shown)) .pysssss-combo-prefix {
display: inline;
}
.litecontextmenu:has(input:not(:placeholder-shown)) .litemenu-entry {
padding-left: 2px !important;
}
/* Grid mode */
.pysssss-combo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 10px;
overflow-x: hidden;
max-width: 60vw;
}
.pysssss-combo-grid .comfy-context-menu-filter {
grid-column: 1 / -1;
position: sticky;
top: 0;
}
.pysssss-combo-grid .litemenu-entry {
word-break: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
}
.pysssss-combo-grid .litemenu-entry:before {
content: "";
display: block;
width: 100%;
height: 250px;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
/* No-image image attribution: Picture icons created by Pixel perfect - Flaticon */
background-image: var(--background-image, url(extensions/ComfyUI-Custom-Scripts/js/assets/no-image.png));
}
`,
parent: document.body,
});
const p1 = loadImageList("checkpoints");
const p2 = loadImageList("loras");
const refreshComboInNodes = app.refreshComboInNodes;
app.refreshComboInNodes = async function () {
const r = await Promise.all([
refreshComboInNodes.apply(this, arguments),
loadImageList("checkpoints").catch(() => {}),
loadImageList("loras").catch(() => {}),
]);
return r[0];
};
const imageHost = $el("img.pysssss-combo-image");
const positionMenu = (menu, fillWidth) => {
// compute best position
let left = app.canvas.last_mouse[0] - 10;
let top = app.canvas.last_mouse[1] - 10;
const body_rect = document.body.getBoundingClientRect();
const root_rect = menu.getBoundingClientRect();
if (body_rect.width && left > body_rect.width - root_rect.width - 10) left = body_rect.width - root_rect.width - 10;
if (body_rect.height && top > body_rect.height - root_rect.height - 10) top = body_rect.height - root_rect.height - 10;
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
if (fillWidth) {
menu.style.right = "10px";
}
};
const updateMenu = async (menu, type) => {
try {
await p1;
await p2;
} catch (error) {
console.error(error);
console.error("Error loading pysssss.betterCombos data");
}
// Clamp max height so it doesn't overflow the screen
const position = menu.getBoundingClientRect();
const maxHeight = window.innerHeight - position.top - 20;
menu.style.maxHeight = `${maxHeight}px`;
const images = imagesByType[type];
const items = menu.querySelectorAll(".litemenu-entry");
// Add image handler to items
const addImageHandler = (item) => {
const text = item.getAttribute("data-value").trim();
if (images[text]) {
const textNode = document.createTextNode("*");
item.appendChild(textNode);
item.addEventListener(
"mouseover",
() => {
imageHost.src = `/pysssss/view/${encodeRFC3986URIComponent(images[text])}?${+new Date()}`;
document.body.appendChild(imageHost);
showImage(item, imageHost);
},
{ passive: true }
);
item.addEventListener(
"mouseout",
() => {
imageHost.remove();
},
{ passive: true }
);
item.addEventListener(
"click",
() => {
imageHost.remove();
},
{ passive: true }
);
}
};
const createTree = () => {
// Create a map to store folder structures
const folderMap = new Map();
const rootItems = [];
const splitBy = (navigator.platform || navigator.userAgent).includes("Win") ? /\/|\\/ : /\//;
const itemsSymbol = Symbol("items");
// First pass - organize items into folder structure
for (const item of items) {
const path = item.getAttribute("data-value").split(splitBy);
// Remove path from visible text
item.textContent = path[path.length - 1];
if (path.length > 1) {
// Add the prefix path back in so it can be filtered on
const prefix = $el("span.pysssss-combo-prefix", {
textContent: path.slice(0, -1).join("/") + "/",
});
item.prepend(prefix);
}
addImageHandler(item);
if (path.length === 1) {
rootItems.push(item);
continue;
}
// Temporarily remove the item from current position
item.remove();
// Create folder hierarchy
let currentLevel = folderMap;
for (let i = 0; i < path.length - 1; i++) {
const folder = path[i];
if (!currentLevel.has(folder)) {
currentLevel.set(folder, new Map());
}
currentLevel = currentLevel.get(folder);
}
// Store the actual item in the deepest folder
if (!currentLevel.has(itemsSymbol)) {
currentLevel.set(itemsSymbol, []);
}
currentLevel.get(itemsSymbol).push(item);
}
const createFolderElement = (name) => {
const folder = $el("div.litemenu-entry.pysssss-combo-folder", {
innerHTML: `<span class="pysssss-combo-folder-arrow">▶</span> ${name}`,
style: { paddingLeft: "5px" },
});
return folder;
};
const insertFolderStructure = (parentElement, map, level = 0) => {
for (const [folderName, content] of map.entries()) {
if (folderName === itemsSymbol) continue;
const folderElement = createFolderElement(folderName);
folderElement.style.paddingLeft = `${level * 10 + 5}px`;
parentElement.appendChild(folderElement);
const childContainer = $el("div.pysssss-combo-folder-contents", {
style: { display: "none" },
});
// Add items in this folder
const items = content.get(itemsSymbol) || [];
for (const item of items) {
item.style.paddingLeft = `${(level + 1) * 10 + 14}px`;
childContainer.appendChild(item);
}
// Recursively add subfolders
insertFolderStructure(childContainer, content, level + 1);
parentElement.appendChild(childContainer);
// Add click handler for folder
folderElement.addEventListener("click", (e) => {
e.stopPropagation();
const arrow = folderElement.querySelector(".pysssss-combo-folder-arrow");
const contents = folderElement.nextElementSibling;
if (contents.style.display === "none") {
contents.style.display = "block";
arrow.textContent = "▼";
} else {
contents.style.display = "none";
arrow.textContent = "▶";
}
});
}
};
insertFolderStructure(items[0]?.parentElement || menu, folderMap);
positionMenu(menu);
};
const addImageData = (item) => {
const text = item.getAttribute("data-value").trim();
if (images[text]) {
item.style.setProperty("--background-image", `url(/pysssss/view/${encodeRFC3986URIComponent(images[text])})`);
}
};
if (displaySetting.value === 1 || displaySetting.value === true) {
createTree();
} else if (displaySetting.value === 2) {
menu.classList.add("pysssss-combo-grid");
for (const item of items) {
addImageData(item);
}
positionMenu(menu, true);
} else {
for (const item of items) {
addImageHandler(item);
}
}
};
const mutationObserver = new MutationObserver((mutations) => {
const node = app.canvas.current_node;
if (!node || (node.comfyClass !== LORA_LOADER && node.comfyClass !== CHECKPOINT_LOADER)) {
return;
}
for (const mutation of mutations) {
for (const removed of mutation.removedNodes) {
if (removed.classList?.contains("litecontextmenu")) {
imageHost.remove();
}
}
for (const added of mutation.addedNodes) {
if (added.classList?.contains("litecontextmenu")) {
const overWidget = app.canvas.getWidgetAtCursor();
const type = getType(node);
if (overWidget?.name === getWidgetName(type)) {
requestAnimationFrame(() => {
// Bad hack to prevent showing on right click menu by checking for the filter input
if (!added.querySelector(".comfy-context-menu-filter")) return;
updateMenu(added, type);
});
}
return;
}
}
}
});
mutationObserver.observe(document.body, { childList: true, subtree: false });
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
const isCkpt = nodeData.name === CHECKPOINT_LOADER;
const isLora = nodeData.name === 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);
this.widgets.find((w) => w.name === "prompt").computeSize = () => [0, -4];
let exampleWidget;
const get = async (route, suffix) => {
const url = encodeRFC3986URIComponent(`${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;
const pos = v.lastIndexOf(".");
const name = v.substr(0, pos);
let exampleName = exampleList.value;
let viewPath = `/${name}`;
if (exampleName === "notes") {
viewPath += ".txt";
} else {
viewPath += `/${exampleName}`;
}
const example = await (await get("view", viewPath)).text();
if (!exampleWidget) {
exampleWidget = ComfyWidgets["STRING"](this, "prompt", ["STRING", { multiline: true }], app).widget;
exampleWidget.inputEl.readOnly = true;
exampleWidget.inputEl.style.opacity = 0.6;
}
exampleWidget.value = example;
};
const exampleCb = exampleList.callback;
exampleList.callback = function () {
getExample();
return exampleCb?.apply(this, arguments) ?? exampleList.value;
};
const listExamples = async () => {
exampleList.disabled = true;
exampleList.options.values = ["[none]"];
exampleList.value = "[none]";
let examples = [];
if (this.widgets[0].value) {
try {
examples = await (await get("examples", `/${this.widgets[0].value}`)).json();
} catch (error) {}
}
exampleList.options.values = ["[none]", ...examples];
exampleList.value = exampleList.options.values[+!!examples.length];
exampleList.callback();
exampleList.disabled = !examples.length;
app.graph.setDirtyCanvas(true, true);
};
// Expose function to update examples
nodeType.prototype["pysssss.updateExamples"] = listExamples;
const modelWidget = this.widgets[0];
const modelCb = modelWidget.callback;
let prev = undefined;
modelWidget.callback = function () {
let ret = modelCb?.apply(this, arguments) ?? modelWidget.value;
if (typeof ret === "object" && "content" in ret) {
ret = ret.content;
modelWidget.value = ret;
}
let v = ret;
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,
callback: async () => {
const url = new URL(img.src);
await api.fetchApi("/pysssss/save/" + encodeRFC3986URIComponent(`${getType(n)}/${n.widgets[0].value}`), {
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",
},
});
loadImageList(getType(n));
},
})),
},
});
}
}
}
return getExtraMenuOptions?.apply(this, arguments);
};
},
});

View File

@@ -0,0 +1,62 @@
.pysssss-autocomplete {
color: var(--descrip-text);
background-color: var(--comfy-menu-bg);
position: absolute;
font-family: sans-serif;
box-shadow: 3px 3px 8px rgba(0, 0, 0, 0.4);
z-index: 9999;
overflow: auto;
}
.pysssss-autocomplete-item {
cursor: pointer;
padding: 3px 7px;
display: flex;
border-left: 3px solid transparent;
align-items: center;
}
.pysssss-autocomplete-item--selected {
border-left-color: dodgerblue;
}
.pysssss-autocomplete-highlight {
font-weight: bold;
text-decoration: underline;
text-decoration-color: dodgerblue;
}
.pysssss-autocomplete-pill {
margin-left: auto;
font-size: 10px;
color: #fff;
padding: 2px 4px 2px 14px;
position: relative;
}
.pysssss-autocomplete-pill::after {
content: "";
display: block;
background: rgba(255, 255, 255, 0.25);
width: calc(100% - 10px);
height: 100%;
position: absolute;
left: 10px;
top: 0;
border-radius: 5px;
}
.pysssss-autocomplete-pill + .pysssss-autocomplete-pill {
margin-left: 0;
}
.pysssss-autocomplete-item-info {
margin-left: auto;
transition: filter 0.2s;
will-change: filter;
text-decoration: none;
padding-left: 10px;
}
.pysssss-autocomplete-item-info:hover {
filter: invert(1);
}

View File

@@ -0,0 +1,692 @@
import { $el } from "../../../../scripts/ui.js";
import { addStylesheet } from "./utils.js";
addStylesheet(import.meta.url);
/*
https://github.com/component/textarea-caret-position
The MIT License (MIT)
Copyright (c) 2015 Jonathan Ong me@jongleberry.com
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const getCaretCoordinates = (function () {
// We'll copy the properties below into the mirror div.
// Note that some browsers, such as Firefox, do not concatenate properties
// into their shorthand (e.g. padding-top, padding-bottom etc. -> padding),
// so we have to list every single property explicitly.
var properties = [
"direction", // RTL support
"boxSizing",
"width", // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
"height",
"overflowX",
"overflowY", // copy the scrollbar for IE
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"borderStyle",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
// https://developer.mozilla.org/en-US/docs/Web/CSS/font
"fontStyle",
"fontVariant",
"fontWeight",
"fontStretch",
"fontSize",
"fontSizeAdjust",
"lineHeight",
"fontFamily",
"textAlign",
"textTransform",
"textIndent",
"textDecoration", // might not make a difference, but better be safe
"letterSpacing",
"wordSpacing",
"tabSize",
"MozTabSize",
];
var isBrowser = typeof window !== "undefined";
var isFirefox = isBrowser && window.mozInnerScreenX != null;
return function getCaretCoordinates(element, position, options) {
if (!isBrowser) {
throw new Error("textarea-caret-position#getCaretCoordinates should only be called in a browser");
}
var debug = (options && options.debug) || false;
if (debug) {
var el = document.querySelector("#input-textarea-caret-position-mirror-div");
if (el) el.parentNode.removeChild(el);
}
// The mirror div will replicate the textarea's style
var div = document.createElement("div");
div.id = "input-textarea-caret-position-mirror-div";
document.body.appendChild(div);
var style = div.style;
var computed = window.getComputedStyle ? window.getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9
var isInput = element.nodeName === "INPUT";
// Default textarea styles
style.whiteSpace = "pre-wrap";
if (!isInput) style.wordWrap = "break-word"; // only for textarea-s
// Position off-screen
style.position = "absolute"; // required to return coordinates properly
if (!debug) style.visibility = "hidden"; // not 'display: none' because we want rendering
// Transfer the element's properties to the div
properties.forEach(function (prop) {
if (isInput && prop === "lineHeight") {
// Special case for <input>s because text is rendered centered and line height may be != height
if (computed.boxSizing === "border-box") {
var height = parseInt(computed.height);
var outerHeight =
parseInt(computed.paddingTop) +
parseInt(computed.paddingBottom) +
parseInt(computed.borderTopWidth) +
parseInt(computed.borderBottomWidth);
var targetHeight = outerHeight + parseInt(computed.lineHeight);
if (height > targetHeight) {
style.lineHeight = height - outerHeight + "px";
} else if (height === targetHeight) {
style.lineHeight = computed.lineHeight;
} else {
style.lineHeight = 0;
}
} else {
style.lineHeight = computed.height;
}
} else {
style[prop] = computed[prop];
}
});
if (isFirefox) {
// Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
if (element.scrollHeight > parseInt(computed.height)) style.overflowY = "scroll";
} else {
style.overflow = "hidden"; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
}
div.textContent = element.value.substring(0, position);
// The second special handling for input type="text" vs textarea:
// spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
if (isInput) div.textContent = div.textContent.replace(/\s/g, "\u00a0");
var span = document.createElement("span");
// Wrapping must be replicated *exactly*, including when a long word gets
// onto the next line, with whitespace at the end of the line before (#7).
// The *only* reliable way to do that is to copy the *entire* rest of the
// textarea's content into the <span> created at the caret position.
// For inputs, just '.' would be enough, but no need to bother.
span.textContent = element.value.substring(position) || "."; // || because a completely empty faux span doesn't render at all
div.appendChild(span);
var coordinates = {
top: span.offsetTop + parseInt(computed["borderTopWidth"]),
left: span.offsetLeft + parseInt(computed["borderLeftWidth"]),
height: parseInt(computed["lineHeight"]),
};
if (debug) {
span.style.backgroundColor = "#aaa";
} else {
document.body.removeChild(div);
}
return coordinates;
};
})();
/*
Key functions from:
https://github.com/yuku/textcomplete
© Yuku Takahashi - This software is licensed under the MIT license.
The MIT License (MIT)
Copyright (c) 2015 Jonathan Ong me@jongleberry.com
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const CHAR_CODE_ZERO = "0".charCodeAt(0);
const CHAR_CODE_NINE = "9".charCodeAt(0);
class TextAreaCaretHelper {
constructor(el, getScale) {
this.el = el;
this.getScale = getScale;
}
#calculateElementOffset() {
const rect = this.el.getBoundingClientRect();
const owner = this.el.ownerDocument;
if (owner == null) {
throw new Error("Given element does not belong to document");
}
const { defaultView, documentElement } = owner;
if (defaultView == null) {
throw new Error("Given element does not belong to window");
}
const offset = {
top: rect.top + defaultView.pageYOffset,
left: rect.left + defaultView.pageXOffset,
};
if (documentElement) {
offset.top -= documentElement.clientTop;
offset.left -= documentElement.clientLeft;
}
return offset;
}
#isDigit(charCode) {
return CHAR_CODE_ZERO <= charCode && charCode <= CHAR_CODE_NINE;
}
#getLineHeightPx() {
const computedStyle = getComputedStyle(this.el);
const lineHeight = computedStyle.lineHeight;
// If the char code starts with a digit, it is either a value in pixels,
// or unitless, as per:
// https://drafts.csswg.org/css2/visudet.html#propdef-line-height
// https://drafts.csswg.org/css2/cascade.html#computed-value
if (this.#isDigit(lineHeight.charCodeAt(0))) {
const floatLineHeight = parseFloat(lineHeight);
// In real browsers the value is *always* in pixels, even for unit-less
// line-heights. However, we still check as per the spec.
return this.#isDigit(lineHeight.charCodeAt(lineHeight.length - 1))
? floatLineHeight * parseFloat(computedStyle.fontSize)
: floatLineHeight;
}
// Otherwise, the value is "normal".
// If the line-height is "normal", calculate by font-size
return this.#calculateLineHeightPx(this.el.nodeName, computedStyle);
}
/**
* Returns calculated line-height of the given node in pixels.
*/
#calculateLineHeightPx(nodeName, computedStyle) {
const body = document.body;
if (!body) return 0;
const tempNode = document.createElement(nodeName);
tempNode.innerHTML = "&nbsp;";
Object.assign(tempNode.style, {
fontSize: computedStyle.fontSize,
fontFamily: computedStyle.fontFamily,
padding: "0",
position: "absolute",
});
body.appendChild(tempNode);
// Make sure textarea has only 1 row
if (tempNode instanceof HTMLTextAreaElement) {
tempNode.rows = 1;
}
// Assume the height of the element is the line-height
const height = tempNode.offsetHeight;
body.removeChild(tempNode);
return height;
}
getCursorOffset() {
const scale = this.getScale();
const elOffset = this.#calculateElementOffset();
const elScroll = this.#getElScroll();
const cursorPosition = this.#getCursorPosition();
const lineHeight = this.#getLineHeightPx();
const top = elOffset.top - (elScroll.top * scale) + (cursorPosition.top + lineHeight) * scale;
const left = elOffset.left - elScroll.left + cursorPosition.left;
const clientTop = this.el.getBoundingClientRect().top;
if (this.el.dir !== "rtl") {
return { top, left, lineHeight, clientTop };
} else {
const right = document.documentElement ? document.documentElement.clientWidth - left : 0;
return { top, right, lineHeight, clientTop };
}
}
#getElScroll() {
return { top: this.el.scrollTop, left: this.el.scrollLeft };
}
#getCursorPosition() {
return getCaretCoordinates(this.el, this.el.selectionEnd);
}
getBeforeCursor() {
return this.el.selectionStart !== this.el.selectionEnd ? null : this.el.value.substring(0, this.el.selectionEnd);
}
getAfterCursor() {
return this.el.value.substring(this.el.selectionEnd);
}
insertAtCursor(value, offset, finalOffset) {
if (this.el.selectionStart != null) {
const startPos = this.el.selectionStart;
const endPos = this.el.selectionEnd;
// Move selection to beginning of offset
this.el.selectionStart = this.el.selectionStart + offset;
// Using execCommand to support undo, but since it's officially
// 'deprecated' we need a backup solution, but it won't support undo :(
let pasted = true;
try {
if (!document.execCommand("insertText", false, value)) {
pasted = false;
}
} catch (e) {
console.error("Error caught during execCommand:", e);
pasted = false;
}
if (!pasted) {
console.error(
"execCommand unsuccessful; not supported. Adding text manually, no undo support.");
textarea.setRangeText(modifiedText, this.el.selectionStart, this.el.selectionEnd, 'end');
}
this.el.selectionEnd = this.el.selectionStart = startPos + value.length + offset + (finalOffset ?? 0);
} else {
// Using execCommand to support undo, but since it's officially
// 'deprecated' we need a backup solution, but it won't support undo :(
let pasted = true;
try {
if (!document.execCommand("insertText", false, value)) {
pasted = false;
}
} catch (e) {
console.error("Error caught during execCommand:", e);
pasted = false;
}
if (!pasted) {
console.error(
"execCommand unsuccessful; not supported. Adding text manually, no undo support.");
this.el.value += value;
}
}
}
}
/*********************/
/**
* @typedef {{
* text: string,
* priority?: number,
* info?: Function,
* hint?: string,
* showValue?: boolean,
* caretOffset?: number
* }} AutoCompleteEntry
*/
export class TextAreaAutoComplete {
static globalSeparator = "";
static enabled = true;
static insertOnTab = true;
static insertOnEnter = true;
static replacer = undefined;
static lorasEnabled = false;
static suggestionCount = 20;
/** @type {Record<string, Record<string, AutoCompleteEntry>>} */
static groups = {};
/** @type {Set<string>} */
static globalGroups = new Set();
/** @type {Record<string, AutoCompleteEntry>} */
static globalWords = {};
/** @type {Record<string, AutoCompleteEntry>} */
static globalWordsExclLoras = {};
/** @type {HTMLTextAreaElement} */
el;
/** @type {Record<string, AutoCompleteEntry>} */
overrideWords;
overrideSeparator = "";
get words() {
return this.overrideWords ?? TextAreaAutoComplete.globalWords;
}
get separator() {
return this.overrideSeparator ?? TextAreaAutoComplete.globalSeparator;
}
/**
* @param {HTMLTextAreaElement} el
*/
constructor(el, words = null, separator = null) {
this.el = el;
this.helper = new TextAreaCaretHelper(el, () => app.canvas.ds.scale);
this.dropdown = $el("div.pysssss-autocomplete");
this.overrideWords = words;
this.overrideSeparator = separator;
this.#setup();
}
#setup() {
this.el.addEventListener("keydown", this.#keyDown.bind(this));
this.el.addEventListener("keypress", this.#keyPress.bind(this));
this.el.addEventListener("keyup", this.#keyUp.bind(this));
this.el.addEventListener("click", this.#hide.bind(this));
this.el.addEventListener("blur", () => setTimeout(() => this.#hide(), 150));
}
/**
* @param {KeyboardEvent} e
*/
#keyDown(e) {
if (!TextAreaAutoComplete.enabled) return;
if (this.dropdown.parentElement) {
// We are visible
switch (e.key) {
case "ArrowUp":
e.preventDefault();
if (this.selected.index) {
this.#setSelected(this.currentWords[this.selected.index - 1].wordInfo);
} else {
this.#setSelected(this.currentWords[this.currentWords.length - 1].wordInfo);
}
break;
case "ArrowDown":
e.preventDefault();
if (this.selected.index === this.currentWords.length - 1) {
this.#setSelected(this.currentWords[0].wordInfo);
} else {
this.#setSelected(this.currentWords[this.selected.index + 1].wordInfo);
}
break;
case "Tab":
if (TextAreaAutoComplete.insertOnTab) {
this.#insertItem();
e.preventDefault();
}
break;
}
}
}
/**
* @param {KeyboardEvent} e
*/
#keyPress(e) {
if (!TextAreaAutoComplete.enabled) return;
if (this.dropdown.parentElement) {
// We are visible
switch (e.key) {
case "Enter":
if (!e.ctrlKey) {
if (TextAreaAutoComplete.insertOnEnter) {
this.#insertItem();
e.preventDefault();
}
}
break;
}
}
if (!e.defaultPrevented) {
this.#update();
}
}
#keyUp(e) {
if (!TextAreaAutoComplete.enabled) return;
if (this.dropdown.parentElement) {
// We are visible
switch (e.key) {
case "Escape":
e.preventDefault();
this.#hide();
break;
}
} else if (e.key.length > 1 && e.key != "Delete" && e.key != "Backspace") {
return;
}
if (!e.defaultPrevented) {
this.#update();
}
}
#setSelected(item) {
if (this.selected) {
this.selected.el.classList.remove("pysssss-autocomplete-item--selected");
}
this.selected = item;
this.selected.el.classList.add("pysssss-autocomplete-item--selected");
}
#insertItem() {
if (!this.selected) return;
this.selected.el.click();
}
#getFilteredWords(term) {
term = term.toLocaleLowerCase();
const priorityMatches = [];
const prefixMatches = [];
const includesMatches = [];
for (const word of Object.keys(this.words)) {
const lowerWord = word.toLocaleLowerCase();
if (lowerWord === term) {
// Dont include exact matches
continue;
}
const pos = lowerWord.indexOf(term);
if (pos === -1) {
// No match
continue;
}
const wordInfo = this.words[word];
if (wordInfo.priority) {
priorityMatches.push({ pos, wordInfo });
} else if (pos) {
includesMatches.push({ pos, wordInfo });
} else {
prefixMatches.push({ pos, wordInfo });
}
}
priorityMatches.sort(
(a, b) =>
b.wordInfo.priority - a.wordInfo.priority ||
a.wordInfo.text.length - b.wordInfo.text.length ||
a.wordInfo.text.localeCompare(b.wordInfo.text)
);
const top = priorityMatches.length * 0.2;
return priorityMatches.slice(0, top).concat(prefixMatches, priorityMatches.slice(top), includesMatches).slice(0, TextAreaAutoComplete.suggestionCount);
}
#update() {
let before = this.helper.getBeforeCursor();
if (before?.length) {
const m = before.match(/([^,;"|{}()\n]+)$/);
if (m) {
before = m[0]
.replace(/^\s+/, "")
.replace(/\s/g, "_") || null;
} else {
before = null;
}
}
if (!before) {
this.#hide();
return;
}
this.currentWords = this.#getFilteredWords(before);
if (!this.currentWords.length) {
this.#hide();
return;
}
this.dropdown.style.display = "";
let hasSelected = false;
const items = this.currentWords.map(({ wordInfo, pos }, i) => {
const parts = [
$el("span", {
textContent: wordInfo.text.substr(0, pos),
}),
$el("span.pysssss-autocomplete-highlight", {
textContent: wordInfo.text.substr(pos, before.length),
}),
$el("span", {
textContent: wordInfo.text.substr(pos + before.length),
}),
];
if (wordInfo.hint) {
parts.push(
$el("span.pysssss-autocomplete-pill", {
textContent: wordInfo.hint,
})
);
}
if (wordInfo.priority) {
parts.push(
$el("span.pysssss-autocomplete-pill", {
textContent: wordInfo.priority,
})
);
}
if (wordInfo.value && wordInfo.text !== wordInfo.value && wordInfo.showValue !== false) {
parts.push(
$el("span.pysssss-autocomplete-pill", {
textContent: wordInfo.value,
})
);
}
if (wordInfo.info) {
parts.push(
$el("a.pysssss-autocomplete-item-info", {
textContent: "",
title: "View info...",
onclick: (e) => {
e.stopPropagation();
wordInfo.info();
e.preventDefault();
},
})
);
}
const item = $el(
"div.pysssss-autocomplete-item",
{
onclick: () => {
this.el.focus();
let value = wordInfo.value ?? wordInfo.text;
const use_replacer = wordInfo.use_replacer ?? true;
if (TextAreaAutoComplete.replacer && use_replacer) {
value = TextAreaAutoComplete.replacer(value);
}
value = this.#escapeParentheses(value);
const afterCursor = this.helper.getAfterCursor();
const shouldAddSeparator = !afterCursor.trim().startsWith(this.separator.trim());
this.helper.insertAtCursor(
value + (shouldAddSeparator ? this.separator : ''),
-before.length,
wordInfo.caretOffset
);
setTimeout(() => {
this.#update();
}, 150);
},
},
parts
);
if (wordInfo === this.selected) {
hasSelected = true;
}
wordInfo.index = i;
wordInfo.el = item;
return item;
});
this.#setSelected(hasSelected ? this.selected : this.currentWords[0].wordInfo);
this.dropdown.replaceChildren(...items);
if (!this.dropdown.parentElement) {
document.body.append(this.dropdown);
}
const position = this.helper.getCursorOffset();
this.dropdown.style.left = (position.left ?? 0) + "px";
this.dropdown.style.top = (position.top ?? 0) + "px";
this.dropdown.style.maxHeight = (window.innerHeight - position.top) + "px";
}
#escapeParentheses(text) {
return text.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
#hide() {
this.selected = null;
this.dropdown.remove();
}
static updateWords(id, words, addGlobal = true) {
const isUpdate = id in TextAreaAutoComplete.groups;
TextAreaAutoComplete.groups[id] = words;
if (addGlobal) {
TextAreaAutoComplete.globalGroups.add(id);
}
if (isUpdate) {
// Remerge all words
TextAreaAutoComplete.globalWords = Object.assign(
{},
...Object.keys(TextAreaAutoComplete.groups)
.filter((k) => TextAreaAutoComplete.globalGroups.has(k))
.map((k) => TextAreaAutoComplete.groups[k])
);
} else if (addGlobal) {
// Just insert the new words
Object.assign(TextAreaAutoComplete.globalWords, words);
}
}
}

View File

@@ -0,0 +1,244 @@
// @ts-check
// @ts-ignore
import { ComfyWidgets } from "../../../../scripts/widgets.js";
// @ts-ignore
import { api } from "../../../../scripts/api.js";
// @ts-ignore
import { app } from "../../../../scripts/app.js";
const PathHelper = {
get(obj, path) {
if (typeof path !== "string") {
// Hardcoded value
return path;
}
if (path[0] === '"' && path[path.length - 1] === '"') {
// Hardcoded string
return JSON.parse(path);
}
// Evaluate the path
path = path.split(".").filter(Boolean);
for (const p of path) {
const k = isNaN(+p) ? p : +p;
obj = obj[k];
}
return obj;
},
set(obj, path, value) {
// https://stackoverflow.com/a/54733755
if (Object(obj) !== obj) return obj; // When obj is not an object
// If not yet an array, get the keys from the string-path
if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || [];
path.slice(0, -1).reduce(
(
a,
c,
i // Iterate all of them except the last one
) =>
Object(a[c]) === a[c] // Does the key exist and is its value an object?
? // Yes: then follow that path
a[c]
: // No: create the key. Is the next key a potential array-index?
(a[c] =
Math.abs(path[i + 1]) >> 0 === +path[i + 1]
? [] // Yes: assign a new array object
: {}), // No: assign a new plain object
obj
)[path[path.length - 1]] = value; // Finally assign the value to the last key
return obj; // Return the top-level object to allow chaining
},
};
/***
@typedef { {
left: string;
op: "eq" | "ne",
right: string
} } IfCondition
@typedef { {
type: "if",
condition: Array<IfCondition>,
true?: Array<BindingCallback>,
false?: Array<BindingCallback>
} } IfCallback
@typedef { {
type: "fetch",
url: string,
then: Array<BindingCallback>
} } FetchCallback
@typedef { {
type: "set",
target: string,
value: string
} } SetCallback
@typedef { {
type: "validate-combo",
} } ValidateComboCallback
@typedef { IfCallback | FetchCallback | SetCallback | ValidateComboCallback } BindingCallback
@typedef { {
source: string,
callback: Array<BindingCallback>
} } Binding
***/
/**
* @param {IfCondition} condition
*/
function evaluateCondition(condition, state) {
const left = PathHelper.get(state, condition.left);
const right = PathHelper.get(state, condition.right);
let r;
if (condition.op === "eq") {
r = left === right;
} else {
r = left !== right;
}
return r;
}
/**
* @type { Record<BindingCallback["type"], (cb: any, state: Record<string, any>) => Promise<void>> }
*/
const callbacks = {
/**
* @param {IfCallback} cb
*/
async if(cb, state) {
// For now only support ANDs
let success = true;
for (const condition of cb.condition) {
const r = evaluateCondition(condition, state);
if (!r) {
success = false;
break;
}
}
for (const m of cb[success + ""] ?? []) {
await invokeCallback(m, state);
}
},
/**
* @param {FetchCallback} cb
*/
async fetch(cb, state) {
const url = cb.url.replace(/\{([^\}]+)\}/g, (m, v) => {
return PathHelper.get(state, v);
});
const res = await (await api.fetchApi(url)).json();
state["$result"] = res;
for (const m of cb.then) {
await invokeCallback(m, state);
}
},
/**
* @param {SetCallback} cb
*/
async set(cb, state) {
const value = PathHelper.get(state, cb.value);
PathHelper.set(state, cb.target, value);
},
async "validate-combo"(cb, state) {
const w = state["$this"];
const valid = w.options.values.includes(w.value);
if (!valid) {
w.value = w.options.values[0];
}
},
};
async function invokeCallback(callback, state) {
if (callback.type in callbacks) {
// @ts-ignore
await callbacks[callback.type](callback, state);
} else {
console.warn(
"%c[🐍 pysssss]",
"color: limegreen",
`[binding ${state.$node.comfyClass}.${state.$this.name}]`,
"unsupported binding callback type:",
callback.type
);
}
}
app.registerExtension({
name: "pysssss.Binding",
beforeRegisterNodeDef(node, nodeData) {
const hasBinding = (v) => {
if (!v) return false;
return Object.values(v).find((c) => c[1]?.["pysssss.binding"]);
};
const inputs = { ...nodeData.input?.required, ...nodeData.input?.optional };
if (hasBinding(inputs)) {
const onAdded = node.prototype.onAdded;
node.prototype.onAdded = function () {
const r = onAdded?.apply(this, arguments);
for (const widget of this.widgets || []) {
const bindings = inputs[widget.name][1]?.["pysssss.binding"];
if (!bindings) continue;
for (const binding of bindings) {
/**
* @type {import("../../../../../web/types/litegraph.d.ts").IWidget}
*/
const source = this.widgets.find((w) => w.name === binding.source);
if (!source) {
console.warn(
"%c[🐍 pysssss]",
"color: limegreen",
`[binding ${node.comfyClass}.${widget.name}]`,
"unable to find source binding widget:",
binding.source,
binding
);
continue;
}
let lastValue;
async function valueChanged() {
const state = {
$this: widget,
$source: source,
$node: node,
};
for (const callback of binding.callback) {
await invokeCallback(callback, state);
}
app.graph.setDirtyCanvas(true, false);
}
const cb = source.callback;
source.callback = function () {
const v = cb?.apply(this, arguments) ?? source.value;
if (v !== lastValue) {
lastValue = v;
valueChanged();
}
return v;
};
lastValue = source.value;
valueChanged();
}
}
return r;
};
}
},
});

View File

@@ -0,0 +1,102 @@
.pysssss-lightbox {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
z-index: 1001;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
transition: opacity 0.2s;
}
.pysssss-lightbox-prev,
.pysssss-lightbox-next {
height: 60px;
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 {
display: grid;
flex: auto;
place-content: center;
text-align: center;
}
.pysssss-lightbox-link {
display: flex;
justify-content: center;
align-items: center;
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;
}

View File

@@ -0,0 +1,149 @@
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();
},
onwheel: (e) => {
if (!(e instanceof WheelEvent) || e.ctrlKey) {
return;
}
const direction = Math.sign(e.deltaY);
this.update(direction);
},
});
}
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) {
if (shift < 0 && this.index <= 0) {
return;
}
if (shift > 0 && this.index >= this.images.length - 1) {
return;
}
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";
try {
await loadImage(img);
} catch (err) {
console.error('failed to load image', img, err);
}
this.spinner.style.display = "none";
this.link.href = img;
this.img.src = img;
this.img.style.opacity = 1;
}
async updateWithNewImage(img, feedDirection) {
// No-op if lightbox is not open
if (this.el.style.display === "none" || this.el.style.opacity === "0") return;
// Ensure currently shown image does not change
const [method, shift] = feedDirection === "newest first" ? ["unshift", 1] : ["push", 0];
this.images[method](img);
await this.update(shift);
}
}
export const lightbox = new Lightbox();
addEventListener('keydown', (event) => {
if (lightbox.el.style.display === 'none') {
return;
}
const { key } = event;
switch (key) {
case 'ArrowLeft':
case 'a':
lightbox.update(-1);
break;
case 'ArrowRight':
case 'd':
lightbox.update(1);
break;
case 'Escape':
lightbox.close();
break;
}
});

View File

@@ -0,0 +1,119 @@
.pysssss-model-info {
color: white;
font-family: sans-serif;
max-width: 90vw;
}
.pysssss-model-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
.pysssss-model-info h2 {
text-align: center;
margin: 0 0 10px 0;
}
.pysssss-model-info p {
margin: 5px 0;
}
.pysssss-model-info a {
color: dodgerblue;
}
.pysssss-model-info a:hover {
text-decoration: underline;
}
.pysssss-model-tags-list {
display: flex;
flex-wrap: wrap;
list-style: none;
gap: 10px;
max-height: 200px;
overflow: auto;
margin: 10px 0;
padding: 0;
}
.pysssss-model-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-model-tag--selected span::before {
content: "✅";
position: absolute;
background-color: dodgerblue;
left: 0;
top: 0;
right: 0;
bottom: 0;
text-align: center;
}
.pysssss-model-tag:hover {
outline: 2px solid dodgerblue;
}
.pysssss-model-tag p {
margin: 0;
}
.pysssss-model-tag span {
text-align: center;
border-radius: 5px;
background-color: dodgerblue;
color: #fff;
padding: 2px;
position: relative;
min-width: 20px;
overflow: hidden;
}
.pysssss-model-metadata .comfy-modal-content {
max-width: 100%;
}
.pysssss-model-metadata label {
margin-right: 1ch;
color: #ccc;
}
.pysssss-model-metadata span {
color: dodgerblue;
}
.pysssss-preview {
max-width: 50%;
margin-left: 10px;
position: relative;
}
.pysssss-preview img {
max-height: 300px;
}
.pysssss-preview button {
position: absolute;
font-size: 12px;
bottom: 10px;
right: 10px;
}
.pysssss-preview button+button {
bottom: 34px;
}
.pysssss-preview button.pysssss-preview-nav {
bottom: unset;
right: 30px;
top: 10px;
font-size: 14px;
line-height: 14px;
}
.pysssss-preview button.pysssss-preview-nav+.pysssss-preview-nav {
right: 10px;
}
.pysssss-model-notes {
background-color: rgba(0, 0, 0, 0.25);
padding: 5px;
margin-top: 5px;
}
.pysssss-model-notes:empty {
display: none;
}

View File

@@ -0,0 +1,358 @@
import { $el, ComfyDialog } from "../../../../scripts/ui.js";
import { api } from "../../../../scripts/api.js";
import { addStylesheet } from "./utils.js";
addStylesheet(import.meta.url);
class MetadataDialog extends ComfyDialog {
constructor() {
super();
this.element.classList.add("pysssss-model-metadata");
}
show(metadata) {
super.show(
$el(
"div",
Object.keys(metadata).map((k) =>
$el("div", [
$el("label", { textContent: k }),
$el("span", { textContent: typeof metadata[k] === "object" ? JSON.stringify(metadata[k]) : metadata[k] }),
])
)
)
);
}
}
export class ModelInfoDialog extends ComfyDialog {
constructor(name, node) {
super();
this.name = name;
this.node = node;
this.element.classList.add("pysssss-model-info");
}
get customNotes() {
return this.metadata["pysssss.notes"];
}
set customNotes(v) {
this.metadata["pysssss.notes"] = v;
}
get hash() {
return this.metadata["pysssss.sha256"];
}
async show(type, value) {
this.type = type;
const req = api.fetchApi("/pysssss/metadata/" + encodeURIComponent(`${type}/${value}`));
this.info = $el("div", { style: { flex: "auto" } });
this.img = $el("img", { style: { display: "none" } });
this.imgWrapper = $el("div.pysssss-preview", [this.img]);
this.main = $el("main", { style: { display: "flex" } }, [this.info, this.imgWrapper]);
this.content = $el("div.pysssss-model-content", [$el("h2", { textContent: this.name }), this.main]);
const loading = $el("div", { textContent: " Loading...", parent: this.content });
super.show(this.content);
this.metadata = await (await req).json();
this.viewMetadata.style.cursor = this.viewMetadata.style.opacity = "";
this.viewMetadata.removeAttribute("disabled");
loading.remove();
this.addInfo();
}
createButtons() {
const btns = super.createButtons();
this.viewMetadata = $el("button", {
type: "button",
textContent: "View raw metadata",
disabled: "disabled",
style: {
opacity: 0.5,
cursor: "not-allowed",
},
onclick: (e) => {
if (this.metadata) {
new MetadataDialog().show(this.metadata);
}
},
});
btns.unshift(this.viewMetadata);
return btns;
}
getNoteInfo() {
function parseNote() {
if (!this.customNotes) return [];
let notes = [];
// Extract links from notes
const r = new RegExp("(\\bhttps?:\\/\\/[^\\s]+)", "g");
let end = 0;
let m;
do {
m = r.exec(this.customNotes);
let pos;
let fin = 0;
if (m) {
pos = m.index;
fin = m.index + m[0].length;
} else {
pos = this.customNotes.length;
}
let pre = this.customNotes.substring(end, pos);
if (pre) {
pre = pre.replaceAll("\n", "<br>");
notes.push(
$el("span", {
innerHTML: pre,
})
);
}
if (m) {
notes.push(
$el("a", {
href: m[0],
textContent: m[0],
target: "_blank",
})
);
}
end = fin;
} while (m);
return notes;
}
let textarea;
let notesContainer;
const editText = "✏️ Edit";
const edit = $el("a", {
textContent: editText,
href: "#",
style: {
float: "right",
color: "greenyellow",
textDecoration: "none",
},
onclick: async (e) => {
e.preventDefault();
if (textarea) {
this.customNotes = textarea.value;
const resp = await api.fetchApi("/pysssss/metadata/notes/" + encodeURIComponent(`${this.type}/${this.name}`), {
method: "POST",
body: this.customNotes,
});
if (resp.status !== 200) {
console.error(resp);
alert(`Error saving notes (${req.status}) ${req.statusText}`);
return;
}
e.target.textContent = editText;
textarea.remove();
textarea = null;
notesContainer.replaceChildren(...parseNote.call(this));
this.node?.["pysssss.updateExamples"]?.();
} else {
e.target.textContent = "💾 Save";
textarea = $el("textarea", {
style: {
width: "100%",
minWidth: "200px",
minHeight: "50px",
},
textContent: this.customNotes,
});
e.target.after(textarea);
notesContainer.replaceChildren();
textarea.style.height = Math.min(textarea.scrollHeight, 300) + "px";
}
},
});
notesContainer = $el("div.pysssss-model-notes", parseNote.call(this));
return $el(
"div",
{
style: { display: "contents" },
},
[edit, notesContainer]
);
}
addInfo() {
const usageHint = this.metadata["modelspec.usage_hint"];
if (usageHint) {
this.addInfoEntry("Usage Hint", usageHint);
}
this.addInfoEntry("Notes", this.getNoteInfo());
}
addInfoEntry(name, value) {
return $el(
"p",
{
parent: this.info,
},
[
typeof name === "string" ? $el("label", { textContent: name + ": " }) : name,
typeof value === "string" ? $el("span", { textContent: value }) : value,
]
);
}
async getCivitaiDetails() {
const req = await fetch("https://civitai.com/api/v1/model-versions/by-hash/" + this.hash);
if (req.status === 200) {
return await req.json();
} else if (req.status === 404) {
throw new Error("Model not found");
} else {
throw new Error(`Error loading info (${req.status}) ${req.statusText}`);
}
}
addCivitaiInfo() {
const promise = this.getCivitaiDetails();
const content = $el("span", { textContent: " Loading..." });
this.addInfoEntry(
$el("label", [
$el("img", {
style: {
width: "18px",
position: "relative",
top: "3px",
margin: "0 5px 0 0",
},
src: "https://civitai.com/favicon.ico",
}),
$el("span", { textContent: "Civitai: " }),
]),
content
);
return promise
.then((info) => {
content.replaceChildren(
$el("a", {
href: "https://civitai.com/models/" + info.modelId,
textContent: "View " + info.model.name,
target: "_blank",
})
);
const allPreviews = info.images?.filter((i) => i.type === "image");
const previews = allPreviews?.filter((i) => i.nsfwLevel <= ModelInfoDialog.nsfwLevel);
if (previews?.length) {
let previewIndex = 0;
let preview;
const updatePreview = () => {
preview = previews[previewIndex];
this.img.src = preview.url;
};
updatePreview();
this.img.style.display = "";
this.img.title = `${previews.length} previews.`;
if (allPreviews.length !== previews.length) {
this.img.title += ` ${allPreviews.length - previews.length} images hidden due to NSFW level.`;
}
this.imgSave = $el("button", {
textContent: "Use as preview",
parent: this.imgWrapper,
onclick: async () => {
// Convert the preview to a blob
const blob = await (await fetch(this.img.src)).blob();
// Store it in temp
const name = "temp_preview." + new URL(this.img.src).pathname.split(".")[1];
const body = new FormData();
body.append("image", new File([blob], name));
body.append("overwrite", "true");
body.append("type", "temp");
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body,
});
if (resp.status !== 200) {
console.error(resp);
alert(`Error saving preview (${req.status}) ${req.statusText}`);
return;
}
// Use as preview
await api.fetchApi("/pysssss/save/" + encodeURIComponent(`${this.type}/${this.name}`), {
method: "POST",
body: JSON.stringify({
filename: name,
type: "temp",
}),
headers: {
"content-type": "application/json",
},
});
app.refreshComboInNodes();
},
});
$el("button", {
textContent: "Show metadata",
parent: this.imgWrapper,
onclick: async () => {
if (preview.meta && Object.keys(preview.meta).length) {
new MetadataDialog().show(preview.meta);
} else {
alert("No image metadata found");
}
},
});
const addNavButton = (icon, direction) => {
$el("button.pysssss-preview-nav", {
textContent: icon,
parent: this.imgWrapper,
onclick: async () => {
previewIndex += direction;
if (previewIndex < 0) {
previewIndex = previews.length - 1;
} else if (previewIndex >= previews.length) {
previewIndex = 0;
}
updatePreview();
},
});
};
if (previews.length > 1) {
addNavButton("", -1);
addNavButton("", 1);
}
} else if (info.images?.length) {
$el("span", { style: { opacity: 0.6 }, textContent: "⚠️ All images hidden due to NSFW level setting.", parent: this.imgWrapper });
}
return info;
})
.catch((err) => {
content.textContent = "⚠️ " + err.message;
});
}
}

View File

@@ -0,0 +1,35 @@
.pysssss-lds-ring {
display: inline-block;
position: absolute;
width: 80px;
height: 80px;
}
.pysssss-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;
}
.pysssss-lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.pysssss-lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.pysssss-lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,9 @@
import { addStylesheet } from "./utils.js";
addStylesheet(import.meta.url);
export function createSpinner() {
const div = document.createElement("div");
div.innerHTML = `<div class="pysssss-lds-ring"><div></div><div></div><div></div><div></div></div>`;
return div.firstElementChild;
}

View File

@@ -0,0 +1,30 @@
import { $el } from "../../../../scripts/ui.js";
export function addStylesheet(url) {
if (url.endsWith(".js")) {
url = url.substr(0, url.length - 2) + "css";
}
$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;
});
}

View File

@@ -0,0 +1,90 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "pysssss.ContextMenuHook",
init() {
const getOrSet = (target, name, create) => {
if (name in target) return target[name];
return (target[name] = create());
};
const symbol = getOrSet(window, "__pysssss__", () => Symbol("__pysssss__"));
const store = getOrSet(window, symbol, () => ({}));
const contextMenuHook = getOrSet(store, "contextMenuHook", () => ({}));
for (const e of ["ctor", "preAddItem", "addItem"]) {
if (!contextMenuHook[e]) {
contextMenuHook[e] = [];
}
}
// Big ol' hack to get allow customizing the context menu
// Replace the addItem function with our own that wraps the context of "this" with a proxy
// That proxy then replaces the constructor with another proxy
// That proxy then calls the custom ContextMenu that supports filters
const ctorProxy = new Proxy(LiteGraph.ContextMenu, {
construct(target, args) {
return new LiteGraph.ContextMenu(...args);
},
});
function triggerCallbacks(name, getArgs, handler) {
const callbacks = contextMenuHook[name];
if (callbacks && callbacks instanceof Array) {
for (const cb of callbacks) {
const r = cb(...getArgs());
handler?.call(this, r);
}
} else {
console.warn("[pysssss 🐍]", `invalid ${name} callbacks`, callbacks, name in contextMenuHook);
}
}
const addItem = LiteGraph.ContextMenu.prototype.addItem;
LiteGraph.ContextMenu.prototype.addItem = function () {
const proxy = new Proxy(this, {
get(target, prop) {
if (prop === "constructor") {
return ctorProxy;
}
return target[prop];
},
});
proxy.__target__ = this;
let el;
let args = arguments;
triggerCallbacks(
"preAddItem",
() => [el, this, args],
(r) => {
if (r !== undefined) el = r;
}
);
if (el === undefined) {
el = addItem.apply(proxy, arguments);
}
triggerCallbacks(
"addItem",
() => [el, this, args],
(r) => {
if (r !== undefined) el = r;
}
);
return el;
};
// We also need to patch the ContextMenu constructor to unwrap the parent else it fails a LiteGraph type check
const ctxMenu = LiteGraph.ContextMenu;
LiteGraph.ContextMenu = function (values, options) {
if (options?.parentMenu) {
if (options.parentMenu.__target__) {
options.parentMenu = options.parentMenu.__target__;
}
}
triggerCallbacks("ctor", () => [values, options]);
return ctxMenu.call(this, values, options);
};
LiteGraph.ContextMenu.prototype = ctxMenu.prototype;
},
});

View File

@@ -0,0 +1,98 @@
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") || menus[i].firstElementChild.value?.content?.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) {
const fApplyColor = function(node){
if (picker.value) {
if (node.constructor === LiteGraph.LGraphGroup) {
node.color = picker.value;
} else {
node.color = colorShade(picker.value, 20);
node.bgcolor = picker.value;
}
}
}
const graphcanvas = LGraphCanvas.active_canvas;
if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1){
fApplyColor(activeNode);
} else {
for (let i in graphcanvas.selected_nodes) {
fApplyColor(graphcanvas.selected_nodes[i]);
}
}
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;
};
},
});

View File

@@ -0,0 +1,58 @@
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",
async setup() {
let link = document.querySelector("link[rel~='icon']");
if (!link) {
link = document.createElement("link");
link.rel = "icon";
document.head.appendChild(link);
}
const getUrl = (active, user) => new URL(`assets/favicon${active ? "-active" : ""}${user ? ".user" : ""}.ico`, import.meta.url);
const testUrl = async (active) => {
const url = getUrl(active, true);
const r = await fetch(url, {
method: "HEAD",
});
if (r.status === 200) {
return url;
}
return getUrl(active, false);
};
const activeUrl = await testUrl(true);
const idleUrl = await testUrl(false);
let executing = false;
const update = () => (link.href = executing ? activeUrl : idleUrl);
for (const e of ["execution_start", "progress"]) {
api.addEventListener(e, () => {
executing = true;
update();
});
}
api.addEventListener("executing", ({ detail }) => {
// null will be sent when it's finished
executing = !!detail;
update();
});
api.addEventListener("status", ({ detail }) => {
let title = "ComfyUI";
if (detail && detail.exec_info.queue_remaining) {
title = `(${detail.exec_info.queue_remaining}) ${title}`;
}
document.title = title;
update();
executing = false;
});
update();
},
});

View File

@@ -0,0 +1,91 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "pysssss.GraphArrange",
setup(app) {
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig.apply(this, arguments);
options.push({ content: "Arrange (float left)", callback: () => graph.arrange() });
options.push({
content: "Arrange (float right)",
callback: () => {
(function () {
var margin = 50;
var layout;
const nodes = this.computeExecutionOrder(false, true);
const columns = [];
// Find node first use
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i];
let max = null;
for (const out of node.outputs || []) {
if (out.links) {
for (const link of out.links) {
const outNode = app.graph.getNodeById(app.graph.links[link].target_id);
if (!outNode) continue;
var l = outNode._level - 1;
if (max === null) max = l;
else if (l < max) max = l;
}
}
}
if (max != null) node._level = max;
}
for (let i = 0; i < nodes.length; ++i) {
const node = nodes[i];
const col = node._level || 1;
if (!columns[col]) {
columns[col] = [];
}
columns[col].push(node);
}
let x = margin;
for (let i = 0; i < columns.length; ++i) {
const column = columns[i];
if (!column) {
continue;
}
column.sort((a, b) => {
var as = !(a.type === "SaveImage" || a.type === "PreviewImage");
var bs = !(b.type === "SaveImage" || b.type === "PreviewImage");
var r = as - bs;
if (r === 0) r = (a.inputs?.length || 0) - (b.inputs?.length || 0);
if (r === 0) r = (a.outputs?.length || 0) - (b.outputs?.length || 0);
return r;
});
let max_size = 100;
let y = margin + LiteGraph.NODE_TITLE_HEIGHT;
for (let j = 0; j < column.length; ++j) {
const node = column[j];
node.pos[0] = layout == LiteGraph.VERTICAL_LAYOUT ? y : x;
node.pos[1] = layout == LiteGraph.VERTICAL_LAYOUT ? x : y;
const max_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 1 : 0;
if (node.size[max_size_index] > max_size) {
max_size = node.size[max_size_index];
}
const node_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 0 : 1;
y += node.size[node_size_index] + margin + LiteGraph.NODE_TITLE_HEIGHT + j;
}
// Right align in column
for (let j = 0; j < column.length; ++j) {
const node = column[j];
node.pos[0] += max_size - node.size[0];
}
x += max_size + margin;
}
this.setDirtyCanvas(true, true);
}).apply(app.graph);
},
});
return options;
};
},
});

View File

@@ -0,0 +1,604 @@
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;
}
div > .pysssss-image-feed {
position: static;
}
.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;
padding-top: 5px;
}
.pysssss-image-feed--left, .pysssss-image-feed--right {
top: 0;
height: 100vh;
min-width: 200px;
max-width: calc(var(--max-size, 10) * 1vw);
}
.comfyui-body-left .pysssss-image-feed--left, .comfyui-body-right .pysssss-image-feed--right {
height: 100%;
}
.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;
scrollbar-gutter: stable both-edges;
padding: 5px;
background: var(--comfy-input-bg);
border-radius: 5px;
margin: 5px;
margin-top: 0px;
}
.pysssss-image-feed-list:empty {
display: none;
}
.pysssss-image-feed-list div {
height: 100%;
text-align: center;
}
.pysssss-image-feed-list::-webkit-scrollbar {
background: var(--comfy-input-bg);
border-radius: 5px;
}
.pysssss-image-feed-list::-webkit-scrollbar-thumb {
background:var(--comfy-menu-bg);
border: 5px solid transparent;
border-radius: 8px;
background-clip: content-box;
}
.pysssss-image-feed-list::-webkit-scrollbar-thumb:hover {
background: var(--border-color);
background-clip: content-box;
}
.pysssss-image-feed-list img {
object-fit: var(--img-fit, contain);
max-width: 100%;
max-height: calc(var(--max-size) * 1vh);
border-radius: 4px;
}
.pysssss-image-feed-list img:hover {
filter: brightness(1.2);
}`,
parent: document.body,
});
app.registerExtension({
name: "pysssss.ImageFeed",
async setup() {
let visible = true;
const seenImages = new Map();
const showButton = $el("button.comfy-settings-btn", {
textContent: "🖼️",
style: {
right: "16px",
cursor: "pointer",
display: "none",
},
});
let showMenuButton;
if (!app.menu?.element.style.display && app.menu?.settingsGroup) {
showMenuButton = new (await import("../../../scripts/ui/components/button.js")).ComfyButton({
icon: "image-multiple",
action: () => showButton.click(),
tooltip: "Show Image Feed 🐍",
content: "Show Image Feed 🐍",
});
showMenuButton.enabled = false;
showMenuButton.element.style.display = "none";
app.menu.settingsGroup.append(showMenuButton);
}
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");
const imageList = $el("div.pysssss-image-feed-list");
function updateMenuParent(location) {
if (showMenuButton) {
const el = document.querySelector(".comfyui-body-" + location);
if (!el) return;
el.append(imageFeed);
} else {
if (!imageFeed.parent) {
document.body.append(imageFeed);
}
}
}
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}`;
updateMenuParent(feedLocation.value);
saveVal("Location", feedLocation.value);
window.dispatchEvent(new Event("resize"));
},
},
["left", "top", "right", "bottom", "hidden"].map((m) =>
$el("option", {
value: m,
textContent: m,
selected: feedLocation.value === m,
})
)
),
]),
]);
},
onChange(value) {
if (value === "hidden") {
imageFeed.remove();
if (showMenuButton) {
requestAnimationFrame(() => {
showMenuButton.element.style.display = "none";
});
}
showButton.style.display = "none";
} else {
showMenuButton.element.style.display = "unset";
showButton.style.display = visible ? "none" : "unset";
imageFeed.className = `pysssss-image-feed pysssss-image-feed--${value}`;
updateMenuParent(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 deduplicateFeed = app.ui.settings.addSetting({
id: "pysssss.ImageFeed.Deduplication",
name: "🐍 Image Feed Deduplication",
tooltip: `Ensures unique images in the image feed but at the cost of CPU-bound performance impact \
(from hundreds of milliseconds to seconds per image, depending on byte size). For workflows that produce duplicate images, turning this setting on may yield overall client-side performance improvements \
by reducing the number of images in the feed.
Recommended: "enabled (max performance)" uness images are erroneously deduplicated.`,
defaultValue: 0,
type: "combo",
options: (value) => {
let dedupeOptions = { disabled: 0, "enabled (slow)": 1, "enabled (performance)": 0.5, "enabled (max performance)": 0.25 };
return Object.entries(dedupeOptions).map(([k, v]) => ({
value: v,
text: k,
selected: k === value,
}));
},
});
const maxImages = app.ui.settings.addSetting({
id: "pysssss.ImageFeed.MaxImages",
name: "🐍 Image Feed Max Images",
tooltip: `Limits the number of images in the feed to a maximum, removing the oldest images as new ones are added.`,
defaultValue: 0,
type: "number",
});
const saveNodeOnly = app.ui.settings.addSetting({
id: "pysssss.ImageFeed.SaveNodeOnly",
name: "🐍 Image Feed Display 'SaveImage' Only",
tooltip: `Only show images from 'SaveImage' nodes. This prevents 'PreviewImage' node outputs from appearing in the feed.`,
defaultValue: false,
type: "boolean",
});
const clearButton = $el("button.pysssss-image-feed-btn.clear-btn", {
textContent: "Clear",
onclick: () => {
imageList.replaceChildren();
window.dispatchEvent(new Event("resize"));
},
});
const hideButton = $el("button.pysssss-image-feed-btn.hide-btn", {
textContent: "❌",
onclick: () => {
imageFeed.style.display = "none";
showButton.style.display = feedLocation.value === "hidden" ? "none" : "unset";
if (showMenuButton) {
showMenuButton.enabled = true;
showMenuButton.element.style.display = "";
}
saveVal("Visible", 0);
visible = false;
window.dispatchEvent(new Event("resize"));
},
});
let columnInput;
function updateColumnCount(v) {
columnInput.parentElement.title = `Controls the number of columns in the feed (${v} columns).\nClick label to set custom value.`;
imageFeed.style.setProperty("--img-sz", v);
saveVal("ImageSize", v);
columnInput.max = Math.max(10, v, columnInput.max);
columnInput.value = v;
window.dispatchEvent(new Event("resize"));
}
function addImageToFeed(href) {
const method = feedDirection.value === "newest first" ? "prepend" : "append";
if (maxImages.value > 0 && imageList.children.length >= maxImages.value) {
imageList.children[method === "prepend" ? imageList.children.length - 1 : 0].remove();
}
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 })]
),
])
);
// If lightbox is open, update it with new image
lightbox.updateWithNewImage(href, feedDirection.value);
}
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);
window.dispatchEvent(new Event("resize"));
},
$: (el) => {
requestAnimationFrame(() => {
el.value = getVal("FeedSize", 25);
el.oninput({ target: el });
});
},
}),
]),
$el("section.size-control.image-size-control", {}, [
$el("a", {
textContent: "Column count...",
style: {
cursor: "pointer",
textDecoration: "underline",
},
onclick: () => {
const v = +prompt("Enter custom column count", 20);
if (!isNaN(v)) {
updateColumnCount(v);
}
},
}),
$el("input", {
type: "range",
min: 1,
max: 10,
step: 1,
oninput: (e) => {
updateColumnCount(e.target.value);
},
$: (el) => {
columnInput = el;
requestAnimationFrame(() => {
updateColumnCount(getVal("ImageSize", 4));
});
},
}),
]),
]),
]),
$el("div.pysssss-image-feed-btn-group", {}, [clearButton, hideButton]),
]),
imageList
);
showButton.onclick = () => {
imageFeed.style.display = "flex";
showButton.style.display = "none";
if (showMenuButton) {
showMenuButton.enabled = false;
showMenuButton.element.style.display = "none";
}
saveVal("Visible", 1);
visible = true;
window.dispatchEvent(new Event("resize"));
};
document.querySelector(".comfy-settings-btn").after(showButton);
window.dispatchEvent(new Event("resize"));
if (!+getVal("Visible", 1)) {
hideButton.onclick();
}
api.addEventListener("executed", ({ detail }) => {
if (visible && detail?.output?.images) {
if (detail.node?.includes?.(":")) {
// Ignore group nodes
const n = app.graph.getNodeById(detail.node.split(":")[0]);
if (n?.getInnerNodes) return;
}
// Apply "Display Save Image Node Only" filter if setting is enabled
const nodeName = detail.node?.split(":")?.[0];
if (nodeName) {
const node = app.graph.getNodeById(nodeName);
if (saveNodeOnly.value && node?.type !== "SaveImage") return;
}
for (const src of detail.output.images) {
const href = `./view?filename=${encodeURIComponent(src.filename)}&type=${src.type}&
subfolder=${encodeURIComponent(src.subfolder)}&t=${+new Date()}`;
// deduplicateFeed.value is essentially the scaling factor used for image hashing
// but when deduplication is disabled, this value is "0"
if (deduplicateFeed.value > 0) {
// deduplicate by ignoring images with the same filename/type/subfolder
const fingerprint = JSON.stringify({ filename: src.filename, type: src.type, subfolder: src.subfolder });
if (seenImages.has(fingerprint)) {
// NOOP: image is a duplicate
} else {
seenImages.set(fingerprint, true);
let img = $el("img", { src: href });
img.onerror = () => {
// fall back to default behavior
addImageToFeed(href);
};
img.onload = () => {
// redraw the image onto a canvas to strip metadata (resize if performance mode)
let imgCanvas = document.createElement("canvas");
let imgScalar = deduplicateFeed.value;
imgCanvas.width = imgScalar * img.width;
imgCanvas.height = imgScalar * img.height;
let imgContext = imgCanvas.getContext("2d");
imgContext.drawImage(img, 0, 0, imgCanvas.width, imgCanvas.height);
const data = imgContext.getImageData(0, 0, imgCanvas.width, imgCanvas.height);
// calculate fast hash of the image data
let hash = 0;
for (const b of data.data) {
hash = (hash << 5) - hash + b;
}
// add image to feed if we've never seen the hash before
if (seenImages.has(hash)) {
// NOOP: image is a duplicate
} else {
// if we got to here, then the image is unique--so add to feed
seenImages.set(hash, true);
addImageToFeed(href);
}
};
}
} else {
addImageToFeed(href);
}
}
}
});
},
});

View File

@@ -0,0 +1,54 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "pysssss.KSamplerAdvDenoise",
async beforeRegisterNodeDef(nodeType) {
// Add menu options to conver to/from widgets
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
const r = origGetExtraMenuOptions?.apply?.(this, arguments);
let stepsWidget = null;
let startAtWidget = null;
let endAtWidget = null;
for (const w of this.widgets || []) {
if (w.name === "steps") {
stepsWidget = w;
} else if (w.name === "start_at_step") {
startAtWidget = w;
} else if (w.name === "end_at_step") {
endAtWidget = w;
}
}
if (stepsWidget && startAtWidget && endAtWidget) {
options.push(
{
content: "Set Denoise",
callback: () => {
const steps = +prompt("How many steps do you want?", 15);
if (isNaN(steps)) {
return;
}
const denoise = +prompt("How much denoise? (0-1)", 0.5);
if (isNaN(denoise)) {
return;
}
stepsWidget.value = Math.floor(steps / Math.max(0, Math.min(1, denoise)));
stepsWidget.callback?.(stepsWidget.value);
startAtWidget.value = stepsWidget.value - steps;
startAtWidget.callback?.(startAtWidget.value);
endAtWidget.value = stepsWidget.value;
endAtWidget.callback?.(endAtWidget.value);
},
},
null
);
}
return r;
};
},
});

View File

@@ -0,0 +1,57 @@
import { app } from "../../../scripts/app.js";
import { $el } from "../../../scripts/ui.js";
const id = "pysssss.LinkRenderMode";
const ext = {
name: id,
async setup(app) {
if (app.extensions.find((ext) => ext.name === "Comfy.LinkRenderMode")) {
console.log("%c[🐍 pysssss]", "color: limegreen", "Skipping LinkRenderMode as core extension found");
return;
}
const setting = app.ui.settings.addSetting({
id,
name: "🐍 Link Render Mode",
defaultValue: 2,
type: () => {
return $el("tr", [
$el("td", [
$el("label", {
for: id.replaceAll(".", "-"),
textContent: "🐍 Link Render Mode:",
}),
]),
$el("td", [
$el(
"select",
{
textContent: "Manage",
style: {
fontSize: "14px",
},
oninput: (e) => {
setting.value = e.target.value;
app.canvas.links_render_mode = +e.target.value;
app.graph.setDirtyCanvas(true, true);
},
},
LiteGraph.LINK_RENDER_MODES.map((m, i) =>
$el("option", {
value: i,
textContent: m,
selected: i == app.canvas.links_render_mode,
})
)
),
]),
]);
},
onChange(value) {
app.canvas.links_render_mode = +value;
app.graph.setDirtyCanvas(true);
},
});
},
};
app.registerExtension(ext);

View File

@@ -0,0 +1,44 @@
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.onNodeCreated = function() {
// These are typed as any to bypass backend validation
// update frontend to restrict types
for(const input of this.inputs) {
input.type = "INT,FLOAT,IMAGE,LATENT";
}
}
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;
};
}
},
});

View File

@@ -0,0 +1,49 @@
import { app } from "../../../scripts/app.js";
const id = "pysssss.MiddleClickAddDefaultNode";
const ext = {
name: id,
async setup(app) {
app.ui.settings.addSetting({
id,
name: "🐍 Middle click slot to add",
defaultValue: "Reroute",
type: "combo",
options: (value) =>
[
...Object.keys(LiteGraph.registered_node_types)
.filter((k) => k.includes("Reroute"))
.sort((a, b) => {
if (a === "Reroute") return -1;
if (b === "Reroute") return 1;
return a.localeCompare(b);
}),
"[None]",
].map((m) => ({
value: m,
text: m,
selected: !value ? m === "[None]" : m === value,
})),
onChange(value) {
const enable = value && value !== "[None]";
if (value === true) {
value = "Reroute";
}
LiteGraph.middle_click_slot_add_default_node = enable;
if (enable) {
for (const arr of Object.values(LiteGraph.slot_types_default_in).concat(
Object.values(LiteGraph.slot_types_default_out)
)) {
const idx = arr.indexOf(value);
if (idx !== 0) {
arr.splice(idx, 1);
}
arr.unshift(value);
}
}
},
});
},
};
app.registerExtension(ext);

View File

@@ -0,0 +1,412 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { $el } from "../../../scripts/ui.js";
import { ModelInfoDialog } from "./common/modelInfoDialog.js";
const MAX_TAGS = 500;
const NsfwLevel = {
PG: 1,
PG13: 2,
R: 4,
X: 8,
XXX: 16,
Blocked: 32,
};
export class LoraInfoDialog extends ModelInfoDialog {
getTagFrequency() {
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]);
}
getResolutions() {
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;
}
getTagList(tags) {
return tags.map((t) =>
$el(
"li.pysssss-model-tag",
{
dataset: {
tag: t[0],
},
$: (el) => {
el.onclick = () => {
el.classList.toggle("pysssss-model-tag--selected");
};
},
},
[
$el("p", {
textContent: t[0],
}),
$el("span", {
textContent: t[1],
}),
]
)
);
}
addTags() {
let tags = this.getTagFrequency();
if (!tags?.length) {
tags = this.metadata["modelspec.tags"]?.split(",").map((t) => [t.trim(), 1]);
}
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.getTagFrequency()));
hasMore.remove();
},
}),
]);
}
list = $el("ol.pysssss-model-tags-list", this.getTagList(tags));
this.tags = $el("div", [list]);
} else {
this.tags = $el("p", { textContent: "⚠️ No tag frequency metadata found" });
}
this.content.append(this.tags);
if (hasMore) {
this.content.append(hasMore);
}
}
addExample(title, value, name) {
const textArea = $el("textarea", {
textContent: value,
style: {
whiteSpace: "pre-wrap",
margin: "10px 0",
color: "#fff",
background: "#222",
padding: "5px",
borderRadius: "5px",
maxHeight: "250px",
overflow: "auto",
display: "block",
border: "none",
width: "calc(100% - 10px)",
},
});
$el(
"p",
{
parent: this.content,
textContent: `${title}: `,
},
[
textArea,
$el("button", {
onclick: async () => {
await this.saveAsExample(textArea.value, `${name}.txt`);
},
textContent: "Save as Example",
style: {
fontSize: "14px",
},
}),
$el("hr"),
]
);
}
async addInfo() {
this.addInfoEntry("Name", this.metadata.ss_output_name || "⚠️ Unknown");
this.addInfoEntry("Base Model", this.metadata.ss_sd_model_name || "⚠️ Unknown");
this.addInfoEntry("Clip Skip", this.metadata.ss_clip_skip || "⚠️ Unknown");
this.addInfoEntry(
"Resolution",
$el(
"select",
this.getResolutions().map((r) => $el("option", { textContent: r }))
)
);
super.addInfo();
const p = this.addCivitaiInfo();
this.addTags();
const info = await p;
this.addExample("Trained Words", info?.trainedWords?.join(", ") ?? "", "trainedwords");
const triggerPhrase = this.metadata["modelspec.trigger_phrase"];
if (triggerPhrase) {
this.addExample("Trigger Phrase", triggerPhrase, "triggerphrase");
}
$el("div", {
parent: this.content,
innerHTML: info?.description ?? this.metadata["modelspec.description"] ?? "[No description provided]",
style: {
maxHeight: "250px",
overflow: "auto",
},
});
}
async saveAsExample(example, name = "example.txt") {
if (!example.length) {
return;
}
try {
name = prompt("Enter example name", name);
if (!name) return;
await api.fetchApi("/pysssss/examples/" + encodeURIComponent(`${this.type}/${this.name}`), {
method: "POST",
body: JSON.stringify({
name,
example,
}),
headers: {
"content-type": "application/json",
},
});
this.node?.["pysssss.updateExamples"]?.();
alert("Saved!");
} catch (error) {
console.error(error);
alert("Error saving: " + error);
}
}
createButtons() {
const btns = super.createButtons();
function tagsToCsv(tags) {
return tags.map((el) => el.dataset.tag).join(", ");
}
function copyTags(e, tags) {
const textarea = $el("textarea", {
parent: document.body,
style: {
position: "fixed",
},
textContent: tagsToCsv(tags),
});
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: "Save Selected as Example",
onclick: async (e) => {
const tags = tagsToCsv([...this.tags.querySelectorAll(".pysssss-model-tag--selected")]);
await this.saveAsExample(tags);
},
}),
$el("button", {
type: "button",
textContent: "Copy Selected",
onclick: (e) => {
copyTags(e, [...this.tags.querySelectorAll(".pysssss-model-tag--selected")]);
},
}),
$el("button", {
type: "button",
textContent: "Copy All",
onclick: (e) => {
copyTags(e, [...this.tags.querySelectorAll(".pysssss-model-tag")]);
},
})
);
return btns;
}
}
class CheckpointInfoDialog extends ModelInfoDialog {
async addInfo() {
super.addInfo();
const info = await this.addCivitaiInfo();
if (info) {
this.addInfoEntry("Base Model", info.baseModel || "⚠️ Unknown");
$el("div", {
parent: this.content,
innerHTML: info.description,
style: {
maxHeight: "250px",
overflow: "auto",
},
});
}
}
}
const lookups = {};
function addInfoOption(node, type, infoClass, widgetNamePattern, opts) {
const widgets = widgetNamePattern
? node.widgets.filter((w) => w.name === widgetNamePattern || w.name.match(`^${widgetNamePattern}$`))
: [node.widgets[0]];
for (const widget of widgets) {
let value = widget.value;
if (value?.content) {
value = value.content;
}
if (!value || value === "None") {
return;
}
let optName;
const split = value.split(/[.\\/]/);
optName = split[split.length - 2];
opts.push({
content: optName,
callback: async () => {
new infoClass(value, node).show(type, value);
},
});
}
}
function addTypeOptions(node, typeName, options) {
const type = typeName.toLowerCase() + "s";
const values = lookups[typeName][node.type];
if (!values) return;
const widgets = Object.keys(values);
const cls = type === "loras" ? LoraInfoDialog : CheckpointInfoDialog;
const opts = [];
for (const w of widgets) {
addInfoOption(node, type, cls, w, opts);
}
if (!opts.length) return;
if (opts.length === 1) {
opts[0].content = `View ${typeName} info...`;
options.unshift(opts[0]);
} else {
options.unshift({
title: `View ${typeName} info...`,
has_submenu: true,
submenu: {
options: opts,
},
});
}
}
app.registerExtension({
name: "pysssss.ModelInfo",
setup() {
const addSetting = (type, defaultValue) => {
app.ui.settings.addSetting({
id: `pysssss.ModelInfo.${type}Nodes`,
name: `🐍 Model Info - ${type} Nodes/Widgets`,
type: "text",
defaultValue,
tooltip: `Comma separated list of NodeTypeName or NodeTypeName.WidgetName that contain ${type} node names that should have the View Info option available.\nIf no widget name is specifed the first widget will be used. Regex matches (e.g. NodeName..*lora_\\d+) are supported in the widget name.`,
onChange(value) {
lookups[type] = value.split(",").reduce((p, n) => {
n = n.trim();
const pos = n.indexOf(".");
const split = pos === -1 ? [n] : [n.substring(0, pos), n.substring(pos + 1)];
p[split[0]] ??= {};
p[split[0]][split[1] ?? ""] = true;
return p;
}, {});
},
});
};
addSetting(
"Lora",
["LoraLoader.lora_name", "LoraLoader|pysssss", "LoraLoaderModelOnly.lora_name", "LoRA Stacker.lora_name.*"].join(",")
);
addSetting(
"Checkpoint",
["CheckpointLoader.ckpt_name", "CheckpointLoaderSimple", "CheckpointLoader|pysssss", "Efficient Loader", "Eff. Loader SDXL"].join(",")
);
app.ui.settings.addSetting({
id: `pysssss.ModelInfo.NsfwLevel`,
name: `🐍 Model Info - Image Preview Max NSFW Level`,
type: "combo",
defaultValue: "PG13",
options: Object.keys(NsfwLevel),
tooltip: `Hides preview images that are tagged as a higher NSFW level`,
onChange(value) {
ModelInfoDialog.nsfwLevel = NsfwLevel[value] ?? NsfwLevel.PG;
},
});
},
beforeRegisterNodeDef(nodeType) {
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
if (this.widgets) {
for (const type in lookups) {
addTypeOptions(this, type, options);
}
}
return getExtraMenuOptions?.apply(this, arguments);
};
},
});

View File

@@ -0,0 +1,82 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
// Adds a menu option to toggle follow the executing node
// Adds a menu option to go to the currently executing node
// Adds a menu option to go to a node by type
app.registerExtension({
name: "pysssss.NodeFinder",
setup() {
let followExecution = false;
const centerNode = (id) => {
if (!followExecution || !id) return;
const node = app.graph.getNodeById(id);
if (!node) return;
app.canvas.centerOnNode(node);
};
api.addEventListener("executing", ({ detail }) => centerNode(detail));
// Add canvas menu options
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
const options = orig.apply(this, arguments);
options.push(null, {
content: followExecution ? "Stop following execution" : "Follow execution",
callback: () => {
if ((followExecution = !followExecution)) {
centerNode(app.runningNodeId);
}
},
});
if (app.runningNodeId) {
options.push({
content: "Show executing node",
callback: () => {
const node = app.graph.getNodeById(app.runningNodeId);
if (!node) return;
app.canvas.centerOnNode(node);
},
});
}
const nodes = app.graph._nodes;
const types = nodes.reduce((p, n) => {
if (n.type in p) {
p[n.type].push(n);
} else {
p[n.type] = [n];
}
return p;
}, {});
options.push({
content: "Go to node",
has_submenu: true,
submenu: {
options: Object.keys(types)
.sort()
.map((t) => ({
content: t,
has_submenu: true,
submenu: {
options: types[t]
.sort((a, b) => {
return a.pos[0] - b.pos[0];
})
.map((n) => ({
content: `${n.getTitle()} - #${n.id} (${n.pos[0]}, ${n.pos[1]})`,
callback: () => {
app.canvas.centerOnNode(n);
},
})),
},
})),
},
});
return options;
};
},
});

View File

@@ -0,0 +1,36 @@
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;
}
}
let file = this.widgets[2].value;
if (!file) {
file = "notify.mp3";
}
if (!file.startsWith("http")) {
if (!file.includes("/")) {
file = "assets/" + file;
}
file = new URL(file, import.meta.url)
}
const url = new URL(file);
const audio = new Audio(url);
audio.volume = this.widgets[1].value;
audio.play();
};
}
},
});

View File

@@ -0,0 +1,257 @@
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 MISSING = Symbol();
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;
}
},
});
const drawNodeWidgets = LGraphCanvas.prototype.drawNodeWidgets
LGraphCanvas.prototype.drawNodeWidgets = function(node) {
const c = LiteGraph.WIDGET_BGCOLOR;
try {
if(node[MISSING]) {
LiteGraph.WIDGET_BGCOLOR = "red"
}
return drawNodeWidgets.apply(this, arguments);
} finally {
LiteGraph.WIDGET_BGCOLOR = c;
}
}
},
registerCustomNodes() {
class PresetTextNode extends LiteGraph.LGraphNode {
constructor() {
super();
this.title = "Preset Text 🐍";
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) {
this[MISSING] = true;
app.graph.setDirtyCanvas(true, true);
const msg = `Preset text '${widget.value}' not found. Please fix this and queue again.`;
throw new Error(msg);
}
delete this[MISSING];
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;
},
];
let inheritedSerializeValue = widget.serializeValue || null;
let called = false;
const serializeValue = async (workflowNode, widgetIndex) => {
const origWidgetValue = widget.value;
if (called) return origWidgetValue;
called = true;
let allCallbacks = [...callbacks];
if (inheritedSerializeValue) {
allCallbacks.push(inheritedSerializeValue)
}
let valueIsUndefined = false;
for (const cb of allCallbacks) {
let value = await cb(workflowNode, widgetIndex);
// Need to check the callback return value before it is set on widget.value as it coerces it to a string (even for undefined)
if (value === undefined) valueIsUndefined = true;
widget.value = value;
}
const prompt = valueIsUndefined ? undefined : widget.value;
widget.value = origWidgetValue;
called = false;
return prompt;
};
Object.defineProperty(widget, "serializeValue", {
get() {
return serializeValue;
},
set(cb) {
inheritedSerializeValue = cb;
},
});
}
}
},
});

View File

@@ -0,0 +1,196 @@
import { app } from "../../../scripts/app.js";
// Adds a bunch of context menu entries for quickly adding common steps
function addMenuHandler(nodeType, cb) {
const getOpts = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function () {
const r = getOpts.apply(this, arguments);
cb.apply(this, arguments);
return r;
};
}
function getOrAddVAELoader(node) {
let vaeNode = app.graph._nodes.find((n) => n.type === "VAELoader");
if (!vaeNode) {
vaeNode = addNode("VAELoader", node);
}
return vaeNode;
}
function addNode(name, nextTo, options) {
options = { select: true, shiftY: 0, before: false, ...(options || {}) };
const node = LiteGraph.createNode(name);
app.graph.add(node);
node.pos = [
options.before ? nextTo.pos[0] - node.size[0] - 30 : nextTo.pos[0] + nextTo.size[0] + 30,
nextTo.pos[1] + options.shiftY,
];
if (options.select) {
app.canvas.selectNode(node, false);
}
return node;
}
app.registerExtension({
name: "pysssss.QuickNodes",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.input && nodeData.input.required) {
const keys = Object.keys(nodeData.input.required);
for (let i = 0; i < keys.length; i++) {
if (nodeData.input.required[keys[i]][0] === "VAE") {
addMenuHandler(nodeType, function (_, options) {
options.unshift({
content: "Use VAE",
callback: () => {
getOrAddVAELoader(this).connect(0, this, i);
},
});
});
break;
}
}
}
if (nodeData.name === "KSampler") {
addMenuHandler(nodeType, function (_, options) {
options.unshift(
{
content: "Add Blank Input",
callback: () => {
const imageNode = addNode("EmptyLatentImage", this, { before: true });
imageNode.connect(0, this, 3);
},
},
{
content: "Add Hi-res Fix",
callback: () => {
const upscaleNode = addNode("LatentUpscale", this);
this.connect(0, upscaleNode, 0);
const sampleNode = addNode("KSampler", upscaleNode);
for (let i = 0; i < 3; i++) {
const l = this.getInputLink(i);
if (l) {
app.graph.getNodeById(l.origin_id).connect(l.origin_slot, sampleNode, i);
}
}
upscaleNode.connect(0, sampleNode, 3);
},
},
{
content: "Add 2nd Pass",
callback: () => {
const upscaleNode = addNode("LatentUpscale", this);
this.connect(0, upscaleNode, 0);
const ckptNode = addNode("CheckpointLoaderSimple", this);
const sampleNode = addNode("KSampler", ckptNode);
const positiveLink = this.getInputLink(1);
const negativeLink = this.getInputLink(2);
const positiveNode = positiveLink
? app.graph.add(app.graph.getNodeById(positiveLink.origin_id).clone())
: addNode("CLIPTextEncode");
const negativeNode = negativeLink
? app.graph.add(app.graph.getNodeById(negativeLink.origin_id).clone())
: addNode("CLIPTextEncode");
ckptNode.connect(0, sampleNode, 0);
ckptNode.connect(1, positiveNode, 0);
ckptNode.connect(1, negativeNode, 0);
positiveNode.connect(0, sampleNode, 1);
negativeNode.connect(0, sampleNode, 2);
upscaleNode.connect(0, sampleNode, 3);
},
},
{
content: "Add Save Image",
callback: () => {
const decodeNode = addNode("VAEDecode", this);
this.connect(0, decodeNode, 0);
getOrAddVAELoader(decodeNode).connect(0, decodeNode, 1);
const saveNode = addNode("SaveImage", decodeNode);
decodeNode.connect(0, saveNode, 0);
},
}
);
});
}
if (nodeData.name === "CheckpointLoaderSimple") {
addMenuHandler(nodeType, function (_, options) {
options.unshift({
content: "Add Clip Skip",
callback: () => {
const clipSkipNode = addNode("CLIPSetLastLayer", this);
const clipLinks = this.outputs[1].links ? this.outputs[1].links.map((l) => ({ ...graph.links[l] })) : [];
this.disconnectOutput(1);
this.connect(1, clipSkipNode, 0);
for (const clipLink of clipLinks) {
clipSkipNode.connect(0, clipLink.target_id, clipLink.target_slot);
}
},
});
});
}
if (
nodeData.name === "CheckpointLoaderSimple" ||
nodeData.name === "CheckpointLoader" ||
nodeData.name === "CheckpointLoader|pysssss" ||
nodeData.name === "LoraLoader" ||
nodeData.name === "LoraLoader|pysssss"
) {
addMenuHandler(nodeType, function (_, options) {
function addLora(type) {
const loraNode = addNode(type, this);
const modelLinks = this.outputs[0].links ? this.outputs[0].links.map((l) => ({ ...graph.links[l] })) : [];
const clipLinks = this.outputs[1].links ? this.outputs[1].links.map((l) => ({ ...graph.links[l] })) : [];
this.disconnectOutput(0);
this.disconnectOutput(1);
this.connect(0, loraNode, 0);
this.connect(1, loraNode, 1);
for (const modelLink of modelLinks) {
loraNode.connect(0, modelLink.target_id, modelLink.target_slot);
}
for (const clipLink of clipLinks) {
loraNode.connect(1, clipLink.target_id, clipLink.target_slot);
}
}
options.unshift(
{
content: "Add LoRA",
callback: () => addLora.call(this, "LoraLoader"),
},
{
content: "Add 🐍 LoRA",
callback: () => addLora.call(this, "LoraLoader|pysssss"),
},
{
content: "Add Prompts",
callback: () => {
const positiveNode = addNode("CLIPTextEncode", this);
const negativeNode = addNode("CLIPTextEncode", this, { shiftY: positiveNode.size[1] + 30 });
this.connect(1, positiveNode, 0);
this.connect(1, negativeNode, 0);
},
}
);
});
}
},
});

View File

@@ -0,0 +1,123 @@
import { app } from "../../../scripts/app.js";
const REPEATER = "Repeater|pysssss";
app.registerExtension({
name: "pysssss.Repeater",
init() {
const graphToPrompt = app.graphToPrompt;
app.graphToPrompt = async function () {
const res = await graphToPrompt.apply(this, arguments);
const id = Date.now() + "_";
let u = 0;
let newNodes = {};
const newRepeaters = {};
for (const nodeId in res.output) {
let output = res.output[nodeId];
if (output.class_type === REPEATER) {
const isMulti = output.inputs.output === "multi";
if (output.inputs.node_mode === "create") {
// We need to clone the input for every repeat
const orig = res.output[output.inputs.source[0]];
if (isMulti) {
if (!newRepeaters[nodeId]) {
newRepeaters[nodeId] = [];
newRepeaters[nodeId][output.inputs.repeats - 1] = nodeId;
}
}
for (let i = 0; i < output.inputs.repeats - 1; i++) {
const clonedInputId = id + ++u;
if (isMulti) {
// If multi create we need to clone the repeater too
newNodes[clonedInputId] = structuredClone(orig);
output = structuredClone(output);
const clonedRepeaterId = id + ++u;
newNodes[clonedRepeaterId] = output;
output.inputs["source"][0] = clonedInputId;
newRepeaters[nodeId][i] = clonedRepeaterId;
} else {
newNodes[clonedInputId] = orig;
}
output.inputs[clonedInputId] = [clonedInputId, output.inputs.source[1]];
}
} else if (isMulti) {
newRepeaters[nodeId] = Array(output.inputs.repeats).fill(nodeId);
}
}
}
Object.assign(res.output, newNodes);
newNodes = {};
for (const nodeId in res.output) {
const output = res.output[nodeId];
for (const k in output.inputs) {
const v = output.inputs[k];
if (v instanceof Array) {
const repeaterId = v[0];
const source = newRepeaters[repeaterId];
if (source) {
v[0] = source.pop();
v[1] = 0;
}
}
}
}
// Object.assign(res.output, newNodes);
return res;
};
},
beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === REPEATER) {
const SETUP_OUTPUTS = Symbol();
nodeType.prototype[SETUP_OUTPUTS] = function (repeats) {
if (repeats == null) {
repeats = this.widgets[0].value;
}
while (this.outputs.length > repeats) {
this.removeOutput(repeats);
}
const id = Date.now() + "_";
let u = 0;
while (this.outputs.length < repeats) {
this.addOutput(id + ++u, "*", { label: "*" });
}
};
const onAdded = nodeType.prototype.onAdded;
nodeType.prototype.onAdded = function () {
const self = this;
const repeatsCb = this.widgets[0].callback;
this.widgets[0].callback = async function () {
const v = (await repeatsCb?.apply(this, arguments)) ?? this.value;
if (self.widgets[1].value === "multi") {
self[SETUP_OUTPUTS](v);
}
return v;
};
const outputCb = this.widgets[1].callback;
this.widgets[1].callback = async function () {
const v = (await outputCb?.apply(this, arguments)) ?? this.value;
if (v === "single") {
self.outputs[0].shape = 6;
self[SETUP_OUTPUTS](1);
} else {
delete self.outputs[0].shape;
self[SETUP_OUTPUTS]();
}
return v;
};
return onAdded?.apply(this, arguments);
};
}
},
});

View File

@@ -0,0 +1,348 @@
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;
};
// Hide this node as it is no longer supported
const getNodeTypesCategories = LiteGraph.getNodeTypesCategories;
LiteGraph.getNodeTypesCategories = function() {
return getNodeTypesCategories.apply(this, arguments).filter(c => !c.startsWith("__hidden__"));
}
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) {
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";
}
};
}
},
});

View File

@@ -0,0 +1,81 @@
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: {
width: "100%",
height: "150px",
marginTop: "10px",
order: 100, // Place this item last (until someone else has a higher order)
display: "none",
},
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]
);
app.ui.menuContainer.append(link);
const show = (src, node) => {
img.src = src;
nodeId = Number(node);
link.style.display = "unset";
};
api.addEventListener("executed", ({ detail }) => {
if (!enabled) return;
const images = detail?.output?.images;
if (!images || !images.length) 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}`,].join('&');
show(src, detail.node);
});
api.addEventListener("b_preview", ({ detail }) => {
if (!enabled) return;
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 (!enabled) link.style.display = "none";
},
});
},
};
app.registerExtension(ext);

View File

@@ -0,0 +1,78 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
// Displays input text on a node
// TODO: This should need to be so complicated. Refactor at some point.
app.registerExtension({
name: "pysssss.ShowText",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "ShowText|pysssss") {
function populate(text) {
if (this.widgets) {
// On older frontend versions there is a hidden converted-widget
const isConvertedWidget = +!!this.inputs?.[0].widget;
for (let i = isConvertedWidget; i < this.widgets.length; i++) {
this.widgets[i].onRemove?.();
}
this.widgets.length = isConvertedWidget;
}
const v = [...text];
if (!v[0]) {
v.shift();
}
for (let list of v) {
// Force list to be an array, not sure why sometimes it is/isn't
if (!(list instanceof Array)) list = [list];
for (const l of list) {
const w = ComfyWidgets["STRING"](this, "text_" + this.widgets?.length ?? 0, ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.6;
w.value = l;
}
}
requestAnimationFrame(() => {
const sz = this.computeSize();
if (sz[0] < this.size[0]) {
sz[0] = this.size[0];
}
if (sz[1] < this.size[1]) {
sz[1] = this.size[1];
}
this.onResize?.(sz);
app.graph.setDirtyCanvas(true, false);
});
}
// 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);
populate.call(this, message.text);
};
const VALUES = Symbol();
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function () {
// Store unmodified widget values as they get removed on configure by new frontend
this[VALUES] = arguments[0]?.widgets_values;
return configure?.apply(this, arguments);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
const widgets_values = this[VALUES];
if (widgets_values?.length) {
// In newer frontend there seems to be a delay in creating the initial widget
requestAnimationFrame(() => {
populate.call(this, widgets_values.slice(+(widgets_values.length > 1 && this.inputs?.[0].widget)));
});
}
};
}
},
});

View File

@@ -0,0 +1,73 @@
import { app } from "../../../scripts/app.js";
let setting;
const id = "pysssss.SnapToGrid";
/** Wraps the provided function call to set/reset shiftDown when setting is enabled. */
function wrapCallInSettingCheck(fn) {
if (setting?.value) {
const shift = app.shiftDown;
app.shiftDown = true;
const r = fn();
app.shiftDown = shift;
return r;
}
return fn();
}
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 () {
wrapCallInSettingCheck(() => 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 () {
wrapCallInSettingCheck(() => onResize?.apply(this, arguments));
};
return r;
};
const groupMove = LGraphGroup.prototype.move;
LGraphGroup.prototype.move = function(deltax, deltay, ignore_nodes) {
wrapCallInSettingCheck(() => groupMove.apply(this, arguments));
}
const canvasDrawGroups = LGraphCanvas.prototype.drawGroups;
LGraphCanvas.prototype.drawGroups = function (canvas, ctx) {
wrapCallInSettingCheck(() => canvasDrawGroups.apply(this, arguments));
}
const canvasOnGroupAdd = LGraphCanvas.onGroupAdd;
LGraphCanvas.onGroupAdd = function() {
wrapCallInSettingCheck(() => canvasOnGroupAdd.apply(this, arguments));
}
return configure.apply(this, arguments);
};
},
};
app.registerExtension(ext);

View File

@@ -0,0 +1,163 @@
import { app } from "../../../scripts/app.js";
import { $el } from "../../../scripts/ui.js";
let guide_config;
const id = "pysssss.SnapToGrid.Guide";
const guide_config_default = {
lines: {
enabled: false,
fillStyle: "rgba(255, 0, 0, 0.5)",
},
block: {
enabled: false,
fillStyle: "rgba(0, 0, 255, 0.5)",
},
}
const ext = {
name: id,
init() {
if (localStorage.getItem(id) === null) {
localStorage.setItem(id, JSON.stringify(guide_config_default));
}
guide_config = JSON.parse(localStorage.getItem(id));
app.ui.settings.addSetting({
id,
name: "🐍 Display drag-and-drop guides",
type: (name, setter, value) => {
return $el("tr", [
$el("td", [
$el("label", {
for: id.replaceAll(".", "-"),
textContent: name,
}),
]),
$el("td", [
$el(
"label",
{
textContent: "Lines: ",
style: {
display: "inline-block",
},
},
[
$el("input", {
id: id.replaceAll(".", "-") + "-line-text",
type: "text",
value: guide_config.lines.fillStyle,
onchange: (event) => {
guide_config.lines.fillStyle = event.target.value;
localStorage.setItem(id, JSON.stringify(guide_config));
}
}),
$el("input", {
id: id.replaceAll(".", "-") + "-line-checkbox",
type: "checkbox",
checked: guide_config.lines.enabled,
onchange: (event) => {
guide_config.lines.enabled = !!event.target.checked;
localStorage.setItem(id, JSON.stringify(guide_config));
},
}),
]
),
$el(
"label",
{
textContent: "Block: ",
style: {
display: "inline-block",
},
},
[
$el("input", {
id: id.replaceAll(".", "-") + "-block-text",
type: "text",
value: guide_config.block.fillStyle,
onchange: (event) => {
guide_config.block.fillStyle = event.target.value;
localStorage.setItem(id, JSON.stringify(guide_config));
}
}),
$el("input", {
id: id.replaceAll(".", "-") + '-block-checkbox',
type: "checkbox",
checked: guide_config.block.enabled,
onchange: (event) => {
guide_config.block.enabled = !!event.target.checked;
localStorage.setItem(id, JSON.stringify(guide_config));
},
}),
]
),
]),
]);
}
});
const alwaysSnapToGrid = () =>
app.ui.settings.getSettingValue("pysssss.SnapToGrid", /* default=*/ false);
const snapToGridEnabled = () =>
app.shiftDown || alwaysSnapToGrid();
// Override drag-and-drop behavior to show orthogonal guide lines around selected node(s) and preview of where the node(s) will be placed
const origDrawNode = LGraphCanvas.prototype.drawNode;
LGraphCanvas.prototype.drawNode = function (node, ctx) {
const enabled = guide_config.lines.enabled || guide_config.block.enabled;
if (enabled && this.node_dragged && node.id in this.selected_nodes && snapToGridEnabled()) {
// discretize the canvas into grid
let x = LiteGraph.CANVAS_GRID_SIZE * Math.round(node.pos[0] / LiteGraph.CANVAS_GRID_SIZE);
let y = LiteGraph.CANVAS_GRID_SIZE * Math.round(node.pos[1] / LiteGraph.CANVAS_GRID_SIZE);
// calculate the width and height of the node
// (also need to shift the y position of the node, depending on whether the title is visible)
x -= node.pos[0];
y -= node.pos[1];
let w, h;
if (node.flags.collapsed) {
w = node._collapsed_width;
h = LiteGraph.NODE_TITLE_HEIGHT;
y -= LiteGraph.NODE_TITLE_HEIGHT;
} else {
w = node.size[0];
h = node.size[1];
let titleMode = node.constructor.title_mode;
if (titleMode !== LiteGraph.TRANSPARENT_TITLE && titleMode !== LiteGraph.NO_TITLE) {
h += LiteGraph.NODE_TITLE_HEIGHT;
y -= LiteGraph.NODE_TITLE_HEIGHT;
}
}
// save the original fill style
const f = ctx.fillStyle;
// draw preview for drag-and-drop (rectangle to show where the node will be placed)
if (guide_config.block.enabled) {
ctx.fillStyle = guide_config.block.fillStyle;
ctx.fillRect(x, y, w, h);
}
// add guide lines around node (arbitrarily long enough to span most workflows)
if (guide_config.lines.enabled) {
const xd = 10000;
const yd = 10000;
const thickness = 3;
ctx.fillStyle = guide_config.lines.fillStyle;
ctx.fillRect(x - xd, y, 2*xd, thickness);
ctx.fillRect(x, y - yd, thickness, 2*yd);
ctx.fillRect(x - xd, y + h, 2*xd, thickness);
ctx.fillRect(x + w, y - yd, thickness, 2*yd);
}
// restore the original fill style
ctx.fillStyle = f;
}
return origDrawNode.apply(this, arguments);
};
},
};
app.registerExtension(ext);

View File

@@ -0,0 +1,33 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
// Displays input text on a node
app.registerExtension({
name: "pysssss.StringFunction",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "StringFunction|pysssss") {
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
if (this.widgets) {
const pos = this.widgets.findIndex((w) => w.name === "result");
if (pos !== -1) {
for (let i = pos; i < this.widgets.length; i++) {
this.widgets[i].onRemove?.();
}
this.widgets.length = pos;
}
}
const w = ComfyWidgets["STRING"](this, "result", ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.6;
w.value = message.text;
this.onResize?.(this.size);
};
}
},
});

View File

@@ -0,0 +1,30 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "pysssss.SwapResolution",
async beforeRegisterNodeDef(nodeType, nodeData) {
const inputs = { ...nodeData.input?.required, ...nodeData.input?.optional };
if (inputs.width && inputs.height) {
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
const r = origGetExtraMenuOptions?.apply?.(this, arguments);
options.push(
{
content: "Swap width/height",
callback: () => {
const w = this.widgets.find((w) => w.name === "width");
const h = this.widgets.find((w) => w.name === "height");
const a = w.value;
w.value = h.value;
h.value = a;
app.graph.setDirtyCanvas(true);
},
},
null
);
return r;
};
}
},
});

View File

@@ -0,0 +1,47 @@
import { app } from "../../../scripts/app.js";
const notificationSetup = () => {
if (!("Notification" in window)) {
console.log("This browser does not support notifications.");
alert("This browser does not support notifications.");
return;
}
if (Notification.permission === "denied") {
console.log("Notifications are blocked. Please enable them in your browser settings.");
alert("Notifications are blocked. Please enable them in your browser settings.");
return;
}
if (Notification.permission !== "granted") {
Notification.requestPermission();
}
return true;
};
app.registerExtension({
name: "pysssss.SystemNotification",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "SystemNotification|pysssss") {
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = async function ({ message, mode }) {
onExecuted?.apply(this, arguments);
if (mode === "on empty queue") {
if (app.ui.lastQueueSize !== 0) {
await new Promise((r) => setTimeout(r, 500));
}
if (app.ui.lastQueueSize !== 0) {
return;
}
}
if (!notificationSetup()) return;
const notification = new Notification("ComfyUI", { body: message ?? "Your notification has triggered." });
};
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, arguments);
notificationSetup();
};
}
},
});

View File

@@ -0,0 +1,36 @@
import { app } from "../../../scripts/app.js";
const id = "pysssss.UseNumberInputPrompt";
const ext = {
name: id,
async setup(app) {
const prompt = LGraphCanvas.prototype.prompt;
const setting = app.ui.settings.addSetting({
id,
name: "🐍 Use number input on value entry",
defaultValue: false,
type: "boolean",
});
LGraphCanvas.prototype.prompt = function () {
const dialog = prompt.apply(this, arguments);
if (setting.value && typeof arguments[1] === "number") {
// If this should be a number then update the imput
const input = dialog.querySelector("input");
input.type = "number";
// Add constraints
const widget = app.canvas.node_widget?.[1];
if (widget?.options) {
for (const prop of ["min", "max", "step"]) {
if (widget.options[prop]) input[prop] = widget.options[prop];
}
}
}
return dialog;
};
},
};
app.registerExtension(ext);

View File

@@ -0,0 +1,297 @@
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 regexDefaults;
let setting;
const getNodeDefaults = (node, defaults) => {
const nodeDefaults = defaults[node.type] ?? {};
const propSetBy = {};
Object.keys(regexDefaults)
.filter((r) => new RegExp(r).test(node.type))
.reduce((p, n) => {
const props = regexDefaults[n];
for (const k in props) {
// Use the longest matching key as its probably the most specific
if (!(k in nodeDefaults) || (k in propSetBy && n.length > propSetBy[k].length)) {
propSetBy[k] = n;
nodeDefaults[k] = props[k];
}
}
return p;
}, nodeDefaults);
return nodeDefaults;
};
const applyDefaults = (defaults) => {
for (const node of Object.values(LiteGraph.registered_node_types)) {
const nodeData = node[nodeDataKey];
if (!nodeData) continue;
const nodeDefaults = getNodeDefaults(node, defaults);
if (!nodeDefaults) continue;
const inputs = { ...(nodeData.input?.required || {}), ...(nodeData.input?.optional || {}) };
for (const w in nodeDefaults) {
const widgetDef = inputs[w];
if (widgetDef) {
let v = nodeDefaults[w];
if (widgetDef[0] === "INT" || widgetDef[0] === "FLOAT") {
v = +v;
}
if (widgetDef[1]) {
widgetDef[1].default = v;
} else {
widgetDef[1] = { default: v };
}
}
}
}
};
const getDefaults = () => {
let items;
regexDefaults = {};
try {
items = JSON.parse(setting.value);
items = items.reduce((p, n) => {
if (n.node.startsWith("/") && n.node.endsWith("/")) {
const name = n.node.substring(1, n.node.length - 1);
try {
// Validate regex
new RegExp(name);
if (!regexDefaults[name]) regexDefaults[name] = {};
regexDefaults[name][n.widget] = n.value;
} catch (error) {}
}
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;
};
const onNodeAdded = app.graph.onNodeAdded;
app.graph.onNodeAdded = function (node) {
onNodeAdded?.apply?.(this, arguments);
// See if we have any defaults for this type of node
const nodeDefaults = getNodeDefaults(node.constructor, defaults);
if (!nodeDefaults) return;
// Dont run if they are pre-configured nodes from load/pastes
const stack = new Error().stack;
if (stack.includes("pasteFromClipboard") || stack.includes("loadGraphData")) {
return;
}
for (const k in nodeDefaults) {
if (k.startsWith("property.")) {
const name = k.substring(9);
let v = nodeDefaults[k];
// Special handling for some built in values
if (name in node || ["color", "bgcolor", "title"].includes(name)) {
node[name] = v;
} else {
// Try using the correct type
if (!node.properties) node.properties = {};
if (typeof node.properties[name] === "number") v = +v;
else if (typeof node.properties[name] === "boolean") v = v === "true";
else if (v === "true") v = true;
node.properties[name] = v;
}
}
}
};
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 & Property Defaults:",
}),
]),
$el("td", [
$el("button", {
textContent: "Manage",
onclick: () => {
try {
// Try closing old settings window
if (typeof app.ui.settings.element?.close === "function") {
app.ui.settings.element.close();
}
} catch (error) {}
try {
// Try closing new vue dialog
document.querySelector(".p-dialog-close-button").click();
} catch (error) {
// Fallback to just hiding the element
app.ui.settings.element.style.display = "none";
}
const dialog = new WidgetDefaultsDialog();
dialog.show();
},
style: {
fontSize: "14px",
},
}),
]),
]);
},
});
defaults = getDefaults();
},
});

View File

@@ -0,0 +1,640 @@
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 bounds = n.getBounding();
const r = n.pos[0] + bounds[2];
const b = n.pos[1] + bounds[3];
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,
transform: app.canvas.canvas.getContext("2d").getTransform(), // Save the original transformation matrix
};
}
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;
app.canvas.canvas.getContext("2d").setTransform(this.state.transform); // Reapply the original transformation matrix
}
updateView(bounds) {
const scale = window.devicePixelRatio || 1;
app.canvas.ds.scale = 1;
app.canvas.canvas.width = (bounds[2] - bounds[0]) * scale;
app.canvas.canvas.height = (bounds[3] - bounds[1]) * scale;
app.canvas.ds.offset = [-bounds[0], -bounds[1]];
app.canvas.canvas.getContext("2d").setTransform(scale, 0, 0, scale, 0, 0);
}
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((resolve) => {
try {
// This shouldnt go in here but it's easier than refactoring handleFile
const reader = new FileReader();
reader.onload = async () => {
try {
const value = new Jpeg().readExif(reader.result);
importA1111(app.graph, value);
resolve(true);
} catch (error) {
resolve(false);
}
};
reader.onerror = () => resolve(false);
reader.readAsArrayBuffer(file);
} catch (error) {
resolve(false);
}
})
) {
return;
}
}
return handleFile.apply(this, arguments);
};
}
static escapeXml(unsafe) {
return unsafe.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
static unescapeXml(safe) {
return safe.replaceAll("&amp;", "&").replaceAll("&lt;", "<").replaceAll("&gt;", ">");
}
getDrawTextConfig(_, widget) {
const domWrapper = widget.inputEl.closest(".dom-widget") ?? widget.inputEl;
return {
x: parseInt(domWrapper.style.left),
y: parseInt(domWrapper.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 domWrapper = this.inputEl.closest(".dom-widget") ?? widget.inputEl;
let w = parseInt(domWrapper.style.width);
if (w === 0) {
w = this.node.size[0] - 20;
}
const h = parseInt(domWrapper.style.height);
ctx.fillStyle = style.getPropertyValue("background-color");
ctx.fillRect(x, y, w, h);
ctx.fillStyle = style.getPropertyValue("color");
ctx.font = style.getPropertyValue("font");
const line = t.d * 12;
const split = this.inputEl.value.split("\n");
let start = y;
for (const l of split) {
start += line;
wrapText(ctx, l, x + 4, start, w, line);
}
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);
},
});

View File

@@ -0,0 +1,343 @@
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();
if(this.workflows.length) {
this.workflows.sort();
}
this.loadMenu.style.display = this.workflows.length ? "flex" : "none";
}
getMenuOptions(callback) {
const menu = [];
const directories = new Map();
for (const workflow of this.workflows || []) {
const path = workflow.split("/");
let parent = menu;
let currentPath = "";
for (let i = 0; i < path.length - 1; i++) {
currentPath += "/" + path[i];
let 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: () => callback(workflow),
});
}
return menu;
}
constructor() {
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", () =>
this.getMenuOptions(async (workflow) => {
const json = await getWorkflow(workflow);
app.loadGraphData(json);
})
);
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);
};
}
}
const refreshComboInNodes = app.refreshComboInNodes;
let workflows;
async function sendToWorkflow(img, workflow) {
const graph = !workflow ? app.graph.serialize() : await getWorkflow(workflow);
const nodes = graph.nodes.filter((n) => n.type === "LoadImage");
let targetNode;
if (nodes.length === 0) {
alert("To send the image to another workflow, that workflow must have a LoadImage node.");
return;
} else if (nodes.length > 1) {
targetNode = nodes.find((n) => n.title?.toLowerCase().includes("input"));
if (!targetNode) {
targetNode = nodes[0];
alert(
"The target workflow has multiple LoadImage nodes, include 'input' in the name of the one you want to use. The first one will be used here."
);
}
} else {
targetNode = nodes[0];
}
const blob = await (await fetch(img.src)).blob();
const name =
(workflow || "sendtoworkflow").replace(/\//g, "_") +
"-" +
+new Date() +
new URLSearchParams(img.src.split("?")[1]).get("filename");
const body = new FormData();
body.append("image", new File([blob], name));
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body,
});
if (resp.status === 200) {
await refreshComboInNodes.call(app);
targetNode.widgets_values[0] = name;
app.loadGraphData(graph);
app.graph.getNodeById(targetNode.id);
} else {
alert(resp.status + " - " + resp.statusText);
}
}
app.registerExtension({
name: "pysssss.Workflows",
init() {
$el("style", {
textContent: style,
parent: document.head,
});
},
async refreshComboInNodes() {
workflows.load()
},
async setup() {
workflows = new PysssssWorkflows();
const comfyDefault = "[ComfyUI Default]";
const defaultWorkflow = app.ui.settings.addSetting({
id: "pysssss.Workflows.Default",
name: "🐍 Default Workflow",
defaultValue: comfyDefault,
type: "combo",
options: (value) =>
[comfyDefault, ...workflows.workflows].map((m) => ({
value: m,
text: m,
selected: m === value,
})),
});
document.getElementById("comfy-load-default-button").onclick = async function () {
if (
localStorage["Comfy.Settings.Comfy.ConfirmClear"] === "false" ||
confirm(`Load default workflow (${defaultWorkflow.value})?`)
) {
if (defaultWorkflow.value === comfyDefault) {
app.loadGraphData();
} else {
const json = await getWorkflow(defaultWorkflow.value);
app.loadGraphData(json);
}
}
};
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
const r = getExtraMenuOptions?.apply?.(this, arguments);
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) {
let pos = options.findIndex((o) => o.content === "Save Image");
if (pos === -1) {
pos = 0;
} else {
pos++;
}
options.splice(pos, 0, {
content: "Send to workflow",
has_submenu: true,
submenu: {
options: [
{ callback: () => sendToWorkflow(img), title: "[Current workflow]" },
...workflows.getMenuOptions(sendToWorkflow.bind(null, img)),
],
},
});
}
return r;
};
},
});