diff --git a/README.md b/README.md index 8461d60..5fbf854 100644 --- a/README.md +++ b/README.md @@ -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): -Seahorse +Seahorse + +## 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: -Seahorse +Seahorse # Bugs, Ideas, and the future diff --git a/__init__.py b/__init__.py index e4fdbd4..725453f 100644 --- a/__init__.py +++ b/__init__.py @@ -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= { diff --git a/image_filter.py b/image_filter.py index 457d504..f5cda21 100644 --- a/image_filter.py +++ b/image_filter.py @@ -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]) ) diff --git a/image_filter_messaging.py b/image_filter_messaging.py new file mode 100644 index 0000000..8233475 --- /dev/null +++ b/image_filter_messaging.py @@ -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 + \ No newline at end of file diff --git a/images/blob.png b/images/blob.png new file mode 100644 index 0000000..7a2ab67 Binary files /dev/null and b/images/blob.png differ diff --git a/images/options.png b/images/options.png index bd6fa60..f07ea08 100644 Binary files a/images/options.png and b/images/options.png differ diff --git a/images/popup.png b/images/popup.png index 2d7564f..6b005e9 100644 Binary files a/images/popup.png and b/images/popup.png differ diff --git a/images/seahorse.png b/images/seahorse.png deleted file mode 100644 index 4d36e1e..0000000 Binary files a/images/seahorse.png and /dev/null differ diff --git a/js/filter.css b/js/filter.css index ca622b2..5145ba0 100644 --- a/js/filter.css +++ b/js/filter.css @@ -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 -} \ No newline at end of file +} + +.cg_popup .hidden { + display: none; +} diff --git a/js/floating_window.css b/js/floating_window.css new file mode 100644 index 0000000..94f2926 --- /dev/null +++ b/js/floating_window.css @@ -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; +} + diff --git a/js/floating_window.js b/js/floating_window.js new file mode 100644 index 0000000..74cae82 --- /dev/null +++ b/js/floating_window.js @@ -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'}) \ No newline at end of file diff --git a/js/image_filter.js b/js/image_filter.js index 43d76b4..e22e951 100644 --- a/js/image_filter.js +++ b/js/image_filter.js @@ -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({ } }, + }) \ No newline at end of file diff --git a/js/log.js b/js/log.js index 3d53d79..1f6a3b1 100644 --- a/js/log.js +++ b/js/log.js @@ -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}` : "")) } } \ No newline at end of file diff --git a/js/mask_utils.js b/js/mask_utils.js index fdefa71..db40f5b 100644 --- a/js/mask_utils.js +++ b/js/mask_utils.js @@ -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() } \ No newline at end of file diff --git a/js/popup.js b/js/popup.js index 9876856..476c2f6 100644 --- a/js/popup.js +++ b/js/popup.js @@ -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:"⇦"}) + 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:"⇨"}) - 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, '
') + 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') - 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{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) diff --git a/js/zoomed.css b/js/zoomed.css new file mode 100644 index 0000000..5bfed42 --- /dev/null +++ b/js/zoomed.css @@ -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; +} + diff --git a/pyproject.toml b/pyproject.toml index 588534b..8a9c2b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]