Use new TS frontend uncompressed (#4379)

* Swap frontend uncompressed

* Add uncompressed files
This commit is contained in:
Chenlei Hu
2024-08-15 16:50:25 -04:00
committed by GitHub
parent 3f5939add6
commit bfc214f434
84 changed files with 99382 additions and 34288 deletions

484
web/scripts/api.js vendored
View File

@@ -1,482 +1,2 @@
class ComfyApi extends EventTarget {
#registered = new Set();
constructor() {
super();
this.api_host = location.host;
this.api_base = location.pathname.split('/').slice(0, -1).join('/');
this.initialClientId = sessionStorage.getItem("clientId");
}
apiURL(route) {
return this.api_base + route;
}
fetchApi(route, options) {
if (!options) {
options = {};
}
if (!options.headers) {
options.headers = {};
}
options.headers["Comfy-User"] = this.user;
return fetch(this.apiURL(route), options);
}
addEventListener(type, callback, options) {
super.addEventListener(type, callback, options);
this.#registered.add(type);
}
/**
* Poll status for colab and other things that don't support websockets.
*/
#pollQueue() {
setInterval(async () => {
try {
const resp = await this.fetchApi("/prompt");
const status = await resp.json();
this.dispatchEvent(new CustomEvent("status", { detail: status }));
} catch (error) {
this.dispatchEvent(new CustomEvent("status", { detail: null }));
}
}, 1000);
}
/**
* Creates and connects a WebSocket for realtime updates
* @param {boolean} isReconnect If the socket is connection is a reconnect attempt
*/
#createSocket(isReconnect) {
if (this.socket) {
return;
}
let opened = false;
let existingSession = window.name;
if (existingSession) {
existingSession = "?clientId=" + existingSession;
}
this.socket = new WebSocket(
`ws${window.location.protocol === "https:" ? "s" : ""}://${this.api_host}${this.api_base}/ws${existingSession}`
);
this.socket.binaryType = "arraybuffer";
this.socket.addEventListener("open", () => {
opened = true;
if (isReconnect) {
this.dispatchEvent(new CustomEvent("reconnected"));
}
});
this.socket.addEventListener("error", () => {
if (this.socket) this.socket.close();
if (!isReconnect && !opened) {
this.#pollQueue();
}
});
this.socket.addEventListener("close", () => {
setTimeout(() => {
this.socket = null;
this.#createSocket(true);
}, 300);
if (opened) {
this.dispatchEvent(new CustomEvent("status", { detail: null }));
this.dispatchEvent(new CustomEvent("reconnecting"));
}
});
this.socket.addEventListener("message", (event) => {
try {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data);
const eventType = view.getUint32(0);
const buffer = event.data.slice(4);
switch (eventType) {
case 1:
const view2 = new DataView(event.data);
const imageType = view2.getUint32(0)
let imageMime
switch (imageType) {
case 1:
default:
imageMime = "image/jpeg";
break;
case 2:
imageMime = "image/png"
}
const imageBlob = new Blob([buffer.slice(4)], { type: imageMime });
this.dispatchEvent(new CustomEvent("b_preview", { detail: imageBlob }));
break;
default:
throw new Error(`Unknown binary websocket message of type ${eventType}`);
}
}
else {
const msg = JSON.parse(event.data);
switch (msg.type) {
case "status":
if (msg.data.sid) {
this.clientId = msg.data.sid;
window.name = this.clientId; // use window name so it isnt reused when duplicating tabs
sessionStorage.setItem("clientId", this.clientId); // store in session storage so duplicate tab can load correct workflow
}
this.dispatchEvent(new CustomEvent("status", { detail: msg.data.status }));
break;
case "progress":
this.dispatchEvent(new CustomEvent("progress", { detail: msg.data }));
break;
case "executing":
this.dispatchEvent(new CustomEvent("executing", { detail: msg.data.display_node }));
break;
case "executed":
this.dispatchEvent(new CustomEvent("executed", { detail: msg.data }));
break;
case "execution_start":
this.dispatchEvent(new CustomEvent("execution_start", { detail: msg.data }));
break;
case "execution_success":
this.dispatchEvent(new CustomEvent("execution_success", { detail: msg.data }));
break;
case "execution_error":
this.dispatchEvent(new CustomEvent("execution_error", { detail: msg.data }));
break;
case "execution_cached":
this.dispatchEvent(new CustomEvent("execution_cached", { detail: msg.data }));
break;
default:
if (this.#registered.has(msg.type)) {
this.dispatchEvent(new CustomEvent(msg.type, { detail: msg.data }));
} else {
throw new Error(`Unknown message type ${msg.type}`);
}
}
}
} catch (error) {
console.warn("Unhandled message:", event.data, error);
}
});
}
/**
* Initialises sockets and realtime updates
*/
init() {
this.#createSocket();
}
/**
* Gets a list of extension urls
* @returns An array of script urls to import
*/
async getExtensions() {
const resp = await this.fetchApi("/extensions", { cache: "no-store" });
return await resp.json();
}
/**
* Gets a list of embedding names
* @returns An array of script urls to import
*/
async getEmbeddings() {
const resp = await this.fetchApi("/embeddings", { cache: "no-store" });
return await resp.json();
}
/**
* Loads node object definitions for the graph
* @returns The node definitions
*/
async getNodeDefs() {
const resp = await this.fetchApi("/object_info", { cache: "no-store" });
return await resp.json();
}
/**
*
* @param {number} number The index at which to queue the prompt, passing -1 will insert the prompt at the front of the queue
* @param {object} prompt The prompt data to queue
*/
async queuePrompt(number, { output, workflow }) {
const body = {
client_id: this.clientId,
prompt: output,
extra_data: { extra_pnginfo: { workflow } },
};
if (number === -1) {
body.front = true;
} else if (number != 0) {
body.number = number;
}
const res = await this.fetchApi("/prompt", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.status !== 200) {
throw {
response: await res.json(),
};
}
return await res.json();
}
/**
* Loads a list of items (queue or history)
* @param {string} type The type of items to load, queue or history
* @returns The items of the specified type grouped by their status
*/
async getItems(type) {
if (type === "queue") {
return this.getQueue();
}
return this.getHistory();
}
/**
* Gets the current state of the queue
* @returns The currently running and queued items
*/
async getQueue() {
try {
const res = await this.fetchApi("/queue");
const data = await res.json();
return {
// Running action uses a different endpoint for cancelling
Running: data.queue_running.map((prompt) => ({
prompt,
remove: { name: "Cancel", cb: () => api.interrupt() },
})),
Pending: data.queue_pending.map((prompt) => ({ prompt })),
};
} catch (error) {
console.error(error);
return { Running: [], Pending: [] };
}
}
/**
* Gets the prompt execution history
* @returns Prompt history including node outputs
*/
async getHistory(max_items=200) {
try {
const res = await this.fetchApi(`/history?max_items=${max_items}`);
return { History: Object.values(await res.json()) };
} catch (error) {
console.error(error);
return { History: [] };
}
}
/**
* Gets system & device stats
* @returns System stats such as python version, OS, per device info
*/
async getSystemStats() {
const res = await this.fetchApi("/system_stats");
return await res.json();
}
/**
* Sends a POST request to the API
* @param {*} type The endpoint to post to
* @param {*} body Optional POST data
*/
async #postItem(type, body) {
try {
await this.fetchApi("/" + type, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
} catch (error) {
console.error(error);
}
}
/**
* Deletes an item from the specified list
* @param {string} type The type of item to delete, queue or history
* @param {number} id The id of the item to delete
*/
async deleteItem(type, id) {
await this.#postItem(type, { delete: [id] });
}
/**
* Clears the specified list
* @param {string} type The type of list to clear, queue or history
*/
async clearItems(type) {
await this.#postItem(type, { clear: true });
}
/**
* Interrupts the execution of the running prompt
*/
async interrupt() {
await this.#postItem("interrupt", null);
}
/**
* Gets user configuration data and where data should be stored
* @returns { Promise<{ storage: "server" | "browser", users?: Promise<string, unknown>, migrated?: boolean }> }
*/
async getUserConfig() {
return (await this.fetchApi("/users")).json();
}
/**
* Creates a new user
* @param { string } username
* @returns The fetch response
*/
createUser(username) {
return this.fetchApi("/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username }),
});
}
/**
* Gets all setting values for the current user
* @returns { Promise<string, unknown> } A dictionary of id -> value
*/
async getSettings() {
return (await this.fetchApi("/settings")).json();
}
/**
* Gets a setting for the current user
* @param { string } id The id of the setting to fetch
* @returns { Promise<unknown> } The setting value
*/
async getSetting(id) {
return (await this.fetchApi(`/settings/${encodeURIComponent(id)}`)).json();
}
/**
* Stores a dictionary of settings for the current user
* @param { Record<string, unknown> } settings Dictionary of setting id -> value to save
* @returns { Promise<void> }
*/
async storeSettings(settings) {
return this.fetchApi(`/settings`, {
method: "POST",
body: JSON.stringify(settings)
});
}
/**
* Stores a setting for the current user
* @param { string } id The id of the setting to update
* @param { unknown } value The value of the setting
* @returns { Promise<void> }
*/
async storeSetting(id, value) {
return this.fetchApi(`/settings/${encodeURIComponent(id)}`, {
method: "POST",
body: JSON.stringify(value)
});
}
/**
* Gets a user data file for the current user
* @param { string } file The name of the userdata file to load
* @param { RequestInit } [options]
* @returns { Promise<Response> } The fetch response object
*/
async getUserData(file, options) {
return this.fetchApi(`/userdata/${encodeURIComponent(file)}`, options);
}
/**
* Stores a user data file for the current user
* @param { string } file The name of the userdata file to save
* @param { unknown } data The data to save to the file
* @param { RequestInit & { overwrite?: boolean, stringify?: boolean, throwOnError?: boolean } } [options]
* @returns { Promise<Response> }
*/
async storeUserData(file, data, options = { overwrite: true, stringify: true, throwOnError: true }) {
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}?overwrite=${options?.overwrite}`, {
method: "POST",
body: options?.stringify ? JSON.stringify(data) : data,
...options,
});
if (resp.status !== 200 && options?.throwOnError !== false) {
throw new Error(`Error storing user data file '${file}': ${resp.status} ${(await resp).statusText}`);
}
return resp;
}
/**
* Deletes a user data file for the current user
* @param { string } file The name of the userdata file to delete
*/
async deleteUserData(file) {
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}`, {
method: "DELETE",
});
if (resp.status !== 204) {
throw new Error(`Error removing user data file '${file}': ${resp.status} ${(resp).statusText}`);
}
}
/**
* Move a user data file for the current user
* @param { string } source The userdata file to move
* @param { string } dest The destination for the file
*/
async moveUserData(source, dest, options = { overwrite: false }) {
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(source)}/move/${encodeURIComponent(dest)}?overwrite=${options?.overwrite}`, {
method: "POST",
});
return resp;
}
/**
* @overload
* Lists user data files for the current user
* @param { string } dir The directory in which to list files
* @param { boolean } [recurse] If the listing should be recursive
* @param { true } [split] If the paths should be split based on the os path separator
* @returns { Promise<string[][]>> } The list of split file paths in the format [fullPath, ...splitPath]
*/
/**
* @overload
* Lists user data files for the current user
* @param { string } dir The directory in which to list files
* @param { boolean } [recurse] If the listing should be recursive
* @param { false | undefined } [split] If the paths should be split based on the os path separator
* @returns { Promise<string[]>> } The list of files
*/
async listUserData(dir, recurse, split) {
const resp = await this.fetchApi(
`/userdata?${new URLSearchParams({
recurse,
dir,
split,
})}`
);
if (resp.status === 404) return [];
if (resp.status !== 200) {
throw new Error(`Error getting user data list '${dir}': ${resp.status} ${resp.statusText}`);
}
return resp.json();
}
}
export const api = new ComfyApi();
// Shim for scripts\api.ts
export const api = window.comfyAPI.api.api;

2464
web/scripts/app.js vendored

File diff suppressed because it is too large Load Diff

View File

@@ -1,255 +1,2 @@
// @ts-check
import { api } from "./api.js";
import { clone } from "./utils.js";
export class ChangeTracker {
static MAX_HISTORY = 50;
#app;
undo = [];
redo = [];
activeState = null;
isOurLoad = false;
/** @type { import("./workflows").ComfyWorkflow | null } */
workflow;
ds;
nodeOutputs;
get app() {
return this.#app ?? this.workflow.manager.app;
}
constructor(workflow) {
this.workflow = workflow;
}
#setApp(app) {
this.#app = app;
}
store() {
this.ds = { scale: this.app.canvas.ds.scale, offset: [...this.app.canvas.ds.offset] };
}
restore() {
if (this.ds) {
this.app.canvas.ds.scale = this.ds.scale;
this.app.canvas.ds.offset = this.ds.offset;
}
if (this.nodeOutputs) {
this.app.nodeOutputs = this.nodeOutputs;
}
}
checkState() {
if (!this.app.graph) return;
const currentState = this.app.graph.serialize();
if (!this.activeState) {
this.activeState = clone(currentState);
return;
}
if (!ChangeTracker.graphEqual(this.activeState, currentState)) {
this.undo.push(this.activeState);
if (this.undo.length > ChangeTracker.MAX_HISTORY) {
this.undo.shift();
}
this.activeState = clone(currentState);
this.redo.length = 0;
this.workflow.unsaved = true;
api.dispatchEvent(new CustomEvent("graphChanged", { detail: this.activeState }));
}
}
async updateState(source, target) {
const prevState = source.pop();
if (prevState) {
target.push(this.activeState);
this.isOurLoad = true;
await this.app.loadGraphData(prevState, false, false, this.workflow);
this.activeState = prevState;
}
}
async undoRedo(e) {
if (e.ctrlKey || e.metaKey) {
if (e.key === "y") {
this.updateState(this.redo, this.undo);
return true;
} else if (e.key === "z") {
this.updateState(this.undo, this.redo);
return true;
}
}
}
/** @param { import("./app.js").ComfyApp } app */
static init(app) {
const changeTracker = () => app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker;
globalTracker.#setApp(app);
const loadGraphData = app.loadGraphData;
app.loadGraphData = async function () {
const v = await loadGraphData.apply(this, arguments);
const ct = changeTracker();
if (ct.isOurLoad) {
ct.isOurLoad = false;
} else {
ct.checkState();
}
return v;
};
let keyIgnored = false;
window.addEventListener(
"keydown",
(e) => {
const activeEl = document.activeElement;
requestAnimationFrame(async () => {
let bindInputEl;
// If we are auto queue in change mode then we do want to trigger on inputs
if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === "instant") {
if (activeEl?.tagName === "INPUT" || activeEl?.["type"] === "textarea") {
// Ignore events on inputs, they have their native history
return;
}
bindInputEl = activeEl;
}
keyIgnored = e.key === "Control" || e.key === "Shift" || e.key === "Alt" || e.key === "Meta";
if (keyIgnored) return;
// Check if this is a ctrl+z ctrl+y
if (await changeTracker().undoRedo(e)) return;
// If our active element is some type of input then handle changes after they're done
if (ChangeTracker.bindInput(bindInputEl)) return;
changeTracker().checkState();
});
},
true
);
window.addEventListener("keyup", (e) => {
if (keyIgnored) {
keyIgnored = false;
changeTracker().checkState();
}
});
// Handle clicking DOM elements (e.g. widgets)
window.addEventListener("mouseup", () => {
changeTracker().checkState();
});
// Handle prompt queue event for dynamic widget changes
api.addEventListener("promptQueued", () => {
changeTracker().checkState();
});
// Handle litegraph clicks
const processMouseUp = LGraphCanvas.prototype.processMouseUp;
LGraphCanvas.prototype.processMouseUp = function (e) {
const v = processMouseUp.apply(this, arguments);
changeTracker().checkState();
return v;
};
const processMouseDown = LGraphCanvas.prototype.processMouseDown;
LGraphCanvas.prototype.processMouseDown = function (e) {
const v = processMouseDown.apply(this, arguments);
changeTracker().checkState();
return v;
};
// Handle litegraph context menu for COMBO widgets
const close = LiteGraph.ContextMenu.prototype.close;
LiteGraph.ContextMenu.prototype.close = function (e) {
const v = close.apply(this, arguments);
changeTracker().checkState();
return v;
};
// Detects nodes being added via the node search dialog
const onNodeAdded = LiteGraph.LGraph.prototype.onNodeAdded;
LiteGraph.LGraph.prototype.onNodeAdded = function () {
const v = onNodeAdded?.apply(this, arguments);
if (!app?.configuringGraph) {
const ct = changeTracker();
if (!ct.isOurLoad) {
ct.checkState();
}
}
return v;
};
// Store node outputs
api.addEventListener("executed", ({ detail }) => {
const prompt = app.workflowManager.queuedPrompts[detail.prompt_id];
if (!prompt?.workflow) return;
const nodeOutputs = (prompt.workflow.changeTracker.nodeOutputs ??= {});
const output = nodeOutputs[detail.node];
if (detail.merge && output) {
for (const k in detail.output ?? {}) {
const v = output[k];
if (v instanceof Array) {
output[k] = v.concat(detail.output[k]);
} else {
output[k] = detail.output[k];
}
}
} else {
nodeOutputs[detail.node] = detail.output;
}
});
}
static bindInput(app, activeEl) {
if (activeEl && activeEl.tagName !== "CANVAS" && activeEl.tagName !== "BODY") {
for (const evt of ["change", "input", "blur"]) {
if (`on${evt}` in activeEl) {
const listener = () => {
app.workflowManager.activeWorkflow.changeTracker.checkState();
activeEl.removeEventListener(evt, listener);
};
activeEl.addEventListener(evt, listener);
return true;
}
}
}
}
static graphEqual(a, b, path = "") {
if (a === b) return true;
if (typeof a == "object" && a && typeof b == "object" && b) {
const keys = Object.getOwnPropertyNames(a);
if (keys.length != Object.getOwnPropertyNames(b).length) {
return false;
}
for (const key of keys) {
let av = a[key];
let bv = b[key];
if (!path && key === "nodes") {
// Nodes need to be sorted as the order changes when selecting nodes
av = [...av].sort((a, b) => a.id - b.id);
bv = [...bv].sort((a, b) => a.id - b.id);
} else if (path === "extra.ds") {
// Ignore view changes
continue;
}
if (!ChangeTracker.graphEqual(av, bv, path + (path ? "." : "") + key)) {
return false;
}
}
return true;
}
return false;
}
}
const globalTracker = new ChangeTracker({});
// Shim for scripts\changeTracker.ts
export const ChangeTracker = window.comfyAPI.changeTracker.ChangeTracker;

View File

@@ -1,119 +1,2 @@
export const defaultGraph = {
last_node_id: 9,
last_link_id: 9,
nodes: [
{
id: 7,
type: "CLIPTextEncode",
pos: [413, 389],
size: { 0: 425.27801513671875, 1: 180.6060791015625 },
flags: {},
order: 3,
mode: 0,
inputs: [{ name: "clip", type: "CLIP", link: 5 }],
outputs: [{ name: "CONDITIONING", type: "CONDITIONING", links: [6], slot_index: 0 }],
properties: {},
widgets_values: ["text, watermark"],
},
{
id: 6,
type: "CLIPTextEncode",
pos: [415, 186],
size: { 0: 422.84503173828125, 1: 164.31304931640625 },
flags: {},
order: 2,
mode: 0,
inputs: [{ name: "clip", type: "CLIP", link: 3 }],
outputs: [{ name: "CONDITIONING", type: "CONDITIONING", links: [4], slot_index: 0 }],
properties: {},
widgets_values: ["beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"],
},
{
id: 5,
type: "EmptyLatentImage",
pos: [473, 609],
size: { 0: 315, 1: 106 },
flags: {},
order: 1,
mode: 0,
outputs: [{ name: "LATENT", type: "LATENT", links: [2], slot_index: 0 }],
properties: {},
widgets_values: [512, 512, 1],
},
{
id: 3,
type: "KSampler",
pos: [863, 186],
size: { 0: 315, 1: 262 },
flags: {},
order: 4,
mode: 0,
inputs: [
{ name: "model", type: "MODEL", link: 1 },
{ name: "positive", type: "CONDITIONING", link: 4 },
{ name: "negative", type: "CONDITIONING", link: 6 },
{ name: "latent_image", type: "LATENT", link: 2 },
],
outputs: [{ name: "LATENT", type: "LATENT", links: [7], slot_index: 0 }],
properties: {},
widgets_values: [156680208700286, true, 20, 8, "euler", "normal", 1],
},
{
id: 8,
type: "VAEDecode",
pos: [1209, 188],
size: { 0: 210, 1: 46 },
flags: {},
order: 5,
mode: 0,
inputs: [
{ name: "samples", type: "LATENT", link: 7 },
{ name: "vae", type: "VAE", link: 8 },
],
outputs: [{ name: "IMAGE", type: "IMAGE", links: [9], slot_index: 0 }],
properties: {},
},
{
id: 9,
type: "SaveImage",
pos: [1451, 189],
size: { 0: 210, 1: 26 },
flags: {},
order: 6,
mode: 0,
inputs: [{ name: "images", type: "IMAGE", link: 9 }],
properties: {},
},
{
id: 4,
type: "CheckpointLoaderSimple",
pos: [26, 474],
size: { 0: 315, 1: 98 },
flags: {},
order: 0,
mode: 0,
outputs: [
{ name: "MODEL", type: "MODEL", links: [1], slot_index: 0 },
{ name: "CLIP", type: "CLIP", links: [3, 5], slot_index: 1 },
{ name: "VAE", type: "VAE", links: [8], slot_index: 2 },
],
properties: {},
widgets_values: ["v1-5-pruned-emaonly.ckpt"],
},
],
links: [
[1, 4, 0, 3, 0, "MODEL"],
[2, 5, 0, 3, 3, "LATENT"],
[3, 4, 1, 6, 0, "CLIP"],
[4, 6, 0, 3, 1, "CONDITIONING"],
[5, 4, 1, 7, 0, "CLIP"],
[6, 7, 0, 3, 2, "CONDITIONING"],
[7, 3, 0, 8, 0, "LATENT"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
],
groups: [],
config: {},
extra: {},
version: 0.4,
};
// Shim for scripts\defaultGraph.ts
export const defaultGraph = window.comfyAPI.defaultGraph.defaultGraph;

View File

@@ -1,335 +1,2 @@
import { app, ANIM_PREVIEW_WIDGET } from "./app.js";
const SIZE = Symbol();
function intersect(a, b) {
const x = Math.max(a.x, b.x);
const num1 = Math.min(a.x + a.width, b.x + b.width);
const y = Math.max(a.y, b.y);
const num2 = Math.min(a.y + a.height, b.y + b.height);
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y];
else return null;
}
function getClipPath(node, element) {
const selectedNode = Object.values(app.canvas.selected_nodes)[0];
if (selectedNode && selectedNode !== node) {
const elRect = element.getBoundingClientRect();
const MARGIN = 7;
const scale = app.canvas.ds.scale;
const bounding = selectedNode.getBounding();
const intersection = intersect(
{ x: elRect.x / scale, y: elRect.y / scale, width: elRect.width / scale, height: elRect.height / scale },
{
x: selectedNode.pos[0] + app.canvas.ds.offset[0] - MARGIN,
y: selectedNode.pos[1] + app.canvas.ds.offset[1] - LiteGraph.NODE_TITLE_HEIGHT - MARGIN,
width: bounding[2] + MARGIN + MARGIN,
height: bounding[3] + MARGIN + MARGIN,
}
);
if (!intersection) {
return "";
}
const widgetRect = element.getBoundingClientRect();
const clipX = elRect.left + intersection[0] - widgetRect.x / scale + "px";
const clipY = elRect.top + intersection[1] - widgetRect.y / scale + "px";
const clipWidth = intersection[2] + "px";
const clipHeight = intersection[3] + "px";
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`;
return path;
}
return "";
}
function computeSize(size) {
if (this.widgets?.[0]?.last_y == null) return;
let y = this.widgets[0].last_y;
let freeSpace = size[1] - y;
let widgetHeight = 0;
let dom = [];
for (const w of this.widgets) {
if (w.type === "converted-widget") {
// Ignore
delete w.computedHeight;
} else if (w.computeSize) {
widgetHeight += w.computeSize()[1] + 4;
} else if (w.element) {
// Extract DOM widget size info
const styles = getComputedStyle(w.element);
let minHeight = w.options.getMinHeight?.() ?? parseInt(styles.getPropertyValue("--comfy-widget-min-height"));
let maxHeight = w.options.getMaxHeight?.() ?? parseInt(styles.getPropertyValue("--comfy-widget-max-height"));
let prefHeight = w.options.getHeight?.() ?? styles.getPropertyValue("--comfy-widget-height");
if (prefHeight.endsWith?.("%")) {
prefHeight = size[1] * (parseFloat(prefHeight.substring(0, prefHeight.length - 1)) / 100);
} else {
prefHeight = parseInt(prefHeight);
if (isNaN(minHeight)) {
minHeight = prefHeight;
}
}
if (isNaN(minHeight)) {
minHeight = 50;
}
if (!isNaN(maxHeight)) {
if (!isNaN(prefHeight)) {
prefHeight = Math.min(prefHeight, maxHeight);
} else {
prefHeight = maxHeight;
}
}
dom.push({
minHeight,
prefHeight,
w,
});
} else {
widgetHeight += LiteGraph.NODE_WIDGET_HEIGHT + 4;
}
}
freeSpace -= widgetHeight;
// Calculate sizes with all widgets at their min height
const prefGrow = []; // Nodes that want to grow to their prefd size
const canGrow = []; // Nodes that can grow to auto size
let growBy = 0;
for (const d of dom) {
freeSpace -= d.minHeight;
if (isNaN(d.prefHeight)) {
canGrow.push(d);
d.w.computedHeight = d.minHeight;
} else {
const diff = d.prefHeight - d.minHeight;
if (diff > 0) {
prefGrow.push(d);
growBy += diff;
d.diff = diff;
} else {
d.w.computedHeight = d.minHeight;
}
}
}
if (this.imgs && !this.widgets.find((w) => w.name === ANIM_PREVIEW_WIDGET)) {
// Allocate space for image
freeSpace -= 220;
}
this.freeWidgetSpace = freeSpace;
if (freeSpace < 0) {
// Not enough space for all widgets so we need to grow
size[1] -= freeSpace;
this.graph.setDirtyCanvas(true);
} else {
// Share the space between each
const growDiff = freeSpace - growBy;
if (growDiff > 0) {
// All pref sizes can be fulfilled
freeSpace = growDiff;
for (const d of prefGrow) {
d.w.computedHeight = d.prefHeight;
}
} else {
// We need to grow evenly
const shared = -growDiff / prefGrow.length;
for (const d of prefGrow) {
d.w.computedHeight = d.prefHeight - shared;
}
freeSpace = 0;
}
if (freeSpace > 0 && canGrow.length) {
// Grow any that are auto height
const shared = freeSpace / canGrow.length;
for (const d of canGrow) {
d.w.computedHeight += shared;
}
}
}
// Position each of the widgets
for (const w of this.widgets) {
w.y = y;
if (w.computedHeight) {
y += w.computedHeight;
} else if (w.computeSize) {
y += w.computeSize()[1] + 4;
} else {
y += LiteGraph.NODE_WIDGET_HEIGHT + 4;
}
}
}
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
const elementWidgets = new Set();
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes;
LGraphCanvas.prototype.computeVisibleNodes = function () {
const visibleNodes = computeVisibleNodes.apply(this, arguments);
for (const node of app.graph._nodes) {
if (elementWidgets.has(node)) {
const hidden = visibleNodes.indexOf(node) === -1;
for (const w of node.widgets) {
if (w.element) {
w.element.hidden = hidden;
w.element.style.display = hidden ? "none" : undefined;
if (hidden) {
w.options.onHide?.(w);
}
}
}
}
}
return visibleNodes;
};
let enableDomClipping = true;
export function addDomClippingSetting() {
app.ui.settings.addSetting({
id: "Comfy.DOMClippingEnabled",
name: "Enable DOM element clipping (enabling may reduce performance)",
type: "boolean",
defaultValue: enableDomClipping,
onChange(value) {
enableDomClipping = !!value;
},
});
}
LGraphNode.prototype.addDOMWidget = function (name, type, element, options) {
options = { hideOnZoom: true, selectOn: ["focus", "click"], ...options };
if (!element.parentElement) {
document.body.append(element);
}
element.hidden = true;
element.style.display = "none";
let mouseDownHandler;
if (element.blur) {
mouseDownHandler = (event) => {
if (!element.contains(event.target)) {
element.blur();
}
};
document.addEventListener("mousedown", mouseDownHandler);
}
const { nodeData } = this.constructor;
const tooltip = (nodeData?.input.required?.[name] ?? nodeData?.input.optional?.[name])?.[1]?.tooltip;
if (tooltip && !element.title) {
element.title = tooltip;
}
const widget = {
type,
name,
get value() {
return options.getValue?.() ?? undefined;
},
set value(v) {
options.setValue?.(v);
widget.callback?.(widget.value);
},
draw: function (ctx, node, widgetWidth, y, widgetHeight) {
if (widget.computedHeight == null) {
computeSize.call(node, node.size);
}
const hidden =
node.flags?.collapsed ||
(!!options.hideOnZoom && app.canvas.ds.scale < 0.5) ||
widget.computedHeight <= 0 ||
widget.type === "converted-widget"||
widget.type === "hidden";
element.hidden = hidden;
element.style.display = hidden ? "none" : null;
if (hidden) {
widget.options.onHide?.(widget);
return;
}
const margin = 10;
const elRect = ctx.canvas.getBoundingClientRect();
const transform = new DOMMatrix()
.scaleSelf(elRect.width / ctx.canvas.width, elRect.height / ctx.canvas.height)
.multiplySelf(ctx.getTransform())
.translateSelf(margin, margin + y );
const scale = new DOMMatrix().scaleSelf(transform.a, transform.d);
Object.assign(element.style, {
transformOrigin: "0 0",
transform: scale,
left: `${transform.a + transform.e + elRect.left}px`,
top: `${transform.d + transform.f + elRect.top}px`,
width: `${widgetWidth - margin * 2}px`,
height: `${(widget.computedHeight ?? 50) - margin * 2}px`,
position: "absolute",
zIndex: app.graph._nodes.indexOf(node),
});
if (enableDomClipping) {
element.style.clipPath = getClipPath(node, element);
element.style.willChange = "clip-path";
}
this.options.onDraw?.(widget);
},
element,
options,
onRemove() {
if (mouseDownHandler) {
document.removeEventListener("mousedown", mouseDownHandler);
}
element.remove();
},
};
for (const evt of options.selectOn) {
element.addEventListener(evt, () => {
app.canvas.selectNode(this);
app.canvas.bringToFront(this);
});
}
this.addCustomWidget(widget);
elementWidgets.add(this);
const collapse = this.collapse;
this.collapse = function() {
collapse.apply(this, arguments);
if(this.flags?.collapsed) {
element.hidden = true;
element.style.display = "none";
}
}
const onRemoved = this.onRemoved;
this.onRemoved = function () {
element.remove();
elementWidgets.delete(this);
onRemoved?.apply(this, arguments);
};
if (!this[SIZE]) {
this[SIZE] = true;
const onResize = this.onResize;
this.onResize = function (size) {
options.beforeResize?.call(widget, this);
computeSize.call(this, size);
onResize?.apply(this, arguments);
options.afterResize?.call(widget, this);
};
}
return widget;
};
// Shim for scripts\domWidget.ts
export const addDomClippingSetting = window.comfyAPI.domWidget.addDomClippingSetting;

372
web/scripts/logging.js vendored
View File

@@ -1,370 +1,2 @@
import { $el, ComfyDialog } from "./ui.js";
import { api } from "./api.js";
$el("style", {
textContent: `
.comfy-logging-logs {
display: grid;
color: var(--fg-color);
white-space: pre-wrap;
}
.comfy-logging-log {
display: contents;
}
.comfy-logging-title {
background: var(--tr-even-bg-color);
font-weight: bold;
margin-bottom: 5px;
text-align: center;
}
.comfy-logging-log div {
background: var(--row-bg);
padding: 5px;
}
`,
parent: document.body,
});
// Stringify function supporting max depth and removal of circular references
// https://stackoverflow.com/a/57193345
function stringify(val, depth, replacer, space, onGetObjID) {
depth = isNaN(+depth) ? 1 : depth;
var recursMap = new WeakMap();
function _build(val, depth, o, a, r) {
// (JSON.stringify() has it's own rules, which we respect here by using it for property iteration)
return !val || typeof val != "object"
? val
: ((r = recursMap.has(val)),
recursMap.set(val, true),
(a = Array.isArray(val)),
r
? (o = (onGetObjID && onGetObjID(val)) || null)
: JSON.stringify(val, function (k, v) {
if (a || depth > 0) {
if (replacer) v = replacer(k, v);
if (!k) return (a = Array.isArray(v)), (val = v);
!o && (o = a ? [] : {});
o[k] = _build(v, a ? depth : depth - 1);
}
}),
o === void 0 ? (a ? [] : {}) : o);
}
return JSON.stringify(_build(val, depth), null, space);
}
const jsonReplacer = (k, v, ui) => {
if (v instanceof Array && v.length === 1) {
v = v[0];
}
if (v instanceof Date) {
v = v.toISOString();
if (ui) {
v = v.split("T")[1];
}
}
if (v instanceof Error) {
let err = "";
if (v.name) err += v.name + "\n";
if (v.message) err += v.message + "\n";
if (v.stack) err += v.stack + "\n";
if (!err) {
err = v.toString();
}
v = err;
}
return v;
};
const fileInput = $el("input", {
type: "file",
accept: ".json",
style: { display: "none" },
parent: document.body,
});
class ComfyLoggingDialog extends ComfyDialog {
constructor(logging) {
super();
this.logging = logging;
}
clear() {
this.logging.clear();
this.show();
}
export() {
const blob = new Blob([stringify([...this.logging.entries], 20, jsonReplacer, "\t")], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = $el("a", {
href: url,
download: `comfyui-logs-${Date.now()}.json`,
style: { display: "none" },
parent: document.body,
});
a.click();
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
}
import() {
fileInput.onchange = () => {
const reader = new FileReader();
reader.onload = () => {
fileInput.remove();
try {
const obj = JSON.parse(reader.result);
if (obj instanceof Array) {
this.show(obj);
} else {
throw new Error("Invalid file selected.");
}
} catch (error) {
alert("Unable to load logs: " + error.message);
}
};
reader.readAsText(fileInput.files[0]);
};
fileInput.click();
}
createButtons() {
return [
$el("button", {
type: "button",
textContent: "Clear",
onclick: () => this.clear(),
}),
$el("button", {
type: "button",
textContent: "Export logs...",
onclick: () => this.export(),
}),
$el("button", {
type: "button",
textContent: "View exported logs...",
onclick: () => this.import(),
}),
...super.createButtons(),
];
}
getTypeColor(type) {
switch (type) {
case "error":
return "red";
case "warn":
return "orange";
case "debug":
return "dodgerblue";
}
}
show(entries) {
if (!entries) entries = this.logging.entries;
this.element.style.width = "100%";
const cols = {
source: "Source",
type: "Type",
timestamp: "Timestamp",
message: "Message",
};
const keys = Object.keys(cols);
const headers = Object.values(cols).map((title) =>
$el("div.comfy-logging-title", {
textContent: title,
})
);
const rows = entries.map((entry, i) => {
return $el(
"div.comfy-logging-log",
{
$: (el) => el.style.setProperty("--row-bg", `var(--tr-${i % 2 ? "even" : "odd"}-bg-color)`),
},
keys.map((key) => {
let v = entry[key];
let color;
if (key === "type") {
color = this.getTypeColor(v);
} else {
v = jsonReplacer(key, v, true);
if (typeof v === "object") {
v = stringify(v, 5, jsonReplacer, " ");
}
}
return $el("div", {
style: {
color,
},
textContent: v,
});
})
);
});
const grid = $el(
"div.comfy-logging-logs",
{
style: {
gridTemplateColumns: `repeat(${headers.length}, 1fr)`,
},
},
[...headers, ...rows]
);
const els = [grid];
if (!this.logging.enabled) {
els.unshift(
$el("h3", {
style: { textAlign: "center" },
textContent: "Logging is disabled",
})
);
}
super.show($el("div", els));
}
}
export class ComfyLogging {
/**
* @type Array<{ source: string, type: string, timestamp: Date, message: any }>
*/
entries = [];
#enabled;
#console = {};
get enabled() {
return this.#enabled;
}
set enabled(value) {
if (value === this.#enabled) return;
if (value) {
this.patchConsole();
} else {
this.unpatchConsole();
}
this.#enabled = value;
}
constructor(app) {
this.app = app;
this.dialog = new ComfyLoggingDialog(this);
this.addSetting();
this.catchUnhandled();
this.addInitData();
}
addSetting() {
const settingId = "Comfy.Logging.Enabled";
const htmlSettingId = settingId.replaceAll(".", "-");
const setting = this.app.ui.settings.addSetting({
id: settingId,
name: settingId,
defaultValue: true,
onChange: (value) => {
this.enabled = value;
},
type: (name, setter, value) => {
return $el("tr", [
$el("td", [
$el("label", {
textContent: "Logging",
for: htmlSettingId,
}),
]),
$el("td", [
$el("input", {
id: htmlSettingId,
type: "checkbox",
checked: value,
onchange: (event) => {
setter(event.target.checked);
},
}),
$el("button", {
textContent: "View Logs",
onclick: () => {
this.app.ui.settings.element.close();
this.dialog.show();
},
style: {
fontSize: "14px",
display: "block",
marginTop: "5px",
},
}),
]),
]);
},
});
this.enabled = setting.value;
}
patchConsole() {
// Capture common console outputs
const self = this;
for (const type of ["log", "warn", "error", "debug"]) {
const orig = console[type];
this.#console[type] = orig;
console[type] = function () {
orig.apply(console, arguments);
self.addEntry("console", type, ...arguments);
};
}
}
unpatchConsole() {
// Restore original console functions
for (const type of Object.keys(this.#console)) {
console[type] = this.#console[type];
}
this.#console = {};
}
catchUnhandled() {
// Capture uncaught errors
window.addEventListener("error", (e) => {
this.addEntry("window", "error", e.error ?? "Unknown error");
return false;
});
window.addEventListener("unhandledrejection", (e) => {
this.addEntry("unhandledrejection", "error", e.reason ?? "Unknown error");
});
}
clear() {
this.entries = [];
}
addEntry(source, type, ...args) {
if (this.enabled) {
this.entries.push({
source,
type,
timestamp: new Date(),
message: args,
});
}
}
log(source, ...args) {
this.addEntry(source, "log", ...args);
}
async addInitData() {
if (!this.enabled) return;
const source = "ComfyUI.Logging";
this.addEntry(source, "debug", { UserAgent: navigator.userAgent });
const systemStats = await api.getSystemStats();
this.addEntry(source, "debug", systemStats);
}
}
// Shim for scripts\logging.ts
export const ComfyLogging = window.comfyAPI.logging.ComfyLogging;

3
web/scripts/metadata/flac.js vendored Normal file
View File

@@ -0,0 +1,3 @@
// Shim for scripts\metadata\flac.ts
export const getFromFlacBuffer = window.comfyAPI.flac.getFromFlacBuffer;
export const getFromFlacFile = window.comfyAPI.flac.getFromFlacFile;

3
web/scripts/metadata/png.js vendored Normal file
View File

@@ -0,0 +1,3 @@
// Shim for scripts\metadata\png.ts
export const getFromPngBuffer = window.comfyAPI.png.getFromPngBuffer;
export const getFromPngFile = window.comfyAPI.png.getFromPngFile;

513
web/scripts/pnginfo.js vendored
View File

@@ -1,507 +1,6 @@
import { api } from "./api.js";
export function getPngMetadata(file) {
return new Promise((r) => {
const reader = new FileReader();
reader.onload = (event) => {
// Get the PNG data as a Uint8Array
const pngData = new Uint8Array(event.target.result);
const dataView = new DataView(pngData.buffer);
// Check that the PNG signature is present
if (dataView.getUint32(0) !== 0x89504e47) {
console.error("Not a valid PNG file");
r();
return;
}
// Start searching for chunks after the PNG signature
let offset = 8;
let txt_chunks = {};
// Loop through the chunks in the PNG file
while (offset < pngData.length) {
// Get the length of the chunk
const length = dataView.getUint32(offset);
// Get the chunk type
const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8));
if (type === "tEXt" || type == "comf" || type === "iTXt") {
// Get the keyword
let keyword_end = offset + 8;
while (pngData[keyword_end] !== 0) {
keyword_end++;
}
const keyword = String.fromCharCode(...pngData.slice(offset + 8, keyword_end));
// Get the text
const contentArraySegment = pngData.slice(keyword_end + 1, offset + 8 + length);
const contentJson = new TextDecoder("utf-8").decode(contentArraySegment);
txt_chunks[keyword] = contentJson;
}
offset += 12 + length;
}
r(txt_chunks);
};
reader.readAsArrayBuffer(file);
});
}
function parseExifData(exifData) {
// Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian)
const isLittleEndian = String.fromCharCode(...exifData.slice(0, 2)) === "II";
// Function to read 16-bit and 32-bit integers from binary data
function readInt(offset, isLittleEndian, length) {
let arr = exifData.slice(offset, offset + length)
if (length === 2) {
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16(0, isLittleEndian);
} else if (length === 4) {
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32(0, isLittleEndian);
}
}
// Read the offset to the first IFD (Image File Directory)
const ifdOffset = readInt(4, isLittleEndian, 4);
function parseIFD(offset) {
const numEntries = readInt(offset, isLittleEndian, 2);
const result = {};
for (let i = 0; i < numEntries; i++) {
const entryOffset = offset + 2 + i * 12;
const tag = readInt(entryOffset, isLittleEndian, 2);
const type = readInt(entryOffset + 2, isLittleEndian, 2);
const numValues = readInt(entryOffset + 4, isLittleEndian, 4);
const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4);
// Read the value(s) based on the data type
let value;
if (type === 2) {
// ASCII string
value = String.fromCharCode(...exifData.slice(valueOffset, valueOffset + numValues - 1));
}
result[tag] = value;
}
return result;
}
// Parse the first IFD
const ifdData = parseIFD(ifdOffset);
return ifdData;
}
function splitValues(input) {
var output = {};
for (var key in input) {
var value = input[key];
var splitValues = value.split(':', 2);
output[splitValues[0]] = splitValues[1];
}
return output;
}
export function getWebpMetadata(file) {
return new Promise((r) => {
const reader = new FileReader();
reader.onload = (event) => {
const webp = new Uint8Array(event.target.result);
const dataView = new DataView(webp.buffer);
// Check that the WEBP signature is present
if (dataView.getUint32(0) !== 0x52494646 || dataView.getUint32(8) !== 0x57454250) {
console.error("Not a valid WEBP file");
r();
return;
}
// Start searching for chunks after the WEBP signature
let offset = 12;
let txt_chunks = {};
// Loop through the chunks in the WEBP file
while (offset < webp.length) {
const chunk_length = dataView.getUint32(offset + 4, true);
const chunk_type = String.fromCharCode(...webp.slice(offset, offset + 4));
if (chunk_type === "EXIF") {
if (String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) == "Exif\0\0") {
offset += 6;
}
let data = parseExifData(webp.slice(offset + 8, offset + 8 + chunk_length));
for (var key in data) {
var value = data[key];
let index = value.indexOf(':');
txt_chunks[value.slice(0, index)] = value.slice(index + 1);
}
break;
}
offset += 8 + chunk_length;
}
r(txt_chunks);
};
reader.readAsArrayBuffer(file);
});
}
export function getLatentMetadata(file) {
return new Promise((r) => {
const reader = new FileReader();
reader.onload = (event) => {
const safetensorsData = new Uint8Array(event.target.result);
const dataView = new DataView(safetensorsData.buffer);
let header_size = dataView.getUint32(0, true);
let offset = 8;
let header = JSON.parse(new TextDecoder().decode(safetensorsData.slice(offset, offset + header_size)));
r(header.__metadata__);
};
var slice = file.slice(0, 1024 * 1024 * 4);
reader.readAsArrayBuffer(slice);
});
}
function getString(dataView, offset, length) {
let string = '';
for (let i = 0; i < length; i++) {
string += String.fromCharCode(dataView.getUint8(offset + i));
}
return string;
}
// Function to parse the Vorbis Comment block
function parseVorbisComment(dataView) {
let offset = 0;
const vendorLength = dataView.getUint32(offset, true);
offset += 4;
const vendorString = getString(dataView, offset, vendorLength);
offset += vendorLength;
const userCommentListLength = dataView.getUint32(offset, true);
offset += 4;
const comments = {};
for (let i = 0; i < userCommentListLength; i++) {
const commentLength = dataView.getUint32(offset, true);
offset += 4;
const comment = getString(dataView, offset, commentLength);
offset += commentLength;
const ind = comment.indexOf('=')
const key = comment.substring(0, ind);
comments[key] = comment.substring(ind+1);
}
return comments;
}
// Function to read a FLAC file and parse Vorbis comments
export function getFlacMetadata(file) {
return new Promise((r) => {
const reader = new FileReader();
reader.onload = function(event) {
const arrayBuffer = event.target.result;
const dataView = new DataView(arrayBuffer);
// Verify the FLAC signature
const signature = String.fromCharCode(...new Uint8Array(arrayBuffer, 0, 4));
if (signature !== 'fLaC') {
console.error('Not a valid FLAC file');
return;
}
// Parse metadata blocks
let offset = 4;
let vorbisComment = null;
while (offset < dataView.byteLength) {
const isLastBlock = dataView.getUint8(offset) & 0x80;
const blockType = dataView.getUint8(offset) & 0x7F;
const blockSize = dataView.getUint32(offset, false) & 0xFFFFFF;
offset += 4;
if (blockType === 4) { // Vorbis Comment block type
vorbisComment = parseVorbisComment(new DataView(arrayBuffer, offset, blockSize));
}
offset += blockSize;
if (isLastBlock) break;
}
r(vorbisComment);
};
reader.readAsArrayBuffer(file);
});
}
export async function importA1111(graph, parameters) {
const p = parameters.lastIndexOf("\nSteps:");
if (p > -1) {
const embeddings = await api.getEmbeddings();
const opts = parameters
.substr(p)
.split("\n")[1]
.match(new RegExp("\\s*([^:]+:\\s*([^\"\\{].*?|\".*?\"|\\{.*?\\}))\\s*(,|$)", "g"))
.reduce((p, n) => {
const s = n.split(":");
if (s[1].endsWith(',')) {
s[1] = s[1].substr(0, s[1].length -1);
}
p[s[0].trim().toLowerCase()] = s[1].trim();
return p;
}, {});
const p2 = parameters.lastIndexOf("\nNegative prompt:", p);
if (p2 > -1) {
let positive = parameters.substr(0, p2).trim();
let negative = parameters.substring(p2 + 18, p).trim();
const ckptNode = LiteGraph.createNode("CheckpointLoaderSimple");
const clipSkipNode = LiteGraph.createNode("CLIPSetLastLayer");
const positiveNode = LiteGraph.createNode("CLIPTextEncode");
const negativeNode = LiteGraph.createNode("CLIPTextEncode");
const samplerNode = LiteGraph.createNode("KSampler");
const imageNode = LiteGraph.createNode("EmptyLatentImage");
const vaeNode = LiteGraph.createNode("VAEDecode");
const vaeLoaderNode = LiteGraph.createNode("VAELoader");
const saveNode = LiteGraph.createNode("SaveImage");
let hrSamplerNode = null;
let hrSteps = null;
const ceil64 = (v) => Math.ceil(v / 64) * 64;
function getWidget(node, name) {
return node.widgets.find((w) => w.name === name);
}
function setWidgetValue(node, name, value, isOptionPrefix) {
const w = getWidget(node, name);
if (isOptionPrefix) {
const o = w.options.values.find((w) => w.startsWith(value));
if (o) {
w.value = o;
} else {
console.warn(`Unknown value '${value}' for widget '${name}'`, node);
w.value = value;
}
} else {
w.value = value;
}
}
function createLoraNodes(clipNode, text, prevClip, prevModel) {
const loras = [];
text = text.replace(/<lora:([^:]+:[^>]+)>/g, function (m, c) {
const s = c.split(":");
const weight = parseFloat(s[1]);
if (isNaN(weight)) {
console.warn("Invalid LORA", m);
} else {
loras.push({ name: s[0], weight });
}
return "";
});
for (const l of loras) {
const loraNode = LiteGraph.createNode("LoraLoader");
graph.add(loraNode);
setWidgetValue(loraNode, "lora_name", l.name, true);
setWidgetValue(loraNode, "strength_model", l.weight);
setWidgetValue(loraNode, "strength_clip", l.weight);
prevModel.node.connect(prevModel.index, loraNode, 0);
prevClip.node.connect(prevClip.index, loraNode, 1);
prevModel = { node: loraNode, index: 0 };
prevClip = { node: loraNode, index: 1 };
}
prevClip.node.connect(1, clipNode, 0);
prevModel.node.connect(0, samplerNode, 0);
if (hrSamplerNode) {
prevModel.node.connect(0, hrSamplerNode, 0);
}
return { text, prevModel, prevClip };
}
function replaceEmbeddings(text) {
if(!embeddings.length) return text;
return text.replaceAll(
new RegExp(
"\\b(" + embeddings.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\b|\\b") + ")\\b",
"ig"
),
"embedding:$1"
);
}
function popOpt(name) {
const v = opts[name];
delete opts[name];
return v;
}
graph.clear();
graph.add(ckptNode);
graph.add(clipSkipNode);
graph.add(positiveNode);
graph.add(negativeNode);
graph.add(samplerNode);
graph.add(imageNode);
graph.add(vaeNode);
graph.add(vaeLoaderNode);
graph.add(saveNode);
ckptNode.connect(1, clipSkipNode, 0);
clipSkipNode.connect(0, positiveNode, 0);
clipSkipNode.connect(0, negativeNode, 0);
ckptNode.connect(0, samplerNode, 0);
positiveNode.connect(0, samplerNode, 1);
negativeNode.connect(0, samplerNode, 2);
imageNode.connect(0, samplerNode, 3);
vaeNode.connect(0, saveNode, 0);
samplerNode.connect(0, vaeNode, 0);
vaeLoaderNode.connect(0, vaeNode, 1);
const handlers = {
model(v) {
setWidgetValue(ckptNode, "ckpt_name", v, true);
},
"vae"(v) {
setWidgetValue(vaeLoaderNode, "vae_name", v, true);
},
"cfg scale"(v) {
setWidgetValue(samplerNode, "cfg", +v);
},
"clip skip"(v) {
setWidgetValue(clipSkipNode, "stop_at_clip_layer", -v);
},
sampler(v) {
let name = v.toLowerCase().replace("++", "pp").replaceAll(" ", "_");
if (name.includes("karras")) {
name = name.replace("karras", "").replace(/_+$/, "");
setWidgetValue(samplerNode, "scheduler", "karras");
} else {
setWidgetValue(samplerNode, "scheduler", "normal");
}
const w = getWidget(samplerNode, "sampler_name");
const o = w.options.values.find((w) => w === name || w === "sample_" + name);
if (o) {
setWidgetValue(samplerNode, "sampler_name", o);
}
},
size(v) {
const wxh = v.split("x");
const w = ceil64(+wxh[0]);
const h = ceil64(+wxh[1]);
const hrUp = popOpt("hires upscale");
const hrSz = popOpt("hires resize");
hrSteps = popOpt("hires steps");
let hrMethod = popOpt("hires upscaler");
setWidgetValue(imageNode, "width", w);
setWidgetValue(imageNode, "height", h);
if (hrUp || hrSz) {
let uw, uh;
if (hrUp) {
uw = w * hrUp;
uh = h * hrUp;
} else {
const s = hrSz.split("x");
uw = +s[0];
uh = +s[1];
}
let upscaleNode;
let latentNode;
if (hrMethod.startsWith("Latent")) {
latentNode = upscaleNode = LiteGraph.createNode("LatentUpscale");
graph.add(upscaleNode);
samplerNode.connect(0, upscaleNode, 0);
switch (hrMethod) {
case "Latent (nearest-exact)":
hrMethod = "nearest-exact";
break;
}
setWidgetValue(upscaleNode, "upscale_method", hrMethod, true);
} else {
const decode = LiteGraph.createNode("VAEDecodeTiled");
graph.add(decode);
samplerNode.connect(0, decode, 0);
vaeLoaderNode.connect(0, decode, 1);
const upscaleLoaderNode = LiteGraph.createNode("UpscaleModelLoader");
graph.add(upscaleLoaderNode);
setWidgetValue(upscaleLoaderNode, "model_name", hrMethod, true);
const modelUpscaleNode = LiteGraph.createNode("ImageUpscaleWithModel");
graph.add(modelUpscaleNode);
decode.connect(0, modelUpscaleNode, 1);
upscaleLoaderNode.connect(0, modelUpscaleNode, 0);
upscaleNode = LiteGraph.createNode("ImageScale");
graph.add(upscaleNode);
modelUpscaleNode.connect(0, upscaleNode, 0);
const vaeEncodeNode = (latentNode = LiteGraph.createNode("VAEEncodeTiled"));
graph.add(vaeEncodeNode);
upscaleNode.connect(0, vaeEncodeNode, 0);
vaeLoaderNode.connect(0, vaeEncodeNode, 1);
}
setWidgetValue(upscaleNode, "width", ceil64(uw));
setWidgetValue(upscaleNode, "height", ceil64(uh));
hrSamplerNode = LiteGraph.createNode("KSampler");
graph.add(hrSamplerNode);
ckptNode.connect(0, hrSamplerNode, 0);
positiveNode.connect(0, hrSamplerNode, 1);
negativeNode.connect(0, hrSamplerNode, 2);
latentNode.connect(0, hrSamplerNode, 3);
hrSamplerNode.connect(0, vaeNode, 0);
}
},
steps(v) {
setWidgetValue(samplerNode, "steps", +v);
},
seed(v) {
setWidgetValue(samplerNode, "seed", +v);
},
};
for (const opt in opts) {
if (opt in handlers) {
handlers[opt](popOpt(opt));
}
}
if (hrSamplerNode) {
setWidgetValue(hrSamplerNode, "steps", hrSteps? +hrSteps : getWidget(samplerNode, "steps").value);
setWidgetValue(hrSamplerNode, "cfg", getWidget(samplerNode, "cfg").value);
setWidgetValue(hrSamplerNode, "scheduler", getWidget(samplerNode, "scheduler").value);
setWidgetValue(hrSamplerNode, "sampler_name", getWidget(samplerNode, "sampler_name").value);
setWidgetValue(hrSamplerNode, "denoise", +(popOpt("denoising strength") || "1"));
}
let n = createLoraNodes(positiveNode, positive, { node: clipSkipNode, index: 0 }, { node: ckptNode, index: 0 });
positive = n.text;
n = createLoraNodes(negativeNode, negative, n.prevClip, n.prevModel);
negative = n.text;
setWidgetValue(positiveNode, "text", replaceEmbeddings(positive));
setWidgetValue(negativeNode, "text", replaceEmbeddings(negative));
graph.arrange();
for (const opt of ["model hash", "ensd", "version", "vae hash", "ti hashes", "lora hashes", "hashes"]) {
delete opts[opt];
}
console.warn("Unhandled parameters:", opts);
}
}
}
// Shim for scripts\pnginfo.ts
export const getPngMetadata = window.comfyAPI.pnginfo.getPngMetadata;
export const getFlacMetadata = window.comfyAPI.pnginfo.getFlacMetadata;
export const getWebpMetadata = window.comfyAPI.pnginfo.getWebpMetadata;
export const getLatentMetadata = window.comfyAPI.pnginfo.getLatentMetadata;
export const importA1111 = window.comfyAPI.pnginfo.importA1111;

668
web/scripts/ui.js vendored
View File

@@ -1,664 +1,4 @@
import { api } from "./api.js";
import { ComfyDialog as _ComfyDialog } from "./ui/dialog.js";
import { toggleSwitch } from "./ui/toggleSwitch.js";
import { ComfySettingsDialog } from "./ui/settings.js";
export const ComfyDialog = _ComfyDialog;
/**
* @template { string | (keyof HTMLElementTagNameMap) } K
* @typedef { K extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[K] : HTMLElement } ElementType
*/
/**
* @template { string | (keyof HTMLElementTagNameMap) } K
* @param { K } tag HTML Element Tag and optional classes e.g. div.class1.class2
* @param { string | Element | Element[] | ({
* parent?: Element,
* $?: (el: ElementType<K>) => void,
* dataset?: DOMStringMap,
* style?: Partial<CSSStyleDeclaration>,
* for?: string
* } & Omit<Partial<ElementType<K>>, "style">) | undefined } [propsOrChildren]
* @param { string | Element | Element[] | undefined } [children]
* @returns { ElementType<K> }
*/
export function $el(tag, propsOrChildren, children) {
const split = tag.split(".");
const element = document.createElement(split.shift());
if (split.length > 0) {
element.classList.add(...split);
}
if (propsOrChildren) {
if (typeof propsOrChildren === "string") {
propsOrChildren = { textContent: propsOrChildren };
} else if (propsOrChildren instanceof Element) {
propsOrChildren = [propsOrChildren];
}
if (Array.isArray(propsOrChildren)) {
element.append(...propsOrChildren);
} else {
const {parent, $: cb, dataset, style} = propsOrChildren;
delete propsOrChildren.parent;
delete propsOrChildren.$;
delete propsOrChildren.dataset;
delete propsOrChildren.style;
if (Object.hasOwn(propsOrChildren, "for")) {
element.setAttribute("for", propsOrChildren.for)
}
if (style) {
Object.assign(element.style, style);
}
if (dataset) {
Object.assign(element.dataset, dataset);
}
Object.assign(element, propsOrChildren);
if (children) {
element.append(...(children instanceof Array ? children.filter(Boolean) : [children]));
}
if (parent) {
parent.append(element);
}
if (cb) {
cb(element);
}
}
}
return element;
}
function dragElement(dragEl, settings) {
var posDiffX = 0,
posDiffY = 0,
posStartX = 0,
posStartY = 0,
newPosX = 0,
newPosY = 0;
if (dragEl.getElementsByClassName("drag-handle")[0]) {
// if present, the handle is where you move the DIV from:
dragEl.getElementsByClassName("drag-handle")[0].onmousedown = dragMouseDown;
} else {
// otherwise, move the DIV from anywhere inside the DIV:
dragEl.onmousedown = dragMouseDown;
}
// When the element resizes (e.g. view queue) ensure it is still in the windows bounds
const resizeObserver = new ResizeObserver(() => {
ensureInBounds();
}).observe(dragEl);
function ensureInBounds() {
try {
newPosX = Math.min(document.body.clientWidth - dragEl.clientWidth, Math.max(0, dragEl.offsetLeft));
newPosY = Math.min(document.body.clientHeight - dragEl.clientHeight, Math.max(0, dragEl.offsetTop));
positionElement();
}
catch(exception){
// robust
}
}
function positionElement() {
if(dragEl.style.display === "none") return;
const halfWidth = document.body.clientWidth / 2;
const anchorRight = newPosX + dragEl.clientWidth / 2 > halfWidth;
// set the element's new position:
if (anchorRight) {
dragEl.style.left = "unset";
dragEl.style.right = document.body.clientWidth - newPosX - dragEl.clientWidth + "px";
} else {
dragEl.style.left = newPosX + "px";
dragEl.style.right = "unset";
}
dragEl.style.top = newPosY + "px";
dragEl.style.bottom = "unset";
if (savePos) {
localStorage.setItem(
"Comfy.MenuPosition",
JSON.stringify({
x: dragEl.offsetLeft,
y: dragEl.offsetTop,
})
);
}
}
function restorePos() {
let pos = localStorage.getItem("Comfy.MenuPosition");
if (pos) {
pos = JSON.parse(pos);
newPosX = pos.x;
newPosY = pos.y;
positionElement();
ensureInBounds();
}
}
let savePos = undefined;
settings.addSetting({
id: "Comfy.MenuPosition",
name: "Save menu position",
type: "boolean",
defaultValue: savePos,
onChange(value) {
if (savePos === undefined && value) {
restorePos();
}
savePos = value;
},
});
function dragMouseDown(e) {
e = e || window.event;
e.preventDefault();
// get the mouse cursor position at startup:
posStartX = e.clientX;
posStartY = e.clientY;
document.onmouseup = closeDragElement;
// call a function whenever the cursor moves:
document.onmousemove = elementDrag;
}
function elementDrag(e) {
e = e || window.event;
e.preventDefault();
dragEl.classList.add("comfy-menu-manual-pos");
// calculate the new cursor position:
posDiffX = e.clientX - posStartX;
posDiffY = e.clientY - posStartY;
posStartX = e.clientX;
posStartY = e.clientY;
newPosX = Math.min(document.body.clientWidth - dragEl.clientWidth, Math.max(0, dragEl.offsetLeft + posDiffX));
newPosY = Math.min(document.body.clientHeight - dragEl.clientHeight, Math.max(0, dragEl.offsetTop + posDiffY));
positionElement();
}
window.addEventListener("resize", () => {
ensureInBounds();
});
function closeDragElement() {
// stop moving when mouse button is released:
document.onmouseup = null;
document.onmousemove = null;
}
return restorePos;
}
class ComfyList {
#type;
#text;
#reverse;
constructor(text, type, reverse) {
this.#text = text;
this.#type = type || text.toLowerCase();
this.#reverse = reverse || false;
this.element = $el("div.comfy-list");
this.element.style.display = "none";
}
get visible() {
return this.element.style.display !== "none";
}
async load() {
const items = await api.getItems(this.#type);
this.element.replaceChildren(
...Object.keys(items).flatMap((section) => [
$el("h4", {
textContent: section,
}),
$el("div.comfy-list-items", [
...(this.#reverse ? items[section].reverse() : items[section]).map((item) => {
// Allow items to specify a custom remove action (e.g. for interrupt current prompt)
const removeAction = item.remove || {
name: "Delete",
cb: () => api.deleteItem(this.#type, item.prompt[1]),
};
return $el("div", {textContent: item.prompt[0] + ": "}, [
$el("button", {
textContent: "Load",
onclick: async () => {
await app.loadGraphData(item.prompt[3].extra_pnginfo.workflow, true, false);
if (item.outputs) {
app.nodeOutputs = {};
for (const [key, value] of Object.entries(item.outputs)) {
const realKey = item?.meta?.[key]?.display_node ?? key;
app.nodeOutputs[realKey] = value;
}
}
},
}),
$el("button", {
textContent: removeAction.name,
onclick: async () => {
await removeAction.cb();
await this.update();
},
}),
]);
}),
]),
]),
$el("div.comfy-list-actions", [
$el("button", {
textContent: "Clear " + this.#text,
onclick: async () => {
await api.clearItems(this.#type);
await this.load();
},
}),
$el("button", {textContent: "Refresh", onclick: () => this.load()}),
])
);
}
async update() {
if (this.visible) {
await this.load();
}
}
async show() {
this.element.style.display = "block";
this.button.textContent = "Close";
await this.load();
}
hide() {
this.element.style.display = "none";
this.button.textContent = "View " + this.#text;
}
toggle() {
if (this.visible) {
this.hide();
return false;
} else {
this.show();
return true;
}
}
}
export class ComfyUI {
constructor(app) {
this.app = app;
this.dialog = new ComfyDialog();
this.settings = new ComfySettingsDialog(app);
this.batchCount = 1;
this.lastQueueSize = 0;
this.queue = new ComfyList("Queue");
this.history = new ComfyList("History", "history", true);
api.addEventListener("status", () => {
this.queue.update();
this.history.update();
});
const confirmClear = this.settings.addSetting({
id: "Comfy.ConfirmClear",
name: "Require confirmation when clearing workflow",
type: "boolean",
defaultValue: true,
});
const promptFilename = this.settings.addSetting({
id: "Comfy.PromptFilename",
name: "Prompt for filename when saving workflow",
type: "boolean",
defaultValue: true,
});
/**
* file format for preview
*
* format;quality
*
* ex)
* webp;50 -> webp, quality 50
* jpeg;80 -> rgb, jpeg, quality 80
*
* @type {string}
*/
const previewImage = this.settings.addSetting({
id: "Comfy.PreviewFormat",
name: "When displaying a preview in the image widget, convert it to a lightweight image, e.g. webp, jpeg, webp;50, etc.",
type: "text",
defaultValue: "",
});
this.settings.addSetting({
id: "Comfy.DisableSliders",
name: "Disable sliders.",
type: "boolean",
defaultValue: false,
});
this.settings.addSetting({
id: "Comfy.DisableFloatRounding",
name: "Disable rounding floats (requires page reload).",
type: "boolean",
defaultValue: false,
});
this.settings.addSetting({
id: "Comfy.FloatRoundingPrecision",
name: "Decimal places [0 = auto] (requires page reload).",
type: "slider",
attrs: {
min: 0,
max: 6,
step: 1,
},
defaultValue: 0,
});
const fileInput = $el("input", {
id: "comfy-file-input",
type: "file",
accept: ".json,image/png,.latent,.safetensors,image/webp,audio/flac",
style: {display: "none"},
parent: document.body,
onchange: () => {
app.handleFile(fileInput.files[0]);
},
});
this.loadFile = () => fileInput.click();
const autoQueueModeEl = toggleSwitch(
"autoQueueMode",
[
{ text: "instant", tooltip: "A new prompt will be queued as soon as the queue reaches 0" },
{ text: "change", tooltip: "A new prompt will be queued when the queue is at 0 and the graph is/has changed" },
],
{
onChange: (value) => {
this.autoQueueMode = value.item.value;
},
}
);
autoQueueModeEl.style.display = "none";
api.addEventListener("graphChanged", () => {
if (this.autoQueueMode === "change" && this.autoQueueEnabled === true) {
if (this.lastQueueSize === 0) {
this.graphHasChanged = false;
app.queuePrompt(0, this.batchCount);
} else {
this.graphHasChanged = true;
}
}
});
this.menuHamburger = $el(
"div.comfy-menu-hamburger",
{
parent: document.body,
onclick: () => {
this.menuContainer.style.display = "block";
this.menuHamburger.style.display = "none";
},
},
[$el("div"), $el("div"), $el("div")]
);
this.menuContainer = $el("div.comfy-menu", { parent: document.body }, [
$el("div.drag-handle.comfy-menu-header", {
style: {
overflow: "hidden",
position: "relative",
width: "100%",
cursor: "default"
}
}, [
$el("span.drag-handle"),
$el("span.comfy-menu-queue-size", { $: (q) => (this.queueSize = q) }),
$el("div.comfy-menu-actions", [
$el("button.comfy-settings-btn", {
textContent: "⚙️",
onclick: () => this.settings.show(),
}),
$el("button.comfy-close-menu-btn", {
textContent: "\u00d7",
onclick: () => {
this.menuContainer.style.display = "none";
this.menuHamburger.style.display = "flex";
},
}),
]),
]),
$el("button.comfy-queue-btn", {
id: "queue-button",
textContent: "Queue Prompt",
onclick: () => app.queuePrompt(0, this.batchCount),
}),
$el("div", {}, [
$el("label", {innerHTML: "Extra options"}, [
$el("input", {
type: "checkbox",
onchange: (i) => {
document.getElementById("extraOptions").style.display = i.srcElement.checked ? "block" : "none";
this.batchCount = i.srcElement.checked ? document.getElementById("batchCountInputRange").value : 1;
document.getElementById("autoQueueCheckbox").checked = false;
this.autoQueueEnabled = false;
},
}),
]),
]),
$el("div", {id: "extraOptions", style: {width: "100%", display: "none"}}, [
$el("div",[
$el("label", {innerHTML: "Batch count"}),
$el("input", {
id: "batchCountInputNumber",
type: "number",
value: this.batchCount,
min: "1",
style: {width: "35%", "margin-left": "0.4em"},
oninput: (i) => {
this.batchCount = i.target.value;
document.getElementById("batchCountInputRange").value = this.batchCount;
},
}),
$el("input", {
id: "batchCountInputRange",
type: "range",
min: "1",
max: "100",
value: this.batchCount,
oninput: (i) => {
this.batchCount = i.srcElement.value;
document.getElementById("batchCountInputNumber").value = i.srcElement.value;
},
}),
]),
$el("div",[
$el("label",{
for:"autoQueueCheckbox",
innerHTML: "Auto Queue"
}),
$el("input", {
id: "autoQueueCheckbox",
type: "checkbox",
checked: false,
title: "Automatically queue prompt when the queue size hits 0",
onchange: (e) => {
this.autoQueueEnabled = e.target.checked;
autoQueueModeEl.style.display = this.autoQueueEnabled ? "" : "none";
}
}),
autoQueueModeEl
])
]),
$el("div.comfy-menu-btns", [
$el("button", {
id: "queue-front-button",
textContent: "Queue Front",
onclick: () => app.queuePrompt(-1, this.batchCount)
}),
$el("button", {
$: (b) => (this.queue.button = b),
id: "comfy-view-queue-button",
textContent: "View Queue",
onclick: () => {
this.history.hide();
this.queue.toggle();
},
}),
$el("button", {
$: (b) => (this.history.button = b),
id: "comfy-view-history-button",
textContent: "View History",
onclick: () => {
this.queue.hide();
this.history.toggle();
},
}),
]),
this.queue.element,
this.history.element,
$el("button", {
id: "comfy-save-button",
textContent: "Save",
onclick: () => {
let filename = "workflow.json";
if (promptFilename.value) {
filename = prompt("Save workflow as:", filename);
if (!filename) return;
if (!filename.toLowerCase().endsWith(".json")) {
filename += ".json";
}
}
app.graphToPrompt().then(p=>{
const json = JSON.stringify(p.workflow, 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);
});
},
}),
$el("button", {
id: "comfy-dev-save-api-button",
textContent: "Save (API Format)",
style: {width: "100%", display: "none"},
onclick: () => {
let filename = "workflow_api.json";
if (promptFilename.value) {
filename = prompt("Save workflow (API) as:", filename);
if (!filename) return;
if (!filename.toLowerCase().endsWith(".json")) {
filename += ".json";
}
}
app.graphToPrompt().then(p=>{
const json = JSON.stringify(p.output, 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);
});
},
}),
$el("button", {id: "comfy-load-button", textContent: "Load", onclick: () => fileInput.click()}),
$el("button", {
id: "comfy-refresh-button",
textContent: "Refresh",
onclick: () => app.refreshComboInNodes()
}),
$el("button", {id: "comfy-clipspace-button", textContent: "Clipspace", onclick: () => app.openClipspace()}),
$el("button", {
id: "comfy-clear-button", textContent: "Clear", onclick: () => {
if (!confirmClear.value || confirm("Clear workflow?")) {
app.clean();
app.graph.clear();
app.resetView();
}
}
}),
$el("button", {
id: "comfy-load-default-button", textContent: "Load Default", onclick: async () => {
if (!confirmClear.value || confirm("Load default workflow?")) {
app.resetView();
await app.loadGraphData()
}
}
}),
$el("button", {
id: "comfy-reset-view-button", textContent: "Reset View", onclick: async () => {
app.resetView();
}
}),
]);
const devMode = this.settings.addSetting({
id: "Comfy.DevMode",
name: "Enable Dev mode Options",
type: "boolean",
defaultValue: false,
onChange: function(value) { document.getElementById("comfy-dev-save-api-button").style.display = value ? "flex" : "none"},
});
this.restoreMenuPosition = dragElement(this.menuContainer, this.settings);
this.setStatus({exec_info: {queue_remaining: "X"}});
}
setStatus(status) {
this.queueSize.textContent = "Queue size: " + (status ? status.exec_info.queue_remaining : "ERR");
if (status) {
if (
this.lastQueueSize != 0 &&
status.exec_info.queue_remaining == 0 &&
this.autoQueueEnabled &&
(this.autoQueueMode === "instant" || this.graphHasChanged) &&
!app.lastExecutionError
) {
app.queuePrompt(0, this.batchCount);
status.exec_info.queue_remaining += this.batchCount;
this.graphHasChanged = false;
}
this.lastQueueSize = status.exec_info.queue_remaining;
}
}
}
// Shim for scripts\ui.ts
export const ComfyDialog = window.comfyAPI.ui.ComfyDialog;
export const $el = window.comfyAPI.ui.$el;
export const ComfyUI = window.comfyAPI.ui.ComfyUI;

View File

@@ -1,64 +1,2 @@
import { ComfyDialog } from "../dialog.js";
import { $el } from "../../ui.js";
export class ComfyAsyncDialog extends ComfyDialog {
#resolve;
constructor(actions) {
super(
"dialog.comfy-dialog.comfyui-dialog",
actions?.map((opt) => {
if (typeof opt === "string") {
opt = { text: opt };
}
return $el("button.comfyui-button", {
type: "button",
textContent: opt.text,
onclick: () => this.close(opt.value ?? opt.text),
});
})
);
}
show(html) {
this.element.addEventListener("close", () => {
this.close();
});
super.show(html);
return new Promise((resolve) => {
this.#resolve = resolve;
});
}
showModal(html) {
this.element.addEventListener("close", () => {
this.close();
});
super.show(html);
this.element.showModal();
return new Promise((resolve) => {
this.#resolve = resolve;
});
}
close(result = null) {
this.#resolve(result);
this.element.close();
super.close();
}
static async prompt({ title = null, message, actions }) {
const dialog = new ComfyAsyncDialog(actions);
const content = [$el("span", message)];
if (title) {
content.unshift($el("h3", title));
}
const res = await dialog.showModal(content);
dialog.element.remove();
return res;
}
}
// Shim for scripts\ui\components\asyncDialog.ts
export const ComfyAsyncDialog = window.comfyAPI.asyncDialog.ComfyAsyncDialog;

View File

@@ -1,163 +1,2 @@
// @ts-check
import { $el } from "../../ui.js";
import { applyClasses, toggleElement } from "../utils.js";
import { prop } from "../../utils.js";
/**
* @typedef {{
* icon?: string;
* overIcon?: string;
* iconSize?: number;
* content?: string | HTMLElement;
* tooltip?: string;
* enabled?: boolean;
* action?: (e: Event, btn: ComfyButton) => void,
* classList?: import("../utils.js").ClassList,
* visibilitySetting?: { id: string, showValue: any },
* app?: import("../../app.js").ComfyApp
* }} ComfyButtonProps
*/
export class ComfyButton {
#over = 0;
#popupOpen = false;
isOver = false;
iconElement = $el("i.mdi");
contentElement = $el("span");
/**
* @type {import("./popup.js").ComfyPopup}
*/
popup;
/**
* @param {ComfyButtonProps} opts
*/
constructor({
icon,
overIcon,
iconSize,
content,
tooltip,
action,
classList = "comfyui-button",
visibilitySetting,
app,
enabled = true,
}) {
this.element = $el("button", {
onmouseenter: () => {
this.isOver = true;
if(this.overIcon) {
this.updateIcon();
}
},
onmouseleave: () => {
this.isOver = false;
if(this.overIcon) {
this.updateIcon();
}
}
}, [this.iconElement, this.contentElement]);
this.icon = prop(this, "icon", icon, toggleElement(this.iconElement, { onShow: this.updateIcon }));
this.overIcon = prop(this, "overIcon", overIcon, () => {
if(this.isOver) {
this.updateIcon();
}
});
this.iconSize = prop(this, "iconSize", iconSize, this.updateIcon);
this.content = prop(
this,
"content",
content,
toggleElement(this.contentElement, {
onShow: (el, v) => {
if (typeof v === "string") {
el.textContent = v;
} else {
el.replaceChildren(v);
}
},
})
);
this.tooltip = prop(this, "tooltip", tooltip, (v) => {
if (v) {
this.element.title = v;
} else {
this.element.removeAttribute("title");
}
});
this.classList = prop(this, "classList", classList, this.updateClasses);
this.hidden = prop(this, "hidden", false, this.updateClasses);
this.enabled = prop(this, "enabled", enabled, () => {
this.updateClasses();
this.element.disabled = !this.enabled;
});
this.action = prop(this, "action", action);
this.element.addEventListener("click", (e) => {
if (this.popup) {
// we are either a touch device or triggered by click not hover
if (!this.#over) {
this.popup.toggle();
}
}
this.action?.(e, this);
});
if (visibilitySetting?.id) {
const settingUpdated = () => {
this.hidden = app.ui.settings.getSettingValue(visibilitySetting.id) !== visibilitySetting.showValue;
};
app.ui.settings.addEventListener(visibilitySetting.id + ".change", settingUpdated);
settingUpdated();
}
}
updateIcon = () => (this.iconElement.className = `mdi mdi-${(this.isOver && this.overIcon) || this.icon}${this.iconSize ? " mdi-" + this.iconSize + "px" : ""}`);
updateClasses = () => {
const internalClasses = [];
if (this.hidden) {
internalClasses.push("hidden");
}
if (!this.enabled) {
internalClasses.push("disabled");
}
if (this.popup) {
if (this.#popupOpen) {
internalClasses.push("popup-open");
} else {
internalClasses.push("popup-closed");
}
}
applyClasses(this.element, this.classList, ...internalClasses);
};
/**
*
* @param { import("./popup.js").ComfyPopup } popup
* @param { "click" | "hover" } mode
*/
withPopup(popup, mode = "click") {
this.popup = popup;
if (mode === "hover") {
for (const el of [this.element, this.popup.element]) {
el.addEventListener("mouseenter", () => {
this.popup.open = !!++this.#over;
});
el.addEventListener("mouseleave", () => {
this.popup.open = !!--this.#over;
});
}
}
popup.addEventListener("change", () => {
this.#popupOpen = popup.open;
this.updateClasses();
});
return this;
}
}
// Shim for scripts\ui\components\button.ts
export const ComfyButton = window.comfyAPI.button.ComfyButton;

View File

@@ -1,45 +1,2 @@
// @ts-check
import { $el } from "../../ui.js";
import { ComfyButton } from "./button.js";
import { prop } from "../../utils.js";
export class ComfyButtonGroup {
element = $el("div.comfyui-button-group");
/** @param {Array<ComfyButton | HTMLElement>} buttons */
constructor(...buttons) {
this.buttons = prop(this, "buttons", buttons, () => this.update());
}
/**
* @param {ComfyButton} button
* @param {number} index
*/
insert(button, index) {
this.buttons.splice(index, 0, button);
this.update();
}
/** @param {ComfyButton} button */
append(button) {
this.buttons.push(button);
this.update();
}
/** @param {ComfyButton|number} indexOrButton */
remove(indexOrButton) {
if (typeof indexOrButton !== "number") {
indexOrButton = this.buttons.indexOf(indexOrButton);
}
if (indexOrButton > -1) {
const r = this.buttons.splice(indexOrButton, 1);
this.update();
return r;
}
}
update() {
this.element.replaceChildren(...this.buttons.map((b) => b["element"] ?? b));
}
}
// Shim for scripts\ui\components\buttonGroup.ts
export const ComfyButtonGroup = window.comfyAPI.buttonGroup.ComfyButtonGroup;

View File

@@ -1,128 +1,2 @@
// @ts-check
import { prop } from "../../utils.js";
import { $el } from "../../ui.js";
import { applyClasses } from "../utils.js";
export class ComfyPopup extends EventTarget {
element = $el("div.comfyui-popup");
/**
* @param {{
* target: HTMLElement,
* container?: HTMLElement,
* classList?: import("../utils.js").ClassList,
* ignoreTarget?: boolean,
* closeOnEscape?: boolean,
* position?: "absolute" | "relative",
* horizontal?: "left" | "right"
* }} param0
* @param {...HTMLElement} children
*/
constructor(
{
target,
container = document.body,
classList = "",
ignoreTarget = true,
closeOnEscape = true,
position = "absolute",
horizontal = "left",
},
...children
) {
super();
this.target = target;
this.ignoreTarget = ignoreTarget;
this.container = container;
this.position = position;
this.closeOnEscape = closeOnEscape;
this.horizontal = horizontal;
container.append(this.element);
this.children = prop(this, "children", children, () => {
this.element.replaceChildren(...this.children);
this.update();
});
this.classList = prop(this, "classList", classList, () => applyClasses(this.element, this.classList, "comfyui-popup", horizontal));
this.open = prop(this, "open", false, (v, o) => {
if (v === o) return;
if (v) {
this.#show();
} else {
this.#hide();
}
});
}
toggle() {
this.open = !this.open;
}
#hide() {
this.element.classList.remove("open");
window.removeEventListener("resize", this.update);
window.removeEventListener("click", this.#clickHandler, { capture: true });
window.removeEventListener("keydown", this.#escHandler, { capture: true });
this.dispatchEvent(new CustomEvent("close"));
this.dispatchEvent(new CustomEvent("change"));
}
#show() {
this.element.classList.add("open");
this.update();
window.addEventListener("resize", this.update);
window.addEventListener("click", this.#clickHandler, { capture: true });
if (this.closeOnEscape) {
window.addEventListener("keydown", this.#escHandler, { capture: true });
}
this.dispatchEvent(new CustomEvent("open"));
this.dispatchEvent(new CustomEvent("change"));
}
#escHandler = (e) => {
if (e.key === "Escape") {
this.open = false;
e.preventDefault();
e.stopImmediatePropagation();
}
};
#clickHandler = (e) => {
/** @type {any} */
const target = e.target;
if (!this.element.contains(target) && this.ignoreTarget && !this.target.contains(target)) {
this.open = false;
}
};
update = () => {
const rect = this.target.getBoundingClientRect();
this.element.style.setProperty("--bottom", "unset");
if (this.position === "absolute") {
if (this.horizontal === "left") {
this.element.style.setProperty("--left", rect.left + "px");
} else {
this.element.style.setProperty("--left", rect.right - this.element.clientWidth + "px");
}
this.element.style.setProperty("--top", rect.bottom + "px");
this.element.style.setProperty("--limit", rect.bottom + "px");
} else {
this.element.style.setProperty("--left", 0 + "px");
this.element.style.setProperty("--top", rect.height + "px");
this.element.style.setProperty("--limit", rect.height + "px");
}
const thisRect = this.element.getBoundingClientRect();
if (thisRect.height < 30) {
// Move up instead
this.element.style.setProperty("--top", "unset");
this.element.style.setProperty("--bottom", rect.height + 5 + "px");
this.element.style.setProperty("--limit", rect.height + 5 + "px");
}
};
}
// Shim for scripts\ui\components\popup.ts
export const ComfyPopup = window.comfyAPI.popup.ComfyPopup;

View File

@@ -1,43 +1,2 @@
// @ts-check
import { $el } from "../../ui.js";
import { ComfyButton } from "./button.js";
import { prop } from "../../utils.js";
import { ComfyPopup } from "./popup.js";
export class ComfySplitButton {
/**
* @param {{
* primary: ComfyButton,
* mode?: "hover" | "click",
* horizontal?: "left" | "right",
* position?: "relative" | "absolute"
* }} param0
* @param {Array<ComfyButton> | Array<HTMLElement>} items
*/
constructor({ primary, mode, horizontal = "left", position = "relative" }, ...items) {
this.arrow = new ComfyButton({
icon: "chevron-down",
});
this.element = $el("div.comfyui-split-button" + (mode === "hover" ? ".hover" : ""), [
$el("div.comfyui-split-primary", primary.element),
$el("div.comfyui-split-arrow", this.arrow.element),
]);
this.popup = new ComfyPopup({
target: this.element,
container: position === "relative" ? this.element : document.body,
classList: "comfyui-split-button-popup" + (mode === "hover" ? " hover" : ""),
closeOnEscape: mode === "click",
position,
horizontal,
});
this.arrow.withPopup(this.popup, mode);
this.items = prop(this, "items", items, () => this.update());
}
update() {
this.popup.element.replaceChildren(...this.items.map((b) => b.element ?? b));
}
}
// Shim for scripts\ui\components\splitButton.ts
export const ComfySplitButton = window.comfyAPI.splitButton.ComfySplitButton;

View File

@@ -1,38 +1,2 @@
import { $el } from "../ui.js";
export class ComfyDialog extends EventTarget {
#buttons;
constructor(type = "div", buttons = null) {
super();
this.#buttons = buttons;
this.element = $el(type + ".comfy-modal", { parent: document.body }, [
$el("div.comfy-modal-content", [$el("p", { $: (p) => (this.textElement = p) }), ...this.createButtons()]),
]);
}
createButtons() {
return (
this.#buttons ?? [
$el("button", {
type: "button",
textContent: "Close",
onclick: () => this.close(),
}),
]
);
}
close() {
this.element.style.display = "none";
}
show(html) {
if (typeof html === "string") {
this.textElement.innerHTML = html;
} else {
this.textElement.replaceChildren(...(html instanceof Array ? html : [html]));
}
this.element.style.display = "flex";
}
}
// Shim for scripts\ui\dialog.ts
export const ComfyDialog = window.comfyAPI.dialog.ComfyDialog;

View File

@@ -1,287 +1,2 @@
// @ts-check
/*
Original implementation:
https://github.com/TahaSh/drag-to-reorder
MIT License
Copyright (c) 2023 Taha Shashtari
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.
*/
import { $el } from "../ui.js";
$el("style", {
parent: document.head,
textContent: `
.draggable-item {
position: relative;
will-change: transform;
user-select: none;
}
.draggable-item.is-idle {
transition: 0.25s ease transform;
}
.draggable-item.is-draggable {
z-index: 10;
}
`
});
export class DraggableList extends EventTarget {
listContainer;
draggableItem;
pointerStartX;
pointerStartY;
scrollYMax;
itemsGap = 0;
items = [];
itemSelector;
handleClass = "drag-handle";
off = [];
offDrag = [];
constructor(element, itemSelector) {
super();
this.listContainer = element;
this.itemSelector = itemSelector;
if (!this.listContainer) return;
this.off.push(this.on(this.listContainer, "mousedown", this.dragStart));
this.off.push(this.on(this.listContainer, "touchstart", this.dragStart));
this.off.push(this.on(document, "mouseup", this.dragEnd));
this.off.push(this.on(document, "touchend", this.dragEnd));
}
getAllItems() {
if (!this.items?.length) {
this.items = Array.from(this.listContainer.querySelectorAll(this.itemSelector));
this.items.forEach((element) => {
element.classList.add("is-idle");
});
}
return this.items;
}
getIdleItems() {
return this.getAllItems().filter((item) => item.classList.contains("is-idle"));
}
isItemAbove(item) {
return item.hasAttribute("data-is-above");
}
isItemToggled(item) {
return item.hasAttribute("data-is-toggled");
}
on(source, event, listener, options) {
listener = listener.bind(this);
source.addEventListener(event, listener, options);
return () => source.removeEventListener(event, listener);
}
dragStart(e) {
if (e.target.classList.contains(this.handleClass)) {
this.draggableItem = e.target.closest(this.itemSelector);
}
if (!this.draggableItem) return;
this.pointerStartX = e.clientX || e.touches[0].clientX;
this.pointerStartY = e.clientY || e.touches[0].clientY;
this.scrollYMax = this.listContainer.scrollHeight - this.listContainer.clientHeight;
this.setItemsGap();
this.initDraggableItem();
this.initItemsState();
this.offDrag.push(this.on(document, "mousemove", this.drag));
this.offDrag.push(this.on(document, "touchmove", this.drag, { passive: false }));
this.dispatchEvent(
new CustomEvent("dragstart", {
detail: { element: this.draggableItem, position: this.getAllItems().indexOf(this.draggableItem) },
})
);
}
setItemsGap() {
if (this.getIdleItems().length <= 1) {
this.itemsGap = 0;
return;
}
const item1 = this.getIdleItems()[0];
const item2 = this.getIdleItems()[1];
const item1Rect = item1.getBoundingClientRect();
const item2Rect = item2.getBoundingClientRect();
this.itemsGap = Math.abs(item1Rect.bottom - item2Rect.top);
}
initItemsState() {
this.getIdleItems().forEach((item, i) => {
if (this.getAllItems().indexOf(this.draggableItem) > i) {
item.dataset.isAbove = "";
}
});
}
initDraggableItem() {
this.draggableItem.classList.remove("is-idle");
this.draggableItem.classList.add("is-draggable");
}
drag(e) {
if (!this.draggableItem) return;
e.preventDefault();
const clientX = e.clientX || e.touches[0].clientX;
const clientY = e.clientY || e.touches[0].clientY;
const listRect = this.listContainer.getBoundingClientRect();
if (clientY > listRect.bottom) {
if (this.listContainer.scrollTop < this.scrollYMax) {
this.listContainer.scrollBy(0, 10);
this.pointerStartY -= 10;
}
} else if (clientY < listRect.top && this.listContainer.scrollTop > 0) {
this.pointerStartY += 10;
this.listContainer.scrollBy(0, -10);
}
const pointerOffsetX = clientX - this.pointerStartX;
const pointerOffsetY = clientY - this.pointerStartY;
this.updateIdleItemsStateAndPosition();
this.draggableItem.style.transform = `translate(${pointerOffsetX}px, ${pointerOffsetY}px)`;
}
updateIdleItemsStateAndPosition() {
const draggableItemRect = this.draggableItem.getBoundingClientRect();
const draggableItemY = draggableItemRect.top + draggableItemRect.height / 2;
// Update state
this.getIdleItems().forEach((item) => {
const itemRect = item.getBoundingClientRect();
const itemY = itemRect.top + itemRect.height / 2;
if (this.isItemAbove(item)) {
if (draggableItemY <= itemY) {
item.dataset.isToggled = "";
} else {
delete item.dataset.isToggled;
}
} else {
if (draggableItemY >= itemY) {
item.dataset.isToggled = "";
} else {
delete item.dataset.isToggled;
}
}
});
// Update position
this.getIdleItems().forEach((item) => {
if (this.isItemToggled(item)) {
const direction = this.isItemAbove(item) ? 1 : -1;
item.style.transform = `translateY(${direction * (draggableItemRect.height + this.itemsGap)}px)`;
} else {
item.style.transform = "";
}
});
}
dragEnd() {
if (!this.draggableItem) return;
this.applyNewItemsOrder();
this.cleanup();
}
applyNewItemsOrder() {
const reorderedItems = [];
let oldPosition = -1;
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index;
return;
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item;
return;
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1;
reorderedItems[newIndex] = item;
});
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index];
if (typeof item === "undefined") {
reorderedItems[index] = this.draggableItem;
}
}
reorderedItems.forEach((item) => {
this.listContainer.appendChild(item);
});
this.items = reorderedItems;
this.dispatchEvent(
new CustomEvent("dragend", {
detail: { element: this.draggableItem, oldPosition, newPosition: reorderedItems.indexOf(this.draggableItem) },
})
);
}
cleanup() {
this.itemsGap = 0;
this.items = [];
this.unsetDraggableItem();
this.unsetItemState();
this.offDrag.forEach((f) => f());
this.offDrag = [];
}
unsetDraggableItem() {
this.draggableItem.style = null;
this.draggableItem.classList.remove("is-draggable");
this.draggableItem.classList.add("is-idle");
this.draggableItem = null;
}
unsetItemState() {
this.getIdleItems().forEach((item, i) => {
delete item.dataset.isAbove;
delete item.dataset.isToggled;
item.style.transform = "";
});
}
dispose() {
this.off.forEach((f) => f());
}
}
// Shim for scripts\ui\draggableList.ts
export const DraggableList = window.comfyAPI.draggableList.DraggableList;

View File

@@ -1,97 +1,3 @@
import { $el } from "../ui.js";
export function calculateImageGrid(imgs, dw, dh) {
let best = 0;
let w = imgs[0].naturalWidth;
let h = imgs[0].naturalHeight;
const numImages = imgs.length;
let cellWidth, cellHeight, cols, rows, shiftX;
// compact style
for (let c = 1; c <= numImages; c++) {
const r = Math.ceil(numImages / c);
const cW = dw / c;
const cH = dh / r;
const scaleX = cW / w;
const scaleY = cH / h;
const scale = Math.min(scaleX, scaleY, 1);
const imageW = w * scale;
const imageH = h * scale;
const area = imageW * imageH * numImages;
if (area > best) {
best = area;
cellWidth = imageW;
cellHeight = imageH;
cols = c;
rows = r;
shiftX = c * ((cW - imageW) / 2);
}
}
return { cellWidth, cellHeight, cols, rows, shiftX };
}
export function createImageHost(node) {
const el = $el("div.comfy-img-preview");
let currentImgs;
let first = true;
function updateSize() {
let w = null;
let h = null;
if (currentImgs) {
let elH = el.clientHeight;
if (first) {
first = false;
// On first run, if we are small then grow a bit
if (elH < 190) {
elH = 190;
}
el.style.setProperty("--comfy-widget-min-height", elH);
} else {
el.style.setProperty("--comfy-widget-min-height", null);
}
const nw = node.size[0];
({ cellWidth: w, cellHeight: h } = calculateImageGrid(currentImgs, nw - 20, elH));
w += "px";
h += "px";
el.style.setProperty("--comfy-img-preview-width", w);
el.style.setProperty("--comfy-img-preview-height", h);
}
}
return {
el,
updateImages(imgs) {
if (imgs !== currentImgs) {
if (currentImgs == null) {
requestAnimationFrame(() => {
updateSize();
});
}
el.replaceChildren(...imgs);
currentImgs = imgs;
node.onResize(node.size);
node.graph.setDirtyCanvas(true, true);
}
},
getHeight() {
updateSize();
},
onDraw() {
// Element from point uses a hittest find elements so we need to toggle pointer events
el.style.pointerEvents = "all";
const over = document.elementFromPoint(app.canvas.mouse[0], app.canvas.mouse[1]);
el.style.pointerEvents = "none";
if(!over) return;
// Set the overIndex so Open Image etc work
const idx = currentImgs.indexOf(over);
node.overIndex = idx;
},
};
}
// Shim for scripts\ui\imagePreview.ts
export const calculateImageGrid = window.comfyAPI.imagePreview.calculateImageGrid;
export const createImageHost = window.comfyAPI.imagePreview.createImageHost;

View File

@@ -1,302 +1,2 @@
// @ts-check
import { $el } from "../../ui.js";
import { downloadBlob } from "../../utils.js";
import { ComfyButton } from "../components/button.js";
import { ComfyButtonGroup } from "../components/buttonGroup.js";
import { ComfySplitButton } from "../components/splitButton.js";
import { ComfyViewHistoryButton } from "./viewHistory.js";
import { ComfyQueueButton } from "./queueButton.js";
import { ComfyWorkflowsMenu } from "./workflows.js";
import { ComfyViewQueueButton } from "./viewQueue.js";
import { getInteruptButton } from "./interruptButton.js";
const collapseOnMobile = (t) => {
(t.element ?? t).classList.add("comfyui-menu-mobile-collapse");
return t;
};
const showOnMobile = (t) => {
(t.element ?? t).classList.add("lt-lg-show");
return t;
};
export class ComfyAppMenu {
#sizeBreak = "lg";
#lastSizeBreaks = {
lg: null,
md: null,
sm: null,
xs: null,
};
#sizeBreaks = Object.keys(this.#lastSizeBreaks);
#cachedInnerSize = null;
#cacheTimeout = null;
/**
* @param { import("../../app.js").ComfyApp } app
*/
constructor(app) {
this.app = app;
this.workflows = new ComfyWorkflowsMenu(app);
const getSaveButton = (t) =>
new ComfyButton({
icon: "content-save",
tooltip: "Save the current workflow",
action: () => app.workflowManager.activeWorkflow.save(),
content: t,
});
this.logo = $el("h1.comfyui-logo.nlg-hide", { title: "ComfyUI" }, "ComfyUI");
this.saveButton = new ComfySplitButton(
{
primary: getSaveButton(),
mode: "hover",
position: "absolute",
},
getSaveButton("Save"),
new ComfyButton({
icon: "content-save-edit",
content: "Save As",
tooltip: "Save the current graph as a new workflow",
action: () => app.workflowManager.activeWorkflow.save(true),
}),
new ComfyButton({
icon: "download",
content: "Export",
tooltip: "Export the current workflow as JSON",
action: () => this.exportWorkflow("workflow", "workflow"),
}),
new ComfyButton({
icon: "api",
content: "Export (API Format)",
tooltip: "Export the current workflow as JSON for use with the ComfyUI API",
action: () => this.exportWorkflow("workflow_api", "output"),
visibilitySetting: { id: "Comfy.DevMode", showValue: true },
app,
})
);
this.actionsGroup = new ComfyButtonGroup(
new ComfyButton({
icon: "refresh",
content: "Refresh",
tooltip: "Refresh widgets in nodes to find new models or files",
action: () => app.refreshComboInNodes(),
}),
new ComfyButton({
icon: "clipboard-edit-outline",
content: "Clipspace",
tooltip: "Open Clipspace window",
action: () => app["openClipspace"](),
}),
new ComfyButton({
icon: "fit-to-page-outline",
content: "Reset View",
tooltip: "Reset the canvas view",
action: () => app.resetView(),
}),
new ComfyButton({
icon: "cancel",
content: "Clear",
tooltip: "Clears current workflow",
action: () => {
if (!app.ui.settings.getSettingValue("Comfy.ConfirmClear", true) || confirm("Clear workflow?")) {
app.clean();
app.graph.clear();
}
},
})
);
this.settingsGroup = new ComfyButtonGroup(
new ComfyButton({
icon: "cog",
content: "Settings",
tooltip: "Open settings",
action: () => {
app.ui.settings.show();
},
})
);
this.viewGroup = new ComfyButtonGroup(
new ComfyViewHistoryButton(app).element,
new ComfyViewQueueButton(app).element,
getInteruptButton("nlg-hide").element
);
this.mobileMenuButton = new ComfyButton({
icon: "menu",
action: (_, btn) => {
btn.icon = this.element.classList.toggle("expanded") ? "menu-open" : "menu";
window.dispatchEvent(new Event("resize"));
},
classList: "comfyui-button comfyui-menu-button",
});
this.element = $el("nav.comfyui-menu.lg", { style: { display: "none" } }, [
this.logo,
this.workflows.element,
this.saveButton.element,
collapseOnMobile(this.actionsGroup).element,
$el("section.comfyui-menu-push"),
collapseOnMobile(this.settingsGroup).element,
collapseOnMobile(this.viewGroup).element,
getInteruptButton("lt-lg-show").element,
new ComfyQueueButton(app).element,
showOnMobile(this.mobileMenuButton).element,
]);
let resizeHandler;
this.menuPositionSetting = app.ui.settings.addSetting({
id: "Comfy.UseNewMenu",
defaultValue: "Disabled",
name: "[Beta] Use new menu and workflow management. Note: On small screens the menu will always be at the top.",
type: "combo",
options: ["Disabled", "Top", "Bottom"],
onChange: async (v) => {
if (v && v !== "Disabled") {
if (!resizeHandler) {
resizeHandler = () => {
this.calculateSizeBreak();
};
window.addEventListener("resize", resizeHandler);
}
this.updatePosition(v);
} else {
if (resizeHandler) {
window.removeEventListener("resize", resizeHandler);
resizeHandler = null;
}
document.body.style.removeProperty("display");
app.ui.menuContainer.style.removeProperty("display");
this.element.style.display = "none";
app.ui.restoreMenuPosition();
}
window.dispatchEvent(new Event("resize"));
},
});
}
updatePosition(v) {
document.body.style.display = "grid";
this.app.ui.menuContainer.style.display = "none";
this.element.style.removeProperty("display");
this.position = v;
if (v === "Bottom") {
this.app.bodyBottom.append(this.element);
} else {
this.app.bodyTop.prepend(this.element);
}
this.calculateSizeBreak();
}
updateSizeBreak(idx, prevIdx, direction) {
const newSize = this.#sizeBreaks[idx];
if (newSize === this.#sizeBreak) return;
this.#cachedInnerSize = null;
clearTimeout(this.#cacheTimeout);
this.#sizeBreak = this.#sizeBreaks[idx];
for (let i = 0; i < this.#sizeBreaks.length; i++) {
const sz = this.#sizeBreaks[i];
if (sz === this.#sizeBreak) {
this.element.classList.add(sz);
} else {
this.element.classList.remove(sz);
}
if (i < idx) {
this.element.classList.add("lt-" + sz);
} else {
this.element.classList.remove("lt-" + sz);
}
}
if (idx) {
// We're on a small screen, force the menu at the top
if (this.position !== "Top") {
this.updatePosition("Top");
}
} else if (this.position != this.menuPositionSetting.value) {
// Restore user position
this.updatePosition(this.menuPositionSetting.value);
}
// Allow multiple updates, but prevent bouncing
if (!direction) {
direction = prevIdx - idx;
} else if (direction != prevIdx - idx) {
return;
}
this.calculateSizeBreak(direction);
}
calculateSizeBreak(direction = 0) {
let idx = this.#sizeBreaks.indexOf(this.#sizeBreak);
const currIdx = idx;
const innerSize = this.calculateInnerSize(idx);
if (window.innerWidth >= this.#lastSizeBreaks[this.#sizeBreaks[idx - 1]]) {
if (idx > 0) {
idx--;
}
} else if (innerSize > this.element.clientWidth) {
this.#lastSizeBreaks[this.#sizeBreak] = Math.max(window.innerWidth, innerSize);
// We need to shrink
if (idx < this.#sizeBreaks.length - 1) {
idx++;
}
}
this.updateSizeBreak(idx, currIdx, direction);
}
calculateInnerSize(idx) {
// Cache the inner size to prevent too much calculation when resizing the window
clearTimeout(this.#cacheTimeout);
if (this.#cachedInnerSize) {
// Extend cache time
this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100);
} else {
let innerSize = 0;
let count = 1;
for (const c of this.element.children) {
if (c.classList.contains("comfyui-menu-push")) continue; // ignore right push
if (idx && c.classList.contains("comfyui-menu-mobile-collapse")) continue; // ignore collapse items
innerSize += c.clientWidth;
count++;
}
innerSize += 8 * count;
this.#cachedInnerSize = innerSize;
this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100);
}
return this.#cachedInnerSize;
}
/**
* @param {string} defaultName
*/
getFilename(defaultName) {
if (this.app.ui.settings.getSettingValue("Comfy.PromptFilename", true)) {
defaultName = prompt("Save workflow as:", defaultName);
if (!defaultName) return;
if (!defaultName.toLowerCase().endsWith(".json")) {
defaultName += ".json";
}
}
return defaultName;
}
/**
* @param {string} [filename]
* @param { "workflow" | "output" } [promptProperty]
*/
async exportWorkflow(filename, promptProperty) {
if (this.app.workflowManager.activeWorkflow?.path) {
filename = this.app.workflowManager.activeWorkflow.name;
}
const p = await this.app.graphToPrompt();
const json = JSON.stringify(p[promptProperty], null, 2);
const blob = new Blob([json], { type: "application/json" });
const file = this.getFilename(filename);
if (!file) return;
downloadBlob(file, blob);
}
}
// Shim for scripts\ui\menu\index.ts
export const ComfyAppMenu = window.comfyAPI.index.ComfyAppMenu;

View File

@@ -1,23 +1,2 @@
// @ts-check
import { api } from "../../api.js";
import { ComfyButton } from "../components/button.js";
export function getInteruptButton(visibility) {
const btn = new ComfyButton({
icon: "close",
tooltip: "Cancel current generation",
enabled: false,
action: () => {
api.interrupt();
},
classList: ["comfyui-button", "comfyui-interrupt-button", visibility],
});
api.addEventListener("status", ({ detail }) => {
const sz = detail?.exec_info?.queue_remaining;
btn.enabled = sz > 0;
});
return btn;
}
// Shim for scripts\ui\menu\interruptButton.ts
export const getInterruptButton = window.comfyAPI.interruptButton.getInterruptButton;

View File

@@ -1,710 +0,0 @@
.relative {
position: relative;
}
.hidden {
display: none !important;
}
.mdi.rotate270::before {
transform: rotate(270deg);
}
/* Generic */
.comfyui-button {
display: flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
border: none;
border-radius: 4px;
padding: 4px 8px;
box-sizing: border-box;
margin: 0;
transition: box-shadow 0.1s;
}
.comfyui-button:active {
box-shadow: inset 1px 1px 10px rgba(0, 0, 0, 0.5);
}
.comfyui-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.primary .comfyui-button,
.primary.comfyui-button {
background-color: var(--primary-bg) !important;
color: var(--primary-fg) !important;
}
.primary .comfyui-button:not(:disabled):hover,
.primary.comfyui-button:not(:disabled):hover {
background-color: var(--primary-hover-bg) !important;
color: var(--primary-hover-fg) !important;
}
/* Popup */
.comfyui-popup {
position: absolute;
left: var(--left);
right: var(--right);
top: var(--top);
bottom: var(--bottom);
z-index: 2000;
max-height: calc(100vh - var(--limit) - 10px);
box-shadow: 3px 3px 5px 0px rgba(0, 0, 0, 0.3);
}
.comfyui-popup:not(.open) {
display: none;
}
.comfyui-popup.right.open {
border-top-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
overflow: hidden;
}
/* Split button */
.comfyui-split-button {
position: relative;
display: flex;
}
.comfyui-split-primary {
flex: auto;
}
.comfyui-split-primary .comfyui-button {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: 1px solid var(--comfy-menu-bg);
width: 100%;
}
.comfyui-split-arrow .comfyui-button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding-left: 2px;
padding-right: 2px;
}
.comfyui-split-button-popup {
white-space: nowrap;
background-color: var(--content-bg);
color: var(--content-fg);
display: flex;
flex-direction: column;
overflow: auto;
}
.comfyui-split-button-popup.hover {
z-index: 2001;
}
.comfyui-split-button-popup > .comfyui-button {
border: none;
background-color: transparent;
color: var(--fg-color);
padding: 8px 12px 8px 8px;
}
.comfyui-split-button-popup > .comfyui-button:not(:disabled):hover {
background-color: var(--comfy-input-bg);
}
/* Button group */
.comfyui-button-group {
display: flex;
border-radius: 4px;
overflow: hidden;
}
.comfyui-button-group > .comfyui-button,
.comfyui-button-group > .comfyui-button-wrapper > .comfyui-button {
padding: 4px 10px;
border-radius: 0;
}
/* Menu */
.comfyui-menu {
width: 100vw;
background: var(--comfy-menu-bg);
color: var(--fg-color);
font-family: Arial, Helvetica, sans-serif;
font-size: 0.8em;
display: flex;
padding: 4px 8px;
align-items: center;
gap: 8px;
box-sizing: border-box;
z-index: 1000;
order: 0;
grid-column: 1/-1;
overflow: auto;
max-height: 90vh;
}
.comfyui-menu>* {
flex-shrink: 0;
}
.comfyui-menu .mdi::before {
font-size: 18px;
}
.comfyui-menu .comfyui-button {
background: var(--comfy-input-bg);
color: var(--fg-color);
white-space: nowrap;
}
.comfyui-menu .comfyui-button:not(:disabled):hover {
background: var(--border-color);
color: var(--content-fg);
}
.comfyui-menu .comfyui-split-button-popup > .comfyui-button {
border-radius: 0;
background-color: transparent;
}
.comfyui-menu .comfyui-split-button-popup > .comfyui-button:not(:disabled):hover {
background-color: var(--comfy-input-bg);
}
.comfyui-menu .comfyui-split-button-popup.left {
border-top-right-radius: 4px;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.comfyui-menu .comfyui-button.popup-open {
background-color: var(--content-bg);
color: var(--content-fg);
}
.comfyui-menu-push {
margin-left: -0.8em;
flex: auto;
}
.comfyui-logo {
font-size: 1.2em;
margin: 0;
user-select: none;
cursor: default;
}
/* Workflows */
.comfyui-workflows-button {
flex-direction: row-reverse;
max-width: 200px;
position: relative;
z-index: 0;
}
.comfyui-workflows-button.popup-open {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.comfyui-workflows-button.unsaved {
font-style: italic;
}
.comfyui-workflows-button-progress {
position: absolute;
top: 0;
left: 0;
background-color: green;
height: 100%;
border-radius: 4px;
z-index: -1;
}
.comfyui-workflows-button > span {
flex: auto;
text-align: left;
overflow: hidden;
}
.comfyui-workflows-button-inner {
display: flex;
align-items: center;
gap: 7px;
width: 150px;
}
.comfyui-workflows-label {
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
flex: auto;
position: relative;
}
.comfyui-workflows-button.unsaved .comfyui-workflows-label {
padding-left: 8px;
}
.comfyui-workflows-button.unsaved .comfyui-workflows-label:after {
content: "*";
position: absolute;
top: 0;
left: 0;
}
.comfyui-workflows-button-inner .mdi-graph::before {
transform: rotate(-90deg);
}
.comfyui-workflows-popup {
font-family: Arial, Helvetica, sans-serif;
font-size: 0.8em;
padding: 10px;
overflow: auto;
background-color: var(--content-bg);
color: var(--content-fg);
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
z-index: 400;
}
.comfyui-workflows-panel {
min-height: 150px;
}
.comfyui-workflows-panel .lds-ring {
transform: translate(-50%);
position: absolute;
left: 50%;
top: 75px;
}
.comfyui-workflows-panel h3 {
margin: 10px 0 10px 0;
font-size: 11px;
opacity: 0.8;
}
.comfyui-workflows-panel section header {
display: flex;
justify-content: space-between;
align-items: center;
}
.comfy-ui-workflows-search .mdi {
position: relative;
top: 2px;
pointer-events: none;
}
.comfy-ui-workflows-search input {
background-color: var(--comfy-input-bg);
color: var(--input-text);
border: none;
border-radius: 4px;
padding: 4px 10px;
margin-left: -24px;
text-indent: 18px;
}
.comfy-ui-workflows-search input:placeholder-shown {
width: 10px;
}
.comfy-ui-workflows-search input:placeholder-shown:focus {
width: auto;
}
.comfyui-workflows-actions {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.comfyui-workflows-actions .comfyui-button {
background: var(--comfy-input-bg);
color: var(--input-text);
}
.comfyui-workflows-actions .comfyui-button:not(:disabled):hover {
background: var(--primary-bg);
color: var(--primary-fg);
}
.comfyui-workflows-favorites,
.comfyui-workflows-open {
border-bottom: 1px solid var(--comfy-input-bg);
padding-bottom: 5px;
margin-bottom: 5px;
}
.comfyui-workflows-open .active {
font-weight: bold;
color: var(--primary-fg);
}
.comfyui-workflows-favorites:empty {
display: none;
}
.comfyui-workflows-tree {
padding: 0;
margin: 0;
}
.comfyui-workflows-tree:empty::after {
content: "No saved workflows";
display: block;
text-align: center;
}
.comfyui-workflows-tree > ul {
padding: 0;
}
.comfyui-workflows-tree > ul ul {
margin: 0;
padding: 0 0 0 25px;
}
.comfyui-workflows-tree:not(.filtered) .closed > ul {
display: none;
}
.comfyui-workflows-tree li,
.comfyui-workflows-tree-file {
--item-height: 32px;
list-style-type: none;
height: var(--item-height);
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
user-select: none;
}
.comfyui-workflows-tree-file.active::before,
.comfyui-workflows-tree li:hover::before,
.comfyui-workflows-tree-file:hover::before {
content: "";
position: absolute;
width: 100%;
left: 0;
height: var(--item-height);
background-color: var(--content-hover-bg);
color: var(--content-hover-fg);
z-index: -1;
}
.comfyui-workflows-tree-file.active::before {
background-color: var(--primary-bg);
color: var(--primary-fg);
}
.comfyui-workflows-tree-file.running:not(:hover)::before {
content: "";
position: absolute;
width: var(--progress, 0);
left: 0;
height: var(--item-height);
background-color: green;
z-index: -1;
}
.comfyui-workflows-tree-file.unsaved span {
font-style: italic;
}
.comfyui-workflows-tree-file span {
flex: auto;
}
.comfyui-workflows-tree-file span + .comfyui-workflows-file-action {
margin-left: 10px;
}
.comfyui-workflows-tree-file .comfyui-workflows-file-action {
background-color: transparent;
color: var(--fg-color);
padding: 2px 4px;
}
.comfyui-workflows-tree-file.active .comfyui-workflows-file-action {
color: var(--primary-fg);
}
.lg ~ .comfyui-workflows-popup .comfyui-workflows-tree-file:not(:hover) .comfyui-workflows-file-action {
opacity: 0;
}
.comfyui-workflows-tree-file .comfyui-workflows-file-action:hover {
background-color: var(--primary-bg);
color: var(--primary-fg);
}
.comfyui-workflows-tree-file .comfyui-workflows-file-action-primary {
background-color: transparent;
color: var(--fg-color);
padding: 2px 4px;
margin: 0 -4px;
}
.comfyui-workflows-file-action-favorite .mdi-star {
color: orange;
}
/* View List */
.comfyui-view-list-popup {
padding: 10px;
background-color: var(--content-bg);
color: var(--content-fg);
min-width: 170px;
min-height: 435px;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
}
.comfyui-view-list-popup h3 {
margin: 0 0 5px 0;
}
.comfyui-view-list-items {
width: 100%;
background: var(--comfy-menu-bg);
border-radius: 5px;
display: flex;
justify-content: center;
flex: auto;
align-items: center;
flex-direction: column;
}
.comfyui-view-list-items section {
max-height: 400px;
overflow: auto;
width: 100%;
display: grid;
grid-template-columns: auto auto auto;
align-items: center;
justify-content: center;
gap: 5px;
padding: 5px 0;
}
.comfyui-view-list-items section + section {
border-top: 1px solid var(--border-color);
margin-top: 10px;
padding-top: 5px;
}
.comfyui-view-list-items section h5 {
grid-column: 1 / 4;
text-align: center;
margin: 5px;
}
.comfyui-view-list-items span {
text-align: center;
padding: 0 2px;
}
.comfyui-view-list-popup header {
margin-bottom: 10px;
display: flex;
gap: 5px;
}
.comfyui-view-list-popup header .comfyui-button {
border: 1px solid transparent;
}
.comfyui-view-list-popup header .comfyui-button:not(:disabled):hover {
border: 1px solid var(--comfy-menu-bg);
}
/* Queue button */
.comfyui-queue-button .comfyui-split-primary .comfyui-button {
padding-right: 12px;
}
.comfyui-queue-count {
margin-left: 5px;
border-radius: 10px;
background-color: rgb(8, 80, 153);
padding: 2px 4px;
font-size: 10px;
min-width: 1em;
display: inline-block;
}
/* Queue options*/
.comfyui-queue-options {
padding: 10px;
font-family: Arial, Helvetica, sans-serif;
font-size: 12px;
display: flex;
gap: 10px;
}
.comfyui-queue-batch {
display: flex;
flex-direction: column;
border-right: 1px solid var(--comfy-menu-bg);
padding-right: 10px;
gap: 5px;
}
.comfyui-queue-batch input {
width: 145px;
}
.comfyui-queue-batch .comfyui-queue-batch-value {
width: 70px;
}
.comfyui-queue-mode {
display: flex;
flex-direction: column;
}
.comfyui-queue-mode span {
font-weight: bold;
margin-bottom: 2px;
}
.comfyui-queue-mode label {
display: flex;
flex-direction: row-reverse;
justify-content: start;
gap: 5px;
padding: 2px 0;
}
.comfyui-queue-mode label input {
padding: 0;
margin: 0;
}
/** Send to workflow widget selection dialog */
.comfy-widget-selection-dialog {
border: none;
}
.comfy-widget-selection-dialog div {
color: var(--fg-color);
font-family: Arial, Helvetica, sans-serif;
}
.comfy-widget-selection-dialog h2 {
margin-top: 0;
}
.comfy-widget-selection-dialog section {
width: fit-content;
display: flex;
flex-direction: column;
}
.comfy-widget-selection-item {
display: flex;
gap: 10px;
align-items: center;
}
.comfy-widget-selection-item span {
margin-right: auto;
}
.comfy-widget-selection-item span::before {
content: '#' attr(data-id);
opacity: 0.5;
margin-right: 5px;
}
.comfy-modal .comfy-widget-selection-item button {
font-size: 1em;
}
/***** Responsive *****/
.lg.comfyui-menu .lt-lg-show {
display: none !important;
}
.comfyui-menu:not(.lg) .nlg-hide {
display: none !important;
}
/** Large screen */
.lg.comfyui-menu>.comfyui-menu-mobile-collapse .comfyui-button span,
.lg.comfyui-menu>.comfyui-menu-mobile-collapse.comfyui-button span {
display: none;
}
.lg.comfyui-menu>.comfyui-menu-mobile-collapse .comfyui-popup .comfyui-button span {
display: unset;
}
/** Non large screen */
.lt-lg.comfyui-menu {
flex-wrap: wrap;
}
.lt-lg.comfyui-menu > *:not(.comfyui-menu-mobile-collapse) {
order: 1;
}
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse {
order: 9999;
width: 100%;
}
.comfyui-body-bottom .lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse {
order: -1;
}
.comfyui-body-bottom .lt-lg.comfyui-menu > .comfyui-menu-button {
top: unset;
bottom: 4px;
}
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse.comfyui-button-group {
flex-wrap: wrap;
}
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse .comfyui-button,
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse.comfyui-button {
padding: 10px;
}
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse .comfyui-button,
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse .comfyui-button-wrapper {
width: 100%;
}
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse .comfyui-popup {
position: static;
background-color: var(--comfy-input-bg);
max-width: unset;
max-height: 50vh;
overflow: auto;
}
.lt-lg.comfyui-menu:not(.expanded) > .comfyui-menu-mobile-collapse {
display: none;
}
.lt-lg .comfyui-queue-button {
margin-right: 44px;
}
.lt-lg .comfyui-menu-button {
position: absolute;
top: 4px;
right: 8px;
}
.lt-lg.comfyui-menu > .comfyui-menu-mobile-collapse .comfyui-view-list-popup {
border-radius: 0;
}
.lt-lg.comfyui-menu .comfyui-workflows-popup {
width: 100vw;
}
/** Small */
.lt-md .comfyui-workflows-button-inner {
width: unset !important;
}
.lt-md .comfyui-workflows-label {
display: none;
}
/** Extra small */
.lt-sm .comfyui-queue-button {
margin-right: 0;
width: 100%;
}
.lt-sm .comfyui-queue-button .comfyui-button {
justify-content: center;
}
.lt-sm .comfyui-interrupt-button {
margin-right: 45px;
}
.comfyui-body-bottom .lt-sm.comfyui-menu > .comfyui-menu-button{
bottom: 41px;
}

View File

@@ -1,93 +1,2 @@
// @ts-check
import { ComfyButton } from "../components/button.js";
import { $el } from "../../ui.js";
import { api } from "../../api.js";
import { ComfySplitButton } from "../components/splitButton.js";
import { ComfyQueueOptions } from "./queueOptions.js";
import { prop } from "../../utils.js";
export class ComfyQueueButton {
element = $el("div.comfyui-queue-button");
#internalQueueSize = 0;
queuePrompt = async (e) => {
this.#internalQueueSize += this.queueOptions.batchCount;
// Hold shift to queue front, event is undefined when auto-queue is enabled
await this.app.queuePrompt(e?.shiftKey ? -1 : 0, this.queueOptions.batchCount);
};
constructor(app) {
this.app = app;
this.queueSizeElement = $el("span.comfyui-queue-count", {
textContent: "?",
});
const queue = new ComfyButton({
content: $el("div", [
$el("span", {
textContent: "Queue",
}),
this.queueSizeElement,
]),
icon: "play",
classList: "comfyui-button",
action: this.queuePrompt,
});
this.queueOptions = new ComfyQueueOptions(app);
const btn = new ComfySplitButton(
{
primary: queue,
mode: "click",
position: "absolute",
horizontal: "right",
},
this.queueOptions.element
);
btn.element.classList.add("primary");
this.element.append(btn.element);
this.autoQueueMode = prop(this, "autoQueueMode", "", () => {
switch (this.autoQueueMode) {
case "instant":
queue.icon = "infinity";
break;
case "change":
queue.icon = "auto-mode";
break;
default:
queue.icon = "play";
break;
}
});
this.queueOptions.addEventListener("autoQueueMode", (e) => (this.autoQueueMode = e["detail"]));
api.addEventListener("graphChanged", () => {
if (this.autoQueueMode === "change") {
if (this.#internalQueueSize) {
this.graphHasChanged = true;
} else {
this.graphHasChanged = false;
this.queuePrompt();
}
}
});
api.addEventListener("status", ({ detail }) => {
this.#internalQueueSize = detail?.exec_info?.queue_remaining;
if (this.#internalQueueSize != null) {
this.queueSizeElement.textContent = this.#internalQueueSize > 99 ? "99+" : this.#internalQueueSize + "";
this.queueSizeElement.title = `${this.#internalQueueSize} prompts in queue`;
if (!this.#internalQueueSize && !app.lastExecutionError) {
if (this.autoQueueMode === "instant" || (this.autoQueueMode === "change" && this.graphHasChanged)) {
this.graphHasChanged = false;
this.queuePrompt();
}
}
}
});
}
}
// Shim for scripts\ui\menu\queueButton.ts
export const ComfyQueueButton = window.comfyAPI.queueButton.ComfyQueueButton;

View File

@@ -1,77 +1,2 @@
// @ts-check
import { $el } from "../../ui.js";
import { prop } from "../../utils.js";
export class ComfyQueueOptions extends EventTarget {
element = $el("div.comfyui-queue-options");
constructor(app) {
super();
this.app = app;
this.batchCountInput = $el("input", {
className: "comfyui-queue-batch-value",
type: "number",
min: "1",
value: "1",
oninput: () => (this.batchCount = +this.batchCountInput.value),
});
this.batchCountRange = $el("input", {
type: "range",
min: "1",
max: "100",
value: "1",
oninput: () => (this.batchCount = +this.batchCountRange.value),
});
this.element.append(
$el("div.comfyui-queue-batch", [
$el(
"label",
{
textContent: "Batch count: ",
},
this.batchCountInput
),
this.batchCountRange,
])
);
const createOption = (text, value, checked = false) =>
$el(
"label",
{ textContent: text },
$el("input", {
type: "radio",
name: "AutoQueueMode",
checked,
value,
oninput: (e) => (this.autoQueueMode = e.target["value"]),
})
);
this.autoQueueEl = $el("div.comfyui-queue-mode", [
$el("span", "Auto Queue:"),
createOption("Disabled", "", true),
createOption("Instant", "instant"),
createOption("On Change", "change"),
]);
this.element.append(this.autoQueueEl);
this.batchCount = prop(this, "batchCount", 1, () => {
this.batchCountInput.value = this.batchCount + "";
this.batchCountRange.value = this.batchCount + "";
});
this.autoQueueMode = prop(this, "autoQueueMode", "Disabled", () => {
this.dispatchEvent(
new CustomEvent("autoQueueMode", {
detail: this.autoQueueMode,
})
);
});
}
}
// Shim for scripts\ui\menu\queueOptions.ts
export const ComfyQueueOptions = window.comfyAPI.queueOptions.ComfyQueueOptions;

View File

@@ -1,27 +0,0 @@
// @ts-check
import { ComfyButton } from "../components/button.js";
import { ComfyViewList, ComfyViewListButton } from "./viewList.js";
export class ComfyViewHistoryButton extends ComfyViewListButton {
constructor(app) {
super(app, {
button: new ComfyButton({
content: "View History",
icon: "history",
tooltip: "View history",
classList: "comfyui-button comfyui-history-button",
}),
list: ComfyViewHistoryList,
mode: "History",
});
}
}
export class ComfyViewHistoryList extends ComfyViewList {
async loadItems() {
const items = await super.loadItems();
items["History"].reverse();
return items;
}
}

View File

@@ -1,203 +0,0 @@
// @ts-check
import { ComfyButton } from "../components/button.js";
import { $el } from "../../ui.js";
import { api } from "../../api.js";
import { ComfyPopup } from "../components/popup.js";
export class ComfyViewListButton {
get open() {
return this.popup.open;
}
set open(open) {
this.popup.open = open;
}
constructor(app, { button, list, mode }) {
this.app = app;
this.button = button;
this.element = $el("div.comfyui-button-wrapper", this.button.element);
this.popup = new ComfyPopup({
target: this.element,
container: this.element,
horizontal: "right",
});
this.list = new (list ?? ComfyViewList)(app, mode, this.popup);
this.popup.children = [this.list.element];
this.popup.addEventListener("open", () => {
this.list.update();
});
this.popup.addEventListener("close", () => {
this.list.close();
});
this.button.withPopup(this.popup);
api.addEventListener("status", () => {
if (this.popup.open) {
this.popup.update();
}
});
}
}
export class ComfyViewList {
popup;
constructor(app, mode, popup) {
this.app = app;
this.mode = mode;
this.popup = popup;
this.type = mode.toLowerCase();
this.items = $el(`div.comfyui-${this.type}-items.comfyui-view-list-items`);
this.clear = new ComfyButton({
icon: "cancel",
content: "Clear",
action: async () => {
this.showSpinner(false);
await api.clearItems(this.type);
await this.update();
},
});
this.refresh = new ComfyButton({
icon: "refresh",
content: "Refresh",
action: async () => {
await this.update(false);
},
});
this.element = $el(`div.comfyui-${this.type}-popup.comfyui-view-list-popup`, [
$el("h3", mode),
$el("header", [this.clear.element, this.refresh.element]),
this.items,
]);
api.addEventListener("status", () => {
if (this.popup.open) {
this.update();
}
});
}
async close() {
this.items.replaceChildren();
}
async update(resize = true) {
this.showSpinner(resize);
const res = await this.loadItems();
let any = false;
const names = Object.keys(res);
const sections = names
.map((section) => {
const items = res[section];
if (items?.length) {
any = true;
} else {
return;
}
const rows = [];
if (names.length > 1) {
rows.push($el("h5", section));
}
rows.push(...items.flatMap((item) => this.createRow(item, section)));
return $el("section", rows);
})
.filter(Boolean);
if (any) {
this.items.replaceChildren(...sections);
} else {
this.items.replaceChildren($el("h5", "None"));
}
this.popup.update();
this.clear.enabled = this.refresh.enabled = true;
this.element.style.removeProperty("height");
}
showSpinner(resize = true) {
// if (!this.spinner) {
// this.spinner = createSpinner();
// }
// if (!resize) {
// this.element.style.height = this.element.clientHeight + "px";
// }
// this.clear.enabled = this.refresh.enabled = false;
// this.items.replaceChildren(
// $el(
// "div",
// {
// style: {
// fontSize: "18px",
// },
// },
// this.spinner
// )
// );
// this.popup.update();
}
async loadItems() {
return await api.getItems(this.type);
}
getRow(item, section) {
return {
text: item.prompt[0] + "",
actions: [
{
text: "Load",
action: async () => {
try {
await this.app.loadGraphData(item.prompt[3].extra_pnginfo.workflow);
if (item.outputs) {
this.app.nodeOutputs = item.outputs;
}
} catch (error) {
alert("Error loading workflow: " + error.message);
console.error(error);
}
},
},
{
text: "Delete",
action: async () => {
try {
await api.deleteItem(this.type, item.prompt[1]);
this.update();
} catch (error) {}
},
},
],
};
}
createRow = (item, section) => {
const row = this.getRow(item, section);
return [
$el("span", row.text),
...row.actions.map(
(a) =>
new ComfyButton({
content: a.text,
action: async (e, btn) => {
btn.enabled = false;
try {
await a.action();
} catch (error) {
throw error;
} finally {
btn.enabled = true;
}
},
}).element
),
];
};
}

View File

@@ -1,55 +0,0 @@
// @ts-check
import { ComfyButton } from "../components/button.js";
import { ComfyViewList, ComfyViewListButton } from "./viewList.js";
import { api } from "../../api.js";
export class ComfyViewQueueButton extends ComfyViewListButton {
constructor(app) {
super(app, {
button: new ComfyButton({
content: "View Queue",
icon: "format-list-numbered",
tooltip: "View queue",
classList: "comfyui-button comfyui-queue-button",
}),
list: ComfyViewQueueList,
mode: "Queue",
});
}
}
export class ComfyViewQueueList extends ComfyViewList {
getRow = (item, section) => {
if (section !== "Running") {
return super.getRow(item, section);
}
return {
text: item.prompt[0] + "",
actions: [
{
text: "Load",
action: async () => {
try {
await this.app.loadGraphData(item.prompt[3].extra_pnginfo.workflow);
if (item.outputs) {
this.app.nodeOutputs = item.outputs;
}
} catch (error) {
alert("Error loading workflow: " + error.message);
console.error(error);
}
},
},
{
text: "Cancel",
action: async () => {
try {
await api.interrupt();
} catch (error) {}
},
},
],
};
}
}

View File

@@ -1,770 +1,3 @@
// @ts-check
import { ComfyButton } from "../components/button.js";
import { prop, getStorageValue, setStorageValue } from "../../utils.js";
import { $el } from "../../ui.js";
import { api } from "../../api.js";
import { ComfyPopup } from "../components/popup.js";
import { createSpinner } from "../spinner.js";
import { ComfyWorkflow, trimJsonExt } from "../../workflows.js";
import { ComfyAsyncDialog } from "../components/asyncDialog.js";
export class ComfyWorkflowsMenu {
#first = true;
element = $el("div.comfyui-workflows");
get open() {
return this.popup.open;
}
set open(open) {
this.popup.open = open;
}
/**
* @param {import("../../app.js").ComfyApp} app
*/
constructor(app) {
this.app = app;
this.#bindEvents();
const classList = {
"comfyui-workflows-button": true,
"comfyui-button": true,
unsaved: getStorageValue("Comfy.PreviousWorkflowUnsaved") === "true",
running: false,
};
this.buttonProgress = $el("div.comfyui-workflows-button-progress");
this.workflowLabel = $el("span.comfyui-workflows-label", "");
this.button = new ComfyButton({
content: $el("div.comfyui-workflows-button-inner", [$el("i.mdi.mdi-graph"), this.workflowLabel, this.buttonProgress]),
icon: "chevron-down",
classList,
});
this.element.append(this.button.element);
this.popup = new ComfyPopup({ target: this.element, classList: "comfyui-workflows-popup" });
this.content = new ComfyWorkflowsContent(app, this.popup);
this.popup.children = [this.content.element];
this.popup.addEventListener("change", () => {
this.button.icon = "chevron-" + (this.popup.open ? "up" : "down");
});
this.button.withPopup(this.popup);
this.unsaved = prop(this, "unsaved", classList.unsaved, (v) => {
classList.unsaved = v;
this.button.classList = classList;
setStorageValue("Comfy.PreviousWorkflowUnsaved", v);
});
}
#updateProgress = () => {
const prompt = this.app.workflowManager.activePrompt;
let percent = 0;
if (this.app.workflowManager.activeWorkflow === prompt?.workflow) {
const total = Object.values(prompt.nodes);
const done = total.filter(Boolean);
percent = (done.length / total.length) * 100;
}
this.buttonProgress.style.width = percent + "%";
};
#updateActive = () => {
const active = this.app.workflowManager.activeWorkflow;
this.button.tooltip = active.path;
this.workflowLabel.textContent = active.name;
this.unsaved = active.unsaved;
if (this.#first) {
this.#first = false;
this.content.load();
}
this.#updateProgress();
};
#bindEvents() {
this.app.workflowManager.addEventListener("changeWorkflow", this.#updateActive);
this.app.workflowManager.addEventListener("rename", this.#updateActive);
this.app.workflowManager.addEventListener("delete", this.#updateActive);
this.app.workflowManager.addEventListener("save", () => {
this.unsaved = this.app.workflowManager.activeWorkflow.unsaved;
});
this.app.workflowManager.addEventListener("execute", (e) => {
this.#updateProgress();
});
api.addEventListener("graphChanged", () => {
this.unsaved = true;
});
}
#getMenuOptions(callback) {
const menu = [];
const directories = new Map();
for (const workflow of this.app.workflowManager.workflows || []) {
const path = workflow.pathParts;
if (!path) continue;
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: trimJsonExt(path[path.length - 1]),
callback: () => callback(workflow),
});
}
return menu;
}
#getFavoriteMenuOptions(callback) {
const menu = [];
for (const workflow of this.app.workflowManager.workflows || []) {
if (workflow.isFavorite) {
menu.push({
title: "⭐ " + workflow.name,
callback: () => callback(workflow),
});
}
}
return menu;
}
/**
* @param {import("../../app.js").ComfyApp} app
*/
registerExtension(app) {
const self = this;
app.registerExtension({
name: "Comfy.Workflows",
async beforeRegisterNodeDef(nodeType) {
function getImageWidget(node) {
const inputs = { ...node.constructor?.nodeData?.input?.required, ...node.constructor?.nodeData?.input?.optional };
for (const input in inputs) {
if (inputs[input][0] === "IMAGEUPLOAD") {
const imageWidget = node.widgets.find((w) => w.name === (inputs[input]?.[1]?.widget ?? "image"));
if (imageWidget) return imageWidget;
}
}
}
function setWidgetImage(node, widget, img) {
const url = new URL(img.src);
const filename = url.searchParams.get("filename");
const subfolder = url.searchParams.get("subfolder");
const type = url.searchParams.get("type");
const imageId = `${subfolder ? subfolder + "/" : ""}${filename} [${type}]`;
widget.value = imageId;
node.imgs = [img];
app.graph.setDirtyCanvas(true, true);
}
/**
* @param {HTMLImageElement} img
* @param {ComfyWorkflow} workflow
*/
async function sendToWorkflow(img, workflow) {
const openWorkflow = app.workflowManager.openWorkflows.find((w) => w.path === workflow.path);
if (openWorkflow) {
workflow = openWorkflow;
}
await workflow.load();
let options = [];
const nodes = app.graph.computeExecutionOrder(false);
for (const node of nodes) {
const widget = getImageWidget(node);
if (widget == null) continue;
if (node.title?.toLowerCase().includes("input")) {
options = [{ widget, node }];
break;
} else {
options.push({ widget, node });
}
}
if (!options.length) {
alert("No image nodes have been found in this workflow!");
return;
} else if (options.length > 1) {
const dialog = new WidgetSelectionDialog(options);
const res = await dialog.show(app);
if (!res) return;
options = [res];
}
setWidgetImage(options[0].node, options[0].widget, img);
}
const getExtraMenuOptions = nodeType.prototype["getExtraMenuOptions"];
nodeType.prototype["getExtraMenuOptions"] = function (_, options) {
const r = getExtraMenuOptions?.apply?.(this, arguments);
const setting = app.ui.settings.getSettingValue("Comfy.UseNewMenu", false);
if (setting && setting != "Disabled") {
const t = /** @type { {imageIndex?: number, overIndex?: number, imgs: string[]} } */ /** @type {any} */ (this);
let img;
if (t.imageIndex != null) {
// An image is selected so select that
img = t.imgs?.[t.imageIndex];
} else if (t.overIndex != null) {
// No image is selected but one is hovered
img = t.img?.s[t.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, app.workflowManager.activeWorkflow),
title: "[Current workflow]",
},
...self.#getFavoriteMenuOptions(sendToWorkflow.bind(null, img)),
null,
...self.#getMenuOptions(sendToWorkflow.bind(null, img)),
],
},
});
}
}
return r;
};
},
});
}
}
export class ComfyWorkflowsContent {
element = $el("div.comfyui-workflows-panel");
treeState = {};
treeFiles = {};
/** @type { Map<ComfyWorkflow, WorkflowElement> } */
openFiles = new Map();
/** @type {WorkflowElement} */
activeElement = null;
/**
* @param {import("../../app.js").ComfyApp} app
* @param {ComfyPopup} popup
*/
constructor(app, popup) {
this.app = app;
this.popup = popup;
this.actions = $el("div.comfyui-workflows-actions", [
new ComfyButton({
content: "Default",
icon: "file-code",
iconSize: 18,
classList: "comfyui-button primary",
tooltip: "Load default workflow",
action: () => {
popup.open = false;
app.loadGraphData();
app.resetView();
},
}).element,
new ComfyButton({
content: "Browse",
icon: "folder",
iconSize: 18,
tooltip: "Browse for an image or exported workflow",
action: () => {
popup.open = false;
app.ui.loadFile();
},
}).element,
new ComfyButton({
content: "Blank",
icon: "plus-thick",
iconSize: 18,
tooltip: "Create a new blank workflow",
action: () => {
app.workflowManager.setWorkflow(null);
app.clean();
app.graph.clear();
app.workflowManager.activeWorkflow.track();
popup.open = false;
},
}).element,
]);
this.spinner = createSpinner();
this.element.replaceChildren(this.actions, this.spinner);
this.popup.addEventListener("open", () => this.load());
this.popup.addEventListener("close", () => this.element.replaceChildren(this.actions, this.spinner));
this.app.workflowManager.addEventListener("favorite", (e) => {
const workflow = e["detail"];
const button = this.treeFiles[workflow.path]?.primary;
if (!button) return; // Can happen when a workflow is renamed
button.icon = this.#getFavoriteIcon(workflow);
button.overIcon = this.#getFavoriteOverIcon(workflow);
this.updateFavorites();
});
for (const e of ["save", "open", "close", "changeWorkflow"]) {
// TODO: dont be lazy and just update the specific element
app.workflowManager.addEventListener(e, () => this.updateOpen());
}
this.app.workflowManager.addEventListener("rename", () => this.load());
this.app.workflowManager.addEventListener("execute", (e) => this.#updateActive());
}
async load() {
await this.app.workflowManager.loadWorkflows();
this.updateTree();
this.updateFavorites();
this.updateOpen();
this.element.replaceChildren(this.actions, this.openElement, this.favoritesElement, this.treeElement);
}
updateOpen() {
const current = this.openElement;
this.openFiles.clear();
this.openElement = $el("div.comfyui-workflows-open", [
$el("h3", "Open"),
...this.app.workflowManager.openWorkflows.map((w) => {
const wrapper = new WorkflowElement(this, w, {
primary: { element: $el("i.mdi.mdi-18px.mdi-progress-pencil") },
buttons: [
this.#getRenameButton(w),
new ComfyButton({
icon: "close",
iconSize: 18,
classList: "comfyui-button comfyui-workflows-file-action",
tooltip: "Close workflow",
action: (e) => {
e.stopImmediatePropagation();
this.app.workflowManager.closeWorkflow(w);
},
}),
],
});
if (w.unsaved) {
wrapper.element.classList.add("unsaved");
}
if(w === this.app.workflowManager.activeWorkflow) {
wrapper.element.classList.add("active");
}
this.openFiles.set(w, wrapper);
return wrapper.element;
}),
]);
this.#updateActive();
current?.replaceWith(this.openElement);
}
updateFavorites() {
const current = this.favoritesElement;
const favorites = [...this.app.workflowManager.workflows.filter((w) => w.isFavorite)];
this.favoritesElement = $el("div.comfyui-workflows-favorites", [
$el("h3", "Favorites"),
...favorites
.map((w) => {
return this.#getWorkflowElement(w).element;
})
.filter(Boolean),
]);
current?.replaceWith(this.favoritesElement);
}
filterTree() {
if (!this.filterText) {
this.treeRoot.classList.remove("filtered");
// Unfilter whole tree
for (const item of Object.values(this.treeFiles)) {
item.element.parentElement.style.removeProperty("display");
this.showTreeParents(item.element.parentElement);
}
return;
}
this.treeRoot.classList.add("filtered");
const searchTerms = this.filterText.toLocaleLowerCase().split(" ");
for (const item of Object.values(this.treeFiles)) {
const parts = item.workflow.pathParts;
let termIndex = 0;
let valid = false;
for (const part of parts) {
let currentIndex = 0;
do {
currentIndex = part.indexOf(searchTerms[termIndex], currentIndex);
if (currentIndex > -1) currentIndex += searchTerms[termIndex].length;
} while (currentIndex !== -1 && ++termIndex < searchTerms.length);
if (termIndex >= searchTerms.length) {
valid = true;
break;
}
}
if (valid) {
item.element.parentElement.style.removeProperty("display");
this.showTreeParents(item.element.parentElement);
} else {
item.element.parentElement.style.display = "none";
this.hideTreeParents(item.element.parentElement);
}
}
}
hideTreeParents(element) {
// Hide all parents if no children are visible
if (element.parentElement?.classList.contains("comfyui-workflows-tree") === false) {
for (let i = 1; i < element.parentElement.children.length; i++) {
const c = element.parentElement.children[i];
if (c.style.display !== "none") {
return;
}
}
element.parentElement.style.display = "none";
this.hideTreeParents(element.parentElement);
}
}
showTreeParents(element) {
if (element.parentElement?.classList.contains("comfyui-workflows-tree") === false) {
element.parentElement.style.removeProperty("display");
this.showTreeParents(element.parentElement);
}
}
updateTree() {
const current = this.treeElement;
const nodes = {};
let typingTimeout;
this.treeFiles = {};
this.treeRoot = $el("ul.comfyui-workflows-tree");
this.treeElement = $el("section", [
$el("header", [
$el("h3", "Browse"),
$el("div.comfy-ui-workflows-search", [
$el("i.mdi.mdi-18px.mdi-magnify"),
$el("input", {
placeholder: "Search",
value: this.filterText ?? "",
oninput: (e) => {
this.filterText = e.target["value"]?.trim();
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => this.filterTree(), 250);
},
}),
]),
]),
this.treeRoot,
]);
for (const workflow of this.app.workflowManager.workflows) {
if (!workflow.pathParts) continue;
let currentPath = "";
let currentRoot = this.treeRoot;
for (let i = 0; i < workflow.pathParts.length; i++) {
currentPath += (currentPath ? "\\" : "") + workflow.pathParts[i];
const parentNode = nodes[currentPath] ?? this.#createNode(currentPath, workflow, i, currentRoot);
nodes[currentPath] = parentNode;
currentRoot = parentNode;
}
}
current?.replaceWith(this.treeElement);
this.filterTree();
}
#expandNode(el, workflow, thisPath, i) {
const expanded = !el.classList.toggle("closed");
if (expanded) {
let c = "";
for (let j = 0; j <= i; j++) {
c += (c ? "\\" : "") + workflow.pathParts[j];
this.treeState[c] = true;
}
} else {
let c = thisPath;
for (let j = i + 1; j < workflow.pathParts.length; j++) {
c += (c ? "\\" : "") + workflow.pathParts[j];
delete this.treeState[c];
}
delete this.treeState[thisPath];
}
}
#updateActive() {
this.#removeActive();
const active = this.app.workflowManager.activePrompt;
if (!active?.workflow) return;
const open = this.openFiles.get(active.workflow);
if (!open) return;
this.activeElement = open;
const total = Object.values(active.nodes);
const done = total.filter(Boolean);
const percent = done.length / total.length;
open.element.classList.add("running");
open.element.style.setProperty("--progress", percent * 100 + "%");
open.primary.element.classList.remove("mdi-progress-pencil");
open.primary.element.classList.add("mdi-play");
}
#removeActive() {
if (!this.activeElement) return;
this.activeElement.element.classList.remove("running");
this.activeElement.element.style.removeProperty("--progress");
this.activeElement.primary.element.classList.add("mdi-progress-pencil");
this.activeElement.primary.element.classList.remove("mdi-play");
}
/** @param {ComfyWorkflow} workflow */
#getFavoriteIcon(workflow) {
return workflow.isFavorite ? "star" : "file-outline";
}
/** @param {ComfyWorkflow} workflow */
#getFavoriteOverIcon(workflow) {
return workflow.isFavorite ? "star-off" : "star-outline";
}
/** @param {ComfyWorkflow} workflow */
#getFavoriteTooltip(workflow) {
return workflow.isFavorite ? "Remove this workflow from your favorites" : "Add this workflow to your favorites";
}
/** @param {ComfyWorkflow} workflow */
#getFavoriteButton(workflow, primary) {
return new ComfyButton({
icon: this.#getFavoriteIcon(workflow),
overIcon: this.#getFavoriteOverIcon(workflow),
iconSize: 18,
classList: "comfyui-button comfyui-workflows-file-action-favorite" + (primary ? " comfyui-workflows-file-action-primary" : ""),
tooltip: this.#getFavoriteTooltip(workflow),
action: (e) => {
e.stopImmediatePropagation();
workflow.favorite(!workflow.isFavorite);
},
});
}
/** @param {ComfyWorkflow} workflow */
#getDeleteButton(workflow) {
const deleteButton = new ComfyButton({
icon: "delete",
tooltip: "Delete this workflow",
classList: "comfyui-button comfyui-workflows-file-action",
iconSize: 18,
action: async (e, btn) => {
e.stopImmediatePropagation();
if (btn.icon === "delete-empty") {
btn.enabled = false;
await workflow.delete();
await this.load();
} else {
btn.icon = "delete-empty";
btn.element.style.background = "red";
}
},
});
deleteButton.element.addEventListener("mouseleave", () => {
deleteButton.icon = "delete";
deleteButton.element.style.removeProperty("background");
});
return deleteButton;
}
/** @param {ComfyWorkflow} workflow */
#getInsertButton(workflow) {
return new ComfyButton({
icon: "file-move-outline",
iconSize: 18,
tooltip: "Insert this workflow into the current workflow",
classList: "comfyui-button comfyui-workflows-file-action",
action: (e) => {
if (!this.app.shiftDown) {
this.popup.open = false;
}
e.stopImmediatePropagation();
if (!this.app.shiftDown) {
this.popup.open = false;
}
workflow.insert();
},
});
}
/** @param {ComfyWorkflow} workflow */
#getRenameButton(workflow) {
return new ComfyButton({
icon: "pencil",
tooltip: workflow.path ? "Rename this workflow" : "This workflow can't be renamed as it hasn't been saved.",
classList: "comfyui-button comfyui-workflows-file-action",
iconSize: 18,
enabled: !!workflow.path,
action: async (e) => {
e.stopImmediatePropagation();
const newName = prompt("Enter new name", workflow.path);
if (newName) {
await workflow.rename(newName);
}
},
});
}
/** @param {ComfyWorkflow} workflow */
#getWorkflowElement(workflow) {
return new WorkflowElement(this, workflow, {
primary: this.#getFavoriteButton(workflow, true),
buttons: [this.#getInsertButton(workflow), this.#getRenameButton(workflow), this.#getDeleteButton(workflow)],
});
}
/** @param {ComfyWorkflow} workflow */
#createLeafNode(workflow) {
const fileNode = this.#getWorkflowElement(workflow);
this.treeFiles[workflow.path] = fileNode;
return fileNode;
}
#createNode(currentPath, workflow, i, currentRoot) {
const part = workflow.pathParts[i];
const parentNode = $el("ul" + (this.treeState[currentPath] ? "" : ".closed"), {
$: (el) => {
el.onclick = (e) => {
this.#expandNode(el, workflow, currentPath, i);
e.stopImmediatePropagation();
};
},
});
currentRoot.append(parentNode);
// Create a node for the current part and an inner UL for its children if it isnt a leaf node
const leaf = i === workflow.pathParts.length - 1;
let nodeElement;
if (leaf) {
nodeElement = this.#createLeafNode(workflow).element;
} else {
nodeElement = $el("li", [$el("i.mdi.mdi-18px.mdi-folder"), $el("span", part)]);
}
parentNode.append(nodeElement);
return parentNode;
}
}
class WorkflowElement {
/**
* @param { ComfyWorkflowsContent } parent
* @param { ComfyWorkflow } workflow
*/
constructor(parent, workflow, { tagName = "li", primary, buttons }) {
this.parent = parent;
this.workflow = workflow;
this.primary = primary;
this.buttons = buttons;
this.element = $el(
tagName + ".comfyui-workflows-tree-file",
{
onclick: () => {
workflow.load();
this.parent.popup.open = false;
},
title: this.workflow.path,
},
[this.primary?.element, $el("span", workflow.name), ...buttons.map((b) => b.element)]
);
}
}
class WidgetSelectionDialog extends ComfyAsyncDialog {
#options;
/**
* @param {Array<{widget: {name: string}, node: {pos: [number, number], title: string, id: string, type: string}}>} options
*/
constructor(options) {
super();
this.#options = options;
}
show(app) {
this.element.classList.add("comfy-widget-selection-dialog");
return super.show(
$el("div", [
$el("h2", "Select image target"),
$el(
"p",
"This workflow has multiple image loader nodes, you can rename a node to include 'input' in the title for it to be automatically selected, or select one below."
),
$el(
"section",
this.#options.map((opt) => {
return $el("div.comfy-widget-selection-item", [
$el("span", { dataset: { id: opt.node.id } }, `${opt.node.title ?? opt.node.type} ${opt.widget.name}`),
$el(
"button.comfyui-button",
{
onclick: () => {
app.canvas.ds.offset[0] = -opt.node.pos[0] + 50;
app.canvas.ds.offset[1] = -opt.node.pos[1] + 50;
app.canvas.selectNode(opt.node);
app.graph.setDirtyCanvas(true, true);
},
},
"Show"
),
$el(
"button.comfyui-button.primary",
{
onclick: () => {
this.close(opt);
},
},
"Select"
),
]);
})
),
])
);
}
}
// Shim for scripts\ui\menu\workflows.ts
export const ComfyWorkflowsMenu = window.comfyAPI.workflows.ComfyWorkflowsMenu;
export const ComfyWorkflowsContent = window.comfyAPI.workflows.ComfyWorkflowsContent;

View File

@@ -1,333 +1,2 @@
import { $el } from "../ui.js";
import { api } from "../api.js";
import { ComfyDialog } from "./dialog.js";
export class ComfySettingsDialog extends ComfyDialog {
constructor(app) {
super();
this.app = app;
this.settingsValues = {};
this.settingsLookup = {};
this.element = $el(
"dialog",
{
id: "comfy-settings-dialog",
parent: document.body,
},
[
$el("table.comfy-modal-content.comfy-table", [
$el(
"caption",
{ textContent: "Settings" },
$el("button.comfy-btn", {
type: "button",
textContent: "\u00d7",
onclick: () => {
this.element.close();
},
})
),
$el("tbody", { $: (tbody) => (this.textElement = tbody) }),
$el("button", {
type: "button",
textContent: "Close",
style: {
cursor: "pointer",
},
onclick: () => {
this.element.close();
},
}),
]),
]
);
}
get settings() {
return Object.values(this.settingsLookup);
}
#dispatchChange(id, value, oldValue) {
this.dispatchEvent(
new CustomEvent(id + ".change", {
detail: {
value,
oldValue
},
})
);
}
async load() {
if (this.app.storageLocation === "browser") {
this.settingsValues = localStorage;
} else {
this.settingsValues = await api.getSettings();
}
// Trigger onChange for any settings added before load
for (const id in this.settingsLookup) {
const value = this.settingsValues[this.getId(id)];
this.settingsLookup[id].onChange?.(value);
this.#dispatchChange(id, value);
}
}
getId(id) {
if (this.app.storageLocation === "browser") {
id = "Comfy.Settings." + id;
}
return id;
}
getSettingValue(id, defaultValue) {
let value = this.settingsValues[this.getId(id)];
if(value != null) {
if(this.app.storageLocation === "browser") {
try {
value = JSON.parse(value);
} catch (error) {
}
}
}
return value ?? defaultValue;
}
async setSettingValueAsync(id, value) {
const json = JSON.stringify(value);
localStorage["Comfy.Settings." + id] = json; // backwards compatibility for extensions keep setting in storage
let oldValue = this.getSettingValue(id, undefined);
this.settingsValues[this.getId(id)] = value;
if (id in this.settingsLookup) {
this.settingsLookup[id].onChange?.(value, oldValue);
}
this.#dispatchChange(id, value, oldValue);
await api.storeSetting(id, value);
}
setSettingValue(id, value) {
this.setSettingValueAsync(id, value).catch((err) => {
alert(`Error saving setting '${id}'`);
console.error(err);
});
}
addSetting({ id, name, type, defaultValue, onChange, attrs = {}, tooltip = "", options = undefined }) {
if (!id) {
throw new Error("Settings must have an ID");
}
if (id in this.settingsLookup) {
throw new Error(`Setting ${id} of type ${type} must have a unique ID.`);
}
let skipOnChange = false;
let value = this.getSettingValue(id);
if (value == null) {
if (this.app.isNewUserSession) {
// Check if we have a localStorage value but not a setting value and we are a new user
const localValue = localStorage["Comfy.Settings." + id];
if (localValue) {
value = JSON.parse(localValue);
this.setSettingValue(id, value); // Store on the server
}
}
if (value == null) {
value = defaultValue;
}
}
// Trigger initial setting of value
if (!skipOnChange) {
onChange?.(value, undefined);
}
this.settingsLookup[id] = {
id,
onChange,
name,
render: () => {
if (type === "hidden") return;
const setter = (v) => {
if (onChange) {
onChange(v, value);
}
this.setSettingValue(id, v);
value = v;
};
value = this.getSettingValue(id, defaultValue);
let element;
const htmlID = id.replaceAll(".", "-");
const labelCell = $el("td", [
$el("label", {
for: htmlID,
classList: [tooltip !== "" ? "comfy-tooltip-indicator" : ""],
textContent: name,
}),
]);
if (typeof type === "function") {
element = type(name, setter, value, attrs);
} else {
switch (type) {
case "boolean":
element = $el("tr", [
labelCell,
$el("td", [
$el("input", {
id: htmlID,
type: "checkbox",
checked: value,
onchange: (event) => {
const isChecked = event.target.checked;
if (onChange !== undefined) {
onChange(isChecked);
}
this.setSettingValue(id, isChecked);
},
}),
]),
]);
break;
case "number":
element = $el("tr", [
labelCell,
$el("td", [
$el("input", {
type,
value,
id: htmlID,
oninput: (e) => {
setter(e.target.value);
},
...attrs,
}),
]),
]);
break;
case "slider":
element = $el("tr", [
labelCell,
$el("td", [
$el(
"div",
{
style: {
display: "grid",
gridAutoFlow: "column",
},
},
[
$el("input", {
...attrs,
value,
type: "range",
oninput: (e) => {
setter(e.target.value);
e.target.nextElementSibling.value = e.target.value;
},
}),
$el("input", {
...attrs,
value,
id: htmlID,
type: "number",
style: { maxWidth: "4rem" },
oninput: (e) => {
setter(e.target.value);
e.target.previousElementSibling.value = e.target.value;
},
}),
]
),
]),
]);
break;
case "combo":
element = $el("tr", [
labelCell,
$el("td", [
$el(
"select",
{
oninput: (e) => {
setter(e.target.value);
},
},
(typeof options === "function" ? options(value) : options || []).map((opt) => {
if (typeof opt === "string") {
opt = { text: opt };
}
const v = opt.value ?? opt.text;
return $el("option", {
value: v,
textContent: opt.text,
selected: value + "" === v + "",
});
})
),
]),
]);
break;
case "text":
default:
if (type !== "text") {
console.warn(`Unsupported setting type '${type}, defaulting to text`);
}
element = $el("tr", [
labelCell,
$el("td", [
$el("input", {
value,
id: htmlID,
oninput: (e) => {
setter(e.target.value);
},
...attrs,
}),
]),
]);
break;
}
}
if (tooltip) {
element.title = tooltip;
}
return element;
},
};
const self = this;
return {
get value() {
return self.getSettingValue(id, defaultValue);
},
set value(v) {
self.setSettingValue(id, v);
},
};
}
show() {
this.textElement.replaceChildren(
$el(
"tr",
{
style: { display: "none" },
},
[$el("th"), $el("th", { style: { width: "33%" } })]
),
...this.settings.sort((a, b) => a.name.localeCompare(b.name)).map((s) => s.render()).filter(Boolean)
);
this.element.showModal();
}
}
// Shim for scripts\ui\settings.ts
export const ComfySettingsDialog = window.comfyAPI.settings.ComfySettingsDialog;

View File

@@ -1,34 +0,0 @@
.lds-ring {
display: inline-block;
position: relative;
width: 1em;
height: 1em;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 100%;
height: 100%;
border: 0.15em solid #fff;
border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #fff transparent transparent transparent;
}
.lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -1,9 +1,2 @@
import { addStylesheet } from "../utils.js";
addStylesheet(import.meta.url);
export function createSpinner() {
const div = document.createElement("div");
div.innerHTML = `<div class="lds-ring"><div></div><div></div><div></div><div></div></div>`;
return div.firstElementChild;
}
// Shim for scripts\ui\spinner.ts
export const createSpinner = window.comfyAPI.spinner.createSpinner;

View File

@@ -1,60 +1,2 @@
import { $el } from "../ui.js";
/**
* @typedef { { text: string, value?: string, tooltip?: string } } ToggleSwitchItem
*/
/**
* Creates a toggle switch element
* @param { string } name
* @param { Array<string | ToggleSwitchItem } items
* @param { Object } [opts]
* @param { (e: { item: ToggleSwitchItem, prev?: ToggleSwitchItem }) => void } [opts.onChange]
*/
export function toggleSwitch(name, items, { onChange } = {}) {
let selectedIndex;
let elements;
function updateSelected(index) {
if (selectedIndex != null) {
elements[selectedIndex].classList.remove("comfy-toggle-selected");
}
onChange?.({ item: items[index], prev: selectedIndex == null ? undefined : items[selectedIndex] });
selectedIndex = index;
elements[selectedIndex].classList.add("comfy-toggle-selected");
}
elements = items.map((item, i) => {
if (typeof item === "string") item = { text: item };
if (!item.value) item.value = item.text;
const toggle = $el(
"label",
{
textContent: item.text,
title: item.tooltip ?? "",
},
$el("input", {
name,
type: "radio",
value: item.value ?? item.text,
checked: item.selected,
onchange: () => {
updateSelected(i);
},
})
);
if (item.selected) {
updateSelected(i);
}
return toggle;
});
const container = $el("div.comfy-toggle-switch", elements);
if (selectedIndex == null) {
elements[0].children[0].checked = true;
updateSelected(0);
}
return container;
}
// Shim for scripts\ui\toggleSwitch.ts
export const toggleSwitch = window.comfyAPI.toggleSwitch.toggleSwitch;

View File

@@ -1,135 +0,0 @@
.comfy-user-selection {
width: 100vw;
height: 100vh;
position: absolute;
top: 0;
left: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
font-family: sans-serif;
background: linear-gradient(var(--tr-even-bg-color), var(--tr-odd-bg-color));
}
.comfy-user-selection-inner {
background: var(--comfy-menu-bg);
margin-top: -30vh;
padding: 20px 40px;
border-radius: 10px;
min-width: 365px;
position: relative;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
}
.comfy-user-selection-inner form {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.comfy-user-selection-inner h1 {
margin: 10px 0 30px 0;
font-weight: normal;
}
.comfy-user-selection-inner label {
display: flex;
flex-direction: column;
width: 100%;
}
.comfy-user-selection input,
.comfy-user-selection select {
background-color: var(--comfy-input-bg);
color: var(--input-text);
border: 0;
border-radius: 5px;
padding: 5px;
margin-top: 10px;
}
.comfy-user-selection input::placeholder {
color: var(--descrip-text);
opacity: 1;
}
.comfy-user-existing {
width: 100%;
}
.no-users .comfy-user-existing {
display: none;
}
.comfy-user-selection-inner .or-separator {
margin: 10px 0;
padding: 10px;
display: block;
text-align: center;
width: 100%;
color: var(--descrip-text);
}
.comfy-user-selection-inner .or-separator {
overflow: hidden;
text-align: center;
margin-left: -10px;
}
.comfy-user-selection-inner .or-separator::before,
.comfy-user-selection-inner .or-separator::after {
content: "";
background-color: var(--border-color);
position: relative;
height: 1px;
vertical-align: middle;
display: inline-block;
width: calc(50% - 20px);
top: -1px;
}
.comfy-user-selection-inner .or-separator::before {
right: 10px;
margin-left: -50%;
}
.comfy-user-selection-inner .or-separator::after {
left: 10px;
margin-right: -50%;
}
.comfy-user-selection-inner section {
width: 100%;
padding: 10px;
margin: -10px;
transition: background-color 0.2s;
}
.comfy-user-selection-inner section.selected {
background: var(--border-color);
border-radius: 5px;
}
.comfy-user-selection-inner footer {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 20px;
}
.comfy-user-selection-inner .comfy-user-error {
color: var(--error-text);
margin-bottom: 10px;
}
.comfy-user-button-next {
font-size: 16px;
padding: 6px 10px;
width: 100px;
display: flex;
gap: 5px;
align-items: center;
justify-content: center;
}

View File

@@ -1,114 +1,2 @@
import { api } from "../api.js";
import { $el } from "../ui.js";
import { addStylesheet } from "../utils.js";
import { createSpinner } from "./spinner.js";
export class UserSelectionScreen {
async show(users, user) {
// This will rarely be hit so move the loading to on demand
await addStylesheet(import.meta.url);
const userSelection = document.getElementById("comfy-user-selection");
userSelection.style.display = "";
return new Promise((resolve) => {
const input = userSelection.getElementsByTagName("input")[0];
const select = userSelection.getElementsByTagName("select")[0];
const inputSection = input.closest("section");
const selectSection = select.closest("section");
const form = userSelection.getElementsByTagName("form")[0];
const error = userSelection.getElementsByClassName("comfy-user-error")[0];
const button = userSelection.getElementsByClassName("comfy-user-button-next")[0];
let inputActive = null;
input.addEventListener("focus", () => {
inputSection.classList.add("selected");
selectSection.classList.remove("selected");
inputActive = true;
});
select.addEventListener("focus", () => {
inputSection.classList.remove("selected");
selectSection.classList.add("selected");
inputActive = false;
select.style.color = "";
});
select.addEventListener("blur", () => {
if (!select.value) {
select.style.color = "var(--descrip-text)";
}
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (inputActive == null) {
error.textContent = "Please enter a username or select an existing user.";
} else if (inputActive) {
const username = input.value.trim();
if (!username) {
error.textContent = "Please enter a username.";
return;
}
// Create new user
input.disabled = select.disabled = input.readonly = select.readonly = true;
const spinner = createSpinner();
button.prepend(spinner);
try {
const resp = await api.createUser(username);
if (resp.status >= 300) {
let message = "Error creating user: " + resp.status + " " + resp.statusText;
try {
const res = await resp.json();
if(res.error) {
message = res.error;
}
} catch (error) {
}
throw new Error(message);
}
resolve({ username, userId: await resp.json(), created: true });
} catch (err) {
spinner.remove();
error.textContent = err.message ?? err.statusText ?? err ?? "An unknown error occurred.";
input.disabled = select.disabled = input.readonly = select.readonly = false;
return;
}
} else if (!select.value) {
error.textContent = "Please select an existing user.";
return;
} else {
resolve({ username: users[select.value], userId: select.value, created: false });
}
});
if (user) {
const name = localStorage["Comfy.userName"];
if (name) {
input.value = name;
}
}
if (input.value) {
// Focus the input, do this separately as sometimes browsers like to fill in the value
input.focus();
}
const userIds = Object.keys(users ?? {});
if (userIds.length) {
for (const u of userIds) {
$el("option", { textContent: users[u], value: u, parent: select });
}
select.style.color = "var(--descrip-text)";
if (select.value) {
// Focus the select, do this separately as sometimes browsers like to fill in the value
select.focus();
}
} else {
userSelection.classList.add("no-users");
input.focus();
}
}).then((r) => {
userSelection.remove();
return r;
});
}
}
// Shim for scripts\ui\userSelection.ts
export const UserSelectionScreen = window.comfyAPI.userSelection.UserSelectionScreen;

View File

@@ -1,56 +1,3 @@
/**
* @typedef { string | string[] | Record<string, boolean> } ClassList
*/
/**
* @param { HTMLElement } element
* @param { ClassList } classList
* @param { string[] } requiredClasses
*/
export function applyClasses(element, classList, ...requiredClasses) {
classList ??= "";
let str;
if (typeof classList === "string") {
str = classList;
} else if (classList instanceof Array) {
str = classList.join(" ");
} else {
str = Object.entries(classList).reduce((p, c) => {
if (c[1]) {
p += (p.length ? " " : "") + c[0];
}
return p;
}, "");
}
element.className = str;
if (requiredClasses) {
element.classList.add(...requiredClasses);
}
}
/**
* @param { HTMLElement } element
* @param { { onHide?: (el: HTMLElement) => void, onShow?: (el: HTMLElement, value) => void } } [param1]
* @returns
*/
export function toggleElement(element, { onHide, onShow } = {}) {
let placeholder;
let hidden;
return (value) => {
if (value) {
if (hidden) {
hidden = false;
placeholder.replaceWith(element);
}
onShow?.(element, value);
} else {
if (!placeholder) {
placeholder = document.createComment("");
}
hidden = true;
element.replaceWith(placeholder);
onHide?.(element);
}
};
}
// Shim for scripts\ui\utils.ts
export const applyClasses = window.comfyAPI.utils.applyClasses;
export const toggleElement = window.comfyAPI.utils.toggleElement;

164
web/scripts/utils.js vendored
View File

@@ -1,156 +1,8 @@
import { $el } from "./ui.js";
import { api } from "./api.js";
// Simple date formatter
const parts = {
d: (d) => d.getDate(),
M: (d) => d.getMonth() + 1,
h: (d) => d.getHours(),
m: (d) => d.getMinutes(),
s: (d) => d.getSeconds(),
};
const format =
Object.keys(parts)
.map((k) => k + k + "?")
.join("|") + "|yyy?y?";
function formatDate(text, date) {
return text.replace(new RegExp(format, "g"), function (text) {
if (text === "yy") return (date.getFullYear() + "").substring(2);
if (text === "yyyy") return date.getFullYear();
if (text[0] in parts) {
const p = parts[text[0]](date);
return (p + "").padStart(text.length, "0");
}
return text;
});
}
export function clone(obj) {
try {
if (typeof structuredClone !== "undefined") {
return structuredClone(obj);
}
} catch (error) {
// structuredClone is stricter than using JSON.parse/stringify so fallback to that
}
return JSON.parse(JSON.stringify(obj));
}
export function applyTextReplacements(app, value) {
return value.replace(/%([^%]+)%/g, function (match, text) {
const split = text.split(".");
if (split.length !== 2) {
// Special handling for dates
if (split[0].startsWith("date:")) {
return formatDate(split[0].substring(5), new Date());
}
if (text !== "width" && text !== "height") {
// Dont warn on standard replacements
console.warn("Invalid replacement pattern", text);
}
return match;
}
// Find node with matching S&R property name
let nodes = app.graph._nodes.filter((n) => n.properties?.["Node name for S&R"] === split[0]);
// If we cant, see if there is a node with that title
if (!nodes.length) {
nodes = app.graph._nodes.filter((n) => n.title === split[0]);
}
if (!nodes.length) {
console.warn("Unable to find node", split[0]);
return match;
}
if (nodes.length > 1) {
console.warn("Multiple nodes matched", split[0], "using first match");
}
const node = nodes[0];
const widget = node.widgets?.find((w) => w.name === split[1]);
if (!widget) {
console.warn("Unable to find widget", split[1], "on node", split[0], node);
return match;
}
return ((widget.value ?? "") + "").replaceAll(/\/|\\/g, "_");
});
}
export async function addStylesheet(urlOrFile, relativeTo) {
return new Promise((res, rej) => {
let url;
if (urlOrFile.endsWith(".js")) {
url = urlOrFile.substr(0, urlOrFile.length - 2) + "css";
} else {
url = new URL(urlOrFile, relativeTo ?? `${window.location.protocol}//${window.location.host}`).toString();
}
$el("link", {
parent: document.head,
rel: "stylesheet",
type: "text/css",
href: url,
onload: res,
onerror: rej,
});
});
}
/**
* @param { string } filename
* @param { Blob } blob
*/
export function downloadBlob(filename, blob) {
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);
}
/**
* @template T
* @param {string} name
* @param {T} [defaultValue]
* @param {(currentValue: any, previousValue: any)=>void} [onChanged]
* @returns {T}
*/
export function prop(target, name, defaultValue, onChanged) {
let currentValue;
Object.defineProperty(target, name, {
get() {
return currentValue;
},
set(newValue) {
const prevValue = currentValue;
currentValue = newValue;
onChanged?.(currentValue, prevValue, target, name);
},
});
return defaultValue;
}
export function getStorageValue(id) {
const clientId = api.clientId ?? api.initialClientId;
return (clientId && sessionStorage.getItem(`${id}:${clientId}`)) ?? localStorage.getItem(id);
}
export function setStorageValue(id, value) {
const clientId = api.clientId ?? api.initialClientId;
if (clientId) {
sessionStorage.setItem(`${id}:${clientId}`, value);
}
localStorage.setItem(id, value);
}
// Shim for scripts\utils.ts
export const clone = window.comfyAPI.utils.clone;
export const applyTextReplacements = window.comfyAPI.utils.applyTextReplacements;
export const addStylesheet = window.comfyAPI.utils.addStylesheet;
export const downloadBlob = window.comfyAPI.utils.downloadBlob;
export const prop = window.comfyAPI.utils.prop;
export const getStorageValue = window.comfyAPI.utils.getStorageValue;
export const setStorageValue = window.comfyAPI.utils.setStorageValue;

539
web/scripts/widgets.js vendored
View File

@@ -1,533 +1,6 @@
import { api } from "./api.js"
import "./domWidget.js";
let controlValueRunBefore = false;
export function updateControlWidgetLabel(widget) {
let replacement = "after";
let find = "before";
if (controlValueRunBefore) {
[find, replacement] = [replacement, find]
}
widget.label = (widget.label ?? widget.name).replace(find, replacement);
}
const IS_CONTROL_WIDGET = Symbol();
const HAS_EXECUTED = Symbol();
function getNumberDefaults(inputData, defaultStep, precision, enable_rounding) {
let defaultVal = inputData[1]["default"];
let { min, max, step, round} = inputData[1];
if (defaultVal == undefined) defaultVal = 0;
if (min == undefined) min = 0;
if (max == undefined) max = 2048;
if (step == undefined) step = defaultStep;
// precision is the number of decimal places to show.
// by default, display the the smallest number of decimal places such that changes of size step are visible.
if (precision == undefined) {
precision = Math.max(-Math.floor(Math.log10(step)),0);
}
if (enable_rounding && (round == undefined || round === true)) {
// by default, round the value to those decimal places shown.
round = Math.round(1000000*Math.pow(0.1,precision))/1000000;
}
return { val: defaultVal, config: { min, max, step: 10.0 * step, round, precision } };
}
export function addValueControlWidget(node, targetWidget, defaultValue = "randomize", values, widgetName, inputData) {
let name = inputData[1]?.control_after_generate;
if(typeof name !== "string") {
name = widgetName;
}
const widgets = addValueControlWidgets(node, targetWidget, defaultValue, {
addFilterList: false,
controlAfterGenerateName: name
}, inputData);
return widgets[0];
}
export function addValueControlWidgets(node, targetWidget, defaultValue = "randomize", options, inputData) {
if (!defaultValue) defaultValue = "randomize";
if (!options) options = {};
const getName = (defaultName, optionName) => {
let name = defaultName;
if (options[optionName]) {
name = options[optionName];
} else if (typeof inputData?.[1]?.[defaultName] === "string") {
name = inputData?.[1]?.[defaultName];
} else if (inputData?.[1]?.control_prefix) {
name = inputData?.[1]?.control_prefix + " " + name
}
return name;
}
const widgets = [];
const valueControl = node.addWidget(
"combo",
getName("control_after_generate", "controlAfterGenerateName"),
defaultValue,
function () {},
{
values: ["fixed", "increment", "decrement", "randomize"],
serialize: false, // Don't include this in prompt.
}
);
valueControl.tooltip = "Allows the linked widget to be changed automatically, for example randomizing the noise seed.";
valueControl[IS_CONTROL_WIDGET] = true;
updateControlWidgetLabel(valueControl);
widgets.push(valueControl);
const isCombo = targetWidget.type === "combo";
let comboFilter;
if (isCombo) {
valueControl.options.values.push("increment-wrap");
}
if (isCombo && options.addFilterList !== false) {
comboFilter = node.addWidget(
"string",
getName("control_filter_list", "controlFilterListName"),
"",
function () {},
{
serialize: false, // Don't include this in prompt.
}
);
updateControlWidgetLabel(comboFilter);
comboFilter.tooltip = "Allows for filtering the list of values when changing the value via the control generate mode. Allows for RegEx matches in the format /abc/ to only filter to values containing 'abc'."
widgets.push(comboFilter);
}
const applyWidgetControl = () => {
var v = valueControl.value;
if (isCombo && v !== "fixed") {
let values = targetWidget.options.values;
const filter = comboFilter?.value;
if (filter) {
let check;
if (filter.startsWith("/") && filter.endsWith("/")) {
try {
const regex = new RegExp(filter.substring(1, filter.length - 1));
check = (item) => regex.test(item);
} catch (error) {
console.error("Error constructing RegExp filter for node " + node.id, filter, error);
}
}
if (!check) {
const lower = filter.toLocaleLowerCase();
check = (item) => item.toLocaleLowerCase().includes(lower);
}
values = values.filter(item => check(item));
if (!values.length && targetWidget.options.values.length) {
console.warn("Filter for node " + node.id + " has filtered out all items", filter);
}
}
let current_index = values.indexOf(targetWidget.value);
let current_length = values.length;
switch (v) {
case "increment":
current_index += 1;
break;
case "increment-wrap":
current_index += 1;
if ( current_index >= current_length ) {
current_index = 0;
}
break;
case "decrement":
current_index -= 1;
break;
case "randomize":
current_index = Math.floor(Math.random() * current_length);
default:
break;
}
current_index = Math.max(0, current_index);
current_index = Math.min(current_length - 1, current_index);
if (current_index >= 0) {
let value = values[current_index];
targetWidget.value = value;
targetWidget.callback(value);
}
} else {
//number
let min = targetWidget.options.min;
let max = targetWidget.options.max;
// limit to something that javascript can handle
max = Math.min(1125899906842624, max);
min = Math.max(-1125899906842624, min);
let range = (max - min) / (targetWidget.options.step / 10);
//adjust values based on valueControl Behaviour
switch (v) {
case "fixed":
break;
case "increment":
targetWidget.value += targetWidget.options.step / 10;
break;
case "decrement":
targetWidget.value -= targetWidget.options.step / 10;
break;
case "randomize":
targetWidget.value = Math.floor(Math.random() * range) * (targetWidget.options.step / 10) + min;
default:
break;
}
/*check if values are over or under their respective
* ranges and set them to min or max.*/
if (targetWidget.value < min) targetWidget.value = min;
if (targetWidget.value > max)
targetWidget.value = max;
targetWidget.callback(targetWidget.value);
}
};
valueControl.beforeQueued = () => {
if (controlValueRunBefore) {
// Don't run on first execution
if (valueControl[HAS_EXECUTED]) {
applyWidgetControl();
}
}
valueControl[HAS_EXECUTED] = true;
};
valueControl.afterQueued = () => {
if (!controlValueRunBefore) {
applyWidgetControl();
}
};
return widgets;
};
function seedWidget(node, inputName, inputData, app, widgetName) {
const seed = createIntWidget(node, inputName, inputData, app, true);
const seedControl = addValueControlWidget(node, seed.widget, "randomize", undefined, widgetName, inputData);
seed.widget.linkedWidgets = [seedControl];
return seed;
}
function createIntWidget(node, inputName, inputData, app, isSeedInput) {
const control = inputData[1]?.control_after_generate;
if (!isSeedInput && control) {
return seedWidget(node, inputName, inputData, app, typeof control === "string" ? control : undefined);
}
let widgetType = isSlider(inputData[1]["display"], app);
const { val, config } = getNumberDefaults(inputData, 1, 0, true);
Object.assign(config, { precision: 0 });
return {
widget: node.addWidget(
widgetType,
inputName,
val,
function (v) {
const s = this.options.step / 10;
let sh = this.options.min % s;
if (isNaN(sh)) {
sh = 0;
}
this.value = Math.round((v - sh) / s) * s + sh;
},
config
),
};
}
function addMultilineWidget(node, name, opts, app) {
const inputEl = document.createElement("textarea");
inputEl.className = "comfy-multiline-input";
inputEl.value = opts.defaultVal;
inputEl.placeholder = opts.placeholder || name;
const widget = node.addDOMWidget(name, "customtext", inputEl, {
getValue() {
return inputEl.value;
},
setValue(v) {
inputEl.value = v;
},
});
widget.inputEl = inputEl;
inputEl.addEventListener("input", () => {
widget.callback?.(widget.value);
});
return { minWidth: 400, minHeight: 200, widget };
}
function isSlider(display, app) {
if (app.ui.settings.getSettingValue("Comfy.DisableSliders")) {
return "number"
}
return (display==="slider") ? "slider" : "number"
}
export function initWidgets(app) {
app.ui.settings.addSetting({
id: "Comfy.WidgetControlMode",
name: "Widget Value Control Mode",
type: "combo",
defaultValue: "after",
options: ["before", "after"],
tooltip: "Controls when widget values are updated (randomize/increment/decrement), either before the prompt is queued or after.",
onChange(value) {
controlValueRunBefore = value === "before";
for (const n of app.graph._nodes) {
if (!n.widgets) continue;
for (const w of n.widgets) {
if (w[IS_CONTROL_WIDGET]) {
updateControlWidgetLabel(w);
if (w.linkedWidgets) {
for (const l of w.linkedWidgets) {
updateControlWidgetLabel(l);
}
}
}
}
}
app.graph.setDirtyCanvas(true);
},
});
}
export const ComfyWidgets = {
"INT:seed": seedWidget,
"INT:noise_seed": seedWidget,
FLOAT(node, inputName, inputData, app) {
let widgetType = isSlider(inputData[1]["display"], app);
let precision = app.ui.settings.getSettingValue("Comfy.FloatRoundingPrecision");
let disable_rounding = app.ui.settings.getSettingValue("Comfy.DisableFloatRounding")
if (precision == 0) precision = undefined;
const { val, config } = getNumberDefaults(inputData, 0.5, precision, !disable_rounding);
return { widget: node.addWidget(widgetType, inputName, val,
function (v) {
if (config.round) {
this.value = Math.round((v + Number.EPSILON)/config.round)*config.round;
if (this.value > config.max) this.value = config.max;
if (this.value < config.min) this.value = config.min;
} else {
this.value = v;
}
}, config) };
},
INT(node, inputName, inputData, app) {
return createIntWidget(node, inputName, inputData, app);
},
BOOLEAN(node, inputName, inputData) {
let defaultVal = false;
let options = {};
if (inputData[1]) {
if (inputData[1].default)
defaultVal = inputData[1].default;
if (inputData[1].label_on)
options["on"] = inputData[1].label_on;
if (inputData[1].label_off)
options["off"] = inputData[1].label_off;
}
return {
widget: node.addWidget(
"toggle",
inputName,
defaultVal,
() => {},
options,
)
};
},
STRING(node, inputName, inputData, app) {
const defaultVal = inputData[1].default || "";
const multiline = !!inputData[1].multiline;
let res;
if (multiline) {
res = addMultilineWidget(node, inputName, { defaultVal, ...inputData[1] }, app);
} else {
res = { widget: node.addWidget("text", inputName, defaultVal, () => {}, {}) };
}
if(inputData[1].dynamicPrompts != undefined)
res.widget.dynamicPrompts = inputData[1].dynamicPrompts;
return res;
},
COMBO(node, inputName, inputData) {
const type = inputData[0];
let defaultValue = type[0];
if (inputData[1] && inputData[1].default) {
defaultValue = inputData[1].default;
}
const res = { widget: node.addWidget("combo", inputName, defaultValue, () => {}, { values: type }) };
if (inputData[1]?.control_after_generate) {
res.widget.linkedWidgets = addValueControlWidgets(node, res.widget, undefined, undefined, inputData);
}
return res;
},
IMAGEUPLOAD(node, inputName, inputData, app) {
const imageWidget = node.widgets.find((w) => w.name === (inputData[1]?.widget ?? "image"));
let uploadWidget;
function showImage(name) {
const img = new Image();
img.onload = () => {
node.imgs = [img];
app.graph.setDirtyCanvas(true);
};
let folder_separator = name.lastIndexOf("/");
let subfolder = "";
if (folder_separator > -1) {
subfolder = name.substring(0, folder_separator);
name = name.substring(folder_separator + 1);
}
img.src = api.apiURL(`/view?filename=${encodeURIComponent(name)}&type=input&subfolder=${subfolder}${app.getPreviewFormatParam()}${app.getRandParam()}`);
node.setSizeForImage?.();
}
var default_value = imageWidget.value;
Object.defineProperty(imageWidget, "value", {
set : function(value) {
this._real_value = value;
},
get : function() {
let value = "";
if (this._real_value) {
value = this._real_value;
} else {
return default_value;
}
if (value.filename) {
let real_value = value;
value = "";
if (real_value.subfolder) {
value = real_value.subfolder + "/";
}
value += real_value.filename;
if(real_value.type && real_value.type !== "input")
value += ` [${real_value.type}]`;
}
return value;
}
});
// Add our own callback to the combo widget to render an image when it changes
const cb = node.callback;
imageWidget.callback = function () {
showImage(imageWidget.value);
if (cb) {
return cb.apply(this, arguments);
}
};
// On load if we have a value then render the image
// The value isnt set immediately so we need to wait a moment
// No change callbacks seem to be fired on initial setting of the value
requestAnimationFrame(() => {
if (imageWidget.value) {
showImage(imageWidget.value);
}
});
async function uploadFile(file, updateNode, pasted = false) {
try {
// Wrap file in formdata so it includes filename
const body = new FormData();
body.append("image", file);
if (pasted) body.append("subfolder", "pasted");
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body,
});
if (resp.status === 200) {
const data = await resp.json();
// Add the file to the dropdown list and update the widget value
let path = data.name;
if (data.subfolder) path = data.subfolder + "/" + path;
if (!imageWidget.options.values.includes(path)) {
imageWidget.options.values.push(path);
}
if (updateNode) {
showImage(path);
imageWidget.value = path;
}
} else {
alert(resp.status + " - " + resp.statusText);
}
} catch (error) {
alert(error);
}
}
const fileInput = document.createElement("input");
Object.assign(fileInput, {
type: "file",
accept: "image/jpeg,image/png,image/webp",
style: "display: none",
onchange: async () => {
if (fileInput.files.length) {
await uploadFile(fileInput.files[0], true);
}
},
});
document.body.append(fileInput);
// Create the button widget for selecting the files
uploadWidget = node.addWidget("button", inputName, "image", () => {
fileInput.click();
});
uploadWidget.label = "choose file to upload";
uploadWidget.serialize = false;
// Add handler to check if an image is being dragged over our node
node.onDragOver = function (e) {
if (e.dataTransfer && e.dataTransfer.items) {
const image = [...e.dataTransfer.items].find((f) => f.kind === "file");
return !!image;
}
return false;
};
// On drop upload files
node.onDragDrop = function (e) {
console.log("onDragDrop called");
let handled = false;
for (const file of e.dataTransfer.files) {
if (file.type.startsWith("image/")) {
uploadFile(file, !handled); // Dont await these, any order is fine, only update on first one
handled = true;
}
}
return handled;
};
node.pasteFile = function(file) {
if (file.type.startsWith("image/")) {
const is_pasted = (file.name === "image.png") &&
(file.lastModified - Date.now() < 2000);
uploadFile(file, true, is_pasted);
return true;
}
return false;
}
return { widget: uploadWidget };
},
};
// Shim for scripts\widgets.ts
export const updateControlWidgetLabel = window.comfyAPI.widgets.updateControlWidgetLabel;
export const addValueControlWidget = window.comfyAPI.widgets.addValueControlWidget;
export const addValueControlWidgets = window.comfyAPI.widgets.addValueControlWidgets;
export const initWidgets = window.comfyAPI.widgets.initWidgets;
export const ComfyWidgets = window.comfyAPI.widgets.ComfyWidgets;

View File

@@ -1,450 +1,4 @@
// @ts-check
import { api } from "./api.js";
import { ChangeTracker } from "./changeTracker.js";
import { ComfyAsyncDialog } from "./ui/components/asyncDialog.js";
import { getStorageValue, setStorageValue } from "./utils.js";
function appendJsonExt(path) {
if (!path.toLowerCase().endsWith(".json")) {
path += ".json";
}
return path;
}
export function trimJsonExt(path) {
return path?.replace(/\.json$/, "");
}
export class ComfyWorkflowManager extends EventTarget {
/** @type {string | null} */
#activePromptId = null;
#unsavedCount = 0;
#activeWorkflow;
/** @type {Record<string, ComfyWorkflow>} */
workflowLookup = {};
/** @type {Array<ComfyWorkflow>} */
workflows = [];
/** @type {Array<ComfyWorkflow>} */
openWorkflows = [];
/** @type {Record<string, {workflow?: ComfyWorkflow, nodes?: Record<string, boolean>}>} */
queuedPrompts = {};
get activeWorkflow() {
return this.#activeWorkflow ?? this.openWorkflows[0];
}
get activePromptId() {
return this.#activePromptId;
}
get activePrompt() {
return this.queuedPrompts[this.#activePromptId];
}
/**
* @param {import("./app.js").ComfyApp} app
*/
constructor(app) {
super();
this.app = app;
ChangeTracker.init(app);
this.#bindExecutionEvents();
}
#bindExecutionEvents() {
// TODO: on reload, set active prompt based on the latest ws message
const emit = () => this.dispatchEvent(new CustomEvent("execute", { detail: this.activePrompt }));
let executing = null;
api.addEventListener("execution_start", (e) => {
this.#activePromptId = e.detail.prompt_id;
// This event can fire before the event is stored, so put a placeholder
this.queuedPrompts[this.#activePromptId] ??= { nodes: {} };
emit();
});
api.addEventListener("execution_cached", (e) => {
if (!this.activePrompt) return;
for (const n of e.detail.nodes) {
this.activePrompt.nodes[n] = true;
}
emit();
});
api.addEventListener("executed", (e) => {
if (!this.activePrompt) return;
this.activePrompt.nodes[e.detail.node] = true;
emit();
});
api.addEventListener("executing", (e) => {
if (!this.activePrompt) return;
if (executing) {
// Seems sometimes nodes that are cached fire executing but not executed
this.activePrompt.nodes[executing] = true;
}
executing = e.detail;
if (!executing) {
delete this.queuedPrompts[this.#activePromptId];
this.#activePromptId = null;
}
emit();
});
}
async loadWorkflows() {
try {
let favorites;
const resp = await api.getUserData("workflows/.index.json");
let info;
if (resp.status === 200) {
info = await resp.json();
favorites = new Set(info?.favorites ?? []);
} else {
favorites = new Set();
}
const workflows = (await api.listUserData("workflows", true, true)).map((w) => {
let workflow = this.workflowLookup[w[0]];
if (!workflow) {
workflow = new ComfyWorkflow(this, w[0], w.slice(1), favorites.has(w[0]));
this.workflowLookup[workflow.path] = workflow;
}
return workflow;
});
this.workflows = workflows;
} catch (error) {
alert("Error loading workflows: " + (error.message ?? error));
this.workflows = [];
}
}
async saveWorkflowMetadata() {
await api.storeUserData("workflows/.index.json", {
favorites: [...this.workflows.filter((w) => w.isFavorite).map((w) => w.path)],
});
}
/**
* @param {string | ComfyWorkflow | null} workflow
*/
setWorkflow(workflow) {
if (workflow && typeof workflow === "string") {
// Selected by path, i.e. on reload of last workflow
const found = this.workflows.find((w) => w.path === workflow);
if (found) {
workflow = found;
workflow.unsaved = !workflow || getStorageValue("Comfy.PreviousWorkflowUnsaved") === "true";
}
}
if (!(workflow instanceof ComfyWorkflow)) {
// Still not found, either reloading a deleted workflow or blank
workflow = new ComfyWorkflow(this, workflow || "Unsaved Workflow" + (this.#unsavedCount++ ? ` (${this.#unsavedCount})` : ""));
}
const index = this.openWorkflows.indexOf(workflow);
if (index === -1) {
// Opening a new workflow
this.openWorkflows.push(workflow);
}
this.#activeWorkflow = workflow;
setStorageValue("Comfy.PreviousWorkflow", this.activeWorkflow.path ?? "");
this.dispatchEvent(new CustomEvent("changeWorkflow"));
}
storePrompt({ nodes, id }) {
this.queuedPrompts[id] ??= {};
this.queuedPrompts[id].nodes = {
...nodes.reduce((p, n) => {
p[n] = false;
return p;
}, {}),
...this.queuedPrompts[id].nodes,
};
this.queuedPrompts[id].workflow = this.activeWorkflow;
}
/**
* @param {ComfyWorkflow} workflow
*/
async closeWorkflow(workflow, warnIfUnsaved = true) {
if (!workflow.isOpen) {
return true;
}
if (workflow.unsaved && warnIfUnsaved) {
const res = await ComfyAsyncDialog.prompt({
title: "Save Changes?",
message: `Do you want to save changes to "${workflow.path ?? workflow.name}" before closing?`,
actions: ["Yes", "No", "Cancel"],
});
if (res === "Yes") {
const active = this.activeWorkflow;
if (active !== workflow) {
// We need to switch to the workflow to save it
await workflow.load();
}
if (!(await workflow.save())) {
// Save was canceled, restore the previous workflow
if (active !== workflow) {
await active.load();
}
return;
}
} else if (res === "Cancel") {
return;
}
}
workflow.changeTracker = null;
this.openWorkflows.splice(this.openWorkflows.indexOf(workflow), 1);
if (this.openWorkflows.length) {
this.#activeWorkflow = this.openWorkflows[0];
await this.#activeWorkflow.load();
} else {
// Load default
await this.app.loadGraphData();
}
}
}
export class ComfyWorkflow {
#name;
#path;
#pathParts;
#isFavorite = false;
/** @type {ChangeTracker | null} */
changeTracker = null;
unsaved = false;
get name() {
return this.#name;
}
get path() {
return this.#path;
}
get pathParts() {
return this.#pathParts;
}
get isFavorite() {
return this.#isFavorite;
}
get isOpen() {
return !!this.changeTracker;
}
/**
* @overload
* @param {ComfyWorkflowManager} manager
* @param {string} path
*/
/**
* @overload
* @param {ComfyWorkflowManager} manager
* @param {string} path
* @param {string[]} pathParts
* @param {boolean} isFavorite
*/
/**
* @param {ComfyWorkflowManager} manager
* @param {string} path
* @param {string[]} [pathParts]
* @param {boolean} [isFavorite]
*/
constructor(manager, path, pathParts, isFavorite) {
this.manager = manager;
if (pathParts) {
this.#updatePath(path, pathParts);
this.#isFavorite = isFavorite;
} else {
this.#name = path;
this.unsaved = true;
}
}
/**
* @param {string} path
* @param {string[]} [pathParts]
*/
#updatePath(path, pathParts) {
this.#path = path;
if (!pathParts) {
if (!path.includes("\\")) {
pathParts = path.split("/");
} else {
pathParts = path.split("\\");
}
}
this.#pathParts = pathParts;
this.#name = trimJsonExt(pathParts[pathParts.length - 1]);
}
async getWorkflowData() {
const resp = await api.getUserData("workflows/" + this.path);
if (resp.status !== 200) {
alert(`Error loading workflow file '${this.path}': ${resp.status} ${resp.statusText}`);
return;
}
return await resp.json();
}
load = async () => {
if (this.isOpen) {
await this.manager.app.loadGraphData(this.changeTracker.activeState, true, true, this);
} else {
const data = await this.getWorkflowData();
if (!data) return;
await this.manager.app.loadGraphData(data, true, true, this);
}
};
async save(saveAs = false) {
if (!this.path || saveAs) {
return !!(await this.#save(null, false));
} else {
return !!(await this.#save(this.path, true));
}
}
/**
* @param {boolean} value
*/
async favorite(value) {
try {
if (this.#isFavorite === value) return;
this.#isFavorite = value;
await this.manager.saveWorkflowMetadata();
this.manager.dispatchEvent(new CustomEvent("favorite", { detail: this }));
} catch (error) {
alert("Error favoriting workflow " + this.path + "\n" + (error.message ?? error));
}
}
/**
* @param {string} path
*/
async rename(path) {
path = appendJsonExt(path);
let resp = await api.moveUserData("workflows/" + this.path, "workflows/" + path);
if (resp.status === 409) {
if (!confirm(`Workflow '${path}' already exists, do you want to overwrite it?`)) return resp;
resp = await api.moveUserData("workflows/" + this.path, "workflows/" + path, { overwrite: true });
}
if (resp.status !== 200) {
alert(`Error renaming workflow file '${this.path}': ${resp.status} ${resp.statusText}`);
return;
}
const isFav = this.isFavorite;
if (isFav) {
await this.favorite(false);
}
path = (await resp.json()).substring("workflows/".length);
this.#updatePath(path, null);
if (isFav) {
await this.favorite(true);
}
this.manager.dispatchEvent(new CustomEvent("rename", { detail: this }));
setStorageValue("Comfy.PreviousWorkflow", this.path ?? "");
}
async insert() {
const data = await this.getWorkflowData();
if (!data) return;
const old = localStorage.getItem("litegrapheditor_clipboard");
const graph = new LGraph(data);
const canvas = new LGraphCanvas(null, graph, { skip_events: true, skip_render: true });
canvas.selectNodes();
canvas.copyToClipboard();
this.manager.app.canvas.pasteFromClipboard();
localStorage.setItem("litegrapheditor_clipboard", old);
}
async delete() {
// TODO: fix delete of current workflow - should mark workflow as unsaved and when saving use old name by default
try {
if (this.isFavorite) {
await this.favorite(false);
}
await api.deleteUserData("workflows/" + this.path);
this.unsaved = true;
this.#path = null;
this.#pathParts = null;
this.manager.workflows.splice(this.manager.workflows.indexOf(this), 1);
this.manager.dispatchEvent(new CustomEvent("delete", { detail: this }));
} catch (error) {
alert(`Error deleting workflow: ${error.message || error}`);
}
}
track() {
if (this.changeTracker) {
this.changeTracker.restore();
} else {
this.changeTracker = new ChangeTracker(this);
}
}
/**
* @param {string|null} path
* @param {boolean} overwrite
*/
async #save(path, overwrite) {
if (!path) {
path = prompt("Save workflow as:", trimJsonExt(this.path) ?? this.name ?? "workflow");
if (!path) return;
}
path = appendJsonExt(path);
const p = await this.manager.app.graphToPrompt();
const json = JSON.stringify(p.workflow, null, 2);
let resp = await api.storeUserData("workflows/" + path, json, { stringify: false, throwOnError: false, overwrite });
if (resp.status === 409) {
if (!confirm(`Workflow '${path}' already exists, do you want to overwrite it?`)) return;
resp = await api.storeUserData("workflows/" + path, json, { stringify: false });
}
if (resp.status !== 200) {
alert(`Error saving workflow '${this.path}': ${resp.status} ${resp.statusText}`);
return;
}
path = (await resp.json()).substring("workflows/".length);
if (!this.path) {
// Saved new workflow, patch this instance
this.#updatePath(path, null);
await this.manager.loadWorkflows();
this.unsaved = false;
this.manager.dispatchEvent(new CustomEvent("rename", { detail: this }));
setStorageValue("Comfy.PreviousWorkflow", this.path ?? "");
} else if (path !== this.path) {
// Saved as, open the new copy
await this.manager.loadWorkflows();
const workflow = this.manager.workflowLookup[path];
await workflow.load();
} else {
// Normal save
this.unsaved = false;
this.manager.dispatchEvent(new CustomEvent("save", { detail: this }));
}
return true;
}
}
// Shim for scripts\workflows.ts
export const trimJsonExt = window.comfyAPI.workflows.trimJsonExt;
export const ComfyWorkflowManager = window.comfyAPI.workflows.ComfyWorkflowManager;
export const ComfyWorkflow = window.comfyAPI.workflows.ComfyWorkflow;