diff --git a/javascript/active_units.js b/javascript/active_units.js deleted file mode 100644 index d76e1ed71..000000000 --- a/javascript/active_units.js +++ /dev/null @@ -1,314 +0,0 @@ -/** - * 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.controlTypeSelector = tab.querySelectorAll('.controlnet_control_type_filter_group input'); - 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(); - } - - getTabNavButton() { - return this.tabNav.querySelector(`:nth-child(${this.tabIndex + 1})`); - } - - // Control type selector can be - // - Radio - // - Dropdown - controlTypeSelectorIsDropdown() { - return this.controlTypeSelector.length == 1; - } - - getActiveControlType() { - if (this.controlTypeSelectorIsDropdown()) { - return this.controlTypeSelector[0].value; - } - - for (let radio of this.controlTypeSelector) { - 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() { - if (this.controlTypeSelectorIsDropdown()) { - const input = this.controlTypeSelector[0]; - const desc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value"); - const tab = this; - Object.defineProperty(input, "value", { - get: desc.get, - set: function (v) { - desc.set.call(this, v); - tab.updateActiveControlType(); - }, - }); - return; - } - for (const radio of this.controlTypeSelector) { - 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); - }); - } - } - } - - gradioApp().querySelectorAll('#controlnet').forEach(accordion => { - if (cnetAllAccordions.has(accordion)) return; - accordion.querySelectorAll('.cnet-unit-tab') - .forEach(tab => new ControlNetUnitTab(tab, accordion)); - cnetAllAccordions.add(accordion); - }); - }); -})(); \ No newline at end of file diff --git a/javascript/controlnet_unit.mjs b/javascript/controlnet_unit.mjs new file mode 100644 index 000000000..147ecebaa --- /dev/null +++ b/javascript/controlnet_unit.mjs @@ -0,0 +1,271 @@ +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); +} + +export class ControlNetUnit { + 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.inputImageGroup = tab.querySelector('.cnet-input-image-group'); + this.controlTypeSelector = tab.querySelectorAll('.controlnet_control_type_filter_group input'); + this.resizeModeRadios = tab.querySelectorAll('.controlnet_resize_mode_radio input[type="radio"]'); + this.runPreprocessorButton = tab.querySelector('.cnet-run-preprocessor'); + this.generatedImageGroup = tab.querySelector('.cnet-generated-image-group'); + this.poseEditButton = tab.querySelector('.cnet-edit-pose'); + this.allowPreviewCheckbox = tab.querySelector('.cnet-allow-preview input'); + + 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(); + } + + getTabNavButton() { + return this.tabNav.querySelector(`:nth-child(${this.tabIndex + 1})`); + } + + // Control type selector can be + // - Radio + // - Dropdown + controlTypeSelectorIsDropdown() { + return this.controlTypeSelector.length == 1; + } + + getActiveControlType() { + if (this.controlTypeSelectorIsDropdown()) { + return this.controlTypeSelector[0].value; + } + + for (let radio of this.controlTypeSelector) { + 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); + } + + attachEnabledButtonListener() { + this.enabledCheckbox.addEventListener('change', () => { + this.updateActiveState(); + this.updateActiveUnitCount(); + }); + } + + attachControlTypeRadioListener() { + if (this.controlTypeSelectorIsDropdown()) { + const input = this.controlTypeSelector[0]; + const desc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value"); + const tab = this; + Object.defineProperty(input, "value", { + get: desc.get, + set: function (v) { + desc.set.call(this, v); + tab.updateActiveControlType(); + }, + }); + return; + } + for (const radio of this.controlTypeSelector) { + 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); + }); + } + } +} diff --git a/javascript/index.mjs b/javascript/index.mjs new file mode 100644 index 000000000..afc336aac --- /dev/null +++ b/javascript/index.mjs @@ -0,0 +1,24 @@ +import { ControlNetUnit } from "./controlnet_unit.mjs"; +import { initControlNetModals } from "./modal.mjs"; +import { OpenposeEditor } from "./openpose_editor.mjs"; +import { loadPhotopea } from "./photopea.mjs"; + +(function () { + const cnetAllAccordions = new Set(); + onUiUpdate(() => { + gradioApp().querySelectorAll('#controlnet').forEach(accordion => { + if (cnetAllAccordions.has(accordion)) return; + + accordion.querySelectorAll('.cnet-unit-tab') + .forEach(tab => { + const unit = new ControlNetUnit(tab, accordion); + const openposeEditor = new OpenposeEditor(unit); + }); + + initControlNetModals(accordion); + loadPhotopea(accordion); + + cnetAllAccordions.add(accordion); + }); + }); +})(); \ No newline at end of file diff --git a/javascript/modal.js b/javascript/modal.js deleted file mode 100644 index dc6190de8..000000000 --- a/javascript/modal.js +++ /dev/null @@ -1,33 +0,0 @@ -(function () { - const cnetModalRegisteredElements = new Set(); - onUiUpdate(() => { - // Get all the buttons that open a modal - const btns = gradioApp().querySelectorAll(".cnet-modal-open"); - - // Get all the 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 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"; - }); - }); - }); -})(); diff --git a/javascript/modal.mjs b/javascript/modal.mjs new file mode 100644 index 000000000..f2d8b31fa --- /dev/null +++ b/javascript/modal.mjs @@ -0,0 +1,24 @@ +export function initControlNetModals(container) { + // Get all the buttons that open a modal + const btns = container.querySelectorAll(".cnet-modal-open"); + + // Get all the elements that close a modal + const spans = container.querySelectorAll(".cnet-modal-close"); + + // For each button, add a click event listener that opens the corresponding modal + btns.forEach((btn) => { + const modalId = btn.id.replace('cnet-modal-open-', ''); + const modal = container.querySelector("#cnet-modal-" + modalId); + btn.addEventListener('click', () => { + modal.style.display = "block"; + }); + }); + + // For each element, add a click event listener that closes the corresponding modal + spans.forEach((span) => { + const modal = span.parentNode; + span.addEventListener('click', () => { + modal.style.display = "none"; + }); + }); +} diff --git a/javascript/openpose_editor.js b/javascript/openpose_editor.js deleted file mode 100644 index 1c7b570a2..000000000 --- a/javascript/openpose_editor.js +++ /dev/null @@ -1,152 +0,0 @@ -(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); -})(); \ No newline at end of file diff --git a/javascript/openpose_editor.mjs b/javascript/openpose_editor.mjs new file mode 100644 index 000000000..d4db33753 --- /dev/null +++ b/javascript/openpose_editor.mjs @@ -0,0 +1,149 @@ +import { ControlNetUnit } from "./controlnet_unit.mjs"; + +export class OpenposeEditor { + /** + * OpenposeEditor + * @param {ControlNetUnit} unit + */ + constructor(unit) { + this.unit = unit; + this.iframe = this.unit.generatedImageGroup.querySelector('.cnet-modal iframe'); + this.closeModalButton = this.unit.generatedImageGroup.querySelector('.cnet-modal .cnet-modal-close'); + this.uploadButton = this.unit.inputImageGroup.querySelector('.cnet-upload-pose input'); + this.editorURL = null; + this.unit.poseEditButton.addEventListener('click', this.trigger.bind(this)); + // Updates preview image when edit is done. + window.addEventListener('message', ((event) => { + const message = event.data; + const modalId = this.unit.poseEditButton.id.replace('cnet-modal-open-', ''); + if (message.modalId !== modalId) return; + this.updatePreviewPose(message.poseURL); + this.closeModalButton.click(); + }).bind(this)); + // Updates preview image when JSON file is uploaded. + this.uploadButton.addEventListener('change', ((event) => { + const file = event.target.files[0]; + if (!file) + return; + + const reader = new FileReader(); + reader.onload = (e) => { + const contents = e.target.result; + const poseURL = `data:application/json;base64,${btoa(contents)}`; + this.updatePreviewPose(poseURL); + }; + reader.readAsText(file); + // Reset the file input value so that uploading the same file still triggers callback. + event.target.value = ''; + }).bind(this)); + } + + async 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); + } + + navigateIframe() { + const iframe = this.iframe; + const editorURL = this.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(); + } + }); + } + + // When edit button is clicked. + async trigger() { + const inputImageGroup = this.unit.tab.querySelector('.cnet-input-image-group'); + const inputImage = inputImageGroup.querySelector('.cnet-image img'); + const downloadLink = this.unit.generatedImageGroup.querySelector('.cnet-download-pose a'); + const modalId = this.unit.poseEditButton.id.replace('cnet-modal-open-', ''); + + if (!this.editorURL) { + this.editorURL = await this.checkEditorAvailable(); + if (!this.editorURL) { + alert("No openpose editor available.") + } + } + + await this.navigateIframe(); + this.iframe.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. + this.iframe.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. + */ + updatePreviewPose(poseURL) { + const downloadLink = this.unit.generatedImageGroup.querySelector('.cnet-download-pose a'); + const renderButton = this.unit.generatedImageGroup.querySelector('.cnet-render-pose'); + const poseTextbox = this.unit.generatedImageGroup.querySelector('.cnet-pose-json textarea'); + const allowPreviewCheckbox = this.unit.allowPreviewCheckbox; + + 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; + // 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); + } + updateInput(poseTextbox); + renderButton.click(); + } +} diff --git a/javascript/photopea.js b/javascript/photopea.js deleted file mode 100644 index d2b1ebc98..000000000 --- a/javascript/photopea.js +++ /dev/null @@ -1,435 +0,0 @@ -(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); -})(); \ No newline at end of file diff --git a/javascript/photopea.mjs b/javascript/photopea.mjs new file mode 100644 index 000000000..c442f153c --- /dev/null +++ b/javascript/photopea.mjs @@ -0,0 +1,421 @@ +/* +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; +} + +export function loadPhotopea(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(); + }); +}