Automated PR - 2026-01-06

This commit is contained in:
sync-bot
2026-01-06 06:06:43 +00:00
parent 08f892eace
commit e1d2cff331
91 changed files with 34231 additions and 65642 deletions
+341 -93
View File
@@ -5,7 +5,7 @@ import comfy
import torch
from .easy_samplers import LTXVBaseSampler, LTXVExtendSampler, LTXVInContextSampler
from .latents import LTXVSelectLatents
from .latents import LTXVDilateLatent, LTXVSelectLatents
from .nodes_registry import comfy_node
@@ -16,7 +16,9 @@ class TileConfig:
tile_latents: dict
tile_guiding_latents: dict
tile_negative_index_latents: dict
tile_cond_image: torch.Tensor
tile_keyframes: torch.Tensor
keyframe_per_tile_indices: list
tile_normalizing_latents: dict
tile_height: int
tile_width: int
v: int
@@ -33,6 +35,7 @@ class SamplingConfig:
temporal_tile_size: int
temporal_overlap: int
temporal_overlap_cond_strength: float
cond_image_strength: float
guiding_strength: float
adain_factor: float
optional_negative_index: int
@@ -42,7 +45,8 @@ class SamplingConfig:
width_scale_factor: int
height_scale_factor: int
per_tile_seed_offsets: list
per_tile_use_negative_latents: list
guiding_start_step: int
guiding_end_step: int
@dataclass
@@ -81,16 +85,6 @@ class LTXVLoopingSampler:
"tooltip": "The latents to use for creating the long video, they can be guiding latents or empty latents when no guidance is used."
},
),
"guiding_strength": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "The strength of the conditioning on guiding latents, when optional_guiding_latents are provided.",
},
),
"temporal_tile_size": (
"INT",
{
@@ -111,6 +105,16 @@ class LTXVLoopingSampler:
"tooltip": "The overlap between the temporal tiles, in pixel frames.",
},
),
"guiding_strength": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "The strength of the conditioning on guiding latents, when optional_guiding_latents are provided.",
},
),
"temporal_overlap_cond_strength": (
"FLOAT",
{
@@ -121,6 +125,16 @@ class LTXVLoopingSampler:
"tooltip": "The strength of the conditioning on the latents from the previous temporal tile.",
},
),
"cond_image_strength": (
"FLOAT",
{
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "The strength of the conditioning on the optional_cond_images.",
},
),
"horizontal_tiles": (
"INT",
{
@@ -150,7 +164,7 @@ class LTXVLoopingSampler:
),
},
"optional": {
"optional_cond_image": (
"optional_cond_images": (
"IMAGE",
{
"tooltip": "The image to use for conditioning the first frame in the video (i2v setup). If not provided, the first frame will be unconditioned (t2v setup). The image will be resized to the size of the first frame."
@@ -184,6 +198,37 @@ class LTXVLoopingSampler:
"tooltip": "Special optional latents to condition on a negative index before each new temporal tile as a way to provide long term context during video generation."
},
),
"guiding_start_step": (
"INT",
{
"default": 0,
"min": 0,
"max": 1000,
"tooltip": "The step at which the guiding latents start to be used.",
},
),
"guiding_end_step": (
"INT",
{
"default": 1000,
"min": 0,
"max": 1000,
"tooltip": "The step at which the guiding latents stop to be used.",
},
),
"optional_cond_image_indices": (
"STRING",
{
"default": "0",
"tooltip": "The indices of the keyframes to use for the sampling, separated by commas. The indices are counted from the end of the video.",
},
),
"optional_normalizing_latents": (
"LATENT",
{
"tooltip": "The latents to use for normalizing the output latents, they will be used to normalize the output latents to the same statistics as the input latents."
},
),
},
}
@@ -198,14 +243,21 @@ class LTXVLoopingSampler:
if latent_dict is None:
return None
tile_samples = latent_dict["samples"][:, :, :, v_start:v_end, h_start:h_end]
return {"samples": tile_samples}
if "noise_mask" in latent_dict and latent_dict["noise_mask"] is not None:
tile_masks = latent_dict["noise_mask"][
:, :, :, v_start:v_end, h_start:h_end
]
return {"samples": tile_samples, "noise_mask": tile_masks}
else:
return {"samples": tile_samples}
def _extract_spatial_tile(
self,
samples,
latents,
optional_guiding_latents,
optional_negative_index_latents,
optional_cond_image,
optional_normalizing_latents,
optional_keyframes,
v_start,
v_end,
h_start,
@@ -216,7 +268,7 @@ class LTXVLoopingSampler:
"""Extract spatial tiles from all inputs for a given spatial region."""
# Extract spatial tile from latents
tile_latents = self._extract_latent_spatial_tile(
{"samples": samples}, v_start, v_end, h_start, h_end
latents, v_start, v_end, h_start, h_end
)
# Extract spatial tile from guiding latents if provided
@@ -229,25 +281,30 @@ class LTXVLoopingSampler:
optional_negative_index_latents, v_start, v_end, h_start, h_end
)
# Extract spatial tile from conditioning image if provided
tile_cond_image = None
if optional_cond_image is not None:
# Extract spatial tile from normalizing latents if provided
tile_normalizing_latents = self._extract_latent_spatial_tile(
optional_normalizing_latents, v_start, v_end, h_start, h_end
)
if optional_keyframes is not None:
# Scale coordinates for image
img_h_start = v_start * height_scale_factor
img_h_end = v_end * height_scale_factor
img_w_start = h_start * width_scale_factor
img_w_end = h_end * width_scale_factor
# Extract image tile
tile_cond_image = optional_cond_image[
tile_keyframes = optional_keyframes[
:, img_h_start:img_h_end, img_w_start:img_w_end, :
]
else:
tile_keyframes = None
return (
tile_latents,
tile_guiding_latents,
tile_negative_index_latents,
tile_cond_image,
tile_keyframes,
tile_normalizing_latents,
)
def _process_temporal_chunks(
@@ -309,9 +366,28 @@ class LTXVLoopingSampler:
seed_offset = self._get_per_tile_value(
sampling_config.per_tile_seed_offsets, i_temporal_tile
)
use_negative_latents = self._get_per_tile_value(
sampling_config.per_tile_use_negative_latents, i_temporal_tile
)
if tile_config.tile_normalizing_latents is not None:
normalizing_latent_chunk = LTXVSelectLatents().select_latents(
tile_config.tile_normalizing_latents,
start_index,
min(
end_index - 1,
tile_config.tile_normalizing_latents["samples"].shape[2] - 1,
),
)[0]
normalize_per_frame = True
print(
"Normalizing latents provided, normalizing per frame and channel with factor",
sampling_config.adain_factor,
)
else:
normalizing_latent_chunk = first_tile_out_latents
normalize_per_frame = False
print(
"No normalizing latents provided, normalizing per channel using first chunk with factor",
sampling_config.adain_factor,
)
model_config.noise.seed = self._calculate_tile_seed(
tile_config.first_seed,
@@ -330,6 +406,30 @@ class LTXVLoopingSampler:
chunk_index,
)
this_chunk_keyframe_indices = [
in_tile_index
for (tile_index, in_tile_index) in tile_config.keyframe_per_tile_indices
if tile_index == i_temporal_tile
]
if this_chunk_keyframe_indices and tile_config.tile_keyframes is not None:
this_chunk_keyframes = torch.cat(
[
tile_config.tile_keyframes[i_keyframe].unsqueeze(0)
for (i_keyframe, (tile_index, in_tile_index)) in enumerate(
tile_config.keyframe_per_tile_indices
)
if tile_index == i_temporal_tile
]
)
print(
f"Chunk {i_temporal_tile} keyframe indices: {this_chunk_keyframe_indices}"
)
else:
this_chunk_keyframes = None
print(f"Chunk {i_temporal_tile} has no keyframes")
this_chunk_keyframe_indices = ",".join(
[str(i) for i in this_chunk_keyframe_indices]
)
if start_index == 0:
if tile_config.tile_guiding_latents is not None:
tile_out_latents = LTXVInContextSampler().sample(
@@ -339,18 +439,17 @@ class LTXVLoopingSampler:
sigmas=model_config.sigmas,
noise=model_config.noise,
guiding_latents=guiding_latent_chunk,
optional_cond_image=tile_config.tile_cond_image,
optional_cond_images=this_chunk_keyframes,
optional_cond_indices=this_chunk_keyframe_indices,
num_frames=-1,
optional_negative_index_latents=(
tile_config.tile_negative_index_latents
if use_negative_latents
else None
),
optional_negative_index_latents=tile_config.tile_negative_index_latents,
optional_negative_index=sampling_config.optional_negative_index,
optional_negative_index_strength=sampling_config.optional_negative_index_strength,
optional_initialization_latents=latent_chunk,
optional_cond_strength=sampling_config.temporal_overlap_cond_strength,
optional_guiding_strength=sampling_config.guiding_strength,
cond_image_strength=sampling_config.cond_image_strength,
guiding_strength=sampling_config.guiding_strength,
guiding_start_step=sampling_config.guiding_start_step,
guiding_end_step=sampling_config.guiding_end_step,
)[0]
else:
tile_out_latents = LTXVBaseSampler().sample(
@@ -373,19 +472,17 @@ class LTXVLoopingSampler:
* sampling_config.width_scale_factor,
height=tile_config.tile_height
* sampling_config.height_scale_factor,
optional_cond_images=tile_config.tile_cond_image,
optional_cond_indices="0",
optional_cond_images=this_chunk_keyframes,
optional_cond_indices=this_chunk_keyframe_indices,
crop="center",
crf=30,
strength=sampling_config.temporal_overlap_cond_strength,
optional_negative_index_latents=(
tile_config.tile_negative_index_latents
if use_negative_latents
else None
),
strength=sampling_config.cond_image_strength,
optional_negative_index_latents=tile_config.tile_negative_index_latents,
optional_negative_index=sampling_config.optional_negative_index,
optional_negative_index_strength=sampling_config.optional_negative_index_strength,
optional_initialization_latents=latent_chunk,
guiding_start_step=sampling_config.guiding_start_step,
guiding_end_step=sampling_config.guiding_end_step,
)[0]
first_tile_out_latents = copy.deepcopy(tile_out_latents)
else:
@@ -406,17 +503,19 @@ class LTXVLoopingSampler:
guider=new_guider,
strength=sampling_config.temporal_overlap_cond_strength,
guiding_strength=sampling_config.guiding_strength,
cond_image_strength=sampling_config.cond_image_strength,
optional_guiding_latents=guiding_latent_chunk,
optional_reference_latents=first_tile_out_latents,
optional_cond_images=this_chunk_keyframes,
optional_cond_indices=this_chunk_keyframe_indices,
optional_reference_latents=normalizing_latent_chunk,
normalize_per_frame=normalize_per_frame,
adain_factor=sampling_config.adain_factor,
optional_negative_index_latents=(
tile_config.tile_negative_index_latents
if use_negative_latents
else None
),
optional_negative_index_latents=tile_config.tile_negative_index_latents,
optional_negative_index=sampling_config.optional_negative_index,
optional_negative_index_strength=sampling_config.optional_negative_index_strength,
optional_initialization_latents=latent_chunk,
guiding_start_step=sampling_config.guiding_start_step,
guiding_end_step=sampling_config.guiding_end_step,
)[0]
chunk_index += 1
@@ -424,25 +523,33 @@ class LTXVLoopingSampler:
return tile_out_latents
def _create_spatial_weights(
self, tile_samples, v, h, horizontal_tiles, vertical_tiles, spatial_overlap
self,
tile_shape,
v,
h,
horizontal_tiles,
vertical_tiles,
spatial_overlap,
device,
dtype,
):
"""Create blending weights for spatial tiles."""
tile_weights = torch.ones_like(tile_samples, device=tile_samples.device)
tile_weights = torch.ones(tile_shape, device=device, dtype=dtype)
# Apply horizontal blending weights
if h > 0: # Left overlap
h_blend = torch.linspace(0, 1, spatial_overlap, device=tile_samples.device)
h_blend = torch.linspace(0, 1, spatial_overlap, device=device, dtype=dtype)
tile_weights[:, :, :, :, :spatial_overlap] *= h_blend.view(1, 1, 1, 1, -1)
if h < horizontal_tiles - 1: # Right overlap
h_blend = torch.linspace(1, 0, spatial_overlap, device=tile_samples.device)
h_blend = torch.linspace(1, 0, spatial_overlap, device=device, dtype=dtype)
tile_weights[:, :, :, :, -spatial_overlap:] *= h_blend.view(1, 1, 1, 1, -1)
# Apply vertical blending weights
if v > 0: # Top overlap
v_blend = torch.linspace(0, 1, spatial_overlap, device=tile_samples.device)
v_blend = torch.linspace(0, 1, spatial_overlap, device=device, dtype=dtype)
tile_weights[:, :, :, :spatial_overlap, :] *= v_blend.view(1, 1, 1, -1, 1)
if v < vertical_tiles - 1: # Bottom overlap
v_blend = torch.linspace(1, 0, spatial_overlap, device=tile_samples.device)
v_blend = torch.linspace(1, 0, spatial_overlap, device=device, dtype=dtype)
tile_weights[:, :, :, -spatial_overlap:, :] *= v_blend.view(1, 1, 1, -1, 1)
return tile_weights
@@ -470,11 +577,22 @@ class LTXVLoopingSampler:
"""Get a value from a per-tile configuration list, using the last value if the list is shorter."""
return value_list[min(tile_index, len(value_list) - 1)]
def _parse_per_tile_config(self, config_string, default_value, converter_func):
"""Parse a comma-separated per-tile configuration string into a list with type conversion."""
def _parse_comma_separated_string(
self, config_string, default_value, converter_func, optional_total_size=None
):
"""Parse a comma-separated configuration string into a list with type conversion."""
if config_string == "":
config_string = default_value
return [converter_func(item.strip()) for item in config_string.split(",")]
values = [converter_func(item.strip()) for item in config_string.split(",")]
def handle_negative_index(value):
if value < 0:
return value + optional_total_size
return value
if optional_total_size is not None:
values = [handle_negative_index(value) for value in values]
return values
def _prepare_guider_for_chunk(
self, guider, optional_positive_conditionings, chunk_index
@@ -499,6 +617,78 @@ class LTXVLoopingSampler:
else:
return guider
def _calculate_keyframe_per_tile_indices(
self, keyframe_indices, temporal_tile_size, temporal_overlap, num_frames
):
"""
Calculate which temporal tile each keyframe falls into.
Returns a list of tuples (temporal_tile_index, in_tile_index) for each keyframe.
- temporal_tile_index: which temporal tile the keyframe falls in
- in_tile_index: index within that specific temporal tile
First tile: frames [0, temporal_tile_size - 8] (size = temporal_tile_size - 8 + 1)
Subsequent tiles follow the pattern from _process_temporal_chunks:
- Tile n starts at: n * (temporal_tile_size - temporal_overlap)
- Tile n ends at: temporal_tile_size + n * (temporal_tile_size - temporal_overlap) - 1
For subsequent tiles, keyframes with in_tile_index < temporal_overlap
don't count as falling in that tile.
"""
result = []
for keyframe_index in keyframe_indices:
if keyframe_index >= num_frames:
print(
f"Keyframe index {keyframe_index} is greater than num_frames {num_frames}, skipping"
)
continue
# First tile (tile 0): covers frames [0, temporal_tile_size - 8]
if keyframe_index < temporal_tile_size - 7:
result.append((0, keyframe_index))
continue
# Find which subsequent tile this keyframe could fall into
# Tile n starts at: n * (temporal_tile_size - temporal_overlap) - 7
# Tile n ends at: temporal_tile_size + n * (temporal_tile_size - temporal_overlap) - 1 - 7
tile_step = temporal_tile_size - temporal_overlap
tile_index = 1
while True:
tile_start = tile_index * tile_step - 7
tile_end = temporal_tile_size + tile_index * tile_step - 1 - 7
print(
f"Tile {tile_index} starts at {tile_start} and ends at {tile_end}"
)
# Check if keyframe falls within this tile's range
if keyframe_index <= tile_end:
in_tile_index = (
keyframe_index - tile_start - 7
) # this extra -7 is needed because the first latent is re-interpreted inside the temporal tile as 1 pixel frame
# For tiles > 0, if in_tile_index < temporal_overlap,
# the keyframe doesn't "fall" in this tile - assign to previous tile
if in_tile_index < temporal_overlap:
tile_index -= 1
if tile_index == 0:
# Previous tile is the first tile
in_tile_index = keyframe_index
else:
# Previous tile starts at (tile_index) * tile_step
prev_start = tile_start - tile_step
in_tile_index = (
keyframe_index - prev_start - 7
) # same reason as above
result.append((tile_index, in_tile_index))
break
tile_index += 1
return result
def sample(
self,
model,
@@ -516,18 +706,27 @@ class LTXVLoopingSampler:
horizontal_tiles,
vertical_tiles,
spatial_overlap,
optional_cond_image=None,
optional_cond_images=None,
cond_image_strength=1.0,
optional_guiding_latents=None,
optional_negative_index_latents=None,
optional_negative_index=-1,
optional_negative_index_strength=1.0,
optional_negative_index_strength=1.0, # hidden interface
optional_positive_conditionings=None,
per_tile_seed_offsets="0",
per_tile_use_negative_latents="1",
guiding_start_step=0,
guiding_end_step=1000,
optional_cond_image_indices="0",
optional_normalizing_latents=None,
per_tile_seed_offsets="0", # hidden interface
):
# Get dimensions and prepare for spatial tiling
samples = latents["samples"]
if (
isinstance(samples, comfy.nested_tensor.NestedTensor)
and len(samples.tensors) == 2
):
raise ValueError(
"LoopingSampler currently does not support Audio Visual latents. please only use video latents."
)
batch, channels, frames, height, width = samples.shape
time_scale_factor, width_scale_factor, height_scale_factor = (
vae.downscale_index_formula
@@ -536,18 +735,52 @@ class LTXVLoopingSampler:
temporal_overlap = temporal_overlap // time_scale_factor
first_seed = noise.seed
per_tile_seed_offsets = self._parse_per_tile_config(
per_tile_seed_offsets = self._parse_comma_separated_string(
per_tile_seed_offsets, "0", int
)
per_tile_use_negative_latents = self._parse_per_tile_config(
per_tile_use_negative_latents, "1", lambda x: bool(int(x))
keyframe_indices = self._parse_comma_separated_string(
optional_cond_image_indices,
"0",
int,
optional_total_size=frames * time_scale_factor - 7,
)
keyframe_per_tile_indices = self._calculate_keyframe_per_tile_indices(
keyframe_indices,
temporal_tile_size * time_scale_factor,
temporal_overlap * time_scale_factor,
frames * time_scale_factor - 7,
)
print(f"Keyframe per tile indices: {keyframe_per_tile_indices}")
if optional_cond_images is not None:
optional_keyframes = (
comfy.utils.common_upscale(
optional_cond_images.movedim(-1, 1),
width * width_scale_factor,
height * height_scale_factor,
"bilinear",
crop="center",
)
.movedim(1, -1)
.clamp(0, 1)
)
else:
optional_keyframes = None
if optional_guiding_latents is not None:
guide = optional_guiding_latents["samples"]
assert (
latents["samples"].shape[2]
== optional_guiding_latents["samples"].shape[2]
samples.shape[2] == guide.shape[2]
), "The number of frames in the latents and optional_guiding_latents must be the same"
assert (
samples.shape[3] % guide.shape[3] == 0
and samples.shape[4] % guide.shape[4] == 0
), "The ratio of the height and width of the latents and optional_guiding_latents must be an integer"
grid_size_h = samples.shape[3] // guide.shape[3]
grid_size_w = samples.shape[4] // guide.shape[4]
optional_guiding_latents = LTXVDilateLatent().dilate_latent(
optional_guiding_latents, grid_size_w, grid_size_h
)[0]
# Calculate tile sizes with overlap
base_tile_height = (
@@ -557,21 +790,9 @@ class LTXVLoopingSampler:
width + (horizontal_tiles - 1) * spatial_overlap
) // horizontal_tiles
# Initialize output tensor and weight tensor
final_output = torch.zeros_like(samples, device=samples.device)
weights = torch.zeros_like(samples, device=samples.device)
if optional_cond_image is not None:
img_height = height * height_scale_factor
img_width = width * width_scale_factor
optional_cond_image = comfy.utils.common_upscale(
optional_cond_image.movedim(-1, 1),
img_width,
img_height,
"bicubic",
crop="center",
).movedim(1, -1)
img_batch, img_height, img_width, img_channels = optional_cond_image.shape
# Output tensors will be initialized after first tile to get correct temporal dimension
final_output = None
weights = None
# Process each spatial tile
for v in range(vertical_tiles):
@@ -605,12 +826,14 @@ class LTXVLoopingSampler:
tile_latents,
tile_guiding_latents,
tile_negative_index_latents,
tile_cond_image,
tile_keyframes,
tile_normalizing_latents,
) = self._extract_spatial_tile(
samples,
latents,
optional_guiding_latents,
optional_negative_index_latents,
optional_cond_image,
optional_normalizing_latents,
optional_keyframes,
v_start,
v_end,
h_start,
@@ -624,7 +847,9 @@ class LTXVLoopingSampler:
tile_latents=tile_latents,
tile_guiding_latents=tile_guiding_latents,
tile_negative_index_latents=tile_negative_index_latents,
tile_cond_image=tile_cond_image,
tile_keyframes=tile_keyframes,
keyframe_per_tile_indices=keyframe_per_tile_indices,
tile_normalizing_latents=tile_normalizing_latents,
tile_height=tile_height,
tile_width=tile_width,
v=v,
@@ -638,16 +863,22 @@ class LTXVLoopingSampler:
temporal_tile_size=temporal_tile_size,
temporal_overlap=temporal_overlap,
temporal_overlap_cond_strength=temporal_overlap_cond_strength,
cond_image_strength=cond_image_strength,
guiding_strength=guiding_strength,
adain_factor=adain_factor,
optional_negative_index=optional_negative_index,
optional_negative_index=(
-1 * tile_negative_index_latents["samples"].shape[2]
if tile_negative_index_latents is not None
else -1
),
optional_negative_index_strength=optional_negative_index_strength,
optional_positive_conditionings=optional_positive_conditionings,
time_scale_factor=time_scale_factor,
width_scale_factor=width_scale_factor,
height_scale_factor=height_scale_factor,
per_tile_seed_offsets=per_tile_seed_offsets,
per_tile_use_negative_latents=per_tile_use_negative_latents,
guiding_start_step=guiding_start_step,
guiding_end_step=guiding_end_step,
)
model_config = ModelConfig(
@@ -665,19 +896,36 @@ class LTXVLoopingSampler:
model_config,
)
# Create weight mask for this spatial tile
# Initialize output tensors on first tile (to get correct temporal dimension)
if final_output is None:
out_temporal = tile_out_latents["samples"].shape[2]
final_output = torch.zeros(
batch,
channels,
out_temporal,
height,
width,
device=samples.device,
dtype=samples.dtype,
)
weights = torch.zeros_like(final_output)
# Move tile samples to output device and create weight mask
tile_samples = tile_out_latents["samples"].to(final_output.device)
tile_weights = self._create_spatial_weights(
tile_latents["samples"],
tile_samples.shape,
v,
h,
horizontal_tiles,
vertical_tiles,
spatial_overlap,
final_output.device,
final_output.dtype,
)
# Add weighted tile to final output
final_output[:, :, :, v_start:v_end, h_start:h_end] += (
tile_out_latents["samples"].to(final_output.device) * tile_weights
tile_samples * tile_weights
)
weights[:, :, :, v_start:v_end, h_start:h_end] += tile_weights