import json import os import base64 import binascii from io import BytesIO from PIL import Image, ImageOps import numpy as np import torch import folder_paths import node_helpers import comfy.utils import comfy_extras.nodes_lt as nodes_lt from comfy.ldm.lightricks.symmetric_patchifier import SymmetricPatchifier, latent_to_pixel_coords from comfy_api.latest import io def _parse_guides_json(guides_json): if guides_json is None: return [] raw = guides_json.strip() if not raw: return [] try: data = json.loads(raw) except json.JSONDecodeError: if os.path.exists(raw): with open(raw, "r", encoding="utf-8") as f: data = json.load(f) else: raise if isinstance(data, dict) and "guides" in data: guides = data["guides"] elif isinstance(data, list): guides = data else: raise ValueError("guides_json must be a list or a dict with a 'guides' key") cleaned = [] for idx, item in enumerate(guides): if not isinstance(item, dict): raise ValueError(f"guide #{idx} must be an object") image_path = item.get("image") or item.get("image_base64") or item.get("image_path") or item.get("path") if not image_path: raise ValueError(f"guide #{idx} missing image (path/base64)") frame_idx = item.get("frame_idx", item.get("frame", 0)) strength = item.get("strength", 1.0) preprocess = item.get("preprocess", True) preprocess_crf = item.get("preprocess_crf", 33) cleaned.append({ "image": image_path, "frame_idx": int(frame_idx), "strength": float(strength), "preprocess": bool(preprocess), "preprocess_crf": int(preprocess_crf), }) return cleaned def _parse_refs_json(refs_json): if refs_json is None: return [] raw = refs_json.strip() if not raw: return [] try: data = json.loads(raw) except json.JSONDecodeError: if os.path.exists(raw): with open(raw, "r", encoding="utf-8") as f: data = json.load(f) else: raise if isinstance(data, dict) and "refs" in data: refs = data["refs"] elif isinstance(data, list): refs = data else: raise ValueError("refs_json must be a list or a dict with a 'refs' key") cleaned = [] for idx, item in enumerate(refs): if not isinstance(item, dict): raise ValueError(f"ref #{idx} must be an object") image_path = item.get("image") or item.get("image_base64") or item.get("image_path") or item.get("path") if not image_path: raise ValueError(f"ref #{idx} missing image (path/base64)") strength = item.get("strength", 1.0) preprocess = item.get("preprocess", True) preprocess_crf = item.get("preprocess_crf", 33) cleaned.append({ "image": image_path, "strength": float(strength), "preprocess": bool(preprocess), "preprocess_crf": int(preprocess_crf), }) return cleaned def _resolve_image_path(image_path): path = image_path.strip() if os.path.isabs(path) and os.path.exists(path): return path if os.path.exists(path): return path try: resolved = folder_paths.get_annotated_filepath(path) if os.path.exists(resolved): return resolved except Exception: pass raise FileNotFoundError(f"Image not found: {image_path}") def _load_image_tensor(image_path): img = Image.open(image_path) img = ImageOps.exif_transpose(img) if img.mode != "RGB": img = img.convert("RGB") image = np.array(img).astype(np.float32) / 255.0 return torch.from_numpy(image)[None,] def _load_image_tensor_from_base64(image_data): raw = image_data.strip() if raw.startswith("data:"): comma_idx = raw.find(",") if comma_idx == -1: raise ValueError("Invalid data URI for image") raw = raw[comma_idx + 1:] elif raw.startswith("base64,"): raw = raw[len("base64,"):] raw = "".join(raw.split()) if not raw: raise ValueError("Empty base64 image data") padded = raw + ("=" * ((4 - (len(raw) % 4)) % 4)) try: image_bytes = base64.b64decode(padded, validate=True) except (binascii.Error, ValueError) as e: raise ValueError("Invalid base64 image data") from e img = Image.open(BytesIO(image_bytes)) img = ImageOps.exif_transpose(img) if img.mode != "RGB": img = img.convert("RGB") image = np.array(img).astype(np.float32) / 255.0 return torch.from_numpy(image)[None,] def _load_image_tensor_from_json_value(image_value): if not isinstance(image_value, str): raise ValueError("image must be a string (path or base64)") value = image_value.strip() if not value: raise ValueError("image is empty") maybe_data_uri = value.startswith("data:") or value.startswith("base64,") if not maybe_data_uri: try: resolved = _resolve_image_path(value) return _load_image_tensor(resolved) except FileNotFoundError: pass return _load_image_tensor_from_base64(value) _LTXV_PATCHIFIER = SymmetricPatchifier(1, start_end=True) 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): for t in cond: if "keyframe_idxs" in t[1]: keyframe_idxs = t[1]["keyframe_idxs"] break else: return None, 0 num_keyframes = torch.unique(keyframe_idxs[:, 0, :, 0]).shape[0] return keyframe_idxs, num_keyframes def _add_keyframe_index(cond, frame_idx, guiding_latent, scale_factors): keyframe_idxs, _ = _get_keyframe_idxs(cond) _, latent_coords = _LTXV_PATCHIFIER.patchify(guiding_latent) pixel_coords = latent_to_pixel_coords(latent_coords, scale_factors, causal_fix=frame_idx == 0) pixel_coords[:, 0] += frame_idx if keyframe_idxs is None: keyframe_idxs = pixel_coords else: keyframe_idxs = torch.cat([keyframe_idxs, pixel_coords], dim=2) return node_helpers.conditioning_set_values(cond, {"keyframe_idxs": keyframe_idxs}) def _build_mask_ramp(guide_frames, slope_len, device, dtype): if guide_frames <= 1: return torch.ones((guide_frames,), device=device, dtype=dtype) slope_len = max(1, min(slope_len, guide_frames // 2)) coeffs = torch.ones((guide_frames,), device=device, dtype=dtype) for i in range(slope_len): value = (i + 1) / slope_len coeffs[i] = value coeffs[-(i + 1)] = value return coeffs def _maybe_expand_guide(image_1, guiding_latent, mask_mode, ramp_frames): if mask_mode != "ramp" or ramp_frames <= 1: return image_1, guiding_latent if guiding_latent.shape[2] == 1 and ramp_frames > 1: guiding_latent = guiding_latent.repeat(1, 1, ramp_frames, 1, 1) image_1 = image_1.repeat(ramp_frames, 1, 1, 1) return image_1, guiding_latent def _append_keyframe( positive, negative, frame_idx, latent_image, noise_mask, guiding_latent, strength, scale_factors, mask_mode, ramp_frames, ): if latent_image.shape[1] != 128 or guiding_latent.shape[1] != 128: raise ValueError("Adding guide to a combined AV latent is not supported.") positive = _add_keyframe_index(positive, frame_idx, guiding_latent, scale_factors) negative = _add_keyframe_index(negative, frame_idx, guiding_latent, scale_factors) guide_frames = guiding_latent.shape[2] if mask_mode == "ramp": slope_len = max(1, min(ramp_frames // 2, guide_frames // 2)) coeffs = _build_mask_ramp( guide_frames, slope_len, noise_mask.device, noise_mask.dtype, ) coeffs = coeffs.view(1, 1, guide_frames, 1, 1) mask = 1.0 - (strength * coeffs) mask = mask.expand( noise_mask.shape[0], 1, guide_frames, noise_mask.shape[3], noise_mask.shape[4], ) else: mask = torch.full( (noise_mask.shape[0], 1, guide_frames, noise_mask.shape[3], noise_mask.shape[4]), 1.0 - strength, dtype=noise_mask.dtype, device=noise_mask.device, ) latent_image = torch.cat([latent_image, guiding_latent], dim=2) noise_mask = torch.cat([noise_mask, mask], dim=2) return positive, negative, latent_image, noise_mask def _encode_guide(vae, latent_width, latent_height, images, scale_factors): time_scale_factor, width_scale_factor, height_scale_factor = scale_factors images = images[:(images.shape[0] - 1) // time_scale_factor * time_scale_factor + 1] pixels = comfy.utils.common_upscale( images.movedim(-1, 1), latent_width * width_scale_factor, latent_height * height_scale_factor, "bilinear", crop="disabled", ).movedim(1, -1) encode_pixels = pixels[:, :, :, :3] t = vae.encode(encode_pixels) return encode_pixels, t def _resize_for_preprocess(images, target_width, target_height, upscale_method): divisible_by = 32 if divisible_by > 1: target_width = target_width - (target_width % divisible_by) target_height = target_height - (target_height % divisible_by) _, height, width, _ = images.shape if target_width == 0 and target_height == 0: target_width = width target_height = height elif target_width == 0: target_width = round(width * (target_height / height)) elif target_height == 0: target_height = round(height * (target_width / width)) ratio = max(target_width / width, target_height / height) new_width = max(1, round(width * ratio)) new_height = max(1, round(height * ratio)) resized = comfy.utils.common_upscale( images.movedim(-1, 1), new_width, new_height, upscale_method, crop="disabled", ).movedim(1, -1) left = max(0, (new_width - target_width) // 2) top = max(0, (new_height - target_height) // 2) right = left + target_width bottom = top + target_height resized = resized[:, top:bottom, left:right, :] return resized.clamp(0, 1) def _get_latent_index(cond, latent_length, guide_length, frame_idx, scale_factors): time_scale_factor, _, _ = scale_factors # Negative frame_idx means positions before frame 0 (do not remap to end). if frame_idx >= 0 and guide_length > 1 and frame_idx != 0: frame_idx = (frame_idx - 1) // time_scale_factor * time_scale_factor + 1 latent_idx = (frame_idx + time_scale_factor - 1) // time_scale_factor return frame_idx, latent_idx def _adjust_negative_frame_idx(frame_idx, guide_length, time_scale_factor, negative_frame_mode): if negative_frame_mode != "before_start": return frame_idx if frame_idx < 0 and guide_length > 1: frame_idx = frame_idx - time_scale_factor * (guide_length - 1) return frame_idx def _format_id_list(ids): if not ids: return "[]" if len(ids) <= 10: return "[" + ", ".join(str(i) for i in ids) + "]" return f"[{ids[0]}..{ids[-1]}] (len={len(ids)})" def _build_info_text( frame_step, mask_mode, ramp_frames, negative_frame_mode, upscale_method, guide_info, step_multiplier=None, ): header_parts = [ f"frame_step={frame_step}", f"mask_mode={mask_mode}", f"ramp_frames={ramp_frames}", f"negative_frame_mode={negative_frame_mode}", f"upscale_method={upscale_method}", ] if step_multiplier is not None: header_parts.insert(1, f"step_multiplier={step_multiplier}") header_parts.insert(2, f"effective_step={frame_step * step_multiplier}") lines = ["; ".join(header_parts)] lines.extend(guide_info) return "\n".join(lines) class LTXVAddGuideMultiJsonFc: @classmethod def INPUT_TYPES(cls): return { "required": { "positive": ("CONDITIONING",), "negative": ("CONDITIONING",), "vae": ("VAE",), "latent": ("LATENT",), "mask_mode": ( ["constant", "ramp"], {"default": "constant"}, ), "ramp_frames": ( "INT", {"default": 1, "min": 1, "max": 64, "step": 1}, ), "upscale_method": ( ["nearest-exact", "bilinear", "lanczos"], {"default": "nearest-exact"}, ), "negative_frame_mode": ( ["allow_cross_zero", "before_start"], {"default": "allow_cross_zero"}, ), "guides_json": ("STRING", {"default": "", "multiline": True}), } } RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT", "IMAGE", "STRING") RETURN_NAMES = ("positive", "negative", "latent", "processed_images", "info") FUNCTION = "add" CATEGORY = "LTX2" def add( self, positive, negative, vae, latent, mask_mode, ramp_frames, upscale_method, negative_frame_mode, guides_json, ): guides = _parse_guides_json(guides_json) scale_factors = vae.downscale_index_formula latent_image = latent["samples"] noise_mask = _get_noise_mask(latent) _, _, latent_length, latent_height, latent_width = latent_image.shape time_scale_factor = scale_factors[0] _, width_scale_factor, height_scale_factor = scale_factors target_width = latent_width * width_scale_factor target_height = latent_height * height_scale_factor processed_images = [] guide_info = [] for guide in guides: img = _load_image_tensor_from_json_value(guide["image"]) img = _resize_for_preprocess(img, target_width, target_height, upscale_method) if guide.get("preprocess", True): img = nodes_lt.LTXVPreprocess().execute( img, guide.get("preprocess_crf", 33), )[0] processed_images.append(img) f_idx = guide["frame_idx"] strength = guide["strength"] image_1, t = _encode_guide(vae, latent_width, latent_height, img, scale_factors) image_1, t = _maybe_expand_guide(image_1, t, mask_mode, ramp_frames) f_idx = _adjust_negative_frame_idx( f_idx, len(image_1), time_scale_factor, negative_frame_mode, ) frame_idx, latent_idx = _get_latent_index( positive, latent_length, len(image_1), f_idx, scale_factors, ) guide_frames = t.shape[2] latent_ids = list(range(latent_idx, latent_idx + guide_frames)) frame_ids = [frame_idx + time_scale_factor * k for k in range(guide_frames)] guide_info.append( "guide#{idx}: frame_idx={frame_idx}; latent_ids={latent_ids}; frame_ids={frame_ids}; strength={strength}".format( idx=len(guide_info) + 1, frame_idx=frame_idx, latent_ids=_format_id_list(latent_ids), frame_ids=_format_id_list(frame_ids), strength=strength, ) ) if latent_idx + t.shape[2] > latent_length: raise ValueError("Conditioning frames exceed the length of the latent sequence.") positive, negative, latent_image, noise_mask = _append_keyframe( positive, negative, frame_idx, latent_image, noise_mask, t, strength, scale_factors, mask_mode, ramp_frames, ) if processed_images: processed_batch = torch.cat(processed_images, dim=0) else: processed_batch = torch.empty( (0, target_height, target_width, 3), dtype=latent_image.dtype, device=latent_image.device, ) info_text = _build_info_text( frame_step=time_scale_factor, mask_mode=mask_mode, ramp_frames=ramp_frames, negative_frame_mode=negative_frame_mode, upscale_method=upscale_method, guide_info=guide_info, ) return ( positive, negative, {"samples": latent_image, "noise_mask": noise_mask}, processed_batch, info_text, ) class LTXVAddGuideMultiFc(io.ComfyNode): @classmethod def define_schema(cls): options = [] for num_guides in range(1, 21): guide_inputs = [] for i in range(1, num_guides + 1): guide_inputs.extend([ io.Image.Input(f"image_{i}"), io.Int.Input( f"frame_idx_{i}", default=0, min=-9999, max=9999, tooltip=f"Frame index for guide {i}.", ), io.Float.Input( f"strength_{i}", default=1.0, min=0.0, max=1.0, step=0.01, tooltip=f"Strength for guide {i}.", ), io.Boolean.Input( f"preprocess_{i}", default=True, tooltip=f"Apply LTXVPreprocess to guide {i} before encoding.", ), io.Int.Input( f"preprocess_crf_{i}", default=33, min=0, max=51, step=1, tooltip=f"CRF value for LTXVPreprocess for guide {i}.", ), ]) options.append(io.DynamicCombo.Option( key=str(num_guides), inputs=guide_inputs, )) return io.Schema( node_id="LTXVAddGuideMultiFc", category="KJNodes/ltxv", description=( "Add multiple guide images at specified frame indices with strengths, " "uses DynamicCombo which requires ComfyUI 0.8.1 and frontend 1.33.4 or later." ), inputs=[ io.Conditioning.Input("positive", tooltip="Positive conditioning to which guide keyframe info will be added"), io.Conditioning.Input("negative", tooltip="Negative conditioning to which guide keyframe info will be added"), io.Vae.Input("vae", tooltip="Video VAE used to encode the guide images"), io.Latent.Input("latent", tooltip="Video latent, guides are added to the end of this latent"), io.Combo.Input( "mask_mode", options=["constant", "ramp"], default="constant", tooltip="Select how to build the guide noise mask.", ), io.Int.Input( "ramp_frames", default=1, min=1, max=64, step=1, tooltip="Expand single-frame guides to this many frames when using ramp.", ), io.Combo.Input( "upscale_method", options=["nearest-exact", "bilinear", "lanczos"], default="nearest-exact", tooltip="Resize method used before preprocessing.", ), io.Combo.Input( "negative_frame_mode", options=["allow_cross_zero", "before_start"], default="allow_cross_zero", tooltip="How to place negative frame indices when guide spans multiple frames.", ), io.DynamicCombo.Input( "num_guides", options=options, display_name="Number of Guides", tooltip="Select how many guide images to use", ), ], outputs=[ io.Conditioning.Output(display_name="positive"), io.Conditioning.Output(display_name="negative"), io.Latent.Output(display_name="latent", tooltip="Video latent with added guides"), io.Image.Output(display_name="processed_images", tooltip="Batch of processed guide images"), io.String.Output(display_name="info", tooltip="Guide index mapping info"), ], ) @classmethod def execute( cls, positive, negative, vae, latent, mask_mode, ramp_frames, upscale_method, negative_frame_mode, num_guides, ) -> io.NodeOutput: scale_factors = vae.downscale_index_formula latent_image = latent["samples"] noise_mask = _get_noise_mask(latent) _, _, latent_length, latent_height, latent_width = latent_image.shape _, width_scale_factor, height_scale_factor = scale_factors target_width = latent_width * width_scale_factor target_height = latent_height * height_scale_factor image_keys = sorted([k for k in num_guides.keys() if k.startswith("image_")]) processed_images = [] guide_info = [] for img_key in image_keys: i = img_key.split("_")[1] img = num_guides[f"image_{i}"] f_idx = num_guides[f"frame_idx_{i}"] strength = num_guides[f"strength_{i}"] img = _resize_for_preprocess(img, target_width, target_height, upscale_method) if num_guides.get(f"preprocess_{i}", True): img = nodes_lt.LTXVPreprocess().execute( img, num_guides.get(f"preprocess_crf_{i}", 33), )[0] processed_images.append(img) image_1, t = _encode_guide(vae, latent_width, latent_height, img, scale_factors) image_1, t = _maybe_expand_guide(image_1, t, mask_mode, ramp_frames) f_idx = _adjust_negative_frame_idx( f_idx, len(image_1), scale_factors[0], negative_frame_mode, ) frame_idx, latent_idx = _get_latent_index( positive, latent_length, len(image_1), f_idx, scale_factors, ) guide_frames = t.shape[2] latent_ids = list(range(latent_idx, latent_idx + guide_frames)) frame_ids = [frame_idx + scale_factors[0] * k for k in range(guide_frames)] guide_info.append( "guide#{idx}: frame_idx={frame_idx}; latent_ids={latent_ids}; frame_ids={frame_ids}; strength={strength}".format( idx=len(guide_info) + 1, frame_idx=frame_idx, latent_ids=_format_id_list(latent_ids), frame_ids=_format_id_list(frame_ids), strength=strength, ) ) assert latent_idx + t.shape[2] <= latent_length, "Conditioning frames exceed the length of the latent sequence." positive, negative, latent_image, noise_mask = _append_keyframe( positive, negative, frame_idx, latent_image, noise_mask, t, strength, scale_factors, mask_mode, ramp_frames, ) processed_batch = torch.cat(processed_images, dim=0) info_text = _build_info_text( frame_step=scale_factors[0], mask_mode=mask_mode, ramp_frames=ramp_frames, negative_frame_mode=negative_frame_mode, upscale_method=upscale_method, guide_info=guide_info, ) return io.NodeOutput( positive, negative, {"samples": latent_image, "noise_mask": noise_mask}, processed_batch, info_text, ) class LTXVAddRefMultiFc(io.ComfyNode): @classmethod def define_schema(cls): options = [] for num_refs in range(1, 21): ref_inputs = [] for i in range(1, num_refs + 1): ref_inputs.extend([ io.Image.Input(f"image_{i}"), io.Float.Input( f"strength_{i}", default=1.0, min=0.0, max=1.0, step=0.01, tooltip=f"Strength for reference {i}.", ), io.Boolean.Input( f"preprocess_{i}", default=True, tooltip=f"Apply LTXVPreprocess to reference {i} before encoding.", ), io.Int.Input( f"preprocess_crf_{i}", default=33, min=0, max=51, step=1, tooltip=f"CRF value for LTXVPreprocess for reference {i}.", ), ]) options.append(io.DynamicCombo.Option( key=str(num_refs), inputs=ref_inputs, )) return io.Schema( node_id="LTXVAddRefMultiFc", category="KJNodes/ltxv", description=( "Add multiple reference images at automatically assigned negative frame indices, " "uses DynamicCombo which requires ComfyUI 0.8.1 and frontend 1.33.4 or later." ), inputs=[ io.Conditioning.Input("positive", tooltip="Positive conditioning to which guide keyframe info will be added"), io.Conditioning.Input("negative", tooltip="Negative conditioning to which guide keyframe info will be added"), io.Vae.Input("vae", tooltip="Video VAE used to encode the guide images"), io.Latent.Input("latent", tooltip="Video latent, guides are added to the end of this latent"), io.Int.Input( "step_multiplier", default=2, min=1, max=100, step=1, tooltip="Multiply frame_step to space reference frames.", ), io.Combo.Input( "mask_mode", options=["constant", "ramp"], default="constant", tooltip="Select how to build the guide noise mask.", ), io.Int.Input( "ramp_frames", default=1, min=1, max=64, step=1, tooltip="Expand single-frame refs to this many frames when using ramp.", ), io.Combo.Input( "upscale_method", options=["nearest-exact", "bilinear", "lanczos"], default="nearest-exact", tooltip="Resize method used before preprocessing.", ), io.Combo.Input( "negative_frame_mode", options=["allow_cross_zero", "before_start"], default="allow_cross_zero", tooltip="How to place negative frame indices when refs span multiple frames.", ), io.DynamicCombo.Input( "num_refs", options=options, display_name="Number of Refs", tooltip="Select how many reference images to use", ), ], outputs=[ io.Conditioning.Output(display_name="positive"), io.Conditioning.Output(display_name="negative"), io.Latent.Output(display_name="latent", tooltip="Video latent with added references"), io.Image.Output(display_name="processed_images", tooltip="Batch of processed reference images"), io.Int.Output(display_name="frame_step", tooltip="Frame step (vae.downscale_index_formula[0])"), io.String.Output(display_name="info", tooltip="Reference index mapping info"), ], ) @classmethod def execute( cls, positive, negative, vae, latent, step_multiplier, mask_mode, ramp_frames, upscale_method, negative_frame_mode, num_refs, ) -> io.NodeOutput: scale_factors = vae.downscale_index_formula frame_step = scale_factors[0] effective_step = frame_step * step_multiplier latent_image = latent["samples"] noise_mask = _get_noise_mask(latent) _, _, latent_length, latent_height, latent_width = latent_image.shape _, width_scale_factor, height_scale_factor = scale_factors target_width = latent_width * width_scale_factor target_height = latent_height * height_scale_factor image_keys = sorted([k for k in num_refs.keys() if k.startswith("image_")]) processed_images = [] guide_info = [] for img_key in image_keys: i = img_key.split("_")[1] img = num_refs[f"image_{i}"] strength = num_refs[f"strength_{i}"] frame_idx = -effective_step * int(i) img = _resize_for_preprocess(img, target_width, target_height, upscale_method) if num_refs.get(f"preprocess_{i}", True): img = nodes_lt.LTXVPreprocess().execute( img, num_refs.get(f"preprocess_crf_{i}", 33), )[0] processed_images.append(img) image_1, t = _encode_guide(vae, latent_width, latent_height, img, scale_factors) image_1, t = _maybe_expand_guide(image_1, t, mask_mode, ramp_frames) frame_idx = _adjust_negative_frame_idx( frame_idx, len(image_1), scale_factors[0], negative_frame_mode, ) frame_idx, latent_idx = _get_latent_index( positive, latent_length, len(image_1), frame_idx, scale_factors, ) guide_frames = t.shape[2] latent_ids = list(range(latent_idx, latent_idx + guide_frames)) frame_ids = [frame_idx + frame_step * k for k in range(guide_frames)] guide_info.append( "ref#{idx}: frame_idx={frame_idx}; latent_ids={latent_ids}; frame_ids={frame_ids}; strength={strength}".format( idx=len(guide_info) + 1, frame_idx=frame_idx, latent_ids=_format_id_list(latent_ids), frame_ids=_format_id_list(frame_ids), strength=strength, ) ) assert latent_idx + t.shape[2] <= latent_length, "Conditioning frames exceed the length of the latent sequence." positive, negative, latent_image, noise_mask = _append_keyframe( positive, negative, frame_idx, latent_image, noise_mask, t, strength, scale_factors, mask_mode, ramp_frames, ) processed_batch = torch.cat(processed_images, dim=0) info_text = _build_info_text( frame_step=frame_step, mask_mode=mask_mode, ramp_frames=ramp_frames, negative_frame_mode=negative_frame_mode, upscale_method=upscale_method, guide_info=guide_info, step_multiplier=step_multiplier, ) return io.NodeOutput( positive, negative, {"samples": latent_image, "noise_mask": noise_mask}, processed_batch, frame_step, info_text, ) class LTXVAddRefMultiJsonFc: @classmethod def INPUT_TYPES(cls): return { "required": { "positive": ("CONDITIONING",), "negative": ("CONDITIONING",), "vae": ("VAE",), "latent": ("LATENT",), "step_multiplier": ( "INT", {"default": 2, "min": 1, "max": 100, "step": 1}, ), "mask_mode": ( ["constant", "ramp"], {"default": "constant"}, ), "ramp_frames": ( "INT", {"default": 1, "min": 1, "max": 64, "step": 1}, ), "upscale_method": ( ["nearest-exact", "bilinear", "lanczos"], {"default": "nearest-exact"}, ), "negative_frame_mode": ( ["allow_cross_zero", "before_start"], {"default": "allow_cross_zero"}, ), "refs_json": ("STRING", {"default": "", "multiline": True}), } } RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT", "IMAGE", "INT", "STRING") RETURN_NAMES = ("positive", "negative", "latent", "processed_images", "frame_step", "info") FUNCTION = "add" CATEGORY = "LTX2" def add( self, positive, negative, vae, latent, step_multiplier, mask_mode, ramp_frames, upscale_method, negative_frame_mode, refs_json, ): refs = _parse_refs_json(refs_json) scale_factors = vae.downscale_index_formula frame_step = scale_factors[0] effective_step = frame_step * step_multiplier latent_image = latent["samples"] noise_mask = _get_noise_mask(latent) _, _, latent_length, latent_height, latent_width = latent_image.shape _, width_scale_factor, height_scale_factor = scale_factors target_width = latent_width * width_scale_factor target_height = latent_height * height_scale_factor processed_images = [] guide_info = [] for ref_index, ref in enumerate(refs, start=1): img = _load_image_tensor_from_json_value(ref["image"]) strength = ref["strength"] frame_idx = -effective_step * ref_index img = _resize_for_preprocess(img, target_width, target_height, upscale_method) if ref.get("preprocess", True): img = nodes_lt.LTXVPreprocess().execute( img, ref.get("preprocess_crf", 33), )[0] processed_images.append(img) image_1, t = _encode_guide(vae, latent_width, latent_height, img, scale_factors) image_1, t = _maybe_expand_guide(image_1, t, mask_mode, ramp_frames) frame_idx = _adjust_negative_frame_idx( frame_idx, len(image_1), frame_step, negative_frame_mode, ) frame_idx, latent_idx = _get_latent_index( positive, latent_length, len(image_1), frame_idx, scale_factors, ) guide_frames = t.shape[2] latent_ids = list(range(latent_idx, latent_idx + guide_frames)) frame_ids = [frame_idx + frame_step * k for k in range(guide_frames)] guide_info.append( "ref#{idx}: frame_idx={frame_idx}; latent_ids={latent_ids}; frame_ids={frame_ids}; strength={strength}".format( idx=len(guide_info) + 1, frame_idx=frame_idx, latent_ids=_format_id_list(latent_ids), frame_ids=_format_id_list(frame_ids), strength=strength, ) ) if latent_idx + t.shape[2] > latent_length: raise ValueError("Conditioning frames exceed the length of the latent sequence.") positive, negative, latent_image, noise_mask = _append_keyframe( positive, negative, frame_idx, latent_image, noise_mask, t, strength, scale_factors, mask_mode, ramp_frames, ) if processed_images: processed_batch = torch.cat(processed_images, dim=0) else: processed_batch = torch.empty( (0, target_height, target_width, 3), dtype=latent_image.dtype, device=latent_image.device, ) info_text = _build_info_text( frame_step=frame_step, mask_mode=mask_mode, ramp_frames=ramp_frames, negative_frame_mode=negative_frame_mode, upscale_method=upscale_method, guide_info=guide_info, step_multiplier=step_multiplier, ) return ( positive, negative, {"samples": latent_image, "noise_mask": noise_mask}, processed_batch, frame_step, info_text, ) class LTX2R2VBrowserLLM(io.ComfyNode): @classmethod def define_schema(cls) -> io.Schema: return io.Schema( node_id="LTX2R2VBrowserLLM", display_name="LTX2 R2V Browser LLM", category="LTX2", description="Calls an OpenAI-compatible endpoint from the browser frontend.", inputs=[ io.String.Input( "system_prompt", default="", multiline=True, tooltip="System prompt passed to the LLM.", ), io.String.Input( "prompt", default="", multiline=True, tooltip="User prompt passed to the LLM.", ), io.String.Input( "api_endpoint", default="https://api.openai.com/v1/chat/completions", tooltip="OpenAI-compatible chat completions endpoint.", ), io.String.Input( "model", default="gpt-4o-mini", tooltip="Model name to request.", ), io.String.Input( "api_key", default="", tooltip="API key (stored in browser cookie by frontend).", ), io.Image.Input( "image", optional=True, tooltip="Optional reference image input.", ), io.String.Input( "response", default="", multiline=True, tooltip="LLM response populated by the frontend.", ), ], outputs=[ io.String.Output(display_name="text"), ], ) @classmethod def execute( cls, system_prompt, prompt, api_endpoint, model, api_key, response, image=None, ) -> io.NodeOutput: return io.NodeOutput(response or "") NODE_CLASS_MAPPINGS = { "LTXVAddGuideMultiJsonFc": LTXVAddGuideMultiJsonFc, "LTXVAddGuideMultiFc": LTXVAddGuideMultiFc, "LTXVAddRefMultiFc": LTXVAddRefMultiFc, "LTXVAddRefMultiJsonFc": LTXVAddRefMultiJsonFc, "LTX2R2VBrowserLLM": LTX2R2VBrowserLLM, } NODE_DISPLAY_NAME_MAPPINGS = { "LTXVAddGuideMultiJsonFc": "LTXV Add Guide Multi (JSON) FC", "LTXVAddGuideMultiFc": "LTXV Add Guide Multi FC", "LTXVAddRefMultiFc": "LTXV Add Ref Multi FC", "LTXVAddRefMultiJsonFc": "LTXV Add Ref Multi (JSON) FC", "LTX2R2VBrowserLLM": "LTX2 R2V Browser LLM", }