commit 52557c9352c6d7800b8c71a11c99e8835cd1c2f8 Author: jtydhr88 Date: Wed Apr 12 20:42:36 2023 -0400 init commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15a5b2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea/ +venv/ +**/__pycache__/** \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..eaf2580 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Stable Diffusion WebUI Canvas Editor +A custom extension for [AUTOMATIC1111/stable-diffusion-webui](https://github.com/AUTOMATIC1111/stable-diffusion-webui) that integrated a full capability canvas editor which you can use layer, text, image, elements and so on, then send to ControlNet, basing on [Polotno](https://polotno.com/). +![1.png](doc/images/overall.png) +![controlnet.png](doc/images/controlnet.png) + +## Installation +Just like you install other extension of webui: +1. go to Extensions -> Install from URL +2. paste this repo link +3. install +4. go to Installed, apply and restart UI + +## Key Feature +1. Full capability image editor, such as Effects, Crop, Position, Lock, etc +2. Templates![templates.png](doc/images/templates.png) +3. Text![text.png](doc/images/text.png) +4. Photos![photos.png](doc/images/photos.png) +5. Elements![elements.png](doc/images/elements.png) +6. Upload![upload.png](doc/images/upload.png) +7. Background![background.png](doc/images/background.png) +8. Layers![layers.png](doc/images/layers.png) + +## Further Plan +1. rebuild Polotno +2. Send image to img2img, Sketch, Inpaint, etc +3. Pen and eraser support +4. connect to Segment Anything to segment image +5. any suggestions or requirements are welcome + +## Polotno API Key +I included my Polotno api key, notice it only supports local environment (localhost or 127.0.0.1) for non-commercial purpose. +It is easy to create your own free api key from [Polotno API](https://polotno.com/cabinet), then you can replace mine from webui Settings -> Canvas Editor + +## Credits +Created by [jtydhr88](https://github.com/jtydhr88) basing on [Polotno](https://polotno.com/). diff --git a/doc/images/background.png b/doc/images/background.png new file mode 100644 index 0000000..afe89d4 Binary files /dev/null and b/doc/images/background.png differ diff --git a/doc/images/controlnet.png b/doc/images/controlnet.png new file mode 100644 index 0000000..a00adb6 Binary files /dev/null and b/doc/images/controlnet.png differ diff --git a/doc/images/elements.png b/doc/images/elements.png new file mode 100644 index 0000000..95929ad Binary files /dev/null and b/doc/images/elements.png differ diff --git a/doc/images/layers.png b/doc/images/layers.png new file mode 100644 index 0000000..864702e Binary files /dev/null and b/doc/images/layers.png differ diff --git a/doc/images/overall.png b/doc/images/overall.png new file mode 100644 index 0000000..5eaf4cc Binary files /dev/null and b/doc/images/overall.png differ diff --git a/doc/images/photos.png b/doc/images/photos.png new file mode 100644 index 0000000..1ae131a Binary files /dev/null and b/doc/images/photos.png differ diff --git a/doc/images/templates.png b/doc/images/templates.png new file mode 100644 index 0000000..fb5262a Binary files /dev/null and b/doc/images/templates.png differ diff --git a/doc/images/text.png b/doc/images/text.png new file mode 100644 index 0000000..d364e87 Binary files /dev/null and b/doc/images/text.png differ diff --git a/doc/images/upload.png b/doc/images/upload.png new file mode 100644 index 0000000..0db938f Binary files /dev/null and b/doc/images/upload.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..c8fa778 --- /dev/null +++ b/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/javascript/canvas-editor-import.js b/javascript/canvas-editor-import.js new file mode 100644 index 0000000..d112559 --- /dev/null +++ b/javascript/canvas-editor-import.js @@ -0,0 +1,42 @@ +(function () { + if (!globalThis.canvasEditor) globalThis.canvasEditor = {}; + const canvasEditor = globalThis.canvasEditor; + + function load(cont) { + const scripts = cont.textContent.trim().split('\n'); + const base_path = `/file=${scripts.shift()}/js`; + cont.textContent = ''; + + const df = document.createDocumentFragment(); + for (let src of scripts) { + const script = document.createElement('script'); + script.async = true; + script.type = 'module'; + script.src = `file=${src}`; + df.appendChild(script); + } + + globalThis.canvasEditor.import = async () => { + const polotno = await import(`${base_path}/polotno.bundle.js`); + + return { polotno }; + }; + + if (!globalThis.canvasEditor.imports) { + globalThis.canvasEditor.imports = {}; + } + + if (!globalThis.canvasEditor.imports.polotno) { + globalThis.canvasEditor.imports.polotno = async () => await import(`${base_path}/polotno.bundle.js`); + } + + cont.appendChild(df); + + + } + + onUiLoaded(function () { + canvasEditorImport = gradioApp().querySelector('#canvas-editor-import'); + load(canvasEditorImport); + }); +})(); \ No newline at end of file diff --git a/javascript/lazyload/canvas-editor.js b/javascript/lazyload/canvas-editor.js new file mode 100644 index 0000000..ffcab6d --- /dev/null +++ b/javascript/lazyload/canvas-editor.js @@ -0,0 +1,136 @@ +console.log('[3D Model Loader] loading...'); + +async function _import() { + if (!globalThis.canvasEditor || !globalThis.canvasEditor.import) { + return await import('polotno'); + } else { + return await globalThis.canvasEditor.imports.polotno(); + } +} + +await _import(); + +(async function () { + const container = gradioApp().querySelector('#canvas-editor-container'); + const apiKey = gradioApp().querySelector('#canvas-editor-polotno-api-key'); + const apiKeyValue = apiKey.value; + + const { store } = createPolotnoApp({ + // this is a demo key just for that project + // (!) please don't use it in your projects + // to create your own API key please go here: https://polotno.com/cabinet + key: apiKeyValue, + // you can hide back-link on a paid license + // but it will be good if you can keep it for Polotno project support + showCredit: true, + container: container, + }); + function dataURLtoFile(dataurl, filename) { + var arr = dataurl.split(','), + mime = arr[0].match(/:(.*?);/)[1], + bstr = atob(arr[1]), + n = bstr.length, + u8arr = new Uint8Array(n); + + while(n--){ + u8arr[n] = bstr.charCodeAt(n); + } + + return new File([u8arr], filename, {type:mime}); + } + + window.sendImageCanvasEditor = async function (type, index) { + const imageDataURL = await store.toDataURL(); + + var file = dataURLtoFile(imageDataURL, 'my-image-file.jpg'); + + const dt = new DataTransfer(); + dt.items.add(file); + + const selector = type === "txt2img" ? "#txt2img_script_container" : "#img2img_script_container"; + + if (type === "txt2img") { + switch_to_txt2img(); + } else if (type === "img2img") { + switch_to_img2img(); + } + + let container = gradioApp().querySelector(selector); + + let element = container.querySelector('#controlnet'); + + if (!element) { + for (const spans of container.querySelectorAll < HTMLSpanElement > ( + '.cursor-pointer > span' + )) { + if (!spans.textContent?.includes('ControlNet')) { + continue + } + if (spans.textContent?.includes('M2M')) { + continue + } + element = spans.parentElement?.parentElement + } + if (!element) { + console.error('ControlNet element not found') + return + } + } + + const imageElems = element.querySelectorAll('div[data-testid="image"]') + + if (!imageElems[Number(index)]) { + let accordion = element.querySelector('.icon'); + + if (accordion) { + accordion.click(); + + let controlNetAppeared = false; + + let observer = new MutationObserver(function (mutations) { + mutations.forEach(function (mutation) { + if (mutation.type === "childList" && mutation.addedNodes.length > 0) { + for (let i = 0; i < mutation.addedNodes.length; i++) { + if (mutation.addedNodes[i].tagName === "INPUT") { + + controlNetAppeared = true; + + const imageElems2 = element.querySelectorAll('div[data-testid="image"]'); + + updateGradioImage(imageElems2[Number(index)], dt); + + observer.disconnect(); + + return; + } + } + } + }); + }); + + observer.observe(element, {childList: true, subtree: true}); + } + } else { + updateGradioImage(imageElems[Number(index)], dt); + } + + }; + + function updateGradioImage (element, dt) { + let clearButton = element.querySelector("button[aria-label='Clear']"); + + if (clearButton) { + clearButton.click(); + } + + const input = element.querySelector("input[type='file']"); + input.value = '' + input.files = dt.files + input.dispatchEvent( + new Event('change', { + bubbles: true, + composed: true, + }) + ) + } +})(); \ No newline at end of file diff --git a/js/es-module-shims.js b/js/es-module-shims.js new file mode 100644 index 0000000..b550ba8 --- /dev/null +++ b/js/es-module-shims.js @@ -0,0 +1,789 @@ +/* ES Module Shims 1.3.6 */ +(function () { + + const edge = navigator.userAgent.match(/Edge\/\d\d\.\d+$/); + + let baseUrl; + + function createBlob (source, type = 'text/javascript') { + return URL.createObjectURL(new Blob([source], { type })); + } + + const noop = () => {}; + + const baseEl = document.querySelector('base[href]'); + if (baseEl) + baseUrl = baseEl.href; + + if (!baseUrl && typeof location !== 'undefined') { + baseUrl = location.href.split('#')[0].split('?')[0]; + const lastSepIndex = baseUrl.lastIndexOf('/'); + if (lastSepIndex !== -1) + baseUrl = baseUrl.slice(0, lastSepIndex + 1); + } + + function isURL (url) { + try { + new URL(url); + return true; + } + catch { + return false; + } + } + + const backslashRegEx = /\\/g; + function resolveIfNotPlainOrUrl (relUrl, parentUrl) { + // strip off any trailing query params or hashes + parentUrl = parentUrl && parentUrl.split('#')[0].split('?')[0]; + if (relUrl.indexOf('\\') !== -1) + relUrl = relUrl.replace(backslashRegEx, '/'); + // protocol-relative + if (relUrl[0] === '/' && relUrl[1] === '/') { + return parentUrl.slice(0, parentUrl.indexOf(':') + 1) + relUrl; + } + // relative-url + else if (relUrl[0] === '.' && (relUrl[1] === '/' || relUrl[1] === '.' && (relUrl[2] === '/' || relUrl.length === 2 && (relUrl += '/')) || + relUrl.length === 1 && (relUrl += '/')) || + relUrl[0] === '/') { + const parentProtocol = parentUrl.slice(0, parentUrl.indexOf(':') + 1); + // Disabled, but these cases will give inconsistent results for deep backtracking + //if (parentUrl[parentProtocol.length] !== '/') + // throw new Error('Cannot resolve'); + // read pathname from parent URL + // pathname taken to be part after leading "/" + let pathname; + if (parentUrl[parentProtocol.length + 1] === '/') { + // resolving to a :// so we need to read out the auth and host + if (parentProtocol !== 'file:') { + pathname = parentUrl.slice(parentProtocol.length + 2); + pathname = pathname.slice(pathname.indexOf('/') + 1); + } + else { + pathname = parentUrl.slice(8); + } + } + else { + // resolving to :/ so pathname is the /... part + pathname = parentUrl.slice(parentProtocol.length + (parentUrl[parentProtocol.length] === '/')); + } + + if (relUrl[0] === '/') + return parentUrl.slice(0, parentUrl.length - pathname.length - 1) + relUrl; + + // join together and split for removal of .. and . segments + // looping the string instead of anything fancy for perf reasons + // '../../../../../z' resolved to 'x/y' is just 'z' + const segmented = pathname.slice(0, pathname.lastIndexOf('/') + 1) + relUrl; + + const output = []; + let segmentIndex = -1; + for (let i = 0; i < segmented.length; i++) { + // busy reading a segment - only terminate on '/' + if (segmentIndex !== -1) { + if (segmented[i] === '/') { + output.push(segmented.slice(segmentIndex, i + 1)); + segmentIndex = -1; + } + } + + // new segment - check if it is relative + else if (segmented[i] === '.') { + // ../ segment + if (segmented[i + 1] === '.' && (segmented[i + 2] === '/' || i + 2 === segmented.length)) { + output.pop(); + i += 2; + } + // ./ segment + else if (segmented[i + 1] === '/' || i + 1 === segmented.length) { + i += 1; + } + else { + // the start of a new segment as below + segmentIndex = i; + } + } + // it is the start of a new segment + else { + segmentIndex = i; + } + } + // finish reading out the last segment + if (segmentIndex !== -1) + output.push(segmented.slice(segmentIndex)); + return parentUrl.slice(0, parentUrl.length - pathname.length) + output.join(''); + } + } + + /* + * Import maps implementation + * + * To make lookups fast we pre-resolve the entire import map + * and then match based on backtracked hash lookups + * + */ + function resolveUrl (relUrl, parentUrl) { + return resolveIfNotPlainOrUrl(relUrl, parentUrl) || (relUrl.indexOf(':') !== -1 ? relUrl : resolveIfNotPlainOrUrl('./' + relUrl, parentUrl)); + } + + function resolveAndComposePackages (packages, outPackages, baseUrl, parentMap) { + for (let p in packages) { + const resolvedLhs = resolveIfNotPlainOrUrl(p, baseUrl) || p; + if (outPackages[resolvedLhs]) { + throw new Error(`Dynamic import map rejected: Overrides entry "${resolvedLhs}" from ${outPackages[resolvedLhs]} to ${packages[resolvedLhs]}.`); + } + let target = packages[p]; + if (typeof target !== 'string') + continue; + const mapped = resolveImportMap(parentMap, resolveIfNotPlainOrUrl(target, baseUrl) || target, baseUrl); + if (mapped) { + outPackages[resolvedLhs] = mapped; + continue; + } + targetWarning(p, packages[p], 'bare specifier did not resolve'); + } + } + + function resolveAndComposeImportMap (json, baseUrl, parentMap) { + const outMap = { imports: Object.assign({}, parentMap.imports), scopes: Object.assign({}, parentMap.scopes) }; + + if (json.imports) + resolveAndComposePackages(json.imports, outMap.imports, baseUrl, parentMap); + + if (json.scopes) + for (let s in json.scopes) { + const resolvedScope = resolveUrl(s, baseUrl); + resolveAndComposePackages(json.scopes[s], outMap.scopes[resolvedScope] || (outMap.scopes[resolvedScope] = {}), baseUrl, parentMap); + } + + return outMap; + } + + function getMatch (path, matchObj) { + if (matchObj[path]) + return path; + let sepIndex = path.length; + do { + const segment = path.slice(0, sepIndex + 1); + if (segment in matchObj) + return segment; + } while ((sepIndex = path.lastIndexOf('/', sepIndex - 1)) !== -1) + } + + function applyPackages (id, packages) { + const pkgName = getMatch(id, packages); + if (pkgName) { + const pkg = packages[pkgName]; + if (pkg === null) return; + if (id.length > pkgName.length && pkg[pkg.length - 1] !== '/') + targetWarning(pkgName, pkg, "should have a trailing '/'"); + else + return pkg + id.slice(pkgName.length); + } + } + + function targetWarning (match, target, msg) { + console.warn("Package target " + msg + ", resolving target '" + target + "' for " + match); + } + + function resolveImportMap (importMap, resolvedOrPlain, parentUrl) { + let scopeUrl = parentUrl && getMatch(parentUrl, importMap.scopes); + while (scopeUrl) { + const packageResolution = applyPackages(resolvedOrPlain, importMap.scopes[scopeUrl]); + if (packageResolution) + return packageResolution; + scopeUrl = getMatch(scopeUrl.slice(0, scopeUrl.lastIndexOf('/')), importMap.scopes); + } + return applyPackages(resolvedOrPlain, importMap.imports) || resolvedOrPlain.indexOf(':') !== -1 && resolvedOrPlain; + } + + const optionsScript = document.querySelector('script[type=esms-options]'); + + const esmsInitOptions = optionsScript ? JSON.parse(optionsScript.innerHTML) : self.esmsInitOptions ? self.esmsInitOptions : {}; + + let shimMode = !!esmsInitOptions.shimMode; + const resolveHook = globalHook(shimMode && esmsInitOptions.resolve); + + const skip = esmsInitOptions.skip ? new RegExp(esmsInitOptions.skip) : null; + + let nonce = esmsInitOptions.nonce; + + if (!nonce) { + const nonceElement = document.querySelector('script[nonce]'); + if (nonceElement) + nonce = nonceElement.nonce || nonceElement.getAttribute('nonce'); + } + + const onerror = globalHook(esmsInitOptions.onerror || noop); + const onpolyfill = globalHook(esmsInitOptions.onpolyfill || noop); + + const { revokeBlobURLs, noLoadEventRetriggers } = esmsInitOptions; + + const fetchHook = esmsInitOptions.fetch ? globalHook(esmsInitOptions.fetch) : fetch; + + function globalHook (name) { + return typeof name === 'string' ? self[name] : name; + } + + const enable = Array.isArray(esmsInitOptions.polyfillEnable) ? esmsInitOptions.polyfillEnable : []; + const cssModulesEnabled = enable.includes('css-modules'); + const jsonModulesEnabled = enable.includes('json-modules'); + + function setShimMode () { + shimMode = true; + } + + let err; + window.addEventListener('error', _err => err = _err); + function dynamicImportScript (url, { errUrl = url } = {}) { + err = undefined; + const src = createBlob(`import*as m from'${url}';self._esmsi=m`); + const s = Object.assign(document.createElement('script'), { type: 'module', src }); + s.setAttribute('nonce', nonce); + s.setAttribute('noshim', ''); + const p = new Promise((resolve, reject) => { + // Safari is unique in supporting module script error events + s.addEventListener('error', cb); + s.addEventListener('load', cb); + + function cb (_err) { + document.head.removeChild(s); + if (self._esmsi) { + resolve(self._esmsi, baseUrl); + self._esmsi = undefined; + } + else { + reject(!(_err instanceof Event) && _err || err && err.error || new Error(`Error loading or executing the graph of ${errUrl} (check the console for ${src}).`)); + err = undefined; + } + } + }); + document.head.appendChild(s); + return p; + } + + let dynamicImport = dynamicImportScript; + + const supportsDynamicImportCheck = dynamicImportScript(createBlob('export default u=>import(u)')).then(_dynamicImport => { + if (_dynamicImport) + dynamicImport = _dynamicImport.default; + return !!_dynamicImport; + }, noop); + + // support browsers without dynamic import support (eg Firefox 6x) + let supportsJsonAssertions = false; + let supportsCssAssertions = false; + + let supportsImportMeta = false; + let supportsImportMaps = false; + + let supportsDynamicImport = false; + + const featureDetectionPromise = Promise.resolve(supportsDynamicImportCheck).then(_supportsDynamicImport => { + if (!_supportsDynamicImport) + return; + supportsDynamicImport = true; + + return Promise.all([ + dynamicImport(createBlob('import.meta')).then(() => supportsImportMeta = true, noop), + cssModulesEnabled && dynamicImport(createBlob('import"data:text/css,{}"assert{type:"css"}')).then(() => supportsCssAssertions = true, noop), + jsonModulesEnabled && dynamicImport(createBlob('import"data:text/json,{}"assert{type:"json"}')).then(() => supportsJsonAssertions = true, noop), + new Promise(resolve => { + self._$s = v => { + document.head.removeChild(iframe); + if (v) supportsImportMaps = true; + delete self._$s; + resolve(); + }; + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + document.head.appendChild(iframe); + iframe.src = createBlob(`