From 55910ae73b52254d33a3328c76a23d9897377147 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 2 Jun 2026 14:53:31 +1000 Subject: [PATCH] #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