This commit is contained in:
Chris
2026-06-02 14:53:31 +10:00
parent 694f8444e6
commit 55910ae73b
5 changed files with 183 additions and 78 deletions
+8 -1
View File
@@ -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
+73 -63
View File
@@ -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() )
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()
+7 -5
View File
@@ -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.n_extras; i++) { create('input', 'extra', this.extras_row, {value:detail.extras[i]}) }
if (!using_saved && !this.autosend()) this.sound_maker.request()
if (!using_saved && !this.autosend()) this.sound_maker.request('open')
if (detail.maskedit) this.handle_maskedit(detail)
else if (detail.urls) this.handle_urls(detail)
+21 -9
View File
@@ -1,3 +1,4 @@
import { app } from "../../scripts/app.js";
export function create( tag, clss, parent, properties ) {
const nd = document.createElement(tag);
@@ -14,20 +15,31 @@ export class CallbackThrottle {
this.unreset()
}
reset() {
this.last_reset = Date.now()
log(m) {
if ((app.ui.settings.getSettingValue("Image Filter.Z.Detailed Logging"))) console.log(m)
}
unreset() {
this.last_reset = Date.now() - this.millisecs
reset(msg) {
this.next_allowed = Date.now() + this.millisecs
if (msg) this.log(`reset ${msg} - need to wait ${this.need_to_wait()}`)
}
request() {
const elapsed = Date.now()-this.last_reset
if (Date.now()-this.last_reset > 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')
}
}
}
+74
View File
@@ -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