ComfyUI-Manager/js/comfyui-share-openart.js
Dr.Lt.Data 3c11361502 Merge branch 'main' into feat/cnr
improved: support new front's prompt, alert api
modified: z-indices
2025-01-02 02:58:55 +09:00

747 lines
21 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {app} from "../../scripts/app.js";
import {api} from "../../scripts/api.js";
import {ComfyDialog, $el} from "../../scripts/ui.js";
import { customAlert } from "./common.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) {
customAlert(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);
}
}