"""JoyAI-Echo ComfyUI node implementations. Six nodes faithful to the official inference.py: 1. JoyEcho_ModelLoader — load text encoder + DiT + VAEs (bf16) 2. JoyEcho_TextEncode — encode prompts, auto-release text encoder 3. JoyEcho_Generate — multi-shot denoise + decode with memory bank 4. JoyEcho_SingleShotGenerate — single-shot with per-shot text box and memory chaining 5. JoyEcho_PromptFormat — get system prompt for LLM-based prompt enhancement 6. JoyEcho_LLMEnhance — call LLM API to generate shot prompts from a story idea """ from __future__ import annotations import gc import json import os from pathlib import Path from typing import Any import torch DENOISING_SIGMAS = [1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0] def _empty_cache(): if torch.cuda.is_available(): torch.cuda.empty_cache() def _move(module, device): if module is not None: module.to(device) class SequentialOffloader: """Layer-by-layer GPU offloading for the DiT transformer blocks. Hooks into each transformer block so that only the currently-executing block resides on GPU. All other blocks stay on CPU/pinned memory. Peak VRAM for the generator drops from ~30GB to ~2-3GB (1 block + activations). """ def __init__(self, generator, device: torch.device, pin_memory: bool = True, resident_blocks: int = 0): self._generator = generator self._device = device self._hooks: list[torch.utils.hooks.RemovableHook] = [] self._pin_memory = pin_memory self._installed = False # First N transformer blocks stay permanently on GPU (no hooks, no # streaming). Each streamed block costs a PCIe round-trip per denoise # step; pinning K of 48 cuts that traffic by K/48 at K x per-block # VRAM (bf16 ~0.9GB, fp8-resident ~0.45GB per block). self._resident_blocks = max(0, int(resident_blocks)) def install(self): """Install forward hooks on transformer blocks and move them to CPU.""" if self._installed: return self._installed = True velocity_model = self._generator.model.velocity_model blocks = velocity_model.transformer_blocks # Keep pre/post processing layers on GPU (small footprint) for name, param in velocity_model.named_parameters(): if "transformer_blocks" not in name: param.data = param.data.to(self._device) for name, buf in velocity_model.named_buffers(): if "transformer_blocks" not in name: buf.data = buf.data.to(self._device) n_res = min(self._resident_blocks, len(blocks)) resident = list(blocks)[:n_res] streamed = list(blocks)[n_res:] # Resident blocks live on the GPU permanently. for block in resident: block.to(self._device) # Move streamed blocks to CPU (optionally pinned). Pinning is a # transfer-speed optimization (enables async H2D copies), NOT a # correctness requirement — so it must never be fatal. cudaHostAlloc # exhaustion surfaces as "CUDA error: out of memory" even though it # is HOST page-locked memory that ran out (hit on BEAST 2026-07-19: # the refine's resident_blocks=0 tried to pin all 48 blocks after # the shot passes had pinned only 36 — the last ~11GB of pinning # pushed past what the host could lock). On the first failure we # stop pinning entirely (the pool is exhausted; per-param retries # just burn time) and stream the rest unpinned — the non_blocking # copies silently become synchronous, slower but correct. # Already-pinned tensors are a no-op for pin_memory(), so re-installs # keep whatever pinning already succeeded. _pin = self._pin_memory and torch.cuda.is_available() for block in streamed: block.to("cpu") if _pin: try: for param in block.parameters(): param.data = param.data.pin_memory() for buf in block.buffers(): buf.data = buf.data.pin_memory() except Exception as e: _pin = False print(f"[JoyEcho] WARNING: pinned-memory allocation failed " f"({e}); streaming remaining blocks UNPINNED (slower " f"PCIe transfers, otherwise identical).", flush=True) # Also keep the wrapper's patchifiers and X0Model's non-block params on GPU for name, param in self._generator.named_parameters(): if "velocity_model.transformer_blocks" not in name and "velocity_model" not in name: param.data = param.data.to(self._device) for name, buf in self._generator.named_buffers(): if "velocity_model.transformer_blocks" not in name and "velocity_model" not in name: buf.data = buf.data.to(self._device) def make_pre_hook(block_module): def hook(module, args): block_module.to(self._device, non_blocking=True) if torch.cuda.is_available(): torch.cuda.current_stream().synchronize() return hook def make_post_hook(block_module): def hook(module, args, output): block_module.to("cpu", non_blocking=True) return hook for block in streamed: h1 = block.register_forward_pre_hook(make_pre_hook(block)) h2 = block.register_forward_hook(make_post_hook(block)) self._hooks.extend([h1, h2]) if n_res: print(f"[JoyEcho] Sequential offloading installed: {len(blocks)} blocks " f"({n_res} resident on GPU, {len(streamed)} streamed)", flush=True) else: print(f"[JoyEcho] Sequential offloading installed: {len(blocks)} blocks", flush=True) def remove(self): """Remove all hooks and move entire generator back to CPU.""" for h in self._hooks: h.remove() self._hooks.clear() self._installed = False self._generator.to("cpu") _MODEL_FILE_MANUAL = "(use checkpoint_path)" _MODEL_FILE_CATS = ("checkpoints", "diffusion_models", "unet") _LORA_FILE_MANUAL = "(use lora_path / none)" _LORA_FILE_CATS = ("loras",) _GEMMA_FILE_MANUAL = "(use gemma_path field)" _GEMMA_FILE_CATS = ("text_encoders", "clip") def _list_cat_files(cats, sentinel, exts=("*.safetensors", "*.gguf")) -> list: """Every matching file under the given ComfyUI model-dir categories, as 'category: relative/path' combo entries. Dirs shared between categories (unet is an alias of diffusion_models on newer ComfyUI) are deduped.""" try: import folder_paths except ImportError: return [sentinel] out, seen_dirs, seen = [], set(), set() for cat in cats: try: roots = folder_paths.get_folder_paths(cat) except Exception: continue for root in roots: try: rp = Path(root).resolve() except OSError: continue if not rp.is_dir() or rp in seen_dirs: continue seen_dirs.add(rp) for ext in exts: for f in rp.rglob(ext): label = f"{cat}: {f.relative_to(rp).as_posix()}" if label not in seen: seen.add(label) out.append(label) return [sentinel] + sorted(out) def _resolve_cat_file(choice: str, cats, widget: str, sentinel: str) -> str: import folder_paths cat, _, rel = choice.partition(": ") if cat in cats and rel: for root in folder_paths.get_folder_paths(cat): p = Path(root) / rel if p.is_file(): return str(p) raise FileNotFoundError( f"{widget} {choice!r} no longer exists on disk. Refresh the node " f"list (R) and re-pick, or use {sentinel}.") def _list_model_files() -> list: return _list_cat_files(_MODEL_FILE_CATS, _MODEL_FILE_MANUAL) def _resolve_model_file(choice: str) -> str: return _resolve_cat_file(choice, _MODEL_FILE_CATS, "model_file", _MODEL_FILE_MANUAL) def _list_lora_files() -> list: return _list_cat_files(_LORA_FILE_CATS, _LORA_FILE_MANUAL, exts=("*.safetensors",)) def _resolve_lora_file(choice: str) -> str: return _resolve_cat_file(choice, _LORA_FILE_CATS, "lora_file", _LORA_FILE_MANUAL) def _parse_lora_entries(raw: str, default_strength: float) -> list: """Multi-LoRA (v1.4): lora_path accepts SEVERAL entries separated by commas or newlines. Each entry is "path", "path@strength" or "path:strength" - the suffix counts as a strength only when it parses as a float, so Windows drive letters (F:\\x.safetensors) can never be mistaken for one. Entries without a suffix use the lora_strength widget. The fusion engine (apply_loras) has always summed a LIST of LoRA deltas; only this plumbing was single.""" import re as _re out = [] for part in _re.split(r"[,\n]+", raw or ""): part = part.strip().strip('"').strip("'") if not part: continue strength = float(default_strength) for sep in ("@", ":"): head, s, tail = part.rpartition(sep) if s and head: try: strength = float(tail) part = head.strip() break except ValueError: continue out.append((part, strength)) return out def _list_gemma_files() -> list: # Gemma can be a single-file .safetensors OR a .gguf, and lives in either # models/text_encoders or models/clip depending on how the user filed it. return _list_cat_files(_GEMMA_FILE_CATS, _GEMMA_FILE_MANUAL) def _resolve_gemma_file(choice: str) -> str: return _resolve_cat_file(choice, _GEMMA_FILE_CATS, "gemma_file", _GEMMA_FILE_MANUAL) class JoyEcho_LoraStacker: """Chainable LoRA stack - the familiar dropdown+strength UI. Each node adds up to three LoRAs; wire lora_stack to another stacker to chain more, and the final one into the Model Loader's lora_stack input. Every entry is fused into the DiT at load (safetensors DiT path only).""" @classmethod def INPUT_TYPES(cls): lora_list = ["(none)"] + [x for x in _list_lora_files() if x != _LORA_FILE_MANUAL] opt = {"lora_stack": ("JOYECHO_LORA_STACK", { "tooltip": "Chain from another LoRA Stack node to add more slots."})} for i in (1, 2, 3): opt[f"lora_{i}"] = (lora_list, { "default": "(none)", "tooltip": "LoRA from models/loras. (none) = slot unused."}) opt[f"strength_{i}"] = ("FLOAT", { "default": 1.0, "min": 0.0, "max": 2.0, "step": 0.05}) return {"required": {}, "optional": opt} RETURN_TYPES = ("JOYECHO_LORA_STACK",) RETURN_NAMES = ("lora_stack",) FUNCTION = "stack" CATEGORY = "JoyAI-Echo" def stack(self, lora_stack=None, **kw): out = list(lora_stack) if lora_stack else [] for i in (1, 2, 3): choice = kw.get(f"lora_{i}") or "(none)" if choice != "(none)" and ": " in str(choice): out.append((_resolve_lora_file(choice), float(kw.get(f"strength_{i}", 1.0)))) return (out,) class JoyEcho_ModelLoader: """Load JoyAI-Echo model components: text encoder, DiT generator, and VAEs.""" @classmethod def INPUT_TYPES(cls): return { # All inputs are optional: pick from the dropdowns for the common # case, or fall back to the manual *_path fields for a GGUF's VAE # source, an HF gemma DIRECTORY, or a file outside the model tree. "required": {}, "optional": { # --- DiT: pick a full/GGUF model, or type a full checkpoint --- "model_file": (_list_model_files(), { "default": _MODEL_FILE_MANUAL, "tooltip": "Pick the model instead of typing checkpoint_path. " "A .safetensors = FULL checkpoint (replaces checkpoint_path " "entirely: DiT + VAEs + vocoder + text connectors from that " "file). A .gguf = DiT ONLY - checkpoint_path must still point " "at a full safetensors (e.g. the JoyAI release) to supply the " "VAEs/vocoder/connectors. Refresh the node list (R) after " "adding files.", }), "checkpoint_path": ("STRING", { "default": "", "tooltip": "Manual fallback / GGUF VAE source. A full safetensors " "checkpoint supplying the VAEs, vocoder and text connectors. " "REQUIRED when model_file is a .gguf (DiT only); leave empty " "when model_file is a full .safetensors.", }), # --- text encoder: pick a single file, or type a path/dir --- "gemma_file": (_list_gemma_files(), { "default": _GEMMA_FILE_MANUAL, "tooltip": "Pick the Gemma text encoder from models/text_encoders or " "models/clip instead of typing gemma_path. Single-file " ".safetensors or .gguf only - for an HF gemma-3-12b-it " "DIRECTORY, leave this on the sentinel and type the folder in " "gemma_path. Refresh the node list (R) after adding files.", }), "gemma_path": ("STRING", { "default": "", "tooltip": "Manual fallback for the text encoder. Use for an HF " "gemma-3-12b-it DIRECTORY (dropdowns list files, not folders), " "or an encoder outside models/text_encoders and models/clip. " "Leave empty when gemma_file is set.", }), # --- LoRA: pick from the loras tree, or type a path --- "lora_file": (_list_lora_files(), { "default": _LORA_FILE_MANUAL, "tooltip": "Pick a LoRA from the models/loras tree instead of typing " "lora_path. Applied at lora_strength on the safetensors DiT " "path (ignored when a GGUF DiT is selected). Refresh the node " "list (R) after adding files.", }), "lora_stack": ("JOYECHO_LORA_STACK", { "tooltip": "Wire a JoyEcho LoRA Stack node here for the " "dropdown+strength multi-LoRA UI. Stacks with " "lora_file and lora_path entries.", }), "lora_path": ("STRING", { "default": "", "multiline": True, "tooltip": "One or MORE LoRAs, separated by commas or newlines. Each " "entry is a path, optionally with its own strength: " "'a.safetensors@0.7, b.safetensors@0.5'. Entries without a " "strength use lora_strength. Stacks WITH the lora_file pick. " "Safetensors DiT path only (ignored on a GGUF DiT).", }), "lora_strength": ("FLOAT", { "default": 1.0, "min": 0.0, "max": 2.0, "step": 0.05, }), # --- quantization toggles (DiT, then encoder) --- "fp8_transformer": ("BOOLEAN", { "default": False, "tooltip": "Quantize the DiT's attention/FF linear weights to " "float8_e4m3fn at load (upcast per-layer during inference). " "Roughly halves transformer weight memory - works from the " "normal bf16 checkpoint, keeping JoyAI's memory training and " "projection tensors intact. Slight quality cost; VAEs, text " "encoder and non-linear layers stay bf16. Ignored when a " "GGUF is picked in model_file (already quantized).", }), "fp8_scaled_mm": ("BOOLEAN", { "default": False, "tooltip": "Store DiT linears as float8_e4m3fn AND compute the matmuls " "natively in fp8 via torch._scaled_mm (RTX 40/50-series). " "Unlike fp8_transformer there is NO per-layer upcast tax - " "and at ~22GB resident the DiT can run with " "sequential_offload OFF at moderate resolutions. Overrides " "fp8_transformer. Ignored for GGUF DiTs.", }), "encoder_fp8": ("BOOLEAN", { "default": False, "tooltip": "Store the Gemma text encoder's linear weights as " "float8_e4m3fn (upcast per-layer at encode). Roughly halves " "the encoder's ~24GB footprint - it fits the GPU for the " "encode pass (seconds per shot instead of ~12s on CPU) and " "frees ~11GB system RAM. Encode runs once per queue item, so " "the upcast tax is irrelevant here. Slight embedding shift - " "voice is the canary; A/B before adopting.", }), # --- misc --- "low_vram": ("BOOLEAN", { "default": False, "tooltip": "Load text encoder on CPU for 24GB GPUs. " "Encoding will be slower but uses no GPU memory.", }), }, } RETURN_TYPES = ("JOYECHO_MODEL",) RETURN_NAMES = ("model",) FUNCTION = "load_model" CATEGORY = "JoyAI-Echo" def load_model(self, checkpoint_path: str = "", gemma_path: str = "", lora_path: str = "", lora_strength: float = 1.0, lora_stack=None, low_vram: bool = False, fp8_transformer: bool = False, model_file: str = _MODEL_FILE_MANUAL, lora_file: str = _LORA_FILE_MANUAL, fp8_scaled_mm: bool = False, encoder_fp8: bool = False, gemma_file: str = _GEMMA_FILE_MANUAL): from ltx_core.loader import LTXV_LORA_COMFY_RENAMING_MAP, LoraPathStrengthAndSDOps from ltx_core.quantization import QuantizationPolicy from ltx_distillation.models.ltx_wrapper import create_ltx2_wrapper from ltx_distillation.models.text_encoder_wrapper import create_text_encoder_wrapper from ltx_distillation.models.vae_wrapper import create_vae_wrappers dropdown_lora = None if lora_file and lora_file != _LORA_FILE_MANUAL: dropdown_lora = _resolve_lora_file(lora_file) gguf_dit_path = None if model_file and model_file != _MODEL_FILE_MANUAL: _resolved = _resolve_model_file(model_file) if _resolved.lower().endswith(".gguf"): gguf_dit_path = _resolved print(f"[JoyEcho] model_file: DiT from GGUF {_resolved}; VAEs/vocoder/" f"connectors from checkpoint_path.", flush=True) else: checkpoint_path = _resolved print(f"[JoyEcho] model_file: full checkpoint {_resolved}.", flush=True) # A real pick is always "category: relative/path"; the sentinel (any # "(use ...)" placeholder) has no ": ", so this guard is robust to the # sentinel wording and to stale saved values from an older node version. if gemma_file and ": " in gemma_file: gemma_path = _resolve_gemma_file(gemma_file) print(f"[JoyEcho] gemma_file: {gemma_path}", flush=True) if not str(gemma_path).strip(): raise ValueError( "No text encoder selected. Pick a Gemma in gemma_file, or type its " "path/directory in gemma_path.") if not str(checkpoint_path).strip(): raise ValueError( "checkpoint_path is empty. It must point at a FULL safetensors checkpoint" + (" - with a GGUF picked in model_file it still supplies the VAEs, " "vocoder and text connectors (e.g. echo-longvideo-release.safetensors)." if gguf_dit_path else " (or pick a .safetensors in model_file).")) checkpoint_path = str(Path(checkpoint_path).expanduser().resolve()) gemma_path = str(Path(gemma_path).expanduser().resolve()) # ComfyUI-quantized checkpoints ("fp8mixed learned" builds, marked by # .comfy_quant tensors) are packaged for the standard ComfyUI loader. # This ledger path never applies their weight_scale at runtime (the # scaled-mm consumer needs tensorrt_llm) and LoRA fusion assumes the # LTX transposed fp8 convention - the model would load MIS-SCALED and # LoRA fusion crashes with shape errors. Refuse early and clearly. if checkpoint_path.lower().endswith(".safetensors"): try: import json as _json import struct as _struct with open(checkpoint_path, "rb") as _f: _n = _struct.unpack("