diff --git a/README.md b/README.md index 5425333..576cba0 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,9 @@ This repository provides Colab notebooks that allow you to install and use Comfy ![menu](misc/main.jpg) ![share](misc/share.jpg) * You can share the workflow by clicking the Share button at the bottom of the main menu or selecting Share Output from the Context Menu of the Image node. - * Currently, it supports sharing via [https://comfyworkflows.com/](https://comfyworkflows.com/) and [https://openart.ai](https://openart.ai/workflows/dev), as well as through the Matrix channel. + * Currently, it supports sharing via [https://comfyworkflows.com/](https://comfyworkflows.com/), + [https://openart.ai](https://openart.ai/workflows/dev), [https://youml.com](https://youml.com) + as well as through the Matrix channel. ![menu](misc/share-setting.jpg) diff --git a/__init__.py b/__init__.py index 5484551..2b58c29 100644 --- a/__init__.py +++ b/__init__.py @@ -1874,6 +1874,22 @@ def get_comfyworkflows_auth(): return None +def get_youml_settings(): + if not os.path.exists(os.path.join(comfyui_manager_path, ".youml")): + return None + try: + with open(os.path.join(comfyui_manager_path, ".youml"), "r") as f: + youml_settings = f.read().strip() + return youml_settings if youml_settings else None + except: + return None + + +def set_youml_settings(settings): + with open(os.path.join(comfyui_manager_path, ".youml"), "w") as f: + f.write(settings) + + @server.PromptServer.instance.routes.get("/manager/get_openart_auth") async def api_get_openart_auth(request): # print("Getting stored Matrix credentials...") @@ -1901,6 +1917,21 @@ async def api_get_matrix_auth(request): return web.json_response(matrix_auth) +@server.PromptServer.instance.routes.get("/manager/youml/settings") +async def api_get_youml_settings(request): + youml_settings = get_youml_settings() + if not youml_settings: + return web.Response(status=404) + return web.json_response(json.loads(youml_settings)) + + +@server.PromptServer.instance.routes.post("/manager/youml/settings") +async def api_set_youml_settings(request): + json_data = await request.json() + set_youml_settings(json.dumps(json_data)) + return web.Response(status=200) + + @server.PromptServer.instance.routes.get("/manager/get_comfyworkflows_auth") async def api_get_comfyworkflows_auth(request): # Check if the user has provided Matrix credentials in a file called 'matrix_accesstoken' diff --git a/js/comfyui-manager.js b/js/comfyui-manager.js index 856f956..d2fac79 100644 --- a/js/comfyui-manager.js +++ b/js/comfyui-manager.js @@ -1,7 +1,15 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js" import { ComfyDialog, $el } from "../../scripts/ui.js"; -import { ShareDialog, SUPPORTED_OUTPUT_NODE_TYPES, getPotentialOutputsAndOutputNodes, ShareDialogChooser, showOpenArtShareDialog, showShareDialog } from "./comfyui-share-common.js"; +import { + ShareDialog, + SUPPORTED_OUTPUT_NODE_TYPES, + getPotentialOutputsAndOutputNodes, + ShareDialogChooser, + showOpenArtShareDialog, + showShareDialog, + showYouMLShareDialog +} from "./comfyui-share-common.js"; import { OpenArtShareDialog } from "./comfyui-share-openart.js"; import { CustomNodesInstaller } from "./custom-nodes-downloader.js"; import { AlternativesInstaller } from "./a1111-alter-downloader.js"; @@ -734,6 +742,7 @@ class ManagerMenuDialog extends ComfyDialog { const share_options = [ ['none', 'None'], ['openart', 'OpenArt AI'], + ['youml', 'YouML'], ['matrix', 'Matrix Server'], ['comfyworkflows', 'ComfyWorkflows'], ['all', 'All'], @@ -985,6 +994,9 @@ class ManagerMenuDialog extends ComfyDialog { } else if (share_option === 'matrix' || share_option === 'comfyworkflows') { showShareDialog(share_option); return; + } else if (share_option === 'youml') { + showYouMLShareDialog(); + return; } if (!ShareDialogChooser.instance) { @@ -1001,6 +1013,15 @@ class ManagerMenuDialog extends ComfyDialog { window.open(url, "comfyui-workflow-gallery"); modifyButtonStyle(url); }, + }, + { + title: "Open 'youml.com'", + callback: () => { + const url = "https://youml.com/?from=comfyui-share"; + localStorage.setItem("wg_last_visited", url); + window.open(url, "comfyui-workflow-gallery"); + modifyButtonStyle(url); + }, }, { title: "Open 'comfyworkflows.com'", @@ -1067,6 +1088,9 @@ app.registerExtension({ } else if (share_option === 'matrix' || share_option === 'comfyworkflows') { showShareDialog(share_option); return; + } else if (share_option === 'youml') { + showYouMLShareDialog(); + return; } if(!ShareDialogChooser.instance) { diff --git a/js/comfyui-share-common.js b/js/comfyui-share-common.js index d85637e..74ab6af 100644 --- a/js/comfyui-share-common.js +++ b/js/comfyui-share-common.js @@ -2,6 +2,7 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; import { ComfyDialog, $el } from "../../scripts/ui.js"; import { OpenArtShareDialog } from "./comfyui-share-openart.js"; +import { YouMLShareDialog } from "./comfyui-share-youml.js"; export const SUPPORTED_OUTPUT_NODE_TYPES = [ "PreviewImage", @@ -179,6 +180,23 @@ export const showOpenArtShareDialog = () => { }) } + +export const showYouMLShareDialog = () => { + if (!YouMLShareDialog.instance) { + YouMLShareDialog.instance = new YouMLShareDialog(); + } + + return app.graphToPrompt() + .then(prompt => { + return app.graph._nodes; + }) + .then(nodes => { + const { potential_outputs, potential_output_nodes } = getPotentialOutputsAndOutputNodes(nodes); + YouMLShareDialog.instance.show(potential_outputs, potential_output_nodes); + }) +} + + export const showShareDialog = async (share_option) => { if (!ShareDialog.instance) { ShareDialog.instance = new ShareDialog(share_option); @@ -233,6 +251,16 @@ export class ShareDialogChooser extends ComfyDialog { this.close(); } }, + { + key: "youml", + textContent: "YouML", + website: "https://youml.com", + description: "Share your workflow or transform it into an interactive app on YouML.com", + onclick: () => { + showYouMLShareDialog(); + this.close(); + } + }, { key: "matrix", textContent: "Matrix Server", @@ -264,7 +292,7 @@ export class ShareDialogChooser extends ComfyDialog { display: "flex", 'flex-wrap': 'wrap', 'justify-content': 'space-around', - 'padding': '20px', + 'padding': '10px', } }); @@ -297,7 +325,7 @@ export class ShareDialogChooser extends ComfyDialog { 'text-align': 'left', color: 'white', 'font-size': '14px', - 'margin-bottom': '10px', + 'margin-bottom': '0', }, }); @@ -335,7 +363,7 @@ export class ShareDialogChooser extends ComfyDialog { style: { 'flex-basis': '100%', 'margin': '10px', - 'padding': '20px', + 'padding': '10px 20px', 'border': '1px solid #ddd', 'border-radius': '5px', 'box-shadow': '0 2px 4px rgba(0, 0, 0, 0.1)', diff --git a/js/comfyui-share-youml.js b/js/comfyui-share-youml.js new file mode 100644 index 0000000..80077b2 --- /dev/null +++ b/js/comfyui-share-youml.js @@ -0,0 +1,568 @@ +import {app} from "../../scripts/app.js"; +import {api} from "../../scripts/api.js"; +import {ComfyDialog, $el} from "../../scripts/ui.js"; + +const BASE_URL = "https://youml.com"; +//const BASE_URL = "http://localhost:3000"; +const DEFAULT_HOMEPAGE_URL = `${BASE_URL}/?from=comfyui`; +const TOKEN_PAGE_URL = `${BASE_URL}/my-token`; +const API_ENDPOINT = `${BASE_URL}/api`; + +const style = ` + .youml-share-dialog { + overflow-y: auto; + } + .youml-share-dialog .dialog-header { + text-align: center; + color: white; + margin: 0 0 10px 0; + } + .youml-share-dialog .dialog-section { + margin-bottom: 0; + padding: 0; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + display: flex; + flex-direction: column; + justify-content: center; + } + .youml-share-dialog input, .youml-share-dialog textarea { + display: block; + min-width: 500px; + width: 100%; + padding: 10px; + margin: 10px 0; + border-radius: 4px; + border: 1px solid #ddd; + box-sizing: border-box; + } + .youml-share-dialog textarea { + color: var(--input-text); + background-color: var(--comfy-input-bg); + } + .youml-share-dialog .workflow-description { + min-height: 75px; + } + .youml-share-dialog label { + color: #f8f8f8; + display: block; + margin: 5px 0 0 0; + font-weight: bold; + text-decoration: none; + } + .youml-share-dialog .action-button { + padding: 10px 80px; + margin: 10px 5px; + border-radius: 4px; + border: none; + cursor: pointer; + } + .youml-share-dialog .share-button { + color: #fff; + background-color: #007bff; + } + .youml-share-dialog .close-button { + background-color: none; + } + .youml-share-dialog .action-button-panel { + text-align: right; + display: flex; + justify-content: space-between; + } + .youml-share-dialog .status-message { + color: #fd7909; + text-align: center; + padding: 5px; + font-size: 18px; + } + .youml-share-dialog .status-message a { + color: white; + } + .youml-share-dialog .output-panel { + overflow: auto; + max-height: 180px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + grid-template-rows: auto; + grid-column-gap: 10px; + grid-row-gap: 10px; + margin-bottom: 10px; + padding: 10px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + background-color: var(--bg-color); + } + .youml-share-dialog .output-panel .output-image { + width: 100px; + height: 100px; + objectFit: cover; + borderRadius: 5px; + } + + .youml-share-dialog .output-panel .radio-button { + color:var(--fg-color); + } + .youml-share-dialog .output-panel .radio-text { + color: gray; + display: block; + font-size: 12px; + overflow-x: hidden; + text-overflow: ellipsis; + text-wrap: nowrap; + max-width: 100px; + } + .youml-share-dialog .output-panel .node-id { + color: #FBFBFD; + display: block; + background-color: rgba(0, 0, 0, 0.5); + font-size: 12px; + overflow-x: hidden; + padding: 2px 3px; + text-overflow: ellipsis; + text-wrap: nowrap; + max-width: 100px; + position: absolute; + top: 3px; + left: 3px; + border-radius: 3px; + } + .youml-share-dialog .output-panel .output-label { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-bottom: 10px; + cursor: pointer; + position: relative; + border: 5px solid transparent; + } + .youml-share-dialog .output-panel .output-label:hover { + border: 5px solid #007bff; + } + .youml-share-dialog .output-panel .output-label.checked { + border: 5px solid #007bff; + } + .youml-share-dialog .missing-output-message{ + color: #fd7909; + font-size: 16px; + margin-bottom:10px + } + .youml-share-dialog .select-output-message{ + color: white; + margin-bottom:5px + } +`; + +export class YouMLShareDialog extends ComfyDialog { + static instance = null; + + constructor() { + super(); + $el("style", { + textContent: style, + parent: document.head, + }); + this.element = $el( + "div.comfy-modal.youml-share-dialog", + { + parent: document.body, + }, + [$el("div.comfy-modal-content", {}, [...this.createLayout()])] + ); + this.selectedOutputIndex = 0; + this.selectedNodeId = null; + this.uploadedImages = []; + this.selectedFile = null; + } + + async loadToken() { + let key = "" + try { + const response = await api.fetchApi(`/manager/youml/settings`) + const settings = await response.json() + return settings.token + } catch (error) { + } + return key || ""; + } + + async saveToken(value) { + await api.fetchApi(`/manager/youml/settings`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + token: value + }) + }); + } + + createLayout() { + // Header Section + const headerSection = $el("h3.dialog-header", { + textContent: "Share your workflow to YouML.com", + size: 3, + }); + + // Workflow Info Section + this.nameInput = $el("input", { + type: "text", + placeholder: "Name (required)", + }); + this.descriptionInput = $el("textarea.workflow-description", { + placeholder: "Description (optional, markdown supported)", + }); + const workflowMetadata = $el("div.dialog-section", {}, [ + $el("label", {}, ["Workflow info"]), + this.nameInput, + this.descriptionInput, + ]); + + // Outputs Section + this.outputsSection = $el("div.dialog-section", { + id: "selectOutputs", + }, []); + + const outputUploadSection = $el("div.dialog-section", {}, [ + $el("label", {}, ["Thumbnail"]), + this.outputsSection, + ]); + + // API Token Section + this.apiTokenInput = $el("input", { + type: "password", + placeholder: "Copy & paste your API token", + }); + const getAPITokenButton = $el("button", { + href: DEFAULT_HOMEPAGE_URL, + target: "_blank", + onclick: () => window.open(TOKEN_PAGE_URL, "_blank"), + }, ["Get your API Token"]) + + const apiTokenSection = $el("div.dialog-section", {}, [ + $el("label", {}, ["YouML API Token"]), + this.apiTokenInput, + getAPITokenButton, + ]); + + // Message Section + this.message = $el("div.status-message", {}, []); + + // Share and Close Buttons + this.shareButton = $el("button.action-button.share-button", { + type: "submit", + textContent: "Share", + onclick: () => { + this.handleShareButtonClick(); + }, + }); + + const buttonsSection = $el( + "div.action-button-panel", + {}, + [ + $el("button.action-button.close-button", { + type: "button", + textContent: "Close", + onclick: () => { + this.close(); + }, + }), + this.shareButton, + ] + ); + + // Composing the full layout + const layout = [ + headerSection, + workflowMetadata, + outputUploadSection, + apiTokenSection, + this.message, + buttonsSection, + ]; + + return layout; + } + + async fetchYoumlApi(path, options, statusText) { + if (statusText) { + this.message.textContent = statusText; + } + + const fullPath = new URL(API_ENDPOINT + path) + + const fetchOptions = Object.assign({}, options) + + fetchOptions.headers = { + ...fetchOptions.headers, + "Authorization": `Bearer ${this.apiTokenInput.value}`, + "User-Agent": "ComfyUI-Manager-Youml/1.0.0", + } + + const response = await fetch(fullPath, fetchOptions); + + if (!response.ok) { + throw new Error(response.statusText + " " + (await response.text())); + } + + if (statusText) { + this.message.textContent = ""; + } + const data = await response.json(); + return { + ok: response.ok, + statusText: response.statusText, + status: response.status, + data, + }; + } + + async uploadThumbnail(uploadFile, recipeId) { + const form = new FormData(); + form.append("file", uploadFile, uploadFile.name); + try { + const res = await this.fetchYoumlApi( + `/v1/comfy/recipes/${recipeId}/thumbnail`, + { + method: "POST", + body: form, + }, + "Uploading thumbnail..." + ); + + } catch (e) { + if (e?.response?.status === 413) { + throw new Error("File size is too large (max 20MB)"); + } else { + throw new Error("Error uploading thumbnail: " + e.message); + } + } + } + + async handleShareButtonClick() { + this.message.textContent = ""; + await this.saveToken(this.apiTokenInput.value); + try { + this.shareButton.disabled = true; + this.shareButton.textContent = "Sharing..."; + await this.share(); + } catch (e) { + alert(e.message); + } finally { + this.shareButton.disabled = false; + this.shareButton.textContent = "Share"; + } + } + + async share() { + const prompt = await app.graphToPrompt(); + const workflowJSON = prompt["workflow"]; + const workflowAPIJSON = prompt["output"]; + const form_values = { + name: this.nameInput.value, + description: this.descriptionInput.value, + }; + + if (!this.apiTokenInput.value) { + throw new Error("API token is required"); + } + + if (!this.selectedFile) { + throw new Error("Thumbnail is required"); + } + + if (!form_values.name) { + throw new Error("Title is required"); + } + + + try { + let snapshotData = null; + try { + const snapshot = await api.fetchApi(`/snapshot/get_current`) + snapshotData = await snapshot.json() + } catch (e) { + console.error("Failed to get snapshot", e) + } + + const request = { + name: this.nameInput.value, + description: this.descriptionInput.value, + workflowUiJson: JSON.stringify(workflowJSON), + workflowApiJson: JSON.stringify(workflowAPIJSON), + } + + if (snapshotData) { + request.snapshotJson = JSON.stringify(snapshotData) + } + + const response = await this.fetchYoumlApi( + "/v1/comfy/recipes", + { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(request), + }, + "Uploading workflow..." + ); + + if (response.ok) { + const {id, recipePageUrl, editorPageUrl} = response.data; + if (id) { + let messagePrefix = "Workflow has been shared." + if (this.selectedFile) { + try { + await this.uploadThumbnail(this.selectedFile, id); + } catch (e) { + console.error("Thumbnail upload failed: ", e); + messagePrefix = "Workflow has been shared, but thumbnail upload failed. You can create a thumbnail on YouML later." + } + } + this.message.innerHTML = `${messagePrefix} To turn your workflow into an interactive app, ` + + `visit it on YouML`; + + this.uploadedImages = []; + this.nameInput.value = ""; + this.descriptionInput.value = ""; + this.radioButtons.forEach((ele) => { + ele.checked = false; + ele.parentElement.classList.remove("checked"); + }); + this.selectedOutputIndex = 0; + this.selectedNodeId = null; + this.selectedFile = null; + } + } + } catch (e) { + throw new Error("Error sharing workflow: " + e.message); + } + } + + async fetchImageBlob(url) { + const response = await fetch(url); + const blob = await response.blob(); + return blob; + } + + async show(potentialOutputs, potentialOutputNodes) { + const potentialOutputsToOrder = {}; + potentialOutputNodes.forEach((node, index) => { + if (node.id in potentialOutputsToOrder) { + potentialOutputsToOrder[node.id][1].push(potentialOutputs[index]); + } else { + potentialOutputsToOrder[node.id] = [node, [potentialOutputs[index]]]; + } + }) + const sortedPotentialOutputsToOrder = Object.fromEntries( + Object.entries(potentialOutputsToOrder).sort((a, b) => a[0].id - b[0].id) + ); + const sortedPotentialOutputs = [] + const sortedPotentiaOutputNodes = [] + for (const [key, value] of Object.entries(sortedPotentialOutputsToOrder)) { + sortedPotentiaOutputNodes.push(value[0]); + sortedPotentialOutputs.push(...value[1]); + } + potentialOutputNodes = sortedPotentiaOutputNodes; + potentialOutputs = sortedPotentialOutputs; + + + // If `selectedNodeId` is provided, we will select the corresponding radio + // button for the node. In addition, we move the selected radio button to + // the top of the list. + if (this.selectedNodeId) { + const index = potentialOutputNodes.findIndex(node => node.id === this.selectedNodeId); + if (index >= 0) { + this.selectedOutputIndex = index; + } + } + + this.radioButtons = []; + const newRadioButtons = $el("div.output-panel", + { + id: "selectOutput-Options", + }, + potentialOutputs.map((output, index) => { + const {node_id: nodeId} = output; + const radioButton = $el("input.radio-button", { + type: "radio", + name: "selectOutputImages", + value: index, + required: index === 0 + }, []) + let radioButtonImage; + let filename; + if (output.type === "image" || output.type === "temp") { + radioButtonImage = $el("img.output-image", { + src: `/view?filename=${output.image.filename}&subfolder=${output.image.subfolder}&type=${output.image.type}`, + }, []); + filename = output.image.filename + } else if (output.type === "output") { + radioButtonImage = $el("img.output-image", { + src: output.output.value, + }, []); + filename = output.output.filename + } else { + radioButtonImage = $el("img.output-image", { + src: "", + }, []); + } + const radioButtonText = $el("span.radio-text", {}, [output.title]) + const nodeIdChip = $el("span.node-id", {}, [`Node: ${nodeId}`]) + radioButton.checked = this.selectedOutputIndex === index; + + radioButton.onchange = async () => { + this.selectedOutputIndex = parseInt(radioButton.value); + + // Remove the "checked" class from all radio buttons + this.radioButtons.forEach((ele) => { + ele.parentElement.classList.remove("checked"); + }); + radioButton.parentElement.classList.add("checked"); + + this.fetchImageBlob(radioButtonImage.src).then((blob) => { + const file = new File([blob], filename, { + type: blob.type, + }); + this.selectedFile = file; + }) + }; + + if (radioButton.checked) { + this.fetchImageBlob(radioButtonImage.src).then((blob) => { + const file = new File([blob], filename, { + type: blob.type, + }); + this.selectedFile = file; + }) + } + + this.radioButtons.push(radioButton); + + return $el(`label.output-label${radioButton.checked ? '.checked' : ''}`, {}, + [radioButtonImage, radioButtonText, radioButton, nodeIdChip]); + }) + ); + + let header; + if (this.radioButtons.length === 0) { + header = $el("div.missing-output-message", {textContent: "Queue Prompt to see the outputs and select a thumbnail"}, []) + } else { + header = $el("div.select-output-message", {textContent: "Choose one from the outputs (scroll to see all)"}, []) + } + + this.outputsSection.innerHTML = ""; + this.outputsSection.appendChild(header); + if (this.radioButtons.length > 0) { + this.outputsSection.appendChild(newRadioButtons); + } + + this.message.innerHTML = ""; + this.message.textContent = ""; + + const token = await this.loadToken(); + this.apiTokenInput.value = token; + this.uploadedImages = []; + + this.element.style.display = "block"; + } +}