Spaces:
Running
on
Zero
Running
on
Zero
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"; | |
} | |
} | |