import { app } from "../../scripts/app.js"; import { $el, ComfyDialog } from "../../scripts/ui.js"; const env = "prod"; let DEFAULT_HOMEPAGE_URL = "https://copus.io"; let API_ENDPOINT = "https://api.client.prod.copus.io/copus-client"; if (env !== "prod") { API_ENDPOINT = "https://api.dev.copus.io/copus-client"; DEFAULT_HOMEPAGE_URL = "https://test.copus.io"; } const style = ` .copus-share-dialog a { color: #f8f8f8; } .copus-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", position: "relative", }; export class CopusShareDialog extends ComfyDialog { static instance = null; constructor() { super(); $el("style", { textContent: style, parent: document.head, }); this.element = $el( "div.comfy-modal.copus-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.allFilesImages = []; this.selectedFile = null; this.allFiles = []; this.titleNum = 0; } createButtons() { const inputStyle = { display: "block", minWidth: "500px", width: "100%", padding: "10px", margin: "10px 0", borderRadius: "4px", border: "1px solid #ddd", boxSizing: "border-box", }; const textAreaStyle = { display: "block", minWidth: "500px", width: "100%", padding: "10px", margin: "10px 0", borderRadius: "4px", border: "1px solid #ddd", boxSizing: "border-box", minHeight: "100px", background: "#222", resize: "vertical", color: "#f2f2f2", fontFamily: "Arial", fontWeight: "400", fontSize: "15px", }; 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.TitleInput = $el("input", { type: "text", placeholder: "Title (Required)", style: inputStyle, maxLength: "70", oninput: () => { const titleNum = this.TitleInput.value.length; titleNumDom.textContent = `${titleNum}/70`; }, }); this.SubTitleInput = $el("input", { type: "text", placeholder: "Subtitle (Optional)", style: inputStyle, maxLength: "70", oninput: () => { const titleNum = this.SubTitleInput.value.length; subTitleNumDom.textContent = `${titleNum}/70`; }, }); this.descriptionInput = $el("textarea", { placeholder: "Content (Optional)", style: { ...textAreaStyle, minHeight: "100px", }, }); // Header Section const headerSection = $el("h3", { textContent: "Share your workflow to Copus", size: 3, color: "white", style: { "text-align": "center", color: "white", margin: "0 0 10px 0", }, }); this.getAPIKeyLink = $el( "a", { style: { ...hyperLinkStyle, color: "#59E8C6", }, href: `${DEFAULT_HOMEPAGE_URL}?fromPage=comfyUI`, 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️⃣ Copus 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", }, [] ); const titleNumDom = $el( "label", { style: { fontSize: "12px", position: "absolute", right: "10px", bottom: "-10px", color: "#999", }, }, ["0/70"] ); const subTitleNumDom = $el( "label", { style: { fontSize: "12px", position: "absolute", right: "10px", bottom: "-10px", color: "#999", }, }, ["0/70"] ); const descriptionNumDom = $el( "label", { style: { fontSize: "12px", position: "absolute", right: "10px", bottom: "-10px", color: "#999", }, }, ["0/70"] ); // Additional Inputs Section const additionalInputsSection = $el( "div", { style: { ...sectionStyle, } }, [ $el("label", { style: labelStyle }, ["3️⃣ Title "]), this.TitleInput, titleNumDom, ] ); const SubtitleSection = $el("div", { style: sectionStyle }, [ $el("label", { style: labelStyle }, ["4️⃣ Subtitle "]), this.SubTitleInput, subTitleNumDom, ]); const DescriptionSection = $el("div", { style: sectionStyle }, [ $el("label", { style: labelStyle }, ["5️⃣ Description "]), this.descriptionInput, // descriptionNumDom, ]); // switch between outputs section and additional inputs section this.radioButtons = []; this.radioButtonsCheck = $el("input", { type: "radio", name: "output_type", value: "0", id: "blockchain1", checked: true, }); this.radioButtonsCheckOff = $el("input", { type: "radio", name: "output_type", value: "1", id: "blockchain", }); const blockChainSection = $el("div", { style: sectionStyle }, [ $el("label", { style: labelStyle }, ["6️⃣ Store on blockchain "]), $el( "label", { style: { marginTop: "10px", display: "flex", alignItems: "center", cursor: "pointer", }, }, [ this.radioButtonsCheck, $el("span", { style: { marginLeft: "5px" } }, ["ON"]), ] ), $el( "label", { style: { display: "flex", alignItems: "center", cursor: "pointer" } }, [ this.radioButtonsCheckOff, $el("span", { style: { marginLeft: "5px" } }, ["OFF"]), ] ), $el( "p", { style: { fontSize: "16px", color: "#fff", margin: "10px 0 0 0" } }, ["Secure ownership with a permanent & decentralized storage"] ), ]); // 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, SubtitleSection, DescriptionSection, // contestSection, blockChainSection, this.message, buttonsSection, ]; return layout; } /** * api * @param {url} path * @param {params} options * @param {statusText} statusText * @returns */ async fetchApi(path, options, statusText) { if (statusText) { this.message.textContent = statusText; } const fullPath = new URL(API_ENDPOINT + path); 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, }; } /** * @param {file} uploadFile */ async uploadThumbnail(uploadFile, type) { const form = new FormData(); form.append("file", uploadFile); form.append("apiToken", this.keyInput.value); try { const res = await this.fetchApi( `/client/common/opus/uploadImage`, { method: "POST", body: form, }, "Uploading thumbnail..." ); if (res.status && res.data.status && res.data) { const { data } = res.data; if (type) { this.allFilesImages.push({ url: data, }); } this.uploadedImages.push({ url: data, }); } else { throw new Error("make sure your API key is correct and try again later"); } } 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 = ""; 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"; } /** * share * @param {string} title * @param {string} subtitle * @param {string} content * @param {boolean} storeOnChain * @param {string} coverUrl * @param {string[]} imageUrls * @param {string} apiToken */ async share() { const prompt = await app.graphToPrompt(); const workflowJSON = prompt["workflow"]; const form_values = { title: this.TitleInput.value, subTitle: this.SubTitleInput.value, content: this.descriptionInput.value, storeOnChain: this.radioButtonsCheck.checked ? true : false, }; 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.title) { throw new Error("Title is required"); } 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"); } } } if (this.allFiles.length > 0) { for (const file of this.allFiles) { try { await this.uploadThumbnail(file, true); } catch (e) { this.allFilesImages = []; throw new Error(e.message); } } } try { const res = await this.fetchApi( "/client/common/opus/shareFromComfyUI", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ workflowJson: workflowJSON, apiToken: this.keyInput.value, coverUrl: this.uploadedImages[0].url, imageUrls: this.allFilesImages.map((image) => image.url), ...form_values, }), }, "Uploading workflow..." ); if (res.status && res.data.status && res.data) { localStorage.setItem("copus_token",this.keyInput.value); const { data } = res.data; if (data) { const url = `${DEFAULT_HOMEPAGE_URL}/work/${data}`; this.message.innerHTML = `Workflow has been shared successfully. Click here to view it.`; this.previewImage.src = ""; this.previewImage.style.display = "none"; this.uploadedImages = []; this.allFilesImages = []; this.allFiles = []; this.TitleInput.value = ""; this.SubTitleInput.value = ""; this.descriptionInput.value = ""; 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; const apiToken = localStorage.getItem("copus_token"); this.message.innerHTML = ""; this.message.textContent = ""; this.element.style.display = "block"; this.previewImage.src = ""; this.previewImage.style.display = "none"; this.keyInput.value = apiToken!=null?apiToken:""; this.uploadedImages = []; this.allFilesImages = []; this.allFiles = []; // 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 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); let src = ""; if (output.type === "image" || output.type === "temp") { filename = output.image.filename; src = `/view?filename=${output.image.filename}&subfolder=${output.image.subfolder}&type=${output.image.type}`; } else if (output.type === "output") { src = output.output.value; filename = output.filename; } if (src) { this.fetchImageBlob(src).then((blob) => { const file = new File([blob], filename, { type: blob.type, }); this.allFiles.push(file); }); } 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); } }