Automated PR - 2026-01-05

This commit is contained in:
sync-bot
2026-01-05 20:10:38 +00:00
parent fc3b319d34
commit 9ce438b353
153 changed files with 28100 additions and 0 deletions
@@ -0,0 +1,44 @@
import logging
import os
import sys
from logging import getLogger
from pathlib import Path
from rich.logging import RichHandler
# Get the process rank
IS_MULTI_GPU = os.environ.get("LOCAL_RANK") is not None
RANK = int(os.environ.get("LOCAL_RANK", "0"))
# Configure with Rich
logging.basicConfig(
level="INFO",
format=f"\\[rank {RANK}] %(message)s" if IS_MULTI_GPU else "%(message)s",
handlers=[
RichHandler(
rich_tracebacks=True,
show_time=False,
markup=True,
)
],
)
# Get the logger and configure it
logger = getLogger("ltxv_trainer")
logger.setLevel(logging.DEBUG)
logger.propagate = True
# Set level based on process
if RANK != 0:
logger.setLevel(logging.WARNING)
# Expose common logging functions directly
debug = logger.debug
info = logger.info
warning = logger.warning
error = logger.error
critical = logger.critical
# Add the root directory to the Python path so we can import from scripts.
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
@@ -0,0 +1,401 @@
"""
Audio-visual media captioning using multimodal models.
This module provides captioning capabilities for videos with audio using:
- Qwen2.5-Omni: Local model supporting text, audio, image, and video inputs (default)
- Gemini Flash: Cloud-based API for audio-visual captioning
Requirements:
- Qwen2.5-Omni: transformers>=4.50, torch
- Gemini Flash: google-generativeai (pip install google-generativeai)
Set GEMINI_API_KEY or GOOGLE_API_KEY environment variable
"""
import itertools
import re
from abc import ABC, abstractmethod
from enum import Enum
from pathlib import Path
import torch
# Instruction for audio-visual captioning (default) - includes speech transcription and sounds
DEFAULT_CAPTION_INSTRUCTION = """\
Analyze this media and provide a detailed caption in the following EXACT format. Fill in ALL sections:
[VISUAL]: <Detailed description of people, objects, actions, settings, colors, and movements>
[SPEECH]: <Word-for-word transcription of everything spoken.
Listen carefully and transcribe the exact words. If no speech, write "None">
[SOUNDS]: <Description of music, ambient sounds, sound effects. If none, write "None">
[TEXT]: <Any on-screen text visible. If none, write "None">
You MUST fill in all four sections. For [SPEECH], transcribe the actual words spoken, not a summary."""
# Instruction for video-only captioning (no audio processing)
VIDEO_ONLY_CAPTION_INSTRUCTION = """\
Analyze this media and provide a detailed caption in the following EXACT format. Fill in ALL sections:
[VISUAL]: <Detailed description of people, objects, actions, settings, colors, and movements>
[TEXT]: <Any on-screen text visible. If none, write "None">
You MUST fill in both sections."""
class CaptionerType(str, Enum):
"""Enum for different types of media captioners."""
QWEN_OMNI = "qwen_omni" # Local Qwen2.5-Omni model (audio + video)
GEMINI_FLASH = "gemini_flash" # Gemini Flash API (audio + video)
def create_captioner(captioner_type: CaptionerType, **kwargs) -> "MediaCaptioningModel":
"""Factory function to create a media captioner.
Args:
captioner_type: The type of captioner to create
**kwargs: Additional arguments to pass to the captioner constructor
Returns:
An instance of a MediaCaptioningModel
"""
match captioner_type:
case CaptionerType.QWEN_OMNI:
return QwenOmniCaptioner(**kwargs)
case CaptionerType.GEMINI_FLASH:
return GeminiFlashCaptioner(**kwargs)
case _:
raise ValueError(f"Unsupported captioner type: {captioner_type}")
class MediaCaptioningModel(ABC):
"""Abstract base class for audio-visual media captioning models."""
@abstractmethod
def caption(self, path: str | Path, **kwargs) -> str:
"""Generate a caption for the given video or image.
Args:
path: Path to the video/image file to caption
Returns:
A string containing the generated caption
"""
@property
@abstractmethod
def supports_audio(self) -> bool:
"""Whether this captioner supports audio input."""
@staticmethod
def _is_image_file(path: str | Path) -> bool:
"""Check if the file is an image based on extension."""
return str(path).lower().endswith((".png", ".jpg", ".jpeg", ".heic", ".heif", ".webp"))
@staticmethod
def _is_video_file(path: str | Path) -> bool:
"""Check if the file is a video based on extension."""
return str(path).lower().endswith((".mp4", ".avi", ".mov", ".mkv", ".webm"))
@staticmethod
def _clean_raw_caption(caption: str) -> str:
"""Clean up the raw caption by removing common VLM patterns."""
start = ["The", "This"]
kind = ["video", "image", "scene", "animated sequence", "clip", "footage"]
act = ["displays", "shows", "features", "depicts", "presents", "showcases", "captures", "contains"]
for x, y, z in itertools.product(start, kind, act):
caption = caption.replace(f"{x} {y} {z} ", "", 1)
return caption
class QwenOmniCaptioner(MediaCaptioningModel):
"""Audio-visual captioning using Alibaba's Qwen2.5-Omni model.
Qwen2.5-Omni is an end-to-end multimodal model that can perceive text, images, audio, and video.
It uses a Thinker-Talker architecture where the Thinker generates text and the Talker can
generate speech. For captioning, we use only the Thinker component for text generation.
Key features:
- Block-wise processing for streaming multimodal inputs
- TMRoPE (Time-aligned Multimodal RoPE) for synchronizing video and audio timestamps
- Can extract and process audio directly from video files
See: https://huggingface.co/docs/transformers/en/model_doc/qwen2_5_omni
Model: Qwen/Qwen2.5-Omni-7B (7B parameters)
"""
MODEL_ID = "Qwen/Qwen2.5-Omni-7B"
# Default system prompt required by Qwen2.5-Omni for proper audio processing
DEFAULT_SYSTEM_PROMPT = (
"You are Qwen, a virtual human developed by the Qwen Team, Alibaba Group, "
"capable of perceiving auditory and visual inputs, as well as generating text and speech."
)
def __init__(
self,
device: str | torch.device | None = None,
use_8bit: bool = False,
instruction: str | None = None,
):
"""
Initialize the Qwen2.5-Omni captioner.
Args:
device: Device to use for inference (e.g., 'cuda', 'cuda:0', 'cpu')
use_8bit: Whether to use 8-bit quantization for reduced memory usage
instruction: Custom instruction prompt. If None, uses the default instruction
"""
self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
self.instruction = instruction
self._load_model(use_8bit=use_8bit)
@property
def supports_audio(self) -> bool:
return True
def caption(
self,
path: str | Path,
fps: int = 1,
include_audio: bool = True,
clean_caption: bool = True,
) -> str:
"""Generate a caption for the given video or image.
Args:
path: Path to the video/image file to caption
fps: Frames per second to sample from videos
include_audio: Whether to include audio in the captioning (for videos)
clean_caption: Whether to clean up the raw caption by removing common VLM patterns
Returns:
A string containing the generated caption
"""
path = Path(path)
is_image = self._is_image_file(path)
is_video = self._is_video_file(path)
# Determine if we should process audio
use_audio = include_audio and is_video
# Use custom instruction if provided, otherwise pick appropriate default
if self.instruction is not None:
instruction = self.instruction
else:
instruction = DEFAULT_CAPTION_INSTRUCTION if use_audio else VIDEO_ONLY_CAPTION_INSTRUCTION
# Build the user content based on media type
# Based on HuggingFace docs: https://huggingface.co/docs/transformers/en/model_doc/qwen2_5_omni
user_content = []
if is_image:
user_content.append({"type": "image", "image": str(path)})
elif is_video:
user_content.append({"type": "video", "video": str(path)})
# Add the instruction text
user_content.append({"type": "text", "text": instruction})
# Build conversation - use the default system prompt required by Qwen2.5-Omni
# Using a custom system prompt causes warnings and may affect audio processing
messages = [
{
"role": "system",
"content": [{"type": "text", "text": self.DEFAULT_SYSTEM_PROMPT}],
},
{"role": "user", "content": user_content},
]
# Process inputs using the processor's apply_chat_template
# For videos with audio, use load_audio_from_video=True and use_audio_in_video=True
inputs = self.processor.apply_chat_template(
messages,
load_audio_from_video=use_audio,
add_generation_prompt=True,
tokenize=True,
return_dict=True,
return_tensors="pt",
fps=fps,
padding=True,
use_audio_in_video=use_audio,
).to(self.model.device)
# Generate caption (text only, using Thinker-only model)
# Note: For Qwen2_5OmniThinkerForConditionalGeneration, use standard generate params
# (not thinker_ prefixed ones, those are for the full Qwen2_5OmniForConditionalGeneration)
input_len = inputs["input_ids"].shape[1]
output_tokens = self.model.generate(
**inputs,
use_audio_in_video=use_audio,
do_sample=False,
max_new_tokens=1024,
)
# Extract only the generated tokens (exclude the input/prompt tokens)
generated_tokens = output_tokens[:, input_len:]
# Decode only the generated response
caption_raw = self.processor.batch_decode(
generated_tokens,
skip_special_tokens=True,
clean_up_tokenization_spaces=False,
)[0]
# Remove hallucinated conversation turns (e.g., "Human\nHuman\n..." or "Human: ...")
# This is a known issue with chat models continuing to generate fake turns
# We look for patterns that are clearly hallucinated chat turns, not legitimate uses of "human"
# Match "\nHuman" followed by ":", "\n", or end of string (chat turn patterns)
# This won't match "A human walks..." or "...the human body..."
caption_raw = re.split(r"\nHuman(?::|(?:\s*\n)|$)", caption_raw, maxsplit=1)[0]
caption_raw = caption_raw.strip()
# Clean up caption if requested
return self._clean_raw_caption(caption_raw) if clean_caption else caption_raw
def _load_model(self, use_8bit: bool) -> None:
"""Load the Qwen2.5-Omni model and processor.
Uses the Thinker-only model (Qwen2_5OmniThinkerForConditionalGeneration) for text generation
to save compute by not loading the audio generation components.
"""
from transformers import ( # noqa: PLC0415
BitsAndBytesConfig,
Qwen2_5OmniProcessor,
Qwen2_5OmniThinkerForConditionalGeneration,
)
quantization_config = BitsAndBytesConfig(load_in_8bit=True) if use_8bit else None
# Use Thinker-only model for text generation (saves memory by not loading Talker)
self.model = Qwen2_5OmniThinkerForConditionalGeneration.from_pretrained(
self.MODEL_ID,
dtype=torch.bfloat16,
low_cpu_mem_usage=True,
quantization_config=quantization_config,
device_map="auto",
)
self.processor = Qwen2_5OmniProcessor.from_pretrained(self.MODEL_ID)
class GeminiFlashCaptioner(MediaCaptioningModel):
"""Audio-visual captioning using Google's Gemini Flash API.
Gemini Flash is a cloud-based multimodal model that natively supports
audio and video understanding. Requires a Google API key.
Note: This captioner requires the `google-generativeai` package and a valid API key.
Set the GEMINI_API_KEY or GOOGLE_API_KEY environment variable, or pass the key directly.
"""
MODEL_ID = "gemini-flash-lite-latest"
def __init__(
self,
api_key: str | None = None,
instruction: str | None = None,
):
"""Initialize the Gemini Flash captioner.
Args:
api_key: Google API key. If not provided, will look for
GEMINI_API_KEY or GOOGLE_API_KEY environment variable.
instruction: Custom instruction prompt. If None, uses the default instruction
"""
self.instruction = instruction
self._init_client(api_key)
@property
def supports_audio(self) -> bool:
return True
def caption(
self,
path: str | Path,
fps: int = 3, # noqa: ARG002 - kept for API compatibility
include_audio: bool = True,
clean_caption: bool = True,
) -> str:
"""Generate a caption for the given video or image.
Args:
path: Path to the video/image file to caption
fps: Frames per second (not used for Gemini, kept for API compatibility)
include_audio: Whether to include audio content in the caption
clean_caption: Whether to clean up the raw caption
Returns:
A string containing the generated caption
"""
import time # noqa: PLC0415
path = Path(path)
is_video = self._is_video_file(path)
use_audio = include_audio and is_video
# Use custom instruction if provided, otherwise pick appropriate default
if self.instruction is not None:
instruction = self.instruction
else:
instruction = DEFAULT_CAPTION_INSTRUCTION if use_audio else VIDEO_ONLY_CAPTION_INSTRUCTION
# Upload the file to Gemini
uploaded_file = self._genai.upload_file(path)
# Wait for processing to complete (videos need time to process)
while uploaded_file.state.name == "PROCESSING":
time.sleep(1)
uploaded_file = self._genai.get_file(uploaded_file.name)
if uploaded_file.state.name == "FAILED":
raise RuntimeError(f"File processing failed: {uploaded_file.state.name}")
# Generate caption
response = self._model.generate_content([uploaded_file, instruction])
caption_raw = response.text
# Clean up the uploaded file
self._genai.delete_file(uploaded_file.name)
# Clean up caption if requested
return self._clean_raw_caption(caption_raw) if clean_caption else caption_raw
def _init_client(self, api_key: str | None) -> None:
"""Initialize the Gemini API client."""
import os # noqa: PLC0415
try:
import google.generativeai as genai # noqa: PLC0415
except ImportError as e:
raise ImportError(
"The `google-generativeai` package is required for Gemini Flash captioning. "
"Install it with: `uv pip install google-generativeai`"
) from e
# Get API key from argument or environment
# GEMINI_API_KEY is the recommended variable, GOOGLE_API_KEY also works
resolved_api_key = api_key or os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
if not resolved_api_key:
raise ValueError(
"Gemini API key is required. Provide it via the `api_key` argument "
"or set the GEMINI_API_KEY or GOOGLE_API_KEY environment variable."
)
# Configure the genai library with the API key
genai.configure(api_key=resolved_api_key)
# Store reference to genai module for file operations
self._genai = genai
# Initialize the model
self._model = genai.GenerativeModel(self.MODEL_ID)
def example() -> None:
"""Example usage of the captioning module."""
import sys # noqa: PLC0415
if len(sys.argv) < 2:
print(f"Usage: python {sys.argv[0]} <video_path> [captioner_type]") # noqa: T201
print(" captioner_type: qwen_omni (default) or gemini_flash") # noqa: T201
sys.exit(1)
video_path = sys.argv[1]
captioner_type = CaptionerType(sys.argv[2]) if len(sys.argv) > 2 else CaptionerType.QWEN_OMNI
print(f"Using {captioner_type.value} captioner:") # noqa: T201
captioner = create_captioner(captioner_type)
caption = captioner.caption(video_path)
print(f"CAPTION: {caption}") # noqa: T201
if __name__ == "__main__":
example()
@@ -0,0 +1,472 @@
from pathlib import Path
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Discriminator, Field, Tag, ValidationInfo, field_validator, model_validator
from ltx_trainer.quantization import QuantizationOptions
from ltx_trainer.training_strategies.base_strategy import TrainingStrategyConfigBase
from ltx_trainer.training_strategies.text_to_video import TextToVideoConfig
from ltx_trainer.training_strategies.video_to_video import VideoToVideoConfig
class ConfigBaseModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class ModelConfig(ConfigBaseModel):
"""Configuration for the base model and training mode"""
model_path: str | Path = Field(
...,
description="Model path - local path to safetensors checkpoint file",
)
text_encoder_path: str | Path | None = Field(
default=None,
description="Path to text encoder (required for LTX-2/Gemma models, optional for LTXV/T5 models)",
)
training_mode: Literal["lora", "full"] = Field(
default="lora",
description="Training mode - either LoRA fine-tuning or full model fine-tuning",
)
load_checkpoint: str | Path | None = Field(
default=None,
description="Path to a checkpoint file or directory to load from. "
"If a directory is provided, the latest checkpoint will be used.",
)
@field_validator("model_path")
@classmethod
def validate_model_path(cls, v: str | Path) -> str | Path:
"""Validate that model_path is either a valid URL or an existing local path."""
is_url = str(v).startswith(("http://", "https://"))
if is_url:
raise ValueError(f"Model path cannot be a URL: {v}")
if not Path(v).exists():
raise ValueError(f"Model path does not exist: {v}")
return v
class LoraConfig(ConfigBaseModel):
"""Configuration for LoRA fine-tuning"""
rank: int = Field(
default=64,
description="Rank of LoRA adaptation",
ge=2,
)
alpha: int = Field(
default=64,
description="Alpha scaling factor for LoRA",
ge=1,
)
dropout: float = Field(
default=0.0,
description="Dropout probability for LoRA layers",
ge=0.0,
le=1.0,
)
target_modules: list[str] = Field(
default=["to_k", "to_q", "to_v", "to_out.0"],
description="List of modules to target with LoRA",
)
def _get_strategy_discriminator(v: dict | TrainingStrategyConfigBase) -> str:
"""Discriminator function for strategy config union."""
if isinstance(v, dict):
return v.get("name", "text_to_video")
return v.name
# Union type for all strategy configs with discriminator
TrainingStrategyConfig = Annotated[
Annotated[TextToVideoConfig, Tag("text_to_video")] | Annotated[VideoToVideoConfig, Tag("video_to_video")],
Discriminator(_get_strategy_discriminator),
]
class OptimizationConfig(ConfigBaseModel):
"""Configuration for optimization parameters"""
learning_rate: float = Field(
default=5e-4,
description="Learning rate for optimization",
)
steps: int = Field(
default=3000,
description="Number of training steps",
)
batch_size: int = Field(
default=2,
description="Batch size for training",
)
gradient_accumulation_steps: int = Field(
default=1,
description="Number of steps to accumulate gradients",
)
max_grad_norm: float = Field(
default=1.0,
description="Maximum gradient norm for clipping",
)
optimizer_type: Literal["adamw", "adamw8bit"] = Field(
default="adamw",
description="Type of optimizer to use for training",
)
scheduler_type: Literal[
"constant",
"linear",
"cosine",
"cosine_with_restarts",
"polynomial",
] = Field(
default="linear",
description="Type of scheduler to use for training",
)
scheduler_params: dict = Field(
default_factory=dict,
description="Parameters for the scheduler",
)
enable_gradient_checkpointing: bool = Field(
default=False,
description="Enable gradient checkpointing to save memory at the cost of slower training",
)
class AccelerationConfig(ConfigBaseModel):
"""Configuration for hardware acceleration and compute optimization"""
mixed_precision_mode: Literal["no", "fp16", "bf16"] | None = Field(
default="bf16",
description="Mixed precision training mode",
)
quantization: QuantizationOptions | None = Field(
default=None,
description="Quantization precision to use",
)
load_text_encoder_in_8bit: bool = Field(
default=False,
description="Whether to load the text encoder in 8-bit precision to save memory",
)
class DataConfig(ConfigBaseModel):
"""Configuration for data loading and processing"""
preprocessed_data_root: str = Field(
description="Path to folder containing preprocessed training data",
)
num_dataloader_workers: int = Field(
default=2,
description="Number of background processes for data loading (0 means synchronous loading)",
ge=0,
)
class ValidationConfig(ConfigBaseModel):
"""Configuration for validation during training"""
prompts: list[str] = Field(
default_factory=list,
description="List of prompts to use for validation",
)
negative_prompt: str = Field(
default="worst quality, inconsistent motion, blurry, jittery, distorted",
description="Negative prompt to use for validation examples",
)
images: list[str] | None = Field(
default=None,
description="List of image paths to use for validation. "
"One image path must be provided for each validation prompt",
)
reference_videos: list[str] | None = Field(
default=None,
description="List of reference video paths to use for validation. "
"One video path must be provided for each validation prompt",
)
video_dims: tuple[int, int, int] = Field(
default=(960, 544, 97),
description="Dimensions of validation videos (width, height, frames). "
"Width and height must be divisible by 32. Frames must satisfy frames % 8 == 1 for LTX-2.",
)
@field_validator("video_dims")
@classmethod
def validate_video_dims(cls, v: tuple[int, int, int]) -> tuple[int, int, int]:
"""Validate video dimensions for LTX-2 compatibility."""
width, height, frames = v
if width % 32 != 0:
raise ValueError(f"Width ({width}) must be divisible by 32")
if height % 32 != 0:
raise ValueError(f"Height ({height}) must be divisible by 32")
if frames % 8 != 1:
raise ValueError(f"Frames ({frames}) must satisfy frames % 8 == 1 for LTX-2 (e.g., 1, 9, 17, 25, ...)")
return v
frame_rate: float = Field(
default=25.0,
description="Frame rate for validation videos",
gt=0,
)
seed: int = Field(
default=42,
description="Random seed used when sampling validation videos",
)
inference_steps: int = Field(
default=50,
description="Number of inference steps for validation",
gt=0,
)
interval: int | None = Field(
default=100,
description="Number of steps between validation runs. If None, validation is disabled.",
gt=0,
)
videos_per_prompt: int = Field(
default=1,
description="Number of videos to generate per validation prompt",
gt=0,
)
guidance_scale: float = Field(
default=4.0,
description="CFG guidance scale to use during validation",
ge=1.0,
)
stg_scale: float = Field(
default=1.0,
description="STG (Spatio-Temporal Guidance) scale. 0.0 disables STG. "
"Recommended value is 1.0. STG is combined with CFG for improved video quality.",
ge=0.0,
)
stg_blocks: list[int] | None = Field(
default=[29],
description="Which transformer blocks to perturb for STG. "
"None means all blocks are perturbed. Recommended for LTX-2: [29].",
)
stg_mode: Literal["stg_av", "stg_v"] = Field(
default="stg_av",
description="STG mode: 'stg_av' skips both audio and video self-attention, "
"'stg_v' skips only video self-attention.",
)
generate_audio: bool = Field(
default=True,
description="Whether to generate audio in validation samples. "
"Independent of training strategy setting - you can generate audio "
"in validation even when not training the audio branch.",
)
skip_initial_validation: bool = Field(
default=False,
description="Skip validation video sampling at step 0 (beginning of training)",
)
include_reference_in_output: bool = Field(
default=False,
description="For video-to-video training: concatenate the original reference video side-by-side "
"with the generated output. The reference comes from the input video, not from the model's output.",
)
@field_validator("images")
@classmethod
def validate_images(cls, v: list[str] | None, info: ValidationInfo) -> list[str] | None:
"""Validate that number of images (if provided) matches number of prompts."""
if v is None:
return None
num_prompts = len(info.data.get("prompts", []))
if v is not None and len(v) != num_prompts:
raise ValueError(f"Number of images ({len(v)}) must match number of prompts ({num_prompts})")
for image_path in v:
if not Path(image_path).exists():
raise ValueError(f"Image path '{image_path}' does not exist")
return v
@field_validator("reference_videos")
@classmethod
def validate_reference_videos(cls, v: list[str] | None, info: ValidationInfo) -> list[str] | None:
"""Validate that number of reference videos (if provided) matches number of prompts."""
if v is None:
return None
num_prompts = len(info.data.get("prompts", []))
if v is not None and len(v) != num_prompts:
raise ValueError(f"Number of reference videos ({len(v)}) must match number of prompts ({num_prompts})")
for video_path in v:
if not Path(video_path).exists():
raise ValueError(f"Reference video path '{video_path}' does not exist")
return v
class CheckpointsConfig(ConfigBaseModel):
"""Configuration for model checkpointing during training"""
interval: int | None = Field(
default=None,
description="Number of steps between checkpoint saves. If None, intermediate checkpoints are disabled.",
gt=0,
)
keep_last_n: int = Field(
default=1,
description="Number of most recent checkpoints to keep. Set to -1 to keep all checkpoints.",
ge=-1,
)
class HubConfig(ConfigBaseModel):
"""Configuration for Hugging Face Hub integration"""
push_to_hub: bool = Field(default=False, description="Whether to push the model weights to the Hugging Face Hub")
hub_model_id: str | None = Field(
default=None, description="Hugging Face Hub repository ID (e.g., 'username/repo-name')"
)
@model_validator(mode="after")
def validate_hub_config(self) -> "HubConfig":
"""Validate that hub_model_id is not None when push_to_hub is True."""
if self.push_to_hub and not self.hub_model_id:
raise ValueError("hub_model_id must be specified when push_to_hub is True")
return self
class WandbConfig(ConfigBaseModel):
"""Configuration for Weights & Biases logging"""
enabled: bool = Field(
default=False,
description="Whether to enable W&B logging",
)
project: str = Field(
default="ltxv-trainer",
description="W&B project name",
)
entity: str | None = Field(
default=None,
description="W&B username or team",
)
tags: list[str] = Field(
default_factory=list,
description="Tags to add to the W&B run",
)
log_validation_videos: bool = Field(
default=True,
description="Whether to log validation videos to W&B",
)
class FlowMatchingConfig(ConfigBaseModel):
"""Configuration for flow matching training"""
timestep_sampling_mode: Literal["uniform", "shifted_logit_normal"] = Field(
default="shifted_logit_normal",
description="Mode to use for timestep sampling",
)
timestep_sampling_params: dict = Field(
default_factory=dict,
description="Parameters for timestep sampling",
)
class LtxTrainerConfig(ConfigBaseModel):
"""Unified configuration for LTXV training"""
# Sub-configurations
model: ModelConfig = Field(default_factory=ModelConfig)
lora: LoraConfig | None = Field(default=None)
training_strategy: TrainingStrategyConfig = Field(
default_factory=TextToVideoConfig,
description="Training strategy configuration. Determines the training mode and its parameters.",
)
optimization: OptimizationConfig = Field(default_factory=OptimizationConfig)
acceleration: AccelerationConfig = Field(default_factory=AccelerationConfig)
data: DataConfig
validation: ValidationConfig = Field(default_factory=ValidationConfig)
checkpoints: CheckpointsConfig = Field(default_factory=CheckpointsConfig)
hub: HubConfig = Field(default_factory=HubConfig)
flow_matching: FlowMatchingConfig = Field(default_factory=FlowMatchingConfig)
wandb: WandbConfig = Field(default_factory=WandbConfig)
# General configuration
seed: int = Field(
default=42,
description="Random seed for reproducibility",
)
output_dir: str = Field(
default="outputs",
description="Directory to save model outputs",
)
# noinspection PyNestedDecorators
@field_validator("output_dir")
@classmethod
def expand_output_path(cls, v: str) -> str:
"""Expand user home directory in output path."""
return str(Path(v).expanduser().resolve())
@model_validator(mode="after")
def validate_strategy_compatibility(self) -> "LtxTrainerConfig":
"""Validate that training strategy and other configurations are compatible."""
# Check that reference videos are provided when using video_to_video strategy
if (
self.training_strategy.name == "video_to_video"
and self.validation.interval
and not self.validation.reference_videos
):
raise ValueError(
"reference_videos must be provided in validation config when using video_to_video strategy"
)
# Check that LoRA config is provided when training mode is lora
if self.model.training_mode == "lora" and self.lora is None:
raise ValueError("LoRA configuration must be provided when training_mode is 'lora'")
# Check that LoRA config is provided when using video_to_video strategy
if self.training_strategy.name == "video_to_video" and self.model.training_mode != "lora":
raise ValueError("Training mode must be 'lora' when using video_to_video strategy")
return self
@@ -0,0 +1,155 @@
"""Display utilities for training configuration.
This module provides formatted console output for LtxTrainerConfig.
"""
from rich import box
from rich.console import Console
from rich.table import Table
from ltx_trainer.config import LtxTrainerConfig
def print_config(config: LtxTrainerConfig) -> None:
"""Print configuration as a nicely formatted table with sections."""
def fmt(v: object, max_len: int = 55) -> str:
"""Format any value for display."""
if v is None:
return "[dim]—[/]"
if isinstance(v, bool):
return "[green]✓[/]" if v else "[dim]✗[/]"
if isinstance(v, (list, tuple)):
if not v:
return "[dim]—[/]"
return ", ".join(str(x) for x in v)
s = str(v)
return s[: max_len - 3] + "..." if len(s) > max_len else s
cfg = config
opt = cfg.optimization
val = cfg.validation
accel = cfg.acceleration
# Build sections: list of (section_title, [(key, value), ...])
sections: list[tuple[str, list[tuple[str, str]]]] = [
(
"🎬 Model",
[
("Base", fmt(cfg.model.model_path)),
("Text Encoder", fmt(cfg.model.text_encoder_path) or "[dim]Built-in[/]"),
("Training Mode", f"[bold green]{cfg.model.training_mode.upper()}[/]"),
("Load Checkpoint", fmt(cfg.model.load_checkpoint) if cfg.model.load_checkpoint else "[dim]—[/]"),
],
),
]
if cfg.lora:
sections.append(
(
"🔗 LoRA",
[
("Rank / Alpha", f"{cfg.lora.rank} / {cfg.lora.alpha}"),
("Dropout", str(cfg.lora.dropout)),
("Target Modules", fmt(cfg.lora.target_modules)),
],
)
)
# Strategy section - include strategy-specific fields
strategy_items: list[tuple[str, str]] = [("Name", cfg.training_strategy.name)]
if hasattr(cfg.training_strategy, "with_audio"):
strategy_items.append(("Audio", fmt(cfg.training_strategy.with_audio)))
if hasattr(cfg.training_strategy, "first_frame_conditioning_p"):
strategy_items.append(("First Frame Cond P", str(cfg.training_strategy.first_frame_conditioning_p)))
sections.append(("🎯 Strategy", strategy_items))
sections.extend(
[
(
"⚡ Optimization",
[
("Steps", f"[bold]{opt.steps:,}[/]"),
("Learning Rate", f"{opt.learning_rate:.2e}"),
("Batch Size", str(opt.batch_size)),
("Grad Accumulation", str(opt.gradient_accumulation_steps)),
("Optimizer", opt.optimizer_type),
("Scheduler", opt.scheduler_type),
("Max Grad Norm", str(opt.max_grad_norm)),
("Grad Checkpointing", fmt(opt.enable_gradient_checkpointing)),
],
),
(
"🚀 Acceleration",
[
("Mixed Precision", accel.mixed_precision_mode or "[dim]—[/]"),
("Quantization", str(accel.quantization) if accel.quantization else "[dim]—[/]"),
("Text Encoder 8bit", fmt(accel.load_text_encoder_in_8bit)),
],
),
(
"🎥 Validation",
[
("Prompts", f"{len(val.prompts)} prompt(s)" if val.prompts else "[dim]—[/]"),
("Interval", f"Every {val.interval} steps" if val.interval else "[dim]Disabled[/]"),
("Video Dims", f"{val.video_dims[0]}x{val.video_dims[1]}, {val.video_dims[2]} frames"),
("Frame Rate", f"{val.frame_rate} fps"),
("Inference Steps", str(val.inference_steps)),
("CFG Scale", str(val.guidance_scale)),
(
"STG",
f"scale={val.stg_scale}; blocks={fmt(val.stg_blocks)}; mode={val.stg_mode}"
if val.stg_scale > 0
else "[dim]Disabled[/]",
),
("Seed", str(val.seed)),
],
),
(
"📂 Data & Output",
[
("Dataset", fmt(cfg.data.preprocessed_data_root)),
("Dataloader Workers", str(cfg.data.num_dataloader_workers)),
("Output Dir", fmt(cfg.output_dir)),
("Seed", str(cfg.seed)),
],
),
(
"🔌 Integrations",
[
(
"Checkpoints",
f"Every {cfg.checkpoints.interval} steps (keep {cfg.checkpoints.keep_last_n})"
if cfg.checkpoints.interval
else "[dim]Disabled[/]",
),
("W&B", f"{cfg.wandb.project}" if cfg.wandb.enabled else "[dim]Disabled[/]"),
("HF Hub", cfg.hub.hub_model_id if cfg.hub.push_to_hub else "[dim]Disabled[/]"),
],
),
]
)
# Build table with section headers
table = Table(
title="[bold]⚙️ Training Configuration[/]",
show_header=False,
box=box.ROUNDED,
border_style="bright_blue",
padding=(0, 1),
title_style="bold bright_blue",
)
table.add_column("Key", style="white", width=20)
table.add_column("Value", style="cyan")
for i, (section_title, items) in enumerate(sections):
if i > 0:
table.add_row("", "") # Blank line between sections
table.add_row(f"[bold yellow]{section_title}[/]", "")
for key, value in items:
table.add_row(f" {key}", value)
console = Console()
console.print()
console.print(table)
console.print()
@@ -0,0 +1,270 @@
from pathlib import Path
import torch
from einops import rearrange
from torch import Tensor
from torch.utils.data import Dataset
from ltx_trainer import logger
# Constants for precomputed data directories
PRECOMPUTED_DIR_NAME = ".precomputed"
class DummyDataset(Dataset):
"""Produce random latents and prompt embeddings. For minimal demonstration and benchmarking purposes"""
def __init__(
self,
width: int = 1024,
height: int = 1024,
num_frames: int = 25,
fps: int = 24,
dataset_length: int = 200,
latent_dim: int = 128,
latent_spatial_compression_ratio: int = 32,
latent_temporal_compression_ratio: int = 8,
prompt_embed_dim: int = 4096,
prompt_sequence_length: int = 256,
) -> None:
if width % 32 != 0:
raise ValueError(f"Width must be divisible by 32, got {width=}")
if height % 32 != 0:
raise ValueError(f"Height must be divisible by 32, got {height=}")
if num_frames % 8 != 1:
raise ValueError(f"Number of frames must have a remainder of 1 when divided by 8, got {num_frames=}")
self.width = width
self.height = height
self.num_frames = num_frames
self.fps = fps
self.dataset_length = dataset_length
self.latent_dim = latent_dim
self.num_latent_frames = (num_frames - 1) // latent_temporal_compression_ratio + 1
self.latent_height = height // latent_spatial_compression_ratio
self.latent_width = width // latent_spatial_compression_ratio
self.latent_sequence_length = self.num_latent_frames * self.latent_height * self.latent_width
self.prompt_embed_dim = prompt_embed_dim
self.prompt_sequence_length = prompt_sequence_length
def __len__(self) -> int:
return self.dataset_length
def __getitem__(self, idx: int) -> dict[str, dict[str, Tensor]]:
return {
"latent_conditions": {
"latents": torch.randn(
self.latent_dim,
self.num_latent_frames,
self.latent_height,
self.latent_width,
),
"num_frames": self.num_latent_frames,
"height": self.latent_height,
"width": self.latent_width,
"fps": self.fps,
},
"text_conditions": {
"prompt_embeds": torch.randn(
self.prompt_sequence_length,
self.prompt_embed_dim,
), # random text embeddings
"prompt_attention_mask": torch.ones(
self.prompt_sequence_length,
dtype=torch.bool,
), # random attention mask
},
}
class PrecomputedDataset(Dataset):
def __init__(self, data_root: str, data_sources: dict[str, str] | list[str] | None = None) -> None:
"""
Generic dataset for loading precomputed data from multiple sources.
Args:
data_root: Root directory containing preprocessed data
data_sources: Either:
- Dict mapping directory names to output keys
- List of directory names (keys will equal values)
- None (defaults to ["latents", "conditions"])
Example:
# Standard mode (list)
dataset = PrecomputedDataset("data/", ["latents", "conditions"])
# Standard mode (dict)
dataset = PrecomputedDataset("data/", {"latents": "latent_conditions", "conditions": "text_conditions"})
# IC-LoRA mode
dataset = PrecomputedDataset("data/", ["latents", "conditions", "reference_latents"])
Note:
Latents are always returned in non-patchified format [C, F, H, W].
Legacy patchified format [seq_len, C] is automatically converted.
"""
super().__init__()
self.data_root = self._setup_data_root(data_root)
self.data_sources = self._normalize_data_sources(data_sources)
self.source_paths = self._setup_source_paths()
self.sample_files = self._discover_samples()
self._validate_setup()
@staticmethod
def _setup_data_root(data_root: str) -> Path:
"""Setup and validate the data root directory."""
data_root = Path(data_root).expanduser().resolve()
if not data_root.exists():
raise FileNotFoundError(f"Data root directory does not exist: {data_root}")
# If the given path is the dataset root, use the precomputed subdirectory
if (data_root / PRECOMPUTED_DIR_NAME).exists():
data_root = data_root / PRECOMPUTED_DIR_NAME
return data_root
@staticmethod
def _normalize_data_sources(data_sources: dict[str, str] | list[str] | None) -> dict[str, str]:
"""Normalize data_sources input to a consistent dict format."""
if data_sources is None:
# Default sources
return {"latents": "latent_conditions", "conditions": "text_conditions"}
elif isinstance(data_sources, list):
# Convert list to dict where keys equal values
return {source: source for source in data_sources}
elif isinstance(data_sources, dict):
return data_sources.copy()
else:
raise TypeError(f"data_sources must be dict, list, or None, got {type(data_sources)}")
def _setup_source_paths(self) -> dict[str, Path]:
"""Map data source names to their actual directory paths."""
source_paths = {}
for dir_name in self.data_sources:
source_path = self.data_root / dir_name
source_paths[dir_name] = source_path
# Check that all sources exist.
if not source_path.exists():
raise FileNotFoundError(f"Required {dir_name} directory does not exist: {source_path}")
return source_paths
def _discover_samples(self) -> dict[str, list[Path]]:
"""Discover all valid sample files across all data sources."""
# Use first data source as the reference to discover samples
data_key = "latents" if "latents" in self.data_sources else next(iter(self.data_sources.keys()))
data_path = self.source_paths[data_key]
data_files = list(data_path.glob("**/*.pt"))
if not data_files:
raise ValueError(f"No data files found in {data_path}")
# Initialize sample files dict
sample_files = {output_key: [] for output_key in self.data_sources.values()}
# For each data file, find corresponding files in other sources
for data_file in data_files:
rel_path = data_file.relative_to(data_path)
# Check if corresponding files exist in ALL sources
if self._all_source_files_exist(data_file, rel_path):
self._fill_sample_data_files(data_file, rel_path, sample_files)
return sample_files
def _all_source_files_exist(self, data_file: Path, rel_path: Path) -> bool:
"""Check if corresponding files exist in all data sources."""
for dir_name in self.data_sources:
expected_path = self._get_expected_file_path(dir_name, data_file, rel_path)
if not expected_path.exists():
logger.warning(
f"No matching {dir_name} file found for: {data_file.name} (expected in: {expected_path})"
)
return False
return True
def _get_expected_file_path(self, dir_name: str, data_file: Path, rel_path: Path) -> Path:
"""Get the expected file path for a given data source."""
source_path = self.source_paths[dir_name]
# For conditions, handle legacy naming where latent_X.pt maps to condition_X.pt
if dir_name == "conditions" and data_file.name.startswith("latent_"):
return source_path / f"condition_{data_file.stem[7:]}.pt"
return source_path / rel_path
def _fill_sample_data_files(self, data_file: Path, rel_path: Path, sample_files: dict[str, list[Path]]) -> None:
"""Add a valid sample to the sample_files tracking."""
for dir_name, output_key in self.data_sources.items():
expected_path = self._get_expected_file_path(dir_name, data_file, rel_path)
sample_files[output_key].append(expected_path.relative_to(self.source_paths[dir_name]))
def _validate_setup(self) -> None:
"""Validate that the dataset setup is correct."""
if not self.sample_files:
raise ValueError("No valid samples found - all data sources must have matching files")
# Verify all output keys have the same number of samples
sample_counts = {key: len(files) for key, files in self.sample_files.items()}
if len(set(sample_counts.values())) > 1:
raise ValueError(f"Mismatched sample counts across sources: {sample_counts}")
def __len__(self) -> int:
# Use the first output key as reference count
first_key = next(iter(self.sample_files.keys()))
return len(self.sample_files[first_key])
def __getitem__(self, index: int) -> dict[str, torch.Tensor]:
result = {}
for dir_name, output_key in self.data_sources.items():
source_path = self.source_paths[dir_name]
file_rel_path = self.sample_files[output_key][index]
file_path = source_path / file_rel_path
try:
data = torch.load(file_path, map_location="cpu", weights_only=True)
# Normalize video latent format if this is a latent source
if "latent" in dir_name.lower():
data = self._normalize_video_latents(data)
result[output_key] = data
except Exception as e:
raise RuntimeError(f"Failed to load {output_key} from {file_path}: {e}") from e
# Add index for debugging
result["idx"] = index
return result
@staticmethod
def _normalize_video_latents(data: dict) -> dict:
"""
Normalize video latents to non-patchified format [C, F, H, W].
Used for keeping backward compatibility with legacy datasets.
"""
latents = data["latents"]
# Check if latents are in legacy patchified format [seq_len, C]
if latents.dim() == 2:
# Legacy format: [seq_len, C] where seq_len = F * H * W
num_frames = data["num_frames"]
height = data["height"]
width = data["width"]
# Unpatchify: [seq_len, C] -> [C, F, H, W]
latents = rearrange(
latents,
"(f h w) c -> c f h w",
f=num_frames,
h=height,
w=width,
)
# Update the data dict with unpatchified latents
data = data.copy()
data["latents"] = latents
return data
@@ -0,0 +1,208 @@
import shutil
import tempfile
from pathlib import Path
from typing import List, Union
import imageio
from huggingface_hub import HfApi, create_repo
from huggingface_hub.utils import are_progress_bars_disabled, disable_progress_bars, enable_progress_bars
from rich.progress import Progress, SpinnerColumn, TextColumn
from ltx_trainer import logger
from ltx_trainer.config import LtxTrainerConfig
def push_to_hub(weights_path: Path, sampled_videos_paths: List[Path], config: LtxTrainerConfig) -> None:
"""Push the trained LoRA weights to HuggingFace Hub."""
if not config.hub.hub_model_id:
logger.warning("⚠️ HuggingFace hub_model_id not specified, skipping push to hub")
return
api = HfApi()
# Save original progress bar state
original_progress_state = are_progress_bars_disabled()
disable_progress_bars() # Disable during our custom progress tracking
try:
# Try to create repo if it doesn't exist
try:
repo = create_repo(
repo_id=config.hub.hub_model_id,
repo_type="model",
exist_ok=True, # Don't raise error if repo exists
)
repo_id = repo.repo_id
logger.info(f"🤗 Successfully created HuggingFace model repository at: {repo.url}")
except Exception as e:
logger.error(f"❌ Failed to create HuggingFace model repository: {e}")
return
# Create a single temporary directory for all files
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
transient=True,
) as progress:
try:
# Copy weights
task_copy = progress.add_task("Copying weights...", total=None)
weights_dest = temp_path / weights_path.name
shutil.copy2(weights_path, weights_dest)
progress.update(task_copy, description="✓ Weights copied")
# Create model card and save samples
task_card = progress.add_task("Creating model card and samples...", total=None)
_create_model_card(
output_dir=temp_path,
videos=sampled_videos_paths,
config=config,
)
progress.update(task_card, description="✓ Model card and samples created")
# Upload everything at once
task_upload = progress.add_task("Pushing files to HuggingFace Hub...", total=None)
api.upload_folder(
folder_path=str(temp_path),
repo_id=repo_id,
repo_type="model",
)
progress.update(task_upload, description="✓ Files pushed to HuggingFace Hub")
logger.info("✅ Successfully pushed files to HuggingFace Hub")
except Exception as e:
logger.error(f"❌ Failed to process and push files to HuggingFace Hub: {e}")
raise # Re-raise to handle in outer try block
finally:
# Restore original progress bar state
if not original_progress_state:
enable_progress_bars()
def convert_video_to_gif(video_path: Path, output_path: Path) -> None:
"""Convert a video file to GIF format."""
try:
# Read the video file
reader = imageio.get_reader(str(video_path))
fps = reader.get_meta_data()["fps"]
# Write GIF file with infinite loop
writer = imageio.get_writer(
str(output_path),
fps=min(fps, 15), # Cap FPS at 15 for reasonable file size
loop=0, # 0 means infinite loop
)
for frame in reader:
writer.append_data(frame)
writer.close()
reader.close()
except Exception as e:
logger.error(f"Failed to convert video to GIF: {e}")
def _create_model_card(
output_dir: Union[str, Path],
videos: List[Path],
config: LtxTrainerConfig,
) -> Path:
"""Generate and save a model card for the trained model."""
repo_id = config.hub.hub_model_id
pretrained_model_name_or_path = config.model.model_path
validation_prompts = config.validation.prompts
output_dir = Path(output_dir)
template_path = Path(__file__).parent.parent.parent / "templates" / "model_card.md"
# Read the template
template = template_path.read_text()
# Get model name from repo_id
model_name = repo_id.split("/")[-1]
# Get base model information
base_model_link = str(pretrained_model_name_or_path)
model_path_str = str(pretrained_model_name_or_path)
is_url = model_path_str.startswith(("http://", "https://"))
# For URLs, extract the filename from the URL. For local paths, use the filename stem
base_model_name = model_path_str.split("/")[-1] if is_url else Path(pretrained_model_name_or_path).name
# Format validation prompts and create grid layout
prompts_text = ""
sample_grid = []
if validation_prompts and videos:
prompts_text = "Example prompts used during validation:\n\n"
# Create samples directory
samples_dir = output_dir / "samples"
samples_dir.mkdir(exist_ok=True, parents=True)
# Process videos and create cells
cells = []
for i, (prompt, video) in enumerate(zip(validation_prompts, videos, strict=False)):
if video.exists():
# Add prompt to text section
prompts_text += f"- `{prompt}`\n"
# Convert video to GIF
gif_path = samples_dir / f"sample_{i}.gif"
try:
convert_video_to_gif(video, gif_path)
# Create grid cell with collapsible description
cell = (
f"![example{i + 1}](./samples/sample_{i}.gif)"
"<br>"
'<details style="max-width: 300px; margin: auto;">'
f"<summary>Prompt</summary>"
f"{prompt}"
"</details>"
)
cells.append(cell)
except Exception as e:
logger.error(f"Failed to process video {video}: {e}")
# Calculate optimal grid dimensions
num_cells = len(cells)
if num_cells > 0:
# Aim for a roughly square grid, with max 4 columns
num_cols = min(4, num_cells)
num_rows = (num_cells + num_cols - 1) // num_cols # Ceiling division
# Create grid rows
for row in range(num_rows):
start_idx = row * num_cols
end_idx = min(start_idx + num_cols, num_cells)
row_cells = cells[start_idx:end_idx]
# Properly format the row with table markers and exact number of cells
formatted_row = "| " + " | ".join(row_cells) + " |"
sample_grid.append(formatted_row)
# Join grid rows with just the content, no headers needed
grid_text = "\n".join(sample_grid) if sample_grid else ""
# Fill in the template
model_card_content = template.format(
base_model=base_model_name,
base_model_link=base_model_link,
model_name=model_name,
training_type="LoRA fine-tuning" if config.model.training_mode == "lora" else "Full model fine-tuning",
training_steps=config.optimization.steps,
learning_rate=config.optimization.learning_rate,
batch_size=config.optimization.batch_size,
validation_prompts=prompts_text,
sample_grid=grid_text,
)
# Save the model card directly
model_card_path = output_dir / "README.md"
model_card_path.write_text(model_card_content)
return model_card_path
@@ -0,0 +1,336 @@
# ruff: noqa: PLC0415
"""
Model loader for LTX-2 trainer using the new ltx-core package.
This module provides a unified interface for loading LTX-2 model components
for training, using SingleGPUModelBuilder from ltx-core.
Example usage:
# Load individual components
vae_encoder = load_video_vae_encoder("/path/to/checkpoint.safetensors", device="cuda")
vae_decoder = load_video_vae_decoder("/path/to/checkpoint.safetensors", device="cuda")
text_encoder = load_text_encoder("/path/to/checkpoint.safetensors", "/path/to/gemma", device="cuda")
# Load all components at once
components = load_model("/path/to/checkpoint.safetensors", text_encoder_path="/path/to/gemma")
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
import torch
from ltx_trainer import logger
# Type alias for device specification
Device = str | torch.device
# Type checking imports (not loaded at runtime)
if TYPE_CHECKING:
from ltx_core.components.schedulers import LTX2Scheduler
from ltx_core.model.audio_vae import AudioDecoder, AudioEncoder, Vocoder
from ltx_core.model.transformer import LTXModel
from ltx_core.model.video_vae import VideoDecoder, VideoEncoder
from ltx_core.text_encoders.gemma import AVGemmaTextEncoderModel
def _to_torch_device(device: Device) -> torch.device:
"""Convert device specification to torch.device."""
return torch.device(device) if isinstance(device, str) else device
# =============================================================================
# Individual Component Loaders
# =============================================================================
def load_transformer(
checkpoint_path: str | Path,
device: Device = "cpu",
dtype: torch.dtype = torch.bfloat16,
) -> "LTXModel":
"""Load the LTX transformer model.
Args:
checkpoint_path: Path to the safetensors checkpoint file
device: Device to load model on
dtype: Data type for model weights
Returns:
Loaded LTXModel transformer
"""
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder
from ltx_core.model.transformer.model_configurator import (
LTXV_MODEL_COMFY_RENAMING_MAP,
LTXModelConfigurator,
)
return SingleGPUModelBuilder(
model_path=str(checkpoint_path),
model_class_configurator=LTXModelConfigurator,
model_sd_ops=LTXV_MODEL_COMFY_RENAMING_MAP,
).build(device=_to_torch_device(device), dtype=dtype)
def load_video_vae_encoder(
checkpoint_path: str | Path,
device: Device = "cpu",
dtype: torch.dtype = torch.bfloat16,
) -> "VideoEncoder":
"""Load the video VAE encoder (for preprocessing).
Args:
checkpoint_path: Path to the safetensors checkpoint file
device: Device to load model on
dtype: Data type for model weights
Returns:
Loaded VideoEncoder
"""
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder
from ltx_core.model.video_vae import VAE_ENCODER_COMFY_KEYS_FILTER, VideoEncoderConfigurator
return SingleGPUModelBuilder(
model_path=str(checkpoint_path),
model_class_configurator=VideoEncoderConfigurator,
model_sd_ops=VAE_ENCODER_COMFY_KEYS_FILTER,
).build(device=_to_torch_device(device), dtype=dtype)
def load_video_vae_decoder(
checkpoint_path: str | Path,
device: Device = "cpu",
dtype: torch.dtype = torch.bfloat16,
) -> "VideoDecoder":
"""Load the video VAE decoder (for inference/validation).
Args:
checkpoint_path: Path to the safetensors checkpoint file
device: Device to load model on
dtype: Data type for model weights
Returns:
Loaded VideoDecoder
"""
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder
from ltx_core.model.video_vae import VAE_DECODER_COMFY_KEYS_FILTER, VideoDecoderConfigurator
return SingleGPUModelBuilder(
model_path=str(checkpoint_path),
model_class_configurator=VideoDecoderConfigurator,
model_sd_ops=VAE_DECODER_COMFY_KEYS_FILTER,
).build(device=_to_torch_device(device), dtype=dtype)
def load_audio_vae_encoder(
checkpoint_path: str | Path,
device: Device = "cpu",
dtype: torch.dtype = torch.bfloat16,
) -> "AudioEncoder":
"""Load the audio VAE encoder (for preprocessing).
Args:
checkpoint_path: Path to the safetensors checkpoint file
device: Device to load model on
dtype: Data type for model weights (default bfloat16, but float32 recommended for quality)
Returns:
Loaded AudioEncoder
"""
from ltx_core.loader import SingleGPUModelBuilder
from ltx_core.model.audio_vae import AUDIO_VAE_ENCODER_COMFY_KEYS_FILTER, AudioEncoderConfigurator
return SingleGPUModelBuilder(
model_path=str(checkpoint_path),
model_class_configurator=AudioEncoderConfigurator,
model_sd_ops=AUDIO_VAE_ENCODER_COMFY_KEYS_FILTER,
).build(device=_to_torch_device(device), dtype=dtype)
def load_audio_vae_decoder(
checkpoint_path: str | Path,
device: Device = "cpu",
dtype: torch.dtype = torch.bfloat16,
) -> "AudioDecoder":
"""Load the audio VAE decoder.
Args:
checkpoint_path: Path to the safetensors checkpoint file
device: Device to load model on
dtype: Data type for model weights
Returns:
Loaded AudioDecoder
"""
from ltx_core.loader import SingleGPUModelBuilder
from ltx_core.model.audio_vae import AUDIO_VAE_DECODER_COMFY_KEYS_FILTER, AudioDecoderConfigurator
return SingleGPUModelBuilder(
model_path=str(checkpoint_path),
model_class_configurator=AudioDecoderConfigurator,
model_sd_ops=AUDIO_VAE_DECODER_COMFY_KEYS_FILTER,
).build(device=_to_torch_device(device), dtype=dtype)
def load_vocoder(
checkpoint_path: str | Path,
device: Device = "cpu",
dtype: torch.dtype = torch.bfloat16,
) -> "Vocoder":
"""Load the vocoder (for audio waveform generation).
Args:
checkpoint_path: Path to the safetensors checkpoint file
device: Device to load model on
dtype: Data type for model weights
Returns:
Loaded Vocoder
"""
from ltx_core.loader import SingleGPUModelBuilder
from ltx_core.model.audio_vae import VOCODER_COMFY_KEYS_FILTER, VocoderConfigurator
return SingleGPUModelBuilder(
model_path=str(checkpoint_path),
model_class_configurator=VocoderConfigurator,
model_sd_ops=VOCODER_COMFY_KEYS_FILTER,
).build(device=_to_torch_device(device), dtype=dtype)
def load_text_encoder(
checkpoint_path: str | Path,
gemma_model_path: str | Path,
device: Device = "cpu",
dtype: torch.dtype = torch.bfloat16,
) -> "AVGemmaTextEncoderModel":
"""Load the Gemma text encoder.
Args:
checkpoint_path: Path to the LTX-2 safetensors checkpoint file
gemma_model_path: Path to Gemma model directory
device: Device to load model on
dtype: Data type for model weights
Returns:
Loaded AVGemmaTextEncoderModel
"""
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder
from ltx_core.text_encoders.gemma.encoders.av_encoder import (
AV_GEMMA_TEXT_ENCODER_KEY_OPS,
AVGemmaTextEncoderModelConfigurator,
)
from ltx_core.text_encoders.gemma.encoders.base_encoder import module_ops_from_gemma_root
if not Path(gemma_model_path).is_dir():
raise ValueError(f"Gemma model path is not a directory: {gemma_model_path}")
torch_device = _to_torch_device(device)
text_encoder = SingleGPUModelBuilder(
model_path=str(checkpoint_path),
model_class_configurator=AVGemmaTextEncoderModelConfigurator,
model_sd_ops=AV_GEMMA_TEXT_ENCODER_KEY_OPS,
module_ops=module_ops_from_gemma_root(str(gemma_model_path)),
).build(device=torch_device, dtype=dtype)
return text_encoder
# =============================================================================
# Combined Component Loader
# =============================================================================
@dataclass
class LtxModelComponents:
"""Container for all LTX-2 model components."""
transformer: "LTXModel"
video_vae_encoder: "VideoEncoder | None" = None
video_vae_decoder: "VideoDecoder | None" = None
audio_vae_decoder: "AudioDecoder | None" = None
vocoder: "Vocoder | None" = None
text_encoder: "AVGemmaTextEncoderModel | None" = None
scheduler: "LTX2Scheduler | None" = None
def load_model(
checkpoint_path: str | Path,
text_encoder_path: str | Path | None = None,
device: Device = "cpu",
dtype: torch.dtype = torch.bfloat16,
with_video_vae_encoder: bool = False,
with_video_vae_decoder: bool = True,
with_audio_vae_decoder: bool = True,
with_vocoder: bool = True,
with_text_encoder: bool = True,
) -> LtxModelComponents:
"""
Load LTX-2 model components from a safetensors checkpoint.
This is a convenience function that loads multiple components at once.
For loading individual components, use the dedicated functions:
- load_transformer()
- load_video_vae_encoder()
- load_video_vae_decoder()
- load_audio_vae_decoder()
- load_vocoder()
- load_text_encoder()
Args:
checkpoint_path: Path to the safetensors checkpoint file
text_encoder_path: Path to Gemma model directory (required if with_text_encoder=True)
device: Device to load models on ("cuda", "cpu", etc.)
dtype: Data type for model weights
with_video_vae_encoder: Whether to load the video VAE encoder (for preprocessing)
with_video_vae_decoder: Whether to load the video VAE decoder (for inference/validation)
with_audio_vae_decoder: Whether to load the audio VAE decoder
with_vocoder: Whether to load the vocoder
with_text_encoder: Whether to load the text encoder
Returns:
LtxModelComponents containing all loaded model components
"""
from ltx_core.components.schedulers import LTX2Scheduler
checkpoint_path = Path(checkpoint_path)
# Validate checkpoint exists
if not checkpoint_path.exists():
raise FileNotFoundError(f"Checkpoint not found: {checkpoint_path}")
logger.info(f"Loading LTX-2 model from {checkpoint_path}")
torch_device = _to_torch_device(device)
# Load transformer
logger.debug("Loading transformer...")
transformer = load_transformer(checkpoint_path, torch_device, dtype)
# Load video VAE encoder
video_vae_encoder = None
if with_video_vae_encoder:
logger.debug("Loading video VAE encoder...")
video_vae_encoder = load_video_vae_encoder(checkpoint_path, torch_device, dtype)
# Load video VAE decoder
video_vae_decoder = None
if with_video_vae_decoder:
logger.debug("Loading video VAE decoder...")
video_vae_decoder = load_video_vae_decoder(checkpoint_path, torch_device, dtype)
# Load audio VAE decoder
audio_vae_decoder = None
if with_audio_vae_decoder:
logger.debug("Loading audio VAE decoder...")
audio_vae_decoder = load_audio_vae_decoder(checkpoint_path, torch_device, dtype)
# Load vocoder
vocoder = None
if with_vocoder:
logger.debug("Loading vocoder...")
vocoder = load_vocoder(checkpoint_path, torch_device, dtype)
# Load text encoder
text_encoder = None
if with_text_encoder:
if text_encoder_path is None:
raise ValueError("text_encoder_path must be provided when with_text_encoder=True")
logger.debug("Loading Gemma text encoder...")
text_encoder = load_text_encoder(checkpoint_path, text_encoder_path, torch_device, dtype)
# Create scheduler (stateless, no loading needed)
scheduler = LTX2Scheduler()
return LtxModelComponents(
transformer=transformer,
video_vae_encoder=video_vae_encoder,
video_vae_decoder=video_vae_decoder,
audio_vae_decoder=audio_vae_decoder,
vocoder=vocoder,
text_encoder=text_encoder,
scheduler=scheduler,
)
@@ -0,0 +1,236 @@
"""Progress tracking for LTX training.
This module provides a unified progress display for training and validation sampling,
encapsulating all Rich progress bar logic in one place.
"""
from rich.progress import (
BarColumn,
Progress,
TaskID,
TextColumn,
TimeElapsedColumn,
TimeRemainingColumn,
)
class SamplingContext:
"""Context for validation sampling progress tracking.
Provides a unified progress display showing current video and denoising step.
Display format: "Sampling X/Y [████████████] step Z/W"
The progress bar shows the denoising progress for the current video.
"""
def __init__(self, progress: Progress | None, task: TaskID | None, num_prompts: int, num_steps: int):
self._progress = progress
self._task = task
self._num_prompts = num_prompts
self._num_steps = num_steps
def start_video(self, video_idx: int) -> None:
"""Start tracking a new video (resets step progress)."""
if self._progress is None or self._task is None:
return
# Reset task for new video: completed=0, total=num_steps
self._progress.reset(self._task, total=self._num_steps)
self._progress.update(
self._task,
completed=0,
video=f"{video_idx + 1}/{self._num_prompts}",
info=f"step 0/{self._num_steps}",
)
def advance_step(self) -> None:
"""Advance the denoising step by one."""
if self._progress is None or self._task is None:
return
self._progress.advance(self._task)
completed = int(self._progress.tasks[self._task].completed)
self._progress.update(self._task, info=f"step {completed}/{self._num_steps}")
def cleanup(self) -> None:
"""Hide sampling task when done."""
if self._progress is None or self._task is None:
return
self._progress.update(self._task, visible=False)
class StandaloneSamplingProgress:
"""Standalone progress display for inference scripts.
Unlike SamplingContext (which integrates with TrainingProgress), this class
manages its own Rich Progress instance for use in standalone inference scripts.
Usage:
with StandaloneSamplingProgress(num_steps=30) as ctx:
for step in range(30):
# ... denoising step ...
ctx.advance_step()
"""
def __init__(self, num_steps: int, description: str = "Generating"):
"""Initialize standalone sampling progress.
Args:
num_steps: Total number of denoising steps
description: Description to show in progress bar
"""
self._num_steps = num_steps
self._description = description
self._progress: Progress | None = None
self._task: TaskID | None = None
def __enter__(self) -> "StandaloneSamplingProgress":
"""Start the progress display."""
self._progress = Progress(
TextColumn("[progress.description]{task.description}"),
BarColumn(bar_width=40, style="blue"),
TextColumn("{task.fields[info]}", style="cyan"),
TimeElapsedColumn(),
TextColumn("ETA:"),
TimeRemainingColumn(compact=True),
)
self._progress.__enter__()
self._task = self._progress.add_task(
self._description,
total=self._num_steps,
info=f"step 0/{self._num_steps}",
)
return self
def __exit__(self, *args) -> None:
"""Stop the progress display."""
if self._progress is not None:
self._progress.__exit__(*args)
def advance_step(self) -> None:
"""Advance the denoising step by one."""
if self._progress is None or self._task is None:
return
self._progress.advance(self._task)
completed = int(self._progress.tasks[self._task].completed)
self._progress.update(self._task, info=f"step {completed}/{self._num_steps}")
class TrainingProgress:
"""Manages Rich progress display for training and validation.
This class encapsulates all progress bar logic, providing a clean interface
for the trainer to update progress without dealing with Rich internals.
Usage:
with TrainingProgress(enabled=True, total_steps=1000) as progress:
for step in range(1000):
# ... training step ...
progress.update_training(loss=0.1, lr=1e-4, step_time=0.5)
if should_validate:
sampling_ctx = progress.start_sampling(num_prompts=3, num_steps=30)
sampler = ValidationSampler(..., sampling_context=sampling_ctx)
for prompt_idx, prompt in enumerate(prompts):
sampling_ctx.start_video(prompt_idx)
sampler.generate(...)
sampling_ctx.cleanup()
"""
def __init__(self, enabled: bool, total_steps: int):
"""Initialize progress tracking.
Args:
enabled: Whether to display progress bars (False for non-main processes)
total_steps: Total number of training steps
"""
self._enabled = enabled
self._total_steps = total_steps
self._train_task: TaskID | None = None
if not enabled:
self._progress = None
return
# Single Progress instance with flexible columns
self._progress = Progress(
TextColumn("[progress.description]{task.description}"),
TextColumn("{task.fields[video]}", style="magenta"),
BarColumn(bar_width=40, style="blue"),
TextColumn("{task.fields[info]}", style="cyan"),
TimeElapsedColumn(),
TextColumn("ETA:"),
TimeRemainingColumn(compact=True),
)
def __enter__(self) -> "TrainingProgress":
"""Enter the progress context, starting the live display."""
if self._progress is not None:
self._progress.__enter__()
self._train_task = self._progress.add_task(
"Training",
total=self._total_steps,
video=f"0/{self._total_steps}",
info="Starting...",
)
return self
def __exit__(self, *args) -> None:
"""Exit the progress context, stopping the live display."""
if self._progress is not None:
self._progress.__exit__(*args)
@property
def enabled(self) -> bool:
"""Whether progress display is enabled."""
return self._enabled
def update_training(
self,
*,
loss: float,
lr: float,
step_time: float,
advance: bool = True,
) -> None:
"""Update the training progress display.
Args:
loss: Current training loss
lr: Current learning rate
step_time: Time taken for this step in seconds
advance: Whether to advance the progress by one step
"""
if self._progress is None or self._train_task is None:
return
info = f"Loss: {loss:.4f} | LR: {lr:.2e} | {step_time:.2f}s/step"
self._progress.update(
self._train_task,
advance=1 if advance else 0,
info=info,
)
# Update step count in video column
completed = int(self._progress.tasks[self._train_task].completed)
self._progress.update(self._train_task, video=f"{completed}/{self._total_steps}")
def start_sampling(self, num_prompts: int, num_steps: int) -> SamplingContext:
"""Start validation sampling progress tracking.
Creates a task that shows current video and denoising step progress.
Format: "Sampling X/Y [████████████] step Z/W"
Args:
num_prompts: Number of validation prompts to sample
num_steps: Number of denoising steps per sample
Returns:
SamplingContext for tracking progress (no-op if progress is disabled)
"""
if self._progress is None:
# Return a no-op context when progress is disabled
return SamplingContext(
progress=None,
task=None,
num_prompts=num_prompts,
num_steps=num_steps,
)
task = self._progress.add_task(
"Sampling",
total=num_steps,
completed=0,
video=f"0/{num_prompts}",
info=f"step 0/{num_steps}",
)
return SamplingContext(
progress=self._progress,
task=task,
num_prompts=num_prompts,
num_steps=num_steps,
)
@@ -0,0 +1,90 @@
# Adapted from: https://github.com/bghira/SimpleTuner/blob/main/helpers/training/quantisation/__init__.py
from typing import Literal
import torch
from optimum.quanto import qtype
from ltx_trainer import logger
QuantizationOptions = Literal[
"no_change",
"int8-quanto",
"int4-quanto",
"int2-quanto",
"fp8-quanto",
"fp8uz-quanto",
]
def quantize_model(
model: torch.nn.Module,
precision: QuantizationOptions,
quantize_activations: bool = False,
) -> torch.nn.Module:
"""
Quantize a model using the specified precision settings.
Args:
model: The model to quantize.
precision: The precision level to quantize to (e.g. "int8-quanto", "fp8-quanto").
quantize_activations: Whether to quantize activations in addition to weights.
Returns:
The quantized model, or the original model if no quantization is performed.
"""
if precision is None or precision == "no_change":
return model
from optimum.quanto import freeze, quantize # noqa: PLC0415
weight_quant = _quanto_type_map(precision)
extra_quanto_args = {
"exclude": [
"proj_in",
"time_embed.*",
"caption_projection.*",
"rope",
"*norm*",
"proj_out",
]
}
if quantize_activations:
logger.info("Freezing model weights and activations")
extra_quanto_args["activations"] = weight_quant
else:
logger.info("Freezing model weights only")
quantize(model, weights=weight_quant, **extra_quanto_args)
freeze(model)
return model
def _quanto_type_map(precision: QuantizationOptions) -> torch.dtype | qtype | None: # noqa: PLR0911
if precision == "no_change":
return None
from optimum.quanto import ( # noqa: PLC0415
qfloat8,
qfloat8_e4m3fnuz,
qint2,
qint4,
qint8,
)
if precision == "int2-quanto":
return qint2
elif precision == "int4-quanto":
return qint4
elif precision == "int8-quanto":
return qint8
elif precision in ("fp8-quanto", "fp8uz-quanto"):
if torch.backends.mps.is_available():
logger.warning(
"MPS doesn't support dtype float8. "
"you must select another precision level such as int2, int8, or int8.",
)
return None
if precision == "fp8-quanto":
return qfloat8
elif precision == "fp8uz-quanto":
return qfloat8_e4m3fnuz
raise ValueError(f"Invalid quantisation level: {precision}")
@@ -0,0 +1,128 @@
import torch
class TimestepSampler:
"""Base class for timestep samplers.
Timestep samplers are used to sample timesteps for diffusion models.
They should implement both sample() and sample_for() methods.
"""
def sample(self, batch_size: int, seq_length: int | None = None, device: torch.device = None) -> torch.Tensor:
"""Sample timesteps for a batch.
Args:
batch_size: Number of timesteps to sample
seq_length: (optional) Length of the sequence being processed
device: Device to place the samples on
Returns:
Tensor of shape (batch_size,) containing timesteps
"""
raise NotImplementedError
def sample_for(self, batch: torch.Tensor) -> torch.Tensor:
"""Sample timesteps for a specific batch tensor.
Args:
batch: Input tensor of shape (batch_size, seq_length, ...)
Returns:
Tensor of shape (batch_size,) containing timesteps
"""
raise NotImplementedError
class UniformTimestepSampler(TimestepSampler):
"""Samples timesteps uniformly between min_value and max_value (default 0 and 1)."""
def __init__(self, min_value: float = 0.0, max_value: float = 1.0):
self.min_value = min_value
self.max_value = max_value
def sample(self, batch_size: int, seq_length: int | None = None, device: torch.device = None) -> torch.Tensor: # noqa: ARG002
return torch.rand(batch_size, device=device) * (self.max_value - self.min_value) + self.min_value
def sample_for(self, batch: torch.Tensor) -> torch.Tensor:
if batch.ndim != 3:
raise ValueError(f"Batch should have 3 dimensions, got {batch.ndim}")
return self.sample(batch.shape[0], device=batch.device)
class ShiftedLogitNormalTimestepSampler:
"""
Samples timesteps from a shifted logit-normal distribution,
where the shift is determined by the sequence length.
"""
def __init__(self, std: float = 1.0):
self.std = std
def sample(self, batch_size: int, seq_length: int, device: torch.device = None) -> torch.Tensor:
"""Sample timesteps for a batch from a shifted logit-normal distribution.
Args:
batch_size: Number of timesteps to sample
seq_length: Length of the sequence being processed, used to determine the shift
device: Device to place the samples on
Returns:
Tensor of shape (batch_size,) containing timesteps sampled from a shifted
logit-normal distribution, where the shift is determined by seq_length
"""
shift = self._get_shift_for_sequence_length(seq_length)
normal_samples = torch.randn((batch_size,), device=device) * self.std + shift
timesteps = torch.sigmoid(normal_samples)
return timesteps
def sample_for(self, batch: torch.Tensor) -> torch.Tensor:
"""Sample timesteps for a specific batch tensor.
Args:
batch: Input tensor of shape (batch_size, seq_length, ...)
Returns:
Tensor of shape (batch_size,) containing timesteps sampled from a shifted
logit-normal distribution, where the shift is determined by the sequence length
of the input batch
Raises:
ValueError: If the input batch does not have 3 dimensions
"""
if batch.ndim != 3:
raise ValueError(f"Batch should have 3 dimensions, got {batch.ndim}")
batch_size, seq_length, _ = batch.shape
return self.sample(batch_size, seq_length, device=batch.device)
@staticmethod
def _get_shift_for_sequence_length(
seq_length: int,
min_tokens: int = 1024,
max_tokens: int = 4096,
min_shift: float = 0.95,
max_shift: float = 2.05,
) -> float:
# Calculate the shift value for a given sequence length using linear interpolation
# between min_shift and max_shift based on sequence length.
m = (max_shift - min_shift) / (max_tokens - min_tokens) # Calculate slope
b = min_shift - m * min_tokens # Calculate y-intercept
shift = m * seq_length + b # Apply linear equation y = mx + b
return shift
SAMPLERS = {
"uniform": UniformTimestepSampler,
"shifted_logit_normal": ShiftedLogitNormalTimestepSampler,
}
def example() -> None:
# noinspection PyUnresolvedReferences
import matplotlib.pyplot as plt # noqa: PLC0415
sampler = ShiftedLogitNormalTimestepSampler()
for seq_length in [1024, 2048, 4096, 8192]:
samples = sampler.sample(batch_size=1_000_000, seq_length=seq_length)
# plot the histogram of the samples
plt.hist(samples.numpy(), bins=100, density=True)
plt.title(f"Timestep Samples for Sequence Length {seq_length}")
plt.xlabel("Timestep")
plt.ylabel("Density")
plt.show()
if __name__ == "__main__":
example()
@@ -0,0 +1,955 @@
import os
import time
import warnings
from pathlib import Path
from typing import Callable
import torch
import wandb
import yaml
from accelerate import Accelerator, DistributedType
from accelerate.utils import set_seed
from peft import LoraConfig, get_peft_model, get_peft_model_state_dict, set_peft_model_state_dict
from peft.tuners.tuners_utils import BaseTunerLayer
from peft.utils import ModulesToSaveWrapper
from pydantic import BaseModel
from safetensors.torch import load_file, save_file
from torch import Tensor
from torch.optim import AdamW
from torch.optim.lr_scheduler import (
CosineAnnealingLR,
CosineAnnealingWarmRestarts,
LinearLR,
LRScheduler,
PolynomialLR,
StepLR,
)
from torch.utils.data import DataLoader
from torchvision.transforms import functional as F # noqa: N812
from ltx_trainer import logger
from ltx_trainer.config import LtxTrainerConfig
from ltx_trainer.config_display import print_config
from ltx_trainer.datasets import PrecomputedDataset
from ltx_trainer.hf_hub_utils import push_to_hub
from ltx_trainer.model_loader import load_model as load_ltx_model
from ltx_trainer.model_loader import load_text_encoder
from ltx_trainer.progress import TrainingProgress
from ltx_trainer.quantization import quantize_model
from ltx_trainer.timestep_samplers import SAMPLERS
from ltx_trainer.training_strategies import get_training_strategy
from ltx_trainer.utils import get_gpu_memory_gb, open_image_as_srgb, save_image
from ltx_trainer.validation_sampler import CachedPromptEmbeddings, GenerationConfig, ValidationSampler
from ltx_trainer.video_utils import read_video, save_video
# Disable irrelevant warnings from transformers
os.environ["TOKENIZERS_PARALLELISM"] = "true"
# Silence bitsandbytes warnings about casting
warnings.filterwarnings(
"ignore", message="MatMul8bitLt: inputs will be cast from torch.bfloat16 to float16 during quantization"
)
# Disable progress bars if not main process
IS_MAIN_PROCESS = os.environ.get("LOCAL_RANK", "0") == "0"
if not IS_MAIN_PROCESS:
from transformers.utils.logging import disable_progress_bar
disable_progress_bar()
StepCallback = Callable[[int, int, list[Path]], None] # (step, total, list[sampled_video_path]) -> None
MEMORY_CHECK_INTERVAL = 200
class TrainingStats(BaseModel):
"""Statistics collected during training"""
total_time_seconds: float
steps_per_second: float
samples_per_second: float
peak_gpu_memory_gb: float
global_batch_size: int
num_processes: int
class LtxvTrainer:
def __init__(self, trainer_config: LtxTrainerConfig) -> None:
self._config = trainer_config
if IS_MAIN_PROCESS:
print_config(trainer_config)
self._training_strategy = get_training_strategy(self._config.training_strategy)
self._cached_validation_embeddings = self._load_text_encoder_and_cache_embeddings()
self._load_models()
self._setup_accelerator()
self._collect_trainable_params()
self._load_checkpoint()
self._prepare_models_for_training()
self._dataset = None
self._global_step = -1
self._checkpoint_paths = []
self._init_wandb()
def train( # noqa: PLR0912, PLR0915
self,
disable_progress_bars: bool = False,
step_callback: StepCallback | None = None,
) -> tuple[Path, TrainingStats]:
"""
Start the training process.
Returns:
Tuple of (saved_model_path, training_stats)
"""
device = self._accelerator.device
cfg = self._config
start_mem = get_gpu_memory_gb(device)
train_start_time = time.time()
# Use the same seed for all processes and ensure deterministic operations
set_seed(cfg.seed)
logger.debug(f"Process {self._accelerator.process_index} using seed: {cfg.seed}")
self._init_optimizer()
self._init_dataloader()
data_iter = iter(self._dataloader)
self._init_timestep_sampler()
# Synchronize all processes after initialization
self._accelerator.wait_for_everyone()
Path(cfg.output_dir).mkdir(parents=True, exist_ok=True)
# Save the training configuration as YAML
self._save_config()
logger.info("🚀 Starting training...")
# Create progress tracking (disabled for non-main processes or when explicitly disabled)
progress_enabled = IS_MAIN_PROCESS and not disable_progress_bars
progress = TrainingProgress(
enabled=progress_enabled,
total_steps=cfg.optimization.steps,
)
if IS_MAIN_PROCESS and disable_progress_bars:
logger.warning("Progress bars disabled. Intermediate status messages will be logged instead.")
self._transformer.train()
self._global_step = 0
peak_mem_during_training = start_mem
sampled_videos_paths = None
with progress:
# Initial validation before training starts
if cfg.validation.interval and not cfg.validation.skip_initial_validation:
sampled_videos_paths = self._sample_videos(progress)
if IS_MAIN_PROCESS and sampled_videos_paths and self._config.wandb.log_validation_videos:
self._log_validation_samples(sampled_videos_paths, cfg.validation.prompts)
self._accelerator.wait_for_everyone()
for step in range(cfg.optimization.steps * cfg.optimization.gradient_accumulation_steps):
# Get next batch, reset the dataloader if needed
try:
batch = next(data_iter)
except StopIteration:
data_iter = iter(self._dataloader)
batch = next(data_iter)
step_start_time = time.time()
with self._accelerator.accumulate(self._transformer):
is_optimization_step = (step + 1) % cfg.optimization.gradient_accumulation_steps == 0
if is_optimization_step:
self._global_step += 1
loss = self._training_step(batch)
self._accelerator.backward(loss)
if self._accelerator.sync_gradients and cfg.optimization.max_grad_norm > 0:
self._accelerator.clip_grad_norm_(
self._trainable_params,
cfg.optimization.max_grad_norm,
)
self._optimizer.step()
self._optimizer.zero_grad()
if self._lr_scheduler is not None:
self._lr_scheduler.step()
# Run validation if needed
if (
cfg.validation.interval
and self._global_step > 0
and self._global_step % cfg.validation.interval == 0
and is_optimization_step
):
if self._accelerator.distributed_type == DistributedType.FSDP:
# FSDP: All processes must participate in validation
sampled_videos_paths = self._sample_videos(progress)
if IS_MAIN_PROCESS and sampled_videos_paths and self._config.wandb.log_validation_videos:
self._log_validation_samples(sampled_videos_paths, cfg.validation.prompts)
# DDP: Only main process runs validation
elif IS_MAIN_PROCESS:
sampled_videos_paths = self._sample_videos(progress)
if sampled_videos_paths and self._config.wandb.log_validation_videos:
self._log_validation_samples(sampled_videos_paths, cfg.validation.prompts)
# Save checkpoint if needed
if (
cfg.checkpoints.interval
and self._global_step > 0
and self._global_step % cfg.checkpoints.interval == 0
and is_optimization_step
):
self._save_checkpoint()
self._accelerator.wait_for_everyone()
# Call step callback if provided
if step_callback and is_optimization_step:
step_callback(self._global_step, cfg.optimization.steps, sampled_videos_paths)
self._accelerator.wait_for_everyone()
# Update progress and log metrics
current_lr = self._optimizer.param_groups[0]["lr"]
step_time = (time.time() - step_start_time) * cfg.optimization.gradient_accumulation_steps
progress.update_training(
loss=loss.item(),
lr=current_lr,
step_time=step_time,
advance=is_optimization_step,
)
# Log metrics to W&B (only on main process and optimization steps)
if IS_MAIN_PROCESS and is_optimization_step:
self._log_metrics(
{
"train/loss": loss.item(),
"train/learning_rate": current_lr,
"train/step_time": step_time,
"train/global_step": self._global_step,
}
)
# Fallback logging when progress bars are disabled
if disable_progress_bars and IS_MAIN_PROCESS and self._global_step % 20 == 0:
elapsed = time.time() - train_start_time
progress_percentage = self._global_step / cfg.optimization.steps
if progress_percentage > 0:
total_estimated = elapsed / progress_percentage
total_time = f"{total_estimated // 3600:.0f}h {(total_estimated % 3600) // 60:.0f}m"
else:
total_time = "calculating..."
logger.info(
f"Step {self._global_step}/{cfg.optimization.steps} - "
f"Loss: {loss.item():.4f}, LR: {current_lr:.2e}, "
f"Time/Step: {step_time:.2f}s, Total Time: {total_time}",
)
# Sample GPU memory periodically
if step % MEMORY_CHECK_INTERVAL == 0:
current_mem = get_gpu_memory_gb(device)
peak_mem_during_training = max(peak_mem_during_training, current_mem)
# Collect final stats
train_end_time = time.time()
end_mem = get_gpu_memory_gb(device)
peak_mem = max(start_mem, end_mem, peak_mem_during_training)
# Calculate steps/second over entire training
total_time_seconds = train_end_time - train_start_time
steps_per_second = cfg.optimization.steps / total_time_seconds
samples_per_second = steps_per_second * self._accelerator.num_processes * cfg.optimization.batch_size
stats = TrainingStats(
total_time_seconds=total_time_seconds,
steps_per_second=steps_per_second,
samples_per_second=samples_per_second,
peak_gpu_memory_gb=peak_mem,
num_processes=self._accelerator.num_processes,
global_batch_size=cfg.optimization.batch_size * self._accelerator.num_processes,
)
saved_path = self._save_checkpoint()
if IS_MAIN_PROCESS:
# Log the training statistics
self._log_training_stats(stats)
# Upload artifacts to hub if enabled
if cfg.hub.push_to_hub:
push_to_hub(saved_path, sampled_videos_paths, self._config)
# Log final stats to W&B
if self._wandb_run is not None:
self._log_metrics(
{
"stats/total_time_minutes": stats.total_time_seconds / 60,
"stats/steps_per_second": stats.steps_per_second,
"stats/samples_per_second": stats.samples_per_second,
"stats/peak_gpu_memory_gb": stats.peak_gpu_memory_gb,
}
)
self._wandb_run.finish()
self._accelerator.wait_for_everyone()
self._accelerator.end_training()
return saved_path, stats
def _training_step(self, batch: dict[str, dict[str, Tensor]]) -> Tensor:
"""Perform a single training step using the configured strategy."""
# Apply embedding connectors to transform pre-computed text embeddings
conditions = batch["conditions"]
video_embeds, audio_embeds, attention_mask = self._text_encoder._run_connectors(
conditions["prompt_embeds"], conditions["prompt_attention_mask"]
)
conditions["video_prompt_embeds"] = video_embeds
conditions["audio_prompt_embeds"] = audio_embeds
conditions["prompt_attention_mask"] = attention_mask
# Use strategy to prepare training inputs (returns ModelInputs with Modality objects)
model_inputs = self._training_strategy.prepare_training_inputs(batch, self._timestep_sampler)
# Run transformer forward pass with Modality-based interface
video_pred, audio_pred = self._transformer(
video=model_inputs.video,
audio=model_inputs.audio,
perturbations=None,
)
# Use strategy to compute loss
loss = self._training_strategy.compute_loss(video_pred, audio_pred, model_inputs)
return loss
def _load_text_encoder_and_cache_embeddings(self) -> list[CachedPromptEmbeddings] | None:
"""Load text encoder, computes and returns validation embeddings."""
# This method:
# 1. Loads the text encoder on GPU
# 2. If validation prompts are configured, computes and caches their embeddings
# 3. Unloads the heavy Gemma model while keeping the lightweight embedding connectors
# The text encoder is kept (as self._text_encoder) but with model/tokenizer/feature_extractor
# set to None. Only the embedding connectors remain for use during training.
# Load text encoder on GPU
logger.debug("Loading text encoder...")
if self._config.acceleration.load_text_encoder_in_8bit:
logger.warning(
"⚠️ load_text_encoder_in_8bit is set to True but 8-bit text encoder loading "
"is not currently implemented. The text encoder will be loaded in bfloat16 precision."
)
self._text_encoder = load_text_encoder(
checkpoint_path=self._config.model.model_path,
gemma_model_path=self._config.model.text_encoder_path,
device="cuda",
dtype=torch.bfloat16,
)
# Cache validation embeddings if prompts are configured
cached_embeddings = None
if self._config.validation.prompts:
logger.info(f"Pre-computing embeddings for {len(self._config.validation.prompts)} validation prompts...")
cached_embeddings = []
with torch.inference_mode():
for prompt in self._config.validation.prompts:
v_ctx_pos, a_ctx_pos, _ = self._text_encoder(prompt)
v_ctx_neg, a_ctx_neg, _ = self._text_encoder(self._config.validation.negative_prompt)
cached_embeddings.append(
CachedPromptEmbeddings(
video_context_positive=v_ctx_pos.cpu(),
audio_context_positive=a_ctx_pos.cpu(),
video_context_negative=v_ctx_neg.cpu() if v_ctx_neg is not None else None,
audio_context_negative=a_ctx_neg.cpu() if a_ctx_neg is not None else None,
)
)
# Unload heavy components to free VRAM, keeping only the embedding connectors
self._text_encoder.model = None
self._text_encoder.tokenizer = None
self._text_encoder.feature_extractor_linear = None
torch.cuda.empty_cache()
logger.debug("Validation prompt embeddings cached. Gemma model unloaded")
return cached_embeddings
def _load_models(self) -> None:
"""Load the LTX-2 model components."""
# Load audio components if:
# 1. Training strategy requires audio (training the audio branch), OR
# 2. Validation is configured to generate audio (even if not training audio)
load_audio = self._training_strategy.requires_audio or self._config.validation.generate_audio
# Check if we need VAE encoder (for image or reference video conditioning)
need_vae_encoder = (
self._config.validation.images is not None or self._config.validation.reference_videos is not None
)
# Load all model components (except text encoder - already handled)
components = load_ltx_model(
checkpoint_path=self._config.model.model_path,
device="cpu",
dtype=torch.bfloat16,
with_video_vae_encoder=need_vae_encoder, # Needed for image conditioning
with_video_vae_decoder=True, # Needed for validation sampling
with_audio_vae_decoder=load_audio,
with_vocoder=load_audio,
with_text_encoder=False, # Text encoder handled separately
)
# Extract components
self._transformer = components.transformer
self._vae_decoder = components.video_vae_decoder.to(dtype=torch.bfloat16)
self._vae_encoder = components.video_vae_encoder
if self._vae_encoder is not None:
self._vae_encoder = self._vae_encoder.to(dtype=torch.bfloat16)
self._scheduler = components.scheduler
self._audio_vae = components.audio_vae_decoder
self._vocoder = components.vocoder
# Note: self._text_encoder was set in _load_text_encoder_and_cache_embeddings
# Determine initial dtype based on training mode.
# Note: For FSDP + LoRA, we'll cast to FP32 later in _prepare_models_for_training()
# after the accelerator is set up, and we can detect FSDP.
transformer_dtype = torch.bfloat16 if self._config.model.training_mode == "lora" else torch.float32
self._transformer = self._transformer.to(dtype=transformer_dtype)
if self._config.acceleration.quantization is not None:
if self._config.model.training_mode == "full":
raise ValueError("Quantization is not supported in full training mode.")
logger.warning(f"Quantizing model with precision: {self._config.acceleration.quantization}")
self._transformer = quantize_model(
self._transformer,
precision=self._config.acceleration.quantization,
)
# Freeze all models. We later unfreeze the transformer based on training mode.
# Note: embedding_connectors are already frozen (they come from the frozen text encoder)
self._vae_decoder.requires_grad_(False)
if self._vae_encoder is not None:
self._vae_encoder.requires_grad_(False)
self._transformer.requires_grad_(False)
if self._audio_vae is not None:
self._audio_vae.requires_grad_(False)
if self._vocoder is not None:
self._vocoder.requires_grad_(False)
def _collect_trainable_params(self) -> None:
"""Collect trainable parameters based on training mode."""
if self._config.model.training_mode == "lora":
# For LoRA training, first set up LoRA layers
self._setup_lora()
elif self._config.model.training_mode == "full":
# For full training, unfreeze all transformer parameters
self._transformer.requires_grad_(True)
else:
raise ValueError(f"Unknown training mode: {self._config.model.training_mode}")
self._trainable_params = [p for p in self._transformer.parameters() if p.requires_grad]
logger.debug(f"Trainable params count: {sum(p.numel() for p in self._trainable_params):,}")
def _init_timestep_sampler(self) -> None:
"""Initialize the timestep sampler based on the config."""
sampler_cls = SAMPLERS[self._config.flow_matching.timestep_sampling_mode]
self._timestep_sampler = sampler_cls(**self._config.flow_matching.timestep_sampling_params)
def _setup_lora(self) -> None:
"""Configure LoRA adapters for the transformer. Only called in LoRA training mode."""
logger.debug(f"Adding LoRA adapter with rank {self._config.lora.rank}")
lora_config = LoraConfig(
r=self._config.lora.rank,
lora_alpha=self._config.lora.alpha,
target_modules=self._config.lora.target_modules,
lora_dropout=self._config.lora.dropout,
init_lora_weights=True,
)
# Wrap the transformer with PEFT to add LoRA layers
# noinspection PyTypeChecker
self._transformer = get_peft_model(self._transformer, lora_config)
def _load_checkpoint(self) -> None:
"""Load checkpoint if specified in config."""
if not self._config.model.load_checkpoint:
return
checkpoint_path = self._find_checkpoint(self._config.model.load_checkpoint)
if not checkpoint_path:
logger.warning(f"⚠️ Could not find checkpoint at {self._config.model.load_checkpoint}")
return
logger.info(f"📥 Loading checkpoint from {checkpoint_path}")
if self._config.model.training_mode == "full":
self._load_full_checkpoint(checkpoint_path)
else: # LoRA mode
self._load_lora_checkpoint(checkpoint_path)
def _load_full_checkpoint(self, checkpoint_path: Path) -> None:
"""Load full model checkpoint."""
state_dict = load_file(checkpoint_path)
self._transformer.load_state_dict(state_dict, strict=True)
logger.info("✅ Full model checkpoint loaded successfully")
def _load_lora_checkpoint(self, checkpoint_path: Path) -> None:
"""Load LoRA checkpoint with DDP/FSDP compatibility."""
state_dict = load_file(checkpoint_path)
# Adjust layer names to match internal format.
# (Weights are saved in ComfyUI-compatible format, with "diffusion_model." prefix)
state_dict = {k.replace("diffusion_model.", "", 1): v for k, v in state_dict.items()}
# Load LoRA weights and verify all weights were loaded
base_model = self._transformer.get_base_model()
set_peft_model_state_dict(base_model, state_dict)
logger.info("✅ LoRA checkpoint loaded successfully")
def _prepare_models_for_training(self) -> None:
"""Prepare models for training with Accelerate."""
# For FSDP + LoRA: Cast entire model to FP32.
# FSDP requires uniform dtype across all parameters in wrapped modules.
# In LoRA mode, PEFT creates LoRA params in FP32 while base model is BF16.
# We cast the base model to FP32 to match the LoRA params.
if self._accelerator.distributed_type == DistributedType.FSDP and self._config.model.training_mode == "lora":
logger.debug("FSDP: casting transformer to FP32 for uniform dtype")
self._transformer = self._transformer.to(dtype=torch.float32)
# Enable gradient checkpointing if requested
# For PeftModel, we need to access the underlying base model
transformer = (
self._transformer.get_base_model() if hasattr(self._transformer, "get_base_model") else self._transformer
)
transformer.set_gradient_checkpointing(self._config.optimization.enable_gradient_checkpointing)
# Keep frozen models on CPU for memory efficiency
self._vae_decoder = self._vae_decoder.to("cpu")
if self._vae_encoder is not None:
self._vae_encoder = self._vae_encoder.to("cpu")
# Embedding connectors are already on GPU from _load_text_encoder_and_cache_embeddings
# noinspection PyTypeChecker
self._transformer = self._accelerator.prepare(self._transformer)
# Log GPU memory usage after model preparation
vram_usage_gb = torch.cuda.memory_allocated() / 1024**3
logger.debug(f"GPU memory usage after models preparation: {vram_usage_gb:.2f} GB")
@staticmethod
def _find_checkpoint(checkpoint_path: str | Path) -> Path | None:
"""Find the checkpoint file to load, handling both file and directory paths."""
checkpoint_path = Path(checkpoint_path)
if checkpoint_path.is_file():
if not checkpoint_path.suffix == ".safetensors":
raise ValueError(f"Checkpoint file must have a .safetensors extension: {checkpoint_path}")
return checkpoint_path
if checkpoint_path.is_dir():
# Look for checkpoint files in the directory
checkpoints = list(checkpoint_path.rglob("*step_*.safetensors"))
if not checkpoints:
return None
# Sort by step number and return the latest
def _get_step_num(p: Path) -> int:
try:
return int(p.stem.split("step_")[1])
except (IndexError, ValueError):
return -1
latest = max(checkpoints, key=_get_step_num)
return latest
else:
raise ValueError(f"Invalid checkpoint path: {checkpoint_path}. Must be a file or directory.")
def _init_dataloader(self) -> None:
"""Initialize the training data loader using the strategy's data sources."""
if self._dataset is None:
# Get data sources from the training strategy
data_sources = self._training_strategy.get_data_sources()
self._dataset = PrecomputedDataset(self._config.data.preprocessed_data_root, data_sources=data_sources)
logger.debug(f"Loaded dataset with {len(self._dataset):,} samples from sources: {list(data_sources)}")
num_workers = self._config.data.num_dataloader_workers
dataloader = DataLoader(
self._dataset,
batch_size=self._config.optimization.batch_size,
shuffle=True,
drop_last=True,
num_workers=num_workers,
pin_memory=num_workers > 0,
persistent_workers=num_workers > 0,
)
self._dataloader = self._accelerator.prepare(dataloader)
def _init_lora_weights(self) -> None:
"""Initialize LoRA weights for the transformer."""
logger.debug("Initializing LoRA weights...")
for _, module in self._transformer.named_modules():
if isinstance(module, (BaseTunerLayer, ModulesToSaveWrapper)):
module.reset_lora_parameters(adapter_name="default", init_lora_weights=True)
def _init_optimizer(self) -> None:
"""Initialize the optimizer and learning rate scheduler."""
opt_cfg = self._config.optimization
lr = opt_cfg.learning_rate
if opt_cfg.optimizer_type == "adamw":
optimizer = AdamW(self._trainable_params, lr=lr)
elif opt_cfg.optimizer_type == "adamw8bit":
# noinspection PyUnresolvedReferences
from bitsandbytes.optim import AdamW8bit # noqa: PLC0415
optimizer = AdamW8bit(self._trainable_params, lr=lr)
else:
raise ValueError(f"Unknown optimizer type: {opt_cfg.optimizer_type}")
# Add scheduler initialization
lr_scheduler = self._create_scheduler(optimizer)
# noinspection PyTypeChecker
self._optimizer, self._lr_scheduler = self._accelerator.prepare(optimizer, lr_scheduler)
def _create_scheduler(self, optimizer: torch.optim.Optimizer) -> LRScheduler | None:
"""Create learning rate scheduler based on config."""
scheduler_type = self._config.optimization.scheduler_type
steps = self._config.optimization.steps
params = self._config.optimization.scheduler_params or {}
if scheduler_type is None:
return None
if scheduler_type == "linear":
scheduler = LinearLR(
optimizer,
start_factor=params.pop("start_factor", 1.0),
end_factor=params.pop("end_factor", 0.1),
total_iters=steps,
**params,
)
elif scheduler_type == "cosine":
scheduler = CosineAnnealingLR(
optimizer,
T_max=steps,
eta_min=params.pop("eta_min", 0),
**params,
)
elif scheduler_type == "cosine_with_restarts":
scheduler = CosineAnnealingWarmRestarts(
optimizer,
T_0=params.pop("T_0", steps // 4), # First restart cycle length
T_mult=params.pop("T_mult", 1), # Multiplicative factor for cycle lengths
eta_min=params.pop("eta_min", 5e-5),
**params,
)
elif scheduler_type == "polynomial":
scheduler = PolynomialLR(
optimizer,
total_iters=steps,
power=params.pop("power", 1.0),
**params,
)
elif scheduler_type == "step":
scheduler = StepLR(
optimizer,
step_size=params.pop("step_size", steps // 2),
gamma=params.pop("gamma", 0.1),
**params,
)
elif scheduler_type == "constant":
scheduler = None
else:
raise ValueError(f"Unknown scheduler type: {scheduler_type}")
return scheduler
def _setup_accelerator(self) -> None:
"""Initialize the Accelerator with the appropriate settings."""
# All distributed setup (DDP/FSDP, number of processes, etc.) is controlled by
# the user's Accelerate configuration (accelerate config / accelerate launch).
self._accelerator = Accelerator(
mixed_precision=self._config.acceleration.mixed_precision_mode,
gradient_accumulation_steps=self._config.optimization.gradient_accumulation_steps,
)
if self._accelerator.num_processes > 1:
logger.info(
f"{self._accelerator.distributed_type.value} distributed training enabled "
f"with {self._accelerator.num_processes} processes"
)
local_batch = self._config.optimization.batch_size
global_batch = self._config.optimization.batch_size * self._accelerator.num_processes
logger.info(f"Local batch size: {local_batch}, global batch size: {global_batch}")
# Log torch.compile status from Accelerate's dynamo plugin
is_compile_enabled = (
hasattr(self._accelerator.state, "dynamo_plugin") and self._accelerator.state.dynamo_plugin.backend != "NO"
)
if is_compile_enabled:
plugin = self._accelerator.state.dynamo_plugin
logger.info(f"🔥 torch.compile enabled via Accelerate: backend={plugin.backend}, mode={plugin.mode}")
if self._accelerator.distributed_type == DistributedType.FSDP:
logger.warning(
"⚠️ FSDP + torch.compile is experimental and may hang on the first training iteration. "
"If this occurs, disable torch.compile by removing dynamo_config from your Accelerate config."
)
if self._accelerator.distributed_type == DistributedType.FSDP and self._config.acceleration.quantization:
logger.warning(
f"FSDP with quantization ({self._config.acceleration.quantization}) may have compatibility issues."
"Monitor training stability and consider disabling quantization if issues arise."
)
# Note: Use @torch.no_grad() instead of @torch.inference_mode() to avoid FSDP inplace update errors after validation
@torch.no_grad()
def _sample_videos(self, progress: TrainingProgress) -> list[Path] | None:
"""Run validation by generating videos from validation prompts."""
use_images = self._config.validation.images is not None
use_reference_videos = self._config.validation.reference_videos is not None
generate_audio = self._config.validation.generate_audio
inference_steps = self._config.validation.inference_steps
# Free up GPU memory before validation sampling.
# Zero gradients and empty the cache to reclaim memory.
self._optimizer.zero_grad(set_to_none=True)
torch.cuda.empty_cache()
# Start sampling progress tracking
sampling_ctx = progress.start_sampling(
num_prompts=len(self._config.validation.prompts),
num_steps=inference_steps,
)
# Create validation sampler with loaded models and progress tracking
sampler = ValidationSampler(
transformer=self._transformer,
vae_decoder=self._vae_decoder,
vae_encoder=self._vae_encoder,
text_encoder=None,
audio_decoder=self._audio_vae if generate_audio else None,
vocoder=self._vocoder if generate_audio else None,
sampling_context=sampling_ctx,
)
output_dir = Path(self._config.output_dir) / "samples"
output_dir.mkdir(exist_ok=True, parents=True)
video_paths = []
width, height, num_frames = self._config.validation.video_dims
for prompt_idx, prompt in enumerate(self._config.validation.prompts):
# Update progress to show current video
sampling_ctx.start_video(prompt_idx)
# Load conditioning image if provided
condition_image = None
if use_images:
image_path = self._config.validation.images[prompt_idx]
image = open_image_as_srgb(image_path)
# Convert PIL image to tensor [C, H, W] in [0, 1]
condition_image = F.to_tensor(image)
# Load reference video if provided (for IC-LoRA)
reference_video = None
if use_reference_videos:
ref_video_path = self._config.validation.reference_videos[prompt_idx]
# read_video returns [F, C, H, W] in [0, 1]
reference_video, _ = read_video(ref_video_path, max_frames=num_frames)
# Get cached embeddings for this prompt if available
cached_embeddings = (
self._cached_validation_embeddings[prompt_idx]
if self._cached_validation_embeddings is not None
else None
)
# Create generation config
gen_config = GenerationConfig(
prompt=prompt,
negative_prompt=self._config.validation.negative_prompt,
height=height,
width=width,
num_frames=num_frames,
frame_rate=self._config.validation.frame_rate,
num_inference_steps=inference_steps,
guidance_scale=self._config.validation.guidance_scale,
seed=self._config.validation.seed,
condition_image=condition_image,
reference_video=reference_video,
generate_audio=generate_audio,
include_reference_in_output=self._config.validation.include_reference_in_output,
cached_embeddings=cached_embeddings,
stg_scale=self._config.validation.stg_scale,
stg_blocks=self._config.validation.stg_blocks,
stg_mode=self._config.validation.stg_mode,
)
# Generate sample
video, audio = sampler.generate(
config=gen_config,
device=self._accelerator.device,
)
# Save output (image for single frame, video otherwise)
if IS_MAIN_PROCESS:
ext = "png" if num_frames == 1 else "mp4"
output_path = output_dir / f"step_{self._global_step:06d}_{prompt_idx + 1}.{ext}"
if num_frames == 1:
save_image(video, output_path)
else:
save_video(
video_tensor=video,
output_path=output_path,
fps=self._config.validation.frame_rate,
audio=audio,
audio_sample_rate=self._vocoder.output_sample_rate if audio is not None else None,
)
video_paths.append(output_path)
# Clean up progress tasks
sampling_ctx.cleanup()
# Clear GPU cache after validation
torch.cuda.empty_cache()
rel_outputs_path = output_dir.relative_to(self._config.output_dir)
logger.info(f"🎥 Validation samples for step {self._global_step} saved in {rel_outputs_path}")
return video_paths
@staticmethod
def _log_training_stats(stats: TrainingStats) -> None:
"""Log training statistics."""
stats_str = (
"📊 Training Statistics:\n"
f" - Total time: {stats.total_time_seconds / 60:.1f} minutes\n"
f" - Training speed: {stats.steps_per_second:.2f} steps/second\n"
f" - Samples/second: {stats.samples_per_second:.2f}\n"
f" - Peak GPU memory: {stats.peak_gpu_memory_gb:.2f} GB"
)
if stats.num_processes > 1:
stats_str += f"\n - Number of processes: {stats.num_processes}\n"
stats_str += f" - Global batch size: {stats.global_batch_size}"
logger.info(stats_str)
def _save_checkpoint(self) -> Path | None:
"""Save the model weights."""
is_lora = self._config.model.training_mode == "lora"
is_fsdp = self._accelerator.distributed_type == DistributedType.FSDP
# Prepare paths
save_dir = Path(self._config.output_dir) / "checkpoints"
prefix = "lora" if is_lora else "model"
filename = f"{prefix}_weights_step_{self._global_step:05d}.safetensors"
saved_weights_path = save_dir / filename
# Get state dict (collective operation - all processes must participate)
self._accelerator.wait_for_everyone()
full_state_dict = self._accelerator.get_state_dict(self._transformer)
if not IS_MAIN_PROCESS:
return None
save_dir.mkdir(exist_ok=True, parents=True)
# For LoRA: extract only adapter weights; for full: use as-is
if is_lora:
unwrapped = self._accelerator.unwrap_model(self._transformer, keep_torch_compile=False)
# For FSDP, pass full_state_dict since model params aren't directly accessible
state_dict = get_peft_model_state_dict(unwrapped, state_dict=full_state_dict if is_fsdp else None)
# Remove "base_model.model." prefix added by PEFT
state_dict = {k.replace("base_model.model.", "", 1): v for k, v in state_dict.items()}
# Convert to ComfyUI-compatible format (add "diffusion_model." prefix)
state_dict = {f"diffusion_model.{k}": v for k, v in state_dict.items()}
# Save to disk
save_file(state_dict, saved_weights_path)
else:
# Save to disk
self._accelerator.save(full_state_dict, saved_weights_path)
rel_path = saved_weights_path.relative_to(self._config.output_dir)
logger.info(f"💾 {prefix.capitalize()} weights for step {self._global_step} saved in {rel_path}")
# Keep track of checkpoint paths, and cleanup old checkpoints if needed
self._checkpoint_paths.append(saved_weights_path)
self._cleanup_checkpoints()
return saved_weights_path
def _cleanup_checkpoints(self) -> None:
"""Clean up old checkpoints."""
if 0 < self._config.checkpoints.keep_last_n < len(self._checkpoint_paths):
checkpoints_to_remove = self._checkpoint_paths[: -self._config.checkpoints.keep_last_n]
for old_checkpoint in checkpoints_to_remove:
if old_checkpoint.exists():
old_checkpoint.unlink()
logger.info(f"Removed old checkpoints: {old_checkpoint}")
# Update the list to only contain kept checkpoints
self._checkpoint_paths = self._checkpoint_paths[-self._config.checkpoints.keep_last_n :]
def _save_config(self) -> None:
"""Save the training configuration as a YAML file in the output directory."""
if not IS_MAIN_PROCESS:
return
config_path = Path(self._config.output_dir) / "training_config.yaml"
with open(config_path, "w") as f:
yaml.dump(self._config.model_dump(), f, default_flow_style=False, indent=2)
logger.info(f"💾 Training configuration saved to: {config_path.relative_to(self._config.output_dir)}")
def _init_wandb(self) -> None:
"""Initialize Weights & Biases run."""
if not self._config.wandb.enabled or not IS_MAIN_PROCESS:
self._wandb_run = None
return
wandb_config = self._config.wandb
run = wandb.init(
project=wandb_config.project,
entity=wandb_config.entity,
name=Path(self._config.output_dir).name,
tags=wandb_config.tags,
config=self._config.model_dump(),
)
self._wandb_run = run
def _log_metrics(self, metrics: dict[str, float]) -> None:
"""Log metrics to Weights & Biases."""
if self._wandb_run is not None:
self._wandb_run.log(metrics)
def _log_validation_samples(self, sample_paths: list[Path], prompts: list[str]) -> None:
"""Log validation samples (videos or images) to Weights & Biases."""
if not self._config.wandb.log_validation_videos or self._wandb_run is None:
return
# Determine if outputs are images or videos based on file extension
is_image = sample_paths and sample_paths[0].suffix.lower() in (".png", ".jpg", ".jpeg", ".heic", ".webp")
media_cls = wandb.Image if is_image else wandb.Video
samples = [media_cls(str(path), caption=prompt) for path, prompt in zip(sample_paths, prompts, strict=True)]
self._wandb_run.log({"validation_samples": samples}, step=self._global_step)
@@ -0,0 +1,58 @@
"""Training strategies for different conditioning modes.
This package implements the Strategy Pattern to handle different training modes:
- Text-to-video training (standard generation, optionally with audio)
- Video-to-video training (IC-LoRA mode with reference videos)
Each strategy encapsulates the specific logic for preparing model inputs and computing loss.
"""
from ltx_trainer import logger
from ltx_trainer.training_strategies.base_strategy import (
DEFAULT_FPS,
VIDEO_SCALE_FACTORS,
ModelInputs,
TrainingStrategy,
TrainingStrategyConfigBase,
)
from ltx_trainer.training_strategies.text_to_video import TextToVideoConfig, TextToVideoStrategy
from ltx_trainer.training_strategies.video_to_video import VideoToVideoConfig, VideoToVideoStrategy
# Type alias for all strategy config types
TrainingStrategyConfig = TextToVideoConfig | VideoToVideoConfig
__all__ = [
"DEFAULT_FPS",
"VIDEO_SCALE_FACTORS",
"ModelInputs",
"TextToVideoConfig",
"TextToVideoStrategy",
"TrainingStrategy",
"TrainingStrategyConfig",
"TrainingStrategyConfigBase",
"VideoToVideoConfig",
"VideoToVideoStrategy",
"get_training_strategy",
]
def get_training_strategy(config: TrainingStrategyConfig) -> TrainingStrategy:
"""Factory function to create the appropriate training strategy.
The strategy is determined by the `name` field in the configuration.
Args:
config: Strategy-specific configuration with a `name` field
Returns:
The appropriate training strategy instance
Raises:
ValueError: If strategy name is not supported
"""
match config:
case TextToVideoConfig():
strategy = TextToVideoStrategy(config)
case VideoToVideoConfig():
strategy = VideoToVideoStrategy(config)
case _:
raise ValueError(f"Unknown training strategy config type: {type(config).__name__}")
audio_mode = "(audio enabled 🔈)" if getattr(config, "with_audio", False) else "(audio disabled 🔇)"
logger.debug(f"🎯 Using {strategy.__class__.__name__} training strategy {audio_mode}")
return strategy
@@ -0,0 +1,253 @@
"""Base class for training strategies.
This module defines the abstract base class that all training strategies must implement,
along with the base configuration class.
"""
import random
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Literal
import torch
from pydantic import BaseModel, ConfigDict, Field
from torch import Tensor
from ltx_core.components.patchifiers import (
AudioPatchifier,
VideoLatentPatchifier,
get_pixel_coords,
)
from ltx_core.model.transformer.modality import Modality
from ltx_core.types import AudioLatentShape, SpatioTemporalScaleFactors, VideoLatentShape
from ltx_trainer.timestep_samplers import TimestepSampler
# Default frames per second for video missing in the FPS metadata
DEFAULT_FPS = 24
# VAE scale factors for LTX-2
VIDEO_SCALE_FACTORS = SpatioTemporalScaleFactors.default()
class TrainingStrategyConfigBase(BaseModel):
"""Base configuration class for training strategies.
All strategy-specific configuration classes should inherit from this.
"""
model_config = ConfigDict(extra="forbid")
name: Literal["text_to_video", "video_to_video"] = Field(
description="Unique name identifying the training strategy type"
)
@dataclass
class ModelInputs:
"""Container for model inputs using the Modality-based interface."""
video: Modality
audio: Modality | None
# Training targets (for loss computation)
video_targets: Tensor
audio_targets: Tensor | None
# Masks for loss computation
video_loss_mask: Tensor # Boolean mask: True = compute loss for this token
audio_loss_mask: Tensor | None
# Metadata needed for loss computation in some strategies
ref_seq_len: int | None = None # For IC-LoRA: length of reference sequence
class TrainingStrategy(ABC):
"""Abstract base class for training strategies.
Each strategy encapsulates the logic for a specific training mode,
handling input preparation and loss computation.
"""
def __init__(self, config: TrainingStrategyConfigBase):
"""Initialize strategy with configuration.
Args:
config: Strategy-specific configuration
"""
self.config = config
self._video_patchifier = VideoLatentPatchifier(patch_size=1)
self._audio_patchifier = AudioPatchifier(patch_size=1)
@property
def requires_audio(self) -> bool:
"""Whether this training strategy requires audio components.
Override this property in subclasses that support audio training.
The trainer uses this to determine whether to load audio VAE and vocoder.
Returns:
True if audio components should be loaded, False otherwise.
"""
return False
@abstractmethod
def get_data_sources(self) -> list[str] | dict[str, str]:
"""Get the required data sources for this training strategy.
Returns:
Either a list of data directory names (where output keys match directory names)
or a dictionary mapping data directory names to custom output keys for the dataset
"""
@abstractmethod
def prepare_training_inputs(
self,
batch: dict[str, Any],
timestep_sampler: TimestepSampler,
) -> ModelInputs:
"""Prepare training inputs from a raw data batch.
Args:
batch: Raw batch data from the dataset. Contains:
- "latents": Video latent data
- "conditions": Text embeddings with keys:
- "video_prompt_embeds": Already processed by embedding connectors
- "audio_prompt_embeds": Already processed by embedding connectors
- "prompt_attention_mask": Attention mask
- Additional keys depending on strategy (e.g., "ref_latents" for IC-LoRA)
timestep_sampler: Sampler for generating timesteps and noise
Returns:
ModelInputs containing Modality objects and training targets
"""
@abstractmethod
def compute_loss(
self,
video_pred: Tensor,
audio_pred: Tensor | None,
inputs: ModelInputs,
) -> Tensor:
"""Compute the training loss.
Args:
video_pred: Video prediction from the transformer model
audio_pred: Audio prediction from the transformer model (None for video-only)
inputs: The prepared model inputs containing targets and masks
Returns:
Scalar loss tensor
"""
def _get_video_positions(
self,
num_frames: int,
height: int,
width: int,
batch_size: int,
fps: float,
device: torch.device,
dtype: torch.dtype,
) -> Tensor:
"""Generate video position embeddings using ltx_core's native implementation.
Args:
num_frames: Number of latent frames
height: Latent height
width: Latent width
batch_size: Batch size
fps: Frames per second
device: Target device
dtype: Target dtype
Returns:
Position tensor of shape [B, 3, seq_len, 2]
"""
latent_coords = self._video_patchifier.get_patch_grid_bounds(
output_shape=VideoLatentShape(
frames=num_frames,
height=height,
width=width,
batch=batch_size,
channels=128, # Video latent channels
),
device=device,
)
# Convert latent coords to pixel coords with causal fix
pixel_coords = get_pixel_coords(
latent_coords=latent_coords,
scale_factors=VIDEO_SCALE_FACTORS,
causal_fix=True,
).to(dtype)
# Scale temporal dimension by 1/fps to get time in seconds
pixel_coords[:, 0, ...] = pixel_coords[:, 0, ...] / fps
return pixel_coords
def _get_audio_positions(
self,
num_time_steps: int,
batch_size: int,
device: torch.device,
dtype: torch.dtype,
) -> Tensor:
"""Generate audio position embeddings using ltx_core's native implementation.
Args:
num_time_steps: Number of audio time steps (T, not T*mel_bins)
batch_size: Batch size
device: Target device
dtype: Target dtype
Returns:
Position tensor of shape [B, 1, num_time_steps, 2]
Note:
Audio latents should be in patchified format [B, T, C*F] = [B, T, 128]
where T is the number of time steps, C=8 channels, F=16 mel bins.
This matches the format produced by AudioPatchifier.patchify().
"""
mel_bins = 16
latent_coords = self._audio_patchifier.get_patch_grid_bounds(
output_shape=AudioLatentShape(
frames=num_time_steps,
mel_bins=mel_bins,
batch=batch_size,
channels=8, # Audio latent channels
),
device=device,
)
return latent_coords.to(dtype)
@staticmethod
def _create_per_token_timesteps(conditioning_mask: Tensor, sampled_sigma: Tensor) -> Tensor:
"""Create per-token timesteps based on conditioning mask.
Args:
conditioning_mask: Boolean mask of shape (batch_size, sequence_length),
where True = conditioning token (timestep=0), False = target token (use sigma)
sampled_sigma: Sampled sigma values of shape (batch_size,) or (batch_size, 1, 1)
Returns:
Timesteps tensor of shape [batch_size, sequence_length]
"""
# Expand to match conditioning mask shape [B, seq_len]
expanded_sigma = sampled_sigma.view(-1, 1).expand_as(conditioning_mask)
# Conditioning tokens get 0, target tokens get the sampled sigma
return torch.where(conditioning_mask, torch.zeros_like(expanded_sigma), expanded_sigma)
@staticmethod
def _create_first_frame_conditioning_mask(
batch_size: int,
sequence_length: int,
height: int,
width: int,
device: torch.device,
first_frame_conditioning_p: float = 0.0,
) -> Tensor:
"""Create conditioning mask for first frame conditioning.
Args:
batch_size: Batch size
sequence_length: Total sequence length
height: Latent height
width: Latent width
device: Target device
first_frame_conditioning_p: Probability of conditioning on the first frame
Returns:
Boolean mask where True indicates first frame tokens (if conditioning is enabled)
"""
conditioning_mask = torch.zeros(batch_size, sequence_length, dtype=torch.bool, device=device)
if first_frame_conditioning_p > 0 and random.random() < first_frame_conditioning_p:
first_frame_end_idx = height * width
if first_frame_end_idx < sequence_length:
conditioning_mask[:, :first_frame_end_idx] = True
return conditioning_mask
@@ -0,0 +1,289 @@
"""Text-to-video training strategy.
This strategy implements standard text-to-video generation training where:
- Only target latents are used (no reference videos)
- Standard noise application and loss computation
- Supports first frame conditioning
- Optionally supports joint audio-video training
"""
from typing import Any, Literal
import torch
from pydantic import Field
from torch import Tensor
from ltx_core.model.transformer.modality import Modality
from ltx_trainer import logger
from ltx_trainer.timestep_samplers import TimestepSampler
from ltx_trainer.training_strategies.base_strategy import (
DEFAULT_FPS,
ModelInputs,
TrainingStrategy,
TrainingStrategyConfigBase,
)
class TextToVideoConfig(TrainingStrategyConfigBase):
"""Configuration for text-to-video training strategy."""
name: Literal["text_to_video"] = "text_to_video"
first_frame_conditioning_p: float = Field(
default=0.1,
description="Probability of conditioning on the first frame during training",
ge=0.0,
le=1.0,
)
with_audio: bool = Field(
default=False,
description="Whether to include audio in training (joint audio-video generation)",
)
audio_latents_dir: str = Field(
default="audio_latents",
description="Directory name for audio latents when with_audio is True",
)
class TextToVideoStrategy(TrainingStrategy):
"""Text-to-video training strategy.
This strategy implements regular video generation training where:
- Only target latents are used (no reference videos)
- Standard noise application and loss computation
- Supports first frame conditioning
- Optionally supports joint audio-video training when with_audio=True
"""
config: TextToVideoConfig
def __init__(self, config: TextToVideoConfig):
"""Initialize strategy with configuration.
Args:
config: Text-to-video configuration
"""
super().__init__(config)
@property
def requires_audio(self) -> bool:
"""Whether this training strategy requires audio components."""
return self.config.with_audio
def get_data_sources(self) -> list[str] | dict[str, str]:
"""
Text-to-video training requires latents and text conditions.
When with_audio is True, also requires audio latents.
"""
sources = {
"latents": "latents",
"conditions": "conditions",
}
if self.config.with_audio:
sources[self.config.audio_latents_dir] = "audio_latents"
return sources
def prepare_training_inputs(
self,
batch: dict[str, Any],
timestep_sampler: TimestepSampler,
) -> ModelInputs:
"""Prepare inputs for text-to-video training."""
# Get pre-encoded latents - dataset provides uniform non-patchified format [B, C, F, H, W]
latents = batch["latents"]
video_latents = latents["latents"]
# Get video dimensions (assume same for all batch elements)
num_frames = latents["num_frames"][0].item()
height = latents["height"][0].item()
width = latents["width"][0].item()
# Patchify latents: [B, C, F, H, W] -> [B, seq_len, C]
video_latents = self._video_patchifier.patchify(video_latents)
# Handle FPS with backward compatibility
fps = latents.get("fps", None)
if fps is not None and not torch.all(fps == fps[0]):
logger.warning(
f"Different FPS values found in the batch. Found: {fps.tolist()}, using the first one: {fps[0].item()}"
)
fps = fps[0].item() if fps is not None else DEFAULT_FPS
# Get text embeddings (already processed by embedding connectors in trainer)
conditions = batch["conditions"]
video_prompt_embeds = conditions["video_prompt_embeds"]
audio_prompt_embeds = conditions["audio_prompt_embeds"]
prompt_attention_mask = conditions["prompt_attention_mask"]
batch_size = video_latents.shape[0]
video_seq_len = video_latents.shape[1]
device = video_latents.device
dtype = video_latents.dtype
# Create conditioning mask (first frame conditioning)
video_conditioning_mask = self._create_first_frame_conditioning_mask(
batch_size=batch_size,
sequence_length=video_seq_len,
height=height,
width=width,
device=device,
first_frame_conditioning_p=self.config.first_frame_conditioning_p,
)
# Sample noise and sigmas
sigmas = timestep_sampler.sample_for(video_latents)
video_noise = torch.randn_like(video_latents)
# Apply noise: noisy = (1 - sigma) * clean + sigma * noise
sigmas_expanded = sigmas.view(-1, 1, 1)
noisy_video = (1 - sigmas_expanded) * video_latents + sigmas_expanded * video_noise
# For conditioning tokens, use clean latents
conditioning_mask_expanded = video_conditioning_mask.unsqueeze(-1)
noisy_video = torch.where(conditioning_mask_expanded, video_latents, noisy_video)
# Compute video targets (velocity prediction)
video_targets = video_noise - video_latents
# Create per-token timesteps
video_timesteps = self._create_per_token_timesteps(video_conditioning_mask, sigmas.squeeze())
# Generate video positions using ltx_core's native implementation
video_positions = self._get_video_positions(
num_frames=num_frames,
height=height,
width=width,
batch_size=batch_size,
fps=fps,
device=device,
dtype=dtype,
)
# Create video Modality
video_modality = Modality(
enabled=True,
latent=noisy_video,
timesteps=video_timesteps,
positions=video_positions,
context=video_prompt_embeds,
context_mask=prompt_attention_mask,
)
# Video loss mask: True for tokens we want to compute loss on (non-conditioning tokens)
video_loss_mask = ~video_conditioning_mask
# Handle audio if enabled
audio_modality = None
audio_targets = None
audio_loss_mask = None
if self.config.with_audio:
audio_modality, audio_targets, audio_loss_mask = self._prepare_audio_inputs(
batch=batch,
sigmas=sigmas,
audio_prompt_embeds=audio_prompt_embeds,
prompt_attention_mask=prompt_attention_mask,
batch_size=batch_size,
device=device,
dtype=dtype,
)
return ModelInputs(
video=video_modality,
audio=audio_modality,
video_targets=video_targets,
audio_targets=audio_targets,
video_loss_mask=video_loss_mask,
audio_loss_mask=audio_loss_mask,
)
def _prepare_audio_inputs(
self,
batch: dict[str, Any],
sigmas: Tensor,
audio_prompt_embeds: Tensor,
prompt_attention_mask: Tensor,
batch_size: int,
device: torch.device,
dtype: torch.dtype,
) -> tuple[Modality, Tensor, Tensor]:
"""Prepare audio inputs for joint audio-video training.
Args:
batch: Raw batch data containing audio_latents
sigmas: Sampled sigma values (same as video)
audio_prompt_embeds: Audio context embeddings
prompt_attention_mask: Attention mask for context
batch_size: Batch size
device: Target device
dtype: Target dtype
Returns:
Tuple of (audio_modality, audio_targets, audio_loss_mask)
"""
# Get audio latents - dataset provides uniform non-patchified format [B, C, T, F]
audio_data = batch["audio_latents"]
audio_latents = audio_data["latents"]
# Patchify audio latents: [B, C, T, F] -> [B, T, C*F]
audio_latents = self._audio_patchifier.patchify(audio_latents)
audio_seq_len = audio_latents.shape[1]
# Sample audio noise
audio_noise = torch.randn_like(audio_latents)
# Apply noise to audio (same sigma as video)
sigmas_expanded = sigmas.view(-1, 1, 1)
noisy_audio = (1 - sigmas_expanded) * audio_latents + sigmas_expanded * audio_noise
# Compute audio targets
audio_targets = audio_noise - audio_latents
# Audio timesteps: all tokens use the sampled sigma (no conditioning mask)
audio_timesteps = sigmas.view(-1, 1).expand(-1, audio_seq_len)
# Generate audio positions
audio_positions = self._get_audio_positions(
num_time_steps=audio_seq_len,
batch_size=batch_size,
device=device,
dtype=dtype,
)
# Create audio Modality
audio_modality = Modality(
enabled=True,
latent=noisy_audio,
timesteps=audio_timesteps,
positions=audio_positions,
context=audio_prompt_embeds,
context_mask=prompt_attention_mask,
)
# Audio loss mask: all tokens contribute to loss (no conditioning)
audio_loss_mask = torch.ones(batch_size, audio_seq_len, dtype=torch.bool, device=device)
return audio_modality, audio_targets, audio_loss_mask
def compute_loss(
self,
video_pred: Tensor,
audio_pred: Tensor | None,
inputs: ModelInputs,
) -> Tensor:
"""Compute masked MSE loss for video and optionally audio."""
# Video loss
video_loss = (video_pred - inputs.video_targets).pow(2)
video_loss_mask = inputs.video_loss_mask.unsqueeze(-1).float()
video_loss = video_loss.mul(video_loss_mask).div(video_loss_mask.mean())
video_loss = video_loss.mean()
# If no audio, return video loss only
if not self.config.with_audio or audio_pred is None or inputs.audio_targets is None:
return video_loss
# Audio loss (no conditioning mask)
audio_loss = (audio_pred - inputs.audio_targets).pow(2).mean()
# Combined loss
return video_loss + audio_loss
@@ -0,0 +1,223 @@
"""Video-to-video training strategy for IC-LoRA.
This strategy implements training with reference video conditioning where:
- Reference latents (clean) are concatenated with target latents (noised)
- Video coordinates handle both reference and target sequences
- Loss is computed only on the target portion
"""
from typing import Any, Literal
import torch
from pydantic import Field
from torch import Tensor
from ltx_core.model.transformer.modality import Modality
from ltx_trainer import logger
from ltx_trainer.timestep_samplers import TimestepSampler
from ltx_trainer.training_strategies.base_strategy import (
DEFAULT_FPS,
ModelInputs,
TrainingStrategy,
TrainingStrategyConfigBase,
)
class VideoToVideoConfig(TrainingStrategyConfigBase):
"""Configuration for video-to-video (IC-LoRA) training strategy."""
name: Literal["video_to_video"] = "video_to_video"
first_frame_conditioning_p: float = Field(
default=0.1,
description="Probability of conditioning on the first frame during training",
ge=0.0,
le=1.0,
)
reference_latents_dir: str = Field(
default="reference_latents",
description="Directory name for latents of reference videos",
)
class VideoToVideoStrategy(TrainingStrategy):
"""Video-to-video training strategy for IC-LoRA.
This strategy implements training with reference video conditioning where:
- Reference latents (clean) are concatenated with target latents (noised)
- Video coordinates handle both reference and target sequences
- Loss is computed only on the target portion
"""
config: VideoToVideoConfig
def __init__(self, config: VideoToVideoConfig):
"""Initialize strategy with configuration.
Args:
config: Video-to-video configuration
"""
super().__init__(config)
def get_data_sources(self) -> dict[str, str]:
"""IC-LoRA training requires latents, conditions, and reference latents."""
return {
"latents": "latents",
"conditions": "conditions",
self.config.reference_latents_dir: "ref_latents",
}
def prepare_training_inputs(
self,
batch: dict[str, Any],
timestep_sampler: TimestepSampler,
) -> ModelInputs:
"""Prepare inputs for IC-LoRA training with reference videos."""
# Get pre-encoded latents - dataset provides uniform non-patchified format [B, C, F, H, W]
latents = batch["latents"]
target_latents = latents["latents"]
ref_latents = batch["ref_latents"]["latents"]
# Get dimensions
num_frames = latents["num_frames"][0].item()
height = latents["height"][0].item()
width = latents["width"][0].item()
ref_latents_info = batch["ref_latents"]
ref_frames = ref_latents_info["num_frames"][0].item()
ref_height = ref_latents_info["height"][0].item()
ref_width = ref_latents_info["width"][0].item()
# Patchify latents: [B, C, F, H, W] -> [B, seq_len, C]
target_latents = self._video_patchifier.patchify(target_latents)
ref_latents = self._video_patchifier.patchify(ref_latents)
# Handle FPS
fps = latents.get("fps", None)
if fps is not None and not torch.all(fps == fps[0]):
logger.warning(
f"Different FPS values found in the batch. Found: {fps.tolist()}, using the first one: {fps[0].item()}"
)
fps = fps[0].item() if fps is not None else DEFAULT_FPS
# Get text embeddings (already processed by embedding connectors in trainer)
# Video-to-video uses only video embeddings
conditions = batch["conditions"]
prompt_embeds = conditions["video_prompt_embeds"]
prompt_attention_mask = conditions["prompt_attention_mask"]
batch_size = target_latents.shape[0]
ref_seq_len = ref_latents.shape[1]
target_seq_len = target_latents.shape[1]
device = target_latents.device
dtype = target_latents.dtype
# Create conditioning mask
# Reference tokens are always conditioning (timestep=0)
ref_conditioning_mask = torch.ones(batch_size, ref_seq_len, dtype=torch.bool, device=device)
# Target tokens: check for first frame conditioning
target_conditioning_mask = self._create_first_frame_conditioning_mask(
batch_size=batch_size,
sequence_length=target_seq_len,
height=height,
width=width,
device=device,
first_frame_conditioning_p=self.config.first_frame_conditioning_p,
)
# Combined conditioning mask
conditioning_mask = torch.cat([ref_conditioning_mask, target_conditioning_mask], dim=1)
# Sample noise and sigmas for target
sigmas = timestep_sampler.sample_for(target_latents)
noise = torch.randn_like(target_latents)
sigmas_expanded = sigmas.view(-1, 1, 1)
# Apply noise to target
noisy_target = (1 - sigmas_expanded) * target_latents + sigmas_expanded * noise
# For first frame conditioning in target, use clean latents
target_conditioning_mask_expanded = target_conditioning_mask.unsqueeze(-1)
noisy_target = torch.where(target_conditioning_mask_expanded, target_latents, noisy_target)
# Targets for loss computation
targets = noise - target_latents
# Concatenate reference (clean) and target (noisy)
combined_latents = torch.cat([ref_latents, noisy_target], dim=1)
# Create per-token timesteps
timesteps = self._create_per_token_timesteps(conditioning_mask, sigmas.squeeze())
# Generate positions for reference and target separately, then concatenate
ref_positions = self._get_video_positions(
num_frames=ref_frames,
height=ref_height,
width=ref_width,
batch_size=batch_size,
fps=fps,
device=device,
dtype=dtype,
)
target_positions = self._get_video_positions(
num_frames=num_frames,
height=height,
width=width,
batch_size=batch_size,
fps=fps,
device=device,
dtype=dtype,
)
# Concatenate positions along sequence dimension
positions = torch.cat([ref_positions, target_positions], dim=2)
# Create video Modality
video_modality = Modality(
enabled=True,
latent=combined_latents,
timesteps=timesteps,
positions=positions,
context=prompt_embeds,
context_mask=prompt_attention_mask,
)
# Loss mask: only compute loss on non-conditioning target tokens
# Reference tokens: all False (no loss)
# Target tokens: True where not conditioning
ref_loss_mask = torch.zeros(batch_size, ref_seq_len, dtype=torch.bool, device=device)
target_loss_mask = ~target_conditioning_mask
video_loss_mask = torch.cat([ref_loss_mask, target_loss_mask], dim=1)
return ModelInputs(
video=video_modality,
audio=None,
video_targets=targets,
audio_targets=None,
video_loss_mask=video_loss_mask,
audio_loss_mask=None,
ref_seq_len=ref_seq_len,
)
def compute_loss(
self,
video_pred: Tensor,
_audio_pred: Tensor | None,
inputs: ModelInputs,
) -> Tensor:
"""Compute masked loss only on target portion."""
# Extract target portion of prediction
ref_seq_len = inputs.ref_seq_len
target_pred = video_pred[:, ref_seq_len:, :]
# Get target portion of loss mask
target_loss_mask = inputs.video_loss_mask[:, ref_seq_len:]
# Compute loss
loss = (target_pred - inputs.video_targets).pow(2)
# Apply loss mask
loss_mask = target_loss_mask.unsqueeze(-1).float()
loss = loss.mul(loss_mask).div(loss_mask.mean())
return loss.mean()
@@ -0,0 +1,118 @@
import io
import subprocess
from pathlib import Path
import numpy as np
import torch
from PIL import ExifTags, Image, ImageCms, ImageOps
from PIL.Image import Image as PilImage
from ltx_trainer import logger
def get_gpu_memory_gb(device: torch.device) -> float:
"""
Get current GPU memory usage in GB using nvidia-smi
Args:
device: torch.device to get memory usage for
Returns:
Current GPU memory usage in GB
"""
try:
device_id = device.index if device.index is not None else 0
result = subprocess.check_output(
[
"nvidia-smi",
"--query-gpu=memory.used",
"--format=csv,nounits,noheader",
"-i",
str(device_id),
],
encoding="utf-8",
)
return float(result.strip()) / 1024 # Convert MB to GB
except (subprocess.CalledProcessError, FileNotFoundError, ValueError) as e:
logger.error(f"Failed to get GPU memory from nvidia-smi: {e}")
# Fallback to torch
return torch.cuda.memory_allocated(device) / 1024**3
def open_image_as_srgb(image_path: str | Path | io.BytesIO) -> PilImage:
"""
Opens an image file, applies rotation (if it's set in metadata) and converts it
to the sRGB color space respecting the original image color space .
Args:
image_path: Path to the image file
Returns:
PIL Image in sRGB color space
"""
exif_colorspace_srgb = 1
with Image.open(image_path) as img_raw:
img = ImageOps.exif_transpose(img_raw)
input_icc_profile = img.info.get("icc_profile")
# Try to convert to sRGB if the image has ICC profile metadata
srgb_profile = ImageCms.createProfile(colorSpace="sRGB")
if input_icc_profile is not None:
input_profile = ImageCms.ImageCmsProfile(io.BytesIO(input_icc_profile))
srgb_img = ImageCms.profileToProfile(img, input_profile, srgb_profile, outputMode="RGB")
else:
# Try fall back to checking EXIF
exif_data = img.getexif()
if exif_data is not None:
# Assume sRGB if no ICC profile and EXIF has no ColorSpace tag
color_space_value = exif_data.get(ExifTags.Base.ColorSpace.value)
if color_space_value is not None and color_space_value != exif_colorspace_srgb:
raise ValueError(
"Image has colorspace tag in EXIF but it isn't set to sRGB,"
" conversion is not supported."
f" EXIF ColorSpace tag value is {color_space_value}",
)
srgb_img = img.convert("RGB")
# Set sRGB profile in metadata since now the image is assumed to be in sRGB.
srgb_profile_data = ImageCms.ImageCmsProfile(srgb_profile).tobytes()
srgb_img.info["icc_profile"] = srgb_profile_data
return srgb_img
def save_image(image_tensor: torch.Tensor, output_path: Path | str) -> None:
"""Save an image tensor to a file.
Args:
image_tensor: Image tensor of shape [C, H, W] or [C, 1, H, W] in range [0, 1] or [0, 255].
C must be 3 (RGB).
output_path: Path to save the image (any PIL-supported format, e.g., .png or .jpg)
"""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Handle [C, 1, H, W] format (single frame from video tensor)
if image_tensor.ndim == 4:
# Squeeze frame dimension: [C, 1, H, W] -> [C, H, W]
if image_tensor.shape[1] == 1:
image_tensor = image_tensor.squeeze(1)
else:
raise ValueError(f"Expected single-frame tensor with shape [C, 1, H, W], got shape {image_tensor.shape}")
if image_tensor.ndim != 3:
raise ValueError(f"Expected 3D tensor [C, H, W], got {image_tensor.ndim}D tensor")
if image_tensor.shape[0] != 3:
raise ValueError(f"Expected 3 channels (RGB), got {image_tensor.shape[0]} channels")
# Normalize to [0, 255] uint8
if torch.is_floating_point(image_tensor) and image_tensor.max() <= 1.0:
image_tensor = image_tensor * 255
# Clamp to valid uint8 range to prevent overflow
image_tensor = image_tensor.clamp(0, 255)
# [C, H, W] -> [H, W, C]
image_np: np.ndarray = image_tensor.permute(1, 2, 0).to(torch.uint8).cpu().numpy()
# Save using PIL
Image.fromarray(image_np).save(output_path)
@@ -0,0 +1,817 @@
"""Validation sampling for LTX-2 training using ltx-core components.
This module provides a simplified validation pipeline for generating samples during training,
using the new ltx-core components (VideoLatentTools, AudioLatentTools, LatentState, etc.).
"""
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Literal
import torch
from einops import rearrange
from torch import Tensor
from ltx_core.components.diffusion_steps import EulerDiffusionStep
from ltx_core.components.guiders import CFGGuider, STGGuider
from ltx_core.components.noisers import GaussianNoiser
from ltx_core.components.patchifiers import (
AudioPatchifier,
VideoLatentPatchifier,
get_pixel_coords,
)
from ltx_core.components.schedulers import LTX2Scheduler
from ltx_core.guidance.perturbations import (
BatchedPerturbationConfig,
Perturbation,
PerturbationConfig,
PerturbationType,
)
from ltx_core.model.transformer.modality import Modality
from ltx_core.model.transformer.model import X0Model
from ltx_core.model.video_vae import SpatialTilingConfig, TemporalTilingConfig, TilingConfig
from ltx_core.tools import AudioLatentTools, VideoLatentTools
from ltx_core.types import AudioLatentShape, LatentState, SpatioTemporalScaleFactors, VideoLatentShape, VideoPixelShape
from ltx_trainer.progress import SamplingContext
if TYPE_CHECKING:
from ltx_core.model.audio_vae import AudioDecoder, Vocoder
from ltx_core.model.transformer import LTXModel
from ltx_core.model.video_vae import VideoDecoder, VideoEncoder
from ltx_core.text_encoders.gemma import AVGemmaTextEncoderModel
VIDEO_SCALE_FACTORS = SpatioTemporalScaleFactors.default()
@dataclass
class CachedPromptEmbeddings:
"""Pre-computed text embeddings for a validation prompt.
These embeddings are computed once at training start and reused for all validation runs,
avoiding the need to load the full Gemma text encoder during validation.
"""
video_context_positive: Tensor # [1, seq_len, hidden_dim]
audio_context_positive: Tensor # [1, seq_len, hidden_dim]
video_context_negative: Tensor | None = None
audio_context_negative: Tensor | None = None
@dataclass
class TiledDecodingConfig:
"""Configuration for tiled video decoding to reduce VRAM usage.
Tiled decoding splits the latent tensor into overlapping tiles, decodes each
tile individually, and blends them together. This significantly reduces peak
VRAM usage at the cost of slightly slower decoding.
Defaults match the recommended values from ltx-core tests.
"""
enabled: bool = True # Whether to use tiled decoding (enabled by default)
tile_size_pixels: int = 192 # Spatial tile size in pixels (must be ≥64 and divisible by 32)
tile_overlap_pixels: int = 64 # Spatial tile overlap in pixels (must be divisible by 32)
tile_size_frames: int = 48 # Temporal tile size in frames (must be ≥16 and divisible by 8)
tile_overlap_frames: int = 24 # Temporal tile overlap in frames (must be divisible by 8)
@dataclass
class GenerationConfig:
"""Configuration for video/audio generation."""
prompt: str # Text prompt for generation
negative_prompt: str = "" # Negative prompt to avoid unwanted artifacts
height: int = 544 # Output video height in pixels
width: int = 960 # Output video width in pixels
num_frames: int = 97 # Number of frames to generate
frame_rate: float = 25.0 # Frame rate for temporal position scaling
num_inference_steps: int = 30 # Number of denoising steps
guidance_scale: float = 4.0 # CFG guidance scale
seed: int = 42 # Random seed for reproducibility
condition_image: Tensor | None = None # Optional first frame image for image-to-video
reference_video: Tensor | None = None # For IC-LoRA: [F, C, H, W] in [0, 1]
generate_audio: bool = True # Whether to generate audio alongside video
include_reference_in_output: bool = False # For IC-LoRA: concatenate original reference with generated output
cached_embeddings: CachedPromptEmbeddings | None = None # Pre-computed text embeddings (avoids loading Gemma)
stg_scale: float = 0.0 # STG strength (0.0 = disabled, recommended: 1.0)
stg_blocks: list[int] | None = None # Transformer blocks to perturb (None = all, recommended: [29])
stg_mode: Literal["stg_av", "stg_v"] = "stg_av" # STG mode: "stg_av" (audio+video) or "stg_v" (video only)
# Tiled decoding config: None = use defaults (enabled), False = disable, or TiledDecodingConfig for custom settings
tiled_decoding: TiledDecodingConfig | Literal[False] | None = None
def __post_init__(self) -> None:
"""Apply default tiled decoding config if not provided."""
if self.tiled_decoding is None:
# Use default config with tiling enabled
object.__setattr__(self, "tiled_decoding", TiledDecodingConfig())
elif self.tiled_decoding is False:
# Explicitly disabled - use config with enabled=False
object.__setattr__(self, "tiled_decoding", TiledDecodingConfig(enabled=False))
class ValidationSampler:
"""Generates validation samples during training using ltx-core components.
This class provides a simplified interface for generating video (and optionally audio)
samples during training validation. It supports:
- Text-to-video generation
- Image-to-video generation (first frame conditioning)
- Video-to-video generation (IC-LoRA reference video conditioning)
- Optional audio generation
The implementation follows the patterns from ltx_pipelines.single_stage.
Text embeddings can be provided either via:
- A full text_encoder (encodes prompts on-the-fly)
- Pre-computed cached_embeddings (avoids loading Gemma during validation)
"""
def __init__(
self,
transformer: "LTXModel",
vae_decoder: "VideoDecoder",
vae_encoder: "VideoEncoder | None",
text_encoder: "AVGemmaTextEncoderModel | None" = None,
audio_decoder: "AudioDecoder | None" = None,
vocoder: "Vocoder | None" = None,
sampling_context: SamplingContext | None = None,
):
"""Initialize the validation sampler.
Args:
transformer: LTX-2 transformer model
vae_decoder: Video VAE decoder
vae_encoder: Video VAE encoder (for image/video conditioning), can be None if not needed
text_encoder: Gemma text encoder with embeddings connector (optional if cached_embeddings in config)
audio_decoder: Optional audio VAE decoder (for audio generation)
vocoder: Optional vocoder (for audio generation)
sampling_context: Optional SamplingContext for progress display during denoising
"""
self._transformer = transformer
self._vae_decoder = vae_decoder
self._vae_encoder = vae_encoder
self._text_encoder = text_encoder
self._audio_decoder = audio_decoder
self._vocoder = vocoder
self._sampling_context = sampling_context
# Patchifiers
self._video_patchifier = VideoLatentPatchifier(patch_size=1)
self._audio_patchifier = AudioPatchifier(patch_size=1)
# Note: Use @torch.no_grad() instead of @torch.inference_mode() to avoid FSDP inplace update errors after validation
@torch.no_grad()
def generate(
self,
config: GenerationConfig,
device: torch.device | str = "cuda",
) -> tuple[Tensor, Tensor | None]:
"""Generate a video (and optionally audio) sample.
Args:
config: Generation configuration
device: Device to run generation on
Returns:
Tuple of:
- video: Video tensor [C, F, H, W] in [0, 1] (float32)
- audio: Audio waveform tensor [C, samples] or None
"""
device = torch.device(device) if isinstance(device, str) else device
self._validate_config(config)
# Route to appropriate generation method
if config.reference_video is not None:
return self._generate_with_reference(config, device)
return self._generate_standard(config, device)
def _generate_standard(self, config: GenerationConfig, device: torch.device) -> tuple[Tensor, Tensor | None]:
"""Standard generation (text-to-video or image-to-video)."""
# Get prompt embeddings (from cache or encode on-the-fly)
v_ctx_pos, a_ctx_pos, v_ctx_neg, a_ctx_neg = self._get_prompt_embeddings(config, device)
# Setup generator
generator = torch.Generator(device=device).manual_seed(config.seed)
# Create latent tools
video_tools = self._create_video_latent_tools(config)
audio_tools = self._create_audio_latent_tools(config) if config.generate_audio else None
# Create initial states
video_clean_state = video_tools.create_initial_state(device=device, dtype=torch.bfloat16)
audio_clean_state = (
audio_tools.create_initial_state(device=device, dtype=torch.bfloat16) if audio_tools else None
)
# Apply image conditioning if provided
if config.condition_image is not None:
video_clean_state = self._apply_image_conditioning(
video_clean_state, config.condition_image, config, device
)
# Add noise
noiser = GaussianNoiser(generator=generator)
video_state = noiser(latent_state=video_clean_state, noise_scale=1.0)
audio_state = noiser(latent_state=audio_clean_state, noise_scale=1.0) if audio_clean_state else None
# Run denoising loop
video_state, audio_state = self._run_denoising(
config=config,
video_state=video_state,
audio_state=audio_state,
video_clean_state=video_clean_state,
audio_clean_state=audio_clean_state,
v_ctx_pos=v_ctx_pos,
a_ctx_pos=a_ctx_pos,
v_ctx_neg=v_ctx_neg,
a_ctx_neg=a_ctx_neg,
device=device,
)
# Decode outputs
video_state = video_tools.clear_conditioning(video_state)
video_state = video_tools.unpatchify(video_state)
video_output = self._decode_video(video_state, device, config.tiled_decoding)
audio_output = None
if audio_state is not None and audio_tools is not None:
audio_state = audio_tools.clear_conditioning(audio_state)
audio_state = audio_tools.unpatchify(audio_state)
audio_output = self._decode_audio(audio_state, device)
return video_output, audio_output
def _generate_with_reference(self, config: GenerationConfig, device: torch.device) -> tuple[Tensor, Tensor | None]:
"""Generate with reference video conditioning (IC-LoRA style).
For IC-LoRA:
- Reference video latents are concatenated with target latents
- Reference latents have timestep=0 (clean, not denoised)
- Target latents are denoised normally
- If condition_image is also provided, the first frame of the target is conditioned
- If include_reference_in_output is True, the preprocessed reference video
is concatenated side-by-side with the generated video
"""
# Get prompt embeddings (from cache or encode on-the-fly)
v_ctx_pos, a_ctx_pos, v_ctx_neg, a_ctx_neg = self._get_prompt_embeddings(config, device)
# Setup generator
generator = torch.Generator(device=device).manual_seed(config.seed)
# Preprocess and encode reference video
ref_video_preprocessed = self._preprocess_reference_video(config)
ref_latent, ref_positions = self._encode_video(ref_video_preprocessed, config.frame_rate, device)
ref_seq_len = ref_latent.shape[1]
# Create target video state
video_tools = self._create_video_latent_tools(config)
target_clean_state = video_tools.create_initial_state(device=device, dtype=torch.bfloat16)
# Apply first-frame image conditioning to target if provided
if config.condition_image is not None:
target_clean_state = self._apply_image_conditioning(
target_clean_state, config.condition_image, config, device
)
# Create combined state (reference + target)
# denoise_mask shape is [B, seq_len, 1] after patchification
ref_denoise_mask = torch.zeros(1, ref_seq_len, 1, device=device, dtype=torch.float32)
combined_clean_state = LatentState(
latent=torch.cat([ref_latent, target_clean_state.latent], dim=1),
denoise_mask=torch.cat([ref_denoise_mask, target_clean_state.denoise_mask], dim=1),
positions=torch.cat([ref_positions, target_clean_state.positions], dim=2),
clean_latent=torch.cat([ref_latent, target_clean_state.clean_latent], dim=1),
)
# Add noise (only to the target portion via denoise_mask)
noiser = GaussianNoiser(generator=generator)
combined_state = noiser(latent_state=combined_clean_state, noise_scale=1.0)
# Create audio state if needed
audio_tools = self._create_audio_latent_tools(config) if config.generate_audio else None
audio_clean_state = (
audio_tools.create_initial_state(device=device, dtype=torch.bfloat16) if audio_tools else None
)
audio_state = noiser(latent_state=audio_clean_state, noise_scale=1.0) if audio_clean_state else None
# Run denoising loop
combined_state, audio_state = self._run_denoising(
config=config,
video_state=combined_state,
audio_state=audio_state,
video_clean_state=combined_clean_state,
audio_clean_state=audio_clean_state,
v_ctx_pos=v_ctx_pos,
a_ctx_pos=a_ctx_pos,
v_ctx_neg=v_ctx_neg,
a_ctx_neg=a_ctx_neg,
device=device,
)
# Extract target portion and decode
target_latent = combined_state.latent[:, ref_seq_len:]
video_output = self._decode_video_latent(target_latent, config, device)
# Optionally concatenate original reference video side-by-side
if config.include_reference_in_output:
# Use preprocessed reference (already resized/cropped, in pixel space)
# Convert from [B, C, F, H, W] to [C, F, H, W]
ref_video_pixels = ref_video_preprocessed[0].cpu()
# Normalize from [-1, 1] to [0, 1]
ref_video_pixels = ((ref_video_pixels + 1.0) / 2.0).clamp(0.0, 1.0)
video_output = self._concatenate_videos_side_by_side(ref_video_pixels, video_output)
# Decode audio
audio_output = None
if audio_state is not None and audio_tools is not None:
audio_state = audio_tools.clear_conditioning(audio_state)
audio_state = audio_tools.unpatchify(audio_state)
audio_output = self._decode_audio(audio_state, device)
return video_output, audio_output
def _create_video_latent_tools(self, config: GenerationConfig) -> VideoLatentTools:
"""Create video latent tools for the given configuration."""
pixel_shape = VideoPixelShape(
batch=1,
frames=config.num_frames,
height=config.height,
width=config.width,
fps=config.frame_rate,
)
return VideoLatentTools(
patchifier=self._video_patchifier,
target_shape=VideoLatentShape.from_pixel_shape(shape=pixel_shape),
fps=config.frame_rate,
scale_factors=VIDEO_SCALE_FACTORS,
causal_fix=True,
)
def _create_audio_latent_tools(self, config: GenerationConfig) -> AudioLatentTools:
"""Create audio latent tools for the given configuration."""
return AudioLatentTools(
patchifier=self._audio_patchifier,
target_shape=AudioLatentShape.from_duration(batch=1, duration=config.num_frames / config.frame_rate),
)
def _apply_image_conditioning(
self, video_state: LatentState, image: Tensor, config: GenerationConfig, device: torch.device
) -> LatentState:
"""Apply first-frame image conditioning to the video state."""
# Encode the image
encoded_image = self._encode_conditioning_image(image, config.height, config.width, device)
# Patchify the encoded image (single frame)
patchified_image = self._video_patchifier.patchify(encoded_image) # [1, 1, C] -> [1, num_patches, C]
num_image_tokens = patchified_image.shape[1]
# Update the first frame tokens in the latent
new_latent = video_state.latent.clone()
new_latent[:, :num_image_tokens] = patchified_image.to(new_latent.dtype)
# Update clean_latent as well (conditioning image is clean)
new_clean_latent = video_state.clean_latent.clone()
new_clean_latent[:, :num_image_tokens] = patchified_image.to(new_clean_latent.dtype)
# Set denoise_mask to 0 for conditioned tokens (don't denoise them)
new_denoise_mask = video_state.denoise_mask.clone()
new_denoise_mask[:, :num_image_tokens] = 0.0
return LatentState(
latent=new_latent,
denoise_mask=new_denoise_mask,
positions=video_state.positions,
clean_latent=new_clean_latent,
)
@staticmethod
def _preprocess_reference_video(config: GenerationConfig) -> Tensor:
"""Preprocess reference video: resize, crop, and convert to model input format.
Args:
config: Generation configuration with reference_video
Returns:
Preprocessed video tensor [B, C, F, H, W] in [-1, 1] range
"""
ref_video = config.reference_video # [F, C, H, W] in [0, 1]
target_height, target_width = config.height, config.width
current_height, current_width = ref_video.shape[2:]
# Resize maintaining aspect ratio and center crop if needed
if current_height != target_height or current_width != target_width:
aspect_ratio = current_width / current_height
target_aspect_ratio = target_width / target_height
if aspect_ratio > target_aspect_ratio:
resize_height, resize_width = target_height, int(target_height * aspect_ratio)
else:
resize_height, resize_width = int(target_width / aspect_ratio), target_width
ref_video = torch.nn.functional.interpolate(
ref_video, size=(resize_height, resize_width), mode="bilinear", align_corners=False
)
# Center crop
h_start = (resize_height - target_height) // 2
w_start = (resize_width - target_width) // 2
ref_video = ref_video[:, :, h_start : h_start + target_height, w_start : w_start + target_width]
# Convert to [B, C, F, H, W] and trim to valid frame count (k*8 + 1)
ref_video = rearrange(ref_video, "f c h w -> 1 c f h w")
valid_frames = (ref_video.shape[2] - 1) // 8 * 8 + 1
ref_video = ref_video[:, :, :valid_frames]
# Convert to [-1, 1] range
return ref_video * 2.0 - 1.0
def _encode_video(self, video: Tensor, fps: float, device: torch.device) -> tuple[Tensor, Tensor]:
"""Encode video to patchified latents and compute positions.
Args:
video: Video tensor [B, C, F, H, W] in [-1, 1] range
fps: Frame rate for temporal position scaling
device: Device to run encoding on
Returns:
Tuple of (patchified_latents, positions)
"""
video = video.to(device=device, dtype=torch.float32)
# Encode with VAE
self._vae_encoder.to(device)
with torch.autocast(device_type=str(device).split(":")[0], dtype=torch.bfloat16):
latents = self._vae_encoder(video)
self._vae_encoder.to("cpu")
latents = latents.to(torch.bfloat16)
patchified = self._video_patchifier.patchify(latents)
# Compute positions
latent_shape = VideoLatentShape(
batch=1,
channels=latents.shape[1],
frames=latents.shape[2],
height=latents.shape[3],
width=latents.shape[4],
)
latent_coords = self._video_patchifier.get_patch_grid_bounds(output_shape=latent_shape, device=device)
positions = get_pixel_coords(latent_coords, scale_factors=VIDEO_SCALE_FACTORS, causal_fix=True)
positions = positions.to(torch.bfloat16)
positions[:, 0, ...] = positions[:, 0, ...] / fps
return patchified, positions
def _run_denoising(
self,
config: GenerationConfig,
video_state: LatentState,
audio_state: LatentState | None,
video_clean_state: LatentState,
audio_clean_state: LatentState | None,
v_ctx_pos: Tensor,
a_ctx_pos: Tensor,
v_ctx_neg: Tensor | None,
a_ctx_neg: Tensor | None,
device: torch.device,
) -> tuple[LatentState, LatentState | None]:
"""Run the denoising loop using X0 prediction with CFG and optional STG."""
scheduler = LTX2Scheduler()
sigmas = scheduler.execute(steps=config.num_inference_steps).to(device).float()
stepper = EulerDiffusionStep()
cfg_guider = CFGGuider(config.guidance_scale)
stg_guider = STGGuider(config.stg_scale)
# Build STG perturbation config if STG is enabled
stg_perturbation_config = self._build_stg_perturbation_config(config) if stg_guider.enabled() else None
# Create initial modalities (will be updated each step via replace())
video = Modality(
enabled=True,
latent=video_state.latent,
timesteps=video_state.denoise_mask,
positions=video_state.positions,
context=v_ctx_pos,
context_mask=None,
)
# Audio modality is None when not generating audio
audio: Modality | None = None
if audio_state is not None:
audio = Modality(
enabled=True,
latent=audio_state.latent,
timesteps=audio_state.denoise_mask,
positions=audio_state.positions,
context=a_ctx_pos,
context_mask=None,
)
# Wrap transformer with X0Model to convert velocity predictions to denoised outputs
self._transformer.to(device)
x0_model = X0Model(self._transformer)
with torch.autocast(device_type=str(device).split(":")[0], dtype=torch.bfloat16):
for step_idx, sigma in enumerate(sigmas[:-1]):
# Update modalities with current state and timesteps
video = replace(
video,
latent=video_state.latent,
timesteps=sigma * video_state.denoise_mask,
positions=video_state.positions,
)
if audio is not None and audio_state is not None:
audio = replace(
audio,
latent=audio_state.latent,
timesteps=sigma * audio_state.denoise_mask,
positions=audio_state.positions,
)
# Run model (positive pass) - X0Model returns denoised outputs
pos_video, pos_audio = x0_model(video=video, audio=audio, perturbations=None)
denoised_video, denoised_audio = pos_video, pos_audio
# Apply CFG if guidance_scale != 1.0
if cfg_guider.enabled() and v_ctx_neg is not None:
video_neg = replace(video, context=v_ctx_neg)
audio_neg = replace(audio, context=a_ctx_neg) if audio is not None else None
neg_video, neg_audio = x0_model(video=video_neg, audio=audio_neg, perturbations=None)
denoised_video = denoised_video + cfg_guider.delta(pos_video, neg_video)
if audio is not None and denoised_audio is not None:
denoised_audio = denoised_audio + cfg_guider.delta(pos_audio, neg_audio)
# Apply STG if stg_scale != 0.0
if stg_guider.enabled() and stg_perturbation_config is not None:
perturbed_video, perturbed_audio = x0_model(
video=video, audio=audio, perturbations=stg_perturbation_config
)
denoised_video = denoised_video + stg_guider.delta(pos_video, perturbed_video)
if audio is not None and denoised_audio is not None and perturbed_audio is not None:
denoised_audio = denoised_audio + stg_guider.delta(pos_audio, perturbed_audio)
# Apply conditioning mask (keep conditioned tokens clean)
denoised_video = denoised_video * video_state.denoise_mask + video_clean_state.latent.float() * (
1 - video_state.denoise_mask
)
if audio is not None and audio_state is not None and audio_clean_state is not None:
denoised_audio = denoised_audio * audio_state.denoise_mask + audio_clean_state.latent.float() * (
1 - audio_state.denoise_mask
)
# Euler step
video_state = replace(
video_state,
latent=stepper.step(
sample=video.latent, denoised_sample=denoised_video, sigmas=sigmas, step_index=step_idx
),
)
if audio is not None and audio_state is not None:
audio_state = replace(
audio_state,
latent=stepper.step(
sample=audio.latent, denoised_sample=denoised_audio, sigmas=sigmas, step_index=step_idx
),
)
# Update progress
if self._sampling_context is not None:
self._sampling_context.advance_step()
return video_state, audio_state
@staticmethod
def _build_stg_perturbation_config(config: GenerationConfig) -> BatchedPerturbationConfig:
"""Build the perturbation config for STG based on the stg_mode."""
# Always skip video self-attention for STG
perturbations: list[Perturbation] = [
Perturbation(type=PerturbationType.SKIP_VIDEO_SELF_ATTN, blocks=config.stg_blocks)
]
# Optionally also skip audio self-attention (stg_av mode)
if config.stg_mode == "stg_av":
perturbations.append(Perturbation(type=PerturbationType.SKIP_AUDIO_SELF_ATTN, blocks=config.stg_blocks))
perturbation_config = PerturbationConfig(perturbations=perturbations)
# Batch size is 1 for validation
return BatchedPerturbationConfig(perturbations=[perturbation_config])
def _decode_video_latent(self, latent: Tensor, config: GenerationConfig, device: torch.device) -> Tensor:
"""Decode patchified video latent to pixel space."""
# Unpatchify
latent_frames = config.num_frames // VIDEO_SCALE_FACTORS.time + 1
latent_height = config.height // VIDEO_SCALE_FACTORS.height
latent_width = config.width // VIDEO_SCALE_FACTORS.width
unpatchified = self._video_patchifier.unpatchify(
latent,
output_shape=VideoLatentShape(
height=latent_height,
width=latent_width,
frames=latent_frames,
batch=1,
channels=128,
),
)
# Decode - ensure bfloat16 to match decoder weights
self._vae_decoder.to(device)
unpatchified = unpatchified.to(dtype=torch.bfloat16)
tiled_config = config.tiled_decoding
if tiled_config is not None and tiled_config.enabled:
# Use tiled decoding for reduced VRAM
tiling_config = TilingConfig(
spatial_config=SpatialTilingConfig(
tile_size_in_pixels=tiled_config.tile_size_pixels,
tile_overlap_in_pixels=tiled_config.tile_overlap_pixels,
),
temporal_config=TemporalTilingConfig(
tile_size_in_frames=tiled_config.tile_size_frames,
tile_overlap_in_frames=tiled_config.tile_overlap_frames,
),
)
chunks = []
for video_chunk in self._vae_decoder.tiled_decode(
unpatchified,
tiling_config=tiling_config,
):
chunks.append(video_chunk)
decoded_video = torch.cat(chunks, dim=2)
else:
# Standard full decoding
decoded_video = self._vae_decoder(unpatchified)
decoded_video = ((decoded_video + 1.0) / 2.0).clamp(0.0, 1.0)
self._vae_decoder.to("cpu")
return decoded_video[0].float().cpu()
def _validate_config(self, config: GenerationConfig) -> None:
"""Validate generation configuration."""
if config.height % 32 != 0 or config.width % 32 != 0:
raise ValueError(f"height and width must be divisible by 32, got {config.height}x{config.width}")
if config.num_frames % 8 != 1:
raise ValueError(f"num_frames must satisfy num_frames % 8 == 1, got {config.num_frames}")
if config.generate_audio and (self._audio_decoder is None or self._vocoder is None):
raise ValueError("Audio generation requires audio_decoder and vocoder")
if config.condition_image is not None and self._vae_encoder is None:
raise ValueError("Image conditioning requires vae_encoder")
if config.reference_video is not None and self._vae_encoder is None:
raise ValueError("Reference video conditioning requires vae_encoder")
# Validate prompt embedding source
if config.cached_embeddings is None and self._text_encoder is None:
raise ValueError("Either text_encoder or config.cached_embeddings must be provided")
def _get_prompt_embeddings(
self, config: GenerationConfig, device: torch.device
) -> tuple[Tensor, Tensor, Tensor | None, Tensor | None]:
"""Get prompt embeddings from config cache or encode on-the-fly."""
if config.cached_embeddings is not None:
# Use pre-computed embeddings from config
cached = config.cached_embeddings
v_ctx_pos = cached.video_context_positive.to(device)
a_ctx_pos = cached.audio_context_positive.to(device)
v_ctx_neg = cached.video_context_negative.to(device) if cached.video_context_negative is not None else None
a_ctx_neg = cached.audio_context_negative.to(device) if cached.audio_context_negative is not None else None
return v_ctx_pos, a_ctx_pos, v_ctx_neg, a_ctx_neg
# Fall back to encoding on-the-fly
return self._encode_prompts(config, device)
def _encode_prompts(
self, config: GenerationConfig, device: torch.device
) -> tuple[Tensor, Tensor, Tensor | None, Tensor | None]:
"""Encode positive and negative prompts using the text encoder."""
self._text_encoder.to(device)
v_ctx_pos, a_ctx_pos, _ = self._text_encoder(config.prompt)
v_ctx_neg, a_ctx_neg = None, None
if config.guidance_scale != 1.0:
v_ctx_neg, a_ctx_neg, _ = self._text_encoder(config.negative_prompt)
# Move the base Gemma model to CPU but keep embeddings connectors on GPU
# as this module is also used during training
self._text_encoder.model.to("cpu")
self._text_encoder.feature_extractor_linear.to("cpu")
return v_ctx_pos, a_ctx_pos, v_ctx_neg, a_ctx_neg
def _decode_video(
self, video_state: LatentState, device: torch.device, tiled_config: TiledDecodingConfig | None = None
) -> Tensor:
"""Decode video latents to pixel space.
Args:
video_state: Video latent state to decode
device: Device to run decoding on
tiled_config: Optional tiled decoding configuration for reduced VRAM usage
Returns:
Decoded video tensor [C, F, H, W] in [0, 1] range
"""
self._vae_decoder.to(device)
# Ensure latent is bfloat16 to match decoder weights
latent = video_state.latent.to(dtype=torch.bfloat16)
if tiled_config is not None and tiled_config.enabled:
# Use tiled decoding for reduced VRAM
tiling_config = TilingConfig(
spatial_config=SpatialTilingConfig(
tile_size_in_pixels=tiled_config.tile_size_pixels,
tile_overlap_in_pixels=tiled_config.tile_overlap_pixels,
),
temporal_config=TemporalTilingConfig(
tile_size_in_frames=tiled_config.tile_size_frames,
tile_overlap_in_frames=tiled_config.tile_overlap_frames,
),
)
chunks = []
for video_chunk in self._vae_decoder.tiled_decode(
latent,
tiling_config=tiling_config,
):
chunks.append(video_chunk)
decoded_video = torch.cat(chunks, dim=2)
else:
# Standard full decoding
decoded_video = self._vae_decoder(latent)
decoded_video = ((decoded_video + 1.0) / 2.0).clamp(0.0, 1.0)
self._vae_decoder.to("cpu")
return decoded_video[0].float().cpu()
def _decode_audio(self, audio_state: LatentState, device: torch.device) -> Tensor:
"""Decode audio latents to waveform."""
self._audio_decoder.to(device)
# Ensure latent is bfloat16 to match decoder weights
latent = audio_state.latent.to(dtype=torch.bfloat16)
decoded_audio = self._audio_decoder(latent)
self._audio_decoder.to("cpu")
self._vocoder.to(device)
audio_waveform = self._vocoder(decoded_audio)
self._vocoder.to("cpu")
return audio_waveform.squeeze(0).float().cpu()
@staticmethod
def _concatenate_videos_side_by_side(left_video: Tensor, right_video: Tensor) -> Tensor:
"""Concatenate two videos side-by-side (horizontally).
If the videos have different frame counts, the shorter one is padded with
its last frame repeated.
Args:
left_video: Left video tensor [C, F1, H, W] in [0, 1]
right_video: Right video tensor [C, F2, H, W] in [0, 1]
Returns:
Concatenated video tensor [C, max(F1,F2), H, W*2] in [0, 1]
"""
left_frames = left_video.shape[1]
right_frames = right_video.shape[1]
# Pad shorter video by repeating last frame
if left_frames < right_frames:
padding = left_video[:, -1:, :, :].expand(-1, right_frames - left_frames, -1, -1)
left_video = torch.cat([left_video, padding], dim=1)
elif right_frames < left_frames:
padding = right_video[:, -1:, :, :].expand(-1, left_frames - right_frames, -1, -1)
right_video = torch.cat([right_video, padding], dim=1)
# Concatenate along width dimension
return torch.cat([left_video, right_video], dim=3)
def _encode_conditioning_image(
self,
image: Tensor,
target_height: int,
target_width: int,
device: torch.device,
) -> Tensor:
"""Encode a conditioning image to latent space.
The image is resized to cover the target dimensions while preserving aspect ratio,
then center-cropped to exactly match the target size.
"""
# image is [C, H, W] in [0, 1] # noqa: ERA001
current_height, current_width = image.shape[1:]
# Resize maintaining aspect ratio (cover target, then center crop)
if current_height != target_height or current_width != target_width:
aspect_ratio = current_width / current_height
target_aspect_ratio = target_width / target_height
if aspect_ratio > target_aspect_ratio:
# Image is wider than target - resize to match height, crop width
resize_height = target_height
resize_width = int(target_height * aspect_ratio)
else:
# Image is taller than target - resize to match width, crop height
resize_height = int(target_width / aspect_ratio)
resize_width = target_width
image = rearrange(image, "c h w -> 1 c h w")
image = torch.nn.functional.interpolate(
image, size=(resize_height, resize_width), mode="bilinear", align_corners=False
)
# Center crop to target dimensions
h_start = (resize_height - target_height) // 2
w_start = (resize_width - target_width) // 2
image = image[:, :, h_start : h_start + target_height, w_start : w_start + target_width]
else:
image = rearrange(image, "c h w -> 1 c h w")
# Add frame dimension and convert to [-1, 1]
image = rearrange(image, "b c h w -> b c 1 h w")
image = (image * 2.0 - 1.0).to(device=device, dtype=torch.float32)
# Encode
self._vae_encoder.to(device)
with torch.autocast(device_type=str(device).split(":")[0], dtype=torch.bfloat16):
encoded = self._vae_encoder(image)
self._vae_encoder.to("cpu")
return encoded
@@ -0,0 +1,159 @@
"""Video I/O utilities using PyAV.
This module provides functions for reading and writing video files using PyAV,
with optional audio support.
"""
from fractions import Fraction
from pathlib import Path
import av
import numpy as np
import torch
from torch import Tensor
def get_video_frame_count(video_path: str | Path) -> int:
"""Get the number of frames in a video file.
Args:
video_path: Path to the video file
Returns:
Number of frames in the video
"""
with av.open(str(video_path)) as container:
video_stream = container.streams.video[0]
frame_count = video_stream.frames
if frame_count == 0:
# Fallback: count frames by decoding
frame_count = sum(1 for _ in container.decode(video=0))
return frame_count
def read_video(video_path: str | Path, max_frames: int | None = None) -> tuple[Tensor, float]:
"""Load frames from a video file using PyAV.
Args:
video_path: Path to the video file
max_frames: Maximum number of frames to read. If None, reads all frames.
Returns:
Video tensor with shape [F, C, H, W] in range [0, 1] and frames per second (fps).
"""
with av.open(str(video_path)) as container:
video_stream = container.streams.video[0]
fps = float(video_stream.average_rate or video_stream.base_rate or 24)
frames = []
for frame in container.decode(video=0):
if max_frames is not None and len(frames) >= max_frames:
break
frames.append(frame.to_ndarray(format="rgb24"))
frames_np = np.stack(frames, axis=0) # [F, H, W, C]
video = torch.from_numpy(frames_np).float().div(255.0) # [F, H, W, C] in [0, 1]
return video.permute(0, 3, 1, 2), fps # [F, C, H, W]
def save_video(
video_tensor: torch.Tensor,
output_path: Path | str,
fps: float = 24.0,
audio: torch.Tensor | None = None,
audio_sample_rate: int | None = None,
) -> None:
"""Save a video tensor to a file using PyAV, optionally with audio.
Args:
video_tensor: Video tensor of shape [C, F, H, W] or [F, C, H, W] in range [0, 1] or [0, 255]
output_path: Path to save the video
fps: Frames per second for the output video
audio: Optional audio tensor of shape [C, samples] or [samples, C] in range [-1, 1]
audio_sample_rate: Sample rate for the audio (required if audio is provided)
"""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Normalize to [F, H, W, C] uint8 numpy array
video_np = _prepare_video_array(video_tensor)
_, height, width, _ = video_np.shape
with av.open(str(output_path), mode="w") as container:
# Setup video stream
video_stream = container.add_stream("libx264", rate=int(fps))
video_stream.width = width
video_stream.height = height
video_stream.pix_fmt = "yuv420p"
video_stream.options = {"crf": "18"}
# Setup audio stream if needed
if audio is not None:
if audio_sample_rate is None:
raise ValueError("audio_sample_rate must be provided when audio is given")
audio_stream = container.add_stream("aac", rate=audio_sample_rate)
audio_stream.layout = "stereo"
audio_stream.time_base = Fraction(1, audio_sample_rate)
# Write video frames
for frame_array in video_np:
frame = av.VideoFrame.from_ndarray(frame_array, format="rgb24")
for packet in video_stream.encode(frame):
container.mux(packet)
for packet in video_stream.encode():
container.mux(packet)
# Write audio if provided
if audio is not None:
_write_audio(container, audio_stream, audio, audio_sample_rate)
def _prepare_video_array(video_tensor: torch.Tensor) -> np.ndarray:
"""Convert video tensor to [F, H, W, C] uint8 numpy array."""
# Handle [C, F, H, W] vs [F, C, H, W] format
if video_tensor.shape[0] == 3 and video_tensor.shape[1] > 3:
video_tensor = video_tensor.permute(1, 0, 2, 3) # [C, F, H, W] -> [F, C, H, W]
# Normalize to [0, 255] uint8
if video_tensor.max() <= 1.0:
video_tensor = video_tensor * 255
# [F, C, H, W] -> [F, H, W, C]
return video_tensor.permute(0, 2, 3, 1).to(torch.uint8).cpu().numpy()
def _write_audio(
container: av.container.Container,
audio_stream: av.audio.AudioStream,
audio: torch.Tensor,
sample_rate: int,
) -> None:
"""Write audio tensor to container as stereo AAC."""
audio = audio.cpu().float()
# Normalize to [samples, 2] stereo format
if audio.ndim == 1:
audio = audio.unsqueeze(1).repeat(1, 2) # Mono -> stereo
elif audio.shape[0] == 2 and audio.shape[1] != 2:
audio = audio.T # [2, samples] -> [samples, 2]
if audio.shape[1] == 1:
audio = audio.repeat(1, 2) # Mono -> stereo
# Convert to int16 interleaved: [samples, 2] -> [1, samples*2]
audio_int16 = (audio.clamp(-1, 1) * 32767).to(torch.int16)
audio_interleaved = audio_int16.contiguous().view(1, -1).numpy()
# Create audio frame
frame = av.AudioFrame.from_ndarray(audio_interleaved, format="s16", layout="stereo")
frame.sample_rate = sample_rate
# Resample to encoder format and write
resampler = av.audio.resampler.AudioResampler(
format=audio_stream.codec_context.format,
layout=audio_stream.codec_context.layout,
rate=sample_rate,
)
pts = 0
for resampled_frame in resampler.resample(frame):
resampled_frame.pts = pts
pts += resampled_frame.samples
for packet in audio_stream.encode(resampled_frame):
container.mux(packet)
for packet in audio_stream.encode():
container.mux(packet)