2024-11-22 08:44:42 -05:00
import nodes
import node_helpers
import torch
2026-04-23 21:13:17 +03:00
import torchaudio
2024-11-22 08:44:42 -05:00
import comfy . model_management
import comfy . model_sampling
2026-03-24 00:22:24 +02:00
import comfy . samplers
2025-03-05 00:13:49 -05:00
import comfy . utils
2024-11-22 08:44:42 -05:00
import math
2025-03-05 00:13:49 -05:00
import numpy as np
import av
2025-10-01 22:19:56 +03:00
from io import BytesIO
from typing_extensions import override
2025-03-05 00:13:49 -05:00
from comfy . ldm . lightricks . symmetric_patchifier import SymmetricPatchifier , latent_to_pixel_coords
2025-10-01 22:19:56 +03:00
from comfy_api . latest import ComfyExtension , io
2024-11-22 08:44:42 -05:00
2026-05-16 01:02:57 -06:00
ICLoRAParameters = io . Custom ( " IC_LORA_PARAMETERS " )
class GetICLoRAParameters ( io . ComfyNode ) :
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " GetICLoRAParameters " ,
display_name = " Get IC-LoRA Parameters " ,
description = " Extracts IC-LoRA parameters from the safetensors metadata of a LoRA-loaded "
" model and outputs them for LTXVAddGuide (eg. reference_downscale_factor). " ,
2026-06-17 08:33:09 +08:00
category = " model/conditioning/ltxv " ,
2026-05-16 01:02:57 -06:00
search_aliases = [ " ic-lora " , " ic lora " , " iclora " , " downscale factor " , " reference downscale " ] ,
inputs = [
io . Model . Input (
" iclora_model " ,
tooltip = " Direct output from a LoRA Loader for the specific IC-LoRA "
" from which to extract the metadata. " ,
) ,
] ,
outputs = [
ICLoRAParameters . Output (
" iclora_parameters " ,
tooltip = " IC-LoRA parameters extracted from the LoRA metadata "
" (eg. reference_downscale_factor). Connect to LTXVAddGuide "
" if the LoRA requires special handling of the guides. " ,
) ,
] ,
)
@classmethod
def execute ( cls , iclora_model ) - > io . NodeOutput :
metadata = iclora_model . get_attachment ( " lora_metadata " )
factor = 1
if metadata :
try :
2026-07-25 14:30:37 +03:00
factor = max ( 1 , round ( float ( next ( v for k , v in metadata . items ( ) if k . endswith ( " reference_downscale_factor " ) ) ) ) )
except ( StopIteration , TypeError , ValueError ) :
2026-05-16 01:02:57 -06:00
factor = 1
parameters = { " reference_downscale_factor " : factor }
return io . NodeOutput ( parameters )
2025-10-01 22:19:56 +03:00
class EmptyLTXVLatentVideo ( io . ComfyNode ) :
2024-11-22 08:44:42 -05:00
@classmethod
2025-10-01 22:19:56 +03:00
def define_schema ( cls ) :
return io . Schema (
node_id = " EmptyLTXVLatentVideo " ,
2026-06-17 08:33:09 +08:00
category = " model/latent/ltxv " ,
2025-10-01 22:19:56 +03:00
inputs = [
io . Int . Input ( " width " , default = 768 , min = 64 , max = nodes . MAX_RESOLUTION , step = 32 ) ,
io . Int . Input ( " height " , default = 512 , min = 64 , max = nodes . MAX_RESOLUTION , step = 32 ) ,
io . Int . Input ( " length " , default = 97 , min = 1 , max = nodes . MAX_RESOLUTION , step = 8 ) ,
io . Int . Input ( " batch_size " , default = 1 , min = 1 , max = 4096 ) ,
] ,
outputs = [
io . Latent . Output ( ) ,
] ,
)
2024-11-22 08:44:42 -05:00
2025-10-01 22:19:56 +03:00
@classmethod
def execute ( cls , width , height , length , batch_size = 1 ) - > io . NodeOutput :
2024-11-22 08:44:42 -05:00
latent = torch . zeros ( [ batch_size , 128 , ( ( length - 1 ) / / 8 ) + 1 , height / / 32 , width / / 32 ] , device = comfy . model_management . intermediate_device ( ) )
2026-05-19 20:28:06 -07:00
return io . NodeOutput ( { " samples " : latent , " downscale_ratio_spacial " : 32 } )
2024-11-22 08:44:42 -05:00
2025-10-07 16:55:23 -07:00
generate = execute # TODO: remove
2024-11-22 08:44:42 -05:00
2025-10-01 22:19:56 +03:00
class LTXVImgToVideo ( io . ComfyNode ) :
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " LTXVImgToVideo " ,
2026-06-17 08:33:09 +08:00
category = " model/conditioning/ltxv " ,
2025-10-01 22:19:56 +03:00
inputs = [
io . Conditioning . Input ( " positive " ) ,
io . Conditioning . Input ( " negative " ) ,
io . Vae . Input ( " vae " ) ,
io . Image . Input ( " image " ) ,
io . Int . Input ( " width " , default = 768 , min = 64 , max = nodes . MAX_RESOLUTION , step = 32 ) ,
io . Int . Input ( " height " , default = 512 , min = 64 , max = nodes . MAX_RESOLUTION , step = 32 ) ,
io . Int . Input ( " length " , default = 97 , min = 9 , max = nodes . MAX_RESOLUTION , step = 8 ) ,
io . Int . Input ( " batch_size " , default = 1 , min = 1 , max = 4096 ) ,
io . Float . Input ( " strength " , default = 1.0 , min = 0.0 , max = 1.0 ) ,
] ,
outputs = [
io . Conditioning . Output ( display_name = " positive " ) ,
io . Conditioning . Output ( display_name = " negative " ) ,
io . Latent . Output ( display_name = " latent " ) ,
] ,
)
2024-11-22 08:44:42 -05:00
@classmethod
2025-10-01 22:19:56 +03:00
def execute ( cls , positive , negative , image , vae , width , height , length , batch_size , strength ) - > io . NodeOutput :
2024-11-22 08:44:42 -05:00
pixels = comfy . utils . common_upscale ( image . movedim ( - 1 , 1 ) , width , height , " bilinear " , " center " ) . movedim ( 1 , - 1 )
encode_pixels = pixels [ : , : , : , : 3 ]
t = vae . encode ( encode_pixels )
latent = torch . zeros ( [ batch_size , 128 , ( ( length - 1 ) / / 8 ) + 1 , height / / 32 , width / / 32 ] , device = comfy . model_management . intermediate_device ( ) )
latent [ : , : , : t . shape [ 2 ] ] = t
2025-03-05 00:13:49 -05:00
conditioning_latent_frames_mask = torch . ones (
( batch_size , 1 , latent . shape [ 2 ] , 1 , 1 ) ,
dtype = torch . float32 ,
device = latent . device ,
)
2025-04-28 19:59:17 +03:00
conditioning_latent_frames_mask [ : , : , : t . shape [ 2 ] ] = 1.0 - strength
2025-03-05 00:13:49 -05:00
2025-10-01 22:19:56 +03:00
return io . NodeOutput ( positive , negative , { " samples " : latent , " noise_mask " : conditioning_latent_frames_mask } )
2025-03-05 00:13:49 -05:00
2025-10-07 16:55:23 -07:00
generate = execute # TODO: remove
2025-03-05 00:13:49 -05:00
2026-01-04 22:58:59 -08:00
class LTXVImgToVideoInplace ( io . ComfyNode ) :
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " LTXVImgToVideoInplace " ,
2026-06-17 08:33:09 +08:00
category = " model/conditioning/ltxv " ,
2026-01-04 22:58:59 -08:00
inputs = [
io . Vae . Input ( " vae " ) ,
io . Image . Input ( " image " ) ,
io . Latent . Input ( " latent " ) ,
io . Float . Input ( " strength " , default = 1.0 , min = 0.0 , max = 1.0 ) ,
io . Boolean . Input ( " bypass " , default = False , tooltip = " Bypass the conditioning. " )
] ,
outputs = [
io . Latent . Output ( display_name = " latent " ) ,
] ,
)
@classmethod
def execute ( cls , vae , image , latent , strength , bypass = False ) - > io . NodeOutput :
if bypass :
return ( latent , )
2026-05-08 09:02:17 -06:00
samples = latent [ " samples " ] . clone ( )
2026-01-04 22:58:59 -08:00
_ , height_scale_factor , width_scale_factor = (
vae . downscale_index_formula
)
2026-05-08 09:02:17 -06:00
_ , _ , _ , latent_height , latent_width = samples . shape
2026-01-04 22:58:59 -08:00
width = latent_width * width_scale_factor
height = latent_height * height_scale_factor
if image . shape [ 1 ] != height or image . shape [ 2 ] != width :
pixels = comfy . utils . common_upscale ( image . movedim ( - 1 , 1 ) , width , height , " bilinear " , " center " ) . movedim ( 1 , - 1 )
else :
pixels = image
encode_pixels = pixels [ : , : , : , : 3 ]
t = vae . encode ( encode_pixels )
samples [ : , : , : t . shape [ 2 ] ] = t
2026-05-08 09:02:17 -06:00
conditioning_latent_frames_mask = get_noise_mask ( latent )
2026-01-04 22:58:59 -08:00
conditioning_latent_frames_mask [ : , : , : t . shape [ 2 ] ] = 1.0 - strength
return io . NodeOutput ( { " samples " : samples , " noise_mask " : conditioning_latent_frames_mask } )
generate = execute # TODO: remove
2026-05-18 15:07:04 -06:00
def _append_guide_attention_entry ( positive , negative , pre_filter_count , latent_shape , strength = 1.0 , attention_mask = None ) :
2026-02-26 08:25:23 +02:00
""" Append a guide_attention_entry to both positive and negative conditioning.
Each entry tracks one guide reference for per - reference attention control .
Entries are derived independently from each conditioning to avoid cross - contamination .
"""
new_entry = {
" pre_filter_count " : pre_filter_count ,
" strength " : strength ,
2026-05-18 15:07:04 -06:00
" pixel_mask " : attention_mask . unsqueeze ( 0 ) . unsqueeze ( 0 ) if attention_mask is not None else None , # reshape to (1, 1, F, H, W)
2026-02-26 08:25:23 +02:00
" latent_shape " : latent_shape ,
}
2026-05-18 15:07:04 -06:00
2026-02-26 08:25:23 +02:00
results = [ ]
for cond in ( positive , negative ) :
# Read existing entries from this specific conditioning
existing = [ ]
for t in cond :
found = t [ 1 ] . get ( " guide_attention_entries " , None )
if found is not None :
existing = found
break
2026-05-18 15:07:04 -06:00
# Shallow copy only and append (pixel_mask is never mutated).
2026-02-26 08:25:23 +02:00
entries = [ * existing , new_entry ]
results . append ( node_helpers . conditioning_set_values (
cond , { " guide_attention_entries " : entries }
) )
return results [ 0 ] , results [ 1 ]
2025-03-05 00:13:49 -05:00
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
2026-05-26 20:59:32 -06:00
def get_keyframe_idxs ( cond , latent_shape = None ) :
2025-03-05 00:13:49 -05:00
keyframe_idxs = conditioning_get_any_value ( cond , " keyframe_idxs " , None )
if keyframe_idxs is None :
return None , 0
2026-05-26 20:59:32 -06:00
# Get number of keyframes from latent_shape or guide_attention_entries if available
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
entries = conditioning_get_any_value ( cond , " guide_attention_entries " , None )
if entries :
num_keyframes = sum ( e [ " latent_shape " ] [ 0 ] for e in entries )
return keyframe_idxs , num_keyframes
# fallback, may under-count if keyframes share t-start
2026-01-04 22:58:59 -08:00
# keyframe_idxs contains start/end positions (last dimension), checking for unqiue values only for start
num_keyframes = torch . unique ( keyframe_idxs [ : , 0 , : , 0 ] ) . shape [ 0 ]
2025-03-05 00:13:49 -05:00
return keyframe_idxs , num_keyframes
2025-10-01 22:19:56 +03:00
class LTXVAddGuide ( io . ComfyNode ) :
2026-01-04 22:58:59 -08:00
PATCHIFIER = SymmetricPatchifier ( 1 , start_end = True )
2025-10-01 22:19:56 +03:00
2025-03-05 00:13:49 -05:00
@classmethod
2025-10-01 22:19:56 +03:00
def define_schema ( cls ) :
return io . Schema (
node_id = " LTXVAddGuide " ,
2026-06-17 08:33:09 +08:00
category = " model/conditioning/ltxv " ,
2025-10-01 22:19:56 +03:00
inputs = [
io . Conditioning . Input ( " positive " ) ,
io . Conditioning . Input ( " negative " ) ,
io . Vae . Input ( " vae " ) ,
io . Latent . Input ( " latent " ) ,
io . Image . Input (
" image " ,
tooltip = " Image or video to condition the latent video on. Must be 8*n + 1 frames. "
" If the video is not 8*n + 1 frames, it will be cropped to the nearest 8*n + 1 frames. " ,
) ,
io . Int . Input (
" frame_idx " ,
default = 0 ,
min = - 9999 ,
max = 9999 ,
tooltip = " Frame index to start the conditioning at. "
" For single-frame images or videos with 1-8 frames, any frame_idx value is acceptable. "
" For videos with 9+ frames, frame_idx must be divisible by 8, otherwise it will be rounded "
" down to the nearest multiple of 8. Negative values are counted from the end of the video. " ,
) ,
2026-05-16 00:02:27 +03:00
io . Float . Input ( " strength " , default = 1.0 , min = 0.0 , max = 10.0 , step = 0.01 ) ,
2026-05-18 15:07:04 -06:00
io . Mask . Input (
" attention_mask " ,
optional = True ,
tooltip = " Optional pixel-space spatial mask. Controls per-region "
" conditioning influence via self-attention, multiplied by strength. " ,
) ,
2026-05-16 01:02:57 -06:00
ICLoRAParameters . Input (
" iclora_parameters " ,
optional = True ,
tooltip = " Optional IC-LoRA parameters from a Get IC-LoRA Parameters node. "
" Used for adjusting guide processing as required by certain IC-LoRAs "
" (eg. those with a reference_downscale_factor > 1). "
" When chained, each LTXVAddGuide uses only the parameters connected to it. " ,
) ,
2025-10-01 22:19:56 +03:00
] ,
outputs = [
io . Conditioning . Output ( display_name = " positive " ) ,
io . Conditioning . Output ( display_name = " negative " ) ,
io . Latent . Output ( display_name = " latent " ) ,
] ,
)
@classmethod
2026-05-16 01:02:57 -06:00
def encode ( cls , vae , latent_width , latent_height , images , scale_factors , latent_downscale_factor = 1 ) :
2025-03-05 00:13:49 -05:00
time_scale_factor , width_scale_factor , height_scale_factor = scale_factors
images = images [ : ( images . shape [ 0 ] - 1 ) / / time_scale_factor * time_scale_factor + 1 ]
2026-05-16 01:02:57 -06:00
target_width = int ( latent_width * width_scale_factor / latent_downscale_factor )
target_height = int ( latent_height * height_scale_factor / latent_downscale_factor )
pixels = comfy . utils . common_upscale ( images . movedim ( - 1 , 1 ) , target_width , target_height , " bilinear " , crop = " center " ) . movedim ( 1 , - 1 )
2025-03-05 00:13:49 -05:00
encode_pixels = pixels [ : , : , : , : 3 ]
t = vae . encode ( encode_pixels )
return encode_pixels , t
2026-05-16 01:02:57 -06:00
@classmethod
def dilate_latent ( cls , guide_latent , latent_downscale_factor ) :
if latent_downscale_factor < = 1 :
return guide_latent , None
scale = int ( latent_downscale_factor )
dilated_shape = guide_latent . shape [ : 3 ] + ( guide_latent . shape [ 3 ] * scale , guide_latent . shape [ 4 ] * scale )
dilated = torch . zeros ( dilated_shape , device = guide_latent . device , dtype = guide_latent . dtype )
dilated [ . . . , : : scale , : : scale ] = guide_latent
dilated_mask = torch . full (
( dilated . shape [ 0 ] , 1 , dilated . shape [ 2 ] , dilated . shape [ 3 ] , dilated . shape [ 4 ] ) ,
- 1.0 , device = guide_latent . device , dtype = guide_latent . dtype ,
)
dilated_mask [ . . . , : : scale , : : scale ] = 1.0
return dilated , dilated_mask
@classmethod
def get_reference_downscale_factor ( cls , iclora_parameters ) :
if not iclora_parameters :
return 1
try :
factor = max ( 1 , round ( float ( iclora_parameters . get ( " reference_downscale_factor " , 1 ) ) ) )
except ( TypeError , ValueError ) :
factor = 1
return factor
2025-10-01 22:19:56 +03:00
@classmethod
2026-05-26 20:59:32 -06:00
def get_latent_index ( cls , cond , latent_length , guide_length , frame_idx , scale_factors , latent_shape = None ) :
2025-03-05 00:13:49 -05:00
time_scale_factor , _ , _ = scale_factors
2026-05-26 20:59:32 -06:00
_ , num_keyframes = get_keyframe_idxs ( cond , latent_shape )
2025-03-05 00:13:49 -05:00
latent_count = latent_length - num_keyframes
2025-03-10 10:11:48 +02:00
frame_idx = frame_idx if frame_idx > = 0 else max ( ( latent_count - 1 ) * time_scale_factor + 1 + frame_idx , 0 )
2025-07-02 21:34:51 +02:00
if guide_length > 1 and frame_idx != 0 :
frame_idx = ( frame_idx - 1 ) / / time_scale_factor * time_scale_factor + 1 # frame index - 1 must be divisible by 8 or frame_idx == 0
2025-03-05 00:13:49 -05:00
latent_idx = ( frame_idx + time_scale_factor - 1 ) / / time_scale_factor
return frame_idx , latent_idx
2025-10-01 22:19:56 +03:00
@classmethod
2026-03-05 23:51:20 +02:00
def add_keyframe_index ( cls , cond , frame_idx , guiding_latent , scale_factors , latent_downscale_factor = 1 , causal_fix = None ) :
2025-03-05 00:13:49 -05:00
keyframe_idxs , _ = get_keyframe_idxs ( cond )
2025-10-01 22:19:56 +03:00
_ , latent_coords = cls . PATCHIFIER . patchify ( guiding_latent )
2026-03-05 23:51:20 +02:00
if causal_fix is None :
causal_fix = frame_idx == 0 or guiding_latent . shape [ 2 ] == 1
pixel_coords = latent_to_pixel_coords ( latent_coords , scale_factors , causal_fix = causal_fix )
2025-03-05 00:13:49 -05:00
pixel_coords [ : , 0 ] + = frame_idx
2026-01-26 22:33:19 +02:00
# The following adjusts keyframe end positions for small grid IC-LoRA.
# After dilation, the small grid has the same size and position as the large grid,
# but each token encodes a larger image patch. We adjust the end position (not start)
# so that RoPE represents the correct middle point of each token.
# keyframe_idxs dims: (batch, spatial_dim [t,h,w], token_id, [start, end])
# We only adjust h,w (not t) in dim 1, and only end (not start) in dim 3.
spatial_end_offset = ( latent_downscale_factor - 1 ) * torch . tensor (
scale_factors [ 1 : ] ,
device = pixel_coords . device ,
) . view ( 1 , - 1 , 1 , 1 )
pixel_coords [ : , 1 : , : , 1 : ] + = spatial_end_offset . to ( pixel_coords . dtype )
2025-03-05 00:13:49 -05:00
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 } )
2025-10-01 22:19:56 +03:00
@classmethod
2026-03-05 23:51:20 +02:00
def append_keyframe ( cls , positive , negative , frame_idx , latent_image , noise_mask , guiding_latent , strength , scale_factors , guide_mask = None , in_channels = 128 , latent_downscale_factor = 1 , causal_fix = None ) :
2026-01-04 22:58:59 -08:00
if latent_image . shape [ 1 ] != in_channels or guiding_latent . shape [ 1 ] != in_channels :
raise ValueError ( " Adding guide to a combined AV latent is not supported. " )
2025-04-28 20:42:04 +03:00
2026-03-05 23:51:20 +02:00
positive = cls . add_keyframe_index ( positive , frame_idx , guiding_latent , scale_factors , latent_downscale_factor , causal_fix = causal_fix )
negative = cls . add_keyframe_index ( negative , frame_idx , guiding_latent , scale_factors , latent_downscale_factor , causal_fix = causal_fix )
2025-03-05 00:13:49 -05:00
2026-01-04 22:58:59 -08:00
if guide_mask is not None :
target_h = max ( noise_mask . shape [ 3 ] , guide_mask . shape [ 3 ] )
target_w = max ( noise_mask . shape [ 4 ] , guide_mask . shape [ 4 ] )
2025-03-05 00:13:49 -05:00
2026-01-04 22:58:59 -08:00
if noise_mask . shape [ 3 ] == 1 or noise_mask . shape [ 4 ] == 1 :
noise_mask = noise_mask . expand ( - 1 , - 1 , - 1 , target_h , target_w )
if guide_mask . shape [ 3 ] == 1 or guide_mask . shape [ 4 ] == 1 :
guide_mask = guide_mask . expand ( - 1 , - 1 , - 1 , target_h , target_w )
mask = guide_mask - strength
else :
mask = torch . full (
( noise_mask . shape [ 0 ] , 1 , guiding_latent . shape [ 2 ] , noise_mask . shape [ 3 ] , noise_mask . shape [ 4 ] ) ,
2026-05-16 00:02:27 +03:00
max ( 0.0 , 1.0 - strength ) , # clamp here to amplify only via the attention mask
2026-01-04 22:58:59 -08:00
dtype = noise_mask . dtype ,
device = noise_mask . device ,
)
# This solves audio video combined latent case where latent_image has audio latent concatenated
# in channel dimension with video latent. The solution is to pad guiding latent accordingly.
if latent_image . shape [ 1 ] > guiding_latent . shape [ 1 ] :
pad_len = latent_image . shape [ 1 ] - guiding_latent . shape [ 1 ]
guiding_latent = torch . nn . functional . pad ( guiding_latent , pad = ( 0 , 0 , 0 , 0 , 0 , 0 , 0 , pad_len ) , value = 0 )
2025-03-05 00:13:49 -05:00
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
2025-10-01 22:19:56 +03:00
@classmethod
def replace_latent_frames ( cls , latent_image , noise_mask , guiding_latent , latent_idx , strength ) :
2025-03-05 00:13:49 -05:00
cond_length = guiding_latent . shape [ 2 ]
assert latent_image . shape [ 2 ] > = latent_idx + cond_length , " Conditioning frames exceed the length of the latent sequence. "
mask = torch . full (
( noise_mask . shape [ 0 ] , 1 , cond_length , 1 , 1 ) ,
2026-05-16 00:02:27 +03:00
max ( 0.0 , 1.0 - strength ) , # clamp here to amplify only via the attention mask
2025-03-05 00:13:49 -05:00
dtype = noise_mask . dtype ,
device = noise_mask . device ,
)
latent_image = latent_image . clone ( )
noise_mask = noise_mask . clone ( )
latent_image [ : , : , latent_idx : latent_idx + cond_length ] = guiding_latent
noise_mask [ : , : , latent_idx : latent_idx + cond_length ] = mask
return latent_image , noise_mask
2025-10-01 22:19:56 +03:00
@classmethod
2026-05-18 15:07:04 -06:00
def execute ( cls , positive , negative , vae , latent , image , frame_idx , strength , attention_mask = None , iclora_parameters = None ) - > io . NodeOutput :
2025-03-05 00:13:49 -05:00
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
2026-05-12 16:57:31 -06:00
2026-05-16 01:02:57 -06:00
latent_downscale_factor = cls . get_reference_downscale_factor ( iclora_parameters )
if latent_downscale_factor > 1 :
if latent_width % latent_downscale_factor != 0 or latent_height % latent_downscale_factor != 0 :
raise ValueError (
f " Latent spatial size { latent_width } x { latent_height } must be divisible by "
f " reference_downscale_factor { latent_downscale_factor } from the IC-LoRA parameters. "
)
2026-05-12 16:57:31 -06:00
# For mid-video multi-frame guides, prepend+strip a throwaway first frame so the VAE's "first latent = 1 pixel frame" asymmetry lands on the discarded slot
time_scale_factor = scale_factors [ 0 ]
num_frames_to_keep = ( ( image . shape [ 0 ] - 1 ) / / time_scale_factor ) * time_scale_factor + 1
resolved_frame_idx = frame_idx
if frame_idx < 0 :
2026-05-26 20:59:32 -06:00
_ , num_keyframes = get_keyframe_idxs ( positive , latent_image . shape )
2026-05-12 16:57:31 -06:00
resolved_frame_idx = max ( ( latent_length - num_keyframes - 1 ) * time_scale_factor + 1 + frame_idx , 0 )
causal_fix = resolved_frame_idx == 0 or num_frames_to_keep == 1
if not causal_fix :
image = torch . cat ( [ image [ : 1 ] , image ] , dim = 0 )
2026-05-16 01:02:57 -06:00
image , t = cls . encode ( vae , latent_width , latent_height , image , scale_factors , latent_downscale_factor )
2025-03-05 00:13:49 -05:00
2026-05-12 16:57:31 -06:00
if not causal_fix :
t = t [ : , : , 1 : , : , : ]
image = image [ 1 : ]
2026-05-16 01:02:57 -06:00
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 )
2026-05-26 20:59:32 -06:00
frame_idx , latent_idx = cls . get_latent_index ( positive , latent_length , len ( image ) , frame_idx , scale_factors , latent_shape = latent_image . shape )
2025-03-05 00:13:49 -05:00
assert latent_idx + t . shape [ 2 ] < = latent_length , " Conditioning frames exceed the length of the latent sequence. "
2025-10-01 22:19:56 +03:00
positive , negative , latent_image , noise_mask = cls . append_keyframe (
2025-03-05 00:13:49 -05:00
positive ,
negative ,
frame_idx ,
latent_image ,
noise_mask ,
t ,
strength ,
2026-01-04 22:58:59 -08:00
scale_factors ,
2026-05-16 01:02:57 -06:00
guide_mask = guide_mask ,
latent_downscale_factor = latent_downscale_factor ,
2026-05-12 16:57:31 -06:00
causal_fix = causal_fix ,
2025-03-05 00:13:49 -05:00
)
2026-02-26 08:25:23 +02:00
# 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 ,
2026-05-18 15:07:04 -06:00
attention_mask = attention_mask ,
2026-02-26 08:25:23 +02:00
)
2025-10-01 22:19:56 +03:00
return io . NodeOutput ( positive , negative , { " samples " : latent_image , " noise_mask " : noise_mask } )
2025-03-05 00:13:49 -05:00
2025-10-07 16:55:23 -07:00
generate = execute # TODO: remove
2025-03-05 00:13:49 -05:00
2025-10-01 22:19:56 +03:00
class LTXVCropGuides ( io . ComfyNode ) :
2025-03-05 00:13:49 -05:00
@classmethod
2025-10-01 22:19:56 +03:00
def define_schema ( cls ) :
return io . Schema (
node_id = " LTXVCropGuides " ,
2026-06-17 08:33:09 +08:00
category = " model/conditioning/ltxv " ,
2025-10-01 22:19:56 +03:00
inputs = [
io . Conditioning . Input ( " positive " ) ,
io . Conditioning . Input ( " negative " ) ,
io . Latent . Input ( " latent " ) ,
] ,
outputs = [
io . Conditioning . Output ( display_name = " positive " ) ,
io . Conditioning . Output ( display_name = " negative " ) ,
io . Latent . Output ( display_name = " latent " ) ,
] ,
)
2025-03-05 00:13:49 -05:00
2025-10-01 22:19:56 +03:00
@classmethod
def execute ( cls , positive , negative , latent ) - > io . NodeOutput :
2025-03-05 00:13:49 -05:00
latent_image = latent [ " samples " ] . clone ( )
noise_mask = get_noise_mask ( latent )
2026-05-26 20:59:32 -06:00
_ , num_keyframes = get_keyframe_idxs ( positive , latent_image . shape )
2025-03-05 15:47:32 +02:00
if num_keyframes == 0 :
2025-10-01 22:19:56 +03:00
return io . NodeOutput ( positive , negative , { " samples " : latent_image , " noise_mask " : noise_mask } , )
2025-03-05 00:13:49 -05:00
latent_image = latent_image [ : , : , : - num_keyframes ]
noise_mask = noise_mask [ : , : , : - num_keyframes ]
2026-02-26 08:25:23 +02:00
positive = node_helpers . conditioning_set_values ( positive , {
" keyframe_idxs " : None ,
" guide_attention_entries " : None ,
} )
negative = node_helpers . conditioning_set_values ( negative , {
" keyframe_idxs " : None ,
" guide_attention_entries " : None ,
} )
2025-03-05 00:13:49 -05:00
2025-10-01 22:19:56 +03:00
return io . NodeOutput ( positive , negative , { " samples " : latent_image , " noise_mask " : noise_mask } )
2024-11-22 08:44:42 -05:00
2025-10-07 16:55:23 -07:00
crop = execute # TODO: remove
2024-11-22 08:44:42 -05:00
2025-10-01 22:19:56 +03:00
class LTXVConditioning ( io . ComfyNode ) :
2024-11-22 08:44:42 -05:00
@classmethod
2025-10-01 22:19:56 +03:00
def define_schema ( cls ) :
return io . Schema (
node_id = " LTXVConditioning " ,
2026-06-17 08:33:09 +08:00
category = " model/conditioning/ltxv " ,
2025-10-01 22:19:56 +03:00
inputs = [
io . Conditioning . Input ( " positive " ) ,
io . Conditioning . Input ( " negative " ) ,
io . Float . Input ( " frame_rate " , default = 25.0 , min = 0.0 , max = 1000.0 , step = 0.01 ) ,
] ,
outputs = [
io . Conditioning . Output ( display_name = " positive " ) ,
io . Conditioning . Output ( display_name = " negative " ) ,
] ,
)
@classmethod
def execute ( cls , positive , negative , frame_rate ) - > io . NodeOutput :
2024-11-22 08:44:42 -05:00
positive = node_helpers . conditioning_set_values ( positive , { " frame_rate " : frame_rate } )
negative = node_helpers . conditioning_set_values ( negative , { " frame_rate " : frame_rate } )
2025-10-01 22:19:56 +03:00
return io . NodeOutput ( positive , negative )
2024-11-22 08:44:42 -05:00
2025-10-01 22:19:56 +03:00
class ModelSamplingLTXV ( io . ComfyNode ) :
2024-11-22 08:44:42 -05:00
@classmethod
2025-10-01 22:19:56 +03:00
def define_schema ( cls ) :
return io . Schema (
node_id = " ModelSamplingLTXV " ,
2026-06-17 08:33:09 +08:00
category = " model/patch/ltxv " ,
2025-10-01 22:19:56 +03:00
inputs = [
io . Model . Input ( " model " ) ,
io . Float . Input ( " max_shift " , default = 2.05 , min = 0.0 , max = 100.0 , step = 0.01 ) ,
io . Float . Input ( " base_shift " , default = 0.95 , min = 0.0 , max = 100.0 , step = 0.01 ) ,
io . Latent . Input ( " latent " , optional = True ) ,
] ,
outputs = [
io . Model . Output ( ) ,
] ,
)
2024-11-22 08:44:42 -05:00
2025-10-01 22:19:56 +03:00
@classmethod
def execute ( cls , model , max_shift , base_shift , latent = None ) - > io . NodeOutput :
2024-11-22 08:44:42 -05:00
m = model . clone ( )
if latent is None :
tokens = 4096
else :
tokens = math . prod ( latent [ " samples " ] . shape [ 2 : ] )
x1 = 1024
x2 = 4096
mm = ( max_shift - base_shift ) / ( x2 - x1 )
b = base_shift - mm * x1
shift = ( tokens ) * mm + b
sampling_base = comfy . model_sampling . ModelSamplingFlux
sampling_type = comfy . model_sampling . CONST
class ModelSamplingAdvanced ( sampling_base , sampling_type ) :
pass
model_sampling = ModelSamplingAdvanced ( model . model . model_config )
model_sampling . set_parameters ( shift = shift )
m . add_object_patch ( " model_sampling " , model_sampling )
2024-12-06 12:46:08 +02:00
2025-10-01 22:19:56 +03:00
return io . NodeOutput ( m )
2024-11-22 08:44:42 -05:00
2025-10-01 22:19:56 +03:00
class LTXVScheduler ( io . ComfyNode ) :
2024-11-22 08:44:42 -05:00
@classmethod
2025-10-01 22:19:56 +03:00
def define_schema ( cls ) :
return io . Schema (
node_id = " LTXVScheduler " ,
2026-05-27 17:43:33 -07:00
category = " model/sampling/schedulers " ,
2025-10-01 22:19:56 +03:00
inputs = [
io . Int . Input ( " steps " , default = 20 , min = 1 , max = 10000 ) ,
io . Float . Input ( " max_shift " , default = 2.05 , min = 0.0 , max = 100.0 , step = 0.01 ) ,
io . Float . Input ( " base_shift " , default = 0.95 , min = 0.0 , max = 100.0 , step = 0.01 ) ,
io . Boolean . Input (
id = " stretch " ,
default = True ,
tooltip = " Stretch the sigmas to be in the range [terminal, 1]. " ,
feat: mark 429 widgets as advanced for collapsible UI (#12197)
* feat: mark 429 widgets as advanced for collapsible UI
Mark widgets as advanced across core, comfy_extras, and comfy_api_nodes
to support the new collapsible advanced inputs section in the frontend.
Changes:
- 267 advanced markers in comfy_extras/
- 162 advanced markers in comfy_api_nodes/
- All files pass python3 -m py_compile verification
Widgets marked advanced (hidden by default):
- Scheduler internals: sigma_max, sigma_min, rho, mu, beta, alpha
- Sampler internals: eta, s_noise, order, rtol, atol, h_init, pcoeff, etc.
- Memory optimization: tile_size, overlap, temporal_size, temporal_overlap
- Pipeline controls: add_noise, start_at_step, end_at_step
- Timing controls: start_percent, end_percent
- Layer selection: stop_at_clip_layer, layers, block_number
- Video encoding: codec, crf, format
- Device/dtype: device, noise_device, dtype, weight_dtype
Widgets kept basic (always visible):
- Core params: strength, steps, cfg, denoise, seed, width, height
- Model selectors: ckpt_name, lora_name, vae_name, sampler_name
- Common controls: upscale_method, crop, batch_size, fps, opacity
Related: frontend PR #11939
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: remove advanced=True from DynamicCombo.Input (unsupported)
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: address review - un-mark model merge, video, image, and training node widgets as advanced
Per comfyanonymous review:
- Model merge arguments should not be advanced (all 14 model-specific merge classes)
- SaveAnimatedWEBP lossless/quality/method should not be advanced
- SaveWEBM/SaveVideo codec/crf/format should not be advanced
- TrainLoraNode options should not be advanced (7 inputs)
Amp-Thread-ID: https://ampcode.com/threads/T-019c322b-a3a8-71b7-9962-d44573ca6352
* fix: un-mark batch_size and webcam width/height as advanced (should stay basic)
Amp-Thread-ID: https://ampcode.com/threads/T-019c3236-1417-74aa-82a3-bcb365fbe9d1
---------
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-19 19:20:02 -08:00
advanced = True ,
2025-10-01 22:19:56 +03:00
) ,
io . Float . Input (
id = " terminal " ,
default = 0.1 ,
min = 0.0 ,
max = 0.99 ,
step = 0.01 ,
tooltip = " The terminal value of the sigmas after stretching. " ,
feat: mark 429 widgets as advanced for collapsible UI (#12197)
* feat: mark 429 widgets as advanced for collapsible UI
Mark widgets as advanced across core, comfy_extras, and comfy_api_nodes
to support the new collapsible advanced inputs section in the frontend.
Changes:
- 267 advanced markers in comfy_extras/
- 162 advanced markers in comfy_api_nodes/
- All files pass python3 -m py_compile verification
Widgets marked advanced (hidden by default):
- Scheduler internals: sigma_max, sigma_min, rho, mu, beta, alpha
- Sampler internals: eta, s_noise, order, rtol, atol, h_init, pcoeff, etc.
- Memory optimization: tile_size, overlap, temporal_size, temporal_overlap
- Pipeline controls: add_noise, start_at_step, end_at_step
- Timing controls: start_percent, end_percent
- Layer selection: stop_at_clip_layer, layers, block_number
- Video encoding: codec, crf, format
- Device/dtype: device, noise_device, dtype, weight_dtype
Widgets kept basic (always visible):
- Core params: strength, steps, cfg, denoise, seed, width, height
- Model selectors: ckpt_name, lora_name, vae_name, sampler_name
- Common controls: upscale_method, crop, batch_size, fps, opacity
Related: frontend PR #11939
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: remove advanced=True from DynamicCombo.Input (unsupported)
Amp-Thread-ID: https://ampcode.com/threads/T-019c1734-6b61-702e-b333-f02c399963fc
* fix: address review - un-mark model merge, video, image, and training node widgets as advanced
Per comfyanonymous review:
- Model merge arguments should not be advanced (all 14 model-specific merge classes)
- SaveAnimatedWEBP lossless/quality/method should not be advanced
- SaveWEBM/SaveVideo codec/crf/format should not be advanced
- TrainLoraNode options should not be advanced (7 inputs)
Amp-Thread-ID: https://ampcode.com/threads/T-019c322b-a3a8-71b7-9962-d44573ca6352
* fix: un-mark batch_size and webcam width/height as advanced (should stay basic)
Amp-Thread-ID: https://ampcode.com/threads/T-019c3236-1417-74aa-82a3-bcb365fbe9d1
---------
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
2026-02-19 19:20:02 -08:00
advanced = True ,
2025-10-01 22:19:56 +03:00
) ,
io . Latent . Input ( " latent " , optional = True ) ,
] ,
outputs = [
io . Sigmas . Output ( ) ,
] ,
)
@classmethod
def execute ( cls , steps , max_shift , base_shift , stretch , terminal , latent = None ) - > io . NodeOutput :
2024-11-22 08:44:42 -05:00
if latent is None :
tokens = 4096
else :
tokens = math . prod ( latent [ " samples " ] . shape [ 2 : ] )
sigmas = torch . linspace ( 1.0 , 0.0 , steps + 1 )
x1 = 1024
x2 = 4096
mm = ( max_shift - base_shift ) / ( x2 - x1 )
b = base_shift - mm * x1
sigma_shift = ( tokens ) * mm + b
power = 1
sigmas = torch . where (
sigmas != 0 ,
math . exp ( sigma_shift ) / ( math . exp ( sigma_shift ) + ( 1 / sigmas - 1 ) * * power ) ,
0 ,
)
# Stretch sigmas so that its final value matches the given terminal value.
if stretch :
non_zero_mask = sigmas != 0
non_zero_sigmas = sigmas [ non_zero_mask ]
one_minus_z = 1.0 - non_zero_sigmas
scale_factor = one_minus_z [ - 1 ] / ( 1.0 - terminal )
stretched = 1.0 - ( one_minus_z / scale_factor )
sigmas [ non_zero_mask ] = stretched
2025-10-01 22:19:56 +03:00
return io . NodeOutput ( sigmas )
2024-11-22 08:44:42 -05:00
2025-03-05 00:13:49 -05:00
def encode_single_frame ( output_file , image_array : np . ndarray , crf ) :
container = av . open ( output_file , " w " , format = " mp4 " )
try :
stream = container . add_stream (
2025-04-24 10:58:31 -07:00
" libx264 " , rate = 1 , options = { " crf " : str ( crf ) , " preset " : " veryfast " }
2025-03-05 00:13:49 -05:00
)
stream . height = image_array . shape [ 0 ]
stream . width = image_array . shape [ 1 ]
av_frame = av . VideoFrame . from_ndarray ( image_array , format = " rgb24 " ) . reformat (
format = " yuv420p "
)
container . mux ( stream . encode ( av_frame ) )
container . mux ( stream . encode ( ) )
finally :
container . close ( )
def decode_single_frame ( video_file ) :
container = av . open ( video_file )
try :
stream = next ( s for s in container . streams if s . type == " video " )
frame = next ( container . decode ( stream ) )
finally :
container . close ( )
return frame . to_ndarray ( format = " rgb24 " )
def preprocess ( image : torch . Tensor , crf = 29 ) :
if crf == 0 :
return image
2025-03-05 07:18:13 -05:00
image_array = ( image [ : ( image . shape [ 0 ] / / 2 ) * 2 , : ( image . shape [ 1 ] / / 2 ) * 2 ] * 255.0 ) . byte ( ) . cpu ( ) . numpy ( )
2025-10-01 22:19:56 +03:00
with BytesIO ( ) as output_file :
2025-03-05 00:13:49 -05:00
encode_single_frame ( output_file , image_array , crf )
video_bytes = output_file . getvalue ( )
2025-10-01 22:19:56 +03:00
with BytesIO ( video_bytes ) as video_file :
2025-03-05 00:13:49 -05:00
image_array = decode_single_frame ( video_file )
tensor = torch . tensor ( image_array , dtype = image . dtype , device = image . device ) / 255.0
return tensor
2025-10-01 22:19:56 +03:00
class LTXVPreprocess ( io . ComfyNode ) :
2025-03-05 00:13:49 -05:00
@classmethod
2025-10-01 22:19:56 +03:00
def define_schema ( cls ) :
return io . Schema (
node_id = " LTXVPreprocess " ,
2026-05-08 13:02:55 +08:00
display_name = " LTXV Preprocess " ,
category = " video/preprocessors " ,
2025-10-01 22:19:56 +03:00
inputs = [
io . Image . Input ( " image " ) ,
io . Int . Input (
id = " img_compression " , default = 35 , min = 0 , max = 100 , tooltip = " Amount of compression to apply on image. "
2025-03-05 00:13:49 -05:00
) ,
2025-10-01 22:19:56 +03:00
] ,
outputs = [
io . Image . Output ( display_name = " output_image " ) ,
] ,
)
2025-03-05 00:13:49 -05:00
2025-10-01 22:19:56 +03:00
@classmethod
def execute ( cls , image , img_compression ) - > io . NodeOutput :
2025-03-30 03:03:02 +03:00
output_images = [ ]
for i in range ( image . shape [ 0 ] ) :
output_images . append ( preprocess ( image [ i ] , img_compression ) )
2025-10-01 22:19:56 +03:00
return io . NodeOutput ( torch . stack ( output_images ) )
2025-10-07 16:55:23 -07:00
preprocess = execute # TODO: remove
2025-10-01 22:19:56 +03:00
2026-01-04 22:58:59 -08:00
import comfy . nested_tensor
class LTXVConcatAVLatent ( io . ComfyNode ) :
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " LTXVConcatAVLatent " ,
2026-08-03 05:28:29 +03:00
display_name = " Concat AV Latent " ,
description = " Merge a video latent and an audio latent into a joint AV latent (any AV model, e.g. LTXV or MiniMax H3). " ,
2026-06-17 08:33:09 +08:00
category = " model/latent/ltxv " ,
2026-01-04 22:58:59 -08:00
inputs = [
io . Latent . Input ( " video_latent " ) ,
io . Latent . Input ( " audio_latent " ) ,
] ,
outputs = [
io . Latent . Output ( display_name = " latent " ) ,
] ,
)
2026-08-06 23:36:34 +03:00
@staticmethod
def fit_audio ( reference , audio , noise_mask ) :
""" Trim or zero-pad the audio stream to the length of the one it replaces.
The padded tail is left unmasked so the model generates it , which is what a
clip shorter than the video should do .
"""
dims = [ i for i in range ( reference . ndim ) if reference . shape [ i ] != audio . shape [ i ] ]
if len ( dims ) == 0 :
return audio , noise_mask
if len ( dims ) > 1 or dims [ 0 ] < 2 :
raise ValueError ( " audio latent {} cannot be fitted to {} " . format ( tuple ( audio . shape ) , tuple ( reference . shape ) ) )
dim , length = dims [ 0 ] , reference . shape [ dims [ 0 ] ]
if noise_mask is not None : # masks carry their own shape until sampling resizes them
noise_mask = comfy . utils . reshape_mask ( noise_mask , audio . shape )
if audio . shape [ dim ] > length :
audio = audio . narrow ( dim , 0 , length )
if noise_mask is not None :
noise_mask = noise_mask . narrow ( dim , 0 , length )
else :
pad = torch . zeros_like ( audio . narrow ( dim , 0 , 1 ) ) . repeat (
[ length - audio . shape [ dim ] if i == dim else 1 for i in range ( audio . ndim ) ] )
audio = torch . cat ( [ audio , pad ] , dim = dim )
if noise_mask is not None :
noise_mask = torch . cat ( [ noise_mask , torch . ones_like ( pad ) ] , dim = dim )
return audio , noise_mask
2026-01-04 22:58:59 -08:00
@classmethod
def execute ( cls , video_latent , audio_latent ) - > io . NodeOutput :
output = { }
output . update ( video_latent )
output . update ( audio_latent )
2026-08-06 23:36:34 +03:00
video_samples = video_latent [ " samples " ]
audio_samples = audio_latent [ " samples " ]
2026-01-04 22:58:59 -08:00
video_noise_mask = video_latent . get ( " noise_mask " , None )
audio_noise_mask = audio_latent . get ( " noise_mask " , None )
2026-08-06 23:36:34 +03:00
if video_samples . is_nested : # already an AV latent: keep its video and swap the audio stream
streams = video_samples . unbind ( )
video_samples = streams [ 0 ]
if video_noise_mask is not None :
video_noise_mask = video_noise_mask . unbind ( ) [ 0 ]
audio_samples , audio_noise_mask = cls . fit_audio ( streams [ 1 ] , audio_samples , audio_noise_mask )
2026-01-04 22:58:59 -08:00
if video_noise_mask is not None or audio_noise_mask is not None :
if video_noise_mask is None :
2026-08-06 23:36:34 +03:00
video_noise_mask = torch . ones_like ( video_samples )
2026-01-04 22:58:59 -08:00
if audio_noise_mask is None :
2026-08-06 23:36:34 +03:00
audio_noise_mask = torch . ones_like ( audio_samples )
2026-01-04 22:58:59 -08:00
output [ " noise_mask " ] = comfy . nested_tensor . NestedTensor ( ( video_noise_mask , audio_noise_mask ) )
2026-08-06 23:36:34 +03:00
output [ " samples " ] = comfy . nested_tensor . NestedTensor ( ( video_samples , audio_samples ) )
2026-01-04 22:58:59 -08:00
return io . NodeOutput ( output )
class LTXVSeparateAVLatent ( io . ComfyNode ) :
@classmethod
def define_schema ( cls ) :
return io . Schema (
node_id = " LTXVSeparateAVLatent " ,
2026-08-03 05:28:29 +03:00
display_name = " Separate AV Latent " ,
2026-06-17 08:33:09 +08:00
category = " model/latent/ltxv " ,
2026-08-03 05:28:29 +03:00
description = " Split a joint AV latent into its video and audio latents (any AV model, e.g. LTXV or MiniMax H3). " ,
2026-01-04 22:58:59 -08:00
inputs = [
io . Latent . Input ( " av_latent " ) ,
] ,
outputs = [
io . Latent . Output ( display_name = " video_latent " ) ,
io . Latent . Output ( display_name = " audio_latent " ) ,
] ,
)
@classmethod
def execute ( cls , av_latent ) - > io . NodeOutput :
latents = av_latent [ " samples " ] . unbind ( )
video_latent = av_latent . copy ( )
video_latent [ " samples " ] = latents [ 0 ]
audio_latent = av_latent . copy ( )
audio_latent [ " samples " ] = latents [ 1 ]
if " noise_mask " in av_latent :
masks = av_latent [ " noise_mask " ]
if masks is not None :
masks = masks . unbind ( )
video_latent [ " noise_mask " ] = masks [ 0 ]
audio_latent [ " noise_mask " ] = masks [ 1 ]
return io . NodeOutput ( video_latent , audio_latent )
2026-03-24 00:22:24 +02:00
class LTXVReferenceAudio ( io . ComfyNode ) :
@classmethod
def define_schema ( cls ) - > io . Schema :
return io . Schema (
node_id = " LTXVReferenceAudio " ,
display_name = " LTXV Reference Audio (ID-LoRA) " ,
2026-06-17 08:33:09 +08:00
category = " model/conditioning/ltxv " ,
2026-03-24 00:22:24 +02:00
description = " Set reference audio for ID-LoRA speaker identity transfer. Encodes a reference audio clip into the conditioning and optionally patches the model with identity guidance (extra forward pass without reference, amplifying the speaker identity effect). " ,
inputs = [
io . Model . Input ( " model " ) ,
io . Conditioning . Input ( " positive " ) ,
io . Conditioning . Input ( " negative " ) ,
io . Audio . Input ( " reference_audio " , tooltip = " Reference audio clip whose speaker identity to transfer. ~5 seconds recommended (training duration). Shorter or longer clips may degrade voice identity transfer. " ) ,
io . Vae . Input ( id = " audio_vae " , display_name = " Audio VAE " , tooltip = " LTXV Audio VAE for encoding. " ) ,
io . Float . Input ( " identity_guidance_scale " , default = 3.0 , min = 0.0 , max = 100.0 , step = 0.01 , round = 0.01 , tooltip = " Strength of identity guidance. Runs an extra forward pass without reference each step to amplify speaker identity. Set to 0 to disable (no extra pass). " ) ,
io . Float . Input ( " start_percent " , default = 0.0 , min = 0.0 , max = 1.0 , step = 0.001 , advanced = True , tooltip = " Start of the sigma range where identity guidance is active. " ) ,
io . Float . Input ( " end_percent " , default = 1.0 , min = 0.0 , max = 1.0 , step = 0.001 , advanced = True , tooltip = " End of the sigma range where identity guidance is active. " ) ,
] ,
outputs = [
io . Model . Output ( ) ,
io . Conditioning . Output ( display_name = " positive " ) ,
io . Conditioning . Output ( display_name = " negative " ) ,
] ,
)
@classmethod
def execute ( cls , model , positive , negative , reference_audio , audio_vae , identity_guidance_scale , start_percent , end_percent ) - > io . NodeOutput :
# Encode reference audio to latents and patchify
2026-04-23 21:13:17 +03:00
sample_rate = reference_audio [ " sample_rate " ]
vae_sample_rate = getattr ( audio_vae , " audio_sample_rate " , 44100 )
if vae_sample_rate != sample_rate :
waveform = torchaudio . functional . resample ( reference_audio [ " waveform " ] , sample_rate , vae_sample_rate )
else :
waveform = reference_audio [ " waveform " ]
audio_latents = audio_vae . encode ( waveform . movedim ( 1 , - 1 ) )
2026-03-24 00:22:24 +02:00
b , c , t , f = audio_latents . shape
ref_tokens = audio_latents . permute ( 0 , 2 , 1 , 3 ) . reshape ( b , t , c * f )
ref_audio = { " tokens " : ref_tokens }
positive = node_helpers . conditioning_set_values ( positive , { " ref_audio " : ref_audio } )
negative = node_helpers . conditioning_set_values ( negative , { " ref_audio " : ref_audio } )
# Patch model with identity guidance
m = model . clone ( )
scale = identity_guidance_scale
model_sampling = m . get_model_object ( " model_sampling " )
sigma_start = model_sampling . percent_to_sigma ( start_percent )
sigma_end = model_sampling . percent_to_sigma ( end_percent )
def post_cfg_function ( args ) :
if scale == 0 :
return args [ " denoised " ]
sigma = args [ " sigma " ]
sigma_ = sigma [ 0 ] . item ( )
if sigma_ > sigma_start or sigma_ < sigma_end :
return args [ " denoised " ]
cond_pred = args [ " cond_denoised " ]
cond = args [ " cond " ]
cfg_result = args [ " denoised " ]
model_options = args [ " model_options " ] . copy ( )
x = args [ " input " ]
# Strip ref_audio from conditioning for the no-reference pass
noref_cond = [ ]
for entry in cond :
new_entry = entry . copy ( )
mc = new_entry . get ( " model_conds " , { } ) . copy ( )
mc . pop ( " ref_audio " , None )
new_entry [ " model_conds " ] = mc
noref_cond . append ( new_entry )
( pred_noref , ) = comfy . samplers . calc_cond_batch (
args [ " model " ] , [ noref_cond ] , x , sigma , model_options
)
return cfg_result + ( cond_pred - pred_noref ) * scale
m . set_model_sampler_post_cfg_function ( post_cfg_function )
return io . NodeOutput ( m , positive , negative )
2025-10-01 22:19:56 +03:00
class LtxvExtension ( ComfyExtension ) :
@override
async def get_node_list ( self ) - > list [ type [ io . ComfyNode ] ] :
return [
EmptyLTXVLatentVideo ,
LTXVImgToVideo ,
2026-01-04 22:58:59 -08:00
LTXVImgToVideoInplace ,
2025-10-01 22:19:56 +03:00
ModelSamplingLTXV ,
LTXVConditioning ,
LTXVScheduler ,
2026-05-16 01:02:57 -06:00
GetICLoRAParameters ,
2025-10-01 22:19:56 +03:00
LTXVAddGuide ,
LTXVPreprocess ,
LTXVCropGuides ,
2026-01-04 22:58:59 -08:00
LTXVConcatAVLatent ,
LTXVSeparateAVLatent ,
2026-03-24 00:22:24 +02:00
LTXVReferenceAudio ,
2025-10-01 22:19:56 +03:00
]
async def comfy_entrypoint ( ) - > LtxvExtension :
return LtxvExtension ( )