Browse Source

Add new workflow sharing option

Add YouML as an option to share workflows and convert them into interactive apps.
pull/308/head
alexp 11 months ago committed by Alex Prokofiev
parent
commit
211532e9f0
  1. 4
      README.md
  2. 31
      __init__.py
  3. 26
      js/comfyui-manager.js
  4. 34
      js/comfyui-share-common.js
  5. 568
      js/comfyui-share-youml.js

4
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) ![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. * 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) ![menu](misc/share-setting.jpg)

31
__init__.py

@ -1874,6 +1874,22 @@ def get_comfyworkflows_auth():
return None 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") @server.PromptServer.instance.routes.get("/manager/get_openart_auth")
async def api_get_openart_auth(request): async def api_get_openart_auth(request):
# print("Getting stored Matrix credentials...") # print("Getting stored Matrix credentials...")
@ -1901,6 +1917,21 @@ async def api_get_matrix_auth(request):
return web.json_response(matrix_auth) 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") @server.PromptServer.instance.routes.get("/manager/get_comfyworkflows_auth")
async def api_get_comfyworkflows_auth(request): async def api_get_comfyworkflows_auth(request):
# Check if the user has provided Matrix credentials in a file called 'matrix_accesstoken' # Check if the user has provided Matrix credentials in a file called 'matrix_accesstoken'

26
js/comfyui-manager.js vendored

@ -1,7 +1,15 @@
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js" import { api } from "../../scripts/api.js"
import { ComfyDialog, $el } from "../../scripts/ui.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 { OpenArtShareDialog } from "./comfyui-share-openart.js";
import { CustomNodesInstaller } from "./custom-nodes-downloader.js"; import { CustomNodesInstaller } from "./custom-nodes-downloader.js";
import { AlternativesInstaller } from "./a1111-alter-downloader.js"; import { AlternativesInstaller } from "./a1111-alter-downloader.js";
@ -734,6 +742,7 @@ class ManagerMenuDialog extends ComfyDialog {
const share_options = [ const share_options = [
['none', 'None'], ['none', 'None'],
['openart', 'OpenArt AI'], ['openart', 'OpenArt AI'],
['youml', 'YouML'],
['matrix', 'Matrix Server'], ['matrix', 'Matrix Server'],
['comfyworkflows', 'ComfyWorkflows'], ['comfyworkflows', 'ComfyWorkflows'],
['all', 'All'], ['all', 'All'],
@ -985,6 +994,9 @@ class ManagerMenuDialog extends ComfyDialog {
} else if (share_option === 'matrix' || share_option === 'comfyworkflows') { } else if (share_option === 'matrix' || share_option === 'comfyworkflows') {
showShareDialog(share_option); showShareDialog(share_option);
return; return;
} else if (share_option === 'youml') {
showYouMLShareDialog();
return;
} }
if (!ShareDialogChooser.instance) { if (!ShareDialogChooser.instance) {
@ -1001,6 +1013,15 @@ class ManagerMenuDialog extends ComfyDialog {
window.open(url, "comfyui-workflow-gallery"); window.open(url, "comfyui-workflow-gallery");
modifyButtonStyle(url); 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'", title: "Open 'comfyworkflows.com'",
@ -1067,6 +1088,9 @@ app.registerExtension({
} else if (share_option === 'matrix' || share_option === 'comfyworkflows') { } else if (share_option === 'matrix' || share_option === 'comfyworkflows') {
showShareDialog(share_option); showShareDialog(share_option);
return; return;
} else if (share_option === 'youml') {
showYouMLShareDialog();
return;
} }
if(!ShareDialogChooser.instance) { if(!ShareDialogChooser.instance) {

34
js/comfyui-share-common.js vendored

@ -2,6 +2,7 @@ import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
import { ComfyDialog, $el } from "../../scripts/ui.js"; import { ComfyDialog, $el } from "../../scripts/ui.js";
import { OpenArtShareDialog } from "./comfyui-share-openart.js"; import { OpenArtShareDialog } from "./comfyui-share-openart.js";
import { YouMLShareDialog } from "./comfyui-share-youml.js";
export const SUPPORTED_OUTPUT_NODE_TYPES = [ export const SUPPORTED_OUTPUT_NODE_TYPES = [
"PreviewImage", "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) => { export const showShareDialog = async (share_option) => {
if (!ShareDialog.instance) { if (!ShareDialog.instance) {
ShareDialog.instance = new ShareDialog(share_option); ShareDialog.instance = new ShareDialog(share_option);
@ -233,6 +251,16 @@ export class ShareDialogChooser extends ComfyDialog {
this.close(); 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", key: "matrix",
textContent: "Matrix Server", textContent: "Matrix Server",
@ -264,7 +292,7 @@ export class ShareDialogChooser extends ComfyDialog {
display: "flex", display: "flex",
'flex-wrap': 'wrap', 'flex-wrap': 'wrap',
'justify-content': 'space-around', 'justify-content': 'space-around',
'padding': '20px', 'padding': '10px',
} }
}); });
@ -297,7 +325,7 @@ export class ShareDialogChooser extends ComfyDialog {
'text-align': 'left', 'text-align': 'left',
color: 'white', color: 'white',
'font-size': '14px', 'font-size': '14px',
'margin-bottom': '10px', 'margin-bottom': '0',
}, },
}); });
@ -335,7 +363,7 @@ export class ShareDialogChooser extends ComfyDialog {
style: { style: {
'flex-basis': '100%', 'flex-basis': '100%',
'margin': '10px', 'margin': '10px',
'padding': '20px', 'padding': '10px 20px',
'border': '1px solid #ddd', 'border': '1px solid #ddd',
'border-radius': '5px', 'border-radius': '5px',
'box-shadow': '0 2px 4px rgba(0, 0, 0, 0.1)', 'box-shadow': '0 2px 4px rgba(0, 0, 0, 0.1)',

568
js/comfyui-share-youml.js vendored

@ -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, ` +
`<a href="${recipePageUrl}" target="_blank">visit it on YouML</a>`;
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";
}
}
Loading…
Cancel
Save