diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac39efb --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Test / coverage +.pytest_cache/ +.coverage +htmlcov/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1335c0 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# ComfyUI-SimpleLogics + +A lightweight ComfyUI node library providing logic operators, type conversions, math utilities, and switch nodes. + +No extra dependencies + +## Installation + +Clone into your `ComfyUI/custom_nodes/` folder: + +```bash +git clone https://github.com/danipisca07/ComfyUI-SimpleLogics +``` + +## Nodes + +### Convert (`SimpleLogics/Convert`) +| Node | Description | +|------|-------------| +| Int → Float | Cast integer to float | +| Float → Int | Truncate float to integer | +| Int / Float → String | Numeric to text | +| String → Int / Float | Parse text to number | +| Bool → Int | `True` → `1`, `False` → `0` | +| Int → Bool | `0` → `False`, anything else → `True` | + +### Logic (`SimpleLogics/Logic`) +AND, OR, NOT, XOR, NAND, NOR — all take `BOOLEAN` inputs and return `BOOLEAN`. + +### Compare (`SimpleLogics/Compare`) +**Compare Int / Compare Float** — pick an operator (`==` `!=` `>` `<` `>=` `<=`), returns `BOOLEAN`. + +### Math (`SimpleLogics/Math`) +| Node | Description | +|------|-------------| +| Max / Min Int | Larger / smaller of two integers | +| Max / Min Float | Larger / smaller of two floats | +| Clamp Int / Float | Constrain a value between `min` and `max` | + +### Switch (`SimpleLogics/Switch`) +All switch nodes take a `condition` (BOOLEAN) and return `if_true` or `if_false`. + +| Node | Input types | +|------|-------------| +| Switch Int | INT | +| Switch Float | FLOAT | +| Switch String | STRING | +| Switch Any | Any type | + +## Running Tests + +```bash +pip install pytest +pytest tests/ +``` + +## Contributions? + +Welcome \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..89822be --- /dev/null +++ b/__init__.py @@ -0,0 +1,6 @@ +try: + from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +except ImportError: + from nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..47c36e9 --- /dev/null +++ b/nodes.py @@ -0,0 +1,470 @@ +# SimpleLogics - ComfyUI node library for logic, math, and type conversion + + +# --- Type Conversion --- + +class IntToFloat: + DESCRIPTION = "Converts an integer to a float." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"value": ("INT", {"default": 0})}} + RETURN_TYPES = ("FLOAT",) + FUNCTION = "convert" + CATEGORY = "SimpleLogics/Convert" + + def convert(self, value): + return (float(value),) + + +class FloatToInt: + DESCRIPTION = "Converts a float to an integer by truncating the decimal part." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"value": ("FLOAT", {"default": 0.0})}} + RETURN_TYPES = ("INT",) + FUNCTION = "convert" + CATEGORY = "SimpleLogics/Convert" + + def convert(self, value): + return (int(value),) + + +class IntToString: + DESCRIPTION = "Converts an integer to its string representation." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"value": ("INT", {"default": 0})}} + RETURN_TYPES = ("STRING",) + FUNCTION = "convert" + CATEGORY = "SimpleLogics/Convert" + + def convert(self, value): + return (str(value),) + + +class FloatToString: + DESCRIPTION = "Converts a float to its string representation." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"value": ("FLOAT", {"default": 0.0})}} + RETURN_TYPES = ("STRING",) + FUNCTION = "convert" + CATEGORY = "SimpleLogics/Convert" + + def convert(self, value): + return (str(value),) + + +class StringToInt: + DESCRIPTION = "Parses a string into an integer. Accepts decimal strings like '3.9' (truncated to 3)." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"value": ("STRING", {"default": "0"})}} + RETURN_TYPES = ("INT",) + FUNCTION = "convert" + CATEGORY = "SimpleLogics/Convert" + + def convert(self, value): + return (int(float(value)),) + + +class StringToFloat: + DESCRIPTION = "Parses a string into a float." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"value": ("STRING", {"default": "0.0"})}} + RETURN_TYPES = ("FLOAT",) + FUNCTION = "convert" + CATEGORY = "SimpleLogics/Convert" + + def convert(self, value): + return (float(value),) + + +class BoolToInt: + DESCRIPTION = "Converts a boolean to an integer: True → 1, False → 0." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"value": ("BOOLEAN", {"default": False})}} + RETURN_TYPES = ("INT",) + FUNCTION = "convert" + CATEGORY = "SimpleLogics/Convert" + + def convert(self, value): + return (1 if value else 0,) + + +class IntToBool: + DESCRIPTION = "Converts an integer to a boolean: 0 → False, any other value → True." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"value": ("INT", {"default": 0})}} + RETURN_TYPES = ("BOOLEAN",) + FUNCTION = "convert" + CATEGORY = "SimpleLogics/Convert" + + def convert(self, value): + return (value != 0,) + + +# --- Logic Operators --- + +class LogicAND: + DESCRIPTION = "Returns True only if both inputs are True." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"a": ("BOOLEAN", {"default": False}), "b": ("BOOLEAN", {"default": False})}} + RETURN_TYPES = ("BOOLEAN",) + FUNCTION = "op" + CATEGORY = "SimpleLogics/Logic" + + def op(self, a, b): + return (a and b,) + + +class LogicOR: + DESCRIPTION = "Returns True if at least one input is True." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"a": ("BOOLEAN", {"default": False}), "b": ("BOOLEAN", {"default": False})}} + RETURN_TYPES = ("BOOLEAN",) + FUNCTION = "op" + CATEGORY = "SimpleLogics/Logic" + + def op(self, a, b): + return (a or b,) + + +class LogicNOT: + DESCRIPTION = "Returns the opposite boolean value of the input." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"a": ("BOOLEAN", {"default": False})}} + RETURN_TYPES = ("BOOLEAN",) + FUNCTION = "op" + CATEGORY = "SimpleLogics/Logic" + + def op(self, a): + return (not a,) + + +class LogicXOR: + DESCRIPTION = "Returns True if exactly one of the two inputs is True." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"a": ("BOOLEAN", {"default": False}), "b": ("BOOLEAN", {"default": False})}} + RETURN_TYPES = ("BOOLEAN",) + FUNCTION = "op" + CATEGORY = "SimpleLogics/Logic" + + def op(self, a, b): + return (a ^ b,) + + +class LogicNAND: + DESCRIPTION = "Returns False only if both inputs are True (NOT AND)." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"a": ("BOOLEAN", {"default": False}), "b": ("BOOLEAN", {"default": False})}} + RETURN_TYPES = ("BOOLEAN",) + FUNCTION = "op" + CATEGORY = "SimpleLogics/Logic" + + def op(self, a, b): + return (not (a and b),) + + +class LogicNOR: + DESCRIPTION = "Returns True only if both inputs are False (NOT OR)." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"a": ("BOOLEAN", {"default": False}), "b": ("BOOLEAN", {"default": False})}} + RETURN_TYPES = ("BOOLEAN",) + FUNCTION = "op" + CATEGORY = "SimpleLogics/Logic" + + def op(self, a, b): + return (not (a or b),) + + +# --- Comparison --- + +class CompareInt: + DESCRIPTION = "Compares two integers using the selected operator and returns a boolean result." + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "a": ("INT", {"default": 0}), + "b": ("INT", {"default": 0}), + "op": (["==", "!=", ">", "<", ">=", "<="],), + } + } + RETURN_TYPES = ("BOOLEAN",) + FUNCTION = "compare" + CATEGORY = "SimpleLogics/Compare" + + def compare(self, a, b, op): + result = {"==": a == b, "!=": a != b, ">": a > b, "<": a < b, ">=": a >= b, "<=": a <= b}[op] + return (result,) + + +class CompareFloat: + DESCRIPTION = "Compares two floats using the selected operator and returns a boolean result." + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "a": ("FLOAT", {"default": 0.0}), + "b": ("FLOAT", {"default": 0.0}), + "op": (["==", "!=", ">", "<", ">=", "<="],), + } + } + RETURN_TYPES = ("BOOLEAN",) + FUNCTION = "compare" + CATEGORY = "SimpleLogics/Compare" + + def compare(self, a, b, op): + result = {"==": a == b, "!=": a != b, ">": a > b, "<": a < b, ">=": a >= b, "<=": a <= b}[op] + return (result,) + + +# --- Math --- + +class MathMaxInt: + DESCRIPTION = "Returns the larger of two integers." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"a": ("INT", {"default": 0}), "b": ("INT", {"default": 0})}} + RETURN_TYPES = ("INT",) + FUNCTION = "op" + CATEGORY = "SimpleLogics/Math" + + def op(self, a, b): + return (max(a, b),) + + +class MathMinInt: + DESCRIPTION = "Returns the smaller of two integers." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"a": ("INT", {"default": 0}), "b": ("INT", {"default": 0})}} + RETURN_TYPES = ("INT",) + FUNCTION = "op" + CATEGORY = "SimpleLogics/Math" + + def op(self, a, b): + return (min(a, b),) + + +class MathMaxFloat: + DESCRIPTION = "Returns the larger of two floats." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"a": ("FLOAT", {"default": 0.0}), "b": ("FLOAT", {"default": 0.0})}} + RETURN_TYPES = ("FLOAT",) + FUNCTION = "op" + CATEGORY = "SimpleLogics/Math" + + def op(self, a, b): + return (max(a, b),) + + +class MathMinFloat: + DESCRIPTION = "Returns the smaller of two floats." + @classmethod + def INPUT_TYPES(cls): + return {"required": {"a": ("FLOAT", {"default": 0.0}), "b": ("FLOAT", {"default": 0.0})}} + RETURN_TYPES = ("FLOAT",) + FUNCTION = "op" + CATEGORY = "SimpleLogics/Math" + + def op(self, a, b): + return (min(a, b),) + + +class MathClampInt: + DESCRIPTION = "Clamps an integer between a minimum and maximum value." + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value": ("INT", {"default": 0}), + "min_val": ("INT", {"default": 0}), + "max_val": ("INT", {"default": 100}), + } + } + RETURN_TYPES = ("INT",) + FUNCTION = "op" + CATEGORY = "SimpleLogics/Math" + + def op(self, value, min_val, max_val): + return (max(min_val, min(max_val, value)),) + + +class MathClampFloat: + DESCRIPTION = "Clamps a float between a minimum and maximum value." + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value": ("FLOAT", {"default": 0.0}), + "min_val": ("FLOAT", {"default": 0.0}), + "max_val": ("FLOAT", {"default": 1.0}), + } + } + RETURN_TYPES = ("FLOAT",) + FUNCTION = "op" + CATEGORY = "SimpleLogics/Math" + + def op(self, value, min_val, max_val): + return (max(min_val, min(max_val, value)),) + + +# --- Switch Nodes --- + +class SwitchInt: + DESCRIPTION = "Returns if_true when condition is True, otherwise returns if_false. Works with integers." + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "condition": ("BOOLEAN", {"default": False}), + "if_true": ("INT", {"default": 0}), + "if_false": ("INT", {"default": 0}), + } + } + RETURN_TYPES = ("INT",) + FUNCTION = "switch" + CATEGORY = "SimpleLogics/Switch" + + def switch(self, condition, if_true, if_false): + return (if_true if condition else if_false,) + + +class SwitchFloat: + DESCRIPTION = "Returns if_true when condition is True, otherwise returns if_false. Works with floats." + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "condition": ("BOOLEAN", {"default": False}), + "if_true": ("FLOAT", {"default": 0.0}), + "if_false": ("FLOAT", {"default": 0.0}), + } + } + RETURN_TYPES = ("FLOAT",) + FUNCTION = "switch" + CATEGORY = "SimpleLogics/Switch" + + def switch(self, condition, if_true, if_false): + return (if_true if condition else if_false,) + + +class SwitchString: + DESCRIPTION = "Returns if_true when condition is True, otherwise returns if_false. Works with strings." + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "condition": ("BOOLEAN", {"default": False}), + "if_true": ("STRING", {"default": ""}), + "if_false": ("STRING", {"default": ""}), + } + } + RETURN_TYPES = ("STRING",) + FUNCTION = "switch" + CATEGORY = "SimpleLogics/Switch" + + def switch(self, condition, if_true, if_false): + return (if_true if condition else if_false,) + + +class SwitchAny: + DESCRIPTION = "Returns if_true when condition is True, otherwise returns if_false. Works with any type." + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "condition": ("BOOLEAN", {"default": False}), + "if_true": ("*",), + "if_false": ("*",), + } + } + RETURN_TYPES = ("*",) + FUNCTION = "switch" + CATEGORY = "SimpleLogics/Switch" + + def switch(self, condition, if_true, if_false): + return (if_true if condition else if_false,) + + +# --- Mappings --- + +NODE_CLASS_MAPPINGS = { + # Convert + "SL_IntToFloat": IntToFloat, + "SL_FloatToInt": FloatToInt, + "SL_IntToString": IntToString, + "SL_FloatToString": FloatToString, + "SL_StringToInt": StringToInt, + "SL_StringToFloat": StringToFloat, + "SL_BoolToInt": BoolToInt, + "SL_IntToBool": IntToBool, + # Logic + "SL_AND": LogicAND, + "SL_OR": LogicOR, + "SL_NOT": LogicNOT, + "SL_XOR": LogicXOR, + "SL_NAND": LogicNAND, + "SL_NOR": LogicNOR, + # Compare + "SL_CompareInt": CompareInt, + "SL_CompareFloat": CompareFloat, + # Math + "SL_MaxInt": MathMaxInt, + "SL_MinInt": MathMinInt, + "SL_MaxFloat": MathMaxFloat, + "SL_MinFloat": MathMinFloat, + "SL_ClampInt": MathClampInt, + "SL_ClampFloat": MathClampFloat, + # Switch + "SL_SwitchInt": SwitchInt, + "SL_SwitchFloat": SwitchFloat, + "SL_SwitchString": SwitchString, + "SL_SwitchAny": SwitchAny, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + # Convert + "SL_IntToFloat": "Int → Float", + "SL_FloatToInt": "Float → Int", + "SL_IntToString": "Int → String", + "SL_FloatToString": "Float → String", + "SL_StringToInt": "String → Int", + "SL_StringToFloat": "String → Float", + "SL_BoolToInt": "Bool → Int", + "SL_IntToBool": "Int → Bool", + # Logic + "SL_AND": "AND", + "SL_OR": "OR", + "SL_NOT": "NOT", + "SL_XOR": "XOR", + "SL_NAND": "NAND", + "SL_NOR": "NOR", + # Compare + "SL_CompareInt": "Compare Int", + "SL_CompareFloat": "Compare Float", + # Math + "SL_MaxInt": "Max Int", + "SL_MinInt": "Min Int", + "SL_MaxFloat": "Max Float", + "SL_MinFloat": "Min Float", + "SL_ClampInt": "Clamp Int", + "SL_ClampFloat": "Clamp Float", + # Switch + "SL_SwitchInt": "Switch Int", + "SL_SwitchFloat": "Switch Float", + "SL_SwitchString": "Switch String", + "SL_SwitchAny": "Switch Any", +} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..6474f54 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = --import-mode=importlib diff --git a/tests/test_nodes.py b/tests/test_nodes.py new file mode 100644 index 0000000..fe8de78 --- /dev/null +++ b/tests/test_nodes.py @@ -0,0 +1,105 @@ +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from nodes import ( + IntToFloat, FloatToInt, IntToString, FloatToString, + StringToInt, StringToFloat, BoolToInt, IntToBool, + LogicAND, LogicOR, LogicNOT, LogicXOR, LogicNAND, LogicNOR, + CompareInt, CompareFloat, + MathMaxInt, MathMinInt, MathMaxFloat, MathMinFloat, MathClampInt, MathClampFloat, + SwitchInt, SwitchFloat, SwitchString, SwitchAny, +) + + +# --- Convert --- + +def test_int_to_float(): + assert IntToFloat().convert(3) == (3.0,) + +def test_float_to_int(): + assert FloatToInt().convert(3.9) == (3,) + +def test_int_to_string(): + assert IntToString().convert(42) == ("42",) + +def test_float_to_string(): + assert FloatToString().convert(1.5) == ("1.5",) + +def test_string_to_int(): + assert StringToInt().convert("7") == (7,) + +def test_string_to_float(): + assert StringToFloat().convert("2.5") == (2.5,) + +def test_bool_to_int(): + assert BoolToInt().convert(True) == (1,) + +def test_int_to_bool(): + assert IntToBool().convert(1) == (True,) + + +# --- Logic --- + +def test_and(): + assert LogicAND().op(True, True) == (True,) + +def test_or(): + assert LogicOR().op(False, True) == (True,) + +def test_not(): + assert LogicNOT().op(False) == (True,) + +def test_xor(): + assert LogicXOR().op(True, False) == (True,) + +def test_nand(): + assert LogicNAND().op(True, True) == (False,) + +def test_nor(): + assert LogicNOR().op(False, False) == (True,) + + +# --- Compare --- + +def test_compare_int(): + assert CompareInt().compare(5, 3, ">") == (True,) + +def test_compare_float(): + assert CompareFloat().compare(1.0, 1.0, "==") == (True,) + + +# --- Math --- + +def test_max_int(): + assert MathMaxInt().op(3, 7) == (7,) + +def test_min_int(): + assert MathMinInt().op(3, 7) == (3,) + +def test_max_float(): + assert MathMaxFloat().op(1.5, 2.5) == (2.5,) + +def test_min_float(): + assert MathMinFloat().op(1.5, 2.5) == (1.5,) + +def test_clamp_int(): + assert MathClampInt().op(150, 0, 100) == (100,) + +def test_clamp_float(): + assert MathClampFloat().op(-0.5, 0.0, 1.0) == (0.0,) + + +# --- Switch --- + +def test_switch_int(): + assert SwitchInt().switch(True, 10, 20) == (10,) + +def test_switch_float(): + assert SwitchFloat().switch(False, 1.0, 2.0) == (2.0,) + +def test_switch_string(): + assert SwitchString().switch(True, "yes", "no") == ("yes",) + +def test_switch_any(): + assert SwitchAny().switch(False, "a", "b") == ("b",)