Automated PR - 2026-06-17

This commit is contained in:
github-actions[bot]
2026-06-17 14:21:07 +00:00
parent d6053703e0
commit f4b06fb977
103 changed files with 12887 additions and 3665 deletions
@@ -0,0 +1,718 @@
"""Flexible training strategy for a unified conditioning framework.
This strategy implements the Unified Conditioning Framework that supports:
- Simple fine-tuning with text conditioning (text-to-video/audio)
- Intrinsic conditioning (first_frame, prefix, suffix, spatial_crop, mask)
- Extrinsic conditioning (concatenation-based, IC-LoRA style)
The flexible strategy replaces TextToVideoStrategy and VideoToVideoStrategy by expressing
all conditioning scenarios through configuration rather than code.
"""
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, Any, Literal, Union
import torch
from pydantic import BaseModel, ConfigDict, Field, model_validator
from torch import Tensor
from ltx_core.model.transformer.modality import Modality
from ltx_trainer.timestep_samplers import TimestepSampler
from ltx_trainer.training_strategies.base_strategy import (
DEFAULT_FPS,
VIDEO_SCALE_FACTORS,
ModelInputs,
TrainingStrategy,
TrainingStrategyConfigBase,
)
# =============================================================================
# Configuration Classes
# =============================================================================
class IntrinsicConditionBase(BaseModel):
"""Base for intrinsic conditioning — tokens get clean latents, timestep=0, no loss."""
model_config = ConfigDict(extra="forbid")
probability: float = Field(
default=1.0,
ge=0.0,
le=1.0,
description="Probability of applying this condition",
)
class FirstFrameConditionConfig(IntrinsicConditionBase):
"""First frame conditioning — frame 0 is clean, excluded from loss."""
type: Literal["first_frame"] = "first_frame"
class PrefixConditionConfig(IntrinsicConditionBase):
"""Prefix conditioning — first N temporal units are clean, excluded from loss."""
type: Literal["prefix"] = "prefix"
temporal_boundary: int = Field(
...,
ge=1,
description="Number of temporal units for prefix region. "
"For video: number of latent frames. For audio: number of audio latent timesteps.",
)
class SuffixConditionConfig(IntrinsicConditionBase):
"""Suffix conditioning — last N temporal units are clean, excluded from loss."""
type: Literal["suffix"] = "suffix"
temporal_boundary: int = Field(
...,
ge=1,
description="Number of temporal units for suffix region. "
"For video: number of latent frames. For audio: number of audio latent timesteps.",
)
class SpatialCropConditionConfig(IntrinsicConditionBase):
"""Spatial crop conditioning — rectangular pixel region is clean, excluded from loss."""
type: Literal["spatial_crop"] = "spatial_crop"
spatial_region: tuple[int, int, int, int] = Field(
...,
description="Spatial crop region as (y1, x1, y2, x2) in pixel coordinates",
)
class MaskConditionConfig(IntrinsicConditionBase):
"""Mask conditioning — per-sample binary mask determines conditioning tokens."""
type: Literal["mask"] = "mask"
mask_dir: str = Field(
...,
description="Directory containing per-sample masks",
)
class ReferenceConditionConfig(BaseModel):
"""Reference conditioning (IC-LoRA style concatenation).
External reference latents are concatenated to the target sequence.
Reference tokens are clean (timestep=0), excluded from loss, and
participate in bidirectional self-attention.
"""
model_config = ConfigDict(extra="forbid")
type: Literal["reference"] = "reference"
latents_dir: str = Field(..., description="Directory for reference latents")
probability: float = Field(default=1.0, ge=0.0, le=1.0, description="Probability of applying this condition")
# Discriminated union for condition configs
ConditionConfig = Annotated[
Union[
FirstFrameConditionConfig,
PrefixConditionConfig,
SuffixConditionConfig,
SpatialCropConditionConfig,
MaskConditionConfig,
ReferenceConditionConfig,
],
Field(discriminator="type"),
]
class ModalityConfig(BaseModel):
"""Configuration for a single modality (video or audio)."""
model_config = ConfigDict(extra="forbid")
is_generated: bool = Field(
...,
description="True = generated modality (denoised, contributes to loss), False = conditioning-only modality",
)
latents_dir: str = Field(
...,
description="Directory for latents",
)
conditions: list[ConditionConfig] = Field(
default_factory=list,
description="List of conditions (e.g. first_frame, prefix, reference). Text conditioning is always applied.",
)
class FlexibleStrategyConfig(TrainingStrategyConfigBase):
"""Configuration for the flexible training strategy.
This strategy supports all conditioning scenarios through configuration:
- Text-to-video/audio with simple fine-tuning
- Intrinsic conditioning like first-frame, extension, outpainting
- Reference conditioning like IC-LoRA (concatenation-based reference)
"""
name: Literal["flexible"] = "flexible"
video: ModalityConfig | None = Field(
default=None,
description="Video modality configuration",
)
audio: ModalityConfig | None = Field(
default=None,
description="Audio modality configuration",
)
@model_validator(mode="after")
def validate_at_least_one_generated(self) -> "FlexibleStrategyConfig":
"""Ensure at least one modality has is_generated=true."""
has_video_target = self.video is not None and self.video.is_generated
has_audio_target = self.audio is not None and self.audio.is_generated
if not has_video_target and not has_audio_target:
raise ValueError("At least one modality must have is_generated=true")
return self
@model_validator(mode="after")
def validate_audio_intrinsic_regions(self) -> "FlexibleStrategyConfig":
"""Reject video-only intrinsic regions on the audio modality."""
if self.audio is None:
return self
for cond in self.audio.conditions:
if isinstance(cond, (FirstFrameConditionConfig, SpatialCropConditionConfig)):
raise ValueError(
f"Intrinsic condition '{cond.type}' is not supported for audio. "
f"Audio supports: prefix, suffix, mask."
)
return self
def get_data_sources(self) -> dict[str, str]:
"""Dynamically determine required data sources from config.
Returns a mapping of directory name (under ``preprocessed_data_root``) to
the dataset output key.
"""
sources: dict[str, str] = {"conditions": "conditions"}
if self.video is not None:
sources[self.video.latents_dir] = "video_latents"
if self.audio is not None:
sources[self.audio.latents_dir] = "audio_latents"
for modality_config in (self.video, self.audio):
if modality_config is None:
continue
for cond in modality_config.conditions:
if isinstance(cond, ReferenceConditionConfig):
sources[cond.latents_dir] = cond.latents_dir
elif isinstance(cond, MaskConditionConfig):
sources[cond.mask_dir] = cond.mask_dir
return sources
# =============================================================================
# Helper Data Structures
# =============================================================================
@dataclass
class ModalityProcessingResult:
"""Result of processing a single modality."""
modality: Modality
targets: Tensor | None
loss_mask: Tensor | None
@dataclass
class LatentData:
"""Loaded and patchified latents with metadata."""
latents: Tensor # [B, seq_len, C]
num_frames: int
height: int
width: int
fps: float
# =============================================================================
# FlexibleStrategy Implementation
# =============================================================================
class FlexibleStrategy(TrainingStrategy):
"""Unified training strategy supporting all conditioning scenarios.
This strategy implements the Unified Conditioning Framework, allowing
any training scenario to be expressed through configuration.
"""
config: FlexibleStrategyConfig
def __init__(self, config: FlexibleStrategyConfig):
"""Initialize strategy with configuration.
Args:
config: Flexible strategy configuration
"""
super().__init__(config)
self.config = config
self.reference_spatial_scale_factor, self.reference_temporal_scale_factor = (
self._infer_reference_scale_factors_from_config()
)
def prepare_training_inputs(
self,
batch: dict[str, Any],
timestep_sampler: TimestepSampler,
) -> ModelInputs:
"""Prepare training inputs by processing video and audio modalities."""
video_result = self._process_modality(self.config.video, batch, "video", timestep_sampler)
audio_result = self._process_modality(self.config.audio, batch, "audio", timestep_sampler)
return ModelInputs(
video=video_result.modality if video_result else None,
audio=audio_result.modality if audio_result else None,
video_targets=video_result.targets if video_result else None,
audio_targets=audio_result.targets if audio_result else None,
video_loss_mask=video_result.loss_mask if video_result else None,
audio_loss_mask=audio_result.loss_mask if audio_result else None,
)
def compute_loss(
self,
video_pred: Tensor | None,
audio_pred: Tensor | None,
inputs: ModelInputs,
) -> Tensor:
"""Compute masked MSE loss for video and audio predictions. Returns [B,]."""
total_loss = None
if video_pred is not None and inputs.video_targets is not None:
video_loss = self._compute_modality_loss(
pred=video_pred,
targets=inputs.video_targets,
loss_mask=inputs.video_loss_mask,
)
total_loss = video_loss
if audio_pred is not None and inputs.audio_targets is not None:
audio_loss = self._compute_modality_loss(
pred=audio_pred,
targets=inputs.audio_targets,
loss_mask=inputs.audio_loss_mask,
)
total_loss = audio_loss if total_loss is None else total_loss + audio_loss
if total_loss is None:
raise ValueError("No valid predictions and targets provided for loss computation")
return total_loss
def get_checkpoint_metadata(self) -> dict[str, Any]:
"""Include reference scale factors in checkpoint metadata for inference pipelines."""
metadata: dict[str, Any] = {}
spatial = self.reference_spatial_scale_factor
temporal = self.reference_temporal_scale_factor
if spatial is not None and spatial != 1:
metadata["reference_spatial_scale_factor"] = spatial
metadata["reference_downscale_factor"] = spatial # backward compat
if temporal is not None and temporal != 1:
metadata["reference_temporal_scale_factor"] = temporal
return metadata
def _infer_reference_scale_factors_from_config(self) -> tuple[int | None, int | None]:
"""Infer spatial and temporal scale factors by peeking at one sample pair."""
if self.config.video is None:
return None, None
for cond in self.config.video.conditions:
if not isinstance(cond, ReferenceConditionConfig):
continue
target_dir = Path(self.config.video.latents_dir)
ref_dir = Path(cond.latents_dir)
for sample_file in target_dir.rglob("*.pt"):
ref_file = ref_dir / sample_file.relative_to(target_dir)
if not ref_file.exists():
continue
target_data = torch.load(sample_file, map_location="cpu", weights_only=True)
ref_data = torch.load(ref_file, map_location="cpu", weights_only=True)
if "height" not in ref_data or "height" not in target_data:
continue
spatial = self._infer_scale_factor(
ref_data["height"],
ref_data["width"],
target_data["height"],
target_data["width"],
)
temporal = self._infer_temporal_scale_factor(
ref_data["num_frames"],
target_data["num_frames"],
)
return spatial, temporal
return None, None
def _process_modality(
self,
modality_config: ModalityConfig | None,
batch: dict[str, Any],
modality_key: str,
timestep_sampler: TimestepSampler,
) -> ModalityProcessingResult | None:
"""Process a single modality: load latents, add noise, apply conditions, build Modality."""
if modality_config is None:
return None
# Step 1: Load and patchify latents
data = self._patchify_latent_data(batch[f"{modality_key}_latents"], modality_key)
latents = data.latents
batch_size, seq_len, _ = latents.shape
device = latents.device
dtype = latents.dtype
# Step 2: Get text embeddings
conditions = batch["conditions"]
prompt_embeds = conditions[f"{modality_key}_prompt_embeds"]
prompt_attention_mask = conditions["prompt_attention_mask"]
# Step 3: Initialize noise, timesteps, and loss mask based on is_generated flag
if modality_config.is_generated:
noisy_latents, targets, timesteps, loss_mask, sigmas = self._initialize_noisy_target(
latents, timestep_sampler
)
else:
# Conditioning modality: keep clean (sigma=0), no loss
noisy_latents = latents
targets = None
timesteps = torch.zeros(batch_size, seq_len, device=device, dtype=dtype)
loss_mask = None
sigmas = torch.zeros(batch_size, device=device, dtype=dtype)
# Step 4: Generate positions
if modality_key == "video":
positions = self._get_video_positions(
num_frames=data.num_frames,
height=data.height,
width=data.width,
batch_size=batch_size,
fps=data.fps,
device=device,
)
else:
positions = self._get_audio_positions(
num_time_steps=seq_len,
batch_size=batch_size,
device=device,
)
# Step 5: Apply conditions (intrinsic first, then extrinsic)
for cond in modality_config.conditions:
if isinstance(cond, IntrinsicConditionBase) and modality_config.is_generated:
noisy_latents, timesteps, loss_mask = self._apply_intrinsic_condition(
noisy_latents=noisy_latents,
clean_latents=latents,
timesteps=timesteps,
loss_mask=loss_mask,
config=cond,
height=data.height,
width=data.width,
batch=batch,
)
for cond in modality_config.conditions:
if isinstance(cond, ReferenceConditionConfig):
noisy_latents, positions, timesteps, loss_mask, targets = self._apply_reference_condition(
noisy_latents=noisy_latents,
positions=positions,
timesteps=timesteps,
loss_mask=loss_mask,
targets=targets,
batch=batch,
config=cond,
modality_key=modality_key,
)
# Step 6: Build Modality
modality = Modality(
enabled=True,
latent=noisy_latents,
sigma=sigmas,
timesteps=timesteps,
positions=positions,
context=prompt_embeds,
context_mask=prompt_attention_mask,
)
return ModalityProcessingResult(
modality=modality,
targets=targets,
loss_mask=loss_mask,
)
@staticmethod
def _initialize_noisy_target(
latents: Tensor,
timestep_sampler: TimestepSampler,
) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor]:
"""Add noise to latents and create training targets. Returns (noisy, targets, timesteps, mask, sigmas)."""
batch_size, seq_len, _ = latents.shape
sigmas = timestep_sampler.sample_for(latents)
noise = torch.randn_like(latents)
sigmas_expanded = sigmas.view(-1, 1, 1)
noisy_latents = (1 - sigmas_expanded) * latents + sigmas_expanded * noise
targets = noise - latents # velocity prediction
timesteps = sigmas.view(-1, 1).expand(batch_size, seq_len).clone()
loss_mask = torch.ones(batch_size, seq_len, dtype=torch.bool, device=latents.device)
return noisy_latents, targets, timesteps, loss_mask, sigmas
def _apply_intrinsic_condition(
self,
noisy_latents: Tensor,
clean_latents: Tensor,
timesteps: Tensor,
loss_mask: Tensor,
config: IntrinsicConditionBase,
height: int,
width: int,
batch: dict[str, Any],
) -> tuple[Tensor, Tensor, Tensor]:
"""Apply intrinsic conditioning using a binary mask.
For each token, the mask value determines conditioning strength:
- mask=1: conditioned (clean latent, timestep=0, excluded from loss)
- mask=0: generated (noisy latent, original timestep, contributes to loss)
The conditioning decision is drawn independently per batch element so the training
signal across samples in a batch is i.i.d. -- a single batch-wide draw would
correlate gradient updates across the batch.
"""
batch_size, seq_len, _ = noisy_latents.shape
device = noisy_latents.device
# Per-sample Bernoulli draw -- each element is independently conditioned.
apply_per_sample = torch.rand(batch_size, device=device) < config.probability
if not apply_per_sample.any():
return noisy_latents, timesteps, loss_mask
if isinstance(config, FirstFrameConditionConfig):
mask = self._compute_temporal_mask(batch_size, seq_len, height, width, 1, False, device)
elif isinstance(config, PrefixConditionConfig):
mask = self._compute_temporal_mask(
batch_size, seq_len, height, width, config.temporal_boundary, False, device
)
elif isinstance(config, SuffixConditionConfig):
mask = self._compute_temporal_mask(
batch_size, seq_len, height, width, config.temporal_boundary, True, device
)
elif isinstance(config, SpatialCropConditionConfig):
mask = self._compute_spatial_crop_mask(batch_size, seq_len, height, width, config.spatial_region, device)
elif isinstance(config, MaskConditionConfig):
# Binarize to match inference, which thresholds masks at load time
# (validation_runner._load_and_downsample_mask / _load_audio_mask).
mask = (batch[config.mask_dir]["mask"].reshape(batch_size, seq_len) > 0.5).float()
else:
raise ValueError(f"Unknown intrinsic condition type: {type(config).__name__}")
# Zero the mask for samples the per-sample draw did not select.
mask = mask * apply_per_sample.view(-1, 1).to(mask.dtype)
# Apply binary mask: clean conditioned tokens, noisy generated tokens.
m = mask.unsqueeze(-1)
noisy_latents = m * clean_latents + (1 - m) * noisy_latents
timesteps = (1 - mask) * timesteps
loss_mask = loss_mask & (mask == 0)
return noisy_latents, timesteps, loss_mask
@staticmethod
def _compute_temporal_mask(
batch_size: int,
seq_len: int,
height: int,
width: int,
num_frames: int,
from_end: bool,
device: torch.device,
) -> Tensor:
"""Compute float mask for temporal region (prefix or suffix). Returns [B, seq_len] in {0, 1}."""
tokens_per_frame = height * width
num_tokens = num_frames * tokens_per_frame
mask = torch.zeros(batch_size, seq_len, device=device)
if from_end:
mask[:, -num_tokens:] = 1.0
else:
mask[:, :num_tokens] = 1.0
return mask
@staticmethod
def _compute_spatial_crop_mask(
batch_size: int,
seq_len: int,
height: int,
width: int,
region: tuple[int, int, int, int],
device: torch.device,
) -> Tensor:
"""Compute float mask for spatial crop region (y1, x1, y2, x2) in pixel coords.
Returns [B, seq_len] in {0, 1}.
"""
y1, x1, y2, x2 = region
num_frames = seq_len // (height * width)
# Convert pixel to latent coordinates and clamp (per-axis VAE scale factor).
def to_latent(v: int, scale: int, max_v: int) -> int:
return max(0, min(v // scale, max_v))
ly1 = to_latent(y1, VIDEO_SCALE_FACTORS.height, height)
ly2 = to_latent(y2, VIDEO_SCALE_FACTORS.height, height)
lx1 = to_latent(x1, VIDEO_SCALE_FACTORS.width, width)
lx2 = to_latent(x2, VIDEO_SCALE_FACTORS.width, width)
# Create spatial mask and tile across frames
spatial_mask = torch.zeros(height, width, device=device)
spatial_mask[ly1:ly2, lx1:lx2] = 1.0
full_mask = spatial_mask.flatten().repeat(num_frames)
return full_mask.unsqueeze(0).expand(batch_size, -1)
def _patchify_latent_data(self, latent_data: dict[str, Any], modality_key: str) -> LatentData:
"""Patchify latent data and extract metadata."""
latents = latent_data["latents"]
if modality_key == "video":
num_frames = latent_data["num_frames"][0].item()
height = latent_data["height"][0].item()
width = latent_data["width"][0].item()
fps = latent_data.get("fps")
fps = fps[0].item() if fps is not None else DEFAULT_FPS
latents = self._video_patchifier.patchify(latents)
else:
num_frames = latent_data.get("num_frames", [latents.shape[2]])[0]
if isinstance(num_frames, Tensor):
num_frames = num_frames.item()
height = 1
width = 1
fps = 1.0
latents = self._audio_patchifier.patchify(latents)
return LatentData(latents=latents, num_frames=num_frames, height=height, width=width, fps=fps)
def _apply_reference_condition(
self,
noisy_latents: Tensor,
positions: Tensor,
timesteps: Tensor,
loss_mask: Tensor | None,
targets: Tensor | None,
batch: dict[str, Any],
config: ReferenceConditionConfig,
modality_key: str,
) -> tuple[Tensor, Tensor, Tensor, Tensor | None, Tensor | None]:
"""Concatenate reference latents to target sequence for reference conditioning (IC-LoRA style).
The apply/skip decision is batch-wide (reference conditioning changes the sequence
length, so it cannot be applied to only part of a batch) but is drawn from the torch
RNG so runs are reproducible under ``torch.manual_seed`` — mirroring the intrinsic
per-sample draw rather than Python's unseeded ``random``.
"""
if torch.rand((), device=noisy_latents.device).item() >= config.probability:
return noisy_latents, positions, timesteps, loss_mask, targets
# Load and patchify condition latents
cond = self._patchify_latent_data(batch[config.latents_dir], modality_key)
cond_latents = cond.latents
batch_size, cond_seq_len, _ = cond_latents.shape
device = cond_latents.device
dtype = cond_latents.dtype
# Generate condition positions
if modality_key == "video":
cond_positions = self._get_video_positions(
num_frames=cond.num_frames,
height=cond.height,
width=cond.width,
batch_size=batch_size,
fps=cond.fps,
device=device,
)
else:
cond_positions = self._get_audio_positions(
num_time_steps=cond_seq_len,
batch_size=batch_size,
device=device,
)
# Translate / rescale ref positions into the target's frame (video only).
if modality_key == "video":
spatial_sf = self.reference_spatial_scale_factor or 1
temporal_sf = self.reference_temporal_scale_factor or 1
if spatial_sf != 1 or temporal_sf != 1:
cond_positions = cond_positions.clone()
if temporal_sf != 1:
# Ref positions are already at the ref's effective fps (source_fps / S,
# stored by process_videos.py). Shift by (S - 1) / target_fps so ref's
# last patch aligns with target's last; clamp the causal patch at 0.
t_target = positions[:, 0, 0:1, 1:2] # = 1 / target_fps
cond_positions[:, 0, ...] = torch.clamp(
cond_positions[:, 0, ...] - (temporal_sf - 1) * t_target, min=0
)
if spatial_sf != 1:
cond_positions[:, 1, ...] *= spatial_sf
cond_positions[:, 2, ...] *= spatial_sf
# Condition tokens: clean, timestep=0, no loss
cond_timesteps = torch.zeros(batch_size, cond_seq_len, device=device, dtype=dtype)
cond_loss_mask = torch.zeros(batch_size, cond_seq_len, dtype=torch.bool, device=device)
# Concatenate condition and target sequences (condition first, then target)
combined_latents = torch.cat([cond_latents, noisy_latents], dim=1)
combined_positions = torch.cat([cond_positions, positions], dim=2)
combined_timesteps = torch.cat([cond_timesteps, timesteps], dim=1)
combined_loss_mask = torch.cat([cond_loss_mask, loss_mask], dim=1) if loss_mask is not None else None
# Targets remain unchanged (only for target portion, not condition portion)
return combined_latents, combined_positions, combined_timesteps, combined_loss_mask, targets
@staticmethod
def _compute_modality_loss(pred: Tensor, targets: Tensor, loss_mask: Tensor) -> Tensor:
"""Compute per-element MSE loss for a single modality. Returns [B,]."""
# Slice prediction to match targets length (removes any prepended condition tokens)
target_len = targets.shape[1]
pred = pred[:, -target_len:, :]
mask = loss_mask[:, -target_len:]
# Compute masked MSE loss, reduce per-element [B,] over (seq, channels)
mask_expanded = mask.unsqueeze(-1).float()
squared_error = (pred - targets).pow(2)
masked_loss = squared_error * mask_expanded
return masked_loss.mean(dim=[-2, -1]) / mask_expanded.mean(dim=[-2, -1]).clamp(min=1e-8)
@staticmethod
def _infer_scale_factor(cond_height: int, cond_width: int, target_height: int, target_width: int) -> int:
"""Infer spatial scale factor between condition and target resolutions."""
if target_height == cond_height and target_width == cond_width:
return 1
scale_h = target_height // cond_height if cond_height > 0 else 1
scale_w = target_width // cond_width if cond_width > 0 else 1
if scale_h != scale_w:
raise ValueError(
f"Non-uniform scale factors between condition and target: height={scale_h}, width={scale_w}. "
"Condition and target resolutions must scale uniformly."
)
return scale_h
@staticmethod
def _infer_temporal_scale_factor(cond_num_frames: int, target_num_frames: int) -> int:
"""Infer temporal scale factor between condition and target latent frame counts.
The first latent frame encodes a single pixel frame (the VAE's causal structure),
so the temporal groups count is (num_frames - 1). The scale factor is the ratio
of target groups to condition groups.
"""
if target_num_frames == cond_num_frames:
return 1
target_groups = target_num_frames - 1
cond_groups = cond_num_frames - 1
if cond_groups <= 0 or target_groups <= 0:
return 1
if target_groups % cond_groups != 0:
raise ValueError(
f"Target temporal groups ({target_groups}) is not evenly divisible by "
f"condition temporal groups ({cond_groups})."
)
return target_groups // cond_groups