Spaces:
Runtime error
Runtime error
| 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: 'var(--input-text)', | |
| 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. <a href="${url}" target="_blank">Click here to view it.</a>`; | |
| 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: 'var(--input-text)', | |
| margin: '0 0 5px 0', | |
| fontSize: '12px', | |
| }, | |
| }, []) | |
| this.outputsSection.innerHTML = ""; | |
| this.outputsSection.appendChild(header); | |
| this.outputsSection.appendChild(new_radio_buttons); | |
| } | |
| } | |