From 313a76fb8d83e0bc6538a1572efc78c41fa450c8 Mon Sep 17 00:00:00 2001 From: "Jialong(Bruce) Li" Date: Tue, 8 Sep 2026 04:41:32 +0800 Subject: [PATCH 01/15] Disable int8 weight-only quantization on devices without torch._int_mm (fixes MPS crash) (#16130) --- comfy/controlnet.py | 8 +-- comfy/model_management.py | 18 +++++ comfy/ops.py | 11 +++ comfy_extras/nodes_model_patch.py | 2 +- .../comfy_quant/test_mixed_precision.py | 70 +++++++++++++++++++ 5 files changed, 104 insertions(+), 5 deletions(-) diff --git a/comfy/controlnet.py b/comfy/controlnet.py index 7e35fe027..8072304aa 100644 --- a/comfy/controlnet.py +++ b/comfy/controlnet.py @@ -501,7 +501,7 @@ def controlnet_config(sd, model_options={}): operations = model_options.get("custom_operations", None) if operations is None: - operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype, disable_fast_fp8=True) + operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype, load_device=load_device, disable_fast_fp8=True) offload_device = comfy.model_management.unet_offload_device() return model_config, operations, load_device, unet_dtype, manual_cast_dtype, offload_device @@ -585,7 +585,7 @@ def load_controlnet_sd35(sd, model_options={}): operations = model_options.get("custom_operations", None) if operations is None: - operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype, disable_fast_fp8=True) + operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype, load_device=load_device, disable_fast_fp8=True) control_model = comfy.cldm.dit_embedder.ControlNetEmbedder(img_size=None, patch_size=2, @@ -683,7 +683,7 @@ def load_controlnet_qwen_fun(sd, model_options={}): operations = model_options.get("custom_operations", None) if operations is None: - operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype, disable_fast_fp8=True) + operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype, load_device=load_device, disable_fast_fp8=True) in_features = sd["control_img_in.weight"].shape[1] inner_dim = sd["control_img_in.weight"].shape[0] @@ -838,7 +838,7 @@ def load_controlnet_state_dict(state_dict, model=None, model_options={}): manual_cast_dtype = comfy.model_management.unet_manual_cast(unet_dtype, load_device) operations = model_options.get("custom_operations", None) if operations is None: - operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype) + operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype, load_device=load_device, disable_fast_fp8=True) controlnet_config["operations"] = operations controlnet_config["dtype"] = unet_dtype diff --git a/comfy/model_management.py b/comfy/model_management.py index 1eead636e..a67626cc9 100644 --- a/comfy/model_management.py +++ b/comfy/model_management.py @@ -2022,6 +2022,24 @@ def supports_fp64(device=None): return True +def supports_int8_compute(device=None): + # The eager comfy_kitchen backend implements int8 weight-only quantized + # matmul via torch._int_mm, which PyTorch does not implement for MPS. + # https://github.com/pytorch/pytorch/issues/141287 + if (device is not None and is_device_mps(device)) or mps_mode(): + return False + + if is_intel_xpu(): + return False + + if is_directml_enabled(): + return False + + if is_ixuca(): + return False + + return True + def extended_fp16_support(): # TODO: check why some models work with fp16 on newer torch versions but not on older if torch_version_numeric < (2, 7): diff --git a/comfy/ops.py b/comfy/ops.py index ff64aad59..d9df909bb 100644 --- a/comfy/ops.py +++ b/comfy/ops.py @@ -1342,6 +1342,12 @@ def mixed_precision_ops(quant_config={}, compute_dtype=torch.bfloat16, full_prec compute_dtype=compute_dtype, want_requant=want_requant, ) as (weight, bias): + if self._full_precision_mm and isinstance(weight, QuantizedTensor): + # cast_bias_weight only dequantizes on a dtype change, which is a + # no-op here when the quantized weight's orig_dtype already equals + # the compute dtype. Force it so the disabled/unsupported-format + # fallback doesn't hand a QuantizedTensor to a plain linear() call. + weight = weight.dequantize() return self._forward(input, weight, bias) with CastBiasWeightContext( @@ -1652,6 +1658,7 @@ def pick_operations(weight_dtype, compute_dtype, load_device=None, disable_fast_ fp8_compute = comfy.model_management.supports_fp8_compute(load_device) # TODO: if we support more ops this needs to be more granular nvfp4_compute = comfy.model_management.supports_nvfp4_compute(load_device) mxfp8_compute = comfy.model_management.supports_mxfp8_compute(load_device) + int8_compute = comfy.model_management.supports_int8_compute(load_device) if model_config and hasattr(model_config, 'quant_config') and model_config.quant_config: logging.info("Using mixed precision operations") @@ -1663,6 +1670,10 @@ def pick_operations(weight_dtype, compute_dtype, load_device=None, disable_fast_ if not fp8_compute: disabled.add("float8_e4m3fn") disabled.add("float8_e5m2") + if not int8_compute: + disabled.add("int8_tensorwise") + disabled.add("convrot_w4a4") + disabled.add("asym_w4a8_int8") logging.info("Native ops: {} {}".format(", ".join(QUANT_ALGOS.keys() - disabled), ", emulated ops: {}".format(", ".join(disabled)) if len(disabled) > 0 else "")) return mixed_precision_ops(model_config.quant_config, compute_dtype, disabled=disabled) diff --git a/comfy_extras/nodes_model_patch.py b/comfy_extras/nodes_model_patch.py index 82a5f58eb..eb461de63 100644 --- a/comfy_extras/nodes_model_patch.py +++ b/comfy_extras/nodes_model_patch.py @@ -283,7 +283,7 @@ class ModelPatchLoader: ) manual_cast_dtype = comfy.model_management.unet_manual_cast( dtype, load_device, supported_dtypes=[torch.bfloat16, torch.float32]) - operations = comfy.ops.pick_operations(dtype, manual_cast_dtype) + operations = comfy.ops.pick_operations(dtype, manual_cast_dtype, load_device=load_device) num_blocks = 0 while "control_blocks.{}.after_proj.weight".format(num_blocks) in sd: diff --git a/tests-unit/comfy_quant/test_mixed_precision.py b/tests-unit/comfy_quant/test_mixed_precision.py index 7bbc96616..30a446539 100644 --- a/tests-unit/comfy_quant/test_mixed_precision.py +++ b/tests-unit/comfy_quant/test_mixed_precision.py @@ -3,6 +3,7 @@ import torch import sys import os import json +from types import SimpleNamespace # Add comfy to path sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) @@ -284,6 +285,75 @@ class TestMixedPrecisionOps(unittest.TestCase): saved_conf = json.loads(saved["layer.comfy_quant"].numpy().tobytes()) self.assertTrue(saved_conf["convrot"]) + def test_int8_disabled_on_unsupported_device_falls_back_to_full_precision(self): + """On a device that can't run comfy_kitchen's fast int8 matmul (e.g. MPS, + which lacks aten::_int_mm), pick_operations must mark int8 formats as + disabled so layers dequantize instead of taking the fast quantized path.""" + import comfy.model_management as mm + + orig_supports_int8 = mm.supports_int8_compute + mm.supports_int8_compute = lambda device=None: False + try: + model_config = SimpleNamespace(quant_config={"layer": {"format": "int8_tensorwise"}}) + operations = ops.pick_operations(torch.bfloat16, torch.bfloat16, model_config=model_config) + + torch.manual_seed(789) + weight = torch.randn(16, 256, dtype=torch.bfloat16) + bias = torch.randn(16, dtype=torch.bfloat16) + q_weight = QuantizedTensor.from_float(weight, "TensorWiseINT8Layout", per_channel=True) + state_dict = { + "layer.weight": q_weight._qdata, + "layer.bias": bias, + "layer.weight_scale": q_weight._params.scale, + } + layer_quant_config = {"layer": {"format": "int8_tensorwise"}} + state_dict, _ = comfy.utils.convert_old_quants( + state_dict, + metadata={"_quantization_metadata": json.dumps({"layers": layer_quant_config})}, + ) + + model = torch.nn.Module() + model.layer = operations.Linear(256, 16, device="cpu", dtype=torch.bfloat16) + model.load_state_dict(state_dict, strict=False) + + self.assertIsInstance(model.layer.weight, QuantizedTensor) + # The layer must be forced onto the full-precision (dequantized) + # path since the fast int8 path isn't usable on this device. + self.assertTrue(model.layer._full_precision_mm) + + # The weight's orig_dtype matches the compute dtype here (both bfloat16), + # so cast_bias_weight's dtype-change check alone won't dequantize it. Confirm + # the module still hands a real Tensor (not a QuantizedTensor) to the plain + # linear() call, since dispatching a QuantizedTensor there would route back + # into the disabled fast int8 matmul instead of the full-precision fallback. + seen_weight_types = [] + orig_module_forward = model.layer._forward + def _capturing_forward(input, weight, bias, _orig=orig_module_forward): + seen_weight_types.append(type(weight)) + return _orig(input, weight, bias) + model.layer._forward = _capturing_forward + + input_tensor = torch.randn(4, 256, dtype=torch.bfloat16) + output = model.layer(input_tensor) + self.assertEqual(output.shape, (4, 16)) + self.assertEqual(seen_weight_types, [torch.Tensor]) + finally: + mm.supports_int8_compute = orig_supports_int8 + + def test_supports_int8_compute_treats_mps_mode_as_unsupported_when_device_is_none(self): + """Call sites (like pick_operations' default) may omit load_device. On an + MPS machine that must still report int8 as unsupported instead of + silently defaulting to True, matching supports_fp64's handling of the + same device=None case (see Comfy-Org/ComfyUI#16136).""" + import comfy.model_management as mm + + orig_cpu_state = mm.cpu_state + mm.cpu_state = mm.CPUState.MPS + try: + self.assertFalse(mm.supports_int8_compute(None)) + finally: + mm.cpu_state = orig_cpu_state + def test_convrot_w4a4_loads_into_params(self): """ConvRot W4A4 checkpoints must load as the dedicated kitchen layout.""" if "convrot_w4a4" not in QUANT_ALGOS: From 9ac7352f70b2206d4ef7a345b30106d0fa3807d1 Mon Sep 17 00:00:00 2001 From: guill Date: Mon, 7 Sep 2026 13:50:44 -0700 Subject: [PATCH 02/15] Fix registration issues (#15890) --- main.py | 2 +- nodes.py | 8 ++- tests-unit/main_prestartup_test.py | 68 +++++++++++++++++++ .../nodes_test/test_ignore_display_name.py | 62 +++++++++++++++++ 4 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 tests-unit/main_prestartup_test.py create mode 100644 tests-unit/nodes_test/test_ignore_display_name.py diff --git a/main.py b/main.py index 20db14a36..37e40c31f 100644 --- a/main.py +++ b/main.py @@ -196,9 +196,9 @@ def execute_prestartup_script(): return False node_paths = folder_paths.get_folder_paths("custom_nodes") + node_prestartup_times = [] for custom_node_path in node_paths: possible_modules = os.listdir(custom_node_path) - node_prestartup_times = [] for possible_module in possible_modules: module_path = os.path.join(custom_node_path, possible_module) diff --git a/nodes.py b/nodes.py index c003e6deb..e8f3d1e6e 100644 --- a/nodes.py +++ b/nodes.py @@ -2298,7 +2298,9 @@ async def load_custom_node(module_path: str, ignore=set(), module_parent="custom NODE_CLASS_MAPPINGS[name] = node_cls node_cls.RELATIVE_PYTHON_MODULE = "{}.{}".format(module_parent, get_module_name(module_path)) if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS") and getattr(module, "NODE_DISPLAY_NAME_MAPPINGS") is not None: - NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS) + for name, display_name in module.NODE_DISPLAY_NAME_MAPPINGS.items(): + if name not in ignore: + NODE_DISPLAY_NAME_MAPPINGS[name] = display_name return True # V3 Extension Definition elif hasattr(module, "comfy_entrypoint"): @@ -2325,8 +2327,8 @@ async def load_custom_node(module_path: str, ignore=set(), module_parent="custom if schema.node_id not in ignore: NODE_CLASS_MAPPINGS[schema.node_id] = node_cls node_cls.RELATIVE_PYTHON_MODULE = "{}.{}".format(module_parent, get_module_name(module_path)) - if schema.display_name is not None: - NODE_DISPLAY_NAME_MAPPINGS[schema.node_id] = schema.display_name + if schema.display_name is not None: + NODE_DISPLAY_NAME_MAPPINGS[schema.node_id] = schema.display_name return True except Exception as e: logging.warning(f"Error while calling comfy_entrypoint in {module_path}: {e}") diff --git a/tests-unit/main_prestartup_test.py b/tests-unit/main_prestartup_test.py new file mode 100644 index 000000000..a9c86aa2f --- /dev/null +++ b/tests-unit/main_prestartup_test.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import ast +import importlib +import logging +import os +from pathlib import Path +from types import SimpleNamespace +import time + +import folder_paths + + +def _load_execute_prestartup_script(): + main_path = Path(__file__).resolve().parents[1] / "main.py" + module = ast.parse(main_path.read_text(), filename=str(main_path)) + function = next(node for node in module.body if isinstance(node, ast.FunctionDef) and node.name == "execute_prestartup_script") + compiled = compile(ast.Module(body=[function], type_ignores=[]), filename=str(main_path), mode="exec") + namespace = { + "args": SimpleNamespace(disable_all_custom_nodes=False, whitelist_custom_nodes=[], enable_manager=False), + "folder_paths": folder_paths, + "importlib": importlib, + "logging": logging, + "os": os, + "time": time, + } + exec(compiled, namespace) # noqa: S102 - trusted AST extracted from main.py itself, not external input + return namespace["execute_prestartup_script"] + + +def _load_prestartup_script_for_paths(monkeypatch, custom_nodes_paths: list[str]): + monkeypatch.setattr( + folder_paths, + "get_folder_paths", + lambda name: list(custom_nodes_paths) if name == "custom_nodes" else [], + ) + return _load_execute_prestartup_script() + + +def _make_pack(root: Path, name: str) -> Path: + pack = root / name + pack.mkdir(parents=True) + (pack / "prestartup_script.py").write_text("VALUE = 1\n") + return pack + + +def test_execute_prestartup_script_handles_empty_custom_nodes_paths(monkeypatch): + execute_prestartup_script = _load_prestartup_script_for_paths(monkeypatch, []) + + execute_prestartup_script() + + +def test_execute_prestartup_script_keeps_all_timing_entries(monkeypatch, tmp_path): + first_custom_nodes = tmp_path / "custom_nodes_1" + second_custom_nodes = tmp_path / "custom_nodes_2" + pack_one = _make_pack(first_custom_nodes, "pack_one") + pack_two = _make_pack(second_custom_nodes, "pack_two") + + execute_prestartup_script = _load_prestartup_script_for_paths(monkeypatch, [str(first_custom_nodes), str(second_custom_nodes)]) + + messages: list[str] = [] + monkeypatch.setattr(logging, "info", lambda message, *args, **kwargs: messages.append(message)) + + execute_prestartup_script() + + joined = "\n".join(messages) + assert str(pack_one) in joined + assert str(pack_two) in joined diff --git a/tests-unit/nodes_test/test_ignore_display_name.py b/tests-unit/nodes_test/test_ignore_display_name.py new file mode 100644 index 000000000..010909893 --- /dev/null +++ b/tests-unit/nodes_test/test_ignore_display_name.py @@ -0,0 +1,62 @@ +import sys + +import pytest +import torch + +from comfy.cli_args import args + +if not torch.cuda.is_available(): + args.cpu = True + +import nodes + + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture(autouse=True) +def _restore_node_mappings(): + class_mappings = dict(nodes.NODE_CLASS_MAPPINGS) + display_name_mappings = dict(nodes.NODE_DISPLAY_NAME_MAPPINGS) + try: + yield + finally: + nodes.NODE_CLASS_MAPPINGS.clear() + nodes.NODE_CLASS_MAPPINGS.update(class_mappings) + nodes.NODE_DISPLAY_NAME_MAPPINGS.clear() + nodes.NODE_DISPLAY_NAME_MAPPINGS.update(display_name_mappings) + sys.modules.pop("test_v1_custom_node", None) + sys.modules.pop("test_v3_custom_node", None) + + +async def test_load_custom_node_skips_display_names_for_ignored_nodes(tmp_path, monkeypatch): + v1_module = tmp_path / "test_v1_custom_node.py" + v1_module.write_text( + "NODE_CLASS_MAPPINGS = {\"LeakTest\": object}\n" + "NODE_DISPLAY_NAME_MAPPINGS = {\"LeakTest\": \"Leak Test\"}\n", + ) + + v3_module = tmp_path / "test_v3_custom_node.py" + v3_module.write_text( + "from comfy_api.latest import ComfyExtension\n\n" + "class LeakTestV3Node:\n" + " @classmethod\n" + " def GET_SCHEMA(cls):\n" + " class Schema:\n" + " node_id = \"LeakTestV3\"\n" + " display_name = \"Leak Test V3\"\n\n" + " return Schema()\n\n\n" + "class TestExtension(ComfyExtension):\n" + " async def get_node_list(self):\n" + " return [LeakTestV3Node]\n\n\n" + "async def comfy_entrypoint():\n" + " return TestExtension()\n", + ) + + monkeypatch.syspath_prepend(str(tmp_path)) + + assert await nodes.load_custom_node(str(v1_module), ignore={"LeakTest"}) + assert await nodes.load_custom_node(str(v3_module), ignore={"LeakTestV3"}) + + assert "LeakTest" not in nodes.NODE_DISPLAY_NAME_MAPPINGS + assert "LeakTestV3" not in nodes.NODE_DISPLAY_NAME_MAPPINGS From 41db8f4fa1587d139e412a57b9b69394e3b13f95 Mon Sep 17 00:00:00 2001 From: DELUXA Date: Tue, 8 Sep 2026 00:54:46 +0300 Subject: [PATCH 03/15] Verify aotriton kernels actually launch before enabling pytorch attention (#15648) --- comfy/model_management.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/comfy/model_management.py b/comfy/model_management.py index a67626cc9..dd50f4c4e 100644 --- a/comfy/model_management.py +++ b/comfy/model_management.py @@ -499,19 +499,31 @@ try: can_use_flash_attention() evaluates runtime eligibility for the given parameters; on a ROCm build that includes checking the gpu arch against the - kernel images AOTriton was compiled for. Querying it avoids assuming where - those images live inside the torch install. The probe tensor is shaped and + arches AOTriton was built for. Querying it avoids assuming where the kernel + images live inside the torch install. The probe tensor is shaped and typed to pass the unrelated SDPA checks, so False means no hardware support rather than a rejected shape. + + It answers True on a supported arch whose kernel image was never shipped, + and that only fails at launch, without raising. So run one attention + through the flash backend and force the pending error check. """ try: + device = get_torch_device() if not torch.backends.cuda.is_flash_attention_available(): # not built with flash attention return False - q = torch.empty((1, 1, 8, 64), dtype=torch.float16, device=get_torch_device()) + q = torch.zeros((1, 1, 8, 64), dtype=torch.float16, device=device) params = torch.backends.cuda.SDPAParams(q, q, q, None, 0.0, False, False) - return torch.backends.cuda.can_use_flash_attention(params, False) - except (AttributeError, RuntimeError, TypeError) as e: - logging.warning("Could not query aotriton support: {}".format(e)) + if not torch.backends.cuda.can_use_flash_attention(params, False): + return False + from torch.nn.attention import SDPBackend, sdpa_kernel + with sdpa_kernel(SDPBackend.FLASH_ATTENTION): + torch.nn.functional.scaled_dot_product_attention(q, q, q) + torch.cuda.synchronize() + torch.zeros(1, device=device).add_(1).item() # raises if the launch above failed + return True + except Exception as e: + logging.warning("Could not run flash attention, disabling it: {}".format(e)) return False logging.info("AMD arch: {}".format(arch)) From f5ed117b88964f5536545221615889adbb8f497f Mon Sep 17 00:00:00 2001 From: comfyanonymous <121283862+comfyanonymous@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:27:58 -0700 Subject: [PATCH 04/15] Remove useless code. (#16169) --- comfy/ldm/cosmos/predict2.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/comfy/ldm/cosmos/predict2.py b/comfy/ldm/cosmos/predict2.py index d391d50b1..0f4648202 100644 --- a/comfy/ldm/cosmos/predict2.py +++ b/comfy/ldm/cosmos/predict2.py @@ -881,13 +881,6 @@ class MiniTrainDIT(nn.Module): t_embedding_B_T_D, adaln_lora_B_T_3D = self.t_embedder[1](self.t_embedder[0](timesteps_B_T).to(x_B_T_H_W_D.dtype)) t_embedding_B_T_D = self.t_embedding_norm(t_embedding_B_T_D) - # for logging purpose - affline_scale_log_info = {} - affline_scale_log_info["t_embedding_B_T_D"] = t_embedding_B_T_D.detach() - self.affline_scale_log_info = affline_scale_log_info - self.affline_emb = t_embedding_B_T_D - self.crossattn_emb = crossattn_emb - if extra_pos_emb_B_T_H_W_D_or_T_H_W_B_D is not None: assert ( x_B_T_H_W_D.shape == extra_pos_emb_B_T_H_W_D_or_T_H_W_B_D.shape From 5bbdf8a76678e2c7cfb519a49a9c3a7137fd6280 Mon Sep 17 00:00:00 2001 From: Alexis Rolland Date: Mon, 7 Sep 2026 17:05:59 -0700 Subject: [PATCH 05/15] chore: Harmonize model attention nodes (#16154) --- comfy_extras/nodes_model_advanced.py | 35 +++++++++------ comfy_extras/nodes_sparse_attention.py | 60 +++++++++++++++----------- 2 files changed, 57 insertions(+), 38 deletions(-) diff --git a/comfy_extras/nodes_model_advanced.py b/comfy_extras/nodes_model_advanced.py index c73b0ba12..5e82ef791 100644 --- a/comfy_extras/nodes_model_advanced.py +++ b/comfy_extras/nodes_model_advanced.py @@ -7,6 +7,7 @@ import comfy.ldm.modules.attention import nodes import torch import node_helpers +from comfy_api.latest import io class LCM(comfy.model_sampling.EPS): @@ -366,26 +367,34 @@ class ModelComputeDtype: return (m, ) -class ModelAttentionBackend: +class ModelAttentionBackend(io.ComfyNode): @classmethod - def INPUT_TYPES(s): + def define_schema(cls): backends = ["pytorch attention"] if comfy.ldm.modules.attention.COMFY_KITCHEN_INT8_ATTENTION_IS_AVAILABLE: backends.append("comfy kitchen attention") - return {"required": {"model": ("MODEL",), - "attention": (backends,), - }} + return io.Schema( + node_id="ModelAttentionBackend", + display_name="Model Attention Backend", + category="model/patch", + is_experimental=True, + description="Selects the dense attention implementation for the model. When used with Block Sparse Attention, this backend is used whenever sparse attention is inactive or unsupported.", + inputs=[ + io.Model.Input("model", tooltip="The model to patch."), + io.Combo.Input("attention", display_name="backend", options=backends, default="pytorch attention", + tooltip="The dense attention backend. Comfy Kitchen attention uses quantized INT8 attention and is available only on Nvidia and AMD GPUs."), + ], + outputs=[ + io.Model.Output(display_name="model", tooltip="The model with the selected attention backend."), + ], + ) @classmethod - def VALIDATE_INPUTS(s, attention): + def validate_inputs(cls, attention): return True - RETURN_TYPES = ("MODEL",) - FUNCTION = "patch" - - CATEGORY = "model/patch" - - def patch(self, model, attention): + @classmethod + def execute(cls, model, attention): attention_name = { "comfy kitchen attention": "comfy_kitchen_int8", "pytorch attention": "pytorch", @@ -396,7 +405,7 @@ class ModelAttentionBackend: attention_function = comfy.ldm.modules.attention.get_attention_function("pytorch") m = model.clone() m.set_model_optimized_attention(attention_function) - return (m, ) + return io.NodeOutput(m) NODE_CLASS_MAPPINGS = { diff --git a/comfy_extras/nodes_sparse_attention.py b/comfy_extras/nodes_sparse_attention.py index a807a40f9..441b474c9 100644 --- a/comfy_extras/nodes_sparse_attention.py +++ b/comfy_extras/nodes_sparse_attention.py @@ -354,40 +354,42 @@ class BlockSparseAttention(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="BlockSparseAttention", - display_name="Block Sparse Attention", - category="advanced/model", + display_name="Model Sparse Attention", + category="model/patch", is_experimental=True, - description="Block-sparse attention through comfy_kitchen: each query block attends a selected subset of key blocks exactly, reducing attention compute. " - "The relative speed gain grows with sequence length since short sequences are usually faster dense. " - "Outside the active schedule, dense_blocks and under min_tokens, the model uses the active dense model attention backend.", + search_aliases=["Block Sparse Attention"], + description="Applies block-sparse attention to eligible model attention layers, reducing compute for long sequences. " + "The speed gain grows with sequence length since short sequences are usually faster dense. " + "Outside the start/end_percent, dense_blocks and under min_tokens, the model uses the dense model attention backend. " + "Use the node Model Attention Backend to select that fallback.", inputs=[ - io.Model.Input("model"), - io.DynamicCombo.Input("selection", options=[ - io.DynamicCombo.Option("Sol-Attn (adaptive tau)", [ + io.Model.Input("model", tooltip="The model to patch."), + io.DynamicCombo.Input("selection", display_name="method", options=[ + io.DynamicCombo.Option("sol-attn", [ io.Float.Input("tau", default=1.3, min=0.0, max=4.0, step=0.05, tooltip="Threshold in score-distribution sigmas. Higher is sparser: " "1.0 keeps ~16% of key blocks exact, 1.5 ~7%, 2.0 ~2.7%."), ]), - io.DynamicCombo.Option("top-k (SLA)", [ + io.DynamicCombo.Option("sla", [ io.Float.Input("keep_percent", default=10.0, min=0.5, max=95.0, step=0.5, tooltip="Percent of key blocks each query block keeps exactly (sinks and " "the diagonal ride on top). The selection SLA-style LoRAs are " "distilled against; without such a LoRA higher is closer to dense."), ]), - io.DynamicCombo.Option("VSA (FastVideo)", [ + io.DynamicCombo.Option("vsa", [ io.Float.Input("keep_percent", default=10.0, min=0.5, max=95.0, step=0.5, tooltip="Percent of video cubes each query cube keeps; FastH3-VSA " "checkpoints are trained at 10. Uses the model's to_gate_compress " "layers for the coarse branch when present."), ]), - ], tooltip="How exact key blocks are chosen. " - "Sol-Attn: per head/block adaptive threshold. " - "top-k (SLA): fixed keep_percent everywhere, recommended only with trained weights. " - "VSA (FastVideo): FastH3-VSA's cube tiling and coarse branch, requires weights trained for it."), + ], tooltip="Method used to choose key blocks for full token-level attention. " + "sol-attn: Sparsifying Online Attention uses a training-free adaptive threshold for each attention head and query block. " + "sla: Sparse-Linear Attention keeps a fixed percentage of the highest-scoring key blocks; use only with model weights trained for this pattern. " + "vsa: Video Sparse Attention (FastVideo) uses 3D video-cube tiling and a learned coarse attention branch; requires FastH3 model weights."), io.Float.Input("start_percent", default=0.2, min=0.0, max=1.0, step=0.01, - tooltip="Dense before this point of the schedule."), + tooltip="Percentage point when sparse attention begins. Before this point, attention stays dense."), io.Float.Input("end_percent", default=1.0, min=0.0, max=1.0, step=0.01, - tooltip="Dense after this point of the schedule."), + tooltip="Percentage point when sparse attention ends. After this point, attention returns to dense."), io.String.Input("dense_blocks", default="", advanced=True, tooltip="Transformer blocks that always run dense, e.g. '0, 1, 47-49'."), io.Int.Input("min_tokens", default=12288, min=0, max=1 << 20, step=512, advanced=True, @@ -401,22 +403,30 @@ class BlockSparseAttention(io.ComfyNode): tooltip="MiniMax-H3 only. exact_kv: every query attends the packed text/audio/" "reference rows exactly (~3% cost). exact_kv_and_rows: additionally runs " "the target-audio query rows dense (keeps generated audio intact)."), - io.Boolean.Input("verbose", default=False, advanced=True), + io.Boolean.Input("verbose", default=False, advanced=True, + tooltip="Logs whether each attention shape used sparse attention or why it stayed dense."), ], - outputs=[io.Model.Output()], + outputs=[io.Model.Output(display_name="model", tooltip="The model with block-sparse attention applied.")], ) @classmethod def execute(cls, model, selection, start_percent, end_percent, dense_blocks="", min_tokens=12288, extra_tokens=0, sink_conditioning="exact_kv_and_rows", verbose=False) -> io.NodeOutput: mode = selection["selection"] - return io.NodeOutput(apply_block_sparse_attention( - model, tau=selection.get("tau", 1.3), - topk_ratio=0.0 if mode == "Sol-Attn (adaptive tau)" else selection["keep_percent"] / 100.0, - vsa=mode == "VSA (FastVideo)", - start_percent=start_percent, end_percent=end_percent, min_tokens=min_tokens, - dense_blocks=parse_block_list(dense_blocks), sink_conditioning=sink_conditioning, - extra_tokens=extra_tokens, verbose=verbose)) + patched_model = apply_block_sparse_attention( + model, + tau=selection.get("tau", 1.3), + topk_ratio=0.0 if mode == "sol-attn" else selection["keep_percent"] / 100.0, + vsa=mode == "vsa", + start_percent=start_percent, + end_percent=end_percent, + min_tokens=min_tokens, + dense_blocks=parse_block_list(dense_blocks), + sink_conditioning=sink_conditioning, + extra_tokens=extra_tokens, + verbose=verbose, + ) + return io.NodeOutput(patched_model) class BlockSparseAttentionExtension(ComfyExtension): From efa6c8f804bff78b46a0fd458ebd2e47bba07a30 Mon Sep 17 00:00:00 2001 From: Adam Oster <50100462+adamoster@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:02:58 +0300 Subject: [PATCH 06/15] Add LTXV generated-keyframe nodes and Freeze Latent (#16040) --- comfy_extras/nodes_lt_keyframes.py | 1051 +++++++++++++++++ nodes.py | 1 + .../nodes_lt_keyframes_test.py | 911 ++++++++++++++ 3 files changed, 1963 insertions(+) create mode 100644 comfy_extras/nodes_lt_keyframes.py create mode 100644 tests-unit/comfy_extras_test/nodes_lt_keyframes_test.py diff --git a/comfy_extras/nodes_lt_keyframes.py b/comfy_extras/nodes_lt_keyframes.py new file mode 100644 index 000000000..3cf07d8fb --- /dev/null +++ b/comfy_extras/nodes_lt_keyframes.py @@ -0,0 +1,1051 @@ +import math +import re + +import node_helpers +import torch +from comfy.ldm.lightricks.symmetric_patchifier import SymmetricPatchifier, latent_to_pixel_coords +from comfy_api.latest import ComfyExtension, io +from comfy_extras.nodes_lt import ( + LTXVAddGuide, + _append_guide_attention_entry, + conditioning_get_any_value, + get_keyframe_idxs, + get_noise_mask, +) +from typing_extensions import override + +DEFAULT_TEMPORAL_SCALE = 8 +_OCCUPIED_MASK_MAX = 1.0 - 1e-4 + + +def get_generated_keyframes(cond): + return conditioning_get_any_value(cond, "generated_keyframes", None) + + +def _parse_frame_index_list(value, field, expected_count, first, last, expected_desc, empty_hint): + """Parse a manual frame index override and validate count, uniqueness and range. + + Order is not significant — only the set of positions matters — so the list + is returned as written rather than sorted. ``expected_count`` may be None + when the list itself defines how many keyframes to add. + """ + parts = [part for part in re.split(r"[,\s]+", value.strip()) if part] + if not parts: + raise ValueError( + f"{field} is empty. Provide at least one index, or leave {field} empty {empty_hint}." + ) + try: + indices = [int(part) for part in parts] + except ValueError: + bad = ", ".join(repr(part) for part in parts if not re.fullmatch(r"-?\d+", part)) + raise ValueError( + f"{field} must be a comma-separated list of integers, but could not parse {bad}." + ) from None + + if expected_count is not None and len(indices) != expected_count: + raise ValueError( + f"{field} lists {len(indices)} index/indices but {expected_desc}. Provide exactly " + f"{expected_count}, or leave {field} empty {empty_hint}." + ) + + duplicates = sorted({index for index in indices if indices.count(index) > 1}) + if duplicates: + raise ValueError( + f"{field} must not place two keyframes on the same pixel frame, but " + f"{', '.join(str(index) for index in duplicates)} appear(s) more than once." + ) + + out_of_range = [index for index in indices if not first <= index <= last] + if out_of_range: + raise ValueError( + f"{field} must lie between {first} and {last}, but got " + f"{', '.join(str(index) for index in out_of_range)}." + ) + return indices + + +def _grow_guide_attention_entry(positive, negative, index, extra_pre_filter_count, extra_frames): + """Grow an existing guide_attention_entry when extending generated keyframes.""" + results = [] + for cond in (positive, negative): + existing = [] + for t in cond: + found = t[1].get("guide_attention_entries", None) + if found is not None: + existing = found + break + if index >= len(existing): + raise ValueError( + f"The generated keyframes recorded guide entry {index} but the conditioning only has " + f"{len(existing)}. The conditioning was rebuilt after they were added." + ) + entries = list(existing) + grown = dict(entries[index]) + grown["pre_filter_count"] = grown["pre_filter_count"] + extra_pre_filter_count + shape = list(grown["latent_shape"]) + shape[0] = shape[0] + extra_frames + grown["latent_shape"] = shape + entries[index] = grown + results.append(node_helpers.conditioning_set_values(cond, {"guide_attention_entries": entries})) + return results[0], results[1] + + +def _spaced_positions_keep_last(num_keyframes: int, num_frames: int) -> list[int]: + return ( + torch.linspace(0, num_frames - 1, num_keyframes + 1) + .round() + .to(torch.int64) + .tolist()[1:] + ) + + +def detailing_positions(num_frames: int, interval_frames: float) -> list[int]: + """About one detailing keyframe every ``interval_frames`` pixel frames. + + Skips frame 0 (already a standalone token) and keeps the last frame. + ``interval_frames`` 24 is about 1/s at 24 fps. + """ + if interval_frames <= 0: + raise ValueError(f"interval_frames must be > 0, got {interval_frames}") + if num_frames <= 1: + raise ValueError( + f"A {num_frames}-frame target has no pixel frames to place keyframes on." + ) + count = max(1, round((num_frames - 1) / interval_frames)) + positions = [index for index in _spaced_positions_keep_last(count, num_frames) if index != 0] + if not positions: + raise ValueError( + f"A {num_frames}-frame target has no pixel frames to place keyframes on." + ) + return positions + + +def free_detailing_slots(num_frames: int, interval_frames: float, occupied: set[int]) -> list[int]: + """Density candidates that are not already I2V / guides / detailing KFs.""" + taken = set(occupied) + positions = [ + index + for index in detailing_positions(num_frames, interval_frames) + if index not in taken + ] + if not positions: + raise ValueError( + "Every candidate detailing-keyframe pixel already has an image keyframe " + "or a guide. Leave at least one unoccupied frame, or pass frame_indices." + ) + return positions + + +def scale_frame_indices(indices: list[int], old_num_frames: int, new_num_frames: int) -> list[int]: + """Map pixel indices from one canvas length onto another (e.g. temporal x2).""" + if old_num_frames <= 1: + raise ValueError(f"Cannot scale keyframe indices from a {old_num_frames}-frame canvas.") + if new_num_frames <= 1: + raise ValueError(f"Cannot scale keyframe indices onto a {new_num_frames}-frame canvas.") + scale = (new_num_frames - 1) / (old_num_frames - 1) + remapped = [int(round(index * scale)) for index in indices] + duplicates = sorted({index for index in remapped if remapped.count(index) > 1}) + if duplicates: + raise ValueError( + "Scaling keyframe indices onto the new canvas collapsed " + f"{', '.join(str(index) for index in duplicates)} onto the same pixel frame." + ) + return remapped + + +def _as_int_set(value) -> set[int]: + if value is None: + return set() + if isinstance(value, (int, float)): + return {int(round(value))} + if isinstance(value, (set, frozenset)): + return {int(round(item)) for item in value} + if isinstance(value, (list, tuple)): + out = set() + for item in value: + out |= _as_int_set(item) + return out + if hasattr(value, "detach"): + value = value.detach() + if hasattr(value, "cpu"): + value = value.cpu() + if hasattr(value, "tolist"): + return _as_int_set(value.tolist()) + return {int(round(float(value)))} + + +def pixel_frames_from_keyframe_idxs(keyframe_idxs) -> set[int]: + """Unique RoPE *start* pixel times of extra guide / keyframe tokens. + + ``keyframe_idxs`` is ``(B, 3, tokens, 2)`` (t/h/w × start/end). A keyframe + at pixel 24 spans the half-open interval ``[24, 25)``, so only 24 is + occupied — the exclusive end is not a slot. + """ + if keyframe_idxs is None: + return set() + if keyframe_idxs.ndim >= 4: + starts = keyframe_idxs[:, 0, :, 0] + else: + starts = keyframe_idxs[:, 0] + return _as_int_set(starts) + + +def occupied_pixel_frames(latent, temporal_scale: int, num_frames: int, video_latent_frames=None) -> set[int]: + """Pixel frames that already hold an in-place image keyframe. + + Prefers ``noise_mask`` (0 means frozen / guided). Falls back to + non-zero latent frames when no mask is present. Each occupied latent + index maps to its representative pixel: 0, ``t * scale``, or last frame. + + ``video_latent_frames`` limits the scan to the video portion of T so + appended guide / detailing-keyframe tokens are not treated as video frames. + Extra-token pixel times come from ``pixel_frames_from_keyframe_idxs``. + """ + samples = latent["samples"] + latent_frames = samples.shape[2] + if video_latent_frames is None: + scan_frames = latent_frames + else: + scan_frames = min(max(int(video_latent_frames), 0), latent_frames) + taken = set() + + def add_latent_index(index: int) -> None: + if index <= 0: + pixel = 0 + elif index >= scan_frames - 1: + pixel = num_frames - 1 + else: + pixel = index * temporal_scale + taken.add(min(max(pixel, 0), num_frames - 1)) + + mask = latent.get("noise_mask") + if mask is not None and getattr(mask, "ndim", 0) >= 3: + for index in range(min(mask.shape[2], scan_frames)): + if torch.any(mask[:, :, index : index + 1] < _OCCUPIED_MASK_MAX): + add_latent_index(index) + return taken + + for index in range(scan_frames): + if torch.any(samples[:, :, index : index + 1] != 0): + add_latent_index(index) + return taken + + +def nearest_latent_index(pixel_frame: int, temporal_scale: int, num_latent_frames: int) -> int: + return min(max(round(pixel_frame / temporal_scale), 0), num_latent_frames - 1) + + +def should_copy_nearest_video_frames(keyframe_t, requested_count, has_recorded_indices, batched_singles): + """True when ``keyframes`` is a longer video to sample, not stacked keyframes.""" + return ( + not has_recorded_indices + and requested_count is not None + and not batched_singles + and keyframe_t > requested_count + ) + + +def keyframes_from_video(samples, indices, temporal_scale: int): + """Stack the nearest video latent frame at each pixel index.""" + if not torch.is_tensor(samples) or samples.ndim != 5: + raise ValueError( + "Initializing keyframes from a video needs a plain 5D video latent. " + "Split audio with Separate AV Latent first, and peel generated " + "keyframes before copying from the video." + ) + temporal_scale = int(temporal_scale) + if temporal_scale < 1: + raise ValueError(f"temporal_scale must be >= 1, got {temporal_scale}") + num_latent_frames = samples.shape[2] + if num_latent_frames < 1: + raise ValueError("The video latent has no frames to copy from.") + frames = [] + for pixel_frame in indices: + idx = nearest_latent_index(pixel_frame, temporal_scale, num_latent_frames) + frames.append(samples[:, :, idx : idx + 1]) + return torch.cat(frames, dim=2) + + +def _fit_keyframe_samples(keyframe_samples, samples, num_slots): + """Pad stacked keyframe tokens with zeros up to ``num_slots``; reject extras.""" + expected = ( + samples.shape[0], + samples.shape[1], + num_slots, + samples.shape[3], + samples.shape[4], + ) + have = keyframe_samples.shape[2] + if ( + keyframe_samples.shape[0] != expected[0] + or keyframe_samples.shape[1] != expected[1] + or keyframe_samples.shape[3:] != expected[3:] + ): + raise ValueError( + "The keyframes latent must hold whole latent frames at this latent's shape, expected " + f"{list(expected)} but got {list(keyframe_samples.shape)}. Resize it to this stage's " + "resolution first." + ) + if have > num_slots: + raise ValueError( + f"The keyframes latent holds {have} frame(s) but only {num_slots} free slot(s) " + "are available on this canvas. Pass fewer keyframes, or set frame_indices." + ) + if have < num_slots: + pad = torch.zeros( + (expected[0], expected[1], num_slots - have, expected[3], expected[4]), + dtype=keyframe_samples.dtype, + device=keyframe_samples.device, + ) + keyframe_samples = torch.cat([keyframe_samples, pad], dim=2) + return keyframe_samples + + +class LTXVAddGeneratedKeyframes(io.ComfyNode): + PATCHIFIER = SymmetricPatchifier(1, start_end=True) + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="LTXVAddGeneratedKeyframes", + display_name="LTXV Add Generated Keyframes", + category="model/conditioning/ltxv", + search_aliases=["detailing", "dfr", "generated keyframes"], + description=( + "Append detailing keyframes to a video latent. Each keyframe is one latent " + "frame of tokens whose RoPE position spans a single pixel frame; they are " + "denoised with the video and are not part of the decoded output. Placement " + "is one slot every interval_frames pixels, skipping I2V frames, existing " + "guides, and detailing keyframes already on the cond. Connected keyframes " + "are content only and are re-placed on this canvas unless frame_indices is " + "set. Pull them back out with LTXV Separate Generated Keyframes. Requires a " + "checkpoint trained for generated keyframes (one carrying " + "keyframes_abs_pos_embedding)." + ), + inputs=[ + io.Conditioning.Input( + "positive", + tooltip="Positive conditioning the keyframes are attached to.", + ), + io.Conditioning.Input( + "negative", + tooltip="Negative conditioning the keyframes are attached to.", + ), + io.Vae.Input( + "vae", tooltip="Only used to read the latent scale factors." + ), + io.Latent.Input( + "latent", + tooltip=( + "Plain 5D video latent to generate keyframes alongside. Add them " + "before Concat AV Latent." + ), + ), + io.Int.Input( + "interval_frames", + optional=True, + default=24, + min=1, + max=1024, + tooltip=( + "Pixel-frame stride for auto placement. Default 24 is about one " + "keyframe per second at 24 fps. Occupied pixels are skipped. " + "Ignored when frame_indices is set." + ), + ), + io.Latent.Input( + "keyframes", + optional=True, + tooltip=( + "Optional content to initialize the new keyframes with. Connect " + "keyframes from an earlier Separate (same spatial size), or a " + "plain video latent to copy the nearest frame at each new slot " + "(e.g. after temporal upscale). These are still denoised, not " + "pinned as guides. Recorded indices on a keyframes latent are " + "ignored unless frame_indices is set. Only has an effect when " + "sampling starts below sigma 1." + ), + ), + io.String.Input( + "frame_indices", + optional=True, + default="", + tooltip=( + "Optional pixel-frame indices. Leave empty to place from " + "interval_frames on the current canvas. When set, this list is " + "the placement (connected keyframes are matched in order). The " + "last frame is allowed; frame 0 is not (it is already a " + "standalone token)." + ), + ), + ], + outputs=[ + io.Conditioning.Output( + display_name="positive", + tooltip="Positive conditioning with generated-keyframe attention attached.", + ), + io.Conditioning.Output( + display_name="negative", + tooltip="Negative conditioning with generated-keyframe attention attached.", + ), + io.Latent.Output( + display_name="latent", + tooltip="Video latent with generated keyframes appended on T.", + ), + ], + ) + + @classmethod + def parse_frame_indices(cls, frame_indices, num_pixel_frames, expected_count=None): + """Pixel frame positions from a manual override. + + Frame 0 is excluded (already a standalone token). The terminal frame is + allowed so a DFR segment grid that includes N-1 is legal. + """ + first, last = 1, num_pixel_frames - 1 + if last < first: + raise ValueError( + f"A {num_pixel_frames}-frame target has no pixel frames to place keyframes on." + ) + return _parse_frame_index_list( + frame_indices, + "frame_indices", + expected_count, + first, + last, + expected_desc=( + f"{expected_count} keyframe(s)" + if expected_count is not None + else "the list sets the count" + ), + empty_hint="to place them from interval_frames", + ) + + @classmethod + def keyframe_coords(cls, latent, frame_index, scale_factors): + """Pixel coordinates of one keyframe: the full spatial grid over [t, t + 1).""" + _, latent_coords = cls.PATCHIFIER.patchify(latent[:, :, :1]) + pixel_coords = latent_to_pixel_coords(latent_coords, scale_factors, causal_fix=True) + pixel_coords[:, 0] += frame_index + return pixel_coords + + @classmethod + def execute( + cls, + positive, + negative, + vae, + latent, + interval_frames=24, + keyframes=None, + frame_indices="", + ) -> io.NodeOutput: + samples = latent["samples"] + if not torch.is_tensor(samples) or samples.ndim != 5: + raise ValueError( + "Generated keyframes must be added to a plain video latent. Add them before " + "merging the video and audio latents with Concat AV Latent." + ) + + existing_record = get_generated_keyframes(positive) + if existing_record is not None: + prev_tokens_per_frame = existing_record["tokens_per_frame"] + if prev_tokens_per_frame != samples.shape[3] * samples.shape[4]: + raise ValueError( + f"The existing generated keyframes were added at {prev_tokens_per_frame} tokens per latent " + f"frame but this latent has {samples.shape[3] * samples.shape[4]}. The latent was rescaled " + "after they were added, so more keyframes cannot be appended to them." + ) + prev_first = existing_record["first_latent_frame"] + prev_count = existing_record["num_keyframes"] + if prev_first + prev_count != samples.shape[2]: + raise ValueError( + f"The existing generated keyframes end at latent frame {prev_first + prev_count} but the " + f"latent has {samples.shape[2]}. Something was appended after them, so more keyframes would " + "not be contiguous with the existing block. Add all the keyframes before any guides." + ) + + scale_factors = vae.downscale_index_formula + time_scale_factor = scale_factors[0] + keyframe_idxs, num_guide_frames = get_keyframe_idxs(positive, samples.shape) + num_target_frames = samples.shape[2] - num_guide_frames + num_pixel_frames = (num_target_frames - 1) * time_scale_factor + 1 + occupied = occupied_pixel_frames( + latent, + time_scale_factor, + num_pixel_frames, + video_latent_frames=num_target_frames, + ) + occupied |= pixel_frames_from_keyframe_idxs(keyframe_idxs) + prev_indices = list(existing_record["frame_indices"]) if existing_record is not None else [] + occupied |= set(prev_indices) + + if frame_indices and str(frame_indices).strip(): + indices = cls.parse_frame_indices(frame_indices, num_pixel_frames) + else: + indices = free_detailing_slots(num_pixel_frames, float(interval_frames), occupied) + + clashes = sorted(set(indices) & occupied) + if clashes: + raise ValueError( + f"frame_indices reuses pixel frame(s) {', '.join(str(i) for i in clashes)}, which already hold " + "an image keyframe, a guide, or a generated keyframe. Each keyframe needs its own pixel frame." + ) + + num_keyframes = len(indices) + if keyframes is not None: + keyframe_samples = keyframes["samples"] + if keyframe_samples.ndim != 5: + raise ValueError( + f"The keyframes latent must be 5 dimensional, got {list(keyframe_samples.shape)}." + ) + recorded_positions = keyframes.get("generated_keyframe_indices") + batched_singles = ( + keyframe_samples.shape[2] == 1 + and keyframe_samples.shape[0] != samples.shape[0] + ) + if batched_singles: + if keyframe_samples.shape[0] % samples.shape[0] != 0: + raise ValueError( + f"The keyframes latent batch ({keyframe_samples.shape[0]}) is not a " + f"multiple of the video latent batch ({samples.shape[0]}), so it " + "cannot be reshaped into per-video keyframes." + ) + stacked = keyframe_samples.shape[0] // samples.shape[0] + keyframe_samples = keyframe_samples.reshape( + samples.shape[0], + stacked, + keyframe_samples.shape[1], + *keyframe_samples.shape[3:], + ).movedim(1, 2) + batched_singles = False + if should_copy_nearest_video_frames( + keyframe_samples.shape[2], + num_keyframes, + recorded_positions is not None, + batched_singles, + ): + keyframe_samples = keyframes_from_video( + keyframe_samples, + indices, + time_scale_factor, + ) + else: + keyframe_samples = _fit_keyframe_samples( + keyframe_samples.to(samples), samples, num_keyframes + ) + keyframe_samples = keyframe_samples.to(samples) + else: + keyframe_samples = torch.zeros( + ( + samples.shape[0], + samples.shape[1], + num_keyframes, + samples.shape[3], + samples.shape[4], + ), + dtype=samples.dtype, + device=samples.device, + ) + + generated_coords = torch.cat( + [cls.keyframe_coords(samples, index, scale_factors) for index in indices], + dim=2, + ) + if keyframe_idxs is not None: + generated_coords = torch.cat([keyframe_idxs, generated_coords.to(keyframe_idxs)], dim=2) + + existing_entries = conditioning_get_any_value(positive, "guide_attention_entries", None) or [] + generated_keyframes = { + "first_latent_frame": ( + existing_record["first_latent_frame"] if existing_record else samples.shape[2] + ), + "num_keyframes": ( + existing_record["num_keyframes"] + num_keyframes if existing_record else num_keyframes + ), + "frame_indices": prev_indices + list(indices), + "num_pixel_frames": num_pixel_frames, + "guide_entry_index": ( + existing_record["guide_entry_index"] if existing_record else len(existing_entries) + ), + "tokens_per_frame": samples.shape[3] * samples.shape[4], + } + + values = { + "keyframe_idxs": generated_coords, + "generated_keyframes": generated_keyframes, + } + positive = node_helpers.conditioning_set_values(positive, values) + negative = node_helpers.conditioning_set_values(negative, values) + + if existing_record is not None: + positive, negative = _grow_guide_attention_entry( + positive, + negative, + existing_record["guide_entry_index"], + extra_pre_filter_count=num_keyframes * samples.shape[3] * samples.shape[4], + extra_frames=num_keyframes, + ) + else: + positive, negative = _append_guide_attention_entry( + positive, + negative, + pre_filter_count=num_keyframes * samples.shape[3] * samples.shape[4], + latent_shape=[num_keyframes, samples.shape[3], samples.shape[4]], + strength=1.0, + ) + + noise_mask = get_noise_mask(latent) + keyframe_noise_mask = torch.ones( + ( + noise_mask.shape[0], + 1, + num_keyframes, + noise_mask.shape[3], + noise_mask.shape[4], + ), + dtype=noise_mask.dtype, + device=noise_mask.device, + ) + + out = latent.copy() + out["samples"] = torch.cat([samples, keyframe_samples], dim=2) + out["noise_mask"] = torch.cat([noise_mask, keyframe_noise_mask], dim=2) + return io.NodeOutput(positive, negative, out) + + +class LTXVSeparateGeneratedKeyframes(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="LTXVSeparateGeneratedKeyframes", + display_name="LTXV Separate Generated Keyframes", + category="model/conditioning/ltxv", + search_aliases=["detailing", "dfr", "peel keyframes"], + description=( + "Split the generated keyframes added by LTXV Add Generated Keyframes " + "back out of a sampled latent, and remove them from the conditioning. " + "Separate them before spatially upscaling the video latent. Do not run " + "LTXV Crop Guides first — it treats generated keyframes as disposable " + "guides and drops them." + ), + inputs=[ + io.Conditioning.Input("positive"), + io.Conditioning.Input("negative"), + io.Latent.Input("latent"), + io.Boolean.Input( + "keyframes_to_batch", + default=False, + tooltip=( + "Return the keyframes as a batch of single-frame latents. Leave off " + "to get them as one multi-frame latent, which is what the latent " + "upsampler and a later Add Generated Keyframes expect." + ), + ), + ], + outputs=[ + io.Conditioning.Output( + display_name="positive", + tooltip="Positive conditioning with generated-keyframe metadata removed.", + ), + io.Conditioning.Output( + display_name="negative", + tooltip="Negative conditioning with generated-keyframe metadata removed.", + ), + io.Latent.Output( + display_name="latent", + tooltip="Video latent with the generated keyframes stripped.", + ), + io.Latent.Output( + display_name="keyframes", + tooltip=( + "The peeled keyframes, labeled with generated_keyframe_indices and " + "generated_keyframe_num_frames. Feed these to a later Add Generated " + "Keyframes to initialize new slots, or to Generated Keyframes To " + "Guides to pin them as frozen image guides (indices are remapped " + "if the canvas length changed)." + ), + ), + ], + ) + + @classmethod + def strip_keyframe_idxs(cls, cond, first_token, num_tokens): + keyframe_idxs = conditioning_get_any_value(cond, "keyframe_idxs", None) + if keyframe_idxs is None: + return None + remaining = torch.cat( + [ + keyframe_idxs[:, :, :first_token], + keyframe_idxs[:, :, first_token + num_tokens :], + ], + dim=2, + ) + return remaining if remaining.shape[2] > 0 else None + + @classmethod + def strip_guide_entry(cls, cond, entry_index): + entries = conditioning_get_any_value(cond, "guide_attention_entries", None) + if not entries: + return None + if entry_index >= len(entries): + raise ValueError( + f"The generated keyframes recorded guide entry {entry_index} but the conditioning only has " + f"{len(entries)}. The conditioning was rebuilt after they were added." + ) + remaining = entries[:entry_index] + entries[entry_index + 1 :] + return remaining or None + + @classmethod + def execute(cls, positive, negative, latent, keyframes_to_batch=False) -> io.NodeOutput: + generated_keyframes = get_generated_keyframes(positive) + if generated_keyframes is None: + raise ValueError( + "This latent has no generated keyframes. Add them with LTXV Add Generated Keyframes first." + ) + + samples = latent["samples"] + if not torch.is_tensor(samples) or samples.ndim != 5: + raise ValueError( + "Generated keyframes must be separated from a plain video latent. Split the video and " + "audio latents with Separate AV Latent first." + ) + + tokens_per_frame = samples.shape[3] * samples.shape[4] + if generated_keyframes["tokens_per_frame"] != tokens_per_frame: + raise ValueError( + f"The generated keyframes were added at {generated_keyframes['tokens_per_frame']} tokens per " + f"latent frame but this latent has {tokens_per_frame}. The latent was rescaled after they were " + "added, so the keyframes no longer line up. Separate them before upscaling the latent." + ) + + first_frame = generated_keyframes["first_latent_frame"] + num_keyframes = generated_keyframes["num_keyframes"] + end_frame = first_frame + num_keyframes + if end_frame > samples.shape[2]: + raise ValueError( + f"The generated keyframes span latent frames [{first_frame}, {end_frame}) but the latent " + f"only has {samples.shape[2]}. It was recorded against a different latent." + ) + + keyframe_samples = samples[:, :, first_frame:end_frame].clone() + if keyframes_to_batch: + batch, channels, _, height, width = keyframe_samples.shape + keyframe_samples = keyframe_samples.movedim(2, 1).reshape( + batch * num_keyframes, channels, 1, height, width + ) + + video_samples = torch.cat( + [samples[:, :, :first_frame], samples[:, :, end_frame:]], dim=2 + ) + noise_mask = get_noise_mask(latent) + video_noise_mask = torch.cat( + [noise_mask[:, :, :first_frame], noise_mask[:, :, end_frame:]], dim=2 + ) + + _, num_guide_frames = get_keyframe_idxs(positive, samples.shape) + first_token = (first_frame - (samples.shape[2] - num_guide_frames)) * tokens_per_frame + entry_index = generated_keyframes["guide_entry_index"] + + outputs = [] + for cond in (positive, negative): + outputs.append( + node_helpers.conditioning_set_values( + cond, + { + "keyframe_idxs": cls.strip_keyframe_idxs( + cond, first_token, num_keyframes * tokens_per_frame + ), + "guide_attention_entries": cls.strip_guide_entry(cond, entry_index), + "generated_keyframes": None, + }, + ) + ) + + out = latent.copy() + out["samples"] = video_samples + out["noise_mask"] = video_noise_mask + num_pixel_frames = generated_keyframes.get("num_pixel_frames") + if num_pixel_frames is None: + num_pixel_frames = (first_frame - 1) * DEFAULT_TEMPORAL_SCALE + 1 + keyframes_out = { + "samples": keyframe_samples, + "generated_keyframe_indices": list(generated_keyframes["frame_indices"]), + "generated_keyframe_num_frames": int(num_pixel_frames), + } + return io.NodeOutput(outputs[0], outputs[1], out, keyframes_out) + + +class LTXVGeneratedKeyframesToGuides(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="LTXVGeneratedKeyframesToGuides", + display_name="LTXV Generated Keyframes to Guides", + category="model/conditioning/ltxv", + search_aliases=["detailing", "dfr", "keyframe guides"], + description=( + "Pin generated keyframes from an earlier stage as frozen image guides on " + "a later canvas. They are decoded as standalone frames, resized if needed, " + "and written with noise_mask=0 so they are not denoised again. After a " + "temporal upscale, recorded indices are scaled from the canvas they were " + "generated on onto this one (same moments). Use override_frame_indices to " + "set positions explicitly. To keep generating (and denoising) keyframes at " + "new positions, use Add Generated Keyframes instead." + ), + inputs=[ + io.Conditioning.Input("positive"), + io.Conditioning.Input("negative"), + io.Vae.Input("vae"), + io.Latent.Input( + "latent", + tooltip="The target video latent to add the guides to, e.g. the temporally upscaled one.", + ), + io.Latent.Input( + "keyframes", + tooltip=( + "The keyframes output of LTXV Separate Generated Keyframes, which " + "carries the pixel frame index each keyframe was generated at." + ), + ), + io.Float.Input( + "strength", + default=1.0, + min=0.0, + max=10.0, + step=0.01, + tooltip="Guide strength. 1.0 is a hard pin; lower values relax it.", + ), + io.String.Input( + "override_frame_indices", + optional=True, + default="", + tooltip=( + "Optional — pin at these pixel frames instead of the recorded " + "(or auto-scaled) positions. Provide one index per keyframe. " + "Leave empty to reuse recorded positions, or to scale them when " + "the target canvas is a different length (e.g. after temporal x2)." + ), + ), + ], + outputs=[ + io.Conditioning.Output( + display_name="positive", + tooltip="Positive conditioning with the keyframes pinned as image guides.", + ), + io.Conditioning.Output( + display_name="negative", + tooltip="Negative conditioning with the keyframes pinned as image guides.", + ), + io.Latent.Output( + display_name="latent", + tooltip="Target video latent with the keyframes added as frozen guides.", + ), + ], + ) + + @classmethod + def decode_single_frames(cls, vae, keyframe_samples): + """Decode each keyframe latent on its own, never as one clip.""" + if keyframe_samples.shape[2] != 1: + batch, channels, num_keyframes, height, width = keyframe_samples.shape + keyframe_samples = keyframe_samples.movedim(2, 1).reshape( + batch * num_keyframes, channels, 1, height, width + ) + images = vae.decode(keyframe_samples) + if images.ndim == 5: + images = images.reshape(-1, *images.shape[-3:]) + return images + + @classmethod + def execute( + cls, + positive, + negative, + vae, + latent, + keyframes, + strength, + override_frame_indices="", + ) -> io.NodeOutput: + indices = keyframes.get("generated_keyframe_indices", None) + if indices is None: + raise ValueError( + "This latent does not carry generated keyframe positions. Connect the keyframes output " + "of LTXV Separate Generated Keyframes." + ) + if get_generated_keyframes(positive) is not None: + raise ValueError( + "This conditioning still carries generated keyframes. Connect the positive and " + "negative outputs of LTXV Separate Generated Keyframes." + ) + + samples = latent["samples"] + if not torch.is_tensor(samples) or samples.ndim != 5: + raise ValueError( + "Generated keyframe guides must be added to a plain video latent. Add them before " + "merging the video and audio latents with Concat AV Latent." + ) + if samples.shape[0] != 1: + raise ValueError( + f"Only a batch size of 1 is supported, got {samples.shape[0]}. Each guide is encoded from " + "one image, so it cannot differ across batch elements." + ) + + kf_samples = keyframes["samples"] + if kf_samples.shape[2] != 1: + batch, channels, num_keyframes, height, width = kf_samples.shape + kf_samples = kf_samples.movedim(2, 1).reshape( + batch * num_keyframes, channels, 1, height, width + ) + + resize_needed = kf_samples.shape[3:] != samples.shape[3:] + guides = cls.decode_single_frames(vae, kf_samples) if resize_needed else kf_samples + if guides.shape[0] != len(indices): + raise ValueError( + f"Got {guides.shape[0]} keyframes for {len(indices)} recorded positions." + ) + + _, num_guide_frames = get_keyframe_idxs(positive, samples.shape) + time_scale_factor = vae.downscale_index_formula[0] + num_pixel_frames = (samples.shape[2] - num_guide_frames - 1) * time_scale_factor + 1 + if override_frame_indices and str(override_frame_indices).strip(): + indices = _parse_frame_index_list( + override_frame_indices, + "override_frame_indices", + len(indices), + 1, + num_pixel_frames - 1, + expected_desc=f"the keyframes latent carries {len(indices)}", + empty_hint="to reuse the recorded positions", + ) + else: + old_len = keyframes.get("generated_keyframe_num_frames") + if old_len is not None and int(old_len) != num_pixel_frames: + indices = scale_frame_indices(list(indices), int(old_len), num_pixel_frames) + if indices and max(indices) >= num_pixel_frames: + raise ValueError( + f"Keyframe position {max(indices)} is outside this latent's {num_pixel_frames} frames. The " + "target was resized temporally after the keyframes were generated." + ) + + for index, frame_idx in enumerate(indices): + if resize_needed: + added = LTXVAddGuide.execute( + positive, + negative, + vae, + latent, + guides[index].unsqueeze(0), + int(frame_idx), + strength, + ) + positive, negative, latent = added[0], added[1], added[2] + else: + positive, negative, latent = cls.append_latent_keyframe( + positive, + negative, + vae, + latent, + guides[index : index + 1], + int(frame_idx), + strength, + ) + + return io.NodeOutput(positive, negative, latent) + + @classmethod + def append_latent_keyframe(cls, positive, negative, vae, latent, guiding_latent, frame_idx, strength): + """Append an already encoded keyframe latent the same way LTXVAddGuide would after encoding.""" + noise_mask = get_noise_mask(latent) + positive, negative, latent_image, noise_mask = LTXVAddGuide.append_keyframe( + positive, + negative, + frame_idx, + latent["samples"], + noise_mask, + guiding_latent, + strength, + vae.downscale_index_formula, + ) + positive, negative = _append_guide_attention_entry( + positive, + negative, + pre_filter_count=math.prod(guiding_latent.shape[2:]), + latent_shape=list(guiding_latent.shape[2:]), + strength=strength, + ) + out = latent.copy() + out["samples"] = latent_image + out["noise_mask"] = noise_mask + return positive, negative, out + + +class LTXVFreezeLatent(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="LTXVFreezeLatent", + display_name="LTXV Freeze Latent", + category="model/latent/ltxv", + search_aliases=["noise mask", "freeze audio", "freeze video"], + description=( + "Set noise_mask to 0 so this latent is kept clean during sampling. " + "Works on video or audio. Typical uses: freeze audio before Concat AV " + "so it only provides cross-attention, or freeze any latent that should " + "not be denoised." + ), + inputs=[ + io.Latent.Input( + "latent", + tooltip="Video or audio latent to freeze. Audio is 4D; video is 5D.", + ), + ], + outputs=[ + io.Latent.Output(display_name="latent"), + ], + ) + + @classmethod + def execute(cls, latent) -> io.NodeOutput: + samples = latent["samples"] + if not torch.is_tensor(samples): + raise ValueError( + "Freeze Latent expects a plain tensor, not a concatenated AV latent. " + "Split with Separate AV Latent first." + ) + out = latent.copy() + if samples.ndim == 5: + batch, _, frames, _, _ = samples.shape + out["noise_mask"] = torch.zeros( + (batch, 1, frames, 1, 1), + dtype=torch.float32, + device=samples.device, + ) + elif samples.ndim == 4: + batch, _, frames, _ = samples.shape + out["noise_mask"] = torch.zeros( + (batch, 1, frames, 1), + dtype=torch.float32, + device=samples.device, + ) + else: + raise ValueError( + f"Expected a 4D audio or 5D video latent, got shape {list(samples.shape)}." + ) + return io.NodeOutput(out) + + +class LTXVKeyframesExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [ + LTXVAddGeneratedKeyframes, + LTXVSeparateGeneratedKeyframes, + LTXVGeneratedKeyframesToGuides, + LTXVFreezeLatent, + ] + + +async def comfy_entrypoint() -> LTXVKeyframesExtension: + return LTXVKeyframesExtension() diff --git a/nodes.py b/nodes.py index e8f3d1e6e..038b88350 100644 --- a/nodes.py +++ b/nodes.py @@ -2460,6 +2460,7 @@ async def init_builtin_extra_nodes(): "nodes_minimax_music.py", "nodes_minimax_h3.py", "nodes_lt.py", + "nodes_lt_keyframes.py", "nodes_hooks.py", "nodes_multigpu.py", "nodes_load_3d.py", diff --git a/tests-unit/comfy_extras_test/nodes_lt_keyframes_test.py b/tests-unit/comfy_extras_test/nodes_lt_keyframes_test.py new file mode 100644 index 000000000..b8a49cf77 --- /dev/null +++ b/tests-unit/comfy_extras_test/nodes_lt_keyframes_test.py @@ -0,0 +1,911 @@ +"""Unit tests for native LTXV generated-keyframe nodes and Freeze Latent. + +They cover keyframe placement, conditioning metadata, guide conversion, and freeze-mask behavior. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +import torch + +mock_nodes = MagicMock() +mock_nodes.MAX_RESOLUTION = 16384 +mock_server = MagicMock() + + +def _conditioning_get_any_value(conditioning, key, default=None): + for t in conditioning: + if key in t[1]: + return t[1][key] + return default + + +def _get_noise_mask(latent): + noise_mask = latent.get("noise_mask", None) + latent_image = latent["samples"] + if noise_mask is None: + batch_size, _, latent_length, _, _ = latent_image.shape + noise_mask = torch.ones( + (batch_size, 1, latent_length, 1, 1), + dtype=torch.float32, + device=latent_image.device, + ) + else: + noise_mask = noise_mask.clone() + return noise_mask + + +def _get_keyframe_idxs(cond, latent_shape=None): + keyframe_idxs = _conditioning_get_any_value(cond, "keyframe_idxs", None) + if keyframe_idxs is None: + return None, 0 + if latent_shape is not None and len(latent_shape) == 5: + tokens_per_frame = latent_shape[-2] * latent_shape[-1] + num_keyframes = keyframe_idxs.shape[2] // tokens_per_frame + return keyframe_idxs, num_keyframes + return keyframe_idxs, 0 + + +def _append_guide_attention_entry(positive, negative, pre_filter_count, latent_shape, strength=1.0, attention_mask=None): + import node_helpers + + new_entry = { + "pre_filter_count": pre_filter_count, + "strength": strength, + "pixel_mask": None, + "latent_shape": latent_shape, + } + results = [] + for cond in (positive, negative): + existing = [] + for t in cond: + found = t[1].get("guide_attention_entries", None) + if found is not None: + existing = found + break + results.append( + node_helpers.conditioning_set_values(cond, {"guide_attention_entries": [*existing, new_entry]}) + ) + return results[0], results[1] + + +class _StubAddGuide: + calls = [] + + @classmethod + def append_keyframe( + cls, + positive, + negative, + frame_idx, + latent_image, + noise_mask, + guiding_latent, + strength, + scale_factors, + **kwargs, + ): + cls.calls.append({"method": "append_keyframe", "frame_idx": int(frame_idx), "strength": strength}) + mask = torch.full( + (noise_mask.shape[0], 1, guiding_latent.shape[2], noise_mask.shape[3], noise_mask.shape[4]), + max(0.0, 1.0 - strength), + dtype=noise_mask.dtype, + device=noise_mask.device, + ) + return ( + positive, + negative, + torch.cat([latent_image, guiding_latent], dim=2), + torch.cat([noise_mask, mask], dim=2), + ) + + @classmethod + def execute(cls, positive, negative, vae, latent, image, frame_idx, strength, **kwargs): + cls.calls.append({"method": "execute", "frame_idx": int(frame_idx), "strength": strength, "image": image}) + samples = latent["samples"] + out = latent.copy() + extra = torch.zeros( + (samples.shape[0], samples.shape[1], 1, samples.shape[3], samples.shape[4]), + dtype=samples.dtype, + device=samples.device, + ) + out["samples"] = torch.cat([samples, extra], dim=2) + return _NodeOutput(positive, negative, out) + + +class _NodeOutput: + def __init__(self, *args): + self.args = args + + def __getitem__(self, index): + return self.args[index] + + +_nodes_lt_stub = MagicMock() +_nodes_lt_stub.conditioning_get_any_value = _conditioning_get_any_value +_nodes_lt_stub.get_noise_mask = _get_noise_mask +_nodes_lt_stub.get_keyframe_idxs = _get_keyframe_idxs +_nodes_lt_stub._append_guide_attention_entry = _append_guide_attention_entry +_nodes_lt_stub.LTXVAddGuide = _StubAddGuide + +with patch.dict( + "sys.modules", + { + "nodes": mock_nodes, + "server": mock_server, + "comfy_extras.nodes_lt": _nodes_lt_stub, + }, +): + import comfy_extras.nodes_lt_keyframes as keyframes + + +def _zeros(shape): + return torch.zeros(shape) + + +def _empty_121(): + return {"samples": _zeros((1, 2, 16, 2, 1))} + + +def _empty_241(): + return {"samples": _zeros((1, 2, 31, 2, 1))} + + +def _cond(**extra): + return [({}, dict(extra))] + + +def _vae(): + return SimpleNamespace(downscale_index_formula=(8, 32, 32)) + + +def _mask(shape, occupied): + tensor = torch.ones(shape) + for frame in occupied: + tensor[:, :, frame] = 0.0 + return tensor + + +def _keyframe_idxs_at(starts, tokens_per_frame=1): + times = [] + for start in starts: + times.extend([start] * tokens_per_frame) + n = len(times) + coords = torch.zeros((1, 3, n, 2)) + for i, start in enumerate(times): + coords[0, 0, i, 0] = float(start) + coords[0, 0, i, 1] = float(start + 1) + coords[0, 1, i, 1] = 1.0 + coords[0, 2, i, 1] = 1.0 + return coords + + +@contextmanager +def _stub_get_keyframe_idxs(idxs, num_guide_frames): + original = keyframes.get_keyframe_idxs + keyframes.get_keyframe_idxs = lambda cond, shape=None: (idxs, num_guide_frames) + try: + yield + finally: + keyframes.get_keyframe_idxs = original + + +@contextmanager +def _stub_keyframe_coords(): + original = keyframes.LTXVAddGeneratedKeyframes.keyframe_coords + + def _fake(cls, latent, frame_index, scale_factors): + return torch.zeros((latent.shape[0], 3, latent.shape[3] * latent.shape[4], 2)) + + keyframes.LTXVAddGeneratedKeyframes.keyframe_coords = classmethod(_fake) + try: + yield + finally: + keyframes.LTXVAddGeneratedKeyframes.keyframe_coords = original + + +class TestPlacementHelpers: + def test_detailing_positions_121_24(self): + assert keyframes.detailing_positions(121, 24) == [24, 48, 72, 96, 120] + assert keyframes.free_detailing_slots(121, 24, occupied=set()) == [24, 48, 72, 96, 120] + assert keyframes.free_detailing_slots(241, 24, occupied={0, 48, 96, 144, 192, 240}) == [ + 24, 72, 120, 168, 216 + ] + + def test_free_slots_skip_last_frame_when_occupied(self): + assert keyframes.free_detailing_slots(121, 24, occupied={120}) == [24, 48, 72, 96] + + def test_free_slots_rejects_when_every_candidate_is_occupied(self): + with pytest.raises(ValueError, match="already has an image keyframe"): + keyframes.free_detailing_slots(121, 24, occupied={24, 48, 72, 96, 120}) + + def test_scale_frame_indices_temporal_x2(self): + assert keyframes.scale_frame_indices([24, 48, 72, 96, 120], 121, 241) == [ + 48, 96, 144, 192, 240 + ] + with pytest.raises(ValueError, match="from a 1-frame"): + keyframes.scale_frame_indices([0], 1, 241) + with pytest.raises(ValueError, match="onto a 1-frame"): + keyframes.scale_frame_indices([24], 121, 1) + + def test_scale_frame_indices_rejects_collapsed_duplicates(self): + with pytest.raises(ValueError, match="collapsed"): + keyframes.scale_frame_indices([0, 1], 121, 3) + + def test_detailing_positions_keeps_last_skips_zero(self): + positions = keyframes.detailing_positions(121, 24.0) + assert positions[0] != 0 + assert positions[-1] == 120 + + def test_detailing_positions_rejects_nonpositive_interval(self): + with pytest.raises(ValueError, match="interval_frames"): + keyframes.detailing_positions(121, 0) + + def test_detailing_positions_rejects_one_frame_canvas(self): + with pytest.raises(ValueError, match="no pixel frames"): + keyframes.detailing_positions(1, 24) + with pytest.raises(ValueError, match="no pixel frames"): + keyframes.free_detailing_slots(1, 24, occupied=set()) + + def test_keyframes_from_video_stacking_shape(self): + samples = torch.arange(1 * 2 * 4 * 2 * 1, dtype=torch.float32).reshape(1, 2, 4, 2, 1) + stacked = keyframes.keyframes_from_video(samples, [8, 16, 24], temporal_scale=8) + assert stacked.shape == (1, 2, 3, 2, 1) + assert torch.equal(stacked[:, :, 0:1], samples[:, :, 1:2]) + assert torch.equal(stacked[:, :, 1:2], samples[:, :, 2:3]) + assert torch.equal(stacked[:, :, 2:3], samples[:, :, 3:4]) + + def test_keyframes_from_video_rejects_non_video_and_bad_scale(self): + with pytest.raises(ValueError, match="plain 5D video latent"): + keyframes.keyframes_from_video([0], [8], 8) + with pytest.raises(ValueError, match="temporal_scale"): + keyframes.keyframes_from_video(_zeros((1, 2, 4, 2, 1)), [8], 0) + with pytest.raises(ValueError, match="no frames to copy"): + keyframes.keyframes_from_video(_zeros((1, 2, 0, 2, 1)), [8], 8) + + def test_nearest_latent_index_clamps(self): + assert keyframes.nearest_latent_index(0, 8, 4) == 0 + assert keyframes.nearest_latent_index(8, 8, 4) == 1 + assert keyframes.nearest_latent_index(999, 8, 4) == 3 + + def test_should_copy_nearest_video_frames(self): + assert keyframes.should_copy_nearest_video_frames(31, 5, False, False) is True + assert keyframes.should_copy_nearest_video_frames(5, 5, False, False) is False + assert keyframes.should_copy_nearest_video_frames(4, 5, False, False) is False + assert keyframes.should_copy_nearest_video_frames(31, 5, True, False) is False + assert keyframes.should_copy_nearest_video_frames(31, None, False, False) is False + assert keyframes.should_copy_nearest_video_frames(1, 5, False, True) is False + + def test_parse_frame_index_list_validates_count_range_and_duplicates(self): + assert keyframes._parse_frame_index_list( + "24, 48", "frame_indices", 2, 1, 120, "num_keyframes is 2", "to space them" + ) == [24, 48] + assert keyframes._parse_frame_index_list( + "24 48", "frame_indices", 2, 1, 120, "num_keyframes is 2", "to space them" + ) == [24, 48] + with pytest.raises(ValueError, match="lists 1"): + keyframes._parse_frame_index_list( + "24", "frame_indices", 2, 1, 120, "num_keyframes is 2", "to space them" + ) + with pytest.raises(ValueError, match="same pixel frame"): + keyframes._parse_frame_index_list( + "24,24", "frame_indices", 2, 1, 120, "num_keyframes is 2", "to space them" + ) + with pytest.raises(ValueError, match="must lie between"): + keyframes._parse_frame_index_list( + "0,24", "frame_indices", 2, 1, 120, "num_keyframes is 2", "to space them" + ) + with pytest.raises(ValueError, match="could not parse"): + keyframes._parse_frame_index_list( + "24,abc", "frame_indices", 2, 1, 120, "num_keyframes is 2", "to space them" + ) + + def test_parse_frame_index_list_allows_omitted_count(self): + assert keyframes._parse_frame_index_list( + "24,48,72", "frame_indices", None, 1, 120, "unused", "to auto-place" + ) == [24, 48, 72] + + def test_parse_frame_index_list_rejects_empty_separator_only(self): + with pytest.raises(ValueError, match="is empty"): + keyframes._parse_frame_index_list( + ",", "frame_indices", None, 1, 120, "unused", "to place them from interval_frames" + ) + with pytest.raises(ValueError, match="is empty"): + keyframes._parse_frame_index_list( + " , , ", "frame_indices", None, 1, 120, "unused", "to place them from interval_frames" + ) + + def test_add_parse_frame_indices_allows_last_frame(self): + assert keyframes.LTXVAddGeneratedKeyframes.parse_frame_indices("24,120", 121) == [24, 120] + with pytest.raises(ValueError, match="no pixel frames"): + keyframes.LTXVAddGeneratedKeyframes.parse_frame_indices("1", 1) + + def test_occupied_from_nonzero_samples_without_mask(self): + samples = _zeros((1, 2, 16, 2, 1)) + samples[0, 0, 0, 0, 0] = 1.0 + taken = keyframes.occupied_pixel_frames({"samples": samples}, 8, 121) + assert 0 in taken + assert 120 not in taken + + def test_occupied_prefers_noise_mask_over_nonzero_samples(self): + samples = _zeros((1, 2, 16, 2, 1)) + samples[0, 0, 0, 0, 0] = 1.0 + latent = {"samples": samples, "noise_mask": _mask((1, 1, 16, 1, 1), occupied=set())} + assert keyframes.occupied_pixel_frames(latent, 8, 121) == set() + + def test_occupied_ignores_appended_guide_frames(self): + latent = { + "samples": _zeros((1, 2, 21, 2, 1)), + "noise_mask": _mask((1, 1, 21, 1, 1), occupied={0, 16, 17, 18, 19, 20}), + } + taken = keyframes.occupied_pixel_frames(latent, 8, 121, video_latent_frames=16) + assert taken == {0} + + def test_pixel_frames_from_keyframe_idxs_uses_start_not_exclusive_end(self): + idxs = _keyframe_idxs_at([24]) + assert idxs[0, 0, :, 0].tolist() == [24.0] + assert idxs[0, 0, :, 1].tolist() == [25.0] + assert keyframes.pixel_frames_from_keyframe_idxs(idxs) == {24} + assert keyframes.pixel_frames_from_keyframe_idxs(None) == set() + + def test_pixel_frames_from_keyframe_idxs_rejects_malformed(self): + with pytest.raises((TypeError, AttributeError, IndexError, ValueError)): + keyframes.pixel_frames_from_keyframe_idxs("not-a-tensor") + with pytest.raises((TypeError, ValueError)): + keyframes._as_int_set(object()) + + +class TestNativeSchemas: + def test_generated_keyframe_nodes_use_ltxv_conditioning_category(self): + for cls, node_id, display_name in ( + ( + keyframes.LTXVAddGeneratedKeyframes, + "LTXVAddGeneratedKeyframes", + "LTXV Add Generated Keyframes", + ), + ( + keyframes.LTXVSeparateGeneratedKeyframes, + "LTXVSeparateGeneratedKeyframes", + "LTXV Separate Generated Keyframes", + ), + ( + keyframes.LTXVGeneratedKeyframesToGuides, + "LTXVGeneratedKeyframesToGuides", + "LTXV Generated Keyframes to Guides", + ), + ): + schema = cls.define_schema() + assert schema.node_id == node_id + assert schema.display_name == display_name + assert schema.category == "model/conditioning/ltxv" + assert "dfr" in schema.search_aliases + + def test_freeze_latent_uses_ltxv_latent_category(self): + schema = keyframes.LTXVFreezeLatent.define_schema() + assert schema.node_id == "LTXVFreezeLatent" + assert schema.display_name == "LTXV Freeze Latent" + assert schema.category == "model/latent/ltxv" + + +class TestAddGeneratedKeyframes: + def test_rejects_non_video_latent(self): + with pytest.raises(ValueError, match="plain video latent"): + keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), _cond(), _vae(), {"samples": torch.zeros(1, 2, 16, 2)} + ) + + def test_execute_rejects_separator_only_frame_indices(self): + with pytest.raises(ValueError, match="is empty"): + keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), _cond(), _vae(), _empty_121(), frame_indices="," + ) + + def test_execute_rejects_one_frame_canvas(self): + with pytest.raises(ValueError, match="no pixel frames"): + keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), _cond(), _vae(), {"samples": _zeros((1, 2, 1, 2, 1))} + ) + + def test_rejects_rescaled_or_noncontiguous_existing_keyframes(self): + latent = _empty_121() + with pytest.raises(ValueError, match="rescaled"): + keyframes.LTXVAddGeneratedKeyframes.execute( + _cond( + generated_keyframes={ + "tokens_per_frame": 99, + "first_latent_frame": 16, + "num_keyframes": 0, + } + ), + _cond(), + _vae(), + latent, + ) + with pytest.raises(ValueError, match="contiguous"): + keyframes.LTXVAddGeneratedKeyframes.execute( + _cond( + generated_keyframes={ + "tokens_per_frame": 2, + "first_latent_frame": 10, + "num_keyframes": 3, + } + ), + _cond(), + _vae(), + latent, + ) + + def test_execute_appends_zero_keyframes_on_t(self): + with _stub_keyframe_coords(): + _positive, _negative, out = keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), _cond(), _vae(), _empty_121() + ) + assert out["samples"].shape == (1, 2, 21, 2, 1) + assert out["noise_mask"].shape[2] == 21 + assert torch.all(out["noise_mask"][:, :, 16:21] == 1.0) + + def test_execute_copies_nearest_frames_from_longer_video(self): + video = {"samples": torch.arange(1 * 2 * 16 * 2 * 1, dtype=torch.float32).reshape(1, 2, 16, 2, 1)} + with _stub_keyframe_coords(): + _positive, _negative, out = keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), + _cond(), + _vae(), + _empty_121(), + frame_indices="24,48,72,96,120", + keyframes=video, + ) + assert out["samples"].shape[2] == 21 + stacked = out["samples"][:, :, 16:21] + source = video["samples"] + assert torch.equal(stacked[:, :, 0:1], source[:, :, 3:4]) + assert torch.equal(stacked[:, :, 4:5], source[:, :, 15:16]) + + def test_execute_keeps_stacked_keyframes_when_t_equals_count(self): + stacked = {"samples": torch.arange(1 * 2 * 5 * 2 * 1, dtype=torch.float32).reshape(1, 2, 5, 2, 1)} + with _stub_keyframe_coords(): + _positive, _negative, out = keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), + _cond(), + _vae(), + _empty_121(), + frame_indices="24,48,72,96,120", + keyframes=stacked, + ) + assert out["samples"].shape[2] == 21 + assert torch.equal(out["samples"][:, :, 16:21], stacked["samples"]) + + def test_execute_reshapes_batched_single_frame_keyframes(self): + batched = {"samples": _zeros((5, 2, 1, 2, 1))} + with _stub_keyframe_coords(): + _positive, _negative, out = keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), + _cond(), + _vae(), + _empty_121(), + frame_indices="24,48,72,96,120", + keyframes=batched, + ) + assert out["samples"].shape[2] == 21 + + def test_execute_records_density_slots_and_canvas_length(self): + with _stub_keyframe_coords(): + positive, _negative, _out = keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), _cond(), _vae(), _empty_121() + ) + record = positive[0][1]["generated_keyframes"] + assert record["frame_indices"] == [24, 48, 72, 96, 120] + assert record["num_pixel_frames"] == 121 + assert record["num_keyframes"] == 5 + assert record["first_latent_frame"] == 16 + assert record["guide_entry_index"] == 0 + entries = positive[0][1]["guide_attention_entries"] + assert len(entries) == 1 + assert entries[0]["pre_filter_count"] == 5 * 2 * 1 + assert entries[0]["latent_shape"] == [5, 2, 1] + + def test_execute_copies_from_video_using_auto_slots(self): + video = {"samples": torch.arange(1 * 2 * 16 * 2 * 1, dtype=torch.float32).reshape(1, 2, 16, 2, 1)} + with _stub_keyframe_coords(): + _positive, _negative, out = keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), _cond(), _vae(), _empty_121(), keyframes=video + ) + stacked = out["samples"][:, :, 16:21] + source = video["samples"] + assert torch.equal(stacked[:, :, 0:1], source[:, :, 3:4]) + assert torch.equal(stacked[:, :, 4:5], source[:, :, 15:16]) + + def test_execute_replaces_stacked_tokens_on_current_canvas(self): + stacked = { + "samples": torch.arange(1 * 2 * 5 * 2 * 1, dtype=torch.float32).reshape(1, 2, 5, 2, 1), + "generated_keyframe_indices": [24, 48, 72, 96, 120], + "generated_keyframe_num_frames": 121, + } + with _stub_keyframe_coords(): + positive, _negative, out = keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), _cond(), _vae(), _empty_121(), keyframes=stacked + ) + assert torch.equal(out["samples"][:, :, 16:21], stacked["samples"]) + assert positive[0][1]["generated_keyframes"]["frame_indices"] == [24, 48, 72, 96, 120] + + def test_execute_skips_i2v_last_frame_noise_mask(self): + latent = { + "samples": _zeros((1, 2, 16, 2, 1)), + "noise_mask": _mask((1, 1, 16, 1, 1), occupied={15}), + } + with _stub_keyframe_coords(): + positive, _negative, out = keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), _cond(), _vae(), latent + ) + indices = positive[0][1]["generated_keyframes"]["frame_indices"] + assert 120 not in indices + assert indices == [24, 48, 72, 96] + assert out["samples"].shape[2] == 20 + + def test_execute_replaces_stacked_tokens_on_longer_canvas(self): + stacked = { + "samples": _zeros((1, 2, 5, 2, 1)), + "generated_keyframe_indices": [24, 48, 72, 96, 120], + "generated_keyframe_num_frames": 121, + } + latent = _empty_241() + latent["noise_mask"] = _mask((1, 1, 31, 1, 1), occupied={0}) + with _stub_keyframe_coords(): + positive, _negative, _out = keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), _cond(), _vae(), latent, keyframes=stacked + ) + indices = positive[0][1]["generated_keyframes"]["frame_indices"] + assert indices != [24, 48, 72, 96, 120] + assert indices == [24, 48, 72, 96, 120, 144, 168, 192, 216, 240] + + def test_execute_skips_existing_guide_keyframe_idxs(self): + latent = { + "samples": _zeros((1, 2, 36, 2, 1)), + "noise_mask": _mask((1, 1, 36, 1, 1), occupied={0}), + } + idxs = _keyframe_idxs_at([48, 96, 144, 192, 240]) + with _stub_keyframe_coords(), _stub_get_keyframe_idxs(idxs, 5): + positive, _negative, _out = keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), _cond(), _vae(), latent + ) + assert positive[0][1]["generated_keyframes"]["frame_indices"] == [24, 72, 120, 168, 216] + + def test_execute_rejects_occupied_manual_indices(self): + latent = { + "samples": _zeros((1, 2, 16, 2, 1)), + "noise_mask": _mask((1, 1, 16, 1, 1), occupied={15}), + } + with pytest.raises(ValueError, match="reuses pixel frame"): + keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), _cond(), _vae(), latent, frame_indices="24,120" + ) + + def test_execute_rejects_wrong_spatial_size_keyframes(self): + stacked = {"samples": _zeros((1, 2, 5, 4, 4))} + with pytest.raises(ValueError, match="whole latent frames"): + keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), + _cond(), + _vae(), + _empty_121(), + frame_indices="24,48,72,96,120", + keyframes=stacked, + ) + + def test_execute_rejects_too_many_stacked_keyframes(self): + stacked = { + "samples": _zeros((1, 2, 6, 2, 1)), + "generated_keyframe_indices": [24, 48, 72, 96, 120, 8], + } + with pytest.raises(ValueError, match="only 5 free slot"): + keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), + _cond(), + _vae(), + _empty_121(), + frame_indices="24,48,72,96,120", + keyframes=stacked, + ) + + def test_execute_rejects_non_5d_keyframes(self): + with pytest.raises(ValueError, match="5 dimensional"): + keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), + _cond(), + _vae(), + _empty_121(), + frame_indices="24", + keyframes={"samples": torch.zeros(1, 2, 1, 2)}, + ) + + def test_execute_grows_existing_generated_block(self): + latent = _empty_121() + with _stub_keyframe_coords(): + positive, negative, out = keyframes.LTXVAddGeneratedKeyframes.execute( + _cond(), _cond(), _vae(), latent, frame_indices="24,48" + ) + positive, negative, out = keyframes.LTXVAddGeneratedKeyframes.execute( + positive, negative, _vae(), out, frame_indices="72,96" + ) + record = positive[0][1]["generated_keyframes"] + assert record["frame_indices"] == [24, 48, 72, 96] + assert record["num_keyframes"] == 4 + assert record["first_latent_frame"] == 16 + assert out["samples"].shape[2] == 20 + entries = positive[0][1]["guide_attention_entries"] + assert len(entries) == 1 + assert entries[0]["pre_filter_count"] == 4 * 2 * 1 + assert entries[0]["latent_shape"] == [4, 2, 1] + + +class TestSeparateGeneratedKeyframes: + def test_requires_generated_keyframes(self): + with pytest.raises(ValueError, match="no generated keyframes"): + keyframes.LTXVSeparateGeneratedKeyframes.execute(_cond(), _cond(), _empty_121()) + + def test_execute_peels_keyframes_and_indices(self): + video = _zeros((1, 2, 16, 2, 1)) + keys = torch.arange(1, 1 + 1 * 2 * 5 * 2 * 1, dtype=torch.float32).reshape(1, 2, 5, 2, 1) + samples = torch.cat([video, keys], dim=2) + record = { + "first_latent_frame": 16, + "num_keyframes": 5, + "frame_indices": [24, 48, 72, 96, 120], + "num_pixel_frames": 121, + "guide_entry_index": 0, + "tokens_per_frame": 2, + } + positive, negative, latent, peeled = keyframes.LTXVSeparateGeneratedKeyframes.execute( + _cond( + generated_keyframes=record, + guide_attention_entries=[{"keep": False}, {"keep": True}], + ), + _cond(generated_keyframes=record), + {"samples": samples}, + ) + assert latent["samples"].shape == (1, 2, 16, 2, 1) + assert peeled["samples"].shape == (1, 2, 5, 2, 1) + assert peeled["generated_keyframe_indices"] == [24, 48, 72, 96, 120] + assert peeled["generated_keyframe_num_frames"] == 121 + assert torch.equal(peeled["samples"], keys) + assert positive[0][1]["generated_keyframes"] is None + assert positive[0][1]["guide_attention_entries"] == [{"keep": True}] + assert negative[0][1]["generated_keyframes"] is None + + def test_execute_keyframes_to_batch(self): + samples = _zeros((1, 2, 18, 2, 1)) + record = { + "first_latent_frame": 16, + "num_keyframes": 2, + "frame_indices": [24, 48], + "guide_entry_index": 0, + "tokens_per_frame": 2, + } + _p, _n, _latent, peeled = keyframes.LTXVSeparateGeneratedKeyframes.execute( + _cond(generated_keyframes=record), + _cond(generated_keyframes=record), + {"samples": samples}, + keyframes_to_batch=True, + ) + assert peeled["samples"].shape == (2, 2, 1, 2, 1) + + def test_rejects_token_mismatch_and_short_latent(self): + record = { + "first_latent_frame": 16, + "num_keyframes": 5, + "frame_indices": [24, 48, 72, 96, 120], + "guide_entry_index": 0, + "tokens_per_frame": 99, + } + with pytest.raises(ValueError, match="rescaled"): + keyframes.LTXVSeparateGeneratedKeyframes.execute( + _cond(generated_keyframes=record), + _cond(generated_keyframes=record), + _empty_121(), + ) + record = dict(record) + record["tokens_per_frame"] = 2 + with pytest.raises(ValueError, match="only has"): + keyframes.LTXVSeparateGeneratedKeyframes.execute( + _cond(generated_keyframes=record), + _cond(generated_keyframes=record), + _empty_121(), + ) + + def test_strip_guide_entry(self): + remaining = keyframes.LTXVSeparateGeneratedKeyframes.strip_guide_entry( + [({}, {"guide_attention_entries": [{"a": 1}, {"b": 2}]})], 0 + ) + assert remaining == [{"b": 2}] + empty = keyframes.LTXVSeparateGeneratedKeyframes.strip_guide_entry( + [({}, {"guide_attention_entries": [{"a": 1}]})], 0 + ) + assert empty is None + with pytest.raises(ValueError, match="recorded guide entry"): + keyframes.LTXVSeparateGeneratedKeyframes.strip_guide_entry( + [({}, {"guide_attention_entries": [{"a": 1}]})], 5 + ) + + def test_rejects_non_video_latent(self): + record = { + "first_latent_frame": 0, + "num_keyframes": 1, + "frame_indices": [24], + "guide_entry_index": 0, + "tokens_per_frame": 2, + } + with pytest.raises(ValueError, match="plain video latent"): + keyframes.LTXVSeparateGeneratedKeyframes.execute( + _cond(generated_keyframes=record), + _cond(generated_keyframes=record), + {"samples": torch.zeros(1, 2, 16, 2)}, + ) + + +class TestGeneratedKeyframesToGuides: + def test_requires_recorded_indices(self): + with pytest.raises(ValueError, match="does not carry generated keyframe positions"): + keyframes.LTXVGeneratedKeyframesToGuides.execute( + _cond(), _cond(), _vae(), _empty_121(), {"samples": _zeros((1, 2, 5, 2, 1))}, 1.0 + ) + + def test_rejects_unseparated_conditioning(self): + with pytest.raises(ValueError, match="still carries generated keyframes"): + keyframes.LTXVGeneratedKeyframesToGuides.execute( + _cond(generated_keyframes={"num_keyframes": 1}), + _cond(), + _vae(), + _empty_121(), + {"samples": _zeros((1, 2, 1, 2, 1)), "generated_keyframe_indices": [24]}, + 1.0, + ) + + def test_rejects_non_video_and_batched_canvas(self): + kf = {"samples": _zeros((1, 2, 1, 2, 1)), "generated_keyframe_indices": [24]} + with pytest.raises(ValueError, match="plain video latent"): + keyframes.LTXVGeneratedKeyframesToGuides.execute( + _cond(), _cond(), _vae(), {"samples": torch.zeros(1, 2, 16, 2)}, kf, 1.0 + ) + with pytest.raises(ValueError, match="batch size of 1"): + keyframes.LTXVGeneratedKeyframesToGuides.execute( + _cond(), _cond(), _vae(), {"samples": _zeros((2, 2, 16, 2, 1))}, kf, 1.0 + ) + + def test_pins_same_size_keyframes_via_append(self): + _StubAddGuide.calls.clear() + kf = { + "samples": _zeros((1, 2, 2, 2, 1)), + "generated_keyframe_indices": [24, 48], + "generated_keyframe_num_frames": 121, + } + positive, negative, out = keyframes.LTXVGeneratedKeyframesToGuides.execute( + _cond(), _cond(), _vae(), _empty_121(), kf, 1.0 + ) + assert out["samples"].shape[2] == 18 + assert torch.all(out["noise_mask"][:, :, 16:] == 0.0) + assert [call["frame_idx"] for call in _StubAddGuide.calls] == [24, 48] + assert all(call["method"] == "append_keyframe" for call in _StubAddGuide.calls) + entries = positive[0][1]["guide_attention_entries"] + assert len(entries) == 2 + + def test_scales_indices_after_temporal_x2(self): + _StubAddGuide.calls.clear() + kf = { + "samples": _zeros((1, 2, 2, 2, 1)), + "generated_keyframe_indices": [24, 120], + "generated_keyframe_num_frames": 121, + } + keyframes.LTXVGeneratedKeyframesToGuides.execute( + _cond(), _cond(), _vae(), _empty_241(), kf, 1.0 + ) + assert [call["frame_idx"] for call in _StubAddGuide.calls] == [48, 240] + + def test_override_frame_indices(self): + _StubAddGuide.calls.clear() + kf = { + "samples": _zeros((1, 2, 2, 2, 1)), + "generated_keyframe_indices": [24, 48], + "generated_keyframe_num_frames": 121, + } + keyframes.LTXVGeneratedKeyframesToGuides.execute( + _cond(), _cond(), _vae(), _empty_121(), kf, 0.5, override_frame_indices="32,96" + ) + assert [call["frame_idx"] for call in _StubAddGuide.calls] == [32, 96] + assert all(call["strength"] == 0.5 for call in _StubAddGuide.calls) + + def test_resize_path_decodes_and_calls_add_guide(self): + _StubAddGuide.calls.clear() + vae = _vae() + decoded = [] + + def decode(samples): + decoded.append(tuple(samples.shape)) + return torch.zeros((samples.shape[0], 8, 8, 3)) + + vae.decode = decode + kf = { + "samples": _zeros((1, 2, 2, 4, 4)), + "generated_keyframe_indices": [24, 48], + "generated_keyframe_num_frames": 121, + } + _p, _n, out = keyframes.LTXVGeneratedKeyframesToGuides.execute( + _cond(), _cond(), vae, _empty_121(), kf, 1.0 + ) + assert decoded == [(2, 2, 1, 4, 4)] + assert [call["method"] for call in _StubAddGuide.calls] == ["execute", "execute"] + assert out["samples"].shape[2] == 18 + + def test_rejects_count_mismatch(self): + kf = { + "samples": _zeros((1, 2, 2, 2, 1)), + "generated_keyframe_indices": [24], + "generated_keyframe_num_frames": 121, + } + with pytest.raises(ValueError, match="2 keyframes for 1 recorded"): + keyframes.LTXVGeneratedKeyframesToGuides.execute( + _cond(), _cond(), _vae(), _empty_121(), kf, 1.0 + ) + + def test_override_rejects_separator_only_indices(self): + kf = { + "samples": _zeros((1, 2, 2, 2, 1)), + "generated_keyframe_indices": [24, 48], + "generated_keyframe_num_frames": 121, + } + with pytest.raises(ValueError, match="is empty"): + keyframes.LTXVGeneratedKeyframesToGuides.execute( + _cond(), _cond(), _vae(), _empty_121(), kf, 1.0, override_frame_indices="," + ) + + +class TestFreezeLatent: + def test_video_and_audio_masks(self): + video = keyframes.LTXVFreezeLatent.execute({"samples": _zeros((2, 4, 8, 3, 5))})[0] + assert video["noise_mask"].shape == (2, 1, 8, 1, 1) + assert video["noise_mask"].device.type == "cpu" + assert torch.all(video["noise_mask"] == 0) + audio = keyframes.LTXVFreezeLatent.execute({"samples": _zeros((1, 8, 16, 4))})[0] + assert audio["noise_mask"].shape == (1, 1, 16, 1) + assert torch.all(audio["noise_mask"] == 0) + + def test_preserves_extra_latent_keys(self): + out = keyframes.LTXVFreezeLatent.execute( + {"samples": _zeros((1, 4, 8, 2, 2)), "downscale_ratio_spacial": 32} + )[0] + assert out["downscale_ratio_spacial"] == 32 + + def test_rejects_av_and_wrong_rank(self): + with pytest.raises(ValueError, match="plain tensor"): + keyframes.LTXVFreezeLatent.execute({"samples": [0.0]}) + with pytest.raises(ValueError, match="4D audio or 5D video"): + keyframes.LTXVFreezeLatent.execute({"samples": _zeros((1, 2, 3))}) + + +class TestKeyframeCoords: + def test_single_pixel_span_at_requested_index(self): + latent = torch.zeros((1, 4, 1, 2, 2)) + coords = keyframes.LTXVAddGeneratedKeyframes.keyframe_coords(latent, 24, (8, 32, 32)) + assert coords.shape[0] == 1 + assert coords.shape[1] == 3 + assert coords.shape[-1] == 2 + starts = coords[0, 0, :, 0] + ends = coords[0, 0, :, 1] + assert torch.all(starts == 24) + assert torch.all(ends == 25) + + +def test_extension_registers_all_four_nodes(): + import asyncio + + ext = asyncio.run(keyframes.comfy_entrypoint()) + names = [cls.__name__ for cls in asyncio.run(ext.get_node_list())] + assert names == [ + "LTXVAddGeneratedKeyframes", + "LTXVSeparateGeneratedKeyframes", + "LTXVGeneratedKeyframesToGuides", + "LTXVFreezeLatent", + ] From 00d34d92fe0afbfbab3893ebbab2d5d70f5e9882 Mon Sep 17 00:00:00 2001 From: rattus <46076784+rattus128@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:32:43 +1000 Subject: [PATCH 07/15] Comfy Aimdo 0.5.3 + Memory compiler fixes (#16180) --- comfy/latent_formats.py | 2 + comfy/ldm/lightricks/av_model.py | 2 +- comfy/ldm/minimax/model.py | 2 +- comfy/ldm/minimax_music/ar.py | 2 +- comfy/model_management.py | 4 - comfy/model_prefetch.py | 81 ++++++++++++------- comfy/multigpu.py | 28 ++++--- comfy/sd.py | 10 ++- comfy/text_encoders/llama.py | 2 +- comfy_extras/nodes_sparse_attention.py | 7 +- latent_preview.py | 13 ++- requirements.txt | 2 +- .../execution_test/preview_compiler_test.py | 57 +++++++++++++ 13 files changed, 153 insertions(+), 59 deletions(-) create mode 100644 tests-unit/execution_test/preview_compiler_test.py diff --git a/comfy/latent_formats.py b/comfy/latent_formats.py index 6a60a7630..958bdacad 100644 --- a/comfy/latent_formats.py +++ b/comfy/latent_formats.py @@ -9,6 +9,7 @@ class LatentFormat: latent_rgb_factors_bias = None latent_rgb_factors_reshape = None taesd_decoder_name = None + compile_preview = False spacial_downscale_ratio = 8 temporal_downscale_ratio = 1 @@ -625,6 +626,7 @@ class MiniMaxH3Video(LatentFormat): temporal_downscale_ratio = 4 scale_factor = 1.0 taesd_decoder_name = "taeh3" + compile_preview = True latent_rgb_factors = [ [-0.018555, 0.024344, -0.017536], diff --git a/comfy/ldm/lightricks/av_model.py b/comfy/ldm/lightricks/av_model.py index d253b0144..baffc9ed2 100644 --- a/comfy/ldm/lightricks/av_model.py +++ b/comfy/ldm/lightricks/av_model.py @@ -938,7 +938,7 @@ class LTXAVModel(LTXVModel): stg_self_attn_blocks = transformer_options.get("stg_self_attn_blocks", ()) # Process transformer blocks - comfy.model_prefetch.malloc_graph_begin(self, vx.device) + comfy.model_prefetch.malloc_graph_begin(vx.device) for i, block in enumerate(self.transformer_blocks): comfy.model_prefetch.prefetch_queue_pop( prefetch_queue, vx.device, block, malloc_scope="block" diff --git a/comfy/ldm/minimax/model.py b/comfy/ldm/minimax/model.py index d7959027e..780df2435 100644 --- a/comfy/ldm/minimax/model.py +++ b/comfy/ldm/minimax/model.py @@ -573,7 +573,7 @@ class MiniMaxH3Model(nn.Module): compile_allocations = comfy.model_prefetch.malloc_graph_enabled(x[0].device) if compile_allocations: out = [torch.empty_like(x[0]), torch.empty_like(x[1])] - comfy.model_prefetch.malloc_graph_begin(self, x[0].device) + comfy.model_prefetch.malloc_graph_begin(x[0].device) graph_out = comfy.patcher_extension.WrapperExecutor.new_class_executor( self._forward, self, diff --git a/comfy/ldm/minimax_music/ar.py b/comfy/ldm/minimax_music/ar.py index 78a4c7c86..30b556a49 100644 --- a/comfy/ldm/minimax_music/ar.py +++ b/comfy/ldm/minimax_music/ar.py @@ -297,7 +297,7 @@ class MiniMaxMusic3AR(nn.Module): break if frame_index: - comfy.model_prefetch.malloc_graph_begin(self, device) + comfy.model_prefetch.malloc_graph_begin(device) c0, code_or_stop, stop_token = self._sample_c0(last_hidden, cfg_scale, top_k, generator, vocab_mask) if pending_code is None: pending_code = torch.empty_like(code_or_stop, device="cpu", pin_memory=cuda_device) diff --git a/comfy/model_management.py b/comfy/model_management.py index dd50f4c4e..e62fd3d74 100644 --- a/comfy/model_management.py +++ b/comfy/model_management.py @@ -1385,7 +1385,6 @@ LARGEST_CASTED_WEIGHT = (None, 0) STREAM_AIMDO_CAST_BUFFERS = {} LARGEST_AIMDO_CASTED_WEIGHT = (None, 0) CROSS_STEP_STATE = weakref.WeakSet() -MALLOC_GRAPH_MODULES = weakref.WeakSet() DEFAULT_AIMDO_CAST_BUFFER_RESERVATION_SIZE = 16 * 1024 ** 3 @@ -1471,9 +1470,6 @@ def reset_cast_buffers(): STREAM_CAST_BUFFERS.clear() STREAM_AIMDO_CAST_BUFFERS.clear() - for module in MALLOC_GRAPH_MODULES: - del module._comfy_malloc_graph - MALLOC_GRAPH_MODULES.clear() soft_empty_cache() def get_offload_stream(device): diff --git a/comfy/model_prefetch.py b/comfy/model_prefetch.py index 35be626ae..d5952c8e9 100644 --- a/comfy/model_prefetch.py +++ b/comfy/model_prefetch.py @@ -1,9 +1,9 @@ -import contextlib import logging import threading import warnings import weakref +import comfy_kitchen as ck import torch import comfy_aimdo.malloc_graph @@ -14,11 +14,11 @@ import comfy.model_management import comfy.ops PREFETCH_QUEUES = [] -GRAPH_MODULES = weakref.WeakSet() GRAPH_WARMED_MODULES = weakref.WeakSet() GRAPH_CAPTURE_STREAMS = {} -ACTIVE_MALLOC_GRAPHS = {} +MALLOC_GRAPHS = {} MALLOC_GRAPH_BREAKS = 0 +MALLOC_GRAPH_ROGUES = 0 MALLOC_GRAPH_USED = False def _malloc_graph_break(): @@ -29,40 +29,62 @@ def _malloc_graph_break(): def malloc_graph_enabled(device): return not args.disable_comfy_compiler and comfy.memory_management.aimdo_enabled and comfy.model_management.is_device_cuda(device) -@contextlib.contextmanager -def pause_malloc_graph(sync=False): - graph = ACTIVE_MALLOC_GRAPHS.get(threading.get_ident()) - if graph is not None: - graph.pause(sync=sync) - try: - yield - finally: - if graph is not None: - graph.resume(sync=sync) +class _PauseMallocGraph: + def __init__(self, sync=False): + self.sync = sync -def malloc_graph_begin(module, device): + def __enter__(self): + graph = MALLOC_GRAPHS.get(threading.get_ident()) + if graph is not None and graph._comfy_active: + graph.pause(sync=self.sync) + + def __exit__(self, *args): + graph = MALLOC_GRAPHS.get(threading.get_ident()) + if graph is not None and graph._comfy_active: + graph.resume(sync=self.sync) + +def pause_malloc_graph(sync=False): + return _PauseMallocGraph(sync) + +def malloc_graph_begin(device): global MALLOC_GRAPH_USED if not malloc_graph_enabled(device): return - graph = getattr(module, "_comfy_malloc_graph", None) + thread_id = threading.get_ident() + graph = MALLOC_GRAPHS.get(thread_id) if graph is None: graph = comfy_aimdo.malloc_graph.record( comfy.model_management.current_stream(device), args.assert_graph_breaks ) - module._comfy_malloc_graph = graph - comfy.model_management.MALLOC_GRAPH_MODULES.add(module) + graph._comfy_cuda_graph_modules = weakref.WeakSet() + MALLOC_GRAPHS[thread_id] = graph else: graph.push() - ACTIVE_MALLOC_GRAPHS[threading.get_ident()] = graph + if hasattr(ck, "set_allocation_context"): + ck.set_allocation_context(pause_malloc_graph()) + graph._comfy_active = True MALLOC_GRAPH_USED = True def malloc_graph_end(): thread_id = threading.get_ident() - graph = ACTIVE_MALLOC_GRAPHS.get(thread_id) - if graph is not None: + graph = MALLOC_GRAPHS.get(thread_id) + if graph is not None and graph._comfy_active: if graph.pop(): _malloc_graph_break() - ACTIVE_MALLOC_GRAPHS.pop(thread_id) + graph._comfy_active = False + +def cleanup_malloc_graph(): + global MALLOC_GRAPH_ROGUES + + graph = MALLOC_GRAPHS.pop(threading.get_ident(), None) + if graph is not None: + if graph._comfy_active: + graph.abort() + graph._comfy_active = False + for module in graph._comfy_cuda_graph_modules: + _drop_graph(module) + MALLOC_GRAPH_ROGUES += graph.rogue_count + del graph def cleanup_prefetched_modules(module, comfy_modules): for s in comfy_modules: @@ -95,11 +117,10 @@ def _drop_graph(module): def cleanup_prefetch_queues(): global PREFETCH_QUEUES global MALLOC_GRAPH_BREAKS + global MALLOC_GRAPH_ROGUES global MALLOC_GRAPH_USED - graph = ACTIVE_MALLOC_GRAPHS.pop(threading.get_ident(), None) - if graph is not None: - graph.abort() + cleanup_malloc_graph() for queue in PREFETCH_QUEUES: for entry in queue: if entry is None or not isinstance(entry, tuple): @@ -109,17 +130,17 @@ def cleanup_prefetch_queues(): if comfy_modules is not None: cleanup_prefetched_modules(prefetched_module, comfy_modules) PREFETCH_QUEUES = [] - for module in GRAPH_MODULES: - _drop_graph(module) - GRAPH_MODULES.clear() GRAPH_WARMED_MODULES.clear() if MALLOC_GRAPH_USED: - logging.info("Comfy model compiler graph breaks: %d", MALLOC_GRAPH_BREAKS) + logging.info("Comfy model compiler graph breaks: %d, rogues: %d", MALLOC_GRAPH_BREAKS, MALLOC_GRAPH_ROGUES) MALLOC_GRAPH_BREAKS = 0 + MALLOC_GRAPH_ROGUES = 0 MALLOC_GRAPH_USED = False def prefetch_queue_pop(queue, device, module, dtype=None, core=None, enable_graph=False, generator=None, malloc_scope=None): - malloc_graph = ACTIVE_MALLOC_GRAPHS.get(threading.get_ident()) + malloc_graph = MALLOC_GRAPHS.get(threading.get_ident()) + if malloc_graph is not None and not malloc_graph._comfy_active: + malloc_graph = None enable_graph = enable_graph and malloc_graph is not None and not args.disable_cuda_graphs and comfy.model_management.is_device_cuda(device) and getattr(module, "_v_block", None) is not None if queue is None: if malloc_graph is not None and malloc_scope is not None: @@ -223,7 +244,7 @@ def prefetch_queue_pop(queue, device, module, dtype=None, core=None, enable_grap comfy.model_management.current_stream(device).wait_stream(capture_stream) graph.replay() module._comfy_graph = {"graph": graph, "signature": signature} - GRAPH_MODULES.add(module) + malloc_graph._comfy_cuda_graph_modules.add(module) return if capture_stream is None: core() diff --git a/comfy/multigpu.py b/comfy/multigpu.py index 2b6d8260d..57644af71 100644 --- a/comfy/multigpu.py +++ b/comfy/multigpu.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: import comfy.utils import comfy.patcher_extension import comfy.model_management +import comfy.model_prefetch class MultiGPUThreadPool: @@ -46,18 +47,21 @@ class MultiGPUThreadPool: return result_q.put((None, e)) return - while True: - item = work_q.get() - if item is None: - break - fn, args, kwargs = item - try: - result = fn(*args, **kwargs) - result_q.put((result, None)) - except comfy.model_management.InterruptProcessingException as e: - result_q.put((None, e)) - except Exception as e: - result_q.put((None, e)) + try: + while True: + item = work_q.get() + if item is None: + break + fn, args, kwargs = item + try: + result = fn(*args, **kwargs) + result_q.put((result, None)) + except comfy.model_management.InterruptProcessingException as e: + result_q.put((None, e)) + except Exception as e: + result_q.put((None, e)) + finally: + comfy.model_prefetch.cleanup_malloc_graph() def submit(self, device: torch.device, fn, *args, **kwargs): self._work_queues[device].put((fn, args, kwargs)) diff --git a/comfy/sd.py b/comfy/sd.py index a73607cb0..54fbbd89c 100644 --- a/comfy/sd.py +++ b/comfy/sd.py @@ -35,6 +35,7 @@ import os import comfy.utils import comfy.ops +import comfy.model_prefetch from . import clip_vision from . import gligen @@ -1227,7 +1228,8 @@ class VAE: with model_management.cuda_device_context(self.device): try: memory_used = self.memory_used_decode(samples_in.shape, self.vae_dtype) - model_management.load_models_gpu([self.patcher], memory_required=memory_used, force_full_load=self.disable_offload) + with comfy.model_prefetch.pause_malloc_graph(): + model_management.load_models_gpu([self.patcher], memory_required=memory_used, force_full_load=self.disable_offload) free_memory = self.patcher.get_free_memory(self.device) batch_number = int(free_memory / memory_used) batch_number = max(1, batch_number) @@ -1235,7 +1237,8 @@ class VAE: # Pre-allocate output for VAEs that support direct buffer writes preallocated = False if getattr(self.first_stage_model, 'comfy_has_chunked_io', False): - pixel_samples = torch.empty(self.first_stage_model.decode_output_shape(samples_in.shape), device=self.output_device, dtype=self.vae_output_dtype()) + with comfy.model_prefetch.pause_malloc_graph(): + pixel_samples = torch.empty(self.first_stage_model.decode_output_shape(samples_in.shape), device=self.output_device, dtype=self.vae_output_dtype()) preallocated = True for x in range(0, samples_in.shape[0], batch_number): @@ -1245,7 +1248,8 @@ class VAE: else: out = self.first_stage_model.decode(samples, **vae_options).to(device=self.output_device, dtype=self.vae_output_dtype(), copy=True) if pixel_samples is None: - pixel_samples = torch.empty((samples_in.shape[0],) + tuple(out.shape[1:]), device=self.output_device, dtype=self.vae_output_dtype()) + with comfy.model_prefetch.pause_malloc_graph(): + pixel_samples = torch.empty((samples_in.shape[0],) + tuple(out.shape[1:]), device=self.output_device, dtype=self.vae_output_dtype()) pixel_samples[x:x+batch_number].copy_(out) del out self.process_output(pixel_samples[x:x+batch_number]) diff --git a/comfy/text_encoders/llama.py b/comfy/text_encoders/llama.py index a61c5adc7..c7147904b 100644 --- a/comfy/text_encoders/llama.py +++ b/comfy/text_encoders/llama.py @@ -1036,7 +1036,7 @@ class BaseGenerate: for step in tqdm(range(max_length), desc="Generating tokens"): if step > 0: if compile_allocations: - comfy.model_prefetch.malloc_graph_begin(self, device) + comfy.model_prefetch.malloc_graph_begin(device) embeds = self.model.embed_tokens(decode_tokens).to(execution_dtype) current_input_ids = decode_tokens if initial_input_ids is not None else None position_ids = torch.tensor([[next_pos]], device=device) if next_pos is not None else None diff --git a/comfy_extras/nodes_sparse_attention.py b/comfy_extras/nodes_sparse_attention.py index 441b474c9..006d1eb35 100644 --- a/comfy_extras/nodes_sparse_attention.py +++ b/comfy_extras/nodes_sparse_attention.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging import re +import weakref import comfy_kitchen as ck import torch @@ -142,11 +143,11 @@ class SparseAttnPatch: def vsa_rope_freqs(self, rope_freqs, plan): hit = self.vsa_rope - if hit is not None and hit[0] is rope_freqs and hit[1] is plan: + if hit is not None and hit[0]() is rope_freqs and hit[1] is plan: return hit[2] padded = rope_freqs.new_zeros((1, plan["n"]) + tuple(rope_freqs.shape[2:])) padded[0, plan["inv"]] = rope_freqs[0] - self.vsa_rope = (rope_freqs, plan, padded) + self.vsa_rope = (weakref.ref(rope_freqs), plan, padded) return padded @@ -257,6 +258,7 @@ def h3_sparse_attention(attn, x, rope_freqs, transformer_options, patch: SparseA if patch.vsa: plan = patch.vsa_plan(transformer_options["minimax_h3_layout"], x.device) n = plan["n"] + freqs = patch.vsa_rope_freqs(rope_freqs, plan) key = (block_index, n, tuple(transformer_options.get("uuids", ()))) # statistics per conditioning branch pooled = patch.pooled.get(key) @@ -268,7 +270,6 @@ def h3_sparse_attention(attn, x, rope_freqs, transformer_options, patch: SparseA ) if patch.vsa: - freqs = patch.vsa_rope_freqs(rope_freqs, plan) sink = sink_q = (0, plan["n_prefix"]) extra = {"tail": False, "block_len": plan["block_len"]} gate = attn.to_gate_compress diff --git a/latent_preview.py b/latent_preview.py index d98b70019..e3864e938 100644 --- a/latent_preview.py +++ b/latent_preview.py @@ -4,6 +4,7 @@ from comfy.cli_args import args, LatentPreviewMethod from comfy.taesd.taesd import TAESD from comfy.sd import VAE import comfy.model_management +import comfy.model_prefetch import folder_paths import comfy.utils import logging @@ -45,8 +46,16 @@ class TAESDPreviewerImpl(LatentPreviewer): return preview_to_image(x_sample) class TAEHVPreviewerImpl(TAESDPreviewerImpl): + def __init__(self, taesd, compile_preview=False): + super().__init__(taesd) + self.compile_preview = compile_preview + def decode_latent_to_preview(self, x0): - x_sample = self.taesd.decode(x0[:1, :, :1])[0][0] + samples = x0[:1, :, :1] + if self.compile_preview and comfy.model_prefetch.malloc_graph_enabled(self.taesd.device): + comfy.model_prefetch.malloc_graph_begin(self.taesd.device) + x_sample = self.taesd.decode(samples)[0][0] + comfy.model_prefetch.malloc_graph_end() return preview_to_image(x_sample, do_scale=False) class Latent2RGBPreviewer(LatentPreviewer): @@ -97,7 +106,7 @@ def get_previewer(device, latent_format): if latent_format.taesd_decoder_name in VIDEO_TAES: taesd = VAE(comfy.utils.load_torch_file(taesd_decoder_path)) taesd.first_stage_model.show_progress_bar = False - previewer = TAEHVPreviewerImpl(taesd) + previewer = TAEHVPreviewerImpl(taesd, compile_preview=latent_format.compile_preview) else: taesd = TAESD(None, taesd_decoder_path, latent_channels=latent_format.latent_channels).to(device) previewer = TAESDPreviewerImpl(taesd) diff --git a/requirements.txt b/requirements.txt index 8a12a2266..be4958429 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,7 +23,7 @@ SQLAlchemy>=2.0.0 filelock av>=17.0.0 comfy-kitchen==0.2.33 -comfy-aimdo==0.5.2 +comfy-aimdo==0.5.3 requests simpleeval>=1.0.0 blake3 diff --git a/tests-unit/execution_test/preview_compiler_test.py b/tests-unit/execution_test/preview_compiler_test.py new file mode 100644 index 000000000..dc9664ad3 --- /dev/null +++ b/tests-unit/execution_test/preview_compiler_test.py @@ -0,0 +1,57 @@ +from unittest.mock import MagicMock, Mock + +import pytest + +import latent_preview +from comfy import latent_formats + + +def test_minimax_h3_enables_preview_compiler(): + assert latent_formats.MiniMaxH3Video.compile_preview + assert latent_formats.MiniMaxH3AV.compile_preview + assert not latent_formats.HunyuanVideo.compile_preview + + +def test_video_preview_compiles_decode(monkeypatch): + taesd = Mock() + taesd.device = "cuda:0" + taesd.decode.return_value = [[Mock()]] + previewer = latent_preview.TAEHVPreviewerImpl(taesd, compile_preview=True) + x0 = MagicMock() + samples = Mock(shape=(1, 24, 1, 30, 52)) + x0.__getitem__.return_value = samples + + monkeypatch.setattr(latent_preview, "preview_to_image", Mock()) + monkeypatch.setattr(latent_preview.comfy.model_prefetch, "malloc_graph_enabled", Mock(return_value=True)) + calls = [] + taesd.decode.side_effect = lambda value: calls.append(("decode", value)) or [[Mock()]] + begin = Mock(side_effect=lambda device: calls.append(("begin", device))) + end = Mock(side_effect=lambda: calls.append(("end",))) + monkeypatch.setattr(latent_preview.comfy.model_prefetch, "malloc_graph_begin", begin) + monkeypatch.setattr(latent_preview.comfy.model_prefetch, "malloc_graph_end", end) + + previewer.decode_latent_to_preview(x0) + assert calls == [ + ("begin", "cuda:0"), + ("decode", samples), + ("end",), + ] + + +def test_video_preview_leaves_failed_compiler_scope_for_execution_cleanup(monkeypatch): + taesd = Mock() + taesd.device = "cuda:0" + taesd.decode.side_effect = RuntimeError("decode failed") + previewer = latent_preview.TAEHVPreviewerImpl(taesd, compile_preview=True) + x0 = MagicMock() + + monkeypatch.setattr(latent_preview, "preview_to_image", Mock()) + monkeypatch.setattr(latent_preview.comfy.model_prefetch, "malloc_graph_enabled", Mock(return_value=True)) + monkeypatch.setattr(latent_preview.comfy.model_prefetch, "malloc_graph_begin", Mock()) + end = Mock() + monkeypatch.setattr(latent_preview.comfy.model_prefetch, "malloc_graph_end", end) + + with pytest.raises(RuntimeError, match="decode failed"): + previewer.decode_latent_to_preview(x0) + + end.assert_not_called() From 488e8f8ab84592670bcc2ff6a1aa20fabafd5160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jukka=20Sepp=C3=A4nen?= <40791699+kijai@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:18:45 +0300 Subject: [PATCH 08/15] feat: Pixal3D multiview model support (CORE-421) (#16048) --- comfy/ldm/trellis2/model.py | 54 +++++---- comfy_extras/nodes_trellis2.py | 207 ++++++++++++++++++++++----------- 2 files changed, 167 insertions(+), 94 deletions(-) diff --git a/comfy/ldm/trellis2/model.py b/comfy/ldm/trellis2/model.py index 042e94aa0..fda7dfd37 100644 --- a/comfy/ldm/trellis2/model.py +++ b/comfy/ldm/trellis2/model.py @@ -768,10 +768,7 @@ def _coords_to_proj_world(coords: torch.Tensor, resolution: int, mesh_scale: tor norm = coords[:, 1:].to(torch.float32) / (resolution - 1) * 2.0 - 1.0 R = _PROJ_GRID_ROTATION.to(device=coords.device, dtype=torch.float32) rotated = norm @ R.T - if mesh_scale.ndim == 0: - scale_per_voxel = mesh_scale.expand(coords.shape[0]) - else: - scale_per_voxel = mesh_scale.to(coords.device)[batch_ids] + scale_per_voxel = mesh_scale.to(coords.device).reshape(-1)[batch_ids % mesh_scale.numel()] world = rotated / scale_per_voxel.unsqueeze(-1) / 2.0 return world, batch_ids @@ -800,20 +797,22 @@ def _back_project_to_tokens( ) -> torch.Tensor: if coords_world.dim() == 2: assert batch_ids is not None - B = transform_matrix.shape[0] + n_cond = transform_matrix.shape[0] out = torch.zeros((coords_world.shape[0], feature_map.shape[1]), device=feature_map.device, dtype=feature_map.dtype) - for b in range(B): + # multi-seed: latent sample b uses conditioning b % n_cond + for b in range(int(batch_ids.max().item()) + 1): mask = batch_ids == b if not mask.any(): continue + c = b % n_cond p = coords_world[mask].unsqueeze(0) uv, _, _ = _project_points_to_image( - p, transform_matrix[b:b+1], camera_angle_x[b:b+1], image_resolution) + p, transform_matrix[c:c+1], camera_angle_x[c:c+1], image_resolution) uv_ndc = (uv + 0.5) / image_resolution * 2.0 - 1.0 # padding_mode='border' is load-bearing: masking out-of-frame voxels confuses # the SS DiT (~half the voxels go to zero, producing low poly + rotation drift). - sampled = _sample_features(feature_map[b:b+1], uv_ndc) + sampled = _sample_features(feature_map[c:c+1], uv_ndc) sampled = sampled.squeeze(0).transpose(0, 1) out[mask] = sampled return out @@ -846,25 +845,14 @@ def compute_stage_proj_feats( batch_size: Optional[int] = None, device=None, ) -> torch.Tensor: - """Back-project a Pixal3D stage's feature maps onto its target voxel/grid coords. - - For sparse (shape / texture) stages: pass ``coords`` (with ``coord_resolution``). - Returns ``[N_voxels, C]`` per-voxel features with channel count = - LR channels + optional HR channels. - - For the dense SS stage: pass ``dense_grid_resolution`` (16) + ``batch_size``. - Returns ``[B, R^3, C]`` features for the dense grid. - - """ + """Back-project a stage's feature maps onto sparse coords [N, C] or the dense SS grid [B, R^3, C]; views at stride num_views are averaged.""" if device is None: device = coords.device if coords is not None else proj_pack["mesh_scale"].device mesh_scale = proj_pack["mesh_scale"].to(device) T = proj_pack["transform_matrix"].to(device) cam_angle = proj_pack["camera_angle_x"].to(device) + num_views = int(proj_pack.get("num_views", 1)) feat_map_lr, feat_map_hr, image_resolution = _select_stage_entry(proj_pack, stage) - feat_map_lr = feat_map_lr.to(device) - if feat_map_hr is not None: - feat_map_hr = feat_map_hr.to(device) if coords is not None: if coord_resolution is None: @@ -877,13 +865,18 @@ def compute_stage_proj_feats( device=device, dtype=torch.float32) batch_ids = None - proj_lr = _back_project_to_tokens(coords_world, feat_map_lr, T, cam_angle, + out = None + for v in range(num_views): + sel = slice(v, None, num_views) + proj = _back_project_to_tokens(coords_world, feat_map_lr[sel].to(device), T[sel], cam_angle[sel], image_resolution=image_resolution, batch_ids=batch_ids) - if feat_map_hr is not None: - proj_hr = _back_project_to_tokens(coords_world, feat_map_hr, T, cam_angle, - image_resolution=image_resolution, batch_ids=batch_ids) - return torch.cat([proj_lr, proj_hr], dim=-1) - return proj_lr + if feat_map_hr is not None: + proj_hr = _back_project_to_tokens(coords_world, feat_map_hr[sel].to(device), T[sel], cam_angle[sel], + image_resolution=image_resolution, batch_ids=batch_ids) + proj = torch.cat([proj, proj_hr], dim=-1) + # average views in fp32 like upstream + out = proj.float() if out is None else out.add_(proj) + return out.div_(num_views).to(proj.dtype) def _shape_proj_cond(global_cond: torch.Tensor, image_attn_mode: str, @@ -917,6 +910,11 @@ def _shape_proj_cond(global_cond: torch.Tensor, image_attn_mode: str, f"sub-model expects {proj_in_channels}.{hint}" ) + # multi-seed: latent sample i uses conditioning i % B + if batch_ids is None and logical_batch is not None and proj_feats.shape[0] != logical_batch: + reps = -(-logical_batch // proj_feats.shape[0]) + proj_feats = proj_feats.repeat((reps,) + (1,) * (proj_feats.ndim - 1))[:logical_batch] + # CFG-duplicate proj_feats to match the model's eval batch. if eval_batch is not None and logical_batch is not None and eval_batch > logical_batch: repeats = eval_batch // logical_batch @@ -1125,7 +1123,7 @@ class Trellis2(nn.Module): else: # structure struct_attn = self.image_attn_mode_structure - logical_batch_ss = proj_feats.shape[0] if proj_feats is not None else x.shape[0] + logical_batch_ss = x.shape[0] // len(cond_or_uncond) if cond_or_uncond else x.shape[0] struct_cond = context if struct_attn != "global": struct_cond = _shape_proj_cond(context, struct_attn, proj_feats, diff --git a/comfy_extras/nodes_trellis2.py b/comfy_extras/nodes_trellis2.py index 1e5f535cc..86fa6845f 100644 --- a/comfy_extras/nodes_trellis2.py +++ b/comfy_extras/nodes_trellis2.py @@ -7,6 +7,7 @@ from comfy_extras.nodes_mesh_postprocess import pack_variable_mesh_batch import comfy.latent_formats import comfy.model_management import comfy.utils +import logging import math import torch @@ -690,6 +691,73 @@ def _dino_encode_batch(clip_vision_model, image, out_device, *, want_patches=Fal out["composites"] = composite_list return out +def _naf_upsample(naf_model, lr_feat, composites, image_size, naf_target, out_device, compute_device): + """NAF-upsample each item's DINO patch grid to naf_target, guided by its composite.""" + if naf_model is None: + return None + comfy.model_management.load_model_gpu(naf_model) + inner = naf_model.model + model_dtype = next(inner.parameters()).dtype + out = torch.empty((len(composites), lr_feat.shape[1], *naf_target), device=out_device, dtype=model_dtype) + for i, c in enumerate(composites): + img_i = comfy.utils.common_upscale(c, image_size, image_size, "lanczos", "disabled").to(compute_device, model_dtype) + lr_i = lr_feat[i:i + 1].to(compute_device, model_dtype) + inner(img_i, lr_i, naf_target, output=out[i:i + 1]) + return out + + +def _build_pixal3d_conditioning(clip_vision_model, image, transform_matrix, camera_angle_x, mesh_scale, num_views=1): + """Per-item inputs hold B*num_views entries with each object's views consecutive; mesh_scale holds B.""" + naf_model = clip_vision_model.naf + out_device = comfy.model_management.intermediate_device() + compute_device = comfy.model_management.get_torch_device() + + cond = _dino_encode_batch(clip_vision_model, image, out_device, want_patches=True) + batch_size = cond["batch_size"] // num_views + fm_512_dino, fm_1024_dino = cond["patches_512"], cond["patches_1024"] + composite_list = cond["composites"] + + # NAF HR targets per stage: shape_512=512, shape_1024=512, tex_1024=1024 + hr_shape_512 = _naf_upsample(naf_model, fm_512_dino, composite_list, 512, (512, 512), out_device, compute_device) + hr_shape_1024 = _naf_upsample(naf_model, fm_1024_dino, composite_list, 1024, (512, 512), out_device, compute_device) + hr_tex_1024 = _naf_upsample(naf_model, fm_1024_dino, composite_list, 1024, (1024, 1024), out_device, compute_device) + + # CLS + register tokens averaged over each object's views + global_512 = cond["global_512"].unflatten(0, (batch_size, num_views)).mean(dim=1) + global_1024 = cond["global_1024"].unflatten(0, (batch_size, num_views)).mean(dim=1) + + proj_pack = { + "stages": { + "ss": {"feature_map": fm_512_dino, "feature_map_hr": None, "image_resolution": 512}, + "shape_512": {"feature_map": fm_512_dino, "feature_map_hr": hr_shape_512, "image_resolution": 512}, + "shape_1024": {"feature_map": fm_1024_dino, "feature_map_hr": hr_shape_1024,"image_resolution": 1024}, + "tex_1024": {"feature_map": fm_1024_dino, "feature_map_hr": hr_tex_1024, "image_resolution": 1024}, + }, + "transform_matrix": transform_matrix.to(out_device), + "camera_angle_x": camera_angle_x.to(out_device), + "mesh_scale": mesh_scale.to(out_device), + "num_views": num_views, + "patch_size": 16, + } + + # global_512 -> SS/shape_512 cross-attn; global_1024 -> shape_1024/tex_1024. + ss_proj_feats = compute_stage_proj_feats( + proj_pack, "ss", dense_grid_resolution=16, batch_size=batch_size, + device=compute_device, + ) + base_extras = { + "embeds": global_1024, "proj_feat_pack": proj_pack, + "trellis2_proj_feats": ss_proj_feats, + } + neg_extras = { + "embeds": torch.zeros_like(global_1024), "proj_feat_pack": proj_pack, + "trellis2_proj_feats": ss_proj_feats, + } + positive = [[global_512, base_extras]] + negative = [[torch.zeros_like(global_512), neg_extras]] + return IO.NodeOutput(positive, negative) + + class Pixal3DConditioning(IO.ComfyNode): @classmethod @@ -715,79 +783,85 @@ class Pixal3DConditioning(IO.ComfyNode): @classmethod def execute(cls, clip_vision_model, image, camera_angle_x) -> IO.NodeOutput: - naf_model = clip_vision_model.naf - out_device = comfy.model_management.intermediate_device() - compute_device = comfy.model_management.get_torch_device() - - cond = _dino_encode_batch(clip_vision_model, image, out_device, want_patches=True) - batch_size = cond["batch_size"] - global_512, global_1024 = cond["global_512"], cond["global_1024"] - fm_512_dino, fm_1024_dino = cond["patches_512"], cond["patches_1024"] - composite_list = cond["composites"] - - # The LR DINO grid AND the NAF HR grid are sampled separately - # NAF targets per stage: shape_512=512, shape_1024=512, tex_1024=1024. - def _naf_hr(lr_feat, composites, image_size, naf_target): - if naf_model is None or naf_target is None: - return None - comfy.model_management.load_model_gpu(naf_model) - inner = naf_model.model - model_dtype = next(inner.parameters()).dtype # set at load time (see clip_vision NAF) - hrs = [] - for i, c in enumerate(composites): - img_i = comfy.utils.common_upscale(c, image_size, image_size, "lanczos", "disabled")\ - .to(compute_device).to(model_dtype) - lr_i = lr_feat[i:i + 1].to(compute_device).to(model_dtype) - output = torch.empty((1, lr_i.shape[1], *naf_target), device=out_device, dtype=model_dtype) - hr_i = inner(img_i, lr_i, naf_target, output=output) - hrs.append(hr_i) - return torch.cat(hrs, dim=0) - - hr_shape_512 = _naf_hr(fm_512_dino, composite_list, 512, (512, 512)) - hr_shape_1024 = _naf_hr(fm_1024_dino, composite_list, 1024, (512, 512)) - hr_tex_1024 = _naf_hr(fm_1024_dino, composite_list, 1024, (1024, 1024)) - + batch_size = image.shape[0] # distance_from_fov: grid_point (-1, 0, 0) projects to pixel (0, image_resolution-1). # FOV widget is in degrees for UX; trig + downstream projection expect radians. camera_angle_x = math.radians(float(camera_angle_x)) distance = 0.5 / math.tan(camera_angle_x / 2.0) - cam_angle_t = torch.tensor([camera_angle_x] * batch_size, device=out_device, dtype=torch.float32) - dist_t = torch.tensor([distance] * batch_size, device=out_device, dtype=torch.float32) - scale_t = torch.ones(batch_size, device=out_device, dtype=torch.float32) - T = build_proj_transform_matrix(dist_t, batch_size, device=out_device, dtype=torch.float32) + cam_angle_t = torch.full((batch_size,), camera_angle_x) + dist_t = torch.full((batch_size,), distance) + T = build_proj_transform_matrix(dist_t, batch_size, dist_t.device) + return _build_pixal3d_conditioning(clip_vision_model, image, T, cam_angle_t, torch.ones(batch_size)) - proj_pack = { - "stages": { - "ss": {"feature_map": fm_512_dino, "feature_map_hr": None, "image_resolution": 512}, - "shape_512": {"feature_map": fm_512_dino, "feature_map_hr": hr_shape_512, "image_resolution": 512}, - "shape_1024": {"feature_map": fm_1024_dino, "feature_map_hr": hr_shape_1024,"image_resolution": 1024}, - "tex_1024": {"feature_map": fm_1024_dino, "feature_map_hr": hr_tex_1024, "image_resolution": 1024}, - }, - "transform_matrix": T, - "camera_angle_x": cam_angle_t, - "mesh_scale": scale_t, - "distance": dist_t, - "patch_size": 16, - } - # global_512 → SS/shape_512 cross-attn; global_1024 → shape_1024/tex_1024. - ss_proj_feats = compute_stage_proj_feats( - proj_pack, "ss", dense_grid_resolution=16, batch_size=batch_size, - device=compute_device, +_VIEW_AZIMUTHS = {"front": 0.0, "left": 90.0, "back": 180.0, "right": 270.0} +_VIEW_PAD = 1.1 # unit cube spans 1/1.1 of the frame, upstream's example rig + + +def _orbit_camera_to_world(azimuths_deg, elevations_deg, distance): + """Z-up orbit cameras looking at the origin; azimuth 0 / elevation 0 is the front view.""" + az = torch.deg2rad(torch.tensor(azimuths_deg, dtype=torch.float32)) + el = torch.deg2rad(torch.tensor(elevations_deg, dtype=torch.float32)) + back = torch.stack([torch.sin(az) * torch.cos(el), -torch.cos(az) * torch.cos(el), torch.sin(el)], dim=-1) + right = torch.stack([torch.cos(az), torch.sin(az), torch.zeros_like(az)], dim=-1) + c2w = torch.eye(4).repeat(az.shape[0], 1, 1) + c2w[:, :3, :3] = torch.stack([right, torch.cross(back, right, dim=-1), back], dim=-1) + c2w[:, :3, 3] = back * distance + return c2w + + +class Pixal3DMultiViewConditioning(IO.ComfyNode): + """Fixed orbit rig: front, left, back and right views 90 degrees apart, used as framed.""" + + @classmethod + def define_schema(cls): + views = [IO.Image.Input(name, optional=True, + tooltip=f"Square view of the object's {name} side, with alpha or on a black background, " + "framed like the rig: the object spans about 1/1.1 of the frame at its widest, " + "the same scale in every view. The first connected view (front, left, back, " + "right order) is the front the mesh is posed to.") + for name in _VIEW_AZIMUTHS] + return IO.Schema( + node_id="Pixal3DMultiViewConditioning", + display_name="Pixal3D Multi-View Conditioning", + category="model/conditioning/trellis2", + inputs=[IO.ClipVision.Input("clip_vision_model", tooltip="DINOv3 ViT-L/16 ClipVision with bundled NAF weights."), + IO.Float.Input("fov", default=20.0, min=1.0, max=170.0, step=0.01, round=False, + tooltip="Horizontal FOV in degrees of the views as framed: 20 for rig renders and most " + "multi-view generators, or MoGeGeometryToFOV on one of the views for photos.")] + + views, + outputs=[ + IO.Conditioning.Output(display_name="positive"), + IO.Conditioning.Output(display_name="negative"), + ], ) - neg_global = torch.zeros_like(global_512) - neg_embeds = torch.zeros_like(global_1024) - base_extras = { - "embeds": global_1024, "proj_feat_pack": proj_pack, - "trellis2_proj_feats": ss_proj_feats, - } - neg_extras = { - "embeds": neg_embeds, "proj_feat_pack": proj_pack, - "trellis2_proj_feats": ss_proj_feats, - } - positive = [[global_512, base_extras]] - negative = [[neg_global, neg_extras]] - return IO.NodeOutput(positive, negative) + + @classmethod + def execute(cls, clip_vision_model, fov, front=None, left=None, back=None, right=None) -> IO.NodeOutput: + views = {"front": front, "left": left, "back": back, "right": right} + names = [name for name in _VIEW_AZIMUTHS if views[name] is not None] + if not names: + raise ValueError("Pixal3DMultiViewConditioning needs at least one view") + batch_size = views[names[0]].shape[0] + num_views = len(names) + # the first connected view is the front (upstream re-bases the rig onto view 0; on an orbit that is an azimuth shift) + if names[0] != "front": + logging.warning(f"Pixal3DMultiViewConditioning: no front view, the mesh will be posed with the {names[0]} view as its front") + azimuths = [_VIEW_AZIMUTHS[name] - _VIEW_AZIMUTHS[names[0]] for name in names] + items = [] + for b in range(batch_size): + for name in names: + view = views[name][b % views[name].shape[0]][None] + if view.shape[-1] == 4: + view = view[..., :3] * view[..., 3:4] + if view.shape[1:3] != (1024, 1024): + view = comfy.utils.common_upscale(view.movedim(-1, 1), 1024, 1024, "lanczos", "disabled").movedim(1, -1) + items.append(view) + fov = math.radians(fov) + c2w = _orbit_camera_to_world(azimuths, [0.0] * num_views, _VIEW_PAD * 0.5 / math.tan(fov / 2.0)) + return _build_pixal3d_conditioning(clip_vision_model, torch.cat(items, dim=0), c2w.repeat(batch_size, 1, 1), + torch.full((batch_size * num_views,), fov), torch.ones(batch_size), + num_views=num_views) class Trellis2Extension(ComfyExtension): @@ -796,6 +870,7 @@ class Trellis2Extension(ComfyExtension): return [ Trellis2Conditioning, Pixal3DConditioning, + Pixal3DMultiViewConditioning, Trellis2ShapeStage, EmptyTrellis2LatentStructure, Trellis2TextureStage, From b7ebfd73c5a5bae013dfd331050a05692fdfc538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jukka=20Sepp=C3=A4nen?= <40791699+kijai@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:57:44 +0300 Subject: [PATCH 09/15] Fix quantized text encoder matmul gating and Gemma4 prefill cache positions (#16185) --- comfy/ops.py | 7 ++----- comfy/text_encoders/gemma4.py | 13 +++++++------ 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/comfy/ops.py b/comfy/ops.py index d9df909bb..d53886de0 100644 --- a/comfy/ops.py +++ b/comfy/ops.py @@ -1342,11 +1342,8 @@ def mixed_precision_ops(quant_config={}, compute_dtype=torch.bfloat16, full_prec compute_dtype=compute_dtype, want_requant=want_requant, ) as (weight, bias): - if self._full_precision_mm and isinstance(weight, QuantizedTensor): - # cast_bias_weight only dequantizes on a dtype change, which is a - # no-op here when the quantized weight's orig_dtype already equals - # the compute dtype. Force it so the disabled/unsupported-format - # fallback doesn't hand a QuantizedTensor to a plain linear() call. + if isinstance(weight, QuantizedTensor) and (self._full_precision_mm_config or getattr(self, "quant_format", None) in self._disabled_formats or not weight.layout_cls.supports_fast_matmul()): + # explicit per-layer full precision, or a format this device can't run: don't reach the fast quantized matmul weight = weight.dequantize() return self._forward(input, weight, bias) diff --git a/comfy/text_encoders/gemma4.py b/comfy/text_encoders/gemma4.py index 96a547b60..ee035d522 100644 --- a/comfy/text_encoders/gemma4.py +++ b/comfy/text_encoders/gemma4.py @@ -528,17 +528,18 @@ class Gemma4Transformer(nn.Module): and comfy.model_management.is_device_cuda(x.device)) decode_bias = None decode_masks = None - if decode: + if fixed_kv: + # prefill must advance the device-side write position of the global caches too prepared = set() for kv in past_key_values: if isinstance(kv, FixedKV) and id(kv.position) not in prepared: kv.prepare(seq_len) prepared.add(id(kv.position)) - if mask is not None: - decode_masks = {} - for kv in past_key_values: - if isinstance(kv, FixedKV) and id(kv.position) not in decode_masks: - decode_masks[id(kv.position)] = _fixed_kv_decode_mask(mask, kv, min_val) + if decode and mask is not None: + decode_masks = {} + for kv in past_key_values: + if isinstance(kv, FixedKV) and id(kv.position) not in decode_masks: + decode_masks[id(kv.position)] = _fixed_kv_decode_mask(mask, kv, min_val) if compiled_decode: capacities = tuple(sorted({kv.key.shape[2] for kv in past_key_values if isinstance(kv, FixedKV)})) valid = past_len + 1 From 249c5a3b951823c7990d0eb596f95b8bc542d00a Mon Sep 17 00:00:00 2001 From: comfyanonymous <121283862+comfyanonymous@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:52:21 -0700 Subject: [PATCH 10/15] Cleaner way of enabling quantized mm on text gen but not on text enc. (#16189) --- comfy/ops.py | 67 +++++++++++++++++++++++++----------- comfy/sd.py | 2 +- comfy/text_encoders/ace15.py | 3 +- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/comfy/ops.py b/comfy/ops.py index d53886de0..24631840a 100644 --- a/comfy/ops.py +++ b/comfy/ops.py @@ -1287,6 +1287,16 @@ def _quantized_weight_state_dict(module, sd, prefix, extra_quant_conf=None, extr return sd +class MixedPrecisionOp(CastWeightBiasOp): + quant_format = None + + def can_use_quantized_matmul(self, disabled_formats): + return (self.quant_format in QUANT_ALGOS + and not self._full_precision_mm_config + and self.quant_format not in self._disabled_formats + and self.quant_format not in disabled_formats) + + def mixed_precision_ops(quant_config={}, compute_dtype=torch.bfloat16, full_precision_mm=False, disabled=[]): class MixedPrecisionOps(manual_cast): _quant_config = quant_config @@ -1294,7 +1304,7 @@ def mixed_precision_ops(quant_config={}, compute_dtype=torch.bfloat16, full_prec _full_precision_mm = full_precision_mm _disabled = disabled - class Linear(torch.nn.Module, CastWeightBiasOp): + class Linear(torch.nn.Module, MixedPrecisionOp): _disabled_formats = disabled def __init__(self, in_features: int, out_features: int, bias: bool = True, device=None, dtype=None): @@ -1342,8 +1352,7 @@ def mixed_precision_ops(quant_config={}, compute_dtype=torch.bfloat16, full_prec compute_dtype=compute_dtype, want_requant=want_requant, ) as (weight, bias): - if isinstance(weight, QuantizedTensor) and (self._full_precision_mm_config or getattr(self, "quant_format", None) in self._disabled_formats or not weight.layout_cls.supports_fast_matmul()): - # explicit per-layer full precision, or a format this device can't run: don't reach the fast quantized matmul + if self._full_precision_mm and isinstance(weight, QuantizedTensor): weight = weight.dequantize() return self._forward(input, weight, bias) @@ -1447,7 +1456,7 @@ def mixed_precision_ops(quant_config={}, compute_dtype=torch.bfloat16, full_prec def _apply(self, fn, recurse=True): # This is to get torch.compile + moving weights to another device working return _quantized_apply(self, fn, recurse) - class MoEExperts(torch.nn.Module, CastWeightBiasOp): + class MoEExperts(torch.nn.Module, MixedPrecisionOp): """Container for E quantized expert weights, indexed via expert_weight(i). The bank lives on self.weight as a single 3D tensor — either a @@ -1651,29 +1660,45 @@ def mixed_precision_ops(quant_config={}, compute_dtype=torch.bfloat16, full_prec return MixedPrecisionOps -def pick_operations(weight_dtype, compute_dtype, load_device=None, disable_fast_fp8=False, fp8_optimizations=False, model_config=None): - fp8_compute = comfy.model_management.supports_fp8_compute(load_device) # TODO: if we support more ops this needs to be more granular - nvfp4_compute = comfy.model_management.supports_nvfp4_compute(load_device) - mxfp8_compute = comfy.model_management.supports_mxfp8_compute(load_device) - int8_compute = comfy.model_management.supports_int8_compute(load_device) +def get_disabled_quant_formats(device=None): + disabled = set() + if not comfy.model_management.supports_nvfp4_compute(device): + disabled.add("nvfp4") + if not comfy.model_management.supports_mxfp8_compute(device): + disabled.add("mxfp8") + if not comfy.model_management.supports_fp8_compute(device): + disabled.add("float8_e4m3fn") + disabled.add("float8_e5m2") + if not comfy.model_management.supports_int8_compute(device): + disabled.add("int8_tensorwise") + disabled.add("convrot_w4a4") + disabled.add("asym_w4a8_int8") + return disabled + +@contextlib.contextmanager +def use_quantized_matmul(model, device): + disabled = get_disabled_quant_formats(device) + previous = [] + try: + for module in model.modules(): + if isinstance(module, MixedPrecisionOp) and module.can_use_quantized_matmul(disabled): + previous.append((module, module._full_precision_mm)) + module._full_precision_mm = False + yield + finally: + for module, full_precision_mm in previous: + module._full_precision_mm = full_precision_mm + + +def pick_operations(weight_dtype, compute_dtype, load_device=None, disable_fast_fp8=False, fp8_optimizations=False, model_config=None): if model_config and hasattr(model_config, 'quant_config') and model_config.quant_config: logging.info("Using mixed precision operations") - disabled = set() - if not nvfp4_compute: - disabled.add("nvfp4") - if not mxfp8_compute: - disabled.add("mxfp8") - if not fp8_compute: - disabled.add("float8_e4m3fn") - disabled.add("float8_e5m2") - if not int8_compute: - disabled.add("int8_tensorwise") - disabled.add("convrot_w4a4") - disabled.add("asym_w4a8_int8") + disabled = get_disabled_quant_formats(load_device) logging.info("Native ops: {} {}".format(", ".join(QUANT_ALGOS.keys() - disabled), ", emulated ops: {}".format(", ".join(disabled)) if len(disabled) > 0 else "")) return mixed_precision_ops(model_config.quant_config, compute_dtype, disabled=disabled) + fp8_compute = comfy.model_management.supports_fp8_compute(load_device) if ( fp8_compute and (fp8_optimizations or PerformanceFeature.Fp8MatrixMultiplication in args.fast) and diff --git a/comfy/sd.py b/comfy/sd.py index 54fbbd89c..1d736eebe 100644 --- a/comfy/sd.py +++ b/comfy/sd.py @@ -475,7 +475,7 @@ class CLIP: self.cond_stage_model.set_clip_options({"layer": None}) self.cond_stage_model.set_clip_options({"execution_device": device}) - with model_management.cuda_device_context(device): + with model_management.cuda_device_context(device), comfy.ops.use_quantized_matmul(self.cond_stage_model, device): return self.cond_stage_model.generate(tokens, do_sample=do_sample, max_length=max_length, temperature=temperature, top_k=top_k, top_p=top_p, min_p=min_p, repetition_penalty=repetition_penalty, seed=seed, presence_penalty=presence_penalty) def decode(self, token_ids, skip_special_tokens=True): diff --git a/comfy/text_encoders/ace15.py b/comfy/text_encoders/ace15.py index 3ad519314..ec8fa313e 100644 --- a/comfy/text_encoders/ace15.py +++ b/comfy/text_encoders/ace15.py @@ -162,7 +162,8 @@ def generate_audio_codes(model, positive, negative, min_tokens=1, max_tokens=102 else: ids = [positive] - return sample_manual_loop_no_classes(model, ids, cfg_scale=cfg_scale, temperature=temperature, top_p=top_p, top_k=top_k, min_p=min_p, seed=seed, min_tokens=min_tokens, max_new_tokens=max_tokens) + with comfy.ops.use_quantized_matmul(model, model.execution_device): + return sample_manual_loop_no_classes(model, ids, cfg_scale=cfg_scale, temperature=temperature, top_p=top_p, top_k=top_k, min_p=min_p, seed=seed, min_tokens=min_tokens, max_new_tokens=max_tokens) class ACE15Tokenizer(sd1_clip.SD1Tokenizer): From 0d0b6b5694df394b88dab74dda4da33e0c405725 Mon Sep 17 00:00:00 2001 From: Alex Artyomov Date: Wed, 9 Sep 2026 00:12:47 +0300 Subject: [PATCH 11/15] Add LTXVAddLatentGuide for pinning a pre-encoded latent as a guide (#16176) --- comfy_extras/nodes_lt.py | 178 ++++++++++++- .../nodes_lt_keyframes_test.py | 39 ++- tests-unit/comfy_extras_test/nodes_lt_test.py | 242 ++++++++++++++++++ 3 files changed, 438 insertions(+), 21 deletions(-) create mode 100644 tests-unit/comfy_extras_test/nodes_lt_test.py diff --git a/comfy_extras/nodes_lt.py b/comfy_extras/nodes_lt.py index ef622481c..e9e5d5c51 100644 --- a/comfy_extras/nodes_lt.py +++ b/comfy_extras/nodes_lt.py @@ -428,6 +428,43 @@ class LTXVAddGuide(io.ComfyNode): return latent_image, noise_mask + @classmethod + def attach_guide_latent(cls, positive, negative, latent_image, noise_mask, guide_latent, frame_idx, strength, + scale_factors, latent_downscale_factor=1, causal_fix=None, attention_mask=None): + """Dilate a guide latent onto the canvas, pin it, and record its attention entry. + + Keeps the three values that have to agree in one place: the pre-dilation + shape recorded on the attention entry, the post-dilation token count, and + the downscale factor handed to append_keyframe. context_windows re-derives + the factor from the first two, so they must not drift apart. + """ + guide_latent_shape = list(guide_latent.shape[2:]) # pre-dilation [F, H, W] for spatial-mask downsampling + guide_mask = None + if latent_downscale_factor > 1: + guide_latent, guide_mask = cls.dilate_latent(guide_latent, latent_downscale_factor) + + positive, negative, latent_image, noise_mask = cls.append_keyframe( + positive, + negative, + frame_idx, + latent_image, + noise_mask, + guide_latent, + strength, + scale_factors, + guide_mask=guide_mask, + latent_downscale_factor=latent_downscale_factor, + causal_fix=causal_fix, + ) + + # Track this guide for per-reference attention control. + pre_filter_count = guide_latent.shape[2] * guide_latent.shape[3] * guide_latent.shape[4] + positive, negative = _append_guide_attention_entry( + positive, negative, pre_filter_count, guide_latent_shape, strength=strength, + attention_mask=attention_mask, + ) + return positive, negative, latent_image, noise_mask + @classmethod def execute(cls, positive, negative, vae, latent, image, frame_idx, strength, attention_mask=None, iclora_parameters=None) -> io.NodeOutput: scale_factors = vae.downscale_index_formula @@ -462,32 +499,20 @@ class LTXVAddGuide(io.ComfyNode): t = t[:, :, 1:, :, :] image = image[1:] - guide_latent_shape = list(t.shape[2:]) # pre-dilation [F, H, W] for spatial-mask downsampling - guide_mask = None - if latent_downscale_factor > 1: - t, guide_mask = cls.dilate_latent(t, latent_downscale_factor) - frame_idx, latent_idx = cls.get_latent_index(positive, latent_length, len(image), frame_idx, scale_factors, latent_shape=latent_image.shape) assert latent_idx + t.shape[2] <= latent_length, "Conditioning frames exceed the length of the latent sequence." - positive, negative, latent_image, noise_mask = cls.append_keyframe( + positive, negative, latent_image, noise_mask = cls.attach_guide_latent( positive, negative, - frame_idx, latent_image, noise_mask, t, + frame_idx, strength, scale_factors, - guide_mask=guide_mask, latent_downscale_factor=latent_downscale_factor, causal_fix=causal_fix, - ) - - # Track this guide for per-reference attention control. - pre_filter_count = t.shape[2] * t.shape[3] * t.shape[4] - positive, negative = _append_guide_attention_entry( - positive, negative, pre_filter_count, guide_latent_shape, strength=strength, attention_mask=attention_mask, ) @@ -496,6 +521,130 @@ class LTXVAddGuide(io.ComfyNode): generate = execute # TODO: remove +class LTXVAddLatentGuide(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="LTXVAddLatentGuide", + display_name="LTXV Add Latent Guide", + category="model/conditioning/ltxv", + description="Pins an already-encoded latent as a guide, for when the guide comes out of an " + "earlier stage rather than an image. Same effect as LTXV Add Guide without the " + "VAE decode/encode round trip. A guide that is spatially smaller than the target " + "(an IC-LoRA or detailing reference) is dilated onto a sparse grid, and its RoPE " + "end positions are expanded by the same ratio so it covers the target canvas " + "instead of addressing only the top-left corner of it.", + search_aliases=["latent guide", "add latent guide", "guide latent"], + inputs=[ + io.Conditioning.Input("positive"), + io.Conditioning.Input("negative"), + io.Vae.Input("vae"), + io.Latent.Input("latent", tooltip="Target video latent the guide is pinned onto."), + io.Latent.Input( + "guiding_latent", + tooltip="Guide latent. Its spatial size must divide the target's by the same whole " + "number on both axes; equal size pins it as-is, half size is treated as an " + "x2 IC-LoRA reference.", + ), + io.Int.Input( + "latent_idx", + default=0, + min=-9999, + max=9999, + tooltip="Latent frame index to start the guide at, counted in latent frames rather " + "than pixel frames. Negative values place the guide on frames before the " + "start of the latent, not counted back from its end.", + ), + io.Float.Input( + "strength", + default=1.0, + min=0.0, + max=1.0, + step=0.01, + tooltip="Capped at 1.0. A dilated guide marks its padding positions with a " + "negative denoise mask so the model drops them; above 1.0 the kept " + "positions would go negative too and the whole guide would be dropped. " + "Amplify beyond 1.0 with attention_mask instead.", + ), + io.Mask.Input( + "attention_mask", + optional=True, + tooltip="Optional pixel-space spatial mask. Controls per-region " + "conditioning influence via self-attention, multiplied by strength.", + ), + ], + outputs=[ + io.Conditioning.Output(display_name="positive"), + io.Conditioning.Output(display_name="negative"), + io.Latent.Output(display_name="latent"), + ], + ) + + @classmethod + def execute(cls, positive, negative, vae, latent, guiding_latent, latent_idx, strength, attention_mask=None) -> io.NodeOutput: + scale_factors = vae.downscale_index_formula + latent_image = latent["samples"] + noise_mask = get_noise_mask(latent) + guide_latent = guiding_latent["samples"] + + for name, samples in (("latent", latent_image), ("guiding_latent", guide_latent)): + if samples.ndim != 5: + raise ValueError( + f"{name} must be a 5D video latent (batch, channels, frames, height, width), " + f"got shape {list(samples.shape)}." + ) + + guide_frames = guide_latent.shape[2] + latent_frames = latent_image.shape[2] + if latent_idx + guide_frames > latent_frames: + raise ValueError( + f"Guide of {guide_frames} latent frame(s) at latent_idx {latent_idx} runs past the " + f"end of the {latent_frames}-frame latent. Negative values are allowed and place the " + f"guide before the start of the latent." + ) + + if latent_image.shape[3] % guide_latent.shape[3] != 0 or latent_image.shape[4] % guide_latent.shape[4] != 0: + raise ValueError( + f"Guiding latent spatial size {guide_latent.shape[3]}x{guide_latent.shape[4]} must divide " + f"the target size {latent_image.shape[3]}x{latent_image.shape[4]} by a whole number." + ) + + height_scale = latent_image.shape[3] // guide_latent.shape[3] + width_scale = latent_image.shape[4] // guide_latent.shape[4] + # dilate_latent and append_keyframe take a single IC-LoRA downscale factor for both + # axes, so a non-square ratio would mis-place RoPE on one of them. + if height_scale != width_scale: + raise ValueError( + f"Guiding latent spatial ratio must be square, got height x{height_scale} and " + f"width x{width_scale} ({guide_latent.shape[3]}x{guide_latent.shape[4]} -> " + f"{latent_image.shape[3]}x{latent_image.shape[4]})." + ) + + time_scale_factor = scale_factors[0] + if latent_idx <= 0: + frame_idx = latent_idx * time_scale_factor + else: + frame_idx = 1 + (latent_idx - 1) * time_scale_factor + + positive, negative, latent_image, noise_mask = LTXVAddGuide.attach_guide_latent( + positive, + negative, + latent_image, + noise_mask, + guide_latent, + frame_idx, + strength, + scale_factors, + latent_downscale_factor=width_scale, + attention_mask=attention_mask, + ) + + out = latent.copy() + out["samples"] = latent_image + out["noise_mask"] = noise_mask + return io.NodeOutput(positive, negative, out) + + class LTXVCropGuides(io.ComfyNode): @classmethod def define_schema(cls): @@ -1186,6 +1335,7 @@ class LtxvExtension(ComfyExtension): LTXVScheduler, GetICLoRAParameters, LTXVAddGuide, + LTXVAddLatentGuide, LTXVPreprocess, LTXVCropGuides, LTXVConcatAVLatent, diff --git a/tests-unit/comfy_extras_test/nodes_lt_keyframes_test.py b/tests-unit/comfy_extras_test/nodes_lt_keyframes_test.py index b8a49cf77..ace8f3c6c 100644 --- a/tests-unit/comfy_extras_test/nodes_lt_keyframes_test.py +++ b/tests-unit/comfy_extras_test/nodes_lt_keyframes_test.py @@ -5,9 +5,10 @@ They cover keyframe placement, conditioning metadata, guide conversion, and free from __future__ import annotations +import sys from contextlib import contextmanager from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest import torch @@ -132,15 +133,39 @@ _nodes_lt_stub.get_keyframe_idxs = _get_keyframe_idxs _nodes_lt_stub._append_guide_attention_entry = _append_guide_attention_entry _nodes_lt_stub.LTXVAddGuide = _StubAddGuide -with patch.dict( - "sys.modules", - { +def _import_keyframes_against_stub(): + """Import the module under test with comfy_extras.nodes_lt stubbed, then put it back. + + Only the stubbed keys are restored, not the whole of sys.modules: patch.dict + restores the entire dict on exit, which evicts every module imported inside the + block and forces a later re-import. Re-importing torch internals raises on + duplicate TORCH_LIBRARY registration, which broke running this file alongside + nodes_lt_test.py. + """ + stubs = { "nodes": mock_nodes, "server": mock_server, "comfy_extras.nodes_lt": _nodes_lt_stub, - }, -): - import comfy_extras.nodes_lt_keyframes as keyframes + } + saved = {name: sys.modules.get(name) for name in stubs} + sys.modules.update(stubs) + try: + import comfy_extras.nodes_lt_keyframes as module + + return module + finally: + for name, original in saved.items(): + if original is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = original + # The imported module stays bound to the stubs above, so drop it from the cache + # rather than let a later import pick up a stub-backed copy. The reference + # returned to this module keeps working. + sys.modules.pop("comfy_extras.nodes_lt_keyframes", None) + + +keyframes = _import_keyframes_against_stub() def _zeros(shape): diff --git a/tests-unit/comfy_extras_test/nodes_lt_test.py b/tests-unit/comfy_extras_test/nodes_lt_test.py new file mode 100644 index 000000000..44087d944 --- /dev/null +++ b/tests-unit/comfy_extras_test/nodes_lt_test.py @@ -0,0 +1,242 @@ +"""Unit tests for LTXVAddLatentGuide and the guide-attachment path it shares with LTXVAddGuide. + +The RoPE arithmetic runs for real here: only ``nodes`` and ``server`` are stubbed, so +``append_keyframe``, ``dilate_latent`` and ``_append_guide_attention_entry`` are the +real implementations and the assertions are on actual keyframe coordinates. +""" + +from __future__ import annotations + +import asyncio +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +import torch + +# Stub nodes/server for the import only, then restore exactly those keys. patch.dict is +# not used because it restores the whole of sys.modules on exit, which evicts everything +# imported inside the block and forces a re-import that trips duplicate TORCH_LIBRARY +# registration. Leaving the stubs installed is equally wrong: pytest imports every test +# module at collection time, so a lingering MagicMock "nodes" breaks later modules that +# use the real one. Same shape as tests-unit/comfy_extras_test/image_stitch_test.py. +_stubs = {"nodes": MagicMock(MAX_RESOLUTION=16384), "server": MagicMock()} +_saved = {name: sys.modules.get(name) for name in _stubs} +sys.modules.update(_stubs) +try: + import comfy_extras.nodes_lt as nodes_lt +finally: + for _name, _original in _saved.items(): + if _original is None: + sys.modules.pop(_name, None) + else: + sys.modules[_name] = _original + +LATENT_CHANNELS = 128 +SCALE_FACTORS = (8, 32, 32) +TIME, HEIGHT, WIDTH = 0, 1, 2 +START, END = 0, 1 + + +def _vae(): + return SimpleNamespace(downscale_index_formula=SCALE_FACTORS) + + +def _latent(frames, height, width): + return {"samples": torch.zeros((1, LATENT_CHANNELS, frames, height, width))} + + +def _cond(): + return [({}, {})] + + +def _add_latent_guide(guide_hw, latent_hw=(4, 4), guide_frames=1, latent_frames=3, latent_idx=0): + positive, negative, latent = nodes_lt.LTXVAddLatentGuide.execute( + _cond(), + _cond(), + _vae(), + _latent(latent_frames, *latent_hw), + _latent(guide_frames, *guide_hw), + latent_idx, + 1.0, + ) + metadata = positive[0][1] + return metadata["keyframe_idxs"], metadata["guide_attention_entries"], latent + + +def _axis(keyframe_idxs, axis, bound): + return keyframe_idxs[0, axis, :, bound].tolist() + + +def test_same_size_guide_spans_one_patch_per_token(): + """A 1:1 guide gets no offset: each token's end is one scale factor past its start. + + The inverse of the case below, so a factor derived wrongly at 1:1 is caught too. + """ + keyframe_idxs, entries, _ = _add_latent_guide(guide_hw=(4, 4)) + + for axis in (HEIGHT, WIDTH): + starts = _axis(keyframe_idxs, axis, START) + assert _axis(keyframe_idxs, axis, END) == [s + SCALE_FACTORS[axis] for s in starts] + + assert entries[0]["latent_shape"] == [1, 4, 4] + + +def test_half_size_guide_expands_only_the_end_positions(): + """An x2 guide keeps its start positions and pushes each end out by one scale factor. + + That is what makes the dilated reference cover the whole canvas. Leaving the factor + at 1 keeps same-size coordinates while each token encodes a larger patch, so the + reference addresses only the top-left corner of the target. + """ + same_size, _, _ = _add_latent_guide(guide_hw=(4, 4)) + downscaled, entries, _ = _add_latent_guide(guide_hw=(2, 2)) + + # Dilation puts the small guide on the same sparse grid, so token count is unchanged. + assert downscaled.shape == same_size.shape + + for axis in (HEIGHT, WIDTH): + assert _axis(downscaled, axis, START) == _axis(same_size, axis, START) + expected = [e + SCALE_FACTORS[axis] for e in _axis(same_size, axis, END)] + assert _axis(downscaled, axis, END) == expected + + # Time is never touched by the spatial offset. + assert _axis(downscaled, TIME, START) == _axis(same_size, TIME, START) + assert _axis(downscaled, TIME, END) == _axis(same_size, TIME, END) + + assert entries[0]["latent_shape"] == [1, 2, 2] + + +def test_attention_entry_lets_context_windows_rederive_the_factor(): + """The entry keeps the pre-dilation shape while the token count is post-dilation. + + ``context_windows`` divides the post-dilation guide height by the entry's + ``latent_shape`` height to recover the downscale factor, so these two must not drift + apart or windowed and non-windowed sampling disagree on the guide's RoPE. + """ + _, entries, latent = _add_latent_guide(guide_hw=(2, 2)) + + entry = entries[0] + assert entry["latent_shape"] == [1, 2, 2] # pre-dilation + assert entry["pre_filter_count"] == 1 * 4 * 4 # post-dilation + assert latent["samples"].shape[3] // entry["latent_shape"][1] == 2 + + +@pytest.mark.parametrize( + "latent_idx, expected_start", [(-1, -8), (0, 0), (1, 1), (2, 9)] +) +def test_latent_idx_maps_onto_pixel_frames(latent_idx, expected_start): + """latent_idx is in latent frames, and negatives sit before the start of the latent. + + The first latent frame covers a single pixel frame, so the mapping is 0 -> 0, 1 -> 1, + then 8 apart. Negative values are not counted back from the end. + """ + keyframe_idxs, _, _ = _add_latent_guide( + guide_hw=(4, 4), latent_frames=8, latent_idx=latent_idx + ) + + assert set(_axis(keyframe_idxs, TIME, START)) == {expected_start} + + +@pytest.mark.parametrize( + "kwargs, message", + [ + (dict(guide_hw=(2, 4)), "square"), + (dict(guide_hw=(3, 3)), "whole number"), + (dict(guide_hw=(4, 4), latent_idx=99), "runs past the end"), + (dict(guide_hw=(4, 4), guide_frames=5), "runs past the end"), + ], +) +def test_unusable_guides_are_rejected(kwargs, message): + with pytest.raises(ValueError, match=message): + _add_latent_guide(**kwargs) + + +def test_non_5d_guiding_latent_is_rejected(): + """An image-model latent would otherwise fail with a bare IndexError on shape[4].""" + with pytest.raises(ValueError, match="5D video latent"): + nodes_lt.LTXVAddLatentGuide.execute( + _cond(), + _cond(), + _vae(), + _latent(3, 4, 4), + {"samples": torch.zeros((1, LATENT_CHANNELS, 4, 4))}, + 0, + 1.0, + ) + + +def test_attention_mask_reaches_the_guide_entry(): + """Only coverage that the optional input is forwarded to the guide entry.""" + mask = torch.full((1, 128, 128), 0.5) + positive, negative, _ = nodes_lt.LTXVAddLatentGuide.execute( + _cond(), _cond(), _vae(), _latent(3, 4, 4), _latent(1, 2, 2), 0, 1.0, attention_mask=mask + ) + + # Stored as (1, 1, F, H, W) for downstream self-attention masking. + assert positive[0][1]["guide_attention_entries"][0]["pixel_mask"].shape == (1, 1, 1, 128, 128) + # Positive and negative each get their own entry, so neither leaks into the other. + assert len(negative[0][1]["guide_attention_entries"]) == 1 + + +def test_node_is_registered_with_a_loadable_schema(): + """Both failure modes here are invisible to every other test in this file. + + A bad io.Schema keyword only surfaces when the node is registered, and a node left + out of the extension list simply does not exist in ComfyUI. The strength cap is + asserted here because raising it re-exposes the missing clamp in append_keyframe's + guide_mask branch, where a dilated guide is dropped above 1.0. + """ + schema = nodes_lt.LTXVAddLatentGuide.define_schema() + inputs = {inp.id: inp for inp in schema.inputs} + + assert schema.node_id == "LTXVAddLatentGuide" + assert inputs["strength"].max == 1.0 + assert inputs["attention_mask"].optional is True + + node_list = asyncio.run(nodes_lt.LtxvExtension().get_node_list()) + assert nodes_lt.LTXVAddLatentGuide in node_list + + +@pytest.mark.parametrize( + "iclora_parameters, expected_shape, expected_extra_end", + [ + (None, [1, 4, 4], 0), + ({"reference_downscale_factor": 2}, [1, 2, 2], SCALE_FACTORS[HEIGHT]), + ], +) +def test_add_guide_image_path_still_routes_through_the_shared_helper( + iclora_parameters, expected_shape, expected_extra_end +): + """LTXVAddGuide must be unchanged by sharing attach_guide_latent with the latent node.""" + + class _Vae: + downscale_index_formula = SCALE_FACTORS + + def encode(self, pixels): + frames, height, width, _ = pixels.shape + return torch.zeros( + (1, LATENT_CHANNELS, (frames - 1) // SCALE_FACTORS[TIME] + 1, height // 32, width // 32) + ) + + positive, _, _ = nodes_lt.LTXVAddGuide.execute( + _cond(), + _cond(), + _Vae(), + _latent(3, 4, 4), + torch.zeros((1, 4 * 32, 4 * 32, 3)), + 0, + 1.0, + iclora_parameters=iclora_parameters, + ) + + metadata = positive[0][1] + entry = metadata["guide_attention_entries"][0] + assert entry["latent_shape"] == expected_shape + assert entry["pre_filter_count"] == 1 * 4 * 4 + + keyframe_idxs = metadata["keyframe_idxs"] + starts = _axis(keyframe_idxs, HEIGHT, START) + ends = _axis(keyframe_idxs, HEIGHT, END) + assert ends == [s + SCALE_FACTORS[HEIGHT] + expected_extra_end for s in starts] From 421a1c245c682c04d4325ba365f40c834c66f5b0 Mon Sep 17 00:00:00 2001 From: poorpaper <43514747+poorpaper@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:25:33 +0800 Subject: [PATCH 12/15] Fix MiniMax H3 denoise masks (#15988) --- comfy/ldm/minimax/model.py | 6 ++ .../comfy_test/test_minimax_h3_model.py | 61 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests-unit/comfy_test/test_minimax_h3_model.py diff --git a/comfy/ldm/minimax/model.py b/comfy/ldm/minimax/model.py index 780df2435..5441f4fff 100644 --- a/comfy/ldm/minimax/model.py +++ b/comfy/ldm/minimax/model.py @@ -588,6 +588,12 @@ class MiniMaxH3Model(nn.Module): else: out = graph_out + # Masked rows predict at mask * sigma; scale their velocity to match the outer x0 conversion. + if denoise_mask is not None: + out[0] = out[0] * denoise_mask + if audio_denoise_mask is not None: + out[1] = out[1] * audio_denoise_mask + if scale != 1.0: # d/d(sigma_v) of the carried variable out[1] = ((1.0 - scale) * (audio_src * carry) diff --git a/tests-unit/comfy_test/test_minimax_h3_model.py b/tests-unit/comfy_test/test_minimax_h3_model.py new file mode 100644 index 000000000..acf3d858d --- /dev/null +++ b/tests-unit/comfy_test/test_minimax_h3_model.py @@ -0,0 +1,61 @@ +import torch +from torch import nn + +from comfy.ldm.minimax.model import MiniMaxH3Model, time_shift_sigma +from comfy.model_sampling import CONST + + +def make_model(video_output, audio_output): + model = MiniMaxH3Model.__new__(MiniMaxH3Model) + nn.Module.__init__(model) + model.sigma_shift_video = 12.0 + model.sigma_shift_audio = 3.0 + model._forward = lambda *args, **kwargs: [video_output.clone(), audio_output.clone()] + return model + + +def test_forward_scales_velocity_to_mask_timestep(): + video_output = torch.full((1, 2, 1, 2, 2), 2.0) + audio_output = torch.full((1, 2, 2, 3), 3.0) + video_mask = torch.tensor([[[[[1.0, 0.75], [0.5, 0.25]]]]]) + audio_mask = torch.tensor([[[[1.0, 0.5, 0.25], [0.75, 0.5, 0.0]]]]) + sigma = torch.tensor([0.5]) + clean = torch.arange(video_output.numel(), dtype=torch.float32).reshape_as(video_output) + model_input = clean + sigma.reshape(1, 1, 1, 1, 1) * video_mask * video_output + model = make_model(video_output, audio_output) + + out = model( + [model_input, torch.zeros_like(audio_output)], + sigma * 1000.0, + torch.empty(1, 1, 1), + minimax_payload={"audio_scale": 1.0}, + denoise_mask=video_mask, + audio_denoise_mask=audio_mask, + ) + + torch.testing.assert_close(out[0], video_output * video_mask) + torch.testing.assert_close(out[1], audio_output * audio_mask) + denoised = CONST.calculate_denoised(None, sigma, out[0], model_input) + torch.testing.assert_close(denoised, clean) + + +def test_forward_scales_audio_velocity_before_carry_conversion(): + video_output = torch.ones((1, 1, 1, 1, 1)) + audio_output = torch.full((1, 1, 2, 2), 3.0) + audio_src = torch.full_like(audio_output, 2.0) + audio_mask = torch.tensor([[[[0.75, 0.5], [0.25, 0.0]]]]) + model = make_model(video_output, audio_output) + sigma_v = torch.tensor(0.5) + sigma_a = time_shift_sigma(sigma_v, 12.0, 3.0) + carry = sigma_a / sigma_v + + out = model( + [torch.zeros_like(video_output), audio_src], + sigma_v.reshape(1) * 1000.0, + torch.empty(1, 1, 1), + minimax_payload={"audio_scale": 4.0}, + audio_denoise_mask=audio_mask, + ) + + expected = -3.0 * audio_src * carry + (1.0 + 3.0 * sigma_a) * audio_output * audio_mask + torch.testing.assert_close(out[1], expected) From 672ba9e5e388bd6bfac5ceef61f89ffdd9467200 Mon Sep 17 00:00:00 2001 From: comfyanonymous <121283862+comfyanonymous@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:42:15 -0700 Subject: [PATCH 13/15] Only lock repo PRs after merging if they contain a CLA signature. (#16191) --- .github/workflows/cla.yml | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 31645356c..3b94f4ed9 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -22,7 +22,7 @@ jobs: - name: Build author-only allowlist id: allowlist if: > - github.event_name == 'pull_request_target' || + (github.event_name == 'pull_request_target' && github.event.action != 'closed') || (github.event_name == 'issue_comment' && github.event.issue.pull_request && ( github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read and agree to the Contributor License Agreement' @@ -48,10 +48,10 @@ jobs: fi - name: CLA Assistant - # Run on PR events, on "recheck" comment, or when someone posts the signing phrase. + # Run on open/update PR events, on "recheck", or when someone posts the signing phrase. # IMPORTANT: this phrase must match `custom-pr-sign-comment` below. if: > - github.event_name == 'pull_request_target' || + (github.event_name == 'pull_request_target' && github.event.action != 'closed') || (github.event_name == 'issue_comment' && github.event.issue.pull_request && ( github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read and agree to the Contributor License Agreement' @@ -62,6 +62,8 @@ jobs: # PAT required to write to the centralized signatures repo. PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} with: + lock-pullrequest-aftermerge: false + # Where the CLA document lives (shown to contributors) path-to-document: https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md @@ -94,3 +96,25 @@ jobs: custom-allsigned-prcomment: | ✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged. + + - name: Lock PR containing a CLA signature or bot approval + if: github.event_name == 'pull_request_target' && github.event.action == 'closed' && github.event.pull_request.merged == true + uses: actions/github-script@v7 + with: + retries: 3 + script: | + const pr = context.payload.pull_request; + const issue = { ...context.repo, issue_number: pr.number }; + const comments = await github.paginate(github.rest.issues.listComments, { + ...issue, + per_page: 100, + }); + const signed = comments.some(comment => + (comment.user?.id === pr.user.id && + comment.body?.trim().toLowerCase() === 'i have read and agree to the contributor license agreement') || + (comment.user?.login === 'github-actions[bot]' && + comment.body?.startsWith('✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged.')) + ); + if (signed) { + await github.rest.issues.lock(issue); + } From be47aa22c088111d83d822071f49dfae16fd1ca7 Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:41:31 +0400 Subject: [PATCH 14/15] [Partner Nodes] feat(OpenAI): add GPT Image 2.5 Flare and Sunburst models (#16190) --- comfy_api_nodes/nodes_openai.py | 179 +++++++++++++++++++++----------- 1 file changed, 120 insertions(+), 59 deletions(-) diff --git a/comfy_api_nodes/nodes_openai.py b/comfy_api_nodes/nodes_openai.py index 9260521f1..594baba7d 100644 --- a/comfy_api_nodes/nodes_openai.py +++ b/comfy_api_nodes/nodes_openai.py @@ -402,13 +402,18 @@ class OpenAIGPTImage1(IO.ComfyNode): return IO.NodeOutput(await validate_and_cast_response(response)) -def _gpt_image_shared_inputs(): +GPT_IMAGE_QUALITIES = ("low", "medium", "high") +GPT_IMAGE_25_QUALITIES = ("low", "medium", "high", "xhigh", "max") +GPT_IMAGE_MODELS = ("gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2", "gpt-image-1.5", "gpt-image-1") + + +def _gpt_image_shared_inputs(qualities: tuple[str, ...] = GPT_IMAGE_QUALITIES): """Inputs shared by all GPT Image models (quality + reference images + mask).""" return [ IO.Combo.Input( "quality", default="low", - options=["low", "medium", "high"], + options=list(qualities), tooltip="Image quality, affects cost and generation time.", ), IO.Autogrow.Input( @@ -448,13 +453,58 @@ def _gpt_image_legacy_model_inputs(): ] +def _gpt_image_2_model_inputs(backgrounds: tuple[str, ...], qualities: tuple[str, ...]): + return [ + IO.Combo.Input( + "size", + default="auto", + options=[ + "auto", + "1024x1024", + "1024x1536", + "1536x1024", + "2048x2048", + "2048x1152", + "1152x2048", + "3840x2160", + "2160x3840", + "Custom", + ], + tooltip="Image size. Select 'Custom' to use the custom width and height.", + ), + IO.Int.Input( + "custom_width", + default=1024, + min=480, + max=3840, + step=16, + tooltip="Used only when `size` is 'Custom'. Must be a multiple of 16.", + ), + IO.Int.Input( + "custom_height", + default=1024, + min=480, + max=3840, + step=16, + tooltip="Used only when `size` is 'Custom'. Must be a multiple of 16.", + ), + IO.Combo.Input( + "background", + default="auto", + options=list(backgrounds), + tooltip="Return image with or without background.", + ), + *_gpt_image_shared_inputs(qualities), + ] + + class OpenAIGPTImageNodeV2(IO.ComfyNode): @classmethod def define_schema(cls): return IO.Schema( node_id="OpenAIGPTImageNodeV2", - display_name="OpenAI GPT Image 2", + display_name="OpenAI GPT Image 2.5", category="partner/image/OpenAI", description="Generates images via OpenAI's GPT Image endpoint.", inputs=[ @@ -467,50 +517,17 @@ class OpenAIGPTImageNodeV2(IO.ComfyNode): IO.DynamicCombo.Input( "model", options=[ + IO.DynamicCombo.Option( + "gpt-image-2.5-flare", + _gpt_image_2_model_inputs(("auto", "opaque", "transparent"), GPT_IMAGE_25_QUALITIES), + ), + IO.DynamicCombo.Option( + "gpt-image-2.5-sunburst", + _gpt_image_2_model_inputs(("auto", "opaque", "transparent"), GPT_IMAGE_25_QUALITIES), + ), IO.DynamicCombo.Option( "gpt-image-2", - [ - IO.Combo.Input( - "size", - default="auto", - options=[ - "auto", - "1024x1024", - "1024x1536", - "1536x1024", - "2048x2048", - "2048x1152", - "1152x2048", - "3840x2160", - "2160x3840", - "Custom", - ], - tooltip="Image size. Select 'Custom' to use the custom width and height.", - ), - IO.Int.Input( - "custom_width", - default=1024, - min=1024, - max=3840, - step=16, - tooltip="Used only when `size` is 'Custom'. Must be a multiple of 16.", - ), - IO.Int.Input( - "custom_height", - default=1024, - min=1024, - max=3840, - step=16, - tooltip="Used only when `size` is 'Custom'. Must be a multiple of 16.", - ), - IO.Combo.Input( - "background", - default="auto", - options=["auto", "opaque"], - tooltip="Return image with or without background.", - ), - *_gpt_image_shared_inputs(), - ], + _gpt_image_2_model_inputs(("auto", "opaque"), GPT_IMAGE_QUALITIES), ), IO.DynamicCombo.Option("gpt-image-1.5", _gpt_image_legacy_model_inputs()), IO.DynamicCombo.Option("gpt-image-1", _gpt_image_legacy_model_inputs()), @@ -544,7 +561,7 @@ class OpenAIGPTImageNodeV2(IO.ComfyNode): ], is_api_node=True, price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends(widgets=["model", "model.quality", "n"]), + depends_on=IO.PriceBadgeDepends(widgets=["model", "model.quality", "model.size", "n"], input_groups=["model.images"]), expr=""" ( $ranges := { @@ -559,22 +576,66 @@ class OpenAIGPTImageNodeV2(IO.ComfyNode): "high": [0.133, 0.22] }, "gpt-image-2": { - "low": [0.0058, 0.0228], - "medium": [0.0492, 0.2016], - "high": [0.198, 0.804] + "low": [0.0019, 0.0237], + "medium": [0.0186, 0.2135], + "high": [0.0744, 0.8539] + }, + "gpt-image-2.5-flare": { + "low": [0.0023, 0.0283], + "medium": [0.0056, 0.0636], + "high": [0.0222, 0.2544], + "xhigh": [0.0388, 0.4523], + "max": [0.0887, 1.0175] + }, + "gpt-image-2.5-sunburst": { + "low": [0.0023, 0.0283], + "medium": [0.0056, 0.0636], + "high": [0.0222, 0.2544], + "xhigh": [0.0388, 0.4523], + "max": [0.0887, 1.0175] } }; - $range := $lookup($lookup($ranges, widgets.model), $lookup(widgets, "model.quality")); + $presets := { + "gpt-image-2": { + "low": {"1024x1024": 0.0071, "1024x1536": 0.0057, "1536x1024": 0.0057, "2048x2048": 0.0143, "2048x1152": 0.0057, "1152x2048": 0.0057, "3840x2160": 0.0134, "2160x3840": 0.0134}, + "medium": {"1024x1024": 0.0632, "1024x1536": 0.0494, "1536x1024": 0.0494, "2048x2048": 0.1284, "2048x1152": 0.0509, "1152x2048": 0.0509, "3840x2160": 0.1201, "2160x3840": 0.1201}, + "high": {"1024x1024": 0.2529, "1024x1536": 0.1976, "1536x1024": 0.1976, "2048x2048": 0.5138, "2048x1152": 0.2034, "1152x2048": 0.2034, "3840x2160": 0.4803, "2160x3840": 0.4803} + }, + "gpt-image-2.5": { + "low": {"1024x1024": 0.0084, "1024x1536": 0.0068, "1536x1024": 0.0068, "2048x2048": 0.0170, "2048x1152": 0.0067, "1152x2048": 0.0067, "3840x2160": 0.0159, "2160x3840": 0.0159}, + "medium": {"1024x1024": 0.0188, "1024x1536": 0.0147, "1536x1024": 0.0147, "2048x2048": 0.0383, "2048x1152": 0.0157, "1152x2048": 0.0157, "3840x2160": 0.0371, "2160x3840": 0.0371}, + "high": {"1024x1024": 0.0753, "1024x1536": 0.0589, "1536x1024": 0.0589, "2048x2048": 0.1531, "2048x1152": 0.0606, "1152x2048": 0.0606, "3840x2160": 0.1431, "2160x3840": 0.1431}, + "xhigh": {"1024x1024": 0.1339, "1024x1536": 0.1055, "1536x1024": 0.1055, "2048x2048": 0.2721, "2048x1152": 0.1077, "1152x2048": 0.1077, "3840x2160": 0.2544, "2160x3840": 0.2544}, + "max": {"1024x1024": 0.3013, "1024x1536": 0.2354, "1536x1024": 0.2354, "2048x2048": 0.6123, "2048x1152": 0.2424, "1152x2048": 0.2424, "3840x2160": 0.5724, "2160x3840": 0.5724} + } + }; + $perImage := { + "gpt-image-1": [0.0019, 0.0019], + "gpt-image-1.5": [0.0016, 0.0016], + "gpt-image-2": [0.0098, 0.0147], + "gpt-image-2.5-flare": [0.0117, 0.0176], + "gpt-image-2.5-sunburst": [0.0117, 0.0176] + }; + $model := widgets.model; + $family := ($model = "gpt-image-2.5-flare" or $model = "gpt-image-2.5-sunburst") ? "gpt-image-2.5" : $model; + $qualityRaw := $lookup(widgets, "model.quality"); + $quality := ($qualityRaw != null) ? $qualityRaw : ""; + $sizeRaw := $lookup(widgets, "model.size"); + $size := ($sizeRaw != null) ? $sizeRaw : ""; + $range := $lookup($lookup($ranges, $model), $quality); + $preset := $lookup($lookup($lookup($presets, $family), $quality), $size); + $out := ($preset != null) ? [$preset, $preset] : $range; + $image := $lookup($perImage, $model); + $refsRaw := $lookup(inputGroups, "model.images"); + $refs := ($refsRaw != null) ? $refsRaw : 0; $nRaw := widgets.n; $n := ($nRaw != null and $nRaw != 0) ? $nRaw : 1; - ($n = 1) - ? {"type":"range_usd","min_usd": $range[0], "max_usd": $range[1], "format": {"approximate": true}} - : { - "type":"range_usd", - "min_usd": $range[0] * $n, - "max_usd": $range[1] * $n, - "format": { "suffix": "/Run", "approximate": true } - } + $min := ($out[0] + $refs * $image[0]) * $n; + $max := ($out[1] + $refs * $image[1]) * $n; + $format := ($n = 1) ? {"approximate": true} : {"suffix": "/Run", "approximate": true}; + ($min = $max) + ? {"type": "usd", "usd": $min, "format": $format} + : {"type": "range_usd", "min_usd": $min, "max_usd": $max, "format": $format} ) """, ), @@ -626,7 +687,7 @@ class OpenAIGPTImageNodeV2(IO.ComfyNode): ) size = f"{custom_width}x{custom_height}" - if model_id not in ("gpt-image-1", "gpt-image-1.5", "gpt-image-2"): + if model_id not in GPT_IMAGE_MODELS: raise ValueError(f"Unknown model: {model_id}") if image_tensors: From 02dfb63b806413db406173be837dfcc368c51cff Mon Sep 17 00:00:00 2001 From: Alexis Rolland Date: Tue, 8 Sep 2026 21:44:54 -0700 Subject: [PATCH 15/15] chores: Update tooltips of 3D nodes (CORE-323) (#16179) --- comfy_extras/nodes_load_3d.py | 30 +++++++++++++++--------------- comfy_extras/nodes_save_3d.py | 18 +++++++++--------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/comfy_extras/nodes_load_3d.py b/comfy_extras/nodes_load_3d.py index ecd14eb33..bdb9f7a82 100644 --- a/comfy_extras/nodes_load_3d.py +++ b/comfy_extras/nodes_load_3d.py @@ -156,16 +156,16 @@ class Preview3DAdvanced(IO.ComfyNode): ), IO.Load3DModelInfo.Input("model_3d_info", optional=True, advanced=True), IO.Load3D.Input("viewport_state"), - IO.Load3DCamera.Input("camera_info", optional=True, advanced=True), - IO.Int.Input("width", default=1024, min=1, max=4096, step=1), - IO.Int.Input("height", default=1024, min=1, max=4096, step=1), + IO.Load3DCamera.Input("camera_info", optional=True, advanced=True, tooltip="Viewport camera information: position, look-at target, zoom, and type."), + IO.Int.Input("width", default=1024, min=1, max=4096, step=1, tooltip="Render width of the viewport in pixels."), + IO.Int.Input("height", default=1024, min=1, max=4096, step=1, tooltip="Render height of the viewport in pixels."), ], outputs=[ - IO.File3DAny.Output(display_name="model_3d"), - IO.Load3DModelInfo.Output(display_name="model_3d_info"), - IO.Load3DCamera.Output(display_name="camera_info"), - IO.Int.Output(display_name="width"), - IO.Int.Output(display_name="height"), + IO.File3DAny.Output(display_name="model_3d", tooltip="3D model file (glb/obj/stl/etc.) from an upstream 3D node."), + IO.Load3DModelInfo.Output(display_name="model_3d_info", tooltip="Placement of each model in the scene: position, rotation, and scale (Y-up world space)."), + IO.Load3DCamera.Output(display_name="camera_info", tooltip="Viewport camera information: position, look-at target, zoom, and type."), + IO.Int.Output(display_name="width", tooltip="Render width of the viewport in pixels."), + IO.Int.Output(display_name="height", tooltip="Render height of the viewport in pixels."), ], ) @@ -355,15 +355,15 @@ class Load3DAdvanced(IO.ComfyNode): inputs=[ IO.Combo.Input("model_file", options=["none"] + sorted(files), upload=IO.UploadType.model), IO.Load3D.Input("viewport_state"), - IO.Int.Input("width", default=1024, min=1, max=4096, step=1), - IO.Int.Input("height", default=1024, min=1, max=4096, step=1), + IO.Int.Input("width", default=1024, min=1, max=4096, step=1, tooltip="Render width of the viewport in pixels."), + IO.Int.Input("height", default=1024, min=1, max=4096, step=1, tooltip="Render height of the viewport in pixels."), ], outputs=[ - IO.File3DAny.Output(display_name="model_3d"), - IO.Load3DModelInfo.Output(display_name="model_3d_info"), - IO.Load3DCamera.Output(display_name="camera_info"), - IO.Int.Output(display_name="width"), - IO.Int.Output(display_name="height"), + IO.File3DAny.Output(display_name="model_3d", tooltip="Loaded 3D model file (glb/obj/stl/etc.)."), + IO.Load3DModelInfo.Output(display_name="model_3d_info", tooltip="Placement of each model in the scene: position, rotation, and scale (Y-up world space)."), + IO.Load3DCamera.Output(display_name="camera_info", tooltip="Viewport camera information: position, look-at target, zoom, and type."), + IO.Int.Output(display_name="width", tooltip="Render width of the viewport in pixels."), + IO.Int.Output(display_name="height", tooltip="Render height of the viewport in pixels."), ], ) diff --git a/comfy_extras/nodes_save_3d.py b/comfy_extras/nodes_save_3d.py index 660ab8fe0..a5033b36d 100644 --- a/comfy_extras/nodes_save_3d.py +++ b/comfy_extras/nodes_save_3d.py @@ -929,17 +929,17 @@ class Save3DAdvanced(IO.ComfyNode): ), IO.String.Input("filename_prefix", default="3d/ComfyUI"), IO.Load3D.Input("viewport_state"), - IO.Load3DModelInfo.Input("model_3d_info", optional=True, advanced=True), - IO.Load3DCamera.Input("camera_info", optional=True, advanced=True), - IO.Int.Input("width", default=1024, min=1, max=4096, step=1), - IO.Int.Input("height", default=1024, min=1, max=4096, step=1), + IO.Load3DModelInfo.Input("model_3d_info", optional=True, advanced=True, tooltip="Placement of each model in the scene: position, rotation, and scale (Y-up world space)."), + IO.Load3DCamera.Input("camera_info", optional=True, advanced=True, tooltip="Viewport camera information: position, look-at target, zoom, and type."), + IO.Int.Input("width", default=1024, min=1, max=4096, step=1, tooltip="Render width of the viewport in pixels."), + IO.Int.Input("height", default=1024, min=1, max=4096, step=1, tooltip="Render height of the viewport in pixels."), ], outputs=[ - IO.File3DAny.Output(display_name="model_3d"), - IO.Load3DModelInfo.Output(display_name="model_3d_info"), - IO.Load3DCamera.Output(display_name="camera_info"), - IO.Int.Output(display_name="width"), - IO.Int.Output(display_name="height"), + IO.File3DAny.Output(display_name="model_3d", tooltip="3D model file (glb/obj/stl/etc.) from an upstream 3D node."), + IO.Load3DModelInfo.Output(display_name="model_3d_info", tooltip="Placement of each model in the scene: position, rotation, and scale (Y-up world space)."), + IO.Load3DCamera.Output(display_name="camera_info", tooltip="Viewport camera information: position, look-at target, zoom, and type."), + IO.Int.Output(display_name="width", tooltip="Render width of the viewport in pixels."), + IO.Int.Output(display_name="height", tooltip="Render height of the viewport in pixels."), ], )