diff --git a/CHANGELOG.md b/CHANGELOG.md index 00fcd13..4b71bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +Added: +- Data List and LIST: new "filter empty" node that removes every empty entry + (`None`, empty STRING, empty LIST/DICT/SET, ...); `0` and `False` are kept. + An optional boolean switch also treats a STRING consisting only of whitespace + as empty. + +## [2.1.1] - 2026-10-07 + +Fixed: +- "save IMAGE to file" and "save IMAGE+MASK to file" no longer fail with + "EXIF data is too long" when saving a JPG whose embedded workflow metadata is + larger than a single JPEG EXIF block. A JPEG can only carry one 16-bit-sized + EXIF segment, so oversized metadata is now reduced step by step - first the + full workflow graph, then the workflow prompt info, then the prompt metadata - + and the reduction is reported; the image itself is always written. Should + embedding metadata ever fail for another reason, the image is written again + without metadata instead of not at all. + ## [2.1.0] - 2026-09-19 Backward compatible changes: diff --git a/README.md b/README.md index ecc7204..bc8e3a0 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ ComfyUI list manipulation nodes (for processing individual items): - **Creation**: create Data List (generic and type-specific versions) - **Modification**: append, extend, insert, set item, shuffle, remove, pop, pop random -- **Filtering**: filter, filter select +- **Filtering**: filter, filter empty, filter select - **Access**: get item, first, last, slice, index, contains - **Information**: length, count - **Operations**: sort, reverse, zip, min, max @@ -106,6 +106,7 @@ Python list manipulation nodes (as a single variable): - **Modification**: append, extend, insert, remove, pop, pop random, set_item, shuffle - **Access**: get_item, first, last, slice, index, contains - **Information**: length, count +- **Filtering**: filter empty (optionally treating whitespace-only STRINGs as empty) - **Operations**: sort, reverse, min, max - **Conversion**: convert to data list, convert to SET diff --git a/src/basic_data_handling/_value_utils.py b/src/basic_data_handling/_value_utils.py new file mode 100644 index 0000000..c5936b9 --- /dev/null +++ b/src/basic_data_handling/_value_utils.py @@ -0,0 +1,39 @@ +"""Small helpers shared by the node modules (this is not a ComfyUI node module).""" + +from typing import Any + + +def as_bool(value: Any) -> bool: + """Normalize a ComfyUI BOOLEAN widget/input value into a real bool. + + ComfyUI hands over real booleans, but legacy combo widgets ("True" / "False" + strings) and nodes with ``INPUT_IS_LIST`` (values wrapped into a list) also + occur, so all of them are accepted here. + """ + if isinstance(value, (list, tuple)): + value = value[0] if value else False + if isinstance(value, str): + return value.strip().lower() in ("true", "1", "yes", "on") + return bool(value) + + +def is_empty_entry(value: Any, treat_whitespace_as_empty: bool = False) -> bool: + """Tell whether an entry of a Data List / LIST has to be regarded as empty. + + Empty are ``None``, an empty STRING and an empty collection (empty LIST, + DICT, SET, ...). Numbers and booleans are never empty, so ``0`` and ``False`` + are kept. + + When ``treat_whitespace_as_empty`` is enabled, a STRING that consists only of + whitespace characters (spaces, tabs, line breaks, ...) counts as empty as + well. The whitespace rule applies to STRINGs only; objects without a length + (e.g. plain numbers or 0-dimensional TENSORs) are never empty. + """ + if value is None: + return True + if isinstance(value, str): + return not value.strip() if treat_whitespace_as_empty else not value + try: + return len(value) == 0 + except TypeError: + return False diff --git a/src/basic_data_handling/data_list_nodes.py b/src/basic_data_handling/data_list_nodes.py index 4e8ec06..a9b9007 100644 --- a/src/basic_data_handling/data_list_nodes.py +++ b/src/basic_data_handling/data_list_nodes.py @@ -16,6 +16,7 @@ except: ComfyNodeABC = object from ._dynamic_input import ContainsDynamicDict +from ._value_utils import as_bool, is_empty_entry INT_MAX = 2**15 - 1 # the computer can do more but be nice to the eyes @@ -501,6 +502,52 @@ class DataListFilter(ComfyNodeABC): return (result,) +class DataListFilterEmpty(ComfyNodeABC): + """ + Removes every empty entry of a Data List. + + This node takes a Data List and returns a new Data List without the entries + that are empty, i.e. ``None``, an empty STRING and an empty collection + (empty LIST, DICT, SET, ...). Numbers and booleans are never empty, so ``0`` + and ``False`` are kept. + + When ``treat_whitespace_as_empty`` is enabled, a STRING that consists only of + whitespace characters (spaces, tabs, line breaks, ...) counts as empty as + well and is removed, too. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "list": (IO.ANY, {"tooltip": "The Data List whose empty entries are removed."}), + }, + "optional": { + "treat_whitespace_as_empty": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "When enabled, STRINGs that consist only of whitespace (spaces, tabs, line breaks) are removed as well.", + }, + ), + }, + } + + RETURN_TYPES = (IO.ANY,) + RETURN_NAMES = ("list",) + OUTPUT_TOOLTIPS = ("A new Data List without the empty entries.",) + CATEGORY = "Basic/Data List" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "filter_empty" + INPUT_IS_LIST = True + OUTPUT_IS_LIST = (True,) + + def filter_empty(self, **kwargs: list[Any]) -> tuple[list[Any]]: + treat_whitespace_as_empty = as_bool(kwargs.get("treat_whitespace_as_empty")) + result = [value for value in (kwargs.get("list") or []) if not is_empty_entry(value, treat_whitespace_as_empty)] + return (result,) + + class DataListFilterSelect(ComfyNodeABC): """ Filters a Data List using boolean values. @@ -1334,6 +1381,7 @@ NODE_CLASS_MAPPINGS = { "Basic data handling: DataListEnumerate": DataListEnumerate, "Basic data handling: DataListExtend": DataListExtend, "Basic data handling: DataListFilter": DataListFilter, + "Basic data handling: DataListFilterEmpty": DataListFilterEmpty, "Basic data handling: DataListFilterSelect": DataListFilterSelect, "Basic data handling: DataListFirst": DataListFirst, "Basic data handling: DataListGetItem": DataListGetItem, @@ -1373,6 +1421,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "Basic data handling: DataListEnumerate": "enumerate", "Basic data handling: DataListExtend": "extend", "Basic data handling: DataListFilter": "filter", + "Basic data handling: DataListFilterEmpty": "filter empty", "Basic data handling: DataListFilterSelect": "filter select", "Basic data handling: DataListFirst": "first", "Basic data handling: DataListGetItem": "get item", diff --git a/src/basic_data_handling/list_nodes.py b/src/basic_data_handling/list_nodes.py index 2aeaab4..b9a8f90 100644 --- a/src/basic_data_handling/list_nodes.py +++ b/src/basic_data_handling/list_nodes.py @@ -17,6 +17,7 @@ except: ComfyNodeABC = object from ._dynamic_input import ContainsDynamicDict +from ._value_utils import as_bool, is_empty_entry INT_MAX = 2**15 - 1 # the computer can do more but be nice to the eyes @@ -404,6 +405,50 @@ class ListExtend(ComfyNodeABC): return (result,) +class ListFilterEmpty(ComfyNodeABC): + """ + Removes every empty entry of a LIST. + + This node takes a LIST and returns a new LIST without the entries that are + empty, i.e. ``None``, an empty STRING and an empty collection (empty LIST, + DICT, SET, ...). Numbers and booleans are never empty, so ``0`` and ``False`` + are kept. + + When ``treat_whitespace_as_empty`` is enabled, a STRING that consists only of + whitespace characters (spaces, tabs, line breaks, ...) counts as empty as + well and is removed, too. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "list": ("LIST", {"tooltip": "The LIST whose empty entries are removed."}), + }, + "optional": { + "treat_whitespace_as_empty": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "When enabled, STRINGs that consist only of whitespace (spaces, tabs, line breaks) are removed as well.", + }, + ), + }, + } + + RETURN_TYPES = ("LIST",) + RETURN_NAMES = ("list",) + OUTPUT_TOOLTIPS = ("A new LIST without the empty entries.",) + CATEGORY = "Basic/LIST" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "filter_empty" + + def filter_empty(self, list: list[Any], treat_whitespace_as_empty: bool = False) -> tuple[list[Any]]: + list = list if list is not None else [] + treat = as_bool(treat_whitespace_as_empty) + return ([value for value in list if not is_empty_entry(value, treat)],) + + class ListFirst(ComfyNodeABC): """ Returns the first element in a LIST. @@ -1135,6 +1180,7 @@ NODE_CLASS_MAPPINGS = { "Basic data handling: ListCount": ListCount, "Basic data handling: ListEnumerate": ListEnumerate, "Basic data handling: ListExtend": ListExtend, + "Basic data handling: ListFilterEmpty": ListFilterEmpty, "Basic data handling: ListFirst": ListFirst, "Basic data handling: ListGetItem": ListGetItem, "Basic data handling: ListIndex": ListIndex, @@ -1171,6 +1217,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "Basic data handling: ListCount": "count", "Basic data handling: ListEnumerate": "enumerate", "Basic data handling: ListExtend": "extend", + "Basic data handling: ListFilterEmpty": "filter empty", "Basic data handling: ListFirst": "first", "Basic data handling: ListGetItem": "get item", "Basic data handling: ListIndex": "index", diff --git a/tests/test_data_list_nodes.py b/tests/test_data_list_nodes.py index b64a351..2d667bc 100644 --- a/tests/test_data_list_nodes.py +++ b/tests/test_data_list_nodes.py @@ -13,6 +13,7 @@ from src.basic_data_handling.data_list_nodes import ( DataListEnumerate, DataListExtend, DataListFilter, + DataListFilterEmpty, DataListFilterSelect, DataListFirst, DataListGetItem, @@ -185,6 +186,20 @@ def test_filter(): assert node.filter_data(value=[1, 2, 3], filter=[False, False, False]) == ([1, 2, 3],) +def test_filter_empty(): + node = DataListFilterEmpty() + assert node.filter_empty(list=[1, 2, 3]) == ([1, 2, 3],) + assert node.filter_empty(list=[None, 1, "", 2, [], 3, {}, set()]) == ([1, 2, 3],) + assert node.filter_empty(list=[0, False, 0.0, "", None]) == ([0, False, 0.0],) # 0 and False are not empty + assert node.filter_empty(list=["", " ", "\t", "a"]) == ([" ", "\t", "a"],) # whitespace is kept by default + assert node.filter_empty(list=["", " ", "\t", "a"], treat_whitespace_as_empty=[True]) == (["a"],) + assert node.filter_empty(list=[], treat_whitespace_as_empty=[True]) == ([],) + + # Entries without a length (e.g. TENSORs) are never regarded as empty + opaque = object() + assert node.filter_empty(list=[opaque, None, ""]) == ([opaque],) + + def test_min(): node = DataListMin() assert node.find_min(list=[3, 1, 2]) == (1,) @@ -436,6 +451,7 @@ def test_data_list_none_source(): assert DataListEnumerate().enumerate_list(list=None) == ([],) assert DataListExtend().extend(list_a=None, list_b=None) == ([],) assert DataListFilter().filter_data(value=None, filter=None) == ([],) + assert DataListFilterEmpty().filter_empty(list=None) == ([],) assert DataListFilterSelect().select(value=None, select=None) == ([], []) assert DataListFirst().get_first_element(list=None) == (None,) assert DataListGetItem().get_item(list=None, index=[0]) == (None,) diff --git a/tests/test_list_nodes.py b/tests/test_list_nodes.py index 119d652..4a584f1 100644 --- a/tests/test_list_nodes.py +++ b/tests/test_list_nodes.py @@ -13,6 +13,7 @@ from src.basic_data_handling.list_nodes import ( ListCreateFromJSONString, ListEnumerate, ListExtend, + ListFilterEmpty, ListFirst, ListGetItem, ListIndex, @@ -406,6 +407,18 @@ def test_list_sum(): assert float_result == 6.5 +def test_list_filter_empty(): + node = ListFilterEmpty() + assert node.filter_empty([1, None, 2, "", 3]) == ([1, 2, 3],) + assert node.filter_empty([None, [], {}, set(), "a"]) == (["a"],) + assert node.filter_empty([0, False, 0.0, [], None]) == ([0, False, 0.0],) # 0 and False are not empty + assert node.filter_empty(["", " ", "\t", "a"]) == ([" ", "\t", "a"],) # whitespace is kept by default + assert node.filter_empty(["", " ", "\t", "a"], True) == (["a"],) + assert node.filter_empty([" \n "], "True") == ([],) # tolerant with legacy combo widgets + assert node.filter_empty([]) == ([],) + assert node.filter_empty(None) == ([],) + + def test_list_none_source(): """Graceful behaviour when the input LIST source is None.""" assert ListAll().check_all(None) == (True,) @@ -415,6 +428,7 @@ def test_list_none_source(): assert ListCount().count(None, "value") == (0,) assert ListEnumerate().enumerate_list(None) == ([[]],) assert ListExtend().extend(None, None) == ([],) + assert ListFilterEmpty().filter_empty(None) == ([],) assert ListFirst().get_first_element(None) == (None,) assert ListGetItem().get_item(None, 0) == (None,) assert ListIndex().index(None, "value") == (-1,)