mirror of
https://github.com/yankooliveira/sd-webui-photopea-embed.git
synced 2026-01-26 11:19:45 +00:00
Initial commit.
This commit is contained in:
61
LICENSE
61
LICENSE
@@ -1,21 +1,40 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Yanko Oliveira
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Code: MIT License
|
||||
|
||||
Copyright (c) 2023 Yanko Oliveira
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
------------------------------------------
|
||||
Usage: CreativeML Open RAIL-M
|
||||
|
||||
Use Restrictions
|
||||
|
||||
You agree not to use this extension or derivatives of the extension:
|
||||
|
||||
- In any way that violates any applicable national, federal, state, local or international law or regulation;
|
||||
- For the purpose of exploiting, harming or attempting to exploit or harm minors in any way;
|
||||
- To generate or disseminate verifiably false information and/or content with the purpose of harming others;
|
||||
- To generate or disseminate personal identifiable information that can be used to harm an individual;
|
||||
- To defame, disparage or otherwise harass others;
|
||||
- For fully automated decision making that adversely impacts an individual’s legal rights or otherwise creates or modifies a binding, enforceable obligation;
|
||||
- For any use intended to or which has the effect of discriminating against or harming individuals or groups based on online or offline social behavior or known or predicted personal or personality characteristics;
|
||||
- To exploit any of the vulnerabilities of a specific group of persons based on their age, social, physical or mental characteristics, in order to materially distort the behavior of a person pertaining to that group in a manner that causes or is likely to cause that person or another person physical or psychological harm;
|
||||
- For any use intended to or which has the effect of discriminating against individuals or groups based on legally protected characteristics or categories;
|
||||
- To provide medical advice and medical results interpretation;
|
||||
- To generate or disseminate information for the purpose to be used for administration of justice, law enforcement, immigration or asylum processes, such as predicting an individual will commit fraud/crime commitment (e.g. by text profiling, drawing causal relationships between assertions made in documents, indiscriminate and arbitrarily-targeted use).
|
||||
69
README.md
Normal file
69
README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Photopea Stable Diffusion WebUI Extension
|
||||
|
||||
[](https://youtu.be/f_OXiNAvtII)
|
||||
|
||||
[Photopea](https://www.photopea.com) is essentially Photoshop in a browser. This is a simple extension to add a Photopea tab to [AUTOMATIC1111 Stable Diffusion WebUI](https://github.com/AUTOMATIC1111/stable-diffusion-webui/).
|
||||
|
||||
In the tab, you will have an embedded Photopea editor and a few buttons to send the image to different WebUI sections, and also buttons to send generated content to the embeded Photopea.
|
||||
|
||||
Consider supporting Photopea by [going premium](https://www.photopea.com/api/accounts)!
|
||||
|
||||
## Installation
|
||||
|
||||
On your Stable Diffusion WebUI, click the `Extensions` tab, then the `Install from URL` internal tab in that section. Paste the URL for this repo and click `Install`.
|
||||
|
||||
## Usage
|
||||
|
||||
In the **Photopea** extension tab, you will have the embedded Photopea window. It literally just embeds the exact same Photopea you'd have when accessing the website directly. You can learn how to use Photopea in their [official documentation](https://www.photopea.com/learn/).
|
||||
|
||||
### Options:
|
||||
* **Active Layer Only**: if this box is ticked, only the currently selected layer in Photopea will be sent to the WebUI when using one of the buttons.
|
||||
* **iFrame height**: by default, the Photopea embed is 768px tall, and 100% wide. If you have more or less monitor real estate, you can use the slider to increase or decrease the size of the Photopea window in your tab.
|
||||
|
||||
### Buttons:
|
||||
* **Send to Extras**: sends the currently opened image's flattened contents to the Extras tab. Useful for upscaling etc.
|
||||
* **Send to img2img**: same as above, but sends the image to the img2img tab.
|
||||
* **Inpaint Selection**: in case there's an area selected in the active document, will create a mask with that shape and send both the mask and the image to img2img's "Inpaint Upload" tab.
|
||||
|
||||
### ControlNet:
|
||||
|
||||
In case you have the ControlNet extension installed, you'll also have:
|
||||
|
||||
* **`ControlNet model index` dropdown menu**: in the WebUI `Settings` tab, you can set up more than one ControlNet to be run at the same time. This dropdown lets you choose which model the image will be sent to.
|
||||
* **`Send to txt2img ControlNet` button**: sends the image to ControlNet in the txt2img tab.
|
||||
* **`Send to img2img ControlNet` button**: sends the image to ControlNet in the ixt2img tab.
|
||||
|
||||
### WebUI image galleries
|
||||
In the `txt2txt` and `img2img` tab galleries (where your generated images appear), there will also be a **`Send to Photopea`** button. You can press it to send the currently selected image back to the Photopea tab. It will be added as a new rasterized layer to the currently open document.
|
||||
|
||||
You can also copy and paste the generated results normally into Photopea, and have multiple documents open etc.
|
||||
|
||||
## Code & Usage Licenses
|
||||
I've tried to comment the code thoroughly, especially because it's mostly JS hacks. Feel free to take it apart and reuse it.
|
||||
|
||||
When it comes to usage of the extension, I'm adding restriction guidelines from `CreativeML Open RAIL-M` license.
|
||||
|
||||
You agree not to use the extension or derivatives of the extension:
|
||||
|
||||
- In any way that violates any applicable national, federal, state, local or international law or regulation;
|
||||
|
||||
- For the purpose of exploiting, harming or attempting to exploit or harm minors in any way;
|
||||
|
||||
- To generate or disseminate verifiably false information and/or content with the purpose of harming others;
|
||||
|
||||
- To generate or disseminate personal identifiable information that can be used to harm an individual;
|
||||
|
||||
- To defame, disparage or otherwise harass others;
|
||||
|
||||
- For fully automated decision making that adversely impacts an individual’s legal rights or otherwise creates or modifies a binding, enforceable obligation;
|
||||
|
||||
- For any use intended to or which has the effect of discriminating against or harming individuals or groups based on online or offline social behavior or known or predicted personal or personality characteristics;
|
||||
|
||||
- To exploit any of the vulnerabilities of a specific group of persons based on their age, social, physical or mental characteristics, in order to materially distort the behavior of a person pertaining to that group in a manner that causes or is likely to cause that person or another person physical or psychological harm;
|
||||
|
||||
- For any use intended to or which has the effect of discriminating against individuals or groups based on legally protected characteristics or categories;
|
||||
|
||||
- To provide medical advice and medical results interpretation;
|
||||
|
||||
- To generate or disseminate information for the purpose to be used for administration of justice, law enforcement, immigration or asylum processes, such as predicting an individual will commit fraud/crime commitment (e.g. by text profiling, drawing causal relationships between assertions made in documents, indiscriminate and arbitrarily-targeted use).
|
||||
-----
|
||||
107
javascript/data-utility-functions.js
Normal file
107
javascript/data-utility-functions.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/* Third party utility functions */
|
||||
|
||||
// Turn a base64 string into a blob.
|
||||
// From https://gist.github.com/gauravmehla/7a7dfd87dd7d1b13697b6e894426615f
|
||||
function b64toBlob(b64Data, contentType, sliceSize) {
|
||||
var contentType = contentType || '';
|
||||
var sliceSize = sliceSize || 512;
|
||||
var byteCharacters = atob(b64Data);
|
||||
var byteArrays = [];
|
||||
for (var offset = 0; offset < byteCharacters.length; offset += sliceSize) {
|
||||
var slice = byteCharacters.slice(offset, offset + sliceSize);
|
||||
var byteNumbers = new Array(slice.length);
|
||||
for (var i = 0; i < slice.length; i++) {
|
||||
byteNumbers[i] = slice.charCodeAt(i);
|
||||
}
|
||||
var byteArray = new Uint8Array(byteNumbers);
|
||||
byteArrays.push(byteArray);
|
||||
}
|
||||
return new Blob(byteArrays, { type: contentType });
|
||||
}
|
||||
|
||||
// Turn an image into a b64 string.
|
||||
// From https://stackoverflow.com/questions/6150289/how-can-i-convert-an-image-into-base64-string-using-javascript
|
||||
function blobTob64(url, callback) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.onload = function () {
|
||||
var reader = new FileReader();
|
||||
reader.onloadend = function () {
|
||||
callback(reader.result);
|
||||
}
|
||||
reader.readAsDataURL(xhr.response);
|
||||
};
|
||||
xhr.open('GET', url);
|
||||
xhr.responseType = 'blob';
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
MIT LICENSE
|
||||
Copyright 2011 Jon Leighton
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
// From: https://gist.github.com/jonleighton/958841
|
||||
function base64ArrayBuffer(arrayBuffer) {
|
||||
var base64 = ''
|
||||
var encodings = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
|
||||
var bytes = new Uint8Array(arrayBuffer)
|
||||
var byteLength = bytes.byteLength
|
||||
var byteRemainder = byteLength % 3
|
||||
var mainLength = byteLength - byteRemainder
|
||||
|
||||
var a, b, c, d
|
||||
var chunk
|
||||
|
||||
// Main loop deals with bytes in chunks of 3
|
||||
for (var i = 0; i < mainLength; i = i + 3) {
|
||||
// Combine the three bytes into a single integer
|
||||
chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]
|
||||
|
||||
// Use bitmasks to extract 6-bit segments from the triplet
|
||||
a = (chunk & 16515072) >> 18 // 16515072 = (2^6 - 1) << 18
|
||||
b = (chunk & 258048) >> 12 // 258048 = (2^6 - 1) << 12
|
||||
c = (chunk & 4032) >> 6 // 4032 = (2^6 - 1) << 6
|
||||
d = chunk & 63 // 63 = 2^6 - 1
|
||||
|
||||
// Convert the raw binary segments to the appropriate ASCII encoding
|
||||
base64 += encodings[a] + encodings[b] + encodings[c] + encodings[d]
|
||||
}
|
||||
|
||||
// Deal with the remaining bytes and padding
|
||||
if (byteRemainder == 1) {
|
||||
chunk = bytes[mainLength]
|
||||
|
||||
a = (chunk & 252) >> 2 // 252 = (2^6 - 1) << 2
|
||||
|
||||
// Set the 4 least significant bits to zero
|
||||
b = (chunk & 3) << 4 // 3 = 2^2 - 1
|
||||
|
||||
base64 += encodings[a] + encodings[b] + '=='
|
||||
} else if (byteRemainder == 2) {
|
||||
chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1]
|
||||
|
||||
a = (chunk & 64512) >> 10 // 64512 = (2^6 - 1) << 10
|
||||
b = (chunk & 1008) >> 4 // 1008 = (2^6 - 1) << 4
|
||||
|
||||
// Set the 2 least significant bits to zero
|
||||
c = (chunk & 15) << 2 // 15 = 2^4 - 1
|
||||
|
||||
base64 += encodings[a] + encodings[b] + encodings[c] + '='
|
||||
}
|
||||
|
||||
return base64
|
||||
}
|
||||
281
javascript/photopea-bindings.js
Normal file
281
javascript/photopea-bindings.js
Normal file
@@ -0,0 +1,281 @@
|
||||
/* Setup and navigation */
|
||||
var photopeaWindow = null;
|
||||
var photopeaIframe = null;
|
||||
|
||||
// Called by the iframe set up on photopea-tab.py.
|
||||
function onPhotopeaLoaded(iframe) {
|
||||
console.log("Photopea iFrame loaded");
|
||||
photopeaWindow = iframe.contentWindow;
|
||||
photopeaIframe = iframe;
|
||||
|
||||
// Clone some buttons to send the contents of galleries in txt2img and img2img tab to Photopea.
|
||||
// You can also just copy-paste the images directly but these are the ones I use the most.
|
||||
createSendToPhotopeaButton("image_buttons_txt2img", txt2img_gallery);
|
||||
createSendToPhotopeaButton("image_buttons_img2img", img2img_gallery);
|
||||
|
||||
// Listen to the size slider changes.
|
||||
gradioApp().getElementById("photopeaIframeSlider").addEventListener('input', (event) => {
|
||||
// Get the value of the slider and parse it as an integer
|
||||
const newHeight = parseInt(event.target.value);
|
||||
|
||||
// Update the height of the iframe
|
||||
photopeaIframe.style.height = newHeight + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
// Creates a button in one of the WebUI galleries that will get the currently selected image in the
|
||||
// gallery.
|
||||
// `queryId`: the id for the querySelector to search for the specific gallery list of buttons.
|
||||
// `gallery`: the gallery div itself (cached by WebUI).
|
||||
function createSendToPhotopeaButton(queryId, gallery) {
|
||||
const existingButton = gradioApp().querySelector(`#${queryId} button`);
|
||||
const newButton = existingButton.cloneNode(true);
|
||||
newButton.id = `${queryId}_open_in_photopea`;
|
||||
newButton.textContent = "Send to Photopea";
|
||||
newButton.addEventListener("click", () => openImageInPhotopea(gallery));
|
||||
gradioApp().querySelector(`#${queryId}`).appendChild(newButton);
|
||||
}
|
||||
|
||||
// Switches to the "Photopea" tab by finding and clicking on the DOM button.
|
||||
function goToPhotopeaTab() {
|
||||
// Find Photopea tab button, as we don't know which order it might appear in.
|
||||
const allButtons = gradioApp().querySelector('#tabs').querySelectorAll('button');
|
||||
// The space after the name seems to be added automatically for some reason, so this is likely
|
||||
// flaky across versions. We can't use "contains" because there's also "Send to Photopea"
|
||||
// buttons.
|
||||
photopeaTabButton = Array.from(allButtons).find(button => button.textContent === 'Photopea ');
|
||||
photopeaTabButton.click();
|
||||
}
|
||||
|
||||
// Navigates the UI to the "Inpaint Upload" tab under the img2img tab.
|
||||
// Gradio will destroy and recreate parts of the UI when swapping tabs, so we wait for the page to
|
||||
// be refreshed before trying to find the relevant bits.
|
||||
function goToImg2ImgInpaintUpload(onFinished) {
|
||||
// Start by swapping to the img2img tab.
|
||||
switch_to_img2img();
|
||||
const img2imgdiv = gradioApp().getElementById("mode_img2img");
|
||||
|
||||
waitForWebUiUpdate(img2imgdiv).then(() => {
|
||||
const allButtons = img2imgdiv.querySelectorAll("div.tab-nav > button");
|
||||
const inpaintButton =
|
||||
Array.from(allButtons).find(button => button.textContent === 'Inpaint upload ');
|
||||
inpaintButton.click();
|
||||
onFinished();
|
||||
});
|
||||
}
|
||||
|
||||
/* Image transfer functions */
|
||||
|
||||
// Returns true if the "Active Layer Only" checkbox is ticked, false otherwise.
|
||||
function activeLayerOnly() {
|
||||
return gradioApp()
|
||||
.getElementById("photopea-use-active-layer-only")
|
||||
.querySelector("input[type=checkbox]").checked;
|
||||
}
|
||||
|
||||
// Gets the currently selected image in a WebUI gallery and opens it in Photopea.
|
||||
function openImageInPhotopea(originGallery) {
|
||||
const img = originGallery.querySelectorAll("img")[0].src;
|
||||
goToPhotopeaTab();
|
||||
blobTob64(img, (imageData) => {
|
||||
// When opening a document via the message, Photopea will create it as a smart object layer.
|
||||
// We rasterize it to avoid a few button presses and make it instantly editable.
|
||||
postMessageToPhotopea(`app.open("${imageData}", null, true);`, "*")
|
||||
.then(() => postMessageToPhotopea(`app.activeDocument.activeLayer.rasterize();`, "*"));
|
||||
});
|
||||
}
|
||||
|
||||
// Requests the image from Photopea, converts the array result into a base64 png, then a blob, then
|
||||
// actually send it to the WebUI.
|
||||
function getAndSendImageToWebUITab(webUiTab, sendToControlnet, imageWidgetIndex) {
|
||||
// Photopea only allows exporting the whole image, so in case "Active layer only" is selected in
|
||||
// the UI, instead of just requesting the image to be saved, we also make all non-selected
|
||||
// layers invisible.
|
||||
const saveMessage = activeLayerOnly()
|
||||
? getPhotopeaScriptString(exportSelectedLayerOnly)
|
||||
: 'app.activeDocument.saveToOE("png");';
|
||||
|
||||
postMessageToPhotopea(saveMessage)
|
||||
.then((resultArray) => {
|
||||
// The first index of the payload is an ArrayBuffer of the image. We convert that to
|
||||
// base64 string, then to blob, so it can be sent to a specific image widget in WebUI.
|
||||
// There's likely a direct ArrayBuffer -> Blob conversion, but we're already using b64
|
||||
// as an intermediate format.
|
||||
const base64Png = base64ArrayBuffer(resultArray[0]);
|
||||
sendImageToWebUi(
|
||||
webUiTab,
|
||||
sendToControlnet,
|
||||
imageWidgetIndex,
|
||||
b64toBlob(base64Png, "image/png"));
|
||||
});
|
||||
}
|
||||
|
||||
// Send image to a specific image widget in a Web UI tab. This basically navigates the DOM graph via
|
||||
// queries, and magically presses buttons. You web developers sure work some dark magic.
|
||||
function sendImageToWebUi(webUiTab, sendToControlNet, controlnetModelIndex, blob) {
|
||||
const file = new File([blob], "photopea_output.png")
|
||||
|
||||
switch (webUiTab) {
|
||||
case "txt2img":
|
||||
switch_to_txt2img();
|
||||
break;
|
||||
case "img2img":
|
||||
switch_to_img2img();
|
||||
break;
|
||||
case "extras":
|
||||
switch_to_extras();
|
||||
break;
|
||||
}
|
||||
|
||||
if (sendToControlNet) {
|
||||
// First, select the ControlNet accordion div.
|
||||
const tabId = webUiTab === "txt2img"
|
||||
? "#txt2img_script_container"
|
||||
: "#img2img_script_container";
|
||||
const controlNetDiv = gradioApp().querySelector(tabId).querySelector("#controlnet");
|
||||
// Check if the ControlNet accordion is open by finding the image editing iFrames.
|
||||
setImageOnControlNetInput(controlNetDiv, controlnetModelIndex, file);
|
||||
} else {
|
||||
// For regular tabs, it's less involved - we can simply set the image on input directly.
|
||||
const imageInput = gradioApp().getElementById(`mode_${webUiTab}`).querySelector("input[type='file']");
|
||||
setImageOnInput(imageInput, file);
|
||||
}
|
||||
}
|
||||
|
||||
// I couldn't figure out a way to inject a mask directly on an image widget. So to have an easy way
|
||||
// of masking inpainting via selection, we send the image to "Inpaint Upload", and create a mask
|
||||
// from selection.
|
||||
function sendImageWithMaskSelectionToWebUi() {
|
||||
// Start by verifying if there actually is a selection in the document.
|
||||
postMessageToPhotopea(getPhotopeaScriptString(selectionExists))
|
||||
.then((response) => {
|
||||
if (response[0] === false) {
|
||||
// In case there isn't, do an in-photopea alert (which is less intrusive but more
|
||||
// visible).
|
||||
postMessageToPhotopea(`alert("No selection in active document!");`);
|
||||
} else {
|
||||
// Let's start by swapping to the correct tab. This is a bit more involved due to
|
||||
// Gradio's reconstruction of disabled UI elements.
|
||||
goToImg2ImgInpaintUpload(() => {
|
||||
// In case there is a selection, we'll pass a whole script payload to Photopea
|
||||
// to create the mask and export it.
|
||||
const fullMessage =
|
||||
getPhotopeaScriptString(createMaskFromSelection) + // 1. Create the mask
|
||||
getPhotopeaScriptString(exportSelectedLayerOnly) + // 2. Function that exports the image
|
||||
`app.activeDocument.activeLayer.remove();`; // 3. Removes the temp mask layer
|
||||
|
||||
postMessageToPhotopea(fullMessage).then((resultArray) => {
|
||||
// Set the mask.
|
||||
const base64Png = base64ArrayBuffer(resultArray[0]);
|
||||
const maskInput = gradioApp().getElementById("img_inpaint_mask").querySelector("input");
|
||||
const blob = b64toBlob(base64Png, "image/png");
|
||||
const file = new File([blob], "photopea_output.png");
|
||||
setImageOnInput(maskInput, file);
|
||||
|
||||
// Now go in and get the actual image.
|
||||
const saveMessage = activeLayerOnly()
|
||||
? getPhotopeaScriptString(exportSelectedLayerOnly)
|
||||
: 'app.activeDocument.saveToOE("png");';
|
||||
|
||||
postMessageToPhotopea(saveMessage)
|
||||
.then((resultArray) => {
|
||||
const base64Png = base64ArrayBuffer(resultArray[0]);
|
||||
const baseImgInput = gradioApp().getElementById("img_inpaint_base").querySelector("input");
|
||||
const blob = b64toBlob(base64Png, "image/png");
|
||||
const file = new File([blob], "photopea_output.png");
|
||||
setImageOnInput(baseImgInput, file);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Navigates to the correct ControlNet model tab, then sets the image.
|
||||
function setImageOnControlNetInput(controlNetDiv, controlNetModelIndex, file) {
|
||||
// If we can't find any iframes, the ControlNet accordion is closed.
|
||||
var iframes = controlNetDiv.querySelectorAll("iframe");
|
||||
if (iframes.length == 0) {
|
||||
// The accordion is not open. Find the little icon arrow and click it (yes, if the arrow
|
||||
// ever changes, this will break).
|
||||
controlNetDiv.querySelector("span.icon").click();
|
||||
}
|
||||
waitForWebUiUpdate(controlNetDiv).then(() => {
|
||||
// When more than one Controlnet model is enabled in the WebUI settings, there will be a
|
||||
// series of Controlnet tabs. The one selected in the dropdown will be passed in by the
|
||||
// `controlnetModelIndex`.
|
||||
const tabs = controlNetDiv.querySelectorAll("div.tab-nav > button");
|
||||
if(tabs !== null && tabs.length > 1) {
|
||||
tabs[controlNetModelIndex].click();
|
||||
}
|
||||
|
||||
imageInput = controlNetDiv.querySelectorAll("input[type='file']")[controlNetModelIndex];
|
||||
setImageOnInput(imageInput, file);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Gradio's image widgets are inputs. To set the image in one, we set the image on the input and
|
||||
// force it to refresh.
|
||||
function setImageOnInput(imageInput, file) {
|
||||
// Createa a data transfer element to set as the data in the input.
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
const list = dt.files;
|
||||
|
||||
// Actually set the image in the image widget.
|
||||
imageInput.files = list;
|
||||
|
||||
// Foce the image widget to update with the new image, after setting its source files.
|
||||
const event = new Event('change', {
|
||||
'bubbles': true,
|
||||
"composed": true
|
||||
});
|
||||
imageInput.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// Transforms a JS function body into a string that can be passed as a message to Photopea.
|
||||
function getPhotopeaScriptString(func) {
|
||||
return func.toString() + `${func.name}();`
|
||||
}
|
||||
|
||||
// Posts a message and receives back a promise that will eventually return a 2-element array. One of
|
||||
// them will be Photopea's "done" message, and the other the actual payload.
|
||||
async function postMessageToPhotopea(message) {
|
||||
var request = new Promise(function (resolve, reject) {
|
||||
var responses = [];
|
||||
var photopeaMessageHandle = function (response) {
|
||||
responses.push(response.data);
|
||||
// Photopea will first return the resulting data as a message to the parent window, then
|
||||
// another message saying "done". When we receive the latter, we fulfill the promise.
|
||||
if (response.data == "done") {
|
||||
window.removeEventListener("message", photopeaMessageHandle);
|
||||
resolve(responses)
|
||||
}
|
||||
};
|
||||
// Add a listener to wait for Photopea's response messages.
|
||||
window.addEventListener("message", photopeaMessageHandle);
|
||||
});
|
||||
// Actually execute the request to Photopea.
|
||||
photopeaWindow.postMessage(message, "*");
|
||||
return await request;
|
||||
}
|
||||
|
||||
// Returns a promise that will be resolved when the div passed in the parameter is modified.
|
||||
// This will happen when Gradio reconstructs the UI after, e.g., changing tabs.
|
||||
async function waitForWebUiUpdate(divToWatch) {
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
// Options for the observer (which mutations to observe)
|
||||
const mutationConfig = { attributes: true, childList: true, subtree: true };
|
||||
// Callback for when mutation happened. Will simply invoke the passed `onDivUpdated` and
|
||||
// stop observing.
|
||||
const onMutationHappened = (mutationList, observer) => {
|
||||
observer.disconnect();
|
||||
resolve();
|
||||
}
|
||||
const observer = new MutationObserver(onMutationHappened);
|
||||
observer.observe(divToWatch, mutationConfig);
|
||||
});
|
||||
|
||||
return await promise;
|
||||
}
|
||||
61
javascript/photopea-scripts.js
Normal file
61
javascript/photopea-scripts.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/* Photopea scripts */
|
||||
// The scripts listed here run within Photopea context. We pass them into the app as strings via
|
||||
// POST messages.
|
||||
|
||||
// Hides all layers except the current one, outputs the whole image, then restores the previous
|
||||
// layers state. I'm pretty sure getAndSendImageToWebUITab() using the same code path for this
|
||||
// script and for the regular saveToOE call works out of sheer luck: we register the listener on
|
||||
// postMessageToPhotopea, then receive the data for the internal app.activeDocument.saveToOE("jpg");
|
||||
// below, then its done, and that solves the promise, but we end up with a dangling "done"
|
||||
// response from the script execution message. But hey, if it works... ^^'
|
||||
function exportSelectedLayerOnly() {
|
||||
var allLayers = app.activeDocument.layers;
|
||||
// Make all layers except the currently selected one invisible, and store
|
||||
// their initial state.
|
||||
layerStates = []
|
||||
for (var i = 0; i < allLayers.length; i++) {
|
||||
layerStates.push(allLayers[i].visible)
|
||||
allLayers[i].visible = allLayers[i] == app.activeDocument.activeLayer
|
||||
}
|
||||
|
||||
// Output the image. We output JPG to make sure we don't end up with transparent backgrounds.
|
||||
app.activeDocument.saveToOE("JPG");
|
||||
|
||||
// Restore layers
|
||||
for (var i = 0; i < allLayers.length; i++) {
|
||||
allLayers[i].visible = layerStates[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a black and white mask based on the current selection in the active document.
|
||||
function createMaskFromSelection() {
|
||||
if (app.activeDocument.selection === null) {
|
||||
app.echo("No selection!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a temp layer.
|
||||
newLayer = app.activeDocument.artLayers.add();
|
||||
newLayer.name = "TempMaskLayer";
|
||||
|
||||
// Fill the inverse of the selection with black.
|
||||
app.activeDocument.selection.invert();
|
||||
color = new SolidColor();
|
||||
color.rgb.red = 0
|
||||
color.rgb.green = 0
|
||||
color.rgb.blue = 0
|
||||
app.activeDocument.selection.fill(color);
|
||||
|
||||
// Fill the selected part with white.
|
||||
color.rgb.red = 255
|
||||
color.rgb.green = 255
|
||||
color.rgb.blue = 255
|
||||
app.activeDocument.selection.invert();
|
||||
app.activeDocument.selection.fill(color);
|
||||
}
|
||||
|
||||
function selectionExists() {
|
||||
// This is the best way I could find to figure this out. Seems the `selection` object always
|
||||
// exists, but bounds only has values if a selection exists.
|
||||
app.echoToOE(app.activeDocument.selection.bounds != null);
|
||||
}
|
||||
126
scripts/photopea_tab.py
Normal file
126
scripts/photopea_tab.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import gradio as gr
|
||||
from modules import script_callbacks
|
||||
from modules.shared import opts
|
||||
from modules import extensions
|
||||
|
||||
# Handy constants
|
||||
PHOTOPEA_MAIN_URL = "https://www.photopea.com/"
|
||||
PHOTOPEA_IFRAME_ID = "webui-photopea-iframe"
|
||||
PHOTOPEA_IFRAME_HEIGHT = 768
|
||||
PHOTOPEA_IFRAME_WIDTH = "100%"
|
||||
PHOTOPEA_IFRAME_LOADED_EVENT = "onPhotopeaLoaded"
|
||||
|
||||
|
||||
# Adds the "Photopea" tab to the WebUI
|
||||
def on_ui_tabs():
|
||||
with gr.Blocks(analytics_enabled=False) as photopea_tab:
|
||||
# Check if Controlnet is installed and enabled in settings, so we can show or hide the "Send to Controlnet" buttons.
|
||||
controlnet_exists = False
|
||||
for extension in extensions.active():
|
||||
if "controlnet" in extension.name:
|
||||
controlnet_exists = True
|
||||
break
|
||||
|
||||
with gr.Row():
|
||||
# Add an iframe with Photopea directly in the tab.
|
||||
gr.HTML(
|
||||
f"""<iframe id="{PHOTOPEA_IFRAME_ID}"
|
||||
src = "{PHOTOPEA_MAIN_URL}{get_photopea_url_params()}"
|
||||
width = "{PHOTOPEA_IFRAME_WIDTH}"
|
||||
height = "{PHOTOPEA_IFRAME_HEIGHT}"
|
||||
onload = "{PHOTOPEA_IFRAME_LOADED_EVENT}(this)">"""
|
||||
)
|
||||
with gr.Row():
|
||||
gr.Checkbox(
|
||||
label="Active Layer Only",
|
||||
info="If true, instead of sending the flattened image, will send just the currently selected layer.",
|
||||
elem_id="photopea-use-active-layer-only",
|
||||
)
|
||||
# Controlnet might have more than one model tab (set by the 'control_net_max_models_num' setting).
|
||||
try:
|
||||
num_controlnet_models = opts.control_net_max_models_num
|
||||
except:
|
||||
num_controlnet_models = 1
|
||||
|
||||
select_target_index = gr.Dropdown(
|
||||
[str(i) for i in range(num_controlnet_models)],
|
||||
label="ControlNet model index",
|
||||
value="0",
|
||||
interactive=True,
|
||||
visible=num_controlnet_models > 1,
|
||||
)
|
||||
|
||||
# Just create the size slider here. We'll modify the page via the js bindings.
|
||||
gr.Slider(
|
||||
minimum=512,
|
||||
maximum=2160,
|
||||
value=768,
|
||||
step=10,
|
||||
label="iFrame height",
|
||||
interactive=True,
|
||||
elem_id="photopeaIframeSlider",
|
||||
)
|
||||
|
||||
with gr.Row():
|
||||
with gr.Column():
|
||||
gr.HTML(
|
||||
"""<b>Controlnet extension not found!</b> Either <a href="https://github.com/Mikubill/sd-webui-controlnet" target="_blank">install it</a>, or activate it under Settings.""",
|
||||
visible=not controlnet_exists,
|
||||
)
|
||||
send_t2i_cn = gr.Button(
|
||||
value="Send to txt2img ControlNet", visible=controlnet_exists
|
||||
)
|
||||
send_extras = gr.Button(value="Send to Extras")
|
||||
|
||||
with gr.Column():
|
||||
send_i2i = gr.Button(value="Send to img2img")
|
||||
send_i2i_cn = gr.Button(
|
||||
value="Send to img2img ControlNet", visible=controlnet_exists
|
||||
)
|
||||
with gr.Column():
|
||||
send_selection_inpaint = gr.Button(value="Inpaint selection")
|
||||
|
||||
with gr.Row():
|
||||
gr.HTML(
|
||||
"""<font size="small"><p align="right">Consider supporting Photopea by <a href="https://www.photopea.com/api/accounts" target="_blank">going Premium</a>!</font></p>"""
|
||||
)
|
||||
# The getAndSendImageToWebUITab in photopea-bindings.js takes the following parameters:
|
||||
# webUiTab: the name of the tab. Used to find the gallery via DOM queries.
|
||||
# sendToControlnet: if true, tries to send it to a specific ControlNet widget, otherwise, sends to the native WebUI widget.
|
||||
# controlnetModelIndex: the index of the desired controlnet model tab.
|
||||
send_t2i_cn.click(
|
||||
None,
|
||||
select_target_index,
|
||||
None,
|
||||
_js="(i) => {getAndSendImageToWebUITab('txt2img', true, i)}",
|
||||
)
|
||||
send_extras.click(
|
||||
None,
|
||||
select_target_index,
|
||||
None,
|
||||
_js="(i) => {getAndSendImageToWebUITab('extras', false, i)}",
|
||||
)
|
||||
send_i2i.click(
|
||||
None,
|
||||
select_target_index,
|
||||
None,
|
||||
_js="(i) => {getAndSendImageToWebUITab('img2img', false, i)}",
|
||||
)
|
||||
send_i2i_cn.click(
|
||||
None,
|
||||
select_target_index,
|
||||
None,
|
||||
_js="(i) => {getAndSendImageToWebUITab('img2img', true, i)}",
|
||||
)
|
||||
send_selection_inpaint.click(fn=None, _js="sendImageWithMaskSelectionToWebUi")
|
||||
|
||||
return [(photopea_tab, "Photopea", "photopea_embed")]
|
||||
|
||||
|
||||
# Initialize Photopea with an empty, 512x512 white image. It's baked as a base64 string with URI encoding.
|
||||
def get_photopea_url_params():
|
||||
return "#%7B%22resources%22:%5B%22data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIAAQMAAADOtka5AAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAANQTFRF////p8QbyAAAADZJREFUeJztwQEBAAAAgiD/r25IQAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfBuCAAAB0niJ8AAAAABJRU5ErkJggg==%22%5D%7D"
|
||||
|
||||
|
||||
# Actually hooks up the tab to the WebUI tabs.
|
||||
script_callbacks.on_ui_tabs(on_ui_tabs)
|
||||
Reference in New Issue
Block a user