This commit is contained in:
Chris
2025-05-06 15:24:05 +10:00
parent a269fefe1c
commit 29131ec1f9
9 changed files with 95 additions and 73 deletions
+32 -25
View File
@@ -15,9 +15,15 @@ There's an example workflow that illustrates all of them at the end.
There are also some helper nodes.
If you prefer trying a workflow to reading docs, use of the nodes is illustrated in this seahorse (drag the image into Comfy):
If you prefer trying a workflow to reading docs, use of the nodes is illustrated in this blob (drag the image into Comfy):
<img src="https://github.com/chrisgoringe/cg-image-filter/raw/main/images/seahorse.png" alt="Seahorse" width="200" height="200">
<img src="https://github.com/chrisgoringe/cg-image-filter/raw/main/images/blob.png" alt="Seahorse" width="200" height="200">
## New in 1.5 ##
- Floating window for extras and tips
- Mask editor node now has option for text extras
- Keyboard and mouse navigation in zoomed view
## New in 1.4 ##
@@ -34,17 +40,6 @@ Video previews!
- pick_list to automatically select images in `Image Filter`
- optional initial mask input to `Mask Image Filter`
## New in 1.2 ##
- Options have all moved to the main settings
- New option to show a miniature window instead of taking over the screen - click on it to go into full screen mode
- List of ints output from `Split String by Commas`
## New in 1.1.6 ##
- `Pick from List` to allow you to select matching items from any list
- `Batch from Image List` and `Image List from Batch` to work with image batches
- hover mouse over image and press space to zoom, space again to close the zoom
## Examples of what you might do with them
@@ -60,11 +55,13 @@ Video previews!
![image](images/options.png)
- `AutosendIdentical` In the ImageFilter node, if all images are identical (including if there is just one image) then send
- `If all images are identical, autosend one` - in the ImageFilter node, if all images are identical (including if there is just one image) then send
an image without user interaction.
- `ClickSends` In the ImageFilter node, clicking an image sends it instead of selecting it. Useful if you know you only ever want to send one image at most.
- `PlaySound` Play a 'ding' sound when any of the filter nodes becomes active.
- `SmallWindow` Instead of taking over the whole screen immediately, display a tiny version of the image in the top left. Click that image to go into the full screen mode.
- `Clicking an image sends it` - In the ImageFilter node, clicking an image sends it instead of selecting it. Useful if you know you only ever want to send one image at most.
- `Show a small popup instead of covering the screen` - instead of taking over the whole screen immediately, display a tiny version of the image in the top left. Click that image to go into the full screen mode. You can move the tiny image window around to where you want it by dragging the title bar.
- `Enter the Image Filter node with an image zoomed` - instead of showing the grid of images, zoom in on one. Options are `first` or `last`
- `Play sound when activating` - play a 'ding' sound when any of the filter nodes becomes active. You can change the sound by replacing the file `ding.mp3` in the `js` subfolder.
- `Video Frames per Second` - when previewing vidoe(s), try to play them at this speed
---
@@ -72,10 +69,7 @@ an image without user interaction.
In `Image Filter` and `Text Image Filter` (not `Mask Image Filter`)
- `Space` when hovering over an image enlarges it. While enlarged:
- `Space` closes the zoom
- `ArrowLeft` and `ArrowRight` select the previous/next image
- `ArrowUp` clicks this image (select/deselect or send, depending on the value of `ClickSends`)
- `Space` when hovering over an image enlarges it. See `Zoom` in the `Image Filter` section below.
- `Escape` to click the cancel button
- `Enter`to click the send button
- digits `0`, `1`, etc. to click an image (zero-indexed) (select/deselect or send, depending on the value of `ClickSends`)
@@ -95,8 +89,8 @@ you want to progress:
![image](images/popup.png)
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.
Click the images that you want to keep (their border will turn green) and then click 'Send' to continue the workflow.
If you don't want to keep any of the images, click 'Cancel' (or press 'escape') to terminate the workflow.
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.
@@ -105,6 +99,19 @@ Here's a simple use: generate a batch of images and pick which ones you want to
![workflow](images/workflow.png)
### Zoom
If you hover over an image (it gets a red border) and press the space bar, it will zoom to fill the screen.
You can also use the `Enter the Image Filter node with an image zoomed` setting to start in this mode.
While zoomed, you can navigate with keyboard or mouse:
| |Mouse|Keyboard|Notes|
|-|-|-|-|
|Select/unselect/send|Click the zoomed image|`Arrow Up`|Will select/unselect the image, or send it if you have `Clicking an image sends it` turned on|
|Previous/Next|Click the arrows to the left or right|`Arrow Left` `Arrow Right`|The image number is shown near the top right|
|Unzoom| |`Space`|Back to the grid view|
### Optional inputs
The Latent and Mask inputs are optional. If used, they should have the same number of latents (or masks) as the image batch,
@@ -228,9 +235,9 @@ This workflow:
- inpaints
- uses 'Image Filter' to choose which, if either, of the two images (before and after inpaint) to save
The workflow is embedded in the seahorse in a bottle:
The workflow is embedded in the blob in a bottle:
<img src="https://github.com/chrisgoringe/cg-image-filter/raw/main/images/seahorse.png" alt="Seahorse" width="200" height="200">
<img src="https://github.com/chrisgoringe/cg-image-filter/raw/main/images/blob.png" alt="Seahorse" width="200" height="200">
# Bugs, Ideas, and the future
+4 -4
View File
@@ -5,7 +5,7 @@ from comfy.model_management import InterruptProcessingException
import os
import torch
from .image_filter_messaging import send_and_wait, Response
from .image_filter_messaging import send_and_wait, Response, TimeoutResponse
HIDDEN = {
"prompt": "PROMPT",
@@ -66,7 +66,7 @@ class ImageFilter(PreviewImage):
response:Response = send_and_wait(payload, timeout, uid, node_identifier)
if response.timeout:
if isinstance(response, TimeoutResponse):
if ontimeout=='send none': images_to_return = []
if ontimeout=='send all': images_to_return = [*range(len(images))]
if ontimeout=='send first': images_to_return = [0,]
@@ -75,7 +75,7 @@ class ImageFilter(PreviewImage):
e1, e2, e3 = response.get_extras([extra1, extra2, extra3])
images_to_return = response.selection
if len(images_to_return) == 0: raise InterruptProcessingException()
if images_to_return is None or len(images_to_return) == 0: raise InterruptProcessingException()
if video_frames>1:
images_to_return = [ key*video_frames + frm for key in images_to_return for frm in range(video_frames) ]
@@ -123,7 +123,7 @@ class TextImageFilterWithExtras(PreviewImage):
if mask is not None: payload['mask_urls'] = self.save_images(images=mask_to_image(mask), **kwargs)['ui']['images']
response = send_and_wait(payload, timeout, uid, node_identifier)
if response.timeout:
if isinstance(response, TimeoutResponse):
return (image, text, extra1, extra2, extra3)
return (image, response.text, *response.get_extras([extra1, extra2, extra3]))
+31 -26
View File
@@ -5,11 +5,10 @@ import time, json
from typing import Optional
REQUEST_RESHOW = "-1"
REQUEST_TIMER_RESET = "-2"
CANCEL = "-3"
WAITING_FOR_RESPONSE = "-9"
SPECIALS = [REQUEST_RESHOW, REQUEST_TIMER_RESET, CANCEL, WAITING_FOR_RESPONSE]
SPECIALS = [REQUEST_RESHOW, CANCEL, WAITING_FOR_RESPONSE]
class Response:
def __init__(self, selection:Optional[list[int]] = None, text:Optional[str] = None,
@@ -18,18 +17,17 @@ class Response:
self.text = text
self.masked_image = masked_image
self.extras = extras
self.timeout = False
def get_extras(self,defaults:list[str]) -> list[str]:
return self.extras or defaults
class TimeoutResponse(Response):
def __init__(self):
super().__init__()
self.timeout = True
class TimeoutResponse(Response): pass
class CancelledResponse(Response): pass
class RequestResponse(Response): pass
class MessageState:
latest = None
unique_expected = None
def __init__(self, data:dict|str={}):
if not isinstance(data,dict): data = json.loads(data)
self.unique:str = data.pop('unique', None)
@@ -40,11 +38,18 @@ class MessageState:
def waiting_state(cls): return MessageState(data={'special':WAITING_FOR_RESPONSE})
@classmethod
def start_waiting(cls): cls.latest = cls.waiting_state()
def request_state(cls): return MessageState(data={'special':REQUEST_RESHOW})
@classmethod
def start_waiting(cls, unique):
cls.latest = cls.waiting_state()
cls.unique_expected = unique
@classmethod
def get_response(cls) -> Response:
if cls.waiting(): return TimeoutResponse()
if cls.latest.cancelled: return CancelledResponse()
if cls.latest.request: return RequestResponse()
return cls.latest.response
@classmethod
@@ -53,8 +58,11 @@ class MessageState:
@classmethod
def waiting(cls): return cls.latest.special == WAITING_FOR_RESPONSE
@classmethod
def cancelled(cls): return cls.latest.special == CANCEL
@property
def cancelled(self): return self.special == CANCEL
@property
def request(self): return self.special == REQUEST_RESHOW
@property
def real(self): return self.special is None
@@ -66,17 +74,18 @@ async def cg_image_filter_message(request):
response = post.get("response")
message = MessageState(response)
if (MessageState.waiting()):
MessageState.latest = message
elif (message.real and not MessageState.cancelled()):
MessageState.latest = message
if str(MessageState.unique_expected)==str(message.unique):
if (MessageState.waiting()):
MessageState.latest = message
else:
print(f"Ignoring response {response} because not waiting for one")
else:
print(f"Ignoring response {response}")
print(f"Ignoring mismatched response {response}")
return web.json_response({})
def wait_for_response(secs, uid, unique) -> Response:
MessageState.start_waiting()
MessageState.start_waiting(unique)
try:
end_time = time.monotonic() + secs
while(time.monotonic() < end_time and MessageState.waiting()):
@@ -85,20 +94,16 @@ def wait_for_response(secs, uid, unique) -> Response:
time.sleep(0.2)
if MessageState.waiting():
PromptServer.instance.send_sync("cg-image-filter-images", {"timeout": True, "uid": uid, "unique":unique})
elif MessageState.cancelled():
raise InterruptProcessingException()
elif str(MessageState.latest.unique)!=str(unique):
print("Mismatched uniques...")
return MessageState.get_response()
finally: MessageState.stop_waiting()
def send_and_wait(payload, timeout, uid, unique) -> Response:
payload['uid'] = uid
payload['unique'] = unique
PromptServer.instance.send_sync("cg-image-filter-images", payload)
return wait_for_response(timeout, uid, unique)
while True:
PromptServer.instance.send_sync("cg-image-filter-images", payload)
r = wait_for_response(timeout, uid, unique)
if isinstance(r,CancelledResponse): raise InterruptProcessingException()
if (not isinstance(r, RequestResponse)): return r
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 34 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

+3 -1
View File
@@ -34,6 +34,7 @@ app.registerExtension({
{
id: "Image Filter.Actions.Click Sends",
name: "Clicking an image sends it",
tooltip: "Use if you always want to send exactly one image.",
type: "boolean",
defaultValue: false
},
@@ -54,12 +55,13 @@ app.registerExtension({
id: "Image Filter.UI.Small Window",
name: "Show a small popup instead of covering the screen",
type: "boolean",
tooltip: "click the small popup to activate it",
tooltip: "Click the small popup to activate it",
defaultValue: false
},
{
id: "Image Filter.Z.Detailed Logging",
name: "Turn on detailed logging",
tooltip: "If you are asked to for debugging!",
type: "boolean",
defaultValue: false
},
+25 -17
View File
@@ -82,6 +82,7 @@ class Popup extends HTMLSpanElement {
this.picked = new Set()
document.addEventListener("keydown", this.on_key_down.bind(this))
document.addEventListener("keypress", this.on_key_press.bind(this))
document.body.appendChild(this)
this.last_response_sent = 0
@@ -407,25 +408,32 @@ class Popup extends HTMLSpanElement {
e.preventDefault()
}
on_key_down(e) {
on_key_press(e) {
if (document.activeElement?.type=='text' || document.activeElement?.type=='textarea') {
if (this.floating_window.contains(document.activeElement) || this.contains(document.activeElement)) return
}
if (this.state!=State.IDLE && this.state!=State.TINY) {
this.eat_event(e)
}
}
on_key_down(e) {
if (document.activeElement?.type=='text' || document.activeElement?.type=='textarea') {
if (this.floating_window.contains(document.activeElement) || this.contains(document.activeElement)) return
}
if (this.state==State.FILTER || this.state==State.TEXT) {
if (document.activeElement?.type=='text' || document.activeElement?.type=='textarea') {
return
} else {
if (e.key=='Enter') {
this.send_current_state()
return this.eat_event(e)
}
if (e.key=='Escape') {
this.send_cancel()
return this.eat_event(e)
}
if (`${parseInt(e.key)}`==e.key) {
this.select_unselect(parseInt(e.key))
this.render()
return this.eat_event(e)
}
if (e.key=='Enter') {
this.send_current_state()
return this.eat_event(e)
}
if (e.key=='Escape') {
this.send_cancel()
return this.eat_event(e)
}
if (`${parseInt(e.key)}`==e.key) {
this.select_unselect(parseInt(e.key))
this.render()
return this.eat_event(e)
}
}