mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2025-09-12 12:37:01 +00:00
Use new TS frontend uncompressed (#4379)
* Swap frontend uncompressed * Add uncompressed files
This commit is contained in:
484
web/scripts/api.js
vendored
484
web/scripts/api.js
vendored
@@ -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
2464
web/scripts/app.js
vendored
File diff suppressed because it is too large
Load Diff
257
web/scripts/changeTracker.js
vendored
257
web/scripts/changeTracker.js
vendored
@@ -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;
|
||||
|
121
web/scripts/defaultGraph.js
vendored
121
web/scripts/defaultGraph.js
vendored
@@ -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;
|
||||
|
337
web/scripts/domWidget.js
vendored
337
web/scripts/domWidget.js
vendored
@@ -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
372
web/scripts/logging.js
vendored
@@ -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
3
web/scripts/metadata/flac.js
vendored
Normal 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
3
web/scripts/metadata/png.js
vendored
Normal 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
513
web/scripts/pnginfo.js
vendored
@@ -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
668
web/scripts/ui.js
vendored
@@ -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;
|
||||
|
66
web/scripts/ui/components/asyncDialog.js
vendored
66
web/scripts/ui/components/asyncDialog.js
vendored
@@ -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;
|
||||
|
165
web/scripts/ui/components/button.js
vendored
165
web/scripts/ui/components/button.js
vendored
@@ -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;
|
||||
|
47
web/scripts/ui/components/buttonGroup.js
vendored
47
web/scripts/ui/components/buttonGroup.js
vendored
@@ -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;
|
||||
|
130
web/scripts/ui/components/popup.js
vendored
130
web/scripts/ui/components/popup.js
vendored
@@ -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;
|
||||
|
45
web/scripts/ui/components/splitButton.js
vendored
45
web/scripts/ui/components/splitButton.js
vendored
@@ -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;
|
||||
|
40
web/scripts/ui/dialog.js
vendored
40
web/scripts/ui/dialog.js
vendored
@@ -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;
|
||||
|
289
web/scripts/ui/draggableList.js
vendored
289
web/scripts/ui/draggableList.js
vendored
@@ -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;
|
||||
|
100
web/scripts/ui/imagePreview.js
vendored
100
web/scripts/ui/imagePreview.js
vendored
@@ -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;
|
||||
|
304
web/scripts/ui/menu/index.js
vendored
304
web/scripts/ui/menu/index.js
vendored
@@ -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;
|
||||
|
25
web/scripts/ui/menu/interruptButton.js
vendored
25
web/scripts/ui/menu/interruptButton.js
vendored
@@ -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;
|
||||
|
710
web/scripts/ui/menu/menu.css
vendored
710
web/scripts/ui/menu/menu.css
vendored
@@ -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;
|
||||
}
|
95
web/scripts/ui/menu/queueButton.js
vendored
95
web/scripts/ui/menu/queueButton.js
vendored
@@ -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;
|
||||
|
79
web/scripts/ui/menu/queueOptions.js
vendored
79
web/scripts/ui/menu/queueOptions.js
vendored
@@ -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;
|
||||
|
27
web/scripts/ui/menu/viewHistory.js
vendored
27
web/scripts/ui/menu/viewHistory.js
vendored
@@ -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;
|
||||
}
|
||||
}
|
203
web/scripts/ui/menu/viewList.js
vendored
203
web/scripts/ui/menu/viewList.js
vendored
@@ -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
|
||||
),
|
||||
];
|
||||
};
|
||||
}
|
55
web/scripts/ui/menu/viewQueue.js
vendored
55
web/scripts/ui/menu/viewQueue.js
vendored
@@ -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) {}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
773
web/scripts/ui/menu/workflows.js
vendored
773
web/scripts/ui/menu/workflows.js
vendored
@@ -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;
|
||||
|
335
web/scripts/ui/settings.js
vendored
335
web/scripts/ui/settings.js
vendored
@@ -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;
|
||||
|
34
web/scripts/ui/spinner.css
vendored
34
web/scripts/ui/spinner.css
vendored
@@ -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);
|
||||
}
|
||||
}
|
11
web/scripts/ui/spinner.js
vendored
11
web/scripts/ui/spinner.js
vendored
@@ -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;
|
||||
|
62
web/scripts/ui/toggleSwitch.js
vendored
62
web/scripts/ui/toggleSwitch.js
vendored
@@ -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;
|
||||
|
135
web/scripts/ui/userSelection.css
vendored
135
web/scripts/ui/userSelection.css
vendored
@@ -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;
|
||||
}
|
116
web/scripts/ui/userSelection.js
vendored
116
web/scripts/ui/userSelection.js
vendored
@@ -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;
|
||||
|
59
web/scripts/ui/utils.js
vendored
59
web/scripts/ui/utils.js
vendored
@@ -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
164
web/scripts/utils.js
vendored
@@ -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
539
web/scripts/widgets.js
vendored
@@ -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;
|
||||
|
454
web/scripts/workflows.js
vendored
454
web/scripts/workflows.js
vendored
@@ -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;
|
||||
|
Reference in New Issue
Block a user