import {app} from "../../scripts/app.js"; import {api} from "../../scripts/api.js"; import {ComfyDialog, $el} from "../../scripts/ui.js"; const LOCAL_STORAGE_KEY = "openart_comfy_workflow_key"; const DEFAULT_HOMEPAGE_URL = "https://openart.ai/workflows/dev?developer=true"; //const DEFAULT_HOMEPAGE_URL = "http://localhost:8080/workflows/dev?developer=true"; const API_ENDPOINT = "https://openart.ai/api"; //const API_ENDPOINT = "http://localhost:8080/api"; const style = ` .openart-share-dialog a { color: #f8f8f8; } .openart-share-dialog a:hover { color: #007bff; } .output_label { border: 5px solid transparent; } .output_label:hover { border: 5px solid #59E8C6; } .output_label.checked { border: 5px solid #59E8C6; } `; // Shared component styles const sectionStyle = { marginBottom: 0, padding: 0, borderRadius: "8px", boxShadow: "0 2px 4px rgba(0, 0, 0, 0.05)", display: "flex", flexDirection: "column", justifyContent: "center", }; export class OpenArtShareDialog extends ComfyDialog { static instance = null; constructor() { super(); $el("style", { textContent: style, parent: document.head, }); this.element = $el( "div.comfy-modal.openart-share-dialog", { parent: document.body, style: { "overflow-y": "auto", }, }, [$el("div.comfy-modal-content", {}, [...this.createButtons()])] ); this.selectedOutputIndex = 0; this.selectedNodeId = null; this.uploadedImages = []; this.selectedFile = null; } async readKey() { let key = "" try { key = await api.fetchApi(`/manager/get_openart_auth`) .then(response => response.json()) .then(data => { return data.openart_key; }) .catch(error => { // console.log(error); }); } catch (error) { // console.log(error); } return key || ""; } async saveKey(value) { await api.fetchApi(`/manager/set_openart_auth`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ openart_key: value }) }); } createButtons() { const inputStyle = { display: "block", minWidth: "500px", width: "100%", padding: "10px", margin: "10px 0", borderRadius: "4px", border: "1px solid #ddd", boxSizing: "border-box", }; const hyperLinkStyle = { display: "block", marginBottom: "15px", fontWeight: "bold", fontSize: "14px", }; const labelStyle = { color: "#f8f8f8", display: "block", margin: "10px 0 0 0", fontWeight: "bold", textDecoration: "none", }; const buttonStyle = { padding: "10px 80px", margin: "10px 5px", borderRadius: "4px", border: "none", cursor: "pointer", color: "#fff", backgroundColor: "#007bff", }; // upload images input this.uploadImagesInput = $el("input", { type: "file", multiple: false, style: inputStyle, accept: "image/*", }); this.uploadImagesInput.addEventListener("change", async (e) => { const file = e.target.files[0]; if (!file) { this.previewImage.src = ""; this.previewImage.style.display = "none"; return; } const reader = new FileReader(); reader.onload = async (e) => { const imgData = e.target.result; this.previewImage.src = imgData; this.previewImage.style.display = "block"; this.selectedFile = null // Once user uploads an image, we uncheck all radio buttons this.radioButtons.forEach((ele) => { ele.checked = false; ele.parentElement.classList.remove("checked"); }); // Add the opacity style toggle here to indicate that they only need // to upload one image or choose one from the outputs. this.outputsSection.style.opacity = 0.35; this.uploadImagesInput.style.opacity = 1; }; reader.readAsDataURL(file); }); // preview image this.previewImage = $el("img", { src: "", style: { width: "100%", maxHeight: "100px", objectFit: "contain", display: "none", marginTop: '10px', }, }); this.keyInput = $el("input", { type: "password", placeholder: "Copy & paste your API key", style: inputStyle, }); this.NameInput = $el("input", { type: "text", placeholder: "Title (required)", style: inputStyle, }); this.descriptionInput = $el("textarea", { placeholder: "Description (optional)", style: { ...inputStyle, minHeight: "100px", }, }); // Header Section const headerSection = $el("h3", { textContent: "Share your workflow to OpenArt", size: 3, color: "white", style: { 'text-align': 'center', color: 'white', margin: '0 0 10px 0', } }); // LinkSection this.communityLink = $el("a", { style: hyperLinkStyle, href: DEFAULT_HOMEPAGE_URL, target: "_blank" }, ["👉 Check out thousands of workflows shared from the community"]) this.getAPIKeyLink = $el("a", { style: { ...hyperLinkStyle, color: "#59E8C6" }, href: DEFAULT_HOMEPAGE_URL, target: "_blank" }, ["👉 Get your API key here"]) const linkSection = $el( "div", { style: { marginTop: "10px", display: "flex", flexDirection: "column", }, }, [ this.communityLink, this.getAPIKeyLink, ] ); // Account Section const accountSection = $el("div", {style: sectionStyle}, [ $el("label", {style: labelStyle}, ["1️⃣ OpenArt API Key"]), this.keyInput, ]); // Output Upload Section const outputUploadSection = $el("div", {style: sectionStyle}, [ $el("label", { style: { ...labelStyle, margin: "10px 0 0 0" } }, ["2️⃣ Image/Thumbnail (Required)"]), this.previewImage, this.uploadImagesInput, ]); // Outputs Section this.outputsSection = $el("div", { id: "selectOutputs", }, []); // Additional Inputs Section const additionalInputsSection = $el("div", {style: sectionStyle}, [ $el("label", {style: labelStyle}, ["3️⃣ Workflow Information"]), this.NameInput, this.descriptionInput, ]); // OpenArt Contest Section /* this.joinContestCheckbox = $el("input", { type: 'checkbox', id: "join_contest"s }, []) this.joinContestDescription = $el("a", { style: { ...hyperLinkStyle, display: 'inline-block', color: "#59E8C6", fontSize: '12px', marginLeft: '10px', marginBottom: 0, }, href: "https://contest.openart.ai/", target: "_blank" }, ["🏆 I'm participating in the OpenArt workflow contest"]) this.joinContestLabel = $el("label", { style: { display: 'flex', alignItems: 'center', cursor: 'pointer', } }, [this.joinContestCheckbox, this.joinContestDescription]) const contestSection = $el("div", {style: sectionStyle}, [ this.joinContestLabel, ]); */ // Message Section this.message = $el( "div", { style: { color: "#ff3d00", textAlign: "center", padding: "10px", fontSize: "20px", }, }, [] ); this.shareButton = $el("button", { type: "submit", textContent: "Share", style: buttonStyle, onclick: () => { this.handleShareButtonClick(); }, }); // Share and Close Buttons const buttonsSection = $el( "div", { style: { textAlign: "right", marginTop: "20px", display: "flex", justifyContent: "space-between", }, }, [ $el("button", { type: "button", textContent: "Close", style: { ...buttonStyle, backgroundColor: undefined, }, onclick: () => { this.close(); }, }), this.shareButton, ] ); // Composing the full layout const layout = [ headerSection, linkSection, accountSection, outputUploadSection, this.outputsSection, additionalInputsSection, // contestSection, this.message, buttonsSection, ]; return layout; } async fetchApi(path, options, statusText) { if (statusText) { this.message.textContent = statusText; } const addSearchParams = (url, params = {}) => new URL( `${url.origin}${url.pathname}?${new URLSearchParams([ ...Array.from(url.searchParams.entries()), ...Object.entries(params), ])}` ); const fullPath = addSearchParams(new URL(API_ENDPOINT + path), { workflow_api_key: this.keyInput.value, }); const response = await fetch(fullPath, options); if (!response.ok) { throw new Error(response.statusText); } if (statusText) { this.message.textContent = ""; } const data = await response.json(); return { ok: response.ok, statusText: response.statusText, status: response.status, data, }; } async uploadThumbnail(uploadFile) { const form = new FormData(); form.append("file", uploadFile); try { const res = await this.fetchApi( `/workflows/upload_thumbnail`, { method: "POST", body: form, }, "Uploading thumbnail..." ); if (res.ok && res.data) { const {image_url, width, height} = res.data; this.uploadedImages.push({ url: image_url, width, height, }); } } 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.saveKey(this.keyInput.value); try { this.shareButton.disabled = true; this.shareButton.textContent = "Sharing..."; await this.share(); } catch (e) { alert(e.message); } 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.keyInput.value) { throw new Error("API key is required"); } if (!this.uploadImagesInput.files[0] && !this.selectedFile) { throw new Error("Thumbnail is required"); } if (!form_values.name) { throw new Error("Title is required"); } const current_snapshot = await api.fetchApi(`/snapshot/get_current`) .then(response => response.json()) .catch(error => { // console.log(error); }); if (!this.uploadedImages.length) { if (this.selectedFile) { await this.uploadThumbnail(this.selectedFile); } else { for (const file of this.uploadImagesInput.files) { try { await this.uploadThumbnail(file); } catch (e) { this.uploadedImages = []; throw new Error(e.message); } } if (this.uploadImagesInput.files.length === 0) { throw new Error("No thumbnail uploaded"); } } } // const join_contest = this.joinContestCheckbox.checked; try { const response = await this.fetchApi( "/workflows/publish", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ workflow_json: workflowJSON, upload_images: this.uploadedImages, form_values, advanced_config: { workflow_api_json: workflowAPIJSON, snapshot: current_snapshot, }, // join_contest, }), }, "Uploading workflow..." ); if (response.ok) { const {workflow_id} = response.data; if (workflow_id) { const url = `https://openart.ai/workflows/-/-/${workflow_id}`; this.message.innerHTML = `Workflow has been shared successfully. Click here to view it.`; this.previewImage.src = ""; this.previewImage.style.display = "none"; 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({potential_outputs, potential_output_nodes} = {}) { // Sort `potential_output_nodes` by node ID to make the order always // consistent, but we should also keep `potential_outputs` in the same // order as `potential_output_nodes`. const potential_output_to_order = {}; potential_output_nodes.forEach((node, index) => { if (node.id in potential_output_to_order) { potential_output_to_order[node.id][1].push(potential_outputs[index]); } else { potential_output_to_order[node.id] = [node, [potential_outputs[index]]]; } }) // Sort the object `potential_output_to_order` by key (node ID) const sorted_potential_output_to_order = Object.fromEntries( Object.entries(potential_output_to_order).sort((a, b) => a[0].id - b[0].id) ); const sorted_potential_outputs = [] const sorted_potential_output_nodes = [] for (const [key, value] of Object.entries(sorted_potential_output_to_order)) { sorted_potential_output_nodes.push(value[0]); sorted_potential_outputs.push(...value[1]); } potential_output_nodes = sorted_potential_output_nodes; potential_outputs = sorted_potential_outputs; this.message.innerHTML = ""; this.message.textContent = ""; this.element.style.display = "block"; this.previewImage.src = ""; this.previewImage.style.display = "none"; const key = await this.readKey(); this.keyInput.value = key; this.uploadedImages = []; // 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 = potential_output_nodes.findIndex(node => node.id === this.selectedNodeId); if (index >= 0) { this.selectedOutputIndex = index; } } this.radioButtons = []; const new_radio_buttons = $el("div", { id: "selectOutput-Options", style: { 'overflow-y': 'scroll', 'max-height': '200px', '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)', } }, potential_outputs.map((output, index) => { const {node_id} = output; const radio_button = $el("input", { type: 'radio', name: "selectOutputImages", value: index, required: index === 0 }, []) let radio_button_img; let filename; if (output.type === "image" || output.type === "temp") { radio_button_img = $el("img", { src: `/view?filename=${output.image.filename}&subfolder=${output.image.subfolder}&type=${output.image.type}`, style: { width: "100px", height: "100px", objectFit: "cover", borderRadius: "5px" } }, []); filename = output.image.filename } else if (output.type === "output") { radio_button_img = $el("img", { src: output.output.value, style: { width: "auto", height: "100px", objectFit: "cover", borderRadius: "5px" } }, []); filename = output.filename } else { // unsupported output type // this should never happen // TODO radio_button_img = $el("img", { src: "", style: {width: "auto", height: "100px"} }, []); } const radio_button_text = $el("span", { style: { color: 'gray', display: 'block', fontSize: '12px', overflowX: 'hidden', textOverflow: 'ellipsis', textWrap: 'nowrap', maxWidth: '100px', } }, [output.title]) const node_id_chip = $el("span", { style: { color: '#FBFBFD', display: 'block', backgroundColor: 'rgba(0, 0, 0, 0.5)', fontSize: '12px', overflowX: 'hidden', padding: '2px 3px', textOverflow: 'ellipsis', textWrap: 'nowrap', maxWidth: '100px', position: 'absolute', top: '3px', left: '3px', borderRadius: '3px', } }, [`Node: ${node_id}`]) radio_button.style.color = "var(--fg-color)"; radio_button.checked = this.selectedOutputIndex === index; radio_button.onchange = async () => { this.selectedOutputIndex = parseInt(radio_button.value); // Remove the "checked" class from all radio buttons this.radioButtons.forEach((ele) => { ele.parentElement.classList.remove("checked"); }); radio_button.parentElement.classList.add("checked"); this.fetchImageBlob(radio_button_img.src).then((blob) => { const file = new File([blob], filename, { type: blob.type, }); this.previewImage.src = radio_button_img.src; this.previewImage.style.display = "block"; this.selectedFile = file; }) // Add the opacity style toggle here to indicate that they only need // to upload one image or choose one from the outputs. this.outputsSection.style.opacity = 1; this.uploadImagesInput.style.opacity = 0.35; }; if (radio_button.checked) { this.fetchImageBlob(radio_button_img.src).then((blob) => { const file = new File([blob], filename, { type: blob.type, }); this.previewImage.src = radio_button_img.src; this.previewImage.style.display = "block"; this.selectedFile = file; }) // Add the opacity style toggle here to indicate that they only need // to upload one image or choose one from the outputs. this.outputsSection.style.opacity = 1; this.uploadImagesInput.style.opacity = 0.35; } this.radioButtons.push(radio_button); return $el(`label.output_label${radio_button.checked ? '.checked' : ''}`, { style: { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", marginBottom: "10px", cursor: "pointer", position: 'relative', } }, [radio_button_img, radio_button_text, radio_button, node_id_chip]); }) ); const header = $el("p", { textContent: this.radioButtons.length === 0 ? "Queue Prompt to see the outputs" : "Or choose one from the outputs (scroll to see all)", size: 2, color: "white", style: { color: 'white', margin: '0 0 5px 0', fontSize: '12px', }, }, []) this.outputsSection.innerHTML = ""; this.outputsSection.appendChild(header); this.outputsSection.appendChild(new_radio_buttons); } }