Automated PR - 2026-06-17

This commit is contained in:
github-actions[bot]
2026-06-17 14:06:32 +00:00
parent d6053703e0
commit 0d3d3a3855
90 changed files with 8265 additions and 4511 deletions
-3
View File
@@ -1,7 +1,4 @@
configs/*.yaml
!configs/ltx2_av_lora.yaml
!configs/ltx2_av_lora_low_vram.yaml
!configs/ltx2_v2v_ic_lora.yaml
datasets
outputs
wandb
+125 -35
View File
@@ -6,10 +6,22 @@ This file provides guidance to AI coding assistants (Claude, Cursor, etc.) when
**LTX Trainer** is a training toolkit for fine-tuning the Lightricks LTX audio-video generation models. It supports:
- **Text-to-video (T2V)** - Generate video from text prompts
- **Text-to-audio (T2A)** - Generate audio from text prompts
- **Image-to-video (I2V)** - Generate video conditioned on a first frame
- **Video extension** - Forward (prefix) and backward (suffix) video continuation
- **Video inpainting** - Mask-based spatial/temporal inpainting
- **Video outpainting** - Spatial crop-based outpainting
- **IC-LoRA video-to-video** - In-context control adapters for style/structure transfer
- **Audio-to-video (A2V)** and **Video-to-audio (V2A)** - Cross-modal generation with frozen conditioning
- **Audio extension** - Forward (prefix) and backward (suffix) audio continuation
- **Audio inpainting** - Mask-based audio inpainting
- **IC-LoRA audio-to-audio (A2A)** - Audio reference conditioning for style transfer
- **AV2AV IC-LoRA** - Combined video and audio reference conditioning
- **LoRA training** - Efficient fine-tuning with adapters
- **Full fine-tuning** - Complete model training
- **Audio-video training** - Joint audio and video generation
- **IC-LoRA training** - In-context control adapters for video-to-video transformations
All conditioning scenarios are expressed through the unified `FlexibleStrategy` configuration.
**Supported model versions:**
@@ -39,13 +51,14 @@ packages/ltx-trainer/
│ ├── config_display.py # Config pretty-printing
│ ├── trainer.py # Main training orchestration with Accelerate
│ ├── model_loader.py # Model loading using ltx-core
│ ├── validation_sampler.py # Inference for validation samples
│ ├── validation_runner.py # ValidationRunner — conditioned validation sampling
│ ├── datasets.py # PrecomputedDataset, DummyDataset
│ ├── training_strategies/ # Strategy pattern for different training modes
│ │ ├── __init__.py # Factory function: get_training_strategy()
│ │ ├── base_strategy.py # TrainingStrategy ABC, ModelInputs, TrainingStrategyConfigBase
│ │ ├── text_to_video.py # TextToVideoStrategy, TextToVideoConfig
│ │ ── video_to_video.py # VideoToVideoStrategy, VideoToVideoConfig
│ │ ├── flexible.py # FlexibleStrategy, FlexibleStrategyConfig [RECOMMENDED]
│ │ ── text_to_video.py # TextToVideoStrategy, TextToVideoConfig [DEPRECATED]
│ │ └── video_to_video.py # VideoToVideoStrategy, VideoToVideoConfig [DEPRECATED]
│ ├── timestep_samplers.py # Flow matching timestep sampling
│ ├── gemma_8bit.py # 8-bit Gemma text encoder loading (bitsandbytes)
│ ├── quantization.py # Transformer INT8/INT4/FP8 quantization
@@ -62,13 +75,25 @@ packages/ltx-trainer/
│ ├── process_captions.py # Text embedding computation
│ ├── caption_videos.py # Automatic video captioning
│ ├── decode_latents.py # Latent decoding for debugging
│ ├── inference.py # Inference with trained models
│ ├── compute_reference.py # Generate IC-LoRA reference videos
│ └── split_scenes.py # Scene detection and splitting
├── configs/ # Example training configurations
│ ├── ltx2_av_lora.yaml # Audio-video LoRA training
│ ├── ltx2_av_lora_low_vram.yaml
│ ├── ltx2_v2v_ic_lora.yaml # IC-LoRA video-to-video
│ ├── t2v_lora.yaml # Text-to-video LoRA
│ ├── t2v_lora_low_vram.yaml # Text-to-video LoRA (low VRAM)
│ ├── i2v_lora.yaml # Image-to-video LoRA
│ ├── v2v_ic_lora.yaml # IC-LoRA video-to-video
│ ├── a2v_lora.yaml # Audio-to-video LoRA
│ ├── v2a_lora.yaml # Video-to-audio LoRA
│ ├── video_extend_lora.yaml # Video extension (forward)
│ ├── video_suffix_lora.yaml # Video extension (backward)
│ ├── video_inpainting_lora.yaml # Video inpainting
│ ├── video_outpainting_lora.yaml # Video outpainting
│ ├── t2a_lora.yaml # Text-to-audio LoRA
│ ├── audio_extend_lora.yaml # Audio extension (forward)
│ ├── audio_suffix_lora.yaml # Audio extension (backward)
│ ├── audio_inpainting_lora.yaml # Audio inpainting
│ ├── a2a_ic_lora.yaml # Audio-to-audio IC-LoRA
│ ├── av2av_ic_lora.yaml # AV2AV IC-LoRA
│ └── accelerate/ # FSDP, DDP configs
├── tests/ # Pytest tests
└── docs/ # Documentation
@@ -83,7 +108,8 @@ packages/ltx-trainer/
`load_text_encoder()`, `load_embeddings_processor()`, etc.
- Combined loader: `load_model()` returns `LtxModelComponents` dataclass
- Uses `SingleGPUModelBuilder` from ltx-core internally
- Text encoder and embeddings processor are loaded separately (the text encoder only needs Gemma weights; the embeddings processor only needs the LTX checkpoint)
- Text encoder and embeddings processor are loaded separately (the text encoder only needs Gemma weights; the embeddings
processor only needs the LTX checkpoint)
- 8-bit text encoder loading via `gemma_8bit.py` (bitsandbytes)
**Training Flow:**
@@ -94,7 +120,7 @@ packages/ltx-trainer/
kept)
4. Each training step: embedding connectors applied → strategy prepares `ModelInputs` → transformer forward pass →
strategy computes loss
5. Training strategies (`TextToVideoStrategy`, `VideoToVideoStrategy`) handle mode-specific logic
5. Training strategies (`FlexibleStrategy`) handle mode-specific logic including conditioning, masking, and loss computation
6. Accelerate handles distributed training, mixed precision, and device placement
7. Data flows as precomputed latents through `PrecomputedDataset`
@@ -136,7 +162,12 @@ LTX-2.3) and cross-modality (video↔audio) attention conditioning (both version
- All config in `src/ltx_trainer/config.py`
- Main class: `LtxTrainerConfig`
- Training strategy configs: `TextToVideoConfig`, `VideoToVideoConfig`
- `TrainingStrategyConfig` - Union of `FlexibleStrategyConfig` | `TextToVideoConfig` (deprecated) | `VideoToVideoConfig` (deprecated)
- `FlexibleStrategyConfig` - Unified strategy config with `video`/`audio` `ModalityConfig` blocks
- `ModalityConfig` - Per-modality config: `is_generated`, `latents_dir`, `conditions` list
- `ConditionConfig` - Discriminated union: `FirstFrameConditionConfig`, `PrefixConditionConfig`, `SuffixConditionConfig`, `SpatialCropConditionConfig`, `MaskConditionConfig`, `ReferenceConditionConfig`
- `ValidationSample` - Per-sample validation config with `prompt`, `conditions`, optional `video_dims`/`seed` overrides
- `ValidationCondition` - Discriminated union for validation conditions (first_frame, prefix, suffix, spatial_crop, mask, reference, video_to_audio, audio_to_video)
- Uses Pydantic field validators and model validators
- Config uses `extra="forbid"` — unknown fields cause validation errors
- Config files in `configs/` directory
@@ -206,8 +237,8 @@ These values are shared across all supported model versions:
| Video latent channels | 128 | VAE encoder/decoder, patchifier, `VideoLatentShape` |
| Spatial compression | 32× (H and W) | `SpatioTemporalScaleFactors.default()`, config validators |
| Temporal compression | 8× | `SpatioTemporalScaleFactors.default()`, config validators |
| Frame constraint | `frames % 8 == 1` | Config validators, validation sampler |
| Resolution constraint | Width and height divisible by 32 | Config validators, validation sampler |
| Frame constraint | `frames % 8 == 1` | Config validators, validation runner |
| Resolution constraint | Width and height divisible by 32 | Config validators, validation runner |
| Audio latent channels | 8 | `AudioLatentShape`, audio patchifier |
| Audio mel bins | 16 | `AudioLatentShape`, audio patchifier |
| Patchified token dim (video) | 128 (`128 × 1 × 1 × 1`) | Transformer `in_channels` |
@@ -245,12 +276,53 @@ uv run pytest
```bash
# Single GPU
uv run python scripts/train.py configs/ltx2_av_lora.yaml
uv run python scripts/train.py configs/t2v_lora.yaml
# Multi-GPU with Accelerate
uv run accelerate launch scripts/train.py configs/ltx2_av_lora.yaml
uv run accelerate launch scripts/train.py configs/t2v_lora.yaml
```
## Testing Standards
### Structure
- **Flat functions only** — use `def test_*()`, never `class Test*` with methods. Pytest collects standalone functions.
- **Only test public interfaces** — never call private methods (`_method`) directly. Verify private behavior
indirectly through the public API.
### What to Test
- **Custom validators and business logic** — cross-field validators, domain constraints, error paths. These catch real
bugs.
- **Behavioral tests** — call the public method, verify the outputs have the right shape, values, and structure. One
behavioral test is worth ten config-only tests.
- **Edge cases and error paths** — boundary conditions, composed behaviors, expected exceptions.
- **Contract tests** — required fields, rejected invalid inputs, safety mechanisms like `extra="forbid"`.
### What NOT to Test
- **Pydantic storing a value** — `Foo(x=1); assert foo.x == 1` tests Pydantic, not your code. If a behavioral test
already creates the same config and uses it, the config-only test adds nothing.
- **Pydantic Literal defaults** — `assert config.type == "first_frame"` when `type` is `Literal["first_frame"]`.
- **Pydantic default factories** — `assert config.conditions == []` when the field has `default_factory=list`.
- **Tests already covered by behavioral tests** — if `test_prefix_conditioning` creates a valid `PrefixConditionConfig`
and exercises it end-to-end, a separate `test_prefix_valid` that just creates the same config is redundant.
- **Trivial instantiation tests** — `strategy = Strategy(config); assert strategy.config is not None` when every other
test creates a strategy.
### Keeping Tests DRY
- **Use helper functions** for repeated setup patterns (e.g., `_make_strategy(video=_video_modality(...))` instead of
6-8 lines of config/strategy creation per test).
- **Use named constants** for test dimensions (e.g., `VIDEO_SEQ_LEN`, `TOKENS_PER_FRAME`) instead of magic numbers.
- **Merge tests that share identical setup** — when 5+ tests call `prepare_training_inputs` with the exact same
config and batch, each checking one assertion, merge them into one test that checks all assertions. Pytest reports
the exact failing line anyway.
- **Use `@pytest.mark.parametrize`** for the same logic tested with different inputs (e.g., valid/invalid values for
a field).
- **Use pytest fixtures** for shared batch data and test directories, but prefer explicit helper functions over
fixtures for strategy/config creation (makes the test self-documenting).
## Code Standards
### Type Hints
@@ -286,7 +358,12 @@ Key classes:
- `LtxTrainerConfig` - Main configuration container
- `ModelConfig` - Model paths, training mode (`lora` | `full`), checkpoint loading
- `TrainingStrategyConfig` - Union of `TextToVideoConfig` | `VideoToVideoConfig` (discriminated by `name`)
- `TrainingStrategyConfig` - Union of `FlexibleStrategyConfig` | `TextToVideoConfig` (deprecated) | `VideoToVideoConfig` (deprecated)
- `FlexibleStrategyConfig` - Unified strategy config with `video`/`audio` `ModalityConfig` blocks
- `ModalityConfig` - Per-modality config: `is_generated`, `latents_dir`, `conditions` list
- `ConditionConfig` - Discriminated union: `FirstFrameConditionConfig`, `PrefixConditionConfig`, `SuffixConditionConfig`, `SpatialCropConditionConfig`, `MaskConditionConfig`, `ReferenceConditionConfig`
- `ValidationSample` - Per-sample validation config with `prompt`, `conditions`, optional `video_dims`/`seed` overrides
- `ValidationCondition` - Discriminated union for validation conditions (first_frame, prefix, suffix, spatial_crop, mask, reference, video_to_audio, audio_to_video)
- `LoraConfig` - Rank, alpha, dropout, target modules
- `OptimizationConfig` - Learning rate, batch size, gradient accumulation, scheduler, gradient checkpointing
- `AccelerationConfig` - Mixed precision, quantization, 8-bit text encoder
@@ -310,21 +387,23 @@ Key classes:
- Implements distributed training with Accelerate
- Handles mixed precision, gradient accumulation, checkpointing
- `_training_step()` applies embedding connectors then delegates to strategy
- `_load_text_encoder_and_cache_embeddings()` loads the text encoder + embeddings processor, caches validation embeddings, then unloads the Gemma LLM (keeps only the embeddings processor connectors for training)
- `_load_text_encoder_and_cache_embeddings()` loads the text encoder + embeddings processor, caches validation
embeddings, then unloads the Gemma LLM (keeps only the embeddings processor connectors for training)
- Uses training strategies for mode-specific logic
**`src/ltx_trainer/training_strategies/`** - Strategy pattern
- `base_strategy.py`: `TrainingStrategy` ABC, `ModelInputs` dataclass
- `text_to_video.py`: Standard text-to-video (with optional audio)
- `video_to_video.py`: IC-LoRA video-to-video transformations
- `flexible.py`: FlexibleStrategy — unified conditioning framework (recommended)
- `text_to_video.py`: TextToVideoStrategy (deprecated — use FlexibleStrategy)
- `video_to_video.py`: VideoToVideoStrategy (deprecated — use FlexibleStrategy)
Key methods each strategy implements:
- `get_data_sources()` - Required data directories
- `prepare_training_inputs()` - Convert batch to `ModelInputs` with `Modality` objects
- `compute_loss()` - Calculate training loss (velocity prediction, MSE with masking)
- `requires_audio` property - Whether audio components needed
The strategy's **config** declares its data directories via `get_data_sources()` (single source of truth, used for both dataset wiring and existence validation).
**`src/ltx_trainer/model_loader.py`** - Model loading
@@ -339,14 +418,13 @@ Component loaders:
- `load_embeddings_processor(checkpoint_path)``EmbeddingsProcessor` (feature extractor + connectors)
- `load_model()``LtxModelComponents` (convenience wrapper)
**`src/ltx_trainer/validation_sampler.py`** - Inference for validation
**`src/ltx_trainer/validation_runner.py`** - Conditioned validation sampling
Uses ltx-core components for denoising:
- `LTX2Scheduler` for sigma scheduling
- `EulerDiffusionStep` for diffusion steps
- `CFGGuider` for classifier-free guidance
- `STGGuider` for spatio-temporal guidance
- Manages the full validation lifecycle: embedding caching, media encoding, denoising, decoding
- Supports all validation condition types: first_frame, prefix, suffix, spatial_crop, mask, reference, video_to_audio, audio_to_video
- Handles frozen modality paths (sigma=0 for conditioning modality)
- Builds conditioning items using ltx-core's `VideoConditionByLatentIndex`, `VideoConditionByReferenceLatent`, `VideoConditionByMask`
- Optional side-by-side reference output for IC-LoRA validation
**`src/ltx_trainer/timestep_samplers.py`** - Flow matching timestep sampling
@@ -367,12 +445,18 @@ constructs the `GemmaTextEncoder` with quantized model, feature extractor, and e
**`src/ltx_trainer/datasets.py`** - Dataset handling
- `PrecomputedDataset` loads pre-computed VAE latents and text embeddings
- Supports video latents, audio latents, text embeddings, reference latents (for IC-LoRA)
- Supports video latents, audio latents, text embeddings, reference video latents, reference audio latents, video masks, and audio masks
- Handles legacy patchified format `[seq_len, C]` → automatically unpatchifies to `[C, F, H, W]`
- `DummyDataset` for benchmarking and minimal testing
## Common Development Tasks
### Agent-Assisted Training
When a user asks to train, fine-tune, create a LoRA, or produce a custom LTX-2 model, use the repository skill at
[`.claude/skills/train-model`](../../.claude/skills/train-model/SKILL.md). The skill is the orchestrator for dataset probing, mode selection, preprocessing,
training launch, monitoring, and post-train validation; it treats `packages/ltx-trainer/docs/` as the source of truth.
### Adding a New Configuration Parameter
1. Add field to appropriate config class in `src/ltx_trainer/config.py`
@@ -382,10 +466,16 @@ constructs the `GemmaTextEncoder` with quantized model, feature extractor, and e
### Implementing a New Training Strategy
The `FlexibleStrategy` now covers all use cases (T2V, T2A, I2V, V2V, A2A, AV2AV, inpainting, outpainting, extension, A2V, V2A, IC-LoRA) through
configuration alone. A new strategy is only needed for fundamentally different training paradigms that cannot be
expressed via `ModalityConfig` + `ConditionConfig` combinations.
If you do need a new strategy:
1. Create new file in `src/ltx_trainer/training_strategies/`
2. Create config class inheriting `TrainingStrategyConfigBase`
2. Create config class inheriting `TrainingStrategyConfigBase` and implement `get_data_sources()`
3. Create strategy class inheriting `TrainingStrategy`
4. Implement: `get_data_sources()`, `prepare_training_inputs()`, `compute_loss()`
4. Implement: `prepare_training_inputs()`, `compute_loss()`
5. Add to `__init__.py`: import, add to `TrainingStrategyConfig` union, update factory
6. Add discriminator tag to config.py's `TrainingStrategyConfig`
7. Create example config file in `configs/`
@@ -449,8 +539,8 @@ video_embeds, audio_embeds, binary_mask = text_encoder.embeddings_processor.crea
- Validation errors: Check validators in `config.py`
- Unknown fields: Config uses `extra="forbid"` — all fields must be defined
- Strategy validation: IC-LoRA requires `reference_videos` in validation config
- Video-to-video strategy requires `training_mode: "lora"`
- FlexibleStrategy requires at least one modality with `is_generated: true`
- Audio modality cannot use `first_frame` or `spatial_crop` conditions
**Precomputed Data:**
@@ -480,7 +570,7 @@ Width and height must be divisible by 32.
### Platform Requirements
- Linux required (uses `triton` which is Linux-only)
- CUDA GPU with 24GB+ VRAM recommended (80GB+ for full fine-tuning)
- CUDA GPU with 32GB+ VRAM recommended
## Reference: ltx-core Key Components
+12 -3
View File
@@ -1,8 +1,10 @@
# LTX-2 Trainer
This package provides tools and scripts for training and fine-tuning
Lightricks' **LTX-2** audio-video generation model. It enables LoRA training, full
fine-tuning, and training of video-to-video transformations (IC-LoRA) on custom datasets.
Lightricks' **LTX-2** audio-video generation model. It supports LoRA training, full
fine-tuning, and a flexible conditioning framework covering text-to-video, text-to-audio, image-to-video,
video extension, audio extension, video inpainting, audio inpainting, video outpainting, IC-LoRA for video, audio, and joint
audio-video references, audio-to-video, and video-to-audio.
---
@@ -17,9 +19,16 @@ All detailed guides and technical documentation are in the [docs](./docs/) direc
- [🚀 Training Guide](docs/training-guide.md)
- [🧪 Inference Guide](../ltx-pipelines/README.md)
- [🔧 Utility Scripts](docs/utility-scripts.md)
- [🧩 Custom Training Strategies](docs/custom-training-strategies.md)
- [📚 LTX-Core Documentation](../ltx-core/README.md)
- [🛡️ Troubleshooting Guide](docs/troubleshooting.md)
### 🤖 Agent-Assisted Training
Use the [`train-model`](../../.claude/skills/train-model/SKILL.md) repository skill for an end-to-end guided run:
it probes your data and hardware, chooses the matching training mode, prepares/preprocesses the dataset, launches
training, and monitors the job while using the docs above as the source of truth.
---
## 🔧 Requirements
@@ -28,7 +37,7 @@ All detailed guides and technical documentation are in the [docs](./docs/) direc
- **Gemma Text Encoder** - Local Gemma model directory (required for LTX-2)
- **Linux with CUDA** - CUDA 13+ recommended for optimal performance
- **Nvidia GPU with 80GB+ VRAM** - Recommended for the standard config. For GPUs with 32GB VRAM (e.g., RTX 5090),
use the [low VRAM config](configs/ltx2_av_lora_low_vram.yaml) which enables INT8 quantization and other
use the [low VRAM config](configs/t2v_lora_low_vram.yaml) which enables INT8 quantization and other
memory optimizations
---
@@ -1,315 +0,0 @@
# =============================================================================
# LTX-2 Audio-Video LoRA Training Configuration
# =============================================================================
#
# This configuration is for training LoRA adapters on the LTX-2 model for
# text-to-video generation. It supports both video-only and joint audio-video
# training modes.
#
# Use this configuration when you want to:
# - Fine-tune LTX-2 on your own video dataset
# - Train with or without audio generation
# - Create custom video generation styles or audiovisual concepts
#
# Dataset structure for text-to-video training:
# preprocessed_data_root/
# ├── latents/ # Video latents (VAE-encoded videos)
# ├── conditions/ # Text embeddings for each video
# └── audio_latents/ # Audio latents (only if with_audio: true)
#
# =============================================================================
# -----------------------------------------------------------------------------
# Model Configuration
# -----------------------------------------------------------------------------
# Specifies the base model to fine-tune and the training mode.
model:
# Path to the LTX-2 model checkpoint (.safetensors file)
# This should be a local path to your downloaded model
model_path: "path/to/ltx-2-model.safetensors"
# Path to the text encoder model directory
# For LTX-2, this is typically the Gemma-based text encoder
text_encoder_path: "path/to/gemma-text-encoder"
# Training mode: "lora" for efficient adapter training, "full" for full fine-tuning
# LoRA is recommended for most use cases (faster, less memory, prevents overfitting)
training_mode: "lora"
# Optional: Path to resume training from a checkpoint
# Can be a checkpoint file (.safetensors) or directory (uses latest checkpoint)
load_checkpoint: null
# -----------------------------------------------------------------------------
# LoRA Configuration
# -----------------------------------------------------------------------------
# Controls the Low-Rank Adaptation parameters for efficient fine-tuning.
lora:
# Rank of the LoRA matrices (higher = more capacity but more parameters)
# Typical values: 8, 16, 32, 64. Start with 32 for general fine-tuning.
rank: 32
# Alpha scaling factor (usually set equal to rank)
# The effective scaling is alpha/rank, so alpha=rank means scaling of 1.0
alpha: 32
# Dropout probability for LoRA layers (0.0 = no dropout)
# Can help with regularization if overfitting occurs
dropout: 0.0
# Which transformer modules to apply LoRA to
# The LTX-2 transformer has separate attention and FFN blocks for video and audio:
#
# VIDEO MODULES:
# - attn1.to_k, attn1.to_q, attn1.to_v, attn1.to_out.0 (video self-attention)
# - attn2.to_k, attn2.to_q, attn2.to_v, attn2.to_out.0 (video cross-attention to text)
# - ff.net.0.proj, ff.net.2 (video feed-forward)
#
# AUDIO MODULES:
# - audio_attn1.to_k, audio_attn1.to_q, audio_attn1.to_v, audio_attn1.to_out.0 (audio self-attention)
# - audio_attn2.to_k, audio_attn2.to_q, audio_attn2.to_v, audio_attn2.to_out.0 (audio cross-attention to text)
# - audio_ff.net.0.proj, audio_ff.net.2 (audio feed-forward)
#
# AUDIO-VIDEO CROSS-ATTENTION MODULES (for cross-modal interaction):
# - audio_to_video_attn.to_k, audio_to_video_attn.to_q, audio_to_video_attn.to_v, audio_to_video_attn.to_out.0
# (Q from video, K/V from audio - allows video to attend to audio features)
# - video_to_audio_attn.to_k, video_to_audio_attn.to_q, video_to_audio_attn.to_v, video_to_audio_attn.to_out.0
# (Q from audio, K/V from video - allows audio to attend to video features)
#
# Using short patterns like "to_k" matches ALL attention modules (video, audio, and cross-modal).
# For audio-video training, this is the recommended approach.
target_modules:
# Attention layers (matches both video and audio branches)
- "to_k"
- "to_q"
- "to_v"
- "to_out.0"
# Uncomment below to also train feed-forward layers (can increase the LoRA's capacity):
# - "ff.net.0.proj"
# - "ff.net.2"
# - "audio_ff.net.0.proj"
# - "audio_ff.net.2"
# -----------------------------------------------------------------------------
# Training Strategy Configuration
# -----------------------------------------------------------------------------
# Defines the text-to-video training approach.
training_strategy:
# Strategy name: "text_to_video" for standard text-to-video training
name: "text_to_video"
# Probability of conditioning on the first frame during training
# Higher values train the model to perform better in image-to-video (I2V) mode,
# where a clean first frame is provided and the model generates the rest of the video
# Increase this value to train the model to perform better in image-to-video (I2V) mode
first_frame_conditioning_p: 0.5
# Enable joint audio-video training
# Set to true if your dataset includes audio and you want to train the audio branch
with_audio: true
# Directory name (within preprocessed_data_root) containing audio latents
# Only used when with_audio is true
audio_latents_dir: "audio_latents"
# -----------------------------------------------------------------------------
# Optimization Configuration
# -----------------------------------------------------------------------------
# Controls the training optimization parameters.
optimization:
# Learning rate for the optimizer
# Typical range for LoRA: 1e-5 to 1e-4
learning_rate: 1e-4
# Total number of training steps
steps: 2000
# Batch size per GPU
# Reduce if running out of memory
batch_size: 1
# Number of gradient accumulation steps
# Effective batch size = batch_size * gradient_accumulation_steps * num_gpus
gradient_accumulation_steps: 1
# Maximum gradient norm for clipping (helps training stability)
max_grad_norm: 1.0
# Optimizer type: "adamw" (standard) or "adamw8bit" (memory-efficient)
optimizer_type: "adamw"
# Learning rate scheduler type
# Options: "constant", "linear", "cosine", "cosine_with_restarts", "polynomial"
scheduler_type: "linear"
# Additional scheduler parameters (depends on scheduler_type)
scheduler_params: { }
# Enable gradient checkpointing to reduce memory usage
# Recommended for training with limited GPU memory
enable_gradient_checkpointing: true
# -----------------------------------------------------------------------------
# Acceleration Configuration
# -----------------------------------------------------------------------------
# Hardware acceleration and memory optimization settings.
acceleration:
# Mixed precision training mode
# Options: "no" (fp32), "fp16" (half precision), "bf16" (bfloat16, recommended)
mixed_precision_mode: "bf16"
# Model quantization for reduced memory usage
# Options: null (none), "int8-quanto", "int4-quanto", "int2-quanto", "fp8-quanto", "fp8uz-quanto"
quantization: null
# Load text encoder in 8-bit precision to save memory
# Useful when GPU memory is limited
load_text_encoder_in_8bit: false
# Offload optimizer state to CPU during validation video sampling.
# Helps avoid OOM when VAE decoder + transformer + optimizer state can't coexist
# on the GPU (full fine-tune, high-rank LoRA). No effect for FSDP.
offload_optimizer_during_validation: false
# -----------------------------------------------------------------------------
# Data Configuration
# -----------------------------------------------------------------------------
# Specifies the training data location and loading parameters.
data:
# Root directory containing preprocessed training data
# Should contain: latents/, conditions/, and optionally audio_latents/
preprocessed_data_root: "/path/to/preprocessed/data"
# Number of worker processes for data loading
# Used for parallel data loading to speed up data loading
num_dataloader_workers: 2
# -----------------------------------------------------------------------------
# Validation Configuration
# -----------------------------------------------------------------------------
# Controls validation video generation during training.
# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over
# maximum quality. For production-quality inference, use `packages/ltx-pipelines`.
validation:
# Text prompts for validation video generation
# Provide prompts representative of your training data
# LTX-2 prefers longer, detailed prompts that describe both visual content and audio
prompts:
- "A woman with long brown hair sits at a wooden desk in a cozy home office, typing on a laptop while occasionally glancing at notes beside her. Soft natural light streams through a large window, casting warm shadows across the room. She pauses to take a sip from a ceramic mug, then continues working with focused concentration. The audio captures the gentle clicking of keyboard keys, the soft rustle of papers, and ambient room tone with occasional distant bird chirps from outside."
- "A chef in a white uniform stands in a professional kitchen, carefully plating a gourmet dish with precise movements. Steam rises from freshly cooked vegetables as he arranges them with tweezers. The stainless steel surfaces gleam under bright overhead lights, and various pots simmer on the stove behind him. The audio features the sizzling of pans, the clinking of utensils against plates, and the ambient hum of kitchen ventilation."
# Negative prompt to avoid unwanted artifacts
negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted"
# Optional: First frame images for image-to-video validation
# If provided, must have one image per prompt
images: null
# Output video dimensions [width, height, frames]
# Width and height must be divisible by 32
# Frames must satisfy: frames % 8 == 1 (e.g., 1, 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, ...)
video_dims: [ 576, 576, 89 ]
# Frame rate for generated videos
frame_rate: 25.0
# Random seed for reproducible validation outputs
seed: 42
# Number of denoising steps for validation inference
# Higher values = better quality but slower generation
inference_steps: 30
# Generate validation videos every N training steps
# Set to null to disable validation during training
interval: 100
# Classifier-free guidance scale
# Higher values = stronger adherence to prompt but may introduce artifacts
guidance_scale: 4.0
# STG (Spatio-Temporal Guidance) parameters for improved video quality
# STG is combined with CFG for better temporal coherence
stg_scale: 1.0 # Recommended: 1.0 (0.0 disables STG)
stg_blocks: [29] # Recommended: single block 29
stg_mode: "stg_av" # "stg_av" perturbs both audio and video, "stg_v" video only
# Whether to generate audio in validation samples
# Independent of training_strategy.with_audio - you can generate audio
# in validation even when not training the audio branch
generate_audio: true
# Skip validation at the beginning of training (step 0)
skip_initial_validation: false
# -----------------------------------------------------------------------------
# Checkpoint Configuration
# -----------------------------------------------------------------------------
# Controls model checkpoint saving during training.
checkpoints:
# Save a checkpoint every N steps
# Set to null to disable intermediate checkpoints
interval: 250
# Number of most recent checkpoints to keep
# Set to -1 to keep all checkpoints
keep_last_n: -1
# Precision to use when saving checkpoint weights
# Options: "bfloat16" (default, smaller files) or "float32" (full precision)
precision: "bfloat16"
# -----------------------------------------------------------------------------
# Flow Matching Configuration
# -----------------------------------------------------------------------------
# Parameters for the flow matching training objective.
flow_matching:
# Timestep sampling mode
# "shifted_logit_normal" is recommended for LTX-2 models
timestep_sampling_mode: "shifted_logit_normal"
# Additional parameters for timestep sampling
timestep_sampling_params: { }
# -----------------------------------------------------------------------------
# Hugging Face Hub Configuration
# -----------------------------------------------------------------------------
# Settings for uploading trained models to the Hugging Face Hub.
hub:
# Whether to push the trained model to the Hub
push_to_hub: false
# Repository ID on Hugging Face Hub (e.g., "username/my-lora-model")
# Required if push_to_hub is true
hub_model_id: null
# -----------------------------------------------------------------------------
# Weights & Biases Configuration
# -----------------------------------------------------------------------------
# Settings for experiment tracking with W&B.
wandb:
# Enable W&B logging
enabled: false
# W&B project name
project: "ltx-2-trainer"
# W&B username or team (null uses default account)
entity: null
# Tags to help organize runs
tags: [ "ltx2", "lora" ]
# Log validation videos to W&B
log_validation_videos: true
# -----------------------------------------------------------------------------
# General Configuration
# -----------------------------------------------------------------------------
# Global settings for the training run.
# Random seed for reproducibility
seed: 42
# Directory to save outputs (checkpoints, validation videos, logs)
output_dir: "outputs/ltx2_av_lora"
@@ -1,327 +0,0 @@
# =============================================================================
# LTX-2 Audio-Video LoRA Training Configuration (Low VRAM)
# =============================================================================
#
# This is a memory-optimized variant of the standard audio-video LoRA config.
# It uses 8-bit optimizer, int8 quantization, and reduced LoRA rank to minimize
# GPU memory usage while maintaining good training quality.
#
# Memory optimizations applied:
# - 8-bit AdamW optimizer (reduces optimizer state memory by ~75%)
# - INT8 model quantization (reduces model memory by ~50%)
# - Lower LoRA rank (16 vs 32, reduces trainable parameters)
# - Gradient checkpointing enabled
#
# Recommended for GPUs with 32GB VRAM (e.g., RTX 5090).
#
# Use this configuration when you want to:
# - Fine-tune LTX-2 on your own video dataset
# - Train with or without audio generation
# - Create custom video generation styles or audiovisual concepts
#
# Dataset structure for text-to-video training:
# preprocessed_data_root/
# ├── latents/ # Video latents (VAE-encoded videos)
# ├── conditions/ # Text embeddings for each video
# └── audio_latents/ # Audio latents (only if with_audio: true)
#
# =============================================================================
# -----------------------------------------------------------------------------
# Model Configuration
# -----------------------------------------------------------------------------
# Specifies the base model to fine-tune and the training mode.
model:
# Path to the LTX-2 model checkpoint (.safetensors file)
# This should be a local path to your downloaded model
model_path: "path/to/ltx-2-model.safetensors"
# Path to the text encoder model directory
# For LTX-2, this is typically the Gemma-based text encoder
text_encoder_path: "path/to/gemma-text-encoder"
# Training mode: "lora" for efficient adapter training, "full" for full fine-tuning
# LoRA is recommended for most use cases (faster, less memory, prevents overfitting)
training_mode: "lora"
# Optional: Path to resume training from a checkpoint
# Can be a checkpoint file (.safetensors) or directory (uses latest checkpoint)
load_checkpoint: null
# -----------------------------------------------------------------------------
# LoRA Configuration
# -----------------------------------------------------------------------------
# Controls the Low-Rank Adaptation parameters for efficient fine-tuning.
# Using a lower rank (16) to reduce trainable parameters and memory usage.
# This still provides good capacity for many fine-tuning tasks.
lora:
# Rank of the LoRA matrices (higher = more capacity but more parameters)
# Typical values: 8, 16, 32, 64. Using 16 for low VRAM configuration.
rank: 16
# Alpha scaling factor (usually set equal to rank)
# The effective scaling is alpha/rank, so alpha=rank means scaling of 1.0
alpha: 16
# Dropout probability for LoRA layers (0.0 = no dropout)
# Can help with regularization if overfitting occurs
dropout: 0.0
# Which transformer modules to apply LoRA to
# The LTX-2 transformer has separate attention and FFN blocks for video and audio:
#
# VIDEO MODULES:
# - attn1.to_k, attn1.to_q, attn1.to_v, attn1.to_out.0 (video self-attention)
# - attn2.to_k, attn2.to_q, attn2.to_v, attn2.to_out.0 (video cross-attention to text)
# - ff.net.0.proj, ff.net.2 (video feed-forward)
#
# AUDIO MODULES:
# - audio_attn1.to_k, audio_attn1.to_q, audio_attn1.to_v, audio_attn1.to_out.0 (audio self-attention)
# - audio_attn2.to_k, audio_attn2.to_q, audio_attn2.to_v, audio_attn2.to_out.0 (audio cross-attention to text)
# - audio_ff.net.0.proj, audio_ff.net.2 (audio feed-forward)
#
# AUDIO-VIDEO CROSS-ATTENTION MODULES (for cross-modal interaction):
# - audio_to_video_attn.to_k, audio_to_video_attn.to_q, audio_to_video_attn.to_v, audio_to_video_attn.to_out.0
# (Q from video, K/V from audio - allows video to attend to audio features)
# - video_to_audio_attn.to_k, video_to_audio_attn.to_q, video_to_audio_attn.to_v, video_to_audio_attn.to_out.0
# (Q from audio, K/V from video - allows audio to attend to video features)
#
# Using short patterns like "to_k" matches ALL attention modules (video, audio, and cross-modal).
# For audio-video training, this is the recommended approach.
target_modules:
# Attention layers (matches both video and audio branches)
- "to_k"
- "to_q"
- "to_v"
- "to_out.0"
# Uncomment below to also train feed-forward layers (can increase the LoRA's capacity):
# - "ff.net.0.proj"
# - "ff.net.2"
# - "audio_ff.net.0.proj"
# - "audio_ff.net.2"
# -----------------------------------------------------------------------------
# Training Strategy Configuration
# -----------------------------------------------------------------------------
# Defines the text-to-video training approach.
training_strategy:
# Strategy name: "text_to_video" for standard text-to-video training
name: "text_to_video"
# Probability of conditioning on the first frame during training
# Higher values train the model to perform better in image-to-video (I2V) mode,
# where a clean first frame is provided and the model generates the rest of the video
# Increase this value to train the model to perform better in image-to-video (I2V) mode
first_frame_conditioning_p: 0.5
# Enable joint audio-video training
# Set to true if your dataset includes audio and you want to train the audio branch
with_audio: true
# Directory name (within preprocessed_data_root) containing audio latents
# Only used when with_audio is true
audio_latents_dir: "audio_latents"
# -----------------------------------------------------------------------------
# Optimization Configuration
# -----------------------------------------------------------------------------
# Controls the training optimization parameters.
optimization:
# Learning rate for the optimizer
# Typical range for LoRA: 1e-5 to 1e-4
learning_rate: 1e-4
# Total number of training steps
steps: 2000
# Batch size per GPU
# Reduce if running out of memory
batch_size: 1
# Number of gradient accumulation steps
# Effective batch size = batch_size * gradient_accumulation_steps * num_gpus
gradient_accumulation_steps: 1
# Maximum gradient norm for clipping (helps training stability)
max_grad_norm: 1.0
# Optimizer type: "adamw" (standard) or "adamw8bit" (memory-efficient)
# Using 8-bit AdamW to reduce optimizer state memory by ~75%
optimizer_type: "adamw8bit"
# Learning rate scheduler type
# Options: "constant", "linear", "cosine", "cosine_with_restarts", "polynomial"
scheduler_type: "linear"
# Additional scheduler parameters (depends on scheduler_type)
scheduler_params: { }
# Enable gradient checkpointing to reduce memory usage
# Recommended for training with limited GPU memory
enable_gradient_checkpointing: true
# -----------------------------------------------------------------------------
# Acceleration Configuration
# -----------------------------------------------------------------------------
# Hardware acceleration and memory optimization settings.
acceleration:
# Mixed precision training mode
# Options: "no" (fp32), "fp16" (half precision), "bf16" (bfloat16, recommended)
mixed_precision_mode: "bf16"
# Model quantization for reduced memory usage
# Options: null (none), "int8-quanto", "int4-quanto", "int2-quanto", "fp8-quanto", "fp8uz-quanto"
# Using INT8 quantization to reduce base model memory consumption by ~50%
quantization: "int8-quanto"
# Load text encoder in 8-bit precision to save memory
# Useful when GPU memory is limited
load_text_encoder_in_8bit: true
# Offload optimizer state to CPU during validation video sampling.
# Helps avoid OOM when VAE decoder + transformer + optimizer state can't coexist
# on the GPU (full fine-tune, high-rank LoRA). No effect for FSDP.
offload_optimizer_during_validation: true
# -----------------------------------------------------------------------------
# Data Configuration
# -----------------------------------------------------------------------------
# Specifies the training data location and loading parameters.
data:
# Root directory containing preprocessed training data
# Should contain: latents/, conditions/, and optionally audio_latents/
preprocessed_data_root: "/path/to/preprocessed/data"
# Number of worker processes for data loading
# Used for parallel data loading to speed up data loading
num_dataloader_workers: 2
# -----------------------------------------------------------------------------
# Validation Configuration
# -----------------------------------------------------------------------------
# Controls validation video generation during training.
# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over
# maximum quality. For production-quality inference, use `packages/ltx-pipelines`.
validation:
# Text prompts for validation video generation
# Provide prompts representative of your training data
# LTX-2 prefers longer, detailed prompts that describe both visual content and audio
prompts:
- "A woman with long brown hair sits at a wooden desk in a cozy home office, typing on a laptop while occasionally glancing at notes beside her. Soft natural light streams through a large window, casting warm shadows across the room. She pauses to take a sip from a ceramic mug, then continues working with focused concentration. The audio captures the gentle clicking of keyboard keys, the soft rustle of papers, and ambient room tone with occasional distant bird chirps from outside."
- "A chef in a white uniform stands in a professional kitchen, carefully plating a gourmet dish with precise movements. Steam rises from freshly cooked vegetables as he arranges them with tweezers. The stainless steel surfaces gleam under bright overhead lights, and various pots simmer on the stove behind him. The audio features the sizzling of pans, the clinking of utensils against plates, and the ambient hum of kitchen ventilation."
# Negative prompt to avoid unwanted artifacts
negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted"
# Optional: First frame images for image-to-video validation
# If provided, must have one image per prompt
images: null
# Output video dimensions [width, height, frames]
# Width and height must be divisible by 32
# Frames must satisfy: frames % 8 == 1 (e.g., 1, 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, ...)
video_dims: [ 576, 576, 49 ]
# Frame rate for generated videos
frame_rate: 25.0
# Random seed for reproducible validation outputs
seed: 42
# Number of denoising steps for validation inference
# Higher values = better quality but slower generation
inference_steps: 30
# Generate validation videos every N training steps
# Set to null to disable validation during training
interval: 100
# Classifier-free guidance scale
# Higher values = stronger adherence to prompt but may introduce artifacts
guidance_scale: 4.0
# STG (Spatio-Temporal Guidance) parameters for improved video quality
# STG is combined with CFG for better temporal coherence
stg_scale: 1.0 # Recommended: 1.0 (0.0 disables STG)
stg_blocks: [ 29 ] # Recommended: single block 29
stg_mode: "stg_av" # "stg_av" perturbs both audio and video, "stg_v" video only
# Whether to generate audio in validation samples
# Independent of training_strategy.with_audio - you can generate audio
# in validation even when not training the audio branch
generate_audio: true
# Skip validation at the beginning of training (step 0)
skip_initial_validation: false
# -----------------------------------------------------------------------------
# Checkpoint Configuration
# -----------------------------------------------------------------------------
# Controls model checkpoint saving during training.
checkpoints:
# Save a checkpoint every N steps
# Set to null to disable intermediate checkpoints
interval: 250
# Number of most recent checkpoints to keep
# Set to -1 to keep all checkpoints
keep_last_n: -1
# Precision to use when saving checkpoint weights
# Options: "bfloat16" (default, smaller files) or "float32" (full precision)
precision: "bfloat16"
# -----------------------------------------------------------------------------
# Flow Matching Configuration
# -----------------------------------------------------------------------------
# Parameters for the flow matching training objective.
flow_matching:
# Timestep sampling mode
# "shifted_logit_normal" is recommended for LTX-2 models
timestep_sampling_mode: "shifted_logit_normal"
# Additional parameters for timestep sampling
timestep_sampling_params: { }
# -----------------------------------------------------------------------------
# Hugging Face Hub Configuration
# -----------------------------------------------------------------------------
# Settings for uploading trained models to the Hugging Face Hub.
hub:
# Whether to push the trained model to the Hub
push_to_hub: false
# Repository ID on Hugging Face Hub (e.g., "username/my-lora-model")
# Required if push_to_hub is true
hub_model_id: null
# -----------------------------------------------------------------------------
# Weights & Biases Configuration
# -----------------------------------------------------------------------------
# Settings for experiment tracking with W&B.
wandb:
# Enable W&B logging
enabled: false
# W&B project name
project: "ltx-2-trainer"
# W&B username or team (null uses default account)
entity: null
# Tags to help organize runs
tags: [ "ltx2", "lora" ]
# Log validation videos to W&B
log_validation_videos: true
# -----------------------------------------------------------------------------
# General Configuration
# -----------------------------------------------------------------------------
# Global settings for the training run.
# Random seed for reproducibility
seed: 42
# Directory to save outputs (checkpoints, validation videos, logs)
output_dir: "outputs/ltx2_av_lora"
@@ -1,331 +0,0 @@
# =============================================================================
# LTX-2 Video-to-Video (IC-LoRA) Training Configuration
# =============================================================================
#
# This configuration is for training In-Context LoRA (IC-LoRA) adapters that
# enable video-to-video transformations. IC-LoRA learns to apply visual
# transformations (e.g., depth-to-video, pose control, style transfer, etc.)
# by conditioning on reference videos.
#
# Key differences from text-to-video LoRA:
# - Uses reference videos as conditioning input alongside text prompts
# - Requires preprocessed reference latents in addition to target latents
# - Validation requires reference videos to demonstrate the transformation
#
# Dataset structure for IC-LoRA training:
# preprocessed_data_root/
# ├── latents/ # Target video latents (what the model learns to generate)
# ├── conditions/ # Text embeddings for each video
# └── reference_latents/ # Reference video latents (conditioning input)
#
# =============================================================================
# -----------------------------------------------------------------------------
# Model Configuration
# -----------------------------------------------------------------------------
# Specifies the base model to fine-tune and the training mode.
model:
# Path to the LTX-2 model checkpoint (.safetensors file)
# This should be a local path to your downloaded model
model_path: "path/to/ltx-2-model.safetensors"
# Path to the text encoder model directory
# For LTX-2, this is typically the Gemma-based text encoder
text_encoder_path: "path/to/gemma-text-encoder"
# Training mode: "lora" for efficient adapter training, "full" for full fine-tuning
# Note: video_to_video strategy requires "lora" mode
training_mode: "lora"
# Optional: Path to resume training from a checkpoint
# Can be a checkpoint file (.safetensors) or directory (uses latest checkpoint)
load_checkpoint: null
# -----------------------------------------------------------------------------
# LoRA Configuration
# -----------------------------------------------------------------------------
# Controls the Low-Rank Adaptation parameters for efficient fine-tuning.
lora:
# Rank of the LoRA matrices (higher = more capacity but more parameters)
# Typical values: 8, 16, 32, 64. Start with 16-32 for IC-LoRA.
rank: 32
# Alpha scaling factor (usually set equal to rank)
# The effective scaling is alpha/rank, so alpha=rank means scaling of 1.0
alpha: 32
# Dropout probability for LoRA layers (0.0 = no dropout)
# Can help with regularization if overfitting occurs
dropout: 0.0
# Which transformer modules to apply LoRA to
# The LTX-2 transformer has separate attention and FFN blocks for video and audio:
#
# VIDEO MODULES:
# - attn1.to_k, attn1.to_q, attn1.to_v, attn1.to_out.0 (video self-attention)
# - attn2.to_k, attn2.to_q, attn2.to_v, attn2.to_out.0 (video cross-attention to text)
# - ff.net.0.proj, ff.net.2 (video feed-forward)
#
# AUDIO MODULES (not used for video-only IC-LoRA):
# - audio_attn1.to_k, audio_attn1.to_q, audio_attn1.to_v, audio_attn1.to_out.0 (audio self-attention)
# - audio_attn2.to_k, audio_attn2.to_q, audio_attn2.to_v, audio_attn2.to_out.0 (audio cross-attention to text)
# - audio_ff.net.0.proj, audio_ff.net.2 (audio feed-forward)
#
# AUDIO-VIDEO CROSS-ATTENTION MODULES (for cross-modal interaction, not used for video-only IC-LoRA):
# - audio_to_video_attn.to_k, audio_to_video_attn.to_q, audio_to_video_attn.to_v, audio_to_video_attn.to_out.0
# (Q from video, K/V from audio - allows video to attend to audio features)
# - video_to_audio_attn.to_k, video_to_audio_attn.to_q, video_to_audio_attn.to_v, video_to_audio_attn.to_out.0
# (Q from audio, K/V from video - allows audio to attend to video features)
#
# For IC-LoRA (video-only), we explicitly target video modules.
# Including FFN layers often improves transformation quality.
target_modules:
# Video self-attention
- "attn1.to_k"
- "attn1.to_q"
- "attn1.to_v"
- "attn1.to_out.0"
# Video cross-attention
- "attn2.to_k"
- "attn2.to_q"
- "attn2.to_v"
- "attn2.to_out.0"
# Video feed-forward (often improves transformation quality)
- "ff.net.0.proj"
- "ff.net.2"
# -----------------------------------------------------------------------------
# Training Strategy Configuration
# -----------------------------------------------------------------------------
# Defines the video-to-video (IC-LoRA) training approach.
training_strategy:
# Strategy name: "video_to_video" for IC-LoRA training
name: "video_to_video"
# Probability of conditioning on the first frame during training
# Higher values train the model to perform better in image-to-video (I2V) mode,
# where a clean first frame is provided and the model generates the rest of the video
# Increase this value to train the model to perform better in image-to-video (I2V) mode
first_frame_conditioning_p: 0.2
# Directory name (within preprocessed_data_root) containing reference video latents
# These are the conditioning inputs that guide the transformation
reference_latents_dir: "reference_latents"
# -----------------------------------------------------------------------------
# Optimization Configuration
# -----------------------------------------------------------------------------
# Controls the training optimization parameters.
optimization:
# Learning rate for the optimizer
# Typical range for LoRA: 1e-5 to 1e-4
learning_rate: 2e-4
# Total number of training steps
steps: 3000
# Batch size per GPU
# Reduce if running out of memory
batch_size: 1
# Number of gradient accumulation steps
# Effective batch size = batch_size * gradient_accumulation_steps * num_gpus
gradient_accumulation_steps: 1
# Maximum gradient norm for clipping (helps training stability)
max_grad_norm: 1.0
# Optimizer type: "adamw" (standard) or "adamw8bit" (memory-efficient)
optimizer_type: "adamw"
# Learning rate scheduler type
# Options: "constant", "linear", "cosine", "cosine_with_restarts", "polynomial"
scheduler_type: "linear"
# Additional scheduler parameters (depends on scheduler_type)
scheduler_params: { }
# Enable gradient checkpointing to reduce memory usage
# Recommended for training with limited GPU memory
enable_gradient_checkpointing: true
# -----------------------------------------------------------------------------
# Acceleration Configuration
# -----------------------------------------------------------------------------
# Hardware acceleration and memory optimization settings.
acceleration:
# Mixed precision training mode
# Options: "no" (fp32), "fp16" (half precision), "bf16" (bfloat16, recommended)
mixed_precision_mode: "bf16"
# Model quantization for reduced memory usage
# Options: null (none), "int8-quanto", "int4-quanto", "int2-quanto", "fp8-quanto", "fp8uz-quanto"
quantization: null
# Load text encoder in 8-bit precision to save memory
# Useful when GPU memory is limited
load_text_encoder_in_8bit: false
# Offload optimizer state to CPU during validation video sampling.
# Helps avoid OOM when VAE decoder + transformer + optimizer state can't coexist
# on the GPU (full fine-tune, high-rank LoRA). No effect for FSDP.
offload_optimizer_during_validation: false
# -----------------------------------------------------------------------------
# Data Configuration
# -----------------------------------------------------------------------------
# Specifies the training data location and loading parameters.
data:
# Root directory containing preprocessed training data
# Should contain: latents/, conditions/, and reference_latents/ subdirectories
preprocessed_data_root: "/path/to/preprocessed/data"
# Number of worker processes for data loading
# Used for parallel data loading to speed up data loading
num_dataloader_workers: 2
# -----------------------------------------------------------------------------
# Validation Configuration
# -----------------------------------------------------------------------------
# Controls validation video generation during training.
# NOTE: Validation sampling use simplified inference pipelines and prioritizes speed over
# maximum quality. For production-quality inference, use `packages/ltx-pipelines`.
validation:
# Text prompts for validation video generation
# Provide prompts representative of your training data
# LTX-2 prefers longer, detailed prompts that describe both visual content and audio
prompts:
- "A man in a casual blue jacket walks along a winding path through a lush green park on a bright sunny afternoon. Tall oak trees line the pathway, their leaves rustling gently in the breeze. Dappled sunlight creates shifting patterns on the ground as he strolls at a relaxed pace, occasionally looking up at the scenery around him. The audio captures footsteps on gravel, birds singing in the trees, distant children playing, and the soft whisper of wind through the foliage."
- "A fluffy orange tabby cat sits perfectly still on a wooden windowsill, its green eyes intently tracking small birds hopping on a branch just outside the glass. The cat's ears twitch and rotate, following every movement. Warm afternoon light illuminates its fur, creating a soft golden glow. Behind the cat, a cozy living room with a bookshelf and houseplants is visible. The audio features gentle purring, occasional soft meows, muffled bird chirps through the window, and quiet ambient room sounds."
# Reference videos for validation (REQUIRED for video_to_video strategy)
# Must provide one reference video per prompt
# These are the conditioning inputs for generating validation outputs
reference_videos:
- "/path/to/reference_video_1.mp4"
- "/path/to/reference_video_2.mp4"
# Downscale factor for reference videos (for efficient IC-LoRA training)
# When > 1, reference videos are processed at 1/n resolution
# Must match the --reference-downscale-factor used during dataset preprocessing
# Examples: 1 = same resolution, 2 = half resolution (384x384 ref for 768x768 target)
reference_downscale_factor: 1
# Negative prompt to avoid unwanted artifacts
negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted"
# Optional: First frame images for additional conditioning
# If provided, must have one image per prompt
images: null
# Output video dimensions [width, height, frames]
# Width and height must be divisible by 32
# Frames must satisfy: frames % 8 == 1 (e.g., 1, 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, ...)
video_dims: [ 512, 512, 81 ]
# Frame rate for generated videos
frame_rate: 25.0
# Random seed for reproducible validation outputs
seed: 42
# Number of denoising steps for validation inference
# Higher values = better quality but slower generation
inference_steps: 30
# Generate validation videos every N training steps
# Set to null to disable validation during training
interval: 100
# Classifier-free guidance scale
# Higher values = stronger adherence to prompt but may introduce artifacts
guidance_scale: 4.0
# STG (Spatio-Temporal Guidance) parameters for improved video quality
# STG is combined with CFG for better temporal coherence
stg_scale: 1.0 # Recommended: 1.0 (0.0 disables STG)
stg_blocks: [29] # Recommended: single block 29
stg_mode: "stg_v" # "stg_v" for video-only (no audio training)
# Whether to generate audio in validation samples
# Can be enabled even when not training the audio branch
generate_audio: false
# Skip validation at the beginning of training (step 0)
skip_initial_validation: false
# Concatenate reference video side-by-side with generated output
# Useful for visually comparing the transformation quality
include_reference_in_output: true
# -----------------------------------------------------------------------------
# Checkpoint Configuration
# -----------------------------------------------------------------------------
# Controls model checkpoint saving during training.
checkpoints:
# Save a checkpoint every N steps
# Set to null to disable intermediate checkpoints
interval: 250
# Number of most recent checkpoints to keep
# Set to -1 to keep all checkpoints
keep_last_n: 3
# Precision to use when saving checkpoint weights
# Options: "bfloat16" (default, smaller files) or "float32" (full precision)
precision: "bfloat16"
# -----------------------------------------------------------------------------
# Flow Matching Configuration
# -----------------------------------------------------------------------------
# Parameters for the flow matching training objective.
flow_matching:
# Timestep sampling mode
# "shifted_logit_normal" is recommended for LTX-2 models
timestep_sampling_mode: "shifted_logit_normal"
# Additional parameters for timestep sampling
timestep_sampling_params: { }
# -----------------------------------------------------------------------------
# Hugging Face Hub Configuration
# -----------------------------------------------------------------------------
# Settings for uploading trained models to the Hugging Face Hub.
hub:
# Whether to push the trained model to the Hub
push_to_hub: false
# Repository ID on Hugging Face Hub (e.g., "username/my-ic-lora-model")
# Required if push_to_hub is true
hub_model_id: null
# -----------------------------------------------------------------------------
# Weights & Biases Configuration
# -----------------------------------------------------------------------------
# Settings for experiment tracking with W&B.
wandb:
# Enable W&B logging
enabled: false
# W&B project name
project: "ltx-2-trainer"
# W&B username or team (null uses default account)
entity: null
# Tags to help organize runs
tags: [ "ltx2", "ic-lora", "video-to-video" ]
# Log validation videos to W&B
log_validation_videos: true
# -----------------------------------------------------------------------------
# General Configuration
# -----------------------------------------------------------------------------
# Global settings for the training run.
# Random seed for reproducibility
seed: 42
# Directory to save outputs (checkpoints, validation videos, logs)
output_dir: "outputs/ltx2_v2v_ic_lora"
@@ -10,7 +10,7 @@ sub-configurations:
- **ModelConfig**: Base model and training mode settings
- **LoraConfig**: LoRA training parameters
- **TrainingStrategyConfig**: Training strategy settings (text-to-video or video-to-video)
- **TrainingStrategyConfig**: Training strategy settings (flexible conditioning framework)
- **OptimizationConfig**: Learning rate, batch sizes, and scheduler settings
- **AccelerationConfig**: Mixed precision and quantization settings
- **DataConfig**: Data loading parameters
@@ -24,13 +24,29 @@ sub-configurations:
Check out our example configurations in the `configs` directory:
- 📄 [Audio-Video LoRA Training](../configs/ltx2_av_lora.yaml) - Joint audio-video generation training
- 📄 [Audio-Video LoRA Training (Low VRAM)](../configs/ltx2_av_lora_low_vram.yaml) - Memory-optimized config for 32GB
GPUs (uses 8-bit optimizer, INT8 quantization, and reduced LoRA rank)
- 📄 [IC-LoRA Training](../configs/ltx2_v2v_ic_lora.yaml) - Video-to-video transformation training
- 📄 [Text-to-Video LoRA](../configs/t2v_lora.yaml) - Text-to-video LoRA training
- 📄 [Image-to-Video LoRA](../configs/i2v_lora.yaml) - Image-to-video LoRA training
- 📄 [IC-LoRA Video-to-Video](../configs/v2v_ic_lora.yaml) - IC-LoRA video-to-video training
- 📄 [Audio-to-Video LoRA](../configs/a2v_lora.yaml) - Audio-to-video LoRA training
- 📄 [Video-to-Audio LoRA](../configs/v2a_lora.yaml) - Video-to-audio (Foley) LoRA training
- 📄 [Video Extension LoRA](../configs/video_extend_lora.yaml) - Video extension (forward) LoRA training
- 📄 [Video Suffix LoRA](../configs/video_suffix_lora.yaml) - Video extension (backward) LoRA training
- 📄 [Video Inpainting LoRA](../configs/video_inpainting_lora.yaml) - Video inpainting LoRA training
- 📄 [Video Outpainting LoRA](../configs/video_outpainting_lora.yaml) - Video outpainting (spatial crop) LoRA training
- 📄 [Text-to-Audio LoRA](../configs/t2a_lora.yaml) - Text-to-audio LoRA training
- 📄 [Audio Extension LoRA](../configs/audio_extend_lora.yaml) - Audio extension (forward) LoRA training
- 📄 [Audio Suffix LoRA](../configs/audio_suffix_lora.yaml) - Audio extension (backward) LoRA training
- 📄 [Audio Inpainting LoRA](../configs/audio_inpainting_lora.yaml) - Audio inpainting LoRA training
- 📄 [Audio-to-Audio IC-LoRA](../configs/a2a_ic_lora.yaml) - Audio IC-LoRA transformation training
- 📄 [AV2AV IC-LoRA](../configs/av2av_ic_lora.yaml) - Audio+video IC-LoRA transformation training
- 📄 [T2V LoRA (Low VRAM)](../configs/t2v_lora_low_vram.yaml) - Memory-optimized config for 32GB GPUs
## ⚙️ Configuration Sections
> [!NOTE]
> The YAML snippets below show **recommended starting values**, not necessarily the code defaults.
> Fields you omit from your config file will use the code defaults from [`config.py`](../src/ltx_trainer/config.py).
### ModelConfig
Controls the base model and training mode settings.
@@ -149,37 +165,60 @@ target_modules:
### TrainingStrategyConfig
Configures the training strategy. The trainer includes two built-in strategies described below.
For custom use cases, see [Implementing Custom Training Strategies](custom-training-strategies.md).
Configures the training strategy. The recommended strategy is `"flexible"`, which supports all conditioning scenarios through configuration.
#### Text-to-Video Strategy
#### Flexible Strategy
The flexible strategy provides a unified conditioning framework. Each modality (video, audio) is configured
independently with its own latents directory, generation flag, and list of conditions.
```yaml
training_strategy:
name: "text_to_video"
first_frame_conditioning_p: 0.1 # Probability of first-frame conditioning
with_audio: false # Enable joint audio-video training
audio_latents_dir: "audio_latents" # Directory for audio latents (when with_audio: true)
name: "flexible"
video:
is_generated: true # Video is denoised during training
latents_dir: "latents" # Directory containing precomputed video latents
conditions:
- type: first_frame # Use first frame as conditioning
probability: 0.5 # Apply this condition 50% of the time
audio:
is_generated: true # Audio is denoised during training
latents_dir: "audio_latents" # Directory containing precomputed audio latents
conditions: [] # No additional audio conditions (text-only)
```
#### Video-to-Video Strategy (IC-LoRA)
**ModalityConfig parameters:**
```yaml
training_strategy:
name: "video_to_video"
first_frame_conditioning_p: 0.1
reference_latents_dir: "reference_latents" # Directory for reference video latents
```
| Parameter | Description |
|----------------|------------------------------------------------------------------------------------------------------------------|
| `is_generated` | `true` = modality is denoised (contributes to loss). `false` = frozen conditioning (sigma=0, no loss). |
| `latents_dir` | Directory name within `preprocessed_data_root` containing precomputed latents for this modality. |
| `conditions` | List of conditioning configs applied during training (see condition types below). Text conditioning is implicit. |
**Key parameters:**
**Condition types:**
| Parameter | Description |
|------------------------------|------------------------------------------------------------------|
| `name` | Strategy type: `"text_to_video"` or `"video_to_video"` |
| `first_frame_conditioning_p` | Probability of using first frame as conditioning (0.0-1.0) |
| `with_audio` | (text_to_video only) Enable joint audio-video training |
| `audio_latents_dir` | (text_to_video only) Directory name for audio latents |
| `reference_latents_dir` | (video_to_video only) Directory name for reference video latents |
| Type | Parameters | Description |
|----------------|-----------------------------------------------------|---------------------------------------------------------------------------------------|
| `first_frame` | `probability` | First latent frame is clean, excluded from loss. **Video only.** |
| `prefix` | `temporal_boundary`, `probability` | First N latent temporal units are clean. For extension forward. |
| `suffix` | `temporal_boundary`, `probability` | Last N latent temporal units are clean. For extension backward. |
| `spatial_crop` | `spatial_region` (y1, x1, y2, x2 in px), `probability` | Rectangular region is clean, excluded from loss. For outpainting. **Video only.** |
| `mask` | `mask_dir`, `probability` | Per-sample mask directory. Masks are thresholded at `0.5`; `1` means conditioning, `0` means generate. |
| `reference` | `latents_dir`, `probability` | IC-LoRA style concatenation. Reference tokens are prepended, clean (timestep=0), no loss. |
> [!NOTE]
> The `prefix`, `suffix`, `mask`, and `reference` condition types work on both video and audio modalities —
> place them in the `video.conditions` or `audio.conditions` list as appropriate.
> `first_frame` and `spatial_crop` are video-only conditions.
> [!NOTE]
> Training conditions reference **directories** of precomputed data (within `preprocessed_data_root`),
> while validation conditions reference **individual files** (images, videos, masks) that are encoded
> on-the-fly during validation. The condition `type` names are the same, but the fields differ.
> [!NOTE]
> The legacy `text_to_video` and `video_to_video` strategies are deprecated but remain forward-compatible.
> New configs should use `name: "flexible"`.
### OptimizationConfig
@@ -206,7 +245,7 @@ optimization:
| `steps` | Total number of training steps |
| `batch_size` | Batch size per GPU (reduce if running out of memory) |
| `gradient_accumulation_steps` | Accumulate gradients over multiple steps |
| `scheduler_type` | LR scheduler: `"constant"`, `"linear"`, `"cosine"`, `"cosine_with_restarts"`, `"polynomial"` |
| `scheduler_type` | LR scheduler: `"constant"`, `"linear"`, `"cosine"`, `"cosine_with_restarts"`, `"polynomial"`, `"step"` |
| `enable_gradient_checkpointing` | Trade training speed for GPU memory savings (recommended for large models) |
### AccelerationConfig
@@ -226,7 +265,7 @@ acceleration:
| Parameter | Description |
|---------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `mixed_precision_mode` | Precision mode - `"bf16"` recommended for modern GPUs |
| `quantization` | Model quantization: `null`, `"int8-quanto"`, `"int4-quanto"`, `"fp8-quanto"`, etc. |
| `quantization` | Model quantization: `null`, `"int8-quanto"`, `"int4-quanto"`, `"int2-quanto"`, `"fp8-quanto"`, or `"fp8uz-quanto"` |
| `load_text_encoder_in_8bit` | Load the Gemma text encoder in 8-bit to save GPU memory |
| `offload_optimizer_during_validation` | Move optimizer state to CPU before validation video sampling and back afterwards. Useful when validation OOMs because VAE decoder + transformer + optimizer state can't coexist on the GPU (full fine-tune, high-rank LoRA). No effect for FSDP. |
@@ -244,50 +283,84 @@ data:
| Parameter | Description |
|--------------------------|--------------------------------------------------------------------------------------------|
| `preprocessed_data_root` | Path to your preprocessed dataset (contains `latents/`, `conditions/`, etc.) |
| `preprocessed_data_root` | Path to your preprocessed dataset directory produced by `process_dataset.py` (contains `latents/`, `conditions/`, etc.) |
| `num_dataloader_workers` | Number of parallel data loading processes (0 = synchronous loading, useful when debugging) |
### ValidationConfig
Validation and inference settings for monitoring training progress.
Validation and inference settings for monitoring training progress. Validation samples use a self-describing
format where each sample specifies its own prompt and conditions.
```yaml
validation:
prompts: # Validation prompts
- "A cat playing with a ball"
- "A dog running in a field"
negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted"
images: null # Optional image paths for image-to-video
reference_videos: null # Reference video paths (IC-LoRA only)
video_dims: [ 576, 576, 89 ] # Video dimensions [width, height, frames]
frame_rate: 25.0 # Frame rate for generated videos
seed: 42 # Random seed for reproducibility
inference_steps: 30 # Number of inference steps
interval: 100 # Steps between validation runs
guidance_scale: 4.0 # CFG guidance strength
stg_scale: 1.0 # STG guidance strength (0.0 to disable)
stg_blocks: [ 29 ] # Transformer blocks to perturb for STG
stg_mode: "stg_av" # "stg_av" or "stg_v" (video only)
generate_audio: true # Whether to generate audio
skip_initial_validation: false # Skip validation at step 0
include_reference_in_output: false # Include reference video side-by-side (IC-LoRA)
samples:
- prompt: "A cat playing with a ball"
conditions:
- type: first_frame
image_or_video: "/path/to/image.png"
- prompt: "A dog running in a field"
video_dims: [576, 576, 89] # Output dimensions: [width, height, frames]
negative_prompt: "worst quality, inconsistent motion, blurry, jittery, distorted" # Negative prompt for all samples
frame_rate: 25.0 # Output video frame rate (fps)
seed: 42 # Random seed for reproducibility
inference_steps: 30 # Number of denoising steps
interval: 100 # Run validation every N steps (null to disable)
guidance_scale: 4.0 # CFG scale (higher = stronger prompt adherence)
stg_scale: 1.0 # STG scale (0.0 to disable)
stg_blocks: [29] # Transformer blocks to apply STG perturbation
stg_mode: "stg_av" # STG mode: "stg_av" (audio+video) or "stg_v" (video only)
generate_audio: true # Whether to generate audio during validation
generate_video: true # Whether to generate video during validation
skip_initial_validation: false # Skip validation at step 0
```
**Key parameters:**
| Parameter | Description |
|-------------------------------|--------------------------------------------------------------------------------------------------------------------------|
| `prompts` | List of text prompts for validation video generation |
| `images` | List of image paths for image-to-video validation (must match number of prompts) |
| `reference_videos` | List of reference video paths for IC-LoRA validation (must match number of prompts) |
| `video_dims` | Output dimensions `[width, height, frames]`. Width/height must be divisible by 32, frames must satisfy `frames % 8 == 1` |
| `interval` | Steps between validation runs (set to `null` to disable) |
| `guidance_scale` | CFG (Classifier-Free Guidance) scale. Recommended: 4.0 |
| `stg_scale` | STG (Spatio-Temporal Guidance) scale. 0.0 disables STG. Recommended: 1.0 |
| `stg_blocks` | Transformer blocks to perturb for STG. Recommended: `[29]` (single block) |
| `stg_mode` | STG mode: `"stg_av"` perturbs both audio and video, `"stg_v"` perturbs video only |
| `generate_audio` | Whether to generate audio in validation samples |
| `include_reference_in_output` | For IC-LoRA: concatenate reference video side-by-side with output |
| Parameter | Description |
|--------------------------|--------------------------------------------------------------------------------------------------------------------------|
| `samples` | List of `ValidationSample` objects (see below). Replaces the legacy `prompts`/`images`/`reference_videos` fields. |
| `video_dims` | Output dimensions `[width, height, frames]`. Width/height must be divisible by 32, frames must satisfy `frames % 8 == 1` |
| `interval` | Steps between validation runs (set to `null` to disable) |
| `guidance_scale` | CFG (Classifier-Free Guidance) scale. Recommended: 4.0 |
| `stg_scale` | STG (Spatio-Temporal Guidance) scale. 0.0 disables STG. Recommended: 1.0 |
| `stg_blocks` | Transformer blocks to perturb for STG. Recommended: `[29]` (single block) |
| `stg_mode` | STG mode: `"stg_av"` perturbs both audio and video, `"stg_v"` perturbs video only |
| `generate_audio` | Whether to generate audio in validation samples |
| `generate_video` | Whether to generate video in validation samples. Set to `false` for V2A (video-to-audio) validation. Default: `true` |
| `skip_initial_validation`| Skip validation video sampling at step 0 (beginning of training) |
#### ValidationSample
Each sample in the `samples` list has:
| Field | Description |
|--------------|-------------------------------------------------------------------------------------------------|
| `prompt` | Text prompt for this validation sample. |
| `conditions` | List of validation conditions (see types below). Empty list = text-only generation. |
| `video_dims` | Optional per-sample override for `(width, height, frames)`. Inherits from `ValidationConfig` if not set. |
| `seed` | Optional per-sample override for random seed. Inherits from `ValidationConfig` if not set. |
#### Validation Condition Types
| Type | Parameters | Description |
|------------------|------------------------------------------------------------|-------------------------------------------------------------------------|
| `first_frame` | `image_or_video` (path) | Use the first frame of the image/video as conditioning. |
| `prefix` | `video` or `audio` (path), optional `num_frames`/`duration`| Use a video/audio clip as temporal prefix (for extension forward). |
| `suffix` | `video` or `audio` (path), optional `num_frames`/`duration`| Use a video/audio clip as temporal suffix (for extension backward). |
| `spatial_crop` | `video` (path), `spatial_region` (y1, x1, y2, x2) | Provide spatial context for outpainting. Video only. |
| `mask` | `video` or `audio` (path), `mask` (path) | Mask-based inpainting with a binary mask file. |
| `reference` | `video` or `audio` (path), optional video-reference `downscale_factor`, `temporal_scale_factor`, `include_in_output` | IC-LoRA style reference conditioning. |
| `video_to_audio` | `video` (path) | Freeze video, generate audio. For Foley/V2A tasks. |
| `audio_to_video` | `audio` (path) | Freeze audio, generate video. For audio-driven generation. |
For video `reference` validation conditions, `downscale_factor` is the spatial reference scale and
`temporal_scale_factor` is the temporal reference scale. Set both to match the factors used when
preprocessing video reference latents for training; validation media is encoded on the fly and cannot infer
those factors from the training dataset.
> [!NOTE]
> The legacy fields `prompts`, `images`, and `reference_videos` are deprecated but auto-converted to `samples`
> internally. New configs should use the `samples` format.
### CheckpointsConfig
@@ -298,6 +371,8 @@ checkpoints:
interval: 250 # Steps between checkpoint saves (null = disabled)
keep_last_n: 3 # Number of recent checkpoints to retain
precision: bfloat16 # Precision for saved weights (bfloat16 or float32)
no_resume: false # Ignore saved state, start from step 0
save_training_state: "minimal" # "full", "minimal", or "off"
```
**Key parameters:**
@@ -307,6 +382,8 @@ checkpoints:
| `interval` | Steps between intermediate checkpoint saves (set to `null` to disable) |
| `keep_last_n` | Number of most recent checkpoints to keep (-1 = keep all) |
| `precision` | Precision for saved checkpoint weights: `"bfloat16"` (default) or `"float32"` |
| `no_resume` | When `true`, ignore saved training state and start from step 0. Model weights from `load_checkpoint` are still loaded. |
| `save_training_state` | Save training state for resume: `"full"` (optimizer + scheduler + RNG), `"minimal"` (scheduler + RNG only, sufficient for LoRA), `"off"` (no resume). |
### HubConfig
@@ -364,6 +441,20 @@ flow_matching:
| `timestep_sampling_mode` | Sampling strategy: `"uniform"` or `"shifted_logit_normal"` |
| `timestep_sampling_params` | Additional parameters for the sampling strategy |
### General Configuration
Top-level settings for the training run.
```yaml
seed: 42 # Random seed for reproducibility
output_dir: "outputs/my_training_run" # Directory for outputs (checkpoints, validation videos, logs)
```
| Parameter | Description |
|--------------|----------------------------------------------------------|
| `seed` | Random seed for reproducibility (default: `42`) |
| `output_dir` | Directory to save outputs (default: `"outputs"`) |
## 🚀 Next Steps
Once you've configured your training parameters:
@@ -1,7 +1,7 @@
# Implementing Custom Training Strategies
This guide explains how to implement your own training strategy for specialized use cases like audio-only training,
video inpainting, or other custom training recipes.
This guide explains how to implement your own training strategy for specialized recipes that cannot be expressed with
the built-in `flexible` strategy.
## 📋 Overview
@@ -15,12 +15,20 @@ This architecture lets you implement new training modes without modifying the co
### When You Need a Custom Strategy
> [!NOTE]
> The built-in `flexible` strategy already supports most conditioning scenarios out of the box:
> first-frame conditioning, video extension (prefix/suffix), spatial crop (outpainting),
> mask-based inpainting, IC-LoRA reference conditioning, and frozen modality cross-conditioning
> (audio-to-video, video-to-audio). Only implement a custom strategy if your use case requires
> fundamentally different training logic that cannot be expressed through the flexible strategy's
> configuration.
Consider implementing a custom strategy when you need:
- **Different input modalities** (e.g., audio-only, audio-to-video conditioning)
- **Additional conditioning signals** (e.g., masks for inpainting, depth maps)
- **Custom loss computation** (e.g., weighted losses, auxiliary losses)
- **Different noise application patterns** (e.g., partial masking)
- **Custom loss computation** (e.g., weighted losses, auxiliary losses, perceptual losses)
- **Non-standard noise application** (e.g., noise schedules different from flow matching)
- **Novel conditioning mechanisms** not covered by the flexible strategy's condition types
- **Additional model outputs** beyond the standard video/audio predictions
## 🏗️ Architecture Overview
@@ -28,7 +36,7 @@ Consider implementing a custom strategy when you need:
The trainer delegates all training-mode-specific logic to the strategy:
1. **Initialization** — The trainer calls `get_data_sources()` to determine which preprocessed data directories to load
1. **Initialization** — The trainer calls `config.get_data_sources()` to determine which preprocessed data directories to load
2. **Each training step:**
- Calls `prepare_training_inputs()` to transform the raw batch into model-ready inputs
- Runs the transformer forward pass
@@ -52,8 +60,8 @@ The trainer handles everything else: optimization, checkpointing, validation, an
Before writing code, answer these questions:
1. **What additional data does your strategy need?**
- Example: Inpainting needs mask latents alongside video latents
- Example: Audio-to-video needs reference audio embeddings
- Example: A perceptual-loss strategy may need auxiliary feature targets
- Example: A novel conditioning mechanism may need an additional precomputed directory
2. **What does conditioning look like?**
- Which tokens should be noised vs. kept clean?
@@ -164,6 +172,20 @@ class InpaintingConfig(TrainingStrategyConfigBase):
ge=0.0,
le=1.0,
)
def get_data_sources(self) -> dict[str, str]:
"""Define which data directories to load.
Returns a mapping of directory names (under preprocessed_data_root) to
batch keys. The trainer loads .pt files from each directory and exposes
them in the batch under the specified key. The trainer also uses this
mapping to validate that all required directories exist.
"""
return {
"latents": "latents", # -> batch["latents"]
"conditions": "conditions", # -> batch["conditions"]
self.mask_latents_dir: "masks", # -> batch["masks"]
}
```
**Key points:**
@@ -171,6 +193,7 @@ class InpaintingConfig(TrainingStrategyConfigBase):
- Inherit from `TrainingStrategyConfigBase`
- Use `Literal["your_strategy_name"]` for the `name` field - this enables automatic strategy selection
- Use Pydantic `Field` for validation and documentation
- Implement `get_data_sources()` on the config — it's the single source of truth for data directories (used for both dataset wiring and existence validation)
### Step 4: Implement the Strategy Class
@@ -187,24 +210,6 @@ class InpaintingStrategy(TrainingStrategy):
def __init__(self, config: InpaintingConfig):
super().__init__(config)
@property
def requires_audio(self) -> bool:
"""Whether this strategy requires audio components."""
return False # Set to True if your strategy needs audio
def get_data_sources(self) -> dict[str, str]:
"""Define which data directories to load.
Returns a mapping of directory names to batch keys.
The trainer will load .pt files from each directory and
make them available in the batch under the specified key.
"""
return {
"latents": "latents", # -> batch["latents"]
"conditions": "conditions", # -> batch["conditions"]
self.config.mask_latents_dir: "masks", # -> batch["masks"]
}
def prepare_training_inputs(
self,
batch: dict[str, Any],
@@ -275,7 +280,6 @@ class InpaintingStrategy(TrainingStrategy):
batch_size=batch_size,
fps=24.0, # Or get from latents_data
device=device,
dtype=dtype,
)
# Create video Modality
@@ -328,7 +332,7 @@ You need to register your strategy in two places:
from ltx_trainer.training_strategies.inpainting import InpaintingConfig, InpaintingStrategy
# Add to the TrainingStrategyConfig type alias
TrainingStrategyConfig = TextToVideoConfig | VideoToVideoConfig | InpaintingConfig
TrainingStrategyConfig = TextToVideoConfig | VideoToVideoConfig | FlexibleStrategyConfig | InpaintingConfig
# Add to __all__
__all__ = [
@@ -356,7 +360,8 @@ from ltx_trainer.training_strategies.inpainting import InpaintingConfig
TrainingStrategyConfig = Annotated[
Annotated[TextToVideoConfig, Tag("text_to_video")]
| Annotated[VideoToVideoConfig, Tag("video_to_video")]
| Annotated[InpaintingConfig, Tag("inpainting")], # Add your config
| Annotated[FlexibleStrategyConfig, Tag("flexible")]
| Annotated[InpaintingConfig, Tag("inpainting")],
Discriminator(_get_strategy_discriminator),
]
```
@@ -366,7 +371,7 @@ TrainingStrategyConfig = Annotated[
Create an example config in `configs/`:
```yaml
# configs/ltx2_inpainting_lora.yaml
# configs/custom_inpainting_lora.yaml
model:
model_path: "/path/to/ltx2.safetensors"
@@ -408,8 +413,8 @@ The base `TrainingStrategy` class provides these helper methods:
| `_audio_patchifier.patchify(latents)` | Convert `[B, C, T, F]``[B, T, C*F]` |
| `_get_video_positions(...)` | Generate position embeddings for video |
| `_get_audio_positions(...)` | Generate position embeddings for audio |
| `_create_per_token_timesteps(mask, sigma)` | Create timesteps with 0 for conditioning tokens |
| `_create_first_frame_conditioning_mask(...)` | Create mask for first-frame conditioning |
| `_create_per_token_timesteps(conditioning_mask, sampled_sigma)` | Create timesteps with 0 for conditioning tokens |
| `_create_first_frame_conditioning_mask(...)` | Create mask for first-frame conditioning |
## 📊 Understanding ModelInputs
@@ -418,16 +423,14 @@ The `ModelInputs` dataclass contains everything needed for the forward pass and
```python
@dataclass
class ModelInputs:
video: Modality # Video modality data
audio: Modality | None # Audio modality (None if video-only)
video: Modality | None # Video modality data
audio: Modality | None # Audio modality data
video_targets: Tensor # Target values for loss (velocity)
audio_targets: Tensor | None
video_targets: Tensor | None # Target values for video loss (velocity)
audio_targets: Tensor | None # Target values for audio loss (velocity)
video_loss_mask: Tensor # Boolean: True = compute loss for this token
audio_loss_mask: Tensor | None
ref_seq_len: int | None = None # For IC-LoRA: reference sequence length
video_loss_mask: Tensor | None # Boolean loss mask for video tokens
audio_loss_mask: Tensor | None # Boolean loss mask for audio tokens
```
## 📊 Understanding Modality
@@ -437,18 +440,20 @@ The `Modality` dataclass (from ltx-core) represents a single modality's data:
```python
@dataclass(frozen=True)
class Modality:
enabled: bool # Whether this modality is active
latent: Tensor # [B, seq_len, C] - the latent tokens
timesteps: Tensor # [B, seq_len] - per-token timesteps (sigmas)
positions: Tensor # [B, dims, seq_len, 2] - position bounds
context: Tensor # [B, ctx_len, C] - text embeddings
context_mask: Tensor # [B, ctx_len] - attention mask for context
latent: Tensor # [B, T, D] — patchified latent tokens
sigma: Tensor # [B,] — per-batch noise level (for cross-attn conditioning)
timesteps: Tensor # [B, T] per-token timestep embeddings
positions: Tensor # [B, 3, T, 2] for video, [B, 1, T, 2] for audio — positional bounds
context: Tensor # text conditioning embeddings
enabled: bool = True
context_mask: Tensor | None = None # attention mask for text context
attention_mask: Tensor | None = None # optional 2D self-attention mask [B, T, T]
```
> [!NOTE]
> **Per-token timesteps:** Each token in the sequence has its own timestep. Conditioning tokens—those that should remain
> un-noised—must have `timestep=0`. This is how the model distinguishes clean reference tokens from tokens to denoise. Use
`_create_per_token_timesteps(conditioning_mask, sigma)` to set this up correctly.
> `_create_per_token_timesteps(conditioning_mask, sampled_sigma)` to set this up correctly.
> [!NOTE]
> `Modality` is immutable (frozen dataclass). Use `dataclasses.replace()` to create modified copies.
@@ -461,7 +466,7 @@ class Modality:
from ltx_trainer.config import LtxTrainerConfig
import yaml
with open('configs/ltx2_inpainting_lora.yaml') as f:
with open('configs/custom_inpainting_lora.yaml') as f:
config = LtxTrainerConfig(**yaml.safe_load(f))
print(f'Strategy: {config.training_strategy.name}')
"
@@ -475,13 +480,13 @@ class Modality:
config = InpaintingConfig()
strategy = get_training_strategy(config)
print(f'Data sources: {strategy.get_data_sources()}')
print(f'Data sources: {config.get_data_sources()}')
"
```
3. **Run a short training test:**
```bash
uv run python scripts/train.py configs/ltx2_inpainting_lora.yaml
uv run python scripts/train.py configs/custom_inpainting_lora.yaml
```
## 💡 Tips and Best Practices
@@ -503,7 +508,8 @@ class Modality:
Study these implementations for guidance:
| Strategy | Complexity | Key Features |
|------------------------------------------------------------------------------------|------------|------------------------------------------------|
| [`TextToVideoStrategy`](../src/ltx_trainer/training_strategies/text_to_video.py) | Simple | First-frame conditioning, optional audio |
| [`VideoToVideoStrategy`](../src/ltx_trainer/training_strategies/video_to_video.py) | Medium | Reference video concatenation, split loss mask |
| Strategy | Complexity | Key Features |
|----------|------------|--------------|
| [`FlexibleStrategy`](../src/ltx_trainer/training_strategies/flexible.py) | Medium | Unified conditioning framework — supports all built-in modes |
| [`TextToVideoStrategy`](../src/ltx_trainer/training_strategies/text_to_video.py) | Simple | First-frame conditioning, optional audio (deprecated) |
| [`VideoToVideoStrategy`](../src/ltx_trainer/training_strategies/video_to_video.py) | Medium | Reference video concatenation, split loss mask (deprecated) |
+162 -60
View File
@@ -33,41 +33,33 @@ uv run python scripts/split_scenes.py --help
If your dataset doesn't include captions, you can automatically generate them using multimodal models that understand both video and audio.
The default `qwen_omni` backend talks to a local vLLM server, which you launch once in a separate terminal:
```bash
uv run python scripts/caption_videos.py scenes_output_dir/ \
--output scenes_output_dir/dataset.json
# Terminal 1: start the captioner server (stays running)
uv run python scripts/serve_captioner.py
```
If you're running into VRAM issues, try enabling 8-bit quantization to reduce memory usage:
```bash
# Terminal 2: caption your videos
uv run python scripts/caption_videos.py scenes_output_dir/ \
--output scenes_output_dir/dataset.json \
--use-8bit
--output scenes_output_dir/dataset.json
```
This will create a `dataset.json` file containing video paths and their captions.
**Captioning options:**
| Option | Description |
| ------------------ | ---------------------------------------------------------- |
| `--captioner-type` | `qwen_omni` (default, local) or `gemini_flash` (API) |
| `--use-8bit` | Enable 8-bit quantization for lower VRAM usage |
| `--no-audio` | Disable audio processing (video-only captions) |
| `--override` | Re-caption files that already have captions |
| `--api-key` | API key for Gemini Flash (or set `GOOGLE_API_KEY` env var) |
| Option | Description |
| ------------------ | --------------------------------------------------------------- |
| `--captioner-type` | `qwen_omni` (default, local vLLM server) or `gemini_flash` (API) |
| `--vllm-url` | Base URL of the vLLM server (default `http://127.0.0.1:8001/v1`) |
| `--override` | Re-caption files that already have captions |
| `--api-key` | Gemini API key (else `GEMINI_API_KEY`/`GOOGLE_API_KEY`; with no key, uses gcloud/Vertex AI auth) |
**Caption format:**
The captioner produces structured captions with sections for:
- **Visual content**: People, objects, actions, settings, colors, movements
- **Speech transcription**: Word-for-word transcription of spoken content
- **Sounds**: Music, ambient sounds, sound effects
- **On-screen text**: Any visible text overlays
Each caption is a single, detailed paragraph describing both the visual content and the audio (speech, music, ambient sounds) of the clip. See the [Utility Scripts Reference](utility-scripts.md#automatic-video-captioning) for backend setup and the full list of options.
> [!NOTE]
> The automatically generated captions may contain inaccuracies or hallucinated content.
@@ -80,7 +72,7 @@ This step preprocesses your video dataset by:
1. Resizing and cropping videos to fit specified resolution buckets
2. Computing and caching video latent representations
3. Computing and caching text embeddings for captions
4. (Optional) Computing and caching audio latents
4. Extracting and caching audio latents from videos (automatic, use `--skip-audio` to disable)
> [!WARNING]
> Very large videos (especially high spatial resolution and/or many frames) can cause GPU out-of-memory (OOM)
@@ -97,17 +89,9 @@ uv run python scripts/process_dataset.py dataset.json \
--text-encoder-path /path/to/gemma-model
```
### With Audio Processing
For audio-video training, add the `--with-audio` flag:
```bash
uv run python scripts/process_dataset.py dataset.json \
--resolution-buckets "960x544x49" \
--model-path /path/to/ltx-2-model.safetensors \
--text-encoder-path /path/to/gemma-model \
--with-audio
```
Audio latents are automatically extracted from video files — no extra flag is needed. Use `--skip-audio`
to disable this. For standalone audio files (`.wav`), use the `audio` column in your dataset instead
(see [Convention-Based Column Detection](#convention-based-column-detection) below).
### 🚀 Multi-GPU Preprocessing
@@ -126,7 +110,7 @@ Outputs are written atomically (via a per-process temporary file, then renamed),
corrupt files. By default a rerun **resumes** — items whose output `.pt` already exists are skipped.
> [!IMPORTANT]
> Pass `**--overwrite`** when rerunning with changed parameters (different model checkpoint, resolution buckets,
> Pass **`--overwrite`** when rerunning with changed parameters (different model checkpoint, resolution buckets,
> text encoder, `--lora-trigger`, etc.). Without it the script keeps the stale outputs from the previous run.
>
> ```bash
@@ -152,7 +136,7 @@ The trainer supports videos, single images, or a mix of both in the same dataset
> `--resolution-buckets "960x544x1;960x544x49"`. Images are automatically assigned to the `F=1` bucket and
> videos to an `F>1` bucket.
> - You **must** set `optimization.batch_size: 1` in your training config (see the warning under
> [Resolution Buckets](#-resolution-buckets)), since samples with different shapes cannot be collated into a
> [Resolution Buckets](#resolution-buckets)), since samples with different shapes cannot be collated into a
> single batch. Use `gradient_accumulation_steps` if you need a larger effective batch.
> - Per-step cost differs substantially between a single-frame sample and a many-frame sample, which can lead to
> uneven gradient magnitudes across steps. Consider weighting the two subsets or tuning the learning rate if
@@ -160,7 +144,24 @@ The trainer supports videos, single images, or a mix of both in the same dataset
> - If you prefer a fully officially-supported path, train two separate LoRAs (one on stills, one on video) and
> stack them at inference.
The dataset must be a CSV, JSON, or JSONL metadata file with columns for captions and video paths:
The dataset must be a CSV, JSON, or JSONL metadata file with columns for captions and media paths.
#### Convention-Based Column Detection
The preprocessing script automatically detects and processes columns based on their names. The following columns are recognized:
| Column | Output Dir | Description |
|--------|-----------|-------------|
| `video` (or legacy `media_path`) | `latents/` | Target video to encode |
| `audio` | `audio_latents/` | Explicit audio file (overrides auto-extraction from video) |
| `caption` | `conditions/` | Text caption for the sample |
| `reference_video` (or legacy `ref_media_path`) | `reference_latents/` | IC-LoRA reference video |
| `reference_audio` | `reference_audio_latents/` | IC-LoRA reference audio |
| `video_mask` | `video_masks/` | Binary mask for video inpainting |
| `audio_mask` | `audio_masks/` | Binary mask for audio inpainting |
> [!NOTE]
> **Legacy column names:** `media_path` and `ref_media_path` are accepted as aliases for `video` and `reference_video` respectively. Existing datasets using these names will continue to work without modification.
**JSON format example:**
@@ -168,11 +169,11 @@ The dataset must be a CSV, JSON, or JSONL metadata file with columns for caption
[
{
"caption": "A cat playing with a ball of yarn",
"media_path": "videos/cat_playing.mp4"
"video": "videos/cat_playing.mp4"
},
{
"caption": "A dog running in the park",
"media_path": "videos/dog_running.mp4"
"video": "videos/dog_running.mp4"
}
]
```
@@ -180,18 +181,42 @@ The dataset must be a CSV, JSON, or JSONL metadata file with columns for caption
**JSONL format example:**
```jsonl
{"caption": "A cat playing with a ball of yarn", "media_path": "videos/cat_playing.mp4"}
{"caption": "A dog running in the park", "media_path": "videos/dog_running.mp4"}
{"caption": "A cat playing with a ball of yarn", "video": "videos/cat_playing.mp4"}
{"caption": "A dog running in the park", "video": "videos/dog_running.mp4"}
```
**CSV format example:**
```csv
caption,media_path
caption,video
"A cat playing with a ball of yarn","videos/cat_playing.mp4"
"A dog running in the park","videos/dog_running.mp4"
```
**Additional dataset format examples:**
Audio-only dataset:
```json
{"audio": "song.wav", "caption": "piano melody"}
```
V2V IC-LoRA with reference video:
```json
{"video": "clip.mp4", "reference_video": "depth.mp4", "caption": "depth to video"}
```
A2A IC-LoRA with reference audio:
```json
{"video": "clip.mp4", "reference_audio": "ref.wav", "caption": "match this style"}
```
This form auto-extracts the target audio from `clip.mp4`. For pure audio datasets, use `audio` plus
`reference_audio` columns and preprocess with `--audio-durations`.
Video inpainting with mask:
```json
{"video": "clip.mp4", "video_mask": "mask.mp4", "caption": "fill the sky"}
```
### 📐 Resolution Buckets
Videos are organized into "buckets" of specific dimensions (width × height × frames).
@@ -268,12 +293,31 @@ The preprocessed data is saved in a `.precomputed` directory:
```
dataset/
└── .precomputed/
├── latents/ # Cached video latents
├── conditions/ # Cached text embeddings
├── audio_latents/ # (only if --with-audio) Cached audio latents
── reference_latents/ # (only for IC-LoRA) Cached reference video latents
├── latents/ # Video latents
├── conditions/ # Text embeddings
├── audio_latents/ # Audio latents (auto-extracted or explicit)
── reference_latents/ # Reference video latents (IC-LoRA)
├── reference_audio_latents/ # Reference audio latents (audio IC-LoRA)
├── video_masks/ # Video masks (inpainting)
└── audio_masks/ # Audio masks (audio inpainting)
```
Set `data.preprocessed_data_root` in your training config to this `.precomputed` directory — the parent directory that
contains `latents/`, `conditions/`, and any mode-specific audio/reference/mask directories.
## 🔊 Audio-Only Dataset Preprocessing
For datasets containing only audio files (no `video` column), use `--audio-durations` to specify duration buckets:
```bash
uv run python scripts/process_dataset.py dataset.json \
--audio-durations "2.0;4.0;8.0" \
--model-path /path/to/ltx-2-model.safetensors \
--text-encoder-path /path/to/gemma-model
```
The `--audio-durations` flag provides duration buckets (in seconds) for audio-only datasets. Since there is no video column to derive timing from, explicit duration buckets are required.
## 🪄 IC-LoRA Reference Video Preprocessing
For IC-LoRA training, you need to preprocess datasets that include reference videos.
@@ -281,14 +325,16 @@ Reference videos provide the conditioning input while target videos represent th
### Dataset Format with Reference Videos
The `reference_video` column is automatically detected by convention — no extra CLI flags are needed.
**JSON format:**
```json
[
{
"caption": "A cat playing with a ball of yarn",
"media_path": "videos/cat_playing.mp4",
"reference_path": "references/cat_playing_depth.mp4"
"video": "videos/cat_playing.mp4",
"reference_video": "references/cat_playing_depth.mp4"
}
]
```
@@ -296,32 +342,39 @@ Reference videos provide the conditioning input while target videos represent th
**JSONL format:**
```jsonl
{"caption": "A cat playing with a ball of yarn", "media_path": "videos/cat_playing.mp4", "reference_path": "references/cat_playing_depth.mp4"}
{"caption": "A dog running in the park", "media_path": "videos/dog_running.mp4", "reference_path": "references/dog_running_depth.mp4"}
{"caption": "A cat playing with a ball of yarn", "video": "videos/cat_playing.mp4", "reference_video": "references/cat_playing_depth.mp4"}
{"caption": "A dog running in the park", "video": "videos/dog_running.mp4", "reference_video": "references/dog_running_depth.mp4"}
```
### Preprocessing with Reference Videos
To preprocess a dataset with reference videos, add the `--reference-column` argument specifying the name of the field
in your dataset JSON/JSONL/CSV that contains the reference video paths:
Convention-based detection means you just need the `reference_video` column in your dataset, and `process_dataset.py` will automatically detect and process it. No `--reference-column` flag is needed:
```bash
uv run python scripts/process_dataset.py dataset.json \
--resolution-buckets "960x544x49" \
--model-path /path/to/ltx-2-model.safetensors \
--text-encoder-path /path/to/gemma-model \
--reference-column "reference_path"
--reference-downscale-factor 2 \
--reference-temporal-scale-factor 1
```
This will create an additional `reference_latents/` directory containing the preprocessed reference video latents.
Use `--reference-downscale-factor` for spatial subsampling and `--reference-temporal-scale-factor` for temporal
subsampling. Validation reference conditions should use matching `downscale_factor` and `temporal_scale_factor` values.
> [!NOTE]
> **Legacy column names:** If your dataset uses `ref_media_path`, it is accepted as an alias for `reference_video`.
### Generating Reference Videos
**Dataset Requirements for IC-LoRA:**
- Your dataset must contain paired videos where each target video has a corresponding reference video
- Reference and target videos must have *identical* resolution and length
- Both reference and target videos should be preprocessed together using the same resolution buckets
- Reference and target videos should cover the same content. Reference videos can optionally be lower spatial
resolution or temporally subsampled (see Scaled Reference Conditioning in [Training Modes](training-modes.md)).
- Both reference and target videos should be preprocessed together using the same target resolution buckets, plus any
reference scale factors you choose.
We provide an example script, `[scripts/compute_reference.py](../scripts/compute_reference.py)`, to generate reference
videos for a given dataset. The default implementation generates Canny edge reference videos.
@@ -333,12 +386,63 @@ uv run python scripts/compute_reference.py scenes_output_dir/ \
The script accepts a JSON file as the dataset configuration and updates it in-place by adding the filenames of the generated reference videos.
> [!NOTE]
> `compute_reference.py` writes generated references to the `reference_video` column, which `process_dataset.py`
> detects automatically. The legacy `ref_media_path` column is also accepted.
If you want to generate a different type of condition (depth maps, pose skeletons, etc.), modify or replace the `compute_reference()` function within this script.
### Example Dataset
For reference, see our **[Canny Control Dataset](https://huggingface.co/datasets/Lightricks/Canny-Control-Dataset)** which demonstrates proper IC-LoRA dataset structure with paired videos and Canny edge maps.
## 🎭 Mask Preprocessing for Inpainting
For inpainting training with the `mask` condition type, provide `video_mask` or `audio_mask` columns in your dataset
metadata. These columns point to mask media files (for example a mask image/video for video inpainting, or a waveform or
`.pt` tensor for audio inpainting). `process_dataset.py` downsamples and thresholds them into per-sample `.pt` tensors
under `video_masks/` or `audio_masks/`.
### Processed Video Mask Format
If you create masks manually instead of using `process_dataset.py`, save them as `.pt` files with the key `"mask"`
containing a tensor of shape `[F, H, W]` where:
- `F` = number of latent frames (temporal dimension)
- `H` = latent height (pixel height / 32)
- `W` = latent width (pixel width / 32)
- Values are thresholded at `0.5`: values `> 0.5` are conditioning tokens (clean, excluded from loss),
and values `<= 0.5` are generated tokens (noised, contributes to loss).
### Audio Mask Format
Audio masks follow the same thresholding pattern as video masks but with shape `[T]` (temporal dimension only), where `T` is the number of audio latent frames. They are stored in `audio_masks/`.
### Directory Structure
Place masks in a directory within your preprocessed data root:
```
preprocessed_data_root/
├── latents/ # Video latents
├── conditions/ # Text embeddings
├── video_masks/ # Video masks (one .pt per sample, matching latent filenames)
└── audio_masks/ # Audio masks (one .pt per sample, matching latent filenames)
```
Then reference the mask directory in your training config:
```yaml
training_strategy:
name: "flexible"
video:
is_generated: true
latents_dir: "latents"
conditions:
- type: mask
mask_dir: "video_masks"
```
## 🎯 LoRA Trigger Words
When training a LoRA, you can specify a trigger token that will be prepended to all captions:
@@ -359,9 +463,9 @@ This acts as a trigger word that activates the LoRA during inference when you in
## 🔍 Decoding Videos for Verification
If you add the `--decode` flag, the script will VAE-decode the precomputed latents and save the resulting videos
in `.precomputed/decoded_videos`. When audio preprocessing is enabled (`--with-audio`), audio latents will also be
decoded and saved to `.precomputed/decoded_audio`. This allows you to visually and audibly inspect the processed data.
If you add the `--decode` flag, the script will VAE-decode the precomputed video latents and save the resulting videos
in `.precomputed/decoded_videos`. Reference video latents are decoded to `.precomputed/decoded_reference_videos` when
present. To inspect audio latents, run `scripts/decode_latents.py` with `--with-audio`.
```bash
uv run python scripts/process_dataset.py dataset.json \
@@ -382,6 +486,4 @@ Once your dataset is preprocessed, you can proceed to:
- Start training with the [Training Guide](training-guide.md)
> [!TIP]
> If your training recipe requires additional preprocessed data (e.g., masks, conditioning signals), see
> [Implementing Custom Training Strategies](custom-training-strategies.md) for guidance on extending the
> preprocessing pipeline.
> The `flexible` strategy supports masks for inpainting (`mask` condition type) and spatial crop regions for outpainting (`spatial_crop` condition type) out of the box. For other custom preprocessing needs, see [Custom Training Strategies](custom-training-strategies.md).
+50 -20
View File
@@ -7,12 +7,14 @@ Get up and running with LTX-2 training in just a few steps!
Before you begin, ensure you have:
1. **LTX-2 Model Checkpoint** - A local `.safetensors` file containing the LTX-2 model weights.
Download `ltx-2-19b-dev.safetensors` from: [HuggingFace Hub](https://huggingface.co/Lightricks/LTX-2)
Download `ltx-2.3-22b-dev.safetensors` from: [HuggingFace Hub](https://huggingface.co/Lightricks/LTX-2.3)
The trainer supports LTX-2 and LTX-2.3 checkpoints through the same configuration API; version-specific components
are detected from the checkpoint.
2. **Gemma Text Encoder** - A local directory containing the Gemma model (required for LTX-2).
Download from: [HuggingFace Hub](https://huggingface.co/google/gemma-3-12b-it-qat-q4_0-unquantized/)
3. **Linux with CUDA** - The trainer requires `triton` which is Linux-only
3. **Linux with CUDA** - The trainer requires `triton` which is Linux-only; CUDA 13+ is recommended
4. **GPU with sufficient VRAM** - 80GB recommended for the standard config. For GPUs with 32GB VRAM (e.g., RTX 5090),
use the [low VRAM config](../configs/ltx2_av_lora_low_vram.yaml) which enables INT8 quantization and other
use the [low VRAM config](../configs/t2v_lora_low_vram.yaml) which enables INT8 quantization and other
memory optimizations
## ⚡ Installation
@@ -39,7 +41,18 @@ cd packages/ltx-trainer
## 🏋 Training Workflow
### 1. Prepare Your Dataset
If you are using an agent-enabled environment with repository skills, you can ask for the
[`train-model`](../../../.claude/skills/train-model/SKILL.md) skill to run this workflow with you.
It creates a run workspace, confirms the training mode, prepares data, preprocesses latents,
launches training, and monitors the run while stopping for approval before expensive steps.
### 1. Choose a Training Mode
Start with [`t2v_lora.yaml`](../configs/t2v_lora.yaml) for a first run with videos and captions. For modes such as
IC-LoRA, inpainting, or outpainting, check [Training Modes](training-modes.md) first because your metadata needs extra
columns such as `reference_video`, `video_mask`, or `audio_mask` before preprocessing.
### 2. Prepare Your Dataset
Organize your videos and captions, then preprocess them:
@@ -57,15 +70,18 @@ uv run python scripts/process_dataset.py dataset.json \
--text-encoder-path /path/to/gemma-model
```
By default, preprocessing writes to `.precomputed/`. Use that directory as `data.preprocessed_data_root`
in your training config.
See [Dataset Preparation](dataset-preparation.md) for detailed instructions.
### 2. Configure Training
### 3. Configure Training
Create or modify a configuration YAML file. Start with one of the example configs:
- [`configs/ltx2_av_lora.yaml`](../configs/ltx2_av_lora.yaml) - Audio-video LoRA training
- [`configs/ltx2_av_lora_low_vram.yaml`](../configs/ltx2_av_lora_low_vram.yaml) - Audio-video LoRA training (optimized for 32GB VRAM)
- [`configs/ltx2_v2v_ic_lora.yaml`](../configs/ltx2_v2v_ic_lora.yaml) - IC-LoRA video-to-video
- [`configs/t2v_lora.yaml`](../configs/t2v_lora.yaml) - Text-to-video LoRA
- [`configs/t2v_lora_low_vram.yaml`](../configs/t2v_lora_low_vram.yaml) - Same as above, tuned for ~32GB VRAM (INT8 quantization and memory optimizations)
- [`configs/v2v_ic_lora.yaml`](../configs/v2v_ic_lora.yaml) - IC-LoRA video-to-video
Key settings to update:
@@ -82,33 +98,47 @@ output_dir: "outputs/my_training_run"
See [Configuration Reference](configuration-reference.md) for all available options.
### 3. Start Training
### 4. Start Training
```bash
uv run python scripts/train.py configs/ltx2_av_lora.yaml
uv run python scripts/train.py configs/t2v_lora.yaml
```
For multi-GPU training:
```bash
uv run accelerate launch scripts/train.py configs/ltx2_av_lora.yaml
uv run accelerate launch scripts/train.py configs/t2v_lora.yaml
```
See [Training Guide](training-guide.md) for distributed training and advanced options.
## 🎯 Training Modes
> [!TIP]
> **First time?** Start with [`t2v_lora.yaml`](../configs/t2v_lora.yaml) — it's the simplest mode
> and only requires videos with captions. You can explore other modes once you've confirmed your
> setup works.
The trainer supports several training modes:
| Mode | Description | Config Example |
|----------------------|--------------------------------|--------------------------------------------|
| **LoRA** | Efficient adapter training | `training_strategy.name: "text_to_video"` |
| **Audio-Video LoRA** | Joint audio-video training | `training_strategy.with_audio: true` |
| **IC-LoRA** | Video-to-video transformations | `training_strategy.name: "video_to_video"` |
| **Full Fine-tuning** | Full model training | `model.training_mode: "full"` |
| Mode | Description | Example Config |
|-----------------------|--------------------------------------------|-------------------------------------------------------------------|
| **Text-to-Video** | Generate video+audio from text prompts | [`t2v_lora.yaml`](../configs/t2v_lora.yaml) |
| **Image-to-Video** | Animate from a starting image | [`i2v_lora.yaml`](../configs/i2v_lora.yaml) |
| **Video Extension** | Extend videos temporally (forward/backward)| [`video_extend_lora.yaml`](../configs/video_extend_lora.yaml), [`video_suffix_lora.yaml`](../configs/video_suffix_lora.yaml) |
| **IC-LoRA (V2V)** | Video-to-video transformations | [`v2v_ic_lora.yaml`](../configs/v2v_ic_lora.yaml) |
| **Audio-to-Video** | Generate video conditioned on audio | [`a2v_lora.yaml`](../configs/a2v_lora.yaml) |
| **Video-to-Audio** | Generate audio/foley from video | [`v2a_lora.yaml`](../configs/v2a_lora.yaml) |
| **Video Inpainting** | Fill in masked regions of video | [`video_inpainting_lora.yaml`](../configs/video_inpainting_lora.yaml) |
| **Video Outpainting** | Extend video spatially | [`video_outpainting_lora.yaml`](../configs/video_outpainting_lora.yaml) |
| **Text-to-Audio** | Generate audio from text prompts | [`t2a_lora.yaml`](../configs/t2a_lora.yaml) |
| **Audio Extension** | Extend audio temporally | [`audio_extend_lora.yaml`](../configs/audio_extend_lora.yaml), [`audio_suffix_lora.yaml`](../configs/audio_suffix_lora.yaml) |
| **Audio Inpainting** | Fill in masked regions of audio | [`audio_inpainting_lora.yaml`](../configs/audio_inpainting_lora.yaml) |
| **IC-LoRA (A2A)** | Audio-to-audio transformations | [`a2a_ic_lora.yaml`](../configs/a2a_ic_lora.yaml) |
| **AV2AV IC-LoRA** | Audio+video IC-LoRA transformations | [`av2av_ic_lora.yaml`](../configs/av2av_ic_lora.yaml) |
| **Full Fine-tuning** | Full model training (any mode above) | Set `model.training_mode: "full"` |
See [Training Modes](training-modes.md) for detailed explanations,
or [Custom Training Strategies](custom-training-strategies.md) if you need to implement your own training recipe.
See [Training Modes](training-modes.md) for detailed explanations of each mode.
## Next Steps
@@ -118,7 +148,7 @@ Once you've completed your first training run, you can:
production-ready inference
pipelines for various use cases (T2V, I2V, IC-LoRA, etc.). See the package documentation for details.
- Learn more about [Dataset Preparation](dataset-preparation.md) for advanced preprocessing
- Explore different [Training Modes](training-modes.md) (LoRA, Audio-Video, IC-LoRA)
- Explore different [Training Modes](training-modes.md)
- Dive deeper into [Training Configuration](configuration-reference.md)
- Understand the model architecture in [LTX-Core Documentation](../../ltx-core/README.md)
+24 -12
View File
@@ -8,7 +8,7 @@ model uploads.
After preprocessing your dataset and preparing a configuration file, you can start training using the trainer script:
```bash
uv run python scripts/train.py configs/ltx2_av_lora.yaml
uv run python scripts/train.py configs/t2v_lora.yaml
```
The trainer will:
@@ -19,19 +19,31 @@ The trainer will:
4. **Generate validation videos** (if configured)
5. **Save the trained weights** in your output directory
### Agent-Assisted Training
If your environment supports repository skills, the
[`train-model`](../../../.claude/skills/train-model/SKILL.md) skill provides an end-to-end
orchestrator for this package. It asks what you want the model to learn, maps that intent to
one of the documented [training modes](training-modes.md), probes your filesystem and GPU,
prepares/preprocesses the dataset, writes a run-specific config, launches training, and
monitors the job. It uses the trainer docs as its source of truth and stops for approval before
captioning, preprocessing, or starting expensive training work.
### Output Files
**For LoRA training:**
- `lora_weights.safetensors` - Main LoRA weights file
- `checkpoints/lora_weights_step_00000.safetensors` - LoRA checkpoint weights, with the current step in the filename
- `training_config.yaml` - Copy of training configuration
- `validation_samples/` - Generated validation videos (if enabled)
- `samples/` - Generated validation samples (if enabled)
- `checkpoints/training_state_step_00000.pt` - Optional resume state, depending on `checkpoints.save_training_state`
**For full model fine-tuning:**
- `model_weights.safetensors` - Full model weights
- `checkpoints/model_weights_step_00000.safetensors` - Full model checkpoint weights, with the current step in the filename
- `training_config.yaml` - Copy of training configuration
- `validation_samples/` - Generated validation videos (if enabled)
- `samples/` - Generated validation samples (if enabled)
- `checkpoints/training_state_step_00000.pt` - Optional resume state, depending on `checkpoints.save_training_state`
## 🖥️ Distributed / Multi-GPU Training
@@ -62,22 +74,22 @@ Launch with a specific config using `--config_file`:
# DDP (2 GPUs shown as example)
CUDA_VISIBLE_DEVICES=0,1 \
uv run accelerate launch --config_file configs/accelerate/ddp.yaml \
scripts/train.py configs/ltx2_av_lora.yaml
scripts/train.py configs/t2v_lora.yaml
# DDP + torch.compile
CUDA_VISIBLE_DEVICES=0,1 \
uv run accelerate launch --config_file configs/accelerate/ddp_compile.yaml \
scripts/train.py configs/ltx2_av_lora.yaml
scripts/train.py configs/t2v_lora.yaml
# FSDP (4 GPUs shown as example)
CUDA_VISIBLE_DEVICES=0,1,2,3 \
uv run accelerate launch --config_file configs/accelerate/fsdp.yaml \
scripts/train.py configs/ltx2_av_lora.yaml
scripts/train.py configs/t2v_lora.yaml
# FSDP + torch.compile
CUDA_VISIBLE_DEVICES=0,1,2,3 \
uv run accelerate launch --config_file configs/accelerate/fsdp_compile.yaml \
scripts/train.py configs/ltx2_av_lora.yaml
scripts/train.py configs/t2v_lora.yaml
```
**Notes:**
@@ -93,13 +105,13 @@ If you prefer to use your default Accelerate profile:
```bash
# Use settings from your default accelerate config
uv run accelerate launch scripts/train.py configs/ltx2_av_lora.yaml
uv run accelerate launch scripts/train.py configs/t2v_lora.yaml
# Override number of processes on the fly (e.g., 2 GPUs)
uv run accelerate launch --num_processes 2 scripts/train.py configs/ltx2_av_lora.yaml
uv run accelerate launch --num_processes 2 scripts/train.py configs/t2v_lora.yaml
# Select specific GPUs
CUDA_VISIBLE_DEVICES=0,1 uv run accelerate launch scripts/train.py configs/ltx2_av_lora.yaml
CUDA_VISIBLE_DEVICES=0,1 uv run accelerate launch scripts/train.py configs/t2v_lora.yaml
```
> [!TIP]
+499 -176
View File
@@ -1,165 +1,167 @@
# Training Modes Guide
The trainer supports several training modes, each suited for different use cases and requirements.
The trainer uses the **flexible** training strategy (`name: "flexible"`) — a unified conditioning framework that
supports all training modes through configuration. Every scenario is expressed by setting `is_generated` on each
modality and adding optional conditions, rather than choosing a separate strategy class.
## 🎯 Standard LoRA Training (Video-Only)
## Key Concepts
Standard LoRA (Low-Rank Adaptation) training fine-tunes the model by adding small, trainable adapter layers while
keeping the base model frozen. This approach:
Before diving into individual modes, here are the core ideas behind the flexible strategy:
- **Requires significantly less memory and compute** than full fine-tuning
- **Produces small, portable weight files** (typically a few hundred MB)
- **Is ideal for learning specific styles, effects, or concepts**
- **Can be easily combined with other LoRAs** during inference
- **`is_generated: true`** — the modality is denoised during training and contributes to the loss. This is the
modality the model learns to generate.
- **`is_generated: false`** — the modality is frozen (sigma=0, no noise, no loss). It passes through the transformer
clean and acts as cross-modal conditioning for the generated modality.
- **At least one modality must have `is_generated: true`.**
- **Conditions** are per-modality and can be composed (e.g., `reference` + `first_frame` together on the video
modality).
- Audio does **not** support `first_frame` or `spatial_crop` conditions — only `prefix`, `suffix`, `mask`,
and `reference`.
Configure standard LoRA training with:
> [!TIP]
> If you are using an agent-enabled environment with repository skills and are unsure which mode to choose,
> ask for the [`train-model`](../../../.claude/skills/train-model/SKILL.md) skill. It maps your intent to one of
> these configs and walks through dataset preparation, preprocessing, launch, and monitoring.
## 📊 Quick Reference
| Mode | Video | Audio | Conditions | Config |
|-----------------------|-----------|-----------|---------------------|--------|
| **T2V** | Generated | Generated | — | [`t2v_lora`](../configs/t2v_lora.yaml) |
| **I2V** | Generated | Generated | `first_frame` | [`i2v_lora`](../configs/i2v_lora.yaml) |
| **Video Extension** | Generated | Generated | `prefix`/`suffix` | [`video_extend_lora`](../configs/video_extend_lora.yaml) |
| **V2V IC-LoRA** | Generated | — | `reference` | [`v2v_ic_lora`](../configs/v2v_ic_lora.yaml) |
| **A2V** | Generated | Frozen | — | [`a2v_lora`](../configs/a2v_lora.yaml) |
| **V2A (Foley)** | Frozen | Generated | — | [`v2a_lora`](../configs/v2a_lora.yaml) |
| **Video Inpainting** | Generated | — | `mask` | [`video_inpainting_lora`](../configs/video_inpainting_lora.yaml) |
| **Video Outpainting** | Generated | — | `spatial_crop` | [`video_outpainting_lora`](../configs/video_outpainting_lora.yaml) |
| **T2A** | — | Generated | — | [`t2a_lora`](../configs/t2a_lora.yaml) |
| **Audio Extension** | — | Generated | `prefix`/`suffix` | [`audio_extend_lora`](../configs/audio_extend_lora.yaml) |
| **Audio Inpainting** | — | Generated | `mask` | [`audio_inpainting_lora`](../configs/audio_inpainting_lora.yaml) |
| **A2A IC-LoRA** | — | Generated | `reference` | [`a2a_ic_lora`](../configs/a2a_ic_lora.yaml) |
| **AV2AV IC-LoRA** | Generated | Generated | `reference` (both) | [`av2av_ic_lora`](../configs/av2av_ic_lora.yaml) |
---
## 🎯 Text-to-Video (T2V)
Generate video and audio from text prompts. Both modalities are denoised with no additional conditions.
```yaml
model:
training_mode: "lora"
training_strategy:
name: "text_to_video"
first_frame_conditioning_p: 0.1
with_audio: false # Video-only training
name: "flexible"
video:
is_generated: true
latents_dir: "latents"
audio:
is_generated: true
latents_dir: "audio_latents"
```
## 🔊 Audio-Video LoRA Training
**Example config:** 📄 [t2v_lora.yaml](../configs/t2v_lora.yaml)
LTX-2 supports joint audio-video generation. You can train LoRA adapters that affect both video and audio output:
---
- **Synchronized audio-video generation** - Audio matches the visual content
- **Same efficient LoRA approach** - Just enable audio training
- **Requires audio latents** - Dataset must include preprocessed audio
## 🖼️ Image-to-Video (I2V)
Configure audio-video training with:
Generate video conditioned on a starting image. The first frame is provided as a clean conditioning signal — no noise,
timestep=0, excluded from loss. The `probability` parameter controls how often first-frame conditioning is applied;
remaining samples train in pure T2V mode.
```yaml
model:
training_mode: "lora"
training_strategy:
name: "text_to_video"
first_frame_conditioning_p: 0.1
with_audio: true # Enable audio training
audio_latents_dir: "audio_latents" # Directory containing audio latents
name: "flexible"
video:
is_generated: true
latents_dir: "latents"
conditions:
- type: first_frame
probability: 0.5
audio:
is_generated: true
latents_dir: "audio_latents"
```
**Example configuration file:**
**Example config:** 📄 [i2v_lora.yaml](../configs/i2v_lora.yaml)
- 📄 [Audio-Video LoRA Training](../configs/ltx2_av_lora.yaml)
---
**Dataset structure for audio-video training:**
## ⏩ Video Extension
Extend a video forward (or backward) in time. Prefix or suffix conditioning provides a span of existing latent frames
as clean conditioning. The `temporal_boundary` sets the number of **latent frames** used as context (each latent frame
= 8 pixel frames due to temporal compression).
```yaml
training_strategy:
name: "flexible"
video:
is_generated: true
latents_dir: "latents"
conditions:
- type: prefix # or "suffix" for backward extension
temporal_boundary: 8 # 8 latent frames = 64 pixel frames
probability: 1.0
audio:
is_generated: true
latents_dir: "audio_latents"
```
preprocessed_data_root/
├── latents/ # Video latents
├── conditions/ # Text embeddings
└── audio_latents/ # Audio latents (required when with_audio: true)
```
> [!IMPORTANT]
> When training audio-video LoRAs, ensure your `target_modules` configuration captures video, audio, and
> cross-modal attention branches. Use patterns like `"to_k"` instead of `"attn1.to_k"` to match:
> - Video modules: `attn1.to_k`, `attn2.to_k`
> - Audio modules: `audio_attn1.to_k`, `audio_attn2.to_k`
> - Cross-modal modules: `audio_to_video_attn.to_k`, `video_to_audio_attn.to_k`
>
> The cross-modal attention modules (`audio_to_video_attn` and `video_to_audio_attn`) enable bidirectional
> information flow between audio and video, which is critical for synchronized audiovisual generation.
> See [Understanding Target Modules](configuration-reference.md#understanding-target-modules) for detailed guidance.
> [!NOTE]
> You can generate audio during validation even if you're not training the audio branch.
> Set `validation.generate_audio: true` independently of `training_strategy.with_audio`.
> The `prefix` and `suffix` conditions also work on the audio modality for audio extension.
> Set `temporal_boundary` on the audio modality's conditions list to condition on a prefix or suffix
> of the audio latents.
## 🔥 Full Model Fine-tuning
**Example configs:** 📄 [video_extend_lora.yaml](../configs/video_extend_lora.yaml) (forward), 📄 [video_suffix_lora.yaml](../configs/video_suffix_lora.yaml) (backward)
Full model fine-tuning updates all parameters of the base model, providing maximum flexibility but
requiring substantial computational resources and larger training datasets:
---
- **Offers the highest potential quality and capability improvements**
- **Requires multiple GPUs** and distributed training techniques (e.g., FSDP)
- **Produces large checkpoint files** (several GB)
- **Best for major model adaptations** or when LoRA limitations are reached
## 🔄 IC-LoRA / Video-to-Video (V2V)
Configure full fine-tuning with:
In-Context LoRA learns transformations from paired videos. Pre-encoded reference latents are concatenated to the target
sequence — reference tokens participate in bidirectional self-attention but receive no noise and are excluded from loss.
This enables control adapters (depth, pose), style transfer, deblurring, colorization, and more.
```yaml
model:
training_mode: "full"
training_strategy:
name: "text_to_video"
first_frame_conditioning_p: 0.1
name: "flexible"
video:
is_generated: true
latents_dir: "latents"
conditions:
- type: reference
latents_dir: "reference_latents"
probability: 1.0
- type: first_frame # optional — composable with reference
probability: 0.2
```
> [!IMPORTANT]
> Full fine-tuning of LTX-2 requires multiple high-end GPUs (e.g., 4-8× H100 80GB) and distributed
> training with FSDP. See [Training Guide](training-guide.md) for multi-GPU setup instructions.
> [!NOTE]
> IC-LoRA is video-only by default (no audio modality block). Conditions can be composed — the example above also
> applies first-frame conditioning with 20% probability alongside the reference.
> Use [AV2AV IC-LoRA](#av2av-ic-lora) when both video and audio references should be trained jointly.
## 🔄 In-Context LoRA (IC-LoRA) Training
**Example config:** 📄 [v2v_ic_lora.yaml](../configs/v2v_ic_lora.yaml)
IC-LoRA is a specialized training mode for video-to-video transformations.
Unlike standard training modes that learn from individual videos, IC-LoRA learns transformations from pairs of videos.
IC-LoRA enables a wide range of advanced video-to-video applications, such as:
### Dataset Requirements
- **Control adapters** (e.g., Depth, Pose): Learn to map from a control signal (like a depth map or pose skeleton) to a
target video
- **Video deblurring**: Transform blurry input videos into sharp, high-quality outputs
- **Style transfer**: Apply the style of a reference video to a target video sequence
- **Colorization**: Convert grayscale reference videos into colorized outputs
- **Restoration and enhancement**: Denoise, upscale, or restore old or degraded videos
- **Paired videos** — each target video has a corresponding reference video
- **Same frame count** between reference and target
- Reference videos can optionally be at **lower spatial resolution** (see [Scaled Reference](#scaled-reference-conditioning) below)
- Both must be **preprocessed** before training
By providing paired reference and target videos, IC-LoRA can learn complex transformations that go beyond caption-based
conditioning.
IC-LoRA training fundamentally differs from standard LoRA and full fine-tuning:
- **Reference videos** provide clean, unnoised conditioning input showing the "before" state
- **Target videos** are noised during training and represent the desired "after" state
- **The model learns transformations** from reference videos to target videos
- **Loss is applied only to the target portion**, not the reference
- **Training and inference time increase significantly** due to the doubled sequence length
To enable IC-LoRA training, configure your YAML file with:
```yaml
model:
training_mode: "lora" # Required: IC-LoRA uses LoRA mode
training_strategy:
name: "video_to_video"
first_frame_conditioning_p: 0.1
reference_latents_dir: "reference_latents" # Directory for reference video latents
```
**Example configuration file:**
- 📄 [IC-LoRA Training](../configs/ltx2_v2v_ic_lora.yaml) - Video-to-video transformation training
### Dataset Requirements for IC-LoRA
- Your dataset must contain **paired videos** where each target video has a corresponding reference video
- Reference and target videos must have the **same frame count** (length)
- Reference videos can optionally be at **lower spatial resolution** than target videos (
see [Scaled Reference Conditioning](#scaled-reference-conditioning) below)
- Both reference and target videos should be **preprocessed** before training
**Dataset structure for IC-LoRA training:**
**Dataset structure:**
```
preprocessed_data_root/
├── latents/ # Target video latents (what the model learns to generate)
├── conditions/ # Text embeddings for each video
├── latents/ # Target video latents
├── conditions/ # Text embeddings
└── reference_latents/ # Reference video latents (conditioning input)
```
### Generating Reference Videos
We provide an example script to generate reference videos (e.g., Canny edge maps) for a given dataset.
The script takes a JSON file as input (e.g., output of `caption_videos.py`) and updates it with the generated reference
video paths.
Use the `compute_reference.py` script to generate reference videos (e.g., Canny edge maps) for a dataset:
```bash
uv run python scripts/compute_reference.py scenes_output_dir/ \
@@ -169,84 +171,392 @@ uv run python scripts/compute_reference.py scenes_output_dir/ \
To compute a different condition (depth maps, pose skeletons, etc.), modify the `compute_reference()` function in the
script.
### Configuration Requirements for IC-LoRA
- You **must** provide `reference_videos` in your validation configuration when using IC-LoRA training
- The number of reference videos must match the number of validation prompts
Example validation configuration for IC-LoRA:
```yaml
validation:
prompts:
- "First prompt describing the desired output"
- "Second prompt describing the desired output"
reference_videos:
- "/path/to/reference1.mp4"
- "/path/to/reference2.mp4"
reference_downscale_factor: 1 # Set to match preprocessing (e.g., 2 for half resolution)
include_reference_in_output: true # Show reference side-by-side with output
```
> [!NOTE]
> `compute_reference.py` writes generated references to the `reference_video` column, which
> `process_dataset.py` detects automatically. The legacy `ref_media_path` column is also accepted.
### Scaled Reference Conditioning
For more efficient training and inference, you can use **downscaled reference videos** while keeping target videos at
full resolution. This reduces the number of conditioning tokens, leading to:
For more efficient training and inference, use **downscaled reference videos** while keeping targets at full
resolution. During training, the strategy infers the spatial and temporal scale factors from the preprocessed
reference and target latents and adjusts positional encodings accordingly. This reduces conditioning tokens, leading to:
- **Faster training** due to shorter sequence lengths
- **Faster inference** with reduced memory usage
- **Faster training** shorter sequence lengths
- **Faster inference** reduced memory usage
- **Same aspect ratio** maintained between reference and target
#### How It Works
When the reference video has resolution `H/n × W/n` and the target video has resolution `H × W`, the trainer
automatically detects this scale factor `n` and adjusts the positional encodings so that the reference positions
map to the correct locations in the target coordinate space.
#### Preprocessing Datasets with Scaled References
Use the `--reference-downscale-factor` option when running `process_dataset.py`:
Preprocess with the `--reference-downscale-factor` option:
```bash
# Process dataset with scaled reference videos (half resolution)
uv run python scripts/process_dataset.py dataset.json \
--resolution-buckets 768x768x25 \
--model-path /path/to/ltx2.safetensors \
--text-encoder-path /path/to/gemma \
--reference-column "reference_path" \
--reference-downscale-factor 2
```
This will:
> [!NOTE]
> The `reference_video` column is auto-detected by convention — no `--reference-column` flag needed.
- Process target videos at 768×768 resolution
- Process reference videos at 384×384 resolution (768 / 2)
- The trainer will automatically infer the scale factor from the dimension ratio
**Important**: Set `reference_downscale_factor: 2` in your validation configuration to match the preprocessing:
Validation encodes reference media on the fly, so set `downscale_factor` and `temporal_scale_factor`
on each `reference` validation condition to match the preprocessing factors:
```yaml
validation:
reference_downscale_factor: 2 # Must match the preprocessing factor
reference_videos:
- "/path/to/reference1.mp4"
- "/path/to/reference2.mp4"
samples:
- prompt: "..."
conditions:
- type: reference
video: "/path/to/reference.mp4"
downscale_factor: 2
temporal_scale_factor: 1
include_in_output: true
```
> [!NOTE]
> The scale factor must be a positive integer, and all dimensions must be divisible by 32.
> Common scale factors are 1 (no scaling), 2 (half resolution), or 4 (quarter resolution).
> Common values are 1 (no scaling), 2 (half resolution), or 4 (quarter resolution).
## 📊 Training Mode Comparison
---
| Aspect | LoRA | Audio-Video LoRA | Full Fine-tuning | IC-LoRA |
|----------------------|--------------------------------|--------------------------------|------------------|--------------------------------|
| **Memory Usage** | Low | Low-Medium | High | Medium |
| **Training Speed** | Fast | Fast | Slow | Medium |
| **Output Size** | 100MB-few GB (depends on rank) | 100MB-few GB (depends on rank) | Tens of GB | 100MB-few GB (depends on rank) |
| **Flexibility** | Medium | Medium | High | Specialized |
| **Audio Support** | Optional | Yes | Optional | No |
| **Reference Videos** | No | No | No | Yes (required) |
## 🔊 Audio-to-Video (A2V)
Generate video conditioned on frozen audio. Audio passes through the transformer clean (sigma=0) and influences video
via the built-in cross-modal attention. Only video is denoised.
```yaml
training_strategy:
name: "flexible"
video:
is_generated: true
latents_dir: "latents"
audio:
is_generated: false
latents_dir: "audio_latents"
```
**Example config:** 📄 [a2v_lora.yaml](../configs/a2v_lora.yaml)
---
## 🎵 Video-to-Audio / Foley (V2A)
Generate audio (Foley) conditioned on frozen video. Video passes through the transformer clean (sigma=0) and
conditions audio via cross-modal attention. Only audio is denoised.
```yaml
training_strategy:
name: "flexible"
video:
is_generated: false
latents_dir: "latents"
audio:
is_generated: true
latents_dir: "audio_latents"
```
**Example config:** 📄 [v2a_lora.yaml](../configs/v2a_lora.yaml)
---
## 🎭 Video Inpainting
Fill in masked regions of a video. Per-sample masks loaded from disk define which tokens are conditioning and which
must be generated. Masks are thresholded at `0.5` to match validation/inference: tokens with `mask > 0.5` receive clean
latents and timestep=0 and are excluded from loss; tokens with `mask <= 0.5` are denoised normally and contribute to
loss.
```yaml
training_strategy:
name: "flexible"
video:
is_generated: true
latents_dir: "latents"
conditions:
- type: mask
mask_dir: "video_masks"
probability: 1.0
```
**Dataset structure:**
```
preprocessed_data_root/
├── latents/ # Video latents
├── conditions/ # Text embeddings
└── video_masks/ # Per-sample binary masks (1 → conditioning, 0 → generate)
```
In dataset metadata, provide mask media via the `video_mask` column; preprocessing converts it into `video_masks/`.
**Example config:** 📄 [video_inpainting_lora.yaml](../configs/video_inpainting_lora.yaml)
---
## 🌅 Video Outpainting
Extend a video spatially beyond its original boundaries. A rectangular pixel region is provided as clean conditioning
(no noise, timestep=0, excluded from loss) — the model learns to generate the surrounding content. The `spatial_region`
is specified in pixel coordinates `[y1, x1, y2, x2]` and automatically converted to latent space.
```yaml
training_strategy:
name: "flexible"
video:
is_generated: true
latents_dir: "latents"
conditions:
- type: spatial_crop
spatial_region: [0, 0, 288, 576] # y1, x1, y2, x2 in pixels
probability: 1.0
```
> [!NOTE]
> `spatial_crop` is a video-only condition — it is not supported on the audio modality.
**Example config:** 📄 [video_outpainting_lora.yaml](../configs/video_outpainting_lora.yaml)
---
## 🔈 Text-to-Audio (T2A)
Generate audio from text prompts with no video modality. Only the audio branch of the transformer is denoised. Since
no video modality is configured, this mode uses **audio-only LoRA targets** — explicitly targeting `audio_attn1`,
`audio_attn2`, and `audio_ff` modules.
```yaml
training_strategy:
name: "flexible"
audio:
is_generated: true
latents_dir: "audio_latents"
```
> [!NOTE]
> With no `video` block in the strategy, the trainer only loads audio latents and text embeddings. LoRA adapters
> should explicitly target audio modules (e.g., `audio_attn1.to_k`) rather than short patterns like `to_k` which
> would also match video modules. See [LoRA Target Modules Guidance](#lora-target-modules-guidance) below.
**Example config:** 📄 [t2a_lora.yaml](../configs/t2a_lora.yaml)
---
## 🔊 Audio Extension
Extend audio forward (prefix) or backward (suffix) in time — the audio equivalent of Video Extension. A span of
existing audio latent frames is provided as clean conditioning, and the model generates the continuation. The
`temporal_boundary` sets the number of latent frames used as context. This mode uses **audio-only LoRA targets**.
```yaml
training_strategy:
name: "flexible"
audio:
is_generated: true
latents_dir: "audio_latents"
conditions:
- type: prefix # or "suffix" for backward extension
temporal_boundary: 8
probability: 1.0
```
**Example configs:** 📄 [audio_extend_lora.yaml](../configs/audio_extend_lora.yaml), 📄 [audio_suffix_lora.yaml](../configs/audio_suffix_lora.yaml)
---
## 🎭 Audio Inpainting
Fill in masked regions of audio. Per-sample masks loaded from disk define which audio tokens are conditioning and
which must be generated — the audio equivalent of Video Inpainting. Masks are thresholded at `0.5` with the same
binary semantics as video inpainting. This mode uses **audio-only LoRA targets**.
```yaml
training_strategy:
name: "flexible"
audio:
is_generated: true
latents_dir: "audio_latents"
conditions:
- type: mask
mask_dir: "audio_masks"
probability: 1.0
```
**Dataset structure:**
```
preprocessed_data_root/
├── conditions/ # Text embeddings
├── audio_latents/ # Audio latents
└── audio_masks/ # Per-sample binary masks (1 → conditioning, 0 → generate)
```
In dataset metadata, provide mask media via the `audio_mask` column; preprocessing converts it into `audio_masks/`.
**Example config:** 📄 [audio_inpainting_lora.yaml](../configs/audio_inpainting_lora.yaml)
---
## 🔄 IC-LoRA / Audio-to-Audio (A2A)
In-Context LoRA for audio-to-audio transformations. Pre-encoded reference audio latents are concatenated to the target
sequence — reference tokens participate in bidirectional self-attention but receive no noise and are excluded from loss.
This enables audio style transfer, voice conversion, sound effect transformation, and more. This mode uses
**audio-only LoRA targets**.
```yaml
training_strategy:
name: "flexible"
audio:
is_generated: true
latents_dir: "audio_latents"
conditions:
- type: reference
latents_dir: "reference_audio_latents"
probability: 1.0
```
**Dataset structure:**
```
preprocessed_data_root/
├── conditions/ # Text embeddings
├── audio_latents/ # Target audio latents
└── reference_audio_latents/ # Reference audio latents (conditioning input)
```
**Example config:** 📄 [a2a_ic_lora.yaml](../configs/a2a_ic_lora.yaml)
---
## 🔄 AV2AV IC-LoRA
Joint audio-video In-Context LoRA — both modalities have reference conditioning. Pre-encoded reference latents are
concatenated to each modality's target sequence independently. This enables joint audiovisual transformations such as
synchronized style transfer across both video and audio.
```yaml
training_strategy:
name: "flexible"
video:
is_generated: true
latents_dir: "latents"
conditions:
- type: reference
latents_dir: "reference_latents"
probability: 1.0
audio:
is_generated: true
latents_dir: "audio_latents"
conditions:
- type: reference
latents_dir: "reference_audio_latents"
probability: 1.0
```
> [!NOTE]
> Unlike audio-only IC-LoRA (A2A), AV2AV uses short LoRA target patterns like `"to_k"` to match all branches
> (video, audio, and cross-modal attention), since both modalities are trained.
**Dataset structure:**
```
preprocessed_data_root/
├── latents/ # Target video latents
├── audio_latents/ # Target audio latents
├── conditions/ # Text embeddings
├── reference_latents/ # Reference video latents (conditioning input)
└── reference_audio_latents/ # Reference audio latents (conditioning input)
```
**Example config:** 📄 [av2av_ic_lora.yaml](../configs/av2av_ic_lora.yaml)
---
## 🔥 Full Model Fine-tuning
All modes above default to `training_mode: "lora"`. For full fine-tuning, set `training_mode: "full"` — this updates
all model parameters rather than adding LoRA adapters.
```yaml
model:
training_mode: "full"
training_strategy:
name: "flexible"
video:
is_generated: true
latents_dir: "latents"
audio:
is_generated: true
latents_dir: "audio_latents"
```
> [!IMPORTANT]
> Full fine-tuning requires multiple high-end GPUs (e.g., 4-8× H100 80GB) and distributed training with FSDP.
> See [Training Guide](training-guide.md) for multi-GPU setup instructions.
---
## 🎛️ LoRA Target Modules Guidance
The `target_modules` configuration determines which transformer modules receive LoRA adapters. The right choice depends
on whether your training involves cross-modal (audio ↔ video) interaction.
**For T2V, I2V, A2V, V2A, or any mode involving both modalities** — use short patterns to match all branches
(video, audio, and cross-modal attention):
```yaml
target_modules:
- "to_k"
- "to_q"
- "to_v"
- "to_out.0"
```
> [!IMPORTANT]
> Short patterns like `"to_k"` match video modules (`attn1.to_k`, `attn2.to_k`), audio modules
> (`audio_attn1.to_k`, `audio_attn2.to_k`), and cross-modal modules (`audio_to_video_attn.to_k`,
> `video_to_audio_attn.to_k`). The cross-modal attention modules enable bidirectional information flow between
> audio and video, which is critical for synchronized audiovisual generation.
> See [Understanding Target Modules](configuration-reference.md#understanding-target-modules) for detailed guidance.
**For video-only IC-LoRA** — explicitly target video modules (including FFN layers for better transformation quality):
```yaml
target_modules:
- "attn1.to_k"
- "attn1.to_q"
- "attn1.to_v"
- "attn1.to_out.0"
- "attn2.to_k"
- "attn2.to_q"
- "attn2.to_v"
- "attn2.to_out.0"
- "ff.net.0.proj"
- "ff.net.2"
```
**For audio-only modes (T2A, Audio Extension, Audio Inpainting, A2A IC-LoRA)** — explicitly target audio modules:
```yaml
target_modules:
- "audio_attn1.to_k"
- "audio_attn1.to_q"
- "audio_attn1.to_v"
- "audio_attn1.to_out.0"
- "audio_attn2.to_k"
- "audio_attn2.to_q"
- "audio_attn2.to_v"
- "audio_attn2.to_out.0"
- "audio_ff.net.0.proj"
- "audio_ff.net.2"
```
> [!NOTE]
> Audio-only modes have no `video` block in the strategy, so there is no need to train video or cross-modal
> attention modules. Targeting only `audio_*` modules keeps the LoRA small and focused.
---
## 🎬 Using Trained Models for Inference
@@ -255,12 +565,25 @@ LoRAs:
| Training Mode | Recommended Pipeline |
|-------------------------|-------------------------------------------------------|
| LoRA / Audio-Video LoRA | `TI2VidOneStagePipeline` or `TI2VidTwoStagesPipeline` |
| IC-LoRA | `ICLoraPipeline` |
| T2V / I2V / A2V / Extension / Inpainting / Outpainting | `TI2VidOneStagePipeline` or `TI2VidTwoStagesPipeline` |
| IC-LoRA (V2V / A2A / AV2AV) | `ICLoraPipeline` |
| V2A (Foley) / T2A / Audio Extension / Audio Inpainting | `TI2VidOneStagePipeline` or `TI2VidTwoStagesPipeline` |
All pipelines support loading custom LoRAs via the `loras` parameter. See the [`ltx-pipelines`](../../ltx-pipelines/)
package
documentation for detailed usage instructions.
package documentation for detailed usage instructions.
> [!NOTE]
> You can generate audio during validation even if you're not training the audio branch.
> Set `validation.generate_audio: true` independently of whether audio has `is_generated: true`.
---
## 🔄 Migration from Legacy Strategies
Legacy `text_to_video` and `video_to_video` strategy configs are forward-compatible and will continue to work (with a
deprecation warning). We recommend migrating to `flexible` for access to all conditioning modes.
---
## 🚀 Next Steps
@@ -272,6 +595,6 @@ Once you've chosen your training mode:
> [!TIP]
> Need a training mode that's not covered here?
> See [Implementing Custom Training Strategies](custom-training-strategies.md)
> to learn how to create your own strategy for specialized use cases like video inpainting, audio-only training, or
> custom conditioning.
> First check whether it can be expressed by composing existing `flexible` conditions. Use
> [Implementing Custom Training Strategies](custom-training-strategies.md) only for custom losses,
> noising rules, model outputs, or preprocessing that cannot be represented by configuration.
+10 -10
View File
@@ -8,7 +8,7 @@ Memory management is crucial for successful training with LTX-2.
> [!TIP]
> For GPUs with 32GB VRAM, use the pre-configured low VRAM config:
> [`configs/ltx2_av_lora_low_vram.yaml`](../configs/ltx2_av_lora_low_vram.yaml)
> [`configs/t2v_lora_low_vram.yaml`](../configs/t2v_lora_low_vram.yaml)
> which combines 8-bit optimizer, INT8 quantization, and reduced LoRA rank.
### Memory Optimization Techniques
@@ -111,7 +111,7 @@ Ensure you've installed the dependencies and are using `uv run` to execute scrip
# From the repository root
uv sync
cd packages/ltx-trainer
uv run python scripts/train.py configs/ltx2_av_lora.yaml
uv run python scripts/train.py configs/t2v_lora.yaml
```
> [!TIP]
@@ -168,29 +168,29 @@ LTX-2 requires the number of frames to satisfy `frames % 8 == 1`:
```bash
uv run accelerate launch --config_file configs/accelerate/ddp_compile.yaml \
scripts/train.py configs/ltx2_av_lora.yaml
scripts/train.py configs/t2v_lora.yaml
```
### Issue: Poor Quality Validation Outputs
**Solutions:**
1. **Use Image-to-Video Validation:**
For more reliable validation, use image-to-video (first-frame conditioning) rather than pure text-to-video:
1. **Use conditioned validation:** For more reliable validation, use image-to-video (first-frame conditioning) rather than pure text-to-video:
```yaml
validation:
prompts:
- "a professional portrait video of a person"
images:
- "/path/to/first_frame.png" # One image per prompt
samples:
- prompt: "a professional portrait video of a person"
conditions:
- type: first_frame
image_or_video: "/path/to/first_frame.png"
```
2. **Increase inference steps:**
```yaml
validation:
inference_steps: 50 # Default is 30
inference_steps: 30
```
3. **Adjust guidance settings:**
+51 -132
View File
@@ -35,75 +35,53 @@ uv run python scripts/split_scenes.py video.mp4 scenes/ --max-scenes 50
### Automatic Video Captioning
The `scripts/caption_videos.py` script generates captions for videos (with audio) using multimodal models.
The `scripts/caption_videos.py` script generates a single, detailed combined audio-visual
caption per video as a continuous paragraph of prose. Two backends are available:
- **`qwen_omni` (default)** — Qwen3-Omni-30B-A3B-Thinking served via a local
[vLLM](https://docs.vllm.ai/) HTTP server (~1-3 s/video on H100). Highest quality, runs
fully offline once the model is downloaded.
- **`gemini_flash`** — Google Gemini (cloud, `gemini-3.5-flash`). No GPU required. Auth is
automatic: set `GEMINI_API_KEY` (or `GOOGLE_API_KEY`) for the Developer API, or just have
Google Cloud credentials available (`gcloud auth` / an attached service account) and it
uses Vertex AI with no extra setup.
**Step 1 — launch the captioner server** (`qwen_omni` only, one-time).
`scripts/serve_captioner.py` runs vLLM in an isolated environment via `uvx`, so vLLM's heavy
CUDA dependencies never touch the trainer's venv. It defaults to dynamic FP8 quantization
(~31 GiB weights, fits on 40 GB GPUs, same speed as BF16 on H100):
```bash
# Generate captions for all videos in a directory (uses Qwen2.5-Omni by default)
uv run python scripts/caption_videos.py videos_dir/ --output dataset.json
# Terminal 1 - stays running
uv run python packages/ltx-trainer/scripts/serve_captioner.py
# Use 8-bit quantization to reduce VRAM usage
uv run python scripts/caption_videos.py videos_dir/ --output dataset.json --use-8bit
# Use Gemini Flash API instead (requires API key)
uv run python scripts/caption_videos.py videos_dir/ --output dataset.json \
--captioner-type gemini_flash --api-key YOUR_API_KEY
# Use Gemini Flash with parallel workers for faster throughput
uv run python scripts/caption_videos.py videos_dir/ --output dataset.json \
--captioner-type gemini_flash --num-workers 5
# Caption without audio processing (video-only)
uv run python scripts/caption_videos.py videos_dir/ --output dataset.json --no-audio
# Force re-caption all files
uv run python scripts/caption_videos.py videos_dir/ --output dataset.json --override
# Useful variants:
# --print-cmd show the vLLM command without running it
# --quantization bf16 use BF16 instead (needs ~66 GiB free VRAM)
# --hf-home /mnt/disk override where the ~65 GB model is downloaded
```
**Key features:**
- **Audio-visual captioning**: Processes both video and audio content, including speech transcription
- **Multiple backends**:
- `qwen_omni` (default): Local Qwen2.5-Omni model - processes video + audio locally
- `gemini_flash`: Google Gemini Flash API - cloud-based, requires API key
- **Parallel captioning** (Gemini Flash only): Use `--num-workers` to run multiple API calls concurrently for faster throughput on large datasets
- **Structured output**: Captions include visual description, speech transcription, sounds, and on-screen text
- **Memory optimization**: 8-bit quantization option for limited VRAM
- **Incremental processing**: Skips already-captioned files by default; progress is saved every 5 videos
- **Multiple output formats**: JSON, JSONL, CSV, or TXT
**Caption format:**
The captioner produces structured captions with four sections:
- `[VISUAL]`: Detailed description of visual content
- `[SPEECH]`: Word-for-word transcription of spoken content
- `[SOUNDS]`: Description of music, ambient sounds, sound effects
- `[TEXT]`: Any on-screen text visible in the video
**Parallel captioning with Gemini Flash:**
When using `--captioner-type gemini_flash`, you can speed up large dataset captioning by running multiple API calls at the same time using `--num-workers` (accepts 110, default is 1):
**Step 2 — caption your videos.**
```bash
export GEMINI_API_KEY="your-key-here"
# Terminal 2 - default backend talks to the server above
uv run python packages/ltx-trainer/scripts/caption_videos.py videos_dir/ --output dataset.json
# Caption a large dataset with 5 workers running concurrently
uv run python scripts/caption_videos.py videos_dir/ \
--output dataset.json \
--captioner-type gemini_flash \
--num-workers 5
# Remote server: --vllm-url http://other-host:8001/v1
# Gemini (gemini-3.5-flash): --captioner-type gemini_flash (uses GEMINI_API_KEY, else gcloud/Vertex)
# Gemini, parallel calls: --captioner-type gemini_flash --num-workers 5
# Re-caption everything: --override
```
> [!NOTE]
> `--num-workers` is only supported with `gemini_flash`. Using it with `qwen_omni` or any other local model will raise an error, because local GPU models are not thread-safe.
Captioning is incremental (already-captioned files are skipped, progress saves every 5 videos)
and writes JSON, JSONL, CSV, or TXT based on the output extension.
> [!TIP]
> Keep `--num-workers` between 35 for most use cases. Very high values (810) may hit Gemini API rate limits depending on your quota tier.
Qwen3-Omni-Thinking can optionally emit a `<think>...</think>` chain-of-thought before the
caption (`--enable-thinking`). It is off by default, which is recommended for bulk captioning
(thinking is slower as it generates the reasoning trace first).
**Environment variables (for Gemini Flash):**
Set one of these to use Gemini Flash without passing `--api-key`:
- `GOOGLE_API_KEY`
- `GEMINI_API_KEY`
For Gemini, keep `--num-workers` at 3-5 (higher values may hit API rate limits).
### Dataset Preprocessing
@@ -116,13 +94,6 @@ uv run python scripts/process_dataset.py dataset.json \
--model-path /path/to/ltx-2-model.safetensors \
--text-encoder-path /path/to/gemma-model
# With audio processing
uv run python scripts/process_dataset.py dataset.json \
--resolution-buckets "960x544x49" \
--model-path /path/to/ltx-2-model.safetensors \
--text-encoder-path /path/to/gemma-model \
--with-audio
# With video decoding for verification
uv run python scripts/process_dataset.py dataset.json \
--resolution-buckets "960x544x49" \
@@ -186,6 +157,10 @@ uv run python scripts/compute_reference.py videos_dir/ --output dataset.json
> You can edit this script to generate other types of reference videos for IC-LoRA training,
> such as depth maps, segmentation masks, or any custom video transformation.
> [!NOTE]
> `compute_reference.py` writes generated references to the `reference_video` column, which
> `process_dataset.py` detects automatically.
## 🔍 Debugging and Verification Scripts
### Latents Decoding
@@ -224,73 +199,17 @@ uv run python scripts/decode_latents.py /path/to/latents/dir \
- **Debug training data**: Visualize what the model actually sees during training
- **Quality assessment**: Ensure latent encoding preserves important visual details
### Inference with Trained Models
### Inference Script
For inference with trained LoRAs, use the [`ltx-pipelines`](../../ltx-pipelines/) package which provides
production-ready pipelines:
The `scripts/inference.py` script runs inference with a trained model.
- **Text/Image-to-Video**: `TI2VidOneStagePipeline`, `TI2VidTwoStagesPipeline`
- **Distilled (fast) inference**: `DistilledPipeline`
- **IC-LoRA video-to-video**: `ICLoraPipeline`
- **Keyframe interpolation**: `KeyframeInterpolationPipeline`
> [!TIP]
> For production inference, consider using the [`ltx-pipelines`](../../ltx-pipelines/) package which provides optimized,
> feature-rich pipelines for various use cases:
> - **Text/Image-to-Video**: `TI2VidOneStagePipeline`, `TI2VidTwoStagesPipeline`
> - **Distilled (fast) inference**: `DistilledPipeline`
> - **IC-LoRA video-to-video**: `ICLoraPipeline`
> - **Keyframe interpolation**: `KeyframeInterpolationPipeline`
>
> All pipelines support loading custom LoRAs trained with this trainer.
```bash
# Text-to-video inference (with audio by default)
# By default, uses CFG scale 4.0 and STG scale 1.0 with block 29
uv run python scripts/inference.py \
--checkpoint /path/to/model.safetensors \
--text-encoder-path /path/to/gemma \
--prompt "A cat playing with a ball" \
--output output.mp4
# Video-only (skip audio generation)
uv run python scripts/inference.py \
--checkpoint /path/to/model.safetensors \
--text-encoder-path /path/to/gemma \
--prompt "A cat playing with a ball" \
--skip-audio \
--output output.mp4
# Image-to-video with conditioning image
uv run python scripts/inference.py \
--checkpoint /path/to/model.safetensors \
--text-encoder-path /path/to/gemma \
--prompt "A cat walking" \
--condition-image first_frame.png \
--output output.mp4
# Custom guidance settings
uv run python scripts/inference.py \
--checkpoint /path/to/model.safetensors \
--text-encoder-path /path/to/gemma \
--prompt "A cat playing with a ball" \
--guidance-scale 4.0 \
--stg-scale 1.0 \
--stg-blocks 29 \
--output output.mp4
# Disable STG (CFG only)
uv run python scripts/inference.py \
--checkpoint /path/to/model.safetensors \
--text-encoder-path /path/to/gemma \
--prompt "A cat playing with a ball" \
--stg-scale 0.0 \
--output output.mp4
```
**Guidance parameters:**
| Parameter | Default | Description |
|-----------|---------|-------------|
| `--guidance-scale` | 4.0 | CFG (Classifier-Free Guidance) scale |
| `--stg-scale` | 1.0 | STG (Spatio-Temporal Guidance) scale. 0.0 disables STG |
| `--stg-blocks` | 29 | Transformer block(s) to perturb for STG |
| `--stg-mode` | stg_av | `stg_av` perturbs both audio and video, `stg_v` video only |
All pipelines support loading custom LoRAs trained with this trainer.
## 🚀 Training Scripts
@@ -300,13 +219,13 @@ Use `scripts/train.py` for both single GPU and multi-GPU runs:
```bash
# Single-GPU training
uv run python scripts/train.py configs/ltx2_av_lora.yaml
uv run python scripts/train.py configs/t2v_lora.yaml
# Multi-GPU (uses your accelerate config)
uv run accelerate launch scripts/train.py configs/ltx2_av_lora.yaml
uv run accelerate launch scripts/train.py configs/t2v_lora.yaml
# Override number of processes
uv run accelerate launch --num_processes 4 scripts/train.py configs/ltx2_av_lora.yaml
uv run accelerate launch --num_processes 4 scripts/train.py configs/t2v_lora.yaml
```
For detailed usage, see the [Training Guide](training-guide.md).
@@ -316,5 +235,5 @@ For detailed usage, see the [Training Guide](training-guide.md).
- **Start with `--help`**: Always check available options for each script
- **Test on small datasets**: Verify workflows with a few files before processing large datasets
- **Use decode verification**: Always decode a few samples to verify preprocessing quality
- **Monitor VRAM usage**: Use `--use-8bit` or quantization flags when running into memory issues
- **Monitor VRAM usage**: Reach for quantization or lower-memory settings (e.g. FP8 for the captioner server) when running into memory issues
- **Keep backups**: Make copies of important dataset files before running conversion scripts
+13 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "ltx-trainer"
version = "1.1.5"
version = "v1.1.6"
description = "LTX-2 training, democratized."
readme = "README.md"
authors = [
@@ -12,9 +12,11 @@ dependencies = [
"accelerate>=1.2.1",
"av>=14.2.1",
"bitsandbytes >=0.45.2; sys_platform == 'linux'",
"google-genai>=2.0",
"huggingface-hub[hf-xet]>=0.31.4",
"imageio>=2.37.0",
"imageio-ffmpeg>=0.6.0",
"openai>=2.0",
"opencv-python>=4.11.0.86",
"optimum-quanto>=0.2.6",
"pandas>=2.2.3",
@@ -25,12 +27,16 @@ dependencies = [
"safetensors>=0.5.0",
"scenedetect>=0.6.5.2",
"sentencepiece>=0.2.0",
"soundfile>=0.12.1",
"torch>=2.6.0",
"torchaudio>=2.7.0",
"torchcodec>=0.8.1",
# torchcodec must match the torch version (it ships a torch-ABI C++ extension and declares
# no torch pin of its own); the 0.9 line matches torch 2.9. torchaudio>=2.9 routes
# torchaudio.load() through torchcodec, so audio preprocessing needs it installed.
"torchcodec>=0.8.1,<0.10",
"torchvision>=0.21.0",
"typer>=0.15.1",
"wandb>=0.19.11",
"wandb>=0.27.0",
"setuptools>=79.0.0",
]
@@ -48,8 +54,11 @@ build-backend = "hatchling.build"
[tool.ruff]
target-version = "1.1.5"
target-version = "v1.1.6"
line-length = 120
# Restrict isort first-party detection to src/ so stray dirs (e.g. wandb/ run output)
# next to pyproject.toml don't get classified as first-party packages. See ruff#10519.
src = ["src"]
[tool.ruff.lint]
select = [
+81 -75
View File
@@ -2,29 +2,28 @@
"""
Auto-caption videos with audio using multimodal models.
This script provides a command-line interface for generating captions for videos
(including audio) using multimodal models. It supports:
- Qwen2.5-Omni: Local model for audio-visual captioning (default)
- Gemini Flash: Cloud-based API for audio-visual captioning
The paths to videos in the generated dataset/captions file will be RELATIVE to the
directory where the output file is stored. This makes the dataset more portable and
easier to use in different environments.
Backends:
- Qwen3-Omni-30B-A3B-Thinking via a local vLLM HTTP server (default,
``qwen_omni``). Launch the server once with ``scripts/serve_captioner.py``.
- Gemini Flash 3.5 via Google's API (``gemini_flash``).
The paths in the output file are RELATIVE to the output file's directory,
making the dataset portable.
Basic usage:
# Caption a single video (includes audio by default)
caption_videos.py video.mp4 --output captions.json
# Caption all videos in a directory
caption_videos.py videos_dir/ --output captions.csv
# Caption with custom instruction
caption_videos.py video.mp4 --instruction "Describe what happens in this video in detail."
# Launch the captioner server once (separate terminal)
uv run python scripts/serve_captioner.py
# Caption a directory
caption_videos.py videos_dir/ --output captions.json
# Caption a single video with a custom prompt
caption_videos.py video.mp4 --output cap.json --instruction "Describe in detail."
Advanced usage:
# Use Gemini Flash API (requires GEMINI_API_KEY or GOOGLE_API_KEY env var)
# Use Gemini Flash 3.5 (cloud, requires GEMINI_API_KEY)
caption_videos.py videos_dir/ --captioner-type gemini_flash
# Use Gemini Flash with parallel workers (2-10 workers, cloud API only)
# Gemini with parallel workers
caption_videos.py videos_dir/ --captioner-type gemini_flash --num-workers 5
# Disable audio processing (video-only captions)
caption_videos.py videos_dir/ --no-audio
# Process videos with specific extensions and save as JSON
caption_videos.py videos_dir/ --extensions mp4,mov,avi --output captions.json
# Talk to a remote vLLM server
caption_videos.py videos_dir/ --vllm-url http://192.168.1.10:8001/v1
# Enable Qwen3 chain-of-thought (slower, more detail)
caption_videos.py videos_dir/ --enable-thinking
"""
import csv
@@ -33,7 +32,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from enum import Enum
from pathlib import Path
import torch
import typer
from rich.console import Console
from rich.progress import (
@@ -45,9 +43,14 @@ from rich.progress import (
TimeElapsedColumn,
TimeRemainingColumn,
)
from transformers.utils.logging import disable_progress_bar
from ltx_trainer.captioning import CaptionerType, MediaCaptioningModel, create_captioner
from ltx_trainer.captioning import (
DEFAULT_QWEN_MODEL,
DEFAULT_VLLM_BASE_URL,
CaptionerType,
MediaCaptioningModel,
create_captioner,
)
VIDEO_EXTENSIONS = ["mp4", "avi", "mov", "mkv", "webm"]
IMAGE_EXTENSIONS = ["jpg", "jpeg", "png"]
@@ -61,8 +64,6 @@ app = typer.Typer(
help="Auto-caption videos with audio using multimodal models.",
)
disable_progress_bar()
class OutputFormat(str, Enum):
"""Available output formats for captions."""
@@ -73,15 +74,13 @@ class OutputFormat(str, Enum):
JSONL = "jsonl" # JSON Lines file with one JSON object per line
def caption_media( # noqa: PLR0913
def caption_media(
input_path: Path,
output_path: Path,
captioner: MediaCaptioningModel,
extensions: list[str],
recursive: bool,
fps: int,
include_audio: bool,
clean_caption: bool,
output_format: OutputFormat,
override: bool,
num_workers: int = 1,
@@ -94,8 +93,6 @@ def caption_media( # noqa: PLR0913
extensions: List of media file extensions to include
recursive: Whether to search subdirectories recursively
fps: Frames per second to sample from videos (ignored for images)
include_audio: Whether to include audio in captioning
clean_caption: Whether to clean up captions
output_format: Format to save the captions in
override: Whether to override existing captions
num_workers: Number of parallel workers (only for cloud-based captioners like Gemini)
@@ -149,10 +146,10 @@ def caption_media( # noqa: PLR0913
caption = captioner.caption(
path=media_file,
fps=fps,
include_audio=include_audio,
clean_caption=clean_caption,
)
rel_path = str(media_file.resolve().relative_to(base_dir))
# Don't resolve the file itself, so a symlinked clip keeps its logical path under the
# dataset dir instead of jumping to its (possibly external) link target.
rel_path = str((media_file.parent.resolve() / media_file.name).relative_to(base_dir))
return rel_path, caption
with progress:
@@ -371,16 +368,31 @@ def main( # noqa: PLR0913
help="Type of captioner to use. Valid values: 'qwen_omni' (local), 'gemini_flash' (API)",
case_sensitive=False,
),
device: str | None = typer.Option(
None,
"--device",
"-d",
help="Device to use for inference (e.g., 'cuda', 'cuda:0', 'cpu'). Only for local models.",
vllm_url: str = typer.Option(
DEFAULT_VLLM_BASE_URL,
"--vllm-url",
help=(
"Base URL of the vLLM OpenAI-compatible server (qwen_omni only). "
"Launch the server with `uv run python scripts/serve_captioner.py`."
),
),
use_8bit: bool = typer.Option(
vllm_model: str = typer.Option(
DEFAULT_QWEN_MODEL,
"--vllm-model",
help="Served model identifier on the vLLM server (qwen_omni only).",
),
enable_thinking: bool = typer.Option(
False,
"--use-8bit",
help="Whether to use 8-bit precision for the captioning model (reduces memory usage)",
"--enable-thinking/--no-thinking",
help=(
"Let Qwen3-Omni produce a <think>...</think> chain-of-thought before the caption. "
"Off by default: ~5x slower with marginal quality benefit and occasional hallucinations."
),
),
max_tokens: int = typer.Option(
4096,
"--max-tokens",
help="Maximum new tokens to generate per caption (qwen_omni only).",
),
instruction: str | None = typer.Option(
None,
@@ -401,20 +413,14 @@ def main( # noqa: PLR0913
help="Search for media files in subdirectories recursively",
),
fps: int = typer.Option(
3,
2,
"--fps",
"-f",
help="Frames per second to sample from videos (ignored for images)",
),
include_audio: bool = typer.Option(
True,
"--audio/--no-audio",
help="Whether to include audio in captioning (for videos with audio tracks)",
),
clean_caption: bool = typer.Option(
True,
"--clean-caption/--raw-caption",
help="Whether to clean up captions by removing common VLM patterns",
help=(
"Frames per second to sample from videos. 2 is a typical default; "
"lower values use less compute per video. Ignored for images and for the "
"Gemini backend (which decides its own sampling rate)."
),
),
override: bool = typer.Option(
False,
@@ -441,35 +447,36 @@ def main( # noqa: PLR0913
),
) -> None:
"""Auto-caption videos with audio using multimodal models.
This script supports audio-visual captioning using:
- Qwen2.5-Omni: Local model (default) - processes both video and audio
- Gemini Flash: Cloud API - requires GOOGLE_API_KEY environment variable
Backends:
- ``qwen_omni`` (default): Qwen3-Omni-30B-A3B-Thinking via a local vLLM
HTTP server. Launch the server once in a separate terminal with
``uv run python scripts/serve_captioner.py``. The server stays loaded
across script invocations.
- ``gemini_flash``: Google Gemini (``gemini-3.5-flash``) via the google-genai SDK.
Auth is automatic -- ``GEMINI_API_KEY``/``GOOGLE_API_KEY`` for the Developer API,
or Google Cloud credentials (gcloud / service account) for Vertex AI with no env vars.
The paths in the output file will be relative to the output file's directory.
Examples:
# Caption videos with audio using Qwen2.5-Omni (default)
# Caption videos using the local vLLM server (default)
caption_videos.py videos_dir/ -o captions.json
# Caption using Gemini Flash API
# Point at a remote vLLM server
caption_videos.py videos_dir/ -o captions.json --vllm-url http://other-host:8001/v1
# Caption using Gemini Flash 3.5
caption_videos.py videos_dir/ -o captions.json -c gemini_flash
# Caption without audio (video-only)
caption_videos.py videos_dir/ -o captions.json --no-audio
# Caption with custom instruction
caption_videos.py video.mp4 -o captions.json -i "Describe this video in detail"
"""
# Parallel workers are only safe for cloud-based (stateless) captioners.
# Local models like Qwen-Omni hold GPU state and are not thread-safe.
# Parallel workers are only supported for the cloud Gemini backend; qwen_omni
# drives a single shared vLLM server and is captioned serially from here.
if num_workers > 1 and captioner_type != CaptionerType.GEMINI_FLASH:
console.print(
"[bold red]Error:[/] --num-workers > 1 is only supported with [bold]--captioner-type gemini_flash[/].\n"
"Local models (e.g. qwen_omni) run on GPU and are not thread-safe — "
"parallel calls would cause memory corruption or incorrect results.\n"
"Either set [bold]--num-workers 1[/] (default) or switch to [bold]--captioner-type gemini_flash[/]."
"[bold red]Error:[/] --num-workers > 1 is only supported with "
"[bold]--captioner-type gemini_flash[/]. Use --num-workers 1 (default) "
"for the qwen_omni backend."
)
raise typer.Exit(code=1)
# Determine device for local models
device_str = device or ("cuda" if torch.cuda.is_available() else "cpu")
# Parse extensions
ext_list = [ext.strip() for ext in extensions.split(",")]
@@ -490,14 +497,15 @@ def main( # noqa: PLR0913
output = Path(output).resolve()
console.print(f"Output will be saved to [bold blue]{output}[/]")
# Initialize captioning model
with console.status("Loading captioning model...", spinner="dots"):
with console.status("Initializing captioner...", spinner="dots"):
if captioner_type == CaptionerType.QWEN_OMNI:
captioner = create_captioner(
captioner_type=captioner_type,
device=device_str,
use_8bit=use_8bit,
base_url=vllm_url,
model=vllm_model,
instruction=instruction,
max_tokens=max_tokens,
enable_thinking=enable_thinking,
)
elif captioner_type == CaptionerType.GEMINI_FLASH:
captioner = create_captioner(
@@ -508,7 +516,7 @@ def main( # noqa: PLR0913
else:
raise ValueError(f"Unsupported captioner type: {captioner_type}")
console.print(f"[bold green]✓[/] {captioner_type.value} captioning model loaded successfully")
console.print(f"[bold green]✓[/] {captioner_type.value} captioner ready")
# Caption media files
caption_media(
@@ -518,8 +526,6 @@ def main( # noqa: PLR0913
extensions=ext_list,
recursive=recursive,
fps=fps,
include_audio=include_audio,
clean_caption=clean_caption,
output_format=output_format,
override=override,
num_workers=num_workers,
@@ -2,7 +2,7 @@
Compute reference videos for IC-LoRA training.
This script provides a command-line interface for generating reference videos to be used for IC-LoRA training.
Note that it reads and writes to the same file (the output of caption_videos.py),
where it adds the "reference_path" field to the JSON.
where it adds the "reference_video" field to the JSON.
Basic usage:
# Compute reference videos for all videos in a directory
compute_reference.py videos_dir/ --output videos_dir/captions.json
@@ -11,7 +11,7 @@ Basic usage:
# Standard library imports
import json
from pathlib import Path
from typing import Dict
from typing import Any
# Third-party imports
import cv2
@@ -37,6 +37,10 @@ from ltx_trainer.video_utils import read_video, save_video
console = Console()
disable_progress_bar()
VIDEO_COLUMNS = ("video", "media_path")
REFERENCE_VIDEO_COLUMN = "reference_video"
LEGACY_REFERENCE_COLUMN = "reference_path"
def compute_reference(
images: torch.Tensor,
@@ -79,15 +83,15 @@ def compute_reference(
def _get_meta_data(
output_path: Path,
) -> Dict[str, str]:
) -> list[dict[str, Any]]:
"""Get set of existing reference video paths without loading the actual files.
Args:
output_path: Path to the reference video paths file
Returns:
Dictionary mapping media paths to reference video paths
Dataset rows with media paths and captions
"""
if not output_path.exists():
return {}
return []
console.print(f"[bold blue]Reading meta data from [cyan]{output_path}[/]...[/]")
@@ -98,11 +102,18 @@ def _get_meta_data(
except Exception as e:
console.print(f"[bold yellow]Warning: Could not check meta data: {e}[/]")
return {}
return []
def _get_media_path(item: dict[str, Any]) -> str:
for column in VIDEO_COLUMNS:
if column in item:
return item[column]
raise KeyError(f"Dataset row must contain one of {VIDEO_COLUMNS}")
def _save_dataset_json(
reference_paths: Dict[str, str],
reference_paths: dict[str, str],
output_path: Path,
) -> None:
"""Save dataset json with reference video paths.
@@ -115,17 +126,17 @@ def _save_dataset_json(
json_data = json.load(f)
new_json_data = json_data.copy()
for i, item in enumerate(json_data):
media_path = item["media_path"]
media_path = _get_media_path(item)
reference_path = reference_paths[media_path]
new_json_data[i]["reference_path"] = reference_path
new_json_data[i].pop(LEGACY_REFERENCE_COLUMN, None)
new_json_data[i][REFERENCE_VIDEO_COLUMN] = reference_path
with output_path.open("w", encoding="utf-8") as f:
json.dump(new_json_data, f, indent=2, ensure_ascii=False)
console.print(f"[bold green]✓[/] Reference video paths saved to [cyan]{output_path}[/]")
console.print("[bold yellow]Note:[/] Use these files with ImageOrVideoDataset by setting:")
console.print(" reference_column='[cyan]reference_path[/]'")
console.print(" video_column='[cyan]media_path[/]'")
console.print("[bold yellow]Note:[/] Reference videos were written to the '[cyan]reference_video[/]' column.")
console.print(" [cyan]process_dataset.py[/] detects this column automatically for IC-LoRA preprocessing.")
def process_media(
@@ -158,7 +169,7 @@ def process_media(
def media_path_to_reference_path(media_file: Path) -> Path:
return media_file.parent / (media_file.stem + "_reference" + media_file.suffix)
media_files = [base_dir / Path(sample["media_path"]) for sample in meta_data]
media_files = [base_dir / Path(_get_media_path(sample)) for sample in meta_data]
for media_file in media_files:
reference_path = media_path_to_reference_path(media_file)
media_to_process.append(media_file)
@@ -178,18 +189,23 @@ def process_media(
)
# Process media files
media_paths = [item["media_path"] for item in meta_data]
media_paths = [_get_media_path(item) for item in meta_data]
reference_paths = {rel_path: str(media_path_to_reference_path(Path(rel_path))) for rel_path in media_paths}
with progress:
task = progress.add_task("Computing condition on videos", total=len(media_to_process))
for media_file in media_to_process:
for media_file, rel_path in zip(media_to_process, media_paths, strict=True):
progress.update(task, description=f"Processing [bold blue]{media_file.name}[/]")
rel_path = str(media_file.resolve().relative_to(base_dir))
# Key by the original media-path string (matches the dict seeded above). Avoid
# resolve()/relative_to here — they crash on symlinked or absolute media paths.
reference_path = media_path_to_reference_path(media_file)
reference_paths[rel_path] = str(reference_path.relative_to(base_dir))
try:
ref_stored = str(reference_path.relative_to(base_dir))
except ValueError:
ref_stored = str(reference_path) # absolute/out-of-tree: keep it next to the source
reference_paths[rel_path] = ref_stored
if not reference_path.resolve().exists() or override:
try:
@@ -310,7 +310,7 @@ def main(
help="Device to use for computation",
),
vae_tiling: bool = typer.Option(
default=False,
default=True,
help="Enable VAE tiling for larger video resolutions",
),
seed: int | None = typer.Option(
-443
View File
@@ -1,443 +0,0 @@
#!/usr/bin/env python3
# ruff: noqa: T201
"""
CLI script for running LTX video/audio generation inference.
Usage:
# Text-to-Video + Audio (default behavior)
python scripts/inference.py --checkpoint path/to/model.safetensors \
--text-encoder-path path/to/gemma \
--prompt "A cat playing with a ball" --output output.mp4
# Video only (skip audio)
python scripts/inference.py --checkpoint path/to/model.safetensors \
--text-encoder-path path/to/gemma \
--prompt "A cat playing with a ball" --skip-audio --output output.mp4
# Image-to-Video
python scripts/inference.py --checkpoint path/to/model.safetensors \
--text-encoder-path path/to/gemma \
--prompt "A cat walking" --condition-image first_frame.png --output output.mp4
# Video-to-Video (IC-LoRA style)
python scripts/inference.py --checkpoint path/to/model.safetensors \
--text-encoder-path path/to/gemma \
--prompt "A cat turning into a dog" --reference-video input.mp4 --output output.mp4
# With LoRA weights
python scripts/inference.py --checkpoint path/to/model.safetensors \
--text-encoder-path path/to/gemma \
--lora-path path/to/lora.safetensors \
--prompt "A cat in my custom style" --output output.mp4
"""
import argparse
import re
from pathlib import Path
import torch
import torchaudio
from peft import LoraConfig, get_peft_model, set_peft_model_state_dict
from safetensors.torch import load_file
from torchvision import transforms
from ltx_trainer.model_loader import load_model
from ltx_trainer.progress import StandaloneSamplingProgress
from ltx_trainer.utils import open_image_as_srgb
from ltx_trainer.validation_sampler import GenerationConfig, ValidationSampler
from ltx_trainer.video_utils import read_video, save_video
def load_image(image_path: str) -> torch.Tensor:
"""Load an image and convert to tensor [C, H, W] in [0, 1]."""
image = open_image_as_srgb(image_path)
transform = transforms.ToTensor()
return transform(image)
def extract_lora_target_modules(state_dict: dict[str, torch.Tensor]) -> list[str]:
"""Extract target module names from LoRA checkpoint keys.
LoRA keys follow the pattern (after removing "diffusion_model." prefix):
- transformer_blocks.0.attn1.to_k.lora_A.weight
- transformer_blocks.0.ff.net.0.proj.lora_B.weight
This extracts the full module path like "transformer_blocks.0.attn1.to_k".
Using full paths is more robust than partial patterns.
"""
target_modules = set()
# Pattern to extract everything before .lora_A or .lora_B
pattern = re.compile(r"(.+)\.lora_[AB]\.")
for key in state_dict:
match = pattern.match(key)
if match:
module_path = match.group(1)
target_modules.add(module_path)
return sorted(target_modules)
def load_lora_weights(transformer: torch.nn.Module, lora_path: str | Path) -> torch.nn.Module:
"""Load LoRA weights into the transformer model.
The LoRA rank and target modules are automatically detected from the checkpoint.
Alpha is set equal to rank (standard practice for inference).
Args:
transformer: The base transformer model
lora_path: Path to the LoRA weights (.safetensors)
Returns:
The transformer model with LoRA weights applied
"""
print(f"Loading LoRA weights from {lora_path}...")
# Load the LoRA state dict
state_dict = load_file(str(lora_path))
# Remove "diffusion_model." prefix (ComfyUI-compatible format)
state_dict = {k.replace("diffusion_model.", "", 1): v for k, v in state_dict.items()}
# Extract target modules from the checkpoint
target_modules = extract_lora_target_modules(state_dict)
if not target_modules:
raise ValueError(f"Could not extract target modules from LoRA checkpoint: {lora_path}")
print(f" Detected {len(target_modules)} target modules")
# Auto-detect rank from the first lora_A weight shape
lora_rank = None
for key, value in state_dict.items():
if "lora_A" in key and value.ndim == 2:
lora_rank = value.shape[0]
break
if lora_rank is None:
raise ValueError("Could not auto-detect LoRA rank from weights")
print(f" LoRA rank: {lora_rank}")
# Create LoRA config and wrap the model
# Alpha = rank is standard for inference (maintains the trained scale)
lora_config = LoraConfig(
r=lora_rank,
lora_alpha=lora_rank,
target_modules=target_modules,
lora_dropout=0.0,
init_lora_weights=True,
)
# Wrap the transformer with PEFT to add LoRA layers
transformer = get_peft_model(transformer, lora_config)
# Load the LoRA weights
base_model = transformer.get_base_model()
set_peft_model_state_dict(base_model, state_dict)
print("✓ LoRA weights loaded successfully")
return transformer
def main() -> None: # noqa: PLR0912, PLR0915
parser = argparse.ArgumentParser(
description="LTX Video/Audio Generation",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
# Model arguments
parser.add_argument(
"--checkpoint",
type=str,
required=True,
help="Path to model checkpoint (.safetensors)",
)
parser.add_argument(
"--text-encoder-path",
type=str,
required=True,
help="Path to Gemma text encoder directory",
)
# LoRA arguments
parser.add_argument(
"--lora-path",
type=str,
default=None,
help="Path to LoRA weights (.safetensors)",
)
# Generation arguments
parser.add_argument(
"--prompt",
type=str,
required=True,
help="Text prompt for generation",
)
parser.add_argument(
"--negative-prompt",
type=str,
default="",
help="Negative prompt",
)
parser.add_argument(
"--height",
type=int,
default=544,
help="Video height (must be divisible by 32)",
)
parser.add_argument(
"--width",
type=int,
default=960,
help="Video width (must be divisible by 32)",
)
parser.add_argument(
"--num-frames",
type=int,
default=97,
help="Number of video frames (must be k*8 + 1)",
)
parser.add_argument(
"--frame-rate",
type=float,
default=25.0,
help="Video frame rate",
)
parser.add_argument(
"--num-inference-steps",
type=int,
default=30,
help="Number of denoising steps",
)
parser.add_argument(
"--guidance-scale",
type=float,
default=4.0,
help="Classifier-free guidance scale (CFG)",
)
parser.add_argument(
"--stg-scale",
type=float,
default=1.0,
help="STG (Spatio-Temporal Guidance) scale. 0.0 disables STG. Default: 1.0",
)
parser.add_argument(
"--stg-blocks",
type=int,
nargs="*",
default=[29],
help="Which transformer blocks to perturb for STG. Default: 29 (single block).",
)
parser.add_argument(
"--stg-mode",
type=str,
default="stg_av",
choices=["stg_av", "stg_v"],
help="STG mode: 'stg_av' perturbs both audio and video, 'stg_v' perturbs video only",
)
parser.add_argument(
"--seed",
type=int,
default=42,
help="Random seed for reproducibility",
)
# Conditioning arguments
parser.add_argument(
"--condition-image",
type=str,
default=None,
help="Path to conditioning image for image-to-video generation",
)
parser.add_argument(
"--reference-video",
type=str,
default=None,
help="Path to reference video for video-to-video generation (IC-LoRA style)",
)
parser.add_argument(
"--include-reference-in-output",
action="store_true",
help="Include reference video side-by-side with generated output (only for V2V)",
)
# Audio arguments
parser.add_argument(
"--skip-audio",
action="store_true",
help="Skip audio generation (by default, audio is generated alongside video)",
)
# Output arguments
parser.add_argument(
"--output",
type=str,
required=True,
help="Output video path (.mp4)",
)
parser.add_argument(
"--audio-output",
type=str,
default=None,
help="Output audio path (.wav, optional - if not provided, audio will be embedded in video)",
)
# Device arguments
parser.add_argument(
"--device",
type=str,
default="cuda",
help="Device to run on (cuda/cpu)",
)
args = parser.parse_args()
# Validate conditioning arguments
if args.include_reference_in_output and args.reference_video is None:
parser.error("--include-reference-in-output requires --reference-video")
# Validate arguments
generate_audio = not args.skip_audio
print("=" * 80)
print("LTX Video/Audio Generation")
print("=" * 80)
# Determine if we need VAE encoder (for image or video conditioning)
need_vae_encoder = args.condition_image is not None or args.reference_video is not None
components = load_model(
checkpoint_path=args.checkpoint,
device="cpu", # Load to CPU first, sampler will move to device as needed
dtype=torch.bfloat16,
with_video_vae_encoder=need_vae_encoder,
with_video_vae_decoder=True,
with_audio_vae_decoder=generate_audio,
with_vocoder=generate_audio,
with_text_encoder=True,
text_encoder_path=args.text_encoder_path,
)
# Apply LoRA weights if provided
transformer = components.transformer
if args.lora_path is not None:
transformer = load_lora_weights(transformer, args.lora_path)
# Load conditioning image if provided
condition_image = None
if args.condition_image:
print(f"Loading conditioning image from {args.condition_image}...")
condition_image = load_image(args.condition_image)
# Load reference video if provided
reference_video = None
if args.reference_video:
print(f"Loading reference video from {args.reference_video}...")
reference_video, ref_fps = read_video(args.reference_video, max_frames=args.num_frames)
print(f" Loaded {reference_video.shape[0]} frames @ {ref_fps:.1f} fps")
# Determine generation mode
if args.reference_video is not None and args.condition_image is not None:
mode = "Video-to-Video + Image Conditioning (V2V+I2V)"
elif args.reference_video is not None:
mode = "Video-to-Video (V2V)"
elif args.condition_image is not None:
mode = "Image-to-Video (I2V)"
else:
mode = "Text-to-Video (T2V)"
print("\n" + "=" * 80)
print("Generation Parameters")
print("=" * 80)
print(f"Mode: {mode}")
print(f"Prompt: {args.prompt}")
if args.negative_prompt:
print(f"Negative prompt: {args.negative_prompt}")
print(f"Resolution: {args.width}x{args.height}")
print(f"Frames: {args.num_frames} @ {args.frame_rate} fps")
print(f"Inference steps: {args.num_inference_steps}")
print(f"CFG scale: {args.guidance_scale}")
if args.stg_scale > 0:
blocks_str = args.stg_blocks if args.stg_blocks else "all"
print(f"STG scale: {args.stg_scale} (mode: {args.stg_mode}, blocks: {blocks_str})")
else:
print("STG: disabled")
print(f"Seed: {args.seed}")
if args.lora_path:
print(f"LoRA: {args.lora_path}")
if condition_image is not None:
print(f"Conditioning: Image ({args.condition_image})")
if reference_video is not None:
print(f"Reference: Video ({args.reference_video})")
if args.include_reference_in_output:
print(" → Will include reference side-by-side in output")
if generate_audio:
video_duration = args.num_frames / args.frame_rate
print(f"Audio: Enabled (duration will match video: {video_duration:.2f}s)")
print("=" * 80)
print(f"\nGenerating {'video + audio' if generate_audio else 'video'}...")
# Create generation config
gen_config = GenerationConfig(
prompt=args.prompt,
negative_prompt=args.negative_prompt,
height=args.height,
width=args.width,
num_frames=args.num_frames,
frame_rate=args.frame_rate,
num_inference_steps=args.num_inference_steps,
guidance_scale=args.guidance_scale,
seed=args.seed,
condition_image=condition_image,
reference_video=reference_video,
generate_audio=generate_audio,
include_reference_in_output=args.include_reference_in_output,
stg_scale=args.stg_scale,
stg_blocks=args.stg_blocks,
stg_mode=args.stg_mode,
)
# Generate with progress bar
with StandaloneSamplingProgress(num_steps=args.num_inference_steps) as progress:
# Create sampler with progress context
sampler = ValidationSampler(
transformer=transformer,
vae_decoder=components.video_vae_decoder,
vae_encoder=components.video_vae_encoder,
text_encoder=components.text_encoder,
audio_decoder=components.audio_vae_decoder if generate_audio else None,
vocoder=components.vocoder if generate_audio else None,
sampling_context=progress,
)
video, audio = sampler.generate(
config=gen_config,
device=args.device,
)
# Save video
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Get audio sample rate from vocoder if audio was generated
audio_sample_rate = None
if audio is not None and components.vocoder is not None:
audio_sample_rate = components.vocoder.output_sampling_rate
save_video(
video_tensor=video,
output_path=output_path,
fps=args.frame_rate,
audio=audio,
audio_sample_rate=audio_sample_rate,
)
print(f"✓ Video saved to {args.output}")
# Save separate audio file if requested
if audio is not None and args.audio_output is not None:
audio_output_path = Path(args.audio_output)
audio_output_path.parent.mkdir(parents=True, exist_ok=True)
torchaudio.save(
str(audio_output_path),
audio.cpu(),
sample_rate=audio_sample_rate,
)
duration = audio.shape[1] / audio_sample_rate
print(f"✓ Audio saved: {duration:.2f}s at {audio_sample_rate}Hz")
print("\n" + "=" * 80)
print("Generation complete!")
print("=" * 80)
if __name__ == "__main__":
main()
@@ -154,6 +154,17 @@ class CaptionsDataset(Dataset):
else:
raise ValueError("Expected `dataset_file` to be a path to a CSV, JSON, or JSONL file.")
def _embedding_output_path(self, media_path: Path) -> str:
"""Output `.pt` path relative to the dataset dir; mirrors `process_videos._output_relative`
so caption keys match video/audio latent keys (and absolute paths don't escape output_dir)."""
data_root = self.dataset_file.parent
resolved = data_root / media_path # pathlib: an absolute media_path overrides data_root
try:
rel = resolved.relative_to(data_root)
except ValueError:
rel = Path(*resolved.parts[1:]) if resolved.is_absolute() else resolved
return str(rel.with_suffix(".pt"))
def _load_caption_data_from_csv(self) -> dict[str, str]:
"""Load captions from a CSV file and compute output embedding paths."""
df = pd.read_csv(self.dataset_file)
@@ -166,8 +177,7 @@ class CaptionsDataset(Dataset):
caption_data = {}
for _, row in df.iterrows():
media_path = Path(row[self.media_column].strip())
# Convert media path to embedding output path (same structure, .pt extension)
output_path = str(media_path.with_suffix(".pt"))
output_path = self._embedding_output_path(media_path)
caption_data[output_path] = row[self.caption_column]
return caption_data
@@ -188,8 +198,7 @@ class CaptionsDataset(Dataset):
raise ValueError(f"Key '{self.media_column}' not found in JSON entry: {entry}")
media_path = Path(entry[self.media_column].strip())
# Convert media path to embedding output path (same structure, .pt extension)
output_path = str(media_path.with_suffix(".pt"))
output_path = self._embedding_output_path(media_path)
caption_data[output_path] = entry[self.caption_column]
return caption_data
@@ -206,8 +215,7 @@ class CaptionsDataset(Dataset):
raise ValueError(f"Key '{self.media_column}' not found in JSONL entry: {entry}")
media_path = Path(entry[self.media_column].strip())
# Convert media path to embedding output path (same structure, .pt extension)
output_path = str(media_path.with_suffix(".pt"))
output_path = self._embedding_output_path(media_path)
caption_data[output_path] = entry[self.caption_column]
return caption_data
@@ -326,7 +334,8 @@ def compute_captions_embeddings( # noqa: PLR0913
# TODO(batch-tokenization): When tokenizer supports batching, encode all prompts at once.
# For now, process one at a time:
for i in range(len(batch["prompt"])):
hidden_states, prompt_attention_mask = text_encoder.encode(batch["prompt"][i], padding_side="left")
encoded = text_encoder.encode([batch["prompt"][i]], padding_side="left")
hidden_states, prompt_attention_mask = encoded[0]
video_prompt_embeds, audio_prompt_embeds = embeddings_processor.feature_extractor(
hidden_states, prompt_attention_mask, "left"
)
+268 -165
View File
@@ -1,14 +1,21 @@
#!/usr/bin/env python3
"""
Preprocess a video dataset by computing video clips latents and text captions embeddings.
This script provides a command-line interface for preprocessing video datasets by computing
latent representations of video clips and text embeddings of their captions. The preprocessed
data can be used to accelerate training of video generation models and to save GPU memory.
Preprocess a media dataset for LTX-2 training.
Automatically detects dataset columns and processes each according to a convention table.
Column names determine what gets encoded and where outputs go — no per-role CLI flags needed.
Convention table:
video → Video VAE → latents/
audio → Audio VAE → audio_latents/
reference_video → Video VAE → reference_latents/
reference_audio → Audio VAE → reference_audio_latents/
video_mask → (downsample) → video_masks/
audio_mask → (downsample) → audio_masks/
caption → Text encoder → conditions/
Legacy aliases: media_path → video, ref_media_path → reference_video
Basic usage:
python scripts/process_dataset.py /path/to/dataset.json --resolution-buckets 768x768x49 \
python scripts/process_dataset.py /path/to/dataset.json --resolution-buckets 768x768x49 \\
--model-path /path/to/ltx2.safetensors --text-encoder-path /path/to/gemma
The dataset must be a CSV, JSON, or JSONL file with columns for captions and video paths.
"""
from pathlib import Path
@@ -16,7 +23,15 @@ from pathlib import Path
import typer
from decode_latents import LatentsDecoder
from process_captions import compute_captions_embeddings
from process_videos import compute_latents, compute_scaled_resolution_buckets, parse_resolution_buckets
from process_videos import (
compute_audio_latents,
compute_audio_masks,
compute_latents,
compute_scaled_resolution_buckets,
compute_video_masks,
detect_dataset_columns,
parse_resolution_buckets,
)
from rich.console import Console
from ltx_trainer import logger
@@ -27,52 +42,82 @@ console = Console()
app = typer.Typer(
pretty_exceptions_enable=False,
no_args_is_help=True,
help="Preprocess a video dataset by computing video clips latents and text captions embeddings. "
"The dataset must be a CSV, JSON, or JSONL file with columns for captions and video paths.",
help="Preprocess a media dataset for LTX-2 training. "
"Automatically detects columns (video, audio, reference_video, reference_audio, caption) "
"and processes each with the appropriate encoder.",
)
_KNOWN_ROLES = {"video", "audio", "reference_video", "reference_audio", "video_mask", "audio_mask", "caption"}
_LEGACY_ALIASES = {"media_path": "video", "ref_media_path": "reference_video"}
def preprocess_dataset( # noqa: PLR0913
def preprocess_dataset( # noqa: PLR0912, PLR0913, PLR0915
dataset_file: str,
caption_column: str,
video_column: str,
resolution_buckets: list[tuple[int, int, int]],
batch_size: int,
output_dir: str | None,
lora_trigger: str | None,
vae_tiling: bool,
decode: bool,
resolution_buckets: list[tuple[int, int, int]] | None,
model_path: str,
text_encoder_path: str,
device: str,
output_dir: str | None = None,
video_column: str | None = None,
caption_column: str | None = None,
batch_size: int = 1,
lora_trigger: str | None = None,
vae_tiling: bool = False,
decode: bool = False,
remove_llm_prefixes: bool = False,
reference_column: str | None = None,
reference_downscale_factor: int = 1,
with_audio: bool = False,
reference_temporal_scale_factor: int = 1,
skip_audio: bool = False,
audio_durations: list[float] | None = None,
load_text_encoder_in_8bit: bool = False,
overwrite: bool = False,
) -> None:
"""Run the preprocessing pipeline with the given arguments."""
# Validate dataset file
"""Run the preprocessing pipeline with convention-based column detection."""
_validate_dataset_file(dataset_file)
# Set up output directories
# Detect columns and resolve roles
dataset_columns = detect_dataset_columns(dataset_file)
roles = _resolve_columns(dataset_columns, video_column, caption_column)
# Log detected roles
for role, col in sorted(roles.items()):
alias_note = f" (alias for '{role}')" if col != role else ""
logger.info(f"Detected column '{col}'{alias_note}{role}")
# Validate: need at least caption
if "caption" not in roles:
raise ValueError(
f"No caption column found. Dataset has columns: {dataset_columns}. "
f"Expected 'caption' or use --caption-column to specify."
)
# Validate: need video or audio
has_video = "video" in roles
has_audio = "audio" in roles
if not has_video and not has_audio:
raise ValueError(
f"No media column found. Dataset has columns: {dataset_columns}. "
f"Expected 'video', 'audio', or 'media_path' (legacy)."
)
# Validate: video modes need resolution buckets
if has_video and not resolution_buckets:
raise ValueError("--resolution-buckets is required when the dataset has a video column.")
output_base = Path(output_dir) if output_dir else Path(dataset_file).parent / ".precomputed"
conditions_dir = output_base / "conditions"
latents_dir = output_base / "latents"
if lora_trigger:
logger.info(f'LoRA trigger word "{lora_trigger}" will be prepended to all captions')
# --- Phase 1: Text encoder ---
with free_gpu_memory_context():
# Process captions using the dedicated function
compute_captions_embeddings(
dataset_file=dataset_file,
output_dir=str(conditions_dir),
output_dir=str(output_base / "conditions"),
model_path=model_path,
text_encoder_path=text_encoder_path,
caption_column=caption_column,
media_column=video_column,
caption_column=roles["caption"],
media_column=roles.get("video") or roles.get("audio") or roles["caption"],
lora_trigger=lora_trigger,
remove_llm_prefixes=remove_llm_prefixes,
batch_size=batch_size,
@@ -81,119 +126,177 @@ def preprocess_dataset( # noqa: PLR0913
overwrite=overwrite,
)
# Process videos using the dedicated function
audio_latents_dir = None
if with_audio:
logger.info("Audio preprocessing enabled - will extract and encode audio from videos")
audio_latents_dir = output_base / "audio_latents"
# --- Phase 2: Video VAE (video, reference_video) ---
if has_video and resolution_buckets:
# Determine if audio should be auto-extracted from video files
auto_audio = not skip_audio and "audio" not in roles
with free_gpu_memory_context():
compute_latents(
dataset_file=dataset_file,
video_column=video_column,
resolution_buckets=resolution_buckets,
output_dir=str(latents_dir),
model_path=model_path,
batch_size=batch_size,
device=device,
vae_tiling=vae_tiling,
with_audio=with_audio,
audio_output_dir=str(audio_latents_dir) if audio_latents_dir else None,
overwrite=overwrite,
)
# Process reference videos if reference_column is provided
if reference_column:
# Validate: scaled references with multiple buckets can cause ambiguous bucket matching
if reference_downscale_factor > 1 and len(resolution_buckets) > 1:
raise ValueError(
"When using --reference-downscale-factor > 1, only a single resolution bucket is supported. "
"Using multiple buckets with scaled references can cause ambiguous bucket matching "
"(e.g., a 512x256 reference could match either the scaled-down 1024x512 bucket or the 512x256 "
"bucket). Please use a single resolution bucket or set --reference-downscale-factor to 1."
)
# Calculate and validate scaled resolution buckets for reference videos
reference_buckets = compute_scaled_resolution_buckets(resolution_buckets, reference_downscale_factor)
if reference_downscale_factor > 1:
logger.info(
f"Processing reference videos for IC-LoRA training at 1/{reference_downscale_factor} resolution..."
)
logger.info(f"Reference resolution buckets: {reference_buckets}")
else:
logger.info("Processing reference videos for IC-LoRA training...")
reference_latents_dir = output_base / "reference_latents"
audio_latents_dir = str(output_base / "audio_latents") if auto_audio else None
if auto_audio:
logger.info("Audio will be auto-extracted from video files (use --skip-audio to disable)")
with free_gpu_memory_context():
compute_latents(
dataset_file=dataset_file,
main_media_column=video_column,
video_column=reference_column,
resolution_buckets=reference_buckets,
output_dir=str(reference_latents_dir),
video_column=roles["video"],
resolution_buckets=resolution_buckets,
output_dir=str(output_base / "latents"),
model_path=model_path,
batch_size=batch_size,
device=device,
vae_tiling=vae_tiling,
with_audio=auto_audio,
audio_output_dir=audio_latents_dir,
overwrite=overwrite,
)
# Handle decoding if requested (for verification)
# Process reference video if present
if "reference_video" in roles:
if reference_downscale_factor > 1 and len(resolution_buckets) > 1:
raise ValueError(
"When using --reference-downscale-factor > 1, only a single resolution bucket is supported."
)
if reference_temporal_scale_factor > 1 and len(resolution_buckets) > 1:
raise ValueError(
"When using --reference-temporal-scale-factor > 1, only a single resolution bucket is supported."
)
reference_buckets = compute_scaled_resolution_buckets(resolution_buckets, reference_downscale_factor)
if reference_downscale_factor > 1:
logger.info(f"Processing reference videos at 1/{reference_downscale_factor} resolution...")
if reference_temporal_scale_factor > 1:
logger.info(
f"Temporally subsampling reference videos by {reference_temporal_scale_factor}x "
f"(VAE-aligned pattern)..."
)
with free_gpu_memory_context():
compute_latents(
dataset_file=dataset_file,
main_media_column=roles["video"],
video_column=roles["reference_video"],
resolution_buckets=reference_buckets,
output_dir=str(output_base / "reference_latents"),
model_path=model_path,
batch_size=batch_size,
device=device,
vae_tiling=vae_tiling,
overwrite=overwrite,
temporal_subsample_factor=reference_temporal_scale_factor,
)
# --- Phase 2b: Masks (video_mask, audio_mask) — processed after video latents for alignment ---
if "video_mask" in roles and has_video:
compute_video_masks(
dataset_file=dataset_file,
mask_column=roles["video_mask"],
latents_dir=str(output_base / "latents"),
output_dir=str(output_base / "video_masks"),
main_media_column=roles["video"],
)
# --- Phase 3: Audio VAE (audio, reference_audio) ---
audio_roles_to_process = [
("audio", "audio_latents"),
("reference_audio", "reference_audio_latents"),
]
active_audio_roles = [(role, subdir) for role, subdir in audio_roles_to_process if role in roles]
if active_audio_roles:
# Determine audio duration constraint: video bucket → max_duration, or explicit buckets
max_audio_duration = None
audio_duration_buckets = None
if has_video and resolution_buckets:
max_audio_duration = max(f for f, _h, _w in resolution_buckets) / 25.0
elif audio_durations:
audio_duration_buckets = audio_durations
for role, output_subdir in active_audio_roles:
with free_gpu_memory_context():
compute_audio_latents(
dataset_file=dataset_file,
audio_column=roles[role],
output_dir=str(output_base / output_subdir),
model_path=model_path,
main_media_column=roles.get("video"),
max_duration=max_audio_duration,
duration_buckets=audio_duration_buckets,
device=device,
overwrite=overwrite,
)
# --- Phase 4: Audio masks (after audio latents exist for temporal alignment) ---
if "audio_mask" in roles:
audio_latents_source = output_base / "audio_latents"
if audio_latents_source.exists():
compute_audio_masks(
dataset_file=dataset_file,
mask_column=roles["audio_mask"],
audio_latents_dir=str(audio_latents_source),
output_dir=str(output_base / "audio_masks"),
main_media_column=roles.get("video") or roles.get("audio"),
)
else:
logger.warning("audio_mask column found but no audio_latents/ — run with audio first")
# --- Decode for verification ---
if decode:
logger.info("Decoding latents for verification...")
decoder = LatentsDecoder(model_path=model_path, device=device, vae_tiling=vae_tiling, with_audio=has_audio)
if has_video:
decoder.decode(output_base / "latents", output_base / "decoded_videos")
if "reference_video" in roles and (output_base / "reference_latents").exists():
decoder.decode(output_base / "reference_latents", output_base / "decoded_reference_videos")
decoder = LatentsDecoder(
model_path=model_path,
device=device,
vae_tiling=vae_tiling,
with_audio=with_audio,
)
decoder.decode(latents_dir, output_base / "decoded_videos")
# Also decode reference videos if they exist
if reference_column:
reference_latents_dir = output_base / "reference_latents"
if reference_latents_dir.exists():
logger.info("Decoding reference videos...")
decoder.decode(reference_latents_dir, output_base / "decoded_reference_videos")
# Decode audio latents if they exist
if with_audio and audio_latents_dir and audio_latents_dir.exists():
logger.info("Decoding audio latents...")
decoder.decode_audio(audio_latents_dir, output_base / "decoded_audio")
# Print summary
# --- Summary ---
logger.info(f"Dataset preprocessing complete! Results saved to {output_base}")
if reference_column:
logger.info("Reference videos processed and saved to reference_latents/ directory for IC-LoRA training")
if with_audio:
logger.info("Audio latents saved to audio_latents/ directory for audio-video training")
produced = [d.name for d in output_base.iterdir() if d.is_dir() and not d.name.startswith("decoded")]
logger.info(f"Output directories: {', '.join(sorted(produced))}")
def _validate_dataset_file(dataset_path: str) -> None:
"""Validate that the dataset file exists and has the correct format."""
dataset_file = Path(dataset_path)
if not dataset_file.exists():
raise FileNotFoundError(f"Dataset file does not exist: {dataset_file}")
if not dataset_file.is_file():
raise ValueError(f"Dataset path must be a file, not a directory: {dataset_file}")
if dataset_file.suffix.lower() not in [".csv", ".json", ".jsonl"]:
raise ValueError(f"Dataset file must be CSV, JSON, or JSONL format: {dataset_file}")
def _resolve_columns(
dataset_columns: set[str],
video_column_override: str | None = None,
caption_column_override: str | None = None,
) -> dict[str, str]:
"""Map canonical role names to actual dataset column names.
Returns a dict of role → column_name for recognized roles found in the dataset.
"""
roles: dict[str, str] = {}
for col in dataset_columns:
role = _LEGACY_ALIASES.get(col, col)
if role in _KNOWN_ROLES:
roles[role] = col
if video_column_override and video_column_override in dataset_columns:
roles["video"] = video_column_override
if caption_column_override and caption_column_override in dataset_columns:
roles["caption"] = caption_column_override
return roles
@app.command()
def main( # noqa: PLR0913
dataset_path: str = typer.Argument(
...,
help="Path to metadata file (CSV/JSON/JSONL) containing captions and video paths",
help="Path to metadata file (CSV/JSON/JSONL) with columns matching the convention table",
),
resolution_buckets: str = typer.Option(
...,
help='Resolution buckets in format "WxHxF;WxHxF;..." (e.g. "768x768x25;512x512x49")',
resolution_buckets: str | None = typer.Option(
default=None,
help='Resolution buckets in format "WxHxF;WxHxF;..." (e.g. "768x768x25"). '
"Required when dataset has a video column.",
),
model_path: str = typer.Option(
...,
@@ -203,13 +306,13 @@ def main( # noqa: PLR0913
...,
help="Path to Gemma text encoder directory",
),
caption_column: str = typer.Option(
default="caption",
help="Column name containing captions in the dataset JSON/JSONL/CSV file",
caption_column: str | None = typer.Option(
default=None,
help="Override: treat this column as 'caption' (default: auto-detect 'caption')",
),
video_column: str = typer.Option(
default="media_path",
help="Column name containing video paths in the dataset JSON/JSONL/CSV file",
video_column: str | None = typer.Option(
default=None,
help="Override: treat this column as 'video' (default: auto-detect 'video' or 'media_path')",
),
batch_size: int = typer.Option(
default=1,
@@ -229,32 +332,43 @@ def main( # noqa: PLR0913
),
lora_trigger: str | None = typer.Option(
default=None,
help="Optional trigger word to prepend to each caption (activates the LoRA during inference)",
help="Optional trigger word to prepend to each caption",
),
decode: bool = typer.Option(
default=False,
help="Decode and save latents after encoding (videos and audio) for verification",
help="Decode and save latents after encoding for verification",
),
remove_llm_prefixes: bool = typer.Option(
default=False,
help="Remove LLM prefixes from captions",
),
reference_column: str | None = typer.Option(
skip_audio: bool = typer.Option(
default=False,
help="Don't extract audio from video files (audio extraction is on by default)",
),
audio_durations: str | None = typer.Option(
default=None,
help="Column name containing reference video paths (for video-to-video training)",
help='Audio duration buckets in seconds for audio-only datasets (e.g. "2.0;4.0;8.0"). '
"When set, audio files are trimmed to the best matching duration. "
"Not needed when a video column is present (audio duration derived from video bucket).",
),
with_audio: bool = typer.Option(
default=False,
help="Extract and encode audio from video files",
hidden=True,
help="[DEPRECATED: audio is now on by default, use --skip-audio to disable]",
),
load_text_encoder_in_8bit: bool = typer.Option(
default=False,
help="Load the Gemma text encoder in 8-bit precision to save GPU memory (requires bitsandbytes)",
help="Load the Gemma text encoder in 8-bit precision to save GPU memory",
),
reference_downscale_factor: int = typer.Option(
default=1,
help="Downscale factor for reference video resolution. When > 1, reference videos are processed at "
"1/n resolution (e.g., 2 means half resolution). Used for efficient IC-LoRA training.",
help="Downscale factor for reference video resolution (e.g., 2 = half resolution for IC-LoRA)",
),
reference_temporal_scale_factor: int = typer.Option(
default=1,
help="Temporal subsampling factor for reference videos (e.g., 2 = half frame rate, "
"VAE-aligned: keeps frame 0, then every Nth frame from frame 1 onwards)",
),
overwrite: bool = typer.Option(
default=False,
@@ -262,64 +376,53 @@ def main( # noqa: PLR0913
"changed parameters (different model, resolution, etc.) so stale outputs are replaced.",
),
) -> None:
"""Preprocess a video dataset by computing and saving latents and text embeddings.
For multi-GPU preprocessing, invoke under ``accelerate launch`` - each process
"""Preprocess a media dataset for LTX-2 training.
See module docstring for the convention table. Audio is auto-extracted from
video files by default — use --skip-audio to disable.
For multi-GPU preprocessing, invoke under ``accelerate launch`` -- each process
will handle an interleaved shard of the dataset.
The dataset must be a CSV, JSON, or JSONL file with columns for captions and video paths.
This script is designed for LTX-2 models which use the Gemma text encoder.
Examples:
# Process a dataset with LTX-2 model
python scripts/process_dataset.py dataset.json --resolution-buckets 768x768x25 \\
--model-path /path/to/ltx2.safetensors --text-encoder-path /path/to/gemma
# Process dataset with custom column names
python scripts/process_dataset.py dataset.json --resolution-buckets 768x768x25 \\
--model-path /path/to/ltx2.safetensors --text-encoder-path /path/to/gemma \\
--caption-column "text" --video-column "video_path"
# Process dataset with reference videos for IC-LoRA training
python scripts/process_dataset.py dataset.json --resolution-buckets 768x768x25 \\
--model-path /path/to/ltx2.safetensors --text-encoder-path /path/to/gemma \\
--reference-column "reference_path"
# Process dataset with scaled reference videos (half resolution) for efficient IC-LoRA
python scripts/process_dataset.py dataset.json --resolution-buckets 768x768x25 \\
--model-path /path/to/ltx2.safetensors --text-encoder-path /path/to/gemma \\
--reference-column "reference_path" --reference-downscale-factor 2
# Process dataset with audio for audio-video training
python scripts/process_dataset.py dataset.json --resolution-buckets 768x512x97 \\
--model-path /path/to/ltx2.safetensors --text-encoder-path /path/to/gemma \\
--with-audio
"""
parsed_resolution_buckets = parse_resolution_buckets(resolution_buckets)
if len(parsed_resolution_buckets) > 1:
# Handle deprecated --with-audio flag
if with_audio:
logger.warning(
"Using multiple resolution buckets. "
"When training with multiple resolution buckets, you must use a batch size of 1."
"--with-audio is deprecated. Audio extraction is now on by default. Use --skip-audio to disable."
)
# Validate reference_downscale_factor
parsed_buckets = parse_resolution_buckets(resolution_buckets) if resolution_buckets else None
if parsed_buckets and len(parsed_buckets) > 1:
logger.warning("Using multiple resolution buckets. Training batch size must be 1.")
if reference_downscale_factor < 1:
raise typer.BadParameter("--reference-downscale-factor must be >= 1")
if reference_downscale_factor > 1 and not reference_column:
logger.warning("--reference-downscale-factor specified but no --reference-column provided. Ignoring.")
if reference_temporal_scale_factor < 1:
raise typer.BadParameter("--reference-temporal-scale-factor must be >= 1")
parsed_audio_durations = None
if audio_durations:
parsed_audio_durations = [float(d) for d in audio_durations.split(";")]
if any(d <= 0 for d in parsed_audio_durations):
raise typer.BadParameter("All audio durations must be positive")
preprocess_dataset(
dataset_file=dataset_path,
caption_column=caption_column,
video_column=video_column,
resolution_buckets=parsed_resolution_buckets,
batch_size=batch_size,
output_dir=output_dir,
lora_trigger=lora_trigger,
vae_tiling=vae_tiling,
decode=decode,
resolution_buckets=parsed_buckets,
model_path=model_path,
text_encoder_path=text_encoder_path,
device=device,
output_dir=output_dir,
video_column=video_column,
caption_column=caption_column,
batch_size=batch_size,
lora_trigger=lora_trigger,
vae_tiling=vae_tiling,
decode=decode,
remove_llm_prefixes=remove_llm_prefixes,
reference_column=reference_column,
reference_downscale_factor=reference_downscale_factor,
with_audio=with_audio,
reference_temporal_scale_factor=reference_temporal_scale_factor,
skip_audio=skip_audio,
audio_durations=parsed_audio_durations,
load_text_encoder_in_8bit=load_text_encoder_in_8bit,
overwrite=overwrite,
)
+480 -106
View File
@@ -41,6 +41,7 @@ from torch.utils.data import DataLoader, Dataset, Subset
from torchvision import transforms
from torchvision.transforms import InterpolationMode
from torchvision.transforms.functional import crop, resize, to_tensor
from torchvision.transforms.functional import resize as tv_resize
from transformers.utils.logging import disable_progress_bar
from ltx_core.model.audio_vae import AudioProcessor
@@ -73,6 +74,10 @@ app = typer.Typer(
)
def _clamp_01(x: torch.Tensor) -> torch.Tensor:
return x.clamp_(0, 1)
class MediaDataset(Dataset):
"""
Dataset for processing video and image files.
@@ -92,6 +97,7 @@ class MediaDataset(Dataset):
resolution_buckets: list[tuple[int, int, int]],
reshape_mode: str = "center",
with_audio: bool = False,
temporal_subsample_factor: int = 1,
) -> None:
"""
Initialize the media dataset.
@@ -101,6 +107,8 @@ class MediaDataset(Dataset):
resolution_buckets: List of (frames, height, width) tuples
reshape_mode: How to crop videos ("center", "random")
with_audio: Whether to extract audio from video files
temporal_subsample_factor: Factor for VAE-aligned temporal subsampling.
When > 1, keeps frame 0 then takes every Nth frame from frame 1 onwards.
"""
super().__init__()
@@ -109,6 +117,7 @@ class MediaDataset(Dataset):
self.resolution_buckets = resolution_buckets
self.reshape_mode = reshape_mode
self.with_audio = with_audio
self.temporal_subsample_factor = temporal_subsample_factor
# First load main media paths
self.main_media_paths = self._load_video_paths(main_media_column)
@@ -124,7 +133,7 @@ class MediaDataset(Dataset):
# Set up video transforms
self.transforms = transforms.Compose(
[
transforms.Lambda(lambda x: x.clamp_(0, 1)),
transforms.Lambda(_clamp_01),
transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5], inplace=True),
]
)
@@ -142,8 +151,8 @@ class MediaDataset(Dataset):
# Compute relative path of the video
data_root = self.dataset_file.parent
relative_path = str(video_path.relative_to(data_root))
media_relative_path = str(self.main_media_paths[index].relative_to(data_root))
relative_path = str(_output_relative(video_path, data_root))
media_relative_path = str(_output_relative(self.main_media_paths[index], data_root))
if video_path.suffix.lower() in [".png", ".jpg", ".jpeg"]:
media_tensor = self._preprocess_image(video_path)
@@ -185,97 +194,29 @@ class MediaDataset(Dataset):
@staticmethod
def _extract_audio(video_path: Path, target_duration: float) -> dict[str, torch.Tensor | int] | None:
"""Extract audio track from a video file, trimmed to match video duration."""
try:
# torchaudio can extract audio from video files directly
# waveform shape: [channels, samples]
waveform, sample_rate = torchaudio.load(str(video_path))
# Trim or pad to target duration
target_samples = int(target_duration * sample_rate)
current_samples = waveform.shape[-1]
if current_samples > target_samples:
# Trim to target duration
waveform = waveform[..., :target_samples]
elif current_samples < target_samples:
# Pad with zeros to target duration
padding = target_samples - current_samples
waveform = torch.nn.functional.pad(waveform, (0, padding))
logger.warning(f"Padded audio to {target_duration:.2f} seconds for {video_path}")
return {"waveform": waveform, "sample_rate": sample_rate}
except Exception as e:
logger.debug(f"Could not extract audio from {video_path}: {e}")
"""Extract audio track from a video file, trimmed/padded to match video duration."""
audio = _load_audio_from_file(video_path, max_duration=target_duration)
if audio is None:
return None
def _load_video_paths(self, column: str) -> list[Path]:
"""Load video paths from the specified data source."""
if self.dataset_file.suffix == ".csv":
return self._load_video_paths_from_csv(column)
elif self.dataset_file.suffix == ".json":
return self._load_video_paths_from_json(column)
elif self.dataset_file.suffix == ".jsonl":
return self._load_video_paths_from_jsonl(column)
# Pad if shorter than target (_load_audio_from_file only trims, doesn't pad)
target_samples = int(target_duration * audio.sampling_rate)
if audio.waveform.shape[-1] < target_samples:
padding = target_samples - audio.waveform.shape[-1]
waveform = torch.nn.functional.pad(audio.waveform, (0, padding))
logger.warning(f"Padded audio to {target_duration:.2f} seconds for {video_path}")
else:
raise ValueError("Expected `dataset_file` to be a path to a CSV, JSON, or JSONL file.")
waveform = audio.waveform
def _load_video_paths_from_csv(self, column: str) -> list[Path]:
"""Load video paths from a CSV file."""
df = pd.read_csv(self.dataset_file)
if column not in df.columns:
raise ValueError(f"Column '{column}' not found in CSV file")
return {"waveform": waveform, "sample_rate": audio.sampling_rate}
data_root = self.dataset_file.parent
video_paths = [data_root / Path(line.strip()) for line in df[column].tolist()]
# Validate that all paths exist
invalid_paths = [path for path in video_paths if not path.is_file()]
if invalid_paths:
raise ValueError(f"Found {len(invalid_paths)} invalid video paths. First few: {invalid_paths[:5]}")
return video_paths
def _load_video_paths_from_json(self, column: str) -> list[Path]:
"""Load video paths from a JSON file."""
with open(self.dataset_file, "r", encoding="utf-8") as file:
data = json.load(file)
if not isinstance(data, list):
raise ValueError("JSON file must contain a list of objects")
data_root = self.dataset_file.parent
video_paths = []
for entry in data:
if column not in entry:
raise ValueError(f"Key '{column}' not found in JSON entry")
video_paths.append(data_root / Path(entry[column].strip()))
# Validate that all paths exist
invalid_paths = [path for path in video_paths if not path.is_file()]
if invalid_paths:
raise ValueError(f"Found {len(invalid_paths)} invalid video paths. First few: {invalid_paths[:5]}")
return video_paths
def _load_video_paths_from_jsonl(self, column: str) -> list[Path]:
"""Load video paths from a JSONL file."""
data_root = self.dataset_file.parent
video_paths = []
with open(self.dataset_file, "r", encoding="utf-8") as file:
for line in file:
entry = json.loads(line)
if column not in entry:
raise ValueError(f"Key '{column}' not found in JSONL entry")
video_paths.append(data_root / Path(entry[column].strip()))
# Validate that all paths exist
invalid_paths = [path for path in video_paths if not path.is_file()]
if invalid_paths:
raise ValueError(f"Found {len(invalid_paths)} invalid video paths. First few: {invalid_paths[:5]}")
return video_paths
def _load_video_paths(self, column: str) -> list[Path]:
"""Load video paths from the specified data source, validating existence."""
paths = _load_paths_from_dataset(self.dataset_file, column)
invalid = [p for p in paths if not p.is_file()]
if invalid:
raise ValueError(f"Found {len(invalid)} invalid paths in '{column}'. First few: {invalid[:5]}")
return paths
def _filter_valid_videos(self) -> None:
"""Filter out videos with insufficient frames."""
@@ -348,6 +289,11 @@ class MediaDataset(Dataset):
# Trim video to target number of frames
frames_resized = frames_resized[:target_num_frames]
# VAE-aligned temporal subsampling: keep frame 0, then every Nth frame
if self.temporal_subsample_factor > 1:
indices = _compute_temporal_subsample_indices(target_num_frames, self.temporal_subsample_factor)
frames_resized = frames_resized[indices]
# Apply transforms to each frame and stack
video = torch.stack([self.transforms(frame) for frame in frames_resized], dim=0)
@@ -434,7 +380,18 @@ class MediaDataset(Dataset):
return media_tensor
def compute_latents( # noqa: PLR0913, PLR0915
def _compute_temporal_subsample_indices(num_frames: int, factor: int) -> list[int]:
"""Compute VAE-aligned temporal subsample indices.
Keeps frame 0 (the VAE's standalone first-frame latent), then takes every
``factor``-th frame from frame 1 onwards. This ensures each resulting
8-frame VAE group spans ``factor`` groups of the original video.
"""
if factor == 1:
return list(range(num_frames))
return [0, *list(range(1, num_frames, factor))]
def compute_latents( # noqa: PLR0912, PLR0913, PLR0915
dataset_file: str | Path,
video_column: str,
resolution_buckets: list[tuple[int, int, int]],
@@ -447,7 +404,9 @@ def compute_latents( # noqa: PLR0913, PLR0915
vae_tiling: bool = False,
with_audio: bool = False,
audio_output_dir: str | None = None,
num_dataloader_workers: int = 4,
overwrite: bool = False,
temporal_subsample_factor: int = 1,
) -> None:
"""
Process videos and save latent representations.
@@ -468,9 +427,28 @@ def compute_latents( # noqa: PLR0913, PLR0915
vae_tiling: Whether to enable VAE tiling
with_audio: Whether to extract and encode audio from videos
audio_output_dir: Directory to save audio latents (required if with_audio=True)
num_dataloader_workers: Number of DataLoader worker processes (0 for in-process loading)
overwrite: Re-process every item even if its output exists. Use when rerunning with
changed parameters (different model, resolution, etc.) so stale outputs are replaced.
temporal_subsample_factor: Factor for VAE-aligned temporal subsampling of reference videos
"""
# Validate temporal subsampling compatibility with resolution buckets
if temporal_subsample_factor > 1:
for frames, _h, _w in resolution_buckets:
pixel_frames_minus_one = frames - 1
if pixel_frames_minus_one % temporal_subsample_factor != 0:
raise ValueError(
f"Frame count {frames} is not compatible with "
f"temporal_subsample_factor={temporal_subsample_factor}. "
f"(frames - 1) must be divisible by the factor."
)
subsampled = 1 + pixel_frames_minus_one // temporal_subsample_factor
if (subsampled - 1) % VAE_TEMPORAL_FACTOR != 0:
raise ValueError(
f"After temporal subsampling {frames}{subsampled} frames, "
f"result does not satisfy (frames - 1) % {VAE_TEMPORAL_FACTOR} == 0."
)
if with_audio and audio_output_dir is None:
raise ValueError("audio_output_dir must be provided when with_audio=True")
@@ -484,11 +462,13 @@ def compute_latents( # noqa: PLR0913, PLR0915
resolution_buckets=resolution_buckets,
reshape_mode=reshape_mode,
with_audio=with_audio,
temporal_subsample_factor=temporal_subsample_factor,
)
logger.info(f"Loaded {len(dataset)} valid media files")
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
audio_output_path: Path | None = None
if with_audio:
audio_output_path = Path(audio_output_dir)
@@ -499,10 +479,10 @@ def compute_latents( # noqa: PLR0913, PLR0915
logger.warning("Audio processing requires batch_size=1. Overriding batch_size to 1.")
batch_size = 1
data_root = dataset.dataset_file.parent
data_root = Path(dataset_file).parent
def _is_done(idx: int) -> bool:
rel = dataset.main_media_paths[idx].relative_to(data_root).with_suffix(".pt")
rel = _output_relative(dataset.main_media_paths[idx], data_root).with_suffix(".pt")
if not (output_path / rel).is_file():
return False
return audio_output_path is None or (audio_output_path / rel).is_file()
@@ -510,18 +490,16 @@ def compute_latents( # noqa: PLR0913, PLR0915
dataloader = _build_sharded_dataloader(
dataset,
batch_size=batch_size,
num_workers=4,
num_workers=num_dataloader_workers,
is_done=_is_done,
overwrite=overwrite,
)
if dataloader is None:
return
# Load video VAE encoder
with console.status(f"[bold]Loading video VAE encoder from [cyan]{model_path}[/]...", spinner="dots"):
vae = load_video_vae_encoder(model_path, device=torch_device, dtype=torch.bfloat16)
# Load audio VAE encoder and audio processor if needed
audio_vae_encoder = None
audio_processor = None
if with_audio:
@@ -531,7 +509,6 @@ def compute_latents( # noqa: PLR0913, PLR0915
device=torch_device,
dtype=torch.float32, # Audio VAE needs float32 for quality. TODO: re-test with bfloat16.
)
# Create audio processor for waveform-to-spectrogram conversion
audio_processor = AudioProcessor(
target_sample_rate=audio_vae_encoder.sample_rate,
mel_bins=audio_vae_encoder.mel_bins,
@@ -562,7 +539,7 @@ def compute_latents( # noqa: PLR0913, PLR0915
# Encode video
with torch.inference_mode():
video_latent_data = encode_video(vae=vae, video=video, use_tiling=vae_tiling)
video_latent_data = _encode_video(vae=vae, video=video, use_tiling=vae_tiling)
# Save latents for each item in batch
for i in range(len(batch["relative_path"])):
@@ -572,13 +549,15 @@ def compute_latents( # noqa: PLR0913, PLR0915
# Create output directory maintaining structure
output_file.parent.mkdir(parents=True, exist_ok=True)
# Index into batch to get this item's latents
# Store the latent's effective fps (= source_fps / subsample factor).
# Downstream position math expects the rate the saved latents actually have.
effective_fps = batch["video_metadata"]["fps"][i].item() / temporal_subsample_factor
latent_data = {
"latents": video_latent_data["latents"][i].cpu().contiguous(), # [C, F', H', W']
"num_frames": video_latent_data["num_frames"],
"height": video_latent_data["height"],
"width": video_latent_data["width"],
"fps": batch["video_metadata"]["fps"][i].item(),
"fps": effective_fps,
}
_atomic_save(latent_data, output_file)
@@ -596,7 +575,7 @@ def compute_latents( # noqa: PLR0913, PLR0915
# Encode audio
with torch.inference_mode():
audio_latents = encode_audio(audio_vae_encoder, audio_processor, audio_data)
audio_latents = _encode_audio(audio_vae_encoder, audio_processor, audio_data)
# Save audio latents
audio_output_file = audio_output_path / output_rel_path
@@ -625,7 +604,7 @@ def compute_latents( # noqa: PLR0913, PLR0915
)
def encode_video(
def _encode_video(
vae: torch.nn.Module,
video: torch.Tensor,
dtype: torch.dtype | None = None,
@@ -662,7 +641,7 @@ def encode_video(
# Choose encoding method based on tiling flag
if use_tiling:
latents = tiled_encode_video(
latents = _tiled_encode_video(
vae=vae,
video=video,
tile_size=tile_size,
@@ -685,7 +664,7 @@ def encode_video(
}
def tiled_encode_video( # noqa: PLR0912, PLR0915
def _tiled_encode_video( # noqa: PLR0912, PLR0915
vae: torch.nn.Module,
video: torch.Tensor,
tile_size: int = DEFAULT_TILE_SIZE,
@@ -840,7 +819,7 @@ def tiled_encode_video( # noqa: PLR0912, PLR0915
return output
def encode_audio(
def _encode_audio(
audio_vae_encoder: torch.nn.Module,
audio_processor: torch.nn.Module,
audio: Audio,
@@ -868,6 +847,35 @@ def encode_audio(
if waveform.dim() == 2:
waveform = waveform.unsqueeze(0)
# Convert to stereo if needed (audio VAE expects 2 channels)
# Channel order for surround: 5.1=[L,R,C,LFE,Ls,Rs], 7.1=[L,R,C,LFE,Ls,Rs,Lb,Rb]
num_channels = waveform.shape[1]
if num_channels == 1:
# Mono to stereo: duplicate the channel
waveform = waveform.repeat(1, 2, 1)
elif num_channels == 6:
# 5.1 downmix with normalized weights (sum to 1.0)
# Original: L = L + 0.707*C + 0.707*Ls, weights sum = 2.414
w_main = 1.0 / 2.414 # ~0.414
w_other = 0.707 / 2.414 # ~0.293
left = w_main * waveform[:, 0, :] + w_other * waveform[:, 2, :] + w_other * waveform[:, 4, :]
right = w_main * waveform[:, 1, :] + w_other * waveform[:, 2, :] + w_other * waveform[:, 5, :]
waveform = torch.stack([left, right], dim=1)
elif num_channels == 8:
# 7.1 downmix with normalized weights (sum to 1.0)
# Original: L = L + 0.707*C + 0.707*Ls + 0.707*Lb, weights sum = 3.121
w_main = 1.0 / 3.121 # ~0.320
w_other = 0.707 / 3.121 # ~0.227
center = waveform[:, 2, :]
left = w_main * waveform[:, 0, :] + w_other * (center + waveform[:, 4, :] + waveform[:, 6, :])
right = w_main * waveform[:, 1, :] + w_other * (center + waveform[:, 5, :] + waveform[:, 7, :])
waveform = torch.stack([left, right], dim=1)
elif num_channels > 2:
# Unknown layout: average all channels to mono, then duplicate to stereo
logger.warning(f"Unknown audio channel layout ({num_channels} channels), using mean downmix")
mono = waveform.mean(dim=1, keepdim=True)
waveform = mono.repeat(1, 2, 1)
# Calculate duration
duration = waveform.shape[-1] / audio.sampling_rate
@@ -889,6 +897,372 @@ def encode_audio(
}
AUDIO_FILE_EXTENSIONS = {".wav", ".mp3", ".flac", ".ogg", ".aac", ".m4a"}
VIDEO_FILE_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".webm"}
IMAGE_FILE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".heic", ".heif", ".bmp", ".tiff", ".webp"}
def compute_video_masks(
dataset_file: str | Path,
mask_column: str,
latents_dir: str,
output_dir: str,
main_media_column: str | None = None,
overwrite: bool = False,
) -> None:
"""Preprocess video mask files to latent-space binary masks.
For each sample, loads the mask video/image, applies the same spatial
resize/crop as the target video (read from saved latent metadata), downsamples
to latent dimensions, binarizes, and saves as a .pt tensor.
Args:
dataset_file: Path to metadata file (CSV/JSON/JSONL).
mask_column: Column name containing mask video/image paths.
latents_dir: Directory containing the target video latents (for reading
spatial/temporal metadata to ensure mask alignment).
output_dir: Directory to save mask .pt files.
main_media_column: Column for output file naming (defaults to mask_column).
"""
dataset_path = Path(dataset_file)
data_root = dataset_path.parent
latents_path = Path(latents_dir)
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
naming_column = main_media_column or mask_column
mask_paths = _load_paths_from_dataset(dataset_path, mask_column)
naming_paths = _load_paths_from_dataset(dataset_path, naming_column) if naming_column != mask_column else mask_paths
success = 0
for mask_file, naming_file in zip(mask_paths, naming_paths, strict=True):
rel_path = _output_relative(naming_file, data_root)
latent_file = latents_path / rel_path.with_suffix(".pt")
out_file = output_path / rel_path.with_suffix(".pt")
if not latent_file.exists():
logger.warning(f"No target latent found at {latent_file}, skipping mask {mask_file}")
continue
if not overwrite and out_file.is_file():
continue
target_meta = torch.load(latent_file, map_location="cpu", weights_only=True)
latent_f = target_meta["num_frames"]
latent_h = target_meta["height"]
latent_w = target_meta["width"]
pixel_h = latent_h * VAE_SPATIAL_FACTOR
pixel_w = latent_w * VAE_SPATIAL_FACTOR
pixel_f = (latent_f - 1) * VAE_TEMPORAL_FACTOR + 1
# Load mask as video or image
if mask_file.suffix.lower() in IMAGE_FILE_EXTENSIONS:
img = to_tensor(open_image_as_srgb(mask_file)).mean(dim=0, keepdim=True) # [1, H, W]
img = tv_resize(img.unsqueeze(0), [pixel_h, pixel_w]).squeeze(0) # [1, H, W]
mask_pixels = img.expand(pixel_f, -1, -1) # tile across frames → [F, H, W]
else:
frames, _ = read_video(str(mask_file), max_frames=pixel_f) # [F, C, H, W]
frames = frames[:pixel_f].mean(dim=1) # grayscale → [F, H, W]
frames = torch.nn.functional.interpolate(
frames.unsqueeze(1), size=(pixel_h, pixel_w), mode="nearest"
).squeeze(1) # [F, H, W]
mask_pixels = frames
# Downsample to latent dims: [F, H, W] → [F', H', W']
mask_latent = torch.nn.functional.avg_pool2d(mask_pixels.unsqueeze(1), kernel_size=VAE_SPATIAL_FACTOR).squeeze(
1
) # [F, H', W'] → spatial done
# Temporal: max-pool over groups of VAE_TEMPORAL_FACTOR frames (any masked frame masks the group)
f_spatial = mask_latent.shape[0]
pad_f = (VAE_TEMPORAL_FACTOR - f_spatial % VAE_TEMPORAL_FACTOR) % VAE_TEMPORAL_FACTOR
if pad_f > 0:
mask_latent = torch.nn.functional.pad(mask_latent, (0, 0, 0, 0, 0, pad_f))
h_prime, w_prime = mask_latent.shape[1], mask_latent.shape[2]
mask_latent = mask_latent.reshape(-1, VAE_TEMPORAL_FACTOR, h_prime, w_prime).amax(dim=1)[:latent_f]
# Binarize
mask_latent = (mask_latent > 0.5).float()
out_file.parent.mkdir(parents=True, exist_ok=True)
_atomic_save({"mask": mask_latent}, out_file)
success += 1
logger.info(f"Mask preprocessing complete: {success} masks saved to {output_path}")
def compute_audio_masks(
dataset_file: str | Path,
mask_column: str,
audio_latents_dir: str,
output_dir: str,
main_media_column: str | None = None,
overwrite: bool = False,
) -> None:
"""Preprocess audio mask files to latent-space binary masks.
For each sample, loads the mask (a 1D waveform-like signal or a simple tensor),
resamples it to match the target audio latent temporal length, binarizes, and saves.
Args:
dataset_file: Path to metadata file (CSV/JSON/JSONL).
mask_column: Column name containing mask file paths (.wav or .pt).
audio_latents_dir: Directory containing the target audio latents (for reading
temporal metadata to ensure mask alignment).
output_dir: Directory to save mask .pt files.
main_media_column: Column for output file naming (defaults to mask_column).
"""
dataset_path = Path(dataset_file)
data_root = dataset_path.parent
audio_latents_path = Path(audio_latents_dir)
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
naming_column = main_media_column or mask_column
mask_paths = _load_paths_from_dataset(dataset_path, mask_column)
naming_paths = _load_paths_from_dataset(dataset_path, naming_column) if naming_column != mask_column else mask_paths
success = 0
for mask_file, naming_file in zip(mask_paths, naming_paths, strict=True):
rel_path = _output_relative(naming_file, data_root)
latent_file = audio_latents_path / rel_path.with_suffix(".pt")
out_file = output_path / rel_path.with_suffix(".pt")
if not latent_file.exists():
logger.warning(f"No target audio latent found at {latent_file}, skipping mask {mask_file}")
continue
if not overwrite and out_file.is_file():
continue
target_meta = torch.load(latent_file, map_location="cpu", weights_only=True)
latent_t = target_meta["num_time_steps"]
# Load mask: .pt file (raw tensor) or .wav (use amplitude envelope)
if mask_file.suffix == ".pt":
raw_mask = torch.load(mask_file, map_location="cpu", weights_only=True)
if isinstance(raw_mask, dict):
raw_mask = raw_mask.get("mask", next(iter(raw_mask.values())))
raw_mask = raw_mask.float().flatten()
else:
audio = _load_audio_from_file(mask_file)
if audio is None:
logger.warning(f"Could not load audio mask from {mask_file}")
continue
raw_mask = audio.waveform.abs().mean(dim=0) # mono amplitude envelope
# Resample to target audio latent length
mask_resampled = torch.nn.functional.interpolate(
raw_mask.unsqueeze(0).unsqueeze(0), size=latent_t, mode="nearest"
).squeeze() # [latent_t]
mask_binary = (mask_resampled > 0.5).float()
out_file.parent.mkdir(parents=True, exist_ok=True)
_atomic_save({"mask": mask_binary}, out_file)
success += 1
logger.info(f"Audio mask preprocessing complete: {success} masks saved to {output_path}")
def compute_audio_latents( # noqa: PLR0915
dataset_file: str | Path,
audio_column: str,
output_dir: str,
model_path: str,
main_media_column: str | None = None,
max_duration: float | None = None,
duration_buckets: list[float] | None = None,
device: str = "cuda",
overwrite: bool = False,
) -> None:
"""Encode audio files into latent representations.
Supports standalone audio files (.wav, .mp3, etc.) and audio tracks
extracted from video files (.mp4, etc.).
Args:
dataset_file: Path to metadata file (CSV/JSON/JSONL).
audio_column: Column name containing audio file paths.
output_dir: Directory to save audio latents.
model_path: Path to LTX-2 checkpoint (.safetensors).
main_media_column: Column for output file naming (defaults to audio_column).
Ensures alignment with other latent directories.
max_duration: Maximum audio duration in seconds. Audio is trimmed if longer.
Mutually exclusive with duration_buckets.
duration_buckets: List of allowed durations in seconds (e.g. [2.0, 4.0, 8.0]).
Each audio file is matched to the largest bucket that fits its duration,
then trimmed to exactly that length. Files shorter than the smallest
bucket are skipped. Ensures uniform lengths for batched training.
device: Device to use for computation.
"""
console = Console()
torch_device = torch.device(device)
dataset_path = Path(dataset_file)
data_root = dataset_path.parent
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
naming_column = main_media_column or audio_column
audio_paths = _load_paths_from_dataset(dataset_path, audio_column)
naming_paths = (
_load_paths_from_dataset(dataset_path, naming_column) if naming_column != audio_column else audio_paths
)
with console.status(f"[bold]Loading audio VAE encoder from [cyan]{model_path}[/]...", spinner="dots"):
audio_vae_encoder = load_audio_vae_encoder(
checkpoint_path=model_path,
device=torch_device,
dtype=torch.float32,
)
audio_processor = AudioProcessor(
target_sample_rate=audio_vae_encoder.sample_rate,
mel_bins=audio_vae_encoder.mel_bins,
mel_hop_length=audio_vae_encoder.mel_hop_length,
n_fft=audio_vae_encoder.n_fft,
).to(torch_device)
sorted_buckets = sorted(duration_buckets, reverse=True) if duration_buckets else None
success_count = 0
skip_count = 0
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
MofNCompleteColumn(),
TimeElapsedColumn(),
TimeRemainingColumn(),
console=console,
) as progress:
task = progress.add_task("Encoding audio", total=len(audio_paths))
for audio_path, naming_path in zip(audio_paths, naming_paths, strict=True):
rel_path = _output_relative(naming_path, data_root)
output_file = output_path / rel_path.with_suffix(".pt")
output_file.parent.mkdir(parents=True, exist_ok=True)
if not overwrite and output_file.is_file():
success_count += 1
progress.advance(task)
continue
# Load audio (no trimming yet — need full duration for bucket matching)
audio = _load_audio_from_file(audio_path)
if audio is None:
skip_count += 1
progress.advance(task)
continue
file_duration = audio.waveform.shape[-1] / audio.sampling_rate
# Determine target duration: bucket matching, max_duration cap, or full file
target_duration = file_duration
if sorted_buckets:
bucket = next((b for b in sorted_buckets if b <= file_duration), None)
if bucket is None:
logger.warning(
f"Skipping {audio_path.name} ({file_duration:.1f}s) — shorter than "
f"smallest bucket ({sorted_buckets[-1]:.1f}s)"
)
skip_count += 1
progress.advance(task)
continue
target_duration = bucket
elif max_duration is not None:
target_duration = min(file_duration, max_duration)
# Trim to target duration
target_samples = int(target_duration * audio.sampling_rate)
trimmed_waveform = audio.waveform[:, :target_samples]
audio = Audio(waveform=trimmed_waveform, sampling_rate=audio.sampling_rate)
with torch.inference_mode():
audio_latents = _encode_audio(audio_vae_encoder, audio_processor, audio)
_atomic_save(
{
"latents": audio_latents["latents"].cpu().contiguous(),
"num_time_steps": audio_latents["num_time_steps"],
"frequency_bins": audio_latents["frequency_bins"],
"duration": audio_latents["duration"],
},
output_file,
)
success_count += 1
progress.advance(task)
logger.info(f"Audio encoding complete: {success_count} encoded, {skip_count} skipped. Saved to {output_path}")
def _output_relative(path: Path, data_root: Path) -> Path:
"""Relative path used to name a sample's cached output, mirroring the input layout.
Normally media lives under the dataset directory and this is just the path relative to it.
If a media path is absolute or otherwise outside the dataset directory (e.g. a one-off
metadata file that references media elsewhere), mirror its absolute structure under the
output directory instead of raising, so out-of-tree media stays collision-free.
"""
try:
return path.relative_to(data_root)
except ValueError:
return Path(*path.parts[1:]) if path.is_absolute() else path
def _load_paths_from_dataset(dataset_file: Path, column: str) -> list[Path]:
"""Load file paths from a dataset column, resolving relative to the dataset file's directory."""
data_root = dataset_file.parent
if dataset_file.suffix == ".csv":
df = pd.read_csv(dataset_file)
if column not in df.columns:
raise ValueError(f"Column '{column}' not found in CSV file")
return [data_root / Path(str(v).strip()) for v in df[column].tolist()]
if dataset_file.suffix == ".json":
with open(dataset_file, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
raise ValueError("JSON file must contain a list of objects")
return [data_root / Path(entry[column].strip()) for entry in data]
if dataset_file.suffix == ".jsonl":
paths = []
with open(dataset_file, encoding="utf-8") as f:
for line in f:
entry = json.loads(line)
paths.append(data_root / Path(entry[column].strip()))
return paths
raise ValueError(f"Unsupported dataset format: {dataset_file.suffix}")
def _load_audio_from_file(audio_path: Path, max_duration: float | None = None) -> Audio | None:
"""Load audio from an audio or video file, optionally trimming to max_duration."""
try:
waveform, sample_rate = torchaudio.load(str(audio_path))
except Exception:
logger.debug(f"Could not load audio from {audio_path}")
return None
if max_duration is not None:
max_samples = int(max_duration * sample_rate)
if waveform.shape[-1] > max_samples:
waveform = waveform[:, :max_samples]
return Audio(waveform=waveform, sampling_rate=sample_rate)
def detect_dataset_columns(dataset_file: str | Path) -> set[str]:
"""Read column names from a dataset file without loading all data."""
path = Path(dataset_file)
if path.suffix == ".csv":
df = pd.read_csv(path, nrows=0)
return set(df.columns)
if path.suffix == ".json":
with open(path, encoding="utf-8") as f:
data = json.load(f)
return set(data[0].keys()) if isinstance(data, list) and data else set()
if path.suffix == ".jsonl":
with open(path, encoding="utf-8") as f:
return set(json.loads(f.readline()).keys())
return set()
def parse_resolution_buckets(resolution_buckets_str: str) -> list[tuple[int, int, int]]:
"""Parse resolution buckets from string format to list of tuples (frames, height, width)"""
resolution_buckets = []
@@ -1041,11 +1415,11 @@ def main( # noqa: PLR0913
),
) -> None:
"""Process videos/images and save latent representations for video generation training.
For multi-GPU preprocessing, invoke under ``accelerate launch`` - each process
will handle an interleaved shard of the dataset.
This script processes videos and images from metadata files and saves latent representations
that can be used for training video generation models. The output latents will maintain
the same folder structure and naming as the corresponding media files.
For multi-GPU preprocessing, invoke under ``accelerate launch`` -- each process
will handle an interleaved shard of the dataset.
Examples:
# Process videos from a CSV file
python scripts/process_videos.py dataset.csv --resolution-buckets 768x768x25 \\
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Launch a vLLM server for Qwen3-Omni captioning.
Runs the actual server via ``uvx`` so that vLLM and its CUDA-tied
dependencies live in their own isolated environment (no impact on this
package's dependency tree).
The captioning script (``caption_videos.py``) talks to the server over its
OpenAI-compatible HTTP API. Once the server is up it stays loaded across
captioning runs; no per-script model warmup cost.
Typical usage::
# Default: dynamic FP8 quantization, listen on 127.0.0.1:8001
uv run python scripts/serve_captioner.py
# Just print the chosen `uvx vllm serve ...` command without running it
uv run python scripts/serve_captioner.py --print-cmd
# Use full bf16 on a GPU with >= 66 GiB free VRAM (slightly more reliable
# numerics but 2x the weight memory)
uv run python scripts/serve_captioner.py --quantization bf16
# Use a different port or expose on all interfaces
uv run python scripts/serve_captioner.py --port 9000 --host 0.0.0.0
"""
import os
import shutil
import subprocess
import sys
from pathlib import Path
import typer
from rich.console import Console
console = Console()
# Model identifier we serve. The captioner client must use the same string.
DEFAULT_MODEL = "Qwen/Qwen3-Omni-30B-A3B-Thinking"
# Pinned vLLM version known to support Qwen3-Omni on CUDA 12.x.
# vLLM 0.20+ requires CUDA 13. Update both as the environment evolves.
# The ``[audio]`` extra is required for Qwen3-Omni to decode audio at all.
DEFAULT_VLLM_SPEC = "vllm[audio]==0.11.2"
# Approximate disk needed for the model download (HF cache structure).
MODEL_DISK_GIB = 65.0
app = typer.Typer(
pretty_exceptions_enable=False,
no_args_is_help=False,
help="Launch a local vLLM server for Qwen3-Omni captioning.",
)
def _query_disk_free_gib(path: Path) -> float:
return shutil.disk_usage(str(path)).free / 1024**3
def _build_vllm_args(
*,
model: str,
host: str,
port: int,
quantization: str,
max_model_len: int,
gpu_memory_utilization: float,
extra_args: list[str],
) -> list[str]:
"""Construct the `vllm serve ...` argv."""
args = [
"vllm",
"serve",
model,
"--host",
host,
"--port",
str(port),
"--dtype",
"bfloat16",
"--max-model-len",
str(max_model_len),
"--gpu-memory-utilization",
str(gpu_memory_utilization),
# Let the server accept ``file://`` URLs pointing at local videos.
"--allowed-local-media-path",
"/",
# The model is a multimodal MoE; cap each input to one of each
# modality to match what our captioner sends.
"--limit-mm-per-prompt",
'{"image": 1, "video": 1, "audio": 1}',
# Small concurrent-sequence cap so KV cache headroom isn't fragmented.
"--max-num-seqs",
"4",
]
if quantization == "fp8":
args += ["--quantization", "fp8"]
args += extra_args
return args
@app.command()
def main(
model: str = typer.Option(DEFAULT_MODEL, "--model", help="Model identifier to serve."),
host: str = typer.Option("127.0.0.1", "--host", help="Listen address. Use 0.0.0.0 for remote access."),
port: int = typer.Option(8001, "--port", help="HTTP port."),
quantization: str = typer.Option(
"fp8",
"--quantization",
"-q",
help=(
"Weight precision. 'fp8' (default, dynamic FP8 -- ~31 GiB weights) is "
"the recommended choice; it fits on 40 GiB GPUs and runs at the same "
"speed as bf16 on H100. 'bf16' uses ~60 GiB of weights -- pick it if "
"you have abundant VRAM and want minimal numerical drift."
),
),
max_model_len: int = typer.Option(
32768,
"--max-model-len",
help="Maximum context length the server accepts (must fit input video tokens + max_tokens).",
),
gpu_memory_utilization: float = typer.Option(
0.9,
"--gpu-memory-utilization",
help="Fraction of GPU memory vLLM may reserve (model + KV cache).",
),
hf_home: Path | None = typer.Option( # noqa: B008
None,
"--hf-home",
help=(
"Override HF_HOME (where the model is downloaded). The model is ~65 GB; "
"by default this follows your environment's HF_HOME or HuggingFace's default."
),
),
vllm_spec: str = typer.Option(
DEFAULT_VLLM_SPEC,
"--vllm-spec",
help="pip-style spec passed to `uvx --from`. Pin a version that matches your CUDA.",
),
print_cmd: bool = typer.Option(
False,
"--print-cmd",
help="Print the chosen command without running it.",
),
extra_args: list[str] | None = typer.Argument( # noqa: B008
None,
help="Additional args passed through to `vllm serve` after `--`.",
),
) -> None:
"""Launch the vLLM server for Qwen3-Omni."""
extra = extra_args or []
if quantization not in ("bf16", "fp8"):
console.print(f"[red]--quantization must be 'bf16' or 'fp8'; got {quantization!r}.[/]")
raise typer.Exit(code=1)
# Disk check (only meaningful before first download).
cache_root = hf_home or Path(os.environ.get("HF_HOME", str(Path.home() / ".cache" / "huggingface")))
cache_root.mkdir(parents=True, exist_ok=True)
free_disk = _query_disk_free_gib(cache_root)
if free_disk < MODEL_DISK_GIB:
console.print(
f"[yellow]\u26a0 Only {free_disk:.1f} GiB free on disk under {cache_root} but the "
f"model needs ~{MODEL_DISK_GIB:.0f} GiB. Either free up space, set --hf-home "
f"to a larger volume, or expect the download to fail mid-way.[/]"
)
vllm_args = _build_vllm_args(
model=model,
host=host,
port=port,
quantization=quantization,
max_model_len=max_model_len,
gpu_memory_utilization=gpu_memory_utilization,
extra_args=extra,
)
# Use ``uvx --from vllm==...`` so vLLM lives in its own throwaway venv
# (or a cached tool venv). The `--` separates uvx args from the command's.
uvx_cmd = ["uvx", "--from", vllm_spec, *vllm_args]
env = os.environ.copy()
# vLLM 0.11.x requires the V0 engine for Qwen3-Omni's multimodal pipeline.
env.setdefault("VLLM_USE_V1", "0")
if hf_home is not None:
env["HF_HOME"] = str(hf_home)
console.print("\n[bold]Command:[/]")
console.print(" " + " ".join(uvx_cmd))
if hf_home is not None:
console.print(f" [dim](with HF_HOME={hf_home})[/]")
if print_cmd:
return
console.print("\n[dim]Launching... (first run downloads the model -- ~5 min on a fast link)[/]\n")
try:
completed = subprocess.run(uvx_cmd, env=env, check=False)
except KeyboardInterrupt:
console.print("\n[yellow]Interrupted.[/]")
return
sys.exit(completed.returncode)
if __name__ == "__main__":
app()
+336 -282
View File
@@ -1,59 +1,117 @@
"""
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 (uv pip install google-generativeai)
Set GEMINI_API_KEY or GOOGLE_API_KEY environment variable
- Qwen3-Omni via a local vLLM server (default)
- Gemini Flash 3.5 (cloud API)
Both produce a single combined English caption per video as a single
continuous paragraph of prose.
The Qwen3-Omni backend runs in a separately-launched vLLM server rather than
in-process, so vLLM's heavy CUDA dependencies stay out of this package. The
captioner talks to it over the OpenAI-compatible HTTP API.
Launch the server once (in an isolated environment) with:
.. code-block:: bash
uv run python scripts/serve_captioner.py
That helper picks BF16 vs FP8 dynamic quantization based on the GPU's free
memory and forwards everything else to ``vllm serve``. To check the recommended
command without running it, pass ``--print-cmd``.
To use Gemini instead, install ``google-genai`` and either set ``GEMINI_API_KEY``
(Gemini Developer API) or have Google Cloud credentials available (gcloud / an
attached service account), in which case it uses Vertex AI automatically.
"""
import itertools
import json
import os
import re
import subprocess
import tempfile
from abc import ABC, abstractmethod
from enum import Enum
from pathlib import Path
from typing import ClassVar
import torch
DEFAULT_VIDEO_CAPTION_INSTRUCTION = """\
Analyze this video and produce a single detailed caption covering both its visual content and its audio. Be \
detailed enough that someone reading the caption could form an accurate mental picture of what happens on screen \
and what can be heard. Be exhaustive: include every meaningful detail you can see and hear, including small \
objects, textures, secondary movements, and minor background sounds.
# 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:
Begin the caption directly with the action or visual detail; do not preface it with phrases like \
"The video opens with...", "The scene shows...", "We see...", or "There is...".
[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">
For every shot, include:
- The shot type and framing (extreme wide / wide / medium / medium close-up / close-up / extreme close-up) and any \
camera motion.
- Characters' clothing, appearance, posture, and movement (direction, speed, quality).
- The environment's materials, textures, lighting, and colors.
- All audio: spoken dialogue (quoted exactly in the original language), tone of voice, music (style, mood, \
volume changes), and environmental sounds. If a category is absent -- for example no music is playing, or no one is \
speaking -- state that explicitly. Do not invent specific instruments, music genres, moods, or ambient sounds \
that are not actually present.
- Any on-screen text (signs, titles, labels).
You MUST fill in all four sections. For [SPEECH], transcribe the actual words spoken, not a summary."""
Describe only what is visible or audible. Do not infer emotions, intentions, or anything outside the segment. \
Refer to people descriptively (e.g., "the man in the blue jacket"). Narrate strictly in chronological order; if \
the video contains multiple shots, describe each one in turn.
# 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:
Write everything as a single continuous paragraph of prose. Do not use section headers, bullet points, or labels \
like "Audio:" / "Visual:" / "Shot:". Integrate visual and audio details naturally within the same sentences.
[VISUAL]: <Detailed description of people, objects, actions, settings, colors, and movements>
[TEXT]: <Any on-screen text visible. If none, write "None">
Return a JSON object with exactly one key:
You MUST fill in both sections."""
{"combined_caption_english": "<your caption here>"}"""
DEFAULT_IMAGE_CAPTION_INSTRUCTION = """\
Analyze this image and produce a single detailed caption of its visual content. Be detailed enough that \
someone reading the caption could form an accurate mental picture of the image. Be thorough: include every meaningful \
detail that is actually present, including small objects, textures, and background elements.
Begin the caption directly with the main subject or a visual detail; do not preface it with phrases like \
"The image shows...", "This is a photo of...", "We see...", or "There is...".
Include:
- The framing and composition (close-up / medium / wide / overhead, etc.) and the vantage point.
- The medium or style if distinctive (photograph, illustration, 3D render, painting).
- People's clothing, appearance, and posture, and what they are doing.
- The setting's materials, textures, lighting, and colors.
- Transcribe any visible text verbatim (signs, labels, titles, captions).
Describe only what is visible. Do not infer emotions or intentions, and do not describe sounds, motion, or \
events before or after the moment shown -- this is a single still image. When something is ambiguous, describe \
the visible cue (e.g., "warm low-angle light") rather than guessing the underlying fact (e.g., "sunrise"). \
Refer to people descriptively (e.g., "the man in the blue jacket").
Only describe what is present. Never state that something is absent or missing -- do not write phrases like \
"there is no text", "no people are present", or "no other objects". If a category such as people or text does \
not appear, simply leave it out.
Write everything as a single continuous paragraph of prose. Do not use section headers, bullet points, or \
labels.
Return a JSON object with exactly one key:
{"combined_caption_english": "<your caption here>"}"""
# Default model served by ``scripts/serve_captioner.py``. The captioner does not
# download or load this model itself -- it just sends requests to the vLLM
# server, which already has the model loaded.
DEFAULT_QWEN_MODEL = "Qwen/Qwen3-Omni-30B-A3B-Thinking"
DEFAULT_VLLM_BASE_URL = "http://127.0.0.1:8001/v1"
# Key the combined-caption prompt asks the model to return its caption under.
_CAPTION_JSON_KEY = "combined_caption_english"
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)
QWEN_OMNI = "qwen_omni" # Qwen3-Omni via local vLLM HTTP server
GEMINI_FLASH = "gemini_flash" # Gemini Flash 3.5 cloud API
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
"""
"""Factory function to create a media captioner."""
match captioner_type:
case CaptionerType.QWEN_OMNI:
return QwenOmniCaptioner(**kwargs)
@@ -66,336 +124,332 @@ def create_captioner(captioner_type: CaptionerType, **kwargs) -> "MediaCaptionin
class MediaCaptioningModel(ABC):
"""Abstract base class for audio-visual media captioning models."""
instruction: str | None = None
@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
"""
"""Generate a caption for the given video or image."""
@property
@abstractmethod
def supports_audio(self) -> bool:
"""Whether this captioner supports audio input."""
def _resolve_instruction(self, path: str | Path) -> str:
"""Return the custom instruction, or the image/video default for this input."""
if self.instruction is not None:
return self.instruction
return DEFAULT_IMAGE_CAPTION_INSTRUCTION if self._is_image_file(path) else DEFAULT_VIDEO_CAPTION_INSTRUCTION
@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)
"""Audio-visual captioning via a local vLLM server running Qwen3-Omni.
The vLLM server must already be running. See ``scripts/serve_captioner.py``
for a helper that launches one in an isolated environment (no impact on
this package's dependency tree).
The captioner uses the OpenAI-compatible chat completions API. It sends
a ``file://`` URL pointing at the local video, the default combined-caption
prompt, and parses the JSON-wrapped response.
"""
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,
base_url: str = DEFAULT_VLLM_BASE_URL,
model: str = DEFAULT_QWEN_MODEL,
api_key: str = "EMPTY",
instruction: str | None = None,
max_tokens: int = 4096,
enable_thinking: bool = False,
timeout_s: float = 600.0,
):
"""
Initialize the Qwen2.5-Omni captioner.
"""Initialize the Qwen3-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
base_url: Base URL of the vLLM OpenAI-compatible server (default
``http://127.0.0.1:8001/v1``).
model: Model identifier the server is serving. Must match the
server's ``--served-model-name`` (defaults to the HuggingFace
model ID).
api_key: Token sent in the ``Authorization`` header. vLLM accepts
any value by default.
instruction: Custom instruction prompt. If ``None``, uses the
default combined-caption prompt.
max_tokens: Maximum new tokens to generate per caption. 4096 leaves
comfortable headroom for both ``enable_thinking`` modes.
enable_thinking: Whether to let the Thinking model produce a
``<think>...</think>`` chain-of-thought before the caption.
Off by default: it makes captioning ~5x slower with little
quality benefit and occasionally introduces hallucinations
(e.g., inventing dialogue or background music).
timeout_s: Per-request HTTP timeout.
"""
self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
self.instruction = instruction
self._load_model(use_8bit=use_8bit)
from openai import OpenAI # noqa: PLC0415
@property
def supports_audio(self) -> bool:
return True
self.model = model
self.instruction = instruction
self.max_tokens = max_tokens
self.enable_thinking = enable_thinking
self._client = OpenAI(base_url=base_url, api_key=api_key, timeout=timeout_s)
def caption(
self,
path: str | Path,
fps: int = 1,
include_audio: bool = True,
clean_caption: bool = True,
fps: int = 2,
) -> 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
path: Path to the video/image file to caption.
fps: Frames per second to sample from the video. Passed through to
vLLM's multimodal processor (``mm_processor_kwargs.fps``).
Default 2 is a typical choice for video MLLMs at this resolution.
Ignored for image inputs.
Returns:
A string containing the generated caption
The extracted caption string.
"""
path = Path(path)
is_image = self._is_image_file(path)
is_video = self._is_video_file(path)
if not (is_image or is_video):
raise ValueError(f"Unsupported media 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 = []
instruction = self._resolve_instruction(path)
if is_image:
user_content.append({"type": "image", "image": str(path)})
elif is_video:
user_content.append({"type": "video", "video": str(path)})
content = [
{"type": "image_url", "image_url": {"url": f"file://{path.resolve()}"}},
{"type": "text", "text": instruction},
]
return _parse_caption_response(self._chat(content)).strip()
# Add the instruction text
user_content.append({"type": "text", "text": instruction})
return self._caption_video(path, instruction, fps)
# 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,
def _chat(self, content: list[dict], mm_kwargs: dict | None = None) -> str:
"""Send one chat-completions request and return the raw response text."""
extra_body: dict = {
"repetition_penalty": 1.05,
"chat_template_kwargs": {"enable_thinking": self.enable_thinking},
}
if mm_kwargs:
extra_body["mm_processor_kwargs"] = mm_kwargs
response = self._client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": content}],
max_tokens=self.max_tokens,
temperature=0.0,
extra_body=extra_body,
)
return response.choices[0].message.content or ""
# 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.
def _caption_video(self, path: Path, instruction: str, fps: int) -> str:
"""Caption a video, sending its audio track as a separate modality.
vLLM does not extract a video's audio on its own (and its
``use_audio_in_video`` path is broken server-side), so we pull the audio
into a 16 kHz mono WAV and send it alongside the video -- otherwise the
model only sees frames and fabricates any spoken content.
"""
from transformers import ( # noqa: PLC0415
BitsAndBytesConfig,
Qwen2_5OmniProcessor,
Qwen2_5OmniThinkerForConditionalGeneration,
)
with tempfile.TemporaryDirectory(prefix="qwencap_") as tmp:
work = Path(tmp)
quantization_config = BitsAndBytesConfig(load_in_8bit=True) if use_8bit else None
# Best-effort: ffmpeg fails (and we send video only) if there's no audio.
audio_url: str | None = None
try:
wav = work / "audio.wav"
_extract_audio_wav(path, wav)
audio_url = f"file://{wav.resolve()}"
except subprocess.CalledProcessError:
pass
# 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",
)
def content(video: Path) -> list[dict]:
parts: list[dict] = [{"type": "video_url", "video_url": {"url": f"file://{video.resolve()}"}}]
if audio_url:
parts.append({"type": "audio_url", "audio_url": {"url": audio_url}})
parts.append({"type": "text", "text": instruction})
return parts
self.processor = Qwen2_5OmniProcessor.from_pretrained(self.MODEL_ID)
mm_kwargs = {"fps": fps}
try:
raw = self._chat(content(path), mm_kwargs)
except Exception as e:
# Raw / variable-frame-rate videos over-report their frame count, which
# breaks the server's frame sampler ("... frames from video"). Re-encode
# to a constant frame rate and retry once.
if "frames from video" not in str(e):
raise
cfr = work / "video_cfr.mp4"
_transcode_cfr(path, cfr)
raw = self._chat(content(cfr), mm_kwargs)
return _parse_caption_response(raw).strip()
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.
"""Audio-visual captioning using Google's Gemini via the Google Gen AI SDK.
Uses the ``google-genai`` package (the current SDK; ``google-generativeai``
is deprecated). Auth is resolved automatically:
1. If an API key is given (``api_key`` argument, or ``GEMINI_API_KEY`` /
``GOOGLE_API_KEY`` in the environment) -> the Gemini Developer API (AI Studio).
2. Otherwise, if Google Cloud Application Default Credentials are available
(an attached service account or ``gcloud auth application-default login``)
-> Vertex AI. The project comes from ADC (or ``GOOGLE_CLOUD_PROJECT``) and
the location defaults to ``global`` (override with ``GOOGLE_CLOUD_LOCATION``).
This means it "just works" on a gcloud-authed GCP VM with no env vars.
If neither is available, a clear error explains how to authenticate.
Media is sent inline (``Part.from_bytes``), which works on both backends.
"""
MODEL_ID = "gemini-flash-lite-latest"
MODEL_ID = "gemini-3.5-flash"
_MIME_TYPES: ClassVar[dict[str, str]] = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".heic": "image/heic",
".heif": "image/heif",
".mp4": "video/mp4",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".mkv": "video/x-matroska",
".webm": "video/webm",
}
def __init__(
self,
api_key: str | None = None,
instruction: str | None = None,
model: str | None = None,
):
"""Initialize the Gemini Flash captioner.
"""Initialize the Gemini 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
api_key: Gemini Developer API key. If ``None``, falls back to
``GEMINI_API_KEY`` / ``GOOGLE_API_KEY``; if no key is set at all,
uses Vertex AI via Application Default Credentials.
instruction: Custom instruction prompt. If ``None``, uses the default
image or video prompt depending on the input.
model: Override the served model id (defaults to ``MODEL_ID``).
"""
self.instruction = instruction
self._init_client(api_key)
@property
def supports_audio(self) -> bool:
return True
self.model = model or self.MODEL_ID
self._client = self._make_client(api_key)
def caption(
self,
path: str | Path,
fps: int = 3, # noqa: ARG002 - kept for API compatibility
include_audio: bool = True,
clean_caption: bool = True,
fps: int = 2, # noqa: ARG002 - kept for API compatibility
) -> 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
from google.genai import types # noqa: PLC0415
path = Path(path)
is_video = self._is_video_file(path)
use_audio = include_audio and is_video
instruction = self._resolve_instruction(path)
media = types.Part.from_bytes(data=path.read_bytes(), mime_type=self._mime_type(path))
response = self._client.models.generate_content(
model=self.model,
contents=[media, instruction],
config=types.GenerateContentConfig(temperature=0.0),
)
# 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
# Gemini may also return JSON if it followed our prompt format.
return _parse_caption_response(response.text or "").strip()
# Upload the file to Gemini
uploaded_file = self._genai.upload_file(path)
@classmethod
def _mime_type(cls, path: Path) -> str:
try:
return cls._MIME_TYPES[path.suffix.lower()]
except KeyError:
raise ValueError(f"Unsupported media type for Gemini: {path.suffix}") from None
# 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)
def _make_client(self, api_key: str | None): # noqa: ANN202 - genai.Client type is lazy-imported
from google import genai # noqa: PLC0415
if uploaded_file.state.name == "FAILED":
raise RuntimeError(f"File processing failed: {uploaded_file.state.name}")
# 1. API key (explicit arg or env) -> Gemini Developer API.
key = api_key or os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
if key:
return genai.Client(api_key=key)
# 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
# 2. No key -> Vertex AI via Application Default Credentials (gcloud / service account).
import google.auth # 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`"
_, adc_project = google.auth.default()
except Exception as e:
raise ValueError(
"No Gemini credentials found. Provide an API key (--api-key, or "
"GEMINI_API_KEY / GOOGLE_API_KEY), or set up Google Cloud credentials "
"for Vertex AI (e.g. `gcloud auth application-default login` or an "
"attached service account)."
) 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)
project = os.environ.get("GOOGLE_CLOUD_PROJECT") or adc_project
location = os.environ.get("GOOGLE_CLOUD_LOCATION", "global")
return genai.Client(vertexai=True, project=project, location=location)
def example() -> None:
"""Example usage of the captioning module."""
import sys # noqa: PLC0415
def _parse_caption_response(raw: str) -> str:
"""Extract the caption text from a model response.
Backend-agnostic: works for any model that follows the combined-caption
prompt. Handles the formats a model may produce:
- Plain caption text
- JSON ``{"combined_caption_english": "..."}``
- ``<think>...</think>`` chain-of-thought followed by either of the above
- Truncated JSON (when generation hits a token limit mid-string)
"""
text = re.sub(r"<think>[\s\S]*?</think>", "", raw).strip()
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)
# Thinking models (e.g. Qwen3-Omni-*-Thinking) emit the reasoning trace
# without an opening ``<think>`` tag, because the chat template injects it
# for them -- so the response starts mid-thought and is terminated by a lone
# ``</think>`` before the real answer. Drop everything up to that closer.
if "</think>" in text:
text = text.rsplit("</think>", 1)[1].strip()
video_path = sys.argv[1]
captioner_type = CaptionerType(sys.argv[2]) if len(sys.argv) > 2 else CaptionerType.QWEN_OMNI
if not text:
return raw.strip()
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
try:
parsed = json.loads(text)
if isinstance(parsed, dict) and _CAPTION_JSON_KEY in parsed:
return parsed[_CAPTION_JSON_KEY]
except (json.JSONDecodeError, ValueError):
pass
match = re.search(rf"\{{[^{{}}]*\"{_CAPTION_JSON_KEY}\"[^{{}}]*\}}", text)
if match:
try:
parsed = json.loads(match.group())
if isinstance(parsed, dict) and _CAPTION_JSON_KEY in parsed:
return parsed[_CAPTION_JSON_KEY]
except (json.JSONDecodeError, ValueError):
pass
# Truncated JSON: extract the string value even if the closing quote/brace is missing.
match = re.search(rf'"{_CAPTION_JSON_KEY}"\s*:\s*"((?:[^"\\]|\\.)*)', text)
if match:
try:
return json.loads('"' + match.group(1) + '"')
except (json.JSONDecodeError, ValueError):
return match.group(1)
return text
if __name__ == "__main__":
example()
def _run_ffmpeg(args: list[str]) -> None:
"""Run the ffmpeg binary bundled with ``imageio-ffmpeg`` (a dependency)."""
import imageio_ffmpeg # noqa: PLC0415
cmd = [imageio_ffmpeg.get_ffmpeg_exe(), "-y", "-loglevel", "error", *args]
subprocess.run(cmd, check=True, capture_output=True)
def _extract_audio_wav(src: Path, dest: Path) -> None:
"""Extract the audio track to a 16 kHz mono PCM WAV (matches pretraining).
Raises ``CalledProcessError`` when the video has no audio stream.
"""
_run_ffmpeg(["-i", str(src), "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", str(dest)])
def _transcode_cfr(src: Path, dest: Path) -> None:
"""Re-encode the video to a constant frame rate so the server's frame sampler can
read every requested index (raw / variable-frame-rate videos over-report frames)."""
_run_ffmpeg(["-i", str(src), "-fps_mode", "cfr", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-an", str(dest)])
+310 -16
View File
@@ -1,10 +1,11 @@
from pathlib import Path
from typing import Annotated, Literal
from typing import Annotated, Literal, Union
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.flexible import FlexibleStrategyConfig
from ltx_trainer.training_strategies.text_to_video import TextToVideoConfig
from ltx_trainer.training_strategies.video_to_video import VideoToVideoConfig
@@ -13,6 +14,226 @@ class ConfigBaseModel(BaseModel):
model_config = ConfigDict(extra="forbid")
# =============================================================================
# Validation Condition Types
# =============================================================================
class FirstFrameConditionConfig(ConfigBaseModel):
"""First-frame conditioning (intrinsic, latent_idx=0). Always targets video.
If image_or_video points to a video file, the first frame is automatically extracted.
"""
type: Literal["first_frame"] = "first_frame"
image_or_video: str | Path
class PrefixConditionConfig(ConfigBaseModel):
"""Prefix conditioning for temporal extension (intrinsic). Exactly one of video/audio must be set."""
type: Literal["prefix"] = "prefix"
video: str | None = None
audio: str | None = None
num_frames: int | None = Field(
default=None,
ge=1,
description="Number of pixel frames for video prefix. Must satisfy num_frames %% 8 == 1.",
)
duration: float | None = Field(default=None, gt=0, description="Duration in seconds for audio prefix")
@model_validator(mode="after")
def validate_exactly_one_modality(self) -> "PrefixConditionConfig":
if (self.video is None) == (self.audio is None):
raise ValueError("Exactly one of 'video' or 'audio' must be set for prefix condition")
return self
@model_validator(mode="after")
def validate_num_frames_constraint(self) -> "PrefixConditionConfig":
if self.video is not None and self.num_frames is not None and self.num_frames % 8 != 1:
raise ValueError(
f"num_frames ({self.num_frames}) must satisfy num_frames % 8 == 1 "
f"for video prefix (e.g., 1, 9, 17, 25, ...)"
)
return self
class SuffixConditionConfig(ConfigBaseModel):
"""Suffix conditioning for temporal extension (intrinsic). Exactly one of video/audio must be set."""
type: Literal["suffix"] = "suffix"
video: str | None = None
audio: str | None = None
num_frames: int | None = Field(
default=None,
ge=1,
description="Number of pixel frames for video suffix. Must satisfy num_frames %% 8 == 0.",
)
duration: float | None = Field(default=None, gt=0, description="Duration in seconds for audio suffix")
@model_validator(mode="after")
def validate_exactly_one_modality(self) -> "SuffixConditionConfig":
if (self.video is None) == (self.audio is None):
raise ValueError("Exactly one of 'video' or 'audio' must be set for suffix condition")
return self
@model_validator(mode="after")
def validate_num_frames_constraint(self) -> "SuffixConditionConfig":
if self.video is not None and self.num_frames is not None and self.num_frames % 8 != 0:
raise ValueError(
f"num_frames ({self.num_frames}) must satisfy num_frames % 8 == 0 "
f"for video suffix (e.g., 8, 16, 24, 32, ...)"
)
return self
class SpatialCropConditionConfig(ConfigBaseModel):
"""Spatial crop conditioning for outpainting (intrinsic, video only)."""
type: Literal["spatial_crop"] = "spatial_crop"
video: str
spatial_region: tuple[int, int, int, int] = Field(
..., description="Spatial crop region as (y1, x1, y2, x2) in pixel coordinates"
)
class MaskConditionConfig(ConfigBaseModel):
"""Mask-based conditioning for inpainting (intrinsic). Exactly one of video/audio must be set."""
type: Literal["mask"] = "mask"
video: str | None = None
audio: str | None = None
mask: str
@model_validator(mode="after")
def validate_exactly_one_modality(self) -> "MaskConditionConfig":
if (self.video is None) == (self.audio is None):
raise ValueError("Exactly one of 'video' or 'audio' must be set for mask condition")
return self
class ReferenceConditionConfig(ConfigBaseModel):
"""Reference conditioning (IC-LoRA style concatenation). Exactly one of video/audio must be set."""
type: Literal["reference"] = "reference"
video: str | None = None
audio: str | None = None
downscale_factor: int = Field(default=1, ge=1)
temporal_scale_factor: int = Field(default=1, ge=1)
include_in_output: bool = False
@model_validator(mode="after")
def validate_exactly_one_modality(self) -> "ReferenceConditionConfig":
if (self.video is None) == (self.audio is None):
raise ValueError("Exactly one of 'video' or 'audio' must be set for reference condition")
return self
class VideoToAudioConditionConfig(ConfigBaseModel):
"""Video-to-audio — video is provided as frozen cross-modal conditioning.
The video is kept clean (sigma=0) and influences audio generation via cross-modal attention.
"""
type: Literal["video_to_audio"] = "video_to_audio"
video: str
class AudioToVideoConditionConfig(ConfigBaseModel):
"""Audio-to-video — audio is provided as frozen cross-modal conditioning.
The audio is kept clean (sigma=0) and influences video generation via cross-modal attention.
"""
type: Literal["audio_to_video"] = "audio_to_video"
audio: str
ValidationCondition = Annotated[
Union[
FirstFrameConditionConfig,
PrefixConditionConfig,
SuffixConditionConfig,
SpatialCropConditionConfig,
MaskConditionConfig,
ReferenceConditionConfig,
VideoToAudioConditionConfig,
AudioToVideoConditionConfig,
],
Field(discriminator="type"),
]
def _condition_targets_video(cond: ValidationCondition) -> bool:
"""Check if a validation condition targets the video modality."""
if cond.type in ("first_frame", "spatial_crop", "video_to_audio"):
return True
if cond.type in ("prefix", "suffix", "mask", "reference"):
return getattr(cond, "video", None) is not None
return False
def _condition_targets_audio(cond: ValidationCondition) -> bool:
"""Check if a validation condition targets the audio modality."""
if cond.type == "audio_to_video":
return True
if cond.type in ("prefix", "suffix", "mask", "reference"):
return getattr(cond, "audio", None) is not None
return False
class ValidationSample(ConfigBaseModel):
"""Configuration for a single validation sample — fully self-describing."""
prompt: str
conditions: list[ValidationCondition] = Field(default_factory=list)
video_dims: tuple[int, int, int] | None = Field(
default=None,
description="Per-sample override for (width, height, frames). None = inherit from ValidationConfig.",
)
seed: int | None = Field(
default=None,
description="Per-sample override for random seed. None = inherit from ValidationConfig.",
)
@field_validator("video_dims")
@classmethod
def validate_video_dims(cls, v: tuple[int, int, int] | None) -> tuple[int, int, int] | None:
if v is None:
return v
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
@model_validator(mode="after")
def validate_frozen_modality_conflicts(self) -> "ValidationSample":
frozen_types = {c.type for c in self.conditions if c.type in ("video_to_audio", "audio_to_video")}
if "video_to_audio" in frozen_types and "audio_to_video" in frozen_types:
raise ValueError(
"Cannot have both video_to_audio and audio_to_video conditions — nothing would be generated"
)
if "video_to_audio" in frozen_types:
for c in self.conditions:
if c.type != "video_to_audio" and _condition_targets_video(c):
raise ValueError(
f"Cannot use video-targeting '{c.type}' condition when video is frozen (video_to_audio)"
)
if "audio_to_video" in frozen_types:
for c in self.conditions:
if c.type != "audio_to_video" and _condition_targets_audio(c):
raise ValueError(
f"Cannot use audio-targeting '{c.type}' condition when audio is frozen (audio_to_video)"
)
return self
class ModelConfig(ConfigBaseModel):
"""Configuration for the base model and training mode"""
@@ -89,7 +310,9 @@ def _get_strategy_discriminator(v: dict | TrainingStrategyConfigBase) -> str:
# Union type for all strategy configs with discriminator
TrainingStrategyConfig = Annotated[
Annotated[TextToVideoConfig, Tag("text_to_video")] | Annotated[VideoToVideoConfig, Tag("video_to_video")],
Annotated[TextToVideoConfig, Tag("text_to_video")]
| Annotated[VideoToVideoConfig, Tag("video_to_video")]
| Annotated[FlexibleStrategyConfig, Tag("flexible")],
Discriminator(_get_strategy_discriminator),
]
@@ -191,13 +414,32 @@ class DataConfig(ConfigBaseModel):
ge=0,
)
@field_validator("preprocessed_data_root")
@classmethod
def validate_preprocessed_data_root(cls, v: str) -> str:
"""Validate that preprocessed_data_root exists."""
path = Path(v).expanduser().resolve()
if not path.exists():
raise ValueError(f"Dataset path does not exist: {v}")
if not path.is_dir():
raise ValueError(f"Dataset path is not a directory: {v}")
return str(path)
class ValidationConfig(ConfigBaseModel):
"""Configuration for validation during training"""
# Per-sample configuration (new format — preferred)
samples: list[ValidationSample] = Field(
default_factory=list,
description="List of validation samples. Each sample is fully self-describing with its own "
"prompt, conditions, and optional overrides. Replaces prompts/images/reference_videos.",
)
# Legacy fields (deprecated — converted to samples internally via convert_legacy_format)
prompts: list[str] = Field(
default_factory=list,
description="List of prompts to use for validation",
description="[DEPRECATED: use 'samples' instead] List of prompts to use for validation",
)
negative_prompt: str = Field(
@@ -207,19 +449,22 @@ class ValidationConfig(ConfigBaseModel):
images: list[str] | None = Field(
default=None,
description="List of image paths to use for validation. "
description="[DEPRECATED: use 'samples' with first_frame conditions] "
"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. "
description="[DEPRECATED: use 'samples' with reference conditions] "
"List of reference video paths to use for validation. "
"One video path must be provided for each validation prompt",
)
reference_downscale_factor: int = Field(
default=1,
description="Downscale factor for reference videos in IC-LoRA validation. "
description="[DEPRECATED: use downscale_factor on ReferenceCondition] "
"Downscale factor for reference videos in IC-LoRA validation. "
"When > 1, reference videos are processed at 1/n resolution (e.g., 2 means half resolution). "
"Must match the factor used during dataset preprocessing.",
ge=1,
@@ -301,6 +546,13 @@ class ValidationConfig(ConfigBaseModel):
"in validation even when not training the audio branch.",
)
generate_video: bool = Field(
default=True,
description="Whether to generate video in validation samples. "
"Set to False for audio-only or v2a validation to save VRAM by skipping video VAE decoder loading. "
"When False, validation will only generate audio (requires generate_audio=True).",
)
skip_initial_validation: bool = Field(
default=False,
description="Skip validation video sampling at step 0 (beginning of training)",
@@ -308,7 +560,8 @@ class ValidationConfig(ConfigBaseModel):
include_reference_in_output: bool = Field(
default=False,
description="For video-to-video training: concatenate the original reference video side-by-side "
description="[DEPRECATED: use include_in_output on ReferenceCondition] "
"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.",
)
@@ -346,13 +599,33 @@ class ValidationConfig(ConfigBaseModel):
return v
@model_validator(mode="after")
def convert_legacy_format(self) -> "ValidationConfig":
"""Convert deprecated prompts/images/reference_videos to the new samples format."""
if self.prompts and not self.samples:
samples = []
for i, prompt in enumerate(self.prompts):
conditions: list[ValidationCondition] = []
if self.images and i < len(self.images):
conditions.append(FirstFrameConditionConfig(image_or_video=self.images[i]))
if self.reference_videos and i < len(self.reference_videos):
conditions.append(
ReferenceConditionConfig(
video=self.reference_videos[i],
downscale_factor=self.reference_downscale_factor,
include_in_output=self.include_reference_in_output,
)
)
samples.append(ValidationSample(prompt=prompt, conditions=conditions))
self.samples = samples
return self
@model_validator(mode="after")
def validate_scaled_reference_dimensions(self) -> "ValidationConfig":
"""Validate that scaled reference dimensions are valid when reference_downscale_factor > 1."""
if self.reference_downscale_factor > 1:
width, height, _frames = self.video_dims
# Validate that downscale factor evenly divides the target dimensions
if width % self.reference_downscale_factor != 0:
raise ValueError(
f"Width {width} is not evenly divisible by reference_downscale_factor "
@@ -367,7 +640,6 @@ class ValidationConfig(ConfigBaseModel):
scaled_width = width // self.reference_downscale_factor
scaled_height = height // self.reference_downscale_factor
# Validate scaled dimensions are divisible by 32
if scaled_width % 32 != 0:
raise ValueError(
f"Scaled reference width {scaled_width} (from {width} / {self.reference_downscale_factor}) "
@@ -381,6 +653,16 @@ class ValidationConfig(ConfigBaseModel):
return self
@model_validator(mode="after")
def validate_output_modality_requirements(self) -> "ValidationConfig":
"""Validate output modality settings when validation is configured."""
has_validation = bool(self.prompts) or bool(self.samples)
if has_validation and not self.generate_video and not self.generate_audio:
raise ValueError(
"At least one of generate_video or generate_audio must be True when validation is configured."
)
return self
class CheckpointsConfig(ConfigBaseModel):
"""Configuration for model checkpointing during training"""
@@ -514,19 +796,31 @@ class LtxTrainerConfig(ConfigBaseModel):
"""Expand user home directory in output path."""
return str(Path(v).expanduser().resolve())
def _validate_data_dirs_exist(self) -> None:
"""Verify that every directory declared by the training strategy exists under the data root."""
data_root = Path(self.data.preprocessed_data_root)
for dir_name in self.training_strategy.get_data_sources():
dir_path = data_root / dir_name
if not dir_path.is_dir():
raise ValueError(
f"Required data directory '{dir_name}' does not exist under preprocessed_data_root: {dir_path}"
)
@model_validator(mode="after")
def validate_strategy_compatibility(self) -> "LtxTrainerConfig":
"""Validate that training strategy and other configurations are compatible."""
self._validate_data_dirs_exist()
# 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"
if self.training_strategy.name == "video_to_video" and self.validation.interval:
has_reference = bool(self.validation.reference_videos) or any(
cond.type == "reference" for sample in self.validation.samples for cond in sample.conditions
)
if not has_reference:
raise ValueError(
"reference_videos or samples with reference conditions 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:
@@ -54,60 +54,6 @@ class SamplingContext:
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
+126 -323
View File
@@ -12,8 +12,8 @@ from typing import Any, Callable
import torch
import wandb
import yaml
from accelerate import Accelerator, DistributedDataParallelKwargs, DistributedType
from accelerate.utils import gather_object, set_seed
from accelerate import Accelerator, DistributedType
from accelerate.utils import DistributedDataParallelKwargs, gather_object, 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
@@ -30,26 +30,22 @@ from torch.optim.lr_scheduler import (
StepLR,
)
from torch.utils.data import DataLoader
from torchvision.transforms import functional as F # noqa: N812
from ltx_core.text_encoders.gemma import convert_to_additive_mask
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.gpu_utils import free_gpu_memory, free_gpu_memory_context, get_gpu_memory_gb
from ltx_trainer.gpu_utils import free_gpu_memory, get_gpu_memory_gb
from ltx_trainer.hf_hub_utils import push_to_hub
from ltx_trainer.model_loader import load_embeddings_processor, load_text_encoder
from ltx_trainer.model_loader import load_model as load_ltx_model
from ltx_trainer.model_loader import load_embeddings_processor, load_transformer
from ltx_trainer.progress import TrainingProgress
from ltx_trainer.quantization import quantize_model
from ltx_trainer.sigma_tracker import SigmaBucketTracker
from ltx_trainer.timestep_samplers import SAMPLERS
from ltx_trainer.training_state import ConfigFingerprint, RngStates, TrainingState
from ltx_trainer.training_strategies import get_training_strategy
from ltx_trainer.utils import 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
from ltx_trainer.validation_runner import ValidationRunner
# Disable irrelevant warnings from transformers
os.environ["TOKENIZERS_PARALLELISM"] = "true"
@@ -66,7 +62,7 @@ if not IS_MAIN_PROCESS:
disable_progress_bar()
StepCallback = Callable[[int, int, list[Path] | None], None] # (step, total, sampled paths or None) -> None
StepCallback = Callable[[int, int, list[Path]], None] # (step, total, list[sampled_video_path]) -> None
MEMORY_CHECK_INTERVAL = 200
@@ -96,7 +92,16 @@ class LtxvTrainer:
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()
# ValidationRunner loads its own models (text encoder, VAE encoder/decoder, etc.),
# caches prompt embeddings and conditioning media, then unloads encoders.
self._validation_runner = ValidationRunner(
config=self._config.validation,
model_path=self._config.model.model_path,
text_encoder_path=self._config.model.text_encoder_path,
load_text_encoder_in_8bit=self._config.acceleration.load_text_encoder_in_8bit,
)
self._load_models()
self._setup_accelerator()
self._collect_trainable_params()
@@ -108,8 +113,8 @@ class LtxvTrainer:
self._checkpoint_paths: list[Path] = []
self._training_state_paths: list[Path] = []
self._training_state_size_warned = False
self._wandb_run = None
self._sigma_tracker = SigmaBucketTracker()
self._wandb_run = None
def train( # noqa: PLR0912, PLR0915
self,
@@ -190,7 +195,7 @@ class LtxvTrainer:
with progress:
if cfg.validation.interval and not cfg.validation.skip_initial_validation:
with self._offloaded_optimizer_state():
sampled_videos_paths = self._run_distributed_validation(progress)
sampled_videos_paths = self._run_validation(progress)
self._accelerator.wait_for_everyone()
@@ -223,7 +228,7 @@ class LtxvTrainer:
if self._lr_scheduler is not None:
self._lr_scheduler.step()
# Run validation if needed
# Run validation if needed (handles DDP/FSDP work distribution internally)
if (
cfg.validation.interval
and self._global_step > 0
@@ -231,7 +236,7 @@ class LtxvTrainer:
and is_optimization_step
):
with self._offloaded_optimizer_state():
sampled_videos_paths = self._run_distributed_validation(progress)
sampled_videos_paths = self._run_validation(progress)
# Save checkpoint if needed
if (
@@ -376,111 +381,39 @@ class LtxvTrainer:
perturbations=None,
)
# Use strategy to compute loss
# Use strategy to compute loss (returns per-element [B,] for sigma-bucket tracking)
loss = self._training_strategy.compute_loss(video_pred, audio_pred, model_inputs)
sigma = model_inputs.video.sigma.detach() if model_inputs.video.enabled else model_inputs.audio.sigma.detach()
# Sigma comes from whichever modality is generated (video preferred, else audio).
if model_inputs.video is not None and model_inputs.video.enabled:
sigma = model_inputs.video.sigma.detach()
else:
sigma = model_inputs.audio.sigma.detach()
return TrainingStepOutput(loss=loss, sigma=sigma)
@free_gpu_memory_context(after=True)
def _load_text_encoder_and_cache_embeddings(self) -> list[CachedPromptEmbeddings] | None:
"""Load text encoder + embeddings processor, compute and cache validation embeddings."""
def _load_models(self) -> None:
"""Load the transformer and embeddings processor for training."""
logger.debug("Loading transformer...")
self._transformer = load_transformer(
checkpoint_path=self._config.model.model_path,
device="cpu",
dtype=torch.bfloat16,
)
# This method:
# 1. Loads the pure Gemma text encoder on GPU
# 2. Loads the embeddings processor (feature extractor + connectors)
# 3. If validation prompts are configured, computes and caches their embeddings
# 4. Unloads the Gemma model entirely, keeps the embeddings processor for training
# Load text encoder (pure Gemma LLM) on GPU — LOCAL_RANK before Accelerator exists
# DDP-safe: LOCAL_RANK is set by accelerate before trainer init. Loading on bare
# "cuda" would resolve to cuda:0 on every rank and crash with a device mismatch.
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
init_device = torch.device(f"cuda:{local_rank}" if torch.cuda.is_available() else "cpu")
logger.debug("Loading text encoder...")
text_encoder = load_text_encoder(
gemma_model_path=self._config.model.text_encoder_path,
device=init_device,
dtype=torch.bfloat16,
load_in_8bit=self._config.acceleration.load_text_encoder_in_8bit,
)
# Load embeddings processor (feature extractor + connectors)
logger.debug("Loading embeddings processor...")
self._embeddings_processor = load_embeddings_processor(
checkpoint_path=self._config.model.model_path,
device=init_device,
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:
pos_hs, pos_mask = text_encoder.encode(prompt)
pos_out = self._embeddings_processor.process_hidden_states(pos_hs, pos_mask)
neg_hs, neg_mask = text_encoder.encode(self._config.validation.negative_prompt)
neg_out = self._embeddings_processor.process_hidden_states(neg_hs, neg_mask)
cached_embeddings.append(
CachedPromptEmbeddings(
video_context_positive=pos_out.video_encoding.cpu(),
audio_context_positive=pos_out.audio_encoding.cpu(),
video_context_negative=neg_out.video_encoding.cpu(),
audio_context_negative=(
neg_out.audio_encoding.cpu() if neg_out.audio_encoding is not None else None
),
)
)
# Unload Gemma model and feature extractor, keep only connectors for training
del text_encoder
self._embeddings_processor.feature_extractor = None
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._embeddings_processor 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)
@@ -494,16 +427,7 @@ class LtxvTrainer:
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."""
@@ -692,13 +616,6 @@ class LtxvTrainer:
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)
@@ -740,7 +657,7 @@ class LtxvTrainer:
"""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()
data_sources = self._config.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)}")
@@ -785,41 +702,6 @@ class LtxvTrainer:
# noinspection PyTypeChecker
self._optimizer, self._lr_scheduler = self._accelerator.prepare(optimizer, lr_scheduler)
@contextlib.contextmanager
def _offloaded_optimizer_state(self) -> Iterator[None]:
"""Context manager that offloads optimizer state to CPU during validation.
Opt-in via `acceleration.offload_optimizer_during_validation`. Frees VRAM for
validation video generation when optimizer state is large (e.g. full fine-tune
AdamW, high-rank LoRA). No-op for FSDP (sharded state -- manual `.cpu()` breaks
metadata).
"""
enabled = (
self._config.acceleration.offload_optimizer_during_validation
and self._accelerator.distributed_type != DistributedType.FSDP
)
# Track exactly which tensors we move so we don't promote ones that were
# intentionally on CPU (e.g. AdamW's `step` scalar on recent PyTorch).
offloaded: list[tuple[dict, str]] = []
if enabled:
offloaded_bytes = 0
for state in self._optimizer.state.values():
for k, v in state.items():
if isinstance(v, torch.Tensor) and v.is_cuda:
offloaded.append((state, k))
offloaded_bytes += v.nbytes
if offloaded:
logger.info(f"Offloading optimizer state to CPU ({offloaded_bytes / 1e9:.1f} GB)")
for state, k in offloaded:
state[k] = state[k].cpu()
try:
yield
finally:
device = self._accelerator.device
for state, k in offloaded:
state[k] = state[k].to(device)
def _create_scheduler(self, optimizer: torch.optim.Optimizer) -> LRScheduler | None:
"""Create learning rate scheduler based on config."""
scheduler_type = self._config.optimization.scheduler_type
@@ -920,164 +802,104 @@ class LtxvTrainer:
"Monitor training stability and consider disabling quantization if issues arise."
)
def _run_distributed_validation(self, progress: TrainingProgress) -> list[Path]:
"""Run validation across all ranks and log gathered results on rank 0.
Each rank generates only its assigned subset of prompts (see `_sample_videos`),
so all GPUs stay busy and no rank idles long enough to trigger NCCL timeouts.
Paths are gathered across ranks so rank 0 has the full list for W&B logging.
@contextlib.contextmanager
def _offloaded_optimizer_state(self) -> Iterator[None]:
"""Context manager that offloads optimizer state to CPU during validation.
Opt-in via `acceleration.offload_optimizer_during_validation`. Frees VRAM for
validation video generation when optimizer state is large (e.g. full fine-tune
AdamW, high-rank LoRA). No-op for FSDP (sharded state -- manual `.cpu()` breaks
metadata).
"""
enabled = (
self._config.acceleration.offload_optimizer_during_validation
and self._accelerator.distributed_type != DistributedType.FSDP
)
# Track exactly which tensors we move so we don't promote ones that were
# intentionally on CPU (e.g. AdamW's `step` scalar on recent PyTorch).
offloaded: list[tuple[dict, str]] = []
if enabled:
offloaded_bytes = 0
for state in self._optimizer.state.values():
for k, v in state.items():
if isinstance(v, torch.Tensor) and v.is_cuda:
offloaded.append((state, k))
offloaded_bytes += v.nbytes
if offloaded:
logger.info(f"Offloading optimizer state to CPU ({offloaded_bytes / 1e9:.1f} GB)")
for state, k in offloaded:
state[k] = state[k].cpu()
try:
yield
finally:
device = self._accelerator.device
for state, k in offloaded:
state[k] = state[k].to(device)
def _run_validation(self, progress: TrainingProgress) -> list[Path]:
"""Run distributed validation by delegating to the ValidationRunner.
Each rank generates its assigned subset of validation samples (round-robin by
`process_index`/`num_processes`), so all GPUs stay busy and no rank idles long
enough to trigger NCCL timeouts. Paths are gathered across ranks so rank 0 has
the full list for W&B logging.
Under FSDP with multiple processes, ranks pad with extra generate passes
(same sample, no disk write) so every rank runs the same number of forwards --
avoids collective mismatch.
Note: Multi-node training requires a shared filesystem so rank 0 can read
videos written by other ranks.
"""
sampled = self._sample_videos(progress)
self._optimizer.zero_grad(set_to_none=True)
free_gpu_memory()
if self._accelerator.num_processes > 1:
# gather_object returns a flat list from all ranks
num_samples = len(self._config.validation.samples)
if num_samples == 0:
return []
rank = self._accelerator.process_index
world_size = self._accelerator.num_processes
rank_indices = list(range(rank, num_samples, world_size))
work_items: list[tuple[int, bool]] = [(i, True) for i in rank_indices]
if self._accelerator.distributed_type == DistributedType.FSDP and world_size > 1:
# FSDP forwards run collective ops; pad short ranks with no-save duplicates so
# every rank executes the same number of forwards. A rank with empty
# rank_indices (world_size > num_samples) still pads with sample 0 to stay in
# sync with the others.
max_per_rank = math.ceil(num_samples / world_size)
pad_seed = rank_indices[-1] if rank_indices else 0
work_items += [(pad_seed, False)] * (max_per_rank - len(work_items))
# W&B logging is handled by the trainer (after gathering across ranks),
# so we always pass wandb_run=None to the runner.
sampled = self._validation_runner.run(
transformer=self._transformer,
step=self._global_step,
output_dir=Path(self._config.output_dir),
device=self._accelerator.device,
progress=progress,
wandb_run=None,
work_items=work_items,
)
if world_size > 1:
sampled = sorted(gather_object(sampled), key=lambda x: x[0])
paths = [p for _, p in sampled]
if self._accelerator.is_main_process and paths:
self._log_validation_samples(paths, self._config.validation.prompts)
if (
self._accelerator.is_main_process
and paths
and self._config.wandb.log_validation_videos
and self._wandb_run is not None
):
self._validation_runner.log_to_wandb(self._wandb_run, paths, self._global_step)
# Non-main ranks must not reach checkpoint collectives while main is still logging to W&B.
self._accelerator.wait_for_everyone()
return paths
# Note: Use @torch.no_grad() instead of @torch.inference_mode() to avoid FSDP inplace update errors after validation
@torch.no_grad()
@free_gpu_memory_context(after=True)
def _sample_videos(self, progress: TrainingProgress) -> list[tuple[int, Path]]:
"""Run validation by generating videos from this rank's share of the validation prompts.
Prompts are split round-robin across ranks via `process_index` / `num_processes`,
which collapses to "all prompts" when running on a single GPU. Returns
(prompt_idx, path) tuples so the caller can reconstruct global order without
relying on filename conventions.
Under FSDP with multiple processes, ranks pad with extra generate passes (same prompt,
no disk write) so every rank runs the same number of forwards — avoids collective mismatch.
"""
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
# Zero gradients and free GPU memory to reclaim memory before validation sampling
self._optimizer.zero_grad(set_to_none=True)
free_gpu_memory()
prompts = self._config.validation.prompts
rank = self._accelerator.process_index
world_size = self._accelerator.num_processes
rank_indices = list(range(rank, len(prompts), world_size))
# FSDP: every rank must run the same number of forwards; pad with duplicate generates (no save).
work: list[tuple[int, bool]] = [(i, True) for i in rank_indices]
if self._accelerator.distributed_type == DistributedType.FSDP and world_size > 1:
max_per_rank = math.ceil(len(prompts) / world_size)
pad_seed = rank_indices[-1] if rank_indices else 0
work += [(pad_seed, False)] * (max_per_rank - len(work))
sampling_ctx = progress.start_sampling(
num_prompts=len(work),
num_steps=inference_steps,
)
# Create a 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)
results: list[tuple[int, Path]] = []
width, height, num_frames = self._config.validation.video_dims
for local_i, (prompt_idx, save_output) in enumerate(work):
prompt = prompts[prompt_idx]
sampling_ctx.start_video(local_i)
# 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,
reference_downscale_factor=self._config.validation.reference_downscale_factor,
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,
)
if not save_output:
continue
# Save output (image for single frame, video otherwise)
ext = "png" if num_frames == 1 else "mp4"
output_path = output_dir / f"step_{self._global_step:06d}_{prompt_idx + 1:02d}.{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_sampling_rate if audio is not None else None,
)
results.append((prompt_idx, output_path))
# Clean up progress tasks
sampling_ctx.cleanup()
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 results
@staticmethod
def _log_training_stats(stats: TrainingStats) -> None:
"""Log training statistics."""
@@ -1166,8 +988,8 @@ class LtxvTrainer:
def _save_training_state(self, save_dir: Path) -> None:
"""Save training state alongside checkpoint for resume.
Respects checkpoints.save_training_state config:
- "full": optimizer + scheduler + RNG + step + wandb_run_id
- "minimal": scheduler + RNG + step + wandb_run_id
- "full": optimizer + scheduler + RNG + step
- "minimal": scheduler + RNG + step only
- "off": skip entirely
"""
if not IS_MAIN_PROCESS:
@@ -1270,7 +1092,7 @@ class LtxvTrainer:
logger.info(f"💾 Training configuration saved to: {config_path.relative_to(self._config.output_dir)}")
def _init_wandb(self, resume_run_id: str | None = None) -> None:
"""Initialize Weights & Biases run."""
"""Initialize Weights & Biases run, resuming an existing run if its id is provided."""
if not self._config.wandb.enabled or not IS_MAIN_PROCESS:
self._wandb_run = None
return
@@ -1285,7 +1107,7 @@ class LtxvTrainer:
}
if resume_run_id is not None:
init_kwargs["id"] = resume_run_id
init_kwargs["resume"] = "allow"
init_kwargs["resume"] = "must"
run = wandb.init(**init_kwargs)
self._wandb_run = run
@@ -1293,22 +1115,3 @@ class LtxvTrainer:
"""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")
if is_image:
samples = [
wandb.Image(str(path), caption=prompt) for path, prompt in zip(sample_paths, prompts, strict=True)
]
else:
samples = [
wandb.Video(str(path), caption=prompt, format=path.suffix.lower().lstrip("."))
for path, prompt in zip(sample_paths, prompts, strict=True)
]
self._wandb_run.log({"validation_samples": samples}, step=self._global_step)
@@ -1,10 +1,13 @@
"""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)
- Text-to-video training (standard generation, optionally with audio) [DEPRECATED]
- Video-to-video training (IC-LoRA mode with reference videos) [DEPRECATED]
- Flexible training (unified conditioning framework supporting all scenarios) [RECOMMENDED]
Each strategy encapsulates the specific logic for preparing model inputs and computing loss.
"""
import warnings
from ltx_trainer import logger
from ltx_trainer.training_strategies.base_strategy import (
DEFAULT_FPS,
@@ -13,15 +16,18 @@ from ltx_trainer.training_strategies.base_strategy import (
TrainingStrategy,
TrainingStrategyConfigBase,
)
from ltx_trainer.training_strategies.flexible import FlexibleStrategy, FlexibleStrategyConfig
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
TrainingStrategyConfig = TextToVideoConfig | VideoToVideoConfig | FlexibleStrategyConfig
__all__ = [
"DEFAULT_FPS",
"VIDEO_SCALE_FACTORS",
"FlexibleStrategy",
"FlexibleStrategyConfig",
"ModelInputs",
"TextToVideoConfig",
"TextToVideoStrategy",
@@ -43,16 +49,42 @@ def get_training_strategy(config: TrainingStrategyConfig) -> TrainingStrategy:
The appropriate training strategy instance
Raises:
ValueError: If strategy name is not supported
Note:
The `text_to_video` and `video_to_video` strategies are deprecated.
Please use the `flexible` strategy instead.
"""
match config:
case TextToVideoConfig():
warnings.warn(
"The 'text_to_video' training strategy is deprecated and will be removed "
"in a future version. Please migrate to the 'flexible' strategy. "
"See the migration guide in the documentation.",
DeprecationWarning,
stacklevel=2,
)
strategy = TextToVideoStrategy(config)
case VideoToVideoConfig():
warnings.warn(
"The 'video_to_video' training strategy is deprecated and will be removed "
"in a future version. Please migrate to the 'flexible' strategy. "
"See the migration guide in the documentation.",
DeprecationWarning,
stacklevel=2,
)
strategy = VideoToVideoStrategy(config)
case FlexibleStrategyConfig():
strategy = FlexibleStrategy(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 🔇)"
# Determine audio mode for logging
if hasattr(config, "with_audio"):
audio_mode = "(audio enabled 🔈)" if config.with_audio else "(audio disabled 🔇)"
elif hasattr(config, "audio") and config.audio is not None:
audio_mode = "(audio enabled 🔈)"
else:
audio_mode = "(audio disabled 🔇)"
logger.debug(f"🎯 Using {strategy.__class__.__name__} training strategy {audio_mode}")
return strategy
@@ -34,29 +34,36 @@ class TrainingStrategyConfigBase(BaseModel):
model_config = ConfigDict(extra="forbid")
name: Literal["text_to_video", "video_to_video"] = Field(
name: Literal["text_to_video", "video_to_video", "flexible"] = Field(
description="Unique name identifying the training strategy type"
)
@abstractmethod
def get_data_sources(self) -> dict[str, str]:
"""Get the required data sources for this strategy.
Returns a mapping of directory name (relative to ``preprocessed_data_root``)
to the dataset output key under which that directory's contents are exposed.
This is the single source of truth for which directories the strategy needs:
it drives both dataset wiring (in the trainer) and existence validation
(in ``LtxTrainerConfig``).
"""
@dataclass
class ModelInputs:
"""Container for model inputs using the Modality-based interface."""
video: Modality
video: Modality | None
audio: Modality | None
# Training targets (for loss computation)
video_targets: Tensor
video_targets: Tensor | None
audio_targets: Tensor | None
# Masks for loss computation
video_loss_mask: Tensor # Boolean mask: True = compute loss for this token
# Masks for loss computation (True = compute loss for this token)
video_loss_mask: Tensor | None
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.
@@ -73,24 +80,6 @@ class TrainingStrategy(ABC):
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,
@@ -145,7 +134,6 @@ class TrainingStrategy(ABC):
batch_size: int,
fps: float,
device: torch.device,
dtype: torch.dtype,
) -> Tensor:
"""Generate video position embeddings using ltx_core's native implementation.
Args:
@@ -155,9 +143,8 @@ class TrainingStrategy(ABC):
batch_size: Batch size
fps: Frames per second
device: Target device
dtype: Target dtype
Returns:
Position tensor of shape [B, 3, seq_len, 2]
Position tensor of shape [B, 3, seq_len, 2] (float32)
"""
latent_coords = self._video_patchifier.get_patch_grid_bounds(
output_shape=VideoLatentShape(
@@ -175,7 +162,7 @@ class TrainingStrategy(ABC):
latent_coords=latent_coords,
scale_factors=VIDEO_SCALE_FACTORS,
causal_fix=True,
).to(dtype)
).float()
# Scale temporal dimension by 1/fps to get time in seconds
pixel_coords[:, 0, ...] = pixel_coords[:, 0, ...] / fps
@@ -187,14 +174,12 @@ class TrainingStrategy(ABC):
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:
@@ -204,7 +189,7 @@ class TrainingStrategy(ABC):
"""
mel_bins = 16
latent_coords = self._audio_patchifier.get_patch_grid_bounds(
return self._audio_patchifier.get_patch_grid_bounds(
output_shape=AudioLatentShape(
frames=num_time_steps,
mel_bins=mel_bins,
@@ -214,8 +199,6 @@ class TrainingStrategy(ABC):
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.
@@ -0,0 +1,718 @@
"""Flexible training strategy for a unified conditioning framework.
This strategy implements the Unified Conditioning Framework that supports:
- Simple fine-tuning with text conditioning (text-to-video/audio)
- Intrinsic conditioning (first_frame, prefix, suffix, spatial_crop, mask)
- Extrinsic conditioning (concatenation-based, IC-LoRA style)
The flexible strategy replaces TextToVideoStrategy and VideoToVideoStrategy by expressing
all conditioning scenarios through configuration rather than code.
"""
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, Any, Literal, Union
import torch
from pydantic import BaseModel, ConfigDict, Field, model_validator
from torch import Tensor
from ltx_core.model.transformer.modality import Modality
from ltx_trainer.timestep_samplers import TimestepSampler
from ltx_trainer.training_strategies.base_strategy import (
DEFAULT_FPS,
VIDEO_SCALE_FACTORS,
ModelInputs,
TrainingStrategy,
TrainingStrategyConfigBase,
)
# =============================================================================
# Configuration Classes
# =============================================================================
class IntrinsicConditionBase(BaseModel):
"""Base for intrinsic conditioning — tokens get clean latents, timestep=0, no loss."""
model_config = ConfigDict(extra="forbid")
probability: float = Field(
default=1.0,
ge=0.0,
le=1.0,
description="Probability of applying this condition",
)
class FirstFrameConditionConfig(IntrinsicConditionBase):
"""First frame conditioning — frame 0 is clean, excluded from loss."""
type: Literal["first_frame"] = "first_frame"
class PrefixConditionConfig(IntrinsicConditionBase):
"""Prefix conditioning — first N temporal units are clean, excluded from loss."""
type: Literal["prefix"] = "prefix"
temporal_boundary: int = Field(
...,
ge=1,
description="Number of temporal units for prefix region. "
"For video: number of latent frames. For audio: number of audio latent timesteps.",
)
class SuffixConditionConfig(IntrinsicConditionBase):
"""Suffix conditioning — last N temporal units are clean, excluded from loss."""
type: Literal["suffix"] = "suffix"
temporal_boundary: int = Field(
...,
ge=1,
description="Number of temporal units for suffix region. "
"For video: number of latent frames. For audio: number of audio latent timesteps.",
)
class SpatialCropConditionConfig(IntrinsicConditionBase):
"""Spatial crop conditioning — rectangular pixel region is clean, excluded from loss."""
type: Literal["spatial_crop"] = "spatial_crop"
spatial_region: tuple[int, int, int, int] = Field(
...,
description="Spatial crop region as (y1, x1, y2, x2) in pixel coordinates",
)
class MaskConditionConfig(IntrinsicConditionBase):
"""Mask conditioning — per-sample binary mask determines conditioning tokens."""
type: Literal["mask"] = "mask"
mask_dir: str = Field(
...,
description="Directory containing per-sample masks",
)
class ReferenceConditionConfig(BaseModel):
"""Reference conditioning (IC-LoRA style concatenation).
External reference latents are concatenated to the target sequence.
Reference tokens are clean (timestep=0), excluded from loss, and
participate in bidirectional self-attention.
"""
model_config = ConfigDict(extra="forbid")
type: Literal["reference"] = "reference"
latents_dir: str = Field(..., description="Directory for reference latents")
probability: float = Field(default=1.0, ge=0.0, le=1.0, description="Probability of applying this condition")
# Discriminated union for condition configs
ConditionConfig = Annotated[
Union[
FirstFrameConditionConfig,
PrefixConditionConfig,
SuffixConditionConfig,
SpatialCropConditionConfig,
MaskConditionConfig,
ReferenceConditionConfig,
],
Field(discriminator="type"),
]
class ModalityConfig(BaseModel):
"""Configuration for a single modality (video or audio)."""
model_config = ConfigDict(extra="forbid")
is_generated: bool = Field(
...,
description="True = generated modality (denoised, contributes to loss), False = conditioning-only modality",
)
latents_dir: str = Field(
...,
description="Directory for latents",
)
conditions: list[ConditionConfig] = Field(
default_factory=list,
description="List of conditions (e.g. first_frame, prefix, reference). Text conditioning is always applied.",
)
class FlexibleStrategyConfig(TrainingStrategyConfigBase):
"""Configuration for the flexible training strategy.
This strategy supports all conditioning scenarios through configuration:
- Text-to-video/audio with simple fine-tuning
- Intrinsic conditioning like first-frame, extension, outpainting
- Reference conditioning like IC-LoRA (concatenation-based reference)
"""
name: Literal["flexible"] = "flexible"
video: ModalityConfig | None = Field(
default=None,
description="Video modality configuration",
)
audio: ModalityConfig | None = Field(
default=None,
description="Audio modality configuration",
)
@model_validator(mode="after")
def validate_at_least_one_generated(self) -> "FlexibleStrategyConfig":
"""Ensure at least one modality has is_generated=true."""
has_video_target = self.video is not None and self.video.is_generated
has_audio_target = self.audio is not None and self.audio.is_generated
if not has_video_target and not has_audio_target:
raise ValueError("At least one modality must have is_generated=true")
return self
@model_validator(mode="after")
def validate_audio_intrinsic_regions(self) -> "FlexibleStrategyConfig":
"""Reject video-only intrinsic regions on the audio modality."""
if self.audio is None:
return self
for cond in self.audio.conditions:
if isinstance(cond, (FirstFrameConditionConfig, SpatialCropConditionConfig)):
raise ValueError(
f"Intrinsic condition '{cond.type}' is not supported for audio. "
f"Audio supports: prefix, suffix, mask."
)
return self
def get_data_sources(self) -> dict[str, str]:
"""Dynamically determine required data sources from config.
Returns a mapping of directory name (under ``preprocessed_data_root``) to
the dataset output key.
"""
sources: dict[str, str] = {"conditions": "conditions"}
if self.video is not None:
sources[self.video.latents_dir] = "video_latents"
if self.audio is not None:
sources[self.audio.latents_dir] = "audio_latents"
for modality_config in (self.video, self.audio):
if modality_config is None:
continue
for cond in modality_config.conditions:
if isinstance(cond, ReferenceConditionConfig):
sources[cond.latents_dir] = cond.latents_dir
elif isinstance(cond, MaskConditionConfig):
sources[cond.mask_dir] = cond.mask_dir
return sources
# =============================================================================
# Helper Data Structures
# =============================================================================
@dataclass
class ModalityProcessingResult:
"""Result of processing a single modality."""
modality: Modality
targets: Tensor | None
loss_mask: Tensor | None
@dataclass
class LatentData:
"""Loaded and patchified latents with metadata."""
latents: Tensor # [B, seq_len, C]
num_frames: int
height: int
width: int
fps: float
# =============================================================================
# FlexibleStrategy Implementation
# =============================================================================
class FlexibleStrategy(TrainingStrategy):
"""Unified training strategy supporting all conditioning scenarios.
This strategy implements the Unified Conditioning Framework, allowing
any training scenario to be expressed through configuration.
"""
config: FlexibleStrategyConfig
def __init__(self, config: FlexibleStrategyConfig):
"""Initialize strategy with configuration.
Args:
config: Flexible strategy configuration
"""
super().__init__(config)
self.config = config
self.reference_spatial_scale_factor, self.reference_temporal_scale_factor = (
self._infer_reference_scale_factors_from_config()
)
def prepare_training_inputs(
self,
batch: dict[str, Any],
timestep_sampler: TimestepSampler,
) -> ModelInputs:
"""Prepare training inputs by processing video and audio modalities."""
video_result = self._process_modality(self.config.video, batch, "video", timestep_sampler)
audio_result = self._process_modality(self.config.audio, batch, "audio", timestep_sampler)
return ModelInputs(
video=video_result.modality if video_result else None,
audio=audio_result.modality if audio_result else None,
video_targets=video_result.targets if video_result else None,
audio_targets=audio_result.targets if audio_result else None,
video_loss_mask=video_result.loss_mask if video_result else None,
audio_loss_mask=audio_result.loss_mask if audio_result else None,
)
def compute_loss(
self,
video_pred: Tensor | None,
audio_pred: Tensor | None,
inputs: ModelInputs,
) -> Tensor:
"""Compute masked MSE loss for video and audio predictions. Returns [B,]."""
total_loss = None
if video_pred is not None and inputs.video_targets is not None:
video_loss = self._compute_modality_loss(
pred=video_pred,
targets=inputs.video_targets,
loss_mask=inputs.video_loss_mask,
)
total_loss = video_loss
if audio_pred is not None and inputs.audio_targets is not None:
audio_loss = self._compute_modality_loss(
pred=audio_pred,
targets=inputs.audio_targets,
loss_mask=inputs.audio_loss_mask,
)
total_loss = audio_loss if total_loss is None else total_loss + audio_loss
if total_loss is None:
raise ValueError("No valid predictions and targets provided for loss computation")
return total_loss
def get_checkpoint_metadata(self) -> dict[str, Any]:
"""Include reference scale factors in checkpoint metadata for inference pipelines."""
metadata: dict[str, Any] = {}
spatial = self.reference_spatial_scale_factor
temporal = self.reference_temporal_scale_factor
if spatial is not None and spatial != 1:
metadata["reference_spatial_scale_factor"] = spatial
metadata["reference_downscale_factor"] = spatial # backward compat
if temporal is not None and temporal != 1:
metadata["reference_temporal_scale_factor"] = temporal
return metadata
def _infer_reference_scale_factors_from_config(self) -> tuple[int | None, int | None]:
"""Infer spatial and temporal scale factors by peeking at one sample pair."""
if self.config.video is None:
return None, None
for cond in self.config.video.conditions:
if not isinstance(cond, ReferenceConditionConfig):
continue
target_dir = Path(self.config.video.latents_dir)
ref_dir = Path(cond.latents_dir)
for sample_file in target_dir.rglob("*.pt"):
ref_file = ref_dir / sample_file.relative_to(target_dir)
if not ref_file.exists():
continue
target_data = torch.load(sample_file, map_location="cpu", weights_only=True)
ref_data = torch.load(ref_file, map_location="cpu", weights_only=True)
if "height" not in ref_data or "height" not in target_data:
continue
spatial = self._infer_scale_factor(
ref_data["height"],
ref_data["width"],
target_data["height"],
target_data["width"],
)
temporal = self._infer_temporal_scale_factor(
ref_data["num_frames"],
target_data["num_frames"],
)
return spatial, temporal
return None, None
def _process_modality(
self,
modality_config: ModalityConfig | None,
batch: dict[str, Any],
modality_key: str,
timestep_sampler: TimestepSampler,
) -> ModalityProcessingResult | None:
"""Process a single modality: load latents, add noise, apply conditions, build Modality."""
if modality_config is None:
return None
# Step 1: Load and patchify latents
data = self._patchify_latent_data(batch[f"{modality_key}_latents"], modality_key)
latents = data.latents
batch_size, seq_len, _ = latents.shape
device = latents.device
dtype = latents.dtype
# Step 2: Get text embeddings
conditions = batch["conditions"]
prompt_embeds = conditions[f"{modality_key}_prompt_embeds"]
prompt_attention_mask = conditions["prompt_attention_mask"]
# Step 3: Initialize noise, timesteps, and loss mask based on is_generated flag
if modality_config.is_generated:
noisy_latents, targets, timesteps, loss_mask, sigmas = self._initialize_noisy_target(
latents, timestep_sampler
)
else:
# Conditioning modality: keep clean (sigma=0), no loss
noisy_latents = latents
targets = None
timesteps = torch.zeros(batch_size, seq_len, device=device, dtype=dtype)
loss_mask = None
sigmas = torch.zeros(batch_size, device=device, dtype=dtype)
# Step 4: Generate positions
if modality_key == "video":
positions = self._get_video_positions(
num_frames=data.num_frames,
height=data.height,
width=data.width,
batch_size=batch_size,
fps=data.fps,
device=device,
)
else:
positions = self._get_audio_positions(
num_time_steps=seq_len,
batch_size=batch_size,
device=device,
)
# Step 5: Apply conditions (intrinsic first, then extrinsic)
for cond in modality_config.conditions:
if isinstance(cond, IntrinsicConditionBase) and modality_config.is_generated:
noisy_latents, timesteps, loss_mask = self._apply_intrinsic_condition(
noisy_latents=noisy_latents,
clean_latents=latents,
timesteps=timesteps,
loss_mask=loss_mask,
config=cond,
height=data.height,
width=data.width,
batch=batch,
)
for cond in modality_config.conditions:
if isinstance(cond, ReferenceConditionConfig):
noisy_latents, positions, timesteps, loss_mask, targets = self._apply_reference_condition(
noisy_latents=noisy_latents,
positions=positions,
timesteps=timesteps,
loss_mask=loss_mask,
targets=targets,
batch=batch,
config=cond,
modality_key=modality_key,
)
# Step 6: Build Modality
modality = Modality(
enabled=True,
latent=noisy_latents,
sigma=sigmas,
timesteps=timesteps,
positions=positions,
context=prompt_embeds,
context_mask=prompt_attention_mask,
)
return ModalityProcessingResult(
modality=modality,
targets=targets,
loss_mask=loss_mask,
)
@staticmethod
def _initialize_noisy_target(
latents: Tensor,
timestep_sampler: TimestepSampler,
) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor]:
"""Add noise to latents and create training targets. Returns (noisy, targets, timesteps, mask, sigmas)."""
batch_size, seq_len, _ = latents.shape
sigmas = timestep_sampler.sample_for(latents)
noise = torch.randn_like(latents)
sigmas_expanded = sigmas.view(-1, 1, 1)
noisy_latents = (1 - sigmas_expanded) * latents + sigmas_expanded * noise
targets = noise - latents # velocity prediction
timesteps = sigmas.view(-1, 1).expand(batch_size, seq_len).clone()
loss_mask = torch.ones(batch_size, seq_len, dtype=torch.bool, device=latents.device)
return noisy_latents, targets, timesteps, loss_mask, sigmas
def _apply_intrinsic_condition(
self,
noisy_latents: Tensor,
clean_latents: Tensor,
timesteps: Tensor,
loss_mask: Tensor,
config: IntrinsicConditionBase,
height: int,
width: int,
batch: dict[str, Any],
) -> tuple[Tensor, Tensor, Tensor]:
"""Apply intrinsic conditioning using a binary mask.
For each token, the mask value determines conditioning strength:
- mask=1: conditioned (clean latent, timestep=0, excluded from loss)
- mask=0: generated (noisy latent, original timestep, contributes to loss)
The conditioning decision is drawn independently per batch element so the training
signal across samples in a batch is i.i.d. -- a single batch-wide draw would
correlate gradient updates across the batch.
"""
batch_size, seq_len, _ = noisy_latents.shape
device = noisy_latents.device
# Per-sample Bernoulli draw -- each element is independently conditioned.
apply_per_sample = torch.rand(batch_size, device=device) < config.probability
if not apply_per_sample.any():
return noisy_latents, timesteps, loss_mask
if isinstance(config, FirstFrameConditionConfig):
mask = self._compute_temporal_mask(batch_size, seq_len, height, width, 1, False, device)
elif isinstance(config, PrefixConditionConfig):
mask = self._compute_temporal_mask(
batch_size, seq_len, height, width, config.temporal_boundary, False, device
)
elif isinstance(config, SuffixConditionConfig):
mask = self._compute_temporal_mask(
batch_size, seq_len, height, width, config.temporal_boundary, True, device
)
elif isinstance(config, SpatialCropConditionConfig):
mask = self._compute_spatial_crop_mask(batch_size, seq_len, height, width, config.spatial_region, device)
elif isinstance(config, MaskConditionConfig):
# Binarize to match inference, which thresholds masks at load time
# (validation_runner._load_and_downsample_mask / _load_audio_mask).
mask = (batch[config.mask_dir]["mask"].reshape(batch_size, seq_len) > 0.5).float()
else:
raise ValueError(f"Unknown intrinsic condition type: {type(config).__name__}")
# Zero the mask for samples the per-sample draw did not select.
mask = mask * apply_per_sample.view(-1, 1).to(mask.dtype)
# Apply binary mask: clean conditioned tokens, noisy generated tokens.
m = mask.unsqueeze(-1)
noisy_latents = m * clean_latents + (1 - m) * noisy_latents
timesteps = (1 - mask) * timesteps
loss_mask = loss_mask & (mask == 0)
return noisy_latents, timesteps, loss_mask
@staticmethod
def _compute_temporal_mask(
batch_size: int,
seq_len: int,
height: int,
width: int,
num_frames: int,
from_end: bool,
device: torch.device,
) -> Tensor:
"""Compute float mask for temporal region (prefix or suffix). Returns [B, seq_len] in {0, 1}."""
tokens_per_frame = height * width
num_tokens = num_frames * tokens_per_frame
mask = torch.zeros(batch_size, seq_len, device=device)
if from_end:
mask[:, -num_tokens:] = 1.0
else:
mask[:, :num_tokens] = 1.0
return mask
@staticmethod
def _compute_spatial_crop_mask(
batch_size: int,
seq_len: int,
height: int,
width: int,
region: tuple[int, int, int, int],
device: torch.device,
) -> Tensor:
"""Compute float mask for spatial crop region (y1, x1, y2, x2) in pixel coords.
Returns [B, seq_len] in {0, 1}.
"""
y1, x1, y2, x2 = region
num_frames = seq_len // (height * width)
# Convert pixel to latent coordinates and clamp (per-axis VAE scale factor).
def to_latent(v: int, scale: int, max_v: int) -> int:
return max(0, min(v // scale, max_v))
ly1 = to_latent(y1, VIDEO_SCALE_FACTORS.height, height)
ly2 = to_latent(y2, VIDEO_SCALE_FACTORS.height, height)
lx1 = to_latent(x1, VIDEO_SCALE_FACTORS.width, width)
lx2 = to_latent(x2, VIDEO_SCALE_FACTORS.width, width)
# Create spatial mask and tile across frames
spatial_mask = torch.zeros(height, width, device=device)
spatial_mask[ly1:ly2, lx1:lx2] = 1.0
full_mask = spatial_mask.flatten().repeat(num_frames)
return full_mask.unsqueeze(0).expand(batch_size, -1)
def _patchify_latent_data(self, latent_data: dict[str, Any], modality_key: str) -> LatentData:
"""Patchify latent data and extract metadata."""
latents = latent_data["latents"]
if modality_key == "video":
num_frames = latent_data["num_frames"][0].item()
height = latent_data["height"][0].item()
width = latent_data["width"][0].item()
fps = latent_data.get("fps")
fps = fps[0].item() if fps is not None else DEFAULT_FPS
latents = self._video_patchifier.patchify(latents)
else:
num_frames = latent_data.get("num_frames", [latents.shape[2]])[0]
if isinstance(num_frames, Tensor):
num_frames = num_frames.item()
height = 1
width = 1
fps = 1.0
latents = self._audio_patchifier.patchify(latents)
return LatentData(latents=latents, num_frames=num_frames, height=height, width=width, fps=fps)
def _apply_reference_condition(
self,
noisy_latents: Tensor,
positions: Tensor,
timesteps: Tensor,
loss_mask: Tensor | None,
targets: Tensor | None,
batch: dict[str, Any],
config: ReferenceConditionConfig,
modality_key: str,
) -> tuple[Tensor, Tensor, Tensor, Tensor | None, Tensor | None]:
"""Concatenate reference latents to target sequence for reference conditioning (IC-LoRA style).
The apply/skip decision is batch-wide (reference conditioning changes the sequence
length, so it cannot be applied to only part of a batch) but is drawn from the torch
RNG so runs are reproducible under ``torch.manual_seed`` mirroring the intrinsic
per-sample draw rather than Python's unseeded ``random``.
"""
if torch.rand((), device=noisy_latents.device).item() >= config.probability:
return noisy_latents, positions, timesteps, loss_mask, targets
# Load and patchify condition latents
cond = self._patchify_latent_data(batch[config.latents_dir], modality_key)
cond_latents = cond.latents
batch_size, cond_seq_len, _ = cond_latents.shape
device = cond_latents.device
dtype = cond_latents.dtype
# Generate condition positions
if modality_key == "video":
cond_positions = self._get_video_positions(
num_frames=cond.num_frames,
height=cond.height,
width=cond.width,
batch_size=batch_size,
fps=cond.fps,
device=device,
)
else:
cond_positions = self._get_audio_positions(
num_time_steps=cond_seq_len,
batch_size=batch_size,
device=device,
)
# Translate / rescale ref positions into the target's frame (video only).
if modality_key == "video":
spatial_sf = self.reference_spatial_scale_factor or 1
temporal_sf = self.reference_temporal_scale_factor or 1
if spatial_sf != 1 or temporal_sf != 1:
cond_positions = cond_positions.clone()
if temporal_sf != 1:
# Ref positions are already at the ref's effective fps (source_fps / S,
# stored by process_videos.py). Shift by (S - 1) / target_fps so ref's
# last patch aligns with target's last; clamp the causal patch at 0.
t_target = positions[:, 0, 0:1, 1:2] # = 1 / target_fps
cond_positions[:, 0, ...] = torch.clamp(
cond_positions[:, 0, ...] - (temporal_sf - 1) * t_target, min=0
)
if spatial_sf != 1:
cond_positions[:, 1, ...] *= spatial_sf
cond_positions[:, 2, ...] *= spatial_sf
# Condition tokens: clean, timestep=0, no loss
cond_timesteps = torch.zeros(batch_size, cond_seq_len, device=device, dtype=dtype)
cond_loss_mask = torch.zeros(batch_size, cond_seq_len, dtype=torch.bool, device=device)
# Concatenate condition and target sequences (condition first, then target)
combined_latents = torch.cat([cond_latents, noisy_latents], dim=1)
combined_positions = torch.cat([cond_positions, positions], dim=2)
combined_timesteps = torch.cat([cond_timesteps, timesteps], dim=1)
combined_loss_mask = torch.cat([cond_loss_mask, loss_mask], dim=1) if loss_mask is not None else None
# Targets remain unchanged (only for target portion, not condition portion)
return combined_latents, combined_positions, combined_timesteps, combined_loss_mask, targets
@staticmethod
def _compute_modality_loss(pred: Tensor, targets: Tensor, loss_mask: Tensor) -> Tensor:
"""Compute per-element MSE loss for a single modality. Returns [B,]."""
# Slice prediction to match targets length (removes any prepended condition tokens)
target_len = targets.shape[1]
pred = pred[:, -target_len:, :]
mask = loss_mask[:, -target_len:]
# Compute masked MSE loss, reduce per-element [B,] over (seq, channels)
mask_expanded = mask.unsqueeze(-1).float()
squared_error = (pred - targets).pow(2)
masked_loss = squared_error * mask_expanded
return masked_loss.mean(dim=[-2, -1]) / mask_expanded.mean(dim=[-2, -1]).clamp(min=1e-8)
@staticmethod
def _infer_scale_factor(cond_height: int, cond_width: int, target_height: int, target_width: int) -> int:
"""Infer spatial scale factor between condition and target resolutions."""
if target_height == cond_height and target_width == cond_width:
return 1
scale_h = target_height // cond_height if cond_height > 0 else 1
scale_w = target_width // cond_width if cond_width > 0 else 1
if scale_h != scale_w:
raise ValueError(
f"Non-uniform scale factors between condition and target: height={scale_h}, width={scale_w}. "
"Condition and target resolutions must scale uniformly."
)
return scale_h
@staticmethod
def _infer_temporal_scale_factor(cond_num_frames: int, target_num_frames: int) -> int:
"""Infer temporal scale factor between condition and target latent frame counts.
The first latent frame encodes a single pixel frame (the VAE's causal structure),
so the temporal groups count is (num_frames - 1). The scale factor is the ratio
of target groups to condition groups.
"""
if target_num_frames == cond_num_frames:
return 1
target_groups = target_num_frames - 1
cond_groups = cond_num_frames - 1
if cond_groups <= 0 or target_groups <= 0:
return 1
if target_groups % cond_groups != 0:
raise ValueError(
f"Target temporal groups ({target_groups}) is not evenly divisible by "
f"condition temporal groups ({cond_groups})."
)
return target_groups // cond_groups
@@ -45,6 +45,18 @@ class TextToVideoConfig(TrainingStrategyConfigBase):
description="Directory name for audio latents when with_audio is True",
)
def get_data_sources(self) -> 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.with_audio:
sources[self.audio_latents_dir] = "audio_latents"
return sources
class TextToVideoStrategy(TrainingStrategy):
"""Text-to-video training strategy.
@@ -64,26 +76,6 @@ class TextToVideoStrategy(TrainingStrategy):
"""
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],
@@ -119,7 +111,6 @@ class TextToVideoStrategy(TrainingStrategy):
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(
@@ -157,7 +148,6 @@ class TextToVideoStrategy(TrainingStrategy):
batch_size=batch_size,
fps=fps,
device=device,
dtype=dtype,
)
# Create video Modality
@@ -187,7 +177,6 @@ class TextToVideoStrategy(TrainingStrategy):
prompt_attention_mask=prompt_attention_mask,
batch_size=batch_size,
device=device,
dtype=dtype,
)
return ModelInputs(
@@ -207,7 +196,6 @@ class TextToVideoStrategy(TrainingStrategy):
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:
@@ -217,7 +205,6 @@ class TextToVideoStrategy(TrainingStrategy):
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)
"""
@@ -248,7 +235,6 @@ class TextToVideoStrategy(TrainingStrategy):
num_time_steps=audio_seq_len,
batch_size=batch_size,
device=device,
dtype=dtype,
)
# Create audio Modality
@@ -39,6 +39,14 @@ class VideoToVideoConfig(TrainingStrategyConfigBase):
description="Directory name for latents of reference videos",
)
def get_data_sources(self) -> dict[str, str]:
"""IC-LoRA training requires latents, conditions, and reference latents."""
return {
"latents": "latents",
"conditions": "conditions",
self.reference_latents_dir: "ref_latents",
}
class VideoToVideoStrategy(TrainingStrategy):
"""Video-to-video training strategy for IC-LoRA.
@@ -62,14 +70,6 @@ class VideoToVideoStrategy(TrainingStrategy):
super().__init__(config)
self.reference_downscale_factor = None # Will be inferred from first batch
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( # noqa: PLR0915
self,
batch: dict[str, Any],
@@ -133,7 +133,6 @@ class VideoToVideoStrategy(TrainingStrategy):
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)
@@ -164,7 +163,7 @@ class VideoToVideoStrategy(TrainingStrategy):
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 for loss computation (velocity prediction) - only for target portion
targets = noise - target_latents
# Concatenate reference (clean) and target (noisy)
@@ -181,7 +180,6 @@ class VideoToVideoStrategy(TrainingStrategy):
batch_size=batch_size,
fps=fps,
device=device,
dtype=dtype,
)
# Scale reference positions to match target coordinate space
@@ -200,7 +198,6 @@ class VideoToVideoStrategy(TrainingStrategy):
batch_size=batch_size,
fps=fps,
device=device,
dtype=dtype,
)
# Concatenate positions along sequence dimension
@@ -231,7 +228,6 @@ class VideoToVideoStrategy(TrainingStrategy):
audio_targets=None,
video_loss_mask=video_loss_mask,
audio_loss_mask=None,
ref_seq_len=ref_seq_len,
)
def compute_loss(
@@ -240,13 +236,11 @@ class VideoToVideoStrategy(TrainingStrategy):
_audio_pred: Tensor | None,
inputs: ModelInputs,
) -> Tensor:
"""Compute masked loss only on target portion. Returns [B,]."""
# 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 masked loss on target portion only. Returns [B,]."""
# Slice prediction to match targets length (removes prepended reference tokens)
target_len = inputs.video_targets.shape[1]
target_pred = video_pred[:, -target_len:, :]
target_loss_mask = inputs.video_loss_mask[:, -target_len:]
# Compute per-element loss [B,]
loss = (target_pred - inputs.video_targets).pow(2)
File diff suppressed because it is too large Load Diff
@@ -1,874 +0,0 @@
"""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 GemmaTextEncoder
from ltx_core.text_encoders.gemma.embeddings_processor import EmbeddingsProcessor
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]
reference_downscale_factor: int = 1 # For IC-LoRA: downscale factor (1 = same resolution, 2 = half resolution)
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: "GemmaTextEncoder | None" = None,
audio_decoder: "AudioDecoder | None" = None,
vocoder: "Vocoder | None" = None,
sampling_context: SamplingContext | None = None,
embeddings_processor: "EmbeddingsProcessor | 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 (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
embeddings_processor: Optional embeddings processor (required if text_encoder provided)
"""
self._transformer = transformer
self._vae_decoder = vae_decoder
self._vae_encoder = vae_encoder
self._text_encoder = text_encoder
self._embeddings_processor = embeddings_processor
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]
# Scale reference positions to match target coordinate space
# Position tensor shape: [B, 3, seq_len, 2] where dim 1 is (time, height, width)
if config.reference_downscale_factor != 1:
ref_positions = ref_positions.clone()
ref_positions[:, 1, ...] *= config.reference_downscale_factor # height axis
ref_positions[:, 2, ...] *= config.reference_downscale_factor # width axis
# Time axis (index 0) remains unchanged
# 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.
When reference_downscale_factor > 1, the reference video is downscaled to a smaller
resolution for more efficient inference. The positions will be scaled up later
to match the target coordinate space.
Args:
config: Generation configuration
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]
scale_factor = config.reference_downscale_factor
# Target dimensions for reference (scaled down if scale_factor > 1)
target_height = config.height // scale_factor
target_width = config.width // scale_factor
# Validate scaled dimensions
if target_height % 32 != 0 or target_width % 32 != 0:
raise ValueError(
f"Scaled reference dimensions ({target_height}x{target_width}) must be divisible by 32. "
f"Original: {config.height}x{config.width}, scale_factor: {scale_factor}"
)
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,
sigma=sigmas[0].repeat(video_state.latent.shape[0]),
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,
sigma=sigmas[0].repeat(audio_state.latent.shape[0]),
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,
sigma=sigma.repeat(video_state.latent.shape[0]),
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,
sigma=sigma.repeat(audio_state.latent.shape[0]),
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")
if config.cached_embeddings is None and self._embeddings_processor is None:
raise ValueError("embeddings_processor is required when encoding prompts on-the-fly")
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 + embeddings processor."""
self._text_encoder.to(device)
self._embeddings_processor.to(device)
pos_hs, pos_mask = self._text_encoder.encode(config.prompt)
pos_out = self._embeddings_processor.process_hidden_states(pos_hs, pos_mask)
v_ctx_pos, a_ctx_pos = pos_out.video_encoding, pos_out.audio_encoding
v_ctx_neg, a_ctx_neg = None, None
if config.guidance_scale != 1.0:
neg_hs, neg_mask = self._text_encoder.encode(config.negative_prompt)
neg_out = self._embeddings_processor.process_hidden_states(neg_hs, neg_mask)
v_ctx_neg, a_ctx_neg = neg_out.video_encoding, neg_out.audio_encoding
# Move the base Gemma model to CPU
self._text_encoder.model.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)
first_param = next(self._audio_decoder.parameters(), None)
decoder_dtype = first_param.dtype if first_param is not None else audio_state.latent.dtype
latent = audio_state.latent.to(dtype=decoder_dtype, device=device)
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, H1, W1] in [0, 1]
right_video: Right video tensor [C, F2, H2, W2] in [0, 1]
Returns:
Concatenated video tensor [C, max(F1,F2), H2, W1_scaled+W2] in [0, 1]
"""
left_height, left_width = left_video.shape[2], left_video.shape[3]
right_height = right_video.shape[2]
# Resize left video to match right video's height if needed
if left_height != right_height:
# Scale width proportionally to maintain aspect ratio
scale = right_height / left_height
new_width = int(left_width * scale)
# Interpolate expects [N, C, H, W], we have [C, F, H, W]
# Reshape to [C*F, 1, H, W] -> interpolate -> reshape back
c, f, h, w = left_video.shape
left_video = left_video.reshape(c * f, 1, h, w)
left_video = torch.nn.functional.interpolate(
left_video, size=(right_height, new_width), mode="bilinear", align_corners=False
)
left_video = left_video.reshape(c, f, right_height, new_width)
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