From 55910ae73b52254d33a3328c76a23d9897377147 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 2 Jun 2026 14:53:31 +1000 Subject: [PATCH 1/4] #151 --- README.md | 9 ++- image_filter_nodes.py | 136 +++++++++++++++++++++++------------------- js/popup.js | 12 ++-- js/utils.js | 30 +++++++--- modules/InOutStore.py | 74 +++++++++++++++++++++++ 5 files changed, 183 insertions(+), 78 deletions(-) create mode 100644 modules/InOutStore.py diff --git a/README.md b/README.md index 25521ba..085473c 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,9 @@ or jump down to [example workflows](#example-workflows) for more examples. - [Custom audio](#audiofile) - triple-click in text field in `TextImageFilter` to insert last sent text -- added option in `Mask Image Filter` to +- added option in `Mask Image Filter` to always start from last output +- fixed `Mask Image Filter` fingerprinting to prevent downstream execution when sending the same output +as a previous run (h/t [Reber01Good](https://github.com/Reber01Good)) ## New in 1.8 @@ -301,6 +303,11 @@ Feel free to send me examples of how you use the nodes! --- +# Thanks + +To those who have contributed code or helpful conversations: + +[Reber01Good](https://github.com/Reber01Good) # Bugs, Ideas, and the future diff --git a/image_filter_nodes.py b/image_filter_nodes.py index e89486c..eec3ff6 100644 --- a/image_filter_nodes.py +++ b/image_filter_nodes.py @@ -1,16 +1,20 @@ from nodes import PreviewImage, LoadImage from comfy.model_management import InterruptProcessingException -import os, random -import torch -from typing import Any +from comfy_api.latest import io -import base64 +from .modules.InOutStore import InOutStore +from .image_filter_messaging import send_and_wait, Response, TimeoutResponse + +import os, random, base64, time +from typing import Any from io import BytesIO + +import torch from PIL import Image import numpy as np -from .image_filter_messaging import send_and_wait, Response, TimeoutResponse -from comfy_api.latest import io +import folder_paths +from pathlib import Path class FilterNodeBase: _preview_image = PreviewImage() @@ -22,7 +26,16 @@ class FilterNodeBase: @classmethod def load_mask(cls, file:str, type:str="clipspace", append=" [input]") -> torch.Tensor: - return cls._load_image.load_image(os.path.join(type, file)+append)[1] + f = os.path.join(type, file)+append + path = folder_paths.get_annotated_filepath(f) + print(f"Loading mask from {f}") + return cls._load_image.load_image(f)[1] + + @classmethod + def newest_mask_file(cls) -> Path: + dr = Path(folder_paths.get_input_directory()) / 'clipspace' + masked_files = list(dr.glob("*masked*")) + return max([f for f in masked_files], key=lambda item: item.stat().st_ctime) if masked_files else None @classmethod def fingerprint_inputs(cls, **kwargs): # type: ignore @@ -225,41 +238,6 @@ def mask_from_data(data) -> torch.Tensor: mask = 1. - torch.from_numpy(mask) return mask.unsqueeze(0) -class InOutStore: - stores:dict[str, "InOutStore"] = {} - @classmethod - def get_store(cls, graph_id:str) -> "InOutStore": - if graph_id not in cls.stores: - cls.stores[graph_id] = InOutStore() - return cls.stores[graph_id] - - def __init__(self): - self.previous_inputs:list[Any] = [] - self.last_output:tuple[torch.Tensor, torch.Tensor|None, str, str, str]|None = None - - def get_last(self) -> tuple[torch.Tensor, torch.Tensor|None, str, str, str]: - assert self.last_output is not None, "No last output stored" - return self.last_output - - def update_last(self, *args): - def make_copy(x): return x.clone() if isinstance(x, torch.Tensor) else x - self.previous_inputs = [ make_copy(x) for x in args ] - - def check_input_unchanged(self, *args) -> bool: - if len(self.previous_inputs)!=len(args): return False - for prev, new in zip(self.previous_inputs, args): - if isinstance(prev, torch.Tensor) and isinstance(new, torch.Tensor): - if not torch.equal(prev, new): return False - else: - if prev != new: return False - return True - - def check_input_tensors_congruent(self, *args) -> bool: - if len(self.previous_inputs)!=len(args): return False - for prev, new in zip(self.previous_inputs, args): - if isinstance(prev, torch.Tensor) and isinstance(new, torch.Tensor): - if prev.shape != new.shape: return False - return True class MaskImageFilter(FilterNodeBase, io.ComfyNode): @@ -301,26 +279,38 @@ class MaskImageFilter(FilterNodeBase, io.ComfyNode): mask=None, audiofile="", extra1="", extra2="", extra3="", tip="", **kwargs): iostore = InOutStore.get_store(f"{graph_id}_{cls.hidden.unique_id}") - if if_inputs_unchanged == "Always start with last output" and iostore.last_output is not None: + if if_inputs_unchanged == "Always start with last output" and iostore.have_last_output: if iostore.check_input_tensors_congruent(image): - image, mask, extra1, extra2, extra3 = iostore.get_last() + image, mask, extra1, extra2, extra3 = iostore.get_last_outputs() mask = 1.0 - mask if mask is not None else None # The mask editor works in inverse # check if everything is unchanged (and store these inputs for next check) - if iostore.check_input_unchanged(image, timeout, if_no_mask, graph_id, mask, audiofile, extra1, extra2, extra3, tip) and iostore.last_output is not None: + unchanged_in = ( + iostore.have_last_output and + iostore.compare_with_last_inputs(image, timeout, if_no_mask, graph_id, + mask, audiofile, extra1, extra2, extra3, tip) + ) + + iostore.update_last_inputs(image, timeout, if_no_mask, graph_id, + mask, audiofile, extra1, extra2, extra3, tip) + + if unchanged_in: if if_inputs_unchanged == "Start with last output": - image, mask, extra1, extra2, extra3 = iostore.get_last() + print("\n\nStarting with last output\n\n") + image, mask, extra1, extra2, extra3 = iostore.get_last_outputs() mask = 1.0 - mask if mask is not None else None # The mask editor works in inverse - elif if_inputs_unchanged == "Resend last output": - return io.NodeOutput( *iostore.get_last() ) - - iostore.update_last(image, timeout, if_no_mask, graph_id, mask, audiofile, extra1, extra2, extra3, tip) - + elif if_inputs_unchanged == "Resend last output": + # this should never occur, because of fingerprinting... + return io.NodeOutput( *iostore.get_last_outputs() ) + if mask is not None and mask.shape[:3] == image.shape[:3] and not torch.all(mask==0): input_to_send = torch.cat((image, mask.unsqueeze(-1)), dim=-1) else: input_to_send = image + + last_mask_file = cls.newest_mask_file() + urls = cls.save_images_return_urls(images=input_to_send, **kwargs) payload = { "urls":urls, @@ -331,20 +321,40 @@ class MaskImageFilter(FilterNodeBase, io.ComfyNode): } response = send_and_wait(payload, timeout, graph_id) - if (response.masked_image): # old mask editor - uploads - try: - mask = cls.load_mask(response.masked_image) - except FileNotFoundError: # no mask was uploaded; reload the input mask, or the mask in the input image - mask = mask if mask is not None else cls.load_mask(urls[0]['filename']+" [temp]") - - elif (response.masked_data): # new mask editor - sends the blob - data = response.masked_data.split(',',1)[-1] - mask = mask_from_data(data) + started_waiting_at = time.monotonic() + while ( + ((mask_file:=cls.newest_mask_file()) == last_mask_file) and + (time.monotonic()-started_waiting_at < 5)): time.sleep(1) + + if (mask_file==last_mask_file): + print("Didn't get a new mask file - using input mask or image") + mask = mask if mask is not None else cls.load_mask(urls[0]['filename']+" [temp]") + else: + mask = cls.load_mask(mask_file) + + if mask is None: + print("No mask file - setting blank") + mask = torch.zeros_like(image[...,0]) - if mask is None: mask = torch.zeros_like(image[...,0]) if if_no_mask == 'cancel' and torch.all(mask==0): raise InterruptProcessingException() - iostore.last_output = ( image.clone(), mask.clone(), *response.get_extras((extra1, extra2, extra3)) ) + iostore.update_last_outputs( ( image.clone(), mask.clone(), *response.get_extras((extra1, extra2, extra3)) ) ) if (image.shape[0:3] != mask.shape[0:3]): print(f"Mask shape {mask.shape} does not match image shape {image.shape}") - return io.NodeOutput( *iostore.get_last() ) \ No newline at end of file + return io.NodeOutput( *iostore.get_last_outputs() ) + + # When using "Resend last output", it's not enough to just send the same output; + # we need to also tell the execution engine , so it can avoid uncessary downstream execution. + # + # So fingerprint_inputs needs to return the same value in such cases. + # Can't use the check_input_unchanged method because of its side effect + # (it updates its map of the last inputs received) + # + # Thanks to Reber01Good on GitHub for pointing this out and providing a fix which I have adapted. + @classmethod + def fingerprint_inputs(cls, **kwargs) -> Any: + if kwargs.pop("if_inputs_unchanged", "") == "Resend last output": + iostore = InOutStore.get_store(f"{kwargs.get('graph_id','')}_{cls.hidden.unique_id}") + return iostore.tensor_free_hash( *kwargs.values() ) + else: + return random.random() \ No newline at end of file diff --git a/js/popup.js b/js/popup.js index 8217269..9637d7d 100644 --- a/js/popup.js +++ b/js/popup.js @@ -98,8 +98,8 @@ class Popup extends HTMLElement { document.addEventListener("keydown", this.on_key_down.bind(this)) document.addEventListener("keypress", this.on_key_press.bind(this)) - document.addEventListener("click", ()=>this.sound_maker.reset()) - this.text_edit.addEventListener('input', ()=>this.sound_maker.reset()) + document.addEventListener("click", ()=>this.sound_maker.reset('click')) + this.text_edit.addEventListener('input', ()=>this.sound_maker.reset('text edit')) document.body.appendChild(this) this.last_response_sent = 0 @@ -195,7 +195,7 @@ class Popup extends HTMLElement { *graph_id (string) (*) are added */ - this.sound_maker.unreset() + this.sound_maker.unreset("send response") if (Date.now()-this.last_response_sent < 1000) { Log.message_out(msg, "(throttled)") @@ -293,6 +293,7 @@ class Popup extends HTMLElement { } on_new_node(nd) { + this.sound_maker.unreset('on new node') this.node = nd const fp = this.floater_position() if (fp) this.floating_window.move_to(fp.x, fp.y, true) @@ -352,6 +353,7 @@ class Popup extends HTMLElement { if (this.node!=the_node) this.on_new_node(the_node) if (detail.tick) { + this.sound_maker.request('tick') this.counter_text.innerText = `${detail.tick}s` if (this.state==State.INACTIVE) this.request_reset() return @@ -373,7 +375,7 @@ class Popup extends HTMLElement { this.state = State.TINY this.saved_message = message this.tiny_image.src = get_full_url(message.detail.urls[message.detail.urls.length-1]) - this.sound_maker.request() + this.sound_maker.request('tiny') return `Deferring message and showing small window` } @@ -383,7 +385,7 @@ class Popup extends HTMLElement { this.extras_row.innerHTML = '' for (let i=0; i this.millisecs) { - console.log(`Callback`) + unreset(msg) { + this.next_allowed = Date.now() + if (msg) this.log(`unreset ${msg} - need to wait ${this.need_to_wait()}`) + } + + need_to_wait() { + const ntw = this.next_allowed - Date.now() + return (ntw>0) ? ntw : 0 + } + + request(msg) { + if (msg) this.log(`request ${msg} - need to wait ${this.need_to_wait()}`) + if (this.need_to_wait() <= 0) { + if (msg) this.log(`Callback ${msg}`) this.callback() - this.reset() + this.reset('after callback') } } } \ No newline at end of file diff --git a/modules/InOutStore.py b/modules/InOutStore.py new file mode 100644 index 0000000..a821869 --- /dev/null +++ b/modules/InOutStore.py @@ -0,0 +1,74 @@ +import torch +from typing import Any + +outputs_type = tuple[torch.Tensor, torch.Tensor|None, str, str, str] + +def types(l:list[Any]) -> str: return ":".join(str(x.__class__) for x in l) + +def make_copy(x): return x.clone() if isinstance(x, torch.Tensor) else x + +class InOutStore: + stores:dict[str, "InOutStore"] = {} + @classmethod + def get_store(cls, graph_id:str) -> "InOutStore": + if graph_id not in cls.stores: + cls.stores[graph_id] = InOutStore() + return cls.stores[graph_id] + + def __init__(self): + self.last_inputs:list[Any]|None = None + self.last_output:outputs_type|None = None + + @property + def have_last_output(self): return self.last_output is not None + + @property + def last_input_tensors(self): + assert self.last_inputs is not None + return [ x for x in self.last_inputs if isinstance(x,torch.Tensor) ] + + def get_last_outputs(self) -> outputs_type: + assert self.last_output is not None, "No last output stored" + return self.last_output + + def update_last_outputs(self, outputs:outputs_type): + self.last_output = [ make_copy(x) for x in outputs ] + + def update_last_inputs(self, *args): + self.last_inputs = [ make_copy(x) for x in args ] + + def compare_with_last_inputs(self, *args) -> bool: + if self.last_inputs is None: return False # first time we've been called + if not types(self.last_inputs) == types(args): + assert False, "Called compare_with_last_inputs with different classes" + + # compare the tensors + for prev, new in zip(self.last_inputs, args): + if isinstance(prev, torch.Tensor) and not torch.equal(prev, new) and not torch.equal(1-prev, new): + print("Tensor input changed") + return False + + # compare the non-tensors + if self.tensor_free_hash(*args) != self.tensor_free_hash(self.last_inputs): + print("Non tensor input changed") + return False + + return True + + def check_input_tensors_congruent(self, *args) -> bool: + if self.last_inputs is None: return False + for a,i in enumerate(args): + assert isinstance(a,torch.Tensor) + if self.last_input_tensors[i].shape != a.shape: return False + return True + + def tensor_free_hash(self, *args): + tf = ",".join( str(v) for v in flatten(args) if not isinstance(v, torch.Tensor) and v ) + print(f"Hashing {tf} ... ") + return hash( tf ) + +def flatten(args) -> list[Any]: + f = [] + for arg in args: + f.extend(flatten(arg)) if (isinstance(arg, list) or isinstance(arg,tuple)) else f.append(arg) + return f From 0da6b06464e8e46a68bb5eda71bcbdf1bcc84d47 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 2 Jun 2026 14:54:12 +1000 Subject: [PATCH 2/4] 1.9.1 labels --- __init__.py | 2 +- js/image_filter.js | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/__init__.py b/__init__.py index 6966c06..6a1fd0c 100644 --- a/__init__.py +++ b/__init__.py @@ -5,7 +5,7 @@ @description: A custom node that pauses the flow while you choose which image or images to pass on to the rest of the workflow. Simplified and improved version of cg-image-picker. """ -VERSION = "1.9" +VERSION = "1.9.1" WEB_DIRECTORY = "./js" __all__ = ["WEB_DIRECTORY"] diff --git a/js/image_filter.js b/js/image_filter.js index 31569b3..c826cc4 100644 --- a/js/image_filter.js +++ b/js/image_filter.js @@ -8,7 +8,7 @@ import { Log } from "./log.js"; const FILTER_TYPES = ["Image Filter","Text Image Filter","Text Image Filter with Extras","Mask Image Filter", "Image Filter for List"] -const VERSION = "1.9" +const VERSION = "1.9.1" app.registerExtension({ name: "cg.image_filter", diff --git a/pyproject.toml b/pyproject.toml index c8d2649..364a990 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.9" +version = "1.9.1" license = { file = "LICENSE" } [project.urls] From 864429ec2a121766ed40d0e0695bf8b9baeb8f3e Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 3 Jun 2026 17:00:46 +1000 Subject: [PATCH 3/4] fix always start with last output option --- image_filter_nodes.py | 18 +++++++----------- modules/InOutStore.py | 25 ++++++++----------------- 2 files changed, 15 insertions(+), 28 deletions(-) diff --git a/image_filter_nodes.py b/image_filter_nodes.py index eec3ff6..49c4fa5 100644 --- a/image_filter_nodes.py +++ b/image_filter_nodes.py @@ -25,17 +25,15 @@ class FilterNodeBase: return cls._preview_image.save_images(images, **kwargs)['ui']['images'] @classmethod - def load_mask(cls, file:str, type:str="clipspace", append=" [input]") -> torch.Tensor: + def load_mask(cls, file:str|Path, type:str="clipspace", append=" [input]") -> torch.Tensor: f = os.path.join(type, file)+append - path = folder_paths.get_annotated_filepath(f) - print(f"Loading mask from {f}") return cls._load_image.load_image(f)[1] @classmethod - def newest_mask_file(cls) -> Path: + def newest_mask_file(cls) -> Path|None: dr = Path(folder_paths.get_input_directory()) / 'clipspace' masked_files = list(dr.glob("*masked*")) - return max([f for f in masked_files], key=lambda item: item.stat().st_ctime) if masked_files else None + return max([f for f in masked_files], key=lambda item: item.stat().st_birthtime) if masked_files else None @classmethod def fingerprint_inputs(cls, **kwargs): # type: ignore @@ -279,8 +277,9 @@ class MaskImageFilter(FilterNodeBase, io.ComfyNode): mask=None, audiofile="", extra1="", extra2="", extra3="", tip="", **kwargs): iostore = InOutStore.get_store(f"{graph_id}_{cls.hidden.unique_id}") - if if_inputs_unchanged == "Always start with last output" and iostore.have_last_output: - if iostore.check_input_tensors_congruent(image): + if (if_inputs_unchanged == "Always start with last output" and + iostore.have_last_output and + iostore.check_input_image_congruent(image)): image, mask, extra1, extra2, extra3 = iostore.get_last_outputs() mask = 1.0 - mask if mask is not None else None # The mask editor works in inverse @@ -296,7 +295,6 @@ class MaskImageFilter(FilterNodeBase, io.ComfyNode): if unchanged_in: if if_inputs_unchanged == "Start with last output": - print("\n\nStarting with last output\n\n") image, mask, extra1, extra2, extra3 = iostore.get_last_outputs() mask = 1.0 - mask if mask is not None else None # The mask editor works in inverse elif if_inputs_unchanged == "Resend last output": @@ -327,13 +325,11 @@ class MaskImageFilter(FilterNodeBase, io.ComfyNode): (time.monotonic()-started_waiting_at < 5)): time.sleep(1) if (mask_file==last_mask_file): - print("Didn't get a new mask file - using input mask or image") mask = mask if mask is not None else cls.load_mask(urls[0]['filename']+" [temp]") - else: + elif (mask_file is not None): mask = cls.load_mask(mask_file) if mask is None: - print("No mask file - setting blank") mask = torch.zeros_like(image[...,0]) if if_no_mask == 'cancel' and torch.all(mask==0): raise InterruptProcessingException() diff --git a/modules/InOutStore.py b/modules/InOutStore.py index a821869..db40a42 100644 --- a/modules/InOutStore.py +++ b/modules/InOutStore.py @@ -3,8 +3,6 @@ from typing import Any outputs_type = tuple[torch.Tensor, torch.Tensor|None, str, str, str] -def types(l:list[Any]) -> str: return ":".join(str(x.__class__) for x in l) - def make_copy(x): return x.clone() if isinstance(x, torch.Tensor) else x class InOutStore: @@ -32,40 +30,33 @@ class InOutStore: return self.last_output def update_last_outputs(self, outputs:outputs_type): - self.last_output = [ make_copy(x) for x in outputs ] + self.last_output = tuple( make_copy(x) for x in outputs ) # type: ignore def update_last_inputs(self, *args): self.last_inputs = [ make_copy(x) for x in args ] def compare_with_last_inputs(self, *args) -> bool: if self.last_inputs is None: return False # first time we've been called - if not types(self.last_inputs) == types(args): - assert False, "Called compare_with_last_inputs with different classes" # compare the tensors for prev, new in zip(self.last_inputs, args): - if isinstance(prev, torch.Tensor) and not torch.equal(prev, new) and not torch.equal(1-prev, new): - print("Tensor input changed") + if isinstance(prev, torch.Tensor) and isinstance(new, torch.Tensor) and not torch.equal(prev, new) and not torch.equal(1-prev, new): return False - + if (isinstance(prev, torch.Tensor) and new is None) or (isinstance(new, torch.Tensor) and prev is None): + return False + # compare the non-tensors if self.tensor_free_hash(*args) != self.tensor_free_hash(self.last_inputs): - print("Non tensor input changed") return False return True - def check_input_tensors_congruent(self, *args) -> bool: + def check_input_image_congruent(self, image:torch.Tensor) -> bool: if self.last_inputs is None: return False - for a,i in enumerate(args): - assert isinstance(a,torch.Tensor) - if self.last_input_tensors[i].shape != a.shape: return False - return True + return (image.shape == self.last_input_tensors[0].shape) def tensor_free_hash(self, *args): - tf = ",".join( str(v) for v in flatten(args) if not isinstance(v, torch.Tensor) and v ) - print(f"Hashing {tf} ... ") - return hash( tf ) + return hash( ",".join( str(v) for v in flatten(args) if not isinstance(v, torch.Tensor) )) def flatten(args) -> list[Any]: f = [] From 7ae535ce410e391f4111f7cf4cc8610ead748234 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 8 Jul 2026 21:18:36 +1000 Subject: [PATCH 4/4] #154 --- README.md | 6 ++++-- image_filter_nodes.py | 1 + js/audio/none.mp3 | Bin 0 -> 2947 bytes 3 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 js/audio/none.mp3 diff --git a/README.md b/README.md index 085473c..16f6c5a 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,9 @@ This is a new, experimental feature, so please report any issues... ### audiofile The sound to play when the node is triggered. Can be one of the built-in options, -[`beep.mp3`](js/audio/beep.mp3), [`ding.mp3`](js/audio/ding.mp3), or [`honk.mp3`](js/audio/honk.mp3), -or the path to a local audiofile, or a URL of an audiofile. +[`beep`](js/audio/beep.mp3), [`ding`](js/audio/ding.mp3), [`honk`](js/audio/honk.mp3), +or [`none`](js/audio/none.mp3), or the path to a local audiofile, or a URL of an audiofile. +If no extension is used, `.mp3` will be assumed. You can add files to `js/audio` and then just use their names. @@ -308,6 +309,7 @@ Feel free to send me examples of how you use the nodes! To those who have contributed code or helpful conversations: [Reber01Good](https://github.com/Reber01Good) +[53245342099](https://github.com/53245342099) # Bugs, Ideas, and the future diff --git a/image_filter_nodes.py b/image_filter_nodes.py index 49c4fa5..8f645ed 100644 --- a/image_filter_nodes.py +++ b/image_filter_nodes.py @@ -310,6 +310,7 @@ class MaskImageFilter(FilterNodeBase, io.ComfyNode): last_mask_file = cls.newest_mask_file() urls = cls.save_images_return_urls(images=input_to_send, **kwargs) + if not Path(audiofile).suffix: audiofile += ".mp3" payload = { "urls":urls, "maskedit":True, diff --git a/js/audio/none.mp3 b/js/audio/none.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..001b002b9b5ea0335a6d25fc8919e4e78b1314e3 GIT binary patch literal 2947 zcmeZtF=l1}0f~?RS3@Ap2gJ@9MVZA4?)gQTdFiSDe@{pODZmFJfC_=~{6H+hz`&iz zzy$=NK%f8wT0me51P(yp1p=W!kN^Z(Ku`(<4M5NZ0#kut0T8SLf~`Ps00>Tjz*Qi4 z00ggq;42XP2Lhm7Ha?ENuEu(X1_mtaVa_vEP+?u_%fNUBuyKe?Z=?301>R`5VJ%0|LJ5;Q8g7F-+%RJd nLj+XMQCuEin2ISdY7b_Z5F;|s;kE?Qt} literal 0 HcmV?d00001