Compare commits

...
Author SHA1 Message Date
dependabot[bot] 22758b4595 build(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-18 16:02:32 +00:00
Vito c7f24a3262 Merge pull request #55 from ComfyAssets/feature/batch-list-converter
feat: add batch/list conversion nodes for IMAGE and LATENT
2026-02-09 17:46:52 -08:00
Vito Sansevero c69281e795 fix(batch_list_converter): preserve all latent dict keys during split/join
Previously split_latent_batch and join_latent_batch only handled the
"samples" key, silently dropping metadata like noise_mask or batch_index.
This broke inpaint and masked workflows after a round-trip conversion.
2026-02-09 17:11:01 -08:00
Vito Sansevero 3bf8391e88 feat(batch_list_converter): add batch/list conversion nodes for IMAGE and LATENT
Add 4 utility nodes for converting between batched tensors and lists,
eliminating the dependency on Impact Pack for this common operation:
- ImageBatchToImageList (OUTPUT_IS_LIST)
- ImageListToImageBatch (INPUT_IS_LIST)
- LatentBatchToLatentList (OUTPUT_IS_LIST)
- LatentListToLatentBatch (INPUT_IS_LIST)
2026-02-09 16:39:28 -08:00
Vito d13bfe8fb4 Merge pull request #53 from ComfyAssets/feature/seed-range-and-latent-output
fix: 32-bit seed range, latent batch_size output
2026-02-09 16:32:17 -08:00
Vito Sansevero 702d0c889c fix(seed_history): accept legacy kwargs for backward compatibility
Old workflows may still pass 'mode' to output_seed. Using **kwargs
prevents TypeError when ComfyUI invokes with the extra argument.
2026-02-09 16:29:11 -08:00
Vito Sansevero d1a5282ca0 style: fix Black formatting issues
Remove extra blank line in local_image_loader/node.py and wrap long
tuple assertion in test_empty_latent_batch.py.
2026-02-09 16:26:08 -08:00
Vito Sansevero 36212adf73 ci: reduce test matrix to Python 3.11, 3.12, 3.13
Drop 3.8, 3.9, and 3.10 which are no longer needed.
2026-02-09 16:25:30 -08:00
Vito Sansevero 2cb2262293 chore(local_image_loader): update saved paths and selections 2026-02-09 16:22:10 -08:00
Vito Sansevero 8773c8f249 feat(latent): add batch_size as 4th output from EmptyLatentBatchNode
Exposes batch_size as an output so downstream nodes can reference it
directly without needing a separate input.
2026-02-09 16:22:03 -08:00
Vito Sansevero 0a1cbe4990 fix(seeds): reduce seed range from 64-bit to 32-bit across all nodes
JS Math.random() only has 53 bits of integer precision, making the
64-bit range produce non-uniform values. 32-bit (0xFFFFFFFF) is the
standard ComfyUI seed range and works correctly in both Python and JS.

Also removes the redundant mode input from SeedHistoryNode, relying on
ComfyUI's built-in control_after_generate widget instead.
2026-02-09 16:21:56 -08:00
Vito c4a27137e7 Merge pull request #52 from ComfyAssets/feature/height-width-2-vec
Feature/height width 2 vec
2025-12-21 06:49:05 -08:00
Vito 8457db0041 Merge pull request #50 from ComfyAssets/dependabot/github_actions/actions/checkout-6
build(deps): bump actions/checkout from 5 to 6
2025-12-17 07:07:19 -08:00
Vito 8a84927adf Merge pull request #51 from ComfyAssets/dependabot/github_actions/actions/cache-5
build(deps): bump actions/cache from 4 to 5
2025-12-17 07:06:54 -08:00
dependabot[bot] 97ea5b9a7d build(deps): bump actions/cache from 4 to 5
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 16:28:14 +00:00
dependabot[bot] f5f956ab4e build(deps): bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-24 16:55:51 +00:00
19 changed files with 550 additions and 110 deletions
+4 -4
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python 3.10
uses: actions/setup-python@v6
@@ -21,7 +21,7 @@ jobs:
python-version: '3.10'
- name: Cache pip dependencies
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-quality-${{ hashFiles('**/requirements-dev.txt') }}
@@ -141,7 +141,7 @@ jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python 3.10
uses: actions/setup-python@v6
@@ -172,7 +172,7 @@ jobs:
architecture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python 3.10
uses: actions/setup-python@v6
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
if: ${{ github.repository_owner == 'ComfyAssets' }}
steps:
- name: Check out code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
submodules: true
- name: Publish Custom Node
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python 3.10
uses: actions/setup-python@v6
@@ -114,7 +114,7 @@ jobs:
EOF
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.get_version.outputs.version }}
name: ComfyUI-KikoTools ${{ steps.get_version.outputs.version }}
+5 -5
View File
@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8, 3.9, "3.10", "3.11", "3.12"]
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
@@ -25,7 +25,7 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Cache pip dependencies
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements-dev.txt') }}
@@ -401,7 +401,7 @@ jobs:
test-package-structure:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python 3.10
uses: actions/setup-python@v6
@@ -465,7 +465,7 @@ jobs:
test-documentation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Test documentation completeness
run: |
+14
View File
@@ -3,6 +3,12 @@ KikoTools package initialization and node registry
Handles automatic discovery and registration of all ComfyAssets tools
"""
from .tools.batch_list_converter import (
ImageBatchToImageListNode,
ImageListToImageBatchNode,
LatentBatchToLatentListNode,
LatentListToLatentBatchNode,
)
from .tools.batch_prompts import BatchPromptsNode
from .tools.display_any import DisplayAnyNode
from .tools.display_text import DisplayTextNode
@@ -34,6 +40,10 @@ from .tools.xyz_helpers import (
# ComfyUI node registration mappings
NODE_CLASS_MAPPINGS = {
"ImageBatchToImageList": ImageBatchToImageListNode,
"ImageListToImageBatch": ImageListToImageBatchNode,
"LatentBatchToLatentList": LatentBatchToLatentListNode,
"LatentListToLatentBatch": LatentListToLatentBatchNode,
"BatchPrompts": BatchPromptsNode,
"ResolutionCalculator": ResolutionCalculatorNode,
"WidthHeightSelector": WidthHeightSelectorNode,
@@ -65,6 +75,10 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageBatchToImageList": "Image Batch to Image List",
"ImageListToImageBatch": "Image List to Image Batch",
"LatentBatchToLatentList": "Latent Batch to Latent List",
"LatentListToLatentBatch": "Latent List to Latent Batch",
"BatchPrompts": "Batch Prompts",
"ResolutionCalculator": "Resolution Calculator",
"WidthHeightSelector": "Width Height Selector",
@@ -0,0 +1,15 @@
"""Batch/List conversion tool for ComfyUI."""
from .node import (
ImageBatchToImageListNode,
ImageListToImageBatchNode,
LatentBatchToLatentListNode,
LatentListToLatentBatchNode,
)
__all__ = [
"ImageBatchToImageListNode",
"ImageListToImageBatchNode",
"LatentBatchToLatentListNode",
"LatentListToLatentBatchNode",
]
@@ -0,0 +1,55 @@
"""Pure tensor split/join functions for batch-list conversions."""
import torch
from typing import Dict, List
def split_image_batch(images: torch.Tensor) -> List[torch.Tensor]:
"""Split [B,H,W,C] image batch into list of [1,H,W,C] tensors."""
return [images[i : i + 1] for i in range(images.shape[0])]
def join_image_batch(image_list: List[torch.Tensor]) -> torch.Tensor:
"""Join list of image tensors into single [B,H,W,C] batch."""
return torch.cat(image_list, dim=0)
def split_latent_batch(
latent: Dict[str, torch.Tensor],
) -> List[Dict[str, torch.Tensor]]:
"""Split latent dict into list of single-item latent dicts.
Preserves all keys (e.g. noise_mask, batch_index). Tensor values whose
first dimension matches the batch size of ``samples`` are sliced along
dim-0; all other values are copied as-is to every item.
"""
samples = latent["samples"]
batch_size = samples.shape[0]
result: List[Dict[str, torch.Tensor]] = []
for i in range(batch_size):
item: Dict[str, torch.Tensor] = {}
for key, value in latent.items():
if isinstance(value, torch.Tensor) and value.shape[0] == batch_size:
item[key] = value[i : i + 1]
else:
item[key] = value
result.append(item)
return result
def join_latent_batch(
latent_list: List[Dict[str, torch.Tensor]],
) -> Dict[str, torch.Tensor]:
"""Join list of latent dicts into single batched latent dict.
Tensor values that were sliced during split are concatenated along dim-0.
Non-tensor values are taken from the first item.
"""
result: Dict[str, torch.Tensor] = {}
first = latent_list[0]
for key in first:
if isinstance(first[key], torch.Tensor):
result[key] = torch.cat([lat[key] for lat in latent_list], dim=0)
else:
result[key] = first[key]
return result
@@ -0,0 +1,130 @@
"""Batch/List conversion nodes for ComfyUI."""
from typing import Dict, List, Tuple
import torch
from ...base.base_node import ComfyAssetsBaseNode
from .logic import (
split_image_batch,
join_image_batch,
split_latent_batch,
join_latent_batch,
)
class ImageBatchToImageListNode(ComfyAssetsBaseNode):
"""Split an IMAGE batch [B,H,W,C] into a list of individual images."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
}
}
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("images", "count")
OUTPUT_IS_LIST = (True, False)
FUNCTION = "split_batch"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def split_batch(self, images: torch.Tensor) -> Tuple[List[torch.Tensor], int]:
image_list = split_image_batch(images)
count = len(image_list)
self.log_info(f"Split image batch of {count} into list")
return (image_list, count)
class ImageListToImageBatchNode(ComfyAssetsBaseNode):
"""Join a list of IMAGE tensors into a single batched IMAGE [B,H,W,C]."""
INPUT_IS_LIST = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
}
}
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("images", "count")
FUNCTION = "join_batch"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def join_batch(self, images: List[torch.Tensor]) -> Tuple[torch.Tensor, int]:
batch = join_image_batch(images)
count = batch.shape[0]
self.log_info(f"Joined {count} images into batch")
return (batch, count)
class LatentBatchToLatentListNode(ComfyAssetsBaseNode):
"""Split a LATENT batch into a list of individual latent dicts."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"latent": ("LATENT",),
}
}
RETURN_TYPES = ("LATENT", "INT")
RETURN_NAMES = ("latents", "count")
OUTPUT_IS_LIST = (True, False)
FUNCTION = "split_batch"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def split_batch(
self, latent: Dict[str, torch.Tensor]
) -> Tuple[List[Dict[str, torch.Tensor]], int]:
latent_list = split_latent_batch(latent)
count = len(latent_list)
self.log_info(f"Split latent batch of {count} into list")
return (latent_list, count)
class LatentListToLatentBatchNode(ComfyAssetsBaseNode):
"""Join a list of LATENT dicts into a single batched LATENT."""
INPUT_IS_LIST = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"latents": ("LATENT",),
}
}
RETURN_TYPES = ("LATENT", "INT")
RETURN_NAMES = ("latent", "count")
FUNCTION = "join_batch"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def join_batch(
self, latents: List[Dict[str, torch.Tensor]]
) -> Tuple[Dict[str, torch.Tensor], int]:
batch = join_latent_batch(latents)
count = batch["samples"].shape[0]
self.log_info(f"Joined {count} latents into batch")
return (batch, count)
NODE_CLASS_MAPPINGS = {
"ImageBatchToImageList": ImageBatchToImageListNode,
"ImageListToImageBatch": ImageListToImageBatchNode,
"LatentBatchToLatentList": LatentBatchToLatentListNode,
"LatentListToLatentBatch": LatentListToLatentBatchNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageBatchToImageList": "Image Batch to Image List",
"ImageListToImageBatch": "Image List to Image Batch",
"LatentBatchToLatentList": "Latent Batch to Latent List",
"LatentListToLatentBatch": "Latent List to Latent Batch",
}
+5 -5
View File
@@ -93,14 +93,14 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
}
}
RETURN_TYPES = ("LATENT", "INT", "INT")
RETURN_NAMES = ("latent", "width", "height")
RETURN_TYPES = ("LATENT", "INT", "INT", "INT")
RETURN_NAMES = ("latent", "width", "height", "batch_size")
FUNCTION = "create_empty_latent"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def create_empty_latent(
self, preset: str, width: int, height: int, batch_size: int
) -> Tuple[Dict[str, torch.Tensor], int, int]:
) -> Tuple[Dict[str, torch.Tensor], int, int, int]:
"""
Create empty latent tensor with specified dimensions and batch size.
@@ -111,7 +111,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
batch_size: Number of latents in the batch
Returns:
Tuple containing (latent dictionary with 'samples' tensor, width, height)
Tuple containing (latent dict, width, height, batch_size)
"""
try:
# Extract original preset name from formatted string if needed
@@ -160,7 +160,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
f"(pixel dims: {final_width}×{final_height})"
)
return (latent_dict, final_width, final_height)
return (latent_dict, final_width, final_height, batch_size)
except Exception as e:
# Handle any unexpected errors gracefully
+1 -1
View File
@@ -75,7 +75,7 @@ class KikoFilmGrainNode(ComfyAssetsBaseNode):
{
"default": 0,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"max": 0xFFFFFFFF, # 2**32 - 1
"description": "Random seed for grain pattern generation",
},
),
@@ -1,5 +1,5 @@
{
"last_path": "/home/vito/ai-apps/ComfyUI/output/vids/images",
"last_path": "/home/vito/ai-apps/ComfyUI/output/vids",
"saved_paths": [
"/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01",
"/home/vito/ai-apps/ComfyUI-3.12/output/",
@@ -8,7 +8,6 @@ from typing import Dict, Any, Tuple
from ...base.base_node import ComfyAssetsBaseNode
from .logic import load_image_from_path, create_empty_tensor
NODE_DIR = os.path.dirname(os.path.abspath(__file__))
SELECTIONS_FILE = os.path.join(NODE_DIR, "selections.json")
CONFIG_FILE = os.path.join(NODE_DIR, "config.json")
@@ -11,7 +11,7 @@
},
"18": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/ComfyUI_00002_.png"
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
}
},
"445": {
@@ -66,12 +66,22 @@
},
"517": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/vids/images/kittybear_00004.png"
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
}
},
"144": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/2025-04-24/ComfyUI_00002_.png"
}
},
"569": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00008.png"
}
},
"136": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
}
}
}
+7 -7
View File
@@ -10,14 +10,14 @@ def generate_random_seed() -> int:
Generate a cryptographically strong random seed value.
Returns:
Random integer in the valid ComfyUI seed range
Random integer in the valid ComfyUI seed range (0 to 2**32 - 1)
"""
return random.randint(0, 0xFFFFFFFFFFFFFFFF)
return random.randint(0, 0xFFFFFFFF) # 2**32 - 1
def validate_seed_value(seed: Any) -> bool:
"""
Validate that a seed value is within acceptable range.
Validate that a seed value is within acceptable range (0 to 2**32 - 1).
Args:
seed: Seed value to validate
@@ -30,7 +30,7 @@ def validate_seed_value(seed: Any) -> bool:
try:
seed_int = int(seed)
return 0 <= seed_int <= 0xFFFFFFFFFFFFFFFF
return 0 <= seed_int <= 0xFFFFFFFF # 2**32 - 1
except (ValueError, TypeError):
return False
@@ -54,11 +54,11 @@ def sanitize_seed_value(seed: Any) -> int:
try:
seed_int = int(seed)
# Clamp to valid range
# Clamp to valid range (0 to 2**32 - 1)
if seed_int < 0:
seed_int = 0
elif seed_int > 0xFFFFFFFFFFFFFFFF:
seed_int = 0xFFFFFFFFFFFFFFFF
elif seed_int > 0xFFFFFFFF:
seed_int = 0xFFFFFFFF
return seed_int
+7 -60
View File
@@ -27,26 +27,10 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
{
"default": 12345,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"max": 0xFFFFFFFF, # 2**32 - 1
"control_after_generate": True,
"tooltip": "Seed value for generation processes. "
"Auto-increments/decrements after each run based on mode.",
},
),
},
"optional": {
"mode": (
[
"",
"fixed",
"increment",
"decrement",
"randomize",
], # Added empty string for legacy workflows
{
"default": "fixed",
"tooltip": "Seed behavior after generation: "
"fixed (no change), increment (+1), decrement (-1), or randomize (new random)",
"Use 'control after generate' to set behavior after each run.",
},
),
},
@@ -57,51 +41,20 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
FUNCTION = "output_seed"
CATEGORY = "🫶 ComfyAssets/🌱 Seeds"
@classmethod
def VALIDATE_INPUTS(cls, seed, mode="fixed"):
"""Validate inputs and handle legacy workflows."""
# Handle empty or missing mode from old workflows (legacy support)
if mode is None or mode == "" or mode == "undefined":
return True # Will use default "fixed" in output_seed
# Validate mode is in allowed list
valid_modes = ["fixed", "increment", "decrement", "randomize"]
if mode not in valid_modes:
return f"Invalid mode: {mode}. Must be one of {valid_modes}"
return True
def output_seed(self, seed: int, mode: str = "fixed") -> Tuple[int]:
def output_seed(self, seed: int, **kwargs) -> Tuple[int]:
"""
Output the seed value for use in other nodes.
Args:
seed: Input seed value
mode: Seed mode (fixed, increment, decrement, randomize) - not used in output,
but controls the widget behavior via control_after_generate
**kwargs: Accepts legacy parameters (e.g. mode) for backward compatibility
Returns:
Tuple containing the seed value
"""
try:
# Handle empty mode from old workflows
if not mode or mode == "":
mode = "fixed"
# Validate mode is in allowed list
valid_modes = ["fixed", "increment", "decrement", "randomize"]
if mode not in valid_modes:
import logging
logger = logging.getLogger(__name__)
logger.warning(
f"{self.__class__.__name__}: Invalid mode '{mode}'. Using 'fixed'."
)
mode = "fixed"
# Validate and sanitize the seed
if not validate_seed_value(seed):
# Log the validation error but don't raise
import logging
logger = logging.getLogger(__name__)
@@ -112,15 +65,9 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
return (12345,)
clean_seed = sanitize_seed_value(seed)
# Note: The mode parameter controls the widget's control_after_generate behavior
# The actual increment/decrement/randomize happens automatically in the UI
# based on the control_after_generate setting and the mode dropdown value
return (clean_seed,)
except Exception as e:
# Handle any unexpected errors gracefully
import logging
logger = logging.getLogger(__name__)
@@ -194,7 +141,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
Returns:
Range information string
"""
max_seed = 0xFFFFFFFFFFFFFFFF
max_seed = 0xFFFFFFFF # 2**32 - 1
return f"Valid range: 0 to {max_seed:,} ({hex(max_seed)})"
@classmethod
@@ -218,7 +165,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
Returns:
True if seed is in valid range
"""
return 0 <= seed <= 0xFFFFFFFFFFFFFFFF
return 0 <= seed <= 0xFFFFFFFF # 2**32 - 1
def __str__(self) -> str:
"""String representation of the node."""
@@ -230,6 +177,6 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
f"SeedHistoryNode("
f"category='{self.CATEGORY}', "
f"function='{self.FUNCTION}', "
f"max_seed={hex(0xFFFFFFFFFFFFFFFF)}"
f"max_seed={hex(0xFFFFFFFF)}" # 2**32 - 1
f")"
)
@@ -0,0 +1,262 @@
"""Tests for Batch/List conversion nodes and logic."""
import pytest
import torch
from kikotools.tools.batch_list_converter.logic import (
split_image_batch,
join_image_batch,
split_latent_batch,
join_latent_batch,
)
from kikotools.tools.batch_list_converter.node import (
ImageBatchToImageListNode,
ImageListToImageBatchNode,
LatentBatchToLatentListNode,
LatentListToLatentBatchNode,
)
class TestBatchListConverterLogic:
"""Test pure split/join functions."""
# -- Image split --
def test_split_image_batch_single(self):
"""Single image batch returns list of one."""
images = torch.rand(1, 64, 64, 3)
result = split_image_batch(images)
assert len(result) == 1
assert result[0].shape == (1, 64, 64, 3)
assert torch.equal(result[0], images)
def test_split_image_batch_multiple(self):
"""Multi-image batch splits correctly."""
images = torch.rand(4, 64, 64, 3)
result = split_image_batch(images)
assert len(result) == 4
for i, img in enumerate(result):
assert img.shape == (1, 64, 64, 3)
assert torch.equal(img, images[i : i + 1])
def test_split_image_preserves_batch_dim(self):
"""Each split image keeps 4D shape [1,H,W,C]."""
images = torch.rand(3, 128, 256, 3)
result = split_image_batch(images)
for img in result:
assert img.ndim == 4
assert img.shape[0] == 1
# -- Image join --
def test_join_image_batch_single(self):
"""Join single image produces batch of 1."""
image_list = [torch.rand(1, 64, 64, 3)]
result = join_image_batch(image_list)
assert result.shape == (1, 64, 64, 3)
def test_join_image_batch_multiple(self):
"""Join multiple images into batch."""
image_list = [torch.rand(1, 64, 64, 3) for _ in range(5)]
result = join_image_batch(image_list)
assert result.shape == (5, 64, 64, 3)
def test_image_roundtrip(self):
"""split -> join produces identical tensor."""
original = torch.rand(4, 64, 64, 3)
reconstructed = join_image_batch(split_image_batch(original))
assert torch.equal(original, reconstructed)
# -- Latent split --
def test_split_latent_batch_single(self):
"""Single latent returns list of one dict."""
latent = {"samples": torch.rand(1, 4, 32, 32)}
result = split_latent_batch(latent)
assert len(result) == 1
assert "samples" in result[0]
assert result[0]["samples"].shape == (1, 4, 32, 32)
def test_split_latent_batch_multiple(self):
"""Multi-item latent splits correctly."""
latent = {"samples": torch.rand(3, 4, 32, 32)}
result = split_latent_batch(latent)
assert len(result) == 3
for i, lat in enumerate(result):
assert lat["samples"].shape == (1, 4, 32, 32)
assert torch.equal(lat["samples"], latent["samples"][i : i + 1])
# -- Latent join --
def test_join_latent_batch_single(self):
"""Join single latent dict."""
latent_list = [{"samples": torch.rand(1, 4, 32, 32)}]
result = join_latent_batch(latent_list)
assert "samples" in result
assert result["samples"].shape == (1, 4, 32, 32)
def test_join_latent_batch_multiple(self):
"""Join multiple latent dicts into batch."""
latent_list = [{"samples": torch.rand(1, 4, 32, 32)} for _ in range(4)]
result = join_latent_batch(latent_list)
assert result["samples"].shape == (4, 4, 32, 32)
def test_latent_roundtrip(self):
"""split -> join produces identical tensor."""
original = {"samples": torch.rand(5, 4, 64, 64)}
reconstructed = join_latent_batch(split_latent_batch(original))
assert torch.equal(original["samples"], reconstructed["samples"])
def test_split_latent_preserves_noise_mask(self):
"""noise_mask is sliced alongside samples."""
latent = {
"samples": torch.rand(3, 4, 32, 32),
"noise_mask": torch.rand(3, 1, 32, 32),
}
result = split_latent_batch(latent)
assert len(result) == 3
for i, item in enumerate(result):
assert "noise_mask" in item
assert item["noise_mask"].shape == (1, 1, 32, 32)
assert torch.equal(item["noise_mask"], latent["noise_mask"][i : i + 1])
def test_join_latent_preserves_noise_mask(self):
"""noise_mask is concatenated alongside samples."""
latent_list = [
{
"samples": torch.rand(1, 4, 32, 32),
"noise_mask": torch.rand(1, 1, 32, 32),
}
for _ in range(3)
]
result = join_latent_batch(latent_list)
assert "noise_mask" in result
assert result["noise_mask"].shape == (3, 1, 32, 32)
def test_latent_roundtrip_with_extra_keys(self):
"""Round-trip preserves all tensor keys."""
original = {
"samples": torch.rand(4, 4, 64, 64),
"noise_mask": torch.rand(4, 1, 64, 64),
}
reconstructed = join_latent_batch(split_latent_batch(original))
assert torch.equal(original["samples"], reconstructed["samples"])
assert torch.equal(original["noise_mask"], reconstructed["noise_mask"])
def test_split_latent_copies_non_tensor_values(self):
"""Non-tensor metadata is copied to each item."""
latent = {
"samples": torch.rand(2, 4, 32, 32),
"some_flag": "preserve_me",
}
result = split_latent_batch(latent)
for item in result:
assert item["some_flag"] == "preserve_me"
class TestBatchListConverterNodes:
"""Test ComfyUI node classes."""
# -- ImageBatchToImageList --
def test_image_b2l_attributes(self):
assert ImageBatchToImageListNode.RETURN_TYPES == ("IMAGE", "INT")
assert ImageBatchToImageListNode.RETURN_NAMES == ("images", "count")
assert ImageBatchToImageListNode.OUTPUT_IS_LIST == (True, False)
assert ImageBatchToImageListNode.FUNCTION == "split_batch"
assert ImageBatchToImageListNode.CATEGORY == "🫶 ComfyAssets/📦 Latents"
def test_image_b2l_input_types(self):
inputs = ImageBatchToImageListNode.INPUT_TYPES()
assert "required" in inputs
assert "images" in inputs["required"]
assert inputs["required"]["images"] == ("IMAGE",)
def test_image_b2l_execute(self):
node = ImageBatchToImageListNode()
images = torch.rand(3, 64, 64, 3)
result = node.split_batch(images)
image_list, count = result
assert isinstance(image_list, list)
assert len(image_list) == 3
assert count == 3
# -- ImageListToImageBatch --
def test_image_l2b_attributes(self):
assert ImageListToImageBatchNode.INPUT_IS_LIST is True
assert ImageListToImageBatchNode.RETURN_TYPES == ("IMAGE", "INT")
assert ImageListToImageBatchNode.RETURN_NAMES == ("images", "count")
assert ImageListToImageBatchNode.FUNCTION == "join_batch"
def test_image_l2b_execute(self):
node = ImageListToImageBatchNode()
image_list = [torch.rand(1, 64, 64, 3) for _ in range(4)]
batch, count = node.join_batch(image_list)
assert batch.shape == (4, 64, 64, 3)
assert count == 4
# -- LatentBatchToLatentList --
def test_latent_b2l_attributes(self):
assert LatentBatchToLatentListNode.RETURN_TYPES == ("LATENT", "INT")
assert LatentBatchToLatentListNode.RETURN_NAMES == ("latents", "count")
assert LatentBatchToLatentListNode.OUTPUT_IS_LIST == (True, False)
assert LatentBatchToLatentListNode.FUNCTION == "split_batch"
def test_latent_b2l_execute(self):
node = LatentBatchToLatentListNode()
latent = {"samples": torch.rand(2, 4, 32, 32)}
latent_list, count = node.split_batch(latent)
assert isinstance(latent_list, list)
assert len(latent_list) == 2
assert count == 2
# -- LatentListToLatentBatch --
def test_latent_l2b_attributes(self):
assert LatentListToLatentBatchNode.INPUT_IS_LIST is True
assert LatentListToLatentBatchNode.RETURN_TYPES == ("LATENT", "INT")
assert LatentListToLatentBatchNode.RETURN_NAMES == ("latent", "count")
assert LatentListToLatentBatchNode.FUNCTION == "join_batch"
def test_latent_l2b_execute(self):
node = LatentListToLatentBatchNode()
latent_list = [{"samples": torch.rand(1, 4, 32, 32)} for _ in range(3)]
batch, count = node.join_batch(latent_list)
assert "samples" in batch
assert batch["samples"].shape == (3, 4, 32, 32)
assert count == 3
# -- Inheritance --
def test_all_nodes_inherit_base(self):
from kikotools.base.base_node import ComfyAssetsBaseNode
for cls in (
ImageBatchToImageListNode,
ImageListToImageBatchNode,
LatentBatchToLatentListNode,
LatentListToLatentBatchNode,
):
assert issubclass(cls, ComfyAssetsBaseNode)
# -- Registration mappings --
def test_node_class_mappings(self):
from kikotools.tools.batch_list_converter.node import NODE_CLASS_MAPPINGS
assert len(NODE_CLASS_MAPPINGS) == 4
assert "ImageBatchToImageList" in NODE_CLASS_MAPPINGS
assert "ImageListToImageBatch" in NODE_CLASS_MAPPINGS
assert "LatentBatchToLatentList" in NODE_CLASS_MAPPINGS
assert "LatentListToLatentBatch" in NODE_CLASS_MAPPINGS
def test_node_display_name_mappings(self):
from kikotools.tools.batch_list_converter.node import NODE_DISPLAY_NAME_MAPPINGS
assert len(NODE_DISPLAY_NAME_MAPPINGS) == 4
assert (
NODE_DISPLAY_NAME_MAPPINGS["ImageBatchToImageList"]
== "Image Batch to Image List"
)
+16 -8
View File
@@ -131,8 +131,13 @@ class TestEmptyLatentBatchNode:
def test_node_attributes(self):
"""Test node class attributes."""
assert EmptyLatentBatchNode.RETURN_TYPES == ("LATENT", "INT", "INT")
assert EmptyLatentBatchNode.RETURN_NAMES == ("latent", "width", "height")
assert EmptyLatentBatchNode.RETURN_TYPES == ("LATENT", "INT", "INT", "INT")
assert EmptyLatentBatchNode.RETURN_NAMES == (
"latent",
"width",
"height",
"batch_size",
)
assert EmptyLatentBatchNode.FUNCTION == "create_empty_latent"
assert EmptyLatentBatchNode.CATEGORY == "🫶 ComfyAssets/📦 Latents"
@@ -141,13 +146,14 @@ class TestEmptyLatentBatchNode:
result = self.node.create_empty_latent("custom", 512, 512, 1)
assert isinstance(result, tuple)
assert len(result) == 3 # Now returns (latent, width, height)
assert len(result) == 4 # Returns (latent, width, height, batch_size)
latent_dict, width, height = result
latent_dict, width, height, batch_size = result
assert isinstance(latent_dict, dict)
assert "samples" in latent_dict
assert width == 512
assert height == 512
assert batch_size == 1
samples = latent_dict["samples"]
assert isinstance(samples, torch.Tensor)
@@ -155,12 +161,13 @@ class TestEmptyLatentBatchNode:
def test_create_empty_latent_with_batch(self):
"""Test empty latent creation with batch size."""
batch_size = 3
result = self.node.create_empty_latent("custom", 1024, 768, batch_size)
input_batch_size = 3
result = self.node.create_empty_latent("custom", 1024, 768, input_batch_size)
latent_dict, width, height = result
latent_dict, width, height, batch_size = result
assert width == 1024
assert height == 768
assert batch_size == 3
samples = latent_dict["samples"]
assert samples.shape == (3, 4, 96, 128) # batch=3, 768/8=96, 1024/8=128
@@ -169,10 +176,11 @@ class TestEmptyLatentBatchNode:
# Input dimensions not divisible by 8
result = self.node.create_empty_latent("custom", 513, 515, 1)
latent_dict, width, height = result
latent_dict, width, height, batch_size = result
# Dimensions should be rounded UP to nearest multiple of 8
assert width == 520 # 513 -> 520
assert height == 520 # 515 -> 520
assert batch_size == 1
samples = latent_dict["samples"]
# Should be adjusted to 520x520 -> 65x65 latent
assert samples.shape == (1, 4, 65, 65)
+12 -12
View File
@@ -43,7 +43,7 @@ class TestSeedHistoryNode:
assert "min" in seed_config[1]
assert "max" in seed_config[1]
assert seed_config[1]["min"] == 0
assert seed_config[1]["max"] == 0xFFFFFFFFFFFFFFFF
assert seed_config[1]["max"] == 0xFFFFFFFF # 2**32 - 1
# Test return types
assert SeedHistoryNode.RETURN_TYPES == ("INT",)
@@ -56,7 +56,7 @@ class TestSeedHistoryNode:
node = SeedHistoryNode()
# Test various valid seeds
test_seeds = [0, 12345, 999999, 0xFFFFFFFFFFFFFFFF]
test_seeds = [0, 12345, 999999, 0xFFFFFFFF] # 2**32 - 1
for seed in test_seeds:
result = node.output_seed(seed)
@@ -73,7 +73,7 @@ class TestSeedHistoryNode:
assert result == (12345,) # Fallback
# Test seeds too large
result = node.output_seed(0xFFFFFFFFFFFFFFFF + 1)
result = node.output_seed(0xFFFFFFFF + 1) # 2**32
assert result == (12345,) # Fallback
def test_generate_new_seed(self):
@@ -100,11 +100,11 @@ class TestSeedHistoryNode:
# Valid seeds
assert node.validate_seed_input(0)
assert node.validate_seed_input(12345)
assert node.validate_seed_input(0xFFFFFFFFFFFFFFFF)
assert node.validate_seed_input(0xFFFFFFFF) # 2**32 - 1
# Invalid seeds
assert not node.validate_seed_input(-1)
assert not node.validate_seed_input(0xFFFFFFFFFFFFFFFF + 1)
assert not node.validate_seed_input(0xFFFFFFFF + 1) # 2**32
assert not node.validate_seed_input(None)
def test_get_seed_info(self):
@@ -132,7 +132,7 @@ class TestSeedHistoryNode:
range_info = node.get_seed_range_info()
assert "Valid range" in range_info
# Check for the hex representation which should be in the string
assert "0xffffffffffffffff" in range_info.lower()
assert "0xffffffff" in range_info.lower() # 2**32 - 1
def test_class_methods(self):
"""Test class methods."""
@@ -143,9 +143,9 @@ class TestSeedHistoryNode:
# Test range checking
assert SeedHistoryNode.is_seed_in_range(0)
assert SeedHistoryNode.is_seed_in_range(12345)
assert SeedHistoryNode.is_seed_in_range(0xFFFFFFFFFFFFFFFF)
assert SeedHistoryNode.is_seed_in_range(0xFFFFFFFF) # 2**32 - 1
assert not SeedHistoryNode.is_seed_in_range(-1)
assert not SeedHistoryNode.is_seed_in_range(0xFFFFFFFFFFFFFFFF + 1)
assert not SeedHistoryNode.is_seed_in_range(0xFFFFFFFF + 1) # 2**32
class TestSeedHistoryLogic:
@@ -168,11 +168,11 @@ class TestSeedHistoryLogic:
# Valid seeds
assert validate_seed_value(0)
assert validate_seed_value(12345)
assert validate_seed_value(0xFFFFFFFFFFFFFFFF)
assert validate_seed_value(0xFFFFFFFF) # 2**32 - 1
# Invalid seeds
assert not validate_seed_value(-1)
assert not validate_seed_value(0xFFFFFFFFFFFFFFFF + 1)
assert not validate_seed_value(0xFFFFFFFF + 1) # 2**32
assert not validate_seed_value(None)
assert not validate_seed_value("invalid")
assert not validate_seed_value([])
@@ -182,7 +182,7 @@ class TestSeedHistoryLogic:
# Valid seeds should pass through
assert sanitize_seed_value(12345) == 12345
assert sanitize_seed_value(0) == 0
assert sanitize_seed_value(0xFFFFFFFFFFFFFFFF) == 0xFFFFFFFFFFFFFFFF
assert sanitize_seed_value(0xFFFFFFFF) == 0xFFFFFFFF # 2**32 - 1
# String numbers should convert
assert sanitize_seed_value("12345") == 12345
@@ -190,7 +190,7 @@ class TestSeedHistoryLogic:
# Out of range should clamp
assert sanitize_seed_value(-100) == 0
assert sanitize_seed_value(0xFFFFFFFFFFFFFFFF + 100) == 0xFFFFFFFFFFFFFFFF
assert sanitize_seed_value(0xFFFFFFFF + 100) == 0xFFFFFFFF # clamp to 2**32 - 1
# Invalid should raise
try:
+1 -1
View File
@@ -296,7 +296,7 @@ app.registerExtension({
// Generate new random seed
nodeType.prototype.generateRandomSeed = function () {
const newSeed = Math.floor(Math.random() * 0xFFFFFFFFFFFFFFFF);
const newSeed = Math.floor(Math.random() * 0xFFFFFFFF); // 2**32 - 1
const seedWidget = this.widgets?.find(w => w.name === "seed");
if (seedWidget) {