├── .gitignore ├── LICENSE ├── README.md ├── __init__.py └── js └── photopea-editor.js /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jin Liu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ComfyUI-Photopea 2 | 3 | Edit images in the Photopea editor directly within ComfyUI. 4 | 5 | ## Installation 6 | 7 | ### Git 8 | 9 | ``` 10 | cd custom_nodes 11 | git clone https://github.com/coolzilj/ComfyUI-Photopea.git 12 | ``` 13 | 14 | Restart ComfyUI and the extension should be loaded. 15 | 16 | ### ComfyUI Manager 17 | 18 | 1. Open ComfyUI and go to `Manager` > `Install Custom Nodes`. 19 | 20 | 2. Search for `ComfyUI-Photopea` in the search bar. 21 | 22 | 3. Click `Install`. 23 | 24 | ## Usage 25 | 26 | 27 | https://github.com/coolzilj/ComfyUI-Photopea/assets/1059327/5f4c7ab3-f3b2-45e4-8130-aa9d3594ea2d 28 | 29 | 30 | This extension adds an `Open in Photopea editor` option when you right-click on any node that has an image or mask output. 31 | When you click it, it loads the Photopea editor in an iframe with the image related to the node. 32 | You can edit the image inside Photopea, and once you're satisfied, click `Save to node` to replace the image with the edited version from Photopea. 33 | 34 | There is also a `Photopea Editor` button in the `Clipspace` panel. 35 | 36 | **This extension has not been extensively tested and may have bugs related to Clipspace actions. Use at your own risk.** 37 | 38 | ## Limits (Open for Discussion) 39 | 40 | 1. Each time "Open in Photopea" is clicked, Photopea opens a new document. 41 | 42 | ## Last but not lease 43 | If you find it useful or have special needs in your workflow, I'm open to your feedback. 44 | Twitter: [@SongZi](https://x.com/Songzi39590361) 45 | 46 | [![Star History Chart](https://api.star-history.com/svg?repos=coolzilj/ComfyUI-Photopea&type=Date)](https://star-history.com/#coolzilj/ComfyUI-Photopea&Date) 47 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | @author: Jin Liu 3 | @title: ComfyUI-Photopea 4 | @nickname: ComfyUI-Photopea 5 | @description: Edit images in the Photopea editor directly within ComfyUI. 6 | """ 7 | 8 | NODE_CLASS_MAPPINGS = {} 9 | NODE_DISPLAY_NAME_MAPPINGS = {} 10 | 11 | WEB_DIRECTORY = "js" 12 | __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] 13 | -------------------------------------------------------------------------------- /js/photopea-editor.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | import { api } from "../../scripts/api.js"; 3 | import { ComfyDialog, $el } from "../../scripts/ui.js"; 4 | import { ComfyApp } from "../../scripts/app.js"; 5 | import { ClipspaceDialog } from "../../extensions/core/clipspace.js"; 6 | 7 | function addMenuHandler(nodeType, cb) { 8 | const getOpts = nodeType.prototype.getExtraMenuOptions; 9 | nodeType.prototype.getExtraMenuOptions = function () { 10 | const r = getOpts.apply(this, arguments); 11 | cb.apply(this, arguments); 12 | return r; 13 | }; 14 | } 15 | 16 | function imageToBase64(url, callback) { 17 | fetch(url) 18 | .then((response) => response.blob()) 19 | .then((blob) => { 20 | const reader = new FileReader(); 21 | reader.readAsDataURL(blob); 22 | reader.onloadend = () => { 23 | const base64String = reader.result; 24 | callback(base64String); 25 | }; 26 | }); 27 | } 28 | 29 | async function uploadFile(formData) { 30 | try { 31 | const resp = await api.fetchApi('/upload/image', { 32 | method: 'POST', 33 | body: formData 34 | }) 35 | if (resp.status === 200) { 36 | const data = await resp.json(); 37 | ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']] = new Image(); 38 | ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src = `view?filename=${data.name}&subfolder=${data.subfolder}&type=${data.type}`; 39 | } else { 40 | alert(resp.status + " - " + resp.statusText); 41 | } 42 | } catch (error) { 43 | console.error('Error:', error); 44 | } 45 | } 46 | 47 | class LJPhotopeaEditorDialog extends ComfyDialog { 48 | static instance = null; 49 | 50 | static getInstance() { 51 | if(!LJPhotopeaEditorDialog.instance) { 52 | LJPhotopeaEditorDialog.instance = new LJPhotopeaEditorDialog(); 53 | } 54 | 55 | return LJPhotopeaEditorDialog.instance; 56 | } 57 | 58 | constructor() { 59 | super(); 60 | this.element = $el("div.comfy-modal", { parent: document.body }, 61 | [ $el("div.comfy-modal-content", 62 | [...this.createButtons()]), 63 | ]); 64 | this.iframe = null; 65 | this.iframe_container = null; 66 | } 67 | 68 | createButtons() { 69 | return []; 70 | } 71 | 72 | createButton(name, callback) { 73 | var button = document.createElement("button"); 74 | button.innerText = name; 75 | button.addEventListener("click", callback); 76 | return button; 77 | } 78 | 79 | createLeftButton(name, callback) { 80 | var button = this.createButton(name, callback); 81 | button.style.cssFloat = "left"; 82 | button.style.marginRight = "4px"; 83 | return button; 84 | } 85 | 86 | createRightButton(name, callback) { 87 | var button = this.createButton(name, callback); 88 | button.style.cssFloat = "right"; 89 | button.style.marginLeft = "4px"; 90 | return button; 91 | } 92 | 93 | setlayout() { 94 | const self = this; 95 | 96 | var bottom_panel = document.createElement("div"); 97 | bottom_panel.style.position = "absolute"; 98 | bottom_panel.style.bottom = "0px"; 99 | bottom_panel.style.left = "20px"; 100 | bottom_panel.style.right = "20px"; 101 | bottom_panel.style.height = "50px"; 102 | this.element.appendChild(bottom_panel); 103 | 104 | self.fullscreenButton = this.createLeftButton("Fullscreen", () => { 105 | self.toggleFullscreen(); 106 | }); 107 | 108 | var cancelButton = this.createRightButton("Cancel", () => { 109 | self.close(); 110 | }); 111 | 112 | self.saveButton = this.createRightButton("Save", () => { 113 | self.save(self); 114 | }); 115 | 116 | bottom_panel.appendChild(self.fullscreenButton); 117 | bottom_panel.appendChild(self.saveButton); 118 | bottom_panel.appendChild(cancelButton); 119 | } 120 | 121 | show() { 122 | if(!this.is_layout_created) { 123 | this.setlayout(); 124 | this.is_layout_created = true; 125 | } 126 | 127 | if(ComfyApp.clipspace_return_node) { 128 | this.saveButton.innerText = "Save to node"; 129 | } 130 | else { 131 | this.saveButton.innerText = "Save"; 132 | } 133 | 134 | this.iframe = $el("iframe", { 135 | src: `https://www.photopea.com/`, 136 | style: { 137 | width: "100%", 138 | height: "100%", 139 | border: "none", 140 | position: "relative", 141 | }, 142 | }); 143 | 144 | this.iframe_container = document.createElement("div"); 145 | this.iframe_container.style.flex = "1"; 146 | this.iframe_container.style.paddingBottom = "70px"; 147 | this.element.appendChild(this.iframe_container); 148 | this.element.style.display = "flex"; 149 | this.element.style.flexDirection = "column"; 150 | this.element.style.width = "80vw"; 151 | this.element.style.height = "80vh"; 152 | this.element.style.maxWidth = "100vw"; 153 | this.element.style.maxHeight = "100vh"; 154 | this.element.style.padding = "0"; 155 | this.element.style.zIndex = 8888; 156 | this.iframe_container.appendChild(this.iframe); 157 | 158 | this.iframe.onload = () => { 159 | const target_image_path = ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src; 160 | imageToBase64(target_image_path, (dataURL) => { 161 | this.postMessageToPhotopea(`app.open("${dataURL}", null, false);`, "*"); 162 | }); 163 | }; 164 | } 165 | 166 | close() { 167 | this.element.removeChild(this.iframe_container); 168 | super.close(); 169 | } 170 | 171 | async save(self) { 172 | const saveMessage = 'app.activeDocument.saveToOE("png");'; 173 | const [payload, done] = await self.postMessageToPhotopea(saveMessage); 174 | const file = new Blob([payload], { type: "image/png" }); 175 | const body = new FormData(); 176 | 177 | const filename = "clipspace-photopea-" + performance.now() + ".png"; 178 | 179 | if(ComfyApp.clipspace.widgets) { 180 | const index = ComfyApp.clipspace.widgets.findIndex(obj => obj.name === 'image'); 181 | if(index >= 0) 182 | ComfyApp.clipspace.widgets[index].value = `photopea/${filename} [input]`; 183 | } 184 | 185 | body.append("image", file, filename); 186 | body.append("subfolder", "photopea"); 187 | await uploadFile(body); 188 | 189 | ComfyApp.onClipspaceEditorSave(); 190 | this.close(); 191 | } 192 | 193 | toggleFullscreen() { 194 | if (this.element.style.width === "100vw") { 195 | this.element.style.width = "80vw"; 196 | this.element.style.height = "80vh"; 197 | this.fullscreenButton.innerText = "Fullscreen"; 198 | } else { 199 | this.element.style.width = "100vw"; 200 | this.element.style.height = "100vh"; 201 | this.fullscreenButton.innerText = "Exit Fullscreen"; 202 | } 203 | } 204 | 205 | async postMessageToPhotopea(message) { 206 | var request = new Promise(function (resolve, reject) { 207 | var responses = []; 208 | var photopeaMessageHandle = function (response) { 209 | responses.push(response.data); 210 | if (response.data == "done") { 211 | window.removeEventListener("message", photopeaMessageHandle); 212 | resolve(responses) 213 | } 214 | }; 215 | window.addEventListener("message", photopeaMessageHandle); 216 | }); 217 | this.iframe.contentWindow.postMessage(message, "*"); 218 | return await request; 219 | } 220 | } 221 | 222 | app.registerExtension({ 223 | name: "Comfy.LJ.PhotopeaEditor", 224 | init(app) { 225 | const callback = 226 | function () { 227 | let dlg = LJPhotopeaEditorDialog.getInstance(); 228 | dlg.show(); 229 | }; 230 | 231 | const context_predicate = () => ComfyApp.clipspace && ComfyApp.clipspace.imgs && ComfyApp.clipspace.imgs.length > 0 232 | ClipspaceDialog.registerButton("Photopea Editor", context_predicate, callback); 233 | }, 234 | 235 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 236 | if (Array.isArray(nodeData.output) && (nodeData.output.includes("MASK") || nodeData.output.includes("IMAGE"))) { 237 | addMenuHandler(nodeType, function (_, options) { 238 | options.unshift({ 239 | content: "Open in Photopea Editor", 240 | callback: () => { 241 | ComfyApp.copyToClipspace(this); 242 | ComfyApp.clipspace_return_node = this; 243 | 244 | let dlg = LJPhotopeaEditorDialog.getInstance(); 245 | dlg.show(); 246 | }, 247 | }); 248 | }); 249 | } 250 | } 251 | }); 252 | --------------------------------------------------------------------------------