Spaces:
Runtime error
Runtime error
(function () { | |
/* | |
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 | |
} | |
// 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 }); | |
} | |
function createBlackImageBase64(width, height) { | |
// Create a canvas element | |
var canvas = document.createElement('canvas'); | |
canvas.width = width; | |
canvas.height = height; | |
// Get the context of the canvas | |
var ctx = canvas.getContext('2d'); | |
// Fill the canvas with black color | |
ctx.fillStyle = 'black'; | |
ctx.fillRect(0, 0, width, height); | |
// Get the base64 encoded string | |
var base64Image = canvas.toDataURL('image/png'); | |
return base64Image; | |
} | |
// Functions to be called within photopea context. | |
// Start of photopea functions | |
function pasteImage(base64image) { | |
app.open(base64image, null, /* asSmart */ true); | |
app.echoToOE("success"); | |
} | |
function setLayerNames(names) { | |
const layers = app.activeDocument.layers; | |
if (layers.length !== names.length) { | |
console.error("layer length does not match names length"); | |
echoToOE("error"); | |
return; | |
} | |
for (let i = 0; i < names.length; i++) { | |
const layer = layers[i]; | |
layer.name = names[i]; | |
} | |
app.echoToOE("success"); | |
} | |
function removeLayersWithNames(names) { | |
const layers = app.activeDocument.layers; | |
for (let i = 0; i < layers.length; i++) { | |
const layer = layers[i]; | |
if (names.includes(layer.name)) { | |
layer.remove(); | |
} | |
} | |
app.echoToOE("success"); | |
} | |
function getAllLayerNames() { | |
const layers = app.activeDocument.layers; | |
const names = []; | |
for (let i = 0; i < layers.length; i++) { | |
const layer = layers[i]; | |
names.push(layer.name); | |
} | |
app.echoToOE(JSON.stringify(names)); | |
} | |
// Hides all layers except the current one, outputs the whole image, then restores the previous | |
// layers state. | |
function exportSelectedLayerOnly(format, layerName) { | |
// Gets all layers recursively, including the ones inside folders. | |
function getAllArtLayers(document) { | |
let allArtLayers = []; | |
for (let i = 0; i < document.layers.length; i++) { | |
const currentLayer = document.layers[i]; | |
allArtLayers.push(currentLayer); | |
if (currentLayer.typename === "LayerSet") { | |
allArtLayers = allArtLayers.concat(getAllArtLayers(currentLayer)); | |
} | |
} | |
return allArtLayers; | |
} | |
function makeLayerVisible(layer) { | |
let currentLayer = layer; | |
while (currentLayer != app.activeDocument) { | |
currentLayer.visible = true; | |
if (currentLayer.parent.typename != 'Document') { | |
currentLayer = currentLayer.parent; | |
} else { | |
break; | |
} | |
} | |
} | |
const allLayers = getAllArtLayers(app.activeDocument); | |
// Make all layers except the currently selected one invisible, and store | |
// their initial state. | |
const layerStates = []; | |
for (let i = 0; i < allLayers.length; i++) { | |
const layer = allLayers[i]; | |
layerStates.push(layer.visible); | |
} | |
// Hide all layers to begin with | |
for (let i = 0; i < allLayers.length; i++) { | |
const layer = allLayers[i]; | |
layer.visible = false; | |
} | |
for (let i = 0; i < allLayers.length; i++) { | |
const layer = allLayers[i]; | |
const selected = layer.name === layerName; | |
if (selected) { | |
makeLayerVisible(layer); | |
} | |
} | |
app.activeDocument.saveToOE(format); | |
for (let i = 0; i < allLayers.length; i++) { | |
const layer = allLayers[i]; | |
layer.visible = layerStates[i]; | |
} | |
} | |
function hasActiveDocument() { | |
app.echoToOE(app.documents.length > 0 ? "true" : "false"); | |
} | |
// End of photopea functions | |
const MESSAGE_END_ACK = "done"; | |
const MESSAGE_ERROR = "error"; | |
const PHOTOPEA_URL = "https://www.photopea.com/"; | |
class PhotopeaContext { | |
constructor(photopeaIframe) { | |
this.photopeaIframe = photopeaIframe; | |
this.timeout = 1000; | |
} | |
navigateIframe() { | |
const iframe = this.photopeaIframe; | |
const editorURL = PHOTOPEA_URL; | |
return new Promise(async (resolve) => { | |
if (iframe.src !== editorURL) { | |
iframe.src = editorURL; | |
// Stop waiting after 10s. | |
setTimeout(resolve, 10000); | |
// Testing whether photopea is able to accept message. | |
while (true) { | |
try { | |
await this.invoke(hasActiveDocument); | |
break; | |
} catch (e) { | |
console.log("Keep waiting for photopea to accept message."); | |
} | |
} | |
this.timeout = 5000; // Restore to a longer timeout in normal messaging. | |
} | |
resolve(); | |
}); | |
} | |
// From https://github.com/huchenlei/stable-diffusion-ps-pea/blob/main/src/Photopea.ts | |
postMessageToPhotopea(message) { | |
return new Promise((resolve, reject) => { | |
const responseDataPieces = []; | |
let hasError = false; | |
const photopeaMessageHandle = (event) => { | |
if (event.source !== this.photopeaIframe.contentWindow) { | |
return; | |
} | |
// Filter out the ping messages | |
if (typeof event.data === 'string' && event.data.includes('MSFAPI#')) { | |
return; | |
} | |
// Ignore "done" when no data has been received. The "done" can come from | |
// MSFAPI ping. | |
if (event.data === MESSAGE_END_ACK && responseDataPieces.length === 0) { | |
return; | |
} | |
if (event.data === MESSAGE_END_ACK) { | |
window.removeEventListener("message", photopeaMessageHandle); | |
if (hasError) { | |
reject('Photopea Error.'); | |
} else { | |
resolve(responseDataPieces.length === 1 ? responseDataPieces[0] : responseDataPieces); | |
} | |
} else if (event.data === MESSAGE_ERROR) { | |
responseDataPieces.push(event.data); | |
hasError = true; | |
} else { | |
responseDataPieces.push(event.data); | |
} | |
}; | |
window.addEventListener("message", photopeaMessageHandle); | |
setTimeout(() => reject("Photopea message timeout"), this.timeout); | |
this.photopeaIframe.contentWindow.postMessage(message, "*"); | |
}); | |
} | |
// From https://github.com/huchenlei/stable-diffusion-ps-pea/blob/main/src/Photopea.ts | |
async invoke(func, ...args) { | |
await this.navigateIframe(); | |
const message = `${func.toString()} ${func.name}(${args.map(arg => JSON.stringify(arg)).join(',')});`; | |
try { | |
return await this.postMessageToPhotopea(message); | |
} catch (e) { | |
throw `Failed to invoke ${func.name}. ${e}.`; | |
} | |
} | |
/** | |
* Fetch detected maps from each ControlNet units. | |
* Create a new photopea document. | |
* Add those detected maps to the created document. | |
*/ | |
async fetchFromControlNet(tabs) { | |
if (tabs.length === 0) return; | |
const isImg2Img = tabs[0].querySelector('.cnet-mask-upload').id.includes('img2img'); | |
const generationType = isImg2Img ? 'img2img' : 'txt2img'; | |
const width = gradioApp().querySelector(`#${generationType}_width input[type=number]`).value; | |
const height = gradioApp().querySelector(`#${generationType}_height input[type=number]`).value; | |
const layerNames = ["background"]; | |
await this.invoke(pasteImage, createBlackImageBase64(width, height)); | |
await new Promise(r => setTimeout(r, 200)); | |
for (const [i, tab] of tabs.entries()) { | |
const generatedImage = tab.querySelector('.cnet-generated-image-group .cnet-image img'); | |
if (!generatedImage) continue; | |
await this.invoke(pasteImage, generatedImage.src); | |
// Wait 200ms for pasting to fully complete so that we do not ended up with 2 separate | |
// documents. | |
await new Promise(r => setTimeout(r, 200)); | |
layerNames.push(`unit-${i}`); | |
} | |
await this.invoke(removeLayersWithNames, layerNames); | |
await this.invoke(setLayerNames, layerNames.reverse()); | |
} | |
/** | |
* Send the images in the active photopea document back to each ControlNet units. | |
*/ | |
async sendToControlNet(tabs) { | |
// 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); | |
} | |
function sendToControlNetUnit(b64Image, index) { | |
const tab = tabs[index]; | |
// Upload image to output image element. | |
const outputImage = tab.querySelector('.cnet-photopea-output'); | |
const outputImageUpload = outputImage.querySelector('input[type="file"]'); | |
setImageOnInput(outputImageUpload, new File([b64toBlob(b64Image, "image/png")], "photopea_output.png")); | |
// Make sure `UsePreviewAsInput` checkbox is checked. | |
const checkbox = tab.querySelector('.cnet-preview-as-input input[type="checkbox"]'); | |
if (!checkbox.checked) { | |
checkbox.click(); | |
} | |
} | |
const layerNames = | |
JSON.parse(await this.invoke(getAllLayerNames)) | |
.filter(name => /unit-\d+/.test(name)); | |
for (const layerName of layerNames) { | |
const arrayBuffer = await this.invoke(exportSelectedLayerOnly, 'PNG', layerName); | |
const b64Image = base64ArrayBuffer(arrayBuffer); | |
const layerIndex = Number.parseInt(layerName.split('-')[1]); | |
sendToControlNetUnit(b64Image, layerIndex); | |
} | |
} | |
} | |
let photopeaWarningShown = false; | |
function firstTimeUserPrompt() { | |
if (opts.controlnet_photopea_warning){ | |
const photopeaPopupMsg = "you are about to connect to https://photopea.com\n" + | |
"- Click OK: proceed.\n" + | |
"- Click Cancel: abort.\n" + | |
"Photopea integration can be disabled in Settings > ControlNet > Disable photopea edit.\n" + | |
"This popup can be disabled in Settings > ControlNet > Photopea popup warning."; | |
if (photopeaWarningShown || confirm(photopeaPopupMsg)) photopeaWarningShown = true; | |
else return false; | |
} | |
return true; | |
} | |
const cnetRegisteredAccordions = new Set(); | |
function loadPhotopea() { | |
function registerCallbacks(accordion) { | |
const photopeaMainTrigger = accordion.querySelector('.cnet-photopea-main-trigger'); | |
// Photopea edit feature disabled. | |
if (!photopeaMainTrigger) { | |
console.log("ControlNet photopea edit disabled."); | |
return; | |
} | |
const closeModalButton = accordion.querySelector('.cnet-photopea-edit .cnet-modal-close'); | |
const tabs = accordion.querySelectorAll('.controlnet .input-accordion'); | |
const photopeaIframe = accordion.querySelector('.photopea-iframe'); | |
const photopeaContext = new PhotopeaContext(photopeaIframe, tabs); | |
tabs.forEach(tab => { | |
const photopeaChildTrigger = tab.querySelector('.cnet-photopea-child-trigger'); | |
photopeaChildTrigger.addEventListener('click', async () => { | |
if (!firstTimeUserPrompt()) return; | |
photopeaMainTrigger.click(); | |
if (await photopeaContext.invoke(hasActiveDocument) === "false") { | |
await photopeaContext.fetchFromControlNet(tabs); | |
} | |
}); | |
}); | |
accordion.querySelector('.photopea-fetch').addEventListener('click', () => photopeaContext.fetchFromControlNet(tabs)); | |
accordion.querySelector('.photopea-send').addEventListener('click', () => { | |
photopeaContext.sendToControlNet(tabs) | |
closeModalButton.click(); | |
}); | |
} | |
const accordions = gradioApp().querySelectorAll('#controlnet'); | |
accordions.forEach(accordion => { | |
if (cnetRegisteredAccordions.has(accordion)) return; | |
registerCallbacks(accordion); | |
cnetRegisteredAccordions.add(accordion); | |
}); | |
} | |
onUiUpdate(loadPhotopea); | |
})(); |