import { app } from "../../../scripts/app.js"; import { api } from "../../../scripts/api.js"; import { $el } from "../../../scripts/ui.js"; // Adds workflow management // Original implementation by https://github.com/i-h4x // Thanks for permission to reimplement as an extension const style = ` #comfy-save-button, #comfy-load-button { position: relative; overflow: hidden; } .pysssss-workflow-arrow { position: absolute; top: 0; bottom: 0; right: 0; font-size: 12px; display: flex; align-items: center; width: 24px; justify-content: center; background: rgba(255,255,255,0.1); } .pysssss-workflow-arrow:after { content: "▼"; } .pysssss-workflow-arrow:hover { filter: brightness(1.6); background-color: var(--comfy-menu-bg); } .pysssss-workflow-load .litemenu-entry:not(.has_submenu):before, .pysssss-workflow-load ~ .litecontextmenu .litemenu-entry:not(.has_submenu):before { content: "🎛️"; padding-right: 5px; } .pysssss-workflow-load .litemenu-entry.has_submenu:before, .pysssss-workflow-load ~ .litecontextmenu .litemenu-entry.has_submenu:before { content: "📂"; padding-right: 5px; position: relative; top: -1px; } .pysssss-workflow-popup ~ .litecontextmenu { transform: scale(1.3); } `; async function getWorkflows() { const response = await api.fetchApi("/pysssss/workflows", { cache: "no-store" }); return await response.json(); } async function getWorkflow(name) { const response = await api.fetchApi(`/pysssss/workflows/${encodeURIComponent(name)}`, { cache: "no-store" }); return await response.json(); } async function saveWorkflow(name, workflow, overwrite) { try { const response = await api.fetchApi("/pysssss/workflows", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ name, workflow, overwrite }), }); if (response.status === 201) { return true; } if (response.status === 409) { return false; } throw new Error(response.statusText); } catch (error) { console.error(error); } } class PysssssWorkflows { async load() { this.workflows = await getWorkflows(); if(this.workflows.length) { this.workflows.sort(); } this.loadMenu.style.display = this.workflows.length ? "flex" : "none"; } getMenuOptions(callback) { const menu = []; const directories = new Map(); for (const workflow of this.workflows || []) { const path = workflow.split("/"); let parent = menu; let currentPath = ""; for (let i = 0; i < path.length - 1; i++) { currentPath += "/" + path[i]; let newParent = directories.get(currentPath); if (!newParent) { newParent = { title: path[i], has_submenu: true, submenu: { options: [], }, }; parent.push(newParent); newParent = newParent.submenu.options; directories.set(currentPath, newParent); } parent = newParent; } parent.push({ title: path[path.length - 1], callback: () => callback(workflow), }); } return menu; } constructor() { function addWorkflowMenu(type, getOptions) { return $el("div.pysssss-workflow-arrow", { parent: document.getElementById(`comfy-${type}-button`), onclick: (e) => { e.preventDefault(); e.stopPropagation(); LiteGraph.closeAllContextMenus(); const menu = new LiteGraph.ContextMenu( getOptions(), { event: e, scale: 1.3, }, window ); menu.root.classList.add("pysssss-workflow-popup"); menu.root.classList.add(`pysssss-workflow-${type}`); }, }); } this.loadMenu = addWorkflowMenu("load", () => this.getMenuOptions(async (workflow) => { const json = await getWorkflow(workflow); app.loadGraphData(json); }) ); addWorkflowMenu("save", () => { return [ { title: "Save as", callback: () => { let filename = prompt("Enter filename", this.workflowName || "workflow"); if (filename) { if (!filename.toLowerCase().endsWith(".json")) { filename += ".json"; } this.workflowName = filename; const json = JSON.stringify(app.graph.serialize(), null, 2); // convert the data to a JSON string const blob = new Blob([json], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = $el("a", { href: url, download: filename, style: { display: "none" }, parent: document.body, }); a.click(); setTimeout(function () { a.remove(); window.URL.revokeObjectURL(url); }, 0); } }, }, { title: "Save to workflows", callback: async () => { const name = prompt("Enter filename", this.workflowName || "workflow"); if (name) { this.workflowName = name; const data = app.graph.serialize(); if (!(await saveWorkflow(name, data))) { if (confirm("A workspace with this name already exists, do you want to overwrite it?")) { await saveWorkflow(name, app.graph.serialize(), true); } else { return; } } await this.load(); } }, }, ]; }); this.load(); const handleFile = app.handleFile; const self = this; app.handleFile = function (file) { if (file?.name?.endsWith(".json")) { self.workflowName = file.name; } else { self.workflowName = null; } return handleFile.apply(this, arguments); }; } } const refreshComboInNodes = app.refreshComboInNodes; let workflows; async function sendToWorkflow(img, workflow) { const graph = !workflow ? app.graph.serialize() : await getWorkflow(workflow); const nodes = graph.nodes.filter((n) => n.type === "LoadImage"); let targetNode; if (nodes.length === 0) { alert("To send the image to another workflow, that workflow must have a LoadImage node."); return; } else if (nodes.length > 1) { targetNode = nodes.find((n) => n.title?.toLowerCase().includes("input")); if (!targetNode) { targetNode = nodes[0]; alert( "The target workflow has multiple LoadImage nodes, include 'input' in the name of the one you want to use. The first one will be used here." ); } } else { targetNode = nodes[0]; } const blob = await (await fetch(img.src)).blob(); const name = (workflow || "sendtoworkflow").replace(/\//g, "_") + "-" + +new Date() + new URLSearchParams(img.src.split("?")[1]).get("filename"); const body = new FormData(); body.append("image", new File([blob], name)); const resp = await api.fetchApi("/upload/image", { method: "POST", body, }); if (resp.status === 200) { await refreshComboInNodes.call(app); targetNode.widgets_values[0] = name; app.loadGraphData(graph); app.graph.getNodeById(targetNode.id); } else { alert(resp.status + " - " + resp.statusText); } } app.registerExtension({ name: "pysssss.Workflows", init() { $el("style", { textContent: style, parent: document.head, }); }, async setup() { workflows = new PysssssWorkflows(); app.refreshComboInNodes = function () { workflows.load(); refreshComboInNodes.apply(this, arguments); }; const comfyDefault = "[ComfyUI Default]"; const defaultWorkflow = app.ui.settings.addSetting({ id: "pysssss.Workflows.Default", name: "🐍 Default Workflow", defaultValue: comfyDefault, type: "combo", options: (value) => [comfyDefault, ...workflows.workflows].map((m) => ({ value: m, text: m, selected: m === value, })), }); document.getElementById("comfy-load-default-button").onclick = async function () { if ( localStorage["Comfy.Settings.Comfy.ConfirmClear"] === "false" || confirm(`Load default workflow (${defaultWorkflow.value})?`) ) { if (defaultWorkflow.value === comfyDefault) { app.loadGraphData(); } else { const json = await getWorkflow(defaultWorkflow.value); app.loadGraphData(json); } } }; }, async beforeRegisterNodeDef(nodeType, nodeData, app) { const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; nodeType.prototype.getExtraMenuOptions = function (_, options) { const r = getExtraMenuOptions?.apply?.(this, arguments); let img; if (this.imageIndex != null) { // An image is selected so select that img = this.imgs[this.imageIndex]; } else if (this.overIndex != null) { // No image is selected but one is hovered img = this.imgs[this.overIndex]; } if (img) { let pos = options.findIndex((o) => o.content === "Save Image"); if (pos === -1) { pos = 0; } else { pos++; } options.splice(pos, 0, { content: "Send to workflow", has_submenu: true, submenu: { options: [ { callback: () => sendToWorkflow(img), title: "[Current workflow]" }, ...workflows.getMenuOptions(sendToWorkflow.bind(null, img)), ], }, }); } return r; }; }, });