Merge branch 'main' of https://github.com/chrisgoringe/cg-image-filter
This commit is contained in:
@@ -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
@@ -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
@@ -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()
|
||||
Binary file not shown.
+1
-1
@@ -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
@@ -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
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user