diff --git a/README.md b/README.md index 32dbee5..e7e6aaa 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,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 @@ -144,8 +146,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. @@ -313,6 +316,12 @@ 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) +[53245342099](https://github.com/53245342099) # Bugs, Ideas, and the future 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/image_filter_nodes.py b/image_filter_nodes.py index b5b429d..0cbc7d9 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 def get_audiofiles() -> list[str]: return [f.name for f in (Path(__file__).parent/'js'/'audio').iterdir()] @@ -30,7 +34,7 @@ class FilterNodeBase: @classmethod def newest_mask_file(cls) -> Path|None: - dr = Path(folder_paths.get_input_directory())# / 'clipspace' + 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_birthtime) if masked_files else None @@ -238,41 +242,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): @@ -314,27 +283,40 @@ 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 iostore.check_input_tensors_congruent(image): - image, mask, extra1, extra2, extra3 = iostore.get_last() + 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 # 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() + 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) + if not Path(audiofile).suffix: audiofile += ".mp3" payload = { "urls":urls, "maskedit":True, @@ -350,21 +332,32 @@ class MaskImageFilter(FilterNodeBase, io.ComfyNode): (time.monotonic()-started_waiting_at < 5)): time.sleep(1) if (mask_file==last_mask_file): - if mask is None: - try: - mask = cls.load_mask(urls[0]['filename']+" [temp]") - except FileNotFoundError: - pass + mask = mask if mask is not None else cls.load_mask(urls[0]['filename']+" [temp]") elif (mask_file is not None): mask = cls.load_mask(mask_file) if mask is None: 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.update_last_outputs( ( image.clone(), mask.clone(), extra1, extra2, extra3) ) #*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/audio/none.mp3 b/js/audio/none.mp3 new file mode 100644 index 0000000..001b002 Binary files /dev/null and b/js/audio/none.mp3 differ 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/js/popup.js b/js/popup.js index c1caac7..1abda31 100644 --- a/js/popup.js +++ b/js/popup.js @@ -98,10 +98,7 @@ 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", (e)=>{ - this.sound_maker.reset('click') - var x = e.id - }) + document.addEventListener("click", ()=>this.sound_maker.reset('click')) this.text_edit.addEventListener('input', ()=>this.sound_maker.reset('text edit')) document.body.appendChild(this) @@ -199,7 +196,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)") @@ -301,6 +298,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) @@ -360,6 +358,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 @@ -381,7 +380,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` } @@ -391,7 +390,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..db40a42 --- /dev/null +++ b/modules/InOutStore.py @@ -0,0 +1,65 @@ +import torch +from typing import Any + +outputs_type = tuple[torch.Tensor, torch.Tensor|None, str, str, str] + +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 = 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 + + # compare the tensors + for prev, new in zip(self.last_inputs, args): + 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): + return False + + return True + + def check_input_image_congruent(self, image:torch.Tensor) -> bool: + if self.last_inputs is None: return False + return (image.shape == self.last_input_tensors[0].shape) + + def tensor_free_hash(self, *args): + return hash( ",".join( str(v) for v in flatten(args) if not isinstance(v, torch.Tensor) )) + +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 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]