From e384cf09afa8d767cbd3c69f291d5f7b8f352a4e Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Mon, 2 Mar 2026 02:30:20 +0300 Subject: [PATCH] Added TT_ImageLoaderResizerNode --- node_mappings.py | 5 + nodes_context.py | 6 +- nodes_image.py | 199 ++++++++++++++++++++++++++++++++++++++++ nodes_latent.py | 12 +-- nodes_loaders.py | 28 ++++++ nodes_postproduction.py | 29 ++++++ nodes_sampling.py | 29 ++++++ nodes_vae.py | 28 ++++++ nodes_workflow.py | 11 +-- 9 files changed, 331 insertions(+), 16 deletions(-) diff --git a/node_mappings.py b/node_mappings.py index 3dbf56c..f3e4df8 100644 --- a/node_mappings.py +++ b/node_mappings.py @@ -35,6 +35,7 @@ try: from .nodes.image.image_preview_save import TT_ImagePreviewSave from .nodes.image.image_preview_upscale_save import TT_ImagePreviewUpscaleSave from .nodes.image.guider_image_reference import TT_GuiderImageReference + from .nodes_image import * NODES_COUNT += len(set(dir()) - before) except ImportError: @@ -153,6 +154,8 @@ NODE_CLASS_MAPPINGS = { "TT_ImagePreviewSave": TT_ImagePreviewSave, "TT_ImagePreviewUpscaleSave": TT_ImagePreviewUpscaleSave, "TT_GuiderImageReference": TT_GuiderImageReference, + # Image V3 Nodes + "TT_ImageLoaderResizerNode": TT_ImageLoaderResizerNode, # Latent "TT_LatentFactory": TT_LatentFactory, # Latent V3 Nodes @@ -229,6 +232,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TT_ImagePreviewSave": "TT Image Preview / Save", "TT_ImagePreviewUpscaleSave": "TT Image Preview / Upscale / Save", "TT_GuiderImageReference": "TT Guider Image Reference", + # Image V3 Nodes + "TT_ImageLoaderResizerNode": "TT Image Loader / Resizer", # Latent "TT_LatentFactory": "TT Latent Factory (Deprecated)", # Latent V3 Nodes diff --git a/nodes_context.py b/nodes_context.py index d3f5458..d299555 100644 --- a/nodes_context.py +++ b/nodes_context.py @@ -315,7 +315,7 @@ class TT_ContextExtractImageNode(ContextNode): # V3 entrypoint — registers context nodes with ComfyUI # ============================================================================== -class ContextExtension(ComfyExtension): +class ContextNodesExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ @@ -334,8 +334,8 @@ class ContextExtension(ComfyExtension): ] -async def comfy_entrypoint() -> ContextExtension: - return ContextExtension() +async def comfy_entrypoint() -> ContextNodesExtension: + return ContextNodesExtension() # ============================================================================== diff --git a/nodes_image.py b/nodes_image.py index f7d238b..ddebbb9 100644 --- a/nodes_image.py +++ b/nodes_image.py @@ -1 +1,200 @@ # (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +import math +import os +from pathlib import Path +from typing import override + +import numpy +import torch +from PIL import Image, ImageOps, ImageSequence + +import folder_paths +from comfy import model_management, utils +from comfy_api.latest import ComfyExtension, IO, ui +from comfy_api.latest._io import UploadType +from node_helpers import pillow +from .nodes_latent import MEGAPIXELS + +CATEGORY = "TenserTensor/Image" +RESIZE_METHODS = ["nearest-exact", "bilinear", "area", "bicubic", "bislerp"] + + +def align_to_step(size, scale_factor, dimension_step): + return round(size * scale_factor / dimension_step) * dimension_step + + +# crop = "center" / "disabled" +def resize_image(timage, to_width, to_height, upscale_method, dimension_step=1, crop="disabled"): + samples = timage.movedim(-1, 1) + orig_height, orig_width = samples.shape[2:] + scale_factor = max(to_width / orig_width, to_height / orig_height) + to_width = align_to_step(orig_width, scale_factor, dimension_step) + to_height = align_to_step(orig_height, scale_factor, dimension_step) + + return utils.common_upscale(samples, int(to_width), int(to_height), upscale_method, crop).movedim(1, -1) + + +def resize_image_to_megapixels(timage, resize_method, megapixels, dimension_step=1): + mp_value = float(megapixels.split()[0]) + total_pixels = int(mp_value * 1_000_000) + samples = timage.movedim(-1, 1) + orig_height, orig_width = samples.shape[2:] + scale_factor = math.sqrt(total_pixels / (orig_width * orig_height)) + to_width = align_to_step(orig_width, scale_factor, dimension_step) + to_height = align_to_step(orig_height, scale_factor, dimension_step) + + return utils.common_upscale(samples, int(to_width), int(to_height), resize_method, "disabled").movedim(1, -1) + + +def load_image(filename, create_mask, device=None): + tdevice = torch.device(device) if device is not None else model_management.get_torch_device() + path = folder_paths.get_annotated_filepath(filename) + pil_image = pillow(Image.open, path) + + images, masks = [], [] + width, height = None, None + + for frame in ImageSequence.Iterator(pil_image): + pil_frame = pillow(ImageOps.exif_transpose, frame) + + if pil_frame.mode == "I": + pil_frame = pil_frame.point(lambda i: i * (1 / 255)) + + if len(images) == 0: + width, height = pil_frame.size + + if pil_frame.size != (width, height,): + continue + + if create_mask: + alpha = numpy.zeros((height, width,), dtype=numpy.float32) + if 'A' in pil_frame.getbands(): + alpha = numpy.array(pil_frame.getchannel('A'), dtype=numpy.float32) / 255.0 + elif pil_frame.mode == 'P' and 'transparency' in pil_frame.info: + alpha = numpy.array(pil_frame.convert('RGBA').getchannel('A'), dtype=numpy.float32) / 255.0 + tmask = torch.from_numpy(1.0 - alpha).to(tdevice) + masks.append(tmask) + + rgb_frame = pil_frame.convert("RGB") + np_image = numpy.array(rgb_frame, dtype=numpy.float32) / 255.0 + timage = torch.from_numpy(np_image)[None].to(tdevice) + + images.append(timage) + + if pil_image.format == "MPO": + break + + if len(images) == 0: + raise ValueError(f"ERROR: No frames found in {path}") + + o_image = torch.cat(images, dim=0) if len(images) > 1 else images[0] + + match len(masks): + case 0: + o_mask = torch.zeros((1, height, width), dtype=torch.float32) + case 1: + o_mask = masks[0] + case _: + o_mask = torch.cat(masks, dim=0) + + return o_image, o_mask + + +class TT_ImageLoaderResizerNode(IO.ComfyNode): + @classmethod + def get_image_files(cls): + return sorted([ + f.name for f in os.scandir(folder_paths.get_input_directory()) + if f.is_file() and Path(f.name).suffix.lower() in (".png", ".jpg", ".webp", ".bmp") + ]) + + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="TT_ImageLoaderResizerNode", + display_name="TT Image Loader / Resizer", + category=CATEGORY, + description="", + inputs=[ + IO.Boolean.Input("resize_image", default=False, label_on="Scale", label_off="Keep"), + IO.Combo.Input("resize_method", options=RESIZE_METHODS, advanced=True), + IO.Int.Input("dimension_step", default=1, min=1, max=256, advanced=True), + IO.Combo.Input("megapixels", options=MEGAPIXELS, advanced=True), + IO.Boolean.Input("create_mask", default=False, label_on="Mask", label_off="None"), + IO.Combo.Input("image", options=cls.get_image_files(), upload=UploadType.image) + ], + outputs=[ + IO.Image.Output(display_name="IMAGE"), + IO.Mask.Output(display_name="MASK"), + ], + ) + + @classmethod + def fingerprint_inputs(cls, **kwargs): + path = folder_paths.get_annotated_filepath(kwargs.get("image")) + resize = kwargs.get("resize_image") + result = { + "image": path + str(os.path.getmtime(path)), + "resize_image": resize, + "resize_method": kwargs.get("resize_method"), + "dimension_step": kwargs.get("dimension_step"), + "create_mask": kwargs.get("create_mask"), + } + + if resize: + result["megapixels"] = kwargs.get("megapixels") + + return result + + @classmethod + def validate_inputs(cls, **kwargs) -> bool | str: + path = folder_paths.get_annotated_filepath(kwargs.get("image")) + if not os.path.isfile(path): + return f"Image file not found: {path}" + + return True + + @classmethod + def execute(cls, **kwargs) -> IO.NodeOutput: + timage, tmask = load_image(kwargs.get("image"), kwargs.get("create_mask")) + if kwargs.get("resize_image"): + resize_method, megapixels, dimension_step = kwargs.get("resize_method"), kwargs.get("megapixels"), kwargs.get("dimension_step") + timage = resize_image_to_megapixels( + timage, + resize_method, + megapixels, + dimension_step + ) + tmask = resize_image_to_megapixels( + tmask, + resize_method, + megapixels, + dimension_step + ) + + return IO.NodeOutput(timage, tmask, ui=ui.PreviewImage(timage, cls=cls)) + + +# ============================================================================== +# V3 entrypoint — registers context nodes with ComfyUI +# ============================================================================== + +class ImageNodesExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[IO.ComfyNode]]: + return [ + TT_ImageLoaderResizerNode, + ] + + +async def comfy_entrypoint() -> ImageNodesExtension: + return ImageNodesExtension() + + +# ============================================================================== +# Re-exports for backward compatibility +# ============================================================================== + +__all__ = [ + "TT_ImageLoaderResizerNode", +] diff --git a/nodes_latent.py b/nodes_latent.py index 50446c9..ac77127 100644 --- a/nodes_latent.py +++ b/nodes_latent.py @@ -7,10 +7,11 @@ import torch import comfy.sample from comfy_api.latest import IO, ComfyExtension -from .lib.common import CommonTypes CATEGORY = "TenserTensor/Latent" + ASPECT_RATIOS = ["1:1", "4:3", "3:2", "16:9", "21:9"] +MEGAPIXELS = ["0.25 MP", "0.5 MP", "1 MP", "2 MP", "4 MP", "8 MP"] ORIENTATIONS = ["landscape", "portrait"] MODEL_TYPES = ["FLUX1.D", "FLUX2.D", "SDXL"] CLIP_MULTIPLIERS = ["1x", "2x", "4x"] @@ -26,7 +27,6 @@ class RandomNoise: return comfy.sample.prepare_noise(latent_image, self.seed, batch_idx) - class TT_LatentFactoryNode(IO.ComfyNode): @classmethod def define_schema(cls) -> IO.Schema: @@ -39,7 +39,7 @@ class TT_LatentFactoryNode(IO.ComfyNode): IO.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff), IO.Int.Input("noise_seed", default=0, min=0, max=0xffffffffffffffff, control_after_generate=True), IO.Combo.Input("aspect_ratio", options=ASPECT_RATIOS), - IO.Combo.Input("megapixels", options=CommonTypes.MEGAPIXELS), + IO.Combo.Input("megapixels", options=MEGAPIXELS), IO.Combo.Input("orientation", options=ORIENTATIONS), IO.Combo.Input("model_type", options=MODEL_TYPES), IO.Int.Input("batch_size", default=1, min=1, max=64, advanced=True), @@ -133,7 +133,7 @@ class TT_LatentFactoryNode(IO.ComfyNode): # V3 entrypoint — registers context nodes with ComfyUI # ============================================================================== -class ContextExtension(ComfyExtension): +class LatentNodesExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ @@ -141,8 +141,8 @@ class ContextExtension(ComfyExtension): ] -async def comfy_entrypoint() -> ContextExtension: - return ContextExtension() +async def comfy_entrypoint() -> LatentNodesExtension: + return LatentNodesExtension() # ============================================================================== diff --git a/nodes_loaders.py b/nodes_loaders.py index f7d238b..4005a73 100644 --- a/nodes_loaders.py +++ b/nodes_loaders.py @@ -1 +1,29 @@ # (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +from typing import override + +from comfy_api.latest import ComfyExtension, IO + + +# ============================================================================== +# V3 entrypoint — registers context nodes with ComfyUI +# ============================================================================== + +class LoaderNodesExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[IO.ComfyNode]]: + return [ + # TT_Node, + ] + + +async def comfy_entrypoint() -> LoaderNodesExtension: + return LoaderNodesExtension() + + +# ============================================================================== +# Re-exports for backward compatibility +# ============================================================================== + +__all__ = [ + # "TT_Node", +] diff --git a/nodes_postproduction.py b/nodes_postproduction.py index f7d238b..790888a 100644 --- a/nodes_postproduction.py +++ b/nodes_postproduction.py @@ -1 +1,30 @@ # (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) + +from typing import override + +from comfy_api.latest import ComfyExtension, IO + + +# ============================================================================== +# V3 entrypoint — registers context nodes with ComfyUI +# ============================================================================== + +class VaeNodesExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[IO.ComfyNode]]: + return [ + # TT_Node, + ] + + +async def comfy_entrypoint() -> VaeNodesExtension: + return VaeNodesExtension() + + +# ============================================================================== +# Re-exports for backward compatibility +# ============================================================================== + +__all__ = [ + # "TT_Node", +] diff --git a/nodes_sampling.py b/nodes_sampling.py index f7d238b..cf4ae72 100644 --- a/nodes_sampling.py +++ b/nodes_sampling.py @@ -1 +1,30 @@ # (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) + +from typing import override + +from comfy_api.latest import ComfyExtension, IO + + +# ============================================================================== +# V3 entrypoint — registers context nodes with ComfyUI +# ============================================================================== + +class LatentNodesExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[IO.ComfyNode]]: + return [ + # TT_Node, + ] + + +async def comfy_entrypoint() -> LatentNodesExtension: + return LatentNodesExtension() + + +# ============================================================================== +# Re-exports for backward compatibility +# ============================================================================== + +__all__ = [ + # "TT_Node", +] diff --git a/nodes_vae.py b/nodes_vae.py index f7d238b..ab7cf6f 100644 --- a/nodes_vae.py +++ b/nodes_vae.py @@ -1 +1,29 @@ # (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +from typing import override + +from comfy_api.latest import IO, ComfyExtension + + +# ============================================================================== +# V3 entrypoint — registers context nodes with ComfyUI +# ============================================================================== + +class VaeNodesExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[IO.ComfyNode]]: + return [ + # TT_Node, + ] + + +async def comfy_entrypoint() -> VaeNodesExtension: + return VaeNodesExtension() + + +# ============================================================================== +# Re-exports for backward compatibility +# ============================================================================== + +__all__ = [ + # "TT_Node", +] diff --git a/nodes_workflow.py b/nodes_workflow.py index c627a79..67b978e 100644 --- a/nodes_workflow.py +++ b/nodes_workflow.py @@ -220,10 +220,7 @@ class TT_Flux2WorkflowSettingsAdvancedNode(IO.ComfyNode): @classmethod def execute(cls, **kwargs) -> IO.NodeOutput: kwargs["sampler"], kwargs["sigmas"] = build_sampler_sigmas( - kwargs.get("sampler_name"), - kwargs.get("steps"), - kwargs.get("width"), - kwargs.get("height"), + kwargs.get("sampler_name"), kwargs.get("steps"), kwargs.get("width"), kwargs.get("height"), ) workflow_config = TTWorkflowSettings.create(**kwargs) @@ -248,7 +245,7 @@ class TT_Flux2WorkflowSettingsAdvancedNode(IO.ComfyNode): # V3 entrypoint — registers context nodes with ComfyUI # ============================================================================== -class ContextExtension(ComfyExtension): +class WorkflowNodesExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ @@ -257,8 +254,8 @@ class ContextExtension(ComfyExtension): ] -async def comfy_entrypoint() -> ContextExtension: - return ContextExtension() +async def comfy_entrypoint() -> WorkflowNodesExtension: + return WorkflowNodesExtension() # ==============================================================================