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"; } }