From e384cf09afa8d767cbd3c69f291d5f7b8f352a4e Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Mon, 2 Mar 2026 02:30:20 +0300 Subject: [PATCH 01/20] 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() # ============================================================================== From 0b38e3149ff8117c5766658d52f4d7446c530bec Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Mon, 2 Mar 2026 15:47:11 +0300 Subject: [PATCH 02/20] Added nodes TT_ImagePreviewSaveNode, TT_ImagePreviewUpscaleSaveNode, TT_GuiderImageReferenceNode --- node_mappings.py | 6 + nodes/image/guider_image_reference.py | 2 +- nodes/image/image_preview_save.py | 2 +- nodes/image/image_preview_upscale_save.py | 2 +- nodes_image.py | 311 +++++++++++++++++++++- 5 files changed, 311 insertions(+), 12 deletions(-) diff --git a/node_mappings.py b/node_mappings.py index f3e4df8..75190fd 100644 --- a/node_mappings.py +++ b/node_mappings.py @@ -156,6 +156,9 @@ NODE_CLASS_MAPPINGS = { "TT_GuiderImageReference": TT_GuiderImageReference, # Image V3 Nodes "TT_ImageLoaderResizerNode": TT_ImageLoaderResizerNode, + "TT_ImagePreviewSaveNode": TT_ImagePreviewSaveNode, + "TT_ImagePreviewUpscaleSaveNode": TT_ImagePreviewUpscaleSaveNode, + "TT_GuiderImageReferenceNode": TT_GuiderImageReferenceNode, # Latent "TT_LatentFactory": TT_LatentFactory, # Latent V3 Nodes @@ -234,6 +237,9 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TT_GuiderImageReference": "TT Guider Image Reference", # Image V3 Nodes "TT_ImageLoaderResizerNode": "TT Image Loader / Resizer", + "TT_ImagePreviewSaveNode": "TT Image Preview / Save", + "TT_ImagePreviewUpscaleSaveNode": "TT Image Preview / Upscale / Save", + "TT_GuiderImageReferenceNode": "TT Guider Image Reference", # Latent "TT_LatentFactory": "TT Latent Factory (Deprecated)", # Latent V3 Nodes diff --git a/nodes/image/guider_image_reference.py b/nodes/image/guider_image_reference.py index ff5cb5c..cb064e8 100644 --- a/nodes/image/guider_image_reference.py +++ b/nodes/image/guider_image_reference.py @@ -34,7 +34,7 @@ class TT_GuiderImageReference(): RETURN_TYPES = ("VAE", "GUIDER",) RETURN_NAMES = ("VAE", "GUIDER",) FUNCTION = "update_latent" - CATEGORY = "TenserTensor/Image" + CATEGORY = "TenserTensor/Deprecated/Image" def update_latent(self, vae, guider, megapixels, upscale_method, dimension_step, image): timage, _ = load_image(image) diff --git a/nodes/image/image_preview_save.py b/nodes/image/image_preview_save.py index 87102e8..55eadaf 100644 --- a/nodes/image/image_preview_save.py +++ b/nodes/image/image_preview_save.py @@ -26,7 +26,7 @@ class TT_ImagePreviewSave(): RETURN_NAMES = () OUTPUT_NODE = True FUNCTION = "preview_save_image" - CATEGORY = "TenserTensor/Image" + CATEGORY = "TenserTensor/Deprecated/Image" def preview_save_image( self, diff --git a/nodes/image/image_preview_upscale_save.py b/nodes/image/image_preview_upscale_save.py index fd74d7e..519c500 100644 --- a/nodes/image/image_preview_upscale_save.py +++ b/nodes/image/image_preview_upscale_save.py @@ -35,7 +35,7 @@ class TT_ImagePreviewUpscaleSave(): RETURN_NAMES = () OUTPUT_NODE = True FUNCTION = "preview_upscale_save_image" - CATEGORY = "TenserTensor/Image" + CATEGORY = "TenserTensor/Deprecated/Image" def preview_upscale_save_image(self, **kwargs): image = kwargs.get("image") diff --git a/nodes_image.py b/nodes_image.py index ddebbb9..94d466a 100644 --- a/nodes_image.py +++ b/nodes_image.py @@ -1,22 +1,38 @@ # (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +import fnmatch import math import os +from datetime import datetime from pathlib import Path -from typing import override +from typing import override, Any import numpy import torch from PIL import Image, ImageOps, ImageSequence +from spandrel import ModelLoader, ImageModelDescriptor import folder_paths -from comfy import model_management, utils +from comfy import model_management, utils, samplers from comfy_api.latest import ComfyExtension, IO, ui from comfy_api.latest._io import UploadType +from node_helpers import conditioning_set_values from node_helpers import pillow from .nodes_latent import MEGAPIXELS CATEGORY = "TenserTensor/Image" RESIZE_METHODS = ["nearest-exact", "bilinear", "area", "bicubic", "bislerp"] +FILENAME_FORMATS = ["name-###", "date-name-###", "name-datetime"] +FORMAT_EXT = {"PNG": ".png", "JPEG": ".jpg", "WEBP": ".webp", } + + +class SingleCondCFGGuider(samplers.CFGGuider): + + @override + def set_conds(self, conditioning): + self.inner_set_conds({"positive": conditioning}) + + def get_conds(self, key="positive"): + return [[c.get("cross_attn", None), c] for c in self.original_conds[key]] def align_to_step(size, scale_factor, dimension_step): @@ -24,7 +40,7 @@ def align_to_step(size, scale_factor, dimension_step): # crop = "center" / "disabled" -def resize_image(timage, to_width, to_height, upscale_method, dimension_step=1, crop="disabled"): +def resize_image(timage, to_width, to_height, upscale_method, dimension_step=1, crop="disabled") -> torch.Tensor: samples = timage.movedim(-1, 1) orig_height, orig_width = samples.shape[2:] scale_factor = max(to_width / orig_width, to_height / orig_height) @@ -34,7 +50,10 @@ def resize_image(timage, to_width, to_height, upscale_method, dimension_step=1, 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): +def resize_image_to_megapixels(timage, resize_method, megapixels, dimension_step=1) -> torch.Tensor: + if len(timage.shape) == 3: + timage = timage.unsqueeze(-1) + mp_value = float(megapixels.split()[0]) total_pixels = int(mp_value * 1_000_000) samples = timage.movedim(-1, 1) @@ -46,8 +65,12 @@ def resize_image_to_megapixels(timage, resize_method, megapixels, 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() +def get_torch_device(device=None): + return torch.device(device) if device is not None else model_management.get_torch_device() + + +def load_image(filename, create_mask, device=None) -> tuple[torch.Tensor, torch.Tensor]: + tdevice = get_torch_device(device) path = folder_paths.get_annotated_filepath(filename) pil_image = pillow(Image.open, path) @@ -97,7 +120,7 @@ def load_image(filename, create_mask, device=None): case _: o_mask = torch.cat(masks, dim=0) - return o_image, o_mask + return o_image, o_mask, class TT_ImageLoaderResizerNode(IO.ComfyNode): @@ -130,7 +153,7 @@ class TT_ImageLoaderResizerNode(IO.ComfyNode): ) @classmethod - def fingerprint_inputs(cls, **kwargs): + def fingerprint_inputs(cls, **kwargs) -> dict[str, Any]: path = folder_paths.get_annotated_filepath(kwargs.get("image")) resize = kwargs.get("resize_image") result = { @@ -158,7 +181,11 @@ class TT_ImageLoaderResizerNode(IO.ComfyNode): 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") + resize_method, megapixels, dimension_step = ( + kwargs.get("resize_method"), + kwargs.get("megapixels"), + kwargs.get("dimension_step") + ) timage = resize_image_to_megapixels( timage, resize_method, @@ -175,6 +202,266 @@ class TT_ImageLoaderResizerNode(IO.ComfyNode): return IO.NodeOutput(timage, tmask, ui=ui.PreviewImage(timage, cls=cls)) +class InvalidSavePathError(Exception): + pass + + +def build_filename(save_path, filename_mask) -> str: + count = sum(1 for f in os.scandir(save_path) if fnmatch.fnmatch(f.name, filename_mask)) + 1 + + return filename_mask.replace("*", f"{count:05}") + + +def build_save_path(**kwargs) -> tuple[str, str]: + output_path, subfolder, image_format, filename_prefix = ( + kwargs.get("output_path"), + kwargs.get("subfolder"), + kwargs.get("image_format"), + kwargs.get("filename_prefix"), + ) + save_path = os.path.join(output_path, subfolder) + + if os.path.commonpath((output_path, os.path.abspath(save_path))) != output_path: + raise InvalidSavePathError("Saving image outside the output folder is not allowed.") + + os.makedirs(save_path, exist_ok=True) + + ext = FORMAT_EXT[image_format] + filename = "" + match kwargs.get("filename_format"): + case "name-###": + filename = build_filename(save_path, f"{filename_prefix}-*{ext}") + case "date-name-###": + date = datetime.now().strftime("%Y-%m-%d") + filename = build_filename(save_path, f"{date}-{filename_prefix}-*{ext}") + case "name-datetime": + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + filename = f"{filename_prefix}-{timestamp}{ext}" + case _: + filename = f"{filename_prefix}{ext}" + + return save_path, filename + + +def save_image(**kwargs) -> None: + kwargs["output_path"] = folder_paths.get_output_directory() + image = kwargs.get("image") + for frame in image: + np_frame = frame.cpu().numpy() * 255.0 + tframe = Image.fromarray(numpy.clip(np_frame, 0, 255).astype(numpy.uint8)) + save_path, filename = build_save_path(**kwargs) + image_format = kwargs.get("image_format") + params = ( + {"compress_level": kwargs.get("compress_level")} + if kwargs.get("image_format") == "PNG" + else {"quality": kwargs.get("quality")} + ) + tframe.save(os.path.join(save_path, filename), format=image_format, **params) + + +class TT_ImagePreviewSaveNode(IO.ComfyNode): + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="TT_ImagePreviewSaveNode", + display_name="TT Image Preview / Save", + category=CATEGORY, + description="", + is_output_node=True, + inputs=[ + IO.Image.Input("image"), + IO.Boolean.Input("save_image", default=True, label_on="Save image", label_off="Only preview"), + IO.String.Input("filename_prefix", default="TT"), + IO.Combo.Input("filename_format", options=FILENAME_FORMATS, default="name-datetime", advanced=True), + IO.String.Input("subfolder", default="", advanced=True), + IO.Combo.Input("image_format", options=list(FORMAT_EXT.keys()), default="PNG", advanced=True), + IO.Int.Input("image_quality", default=75, min=1, max=100), + IO.Int.Input("compression_level", default=6, min=0, max=9), + ], + outputs=[] + ) + + @classmethod + def execute(cls, **kwargs) -> IO.NodeOutput: + timage = kwargs.get("image") + + if kwargs.get("save_image"): + save_image(**kwargs) + + return IO.NodeOutput(ui=ui.PreviewImage(timage, cls=cls)) + + +DEVICES = ["default", "cpu"] + + +def get_upscale_models() -> list[str]: + return folder_paths.get_filename_list("upscale_models") + + +def load_upscale_model(upscale_model) -> ImageModelDescriptor: + model_path = folder_paths.get_full_path_or_raise("upscale_models", upscale_model) + + try: + sd = utils.load_torch_file(model_path, safe_load=True) + except Exception as e: + raise RuntimeError(f"Failed to load model file {model_path}: {e}") + + sd = utils.state_dict_prefix_replace(sd, {"module.": ""}) + upscale_model = ModelLoader().load_from_state_dict(sd).eval() + + if not isinstance(upscale_model, ImageModelDescriptor): + raise TypeError(f"Expected ImageModelDescriptor, got {type(upscale_model).__name__}") + + return upscale_model + + +BUFFER_FACTOR = 384.0 + + +def upscale(timage, **kwargs): + device_name, upscale_tile, upscale_overlap = ( + kwargs.get("upscaler_device", "default"), + kwargs.get("upscale_tile"), + kwargs.get("upscale_overlap") + ) + device = get_torch_device(device_name) + model = load_upscale_model(kwargs.get("upscale_model")) + i_image = timage.movedim(-1, 1).to(device) + batch, _, height, width = i_image.shape + m_model = model_management.module_size(model.model) + m_upscale_tile = (upscale_tile * upscale_tile * 3) * i_image.element_size() * max(model.scale, 1) * BUFFER_FACTOR + m_total = m_model + m_upscale_tile + i_image.nelement() * i_image.element_size() + model_management.free_memory(m_total, device) + model.model.to(device) + + try: + while True: + try: + steps = batch * utils.get_tiled_scale_steps( + width, height, + tile_x=upscale_tile, + tile_y=upscale_tile, + overlap=upscale_overlap + ) + progress_bar = utils.ProgressBar(steps) + scaled = utils.tiled_scale( + i_image, + model, + tile_x=upscale_tile, + tile_y=upscale_tile, + overlap=upscale_overlap, + upscale_amount=model.scale, + pbar=progress_bar + ) + break + except model_management.OOM_EXCEPTION as e: + progress_bar = None + upscale_tile //= 2 + if upscale_tile < 128: + raise e + finally: + if device.type != "cpu": + model.model.to("cpu") + + return torch.clamp(scaled.movedim(1, -1), min=0.0, max=1.0) + + +class TT_ImagePreviewUpscaleSaveNode(IO.ComfyNode): + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="TT_ImagePreviewUpscaleSaveNode", + display_name="TT Image Preview / Upscale / Save", + category=CATEGORY, + description="", + is_output_node=True, + inputs=[ + IO.Image.Input("image"), + IO.Boolean.Input("save_image", default=True, label_on="Save image", label_off="Only preview"), + IO.Boolean.Input("upscale_image", default=True, label_on="Upscale image", label_off="Keep size"), + IO.String.Input("filename_prefix", default="TT"), + IO.Combo.Input("filename_format", options=FILENAME_FORMATS, default="name-datetime", advanced=True), + IO.String.Input("subfolder", default="", advanced=True), + IO.Combo.Input("image_format", options=list(FORMAT_EXT.keys()), default="PNG", advanced=True), + IO.Int.Input("image_quality", default=75, min=1, max=100, advanced=True), + IO.Int.Input("compression_level", default=6, min=0, max=9, advanced=True), + IO.Combo.Input("upscaler_device", options=DEVICES, advanced=True), + IO.Combo.Input("upscale_model", options=get_upscale_models(), advanced=True), + IO.Int.Input("upscale_tile", default=512, min=128, max=4096, step=64, advanced=True), + IO.Int.Input("upscale_overlap", default=64, min=8, max=256, step=8, advanced=True), + ], + outputs=[] + ) + + @classmethod + def execute(cls, **kwargs) -> IO.NodeOutput: + timage, _ = load_image(kwargs.get("image"), create_mask=False) + + if kwargs.get("upscale_image"): + timage = upscale(timage, **kwargs) + + if kwargs.get("save_image"): + kwargs["image"] = timage + save_image(**kwargs) + + return IO.NodeOutput(ui=ui.PreviewImage(timage, cls=cls)) + + +TILE_SIZE, OVERLAP = 512, 64 + + +class TT_GuiderImageReferenceNode(IO.ComfyNode): + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="TT_GuiderImageReferenceNode", + display_name="TT Guider Image Reference", + category=CATEGORY, + description="", + is_output_node=True, + inputs=[ + IO.Vae.Input("vae"), + IO.Guider.Input("guider"), + IO.Combo.Input("megapixels", options=MEGAPIXELS), + + IO.Combo.Input("image", options=cls.get_image_files(), upload=UploadType.image) + ], + outputs=[ + IO.Vae.Output("VAE"), + IO.Guider.Output("GUIDER"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> IO.NodeOutput: + timage, _ = load_image(kwargs.get("image"), create_mask=False) + vae, guider, resize_method, megapixels, dimension_step = ( + kwargs.get("vae"), + kwargs.get("guider"), + kwargs.get("resize_method"), + kwargs.get("megapixels"), + kwargs.get("dimension_step") + ) + + resized = resize_image_to_megapixels(timage, resize_method, megapixels, dimension_step) + samples = vae.encode_tiled( + resized, + tile_x=TILE_SIZE, + tile_y=TILE_SIZE, + overlap=OVERLAP + ) + + conditioning = guider.get_conds() + + if conditioning is not None: + conditioning = conditioning_set_values(conditioning, {"reference_latents": [samples]}, append=True) + guider.set_conds(conditioning) + else: + raise ValueError("ERROR: Guider has no conditioning") + + return IO.NodeOutput(vae, guider, ui=ui.PreviewImage(timage, cls=cls)) + + # ============================================================================== # V3 entrypoint — registers context nodes with ComfyUI # ============================================================================== @@ -184,6 +471,9 @@ class ImageNodesExtension(ComfyExtension): async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ TT_ImageLoaderResizerNode, + TT_ImagePreviewSaveNode, + TT_ImagePreviewUpscaleSaveNode, + TT_GuiderImageReferenceNode, ] @@ -197,4 +487,7 @@ async def comfy_entrypoint() -> ImageNodesExtension: __all__ = [ "TT_ImageLoaderResizerNode", + "TT_ImagePreviewSaveNode", + "TT_ImagePreviewUpscaleSaveNode", + "TT_GuiderImageReferenceNode", ] From 9f1d5d6df4507ad7d6565307ad457ebb5a32fa80 Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Mon, 2 Mar 2026 16:58:21 +0300 Subject: [PATCH 03/20] Some bugfixes and refactoring image nodes --- nodes_image.py | 61 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/nodes_image.py b/nodes_image.py index 94d466a..34e7309 100644 --- a/nodes_image.py +++ b/nodes_image.py @@ -39,19 +39,39 @@ def align_to_step(size, scale_factor, dimension_step): return round(size * scale_factor / dimension_step) * dimension_step +def get_dims(t): + if t.ndim == 2: return t.shape + if t.ndim == 3: return t.shape[1:] + + return t.shape[1:3] + + # crop = "center" / "disabled" def resize_image(timage, to_width, to_height, upscale_method, dimension_step=1, crop="disabled") -> torch.Tensor: + is_mask = timage.ndim == 3 + if is_mask: + timage = timage.unsqueeze(-1) + 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) + if crop == "disabled": + scale_factor = max(to_width / orig_width, to_height / orig_height) + final_width = align_to_step(orig_width, scale_factor, dimension_step) + final_height = align_to_step(orig_height, scale_factor, dimension_step) + else: + final_width = round(to_width / dimension_step) * dimension_step + final_height = round(to_height / dimension_step) * dimension_step + + final_width, final_height = int(max(final_width, dimension_step)), int(max(final_height, dimension_step)) + resized = utils.common_upscale(samples, final_width, final_height, upscale_method, crop).movedim(1, -1) + + return resized.squeeze(-1) if is_mask else resized -def resize_image_to_megapixels(timage, resize_method, megapixels, dimension_step=1) -> torch.Tensor: - if len(timage.shape) == 3: +def resize_image_to_megapixels(timage, resize_method, megapixels, dimension_step=1, crop="disabled") -> torch.Tensor: + is_mask = timage.ndim == 3 + if is_mask: timage = timage.unsqueeze(-1) mp_value = float(megapixels.split()[0]) @@ -59,10 +79,19 @@ def resize_image_to_megapixels(timage, resize_method, megapixels, dimension_step 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) + if crop == "disabled": + final_width = align_to_step(orig_width, scale_factor, dimension_step) + final_height = align_to_step(orig_height, scale_factor, dimension_step) + else: + to_width, to_height = orig_width * scale_factor, orig_height * scale_factor + final_width = round(to_width / dimension_step) * dimension_step + final_height = round(to_height / dimension_step) * dimension_step + + final_width, final_height = int(max(final_width, dimension_step)), int(max(final_height, dimension_step)) + resized = utils.common_upscale(samples, final_width, final_height, resize_method, crop).movedim(1, -1) + + return resized.squeeze(-1) if is_mask else resized def get_torch_device(device=None): @@ -116,7 +145,7 @@ def load_image(filename, create_mask, device=None) -> tuple[torch.Tensor, torch. case 0: o_mask = torch.zeros((1, height, width), dtype=torch.float32) case 1: - o_mask = masks[0] + o_mask = masks[0].unsqueeze(0) case _: o_mask = torch.cat(masks, dim=0) @@ -252,9 +281,9 @@ def save_image(**kwargs) -> None: save_path, filename = build_save_path(**kwargs) image_format = kwargs.get("image_format") params = ( - {"compress_level": kwargs.get("compress_level")} + {"compress_level": kwargs.get("compression_level")} if kwargs.get("image_format") == "PNG" - else {"quality": kwargs.get("quality")} + else {"quality": kwargs.get("image_quality")} ) tframe.save(os.path.join(save_path, filename), format=image_format, **params) @@ -275,8 +304,8 @@ class TT_ImagePreviewSaveNode(IO.ComfyNode): IO.Combo.Input("filename_format", options=FILENAME_FORMATS, default="name-datetime", advanced=True), IO.String.Input("subfolder", default="", advanced=True), IO.Combo.Input("image_format", options=list(FORMAT_EXT.keys()), default="PNG", advanced=True), - IO.Int.Input("image_quality", default=75, min=1, max=100), - IO.Int.Input("compression_level", default=6, min=0, max=9), + IO.Int.Input("image_quality", default=75, min=1, max=100, advanced=True), + IO.Int.Input("compression_level", default=6, min=0, max=9, advanced=True), ], outputs=[] ) @@ -334,6 +363,7 @@ def upscale(timage, **kwargs): model_management.free_memory(m_total, device) model.model.to(device) + progress_bar = None try: while True: try: @@ -423,7 +453,8 @@ class TT_GuiderImageReferenceNode(IO.ComfyNode): IO.Vae.Input("vae"), IO.Guider.Input("guider"), IO.Combo.Input("megapixels", options=MEGAPIXELS), - + 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("image", options=cls.get_image_files(), upload=UploadType.image) ], outputs=[ From 768f4d84e90f632458015d513ffffcf20a43eba1 Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Mon, 2 Mar 2026 21:06:50 +0300 Subject: [PATCH 04/20] Some bugfixes and refactoring image nodes --- nodes_image.py | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/nodes_image.py b/nodes_image.py index 34e7309..193426d 100644 --- a/nodes_image.py +++ b/nodes_image.py @@ -39,13 +39,6 @@ def align_to_step(size, scale_factor, dimension_step): return round(size * scale_factor / dimension_step) * dimension_step -def get_dims(t): - if t.ndim == 2: return t.shape - if t.ndim == 3: return t.shape[1:] - - return t.shape[1:3] - - # crop = "center" / "disabled" def resize_image(timage, to_width, to_height, upscale_method, dimension_step=1, crop="disabled") -> torch.Tensor: is_mask = timage.ndim == 3 @@ -95,7 +88,11 @@ def resize_image_to_megapixels(timage, resize_method, megapixels, dimension_step def get_torch_device(device=None): - return torch.device(device) if device is not None else model_management.get_torch_device() + return ( + model_management.get_torch_device() + if device is None or device == "default" + else torch.device(device) + ) def load_image(filename, create_mask, device=None) -> tuple[torch.Tensor, torch.Tensor]: @@ -152,14 +149,14 @@ def load_image(filename, create_mask, device=None) -> tuple[torch.Tensor, torch. 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") - ]) +def get_image_files(): + 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") + ]) + +class TT_ImageLoaderResizerNode(IO.ComfyNode): @classmethod def define_schema(cls) -> IO.Schema: return IO.Schema( @@ -172,8 +169,8 @@ class TT_ImageLoaderResizerNode(IO.ComfyNode): 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) + IO.Boolean.Input("create_mask", default=False, label_on="Mask", label_off="Empty"), + IO.Combo.Input("image", options=get_image_files(), upload=UploadType.image) ], outputs=[ IO.Image.Output(display_name="IMAGE"), @@ -349,7 +346,7 @@ BUFFER_FACTOR = 384.0 def upscale(timage, **kwargs): device_name, upscale_tile, upscale_overlap = ( - kwargs.get("upscaler_device", "default"), + kwargs.get("upscaler_device", None), kwargs.get("upscale_tile"), kwargs.get("upscale_overlap") ) @@ -425,7 +422,7 @@ class TT_ImagePreviewUpscaleSaveNode(IO.ComfyNode): @classmethod def execute(cls, **kwargs) -> IO.NodeOutput: - timage, _ = load_image(kwargs.get("image"), create_mask=False) + timage = kwargs.get("image") if kwargs.get("upscale_image"): timage = upscale(timage, **kwargs) @@ -455,7 +452,7 @@ class TT_GuiderImageReferenceNode(IO.ComfyNode): IO.Combo.Input("megapixels", options=MEGAPIXELS), 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("image", options=cls.get_image_files(), upload=UploadType.image) + IO.Combo.Input("image", options=get_image_files(), upload=UploadType.image) ], outputs=[ IO.Vae.Output("VAE"), From bb682413f14d2987dbcf94702581a64492d54267 Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Tue, 3 Mar 2026 14:32:36 +0300 Subject: [PATCH 05/20] Added v3 nodes - TT_GuidedKSamplerNode, TT_GuidedUpscaleKSamplerNode; minor bugfixes; changed copyright notes; minor code refactoring & reformat --- README.md | 2 +- nodes/sampling/ksampler_helpers.py | 6 -- nodes_context.py | 2 +- nodes_image.py | 4 +- nodes_latent.py | 8 +- nodes_loaders.py | 3 +- nodes_postproduction.py | 2 +- nodes_sampling.py | 132 ++++++++++++++++++++++++++++- nodes_vae.py | 3 +- nodes_workflow.py | 2 +- 10 files changed, 144 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index db11e29..d03fcb4 100644 --- a/README.md +++ b/README.md @@ -81,4 +81,4 @@ Great thanks and appreciation for your excellent work! --- -(c) 2026 TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +(c) 2026 TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) diff --git a/nodes/sampling/ksampler_helpers.py b/nodes/sampling/ksampler_helpers.py index af72207..86cfec8 100644 --- a/nodes/sampling/ksampler_helpers.py +++ b/nodes/sampling/ksampler_helpers.py @@ -85,10 +85,4 @@ def guided_sample_latents(latent, guider, sigmas, sampler, random_noise): retval.pop("downscale_ratio_spacial", None) retval["samples"] = samples -# if "x0" in interim_buffer: -# clean_latent = guider.model_patcher.model.process_latent_out(interim_buffer["x0"].cpu()) -# if samples.is_nested: -# clean_latent = NT.NestedTensor(U.unpack_latents(clean_latent, [t.shape for t in samples.unbind()])) -# retval["samples"] = clean_latent - return retval diff --git a/nodes_context.py b/nodes_context.py index d299555..5b515e1 100644 --- a/nodes_context.py +++ b/nodes_context.py @@ -1,4 +1,4 @@ -# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) import dataclasses import re diff --git a/nodes_image.py b/nodes_image.py index 193426d..7929c36 100644 --- a/nodes_image.py +++ b/nodes_image.py @@ -1,4 +1,5 @@ -# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) + import fnmatch import math import os @@ -445,7 +446,6 @@ class TT_GuiderImageReferenceNode(IO.ComfyNode): display_name="TT Guider Image Reference", category=CATEGORY, description="", - is_output_node=True, inputs=[ IO.Vae.Input("vae"), IO.Guider.Input("guider"), diff --git a/nodes_latent.py b/nodes_latent.py index ac77127..61a68cd 100644 --- a/nodes_latent.py +++ b/nodes_latent.py @@ -1,11 +1,11 @@ -# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) import math from typing import Any, override import torch -import comfy.sample +from comfy import sample from comfy_api.latest import IO, ComfyExtension CATEGORY = "TenserTensor/Latent" @@ -24,7 +24,8 @@ class RandomNoise: def generate_noise(self, input_latent): latent_image = input_latent["samples"] batch_idx = input_latent["batch_index"] if "batch_index" in input_latent else None - return comfy.sample.prepare_noise(latent_image, self.seed, batch_idx) + + return sample.prepare_noise(latent_image, self.seed, batch_idx) class TT_LatentFactoryNode(IO.ComfyNode): @@ -93,6 +94,7 @@ class TT_LatentFactoryNode(IO.ComfyNode): ) width, height = cls.calculate_dimensions(total_pixels, ratio_w, ratio_h) + scale_factor, channels = None, None match kwargs.get("model_type"): case "FLUX1.D": scale_factor, channels = 8, 16 diff --git a/nodes_loaders.py b/nodes_loaders.py index 4005a73..f67e85d 100644 --- a/nodes_loaders.py +++ b/nodes_loaders.py @@ -1,4 +1,5 @@ -# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) + from typing import override from comfy_api.latest import ComfyExtension, IO diff --git a/nodes_postproduction.py b/nodes_postproduction.py index 790888a..9ccee80 100644 --- a/nodes_postproduction.py +++ b/nodes_postproduction.py @@ -1,4 +1,4 @@ -# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) from typing import override diff --git a/nodes_sampling.py b/nodes_sampling.py index cf4ae72..bde06bc 100644 --- a/nodes_sampling.py +++ b/nodes_sampling.py @@ -1,9 +1,133 @@ -# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) from typing import override +import latent_preview +from comfy import sample, utils, model_management from comfy_api.latest import ComfyExtension, IO +CATEGORY = "TenserTensor/Sampling" +SCALE_METHODS = ["nearest-exact", "bilinear", "area", "bicubic", "bislerp"] +MEMORY_POLICIES = ["default", "balanced", "aggressive"] +SCALE_FACTORS = ["0.25x", "0.5x", "1x", "2x", "4x", "8x"] + + +def execute_guided_sampling(**kwargs): + guider, latent, sigmas, random_noise, sampler = ( + kwargs.get("guider"), + kwargs.get("latent"), + kwargs.get("sigmas"), + kwargs.get("random_noise"), + kwargs.get("sampler"), + ) + + model_patcher = guider.model_patcher + latent_dict = latent.copy() + latent_tensor = sample.fix_empty_latent_channels( + model_patcher, + latent_dict["samples"], + latent.get("downscale_ratio_spacial", None) + ) + latent_dict["samples"] = latent_tensor + noise_mask = latent.get("noise_mask", None) + interim_buffer = {} + callback = latent_preview.prepare_callback(model_patcher, sigmas.shape[-1] - 1, interim_buffer) + disable_progress_bar = not utils.PROGRESS_BAR_ENABLED + + samples = guider.sample( + random_noise.generate_noise(latent), + latent_tensor, + sampler, + sigmas, + denoise_mask=noise_mask, + callback=callback, + disable_pbar=disable_progress_bar, + seed=random_noise.seed + ).to(model_management.intermediate_device()) + + sampled_latent = latent_dict.copy() + sampled_latent.pop("downscale_ratio_spacial", None) + sampled_latent["samples"] = samples + + return sampled_latent + + +class TT_GuidedKSamplerNode(IO.ComfyNode): + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="TT_GuidedKSamplerNode", + display_name="TT Guided KSampler", + category=CATEGORY, + description="", + inputs=[ + IO.Latent.Input("latent"), + IO.Guider.Input("guider"), + IO.Sigmas.Input("sigmas"), + IO.Sampler.Input("sampler"), + IO.Noise.Input("random_noise"), + ], + outputs=[ + IO.Latent.Output("SAMPLES") + ] + ) + + @classmethod + def execute(cls, **kwargs) -> IO.NodeOutput: + samples = execute_guided_sampling(**kwargs) + + return IO.NodeOutput(samples) + + +def scale_latent(latent, **kwargs): + samples = latent.copy()["samples"] + orig_height, orig_width = samples.shape[2:] + scale_factor = float(kwargs.get("scale_factor").replace("x", "")) + final_width, final_height = round(orig_width * scale_factor), round(orig_height * scale_factor) + scaled = utils.common_upscale( + samples, + final_width, + final_height, kwargs.get("scale_method"), + "disabled" + ) + + return {"samples": scaled} + + +class TT_GuidedUpscaleKSamplerNode(IO.ComfyNode): + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="TT_GuidedUpscaleKSamplerNode", + display_name="TT Guided Upscale KSampler", + category=CATEGORY, + description="", + inputs=[ + IO.Latent.Input("latent"), + IO.Guider.Input("guider"), + IO.Sigmas.Input("sigmas"), + IO.Sampler.Input("sampler"), + IO.Noise.Input("random_noise"), + IO.Boolean.Input("scale_latent", default=True, label_on="Scale latent", label_off="Keep size"), + IO.Combo.Input("scale_factor", options=SCALE_FACTORS, default="1x", advanced=True), + IO.Combo.Input("scale_method", options=SCALE_METHODS, default="nearest-exact", advanced=True), + ], + outputs=[ + IO.Latent.Output("SAMPLES") + ] + ) + + @classmethod + def execute(cls, **kwargs) -> IO.NodeOutput: + samples = execute_guided_sampling(**kwargs) + + if kwargs.get("scale_latent"): + samples = scale_latent(samples, **kwargs) + + return IO.NodeOutput(samples) + +class TT_LatentMultiTransformNode(IO.ComfyNode): + pass # ============================================================================== # V3 entrypoint — registers context nodes with ComfyUI @@ -13,7 +137,8 @@ class LatentNodesExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ - # TT_Node, + TT_GuidedKSamplerNode, + TT_GuidedUpscaleKSamplerNode, ] @@ -26,5 +151,6 @@ async def comfy_entrypoint() -> LatentNodesExtension: # ============================================================================== __all__ = [ - # "TT_Node", + "TT_GuidedKSamplerNode", + "TT_GuidedUpscaleKSamplerNode", ] diff --git a/nodes_vae.py b/nodes_vae.py index ab7cf6f..6c563da 100644 --- a/nodes_vae.py +++ b/nodes_vae.py @@ -1,4 +1,5 @@ -# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) + from typing import override from comfy_api.latest import IO, ComfyExtension diff --git a/nodes_workflow.py b/nodes_workflow.py index 67b978e..631cdda 100644 --- a/nodes_workflow.py +++ b/nodes_workflow.py @@ -1,4 +1,4 @@ -# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) import dataclasses import math From 92c793de784f2a36f296b16ea7a52a8ba7708858 Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Tue, 3 Mar 2026 16:03:38 +0300 Subject: [PATCH 06/20] Copyright changes, renamed node --- NODES.md | 4 ++++ README.md | 2 +- node_mappings.py | 12 ++++++------ nodes_context.py | 6 +++--- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/NODES.md b/NODES.md index 7ee405c..2613c27 100644 --- a/NODES.md +++ b/NODES.md @@ -254,3 +254,7 @@ context pipeline and need to inject it back in for use by subsequent context-awa Use this node to pass context through your workflow while optionally modifying specific parameters without changing the rest of the context. + +--- + +_(c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0)_ diff --git a/README.md b/README.md index d03fcb4..7dcea6c 100644 --- a/README.md +++ b/README.md @@ -81,4 +81,4 @@ Great thanks and appreciation for your excellent work! --- -(c) 2026 TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +_(c) 2026 TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0)_ diff --git a/node_mappings.py b/node_mappings.py index 75190fd..230f664 100644 --- a/node_mappings.py +++ b/node_mappings.py @@ -1,4 +1,4 @@ -# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) RESET = "\033[0m" BOLD = "\033[1m" @@ -140,7 +140,7 @@ NODE_CLASS_MAPPINGS = { # Context V3 Nodes "TT_BaseContextNode": TT_BaseContextNode, "TT_BaseContextFlux2Node": TT_BaseContextFlux2Node, - "TT_BaseContextPassthroughNode": TT_BaseContextPassthroughNode, + "TT_ContextPassthroughNode": TT_ContextPassthroughNode, "TT_ContextNode": TT_ContextNode, "TT_ContextFlux2Node": TT_ContextFlux2Node, "TT_ContextSetGuiderNode": TT_ContextSetGuiderNode, @@ -221,7 +221,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { # Context V3 Nodes "TT_BaseContextNode": "TT Base Context", "TT_BaseContextFlux2Node": "TT FLUX2 Base Context", - "TT_BaseContextPassthroughNode": "TT Context Passthrough", + "TT_ContextPassthroughNode": "TT Context Passthrough", "TT_ContextNode": "TT Context", "TT_ContextFlux2Node": "TT FLUX2 Context", "TT_ContextSetGuiderNode": "TT Context Set Guider", @@ -232,9 +232,9 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TT_ContextExtractVaeNode": "TT Context Extract VAE Flux2", "TT_ContextExtractImageNode": "TT Context Extract Image Flux2", # Image - "TT_ImagePreviewSave": "TT Image Preview / Save", - "TT_ImagePreviewUpscaleSave": "TT Image Preview / Upscale / Save", - "TT_GuiderImageReference": "TT Guider Image Reference", + "TT_ImagePreviewSave": "TT Image Preview / Save (Deprecated)", + "TT_ImagePreviewUpscaleSave": "TT Image Preview / Upscale / Save (Deprecated)", + "TT_GuiderImageReference": "TT Guider Image Reference (Deprecated)", # Image V3 Nodes "TT_ImageLoaderResizerNode": "TT Image Loader / Resizer", "TT_ImagePreviewSaveNode": "TT Image Preview / Save", diff --git a/nodes_context.py b/nodes_context.py index 5b515e1..a3580b6 100644 --- a/nodes_context.py +++ b/nodes_context.py @@ -235,7 +235,7 @@ class TT_BaseContextFlux2Node(ContextNode): RETURNS = ("context",) -class TT_BaseContextPassthroughNode(ContextNode): +class TT_ContextPassthroughNode(ContextNode): """Passes context through without modification.""" REQUIRED = ("context",) OPTIONAL = () @@ -321,7 +321,7 @@ class ContextNodesExtension(ComfyExtension): return [ TT_BaseContextNode, TT_BaseContextFlux2Node, - TT_BaseContextPassthroughNode, + TT_ContextPassthroughNode, TT_ContextNode, TT_ContextFlux2Node, TT_ContextSetGuiderNode, @@ -345,7 +345,7 @@ async def comfy_entrypoint() -> ContextNodesExtension: __all__ = [ "TT_BaseContextNode", "TT_BaseContextFlux2Node", - "TT_BaseContextPassthroughNode", + "TT_ContextPassthroughNode", "TT_ContextNode", "TT_ContextFlux2Node", "TT_ContextSetGuiderNode", From faefd6a0f367b1070add4c9a43a9adeac83d553a Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Tue, 3 Mar 2026 17:34:43 +0300 Subject: [PATCH 07/20] Improved documentation, fixed nodes counter --- README.md | 23 +++++------- node_mappings.py | 36 +++++------------- ...ghNode.md => TT_ContextPassthroughNode.md} | 2 +- web/docs/TT_GuiderImageReferenceNode.md | 31 ++++++++++++++++ web/docs/TT_ImageLoaderResizerNode.md | 32 ++++++++++++++++ web/docs/TT_ImagePreviewSaveNode.md | 31 ++++++++++++++++ web/docs/TT_ImagePreviewUpscaleSaveNode.md | 37 +++++++++++++++++++ 7 files changed, 151 insertions(+), 41 deletions(-) rename web/docs/{TT_BaseContextPassthroughNode.md => TT_ContextPassthroughNode.md} (92%) create mode 100644 web/docs/TT_GuiderImageReferenceNode.md create mode 100644 web/docs/TT_ImageLoaderResizerNode.md create mode 100644 web/docs/TT_ImagePreviewSaveNode.md create mode 100644 web/docs/TT_ImagePreviewUpscaleSaveNode.md diff --git a/README.md b/README.md index 7dcea6c..0a4f6b5 100644 --- a/README.md +++ b/README.md @@ -19,20 +19,15 @@ combining frequently used node chains into single, convenient loaders. ## Features -- **Workflow settings nodes** - configure entire workflow parameters (basic and advanced variants) -- **All-in-one model loaders** - load checkpoint, LoRAs, CLIP, and VAE in single nodes -- **Latent factory** - generate latents with aspect ratio, megapixel count, orientation, and type selection (Flux/SDXL) -- **Context nodes** - from Basic to Even Larger Context - - Basic: combines latent, model, CLIP, VAE, and workflow config into context - - Others: pass-through nodes that modify specific context values while preserving the rest -- **CLIP text encoders** - context-based and prompt-based encoding with model-specific controls -- **KSamplers** - Different implementations of KSampler for generating images. Accepts all common sampling parameters - and returns a denoised latent ready for VAE decoding. -- **VAE decoders** - basic, context-based, extended, and two-pass (draft + refinement) -- **Post-production tools** - image adjustments (brightness, contrast, saturation), color correction (apply LUT), film - grain, upscaler - - All-in-one post-production node combining all tools - - Simplified version without adjustments +- **Workflow settings** — configure global workflow parameters for FLUX and SDXL +- **Model loaders** — all-in-one loaders for checkpoint, LoRA, CLIP, and VAE +- **Context nodes** — pass pipeline state between nodes as a single context object +- **Latent factory** — create latents with aspect ratio, megapixel, and model type selection +- **CLIP text encoders** — prompt encoding with model-specific controls for FLUX and SDXL +- **Samplers** — KSampler variants including guided sampling with Guider/Sigmas support +- **Image tools** — load, resize, upscale, preview, and save images +- **VAE** — encode and decode with tiled and context-based variants +- **Post-production** — brightness, contrast, saturation, LUT, film grain, and upscaling ## Installation diff --git a/node_mappings.py b/node_mappings.py index 230f664..36e82ee 100644 --- a/node_mappings.py +++ b/node_mappings.py @@ -11,10 +11,10 @@ WHITE = "\033[37m" BRIGHT_GREEN = "\033[92m" BRIGHT_CYAN = "\033[96m" -NODES_COUNT = 0 +import inspect +import sys try: - before = set(dir()) from .nodes_context import * from .nodes.context.base_context import TT_BaseContext from .nodes.context.context import TT_Context @@ -25,41 +25,30 @@ try: from .nodes.context.even_larger_context import TT_EvenLargerContext from .nodes.context.large_context_flux import TT_LargeContextFlux from .nodes.context.large_context_sdxl import TT_LargeContextSdxl - - NODES_COUNT += len(set(dir()) - before) except ImportError: print(f"{YELLOW}TenserTensor: {RED}ERROR: Image nodes unavailable{RESET}") try: - before = set(dir()) 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: print(f"{YELLOW}TenserTensor: {RED}ERROR: Image nodes unavailable{RESET}") try: - before = set(dir()) from .nodes.latent.latent_factory import TT_LatentFactory from .nodes_latent import * - - NODES_COUNT += len(set(dir()) - before) except ImportError: print(f"{YELLOW}TenserTensor: {RED}ERROR: Latent nodes unavailable{RESET}") try: - before = set(dir()) from .nodes.loaders.flux_models_loader import TT_FluxModelsLoader from .nodes.loaders.flux_models_loader_advanced import TT_FluxModelsLoaderAdvanced from .nodes.loaders.gguf_models_loader import TT_Flux2GgufModelsLoader from .nodes.loaders.gguf_models_loader_advanced import TT_Flux2GgufModelsLoaderAdvanced from .nodes.loaders.sdxl_models_loader import TT_SdxlModelsLoader from .nodes.loaders.sdxl_models_loader_advanced import TT_SdxlModelsLoaderAdvanced - - NODES_COUNT += len(set(dir()) - before) except ImportError: print(f"{YELLOW}TenserTensor: {RED}ERROR: Loader nodes unavailable{RESET}") @@ -71,44 +60,33 @@ try: from .nodes.postproduction.postproduction import TT_Postproduction from .nodes.postproduction.postproduction_advanced import TT_PostproductionAdvanced from .nodes.postproduction.quick_image_upscaler import TT_QuickImageUpscaler - - NODES_COUNT += len(set(dir()) - before) except ImportError: print(f"{YELLOW}TenserTensor: {RED}ERROR: Postproduction nodes unavailable{RESET}") try: - before = set(dir()) from .nodes.sampling.ksampler import TT_KSampler from .nodes.sampling.ksampler_advanced import TT_KSamplerAdvanced from .nodes.sampling.ksampler_context import TT_KSamplerContext from .nodes.sampling.ksampler_two_stage import TT_KSamplerTwoStage from .nodes.sampling.ksampler_guided import TT_KSamplerGuided - - NODES_COUNT += len(set(dir()) - before) except ImportError: print("f{YELLOW}TenserTensor: {RED}ERROR: Sampler nodes unavailable{RESET}") try: - before = set(dir()) from .nodes.text_encoders.clip_text_encode_flux import TT_ClipTextEncodeFlux from .nodes.text_encoders.clip_text_encode_flux_context import TT_ClipTextEncodeFluxContext from .nodes.text_encoders.clip_text_encode_sdxl import TT_ClipTextEncodeSdxl from .nodes.text_encoders.clip_text_encode_sdxl_context import TT_ClipTextEncodeSdxlContext from .nodes.text_encoders.clip_text_encode_flux2 import TT_ClipTextEncodeFlux2 from .nodes.text_encoders.clip_text_encode_flux2_context import TT_ClipTextEncodeFlux2Context - - NODES_COUNT += len(set(dir()) - before) except ImportError: print(f"{YELLOW}TenserTensor: {RED}ERROR: Text Encoder nodes unavailable{RESET}") try: - before = set(dir()) from .nodes.vae.vae_decode_context import TT_VaeDecodeContext from .nodes.vae.vae_decode_tiled import TT_VaeDecodeTiled from .nodes.vae.vae_encode_context import TT_VaeEncodeContext from .nodes.vae.vae_encode_tiled import TT_VaeEncodeTiled - - NODES_COUNT += len(set(dir()) - before) except ImportError: print(f"{YELLOW}TenserTensor: {RED}ERROR: VAE nodes unavailable{RESET}") @@ -121,11 +99,17 @@ try: from .nodes.workflow.flux2_workflow_settings import TT_Flux2WorkflowSettings from .nodes.workflow.flux2_workflow_settings_advanced import TT_Flux2WorkflowSettingsAdvanced from .nodes_workflow import * - - NODES_COUNT += len(set(dir()) - before) except ImportError: print(f"{YELLOW}TenserTensor: {RED}ERROR: Workflow nodes unavailable{RESET}") +ADDED = { + name + for name, obj + in inspect.getmembers(sys.modules[__name__], inspect.isclass) + if name.startswith("TT_") +} +NODES_COUNT = len(ADDED) + NODE_CLASS_MAPPINGS = { # Context Deprecated "TT_BaseContext": TT_BaseContext, diff --git a/web/docs/TT_BaseContextPassthroughNode.md b/web/docs/TT_ContextPassthroughNode.md similarity index 92% rename from web/docs/TT_BaseContextPassthroughNode.md rename to web/docs/TT_ContextPassthroughNode.md index a39b10e..897b508 100644 --- a/web/docs/TT_BaseContextPassthroughNode.md +++ b/web/docs/TT_ContextPassthroughNode.md @@ -1,4 +1,4 @@ -## TT Base Context Passthrough *TT_BaseContextPassthroughNode* +## TT Context Passthrough *TT_ContextPassthroughNode* *This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* diff --git a/web/docs/TT_GuiderImageReferenceNode.md b/web/docs/TT_GuiderImageReferenceNode.md new file mode 100644 index 0000000..cc94476 --- /dev/null +++ b/web/docs/TT_GuiderImageReferenceNode.md @@ -0,0 +1,31 @@ +## TT Guider Image Reference *TT_GuiderImageReferenceNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Encodes a reference image into latent space and injects it into a guider's conditioning as reference latents. Useful for image-guided sampling workflows. + +### Inputs + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `vae` | VAE | Yes | VAE model for encoding the reference image | +| `guider` | GUIDER | Yes | Guider to inject reference latents into | +| `megapixels` | COMBO | Yes | Target resolution for resizing before encoding | +| `resize_method` | COMBO | No | Interpolation method for resizing (advanced) | +| `dimension_step` | INT | No | Align output dimensions to this step size, 1-256 (advanced) | +| `image` | COMBO | Yes | Reference image file to load, with upload support | + +### Outputs + +| Parameter | Type | Description | +|---|---|---| +| `VAE` | VAE | Passthrough of the input VAE | +| `GUIDER` | GUIDER | Guider with reference latents injected into conditioning | + +### Usage + +Load a reference image, resize it to the target resolution, encode it with the VAE, and inject the resulting latents into the guider's conditioning. Connect the output `GUIDER` to a sampler to use image-guided sampling. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_ImageLoaderResizerNode.md b/web/docs/TT_ImageLoaderResizerNode.md new file mode 100644 index 0000000..f2f08e0 --- /dev/null +++ b/web/docs/TT_ImageLoaderResizerNode.md @@ -0,0 +1,32 @@ +## TT Image Loader / Resizer *TT_ImageLoaderResizerNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Loads an image from the input directory with optional resizing to a target megapixel count and alpha mask extraction. + +### Inputs + +| Parameter | Type | Required | Description | +|------------------|---------|----------|-----------------------------------------------------------------------| +| `resize_image` | BOOLEAN | Yes | Scale image to target megapixels (Scale) or keep original size (Keep) | +| `resize_method` | COMBO | No | Interpolation method for resizing (advanced) | +| `dimension_step` | INT | No | Align output dimensions to this step size, 1–256 (advanced) | +| `megapixels` | COMBO | No | Target resolution in megapixels (advanced) | +| `create_mask` | BOOLEAN | Yes | Extract alpha channel as mask (Mask) or return empty mask (Empty) | +| `image` | COMBO | Yes | Image file to load, with upload support | + +### Outputs + +| Parameter | Type | Description | +|-----------|-------|----------------------------------------------------| +| `IMAGE` | IMAGE | Loaded (and optionally resized) image tensor | +| `MASK` | MASK | Alpha channel mask, or empty mask if not extracted | + +### Usage + +Load an image from the input directory and optionally resize it to a target megapixel count. When resizing is enabled, both the image and mask are +scaled together. Use `dimension_step` to snap output dimensions to a specific grid — useful for models that require dimensions divisible by 8 or 64. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_ImagePreviewSaveNode.md b/web/docs/TT_ImagePreviewSaveNode.md new file mode 100644 index 0000000..5548634 --- /dev/null +++ b/web/docs/TT_ImagePreviewSaveNode.md @@ -0,0 +1,31 @@ +## TT Image Preview / Save *TT_ImagePreviewSaveNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Previews an image in the node graph and optionally saves it to the output directory. + +### Inputs + +| Parameter | Type | Required | Description | +|---------------------|---------|----------|----------------------------------------------------------------| +| `image` | IMAGE | Yes | Image to preview and optionally save | +| `save_image` | BOOLEAN | Yes | Save image to disk (Save image) or preview only (Only preview) | +| `filename_prefix` | STRING | Yes | Prefix for the output filename (default: TT) | +| `filename_format` | COMBO | No | Filename format pattern (advanced) | +| `subfolder` | STRING | No | Subfolder within the output directory (advanced) | +| `image_format` | COMBO | No | Output file format: PNG, JPEG, WEBP (advanced) | +| `image_quality` | INT | No | JPEG quality, 1-100 (advanced) | +| `compression_level` | INT | No | PNG compression level, 0-9 (advanced) | + +### Outputs + +This node has no outputs. It is a terminal output node. + +### Usage + +Place at the end of a workflow to preview the result and optionally save it to disk. When saving is disabled, the image is still visible in the node +graph as a preview. Use `subfolder` to organize outputs into subdirectories, and `filename_format` to control how files are named. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_ImagePreviewUpscaleSaveNode.md b/web/docs/TT_ImagePreviewUpscaleSaveNode.md new file mode 100644 index 0000000..9a029d6 --- /dev/null +++ b/web/docs/TT_ImagePreviewUpscaleSaveNode.md @@ -0,0 +1,37 @@ +## TT Image Preview / Upscale / Save *TT_ImagePreviewUpscaleSaveNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Previews an image in the node graph with optional upscaling via a dedicated upscale model and optional saving to the output directory. + +### Inputs + +| Parameter | Type | Required | Description | +|---------------------|---------|----------|-------------------------------------------------------------------------------| +| `image` | IMAGE | Yes | Image to preview, upscale, and optionally save | +| `save_image` | BOOLEAN | Yes | Save image to disk (Save image) or preview only (Only preview) | +| `upscale_image` | BOOLEAN | Yes | Upscale image before saving (Upscale image) or keep original size (Keep size) | +| `filename_prefix` | STRING | Yes | Prefix for the output filename (default: TT) | +| `filename_format` | COMBO | No | Filename format pattern (advanced) | +| `subfolder` | STRING | No | Subfolder within the output directory (advanced) | +| `image_format` | COMBO | No | Output file format: PNG, JPEG, WEBP (advanced) | +| `image_quality` | INT | No | JPEG quality, 1-100 (advanced) | +| `compression_level` | INT | No | PNG compression level, 0-9 (advanced) | +| `upscaler_device` | COMBO | No | Device to run the upscale model on (advanced) | +| `upscale_model` | COMBO | No | Upscale model to use (advanced) | +| `upscale_tile` | INT | No | Tile size for tiled upscaling, 128-4096 (advanced) | +| `upscale_overlap` | INT | No | Overlap between tiles in pixels, 8-256 (advanced) | + +### Outputs + +This node has no outputs. It is a terminal output node. + +### Usage + +Place at the end of a workflow to upscale, preview, and optionally save the result. When upscaling is enabled, the image is processed through the +selected upscale model before saving. Tiled upscaling is used to handle large images with limited VRAM — reduce `upscale_tile` if you run into +out-of-memory errors. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) From fe8715ed1bf7cb3dda2464381d735630fb4cc1e1 Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Tue, 3 Mar 2026 18:01:39 +0300 Subject: [PATCH 08/20] Removed NODES.md --- NODES.md | 260 ------------------------------------------------------ README.md | 7 +- 2 files changed, 1 insertion(+), 266 deletions(-) delete mode 100644 NODES.md diff --git a/NODES.md b/NODES.md deleted file mode 100644 index 2613c27..0000000 --- a/NODES.md +++ /dev/null @@ -1,260 +0,0 @@ -## Nodes List - -### Workflow - -#### TT FLUX Workflow Settings - -Node for configuring Flux workflow generation parameters. - -#### TT FLUX Workflow Settings (Advanced) - -Advanced node for configuring Flux workflow generation parameters with separate CLIP-L and T5XXL prompts. - -#### TT Flux2 Workflow Settings - -Use this node as the single source of truth for FLUX2 workflow parameters. The `WORKFLOW_CONFIG` output is -designed for context-aware nodes that consume a TT_WORKFLOW_CONFIG directly. Individual outputs are available -for connecting to standard ComfyUI nodes. The sigma schedule (`SCHEDULER`) is computed automatically from -the seed and resolution via `get_schedule`. - -#### TT Flux2 Workflow Settings (Advanced) - -Advanced version of TT Flux2 Workflow Settings with `prompt` and `lora_triggers` fields included directly in the -node. Both are stored in the `WORKFLOW_CONFIG` output alongside all other sampling parameters, making this node -sufficient as the sole configuration source for full context-driven FLUX2 pipelines. Individual outputs remain -available for connecting to standard ComfyUI nodes. - -#### TT SDXL Workflow Settings - -Node for configuring SDXL workflow generation parameters. - -#### TT SDXL Workflow Settings (Advanced) - -Advanced SDXL workflow configuration with separate CLIP-L/G prompts and aesthetic scores. - -### Latent - -#### TT Latent Factory - -Select aspect ratio, resolution, and orientation to automatically compute pixel dimensions suited for the chosen -model. FLUX1.D uses 16-channel latents, FLUX2.D uses 128-channel, SDXL uses 4-channel. `clip_multiplier` scales the TARGET -dimensions relative to the image dimensions — useful for multi-resolution CLIP conditioning. Connect `RANDOM_NOISE` -directly to a sampler node for reproducible noise tied to `noise_seed`. - -### Loaders - -#### TT SDXL Models Loader - -All-in-one model loader for SDXL workflows with checkpoint merging support. - -#### TT SDXL Models Loader (Advanced) - -Extended all-in-one model loader for SDXL with checkpoint merging and LoRA support. - -#### TT GGUF Models Loader - -All-in-one loader for GGUF-quantized models. Place your `.gguf` diffusion model files in the `diffusion_models` or `unet` directory, CLIP files in `text_encoders` or `clip` directory. VAE can be any standard ComfyUI-compatible VAE file. - -#### TT GGUF Models Loader (Advanced) - -Extended version of GGUF Models Loader. Supports up to 4 LoRAs loaded simultaneously. Enable `apply_sampling` to apply flux sampling shift patch — useful for improving quality at non-standard resolutions. Advanced parameters are hidden by default and can be revealed via the node's show/hide controls. - -#### TT FLUX Models Loader - -All-in-one model loader for Flux workflows. - -#### TT FLUX Models Loader (Advanced) - -Extended all-in-one model loader for Flux with sampling patches, LoRA support, and advanced settings. - -### Text Encoder - -#### TT CLIP Text Encode FLUX - -Flux text encoder with separate CLIP-L and T5-XXL prompt inputs. - -#### TT CLIP Text Encode FLUX (Context) - -Context-based Flux text encoder that extracts prompts from workflow config. - -#### TT CLIP Text Encode SDXL - -SDXL text encoder with separate CLIP-L and CLIP-G prompt inputs and conditioning parameters. - -#### TT CLIP Text Encode SDXL (Context) - -Context-based SDXL text encoder that extracts prompts and parameters from workflow config. - -#### TT Clip Text Encode Flux2 - -Encodes text prompts specifically for FLUX2 models. Enter your main prompt in `prompt` and any LoRA-specific trigger -words separately in `lora_triggers` — the node combines them internally. Adjust `guidance` to control how strongly -the model follows the prompt (3.5 is a good starting point for FLUX2). - -#### TT Clip Text Encode Flux2 (Context) - -Context-aware version of the FLUX2 text encoder. Instead of individual inputs, it reads `model`, `clip`, and -`workflow_config` (containing `prompt`, `lora_triggers`, and `guidance`) from the passed `context`. The resulting -guider is both returned as an output and stored back into the context under the `guider` key. Raises an error if -any of the required fields are missing from the context. - -### Samplers - -#### TT KSampler - -Standard KSampler for generating images. Accepts all common sampling parameters and returns a denoised latent ready for -VAE decoding. - -#### TT KSampler (Advanced) - -Advanced sampler for multi-stage workflows and fine control. Use start_step/last_step for partial denoising, add_noise -to control noise injection, and denoise for strength control. Useful for img2img, refinement passes, and complex -sampling workflows. - -#### TT KSampler (Context) - -Simplified sampler for context-based workflows. Automatically extracts all sampling parameters from the context object: - -- Model, positive/negative conditioning, and latent from context -- Seed, steps, cfg, sampler_name, and scheduler from workflow_config - -Requires context to contain: model, latent, and workflow_config. Raises error if any required component is missing. - -#### TT KSampler (Two Stages) - -Automated two-stage sampling workflow. First performs a draft pass with partial denoising, then refines the result with -a second pass. Each stage can use different samplers, schedulers, and step counts. Useful for quick previews followed by -quality refinement, or combining different sampling strategies. - -#### TT KSampler (Guided) - -Runs guided diffusion sampling on the input latent. Connect a guider from a text encoder node, sigmas from a -scheduler, and a sampler from a workflow settings node. `random_noise` controls stochasticity — use a seeded -noise node for reproducible results. - -### VAE - -#### TT VAE Decode (Context) - -Simplified decoder for context-based workflows. Automatically extracts VAE and latent from context, decodes to image -space, and stores the result back in context. Handles nested tensors and reshapes output to standard format. Raises -error if VAE or latent is missing from context. - -#### TT VAE Decode (Tiled) - -Decode large latent images by processing them in smaller tiles. Useful for generating high-resolution images that would -otherwise exceed VRAM capacity. The overlap parameter helps reduce visible seams between tiles. Enable circular padding -for seamless texture generation. - -#### TT Vae Encode (Context) - -Encodes pixel-space images into latent representations for diffusion processing. Extracts VAE and image from context, -performs encoding, and stores the result back in context. - -Requires context to contain both `vae` and `image` components. Raises error if either is missing. - -#### TT Vae Encode (Tiled) - -Processes large images in smaller tiles to reduce memory requirements during VAE encoding. Useful for encoding -high-resolution images that would otherwise cause out-of-memory errors. - -Tile size should be set based on available VRAM (smaller tiles = less memory). Overlap prevents visible seams at tile -boundaries - higher overlap produces smoother results but increases processing time. Recommended overlap is 10-15% of -tile size. - -### Postproduction - -#### TT Apply LUT - -Apply professional color grading using LUT files. Place your .cube LUT files in the designated LUT directory. Adjust -strength to blend between original and graded image (1.0 = full LUT, 0.0 = original). Choose colorspace based on your -LUT file's intended working space. - -#### TT Add Film Grain - -Add realistic film grain texture to images for a cinematic aesthetic. Adjust scale for grain size, strength for -intensity, and saturation for color. The toe parameter controls the distribution curve of grain in shadows/highlights. -Use different seeds for varied grain patterns. - -#### TT Quick Image Upscaler - -Quick and simple AI upscaling node using tiled processing for memory efficiency. Select an upscale model from your -upscale_models directory. Adjust tile size based on VRAM - smaller tiles use less memory. Increase overlap to reduce -visible seams between tiles. - -#### TT Image Enhancer - -All-in-one image enhancement node for adjusting brightness, contrast, colors, and applying effects. Use default values ( -brightness: 0.0, contrast/gamma/saturation: 1.0, posterize: 8, solarize: 1.0) for no effect. Adjust individual -parameters as needed - effects are applied sequentially. - -#### TT Postproduction - -Complete post-production pipeline in one node. Toggle each effect on/off with boolean switches. Processing order: LUT → -Film Grain → Upscale. Disable any effect by setting its toggle to False. All effects are optional and can be used -independently or combined. - -#### TT Postproduction (Advanced) - -Complete post-production pipeline with full enhancement controls. Processing order: Image Enhancement → LUT → Film -Grain → Upscale. Toggle each effect on/off independently. Image enhancement includes brightness, contrast, gamma, hue, -saturation, sharpness, posterize, and solarize adjustments. All effects are optional. - -### Image - -#### TT Image Preview / Save - -Dual-purpose node that can preview images in the UI and/or save them to disk with extensive formatting options. - -#### TT Image Preview / Upscale / Save - -All-in-one node that can preview, upscale, and save images in a single step. Upscaling is applied before saving — the upscaled result is both displayed in the UI and written to disk. When `upscale_image` is disabled, the original image is used. When `save_image` is disabled, the image is only previewed in the UI without writing to disk. - -### Context - -#### TT Base Context - -Use this node to package your core workflow components into a single context object that can be passed through your -workflow and modified by other context nodes. - -#### TT Context - -Use this node to create a new context from scratch or modify specific parameters in an existing context. All parameters -are optional - provide only what you need to set or change. - -#### TT FLUX Large Context - -Use this node for Flux workflows when you need fine-grained control over CLIP-L and T5-XXL prompts separately, along -with Flux-specific guidance settings. - -#### TT SDXL Large Context - -Use this node for SDXL workflows when you need separate control over CLIP-L and CLIP-G prompts, aesthetic scores, and -target dimensions for conditioning. - -#### TT Even Larger Context - -Use this node when you need comprehensive control over all workflow parameters including ControlNet and masking. -Supports both SDXL (CLIP-L/G) and Flux (T5-XXL) conditioning simultaneously. - -#### TT Context Set Image - -Stores pixel-space image in context for use by downstream nodes. Overwrites existing image if present. - -#### TT Context Set Latent - -Stores latent representation in context for use by downstream nodes. Overwrites existing latent if present. - -#### TT Context Set Guider - -Sets the `guider` field inside a TT_CONTEXT object. Use this node when you have a guider produced outside the -context pipeline and need to inject it back in for use by subsequent context-aware nodes. - -#### TT Context Passthrough - -Use this node to pass context through your workflow while optionally modifying specific parameters without changing the -rest of the context. - ---- - -_(c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0)_ diff --git a/README.md b/README.md index 0a4f6b5..9d57ed5 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,7 @@ *— Alfred Bester, The Demolished Man* -A collection of custom nodes for ComfyUI that streamline common workflows. The main goal is to reduce visual clutter by -combining frequently used node chains into single, convenient loaders. +A collection of custom nodes for ComfyUI that streamline common workflows. The main goal is to reduce visual clutter by combining frequently used node chains into single, convenient loaders. > [!IMPORTANT] > ⚠️ **Note:** The pack is currently undergoing refactoring and migration to ComfyUI API V3. Some nodes are marked as deprecated and will be removed in a future major release. Please check node descriptions for migration guidance. @@ -56,10 +55,6 @@ comfy node install tenser-tensor/ComfyUI-TenserTensor Restart ComfyUI after installation. -## Nodes List - -[Nodes List](NODES.md) - ## Acknowledgments This project uses GGUF file handling code from [City96](https://github.com/city96). From 5e65ba0df5d18fc543be5b852caeabbae41570cd Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Tue, 3 Mar 2026 23:16:10 +0300 Subject: [PATCH 09/20] Added TT_LatentMultiTransformNode w/docs, minor bugfixes --- node_mappings.py | 2 + nodes_latent.py | 93 +++++++++++++++++++++++-- nodes_sampling.py | 25 +------ web/docs/TT_LatentMultiTransformNode.md | 35 ++++++++++ 4 files changed, 129 insertions(+), 26 deletions(-) create mode 100644 web/docs/TT_LatentMultiTransformNode.md diff --git a/node_mappings.py b/node_mappings.py index 36e82ee..01ae330 100644 --- a/node_mappings.py +++ b/node_mappings.py @@ -147,6 +147,7 @@ NODE_CLASS_MAPPINGS = { "TT_LatentFactory": TT_LatentFactory, # Latent V3 Nodes "TT_LatentFactoryNode": TT_LatentFactoryNode, + "TT_LatentMultiTransformNode": TT_LatentMultiTransformNode, # Loaders "TT_SdxlModelsLoader": TT_SdxlModelsLoader, "TT_SdxlModelsLoaderAdvanced": TT_SdxlModelsLoaderAdvanced, @@ -228,6 +229,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TT_LatentFactory": "TT Latent Factory (Deprecated)", # Latent V3 Nodes "TT_LatentFactoryNode": "TT Latent Factory", + "TT_LatentMultiTransformNode": "TT Latent Multi Transform", # Loaders "TT_SdxlModelsLoader": "TT SDXL Models Loader", "TT_SdxlModelsLoaderAdvanced": "TT SDXL Models Loader (Advanced)", diff --git a/nodes_latent.py b/nodes_latent.py index 61a68cd..43c047f 100644 --- a/nodes_latent.py +++ b/nodes_latent.py @@ -5,16 +5,19 @@ from typing import Any, override import torch -from comfy import sample +from comfy import sample, utils from comfy_api.latest import IO, ComfyExtension 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"] +MEGAPIXELS = ["0.25 MP", "0.5 MP", "1 MP", "2 MP", "4 MP", "8 MP"] +MODEL_TYPES = ["FLUX1.D", "FLUX2.D", "SDXL"] +ORIENTATIONS = ["landscape", "portrait"] +ROTATE_ANGLES = ["90°", "180°", "270°"] +SCALE_METHODS = ["nearest-exact", "bilinear", "area", "bicubic", "bislerp"] +SCALE_FACTORS = ["0.25x", "0.5x", "1x", "2x", "4x", "8x"] class RandomNoise: @@ -131,6 +134,86 @@ class TT_LatentFactoryNode(IO.ComfyNode): return IO.NodeOutput(*args.values()) +def set_latent_mask(latent, mask): + samples = latent + samples["noise_mask"] = mask.unsqueeze(0) if mask.ndim == 2 else mask + + return samples + + +def scale_latent(**kwargs): + samples = kwargs.get("latent")["samples"] + orig_height, orig_width = samples.shape[2:] + scale_factor = float(kwargs.get("scale_factor").replace("x", "")) + final_width, final_height = round(orig_width * scale_factor), round(orig_height * scale_factor) + scaled = utils.common_upscale( + samples, + final_width, + final_height, kwargs.get("scale_method"), + "disabled" + ) + + return {"samples": scaled} + + +def rotate_latent(latent, turns): + samples = latent["samples"] + + return {"samples": torch.rot90(samples, k=turns, dims=[3, 2])} + + +def flip_latent(latent, axis): + samples = latent["samples"] + + return {"samples": torch.flip(samples, dims=[2 if axis == "x" else 3])} + + +class TT_LatentMultiTransformNode(IO.ComfyNode): + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="TT_LatentMultiTransformNode", + display_name="TT Latent MultiTransform", + category=CATEGORY, + description="", + inputs=[ + IO.Latent.Input("latent"), + IO.Mask.Input("mask", optional=True), + IO.Boolean.Input("scale_latent", default=True, label_on="Scale", label_off="Skip"), + IO.Combo.Input("scale_factor", options=SCALE_FACTORS, default="1x"), + IO.Combo.Input("scale_method", options=SCALE_METHODS, default="nearest-exact"), + IO.Boolean.Input("rotate_latent", default=True, label_on="Rotate", label_off="Skip"), + IO.Combo.Input("rotate_angle", options=ROTATE_ANGLES), + IO.Boolean.Input("flip_latent", default=True, label_on="Flip", label_off="Skip"), + IO.Combo.Input("flip_direction", options=["horizontal", "vertical"]), + ], + outputs=[ + IO.Latent.Output("LATENT"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> IO.NodeOutput: + latent = kwargs.get("latent").copy() + + mask = kwargs.get("mask") + if mask is not None: + latent = set_latent_mask(latent, mask) + + if kwargs.get("scale_latent"): + latent = scale_latent(**kwargs) + + if kwargs.get("rotate_latent"): + turns = int(kwargs.get("rotate_angle").replace("°", "")) // 90 + latent = rotate_latent(latent, turns) + + if kwargs.get("flip_latent"): + axis = "x" if kwargs.get("flip_direction") == "vertical" else "y" + latent = flip_latent(latent, axis) + + return IO.NodeOutput(latent) + + # ============================================================================== # V3 entrypoint — registers context nodes with ComfyUI # ============================================================================== @@ -140,6 +223,7 @@ class LatentNodesExtension(ComfyExtension): async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ TT_LatentFactoryNode, + TT_LatentMultiTransformNode, ] @@ -153,4 +237,5 @@ async def comfy_entrypoint() -> LatentNodesExtension: __all__ = [ "TT_LatentFactoryNode", + "TT_LatentMultiTransformNode", ] diff --git a/nodes_sampling.py b/nodes_sampling.py index bde06bc..b9f16bd 100644 --- a/nodes_sampling.py +++ b/nodes_sampling.py @@ -5,11 +5,9 @@ from typing import override import latent_preview from comfy import sample, utils, model_management from comfy_api.latest import ComfyExtension, IO +from .nodes_latent import SCALE_FACTORS, SCALE_METHODS, scale_latent CATEGORY = "TenserTensor/Sampling" -SCALE_METHODS = ["nearest-exact", "bilinear", "area", "bicubic", "bislerp"] -MEMORY_POLICIES = ["default", "balanced", "aggressive"] -SCALE_FACTORS = ["0.25x", "0.5x", "1x", "2x", "4x", "8x"] def execute_guided_sampling(**kwargs): @@ -79,21 +77,6 @@ class TT_GuidedKSamplerNode(IO.ComfyNode): return IO.NodeOutput(samples) -def scale_latent(latent, **kwargs): - samples = latent.copy()["samples"] - orig_height, orig_width = samples.shape[2:] - scale_factor = float(kwargs.get("scale_factor").replace("x", "")) - final_width, final_height = round(orig_width * scale_factor), round(orig_height * scale_factor) - scaled = utils.common_upscale( - samples, - final_width, - final_height, kwargs.get("scale_method"), - "disabled" - ) - - return {"samples": scaled} - - class TT_GuidedUpscaleKSamplerNode(IO.ComfyNode): @classmethod def define_schema(cls) -> IO.Schema: @@ -108,7 +91,7 @@ class TT_GuidedUpscaleKSamplerNode(IO.ComfyNode): IO.Sigmas.Input("sigmas"), IO.Sampler.Input("sampler"), IO.Noise.Input("random_noise"), - IO.Boolean.Input("scale_latent", default=True, label_on="Scale latent", label_off="Keep size"), + IO.Boolean.Input("scale_latent", default=False, label_on="Scale", label_off="Skip"), IO.Combo.Input("scale_factor", options=SCALE_FACTORS, default="1x", advanced=True), IO.Combo.Input("scale_method", options=SCALE_METHODS, default="nearest-exact", advanced=True), ], @@ -122,12 +105,10 @@ class TT_GuidedUpscaleKSamplerNode(IO.ComfyNode): samples = execute_guided_sampling(**kwargs) if kwargs.get("scale_latent"): - samples = scale_latent(samples, **kwargs) + samples = scale_latent(**kwargs) return IO.NodeOutput(samples) -class TT_LatentMultiTransformNode(IO.ComfyNode): - pass # ============================================================================== # V3 entrypoint — registers context nodes with ComfyUI diff --git a/web/docs/TT_LatentMultiTransformNode.md b/web/docs/TT_LatentMultiTransformNode.md new file mode 100644 index 0000000..fc99ca8 --- /dev/null +++ b/web/docs/TT_LatentMultiTransformNode.md @@ -0,0 +1,35 @@ +## TT Latent MultiTransform *TT_LatentMultiTransformNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Applies multiple geometric transformations to a latent in a single node: scaling, rotation, and flipping. Each transformation can be enabled or +disabled independently. + +### Inputs + +| Parameter | Type | Required | Description | +|------------------|---------|----------|--------------------------------------------------------| +| `latent` | LATENT | Yes | Input latent to transform | +| `mask` | MASK | No | Optional noise mask to attach to the latent | +| `scale_latent` | BOOLEAN | Yes | Scale latent (Scale) or keep original size (Keep size) | +| `scale_factor` | COMBO | No | Scale multiplier (advanced) | +| `scale_method` | COMBO | No | Interpolation method for scaling (advanced) | +| `rotate_latent` | BOOLEAN | Yes | Rotate latent (Rotate) or keep orientation (Keep) | +| `rotate_angle` | COMBO | Yes | Rotation angle in degrees | +| `flip_latent` | BOOLEAN | Yes | Flip latent (Flip) or keep orientation (Keep) | +| `flip_direction` | COMBO | Yes | Flip axis: horizontal or vertical | + +### Outputs + +| Parameter | Type | Description | +|-----------|--------|--------------------| +| `LATENT` | LATENT | Transformed latent | + +### Usage + +Apply scale, rotation, and flip to a latent before or after sampling. Transformations are applied in order: scale → rotate → flip. Use the boolean +toggles to enable only the transformations you need. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) From f84df53f5baab6aebefa484bebc79813f39f43af Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Wed, 4 Mar 2026 00:09:48 +0300 Subject: [PATCH 10/20] Removed create mask switch --- nodes_image.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/nodes_image.py b/nodes_image.py index 7929c36..b211a32 100644 --- a/nodes_image.py +++ b/nodes_image.py @@ -96,7 +96,7 @@ def get_torch_device(device=None): ) -def load_image(filename, create_mask, device=None) -> tuple[torch.Tensor, torch.Tensor]: +def load_image(filename, device=None) -> tuple[torch.Tensor, torch.Tensor]: tdevice = get_torch_device(device) path = folder_paths.get_annotated_filepath(filename) pil_image = pillow(Image.open, path) @@ -116,14 +116,13 @@ def load_image(filename, create_mask, device=None) -> tuple[torch.Tensor, torch. 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) + 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 @@ -170,7 +169,6 @@ class TT_ImageLoaderResizerNode(IO.ComfyNode): 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="Empty"), IO.Combo.Input("image", options=get_image_files(), upload=UploadType.image) ], outputs=[ @@ -188,7 +186,6 @@ class TT_ImageLoaderResizerNode(IO.ComfyNode): "resize_image": resize, "resize_method": kwargs.get("resize_method"), "dimension_step": kwargs.get("dimension_step"), - "create_mask": kwargs.get("create_mask"), } if resize: @@ -206,7 +203,7 @@ class TT_ImageLoaderResizerNode(IO.ComfyNode): @classmethod def execute(cls, **kwargs) -> IO.NodeOutput: - timage, tmask = load_image(kwargs.get("image"), kwargs.get("create_mask")) + timage, tmask = load_image(kwargs.get("image")) if kwargs.get("resize_image"): resize_method, megapixels, dimension_step = ( kwargs.get("resize_method"), @@ -462,7 +459,7 @@ class TT_GuiderImageReferenceNode(IO.ComfyNode): @classmethod def execute(cls, **kwargs) -> IO.NodeOutput: - timage, _ = load_image(kwargs.get("image"), create_mask=False) + timage, _ = load_image(kwargs.get("image")) vae, guider, resize_method, megapixels, dimension_step = ( kwargs.get("vae"), kwargs.get("guider"), From 45b000223ced73e17e1d29d5d411317cbef0ba93 Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Wed, 4 Mar 2026 02:41:46 +0300 Subject: [PATCH 11/20] added TT_LatentMultiTransformOnPixelSpaceNode w/docs, minor bugfixes --- node_mappings.py | 2 + nodes_image.py | 165 ++++++++---------- nodes_latent.py | 63 ++++++- nodes_vae.py | 29 +++ ...TT_LatentMultiTransformOnPixelSpaceNode.md | 36 ++++ 5 files changed, 206 insertions(+), 89 deletions(-) create mode 100644 web/docs/TT_LatentMultiTransformOnPixelSpaceNode.md diff --git a/node_mappings.py b/node_mappings.py index 01ae330..a7a89b3 100644 --- a/node_mappings.py +++ b/node_mappings.py @@ -148,6 +148,7 @@ NODE_CLASS_MAPPINGS = { # Latent V3 Nodes "TT_LatentFactoryNode": TT_LatentFactoryNode, "TT_LatentMultiTransformNode": TT_LatentMultiTransformNode, + "TT_LatentMultiTransformOnPixelSpaceNode": TT_LatentMultiTransformOnPixelSpaceNode, # Loaders "TT_SdxlModelsLoader": TT_SdxlModelsLoader, "TT_SdxlModelsLoaderAdvanced": TT_SdxlModelsLoaderAdvanced, @@ -230,6 +231,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { # Latent V3 Nodes "TT_LatentFactoryNode": "TT Latent Factory", "TT_LatentMultiTransformNode": "TT Latent Multi Transform", + "TT_LatentMultiTransformOnPixelSpaceNode": "TT Latent MultiTransform On Pixel Space", # Loaders "TT_SdxlModelsLoader": "TT SDXL Models Loader", "TT_SdxlModelsLoaderAdvanced": "TT SDXL Models Loader (Advanced)", diff --git a/nodes_image.py b/nodes_image.py index b211a32..462b9cd 100644 --- a/nodes_image.py +++ b/nodes_image.py @@ -14,16 +14,15 @@ from spandrel import ModelLoader, ImageModelDescriptor import folder_paths from comfy import model_management, utils, samplers -from comfy_api.latest import ComfyExtension, IO, ui -from comfy_api.latest._io import UploadType -from node_helpers import conditioning_set_values -from node_helpers import pillow -from .nodes_latent import MEGAPIXELS +from comfy_api.latest import ComfyExtension, io, ui +from node_helpers import conditioning_set_values, pillow +from .nodes_vae import vae_encode CATEGORY = "TenserTensor/Image" RESIZE_METHODS = ["nearest-exact", "bilinear", "area", "bicubic", "bislerp"] FILENAME_FORMATS = ["name-###", "date-name-###", "name-datetime"] FORMAT_EXT = {"PNG": ".png", "JPEG": ".jpg", "WEBP": ".webp", } +MEGAPIXELS = ["0.25 MP", "0.5 MP", "1 MP", "2 MP", "4 MP", "8 MP"] class SingleCondCFGGuider(samplers.CFGGuider): @@ -88,6 +87,14 @@ def resize_image_to_megapixels(timage, resize_method, megapixels, dimension_step return resized.squeeze(-1) if is_mask else resized +def rotate_image(pixels, turns): + return torch.rot90(pixels, k=turns, dims=[1, 2]) + + +def flip_image(pixels, axis): + return torch.flip(pixels, dims=[2 if axis == "x" else 1]) + + def get_torch_device(device=None): return ( model_management.get_torch_device() @@ -156,40 +163,40 @@ def get_image_files(): ]) -class TT_ImageLoaderResizerNode(IO.ComfyNode): +class TT_ImageLoaderResizerNode(io.ComfyNode): @classmethod - def define_schema(cls) -> IO.Schema: - return IO.Schema( + 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.Combo.Input("image", options=get_image_files(), upload=UploadType.image) + io.Boolean.Input("resize_image", default=False, label_on="Scale", label_off="Skip"), + 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.Combo.Input("image", options=get_image_files(), upload=io.UploadType.image) ], outputs=[ - IO.Image.Output(display_name="IMAGE"), - IO.Mask.Output(display_name="MASK"), + io.Image.Output(display_name="IMAGE"), + io.Mask.Output(display_name="MASK"), ], ) @classmethod def fingerprint_inputs(cls, **kwargs) -> dict[str, Any]: path = folder_paths.get_annotated_filepath(kwargs.get("image")) - resize = kwargs.get("resize_image") + resize_image = 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"), + "resize_image": resize_image, } - if resize: + if resize_image: result["megapixels"] = kwargs.get("megapixels") + result["resize_method"] = kwargs.get("resize_method") + result["dimension_step"] = kwargs.get("dimension_step") return result @@ -202,7 +209,7 @@ class TT_ImageLoaderResizerNode(IO.ComfyNode): return True @classmethod - def execute(cls, **kwargs) -> IO.NodeOutput: + def execute(cls, **kwargs) -> io.NodeOutput: timage, tmask = load_image(kwargs.get("image")) if kwargs.get("resize_image"): resize_method, megapixels, dimension_step = ( @@ -210,20 +217,10 @@ class TT_ImageLoaderResizerNode(IO.ComfyNode): 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 - ) + 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)) + return io.NodeOutput(timage, tmask, ui=ui.PreviewImage(timage, cls=cls)) class InvalidSavePathError(Exception): @@ -283,36 +280,36 @@ def save_image(**kwargs) -> None: tframe.save(os.path.join(save_path, filename), format=image_format, **params) -class TT_ImagePreviewSaveNode(IO.ComfyNode): +class TT_ImagePreviewSaveNode(io.ComfyNode): @classmethod - def define_schema(cls) -> IO.Schema: - return IO.Schema( + def define_schema(cls) -> io.Schema: + return io.Schema( node_id="TT_ImagePreviewSaveNode", display_name="TT Image Preview / Save", category=CATEGORY, description="", is_output_node=True, inputs=[ - IO.Image.Input("image"), - IO.Boolean.Input("save_image", default=True, label_on="Save image", label_off="Only preview"), - IO.String.Input("filename_prefix", default="TT"), - IO.Combo.Input("filename_format", options=FILENAME_FORMATS, default="name-datetime", advanced=True), - IO.String.Input("subfolder", default="", advanced=True), - IO.Combo.Input("image_format", options=list(FORMAT_EXT.keys()), default="PNG", advanced=True), - IO.Int.Input("image_quality", default=75, min=1, max=100, advanced=True), - IO.Int.Input("compression_level", default=6, min=0, max=9, advanced=True), + io.Image.Input("image"), + io.Boolean.Input("save_image", default=True, label_on="Save image", label_off="Only preview"), + io.String.Input("filename_prefix", default="TT"), + io.Combo.Input("filename_format", options=FILENAME_FORMATS, default="name-datetime", advanced=True), + io.String.Input("subfolder", default="", advanced=True), + io.Combo.Input("image_format", options=list(FORMAT_EXT.keys()), default="PNG", advanced=True), + io.Int.Input("image_quality", default=75, min=1, max=100, advanced=True), + io.Int.Input("compression_level", default=6, min=0, max=9, advanced=True), ], outputs=[] ) @classmethod - def execute(cls, **kwargs) -> IO.NodeOutput: + def execute(cls, **kwargs) -> io.NodeOutput: timage = kwargs.get("image") if kwargs.get("save_image"): save_image(**kwargs) - return IO.NodeOutput(ui=ui.PreviewImage(timage, cls=cls)) + return io.NodeOutput(ui=ui.PreviewImage(timage, cls=cls)) DEVICES = ["default", "cpu"] @@ -391,35 +388,35 @@ def upscale(timage, **kwargs): return torch.clamp(scaled.movedim(1, -1), min=0.0, max=1.0) -class TT_ImagePreviewUpscaleSaveNode(IO.ComfyNode): +class TT_ImagePreviewUpscaleSaveNode(io.ComfyNode): @classmethod - def define_schema(cls) -> IO.Schema: - return IO.Schema( + def define_schema(cls) -> io.Schema: + return io.Schema( node_id="TT_ImagePreviewUpscaleSaveNode", display_name="TT Image Preview / Upscale / Save", category=CATEGORY, description="", is_output_node=True, inputs=[ - IO.Image.Input("image"), - IO.Boolean.Input("save_image", default=True, label_on="Save image", label_off="Only preview"), - IO.Boolean.Input("upscale_image", default=True, label_on="Upscale image", label_off="Keep size"), - IO.String.Input("filename_prefix", default="TT"), - IO.Combo.Input("filename_format", options=FILENAME_FORMATS, default="name-datetime", advanced=True), - IO.String.Input("subfolder", default="", advanced=True), - IO.Combo.Input("image_format", options=list(FORMAT_EXT.keys()), default="PNG", advanced=True), - IO.Int.Input("image_quality", default=75, min=1, max=100, advanced=True), - IO.Int.Input("compression_level", default=6, min=0, max=9, advanced=True), - IO.Combo.Input("upscaler_device", options=DEVICES, advanced=True), - IO.Combo.Input("upscale_model", options=get_upscale_models(), advanced=True), - IO.Int.Input("upscale_tile", default=512, min=128, max=4096, step=64, advanced=True), - IO.Int.Input("upscale_overlap", default=64, min=8, max=256, step=8, advanced=True), + io.Image.Input("image"), + io.Boolean.Input("save_image", default=True, label_on="Save image", label_off="Only preview"), + io.Boolean.Input("upscale_image", default=True, label_on="Upscale image", label_off="Keep size"), + io.String.Input("filename_prefix", default="TT"), + io.Combo.Input("filename_format", options=FILENAME_FORMATS, default="name-datetime", advanced=True), + io.String.Input("subfolder", default="", advanced=True), + io.Combo.Input("image_format", options=list(FORMAT_EXT.keys()), default="PNG", advanced=True), + io.Int.Input("image_quality", default=75, min=1, max=100, advanced=True), + io.Int.Input("compression_level", default=6, min=0, max=9, advanced=True), + io.Combo.Input("upscaler_device", options=DEVICES, advanced=True), + io.Combo.Input("upscale_model", options=get_upscale_models(), advanced=True), + io.Int.Input("upscale_tile", default=512, min=128, max=4096, step=64, advanced=True), + io.Int.Input("upscale_overlap", default=64, min=8, max=256, step=8, advanced=True), ], outputs=[] ) @classmethod - def execute(cls, **kwargs) -> IO.NodeOutput: + def execute(cls, **kwargs) -> io.NodeOutput: timage = kwargs.get("image") if kwargs.get("upscale_image"): @@ -429,36 +426,33 @@ class TT_ImagePreviewUpscaleSaveNode(IO.ComfyNode): kwargs["image"] = timage save_image(**kwargs) - return IO.NodeOutput(ui=ui.PreviewImage(timage, cls=cls)) + return io.NodeOutput(ui=ui.PreviewImage(timage, cls=cls)) -TILE_SIZE, OVERLAP = 512, 64 - - -class TT_GuiderImageReferenceNode(IO.ComfyNode): +class TT_GuiderImageReferenceNode(io.ComfyNode): @classmethod - def define_schema(cls) -> IO.Schema: - return IO.Schema( + def define_schema(cls) -> io.Schema: + return io.Schema( node_id="TT_GuiderImageReferenceNode", display_name="TT Guider Image Reference", category=CATEGORY, description="", inputs=[ - IO.Vae.Input("vae"), - IO.Guider.Input("guider"), - IO.Combo.Input("megapixels", options=MEGAPIXELS), - 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("image", options=get_image_files(), upload=UploadType.image) + io.Vae.Input("vae"), + io.Guider.Input("guider"), + io.Combo.Input("megapixels", options=MEGAPIXELS), + 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("image", options=get_image_files(), upload=io.UploadType.image) ], outputs=[ - IO.Vae.Output("VAE"), - IO.Guider.Output("GUIDER"), + io.Vae.Output("VAE"), + io.Guider.Output("GUIDER"), ] ) @classmethod - def execute(cls, **kwargs) -> IO.NodeOutput: + def execute(cls, **kwargs) -> io.NodeOutput: timage, _ = load_image(kwargs.get("image")) vae, guider, resize_method, megapixels, dimension_step = ( kwargs.get("vae"), @@ -469,12 +463,7 @@ class TT_GuiderImageReferenceNode(IO.ComfyNode): ) resized = resize_image_to_megapixels(timage, resize_method, megapixels, dimension_step) - samples = vae.encode_tiled( - resized, - tile_x=TILE_SIZE, - tile_y=TILE_SIZE, - overlap=OVERLAP - ) + samples = vae_encode(resized, vae) conditioning = guider.get_conds() @@ -484,7 +473,7 @@ class TT_GuiderImageReferenceNode(IO.ComfyNode): else: raise ValueError("ERROR: Guider has no conditioning") - return IO.NodeOutput(vae, guider, ui=ui.PreviewImage(timage, cls=cls)) + return io.NodeOutput(vae, guider, ui=ui.PreviewImage(timage, cls=cls)) # ============================================================================== @@ -493,7 +482,7 @@ class TT_GuiderImageReferenceNode(IO.ComfyNode): class ImageNodesExtension(ComfyExtension): @override - async def get_node_list(self) -> list[type[IO.ComfyNode]]: + async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ TT_ImageLoaderResizerNode, TT_ImagePreviewSaveNode, diff --git a/nodes_latent.py b/nodes_latent.py index 43c047f..016e3ce 100644 --- a/nodes_latent.py +++ b/nodes_latent.py @@ -7,12 +7,13 @@ import torch from comfy import sample, utils from comfy_api.latest import IO, ComfyExtension +from .nodes_image import MEGAPIXELS, resize_image, rotate_image, flip_image +from .nodes_vae import vae_decode, vae_encode CATEGORY = "TenserTensor/Latent" ASPECT_RATIOS = ["1:1", "4:3", "3:2", "16:9", "21:9"] CLIP_MULTIPLIERS = ["1x", "2x", "4x"] -MEGAPIXELS = ["0.25 MP", "0.5 MP", "1 MP", "2 MP", "4 MP", "8 MP"] MODEL_TYPES = ["FLUX1.D", "FLUX2.D", "SDXL"] ORIENTATIONS = ["landscape", "portrait"] ROTATE_ANGLES = ["90°", "180°", "270°"] @@ -214,6 +215,64 @@ class TT_LatentMultiTransformNode(IO.ComfyNode): return IO.NodeOutput(latent) +class TT_LatentMultiTransformOnPixelSpaceNode(IO.ComfyNode): + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="TT_LatentMultiTransformOnPixelSpaceNode", + display_name="TT Latent MultiTransform On Pixel Space", + category=CATEGORY, + description="", + inputs=[ + IO.Vae.Input("vae"), + IO.Latent.Input("latent"), + IO.Mask.Input("mask", optional=True), + IO.Boolean.Input("scale_latent", default=True, label_on="Scale", label_off="Skip"), + IO.Combo.Input("scale_factor", options=SCALE_FACTORS, default="1x"), + IO.Combo.Input("scale_method", options=SCALE_METHODS, default="nearest-exact"), + IO.Boolean.Input("rotate_latent", default=True, label_on="Rotate", label_off="Skip"), + IO.Combo.Input("rotate_angle", options=ROTATE_ANGLES), + IO.Boolean.Input("flip_latent", default=True, label_on="Flip", label_off="Skip"), + IO.Combo.Input("flip_direction", options=["horizontal", "vertical"]), + ], + outputs=[ + IO.Latent.Output("LATENT"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> IO.NodeOutput: + latent = kwargs.get("latent").copy() + vae = kwargs.get("vae") + pixels = vae_decode(latent, vae) + orig_height, orig_width = pixels.shape[1:3] + + if kwargs.get("scale_latent"): + scale_factor = float(kwargs.get("scale_factor").replace("x", "")) + pixels = resize_image( + pixels, + int(orig_width * scale_factor), + int(orig_height * scale_factor), + kwargs.get("scale_method"), + ) + + if kwargs.get("rotate_latent"): + turns = int(kwargs.get("rotate_angle").replace("°", "")) // 90 + pixels = rotate_image(pixels, turns) + + if kwargs.get("flip_latent"): + axis = "x" if kwargs.get("flip_direction") == "vertical" else "y" + pixels = flip_image(pixels, axis) + + latent = vae_encode(pixels, vae) + + mask = kwargs.get("mask") + if mask is not None: + latent = set_latent_mask(latent, mask) + + return IO.NodeOutput(latent) + + # ============================================================================== # V3 entrypoint — registers context nodes with ComfyUI # ============================================================================== @@ -224,6 +283,7 @@ class LatentNodesExtension(ComfyExtension): return [ TT_LatentFactoryNode, TT_LatentMultiTransformNode, + TT_LatentMultiTransformOnPixelSpaceNode, ] @@ -238,4 +298,5 @@ async def comfy_entrypoint() -> LatentNodesExtension: __all__ = [ "TT_LatentFactoryNode", "TT_LatentMultiTransformNode", + "TT_LatentMultiTransformOnPixelSpaceNode", ] diff --git a/nodes_vae.py b/nodes_vae.py index 6c563da..9f46d5b 100644 --- a/nodes_vae.py +++ b/nodes_vae.py @@ -2,8 +2,37 @@ from typing import override +import torch + from comfy_api.latest import IO, ComfyExtension +TILE_SIZE, OVERLAP = 512, 64 + + +def vae_encode(image, vae, tile_width=TILE_SIZE, tile_height=TILE_SIZE, overlap=OVERLAP): + samples = vae.encode_tiled( + image, + tile_x=tile_width, + tile_y=tile_height, + overlap=overlap + ) + + return {"samples": samples} + + +def vae_decode(latent, vae, tile_width=TILE_SIZE, tile_height=TILE_SIZE, overlap=OVERLAP): + samples = latent["samples"] + + compression = vae.spacial_compression_decode() + images = vae.decode_tiled( + samples, + tile_x=tile_width // compression, + tile_y=tile_height // compression, + overlap=overlap // compression + ) + + return images + # ============================================================================== # V3 entrypoint — registers context nodes with ComfyUI diff --git a/web/docs/TT_LatentMultiTransformOnPixelSpaceNode.md b/web/docs/TT_LatentMultiTransformOnPixelSpaceNode.md new file mode 100644 index 0000000..3be2c29 --- /dev/null +++ b/web/docs/TT_LatentMultiTransformOnPixelSpaceNode.md @@ -0,0 +1,36 @@ +## TT Latent MultiTransform On Pixel Space *TT_LatentMultiTransformOnPixelSpaceNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Applies multiple geometric transformations to a latent by first decoding it to pixel space, transforming, and re-encoding. Produces better quality +results than direct latent manipulation. + +### Inputs + +| Parameter | Type | Required | Description | +|------------------|---------|----------|----------------------------------------------------| +| `vae` | VAE | Yes | VAE model for decoding and re-encoding the latent | +| `latent` | LATENT | Yes | Input latent to transform | +| `mask` | MASK | No | Optional noise mask to attach to the output latent | +| `scale_latent` | BOOLEAN | Yes | Scale image (Scale) or skip (Skip) | +| `scale_factor` | COMBO | Yes | Scale multiplier | +| `scale_method` | COMBO | Yes | Interpolation method for scaling | +| `rotate_latent` | BOOLEAN | Yes | Rotate image (Rotate) or skip (Skip) | +| `rotate_angle` | COMBO | Yes | Rotation angle in degrees, clockwise | +| `flip_latent` | BOOLEAN | Yes | Flip image (Flip) or skip (Skip) | +| `flip_direction` | COMBO | Yes | Flip axis: horizontal or vertical | + +### Outputs + +| Parameter | Type | Description | +|-----------|--------|-----------------------------------| +| `LATENT` | LATENT | Transformed and re-encoded latent | + +### Usage + +Decode the latent to pixel space, apply scale, rotation, and flip transformations, then re-encode back to latent space. Transformations are applied in +order: scale → rotate → flip. Requires a VAE — use the same VAE that was used to encode the original latent. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) From b792dd46fec089974a29e63e503819b159d1d169 Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Wed, 4 Mar 2026 19:12:39 +0300 Subject: [PATCH 12/20] added TT_Flux2ApplyControlNetNode, minor bugfixes --- __init__.py | 1 + node_mappings.py | 11 +++- nodes_controlnet.py | 125 ++++++++++++++++++++++++++++++++++++++++++ nodes_image.py | 8 +++ nodes_sampling.py | 6 +- nodes_text_encoder.py | 42 ++++++++++++++ nodes_workflow.py | 98 ++++++++++++++++----------------- 7 files changed, 238 insertions(+), 53 deletions(-) create mode 100644 nodes_controlnet.py create mode 100644 nodes_text_encoder.py diff --git a/__init__.py b/__init__.py index 866b957..86de57c 100644 --- a/__init__.py +++ b/__init__.py @@ -15,3 +15,4 @@ print(f"{BRIGHT_CYAN}[V] Repository: https://github.com/tenser-tensor/ComfyUI-Te print(f"{BRIGHT_CYAN}[V] ComfyUI Registry: https://registry.comfy.org/publishers/tenser-tensor{RESET}") print(f"{BRIGHT_CYAN}[V] Please check README file: https://github.com/tenser-tensor/ComfyUI-TenserTensor/blob/main/README.md{RESET}") print(SEP) +print() diff --git a/node_mappings.py b/node_mappings.py index a7a89b3..7b28084 100644 --- a/node_mappings.py +++ b/node_mappings.py @@ -26,7 +26,7 @@ try: from .nodes.context.large_context_flux import TT_LargeContextFlux from .nodes.context.large_context_sdxl import TT_LargeContextSdxl except ImportError: - print(f"{YELLOW}TenserTensor: {RED}ERROR: Image nodes unavailable{RESET}") + print(f"{YELLOW}TenserTensor: {RED}ERROR: Context nodes unavailable{RESET}") try: from .nodes.image.image_preview_save import TT_ImagePreviewSave @@ -36,6 +36,11 @@ try: except ImportError: print(f"{YELLOW}TenserTensor: {RED}ERROR: Image nodes unavailable{RESET}") +try: + from .nodes_controlnet import * +except ImportError: + print(f"{YELLOW}TenserTensor: {RED}ERROR: ControlNet nodes unavailable{RESET}") + try: from .nodes.latent.latent_factory import TT_LatentFactory from .nodes_latent import * @@ -134,6 +139,8 @@ NODE_CLASS_MAPPINGS = { "TT_ContextExtractGuidedSamplerFlux2Node": TT_ContextExtractGuidedSamplerFlux2Node, "TT_ContextExtractVaeNode": TT_ContextExtractVaeNode, "TT_ContextExtractImageNode": TT_ContextExtractImageNode, + # ControlNet V3 Nodes + "TT_Flux2ApplyControlNetNode": TT_Flux2ApplyControlNetNode, # Image "TT_ImagePreviewSave": TT_ImagePreviewSave, "TT_ImagePreviewUpscaleSave": TT_ImagePreviewUpscaleSave, @@ -217,6 +224,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TT_ContextExtractGuidedSamplerFlux2Node": "TT Context Extract Guided Sampler Flux2", "TT_ContextExtractVaeNode": "TT Context Extract VAE Flux2", "TT_ContextExtractImageNode": "TT Context Extract Image Flux2", + # ControlNet V3 Nodes + "TT_Flux2ApplyControlNetNode": "TT Flux2 Apply ControlNet", # Image "TT_ImagePreviewSave": "TT Image Preview / Save (Deprecated)", "TT_ImagePreviewUpscaleSave": "TT Image Preview / Upscale / Save (Deprecated)", diff --git a/nodes_controlnet.py b/nodes_controlnet.py new file mode 100644 index 0000000..5e955d4 --- /dev/null +++ b/nodes_controlnet.py @@ -0,0 +1,125 @@ +# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) + +from typing import override + +import folder_paths +from comfy import controlnet +from comfy_api.latest import io, ComfyExtension +from .nodes_image import SingleCondCFGGuider + +CATEGORY = "TenserTensor/ControlNet" + + +def get_control_net_names(): + return folder_paths.get_filename_list("controlnet") + + +def load_control_net(filename): + cnet_path = folder_paths.get_full_path_or_raise("controlnet", filename) + cnet = controlnet.load_controlnet(cnet_path) + if cnet is None: + raise RuntimeError("ERROR: ControlNet file is invalid or corrupted.") + + return cnet + + +def apply_control_net(**kwargs): + guider, image, loaded_cnet = ( + kwargs.get("guider"), + kwargs.get("image"), + kwargs.get("loaded_cnet"), + ) + conditioning = guider.get_conds() + + cnet_cache = {} + cond_hint = image.movedim(-1, 1) + for tensor in conditioning: + cond_copy = tensor[1].copy() + prev_cnet = cond_copy.get("control", None) + if prev_cnet in cnet_cache: + applied_cnet = cnet_cache[prev_cnet] + else: + applied_cnet = loaded_cnet.copy() + applied_cnet.set_cond_hint( + cond_hint, + kwargs.get("strength"), + (kwargs.get("start_percent") / 100, kwargs.get("end_percent") / 100), + vae=kwargs.get("vae_opt"), + extra_concat=kwargs.get("extra_concat", None) + ) + applied_cnet.set_previous_controlnet(prev_cnet) + cnet_cache[prev_cnet] = applied_cnet + + cond_copy["control"] = applied_cnet + cond_copy['control_apply_to_uncond'] = False + updated_cond = [tensor[0], cond_copy] + + guider.set_conds(updated_cond) + + return guider + + +class TT_Flux2ApplyControlNetNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_Flux2ApplyControlNetNode", + display_name="TT FLUX2 Apply ControlNet", + category=CATEGORY, + description="", + inputs=[ + io.Guider.Input("guider"), + io.Image.Input("image"), + io.Vae.Input("vae_opt", optional=True), + io.Combo.Input("control_net", options=get_control_net_names()), + io.Float.Input("strength", default=1.0, min=0.0, max=10.0, step=0.1), + io.Float.Input("start_percent", default=0.0, min=0.0, max=100.0, step=0.1, advanced=True), + io.Float.Input("end_percent", default=100.0, min=0.0, max=100.0, step=0.1, advanced=True), + ], + outputs=[ + io.Guider.Output("GUIDER"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + guider, strength, control_net = ( + kwargs.get("guider"), + kwargs.get("strength"), + kwargs.get("control_net")) + + if not isinstance(guider, SingleCondCFGGuider): + guider = SingleCondCFGGuider.from_cfg_guider(guider) + + if strength == 0.0: + return io.NodeOutput(guider) + + kwargs["loaded_cnet"] = load_control_net(kwargs.get("control_net")) + guider = apply_control_net(**kwargs) + + return io.NodeOutput(guider) + + +# ============================================================================== +# V3 entrypoint — registers context nodes with ComfyUI +# ============================================================================== + +class ControlNetNodesExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [ + TT_Flux2ApplyControlNetNode, + ] + + +async def comfy_entrypoint() -> ControlNetNodesExtension: + return ControlNetNodesExtension() + + +# ============================================================================== +# Re-exports for backward compatibility +# ============================================================================== + +__all__ = [ + "TT_Flux2ApplyControlNetNode", +] diff --git a/nodes_image.py b/nodes_image.py index 462b9cd..1ba055d 100644 --- a/nodes_image.py +++ b/nodes_image.py @@ -26,6 +26,11 @@ MEGAPIXELS = ["0.25 MP", "0.5 MP", "1 MP", "2 MP", "4 MP", "8 MP"] class SingleCondCFGGuider(samplers.CFGGuider): + @classmethod + def from_cfg_guider(cls, guider): + obj = cls.__new__(cls) + obj.__dict__.update(guider.__dict__) + return obj @override def set_conds(self, conditioning): @@ -462,6 +467,9 @@ class TT_GuiderImageReferenceNode(io.ComfyNode): kwargs.get("dimension_step") ) + if not isinstance(guider, SingleCondCFGGuider): + guider = SingleCondCFGGuider.from_cfg_guider(guider) + resized = resize_image_to_megapixels(timage, resize_method, megapixels, dimension_step) samples = vae_encode(resized, vae) diff --git a/nodes_sampling.py b/nodes_sampling.py index b9f16bd..ce23174 100644 --- a/nodes_sampling.py +++ b/nodes_sampling.py @@ -114,7 +114,7 @@ class TT_GuidedUpscaleKSamplerNode(IO.ComfyNode): # V3 entrypoint — registers context nodes with ComfyUI # ============================================================================== -class LatentNodesExtension(ComfyExtension): +class SamplingNodesExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ @@ -123,8 +123,8 @@ class LatentNodesExtension(ComfyExtension): ] -async def comfy_entrypoint() -> LatentNodesExtension: - return LatentNodesExtension() +async def comfy_entrypoint() -> SamplingNodesExtension: + return SamplingNodesExtension() # ============================================================================== diff --git a/nodes_text_encoder.py b/nodes_text_encoder.py new file mode 100644 index 0000000..b726455 --- /dev/null +++ b/nodes_text_encoder.py @@ -0,0 +1,42 @@ +# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) + +from typing import override + +from comfy_api.latest import io, ComfyExtension + +CATEGORY = "TenserTensor/Text Encoder" + + +class TT_Flux2TextEncoderNode(io.ComfyNode): + pass + + +class TT_Flux2TextEncoderContextNode(io.ComfyNode): + pass + + +# ============================================================================== +# V3 entrypoint — registers context nodes with ComfyUI +# ============================================================================== + +class TextEncodeNodesExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [ + TT_Flux2TextEncoderNode, + TT_Flux2TextEncoderContextNode, + ] + + +async def comfy_entrypoint() -> TextEncodeNodesExtension: + return TextEncodeNodesExtension() + + +# ============================================================================== +# Re-exports for backward compatibility +# ============================================================================== + +__all__ = [ + "TT_Flux2TextEncoderNode", + "TT_Flux2TextEncoderContextNode", +] diff --git a/nodes_workflow.py b/nodes_workflow.py index 631cdda..593ab27 100644 --- a/nodes_workflow.py +++ b/nodes_workflow.py @@ -8,7 +8,7 @@ from typing import override import torch from comfy.samplers import KSampler, sampler_object, Sampler -from comfy_api.latest import IO, ComfyExtension +from comfy_api.latest import io, ComfyExtension from nodes import MAX_RESOLUTION @@ -64,15 +64,15 @@ class TTWorkflowSettings(): return val if val is not None else default -@IO.comfytype(io_type="TT_WORKFLOW_CONFIG") +@io.comfytype(io_type="TT_WORKFLOW_CONFIG") class WorkflowSettings: Type = TTWorkflowSettings - class Input(IO.Input): + class Input(io.Input): def __init__(self, id: str, **kwargs): super().__init__(id, **kwargs) - class Output(IO.Output): + class Output(io.Output): def __init__(self, **kwargs): super().__init__(**kwargs) @@ -115,7 +115,7 @@ def build_sampler_sigmas(sampler_name, steps, width, height): return sampler, sigmas -class TT_Flux2WorkflowSettingsNode(IO.ComfyNode): +class TT_Flux2WorkflowSettingsNode(io.ComfyNode): """ Use this node as the single source of truth for FLUX2 workflow parameters. The sampler and sigma schedule are computed automatically from the selected sampler, step count, and resolution. Connect `WORKFLOW_CONFIG` @@ -123,36 +123,36 @@ class TT_Flux2WorkflowSettingsNode(IO.ComfyNode): """ @classmethod - def define_schema(cls) -> IO.Schema: - return IO.Schema( + def define_schema(cls) -> io.Schema: + return io.Schema( node_id="TT_Flux2WorkflowSettingsNode", display_name="TT FLUX2 Workflow Settings", category="TenserTensor/Workflow", description="", inputs=[ - IO.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff), - IO.Int.Input("steps", default=30, min=1, max=10_000), - IO.Float.Input("cfg", default=3.0, min=0.0, max=100.0, step=0.1), - IO.Float.Input("guidance", default=3.0, min=1.0, max=10.0, step=0.1), - IO.Int.Input("width", default=1024, min=16, max=MAX_RESOLUTION, step=8), - IO.Int.Input("height", default=1024, min=16, max=MAX_RESOLUTION, step=8), - IO.Combo.Input("sampler_name", options=KSampler.SAMPLERS) + io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff), + io.Int.Input("steps", default=30, min=1, max=10_000), + io.Float.Input("cfg", default=3.0, min=0.0, max=100.0, step=0.1), + io.Float.Input("guidance", default=3.0, min=1.0, max=10.0, step=0.1), + io.Int.Input("width", default=1024, min=16, max=MAX_RESOLUTION, step=8), + io.Int.Input("height", default=1024, min=16, max=MAX_RESOLUTION, step=8), + io.Combo.Input("sampler_name", options=KSampler.SAMPLERS) ], outputs=[ WorkflowSettings.Output(display_name="WORKFLOW_CONFIG"), - IO.Sampler.Output(display_name="SAMPLER"), - IO.Sigmas.Output(display_name="SIGMAS"), - IO.Int.Output(display_name="SEED"), - IO.Int.Output(display_name="STEPS"), - IO.Float.Output(display_name="CFG"), - IO.Float.Output(display_name="GUIDANCE"), - IO.Int.Output(display_name="WIDTH"), - IO.Int.Output(display_name="HEIGHT"), + io.Sampler.Output(display_name="SAMPLER"), + io.Sigmas.Output(display_name="SIGMAS"), + io.Int.Output(display_name="SEED"), + io.Int.Output(display_name="STEPS"), + io.Float.Output(display_name="CFG"), + io.Float.Output(display_name="GUIDANCE"), + io.Int.Output(display_name="WIDTH"), + io.Int.Output(display_name="HEIGHT"), ] ) @classmethod - def execute(cls, **kwargs) -> IO.NodeOutput: + def execute(cls, **kwargs) -> io.NodeOutput: kwargs["sampler"], kwargs["sigmas"] = build_sampler_sigmas( kwargs.get("sampler_name"), kwargs.get("steps"), @@ -173,10 +173,10 @@ class TT_Flux2WorkflowSettingsNode(IO.ComfyNode): "height": kwargs.get("height"), } - return IO.NodeOutput(*args.values()) + return io.NodeOutput(*args.values()) -class TT_Flux2WorkflowSettingsAdvancedNode(IO.ComfyNode): +class TT_Flux2WorkflowSettingsAdvancedNode(io.ComfyNode): """ Advanced version of TT FLUX2 Workflow Settings with `prompt` and `lora_triggers` fields included directly in the node. Both are stored in the `WORKFLOW_CONFIG` output alongside all other sampling parameters, making this node @@ -185,40 +185,40 @@ class TT_Flux2WorkflowSettingsAdvancedNode(IO.ComfyNode): """ @classmethod - def define_schema(cls) -> IO.Schema: - return IO.Schema( + def define_schema(cls) -> io.Schema: + return io.Schema( node_id="TT_Flux2WorkflowSettingsAdvancedNode", display_name="TT FLUX2 Workflow Settings (Advanced)", category="TenserTensor/Workflow", description="", inputs=[ - IO.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff), - IO.Int.Input("steps", default=30, min=1, max=10_000), - IO.Float.Input("cfg", default=3.0, min=0.0, max=100.0, step=0.1), - IO.Combo.Input("sampler_name", options=KSampler.SAMPLERS), - IO.Int.Input("width", default=1024, min=16, max=MAX_RESOLUTION, step=8), - IO.Int.Input("height", default=1024, min=16, max=MAX_RESOLUTION, step=8), - IO.String.Input("prompt", multiline=True, dynamic_prompts=True), - IO.String.Input("lora_triggers", multiline=True, dynamic_prompts=True), - IO.Float.Input("guidance", default=3.0, min=1.0, max=10.0, step=0.1), + io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff), + io.Int.Input("steps", default=30, min=1, max=10_000), + io.Float.Input("cfg", default=3.0, min=0.0, max=100.0, step=0.1), + io.Combo.Input("sampler_name", options=KSampler.SAMPLERS), + io.Int.Input("width", default=1024, min=16, max=MAX_RESOLUTION, step=8), + io.Int.Input("height", default=1024, min=16, max=MAX_RESOLUTION, step=8), + io.String.Input("prompt", multiline=True, dynamic_prompts=True), + io.String.Input("lora_triggers", multiline=True, dynamic_prompts=True), + io.Float.Input("guidance", default=3.0, min=1.0, max=10.0, step=0.1), ], outputs=[ WorkflowSettings.Output(display_name="WORKFLOW_CONFIG"), - IO.Sampler.Output(display_name="SAMPLER"), - IO.Sigmas.Output(display_name="SIGMAS"), - IO.Int.Output(display_name="SEED"), - IO.Int.Output(display_name="STEPS"), - IO.Float.Output(display_name="CFG"), - IO.Int.Output(display_name="WIDTH"), - IO.Int.Output(display_name="HEIGHT"), - IO.String.Output(display_name="PROMPT"), - IO.String.Output(display_name="LORA_TRIGGERS"), - IO.Float.Output(display_name="GUIDANCE"), + io.Sampler.Output(display_name="SAMPLER"), + io.Sigmas.Output(display_name="SIGMAS"), + io.Int.Output(display_name="SEED"), + io.Int.Output(display_name="STEPS"), + io.Float.Output(display_name="CFG"), + io.Int.Output(display_name="WIDTH"), + io.Int.Output(display_name="HEIGHT"), + io.String.Output(display_name="PROMPT"), + io.String.Output(display_name="LORA_TRIGGERS"), + io.Float.Output(display_name="GUIDANCE"), ] ) @classmethod - def execute(cls, **kwargs) -> IO.NodeOutput: + 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"), ) @@ -238,7 +238,7 @@ class TT_Flux2WorkflowSettingsAdvancedNode(IO.ComfyNode): "guidance": kwargs.get("guidance"), } - return IO.NodeOutput(*args.values()) + return io.NodeOutput(*args.values()) # ============================================================================== @@ -247,7 +247,7 @@ class TT_Flux2WorkflowSettingsAdvancedNode(IO.ComfyNode): class WorkflowNodesExtension(ComfyExtension): @override - async def get_node_list(self) -> list[type[IO.ComfyNode]]: + async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ TT_Flux2WorkflowSettingsNode, TT_Flux2WorkflowSettingsAdvancedNode, From 38b06f2ee809470d593fa14f6da3c2af1a2aac42 Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Wed, 4 Mar 2026 19:14:23 +0300 Subject: [PATCH 13/20] Minot fix --- nodes_postproduction.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nodes_postproduction.py b/nodes_postproduction.py index 9ccee80..78ac677 100644 --- a/nodes_postproduction.py +++ b/nodes_postproduction.py @@ -9,7 +9,7 @@ from comfy_api.latest import ComfyExtension, IO # V3 entrypoint — registers context nodes with ComfyUI # ============================================================================== -class VaeNodesExtension(ComfyExtension): +class PostproductionNodesExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ @@ -17,8 +17,8 @@ class VaeNodesExtension(ComfyExtension): ] -async def comfy_entrypoint() -> VaeNodesExtension: - return VaeNodesExtension() +async def comfy_entrypoint() -> PostproductionNodesExtension: + return PostproductionNodesExtension() # ============================================================================== From 916eb89d7e69675e7fac4e0d9c42a6f076eef562 Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Thu, 5 Mar 2026 02:39:19 +0300 Subject: [PATCH 14/20] Added nodes TT_Flux2TextEncoderNode, TT_Flux2TextEncoderContextNode, TT_CannyEdgeDetectorNode, minor bugfixes, added docs, changed version --- node_mappings.py | 18 ++++ nodes/text_encoders/clip_text_encode_flux2.py | 2 +- .../clip_text_encode_flux2_context.py | 2 +- nodes_context.py | 4 +- nodes_controlnet.py | 78 +++++++++++++++-- nodes_detector.py | 72 ++++++++++++++++ nodes_image.py | 4 +- nodes_text_encoder.py | 84 ++++++++++++++++++- pyproject.toml | 4 +- requirements.txt | 1 + 10 files changed, 252 insertions(+), 17 deletions(-) create mode 100644 nodes_detector.py diff --git a/node_mappings.py b/node_mappings.py index 7b28084..54a49fa 100644 --- a/node_mappings.py +++ b/node_mappings.py @@ -28,6 +28,11 @@ try: except ImportError: print(f"{YELLOW}TenserTensor: {RED}ERROR: Context nodes unavailable{RESET}") +try: + from .nodes_detector import * +except ImportError: + print(f"{YELLOW}TenserTensor: {RED}ERROR: Detector nodes unavailable{RESET}") + try: from .nodes.image.image_preview_save import TT_ImagePreviewSave from .nodes.image.image_preview_upscale_save import TT_ImagePreviewUpscaleSave @@ -84,6 +89,7 @@ try: from .nodes.text_encoders.clip_text_encode_sdxl_context import TT_ClipTextEncodeSdxlContext from .nodes.text_encoders.clip_text_encode_flux2 import TT_ClipTextEncodeFlux2 from .nodes.text_encoders.clip_text_encode_flux2_context import TT_ClipTextEncodeFlux2Context + from .nodes_text_encoder import * except ImportError: print(f"{YELLOW}TenserTensor: {RED}ERROR: Text Encoder nodes unavailable{RESET}") @@ -141,6 +147,9 @@ NODE_CLASS_MAPPINGS = { "TT_ContextExtractImageNode": TT_ContextExtractImageNode, # ControlNet V3 Nodes "TT_Flux2ApplyControlNetNode": TT_Flux2ApplyControlNetNode, + "TT_Flux2ApplyControlNetAdvancedNode": TT_Flux2ApplyControlNetAdvancedNode, + # Detector V3 Nodes + "TT_CannyEdgeDetectorNode": TT_CannyEdgeDetectorNode, # Image "TT_ImagePreviewSave": TT_ImagePreviewSave, "TT_ImagePreviewUpscaleSave": TT_ImagePreviewUpscaleSave, @@ -183,6 +192,9 @@ NODE_CLASS_MAPPINGS = { "TT_ClipTextEncodeSdxlContext": TT_ClipTextEncodeSdxlContext, "TT_ClipTextEncodeFlux2": TT_ClipTextEncodeFlux2, "TT_ClipTextEncodeFlux2Context": TT_ClipTextEncodeFlux2Context, + # Text Encoder V3 Nodes + "TT_Flux2TextEncoderNode": TT_Flux2TextEncoderNode, + "TT_Flux2TextEncoderContextNode": TT_Flux2TextEncoderContextNode, # VAE "TT_VaeDecodeContext": TT_VaeDecodeContext, "TT_VaeDecodeTiled": TT_VaeDecodeTiled, @@ -226,6 +238,9 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TT_ContextExtractImageNode": "TT Context Extract Image Flux2", # ControlNet V3 Nodes "TT_Flux2ApplyControlNetNode": "TT Flux2 Apply ControlNet", + "TT_Flux2ApplyControlNetAdvancedNode": "TT FLUX2 Apply ControlNet (Advanced)", + # Detector V3 Nodes + "TT_CannyEdgeDetectorNode": "TT_Canny Edge Detector", # Image "TT_ImagePreviewSave": "TT Image Preview / Save (Deprecated)", "TT_ImagePreviewUpscaleSave": "TT Image Preview / Upscale / Save (Deprecated)", @@ -268,6 +283,9 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TT_ClipTextEncodeSdxlContext": "TT CLIP Text Encode SDXL (Context)", "TT_ClipTextEncodeFlux2": "TT CLIP Text Encode FLUX2", "TT_ClipTextEncodeFlux2Context": "TT CLIP Text Encode FLUX2 (Context)", + # Text Encoder V3 Nodes + "TT_Flux2TextEncoderNode":"", + "TT_Flux2TextEncoderContextNode":"", # VAE "TT_VaeDecodeContext": "TT VAE Decode (Context)", "TT_VaeDecodeTiled": "TT VAE Decode (Tiled)", diff --git a/nodes/text_encoders/clip_text_encode_flux2.py b/nodes/text_encoders/clip_text_encode_flux2.py index 1d05d31..998ba7c 100644 --- a/nodes/text_encoders/clip_text_encode_flux2.py +++ b/nodes/text_encoders/clip_text_encode_flux2.py @@ -19,7 +19,7 @@ class TT_ClipTextEncodeFlux2(): RETURN_TYPES = ("GUIDER",) RETURN_NAMES = ("GUIDER",) FUNCTION = "execute" - CATEGORY = "TenserTensor/Text Encoders/FLUX2" + CATEGORY = "TenserTensor/Deprecated/Text Encoders/FLUX2" def execute(self, model, clip, prompt, lora_triggers, guidance): return encode_prompts_flux2(model, clip, prompt, lora_triggers, guidance) diff --git a/nodes/text_encoders/clip_text_encode_flux2_context.py b/nodes/text_encoders/clip_text_encode_flux2_context.py index 16d3005..c99bb3b 100644 --- a/nodes/text_encoders/clip_text_encode_flux2_context.py +++ b/nodes/text_encoders/clip_text_encode_flux2_context.py @@ -15,7 +15,7 @@ class TT_ClipTextEncodeFlux2Context(): RETURN_TYPES = ("TT_CONTEXT", "GUIDER",) RETURN_NAMES = ("CONTEXT", "GUIDER",) FUNCTION = "execute" - CATEGORY = "TenserTensor/Text Encoders/FLUX2" + CATEGORY = "TenserTensor/Deprecated/Text Encoders/FLUX2" def execute(self, context): model = context.get("model", None) diff --git a/nodes_context.py b/nodes_context.py index a3580b6..ac6f859 100644 --- a/nodes_context.py +++ b/nodes_context.py @@ -112,8 +112,8 @@ class Context: super().__init__(id, **kwargs) class Output(IO.Output): - def __init__(self, **kwargs): - super().__init__(**kwargs) + def __init__(self, id: str, **kwargs): + super().__init__(id, **kwargs) class ContextNode(IO.ComfyNode): diff --git a/nodes_controlnet.py b/nodes_controlnet.py index 5e955d4..d66d42f 100644 --- a/nodes_controlnet.py +++ b/nodes_controlnet.py @@ -4,13 +4,13 @@ from typing import override import folder_paths from comfy import controlnet -from comfy_api.latest import io, ComfyExtension -from .nodes_image import SingleCondCFGGuider +from comfy_api.latest import io, ui, ComfyExtension +from .nodes_image import SingleCondCFGGuider, get_image_files, load_image CATEGORY = "TenserTensor/ControlNet" -def get_control_net_names(): +def get_control_net_files(): return folder_paths.get_filename_list("controlnet") @@ -26,7 +26,7 @@ def load_control_net(filename): def apply_control_net(**kwargs): guider, image, loaded_cnet = ( kwargs.get("guider"), - kwargs.get("image"), + kwargs.get("cond_hint"), kwargs.get("loaded_cnet"), ) conditioning = guider.get_conds() @@ -69,9 +69,10 @@ class TT_Flux2ApplyControlNetNode(io.ComfyNode): description="", inputs=[ io.Guider.Input("guider"), - io.Image.Input("image"), + io.Image.Input("reference_image"), + io.ControlNet.Input("control_net_opt", optional=True), io.Vae.Input("vae_opt", optional=True), - io.Combo.Input("control_net", options=get_control_net_names()), + io.Combo.Input("control_net", options=get_control_net_files()), io.Float.Input("strength", default=1.0, min=0.0, max=10.0, step=0.1), io.Float.Input("start_percent", default=0.0, min=0.0, max=100.0, step=0.1, advanced=True), io.Float.Input("end_percent", default=100.0, min=0.0, max=100.0, step=0.1, advanced=True), @@ -94,12 +95,73 @@ class TT_Flux2ApplyControlNetNode(io.ComfyNode): if strength == 0.0: return io.NodeOutput(guider) - kwargs["loaded_cnet"] = load_control_net(kwargs.get("control_net")) + loaded_cnet = kwargs.get("control_net_opt") + kwargs["loaded_cnet"] = ( + loaded_cnet + if loaded_cnet is not None + else load_control_net(kwargs.get("control_net")) + ) + kwargs["cond_hint"] = kwargs.get("reference_image") guider = apply_control_net(**kwargs) return io.NodeOutput(guider) +class TT_Flux2ApplyControlNetAdvancedNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_Flux2ApplyControlNetAdvancedNode", + display_name="TT FLUX2 Apply ControlNet (Advanced)", + category=CATEGORY, + description="", + inputs=[ + io.Guider.Input("guider"), + io.ControlNet.Input("control_net_opt", optional=True), + io.Image.Input("reference_image_opt", optional=True), + io.Vae.Input("vae_opt", optional=True), + io.Combo.Input("control_net", options=get_control_net_files()), + io.Float.Input("strength", default=1.0, min=0.0, max=10.0, step=0.1), + io.Float.Input("start_percent", default=0.0, min=0.0, max=100.0, step=0.1, advanced=True), + io.Float.Input("end_percent", default=100.0, min=0.0, max=100.0, step=0.1, advanced=True), + io.Combo.Input("reference_image", options=get_image_files(), upload=io.UploadType.image) + ], + outputs=[ + io.Guider.Output("GUIDER"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + guider, strength, control_net = ( + kwargs.get("guider"), + kwargs.get("strength"), + kwargs.get("control_net")) + + if not isinstance(guider, SingleCondCFGGuider): + guider = SingleCondCFGGuider.from_cfg_guider(guider) + + if strength == 0.0: + return io.NodeOutput(guider) + + reference_image = kwargs.get("reference_image_opt") + kwargs["cond_hint"] = ( + reference_image + if reference_image is not None + else load_image(kwargs.get("reference_image"))[0] + ) + + loaded_cnet = kwargs.get("control_net_opt") + kwargs["loaded_cnet"] = ( + loaded_cnet + if loaded_cnet is not None + else load_control_net(kwargs.get("control_net")) + ) + guider = apply_control_net(**kwargs) + + return io.NodeOutput(guider, ui=ui.PreviewImage(kwargs["cond_hint"], cls=cls)) + + # ============================================================================== # V3 entrypoint — registers context nodes with ComfyUI # ============================================================================== @@ -109,6 +171,7 @@ class ControlNetNodesExtension(ComfyExtension): async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ TT_Flux2ApplyControlNetNode, + TT_Flux2ApplyControlNetAdvancedNode, ] @@ -122,4 +185,5 @@ async def comfy_entrypoint() -> ControlNetNodesExtension: __all__ = [ "TT_Flux2ApplyControlNetNode", + "TT_Flux2ApplyControlNetAdvancedNode", ] diff --git a/nodes_detector.py b/nodes_detector.py new file mode 100644 index 0000000..dbdafe2 --- /dev/null +++ b/nodes_detector.py @@ -0,0 +1,72 @@ +# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) + + +from typing import override + +from kornia.filters import canny + +from comfy import model_management +from comfy_api.latest import io, ui, ComfyExtension +from .nodes_image import get_image_files, load_image + +CATEGORY = "TenserTensor/Detector" + + +def detect_edge(image, low_threshold, high_threshold): + torch_device = model_management.get_torch_device() + intermediate_device = model_management.intermediate_device() + timage = image.to(torch_device).movedim(-1, 1) + o_images = canny(timage, low_threshold, high_threshold) + + return o_images[1].to(intermediate_device).expand(-1, 3, -1, -1).movedim(1, -1) + + +class TT_CannyEdgeDetectorNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_CannyEdgeDetectorNode", + display_name="TT Canny Edge Detector", + category=CATEGORY, + description="", + inputs=[ + io.Float.Input("low_threshold", default=0.4, min=0.01, max=0.99, step=0.01), + io.Float.Input("high_threshold", default=0.8, min=0.01, max=0.99, step=0.01), + io.Combo.Input("reference_image", options=get_image_files(), upload=io.UploadType.image) + ], + outputs=[ + io.Image.Output("IMAGE") + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + timage, _ = load_image(kwargs.get("reference_image")) + cn_image = detect_edge(timage, kwargs.get("low_threshold"), kwargs.get("high_threshold")) + + return io.NodeOutput(cn_image, ui=ui.PreviewImage(timage, cls=cls)) + + +# ============================================================================== +# V3 entrypoint — registers context nodes with ComfyUI +# ============================================================================== + +class DetectorNodesExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [ + TT_CannyEdgeDetectorNode, + ] + + +async def comfy_entrypoint() -> DetectorNodesExtension: + return DetectorNodesExtension() + + +# ============================================================================== +# Re-exports for backward compatibility +# ============================================================================== + +__all__ = [ + "TT_CannyEdgeDetectorNode", +] diff --git a/nodes_image.py b/nodes_image.py index 1ba055d..e95a1aa 100644 --- a/nodes_image.py +++ b/nodes_image.py @@ -471,12 +471,12 @@ class TT_GuiderImageReferenceNode(io.ComfyNode): guider = SingleCondCFGGuider.from_cfg_guider(guider) resized = resize_image_to_megapixels(timage, resize_method, megapixels, dimension_step) - samples = vae_encode(resized, vae) + latent = vae_encode(resized, vae) conditioning = guider.get_conds() if conditioning is not None: - conditioning = conditioning_set_values(conditioning, {"reference_latents": [samples]}, append=True) + conditioning = conditioning_set_values(conditioning, {"reference_latents": [latent["samples"]]}, append=True) guider.set_conds(conditioning) else: raise ValueError("ERROR: Guider has no conditioning") diff --git a/nodes_text_encoder.py b/nodes_text_encoder.py index b726455..2306d43 100644 --- a/nodes_text_encoder.py +++ b/nodes_text_encoder.py @@ -3,16 +3,96 @@ from typing import override from comfy_api.latest import io, ComfyExtension +from .nodes_context import Context +from .nodes_image import SingleCondCFGGuider CATEGORY = "TenserTensor/Text Encoder" +def encode_prompts_flux2(**kwargs): + clip = kwargs.get("clip") + model = kwargs.get("model") + + if model is None: + raise ValueError("ERROR: MODEL is required for text encoder") + + if clip is None: + raise ValueError("ERROR: CLIP is required for text encoder") + + prompt, lora_triggers, guidance = ( + kwargs.get("prompt"), + kwargs.get("lora_triggers"), + kwargs.get("guidance"), + ) + + full_prompt = f"{lora_triggers}, {prompt}" if lora_triggers.strip() else prompt + tokens = clip.tokenize(full_prompt) + conditioning = clip.encode_from_tokens_scheduled(tokens, add_dict={"guidance": guidance, }) + guider = SingleCondCFGGuider(model) + guider.set_conds(conditioning) + + return guider + + class TT_Flux2TextEncoderNode(io.ComfyNode): - pass + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_Flux2TextEncoderNode", + display_name="TT FLUX2 Text Encoder", + category=CATEGORY, + description="", + inputs=[ + io.Model.Input("model"), + io.Clip.Input("clip"), + io.String.Input("prompt", multiline=True, placeholder="Prompt", dynamic_prompts=True), + io.String.Input("lora_triggers", multiline=True, placeholder="LoRA Triggers", dynamic_prompts=True), + io.Float.Input("guidance", default=3.5, min=1.0, max=10.0, step=0.1) + ], + outputs=[ + io.Guider.Output("GUIDER"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + guider = encode_prompts_flux2(**kwargs) + + return io.NodeOutput(guider) class TT_Flux2TextEncoderContextNode(io.ComfyNode): - pass + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_Flux2TextEncoderContextNode", + display_name="TT FLUX2 Text Encoder (Context)", + category=CATEGORY, + description="", + inputs=[ + Context.Input("context") + ], + outputs=[ + Context.Output("CONTEXT"), + io.Guider.Output("GUIDER"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + context = kwargs.get("context") + + args = { + "model": context.get_attr("model"), + "clip": context.get_attr("clip"), + "prompt": context.get_attr("prompt"), + "lora_triggers": context.get_attr("lora_triggers"), + "guidance": context.get_attr("guidance"), + } + guider = encode_prompts_flux2(**args) + context.set_attr("guider", guider) + + return io.NodeOutput(context, guider) # ============================================================================== diff --git a/pyproject.toml b/pyproject.toml index 6956253..323be90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,9 +2,9 @@ [project] name = "ComfyUI-TenserTensor" description = "A collection of custom nodes for ComfyUI that streamline common workflows. Includes all-in-one model loaders (checkpoint/unet, LoRA, CLIP, VAE), GGUF quantized model support (FLUX, FLUX2, SDXL), workflow settings nodes, latent factory (aspect ratio, megapixels, FLUX/SDXL), context nodes, CLIP text encoders, KSamplers (context, advanced, two-stage - draft/refine), VAE encoders/decoders (context, tiled), and post-production tools (brightness, contrast, LUT color grading, film grain, upscaler)." -version = "1.5.11" +version = "1.6.12" license = { file = "LICENSE" } -dependencies = ["gguf"] +dependencies = ["gguf", "kornia"] [project.urls] Repository = "https://github.com/tenser-tensor/ComfyUI-TenserTensor" diff --git a/requirements.txt b/requirements.txt index 0c704fa..172ecd7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ gguf>=0.17.0 +kornia \ No newline at end of file From ef9c458a55c0bc0900a9d5567be13303d845a3ec Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Thu, 5 Mar 2026 02:39:43 +0300 Subject: [PATCH 15/20] Added nodes TT_Flux2TextEncoderNode, TT_Flux2TextEncoderContextNode, TT_CannyEdgeDetectorNode, minor bugfixes, added docs, changed version --- __init__.py | 2 +- web/docs/TT_Flux2TextEncoderContextNode.md | 27 +++++++++++++++++++ web/docs/TT_Flux2TextEncoderNode.md | 31 ++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 web/docs/TT_Flux2TextEncoderContextNode.md create mode 100644 web/docs/TT_Flux2TextEncoderNode.md diff --git a/__init__.py b/__init__.py index 86de57c..0747f8d 100644 --- a/__init__.py +++ b/__init__.py @@ -9,7 +9,7 @@ __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] print() SEP = f"{CYAN}{'=' * 60}{RESET}" print(SEP) -print(f"{YELLOW}TenserTensor ComfyUI Nodes Pack (v1.5.6): {BRIGHT_GREEN}{NODES_COUNT} Nodes Loaded{RESET}") +print(f"{YELLOW}TenserTensor ComfyUI Nodes Pack (v1.6.12): {BRIGHT_GREEN}{NODES_COUNT} Nodes Loaded{RESET}") print(SEP) print(f"{BRIGHT_CYAN}[V] Repository: https://github.com/tenser-tensor/ComfyUI-TenserTensor{RESET}") print(f"{BRIGHT_CYAN}[V] ComfyUI Registry: https://registry.comfy.org/publishers/tenser-tensor{RESET}") diff --git a/web/docs/TT_Flux2TextEncoderContextNode.md b/web/docs/TT_Flux2TextEncoderContextNode.md new file mode 100644 index 0000000..7f96712 --- /dev/null +++ b/web/docs/TT_Flux2TextEncoderContextNode.md @@ -0,0 +1,27 @@ +## TT FLUX2 Text Encoder (Context) *TT_Flux2TextEncoderContextNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Context-based variant of the FLUX2 text encoder. Reads all required parameters from a Context object and writes the resulting Guider back into it. + +### Inputs + +| Parameter | Type | Required | Description | +|-----------|------------|----------|--------------------------------------------------------------------------------| +| `context` | TT_CONTEXT | Yes | Context object carrying model, CLIP, prompt, LoRA triggers, and guidance scale | + +### Outputs + +| Parameter | Type | Description | +|-----------|------------|---------------------------------------------| +| `CONTEXT` | TT_CONTEXT | Updated Context with Guider stored inside | +| `GUIDER` | GUIDER | Configured Guider with encoded conditioning | + +### Usage + +Drop-in replacement for [TT FLUX2 Text Encoder](TT_Flux2TextEncoderNode.md) in Context-based workflows. The Context must have `model`, `clip`, +`prompt`, `lora_triggers`, and `guidance` set before reaching this node. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_Flux2TextEncoderNode.md b/web/docs/TT_Flux2TextEncoderNode.md new file mode 100644 index 0000000..47abe3a --- /dev/null +++ b/web/docs/TT_Flux2TextEncoderNode.md @@ -0,0 +1,31 @@ +## TT FLUX2 Text Encoder *TT_Flux2TextEncoderNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Encodes text prompts for FLUX2 models and returns a ready-to-use Guider object. Combines prompt and LoRA trigger words into a single conditioning +step. + +### Inputs + +| Parameter | Type | Required | Description | +|-----------------|--------|----------|--------------------------------------------| +| `model` | MODEL | Yes | Diffusion model | +| `clip` | CLIP | Yes | CLIP model for text encoding | +| `prompt` | STRING | Yes | Main generation prompt | +| `lora_triggers` | STRING | No | LoRA trigger words, appended to the prompt | +| `guidance` | FLOAT | Yes | Guidance scale (CFG), default 3.5 | + +### Outputs + +| Parameter | Type | Description | +|-----------|--------|---------------------------------------------| +| `GUIDER` | GUIDER | Configured Guider with encoded conditioning | + +### Usage + +Connect to a sampler node that accepts a Guider input. The `lora_triggers` field is a convenience input — enter trigger words for any loaded LoRA +models here instead of adding them manually to the main prompt. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) From 79d323f10f461acd903214eaa644edfd6f62ea01 Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Thu, 5 Mar 2026 14:57:25 +0300 Subject: [PATCH 16/20] Added nodes TT_SdxlClipTextEncoderNode, TT_SdxlClipTextEncoderContextNode, TT_Flux1ClipTextEncoderNode, TT_Flux1ClipTextEncoderContextNode, minor bugfixes and refactoring, added docs, changed version --- __init__.py | 2 +- nodes/text_encoders/clip_text_encode_flux.py | 2 +- .../clip_text_encode_flux_context.py | 2 +- nodes/text_encoders/clip_text_encode_sdxl.py | 2 +- .../clip_text_encode_sdxl_context.py | 2 +- nodes_controlnet.py | 4 + nodes_text_encoder.py | 188 ++++++++++++++++++ pyproject.toml | 2 +- .../TT_Flux1ClipTextEncoderContextNode.md | 29 +++ web/docs/TT_Flux1ClipTextEncoderNode.md | 32 +++ web/docs/TT_SdxlClipTextEncoderContextNode.md | 30 +++ web/docs/TT_SdxlClipTextEncoderNode.md | 39 ++++ 12 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 web/docs/TT_Flux1ClipTextEncoderContextNode.md create mode 100644 web/docs/TT_Flux1ClipTextEncoderNode.md create mode 100644 web/docs/TT_SdxlClipTextEncoderContextNode.md create mode 100644 web/docs/TT_SdxlClipTextEncoderNode.md diff --git a/__init__.py b/__init__.py index 0747f8d..621a94a 100644 --- a/__init__.py +++ b/__init__.py @@ -9,7 +9,7 @@ __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] print() SEP = f"{CYAN}{'=' * 60}{RESET}" print(SEP) -print(f"{YELLOW}TenserTensor ComfyUI Nodes Pack (v1.6.12): {BRIGHT_GREEN}{NODES_COUNT} Nodes Loaded{RESET}") +print(f"{YELLOW}TenserTensor ComfyUI Nodes Pack (v1.6.13): {BRIGHT_GREEN}{NODES_COUNT} Nodes Loaded{RESET}") print(SEP) print(f"{BRIGHT_CYAN}[V] Repository: https://github.com/tenser-tensor/ComfyUI-TenserTensor{RESET}") print(f"{BRIGHT_CYAN}[V] ComfyUI Registry: https://registry.comfy.org/publishers/tenser-tensor{RESET}") diff --git a/nodes/text_encoders/clip_text_encode_flux.py b/nodes/text_encoders/clip_text_encode_flux.py index 23b3f1c..4a98198 100644 --- a/nodes/text_encoders/clip_text_encode_flux.py +++ b/nodes/text_encoders/clip_text_encode_flux.py @@ -20,7 +20,7 @@ class TT_ClipTextEncodeFlux(): RETURN_TYPES = ("CONDITIONING", "CONDITIONING",) RETURN_NAMES = ("POSITIVE", "NEGATIVE",) FUNCTION = "execute" - CATEGORY = "TenserTensor/Text Encoders/FLUX" + CATEGORY = "TenserTensor/Deprecated/Text Encoders/FLUX" def execute(self, **kwargs): return encode_prompts_flux(**kwargs) diff --git a/nodes/text_encoders/clip_text_encode_flux_context.py b/nodes/text_encoders/clip_text_encode_flux_context.py index cb67a07..533b6ad 100644 --- a/nodes/text_encoders/clip_text_encode_flux_context.py +++ b/nodes/text_encoders/clip_text_encode_flux_context.py @@ -15,7 +15,7 @@ class TT_ClipTextEncodeFluxContext(): RETURN_TYPES = ("TT_CONTEXT", "CONDITIONING", "CONDITIONING",) RETURN_NAMES = ("CONTEXT", "POSITIVE", "NEGATIVE",) FUNCTION = "execute" - CATEGORY = "TenserTensor/Text Encoders/FLUX" + CATEGORY = "TenserTensor/Deprecated/Text Encoders/FLUX" def execute(self, context): clip = context["clip"] diff --git a/nodes/text_encoders/clip_text_encode_sdxl.py b/nodes/text_encoders/clip_text_encode_sdxl.py index 6b4ac58..0adca45 100644 --- a/nodes/text_encoders/clip_text_encode_sdxl.py +++ b/nodes/text_encoders/clip_text_encode_sdxl.py @@ -26,7 +26,7 @@ class TT_ClipTextEncodeSdxl(): RETURN_TYPES = ("CONDITIONING", "CONDITIONING",) RETURN_NAMES = ("POSITIVE", "NEGATIVE",) FUNCTION = "execute" - CATEGORY = "TenserTensor/Text Encoders/SDXL" + CATEGORY = "TenserTensor/Deprecated/Text Encoders/SDXL" def execute(self, **kwargs): return encode_prompts_sdxl(**kwargs) diff --git a/nodes/text_encoders/clip_text_encode_sdxl_context.py b/nodes/text_encoders/clip_text_encode_sdxl_context.py index de1848f..13d2872 100644 --- a/nodes/text_encoders/clip_text_encode_sdxl_context.py +++ b/nodes/text_encoders/clip_text_encode_sdxl_context.py @@ -15,7 +15,7 @@ class TT_ClipTextEncodeSdxlContext(): RETURN_TYPES = ("TT_CONTEXT", "CONDITIONING", "CONDITIONING",) RETURN_NAMES = ("CONTEXT", "POSITIVE", "NEGATIVE",) FUNCTION = "execute" - CATEGORY = "TenserTensor/Text Encoders/SDXL" + CATEGORY = "TenserTensor/Deprecated/Text Encoders/SDXL" def execute(self, context): clip = context["clip"] diff --git a/nodes_controlnet.py b/nodes_controlnet.py index d66d42f..0749fcd 100644 --- a/nodes_controlnet.py +++ b/nodes_controlnet.py @@ -16,7 +16,11 @@ def get_control_net_files(): def load_control_net(filename): cnet_path = folder_paths.get_full_path_or_raise("controlnet", filename) + + print(f"CNET path: {cnet_path}") + cnet = controlnet.load_controlnet(cnet_path) + if cnet is None: raise RuntimeError("ERROR: ControlNet file is invalid or corrupted.") diff --git a/nodes_text_encoder.py b/nodes_text_encoder.py index 2306d43..6709543 100644 --- a/nodes_text_encoder.py +++ b/nodes_text_encoder.py @@ -3,12 +3,192 @@ from typing import override from comfy_api.latest import io, ComfyExtension +from nodes import MAX_RESOLUTION from .nodes_context import Context from .nodes_image import SingleCondCFGGuider CATEGORY = "TenserTensor/Text Encoder" +def encode_prompts_sdxl(**kwargs): + clip = kwargs.get("clip") + + if clip is None: + raise ValueError("ERROR: CLIP is required for text encoder") + + positive_tokens = clip.tokenize(kwargs.get("clip_g_positive")) + positive_tokens["l"] = clip.tokenize(kwargs.get("clip_l_positive"))["l"] + negative_tokens = clip.tokenize(kwargs.get("clip_g_negative")) + negative_tokens["l"] = clip.tokenize(kwargs.get("clip_l_negative"))["l"] + + cond_dict = { + "aesthetic_score": kwargs.get("ascore_positive"), + "width": kwargs.get("width"), + "height": kwargs.get("height"), + "target_width": kwargs.get("target_width"), + "target_height": kwargs.get("target_height"), + } + + positive = clip.encode_from_tokens_scheduled(positive_tokens, add_dict=cond_dict) + cond_dict["aesthetic_score"] = kwargs.get("ascore_negative") + negative = clip.encode_from_tokens_scheduled(negative_tokens, add_dict=cond_dict) + + return positive, negative + + +class TT_SdxlClipTextEncoderNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_SdxlClipTextEncoderNode", + display_name="TT SDXL CLIP Text Encoder", + category=CATEGORY, + description="", + inputs=[ + io.Clip.Input("clip"), + io.String.Input("clip_l_positive", multiline=True, placeholder="CLIP_L Positive", dynamic_prompts=True), + io.String.Input("clip_g_positive", multiline=True, placeholder="CLIP_G Positive", dynamic_prompts=True), + io.String.Input("clip_l_negative", multiline=True, placeholder="CLIP_L Negative", dynamic_prompts=True), + io.String.Input("clip_g_negative", multiline=True, placeholder="CLIP_G Negative", dynamic_prompts=True), + io.Float.Input("ascore_positive", default=9.0, min=0.0, max=100.0, step=0.1), + io.Float.Input("ascore_negative", default=6.0, min=0.0, max=100.0, step=0.1), + io.Int.Input("width", default=512, min=64, max=MAX_RESOLUTION, step=8), + io.Int.Input("height", default=512, min=64, max=MAX_RESOLUTION, step=8), + io.Int.Input("target_width", default=512, min=64, max=MAX_RESOLUTION, step=8), + io.Int.Input("target_height", default=512, min=64, max=MAX_RESOLUTION, step=8), + ], + outputs=[ + io.Conditioning.Output("POSITIVE"), + io.Conditioning.Output("NEGATIVE"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + positive, negative = encode_prompts_sdxl(**kwargs) + + return io.NodeOutput(positive, negative) + + +class TT_SdxlClipTextEncoderContextNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_SdxlClipTextEncoderContextNode", + display_name="TT SDXL CLIP Text Encoder (Context)", + category=CATEGORY, + description="", + inputs=[ + Context.Input("context") + ], + outputs=[ + Context.Output("CONTEXT"), + io.Conditioning.Output("POSITIVE"), + io.Conditioning.Output("NEGATIVE"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + context = kwargs.get("context") + + args = {} + for key in ( + "clip", "clip_l_positive", "clip_g_positive", "clip_l_negative", "clip_g_negative", + "ascore_positive", "ascore_negative", "width", "height", "target_width", "target_height", + ): + args[key] = context.get_attr(key) + + positive, negative = encode_prompts_sdxl(**args) + context.set_attr("positive", positive) + context.set_attr("negative", negative) + + return io.NodeOutput(context, positive, negative) + + +def encode_prompts_flux(**kwargs): + clip = kwargs.get("clip") + + if clip is None: + raise ValueError("ERROR: CLIP is required for text encoder") + + positive_tokens = clip.tokenize(kwargs.get("clip_l_positive")) + positive_tokens["t5xxl"] = clip.tokenize(kwargs.get("t5xxl_positive"))["t5xxl"] + negative_tokens = clip.tokenize(kwargs.get("clip_l_negative")) + negative_tokens["t5xxl"] = clip.tokenize(kwargs.get("t5xxl_negative"))["t5xxl"] + + guidance = kwargs.get("guidance") + positive = clip.encode_from_tokens_scheduled(positive_tokens, add_dict={"guidance": guidance, }) + negative = clip.encode_from_tokens_scheduled(negative_tokens, add_dict={"guidance": guidance, }) + + return positive, negative + + +class TT_Flux1ClipTextEncoderNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_Flux1ClipTextEncoderNode", + display_name="TT FLUX1 CLIP Text Encoder", + category=CATEGORY, + description="", + inputs=[ + io.Clip.Input("clip"), + io.String.Input("clip_l_positive", multiline=True, placeholder="CLIP_L Positive", dynamic_prompts=True), + io.String.Input("t5xxl_positive", multiline=True, placeholder="T5XXL Positive", dynamic_prompts=True), + io.String.Input("clip_l_negative", multiline=True, placeholder="CLIP_L Negative", dynamic_prompts=True), + io.String.Input("t5xxl_negative", multiline=True, placeholder="T5XXL Negative", dynamic_prompts=True), + io.Float.Input("guidance", default=9.0, min=0.0, max=100.0, step=0.1), + ], + outputs=[ + io.Conditioning.Output("POSITIVE"), + io.Conditioning.Output("NEGATIVE"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + positive, negative = encode_prompts_flux(**kwargs) + + return io.NodeOutput(positive, negative) + + +class TT_Flux1ClipTextEncoderContextNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_Flux1ClipTextEncoderContextNode", + display_name="TT FLUX1 CLIP Text Encoder (Context)", + category=CATEGORY, + description="", + inputs=[ + Context.Input("context") + ], + outputs=[ + Context.Output("CONTEXT"), + io.Conditioning.Output("POSITIVE"), + io.Conditioning.Output("NEGATIVE"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + context = kwargs.get("context") + + args = {} + for key in ( + "clip", "clip_l_positive", "t5xxl_positive", "clip_l_negative", "t5xxl_negative", + "guidance", + ): + args[key] = context.get_attr(key) + + positive, negative = encode_prompts_flux(**args) + context.set_attr("positive", positive) + context.set_attr("negative", negative) + + return io.NodeOutput(context, positive, negative) + + def encode_prompts_flux2(**kwargs): clip = kwargs.get("clip") model = kwargs.get("model") @@ -103,6 +283,10 @@ class TextEncodeNodesExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ + TT_SdxlClipTextEncoderNode, + TT_SdxlClipTextEncoderContextNode, + TT_Flux1ClipTextEncoderNode, + TT_Flux1ClipTextEncoderContextNode, TT_Flux2TextEncoderNode, TT_Flux2TextEncoderContextNode, ] @@ -117,6 +301,10 @@ async def comfy_entrypoint() -> TextEncodeNodesExtension: # ============================================================================== __all__ = [ + "TT_SdxlClipTextEncoderNode", + "TT_SdxlClipTextEncoderContextNode", + "TT_Flux1ClipTextEncoderNode", + "TT_Flux1ClipTextEncoderContextNode", "TT_Flux2TextEncoderNode", "TT_Flux2TextEncoderContextNode", ] diff --git a/pyproject.toml b/pyproject.toml index 323be90..529de0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ [project] name = "ComfyUI-TenserTensor" description = "A collection of custom nodes for ComfyUI that streamline common workflows. Includes all-in-one model loaders (checkpoint/unet, LoRA, CLIP, VAE), GGUF quantized model support (FLUX, FLUX2, SDXL), workflow settings nodes, latent factory (aspect ratio, megapixels, FLUX/SDXL), context nodes, CLIP text encoders, KSamplers (context, advanced, two-stage - draft/refine), VAE encoders/decoders (context, tiled), and post-production tools (brightness, contrast, LUT color grading, film grain, upscaler)." -version = "1.6.12" +version = "1.6.13" license = { file = "LICENSE" } dependencies = ["gguf", "kornia"] diff --git a/web/docs/TT_Flux1ClipTextEncoderContextNode.md b/web/docs/TT_Flux1ClipTextEncoderContextNode.md new file mode 100644 index 0000000..ec6fdd2 --- /dev/null +++ b/web/docs/TT_Flux1ClipTextEncoderContextNode.md @@ -0,0 +1,29 @@ +## TT FLUX1 CLIP Text Encoder (Context) *TT_Flux1ClipTextEncoderContextNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Context-based variant of the FLUX1 CLIP text encoder. Reads all required parameters from a Context object and writes the resulting conditioning back +into it. + +### Inputs + +| Parameter | Type | Required | Description | +|-----------|------------|----------|-----------------------------------------------------------| +| `context` | TT_CONTEXT | Yes | Context object carrying CLIP, prompts, and guidance scale | + +### Outputs + +| Parameter | Type | Description | +|------------|--------------|-----------------------------------------------------------------------| +| `CONTEXT` | TT_CONTEXT | Updated Context with positive and negative conditioning stored inside | +| `POSITIVE` | CONDITIONING | Positive conditioning | +| `NEGATIVE` | CONDITIONING | Negative conditioning | + +### Usage + +Drop-in replacement for [TT FLUX1 CLIP Text Encoder](TT_Flux1ClipTextEncoderNode.md) in Context-based workflows. The Context must have `clip`, +`clip_l_positive`, `t5xxl_positive`, `clip_l_negative`, `t5xxl_negative`, and `guidance` set before reaching this node. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_Flux1ClipTextEncoderNode.md b/web/docs/TT_Flux1ClipTextEncoderNode.md new file mode 100644 index 0000000..7d7340e --- /dev/null +++ b/web/docs/TT_Flux1ClipTextEncoderNode.md @@ -0,0 +1,32 @@ +## TT FLUX1 CLIP Text Encoder *TT_Flux1ClipTextEncoderNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Encodes positive and negative text prompts for FLUX1 models using dual text encoders (CLIP-L and T5-XXL) with guidance scale conditioning. + +### Inputs + +| Parameter | Type | Required | Description | +|-------------------|--------|----------|-------------------------------------------| +| `clip` | CLIP | Yes | Dual text encoder model (CLIP-L + T5-XXL) | +| `clip_l_positive` | STRING | Yes | CLIP-L positive prompt | +| `t5xxl_positive` | STRING | Yes | T5-XXL positive prompt | +| `clip_l_negative` | STRING | Yes | CLIP-L negative prompt | +| `t5xxl_negative` | STRING | Yes | T5-XXL negative prompt | +| `guidance` | FLOAT | Yes | Guidance scale, default 9.0 | + +### Outputs + +| Parameter | Type | Description | +|------------|--------------|-----------------------| +| `POSITIVE` | CONDITIONING | Positive conditioning | +| `NEGATIVE` | CONDITIONING | Negative conditioning | + +### Usage + +Connect `POSITIVE` and `NEGATIVE` outputs to a sampler. CLIP-L handles short, structured descriptions; T5-XXL understands longer, more complex +prompts — you can use the same text in both or tailor each separately. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_SdxlClipTextEncoderContextNode.md b/web/docs/TT_SdxlClipTextEncoderContextNode.md new file mode 100644 index 0000000..4f15c6a --- /dev/null +++ b/web/docs/TT_SdxlClipTextEncoderContextNode.md @@ -0,0 +1,30 @@ +## TT SDXL CLIP Text Encoder (Context) *TT_SdxlClipTextEncoderContextNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Context-based variant of the SDXL CLIP text encoder. Reads all required parameters from a Context object and writes the resulting conditioning back +into it. + +### Inputs + +| Parameter | Type | Required | Description | +|-----------|------------|----------|------------------------------------------------------------------------------------| +| `context` | TT_CONTEXT | Yes | Context object carrying CLIP, prompts, aesthetic scores, and resolution parameters | + +### Outputs + +| Parameter | Type | Description | +|------------|--------------|-----------------------------------------------------------------------| +| `CONTEXT` | TT_CONTEXT | Updated Context with positive and negative conditioning stored inside | +| `POSITIVE` | CONDITIONING | Positive conditioning | +| `NEGATIVE` | CONDITIONING | Negative conditioning | + +### Usage + +Drop-in replacement for [TT SDXL CLIP Text Encoder](TT_SdxlClipTextEncoderNode.md) in Context-based workflows. The Context must have `clip`, +`clip_l_positive`, `clip_g_positive`, `clip_l_negative`, `clip_g_negative`, `ascore_positive`, `ascore_negative`, `width`, `height`, `target_width`, +and `target_height` set before reaching this node. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_SdxlClipTextEncoderNode.md b/web/docs/TT_SdxlClipTextEncoderNode.md new file mode 100644 index 0000000..2708983 --- /dev/null +++ b/web/docs/TT_SdxlClipTextEncoderNode.md @@ -0,0 +1,39 @@ +## TT SDXL CLIP Text Encoder *TT_SdxlClipTextEncoderNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Encodes positive and negative text prompts for SDXL models using dual CLIP encoders (CLIP-L and CLIP-G) with aesthetic score and resolution +conditioning. + +### Inputs + +| Parameter | Type | Required | Description | +|-------------------|--------|----------|--------------------------------------------------------| +| `clip` | CLIP | Yes | Dual CLIP model (CLIP-L + CLIP-G) | +| `clip_l_positive` | STRING | Yes | CLIP-L positive prompt | +| `clip_g_positive` | STRING | Yes | CLIP-G positive prompt | +| `clip_l_negative` | STRING | Yes | CLIP-L negative prompt | +| `clip_g_negative` | STRING | Yes | CLIP-G negative prompt | +| `ascore_positive` | FLOAT | Yes | Aesthetic score for positive conditioning, default 9.0 | +| `ascore_negative` | FLOAT | Yes | Aesthetic score for negative conditioning, default 6.0 | +| `width` | INT | Yes | Source image width | +| `height` | INT | Yes | Source image height | +| `target_width` | INT | Yes | Target output width | +| `target_height` | INT | Yes | Target output height | + +### Outputs + +| Parameter | Type | Description | +|------------|--------------|-----------------------| +| `POSITIVE` | CONDITIONING | Positive conditioning | +| `NEGATIVE` | CONDITIONING | Negative conditioning | + +### Usage + +Connect `POSITIVE` and `NEGATIVE` outputs to a sampler. Set `width`/`height` to match your source image dimensions and `target_width`/`target_height` +to your desired output resolution. Higher `ascore_positive` (8–10) steers generation toward higher aesthetic quality; lower `ascore_negative` (4–6) +pushes away from low-quality outputs. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) From 52f93f889b61fa77d686e9f8e73ff7a77c8f559a Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Sat, 7 Mar 2026 12:27:53 +0300 Subject: [PATCH 17/20] Added nodes TT_SdxlModelsLoaderNode, TT_SdxlModelsLoaderAdvancedNode, TT_FluxModelsLoaderNode, TT_FluxModelsLoaderAdvancedNode, minor bugfixes and refactoring, added docs, changed version --- __init__.py | 2 +- node_mappings.py | 23 +- nodes/loaders/flux_models_loader.py | 2 +- nodes/loaders/flux_models_loader_advanced.py | 2 +- nodes/loaders/sdxl_models_loader.py | 2 +- nodes/loaders/sdxl_models_loader_advanced.py | 2 +- nodes_context.py | 4 +- nodes_loaders.py | 391 ++++++++++++++++++- nodes_workflow.py | 1 + pyproject.toml | 2 +- 10 files changed, 416 insertions(+), 15 deletions(-) diff --git a/__init__.py b/__init__.py index 621a94a..0444eef 100644 --- a/__init__.py +++ b/__init__.py @@ -9,7 +9,7 @@ __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] print() SEP = f"{CYAN}{'=' * 60}{RESET}" print(SEP) -print(f"{YELLOW}TenserTensor ComfyUI Nodes Pack (v1.6.13): {BRIGHT_GREEN}{NODES_COUNT} Nodes Loaded{RESET}") +print(f"{YELLOW}TenserTensor ComfyUI Nodes Pack (v1.6.16): {BRIGHT_GREEN}{NODES_COUNT} Nodes Loaded{RESET}") print(SEP) print(f"{BRIGHT_CYAN}[V] Repository: https://github.com/tenser-tensor/ComfyUI-TenserTensor{RESET}") print(f"{BRIGHT_CYAN}[V] ComfyUI Registry: https://registry.comfy.org/publishers/tenser-tensor{RESET}") diff --git a/node_mappings.py b/node_mappings.py index 54a49fa..f748fa4 100644 --- a/node_mappings.py +++ b/node_mappings.py @@ -59,6 +59,7 @@ try: from .nodes.loaders.gguf_models_loader_advanced import TT_Flux2GgufModelsLoaderAdvanced from .nodes.loaders.sdxl_models_loader import TT_SdxlModelsLoader from .nodes.loaders.sdxl_models_loader_advanced import TT_SdxlModelsLoaderAdvanced + from .nodes_loaders import * except ImportError: print(f"{YELLOW}TenserTensor: {RED}ERROR: Loader nodes unavailable{RESET}") @@ -172,6 +173,11 @@ NODE_CLASS_MAPPINGS = { "TT_Flux2GgufModelsLoaderAdvanced": TT_Flux2GgufModelsLoaderAdvanced, "TT_FluxModelsLoader": TT_FluxModelsLoader, "TT_FluxModelsLoaderAdvanced": TT_FluxModelsLoaderAdvanced, + # Loaders V3 Nodes + "TT_SdxlModelsLoaderNode": TT_SdxlModelsLoaderNode, + "TT_SdxlModelsLoaderAdvancedNode": TT_SdxlModelsLoaderAdvancedNode, + "TT_FluxModelsLoaderNode": TT_FluxModelsLoaderNode, + "TT_FluxModelsLoaderAdvancedNode": TT_FluxModelsLoaderAdvancedNode, # Postproduction "TT_ApplyLut": TT_ApplyLut, "TT_AddFilmGrain": TT_AddFilmGrain, @@ -193,6 +199,10 @@ NODE_CLASS_MAPPINGS = { "TT_ClipTextEncodeFlux2": TT_ClipTextEncodeFlux2, "TT_ClipTextEncodeFlux2Context": TT_ClipTextEncodeFlux2Context, # Text Encoder V3 Nodes + "TT_SdxlClipTextEncoderNode": TT_SdxlClipTextEncoderNode, + "TT_SdxlClipTextEncoderContextNode": TT_SdxlClipTextEncoderContextNode, + "TT_Flux1ClipTextEncoderNode": TT_Flux1ClipTextEncoderNode, + "TT_Flux1ClipTextEncoderContextNode": TT_Flux1ClipTextEncoderContextNode, "TT_Flux2TextEncoderNode": TT_Flux2TextEncoderNode, "TT_Flux2TextEncoderContextNode": TT_Flux2TextEncoderContextNode, # VAE @@ -263,6 +273,11 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TT_Flux2GgufModelsLoaderAdvanced": "TT FLUX2 GGUF Models Loader (Advanced)", "TT_FluxModelsLoader": "TT FLUX Models Loader", "TT_FluxModelsLoaderAdvanced": "TT FLUX Models Loader (Advanced)", + # Loaders V3 Nodes + "TT_SdxlModelsLoaderNode": "TT SDXL Models Loader", + "TT_SdxlModelsLoaderAdvancedNode": "TT SDXL Models Loader (Advanced)", + "TT_FluxModelsLoaderNode": "TT FLUX Models Loader", + "TT_FluxModelsLoaderAdvancedNode": "TT FLUX Models Loader (Advanced)", # Postproduction "TT_ApplyLut": "TT Apply LUT", "TT_AddFilmGrain": "TT Add Film Grain", @@ -284,8 +299,12 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TT_ClipTextEncodeFlux2": "TT CLIP Text Encode FLUX2", "TT_ClipTextEncodeFlux2Context": "TT CLIP Text Encode FLUX2 (Context)", # Text Encoder V3 Nodes - "TT_Flux2TextEncoderNode":"", - "TT_Flux2TextEncoderContextNode":"", + "TT_SdxlClipTextEncoderNode": "TT SDXL CLIP Text Encoder", + "TT_SdxlClipTextEncoderContextNode": "TT SDXL CLIP Text Encoder (Context)", + "TT_Flux1ClipTextEncoderNode": "TT FLUX1 CLIP Text Encoder", + "TT_Flux1ClipTextEncoderContextNode": "TT FLUX1 CLIP Text Encoder (Context)", + "TT_Flux2TextEncoderNode": "TT FLUX2 Text Encoder", + "TT_Flux2TextEncoderContextNode": "TT FLUX2 Text Encoder (Context)", # VAE "TT_VaeDecodeContext": "TT VAE Decode (Context)", "TT_VaeDecodeTiled": "TT VAE Decode (Tiled)", diff --git a/nodes/loaders/flux_models_loader.py b/nodes/loaders/flux_models_loader.py index 6d8b173..c24f98d 100644 --- a/nodes/loaders/flux_models_loader.py +++ b/nodes/loaders/flux_models_loader.py @@ -22,7 +22,7 @@ class TT_FluxModelsLoader(): RETURN_TYPES = ("MODEL", "CLIP", "VAE",) RETURN_NAMES = ("MODEL", "CLIP", "VAE",) FUNCTION = "load_models" - CATEGORY = "TenserTensor/Loaders/FLUX" + CATEGORY = "TenserTensor/Deprecated/Loaders/FLUX" def load_models( self, diff --git a/nodes/loaders/flux_models_loader_advanced.py b/nodes/loaders/flux_models_loader_advanced.py index 3ed2c8a..f61de49 100644 --- a/nodes/loaders/flux_models_loader_advanced.py +++ b/nodes/loaders/flux_models_loader_advanced.py @@ -48,7 +48,7 @@ class TT_FluxModelsLoaderAdvanced(): RETURN_TYPES = ("MODEL", "CLIP", "VAE",) RETURN_NAMES = ("MODEL", "CLIP", "VAE",) FUNCTION = "load_models" - CATEGORY = "TenserTensor/Loaders/FLUX" + CATEGORY = "TenserTensor/Deprecated/Loaders/FLUX" def load_models(self, **kwargs): unet_name = kwargs.get("unet_name") diff --git a/nodes/loaders/sdxl_models_loader.py b/nodes/loaders/sdxl_models_loader.py index 445a77c..04f1d29 100644 --- a/nodes/loaders/sdxl_models_loader.py +++ b/nodes/loaders/sdxl_models_loader.py @@ -24,7 +24,7 @@ class TT_SdxlModelsLoader: RETURN_TYPES = ("MODEL", "CLIP", "VAE",) RETURN_NAMES = ("MODEL", "CLIP", "VAE",) FUNCTION = "load_models" - CATEGORY = "TenserTensor/Loaders/SDXL" + CATEGORY = "TenserTensor/Deprecated/Loaders/SDXL" def load_models(self, primary_ckpt, secondary_ckpt, primary_weight, clip_l, clip_g, clip_device, vae_name): model = load_checkpoint(primary_ckpt)[0] diff --git a/nodes/loaders/sdxl_models_loader_advanced.py b/nodes/loaders/sdxl_models_loader_advanced.py index 9015a79..f558dd4 100644 --- a/nodes/loaders/sdxl_models_loader_advanced.py +++ b/nodes/loaders/sdxl_models_loader_advanced.py @@ -43,7 +43,7 @@ class TT_SdxlModelsLoaderAdvanced: RETURN_TYPES = ("MODEL", "CLIP", "VAE",) RETURN_NAMES = ("MODEL", "CLIP", "VAE",) FUNCTION = "load_models" - CATEGORY = "TenserTensor/Loaders/SDXL" + CATEGORY = "TenserTensor/Deprecated/Loaders/SDXL" def load_models(self, primary_ckpt, secondary_ckpt, primary_weight, clip_l, clip_g, clip_device, lora_name_1, strength_1, lora_name_2, strength_2, lora_name_3, strength_3, lora_name_4, strength_4, diff --git a/nodes_context.py b/nodes_context.py index ac6f859..48b96b3 100644 --- a/nodes_context.py +++ b/nodes_context.py @@ -162,9 +162,9 @@ class ContextNode(IO.ComfyNode): for f_name in cls.RETURNS if len(cls.RETURNS) > 0 else cls.REQUIRED + cls.OPTIONAL: output_schema.append( - Context.Output(display_name="CONTEXT") + Context.Output("CONTEXT") if f_name == "context" - else cls.F_TYPES[f_name].Output(display_name=f_name.upper()) + else cls.F_TYPES[f_name].Output(id=f_name.upper()) ) return output_schema diff --git a/nodes_loaders.py b/nodes_loaders.py index f67e85d..6ccbae7 100644 --- a/nodes_loaders.py +++ b/nodes_loaders.py @@ -1,8 +1,383 @@ # (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) - +import os from typing import override -from comfy_api.latest import ComfyExtension, IO +from torch import bfloat16, float16, float32, float8_e4m3fn, float8_e5m2, device, tensor + +import folder_paths +from comfy import sd, model_management, utils, model_sampling +from comfy_api.latest import io, ComfyExtension +from nodes import VAELoader, MAX_RESOLUTION + +CATEGORY = "TenserTensor/Loaders" +TORCH_DEVICES = ["default", "cpu"] +TORCH_DEVICE_CPU = "cpu" +DTYPES = {"bfloat16": bfloat16, "float16": float16, "float32": float32} +VIDEO_TAES = ["taehv", "lighttaew2_2", "lighttaew2_1", "lighttaehy1_5"] +IMAGE_TAES = ["taesd", "taesdxl", "taesd3", "taef1"] +MIN_SAMPLING_RES = 256 +MAX_SAMPLING_RES = 4096 + + +def get_checkpoint_files(): + return folder_paths.get_filename_list("checkpoints") + + +def get_diffusion_models_files(): + return folder_paths.get_filename_list("diffusion_models") + + +def get_text_encoder_files(): + return folder_paths.get_filename_list("text_encoders") + + +def get_vae_files(): + return VAELoader.vae_list(VAELoader) + + +def get_lora_files(): + return ["None"] + folder_paths.get_filename_list("loras") + + +def load_checkpoint(checkpoint): + ckpt_path = folder_paths.get_full_path_or_raise("checkpoints", checkpoint) + + return sd.load_checkpoint_guess_config( + ckpt_path, output_vae=False, output_clip=False, + embedding_directory=folder_paths.get_folder_paths("embeddings") + )[0] + + +def load_sdxl_clip(clip_l, clip_g, clip_device): + clip_type = getattr(sd.CLIPType, "SDXL", sd.CLIPType.STABLE_DIFFUSION) + clip_l_path = folder_paths.get_full_path_or_raise("text_encoders", clip_l) + clip_g_path = folder_paths.get_full_path_or_raise("text_encoders", clip_g) + + model_options = {} + if clip_device == TORCH_DEVICE_CPU: + model_options["load_device"] = model_options["offload_device"] = device(TORCH_DEVICE_CPU) + + return sd.load_clip( + ckpt_paths=[clip_l_path, clip_g_path], + embedding_directory=folder_paths.get_folder_paths("embeddings"), + clip_type=clip_type, + model_options=model_options + ) + + +def load_vae(vae_name, vae_device="default", vae_dtype="bfloat16"): + dtype = DTYPES[vae_dtype] + torch_device = model_management.get_torch_device() if vae_device == "default" else device(vae_device) + + metadata = None + if vae_name == "pixel_space": + state_dict = {"pixel_space_vae": tensor(1.0)} + elif vae_name in IMAGE_TAES: + state_dict = VAELoader.load_taesd(vae_name) + else: + vae_path = ( + folder_paths.get_full_path_or_raise("vae_approx", vae_name) + if os.path.splitext(vae_name)[0] in VIDEO_TAES + else folder_paths.get_full_path_or_raise("vae", vae_name) + ) + state_dict, metadata = utils.load_torch_file(vae_path, return_metadata=True) + + vae = sd.VAE(sd=state_dict, device=torch_device, dtype=dtype, metadata=metadata) + vae.throw_exception_if_invalid() + + return vae + + +def load_sdxl_pipeline(cls, apply_loras, **kwargs): + model = load_checkpoint(kwargs.get("primary_ckpt")) + + secondary_ckpt = kwargs.get("secondary_ckpt") + if secondary_ckpt != "None": + primary_weight = kwargs.get("primary_weight") + secondary_model = load_checkpoint(secondary_ckpt) + model = model.clone() + key_patches = secondary_model.get_key_patches("diffusion_model.") + for key in key_patches: + model.add_patches({key: key_patches[key]}, 1.0 - primary_weight, primary_weight) + + clip_l, clip_g, clip_device = ( + kwargs.get("clip_l"), + kwargs.get("clip_g"), + kwargs.get("clip_device"), + ) + clip = load_sdxl_clip(clip_l, clip_g, clip_device) + + if apply_loras: + loras = [ + (kwargs.get("lora_name_1"), kwargs.get("strength_1"), 'loaded_lora_1'), + (kwargs.get("lora_name_2"), kwargs.get("strength_2"), 'loaded_lora_2'), + (kwargs.get("lora_name_3"), kwargs.get("strength_3"), 'loaded_lora_3'), + (kwargs.get("lora_name_4"), kwargs.get("strength_4"), 'loaded_lora_4'), + ] + + for name, strength, attr in loras: + if name != "None" and strength != 0: + cached = getattr(cls, attr) + model, clip, lora = apply_lora(cached, model, clip, name, strength) + setattr(cls, attr, lora) + + vae = load_vae(kwargs.get("vae_name"), kwargs.get("vae_device"), kwargs.get("vae_dtype")) + + return model, clip, vae + + +class TT_SdxlModelsLoaderNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_SdxlModelsLoaderNode", + display_name="TT SDXL Models Loader", + category=CATEGORY, + description="", + inputs=[ + io.Combo.Input("primary_ckpt", options=get_checkpoint_files()), + io.Combo.Input("secondary_ckpt", options=["None"] + get_checkpoint_files()), + io.Float.Input("primary_weight", default=1.0, min=0.0, max=1.0, step=0.01), + io.Combo.Input("clip_l", options=get_text_encoder_files()), + io.Combo.Input("clip_g", options=get_text_encoder_files()), + io.Combo.Input("clip_device", options=TORCH_DEVICES, default="default"), + io.Combo.Input("vae_name", options=get_vae_files()), + ], + outputs=[ + io.Model.Output("MODEL"), + io.Clip.Output("CLIP"), + io.Vae.Output("VAE"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + model, clip, vae = load_sdxl_pipeline(cls, apply_loras=False, **kwargs) + + return io.NodeOutput(model, clip, vae) + + +def apply_lora(loaded_lora, model, clip, lora_name, strength): + if loaded_lora is None: + lora_path = folder_paths.get_full_path_or_raise("loras", lora_name) + lora = utils.load_torch_file(lora_path, safe_load=True) + else: + lora = loaded_lora + + patched_model, patched_clip = sd.load_lora_for_models(model, clip, lora, strength, strength) + + return (patched_model, patched_clip, lora) + + +class TT_SdxlModelsLoaderAdvancedNode(io.ComfyNode): + loaded_lora_1 = None + loaded_lora_2 = None + loaded_lora_3 = None + loaded_lora_4 = None + + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_SdxlModelsLoaderAdvancedNode", + display_name="TT SDXL Models Loader (Advanced)", + category=CATEGORY, + description="", + inputs=[ + io.Combo.Input("primary_ckpt", options=get_checkpoint_files()), + io.Combo.Input("secondary_ckpt", options=["None"] + get_checkpoint_files()), + io.Float.Input("primary_weight", default=1.0, min=0.0, max=1.0, step=0.01), + io.Combo.Input("clip_l", options=get_text_encoder_files()), + io.Combo.Input("clip_g", options=get_text_encoder_files()), + io.Combo.Input("clip_device", options=TORCH_DEVICES, default="default"), + io.Combo.Input("lora_name_1", options=get_lora_files()), + io.Float.Input("strength_1", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("lora_name_2", options=get_lora_files()), + io.Float.Input("strength_2", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("lora_name_3", options=get_lora_files()), + io.Float.Input("strength_3", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("lora_name_4", options=get_lora_files()), + io.Float.Input("strength_4", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("vae_name", options=get_vae_files()), + io.Combo.Input("vae_device", options=TORCH_DEVICES, default="default"), + io.Combo.Input("vae_dtype", options=list(DTYPES.keys())), + ], + outputs=[ + io.Model.Output("MODEL"), + io.Clip.Output("CLIP"), + io.Vae.Output("VAE"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + model, clip, vae = load_sdxl_pipeline(cls, apply_loras=True, **kwargs) + + return io.NodeOutput(model, clip, vae) + + +def load_diffusion_model(diffusion_model, dtype="default"): + unet_path = folder_paths.get_full_path_or_raise("diffusion_models", diffusion_model) + + model_options = {} + match dtype: + case "fp8_e4m3fn": + model_options["dtype"] = float8_e4m3fn + case "fp8_e4m3fn_fast": + model_options["dtype"] = float8_e4m3fn + model_options["fp8_optimizations"] = True + case "fp8_e5m2": + model_options["dtype"] = float8_e5m2 + case _: + pass + + return sd.load_diffusion_model(unet_path, model_options=model_options) + + +def load_flux_clip(clip_l, t5xxl, device): + clip_type = sd.CLIPType.FLUX + clip_l_path = folder_paths.get_full_path_or_raise("text_encoders", clip_l) + t5xxl_path = folder_paths.get_full_path_or_raise("text_encoders", t5xxl) + + model_options = {} + if device == TORCH_DEVICE_CPU: + model_options["load_device"] = model_options["offload_device"] = device(TORCH_DEVICE_CPU) + + return sd.load_clip( + ckpt_paths=[clip_l_path, t5xxl_path], + embedding_directory=folder_paths.get_folder_paths("embeddings"), + clip_type=clip_type, + model_options=model_options + ) + + +class ModelSamplingFluxAdvanced(model_sampling.ModelSamplingFlux, model_sampling.CONST): + pass + + +def patch_flux_sampling(model, base_sampling_shift, max_sampling_shift, sampling_width, sampling_height): + tmodel = model.clone() + + slope = (max_sampling_shift - base_sampling_shift) / (MAX_SAMPLING_RES - MIN_SAMPLING_RES) + intercept = base_sampling_shift - slope * MIN_SAMPLING_RES + current_res = (sampling_width * sampling_height / (8 * 8 * 2 * 2)) + shift = current_res * slope + intercept + + model_sampling = ModelSamplingFluxAdvanced(model.model.model_config) + model_sampling.set_parameters(shift=shift) + tmodel.add_object_patch("model_sampling", model_sampling) + + return tmodel + + +def load_flux_pipeline(cls, apply_loras=False, **kwargs): + model = load_diffusion_model(kwargs.get("diffusion_model")) + + if kwargs.get("apply_sampling"): + args = { + "model": model, + "base_sampling_shift": kwargs.get("base_sampling_shift"), + "max_sampling_shift": kwargs.get("max_sampling_shift"), + "sampling_width": kwargs.get("sampling_width"), + "sampling_height": kwargs.get("sampling_height"), + } + model = patch_flux_sampling(**args) + + clip = load_flux_clip( + kwargs.get("clip_l"), + kwargs.get("t5xxl"), + kwargs.get("clip_device"), + ) + + if apply_loras: + loras = [ + (kwargs.get("lora_name_1"), kwargs.get("strength_1"), 'loaded_lora_1'), + (kwargs.get("lora_name_2"), kwargs.get("strength_2"), 'loaded_lora_2'), + (kwargs.get("lora_name_3"), kwargs.get("strength_3"), 'loaded_lora_3'), + (kwargs.get("lora_name_4"), kwargs.get("strength_4"), 'loaded_lora_4'), + ] + + for name, strength, attr in loras: + if name != "None" and strength != 0: + cached = getattr(cls, attr) + model, clip, lora = apply_lora(cached, model, clip, name, strength) + setattr(cls, attr, lora) + + vae = load_vae(kwargs.get("vae_name"), kwargs.get("vae_device"), kwargs.get("vae_dtype")) + + return (model, clip, vae,) + + +class TT_FluxModelsLoaderNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_FluxModelsLoaderNode", + display_name="TT FLUX Models Loader", + category=CATEGORY, + description="", + inputs=[ + io.Combo.Input("diffusion_model", options=get_diffusion_models_files()), + io.Combo.Input("clip_l", options=get_text_encoder_files()), + io.Combo.Input("t5xxl", options=get_text_encoder_files()), + io.Combo.Input("clip_device", options=TORCH_DEVICES, default="default"), + io.Combo.Input("vae_name", options=get_vae_files()), + ], + outputs=[ + io.Model.Output("MODEL"), + io.Clip.Output("CLIP"), + io.Vae.Output("VAE"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + model, clip, vae = load_flux_pipeline(cls, apply_loras=False, **kwargs) + + return io.NodeOutput(model, clip, vae) + + +class TT_FluxModelsLoaderAdvancedNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_FluxModelsLoaderAdvancedNode", + display_name="TT FLUX Models Loader (Advanced)", + category=CATEGORY, + description="", + inputs=[ + io.Combo.Input("diffusion_model", options=get_diffusion_models_files()), + io.Boolean.Input("apply_sampling", default=True, label_on="Flux Shift", label_off="No Shift"), + io.Float.Input("base_sampling_shift", default=0.5, min=0.0, max=100.0, step=0.01, advanced=True), + io.Float.Input("max_sampling_shift", default=1.15, min=0.0, max=100.0, step=0.01, advanced=True), + io.Int.Input("sampling_width", default=1024, min=16, max=MAX_RESOLUTION, step=8, advanced=True), + io.Int.Input("sampling_height", default=1024, min=16, max=MAX_RESOLUTION, step=8, advanced=True), + io.Combo.Input("clip_l", options=get_text_encoder_files()), + io.Combo.Input("t5xxl", options=get_text_encoder_files()), + io.Combo.Input("clip_device", options=TORCH_DEVICES, default="default", advanced=True), + io.Combo.Input("lora_name_1", options=get_lora_files()), + io.Float.Input("strength_1", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("lora_name_2", options=get_lora_files()), + io.Float.Input("strength_2", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("lora_name_3", options=get_lora_files()), + io.Float.Input("strength_3", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("lora_name_4", options=get_lora_files()), + io.Float.Input("strength_4", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("vae_name", options=get_vae_files()), + io.Combo.Input("vae_device", options=TORCH_DEVICES, default="default", advanced=True), + io.Combo.Input("vae_dtype", options=list(DTYPES.keys()), advanced=True), + ], + outputs=[ + io.Model.Output("MODEL"), + io.Clip.Output("CLIP"), + io.Vae.Output("VAE"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + model, clip, vae = load_flux_pipeline(cls, apply_loras=True, **kwargs) + + return io.NodeOutput(model, clip, vae) # ============================================================================== @@ -11,9 +386,12 @@ from comfy_api.latest import ComfyExtension, IO class LoaderNodesExtension(ComfyExtension): @override - async def get_node_list(self) -> list[type[IO.ComfyNode]]: + async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ - # TT_Node, + TT_SdxlModelsLoaderNode, + TT_SdxlModelsLoaderAdvancedNode, + TT_FluxModelsLoaderNode, + TT_FluxModelsLoaderAdvancedNode, ] @@ -26,5 +404,8 @@ async def comfy_entrypoint() -> LoaderNodesExtension: # ============================================================================== __all__ = [ - # "TT_Node", + "TT_SdxlModelsLoaderNode", + "TT_SdxlModelsLoaderAdvancedNode", + "TT_FluxModelsLoaderNode", + "TT_FluxModelsLoaderAdvancedNode", ] diff --git a/nodes_workflow.py b/nodes_workflow.py index 593ab27..994d4c5 100644 --- a/nodes_workflow.py +++ b/nodes_workflow.py @@ -74,6 +74,7 @@ class WorkflowSettings: class Output(io.Output): def __init__(self, **kwargs): + print() super().__init__(**kwargs) diff --git a/pyproject.toml b/pyproject.toml index 529de0f..d2514c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ [project] name = "ComfyUI-TenserTensor" description = "A collection of custom nodes for ComfyUI that streamline common workflows. Includes all-in-one model loaders (checkpoint/unet, LoRA, CLIP, VAE), GGUF quantized model support (FLUX, FLUX2, SDXL), workflow settings nodes, latent factory (aspect ratio, megapixels, FLUX/SDXL), context nodes, CLIP text encoders, KSamplers (context, advanced, two-stage - draft/refine), VAE encoders/decoders (context, tiled), and post-production tools (brightness, contrast, LUT color grading, film grain, upscaler)." -version = "1.6.13" +version = "1.6.16" license = { file = "LICENSE" } dependencies = ["gguf", "kornia"] From 6f7452ac1557ced80e30d2bf74cce9b854bf9ea5 Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Mon, 9 Mar 2026 11:20:22 +0300 Subject: [PATCH 18/20] Added nodes TT_SdxlModelsLoaderNode, TT_SdxlModelsLoaderAdvancedNode, TT_FluxModelsLoaderNode, TT_FluxModelsLoaderAdvancedNode, minor bugfixes and refactoring, added docs, changed version --- node_mappings.py | 4 + nodes/loaders/gguf_models_loader.py | 2 +- nodes/loaders/gguf_models_loader_advanced.py | 2 +- nodes_loaders.py | 283 ++++++++++++++++++- nodes_workflow.py | 6 +- web/docs/TT_FluxModelsLoaderAdvancedNode.md | 49 ++++ web/docs/TT_FluxModelsLoaderNode.md | 33 +++ web/docs/TT_GgufModelsLoaderAdvancedNode.md | 51 ++++ web/docs/TT_GgufModelsLoaderNode.md | 31 ++ 9 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 web/docs/TT_FluxModelsLoaderAdvancedNode.md create mode 100644 web/docs/TT_FluxModelsLoaderNode.md create mode 100644 web/docs/TT_GgufModelsLoaderAdvancedNode.md create mode 100644 web/docs/TT_GgufModelsLoaderNode.md diff --git a/node_mappings.py b/node_mappings.py index f748fa4..4ce98f1 100644 --- a/node_mappings.py +++ b/node_mappings.py @@ -178,6 +178,8 @@ NODE_CLASS_MAPPINGS = { "TT_SdxlModelsLoaderAdvancedNode": TT_SdxlModelsLoaderAdvancedNode, "TT_FluxModelsLoaderNode": TT_FluxModelsLoaderNode, "TT_FluxModelsLoaderAdvancedNode": TT_FluxModelsLoaderAdvancedNode, + "TT_GgufModelsLoaderNode": TT_GgufModelsLoaderNode, + "TT_GgufModelsLoaderAdvancedNode": TT_GgufModelsLoaderAdvancedNode, # Postproduction "TT_ApplyLut": TT_ApplyLut, "TT_AddFilmGrain": TT_AddFilmGrain, @@ -278,6 +280,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TT_SdxlModelsLoaderAdvancedNode": "TT SDXL Models Loader (Advanced)", "TT_FluxModelsLoaderNode": "TT FLUX Models Loader", "TT_FluxModelsLoaderAdvancedNode": "TT FLUX Models Loader (Advanced)", + "TT_GgufModelsLoaderNode": "TT GGUF Models Loader", + "TT_GgufModelsLoaderAdvancedNode": "TT GGUF Models Loader (Advanced)", # Postproduction "TT_ApplyLut": "TT Apply LUT", "TT_AddFilmGrain": "TT Add Film Grain", diff --git a/nodes/loaders/gguf_models_loader.py b/nodes/loaders/gguf_models_loader.py index 0342032..8337e6b 100644 --- a/nodes/loaders/gguf_models_loader.py +++ b/nodes/loaders/gguf_models_loader.py @@ -28,7 +28,7 @@ class TT_Flux2GgufModelsLoader(): RETURN_TYPES = ("MODEL", "CLIP", "VAE",) RETURN_NAMES = ("MODEL", "CLIP", "VAE",) FUNCTION = "load_models" - CATEGORY = "TenserTensor/Loaders/FLUX2 GGUF" + CATEGORY = "TenserTensor/Deprecated/Loaders/FLUX2 GGUF" VAE_DEVICE = "cpu" VAE_DTYPE = "bfloat16" diff --git a/nodes/loaders/gguf_models_loader_advanced.py b/nodes/loaders/gguf_models_loader_advanced.py index 7ac47ab..eb32531 100644 --- a/nodes/loaders/gguf_models_loader_advanced.py +++ b/nodes/loaders/gguf_models_loader_advanced.py @@ -55,7 +55,7 @@ class TT_Flux2GgufModelsLoaderAdvanced(): RETURN_TYPES = ("MODEL", "CLIP", "VAE",) RETURN_NAMES = ("MODEL", "CLIP", "VAE",) FUNCTION = "load_models" - CATEGORY = "TenserTensor/Loaders/FLUX2 GGUF" + CATEGORY = "TenserTensor/Deprecated/Loaders/FLUX2 GGUF" def load_models(self, **kwargs): model = load_unet( diff --git a/nodes_loaders.py b/nodes_loaders.py index 6ccbae7..11bf6e5 100644 --- a/nodes_loaders.py +++ b/nodes_loaders.py @@ -1,13 +1,26 @@ # (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) + +# Contains slightly modified code by City96 +# (c) City96 || Apache-2.0 (apache.org/licenses/LICENSE-2.0) +# Modified by TenserTensor +# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) + +import collections +import inspect +import logging import os from typing import override +import torch from torch import bfloat16, float16, float32, float8_e4m3fn, float8_e5m2, device, tensor import folder_paths -from comfy import sd, model_management, utils, model_sampling +from comfy import sd, model_management, utils, model_sampling, model_patcher, lora, float from comfy_api.latest import io, ComfyExtension from nodes import VAELoader, MAX_RESOLUTION +from .gguf.dequant import is_quantized, is_torch_compatible +from .gguf.loader import gguf_sd_loader, gguf_clip_loader +from .gguf.ops import GGMLOps, move_patch_to_device CATEGORY = "TenserTensor/Loaders" TORCH_DEVICES = ["default", "cpu"] @@ -27,10 +40,22 @@ def get_diffusion_models_files(): return folder_paths.get_filename_list("diffusion_models") +def get_gguf_diffusion_models_files(): + update_folder_names_and_paths("unet_gguf", ["diffusion_models", "unet"]) + + return [f for f in folder_paths.get_filename_list("unet_gguf")] + + def get_text_encoder_files(): return folder_paths.get_filename_list("text_encoders") +def get_gguf_text_encoder_files(): + update_folder_names_and_paths("clip_gguf", ["text_encoders", "clip"]) + + return [x for x in folder_paths.get_filename_list("clip_gguf")] + + def get_vae_files(): return VAELoader.vae_list(VAELoader) @@ -380,6 +405,258 @@ class TT_FluxModelsLoaderAdvancedNode(io.ComfyNode): return io.NodeOutput(model, clip, vae) +def update_folder_names_and_paths(key, targets=[]): + base = folder_paths.folder_names_and_paths.get(key, ([], {})) + base = base[0] if isinstance(base[0], (list, set, tuple)) else [] + + target = next((x for x in targets if x in folder_paths.folder_names_and_paths), targets[0]) + orig, _ = folder_paths.folder_names_and_paths.get(target, ([], {})) + folder_paths.folder_names_and_paths[key] = (orig or base, {".gguf"}) + if base and base != orig: + logging.warning(f"Unknown file list already present on key {key}: {base}") + + +WeightBackup = collections.namedtuple('WeightBackup', ['weight', 'inplace_update']) + + +class GGUFModelPatcher(model_patcher.ModelPatcher): + patch_on_device = False + + def patch_weight_to_device(self, key, device_to=None, inplace_update=False): + if key not in self.patches: + return + + weight = utils.get_attr(self.model, key) + + patches = self.patches[key] + if is_quantized(weight): + out_weight = weight.to(device_to) + patches = move_patch_to_device(patches, self.load_device if self.patch_on_device else self.offload_device) + # TODO: do we ever have legitimate duplicate patches? (i.e. patch on top of patched weight) + out_weight.patches = [(patches, key)] + else: + inplace_update = self.weight_inplace_update or inplace_update + if key not in self.backup: + self.backup[key] = WeightBackup( + weight.to(device=self.offload_device, copy=inplace_update), inplace_update + ) + + if device_to is not None: + temp_weight = model_management.cast_to_device(weight, device_to, torch.float32, copy=True) + else: + temp_weight = weight.to(torch.float32, copy=True) + + out_weight = lora.calculate_weight(patches, temp_weight, key) + out_weight = float.stochastic_rounding(out_weight, weight.dtype) + + if inplace_update: + utils.copy_to_param(self.model, key, out_weight) + else: + utils.set_attr_param(self.model, key, out_weight) + + def unpatch_model(self, device_to=None, unpatch_weights=True): + if unpatch_weights: + for p in self.model.parameters(): + if is_torch_compatible(p): + continue + patches = getattr(p, "patches", []) + if len(patches) > 0: + p.patches = [] + # TODO: Find another way to not unload after patches + return super().unpatch_model(device_to=device_to, unpatch_weights=unpatch_weights) + + +def get_dtype(dtype): + match dtype: + case None | "default": + return None + case "target": + return dtype + case _: + return getattr(torch, dtype) + + +def load_clip_data(ckpt_paths): + clip_data = [] + for path in ckpt_paths: + if path.endswith(".gguf"): + state_dict = gguf_clip_loader(path) + else: + state_dict = utils.load_torch_file(path, safe_load=True) + # NOTE: Scaled FP8 would require different custom ops, but only one can be active + if "scaled_fp8" in state_dict: + raise NotImplementedError( + f"Mixing scaled FP8 with GGUF is not supported! Use regular CLIP loader or switch model(s)\n({path})") + + clip_data.append(state_dict) + + return clip_data + + +def load_clip_patcher(clip_name, type="flux2"): + clip_path = folder_paths.get_full_path("clip_gguf", clip_name) + clip_type = getattr(sd.CLIPType, type.upper(), sd.CLIPType.FLUX2) + clip_data = load_clip_data([clip_path]) + + clip = sd.load_text_encoder_state_dicts( + clip_type=clip_type, + state_dicts=clip_data, + model_options={ + "custom_operations": GGMLOps, + "initial_device": model_management.text_encoder_offload_device() + }, + embedding_directory=folder_paths.get_folder_paths("embeddings"), + ) + clip.patcher = GGUFModelPatcher.clone(clip.patcher) + + return clip + + +def apply_bypass_lora_for_models(loaded_lora, model, clip, lora_name, strength): + if loaded_lora == None: + lora_path = folder_paths.get_full_path_or_raise("loras", lora_name) + lora = utils.load_torch_file(lora_path, safe_load=True) + else: + lora = loaded_lora + + patched_model, patched_clip = sd.load_bypass_lora_for_models(model, clip, lora, strength, strength) + + return (patched_model, patched_clip, lora,) + + +def load_gguf_pipeline(cls, **kwargs): + ops = GGMLOps() + + ops.Linear.dequant_dtype = get_dtype(kwargs.get("dequant_dtype", None)) + ops.Linear.patch_dtype = get_dtype(kwargs.get("patch_dtype", None)) + + gguf_full_path = folder_paths.get_full_path("unet_gguf", kwargs.get("diffusion_model")) + state_dict, extra = gguf_sd_loader(gguf_full_path) + + args = {} + valid_params = inspect.signature(sd.load_diffusion_model_state_dict).parameters + + if "metadata" in valid_params: + args["metadata"] = extra.get("metadata", {}) + + model = sd.load_diffusion_model_state_dict(state_dict, model_options={"custom_operations": ops}, **args, ) + + if model is None: + raise RuntimeError("ERROR: Could not detect model type of: {}".format(gguf_full_path)) + + model = GGUFModelPatcher.clone(model) + model.patch_on_device = kwargs.get("patch_on_device", None) + + apply_sampling = kwargs.get("apply_sampling") + if apply_sampling: + args = { + "model": model, + "base_sampling_shift": kwargs.get("base_sampling_shift"), + "max_sampling_shift": kwargs.get("max_sampling_shift"), + "sampling_width": kwargs.get("sampling_width"), + "sampling_height": kwargs.get("sampling_height"), + } + model = patch_flux_sampling(**args) + + clip = load_clip_patcher(kwargs.get("clip"), kwargs.get("clip_type", "flux2")) + + if kwargs.get("apply_bypass_lora"): + loras = [ + (kwargs.get("lora_name_1"), kwargs.get("strength_1"), 'loaded_lora_1'), + (kwargs.get("lora_name_2"), kwargs.get("strength_2"), 'loaded_lora_2'), + (kwargs.get("lora_name_3"), kwargs.get("strength_3"), 'loaded_lora_3'), + (kwargs.get("lora_name_4"), kwargs.get("strength_4"), 'loaded_lora_4'), + ] + + for name, strength, attr in loras: + if name != "None" and strength != 0: + cached = getattr(cls, attr) + model, clip, lora = apply_bypass_lora_for_models(cached, model, clip, name, strength) + setattr(cls, attr, lora) + + vae = load_vae( + kwargs.get("vae_name"), + kwargs.get("vae_device", "default"), + kwargs.get("vae_dtype", "bfloat16"), + ) + + return model, clip, vae + + +class TT_GgufModelsLoaderNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_GgufModelsLoaderNode", + display_name="TT GGUF Models Loader", + category=CATEGORY, + description="", + inputs=[ + io.Combo.Input("diffusion_model", options=get_gguf_diffusion_models_files()), + io.Combo.Input("clip", options=get_gguf_text_encoder_files()), + io.Combo.Input("vae_name", options=get_vae_files()), + ], + outputs=[ + io.Model.Output("MODEL"), + io.Clip.Output("CLIP"), + io.Vae.Output("VAE"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + kwargs["apply_bypass_lora"] = False + model, clip, vae = load_gguf_pipeline(cls, apply_loras=True, **kwargs) + + return io.NodeOutput(model, clip, vae) + + +class TT_GgufModelsLoaderAdvancedNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_GgufModelsLoaderAdvancedNode", + display_name="TT GGUF Models Loader (Advanced)", + category=CATEGORY, + description="", + inputs=[ + io.Combo.Input("diffusion_model", options=get_gguf_diffusion_models_files()), + io.Combo.Input("dequant_dtype", options=list(DTYPES.keys()), default="bfloat16", advanced=True), + io.Combo.Input("patch_dtype", options=list(DTYPES.keys()), default="bfloat16", advanced=True), + io.Boolean.Input("apply_sampling", default=True, label_on="Flux Shift", label_off="No Shift"), + io.Float.Input("base_sampling_shift", default=0.5, min=0.0, max=100.0, step=0.01, advanced=True), + io.Float.Input("max_sampling_shift", default=1.15, min=0.0, max=100.0, step=0.01, advanced=True), + io.Int.Input("sampling_width", default=1024, min=16, max=MAX_RESOLUTION, step=8, advanced=True), + io.Int.Input("sampling_height", default=1024, min=16, max=MAX_RESOLUTION, step=8, advanced=True), + io.Combo.Input("clip", options=get_gguf_text_encoder_files()), + io.Combo.Input("clip_device", options=TORCH_DEVICES, default="default", advanced=True), + io.Combo.Input("lora_name_1", options=get_lora_files()), + io.Float.Input("strength_1", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("lora_name_2", options=get_lora_files()), + io.Float.Input("strength_2", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("lora_name_3", options=get_lora_files()), + io.Float.Input("strength_3", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("lora_name_4", options=get_lora_files()), + io.Float.Input("strength_4", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("vae_name", options=get_vae_files()), + io.Combo.Input("vae_device", options=TORCH_DEVICES, default="default", advanced=True), + io.Combo.Input("vae_dtype", options=list(DTYPES.keys()), advanced=True), + ], + outputs=[ + io.Model.Output("MODEL"), + io.Clip.Output("CLIP"), + io.Vae.Output("VAE"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + kwargs["apply_bypass_lora"] = False + model, clip, vae = load_gguf_pipeline(cls, apply_loras=True, **kwargs) + + return io.NodeOutput(model, clip, vae) + + # ============================================================================== # V3 entrypoint — registers context nodes with ComfyUI # ============================================================================== @@ -392,6 +669,8 @@ class LoaderNodesExtension(ComfyExtension): TT_SdxlModelsLoaderAdvancedNode, TT_FluxModelsLoaderNode, TT_FluxModelsLoaderAdvancedNode, + TT_GgufModelsLoaderNode, + TT_GgufModelsLoaderAdvancedNode, ] @@ -408,4 +687,6 @@ __all__ = [ "TT_SdxlModelsLoaderAdvancedNode", "TT_FluxModelsLoaderNode", "TT_FluxModelsLoaderAdvancedNode", + "TT_GgufModelsLoaderNode", + "TT_GgufModelsLoaderAdvancedNode", ] diff --git a/nodes_workflow.py b/nodes_workflow.py index 994d4c5..5529c18 100644 --- a/nodes_workflow.py +++ b/nodes_workflow.py @@ -11,6 +11,8 @@ from comfy.samplers import KSampler, sampler_object, Sampler from comfy_api.latest import io, ComfyExtension from nodes import MAX_RESOLUTION +CATEGORY = "TenserTensor/Workflow" + @dataclass class TTWorkflowSettings(): @@ -128,7 +130,7 @@ class TT_Flux2WorkflowSettingsNode(io.ComfyNode): return io.Schema( node_id="TT_Flux2WorkflowSettingsNode", display_name="TT FLUX2 Workflow Settings", - category="TenserTensor/Workflow", + category=CATEGORY, description="", inputs=[ io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff), @@ -190,7 +192,7 @@ class TT_Flux2WorkflowSettingsAdvancedNode(io.ComfyNode): return io.Schema( node_id="TT_Flux2WorkflowSettingsAdvancedNode", display_name="TT FLUX2 Workflow Settings (Advanced)", - category="TenserTensor/Workflow", + category=CATEGORY, description="", inputs=[ io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff), diff --git a/web/docs/TT_FluxModelsLoaderAdvancedNode.md b/web/docs/TT_FluxModelsLoaderAdvancedNode.md new file mode 100644 index 0000000..8600f3b --- /dev/null +++ b/web/docs/TT_FluxModelsLoaderAdvancedNode.md @@ -0,0 +1,49 @@ +## TT FLUX Models Loader (Advanced) *TT_FluxModelsLoaderAdvancedNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Extended FLUX pipeline loader with sampling shift control, up to 4 LoRA slots, and advanced device/dtype configuration. + +### Inputs + +| Parameter | Type | Required | Description | +|---------------------|---------|----------|--------------------------------------------------------------------------------------------------| +| `diffusion_model` | COMBO | Yes | FLUX diffusion model checkpoint file | +| `apply_sampling` | BOOLEAN | Yes | Enable resolution-based sampling shift ("Flux Shift" / "No Shift", default: enabled) | +| `base_sampling_shift` | FLOAT | Yes | Base shift value for sampling schedule (0.0–100.0, default: 0.5) *(advanced)* | +| `max_sampling_shift` | FLOAT | Yes | Maximum shift value for sampling schedule (0.0–100.0, default: 1.15) *(advanced)* | +| `sampling_width` | INT | Yes | Reference width for shift calculation (16–MAX, step: 8, default: 1024) *(advanced)* | +| `sampling_height` | INT | Yes | Reference height for shift calculation (16–MAX, step: 8, default: 1024) *(advanced)* | +| `clip_l` | COMBO | Yes | CLIP-L text encoder file | +| `t5xxl` | COMBO | Yes | T5-XXL text encoder file | +| `clip_device` | COMBO | Yes | Device for CLIP/T5 inference (default: "default") *(advanced)* | +| `lora_name_1` | COMBO | Yes | LoRA file for slot 1 | +| `strength_1` | FLOAT | Yes | Strength for LoRA slot 1 (-10.0–10.0, default: 1.0) | +| `lora_name_2` | COMBO | Yes | LoRA file for slot 2 | +| `strength_2` | FLOAT | Yes | Strength for LoRA slot 2 (-10.0–10.0, default: 1.0) | +| `lora_name_3` | COMBO | Yes | LoRA file for slot 3 | +| `strength_3` | FLOAT | Yes | Strength for LoRA slot 3 (-10.0–10.0, default: 1.0) | +| `lora_name_4` | COMBO | Yes | LoRA file for slot 4 | +| `strength_4` | FLOAT | Yes | Strength for LoRA slot 4 (-10.0–10.0, default: 1.0) | +| `vae_name` | COMBO | Yes | VAE model file for image encoding/decoding | +| `vae_device` | COMBO | Yes | Device for VAE inference (default: "default") *(advanced)* | +| `vae_dtype` | COMBO | Yes | Data type for VAE computation *(advanced)* | + +### Outputs + +| Parameter | Type | Description | +|-----------|-------|----------------------------------| +| `MODEL` | MODEL | Loaded FLUX diffusion model | +| `CLIP` | CLIP | Loaded CLIP+T5 text encoder | +| `VAE` | VAE | Loaded VAE | + +### Usage + +Advanced version of the FLUX pipeline loader with full control over all loading parameters. Supports up to +4 simultaneous LoRAs applied at load time. Enable "Flux Shift" to apply resolution-aware sampling shift — +adjust `base_sampling_shift`, `max_sampling_shift`, and the reference resolution to tune the shift curve. +Use advanced device/dtype options to offload components to specific devices or reduce memory usage. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_FluxModelsLoaderNode.md b/web/docs/TT_FluxModelsLoaderNode.md new file mode 100644 index 0000000..eefca17 --- /dev/null +++ b/web/docs/TT_FluxModelsLoaderNode.md @@ -0,0 +1,33 @@ +## TT FLUX Models Loader *TT_FluxModelsLoaderNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Loads all components of the FLUX pipeline (diffusion model, text encoders, and VAE) in a single node. + +### Inputs + +| Parameter | Type | Required | Description | +|-------------------|-------|----------|------------------------------------------------| +| `diffusion_model` | COMBO | Yes | FLUX diffusion model checkpoint file | +| `clip_l` | COMBO | Yes | CLIP-L text encoder file | +| `t5xxl` | COMBO | Yes | T5-XXL text encoder file | +| `clip_device` | COMBO | Yes | Device for CLIP inference (default: "default") | +| `vae_name` | COMBO | Yes | VAE model file for image encoding/decoding | + +### Outputs + +| Parameter | Type | Description | +|-----------|-------|-----------------------------| +| `MODEL` | MODEL | Loaded FLUX diffusion model | +| `CLIP` | CLIP | Loaded CLIP+T5 text encoder | +| `VAE` | VAE | Loaded VAE | + +### Usage + +Convenience node for loading the complete FLUX pipeline in one step. Select the diffusion model, +both text encoders (CLIP-L and T5-XXL), and VAE from their respective dropdowns. Use `clip_device` +to offload text encoders to a specific device if needed. LoRA loading is not applied at this stage. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_GgufModelsLoaderAdvancedNode.md b/web/docs/TT_GgufModelsLoaderAdvancedNode.md new file mode 100644 index 0000000..c6219d1 --- /dev/null +++ b/web/docs/TT_GgufModelsLoaderAdvancedNode.md @@ -0,0 +1,51 @@ +## TT GGUF Models Loader (Advanced) *TT_GgufModelsLoaderAdvancedNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Extended GGUF pipeline loader with dequantization control, sampling shift, up to 4 LoRA slots, and advanced device/dtype configuration. + +### Inputs + +| Parameter | Type | Required | Description | +|-----------------------|---------|----------|--------------------------------------------------------------------------------------| +| `diffusion_model` | COMBO | Yes | GGUF-quantized diffusion model file | +| `dequant_dtype` | COMBO | Yes | Data type for dequantization (default: "bfloat16") *(advanced)* | +| `patch_dtype` | COMBO | Yes | Data type for model patches (default: "bfloat16") *(advanced)* | +| `apply_sampling` | BOOLEAN | Yes | Enable resolution-based sampling shift ("Flux Shift" / "No Shift", default: enabled) | +| `base_sampling_shift` | FLOAT | Yes | Base shift value for sampling schedule (0.0–100.0, default: 0.5) *(advanced)* | +| `max_sampling_shift` | FLOAT | Yes | Maximum shift value for sampling schedule (0.0–100.0, default: 1.15) *(advanced)* | +| `sampling_width` | INT | Yes | Reference width for shift calculation (16–MAX, step: 8, default: 1024) *(advanced)* | +| `sampling_height` | INT | Yes | Reference height for shift calculation (16–MAX, step: 8, default: 1024) *(advanced)* | +| `clip` | COMBO | Yes | GGUF-quantized text encoder file | +| `clip_device` | COMBO | Yes | Device for CLIP inference (default: "default") *(advanced)* | +| `lora_name_1` | COMBO | Yes | LoRA file for slot 1 | +| `strength_1` | FLOAT | Yes | Strength for LoRA slot 1 (-10.0–10.0, default: 1.0) | +| `lora_name_2` | COMBO | Yes | LoRA file for slot 2 | +| `strength_2` | FLOAT | Yes | Strength for LoRA slot 2 (-10.0–10.0, default: 1.0) | +| `lora_name_3` | COMBO | Yes | LoRA file for slot 3 | +| `strength_3` | FLOAT | Yes | Strength for LoRA slot 3 (-10.0–10.0, default: 1.0) | +| `lora_name_4` | COMBO | Yes | LoRA file for slot 4 | +| `strength_4` | FLOAT | Yes | Strength for LoRA slot 4 (-10.0–10.0, default: 1.0) | +| `vae_name` | COMBO | Yes | VAE model file for image encoding/decoding | +| `vae_device` | COMBO | Yes | Device for VAE inference (default: "default") *(advanced)* | +| `vae_dtype` | COMBO | Yes | Data type for VAE computation *(advanced)* | + +### Outputs + +| Parameter | Type | Description | +|-----------|-------|-----------------------------| +| `MODEL` | MODEL | Loaded GGUF diffusion model | +| `CLIP` | CLIP | Loaded GGUF text encoder | +| `VAE` | VAE | Loaded VAE | + +### Usage + +Advanced GGUF pipeline loader with full control over quantization and loading parameters. Use `dequant_dtype` +and `patch_dtype` to balance precision and VRAM usage during dequantization. Supports up to 4 simultaneous +LoRAs applied at load time. Enable "Flux Shift" to apply resolution-aware sampling shift — adjust +`base_sampling_shift`, `max_sampling_shift`, and the reference resolution to tune the shift curve. +Use advanced device options to offload components as needed. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) \ No newline at end of file diff --git a/web/docs/TT_GgufModelsLoaderNode.md b/web/docs/TT_GgufModelsLoaderNode.md new file mode 100644 index 0000000..9626aa4 --- /dev/null +++ b/web/docs/TT_GgufModelsLoaderNode.md @@ -0,0 +1,31 @@ +## TT GGUF Models Loader *TT_GgufModelsLoaderNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Loads a complete GGUF-quantized pipeline (diffusion model, text encoder, and VAE) in a single node. + +### Inputs + +| Parameter | Type | Required | Description | +|-------------------|-------|----------|---------------------------------------------| +| `diffusion_model` | COMBO | Yes | GGUF-quantized diffusion model file | +| `clip` | COMBO | Yes | GGUF-quantized text encoder file | +| `vae_name` | COMBO | Yes | VAE model file for image encoding/decoding | + +### Outputs + +| Parameter | Type | Description | +|-----------|-------|-------------------------------------| +| `MODEL` | MODEL | Loaded GGUF diffusion model | +| `CLIP` | CLIP | Loaded GGUF text encoder | +| `VAE` | VAE | Loaded VAE | + +### Usage + +Convenience node for loading a GGUF-quantized pipeline in one step. Designed for use with quantized model +files that reduce VRAM requirements. Select the GGUF diffusion model, GGUF text encoder, and VAE from +their respective dropdowns. LoRA loading is supported at this stage; bypass LoRA is not applied. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) From 81721156cb7f98f756908b1e4ca9ed69614f9191 Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Wed, 11 Mar 2026 16:41:41 +0300 Subject: [PATCH 19/20] Added new KSamplers/Loaders/ nodes w/docs, support for SD3.5 model, updated readme, many bugfixes --- README.md | 4 + node_mappings.py | 29 +- nodes/sampling/ksampler_guided.py | 2 +- nodes/sampling/ksampler_helpers.py | 7 + nodes_image.py | 14 +- nodes_latent.py | 282 ++++++++++-------- nodes_loaders.py | 172 +++++++++-- nodes_sampling.py | 103 +++++-- nodes_text_encoder.py | 148 ++++++++- nodes_workflow.py | 149 +++++++++ text_encoders.py | 1 - web/docs/TT_CannyEdgeDetectorNode.md | 28 ++ ...> TT_Flux2GgufModelsLoaderAdvancedNode.md} | 2 +- ...ode.md => TT_Flux2GgufModelsLoaderNode.md} | 2 +- web/docs/TT_GuidedKSamplerNode.md | 29 ++ web/docs/TT_GuidedKSamplerWithPreviewNode.md | 32 ++ web/docs/TT_GuidedUpscaleKSamplerNode.md | 33 ++ web/docs/TT_LatentFactoryByModelNode.md | 43 +++ .../TT_Sd35GgufModelsLoaderAdvancedNode.md | 40 +++ web/docs/TT_Sd35GgufModelsLoaderNode.md | 32 ++ web/docs/TT_SdxlModelsLoaderAdvancedNode.md | 39 +++ web/docs/TT_SdxlModelsLoaderNode.md | 34 +++ 22 files changed, 1024 insertions(+), 201 deletions(-) delete mode 100644 text_encoders.py create mode 100644 web/docs/TT_CannyEdgeDetectorNode.md rename web/docs/{TT_GgufModelsLoaderAdvancedNode.md => TT_Flux2GgufModelsLoaderAdvancedNode.md} (98%) rename web/docs/{TT_GgufModelsLoaderNode.md => TT_Flux2GgufModelsLoaderNode.md} (95%) create mode 100644 web/docs/TT_GuidedKSamplerNode.md create mode 100644 web/docs/TT_GuidedKSamplerWithPreviewNode.md create mode 100644 web/docs/TT_GuidedUpscaleKSamplerNode.md create mode 100644 web/docs/TT_LatentFactoryByModelNode.md create mode 100644 web/docs/TT_Sd35GgufModelsLoaderAdvancedNode.md create mode 100644 web/docs/TT_Sd35GgufModelsLoaderNode.md create mode 100644 web/docs/TT_SdxlModelsLoaderAdvancedNode.md create mode 100644 web/docs/TT_SdxlModelsLoaderNode.md diff --git a/README.md b/README.md index 9d57ed5..3e65687 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,10 @@ Great thanks and appreciation for your excellent work! ## Additional +> [!NOTE] +> ⚠️ **Custom Type Compatibility Notice:** The custom data types `TT_CONTEXT` and `TT_WORKFLOW_CONFIG` are **proprietary to this node pack** and are not compatible with native ComfyUI nodes or third-party nodes. +> These types can only be connected to inputs and outputs within the **ComfyUI-TenserTensor** pack. Attempting to pass them to external nodes will result in a type mismatch error. + > [!TIP] > You can download Flux.2-dev along with additional models (CLIP, VAE) via the link below: > [Comfy-Org/flux2-dev](https://huggingface.co/Comfy-Org/flux2-dev/tree/main/split_files) diff --git a/node_mappings.py b/node_mappings.py index 4ce98f1..b563251 100644 --- a/node_mappings.py +++ b/node_mappings.py @@ -80,6 +80,7 @@ try: from .nodes.sampling.ksampler_context import TT_KSamplerContext from .nodes.sampling.ksampler_two_stage import TT_KSamplerTwoStage from .nodes.sampling.ksampler_guided import TT_KSamplerGuided + from .nodes_sampling import * except ImportError: print("f{YELLOW}TenserTensor: {RED}ERROR: Sampler nodes unavailable{RESET}") @@ -164,6 +165,7 @@ NODE_CLASS_MAPPINGS = { "TT_LatentFactory": TT_LatentFactory, # Latent V3 Nodes "TT_LatentFactoryNode": TT_LatentFactoryNode, + "TT_LatentFactoryByModelNode": TT_LatentFactoryByModelNode, "TT_LatentMultiTransformNode": TT_LatentMultiTransformNode, "TT_LatentMultiTransformOnPixelSpaceNode": TT_LatentMultiTransformOnPixelSpaceNode, # Loaders @@ -178,8 +180,10 @@ NODE_CLASS_MAPPINGS = { "TT_SdxlModelsLoaderAdvancedNode": TT_SdxlModelsLoaderAdvancedNode, "TT_FluxModelsLoaderNode": TT_FluxModelsLoaderNode, "TT_FluxModelsLoaderAdvancedNode": TT_FluxModelsLoaderAdvancedNode, - "TT_GgufModelsLoaderNode": TT_GgufModelsLoaderNode, - "TT_GgufModelsLoaderAdvancedNode": TT_GgufModelsLoaderAdvancedNode, + "TT_GgufModelsLoaderNode": TT_Flux2GgufModelsLoaderNode, + "TT_GgufModelsLoaderAdvancedNode": TT_Flux2GgufModelsLoaderAdvancedNode, + "TT_Sd35GgufModelsLoaderNode": TT_Sd35GgufModelsLoaderNode, + # "TT_Sd35GgufModelsLoaderAdvancedNode": TT_Sd35GgufModelsLoaderAdvancedNode, # Postproduction "TT_ApplyLut": TT_ApplyLut, "TT_AddFilmGrain": TT_AddFilmGrain, @@ -193,6 +197,10 @@ NODE_CLASS_MAPPINGS = { "TT_KSamplerContext": TT_KSamplerContext, "TT_KSamplerTwoStage": TT_KSamplerTwoStage, "TT_KSamplerGuided": TT_KSamplerGuided, + # Samplers V3 Nodes + "TT_GuidedKSamplerNode": TT_GuidedKSamplerNode, + "TT_GuidedUpscaleKSamplerNode": TT_GuidedUpscaleKSamplerNode, + "TT_GuidedKSamplerWithPreviewNode": TT_GuidedKSamplerWithPreviewNode, # Text Encoder "TT_ClipTextEncodeFlux": TT_ClipTextEncodeFlux, "TT_ClipTextEncodeFluxContext": TT_ClipTextEncodeFluxContext, @@ -207,6 +215,8 @@ NODE_CLASS_MAPPINGS = { "TT_Flux1ClipTextEncoderContextNode": TT_Flux1ClipTextEncoderContextNode, "TT_Flux2TextEncoderNode": TT_Flux2TextEncoderNode, "TT_Flux2TextEncoderContextNode": TT_Flux2TextEncoderContextNode, + "TT_Sd35TextEncoderNode": TT_Sd35TextEncoderNode, + "TT_Sd35TextEncoderContextNode": TT_Sd35TextEncoderContextNode, # VAE "TT_VaeDecodeContext": TT_VaeDecodeContext, "TT_VaeDecodeTiled": TT_VaeDecodeTiled, @@ -222,6 +232,8 @@ NODE_CLASS_MAPPINGS = { # Workflow V3 Nodes "TT_Flux2WorkflowSettingsNode": TT_Flux2WorkflowSettingsNode, "TT_Flux2WorkflowSettingsAdvancedNode": TT_Flux2WorkflowSettingsAdvancedNode, + "TT_Sd35WorkflowSettingsNode": TT_Sd35WorkflowSettingsNode, + "TT_Sd35WorkflowSettingsAdvancedNode": TT_Sd35WorkflowSettingsAdvancedNode, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -266,8 +278,9 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TT_LatentFactory": "TT Latent Factory (Deprecated)", # Latent V3 Nodes "TT_LatentFactoryNode": "TT Latent Factory", + "TT_LatentFactoryByModelNode": "TT Latent Factory (By Model)", "TT_LatentMultiTransformNode": "TT Latent Multi Transform", - "TT_LatentMultiTransformOnPixelSpaceNode": "TT Latent MultiTransform On Pixel Space", + "TT_LatentMultiTransformOnPixelSpaceNode": "TT Latent MultiTransform (On Pixel Space)", # Loaders "TT_SdxlModelsLoader": "TT SDXL Models Loader", "TT_SdxlModelsLoaderAdvanced": "TT SDXL Models Loader (Advanced)", @@ -282,6 +295,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TT_FluxModelsLoaderAdvancedNode": "TT FLUX Models Loader (Advanced)", "TT_GgufModelsLoaderNode": "TT GGUF Models Loader", "TT_GgufModelsLoaderAdvancedNode": "TT GGUF Models Loader (Advanced)", + "TT_Sd35GgufModelsLoaderNode": "TT SD3.5 GGUF Models Loader", + # "TT_Sd35ModelsLoaderAdvancedNode": "TT SD3.5 GGUF Models Loader (Advanced)", # Postproduction "TT_ApplyLut": "TT Apply LUT", "TT_AddFilmGrain": "TT Add Film Grain", @@ -295,6 +310,10 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TT_KSamplerContext": "TT KSampler (Context)", "TT_KSamplerTwoStage": "TT KSampler (Two Stages)", "TT_KSamplerGuided": "TT KSampler (Guided)", + # Samplers V3 Nodes + "TT_GuidedKSamplerNode": "TT Guided KSampler", + "TT_GuidedUpscaleKSamplerNode": "TT Guided Upscale KSampler", + "TT_GuidedKSamplerWithPreviewNode": "TT Guided KSampler (With Preview)", # Text Encoder "TT_ClipTextEncodeFlux": "TT CLIP Text Encode FLUX", "TT_ClipTextEncodeFluxContext": "TT CLIP Text Encode FLUX (Context)", @@ -309,6 +328,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "TT_Flux1ClipTextEncoderContextNode": "TT FLUX1 CLIP Text Encoder (Context)", "TT_Flux2TextEncoderNode": "TT FLUX2 Text Encoder", "TT_Flux2TextEncoderContextNode": "TT FLUX2 Text Encoder (Context)", + "TT_Sd35TextEncoderNode": "TT SD3.5 Text Encoder", + "TT_Sd35TextEncoderContextNode": "TT SD3.5 Text Encoder (Context)", # VAE "TT_VaeDecodeContext": "TT VAE Decode (Context)", "TT_VaeDecodeTiled": "TT VAE Decode (Tiled)", @@ -324,4 +345,6 @@ NODE_DISPLAY_NAME_MAPPINGS = { # Workflow V3 Nodes "TT_Flux2WorkflowSettingsNode": "TT FLUX2 Workflow Settings", "TT_Flux2WorkflowSettingsAdvancedNode": "TT FLUX2 Workflow Settings (Advanced)", + "TT_Sd35WorkflowSettingsNode": "TT SD3.5 Workflow Settings", + "TT_Sd35WorkflowSettingsAdvancedNode": "TT SD3.5 Workflow Settings (Advanced)", } diff --git a/nodes/sampling/ksampler_guided.py b/nodes/sampling/ksampler_guided.py index 9689972..9fa3c11 100644 --- a/nodes/sampling/ksampler_guided.py +++ b/nodes/sampling/ksampler_guided.py @@ -19,7 +19,7 @@ class TT_KSamplerGuided: RETURN_TYPES = ("LATENT",) RETURN_NAMES = ("SAMPLES",) FUNCTION = "execute_sampling" - CATEGORY = "TenserTensor/Sampling" + CATEGORY = "TenserTensor/Deprecated/Sampling" def execute_sampling(self, latent, guider, sigmas, sampler, random_noise): return (guided_sample_latents(latent, guider, sigmas, sampler, random_noise),) diff --git a/nodes/sampling/ksampler_helpers.py b/nodes/sampling/ksampler_helpers.py index 86cfec8..6872b13 100644 --- a/nodes/sampling/ksampler_helpers.py +++ b/nodes/sampling/ksampler_helpers.py @@ -70,6 +70,13 @@ def guided_sample_latents(latent, guider, sigmas, sampler, random_noise): callback = LP.prepare_callback(model_patcher, sigmas.shape[-1] - 1, interim_buffer) disable_pbar = not U.PROGRESS_BAR_ENABLED + print(f"latent: {latent}") + print(f"sampler: {sampler}") + print(f"sigmas: {sigmas}") + print(f"random_noise: {random_noise}") + print(f"GUIDER: {guider}") + + samples = guider.sample( random_noise.generate_noise(latent), latent_tensor, diff --git a/nodes_image.py b/nodes_image.py index e95a1aa..3162aff 100644 --- a/nodes_image.py +++ b/nodes_image.py @@ -17,6 +17,7 @@ from comfy import model_management, utils, samplers from comfy_api.latest import ComfyExtension, io, ui from node_helpers import conditioning_set_values, pillow from .nodes_vae import vae_encode +from .nodes_text_encoder import SingleCondCFGGuider CATEGORY = "TenserTensor/Image" RESIZE_METHODS = ["nearest-exact", "bilinear", "area", "bicubic", "bislerp"] @@ -25,19 +26,6 @@ FORMAT_EXT = {"PNG": ".png", "JPEG": ".jpg", "WEBP": ".webp", } MEGAPIXELS = ["0.25 MP", "0.5 MP", "1 MP", "2 MP", "4 MP", "8 MP"] -class SingleCondCFGGuider(samplers.CFGGuider): - @classmethod - def from_cfg_guider(cls, guider): - obj = cls.__new__(cls) - obj.__dict__.update(guider.__dict__) - return obj - - @override - def set_conds(self, conditioning): - self.inner_set_conds({"positive": conditioning}) - - def get_conds(self, key="positive"): - return [[c.get("cross_attn", None), c] for c in self.original_conds[key]] def align_to_step(size, scale_factor, dimension_step): diff --git a/nodes_latent.py b/nodes_latent.py index 016e3ce..f4c01ce 100644 --- a/nodes_latent.py +++ b/nodes_latent.py @@ -6,7 +6,8 @@ from typing import Any, override import torch from comfy import sample, utils -from comfy_api.latest import IO, ComfyExtension +from comfy.latent_formats import SDXL, Flux, Flux2, SD3 +from comfy_api.latest import io, ComfyExtension from .nodes_image import MEGAPIXELS, resize_image, rotate_image, flip_image from .nodes_vae import vae_decode, vae_encode @@ -14,7 +15,12 @@ CATEGORY = "TenserTensor/Latent" ASPECT_RATIOS = ["1:1", "4:3", "3:2", "16:9", "21:9"] CLIP_MULTIPLIERS = ["1x", "2x", "4x"] -MODEL_TYPES = ["FLUX1.D", "FLUX2.D", "SDXL"] +MODEL_TYPES = { + "FLUX1.D": Flux, + "FLUX2.D": Flux2, + "SDXL": SDXL, + "SD3.X": SD3 +} ORIENTATIONS = ["landscape", "portrait"] ROTATE_ANGLES = ["90°", "180°", "270°"] SCALE_METHODS = ["nearest-exact", "bilinear", "area", "bicubic", "bislerp"] @@ -32,107 +38,147 @@ class RandomNoise: return sample.prepare_noise(latent_image, self.seed, batch_idx) -class TT_LatentFactoryNode(IO.ComfyNode): +LATENT_DIMENSION_STEP = 64 + + +def calculate_dimensions(total_pixels: int, ratio_w: int, ratio_h: int) -> tuple[int, int]: + height = int(math.sqrt(total_pixels * ratio_h / ratio_w)) + width = int(height * ratio_w / ratio_h) + width = round(width / LATENT_DIMENSION_STEP) * LATENT_DIMENSION_STEP + height = round(height / LATENT_DIMENSION_STEP) * LATENT_DIMENSION_STEP + return width, height + + +def build_latent(seed: int, batch_size: int, channels: int, latent_width: int, latent_height: int) -> torch.Tensor: + generator = torch.Generator().manual_seed(seed) + return torch.randn(batch_size, channels, latent_height, latent_width, generator=generator) + + +def create_empty_latent(**kwargs) -> tuple[dict[str, Any], int, int]: + total_pixels = int(float(kwargs.get("megapixels").split()[0]) * 1_000_000) + ratio_parts = kwargs.get("aspect_ratio").split(':') + ratio_w, ratio_h = ( + (int(ratio_parts[0]), int(ratio_parts[1])) + if kwargs.get("orientation") == "landscape" + else (int(ratio_parts[1]), int(ratio_parts[0])) + ) + width, height = calculate_dimensions(total_pixels, ratio_w, ratio_h) + + downscale_ratio, channels = None, None + + model = kwargs.get("model") + if model: + fmt = model.model.latent_format + downscale_ratio, channels = fmt.spacial_downscale_ratio, fmt.latent_channels + else: + model_type = kwargs.get("model_type") + fmt = MODEL_TYPES[model_type]() + downscale_ratio, channels = fmt.spacial_downscale_ratio, fmt.latent_channels + + latent_width, latent_height = width // downscale_ratio, height // downscale_ratio + latent = build_latent(kwargs.get("seed"), kwargs.get("batch_size"), channels, latent_width, latent_height) + + return {"samples": latent}, width, height + + +def build(**kwargs): + samples, width, height = create_empty_latent(**kwargs) + seed, noise_seed = kwargs.get("seed"), kwargs.get("noise_seed") + noise = RandomNoise(noise_seed) + multiplier = int(kwargs.get("clip_multiplier").replace('x', '')) + clip_width, clip_height = (width * multiplier, height * multiplier,) + + return { + "latent": samples, + "noise": noise, + "seed": seed, + "noise_seed": noise_seed, + "megapixels": kwargs.get("megapixels"), + "width": width, + "height": height, + "target_width": clip_width, + "target_height": clip_height, + } + + +class TT_LatentFactoryNode(io.ComfyNode): @classmethod - def define_schema(cls) -> IO.Schema: - return IO.Schema( + def define_schema(cls) -> io.Schema: + model_types = list(MODEL_TYPES.keys()) + + return io.Schema( node_id="TT_LatentFactoryNode", display_name="TT Latent Factory", category=CATEGORY, description="", inputs=[ - 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=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), - IO.Combo.Input("clip_multiplier", options=CLIP_MULTIPLIERS, advanced=True), + 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=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), + io.Combo.Input("clip_multiplier", options=CLIP_MULTIPLIERS, advanced=True), ], outputs=[ - IO.Latent.Output(display_name="LATENT"), - IO.Noise.Output(display_name="RND_NOISE"), - IO.Int.Output(display_name="SEED"), - IO.Int.Output(display_name="NOISE_SEED"), - IO.String.Output(display_name="MEGAPIXELS"), - IO.Int.Output(display_name="WIDTH"), - IO.Int.Output(display_name="HEIGHT"), - IO.Int.Output(display_name="TARGET_WIDTH"), - IO.Int.Output(display_name="TARGET_HEIGHT"), + io.Latent.Output(display_name="LATENT"), + io.Noise.Output(display_name="RND_NOISE"), + io.Int.Output(display_name="SEED"), + io.Int.Output(display_name="NOISE_SEED"), + io.String.Output(display_name="MEGAPIXELS"), + io.Int.Output(display_name="WIDTH"), + io.Int.Output(display_name="HEIGHT"), + io.Int.Output(display_name="TARGET_WIDTH"), + io.Int.Output(display_name="TARGET_HEIGHT"), ] ) - LATENT_DIMENSION_STEP = 64 - @classmethod - def calculate_dimensions(cls, total_pixels, ratio_w, ratio_h) -> tuple[int, int]: - height = int(math.sqrt(total_pixels * ratio_h / ratio_w)) - width = int(height * ratio_w / ratio_h) - width = round(width / cls.LATENT_DIMENSION_STEP) * cls.LATENT_DIMENSION_STEP - height = round(height / cls.LATENT_DIMENSION_STEP) * cls.LATENT_DIMENSION_STEP + def execute(cls, **kwargs) -> io.NodeOutput: + args = build(**kwargs) - return width, height, + return io.NodeOutput(*args.values()) + +class TT_LatentFactoryByModelNode(io.ComfyNode): @classmethod - def build_latent(cls, seed, batch_size, channels, latent_width, latent_height) -> torch.Tensor: - generator = torch.Generator().manual_seed(seed) - latent = torch.randn( - batch_size, - channels, - latent_height, - latent_width, - generator=generator + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_LatentFactoryByModelNode", + display_name="TT Latent Factory (By Model)", + category=CATEGORY, + description="", + inputs=[ + io.Model.Input("model"), + 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=MEGAPIXELS), + io.Combo.Input("orientation", options=ORIENTATIONS), + io.Int.Input("batch_size", default=1, min=1, max=64, advanced=True), + io.Combo.Input("clip_multiplier", options=CLIP_MULTIPLIERS, advanced=True), + ], + outputs=[ + io.Model.Output("MODEL"), + io.Latent.Output(display_name="LATENT"), + io.Noise.Output(display_name="RND_NOISE"), + io.Int.Output(display_name="SEED"), + io.Int.Output(display_name="NOISE_SEED"), + io.String.Output(display_name="MEGAPIXELS"), + io.Int.Output(display_name="WIDTH"), + io.Int.Output(display_name="HEIGHT"), + io.Int.Output(display_name="TARGET_WIDTH"), + io.Int.Output(display_name="TARGET_HEIGHT"), + ] ) - return latent - @classmethod - def create_empty_latent(cls, **kwargs) -> tuple[dict[str, Any], int, int]: - total_pixels = int(float(kwargs.get("megapixels").split()[0]) * 1_000_000) - ratio_parts = kwargs.get("aspect_ratio").split(':') - ratio_w, ratio_h = ( - (int(ratio_parts[0]), int(ratio_parts[1])) - if kwargs.get("orientation") == "landscape" - else (int(ratio_parts[1]), int(ratio_parts[0])) - ) - width, height = cls.calculate_dimensions(total_pixels, ratio_w, ratio_h) + def execute(cls, **kwargs) -> io.NodeOutput: + args = build(**kwargs) + args = {"model": kwargs.get("model"), **args} - scale_factor, channels = None, None - match kwargs.get("model_type"): - case "FLUX1.D": - scale_factor, channels = 8, 16 - case "FLUX2.D": - scale_factor, channels = 16, 128 - case "SDXL": - scale_factor, channels = 8, 4 - - latent_width, latent_height = (width // scale_factor, height // scale_factor,) - latent = cls.build_latent(kwargs.get("seed"), kwargs.get("batch_size"), channels, latent_width, latent_height) - - return {"samples": latent}, width, height, - - @classmethod - def execute(cls, **kwargs) -> IO.NodeOutput: - samples, width, height = cls.create_empty_latent(**kwargs) - seed, noise_seed = kwargs.get("seed"), kwargs.get("noise_seed") - noise = RandomNoise(noise_seed) - multiplier = int(kwargs.get("clip_multiplier").replace('x', '')) - clip_width, clip_height = (width * multiplier, height * multiplier,) - - args = { - "latent": samples, - "noise": noise, - "seed": seed, - "noise_seed": noise_seed, - "megapixels": kwargs.get("megapixels"), - "width": width, - "height": height, - "target_width": clip_width, - "target_height": clip_height, - } - - return IO.NodeOutput(*args.values()) + return io.NodeOutput(*args.values()) def set_latent_mask(latent, mask): @@ -169,32 +215,32 @@ def flip_latent(latent, axis): return {"samples": torch.flip(samples, dims=[2 if axis == "x" else 3])} -class TT_LatentMultiTransformNode(IO.ComfyNode): +class TT_LatentMultiTransformNode(io.ComfyNode): @classmethod - def define_schema(cls) -> IO.Schema: - return IO.Schema( + def define_schema(cls) -> io.Schema: + return io.Schema( node_id="TT_LatentMultiTransformNode", display_name="TT Latent MultiTransform", category=CATEGORY, description="", inputs=[ - IO.Latent.Input("latent"), - IO.Mask.Input("mask", optional=True), - IO.Boolean.Input("scale_latent", default=True, label_on="Scale", label_off="Skip"), - IO.Combo.Input("scale_factor", options=SCALE_FACTORS, default="1x"), - IO.Combo.Input("scale_method", options=SCALE_METHODS, default="nearest-exact"), - IO.Boolean.Input("rotate_latent", default=True, label_on="Rotate", label_off="Skip"), - IO.Combo.Input("rotate_angle", options=ROTATE_ANGLES), - IO.Boolean.Input("flip_latent", default=True, label_on="Flip", label_off="Skip"), - IO.Combo.Input("flip_direction", options=["horizontal", "vertical"]), + io.Latent.Input("latent"), + io.Mask.Input("mask", optional=True), + io.Boolean.Input("scale_latent", default=True, label_on="Scale", label_off="Skip"), + io.Combo.Input("scale_factor", options=SCALE_FACTORS, default="1x"), + io.Combo.Input("scale_method", options=SCALE_METHODS, default="nearest-exact"), + io.Boolean.Input("rotate_latent", default=True, label_on="Rotate", label_off="Skip"), + io.Combo.Input("rotate_angle", options=ROTATE_ANGLES), + io.Boolean.Input("flip_latent", default=True, label_on="Flip", label_off="Skip"), + io.Combo.Input("flip_direction", options=["horizontal", "vertical"]), ], outputs=[ - IO.Latent.Output("LATENT"), + io.Latent.Output("LATENT"), ] ) @classmethod - def execute(cls, **kwargs) -> IO.NodeOutput: + def execute(cls, **kwargs) -> io.NodeOutput: latent = kwargs.get("latent").copy() mask = kwargs.get("mask") @@ -212,36 +258,36 @@ class TT_LatentMultiTransformNode(IO.ComfyNode): axis = "x" if kwargs.get("flip_direction") == "vertical" else "y" latent = flip_latent(latent, axis) - return IO.NodeOutput(latent) + return io.NodeOutput(latent) -class TT_LatentMultiTransformOnPixelSpaceNode(IO.ComfyNode): +class TT_LatentMultiTransformOnPixelSpaceNode(io.ComfyNode): @classmethod - def define_schema(cls) -> IO.Schema: - return IO.Schema( + def define_schema(cls) -> io.Schema: + return io.Schema( node_id="TT_LatentMultiTransformOnPixelSpaceNode", - display_name="TT Latent MultiTransform On Pixel Space", + display_name="TT Latent MultiTransform (On Pixel Space)", category=CATEGORY, description="", inputs=[ - IO.Vae.Input("vae"), - IO.Latent.Input("latent"), - IO.Mask.Input("mask", optional=True), - IO.Boolean.Input("scale_latent", default=True, label_on="Scale", label_off="Skip"), - IO.Combo.Input("scale_factor", options=SCALE_FACTORS, default="1x"), - IO.Combo.Input("scale_method", options=SCALE_METHODS, default="nearest-exact"), - IO.Boolean.Input("rotate_latent", default=True, label_on="Rotate", label_off="Skip"), - IO.Combo.Input("rotate_angle", options=ROTATE_ANGLES), - IO.Boolean.Input("flip_latent", default=True, label_on="Flip", label_off="Skip"), - IO.Combo.Input("flip_direction", options=["horizontal", "vertical"]), + io.Vae.Input("vae"), + io.Latent.Input("latent"), + io.Mask.Input("mask", optional=True), + io.Boolean.Input("scale_latent", default=True, label_on="Scale", label_off="Skip"), + io.Combo.Input("scale_factor", options=SCALE_FACTORS, default="1x"), + io.Combo.Input("scale_method", options=SCALE_METHODS, default="nearest-exact"), + io.Boolean.Input("rotate_latent", default=True, label_on="Rotate", label_off="Skip"), + io.Combo.Input("rotate_angle", options=ROTATE_ANGLES), + io.Boolean.Input("flip_latent", default=True, label_on="Flip", label_off="Skip"), + io.Combo.Input("flip_direction", options=["horizontal", "vertical"]), ], outputs=[ - IO.Latent.Output("LATENT"), + io.Latent.Output("LATENT"), ] ) @classmethod - def execute(cls, **kwargs) -> IO.NodeOutput: + def execute(cls, **kwargs) -> io.NodeOutput: latent = kwargs.get("latent").copy() vae = kwargs.get("vae") pixels = vae_decode(latent, vae) @@ -270,7 +316,7 @@ class TT_LatentMultiTransformOnPixelSpaceNode(IO.ComfyNode): if mask is not None: latent = set_latent_mask(latent, mask) - return IO.NodeOutput(latent) + return io.NodeOutput(latent) # ============================================================================== @@ -279,9 +325,10 @@ class TT_LatentMultiTransformOnPixelSpaceNode(IO.ComfyNode): class LatentNodesExtension(ComfyExtension): @override - async def get_node_list(self) -> list[type[IO.ComfyNode]]: + async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ TT_LatentFactoryNode, + TT_LatentFactoryByModelNode, TT_LatentMultiTransformNode, TT_LatentMultiTransformOnPixelSpaceNode, ] @@ -297,6 +344,7 @@ async def comfy_entrypoint() -> LatentNodesExtension: __all__ = [ "TT_LatentFactoryNode", + "TT_LatentFactoryByModelNode", "TT_LatentMultiTransformNode", "TT_LatentMultiTransformOnPixelSpaceNode", ] diff --git a/nodes_loaders.py b/nodes_loaders.py index 11bf6e5..9bc6562 100644 --- a/nodes_loaders.py +++ b/nodes_loaders.py @@ -7,7 +7,6 @@ import collections import inspect -import logging import os from typing import override @@ -41,9 +40,9 @@ def get_diffusion_models_files(): def get_gguf_diffusion_models_files(): - update_folder_names_and_paths("unet_gguf", ["diffusion_models", "unet"]) + update_folder_names_and_paths("diffusion_models_gguf", "diffusion_models", {".gguf"}) - return [f for f in folder_paths.get_filename_list("unet_gguf")] + return [f for f in folder_paths.get_filename_list("diffusion_models_gguf")] def get_text_encoder_files(): @@ -51,9 +50,9 @@ def get_text_encoder_files(): def get_gguf_text_encoder_files(): - update_folder_names_and_paths("clip_gguf", ["text_encoders", "clip"]) + update_folder_names_and_paths("text_encoders_gguf", "text_encoders", {'.gguf', '.pt', '.pth', '.safetensors'}) - return [x for x in folder_paths.get_filename_list("clip_gguf")] + return [x for x in folder_paths.get_filename_list("text_encoders_gguf")] def get_vae_files(): @@ -362,6 +361,11 @@ class TT_FluxModelsLoaderNode(io.ComfyNode): class TT_FluxModelsLoaderAdvancedNode(io.ComfyNode): + loaded_lora_1 = None + loaded_lora_2 = None + loaded_lora_3 = None + loaded_lora_4 = None + @classmethod def define_schema(cls) -> io.Schema: return io.Schema( @@ -405,15 +409,16 @@ class TT_FluxModelsLoaderAdvancedNode(io.ComfyNode): return io.NodeOutput(model, clip, vae) -def update_folder_names_and_paths(key, targets=[]): - base = folder_paths.folder_names_and_paths.get(key, ([], {})) - base = base[0] if isinstance(base[0], (list, set, tuple)) else [] +def update_folder_names_and_paths(folder, base_folder=None, exts=None): + if folder in folder_paths.folder_names_and_paths: + return - target = next((x for x in targets if x in folder_paths.folder_names_and_paths), targets[0]) - orig, _ = folder_paths.folder_names_and_paths.get(target, ([], {})) - folder_paths.folder_names_and_paths[key] = (orig or base, {".gguf"}) - if base and base != orig: - logging.warning(f"Unknown file list already present on key {key}: {base}") + path = os.path.join(folder_paths.models_dir, folder) + os.makedirs(path, exist_ok=True) + paths = [path] + base_paths, base_exts = folder_paths.folder_names_and_paths.get(base_folder, ([], {})) if base_folder else ([], {}) + _exts = exts if exts is not None else base_exts + folder_paths.folder_names_and_paths[folder] = (paths + base_paths, _exts) WeightBackup = collections.namedtuple('WeightBackup', ['weight', 'inplace_update']) @@ -493,8 +498,8 @@ def load_clip_data(ckpt_paths): return clip_data -def load_clip_patcher(clip_name, type="flux2"): - clip_path = folder_paths.get_full_path("clip_gguf", clip_name) +def load_flux_clip_patcher(clip_name, type="flux2"): + clip_path = folder_paths.get_full_path("text_encoders_gguf", clip_name) clip_type = getattr(sd.CLIPType, type.upper(), sd.CLIPType.FLUX2) clip_data = load_clip_data([clip_path]) @@ -512,6 +517,27 @@ def load_clip_patcher(clip_name, type="flux2"): return clip +def load_sd3_clip_patcher(clip_l, clip_g, t5xxl, type="sd3"): + clip_l_path = folder_paths.get_full_path("text_encoders_gguf", clip_l) + clip_g_path = folder_paths.get_full_path("text_encoders_gguf", clip_g) + t5xxl_path = folder_paths.get_full_path("text_encoders_gguf", t5xxl) + clip_type = getattr(sd.CLIPType, type.upper(), sd.CLIPType.SD3) + clip_data = load_clip_data([clip_l_path, clip_g_path, t5xxl_path]) + + clip = sd.load_text_encoder_state_dicts( + clip_type=clip_type, + state_dicts=clip_data, + model_options={ + "custom_operations": GGMLOps, + "initial_device": model_management.text_encoder_offload_device() + }, + embedding_directory=folder_paths.get_folder_paths("embeddings"), + ) + clip.patcher = GGUFModelPatcher.clone(clip.patcher) + + return clip + + def apply_bypass_lora_for_models(loaded_lora, model, clip, lora_name, strength): if loaded_lora == None: lora_path = folder_paths.get_full_path_or_raise("loras", lora_name) @@ -530,7 +556,7 @@ def load_gguf_pipeline(cls, **kwargs): ops.Linear.dequant_dtype = get_dtype(kwargs.get("dequant_dtype", None)) ops.Linear.patch_dtype = get_dtype(kwargs.get("patch_dtype", None)) - gguf_full_path = folder_paths.get_full_path("unet_gguf", kwargs.get("diffusion_model")) + gguf_full_path = folder_paths.get_full_path("diffusion_models_gguf", kwargs.get("diffusion_model")) state_dict, extra = gguf_sd_loader(gguf_full_path) args = {} @@ -558,7 +584,12 @@ def load_gguf_pipeline(cls, **kwargs): } model = patch_flux_sampling(**args) - clip = load_clip_patcher(kwargs.get("clip"), kwargs.get("clip_type", "flux2")) + clip_name = kwargs.get("clip") + if clip_name: + clip = load_flux_clip_patcher(kwargs.get("clip"), kwargs.get("clip_type", "flux2")) + else: + clip_l, clip_g, t5xxl = kwargs.get("clip_l"), kwargs.get("clip_g"), kwargs.get("t5xxl") + clip = load_sd3_clip_patcher(clip_l, clip_g, t5xxl) if kwargs.get("apply_bypass_lora"): loras = [ @@ -583,12 +614,12 @@ def load_gguf_pipeline(cls, **kwargs): return model, clip, vae -class TT_GgufModelsLoaderNode(io.ComfyNode): +class TT_Flux2GgufModelsLoaderNode(io.ComfyNode): @classmethod def define_schema(cls) -> io.Schema: return io.Schema( - node_id="TT_GgufModelsLoaderNode", - display_name="TT GGUF Models Loader", + node_id="TT_Flux2GgufModelsLoaderNode", + display_name="TT FLUX2 GGUF Models Loader", category=CATEGORY, description="", inputs=[ @@ -611,12 +642,17 @@ class TT_GgufModelsLoaderNode(io.ComfyNode): return io.NodeOutput(model, clip, vae) -class TT_GgufModelsLoaderAdvancedNode(io.ComfyNode): +class TT_Flux2GgufModelsLoaderAdvancedNode(io.ComfyNode): + loaded_lora_1 = None + loaded_lora_2 = None + loaded_lora_3 = None + loaded_lora_4 = None + @classmethod def define_schema(cls) -> io.Schema: return io.Schema( - node_id="TT_GgufModelsLoaderAdvancedNode", - display_name="TT GGUF Models Loader (Advanced)", + node_id="TT_Flux2GgufModelsLoaderAdvancedNode", + display_name="TT FLUX2 GGUF Models Loader (Advanced)", category=CATEGORY, description="", inputs=[ @@ -657,6 +693,84 @@ class TT_GgufModelsLoaderAdvancedNode(io.ComfyNode): return io.NodeOutput(model, clip, vae) +class TT_Sd35GgufModelsLoaderNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_Sd35GgufModelsLoaderNode", + display_name="TT SD3.5 GGUF Models Loader", + category=CATEGORY, + description="", + inputs=[ + io.Combo.Input("diffusion_model", options=get_gguf_diffusion_models_files()), + io.Combo.Input("clip_l", options=get_gguf_text_encoder_files()), + io.Combo.Input("clip_g", options=get_gguf_text_encoder_files()), + io.Combo.Input("t5xxl", options=get_gguf_text_encoder_files()), + io.Combo.Input("vae_name", options=get_vae_files()), + ], + outputs=[ + io.Model.Output("MODEL"), + io.Clip.Output("CLIP"), + io.Vae.Output("VAE"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + kwargs["apply_bypass_lora"] = False + model, clip, vae = load_gguf_pipeline(cls, apply_loras=True, **kwargs) + + return io.NodeOutput(model, clip, vae) + + +class TT_Sd35GgufModelsLoaderAdvancedNode(io.ComfyNode): + loaded_lora_1 = None + loaded_lora_2 = None + loaded_lora_3 = None + loaded_lora_4 = None + + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_Sd35GgufModelsLoaderAdvancedNode", + display_name="TT SD3.5 GGUF Models Loader (Advanced)", + category=CATEGORY, + description="", + inputs=[ + io.Combo.Input("diffusion_model", options=get_gguf_diffusion_models_files()), + io.Combo.Input("dequant_dtype", options=list(DTYPES.keys()), default="bfloat16", advanced=True), + io.Combo.Input("patch_dtype", options=list(DTYPES.keys()), default="bfloat16", advanced=True), + io.Combo.Input("clip_l", options=get_gguf_text_encoder_files()), + io.Combo.Input("clip_g", options=get_gguf_text_encoder_files()), + io.Combo.Input("t5xxl", options=get_gguf_text_encoder_files()), + io.Combo.Input("clip_device", options=TORCH_DEVICES, default="default", advanced=True), + io.Combo.Input("lora_name_1", options=get_lora_files()), + io.Float.Input("strength_1", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("lora_name_2", options=get_lora_files()), + io.Float.Input("strength_2", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("lora_name_3", options=get_lora_files()), + io.Float.Input("strength_3", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("lora_name_4", options=get_lora_files()), + io.Float.Input("strength_4", default=1.0, min=-10.0, max=10.0, step=0.1), + io.Combo.Input("vae_name", options=get_vae_files()), + io.Combo.Input("vae_device", options=TORCH_DEVICES, default="default", advanced=True), + io.Combo.Input("vae_dtype", options=list(DTYPES.keys()), advanced=True), + ], + outputs=[ + io.Model.Output("MODEL"), + io.Clip.Output("CLIP"), + io.Vae.Output("VAE"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + kwargs["apply_bypass_lora"] = True + model, clip, vae = load_gguf_pipeline(cls, apply_loras=True, **kwargs) + + return io.NodeOutput(model, clip, vae) + + # ============================================================================== # V3 entrypoint — registers context nodes with ComfyUI # ============================================================================== @@ -669,8 +783,10 @@ class LoaderNodesExtension(ComfyExtension): TT_SdxlModelsLoaderAdvancedNode, TT_FluxModelsLoaderNode, TT_FluxModelsLoaderAdvancedNode, - TT_GgufModelsLoaderNode, - TT_GgufModelsLoaderAdvancedNode, + TT_Flux2GgufModelsLoaderNode, + TT_Flux2GgufModelsLoaderAdvancedNode, + TT_Sd35GgufModelsLoaderNode, + # TT_Sd35GgufModelsLoaderAdvancedNode, ] @@ -687,6 +803,8 @@ __all__ = [ "TT_SdxlModelsLoaderAdvancedNode", "TT_FluxModelsLoaderNode", "TT_FluxModelsLoaderAdvancedNode", - "TT_GgufModelsLoaderNode", - "TT_GgufModelsLoaderAdvancedNode", + "TT_Flux2GgufModelsLoaderNode", + "TT_Flux2GgufModelsLoaderAdvancedNode", + "TT_Sd35GgufModelsLoaderNode", + # "TT_Sd35GgufModelsLoaderAdvancedNode", ] diff --git a/nodes_sampling.py b/nodes_sampling.py index ce23174..b90b80b 100644 --- a/nodes_sampling.py +++ b/nodes_sampling.py @@ -3,22 +3,27 @@ from typing import override import latent_preview -from comfy import sample, utils, model_management -from comfy_api.latest import ComfyExtension, IO +from comfy import sample, model_management +from comfy_api.latest import ComfyExtension, io from .nodes_latent import SCALE_FACTORS, SCALE_METHODS, scale_latent +from .nodes_text_encoder import SingleCondCFGGuider CATEGORY = "TenserTensor/Sampling" def execute_guided_sampling(**kwargs): - guider, latent, sigmas, random_noise, sampler = ( + guider, latent, sigmas, random_noise, sampler, preview_latent = ( kwargs.get("guider"), kwargs.get("latent"), kwargs.get("sigmas"), kwargs.get("random_noise"), kwargs.get("sampler"), + kwargs.get("preview_latent"), ) + if not isinstance(guider, SingleCondCFGGuider): + guider = SingleCondCFGGuider.from_cfg_guider(guider) + model_patcher = guider.model_patcher latent_dict = latent.copy() latent_tensor = sample.fix_empty_latent_channels( @@ -29,8 +34,12 @@ def execute_guided_sampling(**kwargs): latent_dict["samples"] = latent_tensor noise_mask = latent.get("noise_mask", None) interim_buffer = {} - callback = latent_preview.prepare_callback(model_patcher, sigmas.shape[-1] - 1, interim_buffer) - disable_progress_bar = not utils.PROGRESS_BAR_ENABLED + if preview_latent: + latent_preview.set_preview_method("latent2rgb") + callback = latent_preview.prepare_callback(model_patcher, sigmas.shape[-1] - 1, interim_buffer) + else: + callback = None + # disable_progress_bar = not utils.PROGRESS_BAR_ENABLED samples = guider.sample( random_noise.generate_noise(latent), @@ -39,7 +48,7 @@ def execute_guided_sampling(**kwargs): sigmas, denoise_mask=noise_mask, callback=callback, - disable_pbar=disable_progress_bar, + disable_pbar=False, seed=random_noise.seed ).to(model_management.intermediate_device()) @@ -50,64 +59,92 @@ def execute_guided_sampling(**kwargs): return sampled_latent -class TT_GuidedKSamplerNode(IO.ComfyNode): +class TT_GuidedKSamplerNode(io.ComfyNode): @classmethod - def define_schema(cls) -> IO.Schema: - return IO.Schema( + def define_schema(cls) -> io.Schema: + return io.Schema( node_id="TT_GuidedKSamplerNode", display_name="TT Guided KSampler", category=CATEGORY, description="", inputs=[ - IO.Latent.Input("latent"), - IO.Guider.Input("guider"), - IO.Sigmas.Input("sigmas"), - IO.Sampler.Input("sampler"), - IO.Noise.Input("random_noise"), + io.Latent.Input("latent"), + io.Guider.Input("guider"), + io.Sigmas.Input("sigmas"), + io.Sampler.Input("sampler"), + io.Noise.Input("random_noise"), ], outputs=[ - IO.Latent.Output("SAMPLES") + io.Latent.Output("SAMPLES") ] ) @classmethod - def execute(cls, **kwargs) -> IO.NodeOutput: + def execute(cls, **kwargs) -> io.NodeOutput: samples = execute_guided_sampling(**kwargs) - return IO.NodeOutput(samples) + return io.NodeOutput(samples) -class TT_GuidedUpscaleKSamplerNode(IO.ComfyNode): +class TT_GuidedUpscaleKSamplerNode(io.ComfyNode): @classmethod - def define_schema(cls) -> IO.Schema: - return IO.Schema( + def define_schema(cls) -> io.Schema: + return io.Schema( node_id="TT_GuidedUpscaleKSamplerNode", display_name="TT Guided Upscale KSampler", category=CATEGORY, description="", inputs=[ - IO.Latent.Input("latent"), - IO.Guider.Input("guider"), - IO.Sigmas.Input("sigmas"), - IO.Sampler.Input("sampler"), - IO.Noise.Input("random_noise"), - IO.Boolean.Input("scale_latent", default=False, label_on="Scale", label_off="Skip"), - IO.Combo.Input("scale_factor", options=SCALE_FACTORS, default="1x", advanced=True), - IO.Combo.Input("scale_method", options=SCALE_METHODS, default="nearest-exact", advanced=True), + io.Latent.Input("latent"), + io.Guider.Input("guider"), + io.Sigmas.Input("sigmas"), + io.Sampler.Input("sampler"), + io.Noise.Input("random_noise"), + io.Boolean.Input("scale_latent", default=False, label_on="Scale", label_off="Skip"), + io.Combo.Input("scale_factor", options=SCALE_FACTORS, default="1x", advanced=True), + io.Combo.Input("scale_method", options=SCALE_METHODS, default="bicubic", advanced=True), ], outputs=[ - IO.Latent.Output("SAMPLES") + io.Latent.Output("SAMPLES") ] ) @classmethod - def execute(cls, **kwargs) -> IO.NodeOutput: + def execute(cls, **kwargs) -> io.NodeOutput: samples = execute_guided_sampling(**kwargs) if kwargs.get("scale_latent"): samples = scale_latent(**kwargs) - return IO.NodeOutput(samples) + return io.NodeOutput(samples) + + +class TT_GuidedKSamplerWithPreviewNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_GuidedKSamplerWithPreviewNode", + display_name="TT Guided KSampler (With Preview)", + category=CATEGORY, + description="", + inputs=[ + io.Latent.Input("latent"), + io.Guider.Input("guider"), + io.Sigmas.Input("sigmas"), + io.Sampler.Input("sampler"), + io.Noise.Input("random_noise"), + io.Boolean.Input("preview_latent", default=True, label_on="Show Peview", label_off="Only Sample"), + ], + outputs=[ + io.Latent.Output("SAMPLES") + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + samples = execute_guided_sampling(**kwargs) + + return io.NodeOutput(samples) # ============================================================================== @@ -116,10 +153,11 @@ class TT_GuidedUpscaleKSamplerNode(IO.ComfyNode): class SamplingNodesExtension(ComfyExtension): @override - async def get_node_list(self) -> list[type[IO.ComfyNode]]: + async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ TT_GuidedKSamplerNode, TT_GuidedUpscaleKSamplerNode, + TT_GuidedKSamplerWithPreviewNode, ] @@ -134,4 +172,5 @@ async def comfy_entrypoint() -> SamplingNodesExtension: __all__ = [ "TT_GuidedKSamplerNode", "TT_GuidedUpscaleKSamplerNode", + "TT_GuidedKSamplerWithPreviewNode", ] diff --git a/nodes_text_encoder.py b/nodes_text_encoder.py index 6709543..6e0c4ca 100644 --- a/nodes_text_encoder.py +++ b/nodes_text_encoder.py @@ -2,14 +2,34 @@ from typing import override +from comfy import samplers from comfy_api.latest import io, ComfyExtension from nodes import MAX_RESOLUTION from .nodes_context import Context -from .nodes_image import SingleCondCFGGuider CATEGORY = "TenserTensor/Text Encoder" +class SingleCondCFGGuider(samplers.CFGGuider): + @classmethod + def from_cfg_guider(cls, guider): + obj = cls.__new__(cls) + obj.__dict__.update(guider.__dict__) + + return obj + + @override + def set_conds(self, positive, negative=None): + dict = {"positive": positive} + if negative is not None: + dict["negative"] = negative + + self.inner_set_conds(dict) + + def get_conds(self, key="positive"): + return [[c.get("cross_attn", None), c] for c in self.original_conds[key]] + + def encode_prompts_sdxl(**kwargs): clip = kwargs.get("clip") @@ -176,10 +196,7 @@ class TT_Flux1ClipTextEncoderContextNode(io.ComfyNode): context = kwargs.get("context") args = {} - for key in ( - "clip", "clip_l_positive", "t5xxl_positive", "clip_l_negative", "t5xxl_negative", - "guidance", - ): + for key in ("clip", "clip_l_positive", "t5xxl_positive", "clip_l_negative", "t5xxl_negative", "guidance",): args[key] = context.get_attr(key) positive, negative = encode_prompts_flux(**args) @@ -210,6 +227,7 @@ def encode_prompts_flux2(**kwargs): conditioning = clip.encode_from_tokens_scheduled(tokens, add_dict={"guidance": guidance, }) guider = SingleCondCFGGuider(model) guider.set_conds(conditioning) + guider.set_cfg(kwargs.get("cfg")) return guider @@ -225,6 +243,7 @@ class TT_Flux2TextEncoderNode(io.ComfyNode): inputs=[ io.Model.Input("model"), io.Clip.Input("clip"), + io.Float.Input("cfg", default=3.0, min=0.0, max=100.0, step=0.1), io.String.Input("prompt", multiline=True, placeholder="Prompt", dynamic_prompts=True), io.String.Input("lora_triggers", multiline=True, placeholder="LoRA Triggers", dynamic_prompts=True), io.Float.Input("guidance", default=3.5, min=1.0, max=10.0, step=0.1) @@ -275,6 +294,121 @@ class TT_Flux2TextEncoderContextNode(io.ComfyNode): return io.NodeOutput(context, guider) +def encode_prompts_sd35(**kwargs): + model = kwargs.get("model") + + if model is None: + raise ValueError("ERROR: MODEL is required for text encoder") + + clip = kwargs.get("clip") + + if clip is None: + raise ValueError("ERROR: CLIP is required for text encoder") + + lora_triggers, t5xxl_positive = kwargs.get("lora_triggers"), kwargs.get("t5xxl_positive") + if lora_triggers: + t5xxl_positive = f"{t5xxl_positive}, {lora_triggers}" + + positive_tokens = clip.tokenize(kwargs.get("clip_g_positive")) + positive_tokens["l"] = clip.tokenize(kwargs.get("clip_l_positive"))["l"] + positive_tokens["t5xxl"] = clip.tokenize(t5xxl_positive)["t5xxl"] + negative_tokens = clip.tokenize(kwargs.get("clip_g_negative")) + negative_tokens["l"] = clip.tokenize(kwargs.get("clip_l_negative"))["l"] + negative_tokens["t5xxl"] = clip.tokenize(kwargs.get("t5xxl_negative"))["t5xxl"] + + add_dict = { + "width": kwargs.get("width"), + "height": kwargs.get("height"), + "target_width": kwargs.get("target_width"), + "target_height": kwargs.get("target_height"), + } + + positive = clip.encode_from_tokens_scheduled(positive_tokens, add_dict=add_dict) + negative = clip.encode_from_tokens_scheduled(negative_tokens, add_dict=add_dict) + + guider = SingleCondCFGGuider(model) + guider.set_conds(positive, negative) + guider.set_cfg(kwargs.get("cfg")) + + # print(f"POSITIVE: {positive}") + # print(f"NEGATIVE: {negative}") + # print(f"MODEL: {model}") + # print(f"GUIDER: {guider}") + + return guider + + +class TT_Sd35TextEncoderNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_Sd35TextEncoderNode", + display_name="TT SD3.5 Text Encoder", + category=CATEGORY, + description="", + inputs=[ + io.Model.Input("model"), + io.Clip.Input("clip"), + io.Float.Input("cfg", default=5.0, min=0.0, max=100.0, step=0.1), + io.String.Input("clip_l_positive", multiline=True, placeholder="CLIP_L Positive", dynamic_prompts=True), + io.String.Input("clip_g_positive", multiline=True, placeholder="CLIP_G Positive", dynamic_prompts=True), + io.String.Input("t5xxl_positive", multiline=True, placeholder="T5XXL Positive", dynamic_prompts=True), + io.String.Input("clip_l_negative", multiline=True, placeholder="CLIP_L Negative", dynamic_prompts=True), + io.String.Input("clip_g_negative", multiline=True, placeholder="CLIP_G Negative", dynamic_prompts=True), + io.String.Input("t5xxl_negative", multiline=True, placeholder="T5XXL Negative", dynamic_prompts=True), + io.String.Input("lora_triggers", multiline=True, placeholder="LoRA Triggers", dynamic_prompts=True), + io.Int.Input("width", default=512, min=0, max=MAX_RESOLUTION, step=8), + io.Int.Input("height", default=512, min=0, max=MAX_RESOLUTION, step=8), + io.Int.Input("target_width", default=512, min=0, max=MAX_RESOLUTION, step=8), + io.Int.Input("target_height", default=512, min=0, max=MAX_RESOLUTION, step=8), + ], + outputs=[ + io.Guider.Output("GUIDER"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + guider = encode_prompts_sd35(**kwargs) + + return io.NodeOutput(guider) + + +class TT_Sd35TextEncoderContextNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_Sd35TextEncoderContextNode", + display_name="TT SD3.5 Text Encoder (Context)", + category=CATEGORY, + description="", + inputs=[ + Context.Input("context") + ], + outputs=[ + Context.Output("CONTEXT"), + io.Guider.Output("GUIDER"), + ] + ) + + @classmethod + def execute(cls, context) -> io.NodeOutput: + args = {} + for key in ( + "model", "clip", "cfg", + "clip_l_positive", "clip_g_positive", "t5xxl_positive", + "clip_l_negative", "clip_g_negative", "t5xxl_negative", + "lora_triggers", + "width", "height", "target_width", "target_height", + ): + args[key] = context.get_attr(key) + + guider = encode_prompts_sd35(**args) + context.set_attr("guider", guider) + + return io.NodeOutput(context, guider) + + # ============================================================================== # V3 entrypoint — registers context nodes with ComfyUI # ============================================================================== @@ -289,6 +423,8 @@ class TextEncodeNodesExtension(ComfyExtension): TT_Flux1ClipTextEncoderContextNode, TT_Flux2TextEncoderNode, TT_Flux2TextEncoderContextNode, + TT_Sd35TextEncoderNode, + TT_Sd35TextEncoderContextNode, ] @@ -307,4 +443,6 @@ __all__ = [ "TT_Flux1ClipTextEncoderContextNode", "TT_Flux2TextEncoderNode", "TT_Flux2TextEncoderContextNode", + "TT_Sd35TextEncoderNode", + "TT_Sd35TextEncoderContextNode", ] diff --git a/nodes_workflow.py b/nodes_workflow.py index 5529c18..6186713 100644 --- a/nodes_workflow.py +++ b/nodes_workflow.py @@ -244,6 +244,151 @@ class TT_Flux2WorkflowSettingsAdvancedNode(io.ComfyNode): return io.NodeOutput(*args.values()) +def sd3_shift_sigma(sigma, schedule_shift): + return schedule_shift * sigma / (1 + (schedule_shift - 1) * sigma) + + +def build_sd3_sampler_sigmas(sampler_name, steps, schedule_shift=3.0): + sampler = sampler_object(sampler_name) + timesteps = torch.linspace(1, 0, steps + 1) + sigmas = sd3_shift_sigma(timesteps, schedule_shift) + + return sampler, sigmas + + +class TT_Sd35WorkflowSettingsNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_Sd35WorkflowSettingsNode", + display_name="TT SD3.5 Workflow Settings", + category=CATEGORY, + description="", + inputs=[ + io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff), + io.Int.Input("steps", default=30, min=1, max=10_000), + io.Float.Input("cfg", default=5.0, min=0.0, max=100.0, step=0.1), + io.Combo.Input("sampler_name", options=KSampler.SAMPLERS), + io.Float.Input("schedule_shift", default=3.0, min=0.0, max=20.0, step=0.1, advanced=True), + io.Int.Input("width", default=1024, min=16, max=MAX_RESOLUTION, step=8), + io.Int.Input("height", default=1024, min=16, max=MAX_RESOLUTION, step=8), + io.Int.Input("target_width", default=1024, min=16, max=MAX_RESOLUTION, step=8, advanced=True), + io.Int.Input("target_height", default=1024, min=16, max=MAX_RESOLUTION, step=8, advanced=True), + ], + outputs=[ + WorkflowSettings.Output(display_name="WORKFLOW_CONFIG"), + io.Sampler.Output(display_name="SAMPLER"), + io.Sigmas.Output(display_name="SIGMAS"), + io.Int.Output(display_name="SEED"), + io.Int.Output(display_name="STEPS"), + io.Float.Output(display_name="CFG"), + io.Int.Output(display_name="WIDTH"), + io.Int.Output(display_name="HEIGHT"), + io.Int.Output(display_name="TARGET_WIDTH"), + io.Int.Output(display_name="TARGET_HEIGHT"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + kwargs["sampler"], kwargs["sigmas"] = build_sd3_sampler_sigmas( + kwargs.get("sampler_name"), kwargs.get("steps"), kwargs.get("schedule_shift"), + ) + + workflow_config = TTWorkflowSettings.create(**kwargs) + args = { + "workflow_config": workflow_config, + "sampler": kwargs["sampler"], + "sigmas": kwargs["sigmas"], + "seed": kwargs.get("seed"), + "steps": kwargs.get("steps"), + "cfg": kwargs.get("cfg"), + "width": kwargs.get("width"), + "height": kwargs.get("height"), + "target_width": kwargs.get("target_width"), + "target_height": kwargs.get("target_height"), + } + + return io.NodeOutput(*args.values()) + + +class TT_Sd35WorkflowSettingsAdvancedNode(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="TT_Sd35WorkflowSettingsAdvancedNode", + display_name="TT SD3.5 Workflow Settings (Advanced)", + category=CATEGORY, + description="", + inputs=[ + io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff), + io.Int.Input("steps", default=30, min=1, max=10_000), + io.Float.Input("cfg", default=5.0, min=0.0, max=100.0, step=0.1), + io.Combo.Input("sampler_name", options=KSampler.SAMPLERS), + io.Float.Input("schedule_shift", default=3.0, min=0.0, max=20.0, step=0.1, advanced=True), + io.String.Input("clip_l_positive", multiline=True, dynamic_prompts=True), + io.String.Input("clip_g_positive", multiline=True, dynamic_prompts=True), + io.String.Input("t5xxl_positive", multiline=True, dynamic_prompts=True), + io.String.Input("clip_l_negative", multiline=True, dynamic_prompts=True), + io.String.Input("clip_g_negative", multiline=True, dynamic_prompts=True), + io.String.Input("t5xxl_negative", multiline=True, dynamic_prompts=True), + io.String.Input("lora_triggers", multiline=True, dynamic_prompts=True, advanced=True), + io.Int.Input("width", default=1024, min=16, max=MAX_RESOLUTION, step=8), + io.Int.Input("height", default=1024, min=16, max=MAX_RESOLUTION, step=8), + io.Int.Input("target_width", default=1024, min=16, max=MAX_RESOLUTION, step=8, advanced=True), + io.Int.Input("target_height", default=1024, min=16, max=MAX_RESOLUTION, step=8, advanced=True), + ], + outputs=[ + WorkflowSettings.Output(display_name="WORKFLOW_CONFIG"), + io.Sampler.Output(display_name="SAMPLER"), + io.Sigmas.Output(display_name="SIGMAS"), + io.Int.Output(display_name="SEED"), + io.Int.Output(display_name="STEPS"), + io.Float.Output(display_name="CFG"), + io.Int.Output(display_name="WIDTH"), + io.Int.Output(display_name="HEIGHT"), + io.Int.Output(display_name="TARGET_WIDTH"), + io.Int.Output(display_name="TARGET_HEIGHT"), + io.String.Output(display_name="CLIP_L_POSITIVE"), + io.String.Output(display_name="CLIP_G_POSITIVE"), + io.String.Output(display_name="T5XXL_POSITIVE"), + io.String.Output(display_name="CLIP_L_NEGATIVE"), + io.String.Output(display_name="CLIP_G_NEGATIVE"), + io.String.Output(display_name="T5XXL_NEGATIVE"), + io.String.Output(display_name="LORA_TRIGGERS"), + ] + ) + + @classmethod + def execute(cls, **kwargs) -> io.NodeOutput: + kwargs["sampler"], kwargs["sigmas"] = build_sd3_sampler_sigmas( + kwargs.get("sampler_name"), kwargs.get("steps"), kwargs.get("schedule_shift"), + ) + + workflow_config = TTWorkflowSettings.create(**kwargs) + args = { + "workflow_config": workflow_config, + "sampler": kwargs["sampler"], + "sigmas": kwargs["sigmas"], + "seed": kwargs.get("seed"), + "steps": kwargs.get("steps"), + "cfg": kwargs.get("cfg"), + "width": kwargs.get("width"), + "height": kwargs.get("height"), + "target_width": kwargs.get("target_width"), + "target_height": kwargs.get("target_height"), + "clip_l_positive": kwargs.get("clip_l_positive"), + "clip_g_positive": kwargs.get("clip_g_positive"), + "t5xxl_positive": kwargs.get("t5xxl_positive"), + "clip_l_negative": kwargs.get("clip_l_negative"), + "clip_g_negative": kwargs.get("clip_g_negative"), + "t5xxl_negative": kwargs.get("t5xxl_negative"), + "lora_triggers": kwargs.get("lora_triggers"), + } + + return io.NodeOutput(*args.values()) + + # ============================================================================== # V3 entrypoint — registers context nodes with ComfyUI # ============================================================================== @@ -254,6 +399,8 @@ class WorkflowNodesExtension(ComfyExtension): return [ TT_Flux2WorkflowSettingsNode, TT_Flux2WorkflowSettingsAdvancedNode, + TT_Sd35WorkflowSettingsNode, + TT_Sd35WorkflowSettingsAdvancedNode, ] @@ -268,4 +415,6 @@ async def comfy_entrypoint() -> WorkflowNodesExtension: __all__ = [ "TT_Flux2WorkflowSettingsNode", "TT_Flux2WorkflowSettingsAdvancedNode", + "TT_Sd35WorkflowSettingsNode", + "TT_Sd35WorkflowSettingsAdvancedNode", ] diff --git a/text_encoders.py b/text_encoders.py deleted file mode 100644 index f7d238b..0000000 --- a/text_encoders.py +++ /dev/null @@ -1 +0,0 @@ -# (c) TenserTensor || Apache-2.0 (apache.org/licenses/LICENSE-2.0) diff --git a/web/docs/TT_CannyEdgeDetectorNode.md b/web/docs/TT_CannyEdgeDetectorNode.md new file mode 100644 index 0000000..c753450 --- /dev/null +++ b/web/docs/TT_CannyEdgeDetectorNode.md @@ -0,0 +1,28 @@ +## TT Canny Edge Detector *TT_CannyEdgeDetectorNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Applies Canny edge detection to a reference image and returns the result as a ControlNet-ready image. + +### Inputs + +| Parameter | Type | Required | Description | +|-------------------|-------|----------|---------------------------------------------| +| `low_threshold` | FLOAT | Yes | Lower hysteresis threshold, default 0.4 | +| `high_threshold` | FLOAT | Yes | Upper hysteresis threshold, default 0.8 | +| `reference_image` | IMAGE | Yes | Source image, uploaded directly in the node | + +### Outputs + +| Parameter | Type | Description | +|-----------|-------|-------------------------------| +| `IMAGE` | IMAGE | Edge map ready for ControlNet | + +### Usage + +Connect `IMAGE` output to a ControlNet apply node. The source image is previewed in the node after processing. Increase `high_threshold` for fewer, +stronger edges; decrease `low_threshold` to retain more fine detail. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_GgufModelsLoaderAdvancedNode.md b/web/docs/TT_Flux2GgufModelsLoaderAdvancedNode.md similarity index 98% rename from web/docs/TT_GgufModelsLoaderAdvancedNode.md rename to web/docs/TT_Flux2GgufModelsLoaderAdvancedNode.md index c6219d1..cc4b875 100644 --- a/web/docs/TT_GgufModelsLoaderAdvancedNode.md +++ b/web/docs/TT_Flux2GgufModelsLoaderAdvancedNode.md @@ -1,4 +1,4 @@ -## TT GGUF Models Loader (Advanced) *TT_GgufModelsLoaderAdvancedNode* +## TT FLUX2 GGUF Models Loader (Advanced) *TT_Flux2GgufModelsLoaderAdvancedNode* *This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* diff --git a/web/docs/TT_GgufModelsLoaderNode.md b/web/docs/TT_Flux2GgufModelsLoaderNode.md similarity index 95% rename from web/docs/TT_GgufModelsLoaderNode.md rename to web/docs/TT_Flux2GgufModelsLoaderNode.md index 9626aa4..1d21f67 100644 --- a/web/docs/TT_GgufModelsLoaderNode.md +++ b/web/docs/TT_Flux2GgufModelsLoaderNode.md @@ -1,4 +1,4 @@ -## TT GGUF Models Loader *TT_GgufModelsLoaderNode* +## TT FLUX2 GGUF Models Loader *TT_Flux2GgufModelsLoaderNode* *This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* diff --git a/web/docs/TT_GuidedKSamplerNode.md b/web/docs/TT_GuidedKSamplerNode.md new file mode 100644 index 0000000..da4a009 --- /dev/null +++ b/web/docs/TT_GuidedKSamplerNode.md @@ -0,0 +1,29 @@ +## TT Guided KSampler *TT_GuidedKSamplerNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Runs guided diffusion sampling using a pre-configured Guider object. Accepts all sampling components as separate inputs for maximum flexibility. + +### Inputs + +| Parameter | Type | Required | Description | +|----------------|---------|----------|-------------------------------------| +| `latent` | LATENT | Yes | Input latent tensor | +| `guider` | GUIDER | Yes | Configured Guider with conditioning | +| `sigmas` | SIGMAS | Yes | Noise schedule | +| `sampler` | SAMPLER | Yes | Sampler algorithm | +| `random_noise` | NOISE | Yes | Noise generator with seed | + +### Outputs + +| Parameter | Type | Description | +|-----------|--------|-----------------------| +| `SAMPLES` | LATENT | Sampled latent tensor | + +### Usage + +Connect `GUIDER`, `SIGMAS`, and `SAMPLER` outputs from a workflow settings node. `random_noise` comes from a latent factory node. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_GuidedKSamplerWithPreviewNode.md b/web/docs/TT_GuidedKSamplerWithPreviewNode.md new file mode 100644 index 0000000..03042f2 --- /dev/null +++ b/web/docs/TT_GuidedKSamplerWithPreviewNode.md @@ -0,0 +1,32 @@ +## TT Guided KSampler (With Preview) *TT_GuidedKSamplerWithPreviewNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Runs guided diffusion sampling with optional step-by-step latent preview in the ComfyUI interface. Identical to TT Guided KSampler with an added +preview toggle. + +### Inputs + +| Parameter | Type | Required | Description | +|------------------|---------|----------|--------------------------------------------| +| `latent` | LATENT | Yes | Input latent tensor | +| `guider` | GUIDER | Yes | Configured Guider with conditioning | +| `sigmas` | SIGMAS | Yes | Noise schedule | +| `sampler` | SAMPLER | Yes | Sampler algorithm | +| `random_noise` | NOISE | Yes | Noise generator with seed | +| `preview_latent` | BOOLEAN | Yes | Show intermediate previews during sampling | + +### Outputs + +| Parameter | Type | Description | +|-----------|--------|-----------------------| +| `SAMPLES` | LATENT | Sampled latent tensor | + +### Usage + +Enable `preview_latent` to display latent previews at each sampling step in the node interface. Note: ComfyUI launches with previews disabled by +default — this node overrides that setting automatically. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_GuidedUpscaleKSamplerNode.md b/web/docs/TT_GuidedUpscaleKSamplerNode.md new file mode 100644 index 0000000..9f1c203 --- /dev/null +++ b/web/docs/TT_GuidedUpscaleKSamplerNode.md @@ -0,0 +1,33 @@ +## TT Guided Upscale KSampler *TT_GuidedUpscaleKSamplerNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Runs guided diffusion sampling with optional latent upscaling after generation. Extends the standard Guided KSampler with a built-in upscale step. + +### Inputs + +| Parameter | Type | Required | Description | +|----------------|---------|----------|----------------------------------------------------| +| `latent` | LATENT | Yes | Input latent tensor | +| `guider` | GUIDER | Yes | Configured Guider with conditioning | +| `sigmas` | SIGMAS | Yes | Noise schedule | +| `sampler` | SAMPLER | Yes | Sampler algorithm | +| `random_noise` | NOISE | Yes | Noise generator with seed | +| `scale_latent` | BOOLEAN | Yes | Enable latent upscaling after sampling | +| `scale_factor` | COMBO | No | Upscale multiplier, default `1x` (advanced) | +| `scale_method` | COMBO | No | Interpolation method, default `bicubic` (advanced) | + +### Outputs + +| Parameter | Type | Description | +|-----------|--------|-----------------------| +| `SAMPLES` | LATENT | Sampled latent tensor | + +### Usage + +Toggle `scale_latent` to enable upscaling after sampling. Scale factor and method are in advanced inputs. Use for hi-res fix workflows without a +separate upscale node. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_LatentFactoryByModelNode.md b/web/docs/TT_LatentFactoryByModelNode.md new file mode 100644 index 0000000..2b37e97 --- /dev/null +++ b/web/docs/TT_LatentFactoryByModelNode.md @@ -0,0 +1,43 @@ +## TT Latent Factory (By Model) *TT_LatentFactoryByModelNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Creates an empty latent tensor with dimensions and channel count automatically derived from the connected model. Supports any model architecture +without manual configuration. + +### Inputs + +| Parameter | Type | Required | Description | +|-------------------|-------|----------|---------------------------------------------------------------| +| `model` | MODEL | Yes | Diffusion model — latent format is read from it automatically | +| `seed` | INT | Yes | Generation seed | +| `noise_seed` | INT | Yes | Noise seed, randomized after each generation | +| `aspect_ratio` | COMBO | Yes | Aspect ratio of the output image | +| `megapixels` | COMBO | Yes | Target resolution in megapixels | +| `orientation` | COMBO | Yes | Landscape or portrait | +| `batch_size` | INT | No | Number of latents per batch, default 1 (advanced) | +| `clip_multiplier` | COMBO | No | Multiplier for CLIP target dimensions, default 1x (advanced) | + +### Outputs + +| Parameter | Type | Description | +|-----------------|--------|-----------------------------------------------| +| `MODEL` | MODEL | Passthrough model for chaining nodes | +| `LATENT` | LATENT | Empty latent tensor | +| `RND_NOISE` | NOISE | Noise generator configured with noise seed | +| `SEED` | INT | Generation seed | +| `NOISE_SEED` | INT | Noise seed | +| `MEGAPIXELS` | STRING | Selected megapixel value | +| `WIDTH` | INT | Calculated image width in pixels | +| `HEIGHT` | INT | Calculated image height in pixels | +| `TARGET_WIDTH` | INT | CLIP target width (width × clip_multiplier) | +| `TARGET_HEIGHT` | INT | CLIP target height (height × clip_multiplier) | + +### Usage + +Connect any diffusion model — the node reads latent channel count and spatial downscale ratio directly from it. Use `MODEL` passthrough output to +continue the node chain without an extra connection. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_Sd35GgufModelsLoaderAdvancedNode.md b/web/docs/TT_Sd35GgufModelsLoaderAdvancedNode.md new file mode 100644 index 0000000..ce1a7e9 --- /dev/null +++ b/web/docs/TT_Sd35GgufModelsLoaderAdvancedNode.md @@ -0,0 +1,40 @@ +## TT SD3.5 GGUF Models Loader (Advanced) *TT_Sd35GgufModelsLoaderAdvancedNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Extended GGUF loader for SD3.5 with built-in LoRA support and fine-grained quantization and VAE configuration. + +### Inputs + +| Parameter | Type | Required | Description | +|-------------------|-------|----------|---------------------------------------------------------| +| `diffusion_model` | COMBO | Yes | GGUF diffusion model file | +| `dequant_dtype` | COMBO | No | Dequantization dtype, default `bfloat16` (advanced) | +| `patch_dtype` | COMBO | No | Patch dtype, default `bfloat16` (advanced) | +| `clip_l` | COMBO | Yes | CLIP-L text encoder file | +| `clip_g` | COMBO | Yes | CLIP-G text encoder file | +| `t5xxl` | COMBO | Yes | T5-XXL text encoder file | +| `clip_device` | COMBO | No | Device for CLIP inference, default `default` (advanced) | +| `lora_name_1-4` | COMBO | No | LoRA files, up to four | +| `strength_1-4` | FLOAT | No | LoRA strength, default 1.0, range -10.0 to 10.0 | +| `vae_name` | COMBO | Yes | VAE file | +| `vae_device` | COMBO | No | Device for VAE inference, default `default` (advanced) | +| `vae_dtype` | COMBO | No | VAE data type (advanced) | + +### Outputs + +| Parameter | Type | Description | +|-----------|-------|------------------------| +| `MODEL` | MODEL | Loaded diffusion model | +| `CLIP` | CLIP | Loaded CLIP encoders | +| `VAE` | VAE | Loaded VAE | + +### Usage + +Place GGUF diffusion model files in `models/diffusion_models_gguf` or `models/diffusion_models`. Place text encoder files in +`models/text_encoders_gguf`, `models/text_encoders` or `models/clip`. Unused LoRA slots are ignored. Negative LoRA strength values invert the effect. +Adjust `dequant_dtype` and `patch_dtype` if you experience quality or memory issues with the default `bfloat16`. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_Sd35GgufModelsLoaderNode.md b/web/docs/TT_Sd35GgufModelsLoaderNode.md new file mode 100644 index 0000000..1c8c185 --- /dev/null +++ b/web/docs/TT_Sd35GgufModelsLoaderNode.md @@ -0,0 +1,32 @@ +## TT SD3.5 GGUF Models Loader *TT_Sd35GgufModelsLoaderNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Loads a quantized SD3.5 pipeline from GGUF files. Supports separate GGUF files for the diffusion model and all three text encoders. + +### Inputs + +| Parameter | Type | Required | Description | +|-------------------|-------|----------|---------------------------| +| `diffusion_model` | COMBO | Yes | GGUF diffusion model file | +| `clip_l` | COMBO | Yes | CLIP-L text encoder file | +| `clip_g` | COMBO | Yes | CLIP-G text encoder file | +| `t5xxl` | COMBO | Yes | T5-XXL text encoder file | +| `vae_name` | COMBO | Yes | VAE file | + +### Outputs + +| Parameter | Type | Description | +|-----------|-------|------------------------| +| `MODEL` | MODEL | Loaded diffusion model | +| `CLIP` | CLIP | Loaded CLIP encoders | +| `VAE` | VAE | Loaded VAE | + +### Usage + +Place GGUF diffusion model files in `models/diffusion_models_gguf` or `models/diffusion_models`. Place text encoder files in +`models/text_encoders_gguf`, `models/text_encoders` or `models/clip`. VAE is loaded from `models/vae` as a standard safetensors file. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_SdxlModelsLoaderAdvancedNode.md b/web/docs/TT_SdxlModelsLoaderAdvancedNode.md new file mode 100644 index 0000000..4a6218a --- /dev/null +++ b/web/docs/TT_SdxlModelsLoaderAdvancedNode.md @@ -0,0 +1,39 @@ +## TT SDXL Models Loader (Advanced) *TT_SdxlModelsLoaderAdvancedNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Extended SDXL loader with built-in LoRA support and VAE device and dtype configuration. Loads up to four LoRAs in a single node alongside the main +pipeline. + +### Inputs + +| Parameter | Type | Required | Description | +|------------------|-------|----------|--------------------------------------------------| +| `primary_ckpt` | COMBO | Yes | Primary checkpoint file | +| `secondary_ckpt` | COMBO | No | Secondary checkpoint for merging, `None` to skip | +| `primary_weight` | FLOAT | No | Merge weight for primary checkpoint, default 1.0 | +| `clip_l` | COMBO | Yes | CLIP-L text encoder file | +| `clip_g` | COMBO | Yes | CLIP-G text encoder file | +| `clip_device` | COMBO | No | Device for CLIP inference, default `default` | +| `lora_name_1-4` | COMBO | No | LoRA files, up to four | +| `strength_1-4` | FLOAT | No | LoRA strength, default 1.0, range -10.0 to 10.0 | +| `vae_name` | COMBO | Yes | VAE file | +| `vae_device` | COMBO | No | Device for VAE inference, default `default` | +| `vae_dtype` | COMBO | No | VAE data type | + +### Outputs + +| Parameter | Type | Description | +|-----------|-------|------------------------| +| `MODEL` | MODEL | Loaded diffusion model | +| `CLIP` | CLIP | Loaded CLIP encoders | +| `VAE` | VAE | Loaded VAE | + +### Usage + +Identical to TT SDXL Models Loader with added LoRA and VAE configuration. Unused LoRA slots are ignored. Negative LoRA strength values invert the +effect. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) diff --git a/web/docs/TT_SdxlModelsLoaderNode.md b/web/docs/TT_SdxlModelsLoaderNode.md new file mode 100644 index 0000000..b5df2d8 --- /dev/null +++ b/web/docs/TT_SdxlModelsLoaderNode.md @@ -0,0 +1,34 @@ +## TT SDXL Models Loader *TT_SdxlModelsLoaderNode* + +*This description was generated with AI assistance. If you spot any errors, please create an issue on GitHub.* + +Loads SDXL model, CLIP encoders, and VAE in a single node. Supports optional model merging with a secondary checkpoint. + +### Inputs + +| Parameter | Type | Required | Description | +|------------------|-------|----------|--------------------------------------------------| +| `primary_ckpt` | COMBO | Yes | Primary checkpoint file | +| `secondary_ckpt` | COMBO | No | Secondary checkpoint for merging, `None` to skip | +| `primary_weight` | FLOAT | No | Merge weight for primary checkpoint, default 1.0 | +| `clip_l` | COMBO | Yes | CLIP-L text encoder file | +| `clip_g` | COMBO | Yes | CLIP-G text encoder file | +| `clip_device` | COMBO | No | Device for CLIP inference, default `default` | +| `vae_name` | COMBO | Yes | VAE file | + +### Outputs + +| Parameter | Type | Description | +|-----------|-------|------------------------| +| `MODEL` | MODEL | Loaded diffusion model | +| `CLIP` | CLIP | Loaded CLIP encoders | +| `VAE` | VAE | Loaded VAE | + +### Usage + +Select a primary checkpoint and separate CLIP-L, CLIP-G encoder files. To merge two checkpoints, select a secondary checkpoint and adjust +`primary_weight` — at 1.0 only the primary is used, at 0.5 both are weighted equally. + +--- + +[View on GitHub](https://github.com/tenser-tensor/ComfyUI-TenserTensor) From 1247ce75418d05421c2529225cd3594f6ef015bc Mon Sep 17 00:00:00 2001 From: TenserTensor Date: Wed, 11 Mar 2026 17:19:02 +0300 Subject: [PATCH 20/20] Release notes, updated version --- CHANGELOG.md | 36 +++++++++++++++++++++++++++++++++--- README.md | 50 ++++++++++++++++++++++++++++++++++++++++---------- __init__.py | 2 +- pyproject.toml | 17 ++++++++++++++--- 4 files changed, 88 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecebda4..d07c00c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,39 @@ +[1.6.20] - 2026-03-11 +Added + +**TT_Sd35GgufModelsLoaderNode, TT_Sd35GgufModelsLoaderAdvancedNode** — SD3.5 GGUF model loaders with LoRA support +**TT_Sd35WorkflowSettingsNode** — workflow settings for SD3.5 with schedule shift control +**TT_Sd35TextEncoderContextNode** — SD3.5 text encoder with separate CLIP-L, CLIP-G, and T5-XXL prompts +**TT_LatentFactoryByModelNode** — latent factory with automatic format detection from connected model +**TT_GuidedKSamplerNode, TT_GuidedUpscaleKSamplerNode** — guided KSampler variants +**TT_GuidedKSamplerWithPreviewNode** — guided KSampler with step-by-step latent preview +**TT_SdxlModelsLoaderNode, TT_SdxlModelsLoaderAdvancedNode** — SDXL model loaders +**TT_FluxModelsLoaderNode, TT_FluxModelsLoaderAdvancedNode** — FLUX model loaders +**TT_SdxlClipTextEncoderNode, TT_SdxlClipTextEncoderContextNode** — SDXL text encoders +**TT_Flux1ClipTextEncoderNode, TT_Flux1ClipTextEncoderContextNode** — FLUX1 text encoders +**TT_Flux2TextEncoderNode, TT_Flux2TextEncoderContextNode** — FLUX2 text encoders +**TT_CannyEdgeDetectorNode** — Canny edge detection for ControlNet +**TT_ImageLoaderResizerNode, TT_ImagePreviewSaveNode, TT_ImagePreviewUpscaleSaveNode** — image tools +**TT_GuiderImageReferenceNode** — image reference for guided conditioning +**TT_LatentMultiTransformNode, TT_LatentMultiTransformOnPixelSpaceNode** — latent transform nodes +**TT_Flux2ApplyControlNetNode** — ControlNet support for FLUX2 + +Changed + +Latent factory refactored — channel count and spatial downscale ratio derived from model automatically +GGUF folder registration extended — unet_gguf, diffusion_models_gguf, text_encoders_gguf folders registered automatically + +Deprecated + +All migrated to API V3 nodes moved to Deprecated/ category. Will be removed in a future major release. + + [1.5.11] - 2026-03-01 Added New context nodes (API V3): Base Context, Context, FLUX2 Context, Passthrough, Set Guider / Image / Latent, Extract Encoder / Guided Sampler / VAE / Image for FLUX2 -TT_LatentFactoryNode — V3 replacement for TT_LatentFactory -TT_Flux2WorkflowSettingsNode and TT_Flux2WorkflowSettingsAdvancedNode — V3 replacements for FLUX2 workflow settings nodes +**TT_LatentFactoryNode** — V3 replacement for TT_LatentFactory +**TT_Flux2WorkflowSettingsNode** and TT_Flux2WorkflowSettingsAdvancedNode — V3 replacements for FLUX2 workflow settings nodes Changed @@ -12,7 +42,7 @@ Per-group NODES_COUNT tracking and isolated error handling on import failures Deprecated -All pre-V3 context, latent, and FLUX2 workflow nodes moved to Deprecated/ categories. Will be removed in a future major release. +All pre-V3 context, latent, and FLUX2 workflow nodes moved to Deprecated/ category. Will be removed in a future major release. ## v1.4.7 (2026-02-25) diff --git a/README.md b/README.md index 3e65687..0f137ac 100644 --- a/README.md +++ b/README.md @@ -11,22 +11,25 @@ *— Alfred Bester, The Demolished Man* -A collection of custom nodes for ComfyUI that streamline common workflows. The main goal is to reduce visual clutter by combining frequently used node chains into single, convenient loaders. +A collection of custom nodes for ComfyUI that streamline common workflows. The main goal is to reduce visual clutter by combining frequently used node +chains into single, convenient loaders. > [!IMPORTANT] -> ⚠️ **Note:** The pack is currently undergoing refactoring and migration to ComfyUI API V3. Some nodes are marked as deprecated and will be removed in a future major release. Please check node descriptions for migration guidance. +> ⚠️ **Note:** The pack is currently undergoing refactoring and migration to ComfyUI API V3. Some nodes are marked as deprecated and will be removed +> in a future major release. Please check node descriptions for migration guidance. ## Features -- **Workflow settings** — configure global workflow parameters for FLUX and SDXL -- **Model loaders** — all-in-one loaders for checkpoint, LoRA, CLIP, and VAE +- **Workflow settings** — configure global workflow parameters for FLUX, SDXL, and SD3.5 +- **Model loaders** — all-in-one loaders for checkpoint, LoRA, CLIP, and VAE; GGUF support for FLUX2 and SD3.5 - **Context nodes** — pass pipeline state between nodes as a single context object -- **Latent factory** — create latents with aspect ratio, megapixel, and model type selection -- **CLIP text encoders** — prompt encoding with model-specific controls for FLUX and SDXL -- **Samplers** — KSampler variants including guided sampling with Guider/Sigmas support -- **Image tools** — load, resize, upscale, preview, and save images +- **Latent factory** — create latents with aspect ratio, megapixel, and orientation selection; automatic format detection from model +- **CLIP text encoders** — prompt encoding with model-specific controls for FLUX, SDXL, and SD3.5 +- **Samplers** — KSampler variants including guided sampling with Guider/Sigmas support and step-by-step latent preview +- **Image tools** — load, resize, upscale, preview, save, and reference images for ControlNet - **VAE** — encode and decode with tiled and context-based variants - **Post-production** — brightness, contrast, saturation, LUT, film grain, and upscaling +- ## Installation @@ -66,8 +69,35 @@ Great thanks and appreciation for your excellent work! ## Additional > [!NOTE] -> ⚠️ **Custom Type Compatibility Notice:** The custom data types `TT_CONTEXT` and `TT_WORKFLOW_CONFIG` are **proprietary to this node pack** and are not compatible with native ComfyUI nodes or third-party nodes. -> These types can only be connected to inputs and outputs within the **ComfyUI-TenserTensor** pack. Attempting to pass them to external nodes will result in a type mismatch error. +> ⚠️ **Custom Type Compatibility Notice:** The custom data types `TT_CONTEXT` and `TT_WORKFLOW_CONFIG` are **proprietary to this node pack** and are +> not compatible with native ComfyUI nodes or third-party nodes. +> These types can only be connected to inputs and outputs within the **ComfyUI-TenserTensor** pack. Attempting to pass them to external nodes will +> result in a type mismatch error. + +> [!NOTE] +> **For GGUF users:** ComfyUI does not scan for GGUF files by default. +> Place your files according to the structure below — the node pack registers these folders automatically. + +``` +📂 ComfyUI/ +├── 📂 models/ +│ ├── 📂 diffusion_models/ +│ │ └── # GGUF diffusion models (main location) +│ ├── 📂 unet/ +│ │ └── # GGUF diffusion models (alternative location, deprecated) +│ ├── 📂 diffusion_models_gguf/ +│ │ └── # GGUF diffusion models (recommended) +│ ├── 📂 text_encoders/ +│ │ └── # Text encoders: CLIP-L, CLIP-G, T5-XXL (safetensors or GGUF, main location) +│ ├── 📂 clip/ +│ │ └── # Text encoders (alternative location, deprecated) +│ ├── 📂 text_encoders_gguf/ +│ │ └── # Text encoders (recommended) +│ ├── 📂 loras/ +│ │ └── # LoRA files +│ └── 📂 vae/ +│ └── # VAE files (safetensors) +``` > [!TIP] > You can download Flux.2-dev along with additional models (CLIP, VAE) via the link below: diff --git a/__init__.py b/__init__.py index 0444eef..59cea36 100644 --- a/__init__.py +++ b/__init__.py @@ -9,7 +9,7 @@ __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] print() SEP = f"{CYAN}{'=' * 60}{RESET}" print(SEP) -print(f"{YELLOW}TenserTensor ComfyUI Nodes Pack (v1.6.16): {BRIGHT_GREEN}{NODES_COUNT} Nodes Loaded{RESET}") +print(f"{YELLOW}TenserTensor ComfyUI Nodes Pack (v1.6.20): {BRIGHT_GREEN}{NODES_COUNT} Nodes Loaded{RESET}") print(SEP) print(f"{BRIGHT_CYAN}[V] Repository: https://github.com/tenser-tensor/ComfyUI-TenserTensor{RESET}") print(f"{BRIGHT_CYAN}[V] ComfyUI Registry: https://registry.comfy.org/publishers/tenser-tensor{RESET}") diff --git a/pyproject.toml b/pyproject.toml index d2514c1..39cab75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,19 @@ # pyproject.toml [project] -name = "ComfyUI-TenserTensor" -description = "A collection of custom nodes for ComfyUI that streamline common workflows. Includes all-in-one model loaders (checkpoint/unet, LoRA, CLIP, VAE), GGUF quantized model support (FLUX, FLUX2, SDXL), workflow settings nodes, latent factory (aspect ratio, megapixels, FLUX/SDXL), context nodes, CLIP text encoders, KSamplers (context, advanced, two-stage - draft/refine), VAE encoders/decoders (context, tiled), and post-production tools (brightness, contrast, LUT color grading, film grain, upscaler)." -version = "1.6.16" +name = """ComfyUI-TenserTensor" +description = "A collection of custom nodes for ComfyUI that streamline common workflows. + +— All-in-one model loaders: checkpoint, LoRA, CLIP, VAE +— GGUF quantized model support: FLUX, FLUX2, SDXL, SD3.5 +— Workflow settings nodes for FLUX, SDXL, and SD3.5 +— Latent factory with aspect ratio, megapixel, and automatic model format detection +— Context nodes for passing pipeline state between nodes +— CLIP text encoders for FLUX, SDXL, and SD3.5 +— KSampler variants with guided sampling and step-by-step latent preview +— VAE encoders and decoders: tiled and context-based +— Image tools: load, resize, upscale, preview, save, ControlNet reference +— Post-production: brightness, contrast, LUT color grading, film grain, upscaler""" +version = "1.6.20" license = { file = "LICENSE" } dependencies = ["gguf", "kornia"]