Files
LTX-2/packages/ltx-pipelines/src/ltx_pipelines/utils/helpers.py
T
indigo 110adc781e Add SCAIL-2 in-context mask channels (Phase 2, plumbing + zero-init surgery)
Port mechanism 2 of SCAIL-2 (arXiv:2606.10804) to LTX-2: extra per-token
in-context conditioning channels (1 environment switch + K=6 character binding
slots) concatenated onto the latent before the first projection.

Faithful temporal encoding for LTX's VAE (temporal factor 8): each latent frame
stacks its 8 pixel sub-frames along the channel dim, giving 8*(K+1)=56 channels
(vs the paper's 4*(K+1)=28 on Wan 2.1).

Plumbing (backward compatible; cond_channels=None leaves every existing pipeline
unchanged):
- LatentState/Modality gain an optional cond_channels field (patchified [B,T,C]).
- LTXModel(mask_conditioning_channels=0) config-gates a widened patchify_proj;
  TransformerArgsPreprocessor concatenates cond_channels (or zero-pads) before it.
- tools.clear_conditioning trims it; token-appending conditioning items
  (reference video/audio, driving, keyframe) extend it via extend_cond_channels.

New:
- VideoConditionByMaskChannels: encodes (K+1) pixel masks -> 56 channels, written
  onto the trailing driving tokens (noisy target stays all-zero, per the paper).
- widen_patchify_proj_for_mask_channels: zero-init checkpoint surgery so a
  converted model reproduces the base output exactly until finetuned.

Verified (CPU, random weights): backward compat, zero-init widened forward is
bit-identical to baseline for any cond_channels, and the driving+mask pipeline
forwards without crashing with correct placement/clipping. Visual quality
requires Phase 3 finetuning; Replacement-mode z_ref height shift still deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 09:50:27 +08:00

334 lines
12 KiB
Python

import logging
import torch
from ltx_core.components.noisers import Noiser
from ltx_core.conditioning import (
ConditioningItem,
VideoConditionByKeyframeIndex,
VideoConditionByLatentIndex,
)
from ltx_core.devices import cleanup_accelerator_memory, get_preferred_device
from ltx_core.model.audio_vae import encode_audio
from ltx_core.model.transformer import Modality
from ltx_core.model.video_vae import TilingConfig, VideoEncoder
from ltx_core.text_encoders.gemma import GemmaTextEncoder
from ltx_core.tools import LatentTools
from ltx_core.types import AudioLatentShape, LatentState, VideoLatentShape, VideoPixelShape
from ltx_pipelines.utils.args import ImageConditioningInput
from ltx_pipelines.utils.media_io import (
decode_audio_from_file,
decode_image,
decode_video_from_file,
get_videostream_fps,
load_image_and_preprocess,
resize_aspect_ratio_preserving,
video_preprocess,
)
def get_device() -> torch.device:
return get_preferred_device()
def cleanup_memory() -> None:
cleanup_accelerator_memory()
def _conform_latent_length(latent: torch.Tensor, expected_frames_count: int) -> torch.Tensor:
actual_frames = latent.shape[2]
if actual_frames > expected_frames_count:
latent = latent[:, :, :expected_frames_count]
elif actual_frames < expected_frames_count:
shape_as_list = list(latent.shape)
shape_as_list[2] = expected_frames_count - actual_frames
pad = torch.zeros(
shape_as_list,
device=latent.device,
dtype=latent.dtype,
)
latent = torch.cat([latent, pad], dim=2)
return latent
def video_latent_from_file(
video_encoder: VideoEncoder,
file_path: str,
output_shape: VideoPixelShape,
device: torch.device,
dtype: torch.dtype,
start_time: float = 0.0,
max_duration: float | None = None,
tiling_config: TilingConfig | None = None,
) -> torch.Tensor | None:
"""Load video from a file, and construct the video latent conforming to video output shape.
Args:
video_encoder: Model used to encode pixel frames to latent space.
file_path: Path to the video file.
output_shape: Target pixel shape (height, width, frames, fps) for the conditioning.
device: Device to run the encoder and hold tensors on.
dtype: Dtype for the output latents.
start_time: Start time in seconds to begin reading the video (default 0.0).
max_duration: Maximum duration in seconds. If None, uses output_shape.frames at
output_shape.fps (default None).
tiling_config: Tiling configuration for the encoder. Defaults to TilingConfig.default().
Returns:
Encoded video latents of shape (1, C, T, H, W) with T = required_latent_frames, or
None (currently this function always returns a tensor).
"""
fps = get_videostream_fps(file_path)
if fps != output_shape.fps:
raise ValueError(f"Input video FPS {fps} does not match output FPS {output_shape.fps}, not supported")
max_duration = max_duration or output_shape.frames / fps
frame_gen = decode_video_from_file(path=file_path, device=device, start_time=start_time, max_duration=max_duration)
frames = video_preprocess(frame_gen, output_shape.height, output_shape.width, dtype, device)
latents = video_encoder.tiled_encode(frames, tiling_config or TilingConfig.default())
required_latent_frames = VideoLatentShape.from_pixel_shape(output_shape).frames
return _conform_latent_length(latents, required_latent_frames)
def audio_latent_from_file(
audio_encoder: torch.nn.Module,
file_path: str,
output_shape: VideoPixelShape,
device: torch.device,
dtype: torch.dtype,
start_time: float = 0.0,
max_duration: float | None = None,
) -> torch.Tensor | None:
"""Load audio from a file, and construct the audio latent conforming to video output shape.
Args:
audio_encoder: Model used to encode audio to latent space.
file_path: Path to the audio or video file containing an audio stream.
output_shape: Target video pixel shape; used to derive required latent frames
and, when max_duration is None, the audio duration (output_shape.frames / fps).
device: Device to run the encoder and hold tensors on.
dtype: Dtype for the output latents.
start_time: Start time in seconds to begin reading the audio (default 0.0).
max_duration: Maximum duration in seconds. If None, uses the full span implied
by output_shape (default None).
Returns:
Encoded audio latents of shape (1, C, T, ...) with T = required_latent_frames, or
None if the file has no audio stream.
"""
max_duration = max_duration or output_shape.frames / output_shape.fps
audio_in = decode_audio_from_file(file_path, device, start_time, max_duration)
if audio_in is None:
return None
latents = encode_audio(audio_in, audio_encoder, None).to(device, dtype)
required_latent_frames = AudioLatentShape.from_video_pixel_shape(output_shape).frames
return _conform_latent_length(latents, required_latent_frames)
def combined_image_conditionings(
images: list[ImageConditioningInput],
height: int,
width: int,
video_encoder: VideoEncoder,
dtype: torch.dtype,
device: torch.device,
) -> list[ConditioningItem]:
"""Create a list of conditionings by replacing the latent at the first frame with the encoded image if present
and using other encoded images as the keyframe conditionings."""
conditionings = []
for img in images:
image = load_image_and_preprocess(
image_path=img.path,
height=height,
width=width,
dtype=dtype,
device=device,
crf=img.crf,
)
encoded_image = video_encoder(image)
if img.frame_idx == 0:
conditioning = VideoConditionByLatentIndex(
latent=encoded_image,
strength=img.strength,
latent_idx=0,
)
else:
conditioning = VideoConditionByKeyframeIndex(
keyframes=encoded_image,
strength=img.strength,
frame_idx=img.frame_idx,
)
conditionings.append(conditioning)
return conditionings
def image_conditionings_by_replacing_latent(
images: list[ImageConditioningInput],
height: int,
width: int,
video_encoder: VideoEncoder,
dtype: torch.dtype,
device: torch.device,
) -> list[ConditioningItem]:
conditionings = []
for img in images:
image = load_image_and_preprocess(
image_path=img.path,
height=height,
width=width,
dtype=dtype,
device=device,
crf=img.crf,
)
encoded_image = video_encoder(image)
conditionings.append(
VideoConditionByLatentIndex(
latent=encoded_image,
strength=img.strength,
latent_idx=img.frame_idx,
)
)
return conditionings
def image_conditionings_by_adding_guiding_latent(
images: list[ImageConditioningInput],
height: int,
width: int,
video_encoder: VideoEncoder,
dtype: torch.dtype,
device: torch.device,
) -> list[ConditioningItem]:
conditionings = []
for img in images:
image = load_image_and_preprocess(
image_path=img.path,
height=height,
width=width,
dtype=dtype,
device=device,
crf=img.crf,
)
encoded_image = video_encoder(image)
conditionings.append(
VideoConditionByKeyframeIndex(keyframes=encoded_image, frame_idx=img.frame_idx, strength=img.strength)
)
return conditionings
def create_noised_state(
tools: LatentTools,
conditionings: list[ConditioningItem],
noiser: Noiser,
dtype: torch.dtype,
device: torch.device,
noise_scale: float = 1.0,
initial_latent: torch.Tensor | None = None,
) -> LatentState:
"""Create a noised latent state from empty state, conditionings, and noiser.
Creates an empty latent state, applies conditionings, and then adds noise
using the provided noiser. Returns the final noised state ready for diffusion.
"""
state = tools.create_initial_state(device, dtype, initial_latent)
state = state_with_conditionings(state, conditionings, tools)
state = noiser(state, noise_scale)
return state
def state_with_conditionings(
latent_state: LatentState, conditioning_items: list[ConditioningItem], latent_tools: LatentTools
) -> LatentState:
"""Apply a list of conditionings to a latent state.
Iterates through the conditioning items and applies each one to the latent
state in sequence. Returns the modified state with all conditionings applied.
"""
for conditioning in conditioning_items:
latent_state = conditioning.apply_to(latent_state=latent_state, latent_tools=latent_tools)
return latent_state
def post_process_latent(denoised: torch.Tensor, denoise_mask: torch.Tensor, clean: torch.Tensor) -> torch.Tensor:
"""Blend denoised output with clean state based on mask."""
return (denoised * denoise_mask + clean.float() * (1 - denoise_mask)).to(denoised.dtype)
def modality_from_latent_state(
state: LatentState,
context: torch.Tensor,
sigma: torch.Tensor,
enabled: bool = True,
) -> Modality:
"""Create a Modality from a latent state.
Constructs a Modality object with the latent state's data, timesteps derived
from the denoise mask and sigma, positions, and the provided context.
"""
return Modality(
enabled=enabled,
latent=state.latent,
sigma=sigma,
timesteps=timesteps_from_mask(state.denoise_mask, sigma),
positions=state.positions,
context=context,
context_mask=None,
attention_mask=state.attention_mask,
cond_channels=state.cond_channels,
)
def timesteps_from_mask(denoise_mask: torch.Tensor, sigma: float | torch.Tensor) -> torch.Tensor:
"""Compute timesteps from a denoise mask and sigma value.
Multiplies the denoise mask by sigma to produce timesteps for each position
in the latent state. Areas where the mask is 0 will have zero timesteps.
When sigma is ``(B,)`` it is reshaped to ``(B, 1, ...)`` so the batch
dimension aligns correctly with ``denoise_mask``.
"""
if isinstance(sigma, torch.Tensor) and sigma.dim() == 1:
sigma = sigma.view(-1, *([1] * (denoise_mask.dim() - 1)))
return denoise_mask * sigma
_UNICODE_REPLACEMENTS = str.maketrans("\u2018\u2019\u201c\u201d\u2014\u2013\u00a0\u2032\u2212", "''\"\"-- '-")
def clean_response(text: str) -> str:
"""Clean a response from curly quotes and leading non-letter characters which Gemma tends to insert."""
text = text.translate(_UNICODE_REPLACEMENTS)
# Remove leading non-letter characters
for i, char in enumerate(text):
if char.isalpha():
return text[i:]
return text
def generate_enhanced_prompt(
text_encoder: GemmaTextEncoder,
prompt: str,
image_path: str | None = None,
image_long_side: int = 896,
seed: int = 42,
) -> str:
"""Generate an enhanced prompt from a text encoder and a prompt."""
image = None
if image_path:
image = decode_image(image_path=image_path)
image = torch.tensor(image)
image = resize_aspect_ratio_preserving(image, image_long_side).to(torch.uint8)
prompt = text_encoder.enhance_i2v(prompt, image, seed=seed)
else:
prompt = text_encoder.enhance_t2v(prompt, seed=seed)
logging.info(f"Enhanced prompt: {prompt}")
return clean_response(prompt)
def assert_resolution(height: int, width: int, is_two_stage: bool) -> None:
"""Assert that the resolution is divisible by the required divisor.
For two-stage pipelines, the resolution must be divisible by 64.
For one-stage pipelines, the resolution must be divisible by 32.
"""
divisor = 64 if is_two_stage else 32
if height % divisor != 0 or width % divisor != 0:
raise ValueError(
f"Resolution ({height}x{width}) is not divisible by {divisor}. "
f"For {'two-stage' if is_two_stage else 'one-stage'} pipelines, "
f"height and width must be multiples of {divisor}."
)