Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22758b4595 | ||
|
|
c7f24a3262 | ||
|
|
c69281e795 | ||
|
|
3bf8391e88 | ||
|
|
d13bfe8fb4 | ||
|
|
702d0c889c | ||
|
|
d1a5282ca0 | ||
|
|
36212adf73 | ||
|
|
2cb2262293 | ||
|
|
8773c8f249 | ||
|
|
0a1cbe4990 | ||
|
|
c4a27137e7 | ||
|
|
8457db0041 | ||
|
|
8a84927adf | ||
|
|
97ea5b9a7d | ||
|
|
f5f956ab4e |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user