mirror of
https://github.com/lllyasviel/stable-diffusion-webui-forge.git
synced 2026-03-07 22:19:49 +00:00
upload a cn
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Give a badge on ControlNet Accordion indicating total number of active
|
||||
* units.
|
||||
* Make active unit's tab name green.
|
||||
* Append control type to tab name.
|
||||
* Disable resize mode selection when A1111 img2img input is used.
|
||||
*/
|
||||
(function () {
|
||||
const cnetAllAccordions = new Set();
|
||||
onUiUpdate(() => {
|
||||
const ImgChangeType = {
|
||||
NO_CHANGE: 0,
|
||||
REMOVE: 1,
|
||||
ADD: 2,
|
||||
SRC_CHANGE: 3,
|
||||
};
|
||||
|
||||
function imgChangeObserved(mutationsList) {
|
||||
// Iterate over all mutations that just occured
|
||||
for (let mutation of mutationsList) {
|
||||
// Check if the mutation is an addition or removal of a node
|
||||
if (mutation.type === 'childList') {
|
||||
// Check if nodes were added
|
||||
if (mutation.addedNodes.length > 0) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.tagName === 'IMG') {
|
||||
return ImgChangeType.ADD;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if nodes were removed
|
||||
if (mutation.removedNodes.length > 0) {
|
||||
for (const node of mutation.removedNodes) {
|
||||
if (node.tagName === 'IMG') {
|
||||
return ImgChangeType.REMOVE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check if the mutation is a change of an attribute
|
||||
else if (mutation.type === 'attributes') {
|
||||
if (mutation.target.tagName === 'IMG' && mutation.attributeName === 'src') {
|
||||
return ImgChangeType.SRC_CHANGE;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ImgChangeType.NO_CHANGE;
|
||||
}
|
||||
|
||||
function childIndex(element) {
|
||||
// Get all child nodes of the parent
|
||||
let children = Array.from(element.parentNode.childNodes);
|
||||
|
||||
// Filter out non-element nodes (like text nodes and comments)
|
||||
children = children.filter(child => child.nodeType === Node.ELEMENT_NODE);
|
||||
|
||||
return children.indexOf(element);
|
||||
}
|
||||
|
||||
function imageInputDisabledAlert() {
|
||||
alert('Inpaint control type must use a1111 input in img2img mode.');
|
||||
}
|
||||
|
||||
class ControlNetUnitTab {
|
||||
constructor(tab, accordion) {
|
||||
this.tab = tab;
|
||||
this.accordion = accordion;
|
||||
this.isImg2Img = tab.querySelector('.cnet-unit-enabled').id.includes('img2img');
|
||||
|
||||
this.enabledCheckbox = tab.querySelector('.cnet-unit-enabled input');
|
||||
this.inputImage = tab.querySelector('.cnet-input-image-group .cnet-image input[type="file"]');
|
||||
this.inputImageContainer = tab.querySelector('.cnet-input-image-group .cnet-image');
|
||||
this.controlTypeRadios = tab.querySelectorAll('.controlnet_control_type_filter_group input[type="radio"]');
|
||||
this.resizeModeRadios = tab.querySelectorAll('.controlnet_resize_mode_radio input[type="radio"]');
|
||||
this.runPreprocessorButton = tab.querySelector('.cnet-run-preprocessor');
|
||||
|
||||
const tabs = tab.parentNode;
|
||||
this.tabNav = tabs.querySelector('.tab-nav');
|
||||
this.tabIndex = childIndex(tab) - 1; // -1 because tab-nav is also at the same level.
|
||||
|
||||
this.attachEnabledButtonListener();
|
||||
this.attachControlTypeRadioListener();
|
||||
this.attachTabNavChangeObserver();
|
||||
this.attachImageUploadListener();
|
||||
this.attachImageStateChangeObserver();
|
||||
this.attachA1111SendInfoObserver();
|
||||
this.attachPresetDropdownObserver();
|
||||
}
|
||||
|
||||
getTabNavButton() {
|
||||
return this.tabNav.querySelector(`:nth-child(${this.tabIndex + 1})`);
|
||||
}
|
||||
|
||||
getActiveControlType() {
|
||||
for (let radio of this.controlTypeRadios) {
|
||||
if (radio.checked) {
|
||||
return radio.value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
updateActiveState() {
|
||||
const tabNavButton = this.getTabNavButton();
|
||||
if (!tabNavButton) return;
|
||||
|
||||
if (this.enabledCheckbox.checked) {
|
||||
tabNavButton.classList.add('cnet-unit-active');
|
||||
} else {
|
||||
tabNavButton.classList.remove('cnet-unit-active');
|
||||
}
|
||||
}
|
||||
|
||||
updateActiveUnitCount() {
|
||||
function getActiveUnitCount(checkboxes) {
|
||||
let activeUnitCount = 0;
|
||||
for (const checkbox of checkboxes) {
|
||||
if (checkbox.checked)
|
||||
activeUnitCount++;
|
||||
}
|
||||
return activeUnitCount;
|
||||
}
|
||||
|
||||
const checkboxes = this.accordion.querySelectorAll('.cnet-unit-enabled input');
|
||||
const span = this.accordion.querySelector('.label-wrap span');
|
||||
|
||||
// Remove existing badge.
|
||||
if (span.childNodes.length !== 1) {
|
||||
span.removeChild(span.lastChild);
|
||||
}
|
||||
// Add new badge if necessary.
|
||||
const activeUnitCount = getActiveUnitCount(checkboxes);
|
||||
if (activeUnitCount > 0) {
|
||||
const div = document.createElement('div');
|
||||
div.classList.add('cnet-badge');
|
||||
div.classList.add('primary');
|
||||
div.innerHTML = `${activeUnitCount} unit${activeUnitCount > 1 ? 's' : ''}`;
|
||||
span.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the active control type to tab displayed text.
|
||||
*/
|
||||
updateActiveControlType() {
|
||||
const tabNavButton = this.getTabNavButton();
|
||||
if (!tabNavButton) return;
|
||||
|
||||
// Remove the control if exists
|
||||
const controlTypeSuffix = tabNavButton.querySelector('.control-type-suffix');
|
||||
if (controlTypeSuffix) controlTypeSuffix.remove();
|
||||
|
||||
// Add new suffix.
|
||||
const controlType = this.getActiveControlType();
|
||||
if (controlType === 'All') return;
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = `[${controlType}]`;
|
||||
span.classList.add('control-type-suffix');
|
||||
tabNavButton.appendChild(span);
|
||||
}
|
||||
|
||||
/**
|
||||
* When 'Inpaint' control type is selected in img2img:
|
||||
* - Make image input disabled
|
||||
* - Clear existing image input
|
||||
*/
|
||||
updateImageInputState() {
|
||||
if (!this.isImg2Img) return;
|
||||
|
||||
const tabNavButton = this.getTabNavButton();
|
||||
if (!tabNavButton) return;
|
||||
|
||||
const controlType = this.getActiveControlType();
|
||||
if (controlType.toLowerCase() === 'inpaint') {
|
||||
this.inputImage.disabled = true;
|
||||
this.inputImage.parentNode.addEventListener('click', imageInputDisabledAlert);
|
||||
const removeButton = this.tab.querySelector(
|
||||
'.cnet-input-image-group .cnet-image button[aria-label="Remove Image"]');
|
||||
if (removeButton) removeButton.click();
|
||||
} else {
|
||||
this.inputImage.disabled = false;
|
||||
this.inputImage.parentNode.removeEventListener('click', imageInputDisabledAlert);
|
||||
}
|
||||
}
|
||||
|
||||
attachEnabledButtonListener() {
|
||||
this.enabledCheckbox.addEventListener('change', () => {
|
||||
this.updateActiveState();
|
||||
this.updateActiveUnitCount();
|
||||
});
|
||||
}
|
||||
|
||||
attachControlTypeRadioListener() {
|
||||
for (const radio of this.controlTypeRadios) {
|
||||
radio.addEventListener('change', () => {
|
||||
this.updateActiveControlType();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Each time the active tab change, all tab nav buttons are cleared and
|
||||
* regenerated by gradio. So we need to reapply the active states on
|
||||
* them.
|
||||
*/
|
||||
attachTabNavChangeObserver() {
|
||||
new MutationObserver((mutationsList) => {
|
||||
for (const mutation of mutationsList) {
|
||||
if (mutation.type === 'childList') {
|
||||
this.updateActiveState();
|
||||
this.updateActiveControlType();
|
||||
}
|
||||
}
|
||||
}).observe(this.tabNav, { childList: true });
|
||||
}
|
||||
|
||||
attachImageUploadListener() {
|
||||
// Automatically check `enable` checkbox when image is uploaded.
|
||||
this.inputImage.addEventListener('change', (event) => {
|
||||
if (!event.target.files) return;
|
||||
if (!this.enabledCheckbox.checked)
|
||||
this.enabledCheckbox.click();
|
||||
});
|
||||
|
||||
// Automatically check `enable` checkbox when JSON pose file is uploaded.
|
||||
this.tab.querySelector('.cnet-upload-pose input').addEventListener('change', (event) => {
|
||||
if (!event.target.files) return;
|
||||
if (!this.enabledCheckbox.checked)
|
||||
this.enabledCheckbox.click();
|
||||
});
|
||||
}
|
||||
|
||||
attachImageStateChangeObserver() {
|
||||
new MutationObserver((mutationsList) => {
|
||||
const changeObserved = imgChangeObserved(mutationsList);
|
||||
|
||||
if (changeObserved === ImgChangeType.ADD) {
|
||||
// enabling the run preprocessor button
|
||||
this.runPreprocessorButton.removeAttribute("disabled");
|
||||
this.runPreprocessorButton.title = 'Run preprocessor';
|
||||
}
|
||||
|
||||
if (changeObserved === ImgChangeType.REMOVE) {
|
||||
// disabling the run preprocessor button
|
||||
this.runPreprocessorButton.setAttribute("disabled", true);
|
||||
this.runPreprocessorButton.title = "No ControlNet input image available";
|
||||
}
|
||||
}).observe(this.inputImageContainer, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe send PNG info buttons in A1111, as they can also directly
|
||||
* set states of ControlNetUnit.
|
||||
*/
|
||||
attachA1111SendInfoObserver() {
|
||||
const pasteButtons = gradioApp().querySelectorAll('#paste');
|
||||
const pngButtons = gradioApp().querySelectorAll(
|
||||
this.isImg2Img ?
|
||||
'#img2img_tab, #inpaint_tab' :
|
||||
'#txt2img_tab'
|
||||
);
|
||||
|
||||
for (const button of [...pasteButtons, ...pngButtons]) {
|
||||
button.addEventListener('click', () => {
|
||||
// The paste/send img generation info feature goes
|
||||
// though gradio, which is pretty slow. Ideally we should
|
||||
// observe the event when gradio has done the job, but
|
||||
// that is not an easy task.
|
||||
// Here we just do a 2 second delay until the refresh.
|
||||
setTimeout(() => {
|
||||
this.updateActiveState();
|
||||
this.updateActiveUnitCount();
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
attachPresetDropdownObserver() {
|
||||
const presetDropDown = this.tab.querySelector('.cnet-preset-dropdown');
|
||||
|
||||
new MutationObserver((mutationsList) => {
|
||||
for (const mutation of mutationsList) {
|
||||
if (mutation.removedNodes.length > 0) {
|
||||
setTimeout(() => {
|
||||
this.updateActiveState();
|
||||
this.updateActiveUnitCount();
|
||||
this.updateActiveControlType();
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}).observe(presetDropDown, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
gradioApp().querySelectorAll('#controlnet').forEach(accordion => {
|
||||
if (cnetAllAccordions.has(accordion)) return;
|
||||
accordion.querySelectorAll('.cnet-unit-tab')
|
||||
.forEach(tab => new ControlNetUnitTab(tab, accordion));
|
||||
cnetAllAccordions.add(accordion);
|
||||
});
|
||||
});
|
||||
})();
|
||||
17
extensions-builtin/sd_forge_controlnet/javascript/canvas.js
Normal file
17
extensions-builtin/sd_forge_controlnet/javascript/canvas.js
Normal file
@@ -0,0 +1,17 @@
|
||||
(function () {
|
||||
var hasApplied = false;
|
||||
onUiUpdate(function () {
|
||||
if (!hasApplied) {
|
||||
if (typeof window.applyZoomAndPanIntegration === "function") {
|
||||
hasApplied = true;
|
||||
window.applyZoomAndPanIntegration("#txt2img_controlnet", Array.from({ length: 20 }, (_, i) => `#txt2img_controlnet_ControlNet-${i}_input_image`));
|
||||
window.applyZoomAndPanIntegration("#img2img_controlnet", Array.from({ length: 20 }, (_, i) => `#img2img_controlnet_ControlNet-${i}_input_image`));
|
||||
window.applyZoomAndPanIntegration("#txt2img_controlnet", ["#txt2img_controlnet_ControlNet_input_image"]);
|
||||
window.applyZoomAndPanIntegration("#img2img_controlnet", ["#img2img_controlnet_ControlNet_input_image"]);
|
||||
//console.log("window.applyZoomAndPanIntegration applied.");
|
||||
} else {
|
||||
//console.log("window.applyZoomAndPanIntegration is not available.");
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
33
extensions-builtin/sd_forge_controlnet/javascript/modal.js
Normal file
33
extensions-builtin/sd_forge_controlnet/javascript/modal.js
Normal file
@@ -0,0 +1,33 @@
|
||||
(function () {
|
||||
const cnetModalRegisteredElements = new Set();
|
||||
onUiUpdate(() => {
|
||||
// Get all the buttons that open a modal
|
||||
const btns = gradioApp().querySelectorAll(".cnet-modal-open");
|
||||
|
||||
// Get all the <span> elements that close a modal
|
||||
const spans = document.querySelectorAll(".cnet-modal-close");
|
||||
|
||||
// For each button, add a click event listener that opens the corresponding modal
|
||||
btns.forEach((btn) => {
|
||||
if (cnetModalRegisteredElements.has(btn)) return;
|
||||
cnetModalRegisteredElements.add(btn);
|
||||
|
||||
const modalId = btn.id.replace('cnet-modal-open-', '');
|
||||
const modal = document.getElementById("cnet-modal-" + modalId);
|
||||
btn.addEventListener('click', () => {
|
||||
modal.style.display = "block";
|
||||
});
|
||||
});
|
||||
|
||||
// For each <span> element, add a click event listener that closes the corresponding modal
|
||||
spans.forEach((span) => {
|
||||
if (cnetModalRegisteredElements.has(span)) return;
|
||||
cnetModalRegisteredElements.add(span);
|
||||
|
||||
const modal = span.parentNode;
|
||||
span.addEventListener('click', () => {
|
||||
modal.style.display = "none";
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,152 @@
|
||||
(function () {
|
||||
async function checkEditorAvailable() {
|
||||
const LOCAL_EDITOR_PATH = '/openpose_editor_index';
|
||||
const REMOTE_EDITOR_PATH = 'https://huchenlei.github.io/sd-webui-openpose-editor/';
|
||||
|
||||
async function testEditorPath(path) {
|
||||
const res = await fetch(path);
|
||||
return res.status === 200 ? path : null;
|
||||
}
|
||||
|
||||
// Use local editor if the user has the extension installed. Fallback
|
||||
// onto remote editor if the local editor is not ready yet.
|
||||
// See https://github.com/huchenlei/sd-webui-openpose-editor/issues/53
|
||||
// for more details.
|
||||
return await testEditorPath(LOCAL_EDITOR_PATH) || await testEditorPath(REMOTE_EDITOR_PATH);
|
||||
}
|
||||
|
||||
const cnetOpenposeEditorRegisteredElements = new Set();
|
||||
let editorURL = null;
|
||||
function loadOpenposeEditor() {
|
||||
// Simulate an `input` DOM event for Gradio Textbox component. Needed after you edit its contents in javascript, otherwise your edits
|
||||
// will only visible on web page and not sent to python.
|
||||
function updateInput(target) {
|
||||
let e = new Event("input", { bubbles: true })
|
||||
Object.defineProperty(e, "target", { value: target })
|
||||
target.dispatchEvent(e);
|
||||
}
|
||||
|
||||
function navigateIframe(iframe, editorURL) {
|
||||
function getPathname(rawURL) {
|
||||
try {
|
||||
return new URL(rawURL).pathname;
|
||||
} catch (e) {
|
||||
return rawURL;
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const darkThemeParam = document.body.classList.contains('dark') ?
|
||||
new URLSearchParams({ theme: 'dark' }).toString() :
|
||||
'';
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const message = event.data;
|
||||
if (message['ready']) resolve();
|
||||
}, { once: true });
|
||||
|
||||
if ((editorURL.startsWith("http") ? iframe.src : getPathname(iframe.src)) !== editorURL) {
|
||||
iframe.src = `${editorURL}?${darkThemeParam}`;
|
||||
// By default assume 5 second is enough for the openpose editor
|
||||
// to load.
|
||||
setTimeout(resolve, 5000);
|
||||
} else {
|
||||
// If no navigation is required, immediately return.
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
const tabs = gradioApp().querySelectorAll('.cnet-unit-tab');
|
||||
tabs.forEach(tab => {
|
||||
if (cnetOpenposeEditorRegisteredElements.has(tab)) return;
|
||||
cnetOpenposeEditorRegisteredElements.add(tab);
|
||||
|
||||
const generatedImageGroup = tab.querySelector('.cnet-generated-image-group');
|
||||
const editButton = generatedImageGroup.querySelector('.cnet-edit-pose');
|
||||
|
||||
editButton.addEventListener('click', async () => {
|
||||
const inputImageGroup = tab.querySelector('.cnet-input-image-group');
|
||||
const inputImage = inputImageGroup.querySelector('.cnet-image img');
|
||||
const downloadLink = generatedImageGroup.querySelector('.cnet-download-pose a');
|
||||
const modalId = editButton.id.replace('cnet-modal-open-', '');
|
||||
const modalIframe = generatedImageGroup.querySelector('.cnet-modal iframe');
|
||||
|
||||
if (!editorURL) {
|
||||
editorURL = await checkEditorAvailable();
|
||||
if (!editorURL) {
|
||||
alert("No openpose editor available.")
|
||||
}
|
||||
}
|
||||
|
||||
await navigateIframe(modalIframe, editorURL);
|
||||
modalIframe.contentWindow.postMessage({
|
||||
modalId,
|
||||
imageURL: inputImage ? inputImage.src : undefined,
|
||||
poseURL: downloadLink.href,
|
||||
}, '*');
|
||||
// Focus the iframe so that the focus is no longer on the `Edit` button.
|
||||
// Pressing space when the focus is on `Edit` button will trigger
|
||||
// the click again to resend the frame message.
|
||||
modalIframe.contentWindow.focus();
|
||||
});
|
||||
/*
|
||||
* Writes the pose data URL to an link element on input image group.
|
||||
* Click a hidden button to trigger a backend rendering of the pose JSON.
|
||||
*
|
||||
* The backend should:
|
||||
* - Set the rendered pose image as preprocessor generated image.
|
||||
*/
|
||||
function updatePreviewPose(poseURL) {
|
||||
const downloadLink = generatedImageGroup.querySelector('.cnet-download-pose a');
|
||||
const renderButton = generatedImageGroup.querySelector('.cnet-render-pose');
|
||||
const poseTextbox = generatedImageGroup.querySelector('.cnet-pose-json textarea');
|
||||
const allowPreviewCheckbox = tab.querySelector('.cnet-allow-preview input');
|
||||
|
||||
if (!allowPreviewCheckbox.checked)
|
||||
allowPreviewCheckbox.click();
|
||||
|
||||
// Only set href when download link exists and needs an update. `downloadLink`
|
||||
// can be null when user closes preview and click `Upload JSON` button again.
|
||||
// https://github.com/Mikubill/sd-webui-controlnet/issues/2308
|
||||
if (downloadLink !== null)
|
||||
downloadLink.href = poseURL;
|
||||
|
||||
poseTextbox.value = poseURL;
|
||||
updateInput(poseTextbox);
|
||||
renderButton.click();
|
||||
}
|
||||
|
||||
// Updates preview image when edit is done.
|
||||
window.addEventListener('message', (event) => {
|
||||
const message = event.data;
|
||||
const modalId = editButton.id.replace('cnet-modal-open-', '');
|
||||
if (message.modalId !== modalId) return;
|
||||
updatePreviewPose(message.poseURL);
|
||||
|
||||
const closeModalButton = generatedImageGroup.querySelector('.cnet-modal .cnet-modal-close');
|
||||
closeModalButton.click();
|
||||
});
|
||||
|
||||
const inputImageGroup = tab.querySelector('.cnet-input-image-group');
|
||||
const uploadButton = inputImageGroup.querySelector('.cnet-upload-pose input');
|
||||
// Updates preview image when JSON file is uploaded.
|
||||
uploadButton.addEventListener('change', (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file)
|
||||
return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
const contents = e.target.result;
|
||||
const poseURL = `data:application/json;base64,${btoa(contents)}`;
|
||||
updatePreviewPose(poseURL);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// Reset the file input value so that uploading the same file still triggers callback.
|
||||
event.target.value = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onUiUpdate(loadOpenposeEditor);
|
||||
})();
|
||||
435
extensions-builtin/sd_forge_controlnet/javascript/photopea.js
Normal file
435
extensions-builtin/sd_forge_controlnet/javascript/photopea.js
Normal file
@@ -0,0 +1,435 @@
|
||||
(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-unit-enabled').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('.cnet-unit-tab');
|
||||
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);
|
||||
})();
|
||||
Reference in New Issue
Block a user