diff --git a/README.md b/README.md index 2a1469e..fa58a1b 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,60 @@ # cg-image-filter - -A simple node that pauses execution of the workflow while you choose which, if any, of the images produced, you want to progress. -So you can look at an image before passing it into upscaling etc., and reject ones you don't like. +A set of three nodes designed to pause execution of the workflow to allow you to make selections and/or edits before continuing. -This is a work in progress replacement for most functions of the cg-image-picker nodes. +## Image Filter -It aims to be: +The image filter node pauses execution of the workflow while you choose which, if any, of the images produced, you want to progress. -- simpler code -- easier to use for the most frequent use cases -- more visual -- compatible with the controller (https://github.com/chrisgoringe/cg-controller) - -It will not have all the features of the original (overbloated!) nodes. - -## Basic use - -Put the Image Filter node into your workflow where you want to pause: +Insert it like this: ![image](images/basic.png) -When the workflow reaches this node, a big panel will appear showing the image or images. You select the images you want by clicking on them (their border turns green) and then clicking the 'Send' button (or pressing 's'). Those images will be rebatched and sent on. +When you run the workflow, and get to this point, a popup window will appear displaying the image(s) for you to select which, if any, +you want to progress: -If you click 'Cancel' (or press 'x'), the workflow will terminate. Combine this with the Comfy Queue (Instant) setting and a new image will be generated. +![image](images/popup.png) -If you tick the 'click to send' checkbox, clicking an image will automatically send just that one - good for when you are picking one option from a batch. +Click the images that you want to keep (their border will turn green) and then click 'Send' (or press 's') to continue the workflow. +If you don't want to keep any of the images, click 'Cancel' (or press 'x') to terminate the workflow. -If you do nothing, and the number of seconds specified in the timeout is exceeded, the run will be cancelled. +The node also has a timeout specified, and a countdown is shown on the left hand side. If you don't Send or Cancel before the timeout, +the node will either cancel or send all the images, depending on the option you have selected. -## Future plans +Here's a simple use: generate a batch of images and pick which ones you want to save: -- a node that takes a single image as input, and when executed, activates the mask editor, then outputs an image and the on-the-fly mask -- a node that takes an image and a string, and allows you to edit the string before passing it on \ No newline at end of file +![workflow](images/workflow.png) + +### Extra bits + +The "Click to send" option can be used when you know you want to send at most one image. +When checked, clicking an image selects it and sends it in a single action. + +The Latent and Mask inputs are optional. If used, they should have the same number of latents (or masks) as the image batch, +and the latents (or masks) corresponding to the selected images will also be output. Use this if (for instance) you want to +select from a batch of images, but the next stage uses the latent - that way you avoid the decode-recode loss. + +## Mask Image Filter + +Designed for a single image, when executed the Mask Image Filter node will automatically launch the mask editor. + +![mask](images/mask.png) + +When you finish mask editing the image and mask will be output. Here's a simple use - generate an image, and then +mask the bit you don't like, before doing an img2img step. + +![mask workflow](images/mask%20workflow.png) + +Again, there is a timeout, and if you don't save a mask before the end of the timeout (or if you press the cancel button in the mask editor), +it will either cancel, or send a blank mask, depending on the option chosen. + +## Text Image Filter + +Also designed for a single image, this node will show the image and a string of text; you can edit the text and then press send. + +![text](images/text.png) + +The image and (edited) text are output. The intended use is for captioning workflows; you can read and edit each caption as it is +generated. Here's a trivial workflow: + +![text workflow](images/text%20workflow.png) \ No newline at end of file diff --git a/__init__.py b/__init__.py index 1bbf304..322abd3 100644 --- a/__init__.py +++ b/__init__.py @@ -7,7 +7,7 @@ from .image_filter import ImageFilter, TextImageFilter, MaskImageFilter -VERSION = "0.1" +VERSION = "1.0" WEB_DIRECTORY = "./js" NODE_CLASS_MAPPINGS= { diff --git a/image_filter.py b/image_filter.py index 9f2905d..2b3267e 100644 --- a/image_filter.py +++ b/image_filter.py @@ -1,7 +1,7 @@ from server import PromptServer from aiohttp import web from nodes import PreviewImage, LoadImage -from comfy.model_management import InterruptProcessingException +from comfy.model_management import InterruptProcessingException, throw_exception_if_processing_interrupted import time, os import torch @@ -22,7 +22,10 @@ async def cg_image_filter_message(request): def wait(secs): Message.data = None end_time = time.monotonic() + secs - while(time.monotonic() < end_time and Message.data is None): time.sleep(1) + while(time.monotonic() < end_time and Message.data is None): + throw_exception_if_processing_interrupted() + PromptServer.instance.send_sync("cg-image-filter-images", {"tick": int(end_time - time.monotonic())}) + time.sleep(0.2) response = Message.data if response is None: PromptServer.instance.send_sync("cg-image-filter-images", {"timeout": True}) @@ -92,7 +95,7 @@ class TextImageFilter(PreviewImage): return { "required": { "image" : ("IMAGE", ), - "text" : ("STRING", {"forceInput":True, "default":""}), + "text" : ("STRING", {"default":""}), "timeout": ("INT", {"default": 60, "tooltip": "Timeout in seconds."}), }, "hidden": HIDDEN, @@ -124,6 +127,7 @@ class MaskImageFilter(PreviewImage, LoadImage): "required": { "image" : ("IMAGE", ), "timeout": ("INT", {"default": 600, "tooltip": "Timeout in seconds."}), + "if_no_mask": (["cancel", "send blank"], {}), }, "hidden": HIDDEN, } @@ -135,12 +139,16 @@ class MaskImageFilter(PreviewImage, LoadImage): @classmethod def VALIDATE_INPUTS(cls, **kwargs): return True - def func(self, image, timeout, uid, **kwargs): + def func(self, image, timeout, uid, if_no_mask, **kwargs): urls:list[str] = self.save_images(images=image, **kwargs)['ui']['images'] PromptServer.instance.send_sync("cg-image-filter-images", {"uid": uid, "urls":urls, "maskedit":True}) - src = wait(timeout) - if src: - filename = src.split('=')[1].split('&')[0] - return self.load_image(os.path.join('clipspace', filename)+" [input]") - else: - return self.load_image(urls[0]['filename']+" [temp]") + + if (src := wait(timeout)): + try: + filename = src.split('=')[1].split('&')[0] + return self.load_image(os.path.join('clipspace', filename)+" [input]") + except FileNotFoundError: + pass + + if if_no_mask == 'cancel': raise InterruptProcessingException() + return self.load_image(urls[0]['filename']+" [temp]") diff --git a/images/basic.png b/images/basic.png index c5cd7ca..09801b8 100644 Binary files a/images/basic.png and b/images/basic.png differ diff --git a/images/mask workflow.png b/images/mask workflow.png new file mode 100644 index 0000000..b4e938e Binary files /dev/null and b/images/mask workflow.png differ diff --git a/images/mask.png b/images/mask.png new file mode 100644 index 0000000..dc10318 Binary files /dev/null and b/images/mask.png differ diff --git a/images/popup.png b/images/popup.png new file mode 100644 index 0000000..2d7564f Binary files /dev/null and b/images/popup.png differ diff --git a/images/text workflow.png b/images/text workflow.png new file mode 100644 index 0000000..593b564 Binary files /dev/null and b/images/text workflow.png differ diff --git a/images/text.png b/images/text.png new file mode 100644 index 0000000..61e11ac Binary files /dev/null and b/images/text.png differ diff --git a/images/workflow.png b/images/workflow.png new file mode 100644 index 0000000..b01d115 Binary files /dev/null and b/images/workflow.png differ diff --git a/js/filter.css b/js/filter.css index 589424f..7f0a0bb 100644 --- a/js/filter.css +++ b/js/filter.css @@ -6,6 +6,18 @@ z-index: 123456; } +.cg_counter { + position: absolute; + width: 60px; + top: 45px; + left: 70px; + color: white; + padding: 10px; + z-index: 123457; + background-color: rgba(128, 128, 128, 0.5); + text-align: center; +} + .cg_popup .grid { position: absolute; width:80%; diff --git a/js/image_filter.js b/js/image_filter.js index 6ca1322..7fbf9de 100644 --- a/js/image_filter.js +++ b/js/image_filter.js @@ -10,6 +10,6 @@ app.registerExtension({ create('link', null, document.getElementsByTagName('HEAD')[0], {'rel':'stylesheet', 'type':'text/css', 'href': new URL("./filter.css", import.meta.url).href } ) api.addEventListener("execution_interrupted", popup.send_cancel.bind(popup)); - api.addEventListener("cg-image-filter-images",popup.show.bind(popup)); + api.addEventListener("cg-image-filter-images",popup.handle_message.bind(popup)); } }) \ No newline at end of file diff --git a/js/popup.js b/js/popup.js index 02798f5..62b55ac 100644 --- a/js/popup.js +++ b/js/popup.js @@ -6,10 +6,9 @@ import { create } from "./utils.js"; class Popup extends HTMLSpanElement { constructor() { super() - this.hide() this.classList.add('cg_popup') - document.body.appendChild(this) + this.counter = create('span', 'cg_counter hidden', document.body) this.grid = create('span', 'grid', this) this.title_bar = create('span', 'title', this) this.buttons = create('span', 'buttons', this) @@ -23,13 +22,15 @@ class Popup extends HTMLSpanElement { this.cancel_button.addEventListener('click', this.send_cancel.bind(this) ) document.addEventListener('keydown', (e)=>{ this.on_keydown(e) }) + document.body.appendChild(this) + this.close() } _send_response(msg) { const body = new FormData(); body.append('response', msg); api.fetchApi("/cg-image-filter-message", { method: "POST", body, }); - this.hide() + this.close() } send_current_state() { @@ -39,9 +40,38 @@ class Popup extends HTMLSpanElement { send_cancel() { this._send_response('') } - hide() { + close() { this.classList.add('hidden') + this.counter.classList.add('hidden') this.active = false + if (document.getElementById('maskEditor')) document.getElementById('maskEditor').style.display = 'none' + } + + handle_message(message) { + const detail = message.detail + if (detail.timeout) this.handle_timeout(detail) + else if (detail.tick) this.handle_tick(detail) + else if (detail.maskedit) this.handle_maskedit(detail) + else if (detail.urls) this.handle_urls(detail) + } + + handle_timeout(detail) { this.close() } + + handle_tick(detail) { this.counter.innerText = `${detail.tick} s` } + + handle_maskedit(detail) { + this.close() + this.node = app.graph._nodes_by_id[detail.uid] + this.node.imgs = [] + detail.urls.forEach((url, i)=>{ + this.node.imgs.push( new Image() ); + this.node.imgs[i].src = api.apiURL( `/view?filename=${encodeURIComponent(url.filename)}&type=${url.type}&subfolder=${url.subfolder}`) + }) + ComfyApp.copyToClipspace(this.node) + ComfyApp.clipspace_return_node = this.node + ComfyApp.open_maskeditor() + this.counter.classList.remove('hidden') + setTimeout(this.respond_after_maskeditor.bind(this), 1000) } respond_after_maskeditor() { @@ -52,28 +82,7 @@ class Popup extends HTMLSpanElement { } } - show(details) { - const detail = details.detail - if (detail.timeout) { - this.hide() - return - } - if (detail.maskedit) { - this.hide() - this.node = app.graph._nodes_by_id[detail.uid] - //this.node.imgs = [...detail.urls] - //this.node.imgs.forEach((url, i)=>{ this.node.imgs[i].src = api.apiURL( `/view?filename=${encodeURIComponent(url.filename)}&type=${url.type}&subfolder=${url.subfolder}`) }) - this.node.imgs = [] - detail.urls.forEach((url, i)=>{ - this.node.imgs.push( new Image() ); - this.node.imgs[i].src = api.apiURL( `/view?filename=${encodeURIComponent(url.filename)}&type=${url.type}&subfolder=${url.subfolder}`) - }) - ComfyApp.copyToClipspace(this.node) - ComfyApp.clipspace_return_node = this.node - ComfyApp.open_maskeditor() - setTimeout(this.respond_after_maskeditor.bind(this), 1000) - return - } + handle_urls(detail) { this.doing_text = (detail.text != null) this.n_images = detail.urls?.length @@ -107,6 +116,7 @@ class Popup extends HTMLSpanElement { if (detail.text) { this.text_edit = create('textarea', 'text_edit', this.grid, {"innerHTML":detail.text}) } this.layout() this.classList.remove('hidden') + this.counter.classList.remove('hidden') } on_keydown(e) { diff --git a/pyproject.toml b/pyproject.toml index 96dd7d1..79fd24e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "cg-image-filter" description = "A custom node that pauses the flow while you choose which image or images to pass on to the rest of the workflow. Simplified and improved version of cg-image-picker." -version = "0.1" +version = "1.0" license = "LICENSE" [project.urls]