Update to 1.5

V 1.5
This commit is contained in:
chrisgoringe
2025-05-07 14:03:36 +10:00
committed by GitHub
17 changed files with 702 additions and 397 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
+1 -1
View File
@@ -9,7 +9,7 @@ from .image_filter import ImageFilter, MaskImageFilter, TextImageFilterWithExtra
from .list_utility_nodes import PickFromList, BatchFromImageList, ImageListFromBatch
from .string_utility_nodes import SplitByCommas, StringToFloat, StringToInt
VERSION = "1.4.2"
VERSION = "1.5"
WEB_DIRECTORY = "./js"
NODE_CLASS_MAPPINGS= {
+31 -95
View File
@@ -1,79 +1,11 @@
from server import PromptServer
from aiohttp import web
from nodes import PreviewImage, LoadImage
from comfy.model_management import InterruptProcessingException, throw_exception_if_processing_interrupted
import time, os, random
from comfy.model_management import InterruptProcessingException
import os
import torch
REQUEST_RESHOW = "-1"
REQUEST_TIMER_RESET = "-2"
CANCEL = "-3"
WAITING_FOR_RESPONSE = "-9"
SPECIALS = [REQUEST_RESHOW, REQUEST_TIMER_RESET, CANCEL, WAITING_FOR_RESPONSE]
class CancelledByUser(Exception): pass
##
#
# response should be a dict just containing key 'response'
# which holds a comma separated list of integers of selected images (0 indexed)
# where '' indicates cancel
#
##
@PromptServer.instance.routes.post('/cg-image-filter-message')
async def cg_image_filter_message(request):
post = await request.post()
response = post.get("response")
if (Message.data is None or (Message.data != CANCEL and response not in SPECIALS)):
Message.setdata(response, "response")
else:
print(f"Ignoring response {response} as current response is {Message.data}")
return web.json_response({})
def wait(secs, uid, unique):
Message.setdata(None, "start of wait")
end_time = time.monotonic() + secs
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()), "uid": uid, "unique":unique})
time.sleep(0.2)
response = Message.data
Message.setdata(None, "read response")
if response is None:
PromptServer.instance.send_sync("cg-image-filter-images", {"timeout": True, "uid": uid, "unique":unique})
return response
def send_with_resend(payload, timeout, uid, unique):
response = WAITING_FOR_RESPONSE
payload['unique'] = unique
while response in SPECIALS:
PromptServer.instance.send_sync("cg-image-filter-images", payload)
response = wait(timeout, uid, unique)
if response == CANCEL:
raise InterruptProcessingException()
if Message.unique != unique:
print("Mismatched uniques...")
return response
def mask_to_image(mask:torch.Tensor):
return torch.stack([mask, mask, mask, 1.0-mask], -1)
class Message:
data:str = None
unique:str = None
@classmethod
def setdata(cls, v, comment):
#print(f"Message.data set to {v} (${comment})")
if v:
chop = v.split('!unique!')
cls.data = chop[0]
cls.unique = chop[1] if len(chop)>1 else None
else:
cls.data = None
cls.unique = None
from .image_filter_messaging import send_and_wait, Response, TimeoutResponse
HIDDEN = {
"prompt": "PROMPT",
@@ -82,8 +14,6 @@ HIDDEN = {
"node_identifier": "NID",
}
class ImageFilter(PreviewImage):
RETURN_TYPES = ("IMAGE","LATENT","MASK","STRING","STRING","STRING","STRING")
RETURN_NAMES = ("images","latents","masks","extra1","extra2","extra3","indexes")
@@ -134,23 +64,23 @@ class ImageFilter(PreviewImage):
urls:list[str] = self.save_images(images=images, **kwargs)['ui']['images']
payload = {"uid": uid, "urls":urls, "allsame":all_the_same, "extras":[extra1, extra2, extra3], "tip":tip, "video_frames":video_frames}
response = send_with_resend(payload, timeout, uid, node_identifier)
response:Response = send_and_wait(payload, timeout, uid, node_identifier)
if response:
response, e1, e2, e3 = response.split("|||")
images_to_return = [int(x) for x in response.split(",") if x]
else:
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,]
if ontimeout=='send last': images_to_return = [len(images)//video_frames,]
else:
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) ]
images = torch.stack(list(images[i] for i in images_to_return))
images = torch.stack(list(images[int(i)] for i in images_to_return))
latents = {"samples": torch.stack(list(latents['samples'][i] for i in images_to_return))} if latents is not None else None
masks = torch.stack(list(masks[i] for i in images_to_return)) if masks is not None else None
@@ -192,15 +122,18 @@ class TextImageFilterWithExtras(PreviewImage):
if textareaheight is not None: payload['textareaheight'] = textareaheight
if mask is not None: payload['mask_urls'] = self.save_images(images=mask_to_image(mask), **kwargs)['ui']['images']
response = send_with_resend(payload, timeout, uid, node_identifier)
response = response.split("|||") if response else [text, extra1, extra2, extra3]
response = send_and_wait(payload, timeout, uid, node_identifier)
if isinstance(response, TimeoutResponse):
return (image, text, extra1, extra2, extra3)
return (image, *response)
return (image, response.text, *response.get_extras([extra1, extra2, extra3]))
def mask_to_image(mask:torch.Tensor):
return torch.stack([mask, mask, mask, 1.0-mask], -1)
class MaskImageFilter(PreviewImage, LoadImage):
RETURN_TYPES = ("IMAGE","MASK")
RETURN_NAMES = ("image","mask")
RETURN_TYPES = ("IMAGE","MASK","STRING","STRING","STRING")
RETURN_NAMES = ("image","mask","extra1","extra2","extra3")
FUNCTION = "func"
CATEGORY = "image_filter"
OUTPUT_NODE = False
@@ -214,7 +147,11 @@ class MaskImageFilter(PreviewImage, LoadImage):
"if_no_mask": (["cancel", "send blank"], {}),
},
"optional": {
"mask" : ("MASK", {"tooltip":"optional initial mask"})
"mask" : ("MASK", {"tooltip":"optional initial mask"}),
"tip" : ("STRING", {"default":"", "tooltip": "Optional - if provided, will be displayed in popup window"}),
"extra1" : ("STRING", {"default":""}),
"extra2" : ("STRING", {"default":""}),
"extra3" : ("STRING", {"default":""}),
},
"hidden": HIDDEN,
}
@@ -226,23 +163,22 @@ class MaskImageFilter(PreviewImage, LoadImage):
@classmethod
def VALIDATE_INPUTS(cls, **kwargs): return True
def func(self, image, timeout, uid, if_no_mask, node_identifier, mask=None, **kwargs):
def func(self, image, timeout, uid, if_no_mask, node_identifier, mask=None, extra1="", extra2="", extra3="", tip="", **kwargs):
if mask is not None and mask.shape[:3] == image.shape[:3] and not torch.all(mask==0):
saveable = torch.cat((image, mask.unsqueeze(-1)), dim=-1)
else:
saveable = image
urls:list[str] = self.save_images(images=saveable, **kwargs)['ui']['images']
payload = {"uid": uid, "urls":urls, "maskedit":True}
response = send_with_resend(payload, timeout, uid, node_identifier)
payload = {"uid": uid, "urls":urls, "maskedit":True, "extras":[extra1, extra2, extra3], "tip":tip}
response = send_and_wait(payload, timeout, uid, node_identifier)
if (response):
if (response.masked_image):
try:
filename = response.split('=')[1].split('&')[0]
return self.load_image(os.path.join('clipspace', filename)+" [input]")
return ( *(self.load_image(os.path.join('clipspace', response.masked_image)+" [input]")), *response.get_extras([extra1, extra2, extra3]) )
except FileNotFoundError:
pass
if if_no_mask == 'cancel':
raise InterruptProcessingException()
return self.load_image(urls[0]['filename']+" [temp]")
return ( *(self.load_image(urls[0]['filename']+" [temp]")), *response.get_extras([extra1, extra2, extra3]) )
+109
View File
@@ -0,0 +1,109 @@
from server import PromptServer
from aiohttp import web
from comfy.model_management import InterruptProcessingException, throw_exception_if_processing_interrupted
import time, json
from typing import Optional
REQUEST_RESHOW = "-1"
CANCEL = "-3"
WAITING_FOR_RESPONSE = "-9"
SPECIALS = [REQUEST_RESHOW, CANCEL, WAITING_FOR_RESPONSE]
class Response:
def __init__(self, selection:Optional[list[int]] = None, text:Optional[str] = None,
masked_image:Optional[str] = None, extras:Optional[list[str]] = None):
self.selection = selection
self.text = text
self.masked_image = masked_image
self.extras = extras
def get_extras(self,defaults:list[str]) -> list[str]:
return self.extras or defaults
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)
self.special:Optional[int] = data.pop('special',None)
self.response:Response = Response(**data)
@classmethod
def waiting_state(cls): return MessageState(data={'special':WAITING_FOR_RESPONSE})
@classmethod
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
def stop_waiting(cls): cls.latest = MessageState()
@classmethod
def waiting(cls): return cls.latest.special == WAITING_FOR_RESPONSE
@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
@PromptServer.instance.routes.post('/cg-image-filter-message')
async def cg_image_filter_message(request):
post = await request.post()
response = post.get("response")
message = MessageState(response)
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 mismatched response {response}")
return web.json_response({})
def wait_for_response(secs, uid, unique) -> Response:
MessageState.start_waiting(unique)
try:
end_time = time.monotonic() + secs
while(time.monotonic() < end_time and MessageState.waiting()):
throw_exception_if_processing_interrupted()
PromptServer.instance.send_sync("cg-image-filter-images", {"tick": int(end_time - time.monotonic()), "uid": uid, "unique":unique})
time.sleep(0.2)
if MessageState.waiting():
PromptServer.instance.send_sync("cg-image-filter-images", {"timeout": True, "uid": uid, "unique":unique})
return MessageState.get_response()
finally: MessageState.stop_waiting()
def send_and_wait(payload, timeout, uid, unique) -> Response:
payload['uid'] = uid
payload['unique'] = 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

+13 -104
View File
@@ -7,49 +7,12 @@
--text_area_height: 150px;
}
.cg_counter {
position: absolute;
top: 45px;
left: 70px;
color: white;
padding: 10px;
z-index: 110000;
background-color: rgba(128, 128, 128, 0.5);
text-align: center;
}
.tiny_window {
position: absolute;
width: 80px;
height: 80px;
left: 70px;
top: 70px;
border: 1px solid white;
background-color: #fdfdfd30;
z-index: 110000;
}
.tiny_image {
padding: 1px;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
.cg_counter .counter_text {}
.cg_counter .counter_reset {
margin-left: 5px;
padding: 1px;
}
.cg_popup .grid {
position: absolute;
width:80%;
left:5%;
height:calc(90% - var(--text_area_height));
top:5%;
width:70%;
left:15%;
height:calc(100% - 60px - var(--text_area_height));
top:55px;
display: grid;
align-content: space-evenly;
justify-content: space-evenly;
@@ -62,9 +25,10 @@
.cg_popup .title {
position: absolute;
left: 40%;
top: 2%;
font-size: larger;
width:100%;
text-align: center;
top: 10px;
font-size: 250%;
}
.cg_popup .tip {
@@ -104,65 +68,10 @@
box-shadow: 0 0 4px 4px red;
}
.cg_popup .zoomed {
position: absolute;
width: 100%;
left: 0;
height: 100%;
right: 0;
display: block;
z-index: 120000;
background-color: rgba(29, 29, 29, 0.886);
}
.cg_popup .zoomed.hidden {
display: none;
}
.cg_popup .zoomed_image {
width: 100%;
height: 100%;
object-fit: contain;
}
.cg_popup .zoomed_number {
position: absolute;
top: 40%;
left: 30px;
color: white;
padding: 10px;
z-index: 110000;
background-color: rgba(128, 128, 128, 0.5);
/*text-align: center;*/
}
.cg_popup .zoomed.highlighted .zoomed_image {
border: 3px solid green;
padding: 2px;
}
.cg_popup .buttons button {
margin: 5px 0 5px 0;
padding: 4px;
display: block;
width: 100px;
}
.cg_popup .buttons input {
padding: 4px;
display: inline;
}
.cg_popup .text_edit {
position: absolute;
display:block;
width: 80%;
left: 10%;
bottom: 10px;
height: calc(var(--text_area_height) - 20px);
}
.cg_popup.hidden {
display:none
}
}
.cg_popup .hidden {
display: none;
}
+94
View File
@@ -0,0 +1,94 @@
.cgfloat {
position: absolute;
z-index: 999999;
border: 1px solid black;
background-color: rgba(95, 158, 160, 0.713);
width: fit-content;
}
.cgfloat.hidden {
display:none
}
.cgfloat_header {
width: 100%;
text-align: center;
background-color: rgba(28, 51, 52, 0.713);
color: whitesmoke;
border-bottom: 1px solid black;
padding:4px;
font-size:larger;
}
.tiny .cgfloat_header {
font-size: smaller;
}
.cgfloat_body {
min-width: fit-content;
display:flex;
flex-direction: column;
}
.cgfloat_body .row {
color: white;
padding: 0px 4px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px dashed rgba(0, 0, 0, 0.45);
}
/* Countdown */
.cgfloat_body .counter_text {
min-width:50px;
text-align: right;
}
.cgfloat_body .counter_reset {
margin-left: 5px;
padding: 1px;
}
/* Buttons */
.cgfloat_body .row.buttons {
justify-content: space-evenly;
}
.cgfloat_body button {
margin: 4px;
padding: 2px;
min-width: 70px;
}
/* Extras */
.cgfloat_body .row.extras {
flex-direction: column;
}
.cgfloat_body .row.extras .extra {
width: 100%;
margin: 2px 0;
}
/* Tips */
.cgfloat_body .row.tip {
margin: 4px;
padding: 4px 0px;
justify-content: flex-start;
}
/* Text Edit */
.cgfloat_body .row.text_edit {
margin: 4px;
}
.cgfloat .tiny_image {
padding: 1px;
width: auto;
max-height: 100px;
object-fit: contain;
pointer-events: none;
}
+59
View File
@@ -0,0 +1,59 @@
export class FloatingWindow extends HTMLDivElement {
constructor(title, x, y, parent) {
super()
this.classList.add('cgfloat')
this.header = document.createElement('div')
this.header.classList.add('cgfloat_header')
this.header.innerText = title
this.append(this.header)
this.body = document.createElement('div')
this.body.classList.add('cgfloat_body')
this.append(this.body)
this.header.addEventListener('mousedown',this.header_mousedown.bind(this))
document.addEventListener('mouseup',this.header_mouseup.bind(this))
document.addEventListener('mousemove',this.header_mousemove.bind(this))
document.addEventListener('mouseleave',this.header_mouseup.bind(this))
this.dragging = false
this.move_to(x,y)
if (parent) parent.append(this)
else document.body.append(this)
}
show() { this.style.display = 'block' }
hide() { this.style.display = 'none' }
set_title(title) { this.header.innerText = title }
move_to(x,y) {
this.position = {x:x,y:y}
this.style.left = `${this.position.x}px`
this.style.top = `${this.position.y}px`
}
swallow(e) {
e.stopPropagation()
e.preventDefault()
}
header_mousedown(e) {
this.dragging = true
this.swallow(e)
}
header_mouseup(e) {
this.dragging = false
}
header_mousemove(e) {
if (this.dragging) {
this.move_to( this.position.x + e.movementX , this.position.y + e.movementY )
this.swallow(e)
}
}
}
customElements.define('cg-floater', FloatingWindow, {extends: 'div'})
+40 -10
View File
@@ -4,6 +4,7 @@ import { api } from "../../scripts/api.js";
import { create } from "./utils.js";
import { popup } from "./popup.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
import { FloatingWindow } from "./floating_window.js";
const FILTER_TYPES = ["Image Filter","Text Image Filter","Text Image Filter with Extras","Mask Image Filter"]
@@ -11,45 +12,73 @@ app.registerExtension({
name: "cg.image_filter",
settings: [
{
id: "ImageFilter.PlaySound",
id: "Image Filter. Image Filter",
name: "Version 1.5",
type: () => {
const x = document.createElement('span')
const a = document.createElement('a')
a.innerText = "Report issues or request features"
a.href = "https://github.com/chrisgoringe/cg-image-filter/issues"
a.target = "_blank"
a.style.paddingRight = "12px"
x.appendChild(a)
return x
},
},
{
id: "Image Filter.UI.Play Sound",
name: "Play sound when activating",
type: "boolean",
defaultValue: true
},
{
id: "ImageFilter.ClickSends",
name: "In Image Filter clicking an image sends it",
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
},
{
id: "ImageFilter.AutosendIdentical",
name: "In Image Filter, if all images are identical, autosend one",
id: "Image Filter.Actions.Autosend Identical",
name: "If all images are identical, autosend one",
type: "boolean",
defaultValue: false
},
{
id: "ImageFilter.SmallWindow",
name: "Initially show a small popup instead of covering the screen",
id: "Image Filter.UI.Start Zoomed",
name: "Enter the Image Filter node with an image zoomed",
type: "combo",
options: [ {value:0, text:"No"}, {value:"1", text:"first"}, {value:"-1", text:"last"} ],
default: 0,
},
{
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",
defaultValue: false
},
{
id: "ImageFilter.DetailedLogging",
id: "Image Filter.Z.Detailed Logging",
name: "Turn on detailed logging",
tooltip: "If you are asked to for debugging!",
type: "boolean",
defaultValue: false
},
{
id: "ImageFilter.FPS",
id: "Image Filter.Video.FPS",
name: "Video Frames per Second",
type: "int",
defaultValue: 1,
defaultValue: 5,
}
],
setup() {
create('link', null, document.getElementsByTagName('HEAD')[0],
{'rel':'stylesheet', 'type':'text/css', 'href': new URL("./filter.css", import.meta.url).href } )
create('link', null, document.getElementsByTagName('HEAD')[0],
{'rel':'stylesheet', 'type':'text/css', 'href': new URL("./floating_window.css", import.meta.url).href } )
create('link', null, document.getElementsByTagName('HEAD')[0],
{'rel':'stylesheet', 'type':'text/css', 'href': new URL("./zoomed.css", import.meta.url).href } )
api.addEventListener("execution_interrupted", popup.send_cancel.bind(popup));
api.addEventListener("cg-image-filter-images",popup.handle_message.bind(popup));
},
@@ -85,4 +114,5 @@ app.registerExtension({
}
},
})
+5 -3
View File
@@ -1,16 +1,18 @@
import { app } from "../../scripts/app.js";
export class Log {
static log(s) { if (s) console.log(s) }
static error(e) { console.error(e) }
static detail(s) {
if (app.ui.settings.getSettingValue("ImageFilter.DetailedLogging")) Log.log(s)
if (app.ui.settings.getSettingValue("Image Filter.Z.Detailed Logging")) Log.log(s)
}
static message_in(message, extra) {
if (!app.ui.settings.getSettingValue("ImageFilter.DetailedLogging")) return
if (!app.ui.settings.getSettingValue("Image Filter.Z.Detailed Logging")) return
if (message.detail && !message.detail.tick) Log.log(`--> ${JSON.stringify(message.detail)}` + (extra ? ` ${extra}` : ""))
if (message.detail && message.detail.tick) Log.log(`--> tick`)
}
static message_out(response, extra) {
if (!app.ui.settings.getSettingValue("ImageFilter.DetailedLogging")) return
if (!app.ui.settings.getSettingValue("Image Filter.Z.Detailed Logging")) return
Log.log(`"<-- ${JSON.stringify(response)}` + (extra ? ` ${extra}` : ""))
}
}
+13
View File
@@ -24,10 +24,23 @@ function get_mask_editor_cancel_button() {
return get_mask_editor_element?.parentElement?.lastChild?.childNodes[2]
}
function get_mask_editor_save_button() {
if (document.getElementById("maskEditor_topBarSaveButton")) return document.getElementById("maskEditor_topBarSaveButton")
return get_mask_editor_element?.parentElement?.lastChild?.childNodes[2]
}
export function mask_editor_listen_for_cancel(callback) {
const cancel_button = get_mask_editor_cancel_button()
if (cancel_button && !cancel_button.filter_listener_added) {
cancel_button.addEventListener('click', callback )
cancel_button.filter_listener_added = true
}
}
export function press_maskeditor_save() {
get_mask_editor_save_button().click()
}
export function press_maskeditor_cancel() {
get_mask_editor_cancel_button().click()
}
+243 -158
View File
@@ -1,9 +1,10 @@
import { app, ComfyApp } from "../../scripts/app.js";
import { api } from "../../scripts/api.js"
import { mask_editor_listen_for_cancel, mask_editor_showing, hide_mask_editor } from "./mask_utils.js";
import { mask_editor_listen_for_cancel, mask_editor_showing, hide_mask_editor, press_maskeditor_cancel, press_maskeditor_save } from "./mask_utils.js";
import { Log } from "./log.js";
import { create } from "./utils.js";
import { FloatingWindow } from "./floating_window.js";
//const EXTENSION_NODES = ["Image Filter", "Text Image Filter", "Mask Image Filter", "Text Image Filter with Extras",]
const POPUP_NODES = ["Image Filter", "Text Image Filter", "Text Image Filter with Extras",]
@@ -16,42 +17,14 @@ function get_full_url(url) {
return api.apiURL( `/view?filename=${encodeURIComponent(url.filename ?? v)}&type=${url.type ?? "input"}&subfolder=${url.subfolder ?? ""}&r=${Math.random()}`)
}
class State {
static INACTIVE = 0
static TINY = 1
static MASK = 2
static FILTER = 3
static TEXT = 4
static ZOOMED = 5
static visible(item, value) {
if (value) item.classList.remove('hidden')
else item.classList.add('hidden')
}
static highlighted(item, value) {
if (value) item.classList.add('highlighted')
else item.classList.remove('highlighted')
}
static render(popup) {
const state = popup.state
State.visible(popup, (state==State.FILTER || state==State.TEXT || state==State.ZOOMED))
State.visible(popup.tiny_window, state==State.TINY)
State.visible(popup.counter, (state==State.FILTER || state==State.ZOOMED || state==State.TEXT || state==State.MASK))
State.visible(popup.zoomed, state==State.ZOOMED)
State.visible(popup.text_edit, state==State.TEXT)
State.visible(popup.send_button, true /*!app.ui.settings.getSettingValue("ImageFilter.ClickSends")*/)
if (state==State.ZOOMED) {
const img_index = popup.zoomed_image_holder.image_index
State.highlighted(popup.zoomed, popup.picked.has(`${img_index}`))
popup.zoomed_number.innerHTML = `${img_index+1}/${popup.n_images}`
}
if (state!=State.MASK) hide_mask_editor()
}
}
const State = Object.freeze({
INACTIVE : 0,
TINY : 1,
MASK : 2,
FILTER : 3,
TEXT : 4,
ZOOMED : 5,
})
class Popup extends HTMLSpanElement {
constructor() {
@@ -62,146 +35,220 @@ class Popup extends HTMLSpanElement {
this.grid = create('span', 'grid', this)
this.overlaygrid = create('span', 'grid overlaygrid', this)
this.grid.addEventListener('click', this.on_click.bind(this))
this.zoomed = create('span', 'zoomed', this)
this.zoomed_image = create('img', 'zoomed_image', this.zoomed)
this.zoomed_number = create('span', 'zoomed_number', this.zoomed)
this.text_edit = create('textarea', 'text_edit', this)
this.title_bar = create('span', 'title', this)
this.buttons = create('span', 'buttons', this)
this.checkboxes = create('span', null, this.buttons)
this.zoomed_prev = create('span', 'zoomed_prev', this.zoomed)
this.zoomed_prev_arrow = create('span', 'zoomed_arrow', this.zoomed_prev, {innerHTML:"&#x21E6"})
this.zoomed_image = create('img', 'zoomed_image', this.zoomed)
this.zoomed_next = create('span', 'zoomed_next', this.zoomed)
this.zoomed_number = create('span', 'zoomed_number', this.zoomed_next)
this.zoomed_next_arrow = create('span', 'zoomed_arrow', this.zoomed_next, {innerHTML:"&#x21E8"})
this.send_button = create('button', 'control', this.buttons, {innerText:"Send"} )
this.cancel_button = create('button', 'control', this.buttons, {innerText:"Cancel"} )
this.extras = create('span', 'extras', this.buttons)
this.tip = create('span', 'tip', this.buttons)
this.zoomed_prev_arrow.addEventListener('click', this.zoom_prev.bind(this))
this.zoomed_next_arrow.addEventListener('click', this.zoom_next.bind(this))
this.zoomed_image.addEventListener('click', this.click_zoomed.bind(this))
this.tiny_window = create('span', 'tiny_window hidden', document.body)
this.tiny_image = create('img', 'tiny_image', this.tiny_window)
this.tiny_window = new FloatingWindow('', 100, 100)
this.tiny_window.classList.add('tiny')
this.tiny_image = create('img', 'tiny_image', this.tiny_window.body)
this.tiny_window.addEventListener('click', this.handle_deferred_message.bind(this))
this.counter = create('span', 'cg_counter hidden', document.body)
this.counter_text = create('span', 'counter_text', this.counter)
this.counter_reset_button = create('button', 'counter_reset', this.counter, {innerText:"Reset"} )
this.floating_window = new FloatingWindow('', 100, 100)
this.grid.addEventListener('click', this.on_click.bind(this))
this.counter_row = create('span', 'counter row', this.floating_window.body)
this.counter_reset_button = create('button', 'counter_reset', this.counter_row, {innerText:"Reset"} )
this.counter_text = create('span', 'counter_text', this.counter_row)
this.counter_reset_button.addEventListener('click', this.request_reset.bind(this) )
this.extras_row = create('span', 'extras row', this.floating_window.body)
this.tip_row = create('span', 'tip row', this.floating_window.body)
this.button_row = create('span', 'buttons row', this.floating_window.body)
this.send_button = create('button', 'control', this.button_row, {innerText:"Send"} )
this.cancel_button = create('button', 'control', this.button_row, {innerText:"Cancel"} )
this.send_button.addEventListener( 'click', this.send_current_state.bind(this) )
this.cancel_button.addEventListener('click', this.send_cancel.bind(this) )
this.counter_reset_button.addEventListener('click', this.request_reset.bind(this) )
this.mask_button_row = create('span', 'buttons row', this.floating_window.body)
this.mask_send_button = create('button', 'control', this.mask_button_row, {innerText:"Send"} )
this.mask_cancel_button = create('button', 'control', this.mask_button_row, {innerText:"Cancel"} )
this.mask_send_button.addEventListener( 'click', press_maskeditor_save )
this.mask_cancel_button.addEventListener('click', press_maskeditor_cancel )
this.text_edit = create('textarea', 'text_edit row', this.floating_window.body)
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
this.state = State.INACTIVE
State.render(this)
this.render()
}
_send_response(msg, special_message, keep_open) {
visible(item, value) {
if (value) item.classList.remove('hidden')
else item.classList.add('hidden')
}
disabled(item, value) {
item.disabled = value
}
highlighted(item, value) {
if (value) item.classList.add('highlighted')
else item.classList.remove('highlighted')
}
render() {
const state = this.state
this.visible(this, (state==State.FILTER || state==State.TEXT || state==State.ZOOMED))
this.visible(this.tiny_window, state==State.TINY)
this.visible(this.zoomed, state==State.ZOOMED)
this.visible(this.floating_window, (state==State.FILTER || state==State.ZOOMED || state==State.TEXT || state==State.MASK))
this.visible(this.button_row, state!=State.MASK)
this.disabled(this.send_button, (state==State.FILTER || state==State.ZOOMED) && this.picked.size==0)
this.visible(this.mask_button_row, state==State.MASK)
this.visible(this.extras_row, this.n_extras>0)
this.visible(this.tip_row, this.tip_row.innerHTML.length>0)
this.visible(this.text_edit, state==State.TEXT)
if (state==State.ZOOMED) {
const img_index = this.zoomed_image_holder.image_index
this.highlighted(this.zoomed, this.picked.has(`${img_index}`))
this.zoomed_number.innerHTML = `${img_index+1}/${this.n_images}`
}
if (state!=State.MASK) hide_mask_editor()
}
_send_response(msg={}, keep_open=false) {
/*
msg is a dict. Valid keys are:
*selection (list[int])
*text (string)
special (int)
masked_image (string)
*extras (list of strings)
*unique (string)
(*) are added
*/
if (Date.now()-this.last_response_sent < 1000) {
Log.message_out(msg, "(throttled)")
return
}
if (!special_message) {
Array.from(this.extras.children).forEach((e)=>{ msg = msg + "|||" + e.value })
msg.unique = `${this.node._ni_widget?.value}`
if (!msg.special) {
if (this.n_extras>0) {
msg.extras = []
Array.from(this.extras_row.children).forEach((e)=>{ msg.extras.push(e.value) })
}
if (this.state==State.FILTER || this.state==State.ZOOMED) msg.selection = Array.from(this.picked)
if (this.state==State.TEXT) msg.text = this.text_edit.value
this.last_response_sent = Date.now()
}
try {
const body = new FormData();
msg = `${msg}!unique!${this.unique}`
body.append('response', msg);
body.append('response', JSON.stringify(msg));
api.fetchApi("/cg-image-filter-message", { method: "POST", body, });
Log.message_out(msg)
} catch (e) { Log.error(e) }
finally { if (!keep_open) this.close() }
} catch (e) {
Log.error(e)
} finally {
if (!keep_open) this.close()
}
}
send_current_state() { this._send_response( (this.state == State.TEXT) ? this.text_edit.value : Array.from(this.picked).join() ) }
send_current_state() {
if (this.state == State.TEXT) {
this._send_response()
} else {
this._send_response()
}
}
send_cancel() { this._send_response(CANCEL, true) }
send_cancel() { this._send_response({special:CANCEL}) }
request_reset() { this._send_response(REQUEST_RESHOW, true, true) }
request_reset() { this._send_response({special:REQUEST_RESHOW}, true) }
close() {
this.state = State.INACTIVE
State.render(this)
this.render()
}
maybe_play_sound() { if (app.ui.settings.getSettingValue("ImageFilter.PlaySound")) this.audio.play(); }
maybe_play_sound() { if (app.ui.settings.getSettingValue("Image Filter.UI.Play Sound")) this.audio.play(); }
handle_message(message) {
Log.message_in(message)
Log.log( this._handle_message(message, false) )
State.render(this)
this.render()
}
handle_deferred_message(e) {
Log.message_in(this.saved_message, "(deferred)")
Log.log( this._handle_message(this.saved_message, true) )
State.render(this)
this.render()
}
autosend() {
return (app.ui.settings.getSettingValue("ImageFilter.AutosendIdentical") && this.allsame)
return (app.ui.settings.getSettingValue("ImageFilter.Actions.AutosendIdentical") && this.allsame)
}
_handle_message(message, use_saved) {
_handle_message(message, using_saved) {
const detail = message.detail
const uid = detail.uid
this.node = app.graph._nodes_by_id[uid]
const uid = message.detail.uid
const unique = message.detail.unique
const node = app.graph._nodes_by_id[uid]
if (!this.node) return console.log(`Message was for ${uid} which doesn't exist`)
if (this.node._ni_widget?.value != message.detail.unique) return console.log(`Message unique id wasn't mine`)
if (!node) {
console.log(`Message was for ${uid} which doesn't exist`)
if (detail.tick) {
this.counter_text.innerText = `${detail.tick}s`
if (this.state==State.INACTIVE) this.request_reset()
return
}
if (node._ni_widget?.value != message.detail.unique) {
console.log(`Message unique id wasn't mine`)
return
if (detail.timeout) {
this.close()
return `Timeout`
}
this.node = node
if (this.handling_message) return `Ignoring message because we're already handling a message`
this.allsame = message.detail.allsame || false
this.set_title(this.node.title ?? "Image Filter")
this.allsame = detail.allsame || false
if (detail.tip) this.tip_row.innerHTML = detail.tip.replace(/(?:\r\n|\r|\n)/g, '<br/>')
else this.tip_row.innerHTML = ""
if (this.state==State.INACTIVE && message.detail.urls && app.ui.settings.getSettingValue("ImageFilter.SmallWindow") && !use_saved && !this.autosend()) {
if (this.state==State.INACTIVE && app.ui.settings.getSettingValue("Image Filter.UI.Small Window") && !using_saved && !this.autosend()) {
this.state = State.TINY
this.saved_message = message
this.tiny_image.src = get_full_url(message.detail.urls[message.detail.urls.length-1])
this.maybe_play_sound()
return `Deferring message and showing small window`
}
//if ((Date.now()-this.last_response_sent < 500)) return `Ignoring message because we just responded`
if (this.handling_message && !detail.timeout) return `Ignoring message because we're already handling a message`
this.handling_message = true
try {
const detail = message.detail
if (detail.tick) {
this.counter_text.innerText = `${detail.tick}s`
if (this.state==State.INACTIVE) this.request_reset()
return
}
if (detail.timeout) {
this.close()
return `Timeout`
}
this.handling_message = true
this.n_extras = detail.extras ? message.detail.extras.length : 0
this.extras_row.innerHTML = ''
for (let i=0; i<this.n_extras; i++) { create('input', 'extra', this.extras_row, {value:detail.extras[i]}) }
if (!use_saved && !this.autosend()) this.maybe_play_sound()
if (!using_saved && !this.autosend()) this.maybe_play_sound()
if (detail.maskedit) this.handle_maskedit(detail)
else if (detail.urls) this.handle_urls(detail)
if (detail.tip) this.tip.innerHTML = detail.tip.replace(/(?:\r\n|\r|\n)/g, '<br/>')
else this.tip.innerHTML = ""
} finally { this.handling_message = false }
}
@@ -215,6 +262,11 @@ class Popup extends HTMLSpanElement {
)
}
set_title(title) {
this.floating_window.set_title(title)
this.tiny_window.set_title(title)
}
handle_maskedit(detail) {
this.state = State.MASK
@@ -234,26 +286,28 @@ class Popup extends HTMLSpanElement {
wait_while_mask_editing() {
if (!this.seen_editor && mask_editor_showing()) {
mask_editor_listen_for_cancel( this.send_cancel.bind(this) )
this.render()
this.seen_editor = true
}
if (mask_editor_showing()) {
setTimeout(this.wait_while_mask_editing.bind(this), 100)
} else {
this._send_response(this.node.imgs[0].src)
this._send_response({masked_image:this.extract_filename(this.node.imgs[0].src)})
}
}
handle_urls(detail) {
this.n_extras = detail.extras ? detail.extras.length : 0
this.video_frames = detail.video_frames || 1
extract_filename(url_string) {
return (new URL(url_string)).searchParams.get('filename')
}
this.extras.innerHTML = ''
for (let i=0; i<this.n_extras; i++) { create('input', 'extra', this.extras, {value:detail.extras[i]}) }
handle_urls(detail) {
this.video_frames = detail.video_frames || 1
// do this after the extras are set up so that we send the right extras
if (this.autosend()) {
this._send_response("0")
this.picked.add(0)
this._send_response()
return
}
@@ -261,16 +315,18 @@ class Popup extends HTMLSpanElement {
this.state = State.TEXT
//this.text_edit.innerHTML = detail.text
this.text_edit.value = detail.text
if (detail.textareaheight) document.getElementsByClassName('cg_popup')[0].style.setProperty('--text_area_height', `${detail.textareaheight}px`)
if (detail.textareaheight) this.text_edit.style.height = `${detail.textareaheight}px`
} else {
if (this.state != State.FILTER && this.state != State.ZOOMED && app.ui.settings.getSettingValue("Image Filter.UI.Start Zoomed")!=0) {
setTimeout(this.zoom_auto.bind(this), 50)
}
this.state = State.FILTER
}
this.n_images = detail.urls?.length
this.laidOut = false
this.title_bar.innerText = app.graph._nodes_by_id[detail.uid]?.title ?? "Image Filter"
this.laidOut = -1
this.picked = new Set()
if (this.n_images==1) this.picked.add('0')
@@ -310,7 +366,7 @@ class Popup extends HTMLSpanElement {
this.frame = (this.frame+1)%this.video_frames
Array.from(this.grid.children).forEach((img)=>{img.src = img.frames[this.frame]})
const fps = app.ui.settings.getSettingValue("ImageFilter.FPS")
const fps = app.ui.settings.getSettingValue("Image Filter.Video.FPS")
const delay = (fps>0) ? 1000/fps : 1000
setTimeout(this.advance_videos.bind(this), delay)
}
@@ -325,65 +381,92 @@ class Popup extends HTMLSpanElement {
this.redraw()
}
on_key_down(e) {
var used_keypress = false;
zoom_auto() {
this.state = State.ZOOMED
this.zoomed_image_holder = (app.ui.settings.getSettingValue("Image Filter.UI.Start Zoomed")==1) ? this.grid.firstChild : this.grid.lastChild
return this.show_zoomed()
}
zoom_next() {
this.zoomed_image_holder = this.zoomed_image_holder.nextSibling || this.zoomed_image_holder.parentNode.firstChild
this.show_zoomed()
}
zoom_prev() {
this.zoomed_image_holder = this.zoomed_image_holder.previousSibling || this.zoomed_image_holder.parentNode.lastChild
this.show_zoomed()
}
click_zoomed() {
const fake_event = { target:this.zoomed_image_holder}
this.on_click(fake_event)
this.show_zoomed()
}
show_zoomed() {
this.zoomed_image.src = this.zoomed_image_holder.src
return this.render()
}
eat_event(e) {
e.stopPropagation()
e.preventDefault()
}
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.INACTIVE && 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.INACTIVE && this.state==State.TINY) return
}
if (this.state==State.FILTER || this.state==State.TEXT) {
if (document.activeElement?.type=='text' || document.activeElement?.type=='textarea') {
return
// don't capture keys when editing text
} else {
if (e.key=='Enter') {
this.send_current_state()
used_keypress = true
}
if (e.key=='Escape') {
this.send_cancel()
used_keypress = true
}
if (`${parseInt(e.key)}`==e.key) {
this.select_unselect(parseInt(e.key))
used_keypress = true
}
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 (this.state==State.FILTER && !used_keypress) {
if (this.state==State.FILTER) {
if (e.key==' ' && this.mouse_is_over) {
this.state = State.ZOOMED
this.zoomed_image_holder = this.mouse_is_over
this.on_mouse_out(this.mouse_is_over)
used_keypress = true
//this.on_mouse_out(this.mouse_is_over)
this.eat_event(e)
return this.show_zoomed()
}
}
if (this.state==State.ZOOMED && !used_keypress) {
if (this.state==State.ZOOMED) {
if (e.key==' ') {
this.state = State.FILTER
this.zoomed_image_holder = null
used_keypress = true
this.eat_event(e)
return this.render()
} else if (e.key=='ArrowUp') {
const fake_event = { target:this.zoomed_image_holder}
this.on_click(fake_event)
this.click_zoomed()
return this.eat_event(e)
} else if (e.key=='ArrowDown') {
// select or unselect
} else if (e.key=='ArrowRight') {
this.zoomed_image_holder = this.zoomed_image_holder.nextSibling || this.zoomed_image_holder.parentNode.firstChild
used_keypress = true
this.zoom_next()
return this.eat_event(e)
} else if (e.key=='ArrowLeft') {
this.zoomed_image_holder = this.zoomed_image_holder.previousSibling || this.zoomed_image_holder.parentNode.lastChild
used_keypress = true
this.zoom_prev()
return this.eat_event(e)
}
}
if (used_keypress) {
e.stopPropagation()
e.preventDefault()
if (this.zoomed_image_holder) this.zoomed_image.src = this.zoomed_image_holder.src
State.render(this)
}
}
select_unselect(n) {
@@ -391,8 +474,9 @@ class Popup extends HTMLSpanElement {
return
}
const s = `${n}`
if (app.ui.settings.getSettingValue("ImageFilter.ClickSends")) {
this._send_response(s)
if (app.ui.settings.getSettingValue("Image Filter.Actions.Click Sends")) {
this.picked.add(s)
this._send_response()
} else {
if (this.picked.has(s)) this.picked.delete(s)
else this.picked.add(s)
@@ -407,11 +491,12 @@ class Popup extends HTMLSpanElement {
}
layout() {
if (this.laidOut) return
const box = this.grid.getBoundingClientRect()
if (this.laidOut==box.width) return
const im_w = this.grid.firstChild.naturalWidth
const im_h = this.grid.firstChild.naturalHeight
const box = this.grid.getBoundingClientRect()
var per_row
if (!im_w || !im_h || !box.width || !box.height) {
setTimeout(this.layout.bind(this), 100)
@@ -428,7 +513,7 @@ class Popup extends HTMLSpanElement {
}
}
per_row = best_pick
this.laidOut = true
this.laidOut = box.width
}
const rows = Math.ceil(this.n_images/per_row)
+61
View File
@@ -0,0 +1,61 @@
.cg_popup .zoomed {
position: absolute;
width: 100%;
left: 0;
height: 100%;
right: 0;
display: flex;
justify-content: space-evenly;
align-items: center;
z-index: 120000;
background-color: rgba(29, 29, 29, 0.886);
}
.cg_popup .zoomed_prev, .zoomed_next {
flex-grow: 1;
}
.cg_popup .zoomed_prev {
text-align: right;
}
.cg_popup .zoomed_arrow {
font-size: 48px;
border-radius: 40px;
padding: 0px 8px 4px 8px;
margin: 6px;
border: 1px solid rgb(128, 128, 128, 1.0);
}
.cg_popup .zoomed_arrow:hover {
border: 1px solid rgb(255, 128, 128, 1.0);
}
.cg_popup .zoomed_image {
/*max-width: 90%;*/
height: 100%;
object-fit: contain;
border: 1px solid white;
padding: 4px;
}
.cg_popup .zoomed_number {
position: absolute;
top: 12px;
margin-left: 12px;
color: white;
padding: 10px 8px 8px 8px;
border-radius: 4px;
z-index: 110000;
background-color: rgba(128, 128, 128, 0.5);
border: 1px solid rgba(128, 128, 128, 1.0);
}
.cg_popup .zoomed.highlighted .zoomed_image {
border: 3px solid green;
padding: 2px;
}
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "cg-image-filter"
description = "A set of custom nodes that pause a workflow while you select images, add masks, or edit text."
version = "1.4.2"
version = "1.5"
license = { file = "LICENSE" }
[project.urls]