This commit is contained in:
Chris
2026-09-12 11:07:45 +10:00
9 changed files with 160 additions and 82 deletions
+12 -3
View File
@@ -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
+1 -1
View File
@@ -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"]
+53 -60
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
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() )
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()
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -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",
+6 -7
View File
@@ -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.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')
}
}
}
+65
View File
@@ -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
+1 -1
View File
@@ -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]